Skip to main content

didbot_claim_check/
record.rs

1//! The `bot.did.operator` record a claim writes, the key it is written at,
2//! and the claim a reader parses it back into.
3//!
4//! One builder, so a claim written from the command line and a claim written
5//! from the policy page are the same record: the same fields in the same
6//! order, at the same key. One parser, [`OperatorClaim::from_json`], so the
7//! server polling for its own standing and a verifier checking somebody
8//! else's read it the same way.
9
10use 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
17/// The lexicon this record is written under. See
18/// `lexicons/bot/did/operator.json`.
19pub const COLLECTION: &str = didbot_lexicon::nsid::OPERATOR;
20
21/// The record key a claim on `subject` is written at: the subject's authority
22/// as its *own* DID spells it, decoded.
23///
24/// Not the hostname a caller was handed. The two differ for a development
25/// server on a port: `did:web` writes that colon percent-encoded, and `%` is
26/// not in atproto's record-key character set, so keying by the argument
27/// produces a key no repository will accept. The server's own poll reads at
28/// `AccountDid::authority` too; deriving it from the same place is what keeps
29/// the writer and the reader on one key.
30///
31/// # Errors
32///
33/// [`ResolveError::Malformed`] when `subject` is not a usable `did:web`.
34pub 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/// The record body a claim writes, field for field matching
44/// `lexicons/bot/did/operator.json` and [`OperatorClaim::from_json`]:
45/// `subject` and `createdAt`, plus the `$type` every atproto record carries,
46/// and `creates` only when there is something to say.
47///
48/// The timestamp is already an RFC 3339 string: formatting a clock reading
49/// belongs to whoever read the clock.
50#[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
63/// The collection an account's credentials are written under. See
64/// `lexicons/bot/did/credential.json`.
65pub const CREDENTIAL_COLLECTION: &str = didbot_lexicon::nsid::CREDENTIAL;
66/// The `$type` of a key credential.
67pub const PUBLIC_KEY_TYPE: &str = "bot.did.credential#publicKey";
68/// The `$type` of an OpenID Connect identity credential.
69pub const OIDC_IDENTITY_TYPE: &str = "bot.did.credential#oidcIdentity";
70
71/// The record body the server writes for one of `did`'s credentials, field
72/// for field matching `lexicons/bot/did/credential.json` and
73/// [`Credential::from_record`].
74#[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/// The fingerprint a key is named by in a record and on a screen:
85/// `SHA256:` and the unpadded base64url digest of the multibase text, the
86/// way OpenSSH shows a key.
87#[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/// One way a process logs in to the hosting server as an account.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum Credential {
101    /// A key the account signs with; whoever holds the private half is the
102    /// account.
103    PublicKey {
104        /// The public half, multibase.
105        public_key_multibase: String,
106    },
107    /// An OpenID Connect identity whose tokens log in as the account.
108    OidcIdentity(OidcIdentity),
109}
110
111impl Credential {
112    /// A key credential for `multibase`.
113    #[must_use]
114    pub fn key(multibase: &str) -> Self {
115        Self::PublicKey {
116            public_key_multibase: multibase.to_owned(),
117        }
118    }
119
120    /// The key's multibase text, for a key credential.
121    #[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    /// The identity, for an OpenID Connect credential.
132    #[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    /// How a screen names this credential: a key by its fingerprint, an
141    /// identity by its issuer.
142    #[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    /// Parses a whole `bot.did.credential` record body into the credential
153    /// it carries. `None` when the record is not one, or carries a
154    /// credential kind this reader does not know.
155    #[must_use]
156    pub fn from_record(value: &Value) -> Option<Self> {
157        Self::from_json(value.get("credential")?)
158    }
159
160    /// The `credential` object as the record spells it.
161    #[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    /// Parses a `credential` object. `None` for a `$type` this reader does
182    /// not know: an unknown credential is not one this server honours.
183    #[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/// Which accounts an allowance covers.
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum Scope {
215    /// The subject itself.
216    Subject,
217    /// Every account beneath the subject, at any depth.
218    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/// One `creates` entry: who may create beneath the subject, and what.
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct Allowance {
244    /// Which accounts may create.
245    pub scope: Scope,
246    /// The kinds they may create, by label. Empty means any kind.
247    pub kinds: Vec<String>,
248}
249
250impl Allowance {
251    /// Whether this allowance lets a covered account create a `kind`.
252    #[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    /// One `creates` entry as the record spells it.
258    #[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    /// Parses one `creates` entry. `None` for a scope this reader does not
268    /// know, which the caller drops: an allowance it cannot read grants
269    /// nothing, and that is the safe reading of a word it does not know.
270    #[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/// One `bot.did.operator` record read out of the operator's repository.
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct OperatorClaim {
288    /// The `subject` field: the DID the claim names.
289    pub subject: String,
290    /// When the operator wrote the claim.
291    pub created_at: OffsetDateTime,
292    /// What may be created beneath the subject. Only meaningful in the
293    /// operator's own repository; a hosted account's record carries none.
294    pub creates: Vec<Allowance>,
295}
296
297impl OperatorClaim {
298    /// A claim on `subject` with no credentials and no allowances.
299    #[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    /// Parses a `bot.did.operator` record's `value` object into a claim.
309    ///
310    /// `None` for anything that is not one: a missing required field, a
311    /// field of the wrong JSON type, or a timestamp that is not RFC 3339.
312    /// A record that is present but malformed reads as no claim at all,
313    /// deliberately — a reader must not interpret a foreign repository's
314    /// record charitably, and "the repository answered with something this
315    /// reader cannot understand" is not the same fact as "the repository
316    /// could not be reached", which is why this returns `Option` rather
317    /// than the error a fetch reports.
318    ///
319    /// An unknown field is ignored, and an entry of `creates` this reader
320    /// cannot parse is dropped rather than failing the record: it reads as
321    /// "nothing granted", which is the safe direction.
322    ///
323    /// It lives beside [`operator_record`] rather than beside any of its
324    /// callers because a server polling for its own standing, a third-party
325    /// verifier checking somebody else's and the command writing one all
326    /// have to agree on the record the same way; two copies of this would
327    /// be two definitions of what a claim is.
328    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    /// Renders the claim as the record body `bot.did.operator` holds —
347    /// the inverse of [`OperatorClaim::from_json`], field for field, through
348    /// [`operator_record`], so every writer and the one reader agree by
349    /// construction.
350    ///
351    /// `Rfc3339` refuses only a year outside `0000..=9999`, which no
352    /// timestamp read by `from_json` or taken from a clock can carry; the
353    /// epoch stands in rather than failing a write over an impossible date.
354    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    /// The key is the DID's own authority, with a development port decoded:
378    /// `%3A` is not a character an atproto record key may carry.
379    #[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        // A development DID carries its port percent-encoded, and the key
386        // is that colon decoded: `%` is not in atproto's record-key set.
387        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}