A library for ATProtocol identities.
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}