1use serde::{Deserialize, Serialize};
8
9use crate::calendar::CalendarSection;
10use crate::compliance::ComplianceSection;
11use crate::conformance::ConformanceLevel;
12use crate::contact::ContactSection;
13use crate::governance::GovernanceSection;
14use crate::interop::InteroperabilitySection;
15use crate::lifecycle::LifecycleStatus;
16use crate::lineage_section::LineageSection;
17use crate::operational::OperationalSection;
18use crate::pricing::PricingSection;
19use crate::profile::ProfileSection;
20use crate::proof_format::DocumentProof;
21use crate::relationships::RelationshipsSection;
22use crate::reputation::ReputationSection;
23use crate::service::ServiceEndpoint;
24use crate::verification_method::VerificationMethod;
25
26pub const OAS_CONTEXT: &str = "https://openagent.id/ns/oas/v1";
28
29pub const DID_CONTEXT: &str = "https://www.w3.org/ns/did/v1";
31
32pub const ED25519_CONTEXT: &str = "https://w3id.org/security/suites/ed25519-2020/v1";
34
35pub const OAS_VERSION: &str = "1.0.0";
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct DocumentMetadata {
53 pub created: String,
55
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub updated: Option<String>,
59
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub version: Option<String>,
63}
64
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91#[serde(rename_all = "camelCase")]
92pub struct OasDocument {
93 #[serde(rename = "@context")]
95 pub context: Vec<String>,
96
97 pub id: String,
99
100 pub controller: String,
102
103 pub verification_method: Vec<VerificationMethod>,
105
106 pub authentication: Vec<String>,
108
109 #[serde(default, skip_serializing_if = "Vec::is_empty")]
111 pub assertion_method: Vec<String>,
112
113 #[serde(default, skip_serializing_if = "Vec::is_empty")]
115 pub capability_invocation: Vec<String>,
116
117 #[serde(default, skip_serializing_if = "Vec::is_empty")]
119 pub capability_delegation: Vec<String>,
120
121 #[serde(default, skip_serializing_if = "Vec::is_empty")]
123 pub service: Vec<ServiceEndpoint>,
124
125 pub oas_version: String,
127
128 pub kind: String,
130
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub name: Option<String>,
134
135 #[serde(skip_serializing_if = "Option::is_none")]
137 pub description: Option<String>,
138
139 #[serde(skip_serializing_if = "Option::is_none")]
141 pub lineage: Option<LineageSection>,
142
143 #[serde(skip_serializing_if = "Option::is_none")]
146 pub governance: Option<GovernanceSection>,
147
148 pub conformance_level: ConformanceLevel,
150
151 #[serde(skip_serializing_if = "Option::is_none")]
153 pub lifecycle_status: Option<LifecycleStatus>,
154
155 pub sequence: u64,
157
158 pub metadata: DocumentMetadata,
160
161 #[serde(skip_serializing_if = "Option::is_none")]
163 pub proof: Option<DocumentProof>,
164
165 #[serde(skip_serializing_if = "Option::is_none")]
167 pub revoked: Option<bool>,
168
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub revoked_at: Option<String>,
172
173 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub profile: Option<ProfileSection>,
177
178 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub contact: Option<ContactSection>,
181
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub calendar: Option<CalendarSection>,
185
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub operational: Option<OperationalSection>,
189
190 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub pricing: Option<PricingSection>,
193
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub interoperability: Option<InteroperabilitySection>,
197
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub reputation: Option<ReputationSection>,
201
202 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub compliance: Option<ComplianceSection>,
205
206 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub relationships: Option<RelationshipsSection>,
209}
210
211impl OasDocument {
212 pub fn default_context() -> Vec<String> {
214 vec![
215 DID_CONTEXT.to_string(),
216 ED25519_CONTEXT.to_string(),
217 OAS_CONTEXT.to_string(),
218 ]
219 }
220
221 pub fn is_root(&self) -> bool {
223 self.kind == "hmr" || self.kind == "mhr" || self.kind == "enr"
224 }
225
226 pub fn is_revoked(&self) -> bool {
228 self.revoked.unwrap_or(false)
229 }
230
231 pub fn primary_public_key_multibase(&self) -> Option<&str> {
235 self.verification_method
236 .first()
237 .map(|vm| vm.public_key_multibase.as_str())
238 }
239
240 pub fn find_verification_method(&self, id_or_fragment: &str) -> Option<&VerificationMethod> {
242 self.verification_method.iter().find(|vm| {
243 vm.id == id_or_fragment
244 || vm.key_id() == id_or_fragment
245 || vm.id.ends_with(&format!("#{id_or_fragment}"))
246 })
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 fn make_minimal_doc(kind: &str) -> OasDocument {
255 OasDocument {
256 context: OasDocument::default_context(),
257 id: format!("did:oas:test:{kind}:test-1"),
258 controller: format!("did:oas:test:{kind}:test-1"),
259 verification_method: vec![VerificationMethod::new(
260 &format!("did:oas:test:{kind}:test-1"),
261 1,
262 "zTestPubKey",
263 )],
264 authentication: vec![format!("did:oas:test:{kind}:test-1#key-1")],
265 assertion_method: vec![],
266 capability_invocation: vec![],
267 capability_delegation: vec![],
268 service: vec![],
269 oas_version: OAS_VERSION.to_string(),
270 kind: kind.to_string(),
271 name: None,
272 description: None,
273 lineage: None,
274 governance: None,
275 conformance_level: ConformanceLevel::L0,
276 lifecycle_status: None,
277 sequence: 1,
278 metadata: DocumentMetadata {
279 created: "2026-01-15T00:00:00Z".to_string(),
280 updated: None,
281 version: None,
282 },
283 proof: None,
284 revoked: None,
285 revoked_at: None,
286 profile: None,
287 contact: None,
288 calendar: None,
289 operational: None,
290 pricing: None,
291 interoperability: None,
292 reputation: None,
293 compliance: None,
294 relationships: None,
295 }
296 }
297
298 #[test]
299 fn is_root_for_hmr() {
300 let doc = make_minimal_doc("hmr");
301 assert!(doc.is_root());
302 }
303
304 #[test]
305 fn is_root_for_mhr() {
306 let doc = make_minimal_doc("mhr");
307 assert!(doc.is_root());
308 }
309
310 #[test]
311 fn is_root_for_enr() {
312 let doc = make_minimal_doc("enr");
313 assert!(doc.is_root());
314 }
315
316 #[test]
317 fn is_not_root_for_agent() {
318 let doc = make_minimal_doc("agent");
319 assert!(!doc.is_root());
320 }
321
322 #[test]
323 fn default_context_has_three_uris() {
324 let ctx = OasDocument::default_context();
325 assert_eq!(ctx.len(), 3);
326 assert!(ctx.contains(&DID_CONTEXT.to_string()));
327 assert!(ctx.contains(&OAS_CONTEXT.to_string()));
328 }
329
330 #[test]
331 fn find_verification_method_by_fragment() {
332 let doc = make_minimal_doc("hmr");
333 let vm = doc.find_verification_method("key-1");
334 assert!(vm.is_some());
335 }
336
337 #[test]
338 fn is_revoked_default_false() {
339 let doc = make_minimal_doc("hmr");
340 assert!(!doc.is_revoked());
341 }
342
343 #[test]
344 fn serde_round_trip() {
345 let doc = make_minimal_doc("agent");
346 let json = serde_json::to_string_pretty(&doc).unwrap_or_default();
347 let parsed: Result<OasDocument, _> = serde_json::from_str(&json);
348 assert!(parsed.is_ok());
349 }
350
351 #[test]
352 fn backward_compat_no_extended_sections() {
353 let json = r#"{
356 "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/ed25519-2020/v1", "https://openagent.id/ns/oas/v1"],
357 "id": "did:oas:test:agent:legacy",
358 "controller": "did:oas:test:hmr:alice",
359 "verificationMethod": [{"id": "did:oas:test:agent:legacy#key-1", "type": "Ed25519VerificationKey2020", "controller": "did:oas:test:hmr:alice", "publicKeyMultibase": "zTest"}],
360 "authentication": ["did:oas:test:agent:legacy#key-1"],
361 "oasVersion": "1.0.0",
362 "kind": "agent",
363 "conformanceLevel": "L0",
364 "sequence": 1,
365 "metadata": {"created": "2026-01-15T00:00:00Z"}
366 }"#;
367 let doc: Result<OasDocument, _> = serde_json::from_str(json);
368 assert!(
369 doc.is_ok(),
370 "Legacy document without extended sections must deserialize"
371 );
372 let doc = doc.unwrap_or_else(|_| make_minimal_doc("agent"));
373 assert!(doc.profile.is_none());
374 assert!(doc.contact.is_none());
375 assert!(doc.calendar.is_none());
376 assert!(doc.operational.is_none());
377 assert!(doc.pricing.is_none());
378 assert!(doc.interoperability.is_none());
379 assert!(doc.reputation.is_none());
380 assert!(doc.compliance.is_none());
381 assert!(doc.relationships.is_none());
382 }
383
384 #[test]
385 fn extended_sections_omitted_when_none() {
386 let doc = make_minimal_doc("agent");
387 let json = serde_json::to_string(&doc).unwrap_or_default();
388 assert!(!json.contains("profile"));
390 assert!(!json.contains("contact"));
391 assert!(!json.contains("calendar"));
392 assert!(!json.contains("operational"));
393 assert!(!json.contains("pricing"));
394 assert!(!json.contains("interoperability"));
395 assert!(!json.contains("reputation"));
396 assert!(!json.contains("compliance"));
397 assert!(!json.contains("relationships"));
398 }
399}