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    pub also_known_as: Vec<String>,
74    /// Available services for this identity.
75    pub service: Vec<Service>,
76
77    /// Cryptographic verification methods.
78    #[serde(alias = "verificationMethod")]
79    pub verification_method: Vec<VerificationMethod>,
80
81    /// Additional document properties not explicitly defined.
82    #[serde(flatten)]
83    pub extra: HashMap<String, Value>,
84}
85
86/// Builder for constructing DID documents with a fluent API.
87/// Provides controlled construction with validation capabilities.
88#[derive(Default)]
89pub struct DocumentBuilder {
90    context: Option<Vec<String>>,
91    id: Option<String>,
92    also_known_as: Vec<String>,
93    service: Vec<Service>,
94    verification_method: Vec<VerificationMethod>,
95    extra: HashMap<String, Value>,
96}
97
98impl DocumentBuilder {
99    /// Creates a new DocumentBuilder with empty fields.
100    pub fn new() -> Self {
101        Self::default()
102    }
103
104    /// Sets the JSON-LD context URLs for the document.
105    pub fn context(mut self, context: Vec<String>) -> Self {
106        self.context = Some(context);
107        self
108    }
109
110    /// Adds a single context URL to the document.
111    pub fn add_context(mut self, context_url: impl Into<String>) -> Self {
112        self.context
113            .get_or_insert_with(|| vec!["https://www.w3.org/ns/did/v1".to_string()])
114            .push(context_url.into());
115        self
116    }
117
118    /// Sets the DID identifier for the document.
119    pub fn id(mut self, id: impl Into<String>) -> Self {
120        self.id = Some(id.into());
121        self
122    }
123
124    /// Sets all alternative identifiers at once.
125    pub fn also_known_as(mut self, aliases: Vec<String>) -> Self {
126        self.also_known_as = aliases;
127        self
128    }
129
130    /// Adds a single alternative identifier.
131    pub fn add_also_known_as(mut self, alias: impl Into<String>) -> Self {
132        self.also_known_as.push(alias.into());
133        self
134    }
135
136    /// Sets all services at once.
137    pub fn services(mut self, services: Vec<Service>) -> Self {
138        self.service = services;
139        self
140    }
141
142    /// Adds a single service to the document.
143    pub fn add_service(mut self, service: Service) -> Self {
144        self.service.push(service);
145        self
146    }
147
148    /// Convenience method to add a PDS service.
149    pub fn add_pds_service(mut self, endpoint: impl Into<String>) -> Self {
150        self.service.push(Service {
151            id: "#atproto_pds".to_string(),
152            r#type: "AtprotoPersonalDataServer".to_string(),
153            service_endpoint: endpoint.into(),
154            extra: HashMap::new(),
155        });
156        self
157    }
158
159    /// Sets all verification methods at once.
160    pub fn verification_methods(mut self, methods: Vec<VerificationMethod>) -> Self {
161        self.verification_method = methods;
162        self
163    }
164
165    /// Adds a single verification method.
166    pub fn add_verification_method(mut self, method: VerificationMethod) -> Self {
167        self.verification_method.push(method);
168        self
169    }
170
171    /// Convenience method to add a Multikey verification method.
172    pub fn add_multikey(
173        mut self,
174        id: impl Into<String>,
175        controller: impl Into<String>,
176        public_key_multibase: impl Into<String>,
177    ) -> Self {
178        let key_multibase = public_key_multibase.into();
179        let key_multibase = key_multibase
180            .strip_prefix("did:key:")
181            .unwrap_or(&key_multibase)
182            .to_string();
183
184        self.verification_method.push(VerificationMethod::Multikey {
185            id: id.into(),
186            controller: controller.into(),
187            public_key_multibase: key_multibase,
188            extra: HashMap::new(),
189        });
190        self
191    }
192
193    /// Adds an extra property to the document.
194    pub fn add_extra(mut self, key: impl Into<String>, value: Value) -> Self {
195        self.extra.insert(key.into(), value);
196        self
197    }
198
199    /// Builds the Document, returning an error if required fields are missing.
200    pub fn build(self) -> Result<Document, &'static str> {
201        let id = self.id.ok_or("Document ID is required")?;
202
203        // Use default context if not provided
204        let context = self
205            .context
206            .unwrap_or_else(|| vec!["https://www.w3.org/ns/did/v1".to_string()]);
207
208        Ok(Document {
209            context,
210            id,
211            also_known_as: self.also_known_as,
212            service: self.service,
213            verification_method: self.verification_method,
214            extra: self.extra,
215        })
216    }
217}
218
219impl Document {
220    /// Creates a new DocumentBuilder for constructing a Document.
221    pub fn builder() -> DocumentBuilder {
222        DocumentBuilder::new()
223    }
224
225    /// Extracts Personal Data Server endpoints from services.
226    /// Returns URLs of all AtprotoPersonalDataServer services.
227    pub fn pds_endpoints(&self) -> Vec<&str> {
228        self.service
229            .iter()
230            .filter_map(|service| {
231                if service.r#type == "AtprotoPersonalDataServer" {
232                    Some(service.service_endpoint.as_str())
233                } else {
234                    None
235                }
236            })
237            .collect()
238    }
239
240    /// Gets the primary handle from alsoKnownAs aliases.
241    /// Returns the first alias with "at://" prefix stripped if present.
242    pub fn handles(&self) -> Option<&str> {
243        self.also_known_as.first().map(|handle| {
244            if let Some(trimmed) = handle.strip_prefix("at://") {
245                trimmed
246            } else {
247                handle.as_str()
248            }
249        })
250    }
251
252    /// Extracts multibase public keys from verification methods.
253    /// Returns public keys from Multikey verification methods only.
254    pub fn did_keys(&self) -> Vec<&str> {
255        self.verification_method
256            .iter()
257            .filter_map(|verification_method| match verification_method {
258                VerificationMethod::Multikey {
259                    public_key_multibase,
260                    ..
261                } => Some(public_key_multibase.as_str()),
262                VerificationMethod::Other { extra: _ } => None,
263            })
264            .collect()
265    }
266}
267
268/// Resolved handle information linking DID to human-readable identifier.
269/// Contains the complete identity resolution result.
270#[cfg_attr(debug_assertions, derive(Debug))]
271#[derive(Clone, Deserialize, Serialize)]
272pub struct Handle {
273    /// The resolved DID identifier.
274    pub did: String,
275    /// Human-readable handle (e.g., "alice.bsky.social").
276    pub handle: String,
277    /// Personal Data Server URL hosting the identity.
278    pub pds: String,
279    /// Available cryptographic verification methods.
280    pub verification_methods: Vec<String>,
281}
282
283#[cfg(test)]
284mod tests {
285    use crate::model::{Document, Service};
286    use std::collections::HashMap;
287
288    #[test]
289    fn test_deserialize() {
290        let document = serde_json::from_str::<Document>(
291            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"}]}"##,
292        );
293        assert!(document.is_ok());
294
295        let document = document.unwrap();
296        assert_eq!(document.id, "did:plc:cbkjy5n7bk3ax2wplmtjofq2");
297    }
298
299    #[test]
300    fn test_document_builder() {
301        // Test basic builder
302        let doc = Document::builder()
303            .id("did:plc:test123")
304            .build()
305            .expect("Should build with just ID");
306
307        assert_eq!(doc.id, "did:plc:test123");
308        assert_eq!(doc.context, vec!["https://www.w3.org/ns/did/v1"]);
309        assert!(doc.also_known_as.is_empty());
310        assert!(doc.service.is_empty());
311        assert!(doc.verification_method.is_empty());
312    }
313
314    #[test]
315    fn test_document_builder_full() {
316        let doc = Document::builder()
317            .id("did:plc:test123")
318            .add_context("https://w3id.org/security/multikey/v1")
319            .add_also_known_as("at://test.bsky.social")
320            .add_also_known_as("https://test.example.com")
321            .add_pds_service("https://pds.example.com")
322            .add_multikey(
323                "did:plc:test123#atproto",
324                "did:plc:test123",
325                "zQ3shXvCK2RyPrSLYQjBEw5CExZkUhJH3n1K2Mb9sC7JbvRMF",
326            )
327            .build()
328            .expect("Should build complete document");
329
330        assert_eq!(doc.id, "did:plc:test123");
331        assert_eq!(doc.context.len(), 2);
332        assert_eq!(doc.also_known_as.len(), 2);
333        assert_eq!(doc.service.len(), 1);
334        assert_eq!(doc.service[0].r#type, "AtprotoPersonalDataServer");
335        assert_eq!(doc.verification_method.len(), 1);
336
337        // Test PDS endpoint extraction
338        let pds_endpoints = doc.pds_endpoints();
339        assert_eq!(pds_endpoints.len(), 1);
340        assert_eq!(pds_endpoints[0], "https://pds.example.com");
341    }
342
343    #[test]
344    fn test_document_builder_with_service() {
345        let service = Service {
346            id: "#custom".to_string(),
347            r#type: "CustomService".to_string(),
348            service_endpoint: "https://custom.example.com".to_string(),
349            extra: HashMap::new(),
350        };
351
352        let doc = Document::builder()
353            .id("did:web:example.com")
354            .add_service(service)
355            .build()
356            .expect("Should build with custom service");
357
358        assert_eq!(doc.service.len(), 1);
359        assert_eq!(doc.service[0].r#type, "CustomService");
360    }
361
362    #[test]
363    fn test_document_builder_missing_id() {
364        let result = Document::builder()
365            .add_also_known_as("at://test.bsky.social")
366            .build();
367
368        assert!(result.is_err());
369        assert_eq!(result.unwrap_err(), "Document ID is required");
370    }
371
372    #[test]
373    fn test_document_builder_with_extra() {
374        let doc = Document::builder()
375            .id("did:plc:test123")
376            .add_extra("customField", serde_json::json!("customValue"))
377            .add_extra("numberField", serde_json::json!(42))
378            .build()
379            .expect("Should build with extra fields");
380
381        assert_eq!(doc.extra.len(), 2);
382        assert_eq!(
383            doc.extra.get("customField").unwrap(),
384            &serde_json::json!("customValue")
385        );
386        assert_eq!(
387            doc.extra.get("numberField").unwrap(),
388            &serde_json::json!(42)
389        );
390    }
391
392    #[test]
393    fn test_deserialize_unsupported_verification_method() {
394        let documents = vec![
395            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"}]}"##,
396            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"}]}"##,
397        ];
398        for document in documents {
399            let document = serde_json::from_str::<Document>(document);
400            assert!(document.is_ok());
401
402            let document = document.unwrap();
403            assert_eq!(document.id, "did:plc:cbkjy5n7bk3ax2wplmtjofq2");
404        }
405    }
406}