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