- JavaScript 100%
Corpora #
A serverless AT Protocol lexicon authority. Corpora signs your lexicons offline and serves them as static bytes, so an NSID namespace stops depending on any one server being online.
The name is the plural of corpus: a body of texts, and in corpus juris, a body of law. Lexicons are the texts, permission sets are the law.
The problem #
An NSID's authority is its domain, reversed. io.atcr.authFullApp resolves by looking up
_lexicon.atcr.io in DNS, following the DID it names, and fetching a record from whatever PDS
that DID currently points at.
So a namespace is only as available as one PDS account. When the PDS behind _lexicon.atcr.io
returned HTTP 500, OAuth logins failed outright with:
invalid_scope: Failed to resolve requested permission set
Auth Servers cache resolved permission sets and serve stale entries on refresh failure, so the failure only bites on a cold cache. That narrows the blast radius without changing the shape of the problem: a namespace many clients depend on hangs off a single long-lived server, usually somebody's personal account.
Lexicon records are read-only and near-static. They do not need a running PDS at all.
The design #
Split the work where the cryptography stops and the serving begins.
Publish, occasionally and offline. Read a directory of lexicon JSON files, build a real AT Protocol repository over them, sign the commit, and emit every byte a resolver could ask for: the DID document, the repo CAR, per-record proof CARs, record JSON, and listings.
Serve, always and statically. Ship those bytes with a request mapper of about a hundred lines. Nothing computes at request time. Nothing holds a key. There is no origin to fail.
lexicons/*.json -> corpora build -> dist/ -> three interchangeable shells
(Node, offline) (Worker | container | standalone)
holds the key hold no key
Shape #
One Node package with a runtime-agnostic core and three deployment shells:
| Entry point | Runtime | Use |
|---|---|---|
corpora/build |
Node, offline | Reads lexicons, signs, emits dist/. The only thing that touches the key. |
corpora/serve |
Any | (Request) => Response. Zero dependencies. |
corpora/worker |
Cloudflare Workers | export default { fetch } |
corpora/node |
Node | node:http adapter, for containers and bare processes |
The serving core is a switch statement over embedded bytes with no I/O and nothing async, so the
same code runs unmodified in a Worker, a container, or a plain process. Changing your mind later
costs no re-signing: on the same hostname, moving between shells leaves repo.car, every proof,
every record and the commit byte-identical, so it is a DNS and TLS change and nothing more.
Changing the hostname is a different matter, because under did:web the hostname is the DID —
that re-signs everything and touches one TXT record per authority domain. For the Worker, even the
DNS half is more than a CNAME: Cloudflare Custom Domains require the zone's nameservers to be at
Cloudflare, so you cannot point at a Worker from outside.
Visiting the hostname in a browser gets a plain HTML page listing the authority's DID, its commit, and every lexicon it serves. Everything else is JSON or CAR bytes.
See examples/ for a working template of each shell.
Getting started #
Corpora is not on npm. Depend on it by path — "corpora": "file:../corpora" — or by a tarball
from npm pack. See examples/README; the container
build needs the tarball, since npm ci runs inside the image and cannot see a sibling directory.
See it work, in one command #
$ git clone <repo-url> corpora && cd corpora
$ npm install
$ npm run dev
That builds a set of example lexicons, starts a local DNS server answering _lexicon TXT
queries, serves the authority, and prints every command you need to walk the chain yourself:
dig against the local resolver, curl for each endpoint, a corpora verify invocation, and a
one-liner that resolves through @atproto/lexicon-resolver. Nothing leaves your machine, and no
domain is required. See LOCAL_E2E.
Publish your own authority #
Only step 6 depends on where you deploy. Steps 1 to 5 produce dist/, and all three shells
serve the same dist/ unchanged — which is why switching between them later is a DNS change
rather than a rebuild.
1. Keep your lexicons where they already live. Corpora takes a path and owns nothing. A flat
directory or a nested tree both work; the id inside each document is authoritative, not the
filename.
2. Generate a signing key.
$ corpora keygen --out signing.key # mode 0600, private half never printed
Put it in a password manager. You need it for every publish — there is no incremental update, since any record change moves the MST root and re-signs the repo. Never commit it, never deploy it: nothing at serving time signs. Losing it costs a rebuild; leaking it has no fix.
3. Find out which DNS records you need, then create them. NSID authority is the NSID minus
its final segment, reversed — and there is no hierarchical fallback, so com.example.hold
(via _lexicon.example.com) and com.example.hold.stats (via _lexicon.hold.example.com) need
separate records. Rather than working this out by hand, ask the build:
$ corpora build --lexicons lexicons --out dist --did did:web:lexicon.example.com --no-dns-check
...
_lexicon.example.com 12 lexicons
_lexicon.hold.example.com 3 lexicons
Create one TXT record per line, each did=<your did>. Then drop --no-dns-check and the build
refuses to proceed until they all resolve and name your DID. This check exists because the
alternative is a lexicon that publishes successfully and resolves for nobody.
4. Build.
$ export CORPORA_SIGNING_KEY="$(…from your password manager…)"
$ corpora build --lexicons lexicons --out dist --did did:web:lexicon.example.com
Builds are reproducible: same inputs and key produce byte-identical output.
5. Verify before deploying.
$ corpora serve --dist dist --port 2583 &
$ corpora verify --origin http://127.0.0.1:2583 --source lexicons --no-dns
6. Deploy — pick a shell. Start from examples/, which has a working template of
each.
Cloudflare Worker — no servers, global replication
Two files. worker.js:
import { createWorker } from 'corpora/worker'
import data from './dist/data.js'
export default createWorker(data)
wrangler.jsonc:
{
"name": "lexicon-authority",
"main": "worker.js",
"compatibility_date": "2026-08-03",
"workers_dev": false,
"routes": [{ "pattern": "lexicon.example.com", "custom_domain": true }]
}
$ npx wrangler deploy
No bindings, no secrets, no nodejs_compat — the serving core uses Web-standard globals only.
custom_domain: true, never a route pattern.example.com/*or*.example.com/*would put the Worker in front of everything else on that zone.- Do not use Workers static assets. They are lazily pulled from central storage, which is the
topology this project exists to remove, and
_headerscannot setContent-Typeon a.car. - Custom Domains need the zone's nameservers at Cloudflare. You cannot CNAME to a Worker from outside.
- Limits are 3 MB gzipped (free) and 10 MB (paid); a real 28-lexicon authority gzips to ~20 KB.
wrangler loginrequests scopes for every Workers product. A token with Workers Scripts:Edit and zone-scoped Workers Routes:Edit, set asCLOUDFLARE_API_TOKEN, is enough.
Full template, with the reasoning inline: examples/worker/.
Container — you already run containers
server.js:
import { serve } from 'corpora/node'
import data from './dist/data.js'
await serve(data, { port: Number(process.env.PORT ?? 2583), host: '0.0.0.0' })
Build dist/ before the image, on a machine that has the key, and let it enter as ordinary
build context. The key never appears in the Dockerfile, in an ARG, or in a layer.
$ corpora build --lexicons lexicons --out dist --did did:web:lexicon.example.com
$ docker build -t lexicon-authority .
$ docker run -p 2583:2583 lexicon-authority
examples/container/Dockerfile is distroless and non-root, and
copies only src/serve and src/node into the runtime stage — so the image contains zero
third-party code, which is only possible because the serving core has no dependencies.
Terminate TLS in front of it. did:web requires HTTPS and tolerates no redirects.
Standalone — you have a VPS
Same server.js as the container, bound to localhost, behind a reverse proxy that terminates TLS:
$ node server.js
examples/standalone/ includes a hardened systemd unit (DynamicUser,
empty capability set) and both Caddy and nginx snippets. Caddy is two lines:
lexicon.example.com {
reverse_proxy 127.0.0.1:2583
}
7. Verify again, against the live URL.
$ corpora verify --origin https://lexicon.example.com --source lexicons
$ goat lex resolve <one.of.your.nsids>
Steps 5 and 7 are not ceremony. A broken authority does not fail on your side; it fails as
invalid_scope on a stranger's login, hours later, on a cold cache.
Open https://lexicon.example.com/ in a browser and you should see your namespace: the DID, the
commit, and every lexicon you just published, each linking to its record and its signed proof.
Republishing #
Edit a lexicon, then repeat 4 through 7. The build compares the new MST root against the last one and declines to write when nothing changed, so a no-op rebuild does not churn commits.
Three ways to break it that all return a valid-looking 200 #
These bite regardless of which shell you deploy, and none of them fail loudly on your side.
- A CDN that strips query strings. XRPC dispatches on the query string, so a cache key that
ignores it collapses every
getRecordonto one entry and serves the wrong lexicon with a 200. Cloudflare's default is safe and so is Workers Caching; CloudFront's recommendedCachingOptimizedpolicy excludes query strings and is not.corpora verifyprobes for it. - A redirect on
/.well-known/did.json. Resolvers fetch it withredirect: 'error', so an apex-to-www rule or a trailing-slash redirect breaks resolution outright. - A missing TXT record one level deep. There is no hierarchical fallback, so adding one lexicon
a level deeper silently needs a new record. The build checks for this, which is the reason not to
reach for
--no-dns-checkout of habit. Mid-cutover, when the records still name your old DID,--dns-check cutoverkeeps the missing-record check and downgrades only the mismatch to a warning.
did:web must also be a bare hostname — no path, and no port except localhost in development.
CLI reference #
corpora keygen [--out <file>]
Generate a secp256k1 signing key. With --out, writes mode 0600 and never prints
the private half.
corpora build --lexicons <dir> --out <dir> --did <did>
Read lexicons, sign, emit dist/. Reads the key from --key-file or
CORPORA_SIGNING_KEY.
--service <url> service endpoint to publish (default: the did:web origin)
--dns-check <mode> strict (default) every record must exist and name --did
cutover every record must exist; naming another
DID warns instead of failing, for building
a new authority before flipping DNS
off skip the checks entirely; use once, to
discover which records you need
--no-dns-check alias for --dns-check off
--rev <tid|time> commit rev; default is derived from content so builds
are reproducible
--against <origin> compare against a deployed authority instead of --out
--force write even when nothing changed
corpora serve --dist <dir> [--port 2583] [--host 127.0.0.1]
Serve dist/ over node:http. Prints working curl commands for your DID.
corpora verify --origin <url> [--source <dir>]
Check a built or deployed authority over HTTP, independently of the code that
built it: did.json shape and no redirects, every proof against the published
key, served records against source both ways, a TXT record per authority
domain, and the query-string cache-key probe.
--no-dns skip TXT checks (needed mid-cutover)
--expect-did-doc assert a sha256 of did.json, published alongside the source
--dns-server ask a specific resolver; also CORPORA_DNS_SERVERS
--json machine-readable output
Exits non-zero if any check fails.
Documentation #
Find the document by the problem you have.
| Read this when | Document |
|---|---|
| You want the whole chain running locally | LOCAL_E2E |
| You are choosing where to deploy, or a CDN is misbehaving | DEPLOYING |
| A lexicon will not resolve, and you need the exact chain | RESOLUTION |
You are handling the signing key, or moving off goat lex publish |
PUBLISHING |
| The build rejected something and you want to know why | BUILD |
| A response differs from what a real PDS returns | SERVING |
| You are wondering why this never shows up on a relay | RELAYS |
| You want the design rationale, DID method, or key custody | ARCHITECTURE |
| You are moving an existing namespace off a PDS | ROADMAP |
| You are wondering what already exists in this space | PRIOR_ART |
| You are working on Corpora itself | INTERFACES |
Scope #
Corpora is the machinery, not a registry. No project's lexicons live here: yours stay in the repo
that owns them, and Corpora is a dependency of that repo. The test fixtures are synthetic
com.example.* schemas.
The first consumer is atcr.io, whose lexicons resolve through a personal PDS today.
Development #
$ npm test # unit, end-to-end, and the workerd tests
$ npm run typecheck # JSDoc types, checked with tsc
Contributions are welcome. MIT licensed.