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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
1089 lines · 34.9 KB Raw History
// @pdsjs/core/handlers/xrpc-server - com.atproto.server.*: the sessions a
// password or app password opens, the app passwords themselves, and the two
// ways an account arrives on an empty server.
//
// createAccount is the migration entry point: it adopts a DID minted
// elsewhere. onboarding/create mints a brand-new one. Both run before any
// account exists, so neither can use route auth.

import {
  generateAppPassword,
  hashAppPassword,
  isAppPasswordScope,
  SCOPE_ACCESS,
  SCOPE_APP_PASS,
  SCOPE_APP_PASS_PRIVILEGED,
} from '../app-password.js';
import {
  createAccessJwt,
  createAccountJwt,
  createRefreshJwt,
  createServiceJwt,
  decodeJwtClaims,
  generateRefreshTokenId,
  verifyRefreshJwt,
} from '../auth.js';
import { base32Encode, base64UrlDecode } from '../crypto.js';
import { drainRequestBody, isSameOriginPost } from '../http.js';
import { generateKemKey } from '../kem.js';
import { buildPlcOperation, signPlcOperationObject } from '../plc.js';
import { cborEncodeDagCbor } from '../repo.js';
import {
  ACCOUNT_SESSION_TTL,
  accountCookie,
  appPasswordLabel,
  REFRESH_TOKEN_TTL_SECONDS,
  rotationGraceExpiry,
} from '../session.js';
import { isValidHandle } from '../validation.js';
import { parseDidKey } from '../verify.js';

/**
 * @typedef {Object} XrpcServerContext
 * @property {import('../ports.js').ActorStoragePort} actorStorage
 * @property {import('../ports.js').SharedStoragePort} sharedStorage
 * @property {ReturnType<typeof import('../session.js').createSessionRegistry>} sessions
 * @property {string} jwtSecret
 * @property {string} [hostname]
 * @property {string} plcUrl
 * @property {number} blobUploadLimit - Largest accepted blob, in bytes, as describeServer reports it
 * @property {string} [password] - The configured password, gating the migration claim of an empty server
 * @property {(candidate: unknown) => Promise<boolean>} verifyAccountPassword
 * @property {() => Promise<boolean>} passwordConfigured
 * @property {boolean} readOnly
 * @property {boolean} [spacesEnabled] - Whether the spaces extension serves this account's spaces
 * @property {import('../ports.js').SignatureVerifierPort|null} verifier
 * @property {import('../ports.js').DidResolverPort|null} didResolver
 * @property {() => Promise<string|null>} getDid
 * @property {() => Promise<import('../crypto.js').SigningKey|null>} getSigningKey
 * @property {(auth: {scope?: string}, lxm: string, aud: string) => Response|null} checkRpcScope
 * @property {() => Promise<{stored: Uint8Array, didKey: string, signingKey: import('../crypto.js').SigningKey}>} generateSigningKey
 * @property {(scope: string|null|undefined) => Promise<{email?: string, emailConfirmed?: boolean}>} emailSessionFields
 * @property {(type: 'account'|'identity'|'sync', body: Object) => Promise<void>} emitEvent
 * @property {(did: string) => Promise<void>} notifyRelay
 * @property {() => Response} readOnlyError
 * @property {(did: string|null) => void} setCachedIdentity - Refreshes the DID and signing-key caches after an account is adopted
 */

/**
 * @param {XrpcServerContext} ctx
 * @returns {{ routes: import('../pds.js').Routes }}
 */
