Serving #
What corpora/serve answers, and where it deliberately differs from a PDS.
Facts marked verified were checked on 2026-08-03 against jarrett.app, a reference
@atproto/pds deployment holding the atcr.io lexicon records, and against the resolver
source in node_modules/@atproto.
The routing table #
| Path | Required params | Success | Content type |
|---|---|---|---|
/.well-known/did.json |
— | data.didDoc |
application/json; charset=utf-8 |
/xrpc/com.atproto.sync.getRecord |
did, collection, rkey |
data.proofs[rkey] |
application/vnd.ipld.car |
/xrpc/com.atproto.repo.getRecord |
repo, collection, rkey (cid optional) |
data.records[rkey] |
application/json; charset=utf-8 |
/xrpc/com.atproto.repo.listRecords |
repo, collection (limit, cursor, reverse optional) |
a page of data.list |
application/json; charset=utf-8 |
/xrpc/com.atproto.sync.getRepo |
did (since ignored) |
data.repoCar |
application/vnd.ipld.car |
/xrpc/com.atproto.repo.describeRepo |
repo |
data.describeRepo |
application/json; charset=utf-8 |
/xrpc/com.atproto.sync.getLatestCommit |
did |
data.commit |
application/json; charset=utf-8 |
/xrpc/com.atproto.sync.getRepoStatus |
did |
{did, active: true, rev} |
application/json; charset=utf-8 |
/xrpc/com.atproto.sync.listRepos |
— | one repo, no cursor | application/json; charset=utf-8 |
/xrpc/com.atproto.sync.listBlobs |
did |
{cids: []} |
application/json; charset=utf-8 |
/xrpc/_health |
— | {version} |
application/json; charset=utf-8 |
/ |
— | the landing page | text/html; charset=utf-8 |
/favicon.svg, /favicon.ico |
— | the mark | image/svg+xml |
Parameter names come from the lexicons, not from a guess. Note that sync.getRecord takes
did and repo.getRecord takes repo; getting that backwards is a silent 400.
The last four exist for mirrors and monitoring, not for resolution, and their shapes were copied
from a live reference PDS rather than invented. Three of them are constants: active is always
true, the repo list is always one entry, and the blob list is always empty. _health reports the
Corpora version that built the bundle, since nothing is running that could have a version of its
own, and it is the one response served no-store rather than cached — a liveness probe answering
from cache is not a liveness probe.
None of this makes the authority relay-indexable. A relay needs requestCrawl and then a
firehose to follow; there is no firehose, and subscribeRepos correctly answers
MethodNotImplemented. See RELAYS for why that is architectural rather than lazy.
GET and HEAD on all of them. OPTIONS is a 204 preflight. Anything else is 405 with an
allow: GET, HEAD, OPTIONS header. access-control-allow-origin: * is on every response
including errors. Nothing ever redirects, on any path — @atproto/identity's
DidWebResolver fetches with redirect: 'error' (verified in source), so a 301 anywhere in
the chain fails resolution outright rather than following.
did.json is public, max-age=300; everything else is public, max-age=86400, immutable.
Errors are no-store, so a 404 from a half-finished deploy cannot outlive the deploy.
The two responses meant for a person #
/ is an HTML page and the favicon is an SVG; between them they are the entire human-facing
surface, and nothing on the resolution path reads either. The page is rendered once, at handler
construction, from the same data the machine endpoints replay — it prints the DID, collection,
rev, commit and signing key that bundle actually carries, lists the lexicons from the prebuilt
listRecords pages rather than from a hard-coded set, and links each one to a live getRecord
and its proof. It cannot describe some other authority, and it cannot list a lexicon this host
does not serve.
It is also the only response Corpora composes rather than replays, which is why everything it
interpolates is HTML-escaped and why a test follows every link on it and asserts a 200. It caches
like did.json at max-age=300, since its content follows the deploy rather than the commit.
createHandler(data, { landing: false }) removes it and / goes back to the standard 404.
The icon is served at both /favicon.svg, which the page asks for, and /favicon.ico, which
browsers ask for unprompted. Both return the same SVG: renderers dispatch on content type rather
than on the extension, and an icon at the guessed path beats a 404 in the log.
Error names, and where they come from #
Every name below was observed from a reference PDS rather than inferred.
| Condition | Status | error |
Verified against |
|---|---|---|---|
| Missing required parameter | 400 | InvalidRequest |
Invalid <nsid> params: Missing required key "rkey" |
Bad limit / reverse type |
400 | InvalidRequest |
Expected integer value type (got "abc"), integer too small (minimum 1, got 0) |
Unknown did on sync.* or describeRepo |
400 | RepoNotFound |
Could not find repo for DID: <did> |
Unknown repo on repo.getRecord / listRecords |
400 | InvalidRequest |
Could not find repo: <did> |
Unknown rkey or collection on repo.getRecord |
400 | RecordNotFound |
Could not locate record: at://<did>/<collection>/<rkey> |
Unknown rkey or collection on sync.getRecord |
404 | RecordNotFound |
name and message only; see below |
Unknown /xrpc/<nsid> |
404 | MethodNotImplemented |
— |
| Unknown path | 404 | NotFound |
— |
Not GET/HEAD/OPTIONS |
405 | MethodNotAllowed |
— |
The split in rows three and four is not an inconsistency in Corpora; it is the reference
PDS's own behaviour, and it follows from the lexicons. com.atproto.sync.getRecord,
sync.getRepo, sync.getLatestCommit and repo.describeRepo all declare RepoNotFound
as an error, so an unknown repo is reported under that name. com.atproto.repo.getRecord
and repo.listRecords declare no repo error at all, so the same condition surfaces as a
bare InvalidRequest. Verified in both directions.
The two places Corpora differs from a PDS #
Unknown rkey on sync.getRecord. A PDS returns 200 and a CAR proving the record's
non-existence — the lexicon description says so explicitly, and it was verified: 200,
application/vnd.ipld.car, 2,259 bytes for an NSID that does not exist. Corpora returns
404 RecordNotFound instead.
This is a choice, not a limitation, and it is worth being precise about why, because the
obvious reason is wrong. Absent keys are unbounded, but exclusion proofs are not: an
exclusion proof is just the MST path to the gap where the key would sit, and a tree over N
records has O(N) gaps. A build could emit one proof per gap and route any unknown rkey to
the right one. We do not, because nothing on the resolution path ever asks for an NSID it
does not expect to exist, and 404 fails such a caller correctly and immediately. The reason
is no consumer, not impossible. RecordNotFound is a declared error of the same
lexicon, so a typed XRPC client handles it without surprise.
Everywhere else an unknown record surfaces — repo.getRecord, and the cid mismatch on it
— Corpora returns 400, byte-for-byte what a PDS returns, because there the reference
behaviour exists and can be matched.
since on sync.getRepo is ignored. A static authority holds exactly one revision.
The full CAR is a valid superset of any diff a caller could request, so it is what they
get.
listRecords against a static order #
The handler answers from data.order — the rkeys in serving order — looking each record up in
data.records as it goes. There is no prebuilt page shape in the bundle to serve verbatim, which
is what makes limit, cursor and reverse work for a caller who does not use the build's page
size, and what keeps each record body embedded exactly once.
An unknown collection here is an empty page rather than an error, which is what a
reference PDS returns (verified) and cannot mislead for a repo holding one collection.
Two more things follow, and the second is a contract on corpora build:
- A cursor is the
rkeyof the last record returned, matching the reference PDS, which also returns a cursor on the final page. A cursor naming no record in the repo is a 400 rather than a silent empty page, because for a fixed record set there is no ambiguity about whether it was ever valid. data.orderis thereverse=falseorder. The handler treats it as canonical and reads it from the other end forreverse=true; it never sorts, and holds no reversed copy. A reference PDS returns records in descending rkey order by default (verified), which is the opposite of MST order, so the build must emitorderdescending for Corpora to agree with a PDS over the same repo.
/.well-known/did.json is the only DID document path #
The did:web specification allows path-bearing identifiers: did:web:example.com:lexicon
would resolve at https://example.com/lexicon/did.json. atproto does not implement
them. DidWebResolver.resolveNoCheck splits the identifier and throws
UnsupportedDidWebPathError on anything with more than one segment; the path form appears
in the source only as a commented-out line labelled "how we would resolve a did:web with
path, if atproto supported it" (verified in source).
So the DID must be a bare host, and /.well-known/did.json is the only document path any
resolver will ever request. Corpora therefore serves exactly that one and nothing else,
which is not an omission. This is also an independent argument for the decision in
ARCHITECTURE to put the authority on a dedicated subdomain:
a path-based DID under an existing site is not merely inelegant, it does not resolve.
Serving did:web locally #
did:web:localhost%3A2583 resolves to http://localhost:2583/.well-known/did.json:
DidWebResolver special-cases the hostname localhost down to plain HTTP, which is what
makes a local end-to-end run possible at all (verified in source).
Two traps in that sentence.
corpora serve binds 127.0.0.1 by default, while Node resolves localhost verbatim, so
on a host where localhost prefers ::1 the resolver will not reach the server — bind
--host ::1, or use --host localhost.
And a port-bearing did:web must be escaped twice in a query string. The DID already
contains a percent-escape, so did:web:localhost%3A2583 becomes
did%3Aweb%3Alocalhost%253A2583 as a parameter value. Write %3A and the server decodes
it back to a colon, compares did:web:localhost:2583 against the DID it was built with,
and answers RepoNotFound naming a DID you never typed. corpora serve prints its example
commands through URLSearchParams so they are right by construction; a curl command
assembled by hand is not.
What the serving core may import #
Nothing. test/serve/zero-deps.test.js reads every file under src/serve/ and fails on any
specifier that is not a relative sibling, including inside JSDoc import() types. The rule
is the reason the Worker shell needs none of the five workarounds described in
ARCHITECTURE, and it is cheap to keep only as long as it
is never broken once.