Skip to main content

atproto_identity/
model.rs

1//! Data structures for DID documents and AT Protocol entities.
2//!
3//! Core data models for AT Protocol identity including DID documents, services,
4//! and verification methods with full JSON serialization support.
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::HashMap;
9
10/// AT Protocol service configuration from a DID document.
11/// Represents services like Personal Data Servers (PDS).
12#[cfg_attr(debug_assertions, derive(Debug))]
13#[derive(Clone, Serialize, Deserialize, PartialEq)]
14#[serde(rename_all = "camelCase")]
15pub struct Service {
16    /// Unique identifier for the service.
17    pub id: String,
18    /// Service type (e.g., "AtprotoPersonalDataServer").
19    pub r#type: String,
20    /// URL endpoint where the service can be reached.
21    pub service_endpoint: String,
22
23    /// Additional service properties not explicitly defined.
24    #[serde(flatten)]
25    pub extra: HashMap<String, Value>,
26}
27
28/// Cryptographic verification method from a DID document.
29/// Used to verify signatures and authenticate identity operations.
30#[cfg_attr(debug_assertions, derive(Debug))]
31#[derive(Clone, Serialize, Deserialize, PartialEq)]
32#[serde(tag = "type")]
33pub enum VerificationMethod {
34    /// Multikey verification method with multibase-encoded public key.
35    Multikey {
36        /// Unique identifier for this verification method.
37        id: String,
38        /// DID that controls this verification method.
39        controller: String,
40
41        /// Public key encoded in multibase format.
42        #[serde(rename = "publicKeyMultibase")]
43        public_key_multibase: String,
44
45        /// Additional verification method properties.
46        #[serde(flatten)]
47        extra: HashMap<String, Value>,
48    },
49
50    /// Other verification method types not explicitly supported.
51    #[serde(untagged)]
52    Other {
53        /// All properties of the unsupported verification method.
54        #[serde(flatten)]
55        extra: HashMap<String, Value>,
56    },
57}
58
59/// Complete DID document containing identity information.
60/// Contains services, verification methods, and aliases for a DID.
61#[cfg_attr(debug_assertions, derive(Debug))]
62#[derive(Clone, Serialize, Deserialize, PartialEq)]
63#[serde(rename_all = "camelCase")]
64pub struct Document {
65    /// JSON-LD context URLs defining the semantics of the DID document.
66    /// Typically includes "<https://www.w3.org/ns/did/v1>" and method-specific contexts.
67    #[serde(rename = "@context", default)]
68    pub context: Vec<String>,
69
70    /// The DID identifier (e.g., "did:plc:abc123").
71    pub id: String,
72    /// Alternative identifiers like handles and domains.
73    #[serde(default)]
74    pub also_known_as: Vec<String>,
75    /// Available services for this identity.
76    #[serde(default)]
77    pub service: Vec<Service>,
78
79    /// Cryptographic verification methods.
80    #[serde(alias = "verificationMethod", default)]
81    pub verification_method: Vec<VerificationMethod>,
82
83    /// Additional document properties not explicitly defined.
84    #[serde(flatten)]
85    pub extra: HashMap<String, Value>,
86}
87
88/// Builder for constructing DID documents with a fluent API.
89/// Provides controlled construction with validation capabilities.
90#[derive(Default)]
91pub struct DocumentBuilder {
92    context: Option<Vec<String>>,
93    id: Option<String>,
94    also_known_as: Vec<String>,
95    service: Vec<Service>,
96    verification_method: Vec<VerificationMethod>,
97    extra: HashMap<String, Value>,
98}
99
100impl DocumentBuilder {
101    /// Creates a new DocumentBuilder with empty fields.
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    /// Sets the JSON-LD context URLs for the document.
107    pub fn context(mut self, context: Vec<String>) -> Self {
108        self.context = Some(context);
109        self
110    }
111
112    /// Adds a single context URL to the document.
113    pub fn add_context(mut self, context_url: impl Into<String>) -> Self {
114        self.context
115            .get_or_insert_with(|| vec!["https://www.w3.org/ns/did/v1".to_string()])
116            .push(context_url.into());
117        self
118    }
119
120    /// Sets the DID identifier for the document.
121    pub fn id(mut self, id: impl Into<String>) -> Self {
122        self.id = Some(id.into());
123        self
124    }
125
126    /// Sets all alternative identifiers at once.
127    pub fn also_known_as(mut self, aliases: Vec<String>) -> Self {
128        self.also_known_as = aliases;
129        self
130    }
131
132    /// Adds a single alternative identifier.
133    pub fn add_also_known_as(mut self, alias: impl Into<String>) -> Self {
134        self.also_known_as.push(alias.into());
135        self
136    }
137
138    /// Sets all services at once.
139    pub fn services(mut self, services: Vec<Service>) -> Self {
140        self.service = services;
141        self
142    }
143
144    /// Adds a single service to the document.
145    pub fn add_service(mut self, service: Service) -> Self {
146        self.service.push(service);
147        self
148    }
149
150    /// Convenience method to add a PDS service.
151    pub fn add_pds_service(mut self, endpoint: impl Into<String>) -> Self {
152        self.service.push(Service {
153            id: "#atproto_pds".to_string(),
154            r#type: "AtprotoPersonalDataServer".to_string(),
155            service_endpoint: endpoint.into(),
156            extra: HashMap::new(),
157        });
158        self
159    }
160
161    /// Sets all verification methods at once.
162    pub fn verification_methods(mut self, methods: Vec<VerificationMethod>) -> Self {
163        self.verification_method = methods;
164        self
165    }
166
167    /// Adds a single verification method.
168    pub fn add_verification_method(mut self, method: VerificationMethod) -> Self {
169        self.verification_method.push(method);
170        self
171    }
172
173    /// Convenience method to add a Multikey verification method.
174    pub fn add_multikey(
175        mut self,
176        id: impl Into<String>,
177        controller: impl Into<String>,
178        public_key_multibase: impl Into<String>,
179    ) -> Self {
180        let key_multibase = public_key_multibase.into();
181        let key_multibase = key_multibase
182            .strip_prefix("did:key:")
183            .unwrap_or(&key_multibase)
184            .to_string();
185
186        self.verification_method.push(VerificationMethod::Multikey {
187            id: id.into(),
188            controller: controller.into(),
189            public_key_multibase: key_multibase,
190            extra: HashMap::new(),
191        });
192        self
193    }
194
195    /// Adds an extra property to the document.
196    pub fn add_extra(mut self, key: impl Into<String>, value: Value) -> Self {
197        self.extra.insert(key.into(), value);
198        self
199    }
200
201    /// Builds the Document, returning an error if required fields are missing.
202    pub fn build(self) -> Result<Document, &'static str> {
203        let id = self.id.ok_or("Document ID is required")?;
204
205        // Use default context if not provided
206        let context = self
207            .context
208            .unwrap_or_else(|| vec!["https://www.w3.org/ns/did/v1".to_string()]);
209
210        Ok(Document {
211            context,
212            id,
213            also_known_as: self.also_known_as,
214            service: self.service,
215            verification_method: self.verification_method,
216            extra: self.extra,
217        })
218    }
219}
220
221impl Document {
222    /// Creates a new DocumentBuilder for constructing a Document.
223    pub fn builder() -> DocumentBuilder {
224        DocumentBuilder::new()
225    }
226
227    /// Extracts Personal Data Server endpoints from services.
228    /// Returns URLs of all AtprotoPersonalDataServer services.
229    pub fn pds_endpoints(&self) -> Vec<&str> {
230        self.service
231            .iter()
232            .filter_map(|service| {
233                if service.r#type == "AtprotoPersonalDataServer" {
234                    Some(service.service_endpoint.as_str())
235                } else {
236                    None
237                }
238            })
239            .collect()
240    }
241
242    /// Gets the primary handle from alsoKnownAs aliases.
243    /// Returns the first alias with "at://" prefix stripped if present.
244    pub fn handles(&self) -> Option<&str> {
245        self.also_known_as.first().map(|handle| {
246            if let Some(trimmed) = handle.strip_prefix("at://") {
247                trimmed
248            } else {
249                handle.as_str()
250            }
251        })
252    }
253
254    /// Extracts multibase public keys from verification methods.
255    /// Returns public keys from Multikey verification methods only.
256    pub fn did_keys(&self) -> Vec<&str> {
257        self.verification_method
258            .iter()
259            .filter_map(|verification_method| match verification_method {
260                VerificationMethod::Multikey {
261                    public_key_multibase,
262                    ..
263                } => Some(public_key_multibase.as_str()),
264                VerificationMethod::Other { extra: _ } => None,
265            })
266            .collect()
267    }
268}
269
270/// Resolved handle information linking DID to human-readable identifier.
271/// Contains the complete identity resolution result.
272#[cfg_attr(debug_assertions, derive(Debug))]
273#[derive(Clone, Deserialize, Serialize)]
274pub struct Handle {
275    /// The resolved DID identifier.
276    pub did: String,
277    /// Human-readable handle (e.g., "alice.bsky.social").
278    pub handle: String,
279    /// Personal Data Server URL hosting the identity.
280    pub pds: String,
281    /// Available cryptographic verification methods.
282    pub verification_methods: Vec<String>,
283}
284
285#[cfg(test)]
286mod tests {
287    use crate::model::{Document, Service};
288    use std::collections::HashMap;
289
290    #[test]
291    fn test_deserialize() {
292        let document = serde_json::from_str::<Document>(
293            r##"{"@context":["https://www.w3.org/ns/did/v1","https://w3id.org/security/multikey/v1","https://w3id.org/security/suites/secp256k1-2019/v1"],"id":"did:plc:cbkjy5n7bk3ax2wplmtjofq2","alsoKnownAs":["at://ngerakines.me","at://nick.gerakines.net","at://nick.thegem.city","https://github.com/ngerakines","https://ngerakines.me/","dns:ngerakines.me"],"verificationMethod":[{"id":"did:plc:cbkjy5n7bk3ax2wplmtjofq2#atproto","type":"Multikey","controller":"did:plc:cbkjy5n7bk3ax2wplmtjofq2","publicKeyMultibase":"zQ3shXvCK2RyPrSLYQjBEw5CExZkUhJH3n1K2Mb9sC7JbvRMF"}],"service":[{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.cauda.cloud"}]}"##,
294        );
295        assert!(document.is_ok());
296
297        let document = document.unwrap();
298        assert_eq!(document.id, "did:plc:cbkjy5n7bk3ax2wplmtjofq2");
299    }
300
301    #[test]
302    fn test_document_builder() {
303        // Test basic builder
304        let doc = Document::builder()
305            .id("did:plc:test123")
306            .build()
307            .expect("Should build with just ID");
308
309        assert_eq!(doc.id, "did:plc:test123");
310        assert_eq!(doc.context, vec!["https://www.w3.org/ns/did/v1"]);
311        assert!(doc.also_known_as.is_empty());
312        assert!(doc.service.is_empty());
313        assert!(doc.verification_method.is_empty());
314    }
315
316    #[test]
317    fn test_document_builder_full() {
318        let doc = Document::builder()
319            .id("did:plc:test123")
320            .add_context("https://w3id.org/security/multikey/v1")
321            .add_also_known_as("at://test.bsky.social")
322            .add_also_known_as("https://test.example.com")
323            .add_pds_service("https://pds.example.com")
324            .add_multikey(
325                "did:plc:test123#atproto",
326                "did:plc:test123",
327                "zQ3shXvCK2RyPrSLYQjBEw5CExZkUhJH3n1K2Mb9sC7JbvRMF",
328            )
329            .build()
330            .expect("Should build complete document");
331
332        assert_eq!(doc.id, "did:plc:test123");
333        assert_eq!(doc.context.len(), 2);
334        assert_eq!(doc.also_known_as.len(), 2);
335        assert_eq!(doc.service.len(), 1);
336        assert_eq!(doc.service[0].r#type, "AtprotoPersonalDataServer");
337        assert_eq!(doc.verification_method.len(), 1);
338
339        // Test PDS endpoint extraction
340        let pds_endpoints = doc.pds_endpoints();
341        assert_eq!(pds_endpoints.len(), 1);
342        assert_eq!(pds_endpoints[0], "https://pds.example.com");
343    }
344
345    #[test]
346    fn test_document_builder_with_service() {
347        let service = Service {
348            id: "#custom".to_string(),
349            r#type: "CustomService".to_string(),
350            service_endpoint: "https://custom.example.com".to_string(),
351            extra: HashMap::new(),
352        };
353
354        let doc = Document::builder()
355            .id("did:web:example.com")
356            .add_service(service)
357            .build()
358            .expect("Should build with custom service");
359
360        assert_eq!(doc.service.len(), 1);
361        assert_eq!(doc.service[0].r#type, "CustomService");
362    }
363
364    #[test]
365    fn test_document_builder_missing_id() {
366        let result = Document::builder()
367            .add_also_known_as("at://test.bsky.social")
368            .build();
369
370        assert!(result.is_err());
371        assert_eq!(result.unwrap_err(), "Document ID is required");
372    }
373
374    #[test]
375    fn test_document_builder_with_extra() {
376        let doc = Document::builder()
377            .id("did:plc:test123")
378            .add_extra("customField", serde_json::json!("customValue"))
379            .add_extra("numberField", serde_json::json!(42))
380            .build()
381            .expect("Should build with extra fields");
382
383        assert_eq!(doc.extra.len(), 2);
384        assert_eq!(
385            doc.extra.get("customField").unwrap(),
386            &serde_json::json!("customValue")
387        );
388        assert_eq!(
389            doc.extra.get("numberField").unwrap(),
390            &serde_json::json!(42)
391        );
392    }
393
394    #[test]
395    fn test_deserialize_unsupported_verification_method() {
396        let documents = vec![
397            r##"{"@context":["https://www.w3.org/ns/did/v1","https://w3id.org/security/multikey/v1","https://w3id.org/security/suites/secp256k1-2019/v1"],"id":"did:plc:cbkjy5n7bk3ax2wplmtjofq2","alsoKnownAs":["at://ngerakines.me","at://nick.gerakines.net","at://nick.thegem.city","https://github.com/ngerakines","https://ngerakines.me/","dns:ngerakines.me"],"verificationMethod":[{"id":"did:plc:cbkjy5n7bk3ax2wplmtjofq2#atproto","type":"Ed25519VerificationKey2020","controller":"did:plc:cbkjy5n7bk3ax2wplmtjofq2","publicKeyMultibase":"zQ3shXvCK2RyPrSLYQjBEw5CExZkUhJH3n1K2Mb9sC7JbvRMF"}],"service":[{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.cauda.cloud"}]}"##,
398            r##"{"@context":["https://www.w3.org/ns/did/v1","https://w3id.org/security/multikey/v1","https://w3id.org/security/suites/secp256k1-2019/v1"],"id":"did:plc:cbkjy5n7bk3ax2wplmtjofq2","alsoKnownAs":["at://ngerakines.me","at://nick.gerakines.net","at://nick.thegem.city","https://github.com/ngerakines","https://ngerakines.me/","dns:ngerakines.me"],"verificationMethod":[{"id": "did:example:123#_Qq0UL2Fq651Q0Fjd6TvnYE-faHiOpRlPVQcY_-tA4A","type": "JsonWebKey2020","controller": "did:example:123","publicKeyJwk": {"crv": "Ed25519","x": "VCpo2LMLhn6iWku8MKvSLg2ZAoC-nlOyPVQaO3FxVeQ","kty": "OKP","kid": "_Qq0UL2Fq651Q0Fjd6TvnYE-faHiOpRlPVQcY_-tA4A"}}],"service":[{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.cauda.cloud"}]}"##,
399        ];
400        for document in documents {
401            let document = serde_json::from_str::<Document>(document);
402            assert!(document.is_ok());
403
404            let document = document.unwrap();
405            assert_eq!(document.id, "did:plc:cbkjy5n7bk3ax2wplmtjofq2");
406        }
407    }
408
409    #[test]
410    fn test_deserialize_service_did_document() {
411        // DID document from api.bsky.app - a service DID without alsoKnownAs
412        let document = serde_json::from_str::<Document>(
413            r##"{"@context":["https://www.w3.org/ns/did/v1","https://w3id.org/security/multikey/v1"],"id":"did:web:api.bsky.app","verificationMethod":[{"id":"did:web:api.bsky.app#atproto","type":"Multikey","controller":"did:web:api.bsky.app","publicKeyMultibase":"zQ3shpRzb2NDriwCSSsce6EqGxG23kVktHZc57C3NEcuNy1jg"}],"service":[{"id":"#bsky_notif","type":"BskyNotificationService","serviceEndpoint":"https://api.bsky.app"},{"id":"#bsky_appview","type":"BskyAppView","serviceEndpoint":"https://api.bsky.app"}]}"##,
414        );
415        assert!(document.is_ok(), "Failed to parse: {:?}", document.err());
416
417        let document = document.unwrap();
418        assert_eq!(document.id, "did:web:api.bsky.app");
419        assert!(document.also_known_as.is_empty());
420        assert_eq!(document.service.len(), 2);
421        assert_eq!(document.service[0].id, "#bsky_notif");
422        assert_eq!(document.service[1].id, "#bsky_appview");
423    }
424}