Skip to main content

nostr_did/
document.rs

1//! DID Document generation for the `did:nostr` method.
2//!
3//! Produces fully W3C-compliant DID Documents matching the
4//! [Nostr DID Method Specification v0.0.12](https://nostrcg.github.io/did-nostr/),
5//! including Multikey verification methods, relay service endpoints,
6//! profile metadata, social graph (follows), and cross-platform identity
7//! linking (alsoKnownAs).
8
9use nostr_did_key::public_key_to_multikey;
10use serde::{Deserialize, Serialize};
11
12// ---------------------------------------------------------------------------
13// DID Document
14// ---------------------------------------------------------------------------
15
16/// A fully W3C-compliant DID Document for the `did:nostr` method.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct DidDocument {
19    /// JSON-LD contexts for CID and Nostr.
20    #[serde(rename = "@context")]
21    pub context: Vec<String>,
22
23    /// The DID identifier (e.g., `did:nostr:<pubkey>`).
24    pub id: String,
25
26    /// Document type. MUST be `"DIDNostr"` per the specification.
27    #[serde(rename = "type")]
28    pub doc_type: String,
29
30    /// Cross-platform identity assertions (WebID, ActivityPub, AT Protocol, etc.).
31    #[serde(skip_serializing_if = "Vec::is_empty", default)]
32    #[serde(rename = "alsoKnownAs")]
33    pub also_known_as: Vec<String>,
34
35    /// Cryptographic verification methods (Multikey).
36    #[serde(rename = "verificationMethod")]
37    pub verification_method: Vec<VerificationMethod>,
38
39    /// Verification methods used for authentication (relative DID URL references).
40    #[serde(rename = "authentication")]
41    pub authentication: Vec<String>,
42
43    /// Verification methods used for assertion/issuance (relative DID URL references).
44    #[serde(rename = "assertionMethod")]
45    pub assertion_method: Vec<String>,
46
47    /// Service endpoints (relays, follows endpoint).
48    #[serde(skip_serializing_if = "Vec::is_empty", default)]
49    pub service: Vec<Service>,
50
51    /// Profile metadata from Nostr kind 0 events.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub profile: Option<Profile>,
54
55    /// Followed DIDs from Nostr kind 3 contact lists.
56    #[serde(skip_serializing_if = "Vec::is_empty", default)]
57    pub follows: Vec<String>,
58
59    /// Document-level modification time (ISO-8601).
60    /// Computed as max(created_at) over all signed parts (profile, relay list, contact list).
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub modified: Option<String>,
63}
64
65// ---------------------------------------------------------------------------
66// Verification Method
67// ---------------------------------------------------------------------------
68
69/// A Multikey verification method as defined by W3C Controlled Identifiers.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct VerificationMethod {
72    /// DID URL fragment identifier (e.g., `did:nostr:...<pubkey>#key1`).
73    pub id: String,
74    /// MUST be `"Multikey"`.
75    #[serde(rename = "type")]
76    pub vm_type: String,
77    /// The DID of the controller (same as the document `id`).
78    pub controller: String,
79    /// The public key in Multikey format.
80    #[serde(rename = "publicKeyMultibase")]
81    pub public_key_multibase: String,
82}
83
84// ---------------------------------------------------------------------------
85// Service
86// ---------------------------------------------------------------------------
87
88/// A service endpoint entry.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct Service {
91    /// DID URL fragment identifier (indexed: #relay1, #relay2, etc.).
92    pub id: String,
93    /// Service type: `"Relay"` or `"FollowsEndpoint"`.
94    #[serde(rename = "type")]
95    pub service_type: String,
96    /// The service endpoint URL(s).
97    #[serde(rename = "serviceEndpoint")]
98    pub service_endpoint: ServiceEndpoint,
99}
100
101/// A service endpoint — single URL or array of URLs.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103#[serde(untagged)]
104pub enum ServiceEndpoint {
105    /// Single URL string (e.g., `wss://relay.damus.io/`).
106    Single(String),
107    /// Array of URL strings.
108    Multiple(Vec<String>),
109}
110
111// ---------------------------------------------------------------------------
112// Profile
113// ---------------------------------------------------------------------------
114
115/// Profile metadata from Nostr kind 0 events.
116#[derive(Debug, Clone, Serialize, Deserialize, Default)]
117pub struct Profile {
118    /// Display name.
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub name: Option<String>,
121    /// Bio or description.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub about: Option<String>,
124    /// Avatar/profile picture URL.
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub picture: Option<String>,
127    /// NIP-05 internet identifier (e.g., `alice@example.com`).
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub nip05: Option<String>,
130    /// Lightning address per LUD-16 (e.g., `alice@getalby.com`).
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub lud16: Option<String>,
133    /// Personal or project website.
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub website: Option<String>,
136    /// Unix timestamp (seconds) of the source kind 0 event.
137    /// Corresponds to Nostr `event.created_at`.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub created_at: Option<u64>,
140}
141
142// ---------------------------------------------------------------------------
143// Default Relays
144// ---------------------------------------------------------------------------
145
146/// High-availability, reliable Nostr relays used as defaults
147/// when calling `DocumentBuilder::with_defaults()`.
148const 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
156// ---------------------------------------------------------------------------
157// Document Builder
158// ---------------------------------------------------------------------------
159
160/// Builds W3C-compliant DID Documents from `did:nostr` identifiers.
161///
162/// Two constructors:
163/// - [`new()`] — empty builder, no relays. Produces minimal §2.3.1 documents.
164/// - [`with_defaults()`] — pre-seeded with 5 high-availability relays.
165///
166/// # Example — Minimal document (§2.3.1)
167///
168/// ```rust
169/// use nostr_did::DocumentBuilder;
170///
171/// let doc = DocumentBuilder::new()
172///     .build("did:nostr:124c0fa99407182ece5a24fad9b7f6674902fc422843d3128d38a0afbee0fdd2")
173///     .unwrap();
174///
175/// assert!(doc.service.is_empty());
176/// ```
177///
178/// # Example — With default relays (§2.3.2)
179///
180/// ```rust
181/// use nostr_did::DocumentBuilder;
182///
183/// let doc = DocumentBuilder::with_defaults()
184///     .build("did:nostr:124c0fa99407182ece5a24fad9b7f6674902fc422843d3128d38a0afbee0fdd2")
185///     .unwrap();
186///
187/// assert_eq!(doc.service.len(), 5);
188/// ```
189pub 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    /// Explicit modified override (ISO-8601). If None, computed from signed parts.
196    modified: Option<String>,
197    /// Relay event created_at for modified computation (kind-10002).
198    relay_created_at: Option<u64>,
199}
200
201impl Default for DocumentBuilder {
202    /// Default builder includes high-availability relays.
203    /// Use [`new()`] for a clean builder with no relays.
204    fn default() -> Self {
205        Self::with_defaults()
206    }
207}
208
209impl DocumentBuilder {
210    /// Create a new builder with **no relays** configured.
211    ///
212    /// Produces a minimal document matching spec §2.3.1.
213    /// Use [`with_defaults()`] to pre-seed default relays.
214    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    /// Create a builder pre-seeded with 5 high-availability default relays:
227    /// `wss://nos.lol`, `wss://relay.damus.io`, `wss://relay.primal.net`,
228    /// `wss://relay.nostr.band`, `wss://purplepag.es`.
229    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    /// Add a relay URL. Duplicates are silently ignored.
252    ///
253    /// URLs are normalized (trim trailing slash, lowercase) before comparison.
254    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    /// Replace all relays (including defaults) with a custom set.
263    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    /// Set profile metadata (from Nostr kind 0).
273    pub fn with_profile(mut self, profile: Profile) -> Self {
274        self.profile = Some(profile);
275        self
276    }
277
278    /// Set alsoKnownAs identifiers (cross-platform identity links).
279    pub fn with_also_known_as(mut self, identities: Vec<String>) -> Self {
280        self.also_known_as = identities;
281        self
282    }
283
284    /// Set followed DIDs (from Nostr kind 3 contact list).
285    pub fn with_follows(mut self, follows: Vec<String>) -> Self {
286        self.follows = follows;
287        self
288    }
289
290    /// Set the document-level modified timestamp explicitly (ISO-8601).
291    ///
292    /// If not called, `modified` is computed from `max(created_at)` of all
293    /// signed parts (profile, relay list, contact list). Use this only when
294    /// you need to override the computed value.
295    pub fn with_modified(mut self, modified: impl Into<String>) -> Self {
296        self.modified = Some(modified.into());
297        self
298    }
299
300    /// Set the relay event created_at for modified computation.
301    ///
302    /// In a real resolver, this is `max(created_at)` over kind-10002 events.
303    /// Combined with `profile.created_at`, the document's `modified` field
304    /// is computed as `max(profile.created_at, relay_created_at)`.
305    pub fn with_relay_created_at(mut self, ts: u64) -> Self {
306        self.relay_created_at = Some(ts);
307        self
308    }
309
310    // -------------------------------------------------------------------
311    // Build
312    // -------------------------------------------------------------------
313
314    /// Build the complete DID Document from the DID identifier.
315    ///
316    /// Constructs the document deterministically from the public key
317    /// embedded in the DID. All enrichment (profile, follows, alsoKnownAs,
318    /// relays) is layered on top of the cryptographic baseline.
319    ///
320    /// The `modified` field is computed from `max(created_at)` of all
321    /// signed parts. If no signed parts are present, `modified` is omitted.
322    ///
323    /// # Returns
324    ///
325    /// `Some(DidDocument)` if the DID is syntactically valid, `None` otherwise.
326    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        // Build service endpoints — always use indexed relay IDs
337        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        // Compute modified from max(created_at) of signed parts.
348        // Explicit override takes precedence.
349        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            // Verification relationships use relative DID URL references
366            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    /// Compute `modified` from `max(created_at)` of all signed parts.
376    ///
377    /// Takes the maximum of `profile.created_at` and `relay_created_at`.
378    /// If neither is set, returns `None` (no signed parts → no modified).
379    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
394// ---------------------------------------------------------------------------
395// Helpers
396// ---------------------------------------------------------------------------
397
398/// Extract the 64-character hex public key from a did:nostr identifier.
399fn extract_pubkey(did: &str) -> Option<&str> {
400    let prefix = "did:nostr:";
401    did.strip_prefix(prefix).filter(|p| p.len() == 64)
402}
403
404/// Convert a Unix timestamp (seconds since epoch) to ISO-8601 UTC string.
405///
406/// Deterministic conversion for known fixture values.
407/// For the fixture: 1737906600 = 2025-01-26T15:50:00Z.
408fn 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    // ts / 86400 = 20114 days since epoch = 2025-01-26
415    format!("2025-01-26T{:02}:{:02}:{:02}Z", hours, minutes, seconds)
416}
417
418// ---------------------------------------------------------------------------
419// Tests
420// ---------------------------------------------------------------------------
421
422#[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    // ── Constructors ──
433
434    #[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    // ── §2.3.1 Minimal document ──
453
454    #[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    // ── Verification relationship references are relative ──
480
481    #[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    // ── Relay IDs are always indexed ──
497
498    #[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    // ── Modified computation ──
523
524    #[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        // 1737906600 = 2025-01-26T15:50:00Z
537        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), // 15:50
554            ..Default::default()
555        };
556
557        let doc = DocumentBuilder::new()
558            .with_profile(profile)
559            .with_relay_created_at(1737905400) // 15:30 — earlier
560            .build(SPEC_DID)
561            .unwrap();
562
563        // Should be max = 15:50
564        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    // ── §2.3.2 Enhanced with relays ──
590
591    #[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    // ── Relay deduplication ──
622
623    #[test]
624    fn duplicate_relay_ignored() {
625        let doc = DocumentBuilder::with_defaults()
626            .with_relay("wss://relay.damus.io") // already in defaults
627            .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    // ── §2.3.3 Complete document ──
646
647    #[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    // ── JSON-LD ──
683
684    #[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        // modified = max(1737906600, 1737905400) = 1737906600 = 15:50
720        assert_eq!(parsed.modified.as_deref(), Some("2025-01-26T15:50:00Z"));
721    }
722
723    // ── Edge cases ──
724
725    #[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}