// @pdsjs/core/handlers/server-meta - the endpoints that describe or bootstrap // this server: /, /xrpc/_health, /.well-known/atproto-did, /init and /status. // // /init is the first-run signup. It has no route auth because it runs before any // account exists, and gates itself on the server password. import { encodeStoredKey, hexToBytes, timingSafeEqual } from '../crypto.js'; import { drainRequestBody } from '../http.js'; import { generateKemKey } from '../kem.js'; import { isValidDid, isValidHandle, isValidHex, isValidPrivateKeyLength, } from '../validation.js'; /** * @typedef {Object} ServerMetaContext * @property {import('../ports.js').ActorStoragePort} actorStorage * @property {import('../ports.js').SharedStoragePort} sharedStorage * @property {boolean} readOnly * @property {string} version * @property {string} [password] * @property {import('../ports.js').Secp256k1SignerPort} [k256Signer] * @property {() => Promise} getDid * @property {(request: Request) => Promise<{did: string, scope: string}|null>} authenticate * @property {(type: 'account'|'identity'|'sync', body: Object) => Promise} emitEvent * @property {(commit: {cid: string, rev: string}) => Promise} emitSyncEvent * @property {() => Promise<{rev: string, cid: string, changed: boolean}|null>} rebuildMst * @property {() => Response} readOnlyError * @property {(did: string|null) => void} setCachedIdentity - Clears the DID and signing-key caches after /init writes them */ /** * @param {ServerMetaContext} ctx * @returns {{ routes: import('../pds.js').Routes }} */ export function createServerMetaHandlers(ctx) { const { actorStorage, sharedStorage, readOnly, version, password, k256Signer, getDid, authenticate, emitEvent, emitSyncEvent, rebuildMst, readOnlyError, setCachedIdentity, } = ctx; /** * /xrpc/_health - Health check endpoint * @returns {Response} */ function handleHealth() { return Response.json({ version: version }); } /** * / - Root path ASCII art * @returns {Response} */ function handleRoot() { const ascii = ` ██████╗ ██████╗ ███████╗ ██╗ ███████╗ ██╔══██╗ ██╔══██╗ ██╔════╝ ██║ ██╔════╝ ██████╔╝ ██║ ██║ ███████╗ ██║ ███████╗ ██╔═══╝ ██║ ██║ ╚════██║ ██ ██║ ╚════██║ ██║ ██████╔╝ ███████║ ██╗ ╚█████╔╝ ███████║ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚════╝ ╚══════╝ ATProto PDS - Personal Data Server `; return new Response(ascii, { headers: { 'Content-Type': 'text/plain; charset=utf-8' }, }); } /** * /.well-known/atproto-did - Returns the DID for this PDS * @returns {Promise} */ async function handleAtprotoDid() { const did = await getDid(); if (!did) { return new Response('User not found', { status: 404 }); } return new Response(did, { headers: { 'Content-Type': 'text/plain' } }); } /** * /init - Initialize PDS with DID and private key * @param {Request} request * @returns {Promise} */ async function handleInit(request) { if (readOnly) return readOnlyError(); // Once a DID is adopted, this endpoint can repoint the account at another // identity and signing key, so it takes the account's own credentials. // Before that there is no account to authenticate as: the configured // password gates the bootstrap. With no password set the bootstrap is // open, which is how setup.js adopts a fresh identity. const initialized = !!(await actorStorage.getDid()); if (initialized) { const auth = await authenticate(request); if (!auth) { await drainRequestBody(request); return Response.json( { error: 'AuthenticationRequired', message: 'This server is already initialized', }, { status: 401 }, ); } } const body = await request.json(); if (!initialized && password) { const supplied = typeof body.password === 'string' ? body.password : ''; if (!(await timingSafeEqual(supplied, password))) { return Response.json( { error: 'AuthenticationRequired', message: 'This server requires the configured password to bootstrap', }, { status: 401 }, ); } } if (!body.did || !body.privateKey) { return Response.json( { error: 'InvalidRequest', message: 'missing did or privateKey' }, { status: 400 }, ); } // Validate DID format if (!isValidDid(body.did)) { return Response.json( { error: 'InvalidRequest', message: 'invalid DID format' }, { status: 400 }, ); } // Convert and validate private key let privateKeyBytes; if (typeof body.privateKey === 'string') { if (!isValidHex(body.privateKey)) { return Response.json( { error: 'InvalidRequest', message: 'privateKey must be valid hex' }, { status: 400 }, ); } privateKeyBytes = hexToBytes(body.privateKey); } else if (Array.isArray(body.privateKey)) { privateKeyBytes = new Uint8Array(body.privateKey); } else { return Response.json( { error: 'InvalidRequest', message: 'privateKey must be hex string or byte array', }, { status: 400 }, ); } if (!isValidPrivateKeyLength(privateKeyBytes)) { return Response.json( { error: 'InvalidRequest', message: 'privateKey must be 32 bytes' }, { status: 400 }, ); } // The key's curve: P-256 unless declared secp256k1 (which needs the // injected signer to be usable). const curve = body.curve ?? 'p256'; if (curve !== 'p256' && curve !== 'secp256k1') { return Response.json( { error: 'InvalidRequest', message: 'curve must be p256 or secp256k1' }, { status: 400 }, ); } if (curve === 'secp256k1' && !k256Signer) { return Response.json( { error: 'InvalidRequest', message: 'this deployment cannot sign with secp256k1 keys', }, { status: 400 }, ); } // The `#atproto_kem` key the caller published in the genesis operation. /** @type {Uint8Array|null} */ let kemKeyBytes = null; if (body.kemKey != null) { if ( typeof body.kemKey !== 'string' || !isValidHex(body.kemKey) || body.kemKey.length !== 64 ) { return Response.json( { error: 'InvalidRequest', message: 'kemKey must be 32 bytes of hex', }, { status: 400 }, ); } kemKeyBytes = hexToBytes(body.kemKey); } // Validate handle format if provided if (body.handle != null && !isValidHandle(body.handle)) { return Response.json( { error: 'InvalidRequest', message: 'invalid handle format' }, { status: 400 }, ); } // Detect fresh bootstrap vs handle change for firehose events const prevDid = await actorStorage.getDid(); const prevHandle = await actorStorage.getHandle(); // Initialize identity in storage await actorStorage.setDid(body.did); await actorStorage.setPrivateKey(encodeStoredKey(privateKeyBytes, curve)); // Without one from the caller the account still gets a key, which its // owner must add to the DID document. if (kemKeyBytes) { await actorStorage.setKemKey(kemKeyBytes); } else if (!(await actorStorage.getKemKey())) { await actorStorage.setKemKey((await generateKemKey()).privateKey); } if (body.handle) { await actorStorage.setHandle(body.handle); } // Register actor in shared storage for handle resolution await sharedStorage.putActor(body.did, body.handle || null, Date.now()); // Clear cached values setCachedIdentity(null); if (!prevDid) { // Account bootstrap: announce identity and account status await emitEvent('identity', { handle: body.handle || null }); await emitEvent('account', { active: true }); const latest = await actorStorage.getLatestCommit(); if (latest) { await emitSyncEvent(latest); } } else if (body.handle && body.handle !== prevHandle) { await emitEvent('identity', { handle: body.handle }); } return Response.json({ ok: true, did: body.did, handle: body.handle || null, }); } /** * com.atproto.sync.requestSync - announce this repo's current state. * * A `#sync` event says "here is where this repo is now", and invites a * consumer to re-read it rather than continue from what it last saw. That * is the only way back for a repo whose history a strict consumer has * stopped being able to follow: sync 1.1 consumers verify every commit * against the tree, and one commit they cannot prove drops the repo for * good — nothing in the ordinary flow of writes ever invites them back. * * The account's own auth, because the announcement is about its repo and * costs every consumer a re-read. * @param {Request} _request * @param {URL} _url * @param {import('../pds.js').Auth|null} auth * @returns {Promise} */ async function handleRequestSync(_request, _url, auth) { if (readOnly) return readOnlyError(); const did = await getDid(); if (!did) { return Response.json( { error: 'InvalidRequest', message: 'PDS not initialized' }, { status: 400 }, ); } if (!auth || auth.did !== did) { return Response.json( { error: 'Forbidden', message: 'Not authorized for this repository' }, { status: 403 }, ); } const latest = await actorStorage.getLatestCommit(); if (!latest) { return Response.json( { error: 'InvalidRequest', message: 'Repository has no commits' }, { status: 400 }, ); } await emitSyncEvent(latest); return Response.json({ rev: latest.rev, cid: latest.cid }); } /** * dev.pdsjs.repo.rebuildMst - rebuild the tree from the records under it. * * For a repo whose tree no longer matches its records. The records are the * durable thing; the tree is derived, so it can be built again and the * result announced with `#sync`. Nothing a client can see changes. * * The account's own auth, like requestSync, and for the same reason: it * rewrites the repo's head and asks every consumer to re-read it. * @param {Request} _request * @param {URL} _url * @param {import('../pds.js').Auth|null} auth * @returns {Promise} */ async function handleRebuildMst(_request, _url, auth) { if (readOnly) return readOnlyError(); const did = await getDid(); if (!did) { return Response.json( { error: 'InvalidRequest', message: 'PDS not initialized' }, { status: 400 }, ); } if (!auth || auth.did !== did) { return Response.json( { error: 'Forbidden', message: 'Not authorized for this repository' }, { status: 403 }, ); } const result = await rebuildMst(); if (!result) { return Response.json( { error: 'InvalidRequest', message: 'Repository has no commits' }, { status: 400 }, ); } return Response.json(result); } /** * /status - Check PDS initialization status * @returns {Promise} */ async function handleStatus() { const did = await getDid(); return Response.json({ initialized: !!did, did: did || null }); } return { routes: { '/': { handler: handleRoot, }, '/xrpc/_health': { handler: handleHealth, }, '/.well-known/atproto-did': { handler: handleAtprotoDid, }, '/init': { method: 'POST', handler: handleInit, }, '/status': { handler: handleStatus, }, '/xrpc/com.atproto.sync.requestSync': { method: 'POST', auth: 'required', handler: handleRequestSync, }, '/xrpc/dev.pdsjs.repo.rebuildMst': { method: 'POST', auth: 'required', handler: handleRebuildMst, }, }, }; }