Skip to main content

did_hosting_server/
did_ops.rs

1//! Transport-independent DID management business logic.
2//!
3//! Both the REST handlers (`routes/did_manage.rs`) and the DIDComm protocol
4//! handlers (`routes/didcomm.rs`) delegate to functions in this module so that
5//! quota checks, validation, store operations, and stats updates live in one
6//! place.
7
8use crate::acl::{self, Role};
9use crate::auth::AuthClaims;
10use crate::auth::session::now_epoch;
11use crate::config::AppConfig;
12use crate::error::AppError;
13use crate::mnemonic::{
14    generate_unique_mnemonic, is_path_available, validate_custom_path, validate_mnemonic,
15};
16use crate::server::AppState;
17
18use crate::store::KeyspaceHandle;
19use did_hosting_common::DidListEntry;
20use serde::{Deserialize, Serialize};
21use tracing::{debug, info, warn};
22
23// Re-export shared types and helpers from did-hosting-common so existing code
24// that imports from `crate::did_ops::*` continues to work.
25pub use did_hosting_common::did_ops::{
26    DidRecord, LogEntryInfo, LogMetadata, content_log_key, content_witness_key, did_key,
27    extract_did_id, extract_did_web_document, extract_log_metadata, extract_service_types,
28    owner_key, parse_log_entries, watcher_sync_key,
29};
30
31// ---------------------------------------------------------------------------
32// Quota index — O(1) per-owner count and size tracking
33// ---------------------------------------------------------------------------
34
35/// Per-owner quota index stored at `quota:{owner_did}`.
36#[derive(Debug, Clone, Serialize, Deserialize, Default)]
37pub struct QuotaIndex {
38    pub did_count: u64,
39    pub total_size: u64,
40}
41
42pub fn quota_key(owner: &str) -> String {
43    format!("quota:{owner}")
44}
45
46/// Get or create the quota index for an owner.
47pub async fn get_quota(dids_ks: &KeyspaceHandle, owner: &str) -> Result<QuotaIndex, AppError> {
48    Ok(dids_ks.get(quota_key(owner)).await?.unwrap_or_default())
49}
50
51/// Increment the quota index on DID create.
52pub async fn quota_on_create(dids_ks: &KeyspaceHandle, owner: &str) -> Result<(), AppError> {
53    let mut q = get_quota(dids_ks, owner).await?;
54    q.did_count += 1;
55    dids_ks.insert(quota_key(owner), &q).await
56}
57
58/// Decrement the quota index on DID delete and subtract content size.
59pub async fn quota_on_delete(
60    dids_ks: &KeyspaceHandle,
61    owner: &str,
62    content_size: u64,
63) -> Result<(), AppError> {
64    let mut q = get_quota(dids_ks, owner).await?;
65    q.did_count = q.did_count.saturating_sub(1);
66    q.total_size = q.total_size.saturating_sub(content_size);
67    dids_ks.insert(quota_key(owner), &q).await
68}
69
70/// Adjust the quota index on DID publish (size change).
71pub async fn quota_on_size_change(
72    dids_ks: &KeyspaceHandle,
73    owner: &str,
74    old_size: u64,
75    new_size: u64,
76) -> Result<(), AppError> {
77    let mut q = get_quota(dids_ks, owner).await?;
78    q.total_size = q
79        .total_size
80        .saturating_sub(old_size)
81        .saturating_add(new_size);
82    dids_ks.insert(quota_key(owner), &q).await
83}
84
85// ---------------------------------------------------------------------------
86// Quota checks — O(1) using the index
87// ---------------------------------------------------------------------------
88
89/// Check whether the owner has reached their DID count limit.
90/// Admins are exempt.
91pub async fn check_did_count_limit(
92    auth: &AuthClaims,
93    dids_ks: &KeyspaceHandle,
94    acl_ks: &KeyspaceHandle,
95    config: &AppConfig,
96) -> Result<(), AppError> {
97    if auth.role == Role::Admin {
98        return Ok(());
99    }
100    let acl_entry = acl::get_acl_entry(acl_ks, &auth.did).await?;
101    let max = acl_entry
102        .as_ref()
103        .map(|e| e.effective_max_did_count(config.limits.default_max_did_count))
104        .unwrap_or(config.limits.default_max_did_count);
105
106    let quota = get_quota(dids_ks, &auth.did).await?;
107    if quota.did_count >= max {
108        warn!(did = %auth.did, count = quota.did_count, max, "DID count quota exceeded");
109        return Err(AppError::QuotaExceeded(format!(
110            "DID count limit reached ({max})"
111        )));
112    }
113    debug!(did = %auth.did, count = quota.did_count, max, "DID count quota check passed");
114    Ok(())
115}
116
117/// Check whether storing `new_size` bytes would exceed the owner's total size quota.
118/// Admins are exempt. `old_size` is the current size of the DID being replaced (0 for new).
119pub async fn check_total_size_limit(
120    auth: &AuthClaims,
121    dids_ks: &KeyspaceHandle,
122    acl_ks: &KeyspaceHandle,
123    config: &AppConfig,
124    old_size: u64,
125    new_size: u64,
126) -> Result<(), AppError> {
127    if auth.role == Role::Admin {
128        return Ok(());
129    }
130    let acl_entry = acl::get_acl_entry(acl_ks, &auth.did).await?;
131    let max = acl_entry
132        .as_ref()
133        .map(|e| e.effective_max_total_size(config.limits.default_max_total_size))
134        .unwrap_or(config.limits.default_max_total_size);
135
136    let quota = get_quota(dids_ks, &auth.did).await?;
137    let proposed = quota
138        .total_size
139        .saturating_sub(old_size)
140        .saturating_add(new_size);
141    if proposed > max {
142        warn!(did = %auth.did, current = quota.total_size, old_size, new_size, max, "total size quota exceeded");
143        return Err(AppError::QuotaExceeded(format!(
144            "total DID document size would exceed limit ({max} bytes)"
145        )));
146    }
147    debug!(did = %auth.did, current = quota.total_size, new_size, max, "total size quota check passed");
148    Ok(())
149}
150
151// ---------------------------------------------------------------------------
152// Auth helper
153// ---------------------------------------------------------------------------
154
155/// Load a DID record and verify the caller is the owner (or admin).
156pub async fn get_authorized_record(
157    dids_ks: &KeyspaceHandle,
158    mnemonic: &str,
159    auth: &AuthClaims,
160) -> Result<DidRecord, AppError> {
161    let record: DidRecord = dids_ks
162        .get(did_key(mnemonic))
163        .await?
164        .ok_or_else(|| AppError::NotFound(format!("DID not found: {mnemonic}")))?;
165    if record.owner != auth.did && auth.role != Role::Admin {
166        warn!(
167            caller = %auth.did,
168            role = %auth.role,
169            owner = %record.owner,
170            mnemonic = %mnemonic,
171            "access denied: not the owner of this DID"
172        );
173        return Err(AppError::Forbidden("not the owner of this DID".into()));
174    }
175    Ok(record)
176}
177
178// ---------------------------------------------------------------------------
179// Disable / enable
180// ---------------------------------------------------------------------------
181
182/// Toggle the `disabled` flag on a DID record.
183pub async fn set_did_disabled(
184    auth: &AuthClaims,
185    state: &AppState,
186    mnemonic: &str,
187    disabled: bool,
188) -> Result<(), AppError> {
189    validate_mnemonic(mnemonic)?;
190    let mut record = get_authorized_record(&state.dids_ks, mnemonic, auth).await?;
191    record.disabled = disabled;
192    state.dids_ks.insert(did_key(mnemonic), &record).await?;
193    info!(
194        did = %auth.did,
195        role = %auth.role,
196        mnemonic = %mnemonic,
197        disabled,
198        "DID disabled state updated"
199    );
200    Ok(())
201}
202
203// ---------------------------------------------------------------------------
204// JSONL validation (wraps the common version with AppError)
205// ---------------------------------------------------------------------------
206
207/// Validate that every line in the JSONL body is a well-formed did:webvh log entry.
208pub fn validate_did_jsonl(content: &str) -> Result<(), AppError> {
209    did_hosting_common::did_ops::validate_did_jsonl(content).map_err(AppError::Validation)
210}
211
212// ---------------------------------------------------------------------------
213// Core operations
214// ---------------------------------------------------------------------------
215
216/// Result of creating a new DID slot.
217pub struct CreateDidResult {
218    pub mnemonic: String,
219    pub did_url: String,
220}
221
222/// Create a new DID slot (reserve a mnemonic/path).
223pub async fn create_did(
224    auth: &AuthClaims,
225    state: &AppState,
226    path: Option<&str>,
227) -> Result<CreateDidResult, AppError> {
228    check_did_count_limit(auth, &state.dids_ks, &state.acl_ks, &state.config).await?;
229
230    let mnemonic = match path {
231        Some(custom_path) if custom_path == ".well-known" => {
232            if auth.role != Role::Admin {
233                return Err(AppError::Forbidden(
234                    "only admins can create the root DID".into(),
235                ));
236            }
237            if !is_path_available(&state.dids_ks, custom_path).await? {
238                return Err(AppError::Conflict(
239                    "root DID (.well-known) already exists".into(),
240                ));
241            }
242            custom_path.to_string()
243        }
244        Some(custom_path) => {
245            validate_custom_path(custom_path)?;
246            if !is_path_available(&state.dids_ks, custom_path).await? {
247                return Err(AppError::Conflict(format!(
248                    "path '{custom_path}' is already taken"
249                )));
250            }
251            custom_path.to_string()
252        }
253        None => generate_unique_mnemonic(&state.dids_ks).await?,
254    };
255
256    let now = now_epoch();
257    let record = DidRecord {
258        owner: auth.did.clone(),
259        mnemonic: mnemonic.clone(),
260        created_at: now,
261        updated_at: now,
262        version_count: 0,
263        did_id: None,
264        content_size: 0,
265        disabled: false,
266        deleted_at: None,
267
268        // T12: legacy construction site; T13 migration fills `domain`.
269        method: "webvh".to_string(),
270        domain: String::new(),
271
272        // Empty slot — no log yet, so no document to read services from.
273        services: None,
274        agent_names: Vec::new(),
275    };
276
277    let mut batch = state.store.batch();
278    batch.insert(&state.dids_ks, did_key(&mnemonic), &record)?;
279    batch.insert_raw(
280        &state.dids_ks,
281        owner_key(&auth.did, &mnemonic),
282        mnemonic.as_bytes().to_vec(),
283    );
284    batch.commit().await?;
285
286    // Update quota index
287    quota_on_create(&state.dids_ks, &auth.did).await?;
288
289    let did_url = format!("{}/{mnemonic}/did.jsonl", state.config.public_base_url());
290
291    if let Some(ref collector) = state.stats_collector {
292        collector.increment_total_dids();
293    }
294    info!(audit = true, did = %auth.did, role = %auth.role, mnemonic = %mnemonic, "DID URI created");
295
296    Ok(CreateDidResult { mnemonic, did_url })
297}
298
299/// Result of publishing a DID log.
300pub struct PublishDidResult {
301    pub did_id: Option<String>,
302    pub did_url: String,
303    pub version_id: Option<String>,
304    pub version_count: u64,
305}
306
307/// Publish (upload) a did.jsonl log for an existing DID slot.
308pub async fn publish_did(
309    auth: &AuthClaims,
310    state: &AppState,
311    mnemonic: &str,
312    did_log: &str,
313) -> Result<PublishDidResult, AppError> {
314    validate_mnemonic(mnemonic)?;
315    let mut record = get_authorized_record(&state.dids_ks, mnemonic, auth).await?;
316
317    validate_did_jsonl(did_log)?;
318
319    let new_size = did_log.len() as u64;
320    let old_size = record.content_size;
321    check_total_size_limit(
322        auth,
323        &state.dids_ks,
324        &state.acl_ks,
325        &state.config,
326        old_size,
327        new_size,
328    )
329    .await?;
330
331    let did_id = extract_did_id(did_log);
332
333    let version_id = did_log
334        .lines()
335        .last()
336        .and_then(|line| serde_json::from_str::<serde_json::Value>(line).ok())
337        .and_then(|v| {
338            v.get("versionId")
339                .and_then(|id| id.as_str())
340                .map(String::from)
341        });
342
343    record.updated_at = now_epoch();
344    record.version_count += 1;
345    record.did_id = did_id.clone();
346    record.content_size = new_size;
347    // Recompute, don't fill-if-empty: an upload can drop a service as
348    // well as add one. Also backfills legacy `None` records on next write.
349    record.services = extract_service_types(did_log);
350
351    let mut batch = state.store.batch();
352    batch.insert_raw(
353        &state.dids_ks,
354        content_log_key(mnemonic),
355        did_log.as_bytes().to_vec(),
356    );
357    batch.insert(&state.dids_ks, did_key(mnemonic), &record)?;
358    batch.commit().await?;
359
360    // Update quota index for size change
361    quota_on_size_change(&state.dids_ks, &record.owner, old_size, new_size).await?;
362
363    // Invalidate cache for this DID
364    state.did_cache.invalidate(&content_log_key(mnemonic));
365
366    if let Some(ref collector) = state.stats_collector {
367        collector.record_update(mnemonic);
368    }
369
370    let did_url = format!("{}/{mnemonic}/did.jsonl", state.config.public_base_url());
371
372    info!(
373        did = %auth.did,
374        role = %auth.role,
375        mnemonic = %mnemonic,
376        size = new_size,
377        version = record.version_count,
378        "did.jsonl published"
379    );
380
381    // If the DID just published was this server's *own*, its keys or services
382    // may have changed. Re-resolve and rotate the identity if so.
383    //
384    // Safe on every publish: it compares mnemonics first (no network), and a
385    // publish of our own DID that didn't change the identity resolves the
386    // document once and no-ops. It deliberately does not fail the publish — the
387    // log entry is committed and correct either way, and a rotation that cannot
388    // proceed logs loudly rather than rolling back a valid publish.
389    crate::identity_rotation::on_did_published(state, mnemonic).await;
390
391    Ok(PublishDidResult {
392        did_id,
393        did_url,
394        version_id,
395        version_count: record.version_count,
396    })
397}
398
399/// Result of uploading witness data.
400pub struct WitnessUploadResult {
401    pub witness_url: String,
402}
403
404/// Upload witness content for a DID.
405pub async fn upload_witness(
406    auth: &AuthClaims,
407    state: &AppState,
408    mnemonic: &str,
409    witness_content: &str,
410) -> Result<WitnessUploadResult, AppError> {
411    validate_mnemonic(mnemonic)?;
412    get_authorized_record(&state.dids_ks, mnemonic, auth).await?;
413
414    if witness_content.is_empty() {
415        return Err(AppError::Validation(
416            "did-witness.json content cannot be empty".into(),
417        ));
418    }
419
420    // Validate that witness content is well-formed JSON
421    serde_json::from_str::<serde_json::Value>(witness_content)
422        .map_err(|e| AppError::Validation(format!("did-witness.json must be valid JSON: {e}")))?;
423
424    let size = witness_content.len();
425
426    state
427        .dids_ks
428        .insert_raw(
429            content_witness_key(mnemonic),
430            witness_content.as_bytes().to_vec(),
431        )
432        .await?;
433
434    let witness_url = format!(
435        "{}/{mnemonic}/did-witness.json",
436        state.config.public_base_url()
437    );
438
439    info!(did = %auth.did, role = %auth.role, mnemonic = %mnemonic, size, "did-witness.json uploaded");
440
441    Ok(WitnessUploadResult { witness_url })
442}
443
444/// Result of retrieving DID info.
445pub struct DidInfoResult {
446    pub record: DidRecord,
447    pub log_metadata: Option<LogMetadata>,
448    pub stats: did_hosting_common::DidStats,
449    pub did_url: String,
450}
451
452/// Get detailed information about a DID.
453pub async fn get_did_info(
454    auth: &AuthClaims,
455    state: &AppState,
456    mnemonic: &str,
457) -> Result<DidInfoResult, AppError> {
458    validate_mnemonic(mnemonic)?;
459    let record = get_authorized_record(&state.dids_ks, mnemonic, auth).await?;
460
461    let log_metadata = match state.dids_ks.get_raw(content_log_key(mnemonic)).await? {
462        Some(bytes) => {
463            let content = String::from_utf8(bytes).unwrap_or_default();
464            Some(extract_log_metadata(&content))
465        }
466        None => None,
467    };
468
469    let did_stats = did_hosting_common::DidStats::default();
470    let did_url = format!("{}/{mnemonic}/did.jsonl", state.config.public_base_url());
471
472    info!(did = %auth.did, mnemonic = %mnemonic, "DID info retrieved");
473
474    Ok(DidInfoResult {
475        record,
476        log_metadata,
477        stats: did_stats,
478        did_url,
479    })
480}
481
482/// Get parsed log entries for a DID.
483pub async fn get_did_log(
484    auth: &AuthClaims,
485    state: &AppState,
486    mnemonic: &str,
487) -> Result<Vec<LogEntryInfo>, AppError> {
488    validate_mnemonic(mnemonic)?;
489    get_authorized_record(&state.dids_ks, mnemonic, auth).await?;
490
491    let bytes = state
492        .dids_ks
493        .get_raw(content_log_key(mnemonic))
494        .await?
495        .ok_or_else(|| AppError::NotFound("no log content for this DID".into()))?;
496
497    let content = String::from_utf8(bytes)
498        .map_err(|e| AppError::Internal(format!("invalid log bytes: {e}")))?;
499
500    let entries = parse_log_entries(&content);
501
502    debug!(mnemonic = %mnemonic, count = entries.len(), "DID log entries retrieved");
503
504    Ok(entries)
505}
506
507/// List DIDs owned by the caller (or by a specific owner if the caller is admin).
508/// When the caller is admin and no `requested_owner` is provided, returns *all* DIDs.
509pub async fn list_dids(
510    auth: &AuthClaims,
511    state: &AppState,
512    requested_owner: Option<&str>,
513    limit: Option<usize>,
514    offset: Option<usize>,
515) -> Result<Vec<DidListEntry>, AppError> {
516    // Admin with no owner filter → return all DIDs across all owners.
517    if auth.role == Role::Admin && requested_owner.is_none() {
518        return list_all_dids(auth, state).await;
519    }
520
521    let target_owner = if auth.role == Role::Admin {
522        requested_owner.unwrap_or(&auth.did)
523    } else {
524        &auth.did
525    };
526
527    let prefix = format!("owner:{target_owner}:");
528    let raw = state.dids_ks.prefix_iter_raw(prefix).await?;
529
530    let mut entries = Vec::with_capacity(raw.len());
531    for (_key, value) in raw {
532        let mnemonic = String::from_utf8(value)
533            .map_err(|e| AppError::Internal(format!("invalid mnemonic bytes: {e}")))?;
534        if let Some(record) = state.dids_ks.get::<DidRecord>(did_key(&mnemonic)).await? {
535            let did_stats = did_hosting_common::DidStats::default();
536            let agent_names = record.agent_names.clone();
537            entries.push(DidListEntry {
538                mnemonic: record.mnemonic,
539                owner: record.owner,
540                created_at: record.created_at,
541                updated_at: record.updated_at,
542                version_count: record.version_count,
543                did_id: record.did_id,
544                total_resolves: did_stats.total_resolves,
545                disabled: record.disabled,
546                method: (!record.method.is_empty()).then(|| record.method.clone()),
547                domain: (!record.domain.is_empty()).then(|| record.domain.clone()),
548                agent_names,
549                services: record.services,
550            });
551        }
552    }
553
554    // Apply pagination
555    let offset = offset.unwrap_or(0);
556    let limit = limit.unwrap_or(1000); // Default max 1000
557    let total = entries.len();
558    let entries: Vec<_> = entries.into_iter().skip(offset).take(limit).collect();
559
560    info!(did = %auth.did, role = %auth.role, owner = %target_owner, total, returned = entries.len(), "DIDs listed");
561
562    Ok(entries)
563}
564
565/// List all DIDs in the store (admin only). Iterates the `did:` prefix.
566async fn list_all_dids(auth: &AuthClaims, state: &AppState) -> Result<Vec<DidListEntry>, AppError> {
567    let raw = state.dids_ks.prefix_iter_raw("did:").await?;
568
569    let mut entries = Vec::with_capacity(raw.len());
570    for (_key, value) in raw {
571        let record: DidRecord = match serde_json::from_slice(&value) {
572            Ok(r) => r,
573            Err(_) => continue,
574        };
575        let did_stats = did_hosting_common::DidStats::default();
576        let agent_names = record.agent_names.clone();
577        entries.push(DidListEntry {
578            mnemonic: record.mnemonic,
579            owner: record.owner,
580            created_at: record.created_at,
581            updated_at: record.updated_at,
582            version_count: record.version_count,
583            did_id: record.did_id,
584            total_resolves: did_stats.total_resolves,
585            disabled: record.disabled,
586            method: (!record.method.is_empty()).then(|| record.method.clone()),
587            domain: (!record.domain.is_empty()).then(|| record.domain.clone()),
588            agent_names,
589            services: record.services,
590        });
591    }
592
593    info!(did = %auth.did, role = %auth.role, count = entries.len(), "all DIDs listed (admin)");
594
595    Ok(entries)
596}
597
598/// Result of deleting a DID.
599pub struct DeleteDidResult {
600    pub mnemonic: String,
601    pub did_id: Option<String>,
602}
603
604/// Soft-delete a DID. Content is preserved for recovery within the retention period.
605/// The cleanup thread will hard-delete records after the configured retention.
606pub async fn delete_did(
607    auth: &AuthClaims,
608    state: &AppState,
609    mnemonic: &str,
610) -> Result<DeleteDidResult, AppError> {
611    validate_mnemonic(mnemonic)?;
612    let mut record = get_authorized_record(&state.dids_ks, mnemonic, auth).await?;
613
614    let did_id = record.did_id.clone();
615
616    // Mark as deleted instead of removing
617    record.deleted_at = Some(now_epoch());
618    state.dids_ks.insert(did_key(mnemonic), &record).await?;
619
620    // Update quota index (content is still stored but quota is freed)
621    quota_on_delete(&state.dids_ks, &record.owner, record.content_size).await?;
622
623    state.did_cache.invalidate(&content_log_key(mnemonic));
624
625    if let Some(ref collector) = state.stats_collector {
626        collector.decrement_total_dids();
627    }
628    info!(audit = true, did = %auth.did, role = %auth.role, mnemonic = %mnemonic, "DID deleted");
629
630    Ok(DeleteDidResult {
631        mnemonic: mnemonic.to_string(),
632        did_id,
633    })
634}
635
636/// Recover a soft-deleted DID by clearing its `deleted_at` timestamp.
637pub async fn recover_did(state: &AppState, mnemonic: &str) -> Result<(), AppError> {
638    validate_mnemonic(mnemonic)?;
639
640    let mut record: DidRecord = state
641        .dids_ks
642        .get(did_key(mnemonic))
643        .await?
644        .ok_or_else(|| AppError::NotFound(format!("DID not found: {mnemonic}")))?;
645
646    if record.deleted_at.is_none() {
647        return Err(AppError::Validation("DID is not deleted".into()));
648    }
649
650    record.deleted_at = None;
651    state.dids_ks.insert(did_key(mnemonic), &record).await?;
652
653    // Restore quota
654    quota_on_create(&state.dids_ks, &record.owner).await?;
655
656    state.did_cache.invalidate(&content_log_key(mnemonic));
657
658    info!(audit = true, mnemonic = %mnemonic, owner = %record.owner, "DID recovered from soft delete");
659
660    Ok(())
661}
662
663// ---------------------------------------------------------------------------
664// Cleanup
665// ---------------------------------------------------------------------------
666
667/// Result of rolling back the last log entry.
668pub struct RollbackDidResult {
669    pub record: DidRecord,
670    pub log_metadata: Option<LogMetadata>,
671    pub did_url: String,
672}
673
674/// Roll back (remove) the last log entry from a DID's JSONL content.
675///
676/// Rejects the operation if there are fewer than 2 log entries (cannot roll back
677/// the genesis entry). Updates the `DidRecord` with the truncated content and
678/// removes any stale witness data.
679pub async fn rollback_did(
680    auth: &AuthClaims,
681    state: &AppState,
682    mnemonic: &str,
683) -> Result<RollbackDidResult, AppError> {
684    validate_mnemonic(mnemonic)?;
685    let mut record = get_authorized_record(&state.dids_ks, mnemonic, auth).await?;
686
687    let bytes = state
688        .dids_ks
689        .get_raw(content_log_key(mnemonic))
690        .await?
691        .ok_or_else(|| AppError::NotFound("no log content for this DID".into()))?;
692
693    let content = String::from_utf8(bytes)
694        .map_err(|e| AppError::Internal(format!("invalid log bytes: {e}")))?;
695
696    let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
697    if lines.len() < 2 {
698        return Err(AppError::Validation(
699            "cannot rollback: DID log must have at least 2 entries".into(),
700        ));
701    }
702
703    let truncated_lines = &lines[..lines.len() - 1];
704    let truncated = truncated_lines.join("\n");
705
706    let new_did_id = extract_did_id(&truncated);
707    let new_size = truncated.len() as u64;
708
709    record.version_count = truncated_lines.len() as u64;
710    record.did_id = new_did_id;
711    record.content_size = new_size;
712    record.updated_at = now_epoch();
713    // Rolling back can retract a service the dropped entry introduced.
714    record.services = extract_service_types(&truncated);
715
716    let mut batch = state.store.batch();
717    batch.insert_raw(
718        &state.dids_ks,
719        content_log_key(mnemonic),
720        truncated.as_bytes().to_vec(),
721    );
722    batch.insert(&state.dids_ks, did_key(mnemonic), &record)?;
723    batch.remove(&state.dids_ks, content_witness_key(mnemonic));
724    batch.commit().await?;
725
726    state.did_cache.invalidate(&content_log_key(mnemonic));
727
728    let log_metadata = Some(extract_log_metadata(&truncated));
729    let did_url = format!("{}/{mnemonic}/did.jsonl", state.config.public_base_url());
730
731    info!(
732        did = %auth.did,
733        role = %auth.role,
734        mnemonic = %mnemonic,
735        remaining = truncated_lines.len(),
736        "DID log entry rolled back"
737    );
738
739    Ok(RollbackDidResult {
740        record,
741        log_metadata,
742        did_url,
743    })
744}
745
746/// Get the raw JSONL content for a DID log as a plain string.
747pub async fn get_raw_log(
748    auth: &AuthClaims,
749    state: &AppState,
750    mnemonic: &str,
751) -> Result<String, AppError> {
752    validate_mnemonic(mnemonic)?;
753    get_authorized_record(&state.dids_ks, mnemonic, auth).await?;
754
755    let bytes = state
756        .dids_ks
757        .get_raw(content_log_key(mnemonic))
758        .await?
759        .ok_or_else(|| AppError::NotFound("no log content for this DID".into()))?;
760
761    String::from_utf8(bytes).map_err(|e| AppError::Internal(format!("invalid log bytes: {e}")))
762}
763
764// ---------------------------------------------------------------------------
765// Cleanup
766// ---------------------------------------------------------------------------
767
768/// Remove DID records that have `version_count == 0` and are older than `ttl_seconds`,
769/// or soft-deleted records past the 30-day retention period.
770pub async fn cleanup_empty_dids(
771    dids_ks: &KeyspaceHandle,
772    ttl_seconds: u64,
773) -> Result<u64, AppError> {
774    const SOFT_DELETE_RETENTION: u64 = 30 * 24 * 3600; // 30 days
775    let now = now_epoch();
776    let raw = dids_ks.prefix_iter_raw("did:").await?;
777    let mut removed = 0u64;
778
779    for (_key, value) in raw {
780        let record: DidRecord = match serde_json::from_slice(&value) {
781            Ok(r) => r,
782            Err(_) => continue,
783        };
784
785        let should_remove =
786            // Empty records past TTL
787            (record.version_count == 0 && now.saturating_sub(record.created_at) > ttl_seconds)
788            // Soft-deleted records past retention
789            || record.deleted_at.is_some_and(|d| now.saturating_sub(d) > SOFT_DELETE_RETENTION);
790
791        if should_remove {
792            dids_ks.remove(did_key(&record.mnemonic)).await?;
793            dids_ks.remove(content_log_key(&record.mnemonic)).await?;
794            dids_ks
795                .remove(content_witness_key(&record.mnemonic))
796                .await?;
797            dids_ks
798                .remove(owner_key(&record.owner, &record.mnemonic))
799                .await?;
800            removed += 1;
801        }
802    }
803
804    Ok(removed)
805}
806
807// ---------------------------------------------------------------------------
808// Unit tests
809// ---------------------------------------------------------------------------
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814
815    // ---- validate_did_jsonl wrapper tests ----
816
817    #[test]
818    fn validate_jsonl_empty_string_rejected() {
819        let result = validate_did_jsonl("");
820        assert!(result.is_err());
821        let err = result.unwrap_err().to_string();
822        assert!(err.contains("empty"), "expected 'empty' in: {err}");
823    }
824
825    #[test]
826    fn validate_jsonl_invalid_json_rejected() {
827        let result = validate_did_jsonl("this is not json");
828        assert!(result.is_err());
829        let err = result.unwrap_err().to_string();
830        assert!(
831            err.contains("invalid log entry at line 1"),
832            "expected line reference in: {err}"
833        );
834    }
835
836    #[test]
837    fn validate_jsonl_valid_json_but_not_log_entry() {
838        let result = validate_did_jsonl(r#"{"hello":"world"}"#);
839        assert!(result.is_err());
840    }
841
842    async fn make_valid_jsonl() -> String {
843        use did_hosting_common::did::{build_did_document, create_log_entry, encode_host};
844
845        let secret = affinidi_tdk::secrets_resolver::secrets::Secret::generate_ed25519(None, None);
846        let pk = secret.get_public_keymultibase().unwrap();
847        let host = encode_host("http://localhost:3000").unwrap();
848        let doc = build_did_document(&host, "test-validate", &pk, &Default::default());
849        let (_scid, jsonl) = create_log_entry(&doc, &secret).await.unwrap();
850        jsonl
851    }
852
853    #[tokio::test]
854    async fn validate_jsonl_blank_lines_skipped() {
855        let entry = make_valid_jsonl().await;
856        let with_blanks = format!("\n{entry}\n\n");
857        assert!(validate_did_jsonl(&with_blanks).is_ok());
858    }
859
860    #[tokio::test]
861    async fn validate_jsonl_valid_single_entry() {
862        let entry = make_valid_jsonl().await;
863        assert!(validate_did_jsonl(&entry).is_ok());
864    }
865
866    #[tokio::test]
867    async fn validate_jsonl_second_line_invalid() {
868        let entry = make_valid_jsonl().await;
869        let content = format!("{entry}\nnot valid json");
870        let result = validate_did_jsonl(&content);
871        assert!(result.is_err());
872        let err = result.unwrap_err().to_string();
873        assert!(err.contains("line 2"), "expected 'line 2' in error: {err}");
874    }
875
876    // ---- rollback helper tests (pure functions used by rollback_did) ----
877
878    #[test]
879    fn rollback_removes_last_line() {
880        let line1 = r#"{"state":{"id":"did:webvh:first:host:path"}}"#;
881        let line2 = r#"{"state":{"id":"did:webvh:second:host:path"}}"#;
882        let jsonl = format!("{line1}\n{line2}");
883        let lines: Vec<&str> = jsonl.lines().filter(|l| !l.trim().is_empty()).collect();
884        assert_eq!(lines.len(), 2);
885        let truncated = lines[..lines.len() - 1].join("\n");
886        assert_eq!(truncated, line1);
887        assert_eq!(
888            extract_did_id(&truncated),
889            Some("did:webvh:first:host:path".to_string())
890        );
891    }
892
893    #[test]
894    fn rollback_rejects_single_entry() {
895        let jsonl = r#"{"state":{"id":"did:webvh:only:host:path"}}"#;
896        let lines: Vec<&str> = jsonl.lines().filter(|l| !l.trim().is_empty()).collect();
897        assert!(lines.len() < 2, "single entry should not be rollback-able");
898    }
899
900    #[test]
901    fn rollback_updates_did_id() {
902        let line1 = r#"{"state":{"id":"did:webvh:genesis:host:path"}}"#;
903        let line2 = r#"{"state":{"id":"did:webvh:update1:host:path"}}"#;
904        let line3 = r#"{"state":{"id":"did:webvh:update2:host:path"}}"#;
905        let jsonl = format!("{line1}\n{line2}\n{line3}");
906        let lines: Vec<&str> = jsonl.lines().filter(|l| !l.trim().is_empty()).collect();
907        let truncated = lines[..lines.len() - 1].join("\n");
908        assert_eq!(
909            extract_did_id(&truncated),
910            Some("did:webvh:update1:host:path".to_string())
911        );
912    }
913}