atproto_identity/
model.rs

1//! Data structures for DID documents and AT Protocol entities.
2//!
3//! Defines the core data models used throughout the AT Protocol identity system
4//! including DID documents, services, and verification methods. All structures
5//! support JSON serialization/deserialization for working with AT Protocol APIs.
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::HashMap;
10
11/// AT Protocol service configuration from a DID document.
12/// Represents services like Personal Data Servers (PDS).
13#[cfg_attr(debug_assertions, derive(Debug))]
14#[derive(Clone, Serialize, Deserialize, PartialEq)]
15#[serde(rename_all = "camelCase")]
16pub struct Service {
17    /// Unique identifier for the service.
18    pub id: String,
19    /// Service type (e.g., "AtprotoPersonalDataServer").
20    pub r#type: String,
21    /// URL endpoint where the service can be reached.
22    pub service_endpoint: String,
23
24    /// Additional service properties not explicitly defined.
25    #[serde(flatten)]
26    pub extra: HashMap<String, Value>,
27}
28
29/// Cryptographic verification method from a DID document.
30/// Used to verify signatures and authenticate identity operations.
31#[cfg_attr(debug_assertions, derive(Debug))]
32#[derive(Clone, Serialize, Deserialize, PartialEq)]
33#[serde(tag = "type")]
34pub enum VerificationMethod {
35    /// Multikey verification method with multibase-encoded public key.
36    Multikey {
37        /// Unique identifier for this verification method.
38        id: String,
39        /// DID that controls this verification method.
40        controller: String,
41
42        /// Public key encoded in multibase format.
43        #[serde(rename = "publicKeyMultibase")]
44        public_key_multibase: String,
45
46        /// Additional verification method properties.
47        #[serde(flatten)]
48        extra: HashMap<String, Value>,
49    },
50
51    /// Other verification method types not explicitly supported.
52    #[serde(untagged)]
53    Other {
54        /// All properties of the unsupported verification method.
55        #[serde(flatten)]
56        extra: HashMap<String, Value>,
57    },
58}
59
60/// Complete DID document containing identity information.
61/// Contains services, verification methods, and aliases for a DID.
62#[cfg_attr(debug_assertions, derive(Debug))]
63#[derive(Clone, Serialize, Deserialize, PartialEq)]
64#[serde(rename_all = "camelCase")]
65pub struct Document {
66    /// The DID identifier (e.g., "did:plc:abc123").
67    pub id: String,
68    /// Alternative identifiers like handles and domains.
69    pub also_known_as: Vec<String>,
70    /// Available services for this identity.
71    pub service: Vec<Service>,
72
73    /// Cryptographic verification methods.
74    #[serde(alias = "verificationMethod")]
75    pub verification_method: Vec<VerificationMethod>,
76
77    /// Additional document properties not explicitly defined.
78    #[serde(flatten)]
79    pub extra: HashMap<String, Value>,
80}
81
82impl Document {
83    /// Extracts Personal Data Server endpoints from services.
84    /// Returns URLs of all AtprotoPersonalDataServer services.
85    pub fn pds_endpoints(&self) -> Vec<&str> {
86        self.service
87            .iter()
88            .filter_map(|service| {
89                if service.r#type == "AtprotoPersonalDataServer" {
90                    Some(service.service_endpoint.as_str())
91                } else {
92                    None
93                }
94            })
95            .collect()
96    }
97
98    /// Gets the primary handle from alsoKnownAs aliases.
99    /// Returns the first alias with "at://" prefix stripped if present.
100    pub fn handles(&self) -> Option<&str> {
101        self.also_known_as.first().map(|handle| {
102            if let Some(trimmed) = handle.strip_prefix("at://") {
103                trimmed
104            } else {
105                handle.as_str()
106            }
107        })
108    }
109
110    /// Extracts multibase public keys from verification methods.
111    /// Returns public keys from Multikey verification methods only.
112    pub fn did_keys(&self) -> Vec<&str> {
113        self.verification_method
114            .iter()
115            .filter_map(|verification_method| match verification_method {
116                VerificationMethod::Multikey {
117                    public_key_multibase,
118                    ..
119                } => Some(public_key_multibase.as_str()),
120                VerificationMethod::Other { extra: _ } => None,
121            })
122            .collect()
123    }
124}
125
126/// Resolved handle information linking DID to human-readable identifier.
127/// Contains the complete identity resolution result.
128#[cfg_attr(debug_assertions, derive(Debug))]
129#[derive(Clone, Deserialize, Serialize)]
130pub struct Handle {
131    /// The resolved DID identifier.
132    pub did: String,
133    /// Human-readable handle (e.g., "alice.bsky.social").
134    pub handle: String,
135    /// Personal Data Server URL hosting the identity.
136    pub pds: String,
137    /// Available cryptographic verification methods.
138    pub verification_methods: Vec<String>,
139}
140
141#[cfg(test)]
142mod tests {
143    use crate::model::Document;
144
145    #[test]
146    fn test_deserialize() {
147        let document = serde_json::from_str::<Document>(
148            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"}]}"##,
149        );
150        assert!(document.is_ok());
151
152        let document = document.unwrap();
153        assert_eq!(document.id, "did:plc:cbkjy5n7bk3ax2wplmtjofq2");
154    }
155
156    #[test]
157    fn test_deserialize_unsupported_verification_method() {
158        let documents = vec![
159            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"}]}"##,
160            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"}]}"##,
161        ];
162        for document in documents {
163            let document = serde_json::from_str::<Document>(document);
164            assert!(document.is_ok());
165
166            let document = document.unwrap();
167            assert_eq!(document.id, "did:plc:cbkjy5n7bk3ax2wplmtjofq2");
168        }
169    }
170}