Something went wrong. Try again.
A serverless AT Protocol lexicon authority
atproto lexicon
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233/** * Turning a signed repo into the `dist/` tree. * * `planDist` is pure: it returns the exact bytes of every file. `writeDist` is the only part * that touches disk, and it prunes the paths it owns first so a deleted lexicon disappears from * the output instead of lingering. */
import { createHash } from 'node:crypto'import { mkdir, rm, writeFile } from 'node:fs/promises'import { dirname, join } from 'node:path'
/** Records per `list/page-N.json`, matching listRecords' maximum page size. */const PAGE_SIZE = 100
/** Files and directories `writeDist` considers its own, and will replace. */const MANAGED = [ 'did.json', 'repo.car', 'commit.json', 'describeRepo.json', 'manifest.json', 'data.js', 'records', 'proofs', 'list',]
/** * @typedef {import('../serve/types.js').CorporaData} CorporaData * @typedef {import('../serve/types.js').Manifest} Manifest * @typedef {{ path: string, bytes: Uint8Array }} DistFile */
/** * What this build writes to `manifest.json`: everything a serving shell reads, plus the * `did.json` fingerprint, which no shell reads and which exists to be published away from the * serving host. docs/INTERFACES.md carries the normative field list. * * @typedef {Manifest & { didDocHash: string }} BuildManifest */
/** * @param {object} opts * @param {string} opts.corporaVersion * @param {string} opts.did * @param {string} opts.collection * @param {string} opts.signingKey * @param {Record<string, unknown>} opts.didDoc * @param {import('./repo.js').SignedRepo} opts.repo * @param {import('./lexicons.js').LexiconSource[]} opts.sources * @param {Record<string, string[]>} opts.authorityDomains * @returns {{ files: DistFile[], manifest: BuildManifest, data: CorporaData }} */export function planDist(opts) { const { did, collection, repo, sources } = opts const nsids = sources.map((s) => s.nsid)
const commit = { cid: repo.commitCid, rev: repo.rev } const describeRepo = { did, didDoc: opts.didDoc, collections: [collection], // The authority has no handle and does not want one: nothing resolves a lexicon by handle. handleIsCorrect: false, } // Descending rkey order, which is what a reference PDS returns from listRecords by default and // the opposite of MST order — verified live against jarrett.app on 2026-08-03, `io.atcr.tag` // first and `io.atcr.authFullApp` last. The handler serves this order exactly as emitted and // cannot reorder it, so the order is decided here or not at all. const order = [...nsids].reverse() // The paged form is written to `list/page-N.json` for readers of the static tree. The embedded // bundle carries `order` instead: the same sequence as rkeys, without a second copy of every // record body. See docs/INTERFACES.md. const list = pages(order.map((nsid) => repo.records[nsid]))
/** @type {DistFile[]} */ const files = [ jsonFile('did.json', opts.didDoc), { path: 'repo.car', bytes: repo.repoCar }, jsonFile('commit.json', commit), jsonFile('describeRepo.json', describeRepo), ...nsids.map((nsid) => jsonFile(`records/${nsid}.json`, repo.records[nsid])), ...nsids.map((nsid) => ({ path: `proofs/${nsid}.car`, bytes: repo.proofs[nsid] })), ...list.map((page, i) => jsonFile(`list/page-${i + 1}.json`, page)), ]
/** @type {BuildManifest} */ const manifest = { corporaVersion: opts.corporaVersion, did, collection, rev: repo.rev, commitCid: repo.commitCid, mstRoot: repo.mstRoot, signingKey: opts.signingKey, // The DID document as *served*: JSON with no whitespace, which is what // src/serve/responses.js writes and what `corpora verify --expect-did-doc` hashes. The // did.json file below is the same document pretty-printed, and does not hash to this. didDocHash: `sha256-${sha256(utf8(JSON.stringify(opts.didDoc)))}`, nsids, authorityDomains: opts.authorityDomains, sources: Object.fromEntries(sources.map((s) => [s.nsid, s.sourceHash])), recordCids: Object.fromEntries(nsids.map((nsid) => [nsid, repo.records[nsid].cid])), // The served artifacts only: manifest.json and data.js embed this number and cannot count // themselves. totalBytes: files.reduce((sum, f) => sum + f.bytes.byteLength, 0), }
/** @type {CorporaData} */ const data = { did, rev: repo.rev, commitCid: repo.commitCid, collection, didDoc: opts.didDoc, commit, describeRepo, order, records: Object.fromEntries(nsids.map((nsid) => [nsid, repo.records[nsid]])), proofs: Object.fromEntries(nsids.map((nsid) => [nsid, repo.proofs[nsid]])), repoCar: repo.repoCar, manifest, }
files.push(jsonFile('manifest.json', manifest), { path: 'data.js', bytes: utf8(renderDataJs(data)) }) return { files, manifest, data }}
/** * @param {string} out * @param {DistFile[]} files */export async function writeDist(out, files) { await mkdir(out, { recursive: true }) for (const path of MANAGED) await rm(join(out, path), { recursive: true, force: true }) for (const file of files) { const target = join(out, ...file.path.split('/')) await mkdir(dirname(target), { recursive: true }) await writeFile(target, file.bytes) }}
/** * The embedded bundle: dependency-free ESM, byte payloads as base64, decoded at module load. * `atob` exists in both Node >= 18 and workerd, so the shells import this identically. * * @param {CorporaData} data * @returns {string} */export function renderDataJs(data) { const entries = [ ['did', json(data.did)], ['rev', json(data.rev)], ['commitCid', json(data.commitCid)], ['collection', json(data.collection)], ['didDoc', json(data.didDoc)], ['commit', json(data.commit)], ['describeRepo', json(data.describeRepo)], ['order', json(data.order)], ['records', json(data.records)], ['proofs', mapOfBytes(data.proofs)], ['repoCar', bytes(data.repoCar)], ['manifest', json(data.manifest)], ] return [ '// Generated by corpora build. Do not edit; rebuild from the lexicon source instead.', '', 'const b = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0))', '', "/** @type {import('../src/serve/types.js').CorporaData} */", 'export default {', ...entries.map(([key, value]) => ` ${key}: ${value},`), '}', '', ].join('\n')}
/** * @param {import('../serve/types.js').RecordResponse[]} records * @returns {import('../serve/types.js').ListRecordsPage[]} */function pages(records) { /** @type {import('../serve/types.js').ListRecordsPage[]} */ const out = [] for (let i = 0; i < records.length || i === 0; i += PAGE_SIZE) { const page = records.slice(i, i + PAGE_SIZE) const more = i + PAGE_SIZE < records.length out.push(more ? { records: page, cursor: rkeyOf(page[page.length - 1]) } : { records: page }) } return out}
/** @param {import('../serve/types.js').RecordResponse} record */function rkeyOf(record) { return record.uri.slice(record.uri.lastIndexOf('/') + 1)}
/** * @param {string} path * @param {unknown} value * @returns {DistFile} */function jsonFile(path, value) { return { path, bytes: utf8(`${JSON.stringify(value, null, 2)}\n`) }}
/** @param {string} text */function utf8(text) { return new TextEncoder().encode(text)}
/** @param {Uint8Array} bytes */function sha256(bytes) { return createHash('sha256').update(bytes).digest('hex')}
/** @param {unknown} value */function json(value) { return JSON.stringify(value, null, 2).split('\n').join('\n ')}
/** @param {Uint8Array} value */function bytes(value) { return `b(${JSON.stringify(Buffer.from(value).toString('base64'))})`}
/** @param {Record<string, Uint8Array>} map */function mapOfBytes(map) { const entries = Object.entries(map).map(([key, value]) => ` ${JSON.stringify(key)}: ${bytes(value)},`) return entries.length ? ['{', ...entries, ' }'].join('\n') : '{}'}