Architecture #
The decision everything follows from #
Signing happens at publish time. Serving is static bytes.
A lexicon repository changes when a human edits a JSON file, a few times a year. Every response a resolver can ask for is therefore knowable in advance. Corpora computes all of them once and serves them as files.
This is what keeps the project small. The hard parts of a PDS (MST mutation, commit sequencing, auth, blobs, the firehose) exist to support writes. Delete writes and what remains is a signer and a lookup table. Corpora is not a PDS; it is a repo compiler with a read-only front end.
Package layout #
One Node package, a runtime-agnostic core, three shells.
corpora/
package.json # exports map gates what each runtime can import
src/
build/ # @atproto/repo + @atproto/crypto. Node only, offline.
serve/ # (Request) => Response. ZERO dependencies.
worker/ # export default { fetch: createHandler(data) }
node/ # node:http adapter
examples/
worker/ # wrangler.toml + 3-line entry
container/ # Dockerfile
standalone/ # bare node process
test/fixtures/ # synthetic com.example.* lexicons
The rule that makes this work: src/serve imports nothing.
That is not stylistic. Cirrus, a PDS on Workers, needed five separate workarounds to run
@atproto/repo inside workerd: aliasing pino to its browser build, forcing CJS module
conditions for multiformats, reaching into BlockMap and CidSet private fields to dodge
broken iterators, a Buffer polyfill for @atproto/crypto, and a 137 line CBOR compatibility
bridge. The root cause is that @atproto/repo pulls in @atproto/common, which pulls pino,
and node:timers/promises.
Split entry points via the package exports map and you meet none of it, because the library
only ever runs in the offline build. Your Worker has zero dependencies, no bindings, and nothing
async at startup.
The serving core #
The handler is a switch statement over embedded bytes: no I/O, nothing async, no runtime config.
Written against Web-standard Request and Response, which is native in Workers and available
globally in Node 18 and later, so the same code runs unmodified everywhere.
export function createHandler(data) {
return (req) => {
const url = new URL(req.url), q = url.searchParams
switch (url.pathname) {
case '/.well-known/did.json':
return json(data.didDoc, { 'cache-control': 'public, max-age=300' })
case '/xrpc/com.atproto.sync.getRecord':
return guard(q, data) ?? car(data.proofs[q.get('rkey')])
case '/xrpc/com.atproto.repo.getRecord':
return guard(q, data) ?? json(data.records[q.get('rkey')])
// ... getRepo, getLatestCommit, listRecords, describeRepo
}
return xrpcError(404, 'NotFound')
}
}
Shells are then trivial. The Worker is export default { fetch: createHandler(data) }. The Node
adapter bridges node:http to Request/Response in about thirty lines.
did.json gets a short cache-control because it is the one artifact where a stale copy during
key rotation causes verification failures. Everything else can cache hard.
Artifacts #
corpora build emits a directory whose structure mirrors the request mapping:
dist/
did.json
repo.car # sync.getRepo
commit.json # sync.getLatestCommit
describeRepo.json
records/<nsid>.json # repo.getRecord response bodies
proofs/<nsid>.car # sync.getRecord response bodies
list/page-1.json
manifest.json # build metadata: source hashes, DID, rev, MST root
data.js # generated module: all of the above, for embedding
dist/ is the neutral contract. Every shell consumes it, and keeping it neutral is what stops
the project becoming Cloudflare-shaped. data.js is a generated module of byte arrays that both
runtimes import identically, so there is no separate embedding mechanism per platform. A Go
shell added later would consume the same dist/ via go:embed.
Size #
Measured against atcr.io's real 28 lexicons: the full repo CAR is 36,835 bytes and a correct partial proof is around 2,100 bytes. A synthetic worst case with 3.5 KB lexicon documents put the complete artifact set at 278,831 bytes, or 0.27 MiB.
Cloudflare's Worker limits, verified 2026-08-03, correcting the figures previously recorded
here as reported: 3 MB gzipped on the free plan and 10 MB gzipped on paid — MB, not MiB —
against a separate 64 MB uncompressed ceiling that was missing entirely. The binding
constraint is neither: it is the 1 second startup limit, since data.js base64-decodes its
payloads in global scope.
The measurement that matters is therefore gzipped, and the earlier "well under 1%" comparison was
against the wrong axis. Rebuilt from what lexicon.atcr.io actually serves on 2026-08-04:
231,214 bytes of data.js, 53,319 bytes gzipped — 1.78% of the free limit. The earlier
0.68% came from synthetic lexicons smaller and more uniform than the real ones; the conclusion
survives the correction, the figure does not. Startup decoding at this size is microseconds.
See DEPLOYING for the per-platform detail, including the CDN cache-key settings that break resolution.
Embed in the script, not in static assets #
Do not use Cloudflare Workers static assets for this. Their documentation describes assets as lazily populated: fetched from storage on first request and cached at the nearest location, with tiered caching to a nearby cache on a miss. That is caching with fallback to central storage, which is exactly the topology this project exists to remove. Worker scripts are the opposite, propagating globally to 330+ cities in under a second, which is replication.
Static assets have a second problem: _headers cannot override Content-Type, which is fixed
at upload from the file extension, and the rules do not apply to Worker-generated responses. A
.car file would get a MIME type you cannot correct.
Since the payload is a fraction of the bundle limit, embed it in the script and both problems disappear.
Build step #
Verified during research (versions as of 2026-07-31):
| Package | Version | Role |
|---|---|---|
@atproto/repo |
0.10.7 | MST, commit, signing, CAR, proofs |
@atproto/crypto |
0.5.4 | Secp256k1Keypair, multikey formatting |
@atproto/identity |
0.5.7 | parseToAtprotoDocument, to self-check did.json |
@atproto/lexicon-resolver |
0.4.12 | Dev only: the official resolver, for tests |
@ipld/car, @ipld/dag-cbor, and multiformats are transitive and need not be direct
dependencies.
The sequence is short. MemoryBlockstore is exported and implements the full storage interface,
so there is no database:
const kp = await Secp256k1Keypair.import(process.env.CORPORA_SIGNING_KEY)
const writes = lexiconDocs.map(doc => ({
action: WriteOpAction.Create, collection: 'com.atproto.lexicon.schema',
rkey: doc.id, record: doc, // rkey IS the NSID
}))
const storage = new MemoryBlockstore()
const repo = await Repo.create(storage, DID, kp, writes) // MST + signed commit
const fullCar = await collect(getFullRepo(storage, repo.cid))
const proof = await collect(getRecords(storage, repo.cid, [{ collection, rkey }]))
getRecords is the MST inclusion-proof generator. Record CID computation and canonical encoding
are handled by the library. A research prototype of this ran correctly on the first attempt and
verified through the official resolver's chain, at roughly 60 lines.
The build should also, beyond producing artifacts: validate that every NSID's authority domain
has a _lexicon TXT record pointing at the configured DID, reject non-integer numbers before
the encoder does it with a worse message, and compare the new MST root against the deployed one
so an unchanged rebuild does not churn commits for nothing.
DID method #
Use did:web on a dedicated subdomain of the namespace domain: did:web:lexicon.atcr.io,
document at https://lexicon.atcr.io/.well-known/did.json, service endpoint pointing at the
same host.
It adds no new failure mode. NSID authority is already bound to the domain, since io.atcr.* is
resolvable only because atcr.io is yours and its DNS answers. If the domain lapses the
namespace is gone regardless of DID method. Binding the DID to the same domain therefore risks
nothing that was not already at risk, and it removes PLC rotation-key custody and the
plc.directory dependency from the design.
did:web:stream.place is a working production lexicon authority using exactly this shape,
including permission sets. See PRIOR_ART.
Rotation under did:web is trivial, contrary to a claim that circulated during research.
The DID document is a file you serve, so rotating means publishing a new did.json with a new
key and re-signing the records in the same build. The DID does not change, the TXT records do
not change, and because it ships as one atomic bundle there is never a window where the
published key and the published signatures disagree. There is no rotation key and nothing to
lose.
The real trade against did:plc is not recovery, it is whether compromising your serving
host is sufficient to forge records. Under did:web it is: an attacker controlling
lexicon.atcr.io serves their own did.json, names their own key, and signs whatever they
like, with no verifiable history. Under did:plc the key is anchored in plc.directory, which
they do not control, so forged records fail verification, and the signed operation log makes
identity changes detectable.
That distinction is mostly academic for ordinary schemas and less so for permission sets, which drive an OAuth consent screen: a forged one can request broader scopes with attacker-written title and description text, shown to users at the moment they decide whether to approve.
Decided: did:web. Host compromise implies larger problems, and the simplicity is worth a
great deal here. The forgery risk above is accepted rather than dismissed.
One cheap compensating control, since did:web has no verifiable history: publish the expected
did.json fingerprint and current MST root somewhere independent of the serving host, such as
the source repository or a release tag. It does not prevent tampering, but it makes tampering
detectable by anyone who cares to check, which is most of what did:plc's operation log buys
in practice.
Corpora should still support did:plc as configuration, for users who have already made that
choice and want to keep their DID while dropping their server.
Key custody #
The signing key is a secp256k1 private key used to sign the repo commit. It exists only where
corpora build runs: a password manager for manual publishing, or a CI secret for automated
publishing. It is never deployed to a serving tier, because nothing at a serving tier signs.
It is also the one input that is not a file in a git tree. See PUBLISHING.
Non-goals #
- Writes. No
createRecord, no auth, no DPoP. Publishing is a deploy, not an API call. - The firehose.
subscribeReposneeds a live socket. Nothing needs to stream a lexicon authority, and relays are not part of the resolution path. - Blobs. Lexicons are JSON.
- Being a general PDS. Anyone who wants accounts wants a PDS.
- Owning lexicon source. Lexicons stay in the project that defines them. Corpora takes a path and is consumed as a dependency.