1use 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#[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 #[serde(default)]
34 pub deleted_at: Option<u64>,
35
36 #[serde(default = "default_method")]
57 pub method: String,
58
59 #[serde(default)]
66 pub domain: String,
67
68 #[serde(default)]
90 pub services: Option<Vec<String>>,
91
92 #[serde(default)]
106 pub agent_names: Vec<AgentNameEntry>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct AgentNameEntry {
113 pub name: String,
118
119 pub enabled: bool,
124
125 pub created_at: u64,
127}
128
129fn default_method() -> String {
130 "webvh".to_string()
131}
132
133#[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#[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
163pub 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
183pub 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
199pub 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 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
241pub fn verify_did_log_proofs(content: &str) -> Result<(), String> {
266 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 report
299 .assert_complete()
300 .map_err(|e| format!("proof verification failed (chain incomplete): {e}"))?;
301
302 Ok(())
303}
304
305pub 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
364pub 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
377pub 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
391pub 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; };
429 if parsed.authority() != domain {
430 continue; }
432 let local = parsed.local_name().to_string();
433 if !names.contains(&local) {
434 names.push(local);
435 }
436 }
437 names
438}
439
440pub 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
521pub 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
548pub 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#[cfg(test)]
576mod tests {
577 use super::*;
578
579 #[test]
582 fn legacy_did_record_deserialises_with_webvh_default_method() {
583 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 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 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}