A serverless AT Protocol lexicon authority
atproto lexicon

README.md

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 _headers cannot set Content-Type on 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 login requests scopes for every Workers product. A token with Workers Scripts:Edit and zone-scoped Workers Routes:Edit, set as CLOUDFLARE_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.

  1. A CDN that strips query strings. XRPC dispatches on the query string, so a cache key that ignores it collapses every getRecord onto one entry and serves the wrong lexicon with a 200. Cloudflare's default is safe and so is Workers Caching; CloudFront's recommended CachingOptimized policy excludes query strings and is not. corpora verify probes for it.
  2. A redirect on /.well-known/did.json. Resolvers fetch it with redirect: 'error', so an apex-to-www rule or a trailing-slash redirect breaks resolution outright.
  3. 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-check out of habit. Mid-cutover, when the records still name your old DID, --dns-check cutover keeps 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.