Something went wrong. Try again.
A serverless AT Protocol lexicon authority
atproto lexicon
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224/** * `corpora build` — the offline half of the project, and the only code that touches the key. * * Read a directory of lexicon documents, check everything that can be checked before signing, * build a real AT Protocol repository over them, sign the commit, and emit `dist/` exactly as * docs/INTERFACES.md specifies. * * Given the same lexicons and the same key, the output is byte-identical — see `revFromRoot` in * ./repo.js for the one place where that took work. */
import { readFile } from 'node:fs/promises'import { join } from 'node:path'import { Secp256k1Keypair } from '@atproto/crypto'import { message } from '../message.js'import { checkAuthorityDns, dnsCheckMode } from './dns.js'import { buildDidWebDocument, checkDidDocument, defaultServiceEndpoint, didWebHost, fetchPlcDocument, isDevelopmentDid,} from './diddoc.js'import { planDist, writeDist } from './emit.js'import { BuildError } from './errors.js'import { COLLECTION, loadLexicons } from './lexicons.js'import { authorityDomains } from './nsid.js'import { parseRev, signRepo } from './repo.js'
export { BuildError } from './errors.js'export { authorityDomain, authorityDomains } from './nsid.js'export { COLLECTION, loadLexicons } from './lexicons.js'
/** * @typedef {object} BuildOptions * @property {string} lexicons directory of lexicon JSON files, read recursively * @property {string} out directory to emit `dist/` into * @property {string} did `did:web:<host>` or `did:plc:<id>` * @property {string | import('@atproto/crypto').Keypair} key secp256k1 private key, hex or imported * @property {string} [service] service endpoint override; defaults to the did:web origin * @property {import('./dns.js').DnsCheckMode | boolean} [dnsCheck] authority-domain TXT checks: * `strict` (the default) requires every record to exist and name `did`; `cutover` requires * every record to exist but lets it still name another DID, reporting that as a warning; `off` * skips the lookups. `true` and `false` are accepted as `strict` and `off`. * @property {boolean} [force] write even when nothing changed * @property {string} [rev] explicit commit rev: a TID or an ISO 8601 instant * @property {string} [against] origin of a deployed authority to compare against * @property {Record<string, unknown>} [didDoc] a did:plc document, instead of fetching it * @property {(name: string) => Promise<string[][]>} [resolveTxt] overridable for tests */
/** * @typedef {object} BuildReport * @property {boolean} written false when the build was skipped as unchanged * @property {string} [unchanged] why it was skipped, when it was * @property {string} did * @property {string} rev * @property {string} commitCid * @property {string} mstRoot * @property {string} signingKey did:key of the public half of the signing key * @property {string} service the service endpoint the DID document publishes * @property {string[]} nsids * @property {Record<string, string[]>} authorityDomains * @property {{ path: string, bytes: number }[]} files * @property {number} totalBytes served artifacts only; excludes manifest.json and data.js * @property {string[]} warnings * @property {import('./emit.js').BuildManifest} manifest */
/** * @param {BuildOptions} opts * @returns {Promise<BuildReport>} */export async function build(opts) { const keypair = typeof opts.key === 'string' ? await importKey(opts.key) : opts.key const signingKey = keypair.did() const did = opts.did if (!did?.startsWith('did:web:') && !did?.startsWith('did:plc:')) { throw new BuildError(`--did must be a did:web or a did:plc, got ${did ? `'${did}'` : 'nothing'}`) }
const { sources, warnings } = await loadLexicons(opts.lexicons) const domains = authorityDomains(sources.map((s) => s.nsid)) const dnsWarnings = await checkAuthorityDns(domains, did, { mode: dnsCheckMode(opts.dnsCheck), resolveTxt: opts.resolveTxt, })
// Before signing: a document naming a different key makes every signature we are about to // produce worthless, and it is the one failure that would otherwise surface as a stranger's // failed login rather than as an error here. const { didDoc, service, warnings: didWarnings } = await didDocumentFor(did, keypair, opts) const repo = await signRepo({ did, keypair, sources, rev: opts.rev ? parseRev(opts.rev) : undefined })
const { files, manifest } = planDist({ corporaVersion: await corporaVersion(), did, collection: COLLECTION, signingKey, didDoc, repo, sources, authorityDomains: domains, })
/** @type {BuildReport} */ const report = { written: true, did, rev: repo.rev, commitCid: repo.commitCid, mstRoot: repo.mstRoot, signingKey, service, nsids: sources.map((s) => s.nsid), authorityDomains: domains, files: files.map((f) => ({ path: f.path, bytes: f.bytes.byteLength })), totalBytes: manifest.totalBytes, warnings: [...warnings, ...dnsWarnings, ...didWarnings], manifest, }
const unchanged = opts.force ? null : await findUnchanged(opts, manifest) if (unchanged) return { ...report, written: false, unchanged }
await writeDist(opts.out, files) return report}
/** * The DID document, minted for did:web and fetched for did:plc, then round-tripped through the * same parser a resolver uses. A malformed document fails the build here rather than a stranger's * login later. * * @param {string} did * @param {import('@atproto/crypto').Keypair} keypair * @param {BuildOptions} opts */async function didDocumentFor(did, keypair, opts) { if (did.startsWith('did:plc:')) { if (opts.service) { throw new BuildError('--service applies to did:web only; a did:plc document is authoritative on plc.directory') } const didDoc = opts.didDoc ?? (await fetchPlcDocument(did)) // The published document is the one plc.directory serves; we can only check that it agrees // with the key we just signed with. const service = checkDidDocument(didDoc, { did, signingKey: keypair.did() }) return { didDoc, service, warnings: [] } }
// Unconditionally, rather than only on the path that derives an endpoint from it: a --service // override must not smuggle an out-of-spec DID past the check. didWebHost(did)
const service = opts.service ?? defaultServiceEndpoint(did) const didDoc = buildDidWebDocument({ did, signingKey: keypair.did(), service }) checkDidDocument(didDoc, { did, signingKey: keypair.did(), service }) return { didDoc, service, warnings: isDevelopmentDid(did) ? [`${did} is the local development form: the spec permits a port only for localhost, in testing and development. It will not resolve from another machine.`] : [], }}
/** * Republishing an unchanged repo churns a new commit for nothing. Compare against whatever * evidence is available: a previous build in `--out`, or a deployed authority via `--against`. * * @param {BuildOptions} opts * @param {import('./emit.js').BuildManifest} manifest * @returns {Promise<string | null>} an explanation when nothing changed */async function findUnchanged(opts, manifest) { if (opts.against) { const url = new URL('/xrpc/com.atproto.sync.getLatestCommit', opts.against) url.searchParams.set('did', manifest.did) const res = await fetch(url).catch((err) => { throw new BuildError(`--against ${opts.against}: ${message(err)}`) }) if (!res.ok) throw new BuildError(`--against ${url} returned HTTP ${res.status}`) const commit = /** @type {{ cid?: string }} */ (await res.json()) return commit.cid === manifest.commitCid ? `${opts.against} already serves commit ${manifest.commitCid}` : null }
/** @type {import('./emit.js').BuildManifest} */ let previous try { previous = JSON.parse(await readFile(join(opts.out, 'manifest.json'), 'utf8')) } catch { return null } // didDocHash covers what the other three do not: a document whose service endpoint changed // while the repo and the key stayed put. A manifest predating the field never matches, which // rebuilds once and is the safe direction. const same = previous.mstRoot === manifest.mstRoot && previous.did === manifest.did && previous.signingKey === manifest.signingKey && previous.didDocHash === manifest.didDocHash if (!same) return null const reformatted = Object.keys(manifest.sources).some((nsid) => previous.sources[nsid] !== manifest.sources[nsid]) return ( `${opts.out} already holds MST root ${manifest.mstRoot} signed by the same key` + (reformatted ? '; the source files changed but the records they encode to did not' : '') )}
/** @param {string} hex */async function importKey(hex) { const key = hex.trim() if (!/^[0-9a-fA-F]{64}$/.test(key)) { throw new BuildError('the signing key must be 64 hex characters (32 bytes); `corpora keygen` prints one') } return Secp256k1Keypair.import(key)}
async function corporaVersion() { const pkg = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8')) return /** @type {string} */ (pkg.version)}