1use didbot_identity::did::AccountDid;
11use didbot_identity::document::{Claim, OidcIdentity};
12use didbot_identity::resolve::ResolveError;
13use serde_json::{json, Value};
14use time::format_description::well_known::Rfc3339;
15use time::OffsetDateTime;
16
17pub const COLLECTION: &str = didbot_lexicon::nsid::OPERATOR;
20
21pub fn record_key(subject: &str) -> Result<String, ResolveError> {
35 Ok(AccountDid::parse(subject)
36 .map_err(|error| ResolveError::Malformed {
37 url: subject.to_owned(),
38 message: format!("{subject:?} is not a usable did:web: {error}"),
39 })?
40 .authority())
41}
42
43#[must_use]
51pub fn operator_record(subject: &str, created_at: &str, creates: &[Allowance]) -> Value {
52 let mut record = json!({
53 "$type": COLLECTION,
54 "subject": subject,
55 "createdAt": created_at,
56 });
57 if !creates.is_empty() {
58 record["creates"] = Value::Array(creates.iter().map(Allowance::to_json).collect());
59 }
60 record
61}
62
63pub const CREDENTIAL_COLLECTION: &str = didbot_lexicon::nsid::CREDENTIAL;
66pub const PUBLIC_KEY_TYPE: &str = "bot.did.credential#publicKey";
68pub const OIDC_IDENTITY_TYPE: &str = "bot.did.credential#oidcIdentity";
70
71#[must_use]
75pub fn credential_record(did: &str, credential: &Credential, created_at: &str) -> Value {
76 json!({
77 "$type": CREDENTIAL_COLLECTION,
78 "did": did,
79 "credential": credential.to_json(),
80 "createdAt": created_at,
81 })
82}
83
84#[must_use]
88pub fn key_fingerprint(multibase: &str) -> String {
89 use base64::Engine as _;
90 use sha2::Digest as _;
91 format!(
92 "SHA256:{}",
93 base64::engine::general_purpose::URL_SAFE_NO_PAD
94 .encode(sha2::Sha256::digest(multibase.as_bytes()))
95 )
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum Credential {
101 PublicKey {
104 public_key_multibase: String,
106 },
107 OidcIdentity(OidcIdentity),
109}
110
111impl Credential {
112 #[must_use]
114 pub fn key(multibase: &str) -> Self {
115 Self::PublicKey {
116 public_key_multibase: multibase.to_owned(),
117 }
118 }
119
120 #[must_use]
122 pub fn public_key_multibase(&self) -> Option<&str> {
123 match self {
124 Self::PublicKey {
125 public_key_multibase,
126 } => Some(public_key_multibase),
127 Self::OidcIdentity(_) => None,
128 }
129 }
130
131 #[must_use]
133 pub fn oidc_identity(&self) -> Option<&OidcIdentity> {
134 match self {
135 Self::OidcIdentity(identity) => Some(identity),
136 Self::PublicKey { .. } => None,
137 }
138 }
139
140 #[must_use]
143 pub fn label(&self) -> String {
144 match self {
145 Self::PublicKey {
146 public_key_multibase,
147 } => key_fingerprint(public_key_multibase),
148 Self::OidcIdentity(identity) => identity.issuer.clone(),
149 }
150 }
151
152 #[must_use]
156 pub fn from_record(value: &Value) -> Option<Self> {
157 Self::from_json(value.get("credential")?)
158 }
159
160 #[must_use]
162 pub fn to_json(&self) -> Value {
163 match self {
164 Self::PublicKey {
165 public_key_multibase,
166 } => json!({
167 "$type": PUBLIC_KEY_TYPE,
168 "publicKeyMultibase": public_key_multibase,
169 }),
170 Self::OidcIdentity(identity) => json!({
171 "$type": OIDC_IDENTITY_TYPE,
172 "issuer": identity.issuer,
173 "claims": identity.claims.iter().map(|claim| json!({
174 "name": claim.name,
175 "value": claim.value,
176 })).collect::<Vec<_>>(),
177 }),
178 }
179 }
180
181 #[must_use]
184 pub fn from_json(value: &Value) -> Option<Self> {
185 match value.get("$type")?.as_str()? {
186 PUBLIC_KEY_TYPE => Some(Self::PublicKey {
187 public_key_multibase: value.get("publicKeyMultibase")?.as_str()?.to_owned(),
188 }),
189 OIDC_IDENTITY_TYPE => {
190 let issuer = value.get("issuer")?.as_str()?.to_owned();
191 let claims = value
192 .get("claims")?
193 .as_array()?
194 .iter()
195 .map(|claim| {
196 Some(Claim {
197 name: claim.get("name")?.as_str()?.to_owned(),
198 value: claim.get("value")?.as_str()?.to_owned(),
199 })
200 })
201 .collect::<Option<Vec<_>>>()?;
202 if claims.is_empty() {
203 return None;
204 }
205 Some(Self::OidcIdentity(OidcIdentity { issuer, claims }))
206 }
207 _ => None,
208 }
209 }
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum Scope {
215 Subject,
217 Descendants,
219}
220
221impl Scope {
222 const SUBJECT: &'static str = "subject";
223 const DESCENDANTS: &'static str = "descendants";
224
225 fn as_str(self) -> &'static str {
226 match self {
227 Self::Subject => Self::SUBJECT,
228 Self::Descendants => Self::DESCENDANTS,
229 }
230 }
231
232 fn parse(word: &str) -> Option<Self> {
233 match word {
234 Self::SUBJECT => Some(Self::Subject),
235 Self::DESCENDANTS => Some(Self::Descendants),
236 _ => None,
237 }
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct Allowance {
244 pub scope: Scope,
246 pub kinds: Vec<String>,
248}
249
250impl Allowance {
251 #[must_use]
253 pub fn permits_kind(&self, kind: &str) -> bool {
254 self.kinds.is_empty() || self.kinds.iter().any(|k| k == kind)
255 }
256
257 #[must_use]
259 pub fn to_json(&self) -> Value {
260 let mut entry = json!({ "scope": self.scope.as_str() });
261 if !self.kinds.is_empty() {
262 entry["kinds"] = json!(self.kinds);
263 }
264 entry
265 }
266
267 #[must_use]
271 pub fn from_json(value: &Value) -> Option<Self> {
272 let scope = Scope::parse(value.get("scope")?.as_str()?)?;
273 let kinds = match value.get("kinds") {
274 None => Vec::new(),
275 Some(kinds) => kinds
276 .as_array()?
277 .iter()
278 .map(|kind| kind.as_str().map(str::to_owned))
279 .collect::<Option<Vec<_>>>()?,
280 };
281 Some(Self { scope, kinds })
282 }
283}
284
285#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct OperatorClaim {
288 pub subject: String,
290 pub created_at: OffsetDateTime,
292 pub creates: Vec<Allowance>,
295}
296
297impl OperatorClaim {
298 #[must_use]
300 pub fn new(subject: impl Into<String>, created_at: OffsetDateTime) -> Self {
301 Self {
302 subject: subject.into(),
303 created_at,
304 creates: Vec::new(),
305 }
306 }
307
308 pub fn from_json(value: &Value) -> Option<Self> {
329 let subject = value.get("subject")?.as_str()?.to_owned();
330 let created_at = value
331 .get("createdAt")?
332 .as_str()
333 .and_then(|text| OffsetDateTime::parse(text, &Rfc3339).ok())?;
334 let creates = value
335 .get("creates")
336 .and_then(Value::as_array)
337 .map(|entries| entries.iter().filter_map(Allowance::from_json).collect())
338 .unwrap_or_default();
339 Some(Self {
340 subject,
341 created_at,
342 creates,
343 })
344 }
345
346 pub fn to_json(&self) -> Value {
355 let created_at = self
356 .created_at
357 .to_offset(time::UtcOffset::UTC)
358 .format(&Rfc3339)
359 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned());
360 operator_record(&self.subject, &created_at, &self.creates)
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn holds_the_fields_the_reading_side_parses() {
370 let value = operator_record("did:web:pds.example.com", "2026-01-01T00:00:00Z", &[]);
371 assert_eq!(value["$type"], COLLECTION);
372 assert_eq!(value["subject"], "did:web:pds.example.com");
373 assert_eq!(value["createdAt"], "2026-01-01T00:00:00Z");
374 assert_eq!(value.as_object().map(|fields| fields.len()), Some(3));
375 }
376
377 #[test]
380 fn the_key_is_the_decoded_authority() {
381 assert_eq!(
382 record_key("did:web:pds.example.com").expect("a key"),
383 "pds.example.com"
384 );
385 assert_eq!(
388 record_key("did:web:localhost%3A8443").expect("a key"),
389 "localhost:8443"
390 );
391 assert!(record_key("did:plc:abc").is_err());
392 }
393
394 #[test]
395 fn a_well_formed_record_round_trips() {
396 let value = serde_json::json!({
397 "subject": "did:web:agents.example",
398 "createdAt": "2026-01-01T00:00:00Z",
399 });
400 let claim = OperatorClaim::from_json(&value).expect("should parse");
401 assert_eq!(claim.subject, "did:web:agents.example");
402 assert_eq!(
403 OperatorClaim::from_json(&claim.to_json()),
404 Some(claim),
405 "what the writer renders is what the reader parses"
406 );
407 }
408
409 #[test]
410 fn allowances_round_trip_and_unknown_entries_drop() {
411 let value = serde_json::json!({
412 "subject": "did:web:laptop.example",
413 "createdAt": "2026-01-01T00:00:00Z",
414 "creates": [
415 { "scope": "subject", "kinds": ["agent"] },
416 { "scope": "descendants" },
417 { "scope": "everyone" },
418 ],
419 "somethingElse": true,
420 });
421 let claim = OperatorClaim::from_json(&value).expect("parses");
422 assert_eq!(claim.creates.len(), 2, "the unknown scope is dropped");
423 assert_eq!(claim.creates[0].scope, Scope::Subject);
424 assert!(claim.creates[0].permits_kind("agent"));
425 assert!(!claim.creates[0].permits_kind("host"));
426 assert!(
427 claim.creates[1].permits_kind("host"),
428 "no kinds means any kind"
429 );
430 assert_eq!(
431 OperatorClaim::from_json(&claim.to_json()),
432 Some(claim),
433 "what the writer renders is what the reader parses"
434 );
435 }
436
437 #[test]
438 fn a_credential_record_round_trips_and_an_unknown_kind_is_not_one() {
439 let key = Credential::key("zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme");
440 let record = credential_record("did:web:laptop.example", &key, "2026-01-01T00:00:00Z");
441 assert_eq!(record["$type"], CREDENTIAL_COLLECTION);
442 assert_eq!(record["did"], "did:web:laptop.example");
443 assert_eq!(record["credential"]["$type"], PUBLIC_KEY_TYPE);
444 assert_eq!(Credential::from_record(&record), Some(key.clone()));
445 assert!(key.label().starts_with("SHA256:"));
446 assert_eq!(key.label().len(), 7 + 43, "an unpadded base64url sha-256");
447
448 let identity = Credential::OidcIdentity(OidcIdentity {
449 issuer: "https://issuer.example".to_owned(),
450 claims: vec![Claim {
451 name: "sub".to_owned(),
452 value: "42".to_owned(),
453 }],
454 });
455 let record = credential_record("did:web:deploy.example", &identity, "2026-01-01T00:00:00Z");
456 assert_eq!(record["credential"]["$type"], OIDC_IDENTITY_TYPE);
457 assert_eq!(Credential::from_record(&record), Some(identity));
458
459 let newer = serde_json::json!({
460 "did": "did:web:x.example",
461 "credential": { "$type": "bot.did.credential#somethingNewer", "handle": "x" },
462 "createdAt": "2026-01-01T00:00:00Z",
463 });
464 assert_eq!(Credential::from_record(&newer), None);
465 }
466
467 #[test]
468 fn a_record_missing_a_required_field_does_not_parse() {
469 let value = serde_json::json!({
470 "subject": "did:web:agents.example",
471 });
472 assert!(
473 OperatorClaim::from_json(&value).is_none(),
474 "no createdAt: not a claim"
475 );
476 }
477
478 #[test]
479 fn a_malformed_timestamp_does_not_parse() {
480 let value = serde_json::json!({
481 "subject": "did:web:agents.example",
482 "createdAt": "not a timestamp",
483 });
484 assert!(OperatorClaim::from_json(&value).is_none());
485 }
486}