Skip to main content

didbot_serve/
wire.rs

1//! The JSON shapes the XRPC routes read and write.
2//!
3//! These types exist separately from the engine's types on purpose. The engine
4//! speaks `OffsetDateTime` and `Vec<u8>`; the wire speaks RFC 3339 and hex.
5//! Keeping the conversion in one place means a field can be renamed in the
6//! engine without silently renaming it for every client.
7
8use std::collections::BTreeMap;
9
10use didbot_claim_check::Credential;
11use didbot_identity::OidcIdentity;
12use didbot_pds::{
13    AccountKind, AccountState, Cid, Holds, HostedAccount, LifecycleSnapshot, ListParams, Lock,
14    Locks, Party, Precondition, RegistryStats, Tag, Written, DEFAULT_LIST_LIMIT,
15};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use time::format_description::well_known::Rfc3339;
19
20use crate::error::ApiError;
21
22/// Body of `bot.did.createAccount`.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase")]
25pub struct CreateAccountRequest {
26    /// The hostname of the new `did:web`: one label under a zone this
27    /// server serves, taken exactly or refused. Exclusive with `pool` and
28    /// `fragments`.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub name: Option<String>,
31    /// The pool the server makes the name in. Absent, with no `name`
32    /// either, the first pool policy permits makes it.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub pool: Option<String>,
35    /// Text for slots of the pool's naming scheme, such as a prefix.
36    /// Requires `pool`.
37    #[serde(default, skip_serializing_if = "Vec::is_empty")]
38    pub fragments: Vec<Fragment>,
39    /// What the account is: `service`, `host`, `pipeline` or `agent`.
40    pub kind: String,
41    /// A multibase public key the new account holds itself, published as
42    /// its `#key-1`. Exclusive with `parkedKey`.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub key: Option<String>,
45    /// The OpenID Connect identity the new account is, published as its
46    /// `#oidc`.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub oidc: Option<OidcIdentity>,
49    /// The fingerprint `bot.did.parkKey` answered with, naming the parked
50    /// key to publish. Exclusive with `key`.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub parked_key: Option<String>,
53    /// The DID of the hosted account creating this one: the account an ID
54    /// token is checked against, and the account a session or a key proof
55    /// must be.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub parent: Option<String>,
58}
59
60/// Text for one slot of a pool's naming scheme.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct Fragment {
63    /// The slot's name in the pool's scheme.
64    pub slot: String,
65    /// The text that fills it.
66    pub value: String,
67}
68
69/// Body of `bot.did.parkKey`.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub struct ParkKeyRequest {
73    /// The hostname the key waits to be admitted under.
74    pub name: String,
75    /// What the account will be.
76    pub kind: String,
77    /// The public key, multibase.
78    pub key: String,
79}
80
81/// Query of `bot.did.createSession`.
82#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
83pub struct SessionQuery {
84    /// The account the session is for: the account an ID token is checked
85    /// against, and the `iss` a key proof must carry.
86    #[serde(default)]
87    pub did: Option<String>,
88}
89
90/// Query of `bot.did.describeParkedKey`.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct ParkedKeyQuery {
93    /// The hostname a key was parked for.
94    pub name: String,
95    /// The fingerprint `bot.did.parkKey` answered with. Absent, the newest
96    /// park under the name is described.
97    #[serde(default)]
98    pub fingerprint: Option<String>,
99}
100
101/// The kind a request names, or a 400 naming what it accepts.
102pub fn parse_kind(kind: &str) -> Result<AccountKind, ApiError> {
103    serde_json::from_value(Value::String(kind.to_owned())).map_err(|_| {
104        ApiError::bad_request(format!(
105            "kind: {kind:?} is not one of service, host, pipeline, agent"
106        ))
107    })
108}
109
110/// Body of `bot.did.approveAuthorization`.
111///
112/// No account field: the account is the one the presented agent token
113/// authenticates as, which is what makes "the account named in the request
114/// is the account approving" a check the credential enforces rather than a
115/// value a caller supplies.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct ApproveAuthorizationRequest {
118    /// The one-time approval token from the decision record.
119    pub token: String,
120}
121
122/// Body of `bot.did.declineAuthorization`.
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124pub struct DeclineAuthorizationRequest {
125    /// The one-time approval token from the decision record.
126    pub token: String,
127    /// Why, in the declining agent's own words. Logged, never published.
128    #[serde(default)]
129    pub reason: Option<String>,
130}
131
132/// Body of `bot.did.deleteAccount`, and of the freeze and deactivate routes
133/// and their inverses.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct DidRequest {
136    /// The account to act on.
137    pub did: String,
138}
139
140/// Query of `bot.did.getAccountLedger`.
141///
142/// A query rather than a body because it reads, and a `did` rather than a
143/// handle because a handle is released when the account is: the one caller
144/// this route exists for is somebody asking about an agent that is already
145/// gone, and by then the name may belong to somebody else.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147pub struct LedgerQuery {
148    /// The agent to read the bookkeeping of.
149    pub did: String,
150}
151
152/// What this deployment is doing right now, for a reader with no
153/// credential.
154///
155/// The same snapshot `routes::require_lifecycle` refuses from, so a page
156/// saying a deployment serves reads and a `503` saying it does not cannot
157/// both be answered at once. Every field is already visible to a caller who
158/// tries the route and reads the refusal; this is that fact stated once,
159/// where it can be read without provoking one.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "camelCase")]
162pub struct LifecycleLine {
163    /// Where the deployment is; see [`didbot_pds::ServerState`].
164    pub state: String,
165    /// See [`didbot_pds::ServerPolicy::serves_reads`].
166    pub serves_reads: bool,
167    /// See [`didbot_pds::ServerPolicy::accepts_record_writes`].
168    pub accepts_record_writes: bool,
169    /// See [`didbot_pds::ServerPolicy::provisions_accounts`].
170    pub provisions_accounts: bool,
171    /// See [`didbot_pds::LifecycleSnapshot::claim_standing`].
172    pub claim_standing: bool,
173}
174
175impl LifecycleLine {
176    /// Reads one snapshot of the lifecycle.
177    pub fn from_snapshot(snapshot: &LifecycleSnapshot) -> Self {
178        Self {
179            state: snapshot.state.to_string(),
180            serves_reads: snapshot.policy.serves_reads,
181            accepts_record_writes: snapshot.policy.accepts_record_writes,
182            provisions_accounts: snapshot.policy.provisions_accounts,
183            claim_standing: snapshot.claim_standing,
184        }
185    }
186}
187
188/// One entry in the `listAccounts` response.
189///
190/// This server's account of its own contents, which is the most a listing
191/// can ever be. A reader deriving "which agents does this operator have" from
192/// it gets a lower bound: a server can omit an entry, and nothing in the
193/// operator's repository would contradict it, because an operator writes one
194/// `bot.did.operator` record per *server* and never one per agent. What a
195/// server cannot do is inflate the count — a DID it invents has no record
196/// behind it in the operator's repository — so a reader that verifies each
197/// entry rather than trusting the list is safe in one direction and short
198/// in the other. See `docs/operator-verification.md`.
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "camelCase")]
201pub struct AccountSummary {
202    /// The account's DID.
203    pub did: String,
204    /// The agent the account was minted for.
205    pub account_id: String,
206    /// The handle the account answers to, when it has one.
207    ///
208    /// The name is the interesting half of an account for anyone reading a
209    /// listing: `basalt-otter.agents.example` is a thing a person can hold in
210    /// their head and `a3f9c1.agents.example` is not. It is reported as the
211    /// server stored it, zone and all, so that a reader can check it lands
212    /// under the zone the server admits to minting rather than trusting the
213    /// label alone.
214    ///
215    /// Absent only for an account provisioned before this deployment issued
216    /// handles. An account whose handle is its own hostname reports that
217    /// hostname: it is what the account actually answers to, and saying
218    /// nothing would be a different claim.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub handle: Option<String>,
221    /// What kind of account it is.
222    #[serde(default)]
223    pub kind: AccountKind,
224    /// When the account was provisioned, RFC 3339.
225    pub created_at: String,
226    /// Whether the data exists; see [`AccountState`].
227    #[serde(default)]
228    pub state: AccountState,
229    /// Every lock hung on the account, each with the party that hung it.
230    ///
231    /// The one surface that can say an account is frozen at all: `#account`
232    /// cannot, because the lexicon has no wire concept of "active but not
233    /// writable". See `docs/conformance.md`.
234    #[serde(default, skip_serializing_if = "Locks::is_empty")]
235    pub locks: Locks,
236    /// Every hold set on the account.
237    #[serde(default, skip_serializing_if = "Holds::is_empty")]
238    pub holds: Holds,
239    /// The hosted account that created this one; absent for a root, whose
240    /// operator is the deployment's. See [`didbot_pds::HostedAccount::parent`].
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub parent: Option<String>,
243    /// The human who answers for this account: the same DID its
244    /// registration names, at any depth. [`Self::parent`] is the next hop
245    /// of the walk in `docs/ownership.md`.
246    pub operator: String,
247    /// What a stranger can authenticate as this account with.
248    pub proof: AccountProof,
249    /// Whether the edge above this account still stands. See [`EdgeState`].
250    pub edge: EdgeState,
251}
252
253/// What the account's own DID document offers a stranger as proof of being
254/// it, beside the `#atproto` key this server signs its commits with.
255///
256/// Read off the stored account rather than the served document: the two are
257/// written from the same values, and a listing that re-fetched every
258/// document would make one read of a roster into one request per account.
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(tag = "kind", rename_all = "camelCase")]
261pub enum AccountProof {
262    /// A key credential: whoever signs with the private half is the
263    /// account.
264    Key {
265        /// The public half, multibase, as the credential record holds it.
266        key: String,
267    },
268    /// An OpenID Connect credential: an ID token from the issuer carrying
269    /// every claim listed, each with the value given.
270    Oidc {
271        /// The issuer and the claims a token from it must carry.
272        #[serde(flatten)]
273        identity: OidcIdentity,
274    },
275    /// No credential record, so the account is reached only through a
276    /// session this server issued.
277    Session,
278}
279
280impl AccountProof {
281    /// The first of `credentials`, a key before an identity, or `Session`
282    /// when there are none.
283    pub fn of(credentials: &[Credential]) -> Self {
284        if let Some(key) = credentials
285            .iter()
286            .find_map(Credential::public_key_multibase)
287        {
288            return Self::Key {
289                key: key.to_owned(),
290            };
291        }
292        if let Some(identity) = credentials.iter().find_map(Credential::oidc_identity) {
293            return Self::Oidc {
294                identity: identity.clone(),
295            };
296        }
297        Self::Session
298    }
299}
300
301/// Whether the edge from an account to its operator still stands.
302///
303/// Derived from the hasp rather than stored beside it, so it cannot
304/// disagree with [`AccountSummary::locks`]: `Quarantined` hung by
305/// `Party::Parent` is what the confinement poll writes when a vouch above
306/// an account is gone, and that tag is the one fact about the *edge*. Every
307/// other lock is about the account.
308#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub enum EdgeState {
311    /// Nothing is hung: the walk in `docs/ownership.md` runs from here.
312    Verified,
313    /// A vouch above this account is gone and the grace window has passed,
314    /// so the walk stops short of the operator.
315    Paused,
316    /// A lock an operator, a policy or the account itself hung. The edge is
317    /// intact; the account is not serving normally.
318    Locked,
319}
320
321impl EdgeState {
322    /// What `account`'s hasp says about the edge above it.
323    pub fn of(account: &HostedAccount) -> Self {
324        if account
325            .locks
326            .holds(Tag::new(Lock::Quarantined, Party::Parent))
327        {
328            Self::Paused
329        } else if account.locks.is_empty() {
330            Self::Verified
331        } else {
332            Self::Locked
333        }
334    }
335
336    /// The kebab-case name the page and the wire use.
337    pub fn as_str(self) -> &'static str {
338        match self {
339            Self::Verified => "verified",
340            Self::Paused => "paused",
341            Self::Locked => "locked",
342        }
343    }
344}
345
346impl AccountSummary {
347    /// Summarizes a stored account, with `operator` as the DID this
348    /// deployment's operator holds.
349    ///
350    /// A timestamp that will not format is reported as an empty string rather
351    /// than failing the whole listing. `Rfc3339` only rejects years outside
352    /// 0000..=9999, which no clock this reads will produce.
353    pub fn from_account(
354        account: &HostedAccount,
355        operator: &str,
356        credentials: &[Credential],
357    ) -> Self {
358        Self {
359            did: account.did.as_str().to_owned(),
360            account_id: account.account_id.clone(),
361            handle: account.handle.clone(),
362            kind: account.kind,
363            created_at: account.created_at.format(&Rfc3339).unwrap_or_default(),
364            state: account.state,
365            locks: account.locks.clone(),
366            holds: account.holds.clone(),
367            parent: account
368                .parent
369                .as_ref()
370                .map(|parent| parent.as_str().to_owned()),
371            operator: operator.to_owned(),
372            proof: AccountProof::of(credentials),
373            edge: EdgeState::of(account),
374        }
375    }
376}
377
378/// Reads a field that may be present and `null`.
379///
380/// `Option<Option<T>>` with this on it distinguishes an absent key from one
381/// written as `null`, which `Option<T>` alone cannot. `putRecord` declares
382/// `swapRecord` nullable and an explicit `null` there means "assert this
383/// record does not exist yet" -- a different request from not asking at all,
384/// and one this server has to be able to see in order to refuse it.
385fn present<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
386where
387    D: serde::Deserializer<'de>,
388    T: Deserialize<'de>,
389{
390    T::deserialize(de).map(Some)
391}
392
393/// The compare-and-swap fields, as they arrive on the wire.
394///
395/// Both are CIDs naming a version to swap against, and both are now
396/// evaluated. [`Swap::resolve`] turns them into the preconditions the store
397/// and the registry check; a CID that does not decode is refused there,
398/// before anything is read, because it is a statement about the request
399/// rather than about the repository.
400#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
401#[serde(rename_all = "camelCase")]
402pub struct Swap {
403    /// Swap against the previous version of this record.
404    ///
405    /// Nullable in the lexicon, so it is read as a double option: `null`
406    /// asserts the record does not exist, and absent asserts nothing.
407    #[serde(
408        default,
409        deserialize_with = "present",
410        skip_serializing_if = "Option::is_none"
411    )]
412    pub swap_record: Option<Option<String>>,
413    /// Swap against the repository's current commit.
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub swap_commit: Option<String>,
416}
417
418impl Swap {
419    /// The preconditions these fields ask for.
420    ///
421    /// `swapRecord: null` is [`Precondition::Absent`] -- the lexicon marks
422    /// the field nullable and `null` asserts the record does not exist yet --
423    /// and a CID is [`Precondition::Version`]. An absent field asserts
424    /// nothing, which is what leaving it out means.
425    pub fn resolve(&self) -> Result<didbot_pds::Swap, ApiError> {
426        Ok(didbot_pds::Swap {
427            record: match self.swap_record.as_ref() {
428                None => Precondition::Unconditional,
429                Some(None) => Precondition::Absent,
430                Some(Some(cid)) => Precondition::Version(parse_swap_cid("swapRecord", cid)?),
431            },
432            commit: self
433                .swap_commit
434                .as_deref()
435                .map(|cid| parse_swap_cid("swapCommit", cid))
436                .transpose()?,
437        })
438    }
439
440    /// The same, for a route whose lexicon declares only `swapCommit`.
441    ///
442    /// `createRecord` and `applyWrites` have no `swapRecord` field. A body
443    /// carrying one is refused rather than ignored: a client that sent it
444    /// believes its write is conditional, and neither route has one key to
445    /// make it conditional on -- `applyWrites` writes many records, and
446    /// `createRecord` is already conditional on the key being free.
447    pub fn resolve_commit_only(&self, route: &str) -> Result<didbot_pds::Swap, ApiError> {
448        if self.swap_record.is_some() {
449            return Err(ApiError::bad_request(format!(
450                "`{route}` declares no `swapRecord`; swap against one record with `putRecord` \
451                 or `deleteRecord`"
452            )));
453        }
454        self.resolve()
455    }
456}
457
458/// Decodes a swap field, or refuses the request.
459///
460/// A 400 rather than an `InvalidSwap`: `InvalidSwap` means the swap did not
461/// hold, and tells a client to re-read the record and retry. A CID that does
462/// not decode will not decode on the retry either.
463fn parse_swap_cid(field: &str, cid: &str) -> Result<Cid, ApiError> {
464    Cid::parse(cid).map_err(|err| {
465        ApiError::bad_request(format!("`{field}` is not a usable cid: {cid:?}: {err}"))
466    })
467}
468
469/// The `validate` flag, which is atproto's own name for the three stances.
470///
471/// The Lexicon specification names three: *explicit*, where every record must
472/// resolve to a known schema and satisfy it; *optimistic*, where a record is
473/// checked when a schema is at hand and stored faithfully when it is not; and
474/// *skip*. The parameter is a tri-state and each state is one of them.
475///
476/// Unset is optimistic, and it is the stance this server takes.
477/// `true` is explicit and is honoured for the one request: a record in a
478/// collection this server holds no lexicon for is refused rather than stored.
479///
480/// `false` skips the schema check, and this server honours it for a
481/// collection it holds no lexicon for: there is no check to skip, and the
482/// record is stored and reported `unknown` just as it is when the flag is
483/// unset. A collection whose lexicon it holds is read by an index, a
484/// clusterer and a canvas that rely on its shape, and a record stored past
485/// its own schema is one no reader downstream can rely on. So `false` naming
486/// one of those `collections` is refused rather than ignored, for the same
487/// reason a swap field is: a request whose precondition this server will
488/// not meet should say so.
489pub fn refuse_skipped_validation<'a>(
490    validate: Option<bool>,
491    collections: impl IntoIterator<Item = &'a str>,
492) -> Option<ApiError> {
493    if validate != Some(false) {
494        return None;
495    }
496    let held = collections
497        .into_iter()
498        .find(|collection| didbot_pds::schema_is_held(collection))?;
499    Some(ApiError::bad_request(format!(
500        "`validate: false` is not supported for `{held}`: this server holds its lexicon and \
501         checks every record written to it"
502    )))
503}
504
505/// The pre-check `validate: true` asks for, which is the explicit stance for
506/// one request.
507///
508/// Runs before anything is stored. The optimistic check the store performs is
509/// a subset of this one, so a record that passes here passes there.
510pub fn require_known_lexicon(
511    collection: &str,
512    record: &Value,
513    validate: Option<bool>,
514) -> Option<ApiError> {
515    if validate != Some(true) {
516        return None;
517    }
518    didbot_pds::validate_with(collection, record, didbot_pds::Stance::Explicit)
519        .err()
520        .map(|err| crate::error::repo_surface(&didbot_pds::ProvisionError::Record(err)))
521}
522
523/// Body of `com.atproto.repo.createRecord`.
524///
525/// `record` is an opaque [`Value`], checked against the collection's lexicon
526/// on write.
527#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
528pub struct CreateRecordRequest {
529    /// The repository to write into, as a DID or a handle.
530    pub repo: String,
531    /// The collection NSID the record belongs to.
532    pub collection: String,
533    /// The record key to write under, if the caller has one in mind.
534    ///
535    /// Optional, and a request rather than an instruction. The collection's
536    /// lexicon declares how its keys are chosen and that is what decides: a
537    /// `literal:self` collection lands at `self` whether this is given or not,
538    /// and a key the strategy will not admit is a 400 rather than something
539    /// quietly written elsewhere.
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    pub rkey: Option<String>,
542    /// The record itself.
543    pub record: Value,
544    /// Whether to check the record against its lexicon; see
545    /// [`refuse_skipped_validation`].
546    #[serde(default, skip_serializing_if = "Option::is_none")]
547    pub validate: Option<bool>,
548    /// The compare-and-swap field this route declares. Refused; see [`Swap`].
549    #[serde(flatten)]
550    pub swap: Swap,
551}
552
553/// Body of `com.atproto.repo.putRecord`.
554///
555/// The same as [`CreateRecordRequest`] with a required `rkey`. The two do the
556/// same thing here, and that is a divergence worth naming: atproto's
557/// `createRecord` refuses a key that is already in use and `putRecord` is the
558/// upsert, and telling them apart needs a compare-and-swap this server cannot
559/// perform. Both overwrite.
560#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
561pub struct PutRecordRequest {
562    /// The repository to write into, as a DID or a handle.
563    pub repo: String,
564    /// The collection NSID the record belongs to.
565    pub collection: String,
566    /// The record key to write under. Required, unlike on `createRecord`.
567    pub rkey: String,
568    /// The record itself.
569    pub record: Value,
570    /// Whether to check the record against its lexicon.
571    #[serde(default, skip_serializing_if = "Option::is_none")]
572    pub validate: Option<bool>,
573    /// Both compare-and-swap fields. Refused; see [`Swap`].
574    #[serde(flatten)]
575    pub swap: Swap,
576}
577
578/// Body of `com.atproto.repo.deleteRecord`.
579#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
580pub struct DeleteRecordRequest {
581    /// The repository to delete from, as a DID or a handle.
582    pub repo: String,
583    /// The collection NSID the record is in.
584    pub collection: String,
585    /// The record key.
586    pub rkey: String,
587    /// Both compare-and-swap fields. Refused; see [`Swap`].
588    #[serde(flatten)]
589    pub swap: Swap,
590}
591
592/// Query of `com.atproto.repo.getRecord`.
593#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
594pub struct GetRecordQuery {
595    /// The repository to read, as a DID or a handle.
596    pub repo: String,
597    /// The collection NSID the record is in.
598    pub collection: String,
599    /// The record key.
600    pub rkey: String,
601    /// Which version of the record, by CID.
602    ///
603    /// Accepted and never satisfied. The lexicon says an absent `cid` returns
604    /// the most recent version, so the ordinary request works; a request that
605    /// names a version is asking for a record this server cannot say it holds,
606    /// because it attaches no CID to anything. That is `RecordNotFound`, which
607    /// is exactly what the reference implementation answers when a named
608    /// version is not the one it has.
609    #[serde(default, skip_serializing_if = "Option::is_none")]
610    pub cid: Option<String>,
611}
612
613/// Query of `com.atproto.repo.describeRepo`.
614#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
615pub struct DescribeRepoQuery {
616    /// The repository to describe, as a DID or a handle.
617    pub repo: String,
618}
619
620/// Response of `com.atproto.repo.createRecord`.
621///
622/// # The `cid` names the record, and it is the one the export uses
623///
624/// The lexicon declares `cid` a *required* output and this server now emits
625/// one: the hash of the record's canonical DAG-CBOR encoding, computed by
626/// `didbot_pds::content_id`. It is the same value the Merkle search
627/// tree keys the record by and the CAR export at
628/// `com.atproto.sync.getRepo` files it under, which is what makes it safe for
629/// a client to do the things a client does with a CID — pin it, deduplicate
630/// by it, quote it from another record, or hand it back as a
631/// compare-and-swap precondition.
632#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
633#[serde(rename_all = "camelCase")]
634pub struct CreateRecordResponse {
635    /// The `at://` URI the record was written to.
636    pub uri: String,
637    /// The CID naming the record's contents.
638    pub cid: String,
639    /// `valid` or `unknown`, and a real report rather than a constant.
640    ///
641    /// The lexicon's `knownValues` are `valid` and `unknown`, and `unknown` is
642    /// what a server says when it stored a record whose lexicon it could not
643    /// resolve. That is exactly what the optimistic stance makes possible
644    ///, so this field is how a client learns which of the two happened
645    /// to the record it just wrote.
646    pub validation_status: &'static str,
647}
648
649impl CreateRecordResponse {
650    /// The answer for a record the store has just written.
651    pub fn written(did: &str, collection: &str, written: &Written) -> Self {
652        Self {
653            uri: record_uri(did, collection, &written.rkey),
654            cid: written.cid.to_string(),
655            validation_status: didbot_pds::validation_status(
656                collection,
657                didbot_pds::Stance::Optimistic,
658            ),
659        }
660    }
661}
662
663/// Response of `com.atproto.repo.getRecord`.
664///
665/// The `cid` is optional in the lexicon and is always present here: it is
666/// derived from the record being returned rather than stored beside it, so
667/// there is no state in which one is available and the other is not. See
668/// [`CreateRecordResponse`].
669#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
670pub struct GetRecordResponse {
671    /// The `at://` URI of the record.
672    pub uri: String,
673    /// The CID naming the record's contents.
674    pub cid: String,
675    /// The record, byte for byte as it was written.
676    pub value: Value,
677}
678
679/// Response of `com.atproto.repo.describeRepo`.
680#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
681#[serde(rename_all = "camelCase")]
682pub struct DescribeRepoResponse {
683    /// The handle the account answers to.
684    pub handle: String,
685    /// The account's DID.
686    pub did: String,
687    /// The account's whole DID document.
688    pub did_doc: Value,
689    /// Every collection the repository holds at least one record in.
690    ///
691    /// An open set, and a report rather than a menu; see
692    /// `describe_repo`.
693    pub collections: Vec<String>,
694    /// Whether the handle resolves back to this DID.
695    ///
696    /// atproto's handle rule is bidirectional and this field is the server
697    /// saying whether it holds. Here it is computed the same way a stranger
698    /// would compute it -- the document claims a handle, the handle is
699    /// resolved, and the two are compared -- rather than asserted, so a
700    /// deployment whose naming has drifted reports `false` instead of
701    /// insisting.
702    pub handle_is_correct: bool,
703}
704
705/// Response of `com.atproto.server.describeServer`.
706///
707/// Every field this server can answer honestly. `inviteCodeRequired`,
708/// `phoneVerificationRequired`, `blobUploadLimit`, `links` and `contact` are
709/// all optional in the lexicon and this server states none of them today —
710/// there is no invite system, no phone verification, and no configured
711/// policy or contact document to point at — so they are left out rather
712/// than filled with a value nobody chose. `availableUserDomains` is the one
713/// field this response exists to get right; see
714/// [`Registry::available_user_domains`](didbot_pds::Registry::available_user_domains).
715#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
716#[serde(rename_all = "camelCase")]
717pub struct DescribeServerResponse {
718    /// This server's own service DID.
719    pub did: String,
720    /// Every domain suffix a handle issued by this deployment may end in.
721    pub available_user_domains: Vec<String>,
722}
723
724/// Query of `com.atproto.repo.listRecords`.
725#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
726pub struct ListRecordsQuery {
727    /// The repository to read, as a DID or a handle.
728    pub repo: String,
729    /// The collection NSID to list.
730    pub collection: String,
731    /// How many records to return.
732    #[serde(default, skip_serializing_if = "Option::is_none")]
733    pub limit: Option<usize>,
734    /// The record key a previous page ended on, resumed after.
735    #[serde(default, skip_serializing_if = "Option::is_none")]
736    pub cursor: Option<String>,
737    /// Read oldest first rather than newest first.
738    #[serde(default, skip_serializing_if = "Option::is_none")]
739    pub reverse: Option<bool>,
740}
741
742/// The most records `listRecords` will return in one answer.
743///
744/// The lexicon's `maximum`. A request above it is capped rather than refused,
745/// which is the same posture the query service takes: a client
746/// asking for ten thousand wants as many as it can have, and an error only
747/// teaches it to ask for exactly the ceiling with a round trip in front.
748pub const MAX_LIST_LIMIT: usize = 100;
749
750impl ListRecordsQuery {
751    /// The limit to apply, defaulting when the caller did not choose one.
752    ///
753    /// A limit of zero is honoured rather than treated as unset: a caller
754    /// asking for no records is asking a question about existence, and
755    /// answering it with fifty records would be answering a different one.
756    /// The lexicon's `minimum` is one, so a conformant client cannot ask it
757    /// anyway.
758    pub fn effective_limit(&self) -> usize {
759        self.limit.unwrap_or(DEFAULT_LIST_LIMIT).min(MAX_LIST_LIMIT)
760    }
761
762    /// The page this query asks the store for.
763    ///
764    /// The cursor is passed through as the record key it is. It is not
765    /// decoded, checked or signed: it names a position in public data the
766    /// same request would have reached anyway, and a cursor naming a key
767    /// that is not there is not an error — it is where to carry on from,
768    /// which is what makes a page survive the deletion of the row it
769    /// resumed after.
770    pub fn page(&self) -> ListParams {
771        ListParams::new(self.effective_limit())
772            .maybe_after(self.cursor.clone())
773            .reversed(self.reverse.unwrap_or(false))
774    }
775}
776
777/// One operation in a `com.atproto.repo.applyWrites` batch.
778///
779/// A closed union, tagged by `$type` exactly as the lexicon spells the
780/// variants. Closed means a `$type` that is none of the three is refused, and
781/// serde's internally tagged enum gives that for free: an unrecognised tag is
782/// a deserialization failure and becomes a 400 naming the field.
783#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
784#[serde(tag = "$type")]
785pub enum RepoWrite {
786    /// Write a new record, minting a key when none is named.
787    #[serde(rename = "com.atproto.repo.applyWrites#create")]
788    Create {
789        /// The collection NSID.
790        collection: String,
791        /// The record key, if the caller has one in mind.
792        #[serde(default, skip_serializing_if = "Option::is_none")]
793        rkey: Option<String>,
794        /// The record. Named `value` here, unlike `record` on the single
795        /// routes; the lexicon spells it both ways and this is the one it
796        /// spells here.
797        value: Value,
798    },
799    /// Replace the record at a key.
800    #[serde(rename = "com.atproto.repo.applyWrites#update")]
801    Update {
802        /// The collection NSID.
803        collection: String,
804        /// The record key.
805        rkey: String,
806        /// The record.
807        value: Value,
808    },
809    /// Remove the record at a key, or make sure it is gone.
810    #[serde(rename = "com.atproto.repo.applyWrites#delete")]
811    Delete {
812        /// The collection NSID.
813        collection: String,
814        /// The record key.
815        rkey: String,
816    },
817}
818
819/// The most writes one `com.atproto.repo.applyWrites` batch may carry.
820///
821/// A batch is one commit, and atproto's sync specification says "at most 200
822/// record operations can be included in a commit"; a relay drops a commit
823/// that carries more. The reference PDS refuses such a batch the same way.
824pub const MAX_APPLY_WRITES: usize = 200;
825
826/// Body of `com.atproto.repo.applyWrites`.
827#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
828pub struct ApplyWritesRequest {
829    /// The repository to act on, as a DID or a handle.
830    pub repo: String,
831    /// The operations, applied in order: at most 200, see
832    /// `MAX_APPLY_WRITES` (this module).
833    pub writes: Vec<RepoWrite>,
834    /// Whether to check every record against its lexicon.
835    #[serde(default, skip_serializing_if = "Option::is_none")]
836    pub validate: Option<bool>,
837    /// The compare-and-swap field this route declares. Refused; see [`Swap`].
838    #[serde(flatten)]
839    pub swap: Swap,
840}
841
842/// One entry in the `applyWrites` results, one per write, in order.
843#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
844#[serde(tag = "$type")]
845pub enum WriteResult {
846    /// A record was created.
847    #[serde(rename = "com.atproto.repo.applyWrites#createResult")]
848    #[serde(rename_all = "camelCase")]
849    Create {
850        /// Where it landed.
851        uri: String,
852        /// `valid` or `unknown`; see [`CreateRecordResponse`].
853        /// The CID naming what was written; see [`CreateRecordResponse`].
854        cid: String,
855        /// `valid`; see [`CreateRecordResponse`].
856        validation_status: &'static str,
857    },
858    /// A record was replaced.
859    #[serde(rename = "com.atproto.repo.applyWrites#updateResult")]
860    #[serde(rename_all = "camelCase")]
861    Update {
862        /// Where it landed.
863        uri: String,
864        /// `valid` or `unknown`; see [`CreateRecordResponse`].
865        /// The CID naming what was written; see [`CreateRecordResponse`].
866        cid: String,
867        /// `valid`; see [`CreateRecordResponse`].
868        validation_status: &'static str,
869    },
870    /// A record was removed, or was already gone.
871    ///
872    /// The lexicon's `deleteResult` has no properties at all, so this carries
873    /// nothing -- not even whether anything was there. A caller that needs to
874    /// know asks `getRecord` first.
875    #[serde(rename = "com.atproto.repo.applyWrites#deleteResult")]
876    Delete {},
877}
878
879/// One entry in the `listRecords` response.
880///
881/// The lexicon requires a `cid` on every row; see [`CreateRecordResponse`].
882#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
883pub struct RecordView {
884    /// The `at://` URI of this record.
885    pub uri: String,
886    /// The CID naming this record's contents.
887    pub cid: String,
888    /// The stored record, byte for byte as it was written.
889    pub value: Value,
890}
891
892impl RecordView {
893    /// Renders one stored record for the wire, naming it by its contents.
894    ///
895    /// Fails only for a record the atproto data model cannot encode, which
896    /// the write path refuses before storing anything; see
897    /// [`didbot_pds::content_id`].
898    pub fn new(
899        did: &str,
900        collection: &str,
901        rkey: &str,
902        value: Value,
903    ) -> Result<Self, didbot_pds::RecordError> {
904        Ok(Self {
905            uri: record_uri(did, collection, rkey),
906            cid: didbot_pds::content_id(&value)?.to_string(),
907            value,
908        })
909    }
910}
911
912/// The `at://` URI naming one record.
913///
914/// Assembled here rather than in a handler so that the URI a write returns and
915/// the URI a listing reports cannot drift apart.
916pub fn record_uri(did: &str, collection: &str, rkey: &str) -> String {
917    format!("at://{did}/{collection}/{rkey}")
918}
919
920/// Response of `bot.did.stats`.
921///
922/// One field per subject, and a subject with more than one figure to it
923/// carries them in an object of its own rather than in a row of prefixed
924/// keys: `blobs` is what this deployment's blobs come to, and every question
925/// about them is answered inside it.
926#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
927#[serde(rename_all = "camelCase")]
928pub struct StatsResponse {
929    /// How many accounts are provisioned.
930    pub accounts: usize,
931    /// How many records exist across every repository.
932    pub records: usize,
933    /// The stored size of those records as compact JSON.
934    pub bytes: usize,
935    /// How many records each collection holds.
936    pub collections: BTreeMap<String, usize>,
937    /// What this deployment's blobs come to, by the state each blob is in.
938    ///
939    /// Measured apart from `bytes` rather than folded into it: records are
940    /// measured as compact JSON and blobs as themselves, and one number over
941    /// two units would be a number nothing could act on. An operator
942    /// watching a run wants to know which of the two is growing.
943    pub blobs: BlobsView,
944    /// How many live accounts hold a reference on a parent, by the child's
945    /// kind: what a principal cannot be deactivated or erased over. Absent
946    /// while nothing hangs beneath anything.
947    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
948    pub live_children: BTreeMap<String, usize>,
949    /// What operator policy this deployment enforces.
950    #[serde(default)]
951    pub policy: PolicyView,
952    /// The `[tree]` bounds this deployment is judging creates by right now.
953    ///
954    /// Read live rather than at startup, so an operator who has edited
955    /// `[tree]` can see here whether the running server has picked the
956    /// edit up yet.
957    #[serde(default)]
958    pub tree: TreeView,
959}
960
961/// The `tree` member of [`StatsResponse`]: one field per `[tree]` key,
962/// spelled the way the key is.
963#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
964#[serde(rename_all = "camelCase")]
965pub struct TreeView {
966    /// `[tree] max_depth`.
967    pub max_depth: u32,
968    /// `[tree] max_children`.
969    pub max_children: u32,
970    /// `[tree] creates_per_hour`.
971    pub creates_per_hour: u32,
972}
973
974impl Default for TreeView {
975    fn default() -> Self {
976        didbot_pds::TreeLimits::default().into()
977    }
978}
979
980impl From<didbot_pds::TreeLimits> for TreeView {
981    fn from(limits: didbot_pds::TreeLimits) -> Self {
982        Self {
983            max_depth: limits.max_depth,
984            max_children: limits.max_children,
985            creates_per_hour: limits.creates_per_hour,
986        }
987    }
988}
989
990/// The `policy` member of [`StatsResponse`].
991///
992/// The digest alone answers "is this server on my newest edit"; the lists
993/// answer the two questions that follow, which are "which of my records is
994/// it enforcing, and on whom" and "why is the one I just wrote not among
995/// them". Every value here is either a record key from the operator's own
996/// public repository or a DID `bot.did.listAgents` answers with, so this
997/// carries the same `Credential::Disclosure` decision the rest of
998/// `bot.did.stats` does.
999#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1000#[serde(rename_all = "camelCase")]
1001pub struct PolicyView {
1002    /// The digest of the operator's policy and binding records this
1003    /// deployment enforces, over every record's key and body. An operator
1004    /// computes the same digest over their own repository and compares;
1005    /// absent until a revision has been read.
1006    #[serde(default, skip_serializing_if = "Option::is_none")]
1007    pub enforced: Option<String>,
1008    /// Every `bot.did.policy` record in force, by record key.
1009    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1010    pub policies: Vec<PolicyRecordView>,
1011    /// Every `bot.did.policyBinding` record in force, by record key.
1012    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1013    pub bindings: Vec<PolicyBindingView>,
1014    /// Every record the newest build would not honour and had nothing in
1015    /// force at its key to keep. These enforce nothing.
1016    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1017    pub refused: Vec<PolicyRefusalView>,
1018    /// Every record the newest build would not honour where the record in
1019    /// force at its key keeps being enforced. The reason is the newer
1020    /// record's, and what is enforced is the older one.
1021    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1022    pub carried: Vec<PolicyRefusalView>,
1023}
1024
1025/// One `bot.did.policy` record in force.
1026#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1027#[serde(rename_all = "camelCase")]
1028pub struct PolicyRecordView {
1029    /// The record's key.
1030    pub key: String,
1031    /// What it is asked to decide.
1032    pub actions: Vec<String>,
1033    /// The `$type` of its document, which says which engine answers it.
1034    pub document: String,
1035}
1036
1037/// One `bot.did.policyBinding` record in force, and whom it reaches.
1038#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1039#[serde(rename_all = "camelCase")]
1040pub struct PolicyBindingView {
1041    /// The record's key.
1042    pub key: String,
1043    /// The keys of the policies it names.
1044    pub policies: Vec<String>,
1045    /// Every account on this server it reaches, in DID order.
1046    pub accounts: Vec<String>,
1047}
1048
1049/// One record a build would not honour, and why.
1050#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1051#[serde(rename_all = "camelCase")]
1052pub struct PolicyRefusalView {
1053    /// The collection the record is in.
1054    pub collection: String,
1055    /// The record's key.
1056    pub key: String,
1057    /// Why the build refused it.
1058    pub reason: String,
1059}
1060
1061impl PolicyView {
1062    /// The view of `set`, as the gate holds it.
1063    #[must_use]
1064    pub fn of(set: &didbot_pds::EnforcedSet) -> Self {
1065        let refusals = |failures: &[didbot_pds::BuildFailure]| {
1066            failures
1067                .iter()
1068                .map(|failure| PolicyRefusalView {
1069                    collection: failure.collection.to_owned(),
1070                    key: failure.key.clone(),
1071                    reason: failure.reason.clone(),
1072                })
1073                .collect()
1074        };
1075        Self {
1076            enforced: Some(set.digest.clone()),
1077            policies: set
1078                .policies
1079                .iter()
1080                .map(|policy| PolicyRecordView {
1081                    key: policy.key.clone(),
1082                    actions: policy.actions.clone(),
1083                    document: policy.document.clone(),
1084                })
1085                .collect(),
1086            bindings: set
1087                .bindings
1088                .iter()
1089                .map(|binding| PolicyBindingView {
1090                    key: binding.key.clone(),
1091                    policies: binding.policies.clone(),
1092                    accounts: binding.accounts.clone(),
1093                })
1094                .collect(),
1095            refused: refusals(&set.refused),
1096            carried: refusals(&set.carried),
1097        }
1098    }
1099}
1100
1101/// How many blobs, and what they occupy.
1102///
1103/// Every member of [`BlobsView`] is one of these, so that a reader taking a
1104/// figure out of this response gets the same two words wherever it took it
1105/// from.
1106#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1107pub struct TallyView {
1108    /// How many blobs are in this state.
1109    pub count: u64,
1110    /// What they occupy, in bytes.
1111    pub bytes: u64,
1112}
1113
1114impl From<didbot_pds::BlobTally> for TallyView {
1115    fn from(tally: didbot_pds::BlobTally) -> Self {
1116        Self {
1117            count: tally.count,
1118            bytes: tally.bytes,
1119        }
1120    }
1121}
1122
1123/// The `blobs` member of [`StatsResponse`]: this deployment's blobs, by the
1124/// state each one is in.
1125///
1126/// Aggregates over every repository and nothing narrower. `bot.did.stats` is
1127/// open by default — `auth::Disclosure` — so a figure that said which
1128/// account held what would publish one account's content to anyone who
1129/// asked; a total cannot. The names of the blobs an incomplete restore lost
1130/// are in the startup log, where the operator is, and `didbot_pds::Reconciled`
1131/// keeps a sample of them for that line.
1132#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1133#[serde(rename_all = "camelCase")]
1134pub struct BlobsView {
1135    /// Every blob the log names, whatever state it is in.
1136    ///
1137    /// `held.count` and `held.bytes` are this deployment's blob inventory
1138    /// and its blob footprint. The three states below partition this one and
1139    /// sum back to it.
1140    pub held: TallyView,
1141    /// Blobs at least one live record points at.
1142    pub referenced: TallyView,
1143    /// Blobs no live record points at, still inside the collection grace
1144    /// window.
1145    ///
1146    /// The ordinary state of every upload between `uploadBlob` and the
1147    /// `createRecord` that names it, so a nonzero reading is a working
1148    /// server rather than a problem.
1149    pub unreferenced: TallyView,
1150    /// Blobs no live record points at whose grace window has run out: what
1151    /// the next collection pass will take, and the quota it will give back.
1152    ///
1153    /// A figure that climbs and does not fall is the one an operator acts
1154    /// on — it says collection is not getting through.
1155    pub collectable: TallyView,
1156    /// Blobs the log named whose bytes the disk did not have when this
1157    /// server last started.
1158    ///
1159    /// Named for when it was read, because that is the only way to read it:
1160    /// it is what the startup reconcile counted, held for the life of the
1161    /// process. Nonzero means this deployment came back from an incomplete
1162    /// copy and will answer `404` for that many blobs until they are put
1163    /// back, and `bytes` is how much a restore would have to bring. Zero
1164    /// means the disk agreed with the log *at that start*, not that it
1165    /// agrees now: nothing recounts while the server runs, and a restart is
1166    /// what takes the reading again.
1167    ///
1168    /// These blobs are in `held` as well. The log names them, so `listBlobs`
1169    /// still does.
1170    pub missing_at_boot: TallyView,
1171    /// Bytes the disk held that the log named none of, deleted during that
1172    /// same startup.
1173    ///
1174    /// An upload interrupted before it was named, a blob whose bytes landed
1175    /// and whose log entry did not, or an account directory the log no
1176    /// longer mentions. Nonzero after a restore says the copy carried blob
1177    /// bytes the log could not account for. Nothing counted here is in
1178    /// `held`.
1179    pub discarded_at_boot: TallyView,
1180}
1181
1182impl StatsResponse {
1183    /// Renders a registry snapshot for the wire.
1184    pub fn from_stats(stats: &RegistryStats) -> Self {
1185        Self {
1186            accounts: stats.accounts,
1187            records: stats.records.records,
1188            bytes: stats.records.bytes,
1189            collections: stats.records.collections.clone(),
1190            blobs: BlobsView {
1191                held: stats.blobs.held.into(),
1192                referenced: stats.blobs.referenced.into(),
1193                unreferenced: stats.blobs.unreferenced.into(),
1194                collectable: stats.blobs.collectable.into(),
1195                missing_at_boot: stats.blobs.missing_at_boot.into(),
1196                discarded_at_boot: stats.blobs.discarded_at_boot.into(),
1197            },
1198            live_children: stats.live_children.clone(),
1199            policy: PolicyView {
1200                enforced: stats.policy_revision.clone(),
1201                ..PolicyView::default()
1202            },
1203            tree: stats.tree.into(),
1204        }
1205    }
1206
1207    /// Replaces [`StatsResponse::policy`] with the whole set `enforced`
1208    /// describes, rather than its digest alone.
1209    ///
1210    /// Separate from [`Self::from_stats`] because the digest comes off a
1211    /// counted snapshot and the records come off the gate; a caller that
1212    /// has both hands in both.
1213    #[must_use]
1214    pub fn with_policy(mut self, enforced: Option<&didbot_pds::EnforcedSet>) -> Self {
1215        if let Some(enforced) = enforced {
1216            self.policy = PolicyView::of(enforced);
1217        }
1218        self
1219    }
1220}
1221
1222/// Query of `com.atproto.sync.getRepo`.
1223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1224pub struct GetRepoQuery {
1225    /// The repository to export, as a DID.
1226    pub did: String,
1227    /// The revision the caller last synced at, if it has one.
1228    ///
1229    /// A revision this deployment can place turns the answer into the blocks
1230    /// created after it; one it cannot is answered in full. See the route.
1231    #[serde(default, skip_serializing_if = "Option::is_none")]
1232    pub since: Option<String>,
1233}
1234
1235/// Query of `com.atproto.sync.getRecord`.
1236///
1237/// Named for its namespace: `com.atproto.repo.getRecord` has a query of its
1238/// own with the same shape and a different meaning — that one answers with the
1239/// record, this one with the proof that it is in the tree.
1240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1241pub struct SyncGetRecordQuery {
1242    /// The repository to read, as a DID.
1243    pub did: String,
1244    /// The collection NSID.
1245    pub collection: String,
1246    /// The record key.
1247    pub rkey: String,
1248}
1249
1250/// Query of `com.atproto.sync.listRepos`.
1251#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1252pub struct ListReposQuery {
1253    /// How many repositories to return.
1254    #[serde(default, skip_serializing_if = "Option::is_none")]
1255    pub limit: Option<usize>,
1256    /// The DID the previous page ended at.
1257    #[serde(default, skip_serializing_if = "Option::is_none")]
1258    pub cursor: Option<String>,
1259}
1260
1261/// The default page size `com.atproto.sync.listRepos` declares.
1262pub const DEFAULT_REPO_LIMIT: usize = 500;
1263
1264/// The largest page `com.atproto.sync.listRepos` declares.
1265pub const MAX_REPO_LIMIT: usize = 1000;
1266
1267impl ListReposQuery {
1268    /// The page size to apply, defaulted and capped.
1269    ///
1270    /// A limit above the ceiling is capped rather than refused, which is the
1271    /// position the query service takes and holds for the same reason:
1272    /// a client asking for ten thousand wants as many as it can have, and an
1273    /// error teaches it to ask for exactly the ceiling instead.
1274    pub fn effective_limit(&self) -> usize {
1275        self.limit.unwrap_or(DEFAULT_REPO_LIMIT).min(MAX_REPO_LIMIT)
1276    }
1277}
1278
1279/// Query of `com.atproto.sync.getBlob`.
1280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1281pub struct GetBlobQuery {
1282    /// The account holding the blob, as a DID.
1283    pub did: String,
1284    /// The blob's content identifier.
1285    pub cid: String,
1286}
1287
1288/// Query of `com.atproto.sync.listBlobs`.
1289#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1290pub struct ListBlobsQuery {
1291    /// The repository to list, as a DID.
1292    pub did: String,
1293    /// The revision to list from. Refused by name; see the handler.
1294    #[serde(default, skip_serializing_if = "Option::is_none")]
1295    pub since: Option<String>,
1296    /// How many CIDs to return.
1297    #[serde(default, skip_serializing_if = "Option::is_none")]
1298    pub limit: Option<usize>,
1299    /// The CID the previous page ended at.
1300    #[serde(default, skip_serializing_if = "Option::is_none")]
1301    pub cursor: Option<String>,
1302}
1303
1304impl ListBlobsQuery {
1305    /// The page size to apply, defaulted and capped.
1306    ///
1307    /// Capped rather than refused, for the reason
1308    /// [`ListReposQuery::effective_limit`] gives.
1309    pub fn effective_limit(&self) -> usize {
1310        self.limit
1311            .unwrap_or(didbot_pds::blobs::DEFAULT_LIST_LIMIT)
1312            .min(didbot_pds::blobs::MAX_LIST_LIMIT)
1313    }
1314}
1315
1316/// Every value given for `key` in a raw query string, in the order they appear.
1317///
1318/// `com.atproto.sync.getBlocks` takes an array parameter, and an array in an
1319/// XRPC query is a repeated key — `?cids=a&cids=b`. `serde_urlencoded`, which
1320/// is what `axum::extract::Query` is built on, cannot deserialize a sequence
1321/// from one, so the raw string is read here instead of growing a dependency
1322/// for one parameter.
1323#[must_use]
1324pub fn repeated(query: &str, key: &str) -> Vec<String> {
1325    query
1326        .split('&')
1327        .filter_map(|pair| pair.split_once('='))
1328        .filter(|(name, _)| percent_decode(name) == key)
1329        .map(|(_, value)| percent_decode(value))
1330        .collect()
1331}
1332
1333/// Percent-decoding, with `+` as a space, as a query string is written.
1334///
1335/// A `%` that is not followed by two hex digits is left alone rather than
1336/// refused: this decodes a parameter that is about to be looked up as a CID
1337/// and fail on its own if it is nonsense, and there is nothing a decoding
1338/// error could say that the lookup will not say better.
1339///
1340/// The escape is read as two *bytes* and never as a `&str` slice. A query
1341/// string is only guaranteed to be UTF-8, not ASCII — `http`'s URI parser
1342/// admits any byte above `0x7F` as long as the path and query decode as
1343/// UTF-8 — so a `%` followed by one ASCII byte and then a multi-byte
1344/// character puts the end of a `[index + 1..index + 3]` slice inside that
1345/// character, and slicing a `str` there panics. Nothing authenticates
1346/// `com.atproto.sync.getBlocks`, which is what reads its parameters out of
1347/// the raw query, so that panic was reachable by anyone who could reach the
1348/// port.
1349fn percent_decode(value: &str) -> String {
1350    /// One hexadecimal digit, as a nibble.
1351    fn nibble(byte: u8) -> Option<u8> {
1352        match byte {
1353            b'0'..=b'9' => Some(byte - b'0'),
1354            b'a'..=b'f' => Some(byte - b'a' + 10),
1355            b'A'..=b'F' => Some(byte - b'A' + 10),
1356            _ => None,
1357        }
1358    }
1359
1360    let bytes = value.as_bytes();
1361    let mut out = Vec::with_capacity(bytes.len());
1362    let mut index = 0;
1363    while index < bytes.len() {
1364        match bytes[index] {
1365            b'+' => {
1366                out.push(b' ');
1367                index += 1;
1368            }
1369            b'%' if index + 2 < bytes.len() => {
1370                match (nibble(bytes[index + 1]), nibble(bytes[index + 2])) {
1371                    (Some(high), Some(low)) => {
1372                        out.push((high << 4) | low);
1373                        index += 3;
1374                    }
1375                    _ => {
1376                        out.push(b'%');
1377                        index += 1;
1378                    }
1379                }
1380            }
1381            byte => {
1382                out.push(byte);
1383                index += 1;
1384            }
1385        }
1386    }
1387    String::from_utf8_lossy(&out).into_owned()
1388}
1389
1390#[cfg(test)]
1391mod tests {
1392    use super::*;
1393
1394    /// An array parameter is a repeated key, and the order it was sent in is
1395    /// the order it is read in — a caller asking for three blocks gets them
1396    /// back keyed by CID, but the request itself is a list.
1397    #[test]
1398    fn a_repeated_parameter_reads_as_a_list() {
1399        assert_eq!(
1400            repeated("did=did:web:a&cids=one&cids=two", "cids"),
1401            vec!["one".to_owned(), "two".to_owned()]
1402        );
1403        assert_eq!(repeated("did=did:web:a", "cids"), Vec::<String>::new());
1404    }
1405
1406    /// A DID with a port in it arrives percent-encoded, which is the case
1407    /// this whole helper exists for: every account in a development zone has
1408    /// one.
1409    #[test]
1410    fn a_percent_encoded_value_comes_back_whole() {
1411        assert_eq!(
1412            repeated("did=did%3Aweb%3Aa.agents.localhost%253A3000", "did"),
1413            vec!["did:web:a.agents.localhost%3A3000".to_owned()]
1414        );
1415    }
1416
1417    /// A `%` escape whose second digit is the first byte of a multi-byte
1418    /// character. The two bytes after the `%` are read as bytes, so the
1419    /// character is left alone rather than sliced through the middle; reading
1420    /// them as a `&str` slice panics instead, which is what this asserts is
1421    /// gone. `com.atproto.sync.getBlocks` reads its parameters through here
1422    /// with no credential in front of it.
1423    #[test]
1424    fn a_percent_escape_running_into_a_multi_byte_character_does_not_panic() {
1425        assert_eq!(
1426            repeated("cids=%a\u{e9}", "cids"),
1427            vec!["%a\u{e9}".to_owned()]
1428        );
1429        // The same shape in the key rather than the value, and in the one
1430        // parameter `getBlocks` reads before it reads `cids` at all.
1431        assert_eq!(repeated("%a\u{e9}=1&did=x", "did"), vec!["x".to_owned()]);
1432    }
1433
1434    /// An escape at the very end of a value still decodes: the bound that
1435    /// keeps the two digits in range must not be one short.
1436    #[test]
1437    fn a_trailing_escape_is_still_decoded() {
1438        assert_eq!(repeated("cids=a%41", "cids"), vec!["aA".to_owned()]);
1439        assert_eq!(repeated("cids=%4", "cids"), vec!["%4".to_owned()]);
1440    }
1441
1442    #[test]
1443    fn a_page_size_is_defaulted_and_capped() {
1444        assert_eq!(
1445            ListReposQuery::default().effective_limit(),
1446            DEFAULT_REPO_LIMIT
1447        );
1448        assert_eq!(
1449            ListReposQuery {
1450                limit: Some(99_999),
1451                cursor: None
1452            }
1453            .effective_limit(),
1454            MAX_REPO_LIMIT
1455        );
1456    }
1457}