Skip to main content

did_hosting_common/
did_ops.rs

1//! Shared DID types and pure helper functions used by both did-hosting-server and
2//! did-hosting-control.
3//!
4//! This module contains the data types and validation/extraction functions that
5//! are independent of any particular server's `AppState` or business logic.
6
7use didwebvh_rs::DIDWebVHState;
8use didwebvh_rs::log_entry::LogEntry;
9use didwebvh_rs::log_entry_state::{LogEntryState, LogEntryValidationStatus};
10use didwebvh_rs::parameters::Parameters;
11use serde::{Deserialize, Serialize};
12
13// ---------------------------------------------------------------------------
14// Data types
15// ---------------------------------------------------------------------------
16
17/// A record tracking a hosted DID.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct DidRecord {
20    pub owner: String,
21    pub mnemonic: String,
22    pub created_at: u64,
23    pub updated_at: u64,
24    pub version_count: u64,
25    #[serde(default)]
26    pub did_id: Option<String>,
27    #[serde(default)]
28    pub content_size: u64,
29    #[serde(default)]
30    pub disabled: bool,
31    /// Soft-delete timestamp. When set, the DID is treated as deleted but
32    /// content is preserved for recovery within the retention period.
33    #[serde(default)]
34    pub deleted_at: Option<u64>,
35
36    // ---- Multi-method + multi-domain fields (T12) ----
37    //
38    // Both `#[serde(default)]` for backwards-compat — v0.6-vintage
39    // `DidRecord`s lack these fields and deserialise unchanged. T13's
40    // M-01 migration walks the keyspace and populates them
41    // (`method = "webvh"` for every legacy record; `domain` derived
42    // from the legacy `public_url` host that T18's bootstrap_domains
43    // seed surfaced).
44    //
45    // The dual storage model (this record carries metadata; raw
46    // content lives at `content_log_key` / `content_witness_key`)
47    // is kept intentionally — see `docs/multi-method-hosting-spec.md`
48    // §3 "Storage" tradeoff note. Inlining `data: Vec<u8>` into the
49    // record made `list_dids` pull content bytes on every scan; the
50    // split keeps metadata reads cheap.
51    /// DID method this record was registered under. Always one of the
52    /// enabled-at-compile-time methods (`webvh`, `web`); the daemon
53    /// rejects any other value on the write path. Legacy records
54    /// (pre-T13 migration) default to `"webvh"` via the `#[serde(default)]`
55    /// fallback in [`Self::default_method`].
56    #[serde(default = "default_method")]
57    pub method: String,
58
59    /// Domain (hostname) this DID is hosted under. Matches the host
60    /// segment of the DID identifier (`did:{method}:…:{domain}:…`).
61    /// Legacy records (pre-T13 migration) default to the empty string;
62    /// the migration fills it with the host derived from the legacy
63    /// `public_url`. New records always carry a normalised non-empty
64    /// value.
65    #[serde(default)]
66    pub domain: String,
67
68    /// Cached `service[].type` values from the latest log entry's DID
69    /// document, in document order. Lets `list_dids` render per-DID
70    /// service badges without reading log bytes — the whole reason the
71    /// dual storage model exists (see the module note above).
72    ///
73    /// Tri-state, and the distinction matters:
74    /// - `None` — not yet computed. Either a slot with no document
75    ///   (`create_did`, `version_count == 0`) or a legacy record written
76    ///   before this field existed. The UI renders no badges.
77    /// - `Some(vec![])` — the document was read and genuinely advertises
78    ///   no services. The UI renders no badges, same as `None`, but the
79    ///   backfill paths know not to re-read the log.
80    /// - `Some(types)` — advertised services.
81    ///
82    /// Kept fresh by every write path that touches `content_log_key`
83    /// (`register_did_atomic`, `publish_did`, the server's sync-from-control
84    /// import, bootstrap). `M-02` sweeps legacy records at boot on all three
85    /// deployments — server and daemon via the full migration registry,
86    /// standalone control via a runner carrying M-02 alone. `publish_did`
87    /// additionally self-heals a `None` it finds, so a record the sweep
88    /// deferred still converges.
89    #[serde(default)]
90    pub services: Option<Vec<String>>,
91
92    /// Agent names (`/@alice`) that resolve to this DID.
93    ///
94    /// A DID may carry several — the agent name specification does not limit a
95    /// subject to one, and a deployment may want a personal name plus
96    /// role-scoped ones.
97    ///
98    /// `#[serde(default)]` so records written before this field existed
99    /// deserialise unchanged; an empty vec and a missing field are the same
100    /// thing, so no migration is needed.
101    ///
102    /// The authoritative name→DID lookup is the `name:{domain}:{name}` index,
103    /// not this list. This is the forward view, kept in the same write batch as
104    /// the index so the two cannot drift.
105    #[serde(default)]
106    pub agent_names: Vec<AgentNameEntry>,
107}
108
109/// One agent name bound to a hosted DID.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct AgentNameEntry {
113    /// The name's local part, without the `@` — `alice` for `/@alice`.
114    ///
115    /// Stored bare rather than as a full URL because the domain is already on
116    /// the record, and a name is only meaningful within its domain.
117    pub name: String,
118
119    /// Whether the name currently resolves.
120    ///
121    /// Disabling stops the redirect but **keeps the name reserved**, so nobody
122    /// else can claim it while it is parked. Removing is what releases it.
123    pub enabled: bool,
124
125    /// When the name was first bound to this DID.
126    pub created_at: u64,
127}
128
129fn default_method() -> String {
130    "webvh".to_string()
131}
132
133/// A single parsed log entry with its DID document and parameters.
134#[derive(Debug, Serialize)]
135#[serde(rename_all = "camelCase")]
136pub struct LogEntryInfo {
137    pub version_id: Option<String>,
138    pub version_time: Option<String>,
139    pub state: Option<serde_json::Value>,
140    pub parameters: Option<serde_json::Value>,
141}
142
143/// Summary of WebVH log entry metadata parsed from the stored JSONL content.
144#[derive(Debug, Default, Serialize)]
145#[serde(rename_all = "camelCase")]
146pub struct LogMetadata {
147    pub log_entry_count: u64,
148    pub latest_version_id: Option<String>,
149    pub latest_version_time: Option<String>,
150    pub method: Option<String>,
151    pub portable: bool,
152    pub pre_rotation: bool,
153    pub deactivated: bool,
154    pub ttl: Option<u32>,
155    pub witnesses: bool,
156    pub witness_count: u32,
157    pub witness_threshold: u32,
158    pub watchers: bool,
159    pub watcher_count: u32,
160    pub watcher_urls: Vec<String>,
161}
162
163// ---------------------------------------------------------------------------
164// Store key helpers
165// ---------------------------------------------------------------------------
166
167pub fn did_key(mnemonic: &str) -> String {
168    format!("did:{mnemonic}")
169}
170
171pub fn content_log_key(mnemonic: &str) -> String {
172    format!("content:{mnemonic}:log")
173}
174
175pub fn content_witness_key(mnemonic: &str) -> String {
176    format!("content:{mnemonic}:witness")
177}
178
179pub fn owner_key(did: &str, mnemonic: &str) -> String {
180    format!("owner:{did}:{mnemonic}")
181}
182
183/// Reverse index: an agent name resolves to the mnemonic that owns it.
184///
185/// Scoped by domain because this service is multi-domain — `@alice` on one
186/// hosted domain is a different name from `@alice` on another, and they must be
187/// claimable independently.
188///
189/// Mirrors the `owner:` reverse-index pattern; written in the same batch as the
190/// `DidRecord` so the index and `DidRecord::agent_names` cannot diverge.
191pub fn agent_name_key(domain: &str, name: &str) -> String {
192    format!("name:{domain}:{name}")
193}
194
195pub fn watcher_sync_key(mnemonic: &str) -> String {
196    format!("watcher_sync:{mnemonic}")
197}
198
199// ---------------------------------------------------------------------------
200// JSONL validation & extraction
201// ---------------------------------------------------------------------------
202
203/// Validate that every line in the JSONL body is a well-formed did:webvh log entry.
204///
205/// In addition to structural shape, the *last* entry's `state.id` must start
206/// with `did:webvh:`. This rules out leaked-push-token attackers republishing
207/// arbitrary JSON that happens to deserialise into the LogEntry shape but
208/// targets a different DID method.
209///
210/// Returns an error string on failure (caller wraps in their own error type).
211pub fn validate_did_jsonl(content: &str) -> Result<(), String> {
212    if content.is_empty() {
213        return Err("did.jsonl content cannot be empty".into());
214    }
215
216    let mut had_entry = false;
217    for (idx, line) in content.lines().enumerate() {
218        let line = line.trim();
219        if line.is_empty() {
220            continue;
221        }
222        LogEntry::deserialize_string(line, None)
223            .map_err(|e| format!("invalid log entry at line {}: {e}", idx + 1))?;
224        had_entry = true;
225    }
226
227    if !had_entry {
228        return Err("did.jsonl content has no entries".into());
229    }
230
231    // Must encode a did:webvh identifier on the latest entry.
232    match extract_did_id(content) {
233        Some(id) if id.starts_with("did:webvh:") => Ok(()),
234        Some(other) => Err(format!(
235            "did.jsonl latest entry must encode a did:webvh identifier (got {other})",
236        )),
237        None => Err("did.jsonl latest entry has no resolvable state.id".into()),
238    }
239}
240
241/// Verify the cryptographic proofs on every log entry in a `did.jsonl`
242/// chain.
243///
244/// This is the semantic-correctness gate for the DID method —
245/// `validate_did_jsonl` is structural-only (it parses each line and
246/// confirms the latest `state.id` is `did:webvh:`) but does not check
247/// that the embedded `proof` actually verifies against
248/// `parameters.updateKeys`. An authenticated owner (or admin via
249/// force) could otherwise publish a `did.jsonl` whose proof is invalid
250/// or signed with an unknown key — clients resolving the DID would
251/// then encounter a chain that doesn't verify and produce inscrutable
252/// downstream failures.
253///
254/// Wraps the upstream `didwebvh-rs::DIDWebVHState::validate` —
255/// signature verification, parameter-transition rules, hash-chain,
256/// pre-rotation key authorisation, and post-deactivation tamper
257/// detection are all enforced. Witness proofs are NOT validated here
258/// (they're optional and uploaded separately via
259/// `MSG_WITNESS_PUBLISH`); use `assert_complete()` if you need to
260/// reject any partial-chain truncation.
261///
262/// Returns the structural-validation message verbatim on parse-time
263/// failures and a "proof verification failed: ..." prefix on the
264/// upstream verifier's report.
265pub fn verify_did_log_proofs(content: &str) -> Result<(), String> {
266    // Re-run the structural parse so callers can use this function
267    // as a single gate. Cheap relative to the proof-verification cost.
268    validate_did_jsonl(content)?;
269
270    let mut state = DIDWebVHState::default();
271    let mut version = None;
272    for (idx, line) in content.lines().enumerate() {
273        let line = line.trim();
274        if line.is_empty() {
275            continue;
276        }
277        let entry = LogEntry::deserialize_string(line, version)
278            .map_err(|e| format!("invalid log entry at line {}: {e}", idx + 1))?;
279        version = Some(entry.get_webvh_version());
280        let version_number = entry
281            .get_version_id_fields()
282            .map_err(|e| format!("invalid versionId at line {}: {e}", idx + 1))?
283            .0;
284        state.log_entries_mut().push(LogEntryState {
285            log_entry: entry,
286            version_number,
287            validation_status: LogEntryValidationStatus::NotValidated,
288            validated_parameters: Parameters::default(),
289        });
290    }
291
292    let report = state
293        .validate()
294        .map_err(|e| format!("proof verification failed: {e}"))?;
295
296    // assert_complete rejects truncation — any entry that failed
297    // verification or any post-deactivation tampering surfaces here.
298    report
299        .assert_complete()
300        .map_err(|e| format!("proof verification failed (chain incomplete): {e}"))?;
301
302    Ok(())
303}
304
305/// Verify that a `did:webvh:...` identifier names the host described
306/// by `server_base_url` and resolves at the slot named by `request_path`.
307///
308/// Used by atomic claim-and-publish flows where the caller asserts a
309/// pre-built `did.jsonl`. Without this check, anyone with write
310/// access to a slot could upload content claiming a different host
311/// (impersonation) or a different path on this host (claim-jumping).
312///
313/// `server_base_url` is the URL the server publishes DIDs at — i.e.
314/// the resulting hosting URL is `<server_base_url>/<request_path>/did.jsonl`.
315/// Trailing slashes on `server_base_url` are tolerated.
316pub fn validate_did_id_matches_request(
317    did_id: &str,
318    request_path: &str,
319    server_base_url: &str,
320) -> Result<(), String> {
321    use didwebvh_rs::url::WebVHURL;
322    use url::Url;
323
324    let id_parsed = WebVHURL::parse_did_url(did_id)
325        .map_err(|e| format!("did_log's DID identifier {did_id} is not a valid did:webvh: {e}"))?;
326
327    let expected_str = format!(
328        "{}/{request_path}/did.jsonl",
329        server_base_url.trim_end_matches('/')
330    );
331    let expected_url = Url::parse(&expected_str).map_err(|e| {
332        format!(
333            "internal: server_base_url {server_base_url} cannot form a valid URL when combined \
334             with the requested path: {e}"
335        )
336    })?;
337    let expected_parsed = WebVHURL::parse_url(&expected_url).map_err(|e| {
338        format!(
339            "internal: the URL this server would publish at ({expected_str}) cannot be expressed \
340             as a webvh URL: {e}"
341        )
342    })?;
343
344    if id_parsed.domain != expected_parsed.domain || id_parsed.port != expected_parsed.port {
345        return Err(format!(
346            "did_log's DID host (domain={}, port={:?}) does not match this server's host \
347             (domain={}, port={:?})",
348            id_parsed.domain, id_parsed.port, expected_parsed.domain, expected_parsed.port,
349        ));
350    }
351    if id_parsed.path != expected_parsed.path {
352        return Err(format!(
353            "did_log's DID path '{}' does not resolve at the requested path '{}' on this server \
354             (server would publish at '{}')",
355            id_parsed.path.trim_matches('/'),
356            request_path,
357            expected_parsed.path.trim_matches('/'),
358        ));
359    }
360
361    Ok(())
362}
363
364/// Extract the `did:webvh:...` identifier from the last non-blank line of
365/// JSONL content via the `state.id` field. Trailing blank lines are skipped.
366pub fn extract_did_id(jsonl_content: &str) -> Option<String> {
367    let last_line = jsonl_content.lines().rfind(|l| !l.trim().is_empty())?;
368    let value: serde_json::Value = serde_json::from_str(last_line).ok()?;
369    value
370        .get("state")
371        .and_then(|state| state.get("id"))
372        .and_then(|id| id.as_str())
373        .filter(|s| s.starts_with("did:webvh:"))
374        .map(|s| s.to_string())
375}
376
377/// Extract the advertised `service[].type` values from the DID document in
378/// the last non-blank line of JSONL content.
379///
380/// Returns `None` when there is no parseable log entry with a `state` — i.e.
381/// the document is unavailable, so callers must not cache an empty vector and
382/// conclude "advertises nothing". `Some(vec![])` means the document was read
383/// and carries no services. This maps directly onto [`DidRecord::services`].
384pub fn extract_service_types(jsonl_content: &str) -> Option<Vec<String>> {
385    let last_line = jsonl_content.lines().rfind(|l| !l.trim().is_empty())?;
386    let value: serde_json::Value = serde_json::from_str(last_line).ok()?;
387    let state = value.get("state")?;
388    Some(crate::did::service_types_from_doc(state))
389}
390
391/// Extract the agent names a DID document claims, for a given hosting domain.
392///
393/// Reads `alsoKnownAs` from the latest log entry's document state and keeps the
394/// entries that are agent names (`/@…`) on `domain`.
395///
396/// # Why derive rather than track separately
397///
398/// The log is signed; `alsoKnownAs` is inside the signed document. Deriving the
399/// resolvable set from it means a node **structurally cannot** serve a name the
400/// DID does not claim — the agent-name specification's Layer-1 anti-spoofing
401/// rule holds by construction rather than by remembering to check it. It is the
402/// same reasoning that makes [`extract_service_types`] read the log instead of
403/// trusting a pushed list.
404///
405/// Canonicalisation goes through the `agent-names` crate so the name derived
406/// here is byte-identical to the one a resolver will compare against
407/// `alsoKnownAs`. Re-implementing that normalisation is how the two sides drift
408/// and every name silently fails to verify.
409pub fn extract_agent_names(jsonl_content: &str, domain: &str) -> Vec<String> {
410    let Some(last_line) = jsonl_content.lines().rfind(|l| !l.trim().is_empty()) else {
411        return Vec::new();
412    };
413    let Ok(value) = serde_json::from_str::<serde_json::Value>(last_line) else {
414        return Vec::new();
415    };
416    let Some(entries) = value
417        .get("state")
418        .and_then(|s| s.get("alsoKnownAs"))
419        .and_then(|a| a.as_array())
420    else {
421        return Vec::new();
422    };
423
424    let mut names = Vec::new();
425    for entry in entries.iter().filter_map(|e| e.as_str()) {
426        let Ok(parsed) = agent_names::AgentName::parse(entry) else {
427            continue; // not an agent name — alsoKnownAs legitimately holds other URIs
428        };
429        if parsed.authority() != domain {
430            continue; // a name on somebody else's domain; not ours to serve
431        }
432        let local = parsed.local_name().to_string();
433        if !names.contains(&local) {
434            names.push(local);
435        }
436    }
437    names
438}
439
440/// Parse JSONL content and extract metadata from the log entries.
441pub fn extract_log_metadata(jsonl_content: &str) -> LogMetadata {
442    let lines: Vec<&str> = jsonl_content.lines().collect();
443    let mut meta = LogMetadata {
444        log_entry_count: lines.len() as u64,
445        ..Default::default()
446    };
447
448    let Some(last_line) = lines.last() else {
449        return meta;
450    };
451    let Ok(entry) = serde_json::from_str::<serde_json::Value>(last_line) else {
452        return meta;
453    };
454
455    meta.latest_version_id = entry
456        .get("versionId")
457        .and_then(|v| v.as_str())
458        .map(|s| s.to_string());
459
460    meta.latest_version_time = entry
461        .get("versionTime")
462        .and_then(|v| v.as_str())
463        .map(|s| s.to_string());
464
465    if let Some(params) = entry.get("parameters") {
466        meta.method = params
467            .get("method")
468            .and_then(|v| v.as_str())
469            .map(|s| s.to_string());
470
471        meta.portable = params
472            .get("portable")
473            .and_then(|v| v.as_bool())
474            .unwrap_or(false);
475
476        meta.pre_rotation = params
477            .get("nextKeyHashes")
478            .and_then(|v| v.as_array())
479            .is_some_and(|a| !a.is_empty());
480
481        meta.deactivated = params
482            .get("deactivated")
483            .and_then(|v| v.as_bool())
484            .unwrap_or(false);
485
486        meta.ttl = params.get("ttl").and_then(|v| v.as_u64()).map(|v| v as u32);
487
488        if let Some(witness) = params.get("witness") {
489            let threshold = witness
490                .get("threshold")
491                .and_then(|v| v.as_u64())
492                .unwrap_or(0) as u32;
493            let count = witness
494                .get("witnesses")
495                .and_then(|v| v.as_array())
496                .map(|a| a.len() as u32)
497                .unwrap_or(0);
498            if count > 0 {
499                meta.witnesses = true;
500                meta.witness_count = count;
501                meta.witness_threshold = threshold;
502            }
503        }
504
505        if let Some(watchers_val) = params.get("watchers")
506            && let Some(arr) = watchers_val.as_array()
507            && !arr.is_empty()
508        {
509            meta.watchers = true;
510            meta.watcher_count = arr.len() as u32;
511            meta.watcher_urls = arr
512                .iter()
513                .filter_map(|v| v.as_str().map(|s| s.to_string()))
514                .collect();
515        }
516    }
517
518    meta
519}
520
521/// Extract a did:web document from JSONL content by rewriting the did:webvh identity.
522///
523/// Returns `Some(json_bytes)` if the last log entry's `state.alsoKnownAs` contains
524/// the expected `did:web` identifier.
525pub fn extract_did_web_document(jsonl_content: &str, expected_did_web: &str) -> Option<Vec<u8>> {
526    let last_line = jsonl_content.lines().last()?;
527    let entry: serde_json::Value = serde_json::from_str(last_line).ok()?;
528    let state = entry.get("state")?;
529
530    let webvh_id = state.get("id")?.as_str()?;
531    if !webvh_id.starts_with("did:webvh:") {
532        return None;
533    }
534
535    let also_known_as = state.get("alsoKnownAs")?.as_array()?;
536    let found = also_known_as
537        .iter()
538        .any(|v| v.as_str() == Some(expected_did_web));
539    if !found {
540        return None;
541    }
542
543    let state_json = serde_json::to_string(state).ok()?;
544    let rewritten = state_json.replace(webvh_id, expected_did_web);
545    Some(rewritten.into_bytes())
546}
547
548/// Parse JSONL log entries into structured `LogEntryInfo` values.
549pub fn parse_log_entries(jsonl_content: &str) -> Vec<LogEntryInfo> {
550    jsonl_content
551        .lines()
552        .filter(|line| !line.trim().is_empty())
553        .filter_map(|line| {
554            let value: serde_json::Value = serde_json::from_str(line).ok()?;
555            Some(LogEntryInfo {
556                version_id: value
557                    .get("versionId")
558                    .and_then(|v| v.as_str())
559                    .map(String::from),
560                version_time: value
561                    .get("versionTime")
562                    .and_then(|v| v.as_str())
563                    .map(String::from),
564                state: value.get("state").cloned(),
565                parameters: value.get("parameters").cloned(),
566            })
567        })
568        .collect()
569}
570
571// ---------------------------------------------------------------------------
572// Unit tests
573// ---------------------------------------------------------------------------
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    // ---- T12: DidRecord new fields backwards-compat ----
580
581    #[test]
582    fn legacy_did_record_deserialises_with_webvh_default_method() {
583        // A v0.6-vintage stored record lacks `method` + `domain`.
584        // The `#[serde(default)]` fallback on both fields must accept
585        // it: `method = "webvh"`, `domain = ""`. T13's M-01 migration
586        // walks the keyspace and fills `domain` with the legacy
587        // public_url host.
588        let legacy = r#"{
589            "owner": "did:example:owner",
590            "mnemonic": "tenant/user1",
591            "created_at": 1700000000,
592            "updated_at": 1700000000,
593            "version_count": 1,
594            "did_id": null,
595            "content_size": 0,
596            "disabled": false,
597            "deleted_at": null
598        }"#;
599        let rec: DidRecord = serde_json::from_str(legacy).unwrap();
600        assert_eq!(rec.method, "webvh");
601        assert_eq!(rec.domain, "");
602    }
603
604    #[test]
605    fn new_did_record_round_trips_method_and_domain() {
606        let original = DidRecord {
607            owner: "did:example:owner".into(),
608            mnemonic: "user1".into(),
609            created_at: 0,
610            updated_at: 0,
611            version_count: 1,
612            did_id: None,
613            content_size: 0,
614            disabled: false,
615            deleted_at: None,
616            method: "web".into(),
617            domain: "tenant-a.example.com".into(),
618            services: None,
619            agent_names: Vec::new(),
620        };
621        let json = serde_json::to_string(&original).unwrap();
622        let back: DidRecord = serde_json::from_str(&json).unwrap();
623        assert_eq!(back.method, "web");
624        assert_eq!(back.domain, "tenant-a.example.com");
625    }
626
627    #[test]
628    fn explicit_empty_domain_round_trips() {
629        // Mid-migration state: M-01 set `method` but `domain` is
630        // still the default empty string. Must round-trip cleanly so
631        // the migration is idempotent.
632        let legacy = r#"{
633            "owner": "did:example:owner",
634            "mnemonic": "u",
635            "created_at": 0,
636            "updated_at": 0,
637            "version_count": 0,
638            "method": "webvh"
639        }"#;
640        let rec: DidRecord = serde_json::from_str(legacy).unwrap();
641        assert_eq!(rec.method, "webvh");
642        assert_eq!(rec.domain, "");
643    }
644
645    #[test]
646    fn extract_did_id_from_state_id() {
647        let jsonl = r#"{"versionId":"1-abc","parameters":{"method":"did:webvh:1.0"},"state":{"id":"did:webvh:abc123:example.com:test"}}"#;
648        assert_eq!(
649            extract_did_id(jsonl),
650            Some("did:webvh:abc123:example.com:test".to_string())
651        );
652    }
653
654    #[test]
655    fn extract_did_id_returns_none_without_state() {
656        let jsonl = r#"{"parameters":{"method":"did:webvh:1.0"}}"#;
657        assert_eq!(extract_did_id(jsonl), None);
658    }
659
660    #[test]
661    fn extract_did_id_returns_none_for_non_webvh() {
662        let jsonl = r#"{"state":{"id":"did:key:z6Mk..."}}"#;
663        assert_eq!(extract_did_id(jsonl), None);
664    }
665
666    #[test]
667    fn extract_did_id_uses_last_line() {
668        let jsonl = r#"{"state":{"id":"did:webvh:first:host:path"}}
669{"state":{"id":"did:webvh:second:host:path"}}"#;
670        assert_eq!(
671            extract_did_id(jsonl),
672            Some("did:webvh:second:host:path".to_string())
673        );
674    }
675
676    #[test]
677    fn log_metadata_empty_content() {
678        let meta = extract_log_metadata("");
679        assert_eq!(meta.log_entry_count, 0);
680        assert_eq!(meta.latest_version_id, None);
681    }
682
683    #[test]
684    fn log_metadata_basic_entry() {
685        let jsonl = r#"{"versionId":"1-QmHash","versionTime":"2025-01-23T04:12:36Z","parameters":{"method":"did:webvh:1.0","portable":true}}"#;
686        let meta = extract_log_metadata(jsonl);
687        assert_eq!(meta.log_entry_count, 1);
688        assert_eq!(meta.latest_version_id.as_deref(), Some("1-QmHash"));
689        assert!(meta.portable);
690    }
691
692    #[test]
693    fn validate_jsonl_empty_rejected() {
694        assert!(validate_did_jsonl("").is_err());
695    }
696
697    #[test]
698    fn validate_jsonl_invalid_json_rejected() {
699        assert!(validate_did_jsonl("not json").is_err());
700    }
701
702    #[test]
703    fn did_record_deserialize_without_content_size() {
704        let json = r#"{"owner":"did:example:a","mnemonic":"test","created_at":100,"updated_at":100,"version_count":1}"#;
705        let record: DidRecord = serde_json::from_str(json).unwrap();
706        assert_eq!(record.content_size, 0);
707        assert!(record.did_id.is_none());
708    }
709
710    #[test]
711    fn parse_log_entries_works() {
712        let jsonl = r#"{"versionId":"1-abc","state":{"id":"test"},"parameters":{"method":"1.0"}}"#;
713        let entries = parse_log_entries(jsonl);
714        assert_eq!(entries.len(), 1);
715        assert_eq!(entries[0].version_id.as_deref(), Some("1-abc"));
716    }
717}
718
719#[cfg(test)]
720mod agent_name_tests {
721    use super::*;
722
723    fn log_with_aka(aka: &[&str]) -> String {
724        let list = aka
725            .iter()
726            .map(|s| format!("\"{s}\""))
727            .collect::<Vec<_>>()
728            .join(",");
729        format!(
730            "{{\"versionId\":\"1\",\"state\":{{\"id\":\"did:webvh:x:host.example\",\"alsoKnownAs\":[{list}]}}}}"
731        )
732    }
733
734    #[test]
735    fn extracts_names_on_the_hosting_domain() {
736        let log = log_with_aka(&["https://host.example/@alice", "host.example/@bob"]);
737        let mut names = extract_agent_names(&log, "host.example");
738        names.sort();
739        assert_eq!(names, vec!["alice", "bob"]);
740    }
741
742    #[test]
743    fn ignores_names_on_other_domains() {
744        let log = log_with_aka(&["https://elsewhere.example/@alice"]);
745        assert!(extract_agent_names(&log, "host.example").is_empty());
746    }
747
748    #[test]
749    fn ignores_non_agent_name_entries() {
750        // alsoKnownAs legitimately holds other identifier types.
751        let log = log_with_aka(&[
752            "did:web:host.example:someone",
753            "https://host.example/@alice",
754        ]);
755        assert_eq!(extract_agent_names(&log, "host.example"), vec!["alice"]);
756    }
757
758    #[test]
759    fn deduplicates() {
760        let log = log_with_aka(&["host.example/@alice", "https://host.example/@alice"]);
761        assert_eq!(extract_agent_names(&log, "host.example"), vec!["alice"]);
762    }
763
764    #[test]
765    fn no_also_known_as_is_empty() {
766        let log = "{\"versionId\":\"1\",\"state\":{\"id\":\"did:webvh:x:host.example\"}}";
767        assert!(extract_agent_names(log, "host.example").is_empty());
768    }
769}