export function createXrpcServerHandlers(ctx) {
  const {
    actorStorage,
    sharedStorage,
    sessions,
    jwtSecret,
    hostname: configuredHostname,
    plcUrl,
    blobUploadLimit,
    password: accountPassword,
    verifyAccountPassword,
    passwordConfigured,
    readOnly,
    spacesEnabled,
    verifier,
    didResolver,
    getDid,
    getSigningKey,
    checkRpcScope,
    generateSigningKey,
    emailSessionFields,
    emitEvent,
    notifyRelay,
    readOnlyError,
    setCachedIdentity,
  } = ctx;

  /**
   * The hostname this server answers as: configured, else what the request
   * says. It names the did:web identity a migrating account addresses.
   * @param {Request} request
   * @returns {string}
   */
  function requestHostname(request) {
    return (
      configuredHostname ||
      request.headers.get('x-hostname') ||
      new URL(request.url).hostname
    );
  }

  /**
   * com.atproto.server.describeServer
   * @param {Request} request
   * @returns {Promise<Response>}
   */
  async function handleDescribeServer(request) {
    const did = await getDid();
    const hostname = requestHostname(request);
    return Response.json({
      // Once an account is hosted, its DID is the server's identity. Before
      // then the server still needs a stable service identity so a migrating
      // account can name it as the `aud` of the service token authorizing
      // createAccount — did:web of our own hostname.
      did: did || `did:web:${hostname}`,
      availableUserDomains: [`.${hostname}`],
      inviteCodeRequired: false,
      phoneVerificationRequired: false,
      // As the stock PDS reports it, for a client that splits a large
      // upload across blobs to size its parts by.
      blobUploadLimit,
      links: {},
      contact: {},
      ...(spacesEnabled ? { spacesAlpha: true } : {}),
    });
  }

  /**
   * com.atproto.server.createSession
   * @param {Request} request
   * @returns {Promise<Response>}
   */
  async function handleCreateSession(request) {
    if (readOnly) return readOnlyError();

    const { identifier, password } = await request.json();

    const did = await getDid();
    if (!did) {
      return Response.json(
        { error: 'InvalidRequest', message: 'PDS not initialized' },
        { status: 400 },
      );
    }

    // Verify identifier matches DID or handle
    const handle = await actorStorage.getHandle();
    if (identifier !== did && identifier !== handle) {
      return Response.json(
        { error: 'AuthenticationRequired', message: 'Invalid identifier' },
        { status: 401 },
      );
    }

    // The account password grants a full session; an app password grants a
    // restricted one. The account password is checked first.
    // No password at all means no password can be correct. Before this
    // was enforced, an unset password made the comparison short-circuit and
    // every login succeeded with full access.
    if (!(await passwordConfigured())) {
      return Response.json(
        {
          error: 'AuthenticationRequired',
          message:
            'Password login is not configured on this server. Set the password option (PDS_PASSWORD).',
        },
        { status: 401 },
      );
    }

    let scope = SCOPE_ACCESS;
    let label = 'Account password';
    if (!(await verifyAccountPassword(password))) {
      const appPassword = await verifyAppPassword(did, password);
      if (!appPassword) {
        return Response.json(
          { error: 'AuthenticationRequired', message: 'Invalid password' },
          { status: 401 },
        );
      }
      scope = appPassword.privileged
        ? SCOPE_APP_PASS_PRIVILEGED
        : SCOPE_APP_PASS;
      label = appPasswordLabel(appPassword.name);
    }

    const accessJwt = await createAccessJwt(did, jwtSecret, 7200, scope);
    const refreshJwt = await createRefreshJwt(
      did,
      jwtSecret,
      REFRESH_TOKEN_TTL_SECONDS,
      scope,
    );
    await sessions.recordSession(refreshJwt, {
      did,
      label,
      scope,
      request,
      dedupe: true,
    });

    return Response.json({
      did,
      handle: handle || did,
      accessJwt,
      refreshJwt,
      ...(await emailSessionFields(scope)),
    });
  }

  /**
   * com.atproto.server.createAccount
   *
   * Single-tenant migration entry point: adopt an existing DID onto this
   * empty server so an account can be moved here from another PDS. Only the
   * migration shape is supported — an existing `did` is required, and the
   * server must not already host an account.
   *
   * A migrating client authorizes the call with a service token minted at its
   * old PDS (`getServiceAuth` with aud = this server's did:web identity and
   * lxm = com.atproto.server.createAccount). We validate the token's claims
   * and, when a verifier and DID resolver are injected, its signature against
   * the issuer's atproto key (P-256 built in; secp256k1 — the curve the
   * reference PDS gives every account — needs the injected verifier the node
   * and cloudflare packages wire up). A token that cannot be verified is
   * rejected. A core-only deployment with neither injected falls back to
   * claims alone. As a manual fallback (local setup, no old PDS), the
   * configured account password also authorizes the call.
   *
   * The repo signing key is freshly generated here; it is what
   * getRecommendedDidCredentials advertises and what the migrating account
   * adds to its DID before activateAccount.
   *
   * @param {Request} request
   * @returns {Promise<Response>}
   */
  async function handleCreateAccount(request) {
    if (readOnly) return readOnlyError();

    let body;
    try {
      body = await request.json();
    } catch {
      return Response.json(
        { error: 'InvalidRequest', message: 'Invalid request body' },
        { status: 400 },
      );
    }

    // Single-tenant: a server already hosting an account cannot take another.
    if (await getDid()) {
      return Response.json(
        {
          error: 'InvalidRequest',
          message: 'This server already hosts an account',
        },
        { status: 400 },
      );
    }

    const did = typeof body.did === 'string' ? body.did : null;
    if (!did) {
      return Response.json(
        {
          error: 'UnsupportedDomain',
          message:
            'Only account migration is supported: an existing did is required',
        },
        { status: 400 },
      );
    }

    const handle =
      typeof body.handle === 'string' ? body.handle.toLowerCase() : null;
    if (!handle || !isValidHandle(handle)) {
      return Response.json(
        { error: 'InvalidHandle', message: 'A valid handle is required' },
        { status: 400 },
      );
    }

    const authError = await authorizeCreateAccount(
      request,
      did,
      requestHostname(request),
      body,
    );
    if (authError) return authError;

    // Mint a fresh repo signing key. The migrating repo was signed under the
    // old PDS's key; that history imports as-is, and this key takes over for
    // records written after the DID is repointed here and activated.
    const { stored } = await generateSigningKey();
    await actorStorage.setPrivateKey(stored);
    // Published by the PLC operation the account submits to move here, which
    // getRecommendedDidCredentials builds with this key in it.
    await actorStorage.setKemKey((await generateKemKey()).privateKey);
    await actorStorage.setDid(did);
    await actorStorage.setHandle(handle);
    // Deactivated until the migration completes: the DID still points at the
    // old PDS, and the repo/blobs have yet to arrive.
    await actorStorage.setAccountStatus('deactivated');
    setCachedIdentity(did);
    if (typeof body.email === 'string' && body.email) {
      await actorStorage.setEmail({
        address: body.email,
        verified: false,
      });
    }

    const scope = SCOPE_ACCESS;
    const accessJwt = await createAccessJwt(did, jwtSecret, 7200, scope);
    const refreshJwt = await createRefreshJwt(
      did,
      jwtSecret,
      REFRESH_TOKEN_TTL_SECONDS,
      scope,
    );
    await sessions.recordSession(refreshJwt, {
      did,
      label: 'Account migration',
      scope,
      request,
      dedupe: true,
    });

    return Response.json({ did, handle, accessJwt, refreshJwt });
  }

  /**
   * Authorize a createAccount call, either by a service token from the old
   * PDS or by the configured account password. Returns an error Response when
   * the caller is not authorized, or null when it is.
   *
   * @param {Request} request
   * @param {string} did - DID being adopted (must be the token issuer)
   * @param {string} hostname - this server's hostname (its did:web identity)
   * @param {{password?: unknown}} body
   * @returns {Promise<Response|null>}
   */
  async function authorizeCreateAccount(request, did, hostname, body) {
    const bearer = (request.headers.get('authorization') || '').match(
      /^Bearer\s+(.+)$/i,
    );
    if (bearer) {
      const invalidToken = () =>
        Response.json(
          {
            error: 'AuthenticationRequired',
            message: 'Invalid service token for account migration',
          },
          { status: 401 },
        );

      const claims = decodeJwtClaims(bearer[1]);
      const now = Math.floor(Date.now() / 1000);
      const claimsOk =
        claims &&
        claims.iss === did &&
        claims.aud === `did:web:${hostname}` &&
        claims.lxm === 'com.atproto.server.createAccount' &&
        typeof claims.exp === 'number' &&
        claims.exp > now;
      if (!claimsOk) return invalidToken();

      // With a verifier and resolver injected, the token's signature is
      // checked against the issuer's atproto key — not just its claims. Fail
      // closed: a token we cannot verify (unresolvable DID, missing key,
      // unsupported curve, bad signature) is rejected. Without that
      // infrastructure (a core-only deployment) the claims stand alone.
      if (verifier && didResolver) {
        const verified = await verifyServiceTokenSignature(bearer[1], did);
        if (!verified) return invalidToken();
      }
      return null;
    }

    // Manual fallback: the account password.
    if (await verifyAccountPassword(body.password)) return null;

    return Response.json(
      {
        error: 'AuthenticationRequired',
        message:
          'Account migration requires a service token from the current PDS, ' +
          'or the configured account password',
      },
      { status: 401 },
    );
  }

  /**
   * Verify a service token's signature against the atproto verification method
   * published in its issuer's DID document. Any failure — unresolvable DID,
   * no atproto key, unsupported curve, an `alg` the key cannot produce, or a
   * bad signature — returns false.
   *
   * @param {string} jwt - the full service token
   * @param {string} iss - the DID that must have signed it
   * @returns {Promise<boolean>}
   */
  async function verifyServiceTokenSignature(jwt, iss) {
    if (!didResolver || !verifier) return false;
    try {
      const parts = jwt.split('.');
      if (parts.length !== 3) return false;

      // Reads past any held answer. This authorizes adopting a DID, it runs
      // once per account, and a held document can name a key the issuer has
      // rotated away from — one the account no longer controls.
      const didDoc = await didResolver(iss, { forceRefresh: true });
      const methods = /** @type {any} */ (didDoc)?.verificationMethod;
      if (!Array.isArray(methods)) return false;
      const atproto = methods.find(
        (/** @type {{id?: string}} */ m) =>
          typeof m?.id === 'string' && m.id.endsWith('#atproto'),
      );
      const didKey = atproto?.publicKeyMultibase;
      if (!didKey) return false;

      // The signature verifies under the resolved key whatever the header
      // claims. The reference implementation refuses a header naming another
      // algorithm, so a token this server accepts is one the rest of the
      // ecosystem accepts too.
      const { alg } = JSON.parse(
        new TextDecoder().decode(base64UrlDecode(parts[0])),
      );
      const expected =
        parseDidKey(didKey).curve === 'secp256k1' ? 'ES256K' : 'ES256';
      if (alg !== expected) return false;

      const signingInput = new TextEncoder().encode(`${parts[0]}.${parts[1]}`);
      const sig = base64UrlDecode(parts[2]);
      return await verifier.verify(didKey, signingInput, sig);
    } catch {
      // Resolution error, unsupported curve with no injected verifier, or a
      // malformed signature all land here — none of them authorize the call.
      return false;
    }
  }

  /**
   * POST /account/onboarding/create — first-run signup of a brand-new identity.
   *
   * This is scripts/setup.js moved server-side: generate a signing key, build
   * and sign the genesis PLC operation, register the did:plc with the
   * directory, and initialize the server. Only for an empty single-tenant
   * server, and gated on the configured account password (PDS_PASSWORD) — the
   * operator's secret — since a fresh identity carries no other proof of who
   * may claim the box. Returns a cookie session so the browser lands in the
   * account console.
   *
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleOnboardingCreate(request, url) {
    if (readOnly) return readOnlyError();
    if (!isSameOriginPost(request, url)) {
      return Response.json(
        { error: 'Forbidden', message: 'Cross-origin request refused' },
        { status: 403 },
      );
    }

    let body;
    try {
      body = await request.json();
    } catch {
      return Response.json(
        { error: 'InvalidRequest', message: 'Invalid request body' },
        { status: 400 },
      );
    }

    if (await getDid()) {
      return Response.json(
        {
          error: 'InvalidRequest',
          message: 'This server already hosts an account',
        },
        { status: 400 },
      );
    }

    // Operator gate: only whoever set PDS_PASSWORD may claim an empty server.
    if (!accountPassword) {
      return Response.json(
        {
          error: 'AuthenticationRequired',
          message:
            'Password login is not configured on this server. Set PDS_PASSWORD.',
        },
        { status: 401 },
      );
    }
    if (body.password !== accountPassword) {
      return Response.json(
        { error: 'AuthenticationRequired', message: 'Invalid server password' },
        { status: 401 },
      );
    }

    const hostname = requestHostname(request);
    // A bare handle defaults to the server's own hostname (the self-verifying
    // case). Anything else is taken as given and baked into the genesis op;
    // an external domain simply reads as unverified until its DNS is set. A
    // trailing :port is stripped — it can ride along from a local dev host but
    // is never part of a handle.
    const requested = (
      typeof body.handle === 'string' && body.handle.trim()
        ? body.handle.trim()
        : hostname
    )
      .toLowerCase()
      .replace(/^@/, '')
      .replace(/:\d+$/, '');
    if (!isValidHandle(requested)) {
      return Response.json(
        {
          error: 'InvalidHandle',
          message: `"${requested}" is not a valid handle. Use a domain like you.example.com.`,
        },
        { status: 400 },
      );
    }

    // Mint the identity and its genesis PLC operation.
    const { stored, didKey, signingKey } = await generateSigningKey();
    const kemKey = await generateKemKey();
    const operation = await signPlcOperationObject(
      buildPlcOperation({
        didKey,
        handle: requested,
        pdsEndpoint: `https://${hostname}`,
        rotationKeys: [didKey],
        kemDidKey: kemKey.didKey,
      }),
      signingKey,
    );
    const hash = new Uint8Array(
      await crypto.subtle.digest(
        'SHA-256',
        /** @type {BufferSource} */ (cborEncodeDagCbor(operation)),
      ),
    );
    const did = `did:plc:${base32Encode(hash.slice(0, 15))}`;

    // Publish it to the PLC directory before touching local state, so a
    // rejected registration leaves the server still empty and retryable.
    let plcRes;
    try {
      plcRes = await fetch(`${plcUrl}/${encodeURIComponent(did)}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(operation),
      });
    } catch (err) {
      return Response.json(
        {
          error: 'PlcRegistrationFailed',
          message: `Could not reach the PLC directory: ${err instanceof Error ? err.message : String(err)}`,
        },
        { status: 502 },
      );
    }
    if (!plcRes.ok) {
      const detail = await plcRes.text().catch(() => '');
      return Response.json(
        {
          error: 'PlcRegistrationFailed',
          message: `PLC directory rejected the identity (${plcRes.status}): ${detail}`,
        },
        { status: 502 },
      );
    }

    // Adopt the identity locally (mirrors handleInit's bootstrap).
    await actorStorage.setDid(did);
    await actorStorage.setPrivateKey(stored);
    await actorStorage.setKemKey(kemKey.privateKey);
    await actorStorage.setHandle(requested);
    if (typeof body.email === 'string' && body.email) {
      await actorStorage.setEmail({
        address: body.email,
        verified: false,
      });
    }
    await sharedStorage.putActor(did, requested, Date.now());
    setCachedIdentity(null);
    await emitEvent('identity', { handle: requested });
    await emitEvent('account', { active: true });
    await notifyRelay(did);

    const token = await createAccountJwt(did, jwtSecret, ACCOUNT_SESSION_TTL);
    return new Response(JSON.stringify({ ok: true, did, handle: requested }), {
      status: 200,
      headers: {
        'Content-Type': 'application/json',
        'Cache-Control': 'no-store',
        'Set-Cookie': accountCookie(token, url.protocol === 'https:'),
      },
    });
  }

  /**
   * com.atproto.server.deleteSession - End the session its refresh token names
   * @param {Request} request
   * @returns {Promise<Response>}
   */
  async function handleDeleteSession(request) {
    await drainRequestBody(request);

    const match = (request.headers.get('authorization') || '').match(
      /^Bearer\s+(.+)$/i,
    );
    if (!match) {
      return Response.json(
        { error: 'AuthenticationRequired', message: 'Refresh token required' },
        { status: 401 },
      );
    }

    let payload;
    try {
      payload = await verifyRefreshJwt(match[1], jwtSecret);
    } catch {
      return Response.json(
        { error: 'InvalidToken', message: 'Invalid refresh token' },
        { status: 401 },
      );
    }

    // Succeeds either way: the caller asked for this session to be over, and
    // on a server with no registry it already is as far as anything here can
    // tell. Reporting failure would only invite a retry loop.
    if (sessions.sessionsAvailable() && payload.jti) {
      await sessions.revokeSessionChain(payload.sub, payload.jti);
    }
    return Response.json({});
  }

  /**
   * com.atproto.server.getSession
   * @param {Request} _request
   * @param {URL} _url
   * @param {import('../pds.js').Auth|null} auth
   * @returns {Promise<Response>}
   */
  async function handleGetSession(_request, _url, auth) {
    if (!auth) {
      return Response.json(
        { error: 'AuthenticationRequired', message: 'Authentication required' },
        { status: 401 },
      );
    }

    const handle = await actorStorage.getHandle();
    return Response.json({
      did: auth.did,
      handle: handle || auth.did,
      ...(await emailSessionFields(auth.scope)),
    });
  }

  /**
   * Look up an app password by its hash.
   * @param {string} did
   * @param {string} password
   * @returns {Promise<import('../ports.js').AppPasswordRecord|null>}
   */
  async function verifyAppPassword(did, password) {
    if (!sharedStorage.getAppPasswordByHash) return null;
    const hash = await hashAppPassword(did, password);
    return sharedStorage.getAppPasswordByHash(did, hash);
  }

  /**
   * App passwords may not manage app passwords. Otherwise one leaked password
   * mints replacements for itself and revoking it achieves nothing.
   * @param {import('../pds.js').Auth} auth
   * @returns {Response|null} An error response, or null when allowed
   */
  function requireFullSession(auth) {
    if (isAppPasswordScope(auth.scope)) {
      return Response.json(
        {
          error: 'InvalidToken',
          message: 'App passwords cannot manage app passwords',
        },
        { status: 403 },
      );
    }
    return null;
  }

  /**
   * com.atproto.server.createAppPassword
   * @param {Request} request
   * @param {URL} _url
   * @param {import('../pds.js').Auth|null} auth
   * @returns {Promise<Response>}
   */
  async function handleCreateAppPassword(request, _url, auth) {
    if (readOnly) return readOnlyError();
    if (!auth) {
      return Response.json(
        { error: 'AuthenticationRequired', message: 'Authentication required' },
        { status: 401 },
      );
    }
    const denied = requireFullSession(auth);
    if (denied) return denied;

    const { name, privileged = false } = await request.json();
    if (!name || typeof name !== 'string') {
      return Response.json(
        { error: 'InvalidRequest', message: 'name is required' },
        { status: 400 },
      );
    }

    const password = generateAppPassword();
    const hash = await hashAppPassword(auth.did, password);
    const createdAt = new Date().toISOString();

    const stored = await sharedStorage.putAppPassword(
      auth.did,
      name,
      hash,
      Boolean(privileged),
      createdAt,
    );
    if (!stored) {
      return Response.json(
        {
          error: 'InvalidRequest',
          message: `App password "${name}" already exists`,
        },
        { status: 400 },
      );
    }

    // The only time the password is ever returned. Nothing but its hash is
    // stored, so a caller that loses this cannot recover it.
    return Response.json({
      name,
      password,
      createdAt,
      privileged: Boolean(privileged),
    });
  }

  /**
   * com.atproto.server.listAppPasswords
   * @param {Request} _request
   * @param {URL} _url
   * @param {import('../pds.js').Auth|null} auth
   * @returns {Promise<Response>}
   */
  async function handleListAppPasswords(_request, _url, auth) {
    if (!auth) {
      return Response.json(
        { error: 'AuthenticationRequired', message: 'Authentication required' },
        { status: 401 },
      );
    }
    const denied = requireFullSession(auth);
    if (denied) return denied;

    const passwords = await sharedStorage.listAppPasswords(auth.did);
    return Response.json({ passwords });
  }

  /**
   * com.atproto.server.revokeAppPassword
   * @param {Request} request
   * @param {URL} _url
   * @param {import('../pds.js').Auth|null} auth
   * @returns {Promise<Response>}
   */
  async function handleRevokeAppPassword(request, _url, auth) {
    if (readOnly) return readOnlyError();
    if (!auth) {
      return Response.json(
        { error: 'AuthenticationRequired', message: 'Authentication required' },
        { status: 401 },
      );
    }
    const denied = requireFullSession(auth);
    if (denied) return denied;

    const { name } = await request.json();
    if (!name || typeof name !== 'string') {
      return Response.json(
        { error: 'InvalidRequest', message: 'name is required' },
        { status: 400 },
      );
    }

    const revoked = await sharedStorage.deleteAppPassword(auth.did, name);
    if (!revoked) {
      return Response.json(
        { error: 'InvalidRequest', message: `No app password named "${name}"` },
        { status: 400 },
      );
    }
    await sessions.revokeAppPasswordSessions(auth.did, name);

    return new Response(null, { status: 200 });
  }

  /**
   * com.atproto.server.refreshSession
   * @param {Request} request
   * @returns {Promise<Response>}
   */
  async function handleRefreshSession(request) {
    if (readOnly) return readOnlyError();

    const authHeader = request.headers.get('authorization');
    if (!authHeader) {
      return Response.json(
        { error: 'AuthenticationRequired', message: 'Refresh token required' },
        { status: 401 },
      );
    }

    const match = authHeader.match(/^Bearer\s+(.+)$/i);
    if (!match) {
      return Response.json(
        { error: 'InvalidToken', message: 'Invalid authorization header' },
        { status: 401 },
      );
    }

    try {
      const payload = await verifyRefreshJwt(match[1], jwtSecret);
      const did = payload.sub;

      // A revoked session's token still verifies — it is a signed JWT — so
      // the registry is what actually ends it. Servers whose storage has no
      // registry keep the old stateless behaviour.
      let session = null;
      let graceExpiresAt = null;
      if (sessions.sessionsAvailable()) {
        session = payload.jti
          ? await sharedStorage.getSession(payload.jti)
          : null;
        if (!session) {
          return Response.json(
            { error: 'InvalidToken', message: 'Session has been revoked' },
            { status: 401 },
          );
        }
        // A spent token stays redeemable for its grace window, then stops.
        graceExpiresAt = rotationGraceExpiry(session, Date.now());
        if (!graceExpiresAt) {
          return Response.json(
            { error: 'ExpiredToken', message: 'Refresh token has expired' },
            { status: 400 },
          );
        }
      }

      // Settle which id the replacement is minted under before minting it, so
      // that every path — a first rotation, a replay, and a rotation that lost
      // a race — issues the one token this session actually moved to.
      let nextJti;
      // Whether this request is the one that has to write the successor's row.
      let recordSuccessor = false;
      if (session?.nextJti) {
        // A replay. The successor stands on its own by now — it may even have
        // rotated again since — so it is reissued, not rewritten: writing it
        // back would drop the successor of its own rotation and leave two
        // live tokens where the session has one. Its row going missing means
        // the session was revoked out from under this token.
        const successor = await sharedStorage.getSession(session.nextJti);
        if (!successor) {
          return Response.json(
            { error: 'InvalidToken', message: 'Session has been revoked' },
            { status: 401 },
          );
        }
        nextJti = session.nextJti;
      } else if (session && graceExpiresAt) {
        const claimed = await sessions.spendSession(
          session,
          graceExpiresAt,
          generateRefreshTokenId(),
        );
        if (!claimed) {
          return Response.json(
            { error: 'InvalidToken', message: 'Session has been revoked' },
            { status: 401 },
          );
        }
        nextJti = claimed.nextJti;
        recordSuccessor = claimed.won;
      }

      // Reissue at the same access level. Dropping this would let an
      // app-password session refresh its way into a full-access one.
      const scope = payload.sessionScope || SCOPE_ACCESS;
      const accessJwt = await createAccessJwt(did, jwtSecret, 7200, scope);
      const refreshJwt = await createRefreshJwt(
        did,
        jwtSecret,
        REFRESH_TOKEN_TTL_SECONDS,
        scope,
        nextJti,
      );

      if (session && recordSuccessor) {
        await sessions.recordSession(refreshJwt, {
          did,
          label: session.label,
          scope,
          request,
          createdAt: session.createdAt,
        });
      }

      const handle = await actorStorage.getHandle();

      return Response.json({
        did,
        handle: handle || did,
        accessJwt,
        refreshJwt,
        ...(await emailSessionFields(scope)),
      });
    } catch (error) {
      // Wrong token type (access token used instead of refresh) is a bad request
      if (error.message.includes('Invalid token type')) {
        return Response.json(
          {
            error: 'InvalidRequest',
            message: 'Refresh token required, access token provided',
          },
          { status: 400 },
        );
      }
      return Response.json(
        { error: 'InvalidToken', message: error.message },
        { status: 401 },
      );
    }
  }

  /**
   * com.atproto.server.getServiceAuth
   * @param {Request} _request
   * @param {URL} url
   * @param {import('../pds.js').Auth|null} auth
   * @returns {Promise<Response>}
   */
  async function handleGetServiceAuth(_request, url, auth) {
    if (!auth) {
      return Response.json(
        { error: 'AuthenticationRequired', message: 'Authentication required' },
        { status: 401 },
      );
    }

    const aud = url.searchParams.get('aud');
    const lxm = url.searchParams.get('lxm');
    if (!aud || !lxm) {
      return Response.json(
        {
          error: 'InvalidRequest',
          message: 'aud and lxm parameters are required',
        },
        { status: 400 },
      );
    }

    // Granular tokens need an rpc: scope covering this method and audience
    const scopeError = checkRpcScope(
      auth,
      lxm,
      aud.includes('#') ? aud : `${aud}#atproto_pds`,
    );
    if (scopeError) return scopeError;

    const expParam = url.searchParams.get('exp');
    let exp;
    if (expParam !== null) {
      exp = Number(expParam);
      const badExpiration = (/** @type {string} */ message) =>
        Response.json({ error: 'BadExpiration', message }, { status: 400 });
      if (!Number.isFinite(exp)) {
        return badExpiration('exp must be a Unix timestamp in seconds');
      }
      const seconds = exp - Math.floor(Date.now() / 1000);
      if (seconds < 0) return badExpiration('expiration is in past');
      if (seconds > 3600) {
        return badExpiration(
          'cannot request a token with an expiration more than an hour in the future',
        );
      }
    }

    const did = await getDid();
    const signingKey = await getSigningKey();
    if (!did || !signingKey) {
      return Response.json(
        { error: 'InvalidRequest', message: 'PDS not initialized' },
        { status: 400 },
      );
    }

    // Service JWTs always carry the bare DID audience (proposal 0014 phase 1)
    const token = await createServiceJwt({
      iss: did,
      aud: aud.split('#')[0],
      lxm,
      signingKey,
      exp,
    });

    return Response.json({ token });
  }

  return {
    routes: {
      '/xrpc/com.atproto.server.describeServer': {
        handler: handleDescribeServer,
      },
      '/xrpc/com.atproto.server.createSession': {
        method: 'POST',
        handler: handleCreateSession,
      },
      // No `auth`: reachable on an uninitialized server, and it validates the
      // migration service token itself.
      '/xrpc/com.atproto.server.createAccount': {
        method: 'POST',
        handler: handleCreateAccount,
      },
      '/xrpc/com.atproto.server.getSession': {
        auth: 'required',
        handler: handleGetSession,
      },
      '/xrpc/com.atproto.server.getServiceAuth': {
        auth: 'required',
        handler: handleGetServiceAuth,
      },
      '/xrpc/com.atproto.server.createAppPassword': {
        method: 'POST',
        auth: 'required',
        handler: handleCreateAppPassword,
      },
      '/xrpc/com.atproto.server.listAppPasswords': {
        auth: 'required',
        handler: handleListAppPasswords,
      },
      '/xrpc/com.atproto.server.revokeAppPassword': {
        method: 'POST',
        auth: 'required',
        handler: handleRevokeAppPassword,
      },
      '/xrpc/com.atproto.server.deleteSession': {
        method: 'POST',
        handler: handleDeleteSession,
      },
      '/xrpc/com.atproto.server.refreshSession': {
        method: 'POST',
        handler: handleRefreshSession,
      },
      // First-run signup. No route auth: it runs before any account exists and
      // gates itself on the server password.
      '/account/onboarding/create': {
        method: 'POST',
        handler: handleOnboardingCreate,
      },
    },
  };
}