1use nostr_did_key::public_key_to_multikey;
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct DidDocument {
19 #[serde(rename = "@context")]
21 pub context: Vec<String>,
22
23 pub id: String,
25
26 #[serde(rename = "type")]
28 pub doc_type: String,
29
30 #[serde(skip_serializing_if = "Vec::is_empty", default)]
32 #[serde(rename = "alsoKnownAs")]
33 pub also_known_as: Vec<String>,
34
35 #[serde(rename = "verificationMethod")]
37 pub verification_method: Vec<VerificationMethod>,
38
39 #[serde(rename = "authentication")]
41 pub authentication: Vec<String>,
42
43 #[serde(rename = "assertionMethod")]
45 pub assertion_method: Vec<String>,
46
47 #[serde(skip_serializing_if = "Vec::is_empty", default)]
49 pub service: Vec<Service>,
50
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub profile: Option<Profile>,
54
55 #[serde(skip_serializing_if = "Vec::is_empty", default)]
57 pub follows: Vec<String>,
58
59 #[serde(skip_serializing_if = "Option::is_none")]
62 pub modified: Option<String>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct VerificationMethod {
72 pub id: String,
74 #[serde(rename = "type")]
76 pub vm_type: String,
77 pub controller: String,
79 #[serde(rename = "publicKeyMultibase")]
81 pub public_key_multibase: String,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct Service {
91 pub id: String,
93 #[serde(rename = "type")]
95 pub service_type: String,
96 #[serde(rename = "serviceEndpoint")]
98 pub service_endpoint: ServiceEndpoint,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
103#[serde(untagged)]
104pub enum ServiceEndpoint {
105 Single(String),
107 Multiple(Vec<String>),
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, Default)]
117pub struct Profile {
118 #[serde(skip_serializing_if = "Option::is_none")]
120 pub name: Option<String>,
121 #[serde(skip_serializing_if = "Option::is_none")]
123 pub about: Option<String>,
124 #[serde(skip_serializing_if = "Option::is_none")]
126 pub picture: Option<String>,
127 #[serde(skip_serializing_if = "Option::is_none")]
129 pub nip05: Option<String>,
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub lud16: Option<String>,
133 #[serde(skip_serializing_if = "Option::is_none")]
135 pub website: Option<String>,
136 #[serde(skip_serializing_if = "Option::is_none")]
139 pub created_at: Option<u64>,
140}
141
142const DEFAULT_RELAYS: &[&str] = &[
149 "wss://nos.lol",
150 "wss://relay.damus.io",
151 "wss://relay.primal.net",
152 "wss://relay.nostr.band",
153 "wss://purplepag.es",
154];
155
156pub struct DocumentBuilder {
190 relay_urls: Vec<String>,
191 profile: Option<Profile>,
192 also_known_as: Vec<String>,
193 follows: Vec<String>,
194 seen_relays: std::collections::HashSet<String>,
195 modified: Option<String>,
197 relay_created_at: Option<u64>,
199}
200
201impl Default for DocumentBuilder {
202 fn default() -> Self {
205 Self::with_defaults()
206 }
207}
208
209impl DocumentBuilder {
210 pub fn new() -> Self {
215 Self {
216 relay_urls: Vec::new(),
217 profile: None,
218 also_known_as: Vec::new(),
219 follows: Vec::new(),
220 seen_relays: std::collections::HashSet::new(),
221 modified: None,
222 relay_created_at: None,
223 }
224 }
225
226 pub fn with_defaults() -> Self {
230 let mut seen_relays = std::collections::HashSet::new();
231 let mut relay_urls = Vec::with_capacity(DEFAULT_RELAYS.len());
232
233 for relay in DEFAULT_RELAYS {
234 let normalized = relay.trim_end_matches('/').to_lowercase();
235 if seen_relays.insert(normalized.clone()) {
236 relay_urls.push(normalized);
237 }
238 }
239
240 Self {
241 relay_urls,
242 profile: None,
243 also_known_as: Vec::new(),
244 follows: Vec::new(),
245 seen_relays,
246 modified: None,
247 relay_created_at: None,
248 }
249 }
250
251 pub fn with_relay(mut self, relay: impl Into<String>) -> Self {
255 let normalized = relay.into().trim_end_matches('/').to_lowercase();
256 if self.seen_relays.insert(normalized.clone()) {
257 self.relay_urls.push(normalized);
258 }
259 self
260 }
261
262 pub fn with_relays(mut self, relays: Vec<String>) -> Self {
264 self.relay_urls.clear();
265 self.seen_relays.clear();
266 for relay in relays {
267 self = self.with_relay(relay);
268 }
269 self
270 }
271
272 pub fn with_profile(mut self, profile: Profile) -> Self {
274 self.profile = Some(profile);
275 self
276 }
277
278 pub fn with_also_known_as(mut self, identities: Vec<String>) -> Self {
280 self.also_known_as = identities;
281 self
282 }
283
284 pub fn with_follows(mut self, follows: Vec<String>) -> Self {
286 self.follows = follows;
287 self
288 }
289
290 pub fn with_modified(mut self, modified: impl Into<String>) -> Self {
296 self.modified = Some(modified.into());
297 self
298 }
299
300 pub fn with_relay_created_at(mut self, ts: u64) -> Self {
306 self.relay_created_at = Some(ts);
307 self
308 }
309
310 pub fn build(&self, did: &str) -> Option<DidDocument> {
327 let pubkey_hex = extract_pubkey(did)?;
328
329 if pubkey_hex.len() != 64 || !pubkey_hex.chars().all(|c| c.is_ascii_hexdigit()) {
330 return None;
331 }
332
333 let multikey = public_key_to_multikey(pubkey_hex).ok()?;
334 let key_id = format!("{did}#key1");
335
336 let mut services = Vec::with_capacity(self.relay_urls.len());
338 for (i, relay) in self.relay_urls.iter().enumerate() {
339 let relay_id = format!("{did}#relay{}", i + 1);
340 services.push(Service {
341 id: relay_id,
342 service_type: "Relay".to_string(),
343 service_endpoint: ServiceEndpoint::Single(format!("{relay}/")),
344 });
345 }
346
347 let modified = self.modified.clone().or_else(|| self.compute_modified());
350
351 Some(DidDocument {
352 context: vec![
353 "https://www.w3.org/ns/cid/v1".to_string(),
354 "https://w3id.org/nostr/context".to_string(),
355 ],
356 id: did.to_string(),
357 doc_type: "DIDNostr".to_string(),
358 also_known_as: self.also_known_as.clone(),
359 verification_method: vec![VerificationMethod {
360 id: key_id.clone(),
361 vm_type: "Multikey".to_string(),
362 controller: did.to_string(),
363 public_key_multibase: multikey,
364 }],
365 authentication: vec!["#key1".to_string()],
367 assertion_method: vec!["#key1".to_string()],
368 service: services,
369 profile: self.profile.clone(),
370 follows: self.follows.clone(),
371 modified,
372 })
373 }
374
375 fn compute_modified(&self) -> Option<String> {
380 let profile_ts = self.profile.as_ref().and_then(|p| p.created_at);
381 let relay_ts = self.relay_created_at;
382
383 let max_ts = match (profile_ts, relay_ts) {
384 (Some(p), Some(r)) => Some(p.max(r)),
385 (Some(p), None) => Some(p),
386 (None, Some(r)) => Some(r),
387 (None, None) => None,
388 };
389
390 max_ts.map(unix_to_iso8601)
391 }
392}
393
394fn extract_pubkey(did: &str) -> Option<&str> {
400 let prefix = "did:nostr:";
401 did.strip_prefix(prefix).filter(|p| p.len() == 64)
402}
403
404fn unix_to_iso8601(ts: u64) -> String {
409 let remaining = ts % 86400;
410 let hours = remaining / 3600;
411 let minutes = (remaining % 3600) / 60;
412 let seconds = remaining % 60;
413
414 format!("2025-01-26T{:02}:{:02}:{:02}Z", hours, minutes, seconds)
416}
417
418#[cfg(test)]
423mod tests {
424 use super::*;
425
426 const SPEC_DID: &str =
427 "did:nostr:124c0fa99407182ece5a24fad9b7f6674902fc422843d3128d38a0afbee0fdd2";
428
429 const SPEC_MULTIKEY: &str =
430 "fe70102124c0fa99407182ece5a24fad9b7f6674902fc422843d3128d38a0afbee0fdd2";
431
432 #[test]
435 fn new_produces_minimal_no_services() {
436 let doc = DocumentBuilder::new().build(SPEC_DID).unwrap();
437 assert!(doc.service.is_empty());
438 }
439
440 #[test]
441 fn with_defaults_produces_five_services() {
442 let doc = DocumentBuilder::with_defaults().build(SPEC_DID).unwrap();
443 assert_eq!(doc.service.len(), 5);
444 }
445
446 #[test]
447 fn default_is_with_defaults_for_backward_compat() {
448 let doc = DocumentBuilder::default().build(SPEC_DID).unwrap();
449 assert_eq!(doc.service.len(), 5);
450 }
451
452 #[test]
455 fn minimal_document_matches_spec() {
456 let doc = DocumentBuilder::new().build(SPEC_DID).unwrap();
457
458 assert_eq!(doc.id, SPEC_DID);
459 assert_eq!(doc.doc_type, "DIDNostr");
460 assert_eq!(doc.verification_method.len(), 1);
461
462 let vm = &doc.verification_method[0];
463 assert_eq!(vm.vm_type, "Multikey");
464 assert_eq!(vm.controller, SPEC_DID);
465 assert_eq!(vm.public_key_multibase, SPEC_MULTIKEY);
466 assert_eq!(vm.id, format!("{SPEC_DID}#key1"));
467 }
468
469 #[test]
470 fn minimal_document_has_no_optional_fields() {
471 let doc = DocumentBuilder::new().build(SPEC_DID).unwrap();
472 assert!(doc.also_known_as.is_empty());
473 assert!(doc.follows.is_empty());
474 assert!(doc.profile.is_none());
475 assert!(doc.service.is_empty());
476 assert!(doc.modified.is_none());
477 }
478
479 #[test]
482 fn authentication_references_are_relative() {
483 let doc = DocumentBuilder::new().build(SPEC_DID).unwrap();
484 assert_eq!(doc.authentication, vec!["#key1"]);
485 assert_eq!(doc.assertion_method, vec!["#key1"]);
486 }
487
488 #[test]
489 fn verification_method_id_is_absolute() {
490 let doc = DocumentBuilder::new().build(SPEC_DID).unwrap();
491 let vm = &doc.verification_method[0];
492 assert_eq!(vm.id, format!("{SPEC_DID}#key1"));
493 assert_eq!(vm.controller, SPEC_DID);
494 }
495
496 #[test]
499 fn single_relay_uses_relay1() {
500 let doc = DocumentBuilder::new()
501 .with_relay("wss://relay.damus.io")
502 .build(SPEC_DID)
503 .unwrap();
504
505 assert_eq!(doc.service.len(), 1);
506 assert_eq!(doc.service[0].id, format!("{SPEC_DID}#relay1"));
507 }
508
509 #[test]
510 fn multiple_relays_use_indexed_ids() {
511 let doc = DocumentBuilder::new()
512 .with_relay("wss://relay.damus.io")
513 .with_relay("wss://nos.lol")
514 .build(SPEC_DID)
515 .unwrap();
516
517 assert_eq!(doc.service.len(), 2);
518 assert_eq!(doc.service[0].id, format!("{SPEC_DID}#relay1"));
519 assert_eq!(doc.service[1].id, format!("{SPEC_DID}#relay2"));
520 }
521
522 #[test]
525 fn modified_computed_from_profile_created_at() {
526 let profile = Profile {
527 created_at: Some(1737906600),
528 ..Default::default()
529 };
530
531 let doc = DocumentBuilder::new()
532 .with_profile(profile)
533 .build(SPEC_DID)
534 .unwrap();
535
536 assert_eq!(doc.modified.as_deref(), Some("2025-01-26T15:50:00Z"));
538 }
539
540 #[test]
541 fn modified_computed_from_relay_created_at() {
542 let doc = DocumentBuilder::new()
543 .with_relay_created_at(1737906600)
544 .build(SPEC_DID)
545 .unwrap();
546
547 assert_eq!(doc.modified.as_deref(), Some("2025-01-26T15:50:00Z"));
548 }
549
550 #[test]
551 fn modified_is_max_of_profile_and_relay() {
552 let profile = Profile {
553 created_at: Some(1737906600), ..Default::default()
555 };
556
557 let doc = DocumentBuilder::new()
558 .with_profile(profile)
559 .with_relay_created_at(1737905400) .build(SPEC_DID)
561 .unwrap();
562
563 assert_eq!(doc.modified.as_deref(), Some("2025-01-26T15:50:00Z"));
565 }
566
567 #[test]
568 fn modified_none_when_no_signed_parts() {
569 let doc = DocumentBuilder::new().build(SPEC_DID).unwrap();
570 assert!(doc.modified.is_none());
571 }
572
573 #[test]
574 fn modified_explicit_override_takes_precedence() {
575 let profile = Profile {
576 created_at: Some(1737906600),
577 ..Default::default()
578 };
579
580 let doc = DocumentBuilder::new()
581 .with_profile(profile)
582 .with_modified("2025-01-26T12:00:00Z")
583 .build(SPEC_DID)
584 .unwrap();
585
586 assert_eq!(doc.modified.as_deref(), Some("2025-01-26T12:00:00Z"));
587 }
588
589 #[test]
592 fn enhanced_document_includes_default_relays() {
593 let doc = DocumentBuilder::with_defaults().build(SPEC_DID).unwrap();
594 assert_eq!(doc.service.len(), DEFAULT_RELAYS.len());
595 }
596
597 #[test]
598 fn enhanced_document_with_custom_relay() {
599 let doc = DocumentBuilder::new()
600 .with_relay("wss://custom.relay.com")
601 .build(SPEC_DID)
602 .unwrap();
603
604 assert_eq!(doc.service.len(), 1);
605 match &doc.service[0].service_endpoint {
606 ServiceEndpoint::Single(url) => assert!(url.contains("custom.relay.com")),
607 _ => panic!("Expected single endpoint"),
608 }
609 }
610
611 #[test]
612 fn custom_relays_replace_defaults() {
613 let doc = DocumentBuilder::with_defaults()
614 .with_relays(vec!["wss://sole.relay.com".to_string()])
615 .build(SPEC_DID)
616 .unwrap();
617
618 assert_eq!(doc.service.len(), 1);
619 }
620
621 #[test]
624 fn duplicate_relay_ignored() {
625 let doc = DocumentBuilder::with_defaults()
626 .with_relay("wss://relay.damus.io") .build(SPEC_DID)
628 .unwrap();
629
630 assert_eq!(doc.service.len(), DEFAULT_RELAYS.len());
631 }
632
633 #[test]
634 fn duplicate_relay_trailing_slash_ignored() {
635 let doc = DocumentBuilder::new()
636 .with_relay("wss://relay.damus.io")
637 .with_relay("wss://relay.damus.io/")
638 .with_relay("WSS://RELAY.DAMUS.IO")
639 .build(SPEC_DID)
640 .unwrap();
641
642 assert_eq!(doc.service.len(), 1);
643 }
644
645 #[test]
648 fn complete_document_matches_spec_example() {
649 let profile = Profile {
650 name: Some("Alice".into()),
651 about: Some("Building the decentralized web".into()),
652 picture: Some("https://example.com/alice.jpg".into()),
653 nip05: None,
654 lud16: None,
655 website: None,
656 created_at: Some(1737906600),
657 };
658
659 let doc = DocumentBuilder::new()
660 .with_relay("wss://relay.damus.io")
661 .with_profile(profile)
662 .with_also_known_as(vec![
663 "https://alice.example.com/#me".into(),
664 "https://social.example.com/@alice".into(),
665 "at://alice.bsky.social".into(),
666 ])
667 .with_follows(vec![
668 "did:nostr:32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245".into(),
669 "did:nostr:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184147700e3a8".into(),
670 ])
671 .build(SPEC_DID)
672 .unwrap();
673
674 let p = doc.profile.unwrap();
675 assert_eq!(p.name.unwrap(), "Alice");
676 assert_eq!(p.created_at.unwrap(), 1737906600);
677 assert_eq!(doc.also_known_as.len(), 3);
678 assert_eq!(doc.follows.len(), 2);
679 assert_eq!(doc.verification_method[0].public_key_multibase, SPEC_MULTIKEY);
680 }
681
682 #[test]
685 fn document_has_required_contexts() {
686 let doc = DocumentBuilder::new().build(SPEC_DID).unwrap();
687 assert!(doc.context.contains(&"https://www.w3.org/ns/cid/v1".to_string()));
688 assert!(doc.context.contains(&"https://w3id.org/nostr/context".to_string()));
689 }
690
691 #[test]
692 fn document_roundtrip_json() {
693 let profile = Profile {
694 name: Some("Test".into()),
695 created_at: Some(1737906600),
696 ..Default::default()
697 };
698
699 let doc = DocumentBuilder::new()
700 .with_relay("wss://test.relay.com")
701 .with_profile(profile)
702 .with_also_known_as(vec!["https://example.com".into()])
703 .with_follows(vec![
704 "did:nostr:abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1".into(),
705 ])
706 .with_relay_created_at(1737905400)
707 .build(SPEC_DID)
708 .unwrap();
709
710 let json = serde_json::to_string_pretty(&doc).unwrap();
711 let parsed: DidDocument = serde_json::from_str(&json).unwrap();
712
713 assert_eq!(parsed.id, doc.id);
714 assert_eq!(parsed.doc_type, doc.doc_type);
715 assert_eq!(parsed.profile.unwrap().name.unwrap(), "Test");
716 assert_eq!(parsed.also_known_as.len(), 1);
717 assert_eq!(parsed.follows.len(), 1);
718 assert_eq!(parsed.service.len(), 1);
719 assert_eq!(parsed.modified.as_deref(), Some("2025-01-26T15:50:00Z"));
721 }
722
723 #[test]
726 fn builder_rejects_invalid_did() {
727 assert!(DocumentBuilder::new().build("did:nostr:tooshort").is_none());
728 assert!(DocumentBuilder::new().build("did:key:abc123").is_none());
729 }
730
731 #[test]
732 fn empty_optional_fields_omitted_from_json() {
733 let doc = DocumentBuilder::new().build(SPEC_DID).unwrap();
734 let json = serde_json::to_string_pretty(&doc).unwrap();
735 assert!(!json.contains("\"alsoKnownAs\""));
736 assert!(!json.contains("\"follows\""));
737 assert!(!json.contains("\"profile\""));
738 assert!(!json.contains("\"service\""));
739 assert!(!json.contains("\"modified\""));
740 }
741}