lexicon-authority: serve io.atcr.* as a static did:web authority

The namespace no longer depends on the personal PDS at jarrett.app being
online. lexicons/ is compiled by Corpora into a signed AT Protocol
repository and served from a Cloudflare Worker that holds no key, no
state and no database. Signing happens offline; nothing at serving time
signs.

This is the failure that prompted it: when that PDS returned HTTP 500,
OAuth logins failed with "invalid_scope: Failed to resolve requested
permission set", and only for users whose Auth Server met the NSID on a
cold cache — which is why it looked user-specific rather than global.

lexicons/ stays the source of truth. dist/ is compiled output and is
gitignored, as is any signing key, which lives outside both repositories
and is needed for every publish: any record change moves the MST root and
re-signs the whole repo.

The README carries the cutover runbook and the distinction that is
easiest to get wrong — lexicon.atcr.io is the host the Worker serves on
and is created once by wrangler, while the six _lexicon.* TXT records are
the authority pointers and are flipped separately.

Cutover completed 2026-08-04. All six authority domains name
did:web:lexicon.atcr.io. Verified by corpora verify (101 checks), goat,
@atproto/lexicon-resolver returning our exact record CIDs, and a cold
OAuth PAR against two unrelated Auth Servers. Two lexicons resolve for the
first time: io.atcr.hold.image.getConfig and io.atcr.hold.stats.daily.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Evan Jarrett
2026-08-08 21:24:00 -05:00
co-authored by Claude Opus 5
parent 017755c6d4
commit 6173da4349
6 changed files with 1805 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# Build output. Regenerated from ../lexicons by `npm run build`; never edited by hand.
dist/
node_modules/
.wrangler/
.dev.vars
# The signing key must never be here, under any name.
*.key
signing.key
+164
View File
@@ -0,0 +1,164 @@
# Lexicon authority
Serves `io.atcr.*` lexicons as a signed, static `did:web` authority on Cloudflare Workers, so the
namespace stops depending on the personal PDS at `jarrett.app` being online.
`../lexicons` stays the source of truth. Nothing here is edited by hand: `dist/` is compiled
output, and the Worker is three lines over it. Built with [Corpora](../../corpora).
## What this changes
Today `io.atcr.*` resolves through `did:plc:wfj5kyialpmcv2fzk6uqwsln` on `jarrett.app`. When that
PDS returned HTTP 500, OAuth logins failed with `invalid_scope: Failed to resolve requested
permission set`. After the cutover the same lexicons are served as precomputed bytes from a Worker
that holds no key, no state and no database, and there is no origin left to fail.
The signing key lives in a password manager and is used only by `corpora build`, offline. It is
never deployed, because nothing at serving time signs.
## Two kinds of DNS, and they are not the same
Confusing these is the easiest way to break the namespace.
| Record | Purpose | When it changes |
| --- | --- | --- |
| `lexicon.atcr.io` | the host the Worker serves on; **is** the DID under `did:web` | created once, by `wrangler` |
| `_lexicon.atcr.io` and five siblings | the authority pointers that say which DID owns the NSIDs | flipped at cutover, step 4 |
Creating `lexicon.atcr.io` changes nothing about resolution. Until the six TXT records are flipped,
every resolver still follows the `did:plc` to `jarrett.app`, and the old authority stays fully
authoritative. That is what makes steps 1 to 3 zero-risk.
The six authority domains, because NSID authority is the NSID minus its final segment, reversed,
and there is no hierarchical fallback:
```
_lexicon.atcr.io io.atcr.* 3 lexicons
_lexicon.hold.atcr.io io.atcr.hold.* 19 lexicons
_lexicon.image.hold.atcr.io io.atcr.hold.image.* 2 lexicons
_lexicon.repo.atcr.io io.atcr.repo.* 1 lexicon
_lexicon.sailor.atcr.io io.atcr.sailor.* 2 lexicons
_lexicon.stats.hold.atcr.io io.atcr.hold.stats.* 1 lexicon
```
All six exist. `npm run domains` re-derives this list from `../lexicons` if a lexicon is ever added
a level deeper — which silently requires a new TXT record, and is the trap that left three of these
lexicons unresolvable until 2026-08-03.
## First time
```console
$ npm install
$ corpora keygen --out signing.key # then move it to a password manager and delete the file
```
The key is a build input and nothing else. You need it for **every** publish, because any record
change moves the MST root and re-signs the whole repo — there is no incremental update. Losing it
costs a rebuild; leaking it has no fix, because whoever holds it can sign a permission set that
renders on an OAuth consent screen with text of their choosing.
## Cutover
Order matters. Nothing here is destructive while the steps stay in sequence.
**1. Deploy the authority, DNS pointers untouched.**
```console
$ export CORPORA_SIGNING_KEY="$(…from your password manager…)"
$ npm run build:cutover
$ npx wrangler deploy
```
`build:cutover` uses `--dns-check cutover`, which still fails if any of the six records is
*missing* but only warns that they name the old `did:plc`. That is the expected state here.
Reach for `--no-dns-check` and you lose the missing-record check too, at the moment it matters
most.
Do not create `lexicon.atcr.io` by hand; `wrangler` creates it when it attaches the Custom Domain,
and a pre-existing CNAME for that exact name makes the attach fail.
**The zone has a wildcard `*.atcr.io` record** (verified 2026-08-03: a name that cannot exist
resolves to the same Cloudflare IPs). Two things follow. A specific record beats a wildcard in
DNS, so the Custom Domain attach is expected to work regardless — but until it happens,
`https://lexicon.atcr.io` already answers, with `HTTP 525` from the wildcard's origin rather than
`NXDOMAIN`. Do not read that 525 as the Worker being broken; it means the Custom Domain is not
attached yet. `npm run verify:pre` is only meaningful after step 1 succeeds.
**2. Verify without touching DNS.** The old authority is still live, so there is no risk yet.
```console
$ npm run verify:pre
$ curl -sI https://lexicon.atcr.io/.well-known/did.json # 200, no redirect
```
**3. Flip the six TXT records** to `did=did:web:lexicon.atcr.io`.
**4. Verify for real**, now including DNS, and cross-check with an independent implementation:
```console
$ npm run verify
$ goat lex resolve io.atcr.authFullApp
$ goat lex resolve io.atcr.hold.image.getConfig # the deeper authority
```
**5. Leave the old records on the PDS.** Do not delete them. Rollback is editing the six TXT
records back, with a 300-second TTL, and the old authority is untouched and immediately
authoritative again.
**6. Retire the old records** once nothing has resolved through them for a full expiration window.
Worth actually doing rather than leaving forever: relay indexes hold copies of those records and
will otherwise serve a frozen snapshot of the schemas indefinitely.
### What the cutover changes in the records themselves
Checked 2026-08-03 by comparing record CIDs, which depend only on content:
- **22 of 24** shared lexicons are byte-identical to what `jarrett.app` serves today.
- **2 differ and both are purely additive** — `io.atcr.hold.scan` gains optional `reason` and
`status`, `io.atcr.sailor.profile` gains optional `ociClient` and `registryDomain`. Zero
removals, and `goat lex breaking` passes both.
- **4 ship for the first time**: `io.atcr.hold.image.config`, `io.atcr.hold.image.getConfig`,
`io.atcr.hold.purgeManifest`, `io.atcr.hold.stats.daily`.
- **1 disappears**: `io.atcr.sailor.webhook`, live on the PDS and absent from `../lexicons`.
Nothing references it, no permission set lists it, and no records of that `$type` exist. Add the
file whenever you want it back; `_lexicon.sailor.atcr.io` already covers it.
Because the resolved content is materially unchanged, Auth Servers holding a cached permission set
never notice the cutover, and those resolving fresh get the same document under a new signature.
## Publishing, afterwards
```console
$ goat lex lint ../lexicons/**/*.json
$ goat lex breaking
$ export CORPORA_SIGNING_KEY="…"
$ npm run build && npm run verify:local # after `npm run serve` in another shell
$ npx wrangler deploy
$ npm run verify
```
Then record `dist/manifest.json`'s `didDocHash` and `mstRoot` somewhere that is not this host — a
git tag or a release note. `did:web` has no operation log, so a fingerprint published elsewhere is
what makes tampering detectable, and `corpora verify --expect-did-doc <hash>` is the other half.
Optionally, tell the indexer, since a static authority never appears on a relay:
```console
$ curl -X POST https://api.lexicon.store/requestIndex \
-H 'content-type: application/json' -d '{"did":"did:web:lexicon.atcr.io"}'
```
## Deploy credentials
`wrangler login` requests OAuth scopes for every Workers product. A scoped API token is narrower:
**Workers Scripts:Edit** on the account and **Workers Routes:Edit** scoped to the `atcr.io` zone,
passed as `CLOUDFLARE_API_TOKEN`, with `CLOUDFLARE_ACCOUNT_ID` set so it does not also need
Account Settings:Read. Attaching a Custom Domain may additionally require `Zone:DNS:Edit`; start
without it and add it only if the deploy fails on permissions.
## Reference
- [Corpora README](../../corpora/README.md) — the tool
- [DEPLOYING](../../corpora/docs/DEPLOYING.md) — Cloudflare limits, the CDN query-string trap
- [ROADMAP](../../corpora/docs/ROADMAP.md) — the cutover sequence this runbook follows
- [RELAYS](../../corpora/docs/RELAYS.md) — why this will never appear on a relay
+1567
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "atcr-lexicon-authority",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Serves io.atcr.* lexicons as a signed, static did:web authority. Source of truth is ../lexicons.",
"scripts": {
"build": "corpora build --lexicons ../lexicons --out dist --did did:web:lexicon.atcr.io",
"build:cutover": "corpora build --lexicons ../lexicons --out dist --did did:web:lexicon.atcr.io --dns-check cutover",
"domains": "corpora build --lexicons ../lexicons --out /tmp/corpora-domains --did did:web:lexicon.atcr.io --dns-check off --force",
"serve": "corpora serve --dist dist --port 2583",
"verify:local": "corpora verify --origin http://127.0.0.1:2583 --source ../lexicons --no-dns",
"verify:pre": "corpora verify --origin https://lexicon.atcr.io --source ../lexicons --no-dns",
"verify": "corpora verify --origin https://lexicon.atcr.io --source ../lexicons",
"deploy": "wrangler deploy",
"size": "wrangler deploy --dry-run --outdir .wrangler/bundled"
},
"dependencies": {
"corpora": "file:../../corpora"
},
"devDependencies": {
"wrangler": "^4.118.0"
}
}
+4
View File
@@ -0,0 +1,4 @@
import { createWorker } from 'corpora/worker'
import data from './dist/data.js'
export default createWorker(data)
+36
View File
@@ -0,0 +1,36 @@
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "atcr-lexicon-authority",
"main": "worker.js",
"compatibility_date": "2026-08-03",
// Deliberately absent: compatibility_flags, nodejs_compat, and every kind of binding.
// The serving core uses Web-standard globals only and nothing at serving time signs, so
// this Worker has no KV, no R2, no D1, no secrets, and no environment.
"workers_dev": false,
// A Custom Domain, NOT a route pattern.
//
// atcr.io serves the appview and the registry. A pattern such as "atcr.io/*" or
// "*.atcr.io/*" would put this Worker in front of that traffic. A Custom Domain binds
// exactly one hostname and creates exactly one DNS record.
//
// Note this is `lexicon.atcr.io`, the host named by the DID. It is not any of the six
// `_lexicon.*` TXT records, which are the authority pointers and are flipped separately.
"routes": [
{
"pattern": "lexicon.atcr.io",
"custom_domain": true
}
],
"observability": {
"enabled": true
}
// Workers Caching is safe here — its cache key includes the query string, which is what
// XRPC dispatches on. Enable if you want edge caching in front of the script:
//
// "cache": { "enabled": true }
}