chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @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<string|null>} getDid
* @property {(request: Request) => Promise<{did: string, scope: string}|null>} authenticate
* @property {(type: 'account'|'identity'|'sync', body: Object) => Promise<void>} emitEvent
* @property {(commit: {cid: string, rev: string}) => Promise<void>} 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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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,
},
},
};
}