~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
397 lines · 12.4 KB Raw History
// @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,
      },
    },
  };
}