Skip to main content

didbot_serve/routes/
repo.rs

1use super::*;
2
3use crate::write_meter::{Bucket, WriteMeter};
4use tracing::warn;
5
6/// `POST /xrpc/com.atproto.repo.createRecord`
7///
8/// 404 `RepoNotFound` when the repository is unknown, 400 when the collection
9/// is not one this deployment defines, the record's `$type` disagrees with it,
10/// or the requested `rkey` is one the collection's key strategy will not admit
11///; 400 `InvalidSwap` when the `swapCommit` named is not the commit the
12/// repository is at. The key in the returned `uri` is the one the lexicon
13/// decided, which is not always the one that was asked for. The response
14/// carries the `uri` and the `cid` the lexicon requires; see
15/// [`CreateRecordResponse`].
16///
17/// # A key already in use is refused
18///
19/// This is `createRecord`, and in atproto `createRecord` creates: a key
20/// already in use is a 400 and `putRecord` is the upsert. This server could not
21/// tell the two apart while there was no CID to condition a refusal on. There
22/// is one now, and compare-and-swap has landed. The write carries
23/// [`Precondition::Absent`], which is the same check every other
24/// precondition goes through and is evaluated in the same place.
25///
26/// The refusal is `InvalidRequest` rather than the `InvalidSwap` the lexicon
27/// declares, and for a reason about names: `InvalidSwap` tells a
28/// client to re-read and retry, and this retry can never succeed. A caller
29/// that meant to replace the record wants `putRecord`.
30pub(crate) async fn create_record(
31    State(state): State<AppState>,
32    headers: HeaderMap,
33    XrpcBody(body): XrpcBody,
34) -> Response {
35    // Stages 1 and 2 of `docs/write-pipeline.md`, before this route reads
36    // the body or spends a credential: a halted server and a server not
37    // accepting writes are both public facts -- every `503` body
38    // names the second -- and the same answer is owed to a caller with a bad token, a
39    // malformed body or neither. Deciding either one needs no body, so a
40    // caller that trips one hears about it rather than about its JSON.
41    if let Err(err) = check_write_not_halted(&state) {
42        return err.into_response();
43    }
44    let writer = match resource_credential(
45        &state,
46        &headers,
47        "POST",
48        "/xrpc/com.atproto.repo.createRecord",
49    ) {
50        Ok(writer) => writer,
51        Err(err) => return err.into_response(),
52    };
53    let request: CreateRecordRequest = match parse_body(&body) {
54        Ok(request) => request,
55        Err(err) => return err.into_response(),
56    };
57    let did = match require_writable_repo(&state, writer.did(), &request.repo) {
58        Ok(did) => did,
59        Err(err) => return err.into_response(),
60    };
61    let mut swap = match request.swap.resolve_commit_only("createRecord") {
62        Ok(swap) => swap,
63        Err(err) => return err.into_response(),
64    };
65    swap.record = Precondition::Absent;
66    let authorized =
67        match authorize_write(&state, &writer, &[(&request.collection, Action::Create)]) {
68            Ok(authorized) => authorized,
69            Err(err) => return err.into_response(),
70        };
71    if let Some(err) = refuse_skipped_validation(request.validate, [request.collection.as_str()]) {
72        return err.into_response();
73    }
74    if let Some(err) = require_known_lexicon(&request.collection, &request.record, request.validate)
75    {
76        return err.into_response();
77    }
78    write_record(
79        &state,
80        &did,
81        authorized.client_id,
82        &request.collection,
83        request.rkey.as_deref(),
84        request.record,
85        &swap,
86        Occupied::Refuse,
87        body.len(),
88    )
89    .await
90}
91
92/// `POST /xrpc/com.atproto.repo.putRecord`
93///
94/// The upsert, which is what it is in atproto: a key already in use is
95/// replaced rather than refused, and that is now the difference between this
96/// and [`create_record`]. Both `swapRecord` and `swapCommit` are honoured,
97/// and `swapRecord: null` asserts the record does not exist yet.
98pub(crate) async fn put_record(
99    State(state): State<AppState>,
100    headers: HeaderMap,
101    XrpcBody(body): XrpcBody,
102) -> Response {
103    // Stages 1 and 2 of `docs/write-pipeline.md`, before this route reads
104    // the body or spends a credential: a halted server and a server not
105    // accepting writes are both public facts -- every `503` body
106    // names the second -- and the same answer is owed to a caller with a bad token, a
107    // malformed body or neither. Deciding either one needs no body, so a
108    // caller that trips one hears about it rather than about its JSON.
109    if let Err(err) = check_write_not_halted(&state) {
110        return err.into_response();
111    }
112    let writer =
113        match resource_credential(&state, &headers, "POST", "/xrpc/com.atproto.repo.putRecord") {
114            Ok(writer) => writer,
115            Err(err) => return err.into_response(),
116        };
117    let request: PutRecordRequest = match parse_body(&body) {
118        Ok(request) => request,
119        Err(err) => return err.into_response(),
120    };
121    let did = match require_writable_repo(&state, writer.did(), &request.repo) {
122        Ok(did) => did,
123        Err(err) => return err.into_response(),
124    };
125    let swap = match request.swap.resolve() {
126        Ok(swap) => swap,
127        Err(err) => return err.into_response(),
128    };
129    // An upsert may do either, and which one is not known until the store
130    // is read, so the token must cover both.
131    let wanted = [
132        (request.collection.as_str(), Action::Create),
133        (request.collection.as_str(), Action::Update),
134    ];
135    let authorized = match authorize_write(&state, &writer, &wanted) {
136        Ok(authorized) => authorized,
137        Err(err) => return err.into_response(),
138    };
139    if let Some(err) = refuse_skipped_validation(request.validate, [request.collection.as_str()]) {
140        return err.into_response();
141    }
142    if let Some(err) = require_known_lexicon(&request.collection, &request.record, request.validate)
143    {
144        return err.into_response();
145    }
146    write_record(
147        &state,
148        &did,
149        authorized.client_id,
150        &request.collection,
151        Some(&request.rkey),
152        request.record,
153        &swap,
154        Occupied::Replace,
155        body.len(),
156    )
157    .await
158}
159
160/// What a route does about a record key that is already taken.
161///
162/// The one difference between `createRecord` and `putRecord`, named so the
163/// refusal can be reported as the thing it is rather than as a swap the
164/// caller never asked for.
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub(crate) enum Occupied {
167    /// `createRecord`: a key in use is a 400 naming it.
168    Refuse,
169    /// `putRecord`: a key in use is replaced.
170    Replace,
171}
172
173/// The write both `createRecord` and `putRecord` perform.
174///
175/// One function because they are one operation apart from `swap` and
176/// `occupied`, and writing the response assembly twice is how the two would
177/// start disagreeing about the shape of a `uri`.
178#[allow(clippy::too_many_arguments)]
179pub(crate) async fn write_record(
180    state: &AppState,
181    did: &str,
182    client_id: Option<&didbot_pds::policy::ClientId>,
183    collection: &str,
184    rkey: Option<&str>,
185    record: serde_json::Value,
186    swap: &didbot_pds::Swap,
187    occupied: Occupied,
188    bytes: usize,
189) -> Response {
190    if let Err(err) = check_write_not_halted(state) {
191        return err.into_response();
192    }
193    // `docs/write-pipeline.md`'s "where the records a policy declares are
194    // read": whatever the applicable policies declared they read about the
195    // accounts this record names is read here, while there is still a
196    // runtime to await on. The gate itself is judged inside the store's
197    // synchronous section, where nothing can.
198    state.lookups.prefetch(collection, Some(&record)).await;
199    // Off the async worker: every write commits through the WAL, which
200    // holds its store mutex across `fsync`, and `createRecord`/`putRecord`
201    // are the highest-traffic writes this server answers.
202    let registry = state.registry.clone();
203    let did_owned = did.to_owned();
204    let collection_owned = collection.to_owned();
205    let rkey_owned = rkey.map(str::to_owned);
206    let swap_owned = swap.clone();
207    let client_owned = client_id.cloned();
208    let meter = state.write_meter.clone();
209    let outcome = tokio::task::spawn_blocking(move || {
210        let charge = || meter_write(&meter, &did_owned, 1, bytes);
211        registry.put_record_from(
212            &did_owned,
213            &collection_owned,
214            rkey_owned.as_deref(),
215            record,
216            &swap_owned,
217            client_owned.as_ref(),
218            Some(&charge),
219        )
220    })
221    .await;
222    let client = client_id.map(didbot_pds::policy::ClientId::as_str);
223    match outcome {
224        Err(join_err) => blocking_task_failed("a record write", join_err).into_response(),
225        Ok(Ok(written)) => {
226            info!(
227                repo = %did,
228                client,
229                collection,
230                rkey = %written.rkey,
231                cid = %written.cid,
232                "wrote record"
233            );
234            Json(CreateRecordResponse::written(did, collection, &written)).into_response()
235        }
236        Ok(Err(err)) => {
237            // The record itself is never logged, not even truncated: this
238            // line exists to explain a refusal, and the body that caused it
239            // is the caller's content rather than the server's business.
240            info!(repo = %did, client, collection, error = %err, "record write refused");
241            // `createRecord`'s implied precondition is reported as what the
242            // caller actually did — named a key that is taken — rather than
243            // as a swap it never sent.
244            if occupied == Occupied::Refuse && is_swap_failure(&err) {
245                // The key is not always the one the caller named: a
246                // `literal:self` collection decides it, so the message says
247                // where the write would have landed in the caller's own
248                // terms.
249                let occupied = match rkey {
250                    Some(rkey) => format!("`{}`", record_uri(did, collection, rkey)),
251                    None => format!("the key `{collection}` chose for this write"),
252                };
253                return ApiError::bad_request(format!(
254                    "`createRecord` will not replace a record, and one is already at \
255                     {occupied}; use `putRecord` to replace it"
256                ))
257                .into_response();
258            }
259            refused_write(&err, did, client).into_response()
260        }
261    }
262}
263
264/// A store's refusal of a write, as the caller hears it.
265///
266/// A write past the record ceiling also goes to the refusal journal, naming
267/// the account: it is the operator's ceiling, and only the operator can
268/// raise it.
269pub(crate) fn refused_write(
270    err: &didbot_pds::ProvisionError,
271    did: &str,
272    client_id: Option<&str>,
273) -> ApiError {
274    let refused = crate::error::repo_surface(err);
275    if matches!(err, didbot_pds::ProvisionError::TooManyRecords { .. }) {
276        crate::refusal::refused(crate::refusal::Refusal {
277            endpoint: "xrpc",
278            client_id,
279            account: Some(did),
280            error: refused.error(),
281            reason: refused.message(),
282            severity: crate::refusal::Severity::Warn,
283        });
284    }
285    refused
286}
287
288/// Whether a refusal was a record-level compare-and-swap failing.
289pub(crate) fn is_swap_failure(err: &didbot_pds::ProvisionError) -> bool {
290    matches!(
291        err,
292        didbot_pds::ProvisionError::Record(didbot_pds::RecordError::SwapFailed { .. })
293    )
294}
295
296/// The one place this server's own lifecycle refuses a request.
297///
298/// `capability` selects a field of [`ServerPolicy`]; `route` names what was
299/// asked for, so the refusal says which surface deferred rather than only
300/// that something did. Every caller is one line
301/// (`require_lifecycle(&state, |p| p.serves_reads, "…")?`), which is the
302/// point: a surface that consults the lifecycle by matching on the state
303/// itself would be a second, drifting copy of
304/// `didbot_pds::server_state`'s policy table.
305///
306/// # Where the read gate is applied, and where it is not
307///
308/// `ServerPolicy::serves_reads` is false only in `booting` and
309/// `provisioning`, which last a store read and a keypair mint respectively
310/// and during which no agent account can exist. So it is applied where an
311/// absent or empty answer would be *misread* — `listAccounts` answering `[]`,
312/// `subscribeRepos` opening an empty stream, `did.json` answering `404` —
313/// and not on the per-repository reads, where the natural `RepoNotFound` is
314/// already the true answer and a `503` would say less.
315///
316/// The refusal is [`ApiError::not_ready`] — a `503` carrying a
317/// `Retry-After`, never a `404` and never an empty answer. See that
318/// constructor and `didbot_pds::RETRY_AFTER_SECONDS` for how a relay is
319/// meant to tell "try later" from "broken" from "nothing here."
320pub(crate) fn require_lifecycle(
321    state: &AppState,
322    capability: impl FnOnce(&ServerPolicy) -> bool,
323    route: &str,
324) -> Result<(), ApiError> {
325    let snapshot = state.lifecycle.snapshot();
326    if capability(&snapshot.policy) {
327        return Ok(());
328    }
329    Err(ApiError::not_ready(route, &snapshot))
330}
331
332/// Refuses a new account once this deployment already holds
333/// [`AppState::max_accounts`] of them; see `crate::tree::create_account`.
334///
335/// Counted from [`Registry::account_count`] rather than a purpose-built
336/// counter, so this and `crate::health`'s periodic line — which reads
337/// [`Registry::stats`], the pricier sibling this call deliberately avoids —
338/// can never disagree about what "how many accounts" means. This route runs
339/// on every provisioning request on a two-worker runtime, so unlike the
340/// health tick it cannot afford `stats`'s per-account clone just to read one
341/// field off it; bounded regardless by [`AppState::max_accounts`] itself,
342/// since nothing can be counted here that was not admitted past this same
343/// check.
344pub(crate) fn require_account_capacity(state: &AppState) -> Result<(), ApiError> {
345    let live = state.registry.account_count() as u64;
346    if live < state.max_accounts {
347        return Ok(());
348    }
349    Err(ApiError::account_cap_reached(state.max_accounts))
350}
351
352/// The shared gate on every `com.atproto.repo.*` write.
353///
354/// Two independent refusals, in a fixed order. The e-stop goes first because
355/// `Halted` is the answer callers already switch on and it names whose stop
356/// it is; the lifecycle follows, and `ServerNotReady` means something only
357/// this server knows — that nobody is currently answerable for it, so it
358/// accepts no new data. A lapsed operator claim trips both, and the caller
359/// hears `Halted` naming the missing record.
360///
361/// `ServerPolicy::accepts_record_writes` is the axis the whole lifecycle
362/// turns on. An unclaimed server keeps every read this one serves and
363/// accumulates nothing new; see `didbot_pds::server_state`.
364pub(crate) fn check_write_not_halted(state: &AppState) -> Result<(), ApiError> {
365    state.estop.check_use(EstopRefusal::Operation)?;
366    require_record_writes(state)?;
367    Ok(())
368}
369
370/// `docs/write-pipeline.md`'s stage 3, run at the earliest moment a route
371/// can run it: the instant the request envelope names a repository.
372///
373/// Resolves the repository, refuses a caller writing into one that is not
374/// its own, and then refuses a repository whose [`didbot_pds::HostedAccount`]
375/// policy does not accept writes. The store performs the last check again on the
376/// way past; this one exists so that the answer a frozen account gets is
377/// `AccountNotWritable` rather than whatever stage 4 would have said about
378/// its record. "Account frozen" says nothing you write will work until an
379/// operator acts and "your record is malformed" says fix this write, and an
380/// agent that hears the second when the first is true retries forever.
381///
382/// The envelope has to be parsed to know which repository is meant, so this
383/// cannot precede parsing outright the way stages 1 and 2 do — it precedes
384/// every judgment of the *record*, which is what stage 4 in the diagram is.
385pub(crate) fn require_writable_repo(
386    state: &AppState,
387    authenticated: &str,
388    repo: &str,
389) -> Result<String, ApiError> {
390    let did = resolve_repo(state, repo)?;
391    if did != authenticated {
392        return Err(ApiError::repo_mismatch(authenticated, &did));
393    }
394    state
395        .registry
396        .check_writable(&did)
397        .map_err(|err| crate::error::repo_surface(&err))?;
398    Ok(did)
399}
400
401/// Spends `ops` write operations and `bytes` request bytes of `did`'s share
402/// of [`AppState::write_meter`], or refuses with `429 RateLimitExceeded`.
403///
404/// `uploadBlob` runs it once the upload has been judged. Record writes hand
405/// [`meter_write`] to the store, which runs it once policy has allowed the
406/// write. Either way, a write policy refuses spends nothing of the
407/// account's share.
408pub(crate) fn charge_write(
409    state: &AppState,
410    did: &str,
411    ops: usize,
412    bytes: usize,
413) -> Result<(), ApiError> {
414    meter_write(&state.write_meter, did, ops, bytes).map_err(|err| ApiError::from(&err))
415}
416
417/// [`charge_write`], against `meter`, refusing as the store refuses: the
418/// admission charge a record write carries into it.
419pub(crate) fn meter_write(
420    meter: &WriteMeter,
421    did: &str,
422    ops: usize,
423    bytes: usize,
424) -> Result<(), didbot_pds::ProvisionError> {
425    let ops = u32::try_from(ops).unwrap_or(u32::MAX);
426    let Err(refusal) = meter.charge(did, ops, bytes as u64) else {
427        return Ok(());
428    };
429    let limits = meter.limits();
430    let (bucket, rate, burst) = match refusal.bucket {
431        Bucket::Ops => (
432            "operations",
433            u64::from(limits.ops_per_hour),
434            u64::from(limits.ops_burst),
435        ),
436        Bucket::Bytes => ("bytes", limits.bytes_per_hour, limits.bytes_burst),
437    };
438    let wanted = refusal.wanted;
439    let retry_after_secs = refusal
440        .retry_after
441        .map(|wait| (wait.as_secs() + u64::from(wait.subsec_nanos() > 0)).max(1));
442    warn!(
443        repo = %did,
444        bucket,
445        wanted,
446        rate_per_hour = rate,
447        burst,
448        retry_after_secs,
449        "write refused: the account is over its write rate"
450    );
451    let message = match retry_after_secs {
452        Some(secs) => format!(
453            "this write needs {wanted} {bucket}, and `{did}` may write {rate} {bucket} an hour \
454             with at most {burst} at once; retry after {secs}s"
455        ),
456        None => format!(
457            "this write needs {wanted} {bucket}, and `{did}` may write at most {burst} {bucket} \
458             at once"
459        ),
460    };
461    Err(didbot_pds::ProvisionError::OverWriteBudget {
462        message,
463        retry_after_secs,
464    })
465}
466
467/// The lifecycle's half of [`check_write_not_halted`], alone.
468///
469/// `crate::blobs` runs its own e-stop check for its own refusal reason and
470/// needs this half separately; nothing else should call it directly.
471pub(crate) fn require_record_writes(state: &AppState) -> Result<(), ApiError> {
472    require_lifecycle(
473        state,
474        |p| p.accepts_record_writes,
475        "com.atproto.repo writes",
476    )
477}
478
479/// `POST /xrpc/com.atproto.repo.deleteRecord`
480///
481/// Idempotent, as the lexicon's own description asks for: "delete a
482/// repository record, or ensure it doesn't exist". A record that was not
483/// there is a 200 and an empty body, exactly like one that was. An unknown
484/// *repository* is still a 404 — that is a caller pointing at nothing, not a
485/// caller making sure of something.
486///
487/// The response omits `commit`, which the lexicon marks optional. There is no
488/// commit here to report.
489pub(crate) async fn delete_record(
490    State(state): State<AppState>,
491    headers: HeaderMap,
492    XrpcBody(body): XrpcBody,
493) -> Response {
494    // Stages 1 and 2 of `docs/write-pipeline.md`, before this route reads
495    // the body or spends a credential: a halted server and a server not
496    // accepting writes are both public facts -- every `503` body
497    // names the second -- and the same answer is owed to a caller with a bad token, a
498    // malformed body or neither. Deciding either one needs no body, so a
499    // caller that trips one hears about it rather than about its JSON.
500    if let Err(err) = check_write_not_halted(&state) {
501        return err.into_response();
502    }
503    let writer = match resource_credential(
504        &state,
505        &headers,
506        "POST",
507        "/xrpc/com.atproto.repo.deleteRecord",
508    ) {
509        Ok(writer) => writer,
510        Err(err) => return err.into_response(),
511    };
512    let request: DeleteRecordRequest = match parse_body(&body) {
513        Ok(request) => request,
514        Err(err) => return err.into_response(),
515    };
516    let did = match require_writable_repo(&state, writer.did(), &request.repo) {
517        Ok(did) => did,
518        Err(err) => return err.into_response(),
519    };
520    let swap = match request.swap.resolve() {
521        Ok(swap) => swap,
522        Err(err) => return err.into_response(),
523    };
524    let client_id = match authorize_write(&state, &writer, &[(&request.collection, Action::Delete)])
525    {
526        Ok(authorized) => authorized.client_id,
527        Err(err) => return err.into_response(),
528    };
529    if let Err(err) = check_write_not_halted(&state) {
530        return err.into_response();
531    }
532    // Off the async worker for the reason `write_record` gives.
533    let registry = state.registry.clone();
534    let did_owned = did.clone();
535    let collection = request.collection.clone();
536    let rkey = request.rkey.clone();
537    let client_owned = client_id.cloned();
538    let meter = state.write_meter.clone();
539    let bytes = body.len();
540    let outcome = tokio::task::spawn_blocking(move || {
541        let charge = || meter_write(&meter, &did_owned, 1, bytes);
542        registry.delete_record_from(
543            &did_owned,
544            &collection,
545            &rkey,
546            &swap,
547            client_owned.as_ref(),
548            Some(&charge),
549        )
550    })
551    .await;
552    match outcome {
553        Err(join_err) => blocking_task_failed("a record deletion", join_err).into_response(),
554        Ok(Ok(removed)) => {
555            info!(
556                repo = %did,
557                client = client_id.map(didbot_pds::policy::ClientId::as_str),
558                collection = %request.collection,
559                rkey = %request.rkey,
560                removed,
561                "record deletion"
562            );
563            Json(json!({})).into_response()
564        }
565        Ok(Err(err)) => crate::error::repo_surface(&err).into_response(),
566    }
567}
568
569/// `POST /xrpc/com.atproto.repo.applyWrites`
570///
571/// One commit, over every operation in the batch, so at most
572/// [`crate::wire::MAX_APPLY_WRITES`] of them. Every write is checked —
573/// against its collection's lexicon, its record key strategy and the atproto
574/// data model — before any of them touches the store, so a batch that was
575/// going to be refused is refused whole rather than applied as a prefix; see
576/// [`didbot_pds::Registry::apply_writes`]. `swapCommit` is a genuine
577/// compare-and-swap against the commit this batch is about to replace, not a
578/// look before it: it is checked under the same lock the batch's one commit
579/// is built under.
580pub(crate) async fn apply_writes(
581    State(state): State<AppState>,
582    headers: HeaderMap,
583    XrpcBody(body): XrpcBody,
584) -> Response {
585    // Stages 1 and 2 of `docs/write-pipeline.md`, before this route reads
586    // the body or spends a credential: a halted server and a server not
587    // accepting writes are both public facts -- every `503` body
588    // names the second -- and the same answer is owed to a caller with a bad token, a
589    // malformed body or neither. Deciding either one needs no body, so a
590    // caller that trips one hears about it rather than about its JSON.
591    if let Err(err) = check_write_not_halted(&state) {
592        return err.into_response();
593    }
594    let writer = match resource_credential(
595        &state,
596        &headers,
597        "POST",
598        "/xrpc/com.atproto.repo.applyWrites",
599    ) {
600        Ok(writer) => writer,
601        Err(err) => return err.into_response(),
602    };
603    let request: ApplyWritesRequest = match parse_body(&body) {
604        Ok(request) => request,
605        Err(err) => return err.into_response(),
606    };
607    if request.writes.len() > crate::wire::MAX_APPLY_WRITES {
608        return ApiError::bad_request(format!(
609            "`applyWrites` takes at most {} writes, and this batch has {}",
610            crate::wire::MAX_APPLY_WRITES,
611            request.writes.len()
612        ))
613        .into_response();
614    }
615    let did = match require_writable_repo(&state, writer.did(), &request.repo) {
616        Ok(did) => did,
617        Err(err) => return err.into_response(),
618    };
619    let swap = match request.swap.resolve_commit_only("applyWrites") {
620        Ok(swap) => swap,
621        Err(err) => return err.into_response(),
622    };
623    let written = request.writes.iter().filter_map(|write| match write {
624        RepoWrite::Create { collection, .. } | RepoWrite::Update { collection, .. } => {
625            Some(collection.as_str())
626        }
627        RepoWrite::Delete { .. } => None,
628    });
629    if let Some(err) = refuse_skipped_validation(request.validate, written) {
630        return err.into_response();
631    }
632    if let Err(err) = check_write_not_halted(&state) {
633        return err.into_response();
634    }
635
636    let writes: Vec<didbot_pds::BatchOp> = request.writes.into_iter().map(to_batch_op).collect();
637    // The batch store writes a named key whether or not it is taken, so a
638    // create with a key and an update are both upserts, as in `put_record`.
639    let wanted: Vec<(&str, Action)> = writes
640        .iter()
641        .flat_map(|op| {
642            let actions: &[Action] = match op {
643                didbot_pds::BatchOp::Create { rkey: None, .. } => &[Action::Create],
644                didbot_pds::BatchOp::Create { rkey: Some(_), .. }
645                | didbot_pds::BatchOp::Update { .. } => &[Action::Create, Action::Update],
646                didbot_pds::BatchOp::Delete { .. } => &[Action::Delete],
647            };
648            actions.iter().map(move |action| (op.collection(), *action))
649        })
650        .collect();
651    let client_id = match authorize_write(&state, &writer, &wanted) {
652        Ok(authorized) => authorized.client_id,
653        Err(err) => return err.into_response(),
654    };
655    let stance = didbot_pds::Stance::from_validate(request.validate);
656    // Kept alongside the writes handed to the registry, so a result can be
657    // told apart as a create or an update and named with the collection it
658    // went into: [`didbot_pds::BatchOutcome`] carries neither, because the
659    // registry already had them from the operation that produced it.
660    let ops = writes.clone();
661    for op in &writes {
662        let after = match op {
663            didbot_pds::BatchOp::Create { record, .. }
664            | didbot_pds::BatchOp::Update { record, .. } => Some(record),
665            didbot_pds::BatchOp::Delete { .. } => None,
666        };
667        state.lookups.prefetch(op.collection(), after).await;
668    }
669    let client = client_id.map(didbot_pds::policy::ClientId::as_str);
670    // Off the async worker for the reason `write_record` gives.
671    let registry = state.registry.clone();
672    let did_owned = did.clone();
673    let client_owned = client_id.cloned();
674    let meter = state.write_meter.clone();
675    // The whole batch, at one operation per write: a batch is the
676    // cheapest way to put many writes into the log.
677    let (charged, bytes) = (writes.len().max(1), body.len());
678    let outcome = tokio::task::spawn_blocking(move || {
679        let charge = || meter_write(&meter, &did_owned, charged, bytes);
680        registry.apply_writes_from(
681            &did_owned,
682            writes,
683            stance,
684            swap.commit.as_ref(),
685            client_owned.as_ref(),
686            Some(&charge),
687        )
688    })
689    .await;
690    match outcome {
691        Err(join_err) => blocking_task_failed("a batch write", join_err).into_response(),
692        Ok(Ok(outcomes)) => {
693            let results: Vec<WriteResult> = ops
694                .iter()
695                .zip(outcomes)
696                .map(|(op, outcome)| write_result(&did, op, outcome))
697                .collect();
698            info!(repo = %did, client, writes = results.len(), "applied a batch as one commit");
699            Json(json!({ "results": results })).into_response()
700        }
701        Ok(Err(err)) => {
702            info!(repo = %did, client, error = %err, "batch refused; nothing was written");
703            refused_write(&err, &did, client).into_response()
704        }
705    }
706}
707
708/// Converts one wire-level write into the shape [`Registry::apply_writes`]
709/// takes, dropping nothing: the collection, key and value every variant
710/// carries reach the registry exactly as the caller sent them.
711pub(crate) fn to_batch_op(write: RepoWrite) -> didbot_pds::BatchOp {
712    match write {
713        RepoWrite::Create {
714            collection,
715            rkey,
716            value,
717        } => didbot_pds::BatchOp::Create {
718            collection,
719            rkey,
720            record: value,
721        },
722        RepoWrite::Update {
723            collection,
724            rkey,
725            value,
726        } => didbot_pds::BatchOp::Update {
727            collection,
728            rkey,
729            record: value,
730        },
731        RepoWrite::Delete { collection, rkey } => didbot_pds::BatchOp::Delete { collection, rkey },
732    }
733}
734
735/// One batch outcome, on the wire — told apart as a create or an update by
736/// the operation that produced it, since [`didbot_pds::BatchOutcome`] itself
737/// does not carry that distinction.
738pub(crate) fn write_result(
739    did: &str,
740    op: &didbot_pds::BatchOp,
741    outcome: didbot_pds::BatchOutcome,
742) -> WriteResult {
743    match (op, outcome) {
744        (
745            didbot_pds::BatchOp::Create { collection, .. },
746            didbot_pds::BatchOutcome::Written(written),
747        ) => WriteResult::Create {
748            uri: record_uri(did, collection, &written.rkey),
749            cid: written.cid.to_string(),
750            validation_status: didbot_pds::validation_status(
751                collection,
752                didbot_pds::Stance::Optimistic,
753            ),
754        },
755        (
756            didbot_pds::BatchOp::Update { collection, .. },
757            didbot_pds::BatchOutcome::Written(written),
758        ) => WriteResult::Update {
759            uri: record_uri(did, collection, &written.rkey),
760            cid: written.cid.to_string(),
761            validation_status: didbot_pds::validation_status(
762                collection,
763                didbot_pds::Stance::Optimistic,
764            ),
765        },
766        (didbot_pds::BatchOp::Delete { .. }, didbot_pds::BatchOutcome::Deleted) => {
767            WriteResult::Delete {}
768        }
769        // `apply_writes` promises `outcomes` answers `writes` one-for-one, in
770        // the same order and the same kind; anything else is this server's
771        // own bug rather than a caller's.
772        (op, outcome) => unreachable!("a {op:?} produced a {outcome:?}"),
773    }
774}
775
776/// `GET /xrpc/com.atproto.repo.getRecord`
777///
778/// 404 `RecordNotFound` when the record is not there, which is the name the
779/// lexicon declares and the only one it declares.
780pub(crate) async fn get_record(
781    State(state): State<AppState>,
782    peer: crate::rate_limit::Peer,
783    headers: axum::http::HeaderMap,
784    query: Result<Query<GetRecordQuery>, QueryRejection>,
785) -> Response {
786    if let Err(refusal) = require_sync_budget(&state, &headers, peer) {
787        return refusal.into_response();
788    }
789    let Query(query) = match query {
790        Ok(query) => query,
791        Err(rejection) => return ApiError::bad_request(rejection.body_text()).into_response(),
792    };
793    let did = match resolve_repo(&state, &query.repo) {
794        Ok(did) => did,
795        Err(err) => return err.into_response(),
796    };
797    let uri = record_uri(&did, &query.collection, &query.rkey);
798    match state
799        .registry
800        .get_record(&did, &query.collection, &query.rkey)
801    {
802        Ok(Some(value)) => {
803            let cid = match didbot_pds::content_id(&value) {
804                Ok(cid) => cid.to_string(),
805                Err(err) => return unnameable_record(&uri, &err).into_response(),
806            };
807            // A caller that named a version is answered from the one version
808            // there is. The lexicon says an absent `cid` returns the most
809            // recent, and this repository keeps no older ones, so a `cid`
810            // that is not the current one names a record this server does not
811            // hold — which is `RecordNotFound`, and is what the reference
812            // implementation answers for the same request.
813            if query.cid.is_some_and(|asked| asked != cid) {
814                return ApiError::record_not_found(&uri).into_response();
815            }
816            Json(GetRecordResponse { uri, cid, value }).into_response()
817        }
818        Ok(None) => ApiError::record_not_found(&uri).into_response(),
819        Err(err) => crate::error::repo_surface(&err).into_response(),
820    }
821}
822
823/// The 500 for a stored record the data model cannot name.
824///
825/// Unreachable for anything written through this server: every store computes
826/// the CID before it stores the record and refuses one it cannot. It exists
827/// for a record replayed out of a write-ahead log written before that was
828/// true, which is a server-side fact and not a request a caller can rewrite.
829pub(crate) fn unnameable_record(subject: &str, error: &didbot_pds::RecordError) -> ApiError {
830    ApiError::new(
831        StatusCode::INTERNAL_SERVER_ERROR,
832        "InternalServerError",
833        format!("`{subject}` is stored and cannot be named: {error}"),
834    )
835}
836
837/// `GET /xrpc/com.atproto.repo.describeRepo`
838///
839/// The whole account as a stranger would see it: its handle, its DID, its DID
840/// document, the collections it has written anything in, and whether the
841/// handle resolves back to the DID.
842///
843/// # What `collections` now means
844///
845/// It was always derived from what the repository holds rather than from
846/// `didbot_lexicon::nsid::ALL`, so nothing here changed when the
847/// collection set opened — but what a reader may conclude from it did.
848/// The list is a report of what this account has written and never a menu of
849/// what it may write, because there is no longer any such menu. A collection
850/// nobody has heard of appearing in it is a client that wrote one, which is
851/// also where a typo now shows up: `com.example.scrobbel` is a second
852/// collection in this list rather than a refusal on the write.
853pub(crate) async fn describe_repo(
854    State(state): State<AppState>,
855    query: Result<Query<DescribeRepoQuery>, QueryRejection>,
856) -> Response {
857    let Query(query) = match query {
858        Ok(query) => query,
859        Err(rejection) => return ApiError::bad_request(rejection.body_text()).into_response(),
860    };
861    let did = match resolve_repo(&state, &query.repo) {
862        Ok(did) => did,
863        Err(err) => return err.into_response(),
864    };
865    let Some(account) = state.registry.account(&did) else {
866        return ApiError::repo_not_found(&query.repo).into_response();
867    };
868    // The document is looked up by the account's own hostname, which is what
869    // `did:web` means: the DID *is* the host it is served from.
870    let Some(document) = state.registry.did_document(account.did.host()) else {
871        return ApiError::new(
872            StatusCode::INTERNAL_SERVER_ERROR,
873            "DidDocumentUnavailable",
874            format!("`{did}` has an account and no DID document"),
875        )
876        .into_response();
877    };
878    // Read out of the document rather than off the account, so this reports
879    // the handle a resolver would actually find.
880    let handle = document.handle().unwrap_or_default().to_owned();
881    let handle_is_correct = state
882        .registry
883        .handle_did(&handle)
884        .is_some_and(|resolved| resolved == did);
885    let collections = state.registry.collections(&did).unwrap_or_default();
886    let did_doc = match serde_json::to_value(&document) {
887        Ok(value) => value,
888        Err(error) => {
889            return ApiError::new(
890                StatusCode::INTERNAL_SERVER_ERROR,
891                "DidDocumentUnavailable",
892                error.to_string(),
893            )
894            .into_response()
895        }
896    };
897    Json(DescribeRepoResponse {
898        handle,
899        did,
900        did_doc,
901        collections,
902        handle_is_correct,
903    })
904    .into_response()
905}
906
907/// `GET /xrpc/com.atproto.repo.listRecords`
908///
909/// Newest first unless `reverse` says otherwise, capped at `limit` and
910/// defaulting to [`DEFAULT_LIST_LIMIT`](didbot_pds::DEFAULT_LIST_LIMIT).
911/// `cursor` resumes strictly after the record key a previous page ended on,
912/// which is what makes a repository readable past the lexicon's ceiling of a
913/// hundred.
914pub(crate) async fn list_records(
915    State(state): State<AppState>,
916    query: Result<Query<ListRecordsQuery>, QueryRejection>,
917) -> Response {
918    // The query is extracted as a `Result` rather than directly, because
919    // axum's own rejection is a plain-text body and every failure this server
920    // returns is `{"error":..,"message":..}`.
921    let Query(query) = match query {
922        Ok(query) => query,
923        Err(rejection) => return ApiError::bad_request(rejection.body_text()).into_response(),
924    };
925
926    let did = match resolve_repo(&state, &query.repo) {
927        Ok(did) => did,
928        Err(err) => return err.into_response(),
929    };
930    let page = query.page();
931    match state.registry.list_records(&did, &query.collection, &page) {
932        Ok(records) => {
933            // The cursor is taken before the rows are turned into views,
934            // because it is the last row's *record key* and a view holds an
935            // `at://` URI. A client hands it straight back.
936            let cursor = page.next_cursor(&records);
937            // Naming a row can fail now that every row carries a CID, and the
938            // whole listing fails rather than the row being dropped: a page
939            // that silently omitted a record this repository holds would be a
940            // wrong answer that reads like a right one.
941            let records: Vec<RecordView> = match records
942                .into_iter()
943                .map(|(rkey, value)| RecordView::new(&did, &query.collection, &rkey, value))
944                .collect::<Result<_, _>>()
945            {
946                Ok(records) => records,
947                Err(err) => {
948                    let subject = format!("at://{did}/{}", query.collection);
949                    return unnameable_record(&subject, &err).into_response();
950                }
951            };
952            // The field is omitted rather than sent empty when the page did
953            // not fill: the lexicon marks it optional so a server can say
954            // there is nothing after this page by leaving it out.
955            let mut body = json!({ "records": records });
956            if let Some(cursor) = cursor {
957                body["cursor"] = json!(cursor);
958            }
959            Json(body).into_response()
960        }
961        Err(err) => crate::error::repo_surface(&err).into_response(),
962    }
963}