mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-04 01:06:57 +00:00
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:
co-authored by
Claude Opus 5
parent
017755c6d4
commit
6173da4349
@@ -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
|
||||
@@ -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
|
||||
Generated
+1567
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createWorker } from 'corpora/worker'
|
||||
import data from './dist/data.js'
|
||||
|
||||
export default createWorker(data)
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user