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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
3267 lines · 106.1 KB Raw History
// @pdsjs/core/handlers/account - The account surface: the server-rendered
// /account pages, the cookie session behind them, and the /account/api JSON
// endpoints that drive the mounted account application.

import {
  renderAccountPage,
  renderAppPasswordsPage,
  renderAppsPage,
  renderEmailPage,
  renderIdentityPage,
  renderPasskeysPage,
  renderRecordsPage,
  renderRepoPage,
  renderSessionsPage,
  renderSignInPage,
  renderSpacesPage,
} from '../account-ui.js';
import {
  generateAppPassword,
  hashAppPassword,
  isAppPasswordScope,
} from '../app-password.js';
import { createAccountJwt, verifyAccountJwt } from '../auth.js';
import { parseCarFile } from '../car.js';
import { hmacSha256, timingSafeEqual } from '../crypto.js';
import {
  EMAIL_CODE_TTL_MS,
  EMAIL_RESEND_COOLDOWN_MS,
  generateEmailCode,
  hashEmailCode,
  isValidEmail,
  readEmailState,
  renderVerificationEmail,
} from '../email.js';
import {
  drainRequestBody,
  htmlResponse,
  isSameOriginPost,
  readCookie,
} from '../http.js';
import { cborDecode, cidToString, tidToMs, toDisplayValue } from '../repo.js';
import {
  ACCOUNT_COOKIE,
  ACCOUNT_SESSION_TTL,
  accountCookie,
  accountSessionDueForRenewal,
} from '../session.js';
import { verifyAssertion, verifyRegistration } from '../webauthn.js';

// How many records a collection view renders before it says it is showing
// a prefix. Applies to both the repo and the space views.
const ACCOUNT_SPACE_RECORD_LIMIT = 25;

// Where the account page stops counting blobs and reports `N+` instead
const ACCOUNT_BLOB_COUNT_LIMIT = 1000;

// How many files the account Files page loads per request
const ACCOUNT_BLOB_PAGE_LIMIT = 30;

// The media kinds the Files page can filter by; mirrors blob-sql's bucketing
const BLOB_KINDS = ['image', 'video', 'audio', 'text', 'other'];

/**
 * How many apps the Apps page shows: one per authority domain, unioned across
 * the collections that hold records and the OAuth sessions in play. An app can
 * appear with data but no session (or the reverse), and several sessions of one
 * app collapse into a single row — so this is the row count, not the session
 * count. Mirrors nsidDomain/clientHost in the account UI's format helper.
 * @param {{name: string}[]} collections
 * @param {{clientId?: string}[]} sessions
 * @returns {number}
 */
function countAppDomains(collections, sessions) {
  const domains = new Set();
  for (const c of collections || []) {
    const parts = String(c.name).split('.');
    domains.add(parts.length < 2 ? String(c.name) : `${parts[1]}.${parts[0]}`);
  }
  for (const s of sessions || []) {
    const clientId = s.clientId || '';
    try {
      domains.add(new URL(clientId).host);
    } catch {
      domains.add(clientId);
    }
  }
  return domains.size;
}

/**
 * A lowercased hostname-looking string, or null. A port is allowed so a dev
 * client like `localhost:5173` still names a row.
 * @param {unknown} value
 * @returns {string|null}
 */
function plausibleHost(value) {
  if (typeof value !== 'string') return null;
  const host = value.trim().toLowerCase();
  return /^[a-z0-9][a-z0-9.:-]{0,252}$/.test(host) ? host : null;
}

// Where the update check asks for the newest published release, per channel.
// The lookup runs server-side and is cached in memory (see
// checkLatestVersion), so one request every few hours serves every browser
// session. Canary builds are published only to the artifacts site; both
// sources answer JSON with a `version` string.
const VERSION_CHECK_URLS = {
  stable: 'https://registry.npmjs.org/@pdsjs/core/latest',
  canary: 'https://artifacts.pdsjs.dev/canary/manifest.json',
};
// How long an answer stands before the source is asked again. A release is a
// rare event, so a stable answer keeps for hours. The canary channel moves
// with every push to main and its manifest is served with a five minute
// cache, so remembering one for longer than the artifact claims to be fresh
// reports a build that has already been replaced as the newest one.
const VERSION_CHECK_TTL = {
  stable: 6 * 60 * 60 * 1000,
  canary: 5 * 60 * 1000,
};
// An unreachable registry is retried sooner, so a transient outage does not
// hide a release for the whole cache window.
const VERSION_CHECK_RETRY = 15 * 60 * 1000;
const RELEASE_NOTES_URL =
  'https://tangled.org/chadtmiller.com/pds.js/blob/main/packages/core/CHANGELOG.md';
// A canary build has no changelog entry; its notes are the commit log.
const CANARY_NOTES_URL = 'https://tangled.org/chadtmiller.com/pds.js';

/**
 * Whether `candidate` is a newer release than `current`. Releases are dotted
 * numerics; a missing or unparseable part compares as 0, so a malformed
 * registry answer never announces an update.
 * @param {string} current
 * @param {string} candidate
 * @returns {boolean}
 */
function isNewerVersion(current, candidate) {
  const a = current.split('.').map(Number);
  const b = candidate.split('.').map(Number);
  for (let i = 0; i < Math.max(a.length, b.length); i++) {
    const x = b[i] || 0;
    const y = a[i] || 0;
    if (x !== y) return x > y;
  }
  return false;
}

/**
 * Whether the published `candidate` calls for an update from `current`.
 * Stable releases order by isNewerVersion. A version with a prerelease
 * suffix belongs to the canary channel, which moves with the main branch,
 * so there any difference is an update: the running build is behind main,
 * or it is a canary build on a server switched back to stable.
 * @param {string} current
 * @param {string|null} candidate
 * @returns {boolean}
 */
function updateAvailable(current, candidate) {
  if (!candidate || candidate === current) return false;
  if (current.includes('-') || candidate.includes('-')) return true;
  return isNewerVersion(current, candidate);
}

/**
 * These are structural types. Thus a member does not name the module that owns
 * the function you give it.
 *
 * @typedef {Object} AccountContext
 * @property {import('../ports.js').ActorStoragePort} actorStorage
 * @property {import('../ports.js').SharedStoragePort} sharedStorage
 * @property {ReturnType<typeof import('../session.js').createSessionRegistry>} sessions
 * @property {ReturnType<typeof import('../passkey.js').createPasskeyCeremony>} passkeys
 * @property {string} jwtSecret
 * @property {string} [hostname]
 * @property {string} plcUrl
 * @property {(candidate: unknown) => Promise<boolean>} verifyAccountPassword
 * @property {() => Promise<boolean>} passwordConfigured
 * @property {(next: string) => Promise<boolean>} changeAccountPassword - False when the storage cannot hold a password
 * @property {() => boolean} passwordChangeable
 * @property {boolean} readOnly
 * @property {import('../ports.js').EmailPort|null} emailer
 * @property {import('../ports.js').SpaceBrowserPort|null} spaceBrowser
 * @property {Record<string, boolean>} [extensionFeatures] - Feature flags the mounted extensions report, merged into the overview's `features` after the built-in ones
 * @property {Array<(count: (collection: string) => number) => Record<string, number>>} [extensionStats] - Counts the mounted extensions report, merged into the overview's `stats` after the built-in ones
 * @property {string|null} accountApp - Built account application, as one HTML document
 * @property {import('../ports.js').LexiconResolverPort} [lexiconResolver]
 * @property {string} version - The release this server runs
 * @property {string|null} [updateUrl] - Where a person updates this deployment; shown when a newer release is published
 * @property {string|null} [scriptName] - The worker script name this deployment runs as; carried on the update link so the update page can find the worker behind a custom domain
 * @property {() => Promise<string|null>} getDid
 * @property {(request: Request) => Promise<any|Response>} readJsonBounded
 * @property {(active: boolean) => Promise<void>} setAccountActive
 * @property {(fallbackHostname?: string|null) => Promise<{rotationKeys: string[], alsoKnownAs: string[], verificationMethods: {atproto: string}, services: {atproto_pds: {type: string, endpoint: string}}}|null>} getRecommendedDidCredentials
 * @property {() => Promise<number>} relinkBlobRecords
 * @property {(requested: unknown, hostname?: string|null) => Promise<{handle: string, changed: boolean}>} renameHandle
 * @property {() => boolean} backupsAvailable
 */

/**
 * @param {AccountContext} ctx
 * @returns {{
 *   routes: import('../pds.js').Routes,
 *   readAccountSession: (request: Request) => Promise<string|null>,
 *   renewAccountSession: (request: Request) => Promise<string|null>,
 *   readAccountUiState: () => Promise<import('../ports.js').AccountUiState>,
 *   accountAppResponse: (bootstrap?: Object|null, status?: number) => Response|null,
 *   accountApi: (request: Request, url: URL, handler: (did: string, body: any) => Promise<unknown>, options?: {write?: boolean, raw?: boolean}) => Promise<Response>,
 *   listConnectedApps: (did: string) => Promise<Array<{clientId: string, scope: string, authorizedAt: string|null, lastAccessedAt: string|null, userAgent: string|null, keyThumbprint: string|null, sessionId: string|null}>>,
 * }}
 */
export function createAccountHandlers(ctx) {
  const {
    actorStorage,
    sharedStorage,
    sessions,
    passkeys,
    jwtSecret,
    hostname,
    plcUrl,
    verifyAccountPassword,
    passwordConfigured,
    changeAccountPassword,
    passwordChangeable,
    readOnly,
    emailer,
    spaceBrowser,
    extensionFeatures = {},
    extensionStats = [],
    accountApp,
    lexiconResolver,
    version,
    updateUrl,
    scriptName,
    getDid,
    readJsonBounded,
    setAccountActive,
    getRecommendedDidCredentials,
    relinkBlobRecords,
    renameHandle,
    backupsAvailable,
  } = ctx;

  /**
   * The claims of the session cookie this request carries, or null when it
   * carries none, or one this server did not sign.
   * @param {Request} request
   * @returns {Promise<import('../auth.js').JwtPayload|null>}
   */
  async function readAccountCookie(request) {
    const token = readCookie(request, ACCOUNT_COOKIE);
    if (!token) return null;
    try {
      const payload = await verifyAccountJwt(token, jwtSecret);
      // A session outlives nothing: if the PDS has since been initialized with
      // a different DID, its cookies are not this account's.
      const did = await getDid();
      return did && payload.sub === did ? payload : null;
    } catch {
      return null;
    }
  }

  /**
   * The DID of the account whose cookie session this request carries, or null
   * when there is no valid session.
   * @param {Request} request
   * @returns {Promise<string|null>}
   */
  async function readAccountSession(request) {
    const payload = await readAccountCookie(request);
    return payload ? /** @type {string} */ (payload.sub) : null;
  }

  /**
   * A replacement session cookie for a request whose own cookie has spent a
   * day of its life. Null when the request carries no valid session, or one
   * still fresh. `PersonalDataServer.fetch` sets it on the response, so an
   * account page in use never reaches the expiry and asks for the password
   * again.
   * @param {Request} request
   * @returns {Promise<string|null>}
   */
  async function renewAccountSession(request) {
    const payload = await readAccountCookie(request);
    const now = Math.floor(Date.now() / 1000);
    if (
      typeof payload?.exp !== 'number' ||
      !accountSessionDueForRenewal(payload.exp, now)
    )
      return null;
    const token = await createAccountJwt(
      /** @type {string} */ (payload.sub),
      jwtSecret,
      ACCOUNT_SESSION_TTL,
    );
    return accountCookie(token, new URL(request.url).protocol === 'https:');
  }

  /**
   * Display name and avatar from the account's own app.bsky.actor.profile
   * record. Read from this repo rather than looked up at a public AppView:
   * the account screen must show this account, not whichever account some
   * third party knows by the same handle.
   * @param {string} did
   * @returns {Promise<{displayName: string|null, avatarUrl: string|null}>}
   */
  async function readAccountProfile(did) {
    const empty = { displayName: null, avatarUrl: null };
    try {
      const stored = await actorStorage.getRecord(
        `at://${did}/app.bsky.actor.profile/self`,
      );
      if (!stored) return empty;

      const value = cborDecode(stored.value);
      const ref = value?.avatar?.ref;
      // Records written over XRPC keep the JSON `{$link}` form; ones loaded
      // from a CAR carry decoded tag-42 bytes.
      const cid =
        typeof ref?.$link === 'string'
          ? ref.$link
          : ref instanceof Uint8Array
            ? cidToString(ref)
            : null;

      return {
        displayName:
          typeof value?.displayName === 'string' ? value.displayName : null,
        avatarUrl: cid
          ? `/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`
          : null,
      };
    } catch {
      // A profile record that will not decode is not worth a broken page
      return empty;
    }
  }

  /**
   * Count the repository's records per collection. Record keys are
   * `collection/rkey`, so the collection is everything up to the first slash.
   * @returns {Promise<Array<{name: string, count: number}>>} Sorted by name
   */
  async function countRecordsByCollection() {
    /** @type {Map<string, number>} */
    const counts = new Map();
    for (const record of await actorStorage.listAllRecords()) {
      const collection = record.key.split('/')[0];
      counts.set(collection, (counts.get(collection) || 0) + 1);
    }
    return [...counts]
      .map(([name, count]) => ({ name, count }))
      .sort((a, b) => a.name.localeCompare(b.name));
  }

  /**
   * Per-collection counts, a creation-time histogram, and the latest write
   * time, from a single pass over the repo. Each record is bucketed by the
   * timestamp its rkey (a TID) encodes, across a fixed trailing window, so the
   * Apps page can draw an activity sparkline over the whole window rather than
   * only the newest firehose events. Records whose rkey is not a TID (e.g.
   * `self`) still count toward the totals but contribute no point and no time.
   * `lastRecordAt` is unbounded by the window, so a row can label how recently
   * an app was active even when its newest write predates the sparkline. Only
   * collections with activity in the window appear in `series`, to keep the
   * payload small.
   * @param {{windowMs?: number, buckets?: number, now?: number}} [opts]
   * @returns {Promise<{collections: {name: string, count: number, lastRecordAt: number|null}[], activity: {start: number, end: number, buckets: number, series: Record<string, number[]>}}>}
   */
  async function countRecordsWithActivity({
    windowMs = 365 * 86_400_000,
    buckets = 52,
    now = Date.now(),
  } = {}) {
    const start = now - windowMs;
    /** @type {Map<string, number>} */
    const counts = new Map();
    /** @type {Map<string, number>} */
    const lastAt = new Map();
    /** @type {Map<string, number[]>} */
    const series = new Map();
    for (const record of await actorStorage.listAllRecords()) {
      const slash = record.key.indexOf('/');
      const collection = slash >= 0 ? record.key.slice(0, slash) : record.key;
      counts.set(collection, (counts.get(collection) || 0) + 1);
      const at = tidToMs(slash >= 0 ? record.key.slice(slash + 1) : '');
      // Newest write per collection, tracked before the window filter so it
      // reflects the true last activity even for records older than the window.
      if (at !== null && at <= now && at > (lastAt.get(collection) || 0)) {
        lastAt.set(collection, at);
      }
      if (at === null || at < start || at > now) continue;
      let arr = series.get(collection);
      if (!arr) {
        arr = new Array(buckets).fill(0);
        series.set(collection, arr);
      }
      const i = Math.min(
        buckets - 1,
        Math.floor(((at - start) / windowMs) * buckets),
      );
      arr[i] += 1;
    }
    const collections = [...counts]
      .map(([name, count]) => ({
        name,
        count,
        lastRecordAt: lastAt.get(name) ?? null,
      }))
      .sort((a, b) => a.name.localeCompare(b.name));
    return {
      collections,
      activity: {
        start,
        end: now,
        buckets,
        series: Object.fromEntries(series),
      },
    };
  }

  /**
   * Count stored blobs, stopping at ACCOUNT_BLOB_COUNT_LIMIT. A full count
   * would walk every page of a repository that may hold thousands; the page
   * reports the cap as `N+` rather than paying for an exact number.
   * @returns {Promise<{count: number, truncated: boolean}>}
   */
  async function countBlobs() {
    let count = 0;
    let cursor = null;
    while (count < ACCOUNT_BLOB_COUNT_LIMIT) {
      /** @type {{cids: string[], cursor: string|null}} */
      const page = await actorStorage.listBlobs(cursor, 100);
      count += page.cids.length;
      cursor = page.cursor;
      if (!cursor || page.cids.length === 0) {
        return { count, truncated: false };
      }
    }
    return { count: ACCOUNT_BLOB_COUNT_LIMIT, truncated: true };
  }

  /**
   * A handle for one OAuth session that is safe to put in a page.
   *
   * The storage key for a session is the refresh token itself, so it can
   * never be rendered: a hidden form field carrying it would hand a live
   * credential to anything that reads the page. This is an HMAC of it under
   * the server secret — stable, opaque, and useless to anyone who cannot
   * already act as this account.
   * @param {string} tokenId - The refresh token
   * @returns {Promise<string>}
   */
  async function oauthSessionId(tokenId) {
    return (await hmacSha256(`oauth-session:${tokenId}`, jwtSecret)).slice(
      0,
      22,
    );
  }

  /**
   * Apps holding an OAuth session, most recently active first. Refresh tokens
   * are stored one per session, so a client that reauthorized appears once per
   * session. lastAccessedAt tracks the session's most recent refresh; access
   * tokens are verified statelessly, so it moves on refresh, not on every
   * request. Older tokens have no updatedAt, so createdAt stands in.
   * @param {string} did
   * @returns {Promise<Array<{clientId: string, scope: string, authorizedAt: string|null, lastAccessedAt: string|null, userAgent: string|null, keyThumbprint: string|null, sessionId: string|null}>>}
   */
  async function listConnectedApps(did) {
    if (!sharedStorage.listOAuthTokensByDid) return [];
    // A token just rotated away is the same session as the one that replaced
    // it, kept only to answer a replay: it is not a session of its own.
    const tokens = (await sharedStorage.listOAuthTokensByDid(did)).filter(
      (token) => !token.rotatedTo,
    );
    const apps = await Promise.all(
      tokens.map(async (token) => {
        const lastAccessed = token.updatedAt ?? token.createdAt;
        return {
          clientId: token.clientId || 'unknown client',
          scope: token.scope || 'atproto',
          authorizedAt: token.createdAt
            ? new Date(token.createdAt).toISOString().slice(0, 10)
            : null,
          lastAccessedAt: lastAccessed
            ? new Date(lastAccessed).toISOString()
            : null,
          userAgent: token.userAgent || null,
          // Enough of the DPoP thumbprint to tell two sessions apart
          keyThumbprint: token.dpopJkt ? token.dpopJkt.slice(0, 12) : null,
          // Absent on a storage adapter that does not return its keys, which
          // costs the row its revoke button rather than breaking the page
          sessionId: token.tokenId ? await oauthSessionId(token.tokenId) : null,
          sortKey: lastAccessed || 0,
        };
      }),
    );
    return apps
      .sort((a, b) => b.sortKey - a.sortKey)
      .map(({ sortKey: _sortKey, ...app }) => app);
  }

  /**
   * Revoke one OAuth session by the handle listConnectedApps rendered.
   * @param {string} did
   * @param {string} sessionId
   * @returns {Promise<{clientId: string}|null>} The app revoked, or null when
   *   no session matched
   */
  async function revokeOAuthSession(did, sessionId) {
    if (
      !sharedStorage.listOAuthTokensByDid ||
      !sharedStorage.deleteOAuthToken
    ) {
      return null;
    }

    for (const token of await sharedStorage.listOAuthTokensByDid(did)) {
      if (!token.tokenId) continue;
      const candidate = await oauthSessionId(token.tokenId);
      if (!(await timingSafeEqual(candidate, sessionId))) continue;

      await sharedStorage.deleteOAuthToken(token.tokenId);
      return { clientId: token.clientId || 'unknown client' };
    }
    return null;
  }

  /**
   * @param {string} did
   * @returns {Promise<Array<import('../ports.js').AppPasswordRecord>>}
   */
  async function listAccountAppPasswords(did) {
    if (!sharedStorage.listAppPasswords) return [];
    return sharedStorage.listAppPasswords(did);
  }

  /**
   * The signed-in DID, or a response to send instead — the sign-in page for a
   * page request, and for a form post the same page carrying an error.
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<{did: string}|{response: Response}>}
   */
  async function requireAccountSession(request, url) {
    const did = await readAccountSession(request);
    if (did) return { did };
    return {
      response: await signInResponse(url, {
        error: 'Your session has expired. Sign in again.',
        status: 401,
      }),
    };
  }

  /**
   * The account application, when this deployment mounted one. It renders
   * every section client-side against the JSON API, so it is served for any
   * section path and routes within itself.
   * @param {Object|null} [bootstrap] - Pre-auth state injected for the app to read before mounting
   * @param {number} [status] - HTTP status for the response
   * @returns {Response|null}
   */
  function accountAppResponse(bootstrap = null, status = 200) {
    if (!accountApp) return null;
    let html = accountApp;
    if (bootstrap) {
      // A classic script in <head> runs before the deferred app module, so the
      // app reads its pre-auth mode before mounting. `<` is escaped so no value
      // can close the script element.
      const data = JSON.stringify(JSON.stringify(bootstrap)).replace(
        /</g,
        '\\u003c',
      );
      html = html.replace(
        '</head>',
        `<script>window.__PDS__=JSON.parse(${data})</script></head>`,
      );
    }
    return new Response(html, {
      status,
      headers: {
        'Content-Type': 'text/html; charset=utf-8',
        'Cache-Control': 'no-store',
        'Referrer-Policy': 'same-origin',
      },
    });
  }

  /**
   * The sign-in screen: the account app in sign-in mode when one is mounted,
   * and the server-rendered form otherwise. A single-account server knows its
   * one identity, so the page names the account and asks for the password.
   * @param {URL} url
   * @param {{error?: string, status?: number}} [opts]
   * @returns {Promise<Response>}
   */
  async function signInResponse(url, { error = '', status = 200 } = {}) {
    const passkeysAvailable = passkeys.passkeysAvailable();
    const account = (await actorStorage.getHandle()) ?? (await getDid());
    if (accountApp) {
      // Non-null: accountAppResponse only returns null when accountApp is unset.
      return /** @type {Response} */ (
        accountAppResponse(
          {
            mode: 'signin',
            hostname: url.host,
            error,
            account,
            passkeys: passkeysAvailable,
          },
          status,
        )
      );
    }
    return htmlResponse(
      renderSignInPage({
        hostname: url.host,
        error,
        account,
        passkeys: passkeysAvailable,
      }),
      status,
    );
  }

  /**
   * GET /account - Account home, or the sign-in form when signed out
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountPage(request, url) {
    // Onboarding runs until the account is live on this server: a fresh empty
    // server (no DID), or a migration still in progress (the account exists but
    // is deactivated — createAccount lands it deactivated until the move
    // finishes). Either way the wizard needs to be reachable to start or resume
    // the move, rather than a sign-in form. Needs the account app; without it,
    // fall through to the server-rendered "not initialized" sign-in page.
    const accountDid = await getDid();
    const notYetLive =
      !accountDid || (await actorStorage.getAccountStatus()) === 'deactivated';
    if (notYetLive && accountApp) {
      // Non-null: accountAppResponse only returns null when accountApp is unset.
      return /** @type {Response} */ (
        accountAppResponse({
          // hostname, not host: a handle never carries a port, and this value
          // seeds the handle field.
          mode: 'onboard',
          hostname: url.hostname,
          plcUrl: plcUrl,
        })
      );
    }

    const did = await readAccountSession(request);
    if (!did) {
      return signInResponse(url);
    }

    const { displayName, avatarUrl } = await readAccountProfile(did);
    const collections = await countRecordsByCollection();
    const blobs = await countBlobs();
    const sessions = await listConnectedApps(did);

    return htmlResponse(
      renderAccountPage({
        hostname: url.host,
        did,
        handle: await actorStorage.getHandle(),
        status: (await actorStorage.getAccountStatus()) || 'active',
        displayName,
        avatarUrl,
        stats: {
          records: collections.reduce((sum, c) => sum + c.count, 0),
          blobs: blobs.count,
          apps: countAppDomains(collections, sessions),
          appPasswords: (await listAccountAppPasswords(did)).length,
        },
      }),
    );
  }

  /**
   * The sessions page, carrying the outcome of a revoke.
   * @param {URL} url
   * @param {string} did
   * @param {{error?: string, notice?: string}} [result]
   * @param {number} [status=200]
   * @returns {Promise<Response>}
   */
  async function sessionsResponse(url, did, result = {}, status = 200) {
    const liveSessions = await sessions.listLiveSessions(did);

    return htmlResponse(
      renderSessionsPage({
        hostname: url.host,
        sessions: liveSessions
          .map((session) => ({
            jti: session.jti,
            label: session.label,
            userAgent: session.userAgent,
            createdAt: session.createdAt.slice(0, 10),
            refreshedAt: session.refreshedAt
              ? session.refreshedAt.slice(0, 10)
              : null,
            appPassword: isAppPasswordScope(session.scope),
          }))
          .sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
        supported: sessions.sessionsAvailable(),
        readOnly: readOnly,
        ...result,
      }),
      status,
    );
  }

  /**
   * GET /account/sessions - Logins opened with a password
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountSessions(request, url) {
    const session = await requireAccountSession(request, url);
    if ('response' in session) return session.response;
    return sessionsResponse(url, session.did);
  }

  /**
   * POST /account/sessions/revoke - End one login
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountSessionRevoke(request, url) {
    const params = new URLSearchParams(await request.text());
    if (!isSameOriginPost(request, url)) {
      return new Response('Forbidden', { status: 403 });
    }

    const session = await requireAccountSession(request, url);
    if ('response' in session) return session.response;
    const { did } = session;

    if (readOnly) {
      return sessionsResponse(
        url,
        did,
        { error: 'This PDS is read-only.' },
        403,
      );
    }

    const jti = params.get('jti') || '';
    // Looked up before deleting so one account cannot end another's session
    const target = sessions.sessionsAvailable()
      ? await sharedStorage.getSession(jti)
      : null;
    if (!target || target.did !== did) {
      return sessionsResponse(
        url,
        did,
        { error: 'That session no longer exists.' },
        400,
      );
    }

    await sessions.revokeSessionChain(did, jti);
    return sessionsResponse(url, did, {
      notice:
        'Session revoked. Its access token stops working when it expires, within two hours.',
    });
  }

  // ── JSON API, for the account app ─────────────────────────────────────

  /**
   * Answer a JSON API request, or say why not. Every route below has already
   * been matched, so this only decides authentication and shape.
   * @param {Request} request
   * @param {URL} url
   * @param {(did: string, body: any) => Promise<unknown>} handler
   * @param {{write?: boolean, raw?: boolean}} [options] - `raw` leaves the request body unread for a handler that streams it
   * @returns {Promise<Response>}
   */
  async function accountApi(request, url, handler, options = {}) {
    const json = (/** @type {unknown} */ body, /** @type {number} */ status) =>
      new Response(JSON.stringify(body), {
        status,
        headers: {
          'Content-Type': 'application/json',
          'Cache-Control': 'no-store',
        },
      });

    let body = {};
    if (request.method === 'POST') {
      // A raw handler reads the request itself, so the body stays unread and
      // a file upload is never buffered as JSON.
      if (options.raw) {
        if (!isSameOriginPost(request, url)) {
          return json({ error: 'Forbidden' }, 403);
        }
      } else {
        const parsed = await readJsonBounded(request);
        if (parsed instanceof Response) return parsed;
        body = parsed;
        if (!isSameOriginPost(request, url)) {
          return json({ error: 'Forbidden' }, 403);
        }
      }
    }

    const did = await readAccountSession(request);
    if (!did) return json({ error: 'Not signed in' }, 401);
    if (options.write && readOnly) {
      return json({ error: 'This PDS is read-only' }, 403);
    }

    try {
      const result = await handler(did, body);
      // A handler that must set a header of its own answers directly
      return result instanceof Response ? result : json(result, 200);
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      // A handler throws to reject a request the caller can fix; anything
      // unexpected still reaches the outer catch as a 500.
      return json({ error: message }, 400);
    }
  }

  // Last answer from the update check, per channel, kept in memory only: a
  // restarted server simply asks again on the next settings visit.
  /** @type {Record<string, {version: string|null, checkedAt: number}>} */
  const latestVersion = {};

  /**
   * The newest version published on a channel, cached in memory. A source
   * that cannot be reached reads as "no known update" (version null) rather
   * than an error, and is retried on a shorter clock than a successful
   * answer.
   * @param {'stable'|'canary'} channel
   * @returns {Promise<{version: string|null, checkedAt: number}>}
   */
  async function checkLatestVersion(channel) {
    const now = Date.now();
    const cached = latestVersion[channel];
    const ttl = cached?.version
      ? VERSION_CHECK_TTL[channel]
      : VERSION_CHECK_RETRY;
    if (cached && now - cached.checkedAt < ttl) {
      return cached;
    }

    /** @type {string|null} */
    let published = null;
    try {
      const response = await fetch(VERSION_CHECK_URLS[channel], {
        signal: AbortSignal.timeout(5_000),
      });
      if (response.ok) {
        const manifest = /** @type {{version?: unknown}} */ (
          await response.json()
        );
        if (typeof manifest.version === 'string') published = manifest.version;
      }
    } catch {
      // Timeout or network failure; the null result carries the meaning.
    }
    latestVersion[channel] = { version: published, checkedAt: now };
    return latestVersion[channel];
  }

  /**
   * GET /account/api/version - The release this server runs, the newest one
   * published on the channel it follows, and the channel itself. The lookup
   * happens server-side and is cached, so a browser session costs at most
   * one same-origin request and the release source sees no visitor traffic.
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleApiVersion(request, url) {
    return accountApi(request, url, async () => {
      const channel = (await readAccountUiState()).releaseChannel;
      const { version: latest, checkedAt } = await checkLatestVersion(channel);
      return {
        current: version,
        latest,
        updateAvailable: updateAvailable(version, latest),
        checkedAt: new Date(checkedAt).toISOString(),
        releaseNotesUrl:
          channel === 'canary' ? CANARY_NOTES_URL : RELEASE_NOTES_URL,
        updateUrl: updateUrl ?? null,
        scriptName: scriptName ?? null,
        channel,
      };
    });
  }

  /**
   * GET /account/api/overview - Who this is, and what the server holds
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleApiOverview(request, url) {
    return accountApi(request, url, async (did) => {
      const { displayName, avatarUrl } = await readAccountProfile(did);
      const { collections, activity } = await countRecordsWithActivity();
      // The exact aggregate when the storage can give one; the paged count,
      // which caps out and reports itself truncated, only as the fallback.
      const blobs =
        typeof actorStorage.getBlobFacets === 'function'
          ? {
              count: (await actorStorage.getBlobFacets()).total.count,
              truncated: false,
            }
          : await countBlobs();
      const commit = await actorStorage.getLatestCommit();
      const connectedApps = await listConnectedApps(did);

      // A rail count covers a collection wherever it lives: the public
      // repo's records plus this account's records in each live space.
      /** @type {Map<string, number>} */
      const spaceCounts = new Map();
      if (spaceBrowser && extensionStats.length > 0) {
        for (const space of await spaceBrowser.listSpaces()) {
          if (space.deletedAt) continue;
          for (const entry of await spaceBrowser.countRecords(space.uri)) {
            spaceCounts.set(
              entry.collection,
              (spaceCounts.get(entry.collection) ?? 0) + entry.count,
            );
          }
        }
      }
      /** @param {string} collection */
      const count = (collection) =>
        (collections.find((c) => c.name === collection)?.count ?? 0) +
        (spaceCounts.get(collection) ?? 0);

      return {
        hostname: url.host,
        did,
        handle: await actorStorage.getHandle(),
        status: (await actorStorage.getAccountStatus()) || 'active',
        displayName,
        avatarUrl,
        readOnly: readOnly,
        theme: (await readAccountUiState()).theme,
        features: {
          passkeys: passkeys.passkeysAvailable(),
          sessions: sessions.sessionsAvailable(),
          spaces: Boolean(spaceBrowser),
          email: Boolean(actorStorage.setEmail),
          emailSender: Boolean(emailer),
          backups: backupsAvailable(),
          settings: typeof actorStorage.setAccountUiState === 'function',
          passwordChange: passwordChangeable(),
          files: typeof actorStorage.listBlobDetails === 'function',
          ...extensionFeatures,
        },
        stats: {
          records: collections.reduce((sum, c) => sum + c.count, 0),
          blobs: blobs.count,
          blobsTruncated: blobs.truncated,
          apps: countAppDomains(collections, connectedApps),
          appPasswords: (await listAccountAppPasswords(did)).length,
          passkeys: (await listAccountPasskeys(did)).length,
          sessions: (await sessions.listLiveSessions(did)).length,
          ...Object.assign({}, ...extensionStats.map((stats) => stats(count))),
        },
        collections,
        recordActivity: activity,
        commit,
      };
    });
  }

  /**
   * GET /account/api/lexicon-status?collections=a,b,c - For each requested NSID,
   * reports whether its lexicon is published (resolvable) via the protocol:
   * DNS authority → DID → com.atproto.lexicon.schema record. Drives the
   * "published" check on the records list. Resolutions are cached by the shared
   * lexicon resolver, so repeat checks are cheap; if no resolver is injected the
   * map is empty and the UI simply shows no checks.
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleApiLexiconStatus(request, url) {
    return accountApi(request, url, async () => {
      /** @type {Record<string, boolean>} */
      const published = {};
      const resolver = lexiconResolver;
      if (!resolver?.isPublished) return { published };
      // Bind now so the narrowed, defined method survives into the closure below.
      const isPublished = resolver.isPublished.bind(resolver);

      const nsids = [
        ...new Set(
          (url.searchParams.get('collections') || '')
            .split(',')
            .map((s) => s.trim())
            .filter(Boolean),
        ),
      ].slice(0, 100);

      await Promise.all(
        nsids.map(async (nsid) => {
          try {
            published[nsid] = await isPublished(nsid);
          } catch {
            published[nsid] = false;
          }
        }),
      );

      return { published };
    });
  }

  /**
   * GET /account/api/lexicon-schema?collection=<nsid> - The resolved lexicon
   * document for one NSID, so the UI can show a published record type's actual
   * schema. Returns `{ lexicon: null }` when it can't be resolved or no resolver
   * is injected.
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleApiLexiconSchema(request, url) {
    return accountApi(request, url, async () => {
      const resolver = lexiconResolver;
      const nsid = (url.searchParams.get('collection') || '').trim();
      if (!resolver?.getLexicon || !nsid) return { lexicon: null };
      try {
        return { lexicon: (await resolver.getLexicon(nsid)) ?? null };
      } catch {
        return { lexicon: null };
      }
    });
  }

  /**
   * GET /account/api/activity - The account's write history, newest first,
   * read from the sequenced commit events. Each commit's ops are flattened
   * into one row apiece. Paginates by the `cursor` seq.
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleApiActivity(request, url) {
    return accountApi(request, url, async () => {
      if (!actorStorage.getEventsBefore) return { events: [], cursor: null };
      const limit = Math.min(
        Math.max(Number(url.searchParams.get('limit')) || 30, 1),
        100,
      );
      const cursor = url.searchParams.get('cursor');
      const before = cursor
        ? Number(cursor)
        : (await actorStorage.getLatestSeq()) + 1;

      const rows = await actorStorage.getEventsBefore(before, limit);
      const events = [];
      let lowest = before;
      for (const row of rows) {
        lowest = Math.min(lowest, row.seq);
        let decoded;
        try {
          decoded = cborDecode(row.evt);
        } catch {
          continue;
        }
        const ops = Array.isArray(decoded?.ops) ? decoded.ops : [];
        for (const op of ops) {
          const slash = String(op.path).indexOf('/');
          events.push({
            seq: row.seq,
            action: op.action,
            collection: slash >= 0 ? op.path.slice(0, slash) : op.path,
            rkey: slash >= 0 ? op.path.slice(slash + 1) : '',
            time: decoded.time || null,
          });
        }
      }

      return {
        events,
        cursor: rows.length >= limit ? String(lowest) : null,
      };
    });
  }

  /**
   * The account app's own preferences — the Apps screen's combined-row
   * aliases, turned-down combine offers, and the appearance choice —
   * normalized on read so handlers can trust the shape whatever an older
   * document held.
   * @returns {Promise<import('../ports.js').AccountUiState>}
   */
  async function readAccountUiState() {
    const stored = actorStorage.getAccountUiState
      ? await actorStorage.getAccountUiState()
      : null;
    const aliases = stored?.appAliases;
    return {
      appAliases:
        aliases && typeof aliases === 'object' && !Array.isArray(aliases)
          ? aliases
          : {},
      dismissedAppPairs: Array.isArray(stored?.dismissedAppPairs)
        ? stored.dismissedAppPairs.filter((p) => typeof p === 'string')
        : [],
      theme:
        stored?.theme === 'light' || stored?.theme === 'dark'
          ? stored.theme
          : 'system',
      releaseChannel: stored?.releaseChannel === 'canary' ? 'canary' : 'stable',
    };
  }

  /**
   * GET /account/api/apps, POST /account/api/apps/revoke
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleApiApps(request, url) {
    return accountApi(request, url, async (did) => {
      const ui = await readAccountUiState();
      return {
        apps: await listConnectedApps(did),
        aliases: ui.appAliases,
        dismissedPairs: ui.dismissedAppPairs,
        aliasesAvailable: typeof actorStorage.setAccountUiState === 'function',
      };
    });
  }

  /**
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleApiAppsRevoke(request, url) {
    return accountApi(
      request,
      url,
      async (did, body) => {
        const revoked = await revokeOAuthSession(did, body.session || '');
        if (!revoked) throw new Error('That session no longer exists.');
        return { revoked: revoked.clientId };
      },
      { write: true },
    );
  }

  /**
   * POST /account/api/apps/alias - Fold one Apps row into another
   * (action=combine), undo it (separate), or remember the offer was turned
   * down (dismiss). `from` is the domain giving up its row — the record
   * authority — and `to` the row that absorbs it, the live client host. The
   * pairing is the browser's reading of the account's own data; here the
   * strings just have to look like hostnames and the document stays bounded.
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleApiAppsAlias(request, url) {
    return accountApi(
      request,
      url,
      async (_did, body) => {
        if (typeof actorStorage.setAccountUiState !== 'function') {
          throw new Error('This server cannot store app aliases');
        }
        const from = plausibleHost(body.from);
        if (!from) throw new Error('from must be a hostname');
        const state = await readAccountUiState();

        if (body.action === 'combine' || body.action === 'dismiss') {
          const to = plausibleHost(body.to);
          if (!to || to === from) {
            throw new Error('to must be a different hostname');
          }
          if (body.action === 'combine') {
            // A row that absorbs another may not itself be folded away, so
            // no chain of aliases ever forms
            delete state.appAliases[to];
            state.appAliases[from] = to;
            if (Object.keys(state.appAliases).length > 50) {
              throw new Error('Too many combined apps');
            }
          } else {
            const pair = `${from}>${to}`;
            if (!state.dismissedAppPairs.includes(pair)) {
              state.dismissedAppPairs.push(pair);
            }
            state.dismissedAppPairs = state.dismissedAppPairs.slice(-100);
          }
        } else if (body.action === 'separate') {
          delete state.appAliases[from];
        } else {
          throw new Error('action must be combine, separate, or dismiss');
        }

        await actorStorage.setAccountUiState(state);
        return {
          aliases: state.appAliases,
          dismissedPairs: state.dismissedAppPairs,
        };
      },
      { write: true },
    );
  }

  /**
   * POST /account/api/settings/appearance - Store which color palette the
   * console uses. The choice lives in the account's UI state document, so it
   * follows the account to other browsers and devices.
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleApiSettingsAppearance(request, url) {
    return accountApi(
      request,
      url,
      async (_did, body) => {
        if (typeof actorStorage.setAccountUiState !== 'function') {
          throw new Error('This server cannot store settings');
        }
        if (!['system', 'light', 'dark'].includes(body.theme)) {
          throw new Error('theme must be system, light, or dark');
        }
        const state = await readAccountUiState();
        state.theme = body.theme;
        await actorStorage.setAccountUiState(state);
        return { theme: state.theme };
      },
      { write: true },
    );
  }

  /**
   * POST /account/api/settings/release-channel - Store which release channel
   * the update check follows: tagged releases, or canary builds of the main
   * branch. The choice only steers the check and the update link; installing
   * a build stays a deliberate redeploy.
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleApiSettingsReleaseChannel(request, url) {
    return accountApi(
      request,
      url,
      async (_did, body) => {
        if (typeof actorStorage.setAccountUiState !== 'function') {
          throw new Error('This server cannot store settings');
        }
        if (!['stable', 'canary'].includes(body.channel)) {
          throw new Error('channel must be stable or canary');
        }
        const state = await readAccountUiState();
        state.releaseChannel = body.channel;
        await actorStorage.setAccountUiState(state);
        return { channel: state.releaseChannel };
      },
      { write: true },
    );
  }

  /**
   * POST /account/api/password - Change the account password. Takes the
   * current password even inside a signed-in session, so a stolen cookie
   * alone cannot rotate the credential. Sessions stay open; app passwords
   * are untouched.
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleApiPassword(request, url) {
    return accountApi(
      request,
      url,
      async (_did, body) => {
        if (!passwordChangeable()) {
          throw new Error('This server cannot store a changed password');
        }
        if (
          typeof body.newPassword !== 'string' ||
          body.newPassword.length < 8
        ) {
          throw new Error('Use a new password of at least 8 characters');
        }
        if (!(await verifyAccountPassword(body.currentPassword))) {
          throw new Error('The current password is wrong');
        }
        if (!(await changeAccountPassword(body.newPassword))) {
          throw new Error('This server cannot store a changed password');
        }
        return { ok: true };
      },
      { write: true },
    );
  }

  /**
   * The remaining section endpoints. Each is the same data the server-rendered
   * page shows, in the shape the account app consumes.
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleApiSessions(request, url) {
    return accountApi(request, url, async (did) => ({
      supported: sessions.sessionsAvailable(),
      sessions: sessions.sessionsAvailable()
        ? (await sessions.listLiveSessions(did))
            .map((session) => ({
              jti: session.jti,
              label: session.label,
              userAgent: session.userAgent,
              createdAt: session.createdAt,
              refreshedAt: session.refreshedAt,
              appPassword: isAppPasswordScope(session.scope),
            }))
            .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
        : [],
    }));
  }

  /** @param {Request} request @param {URL} url */
  async function handleApiSessionsRevoke(request, url) {
    return accountApi(
      request,
      url,
      async (did, body) => {
        const jti = body.jti || '';
        const target = sessions.sessionsAvailable()
          ? await sharedStorage.getSession(jti)
          : null;
        // Checked against the caller's own DID, so one account cannot end
        // another's session by naming its id
        if (!target || target.did !== did) {
          throw new Error('That session no longer exists.');
        }
        await sessions.revokeSessionChain(did, jti);
        return { revoked: jti };
      },
      { write: true },
    );
  }

  /**
   * POST /account/api/sessions/revoke-all - End every password/app-password
   * login at once. The account page runs on its own cookie, not one of these
   * records, so the caller stays signed in here.
   * @param {Request} request @param {URL} url
   */
  async function handleApiSessionsRevokeAll(request, url) {
    return accountApi(
      request,
      url,
      async (did) => {
        if (
          !sessions.sessionsAvailable() ||
          typeof sharedStorage.listSessions !== 'function'
        ) {
          return { revoked: 0 };
        }
        // Snapshot the ids before deleting: a store may hand back its own
        // array by reference, and the deletes below would mutate it mid-count.
        const jtis = (await sharedStorage.listSessions(did)).map(
          (session) => session.jti,
        );
        await Promise.all(jtis.map((jti) => sharedStorage.deleteSession(jti)));
        return { revoked: jtis.length };
      },
      { write: true },
    );
  }

  /** @param {Request} request @param {URL} url */
  async function handleApiAppPasswords(request, url) {
    return accountApi(request, url, async (did) => ({
      passwords: await listAccountAppPasswords(did),
    }));
  }

  /** @param {Request} request @param {URL} url */
  async function handleApiAppPasswordsCreate(request, url) {
    return accountApi(
      request,
      url,
      async (did, body) => {
        if (!sharedStorage.putAppPassword) {
          throw new Error('This server does not store app passwords.');
        }
        const name = (body.name || '').trim();
        if (!name) throw new Error('A name is required.');

        const password = generateAppPassword();
        const stored = await sharedStorage.putAppPassword(
          did,
          name,
          await hashAppPassword(did, password),
          Boolean(body.privileged),
          new Date().toISOString(),
        );
        if (!stored) throw new Error(`App password "${name}" already exists.`);

        // The only time it is returned; storage keeps a hash
        return { name, password };
      },
      { write: true },
    );
  }

  /** @param {Request} request @param {URL} url */
  async function handleApiAppPasswordsRevoke(request, url) {
    return accountApi(
      request,
      url,
      async (did, body) => {
        const name = body.name || '';
        const deleted =
          sharedStorage.deleteAppPassword &&
          (await sharedStorage.deleteAppPassword(did, name));
        if (!deleted) throw new Error(`No app password named "${name}".`);
        await sessions.revokeAppPasswordSessions(did, name);
        return { revoked: name };
      },
      { write: true },
    );
  }

  /** @param {Request} request @param {URL} url */
  async function handleApiPasskeys(request, url) {
    return accountApi(request, url, async (did) => {
      const supported = passkeys.passkeysAvailable();
      const ceremony = supported
        ? await passkeys.startPasskeyCeremony('register', url)
        : null;

      const body = {
        supported,
        rpId: passkeys.passkeyRpId(url),
        accountName: (await actorStorage.getHandle()) || did,
        did,
        // The challenge the browser will answer. Its cookie goes on this
        // response; the copy here is what credentials.create() needs.
        challenge: ceremony?.challenge || '',
        passkeys: (await listAccountPasskeys(did)).map((passkey) => ({
          credentialId: passkey.credentialId,
          name: passkey.name,
          createdAt: passkey.createdAt,
          lastUsedAt: passkey.lastUsedAt,
        })),
      };

      const headers = new Headers({
        'Content-Type': 'application/json',
        'Cache-Control': 'no-store',
      });
      if (ceremony) headers.set('Set-Cookie', ceremony.cookie);
      return new Response(JSON.stringify(body), { headers });
    });
  }

  /** @param {Request} request @param {URL} url */
  async function handleApiPasskeysRevoke(request, url) {
    return accountApi(
      request,
      url,
      async (did, body) => {
        const deleted =
          sharedStorage.deletePasskey &&
          (await sharedStorage.deletePasskey(did, body.credentialId || ''));
        if (!deleted) throw new Error('No such passkey on this account.');
        return { revoked: body.credentialId };
      },
      { write: true },
    );
  }

  /**
   * POST /account/api/email - Set, verify, cancel or remove the address
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleApiEmailAction(request, url) {
    return accountApi(
      request,
      url,
      async (_did, body) => {
        if (!actorStorage.setEmail) {
          throw new Error('This server cannot store an email address.');
        }
        const state = await readEmailState(actorStorage);

        if (body.action === 'cancel') {
          await actorStorage.setEmail({ ...state, pending: null });
          return { ok: true };
        }
        if (body.action === 'remove') {
          await actorStorage.setEmail({
            address: null,
            verified: false,
            pending: null,
          });
          return { ok: true };
        }
        if (body.action === 'resend') {
          const pending = state.pending;
          if (!pending) throw new Error('No address is waiting.');
          if (!emailer) {
            throw new Error('This server cannot send email.');
          }
          const now = Date.now();
          const readyAt = (pending.sentAt || 0) + EMAIL_RESEND_COOLDOWN_MS;
          if (now < readyAt) {
            throw new Error('Wait a moment before asking for another code.');
          }
          const code = generateEmailCode();
          await actorStorage.setEmail({
            ...state,
            pending: {
              address: pending.address,
              codeHash: await hashEmailCode(pending.address, code, jwtSecret),
              expiresAt: now + EMAIL_CODE_TTL_MS,
              sentAt: now,
            },
          });
          try {
            await emailer.send({
              to: pending.address,
              ...renderVerificationEmail({ code, hostname: url.host }),
            });
          } catch (err) {
            // Keep the code the earlier message already carried usable.
            await actorStorage.setEmail(state);
            console.error('Failed to resend verification email:', err);
            throw new Error('The message could not be sent. Try again later.');
          }
          return {
            ok: true,
            sent: pending.address,
            resendAt: now + EMAIL_RESEND_COOLDOWN_MS,
          };
        }
        if (body.action === 'verify') {
          const pending = state.pending;
          if (!pending) throw new Error('No address is waiting.');
          if (pending.expiresAt <= Date.now()) {
            await actorStorage.setEmail({ ...state, pending: null });
            throw new Error('That code expired. Start again.');
          }
          const candidate = await hashEmailCode(
            pending.address,
            (body.code || '').trim(),
            jwtSecret,
          );
          if (!(await timingSafeEqual(candidate, pending.codeHash))) {
            throw new Error('That code is wrong.');
          }
          await actorStorage.setEmail({
            address: pending.address,
            verified: true,
            pending: null,
          });
          return { ok: true, address: pending.address };
        }

        const address = (body.address || '').trim();
        if (!isValidEmail(address)) {
          throw new Error('That does not look like an email address.');
        }
        if (!emailer) {
          await actorStorage.setEmail({
            address,
            verified: false,
            pending: null,
          });
          return { ok: true, unconfirmed: true };
        }

        const code = generateEmailCode();
        const sentAt = Date.now();
        await actorStorage.setEmail({
          ...state,
          pending: {
            address,
            codeHash: await hashEmailCode(address, code, jwtSecret),
            expiresAt: sentAt + EMAIL_CODE_TTL_MS,
            sentAt,
          },
        });
        try {
          await emailer.send({
            to: address,
            ...renderVerificationEmail({ code, hostname: url.host }),
          });
        } catch (err) {
          // A pending record with no message sent offers a code that never
          // arrives
          await actorStorage.setEmail({ ...state, pending: null });
          console.error('Failed to send verification email:', err);
          throw new Error('The message could not be sent. Try again later.');
        }
        return {
          ok: true,
          sent: address,
          resendAt: sentAt + EMAIL_RESEND_COOLDOWN_MS,
        };
      },
      { write: true },
    );
  }

  /** @param {Request} request @param {URL} url */
  async function handleApiEmail(request, url) {
    return accountApi(request, url, async () => {
      const state = await readEmailState(actorStorage);
      const pending =
        state.pending && state.pending.expiresAt > Date.now()
          ? {
              address: state.pending.address,
              resendAt: (state.pending.sentAt || 0) + EMAIL_RESEND_COOLDOWN_MS,
            }
          : null;
      return {
        address: state.address,
        verified: state.verified,
        pending,
        canSend: Boolean(emailer),
        supported: Boolean(actorStorage.setEmail),
      };
    });
  }

  /** @param {Request} request @param {URL} url */
  async function handleApiIdentity(request, url) {
    return accountApi(request, url, async (did) => {
      const credentials = await getRecommendedDidCredentials(url.hostname);
      return {
        did,
        handle: await actorStorage.getHandle(),
        status: (await actorStorage.getAccountStatus()) || 'active',
        signingKey: credentials?.verificationMethods?.atproto || null,
        pdsEndpoint: hostname
          ? `https://${hostname}`
          : `${url.protocol}//${url.host}`,
        plcUrl: plcUrl,
        readOnly: readOnly,
      };
    });
  }

  /**
   * POST /account/api/identity/handle - Rename the account from the account page
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleApiIdentityHandle(request, url) {
    return accountApi(
      request,
      url,
      async (_did, body) => {
        const { handle } = await renameHandle(body.handle, url.hostname);
        return { handle };
      },
      { write: true },
    );
  }

  /** @param {Request} request @param {URL} url */
  async function handleApiStatus(request, url) {
    return accountApi(
      request,
      url,
      async (_did, body) => {
        if (body.action !== 'activate' && body.action !== 'deactivate') {
          throw new Error('Unknown status action.');
        }
        await setAccountActive(body.action === 'activate');
        return {
          status: body.action === 'activate' ? 'active' : 'deactivated',
        };
      },
      { write: true },
    );
  }

  /** @param {Request} request @param {URL} url */
  /**
   * Pull one record's content out of a stored firehose event's CAR — the
   * blocks a #commit carries include the records it touched.
   * @param {unknown} carBytes - The event's `blocks` CAR
   * @param {string} cid - The record block to read
   * @returns {*} The display-shaped record value, or null if absent
   */
  function readRecordFromCar(carBytes, cid) {
    if (!(carBytes instanceof Uint8Array)) return null;
    try {
      const { blocks } = parseCarFile(carBytes);
      const data = blocks.get(cid);
      return data ? toDisplayValue(cborDecode(data)) : null;
    } catch {
      return null;
    }
  }

  /**
   * A record as it stood at one point in its history — the change with sequence
   * `seq`. A create or update carries its content in that event; a delete does
   * not, so its prior content is recovered from the last event that wrote it.
   * @param {string} collection
   * @param {string} rkey
   * @param {number} seq
   * @returns {Promise<{action: string, time: string|null, cid: string|null, value: *}|null>}
   */
  async function recordSnapshotAt(collection, rkey, seq) {
    if (!actorStorage.getEventsBefore) return null;
    const path = `${collection}/${rkey}`;

    const [row] = await actorStorage.getEventsBefore(seq + 1, 1);
    if (!row || row.seq !== seq) return null;
    let event;
    try {
      event = cborDecode(row.evt);
    } catch {
      return null;
    }
    const op = (event.ops || []).find(
      (/** @type {any} */ entry) => entry.path === path,
    );
    if (!op) return null;
    const time = event.time || null;

    // A create or update keeps the content right here.
    if (op.cid?.$link) {
      return {
        action: op.action,
        time,
        cid: op.cid.$link,
        value: readRecordFromCar(event.blocks, op.cid.$link),
      };
    }

    // A delete does not: walk back to the most recent event that wrote this
    // path and read the content it carried.
    let before = seq;
    while (before > 0) {
      const batch = await actorStorage.getEventsBefore(before, 100);
      if (!batch.length) break;
      for (const prior of batch) {
        let ev;
        try {
          ev = cborDecode(prior.evt);
        } catch {
          continue;
        }
        const wrote = (ev.ops || []).find(
          (/** @type {any} */ entry) => entry.path === path && entry.cid?.$link,
        );
        if (wrote)
          return {
            action: 'delete',
            time,
            cid: wrote.cid.$link,
            value: readRecordFromCar(ev.blocks, wrote.cid.$link),
          };
      }
      before = batch[batch.length - 1].seq;
    }
    return { action: 'delete', time, cid: null, value: null };
  }

  /** @param {Request} request @param {URL} url */
  async function handleApiRecords(request, url) {
    return accountApi(request, url, async (did) => {
      const collection = url.searchParams.get('collection') || '';
      const space = url.searchParams.get('space') || '';
      const rkey = url.searchParams.get('rkey') || '';
      const seqParam = url.searchParams.get('seq');
      const cursor = url.searchParams.get('cursor') || null;
      if (!collection) throw new Error('Name a collection to read.');

      const blobBase = `/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}`;
      const decode = (/** @type {any} */ value) =>
        toDisplayValue(
          cborDecode(
            value instanceof Uint8Array ? value : new Uint8Array(value),
          ),
        );

      // A History link names a record and the change it points at: show that
      // version, not whatever the record is now (it may since have changed or
      // been removed), with enough to bridge back to the live one.
      if (rkey && seqParam) {
        const seq = Number(seqParam);
        const snap = Number.isFinite(seq)
          ? await recordSnapshotAt(collection, rkey, seq)
          : null;
        if (!snap) throw new Error('That change is no longer available.');
        const uri = `at://${did}/${collection}/${rkey}`;
        const live = await actorStorage.getRecord(uri);
        return {
          blobBase,
          snapshot: {
            action: snap.action,
            time: snap.time,
            liveExists: Boolean(live),
            liveCurrent: live !== null && live.cid === snap.cid,
          },
          records:
            snap.value != null
              ? [{ uri, cid: snap.cid, value: snap.value }]
              : [],
        };
      }

      if (space) {
        const browser = spaceBrowser;
        if (!browser) throw new Error('Permissioned data is off.');
        const known = await browser.listSpaces();
        // The query names what to show, not what to fetch
        if (!known.some((row) => row.uri === space)) {
          throw new Error('No such space on this account.');
        }
        const { records, truncated } = await browser.listRecords(
          space,
          collection,
          ACCOUNT_SPACE_RECORD_LIMIT,
        );
        return {
          truncated,
          records: records.map((record) => ({
            uri: `${space}/${did}/${collection}/${record.rkey}`,
            cid: record.cid,
            indexedAt: record.indexedAt,
            value: record.value,
          })),
        };
      }

      // A deep link names one record: fetch it directly so a large collection
      // never has to be listed to find it.
      if (rkey) {
        const uri = `at://${did}/${collection}/${rkey}`;
        const row = await actorStorage.getRecord(uri);
        return {
          blobBase,
          cursor: null,
          records: row ? [{ uri, cid: row.cid, value: decode(row.value) }] : [],
        };
      }

      // Newest first — a rkey is a timestamp id, so reverse order is recency.
      const { records, cursor: next } = await actorStorage.listRecords(
        collection,
        cursor,
        ACCOUNT_SPACE_RECORD_LIMIT,
        true,
      );
      return {
        blobBase,
        cursor: next,
        records: records.map((record) => ({
          uri: record.uri,
          cid: record.cid,
          value: decode(record.value),
        })),
      };
    });
  }

  /**
   * GET /account/api/blobs - Files apps have uploaded, each with the records
   * that use it. Narrowed by `kind`, `app` (the app's domain), and `unused`;
   * ordered by `sort` (newest or largest). The first page carries the facets:
   * global counts and bytes by kind, app, and usage, for the filter chips and
   * the storage bar.
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleApiBlobs(request, url) {
    return accountApi(request, url, async (did) => {
      const storage = actorStorage;
      if (typeof storage.listBlobDetails !== 'function') {
        throw new Error('This server cannot list files.');
      }
      const cursor = url.searchParams.get('cursor') || null;
      const kindParam = url.searchParams.get('kind') || '';
      const kind = BLOB_KINDS.includes(kindParam)
        ? /** @type {import('../ports.js').BlobKind} */ (kindParam)
        : undefined;
      // The filter names the app's domain (`bsky.app`); records carry it
      // reversed, as their collection NSID's authority (`app.bsky`).
      const app = (url.searchParams.get('app') || '').toLowerCase();
      const appAuthority = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(app)
        ? app.split('.').reverse().join('.')
        : undefined;
      const options = {
        kind,
        appAuthority,
        unused: url.searchParams.get('unused') === '1',
        sort:
          url.searchParams.get('sort') === 'largest'
            ? /** @type {const} */ ('largest')
            : /** @type {const} */ ('newest'),
      };

      const { blobs, cursor: next } = await storage.listBlobDetails(
        cursor,
        ACCOUNT_BLOB_PAGE_LIMIT,
        options,
      );

      /** @type {object|undefined} */
      let facets;
      if (!cursor && typeof storage.getBlobFacets === 'function') {
        const raw = await storage.getBlobFacets();
        facets = {
          total: raw.total,
          unused: raw.unused,
          kinds: raw.kinds,
          apps: raw.apps.map(({ authority, count, bytes }) => ({
            domain: authority.split('.').reverse().join('.'),
            count,
            bytes,
          })),
        };
      }

      return {
        blobBase: `/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}`,
        cursor: next,
        facets,
        blobs: blobs.map((blob) => ({
          cid: blob.cid,
          mimeType: blob.mimeType,
          size: blob.size,
          createdAt: blob.createdAt
            ? new Date(blob.createdAt).toISOString()
            : null,
          time: blob.time ? new Date(blob.time).toISOString() : null,
          timeFromRecord: blob.timeFromRecord,
          records: blob.records,
        })),
      };
    });
  }

  /**
   * POST /account/api/blobs/relink - Rebuild blob usage from record content.
   * For repos whose records arrived outside the write path (a migration's CAR
   * import), where the link table undercounts what is really used.
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleApiBlobsRelink(request, url) {
    return accountApi(
      request,
      url,
      async () => ({ references: await relinkBlobRecords() }),
      { write: true },
    );
  }

  /**
   * GET /account/apps - Apps holding an OAuth session
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountApps(request, url) {
    const session = await requireAccountSession(request, url);
    if ('response' in session) return session.response;

    return htmlResponse(
      renderAppsPage({
        hostname: url.host,
        apps: await listConnectedApps(session.did),
      }),
    );
  }

  /**
   * POST /account/apps/revoke - End one app's OAuth session
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountAppRevoke(request, url) {
    const params = new URLSearchParams(await request.text());
    if (!isSameOriginPost(request, url)) {
      return new Response('Forbidden', { status: 403 });
    }

    const session = await requireAccountSession(request, url);
    if ('response' in session) return session.response;
    const { did } = session;

    /** @param {{error?: string, notice?: string}} result @param {number} status */
    const page = async (result, status) =>
      htmlResponse(
        renderAppsPage({
          hostname: url.host,
          apps: await listConnectedApps(did),
          ...result,
        }),
        status,
      );

    if (readOnly) return page({ error: 'This PDS is read-only.' }, 403);

    const revoked = await revokeOAuthSession(did, params.get('session') || '');
    if (!revoked) return page({ error: 'That session no longer exists.' }, 400);

    return page(
      {
        notice: `Revoked the session for ${revoked.clientId}. Its access token stops working when it expires, within an hour.`,
      },
      200,
    );
  }

  /**
   * GET /account/app-passwords - App password list and creation form
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountAppPasswords(request, url) {
    const session = await requireAccountSession(request, url);
    if ('response' in session) return session.response;

    return htmlResponse(
      renderAppPasswordsPage({
        hostname: url.host,
        passwords: await listAccountAppPasswords(session.did),
      }),
    );
  }

  /**
   * Re-render the app password page carrying the result of an action.
   * @param {string} did
   * @param {string} hostname
   * @param {{created?: {name: string, password: string}|null, error?: string, notice?: string}} result
   * @param {number} [status=200]
   * @returns {Promise<Response>}
   */
  async function appPasswordsResult(did, hostname, result, status = 200) {
    return htmlResponse(
      renderAppPasswordsPage({
        hostname,
        passwords: await listAccountAppPasswords(did),
        ...result,
      }),
      status,
    );
  }

  /**
   * POST /account/app-passwords/create
   *
   * Renders the result rather than redirecting: the generated password is
   * shown exactly once, and a redirect would have to carry it in the URL.
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountAppPasswordCreate(request, url) {
    const params = new URLSearchParams(await request.text());
    if (!isSameOriginPost(request, url)) {
      return new Response('Forbidden', { status: 403 });
    }

    const session = await requireAccountSession(request, url);
    if ('response' in session) return session.response;
    const { did } = session;

    if (readOnly) {
      return appPasswordsResult(
        did,
        url.host,
        { error: 'This PDS is read-only.' },
        403,
      );
    }
    if (!sharedStorage.putAppPassword) {
      return appPasswordsResult(
        did,
        url.host,
        { error: 'This server does not store app passwords.' },
        400,
      );
    }

    const name = (params.get('name') || '').trim();
    if (!name) {
      return appPasswordsResult(
        did,
        url.host,
        { error: 'A name is required.' },
        400,
      );
    }

    const password = generateAppPassword();
    const stored = await sharedStorage.putAppPassword(
      did,
      name,
      await hashAppPassword(did, password),
      params.get('privileged') === 'true',
      new Date().toISOString(),
    );
    if (!stored) {
      return appPasswordsResult(
        did,
        url.host,
        { error: `App password "${name}" already exists.` },
        400,
      );
    }

    return appPasswordsResult(did, url.host, {
      created: { name, password },
    });
  }

  /**
   * POST /account/app-passwords/revoke
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountAppPasswordRevoke(request, url) {
    const params = new URLSearchParams(await request.text());
    if (!isSameOriginPost(request, url)) {
      return new Response('Forbidden', { status: 403 });
    }

    const session = await requireAccountSession(request, url);
    if ('response' in session) return session.response;
    const { did } = session;

    if (readOnly) {
      return appPasswordsResult(
        did,
        url.host,
        { error: 'This PDS is read-only.' },
        403,
      );
    }

    const name = params.get('name') || '';
    const deleted =
      sharedStorage.deleteAppPassword &&
      (await sharedStorage.deleteAppPassword(did, name));
    if (deleted) await sessions.revokeAppPasswordSessions(did, name);

    return appPasswordsResult(
      did,
      url.host,
      deleted
        ? { notice: `Revoked "${name}". Sessions opened with it stop working.` }
        : { error: `No app password named "${name}".` },
      deleted ? 200 : 400,
    );
  }

  // ── Passkeys ────────────────────────────────────────────────────────────

  /**
   * @param {string} did
   * @returns {Promise<Array<import('../ports.js').PasskeyRecord>>}
   */
  async function listAccountPasskeys(did) {
    if (!sharedStorage.listPasskeys) return [];
    return sharedStorage.listPasskeys(did);
  }

  /**
   * The passkeys page, with a registration challenge ready for the browser.
   * @param {URL} url
   * @param {string} did
   * @param {{error?: string, notice?: string}} [result]
   * @param {number} [status=200]
   * @returns {Promise<Response>}
   */
  async function passkeysResponse(url, did, result = {}, status = 200) {
    const supported = Boolean(sharedStorage.putPasskey);
    const ceremony = supported
      ? await passkeys.startPasskeyCeremony('register', url)
      : null;

    return htmlResponse(
      renderPasskeysPage({
        hostname: url.host,
        passkeys: (await listAccountPasskeys(did)).map((passkey) => ({
          credentialId: passkey.credentialId,
          name: passkey.name,
          createdAt: passkey.createdAt,
          lastUsedAt: passkey.lastUsedAt,
        })),
        supported,
        readOnly: readOnly,
        challenge: ceremony?.challenge || '',
        rpId: passkeys.passkeyRpId(url),
        accountName: (await actorStorage.getHandle()) || did,
        did,
        ...result,
      }),
      status,
      ceremony?.cookie || null,
    );
  }

  /**
   * GET /account/passkeys - Registered passkeys, and the form to add one
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountPasskeys(request, url) {
    const session = await requireAccountSession(request, url);
    if ('response' in session) return session.response;
    return passkeysResponse(url, session.did);
  }

  /**
   * POST /account/passkeys/register - Store a credential the browser created
   *
   * Answers JSON: the browser drives this from script, since only script can
   * run the WebAuthn ceremony.
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountPasskeyRegister(request, url) {
    const body = await readJsonBounded(request);
    if (body instanceof Response) return body;

    if (!isSameOriginPost(request, url)) {
      return Response.json({ error: 'Forbidden' }, { status: 403 });
    }

    const did = await readAccountSession(request);
    if (!did) {
      return Response.json({ error: 'Not signed in' }, { status: 401 });
    }
    if (readOnly) {
      return Response.json({ error: 'This PDS is read-only' }, { status: 403 });
    }
    if (!sharedStorage.putPasskey) {
      return Response.json(
        { error: 'This server cannot store passkeys' },
        { status: 400 },
      );
    }

    const challenge = await passkeys.readPasskeyChallenge(request, 'register');
    if (!challenge) {
      return Response.json(
        { error: 'That registration expired. Reload and try again.' },
        { status: 400 },
      );
    }

    let credential;
    try {
      credential = await verifyRegistration({
        credentialId: body.credentialId,
        publicKey: body.publicKey,
        algorithm: Number(body.algorithm),
        clientDataJSON: body.clientDataJSON,
        expectedChallenge: challenge,
        expectedOrigin: `${url.protocol}//${url.host}`,
      });
    } catch (err) {
      return Response.json(
        { error: err instanceof Error ? err.message : 'Registration failed' },
        { status: 400 },
      );
    }

    const name = (body.name || '').trim() || 'Passkey';
    const stored = await sharedStorage.putPasskey({
      credentialId: credential.credentialId,
      did,
      name,
      publicKey: credential.publicKey,
      algorithm: credential.algorithm,
      signCount: 0,
      createdAt: new Date().toISOString(),
      lastUsedAt: null,
    });
    if (!stored) {
      return Response.json(
        { error: 'That passkey is already registered.' },
        { status: 400 },
      );
    }

    return Response.json({ ok: true, name });
  }

  /**
   * POST /account/passkeys/revoke - Forget one passkey
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountPasskeyRevoke(request, url) {
    const params = new URLSearchParams(await request.text());
    if (!isSameOriginPost(request, url)) {
      return new Response('Forbidden', { status: 403 });
    }

    const session = await requireAccountSession(request, url);
    if ('response' in session) return session.response;
    const { did } = session;

    if (readOnly) {
      return passkeysResponse(
        url,
        did,
        { error: 'This PDS is read-only.' },
        403,
      );
    }

    const credentialId = params.get('credentialId') || '';
    const deleted =
      sharedStorage.deletePasskey &&
      (await sharedStorage.deletePasskey(did, credentialId));

    return passkeysResponse(
      url,
      did,
      deleted
        ? { notice: 'Passkey removed.' }
        : { error: 'No such passkey on this account.' },
      deleted ? 200 : 400,
    );
  }

  /**
   * GET /account/passkey/challenge - Start a passkey sign-in
   * @param {Request} _request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountPasskeyChallenge(_request, url) {
    if (!sharedStorage.getPasskey) {
      return Response.json(
        { error: 'This server does not support passkeys' },
        { status: 400 },
      );
    }

    const { challenge, cookie } = await passkeys.startPasskeyCeremony(
      'sign-in',
      url,
    );
    return new Response(
      JSON.stringify({ challenge, rpId: passkeys.passkeyRpId(url) }),
      {
        headers: {
          'Content-Type': 'application/json',
          'Cache-Control': 'no-store',
          'Set-Cookie': cookie,
        },
      },
    );
  }

  /**
   * POST /account/passkey/sign-in - Open a cookie session from an assertion
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountPasskeySignIn(request, url) {
    const body = await readJsonBounded(request);
    if (body instanceof Response) return body;

    if (!isSameOriginPost(request, url)) {
      return Response.json({ error: 'Forbidden' }, { status: 403 });
    }
    if (!sharedStorage.getPasskey) {
      return Response.json(
        { error: 'This server does not support passkeys' },
        { status: 400 },
      );
    }

    const challenge = await passkeys.readPasskeyChallenge(request, 'sign-in');
    if (!challenge) {
      return Response.json(
        { error: 'That sign-in expired. Try again.' },
        { status: 400 },
      );
    }

    const did = await getDid();
    const passkey = await sharedStorage.getPasskey(body.credentialId || '');
    // One failure message for an unknown credential and for one belonging to
    // another account: which of the two it was is not the client's business.
    if (!passkey || !did || passkey.did !== did) {
      return Response.json(
        { error: 'That passkey is not registered here.' },
        { status: 401 },
      );
    }

    let result;
    try {
      result = await verifyAssertion({
        publicKey: passkey.publicKey,
        algorithm: passkey.algorithm,
        authenticatorData: body.authenticatorData,
        clientDataJSON: body.clientDataJSON,
        signature: body.signature,
        expectedChallenge: challenge,
        expectedOrigin: `${url.protocol}//${url.host}`,
        rpId: passkeys.passkeyRpId(url),
      });
    } catch (err) {
      return Response.json(
        { error: err instanceof Error ? err.message : 'Sign-in failed' },
        { status: 401 },
      );
    }

    // A counter that goes backwards means the credential was cloned. Many
    // authenticators keep no counter at all and always report 0, so this only
    // fires when one that does count regresses.
    if (
      result.signCount > 0 &&
      passkey.signCount > 0 &&
      result.signCount <= passkey.signCount
    ) {
      return Response.json(
        { error: 'That passkey looks cloned and was refused.' },
        { status: 401 },
      );
    }

    if (sharedStorage.touchPasskey) {
      await sharedStorage.touchPasskey(
        passkey.credentialId,
        result.signCount,
        new Date().toISOString(),
      );
    }

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

  /**
   * The email page, carrying the outcome of an action.
   * @param {URL} url
   * @param {{error?: string, notice?: string}} [result]
   * @param {number} [status=200]
   * @returns {Promise<Response>}
   */
  async function emailResponse(url, result = {}, status = 200) {
    const state = await readEmailState(actorStorage);
    const pending =
      state.pending && state.pending.expiresAt > Date.now()
        ? state.pending
        : null;

    return htmlResponse(
      renderEmailPage({
        hostname: url.host,
        address: state.address,
        verified: state.verified,
        pending: pending && { address: pending.address },
        canSend: Boolean(emailer),
        ...result,
      }),
      status,
    );
  }

  /**
   * /account/email - The page on GET, setting the address on POST
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountEmailRoute(request, url) {
    if (request.method === 'POST') {
      return handleAccountEmailSet(request, url);
    }
    return handleAccountEmail(request, url);
  }

  /**
   * GET /account/email - Address, confirmation state, and the forms to change it
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountEmail(request, url) {
    const session = await requireAccountSession(request, url);
    if ('response' in session) return session.response;
    return emailResponse(url);
  }

  /**
   * Shared preamble for the email actions: same-origin, signed in, writable,
   * and backed by a storage adapter that can hold an address.
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<{params: URLSearchParams}|{response: Response}>}
   */
  async function beginEmailAction(request, url) {
    const params = new URLSearchParams(await request.text());
    if (!isSameOriginPost(request, url)) {
      return { response: new Response('Forbidden', { status: 403 }) };
    }

    const session = await requireAccountSession(request, url);
    if ('response' in session) return session;

    if (readOnly) {
      return {
        response: await emailResponse(
          url,
          { error: 'This PDS is read-only.' },
          403,
        ),
      };
    }
    if (!actorStorage.setEmail) {
      return {
        response: await emailResponse(
          url,
          { error: 'This server cannot store an email address.' },
          400,
        ),
      };
    }
    return { params };
  }

  /**
   * POST /account/email - Set an address, sending it a code when possible
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountEmailSet(request, url) {
    const begun = await beginEmailAction(request, url);
    if ('response' in begun) return begun.response;

    const address = (begun.params.get('address') || '').trim();
    if (!isValidEmail(address)) {
      return emailResponse(
        url,
        { error: 'That does not look like an email address.' },
        400,
      );
    }

    const state = await readEmailState(actorStorage);

    // With no way to send, the address is recorded but stays unconfirmed —
    // saying otherwise would claim evidence this server does not have.
    if (!emailer) {
      await actorStorage.setEmail({
        address,
        verified: false,
        pending: null,
      });
      return emailResponse(url, {
        notice: `Saved ${address}. It stays unconfirmed until this server can send mail.`,
      });
    }

    const code = generateEmailCode();
    await actorStorage.setEmail({
      ...state,
      pending: {
        address,
        codeHash: await hashEmailCode(address, code, jwtSecret),
        expiresAt: Date.now() + EMAIL_CODE_TTL_MS,
      },
    });

    try {
      const message = renderVerificationEmail({ code, hostname: url.host });
      await emailer.send({ to: address, ...message });
    } catch (err) {
      // Leaving the pending record would offer a code that never arrived
      await actorStorage.setEmail({ ...state, pending: null });
      console.error('Failed to send verification email:', err);
      return emailResponse(
        url,
        { error: 'The message could not be sent. Try again later.' },
        502,
      );
    }

    return emailResponse(url, {
      notice: `Sent a code to ${address}.`,
    });
  }

  /**
   * POST /account/email/verify - Exchange the code for a confirmed address
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountEmailVerify(request, url) {
    const begun = await beginEmailAction(request, url);
    if ('response' in begun) return begun.response;

    const state = await readEmailState(actorStorage);
    const pending = state.pending;
    if (!pending) {
      return emailResponse(url, { error: 'No address is waiting.' }, 400);
    }
    if (pending.expiresAt <= Date.now()) {
      await actorStorage.setEmail({ ...state, pending: null });
      return emailResponse(
        url,
        { error: 'That code expired. Start again.' },
        400,
      );
    }

    const code = (begun.params.get('code') || '').trim();
    const candidate = await hashEmailCode(pending.address, code, jwtSecret);
    if (!(await timingSafeEqual(candidate, pending.codeHash))) {
      return emailResponse(url, { error: 'That code is wrong.' }, 400);
    }

    await actorStorage.setEmail({
      address: pending.address,
      verified: true,
      pending: null,
    });
    return emailResponse(url, {
      notice: `${pending.address} is confirmed.`,
    });
  }

  /**
   * POST /account/email/cancel - Drop a pending address change
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountEmailCancel(request, url) {
    const begun = await beginEmailAction(request, url);
    if ('response' in begun) return begun.response;

    const state = await readEmailState(actorStorage);
    await actorStorage.setEmail({ ...state, pending: null });
    return emailResponse(url, { notice: 'Change cancelled.' });
  }

  /**
   * POST /account/email/remove - Forget the address entirely
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountEmailRemove(request, url) {
    const begun = await beginEmailAction(request, url);
    if ('response' in begun) return begun.response;

    await actorStorage.setEmail({
      address: null,
      verified: false,
      pending: null,
    });
    return emailResponse(url, { notice: 'Address removed.' });
  }

  /**
   * GET /account/spaces - Permissioned-data spaces this account holds
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountSpaces(request, url) {
    const session = await requireAccountSession(request, url);
    if ('response' in session) return session.response;

    const browser = spaceBrowser;
    const spaces = browser
      ? await Promise.all(
          (await browser.listSpaces()).map(async (space) => ({
            uri: space.uri,
            spaceType: space.spaceType,
            authority: space.spaceDid,
            isOwner: space.isOwner,
            // The policy's name, as `memberspace` for
            // `com.atproto.memberspace.policy`.
            policy: space.policy?.$type.split('.').at(-2) ?? null,
            createdAt: space.createdAt.slice(0, 10),
            deleted: Boolean(space.deletedAt),
            members: await browser.countMembers(space.uri),
            collections: await browser.countRecords(space.uri),
          })),
        )
      : [];

    return htmlResponse(
      renderSpacesPage({
        hostname: url.host,
        spaces,
        enabled: Boolean(browser),
      }),
    );
  }

  /**
   * GET /account/spaces/records - The records in one space collection
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountSpaceRecords(request, url) {
    const session = await requireAccountSession(request, url);
    if ('response' in session) return session.response;

    const space = url.searchParams.get('space') || '';
    const collection = url.searchParams.get('collection') || '';
    const browser = spaceBrowser;

    /** @param {string} error @param {number} status */
    const reject = (error, status) =>
      htmlResponse(
        renderRecordsPage({
          hostname: url.host,
          section: '/account/spaces',
          back: { href: '/account/spaces', label: 'Spaces' },
          collection,
          subtitles: space ? [space] : [],
          records: [],
          truncated: false,
          error,
          blobUrl: () => null,
        }),
        status,
      );

    if (!browser)
      return reject('Permissioned data is off on this server.', 400);
    if (!space || !collection) {
      return reject('Name a space and a collection to read.', 400);
    }

    // Only spaces this storage holds for the account: the query string is
    // the caller's, so it names what to show rather than what to fetch.
    const known = await browser.listSpaces();
    if (!known.some((row) => row.uri === space)) {
      return reject('No such space on this account.', 404);
    }

    const did = session.did;
    const { records, truncated } = await browser.listRecords(
      space,
      collection,
      ACCOUNT_SPACE_RECORD_LIMIT,
    );
    return htmlResponse(
      renderRecordsPage({
        hostname: url.host,
        section: '/account/spaces',
        back: { href: '/account/spaces', label: 'Spaces' },
        collection,
        subtitles: ["This account's own records in that space."],
        // Space uri, then writer, then collection/rkey.
        records: records.map((record) => ({
          uri: `${space}/${did}/${collection}/${record.rkey}`,
          cid: record.cid,
          indexedAt: record.indexedAt,
          value: record.value,
        })),
        truncated,
      }),
    );
  }

  /**
   * GET /account/repo/records - The records in one repo collection
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountRepoRecords(request, url) {
    const session = await requireAccountSession(request, url);
    if ('response' in session) return session.response;

    const collection = url.searchParams.get('collection') || '';
    /** @param {string} error @param {number} status */
    const reject = (error, status) =>
      htmlResponse(
        renderRecordsPage({
          hostname: url.host,
          section: '/account/repo',
          back: { href: '/account/repo', label: 'Repository' },
          collection,
          subtitles: [],
          records: [],
          truncated: false,
          error,
        }),
        status,
      );

    if (!collection) return reject('Name a collection to read.', 400);
    const did = (await getDid()) || session.did;

    // One over the limit, so the page can say it is showing a prefix without
    // reading a second page it will not render.
    const { records } = await actorStorage.listRecords(
      collection,
      null,
      ACCOUNT_SPACE_RECORD_LIMIT + 1,
    );
    const shown = records.slice(0, ACCOUNT_SPACE_RECORD_LIMIT);

    return htmlResponse(
      renderRecordsPage({
        hostname: url.host,
        section: '/account/repo',
        back: { href: '/account/repo', label: 'Repository' },
        collection,
        subtitles: ['Public records, readable by anyone through the sync API.'],
        records: shown.map((record) => ({
          uri: record.uri,
          cid: record.cid,
          value: toDisplayValue(
            cborDecode(
              record.value instanceof Uint8Array
                ? record.value
                : new Uint8Array(record.value),
            ),
          ),
        })),
        // Image blobs referenced by a record render inline, from this
        // server's own blob store
        blobUrl: (cid) =>
          `/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`,
        truncated: records.length > ACCOUNT_SPACE_RECORD_LIMIT,
      }),
    );
  }

  /**
   * GET /account/repo - What this server holds for the account
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountRepo(request, url) {
    const session = await requireAccountSession(request, url);
    if ('response' in session) return session.response;

    return htmlResponse(
      renderRepoPage({
        hostname: url.host,
        collections: await countRecordsByCollection(),
        blobs: await countBlobs(),
        commit: await actorStorage.getLatestCommit(),
      }),
    );
  }

  /**
   * The identity page, optionally carrying the outcome of a status action.
   * @param {URL} url
   * @param {string} did
   * @param {string} [error]
   * @returns {Promise<Response>}
   */
  async function identityResponse(url, did, error = '') {
    const credentials = await getRecommendedDidCredentials(url.hostname);
    return htmlResponse(
      renderIdentityPage({
        hostname: url.host,
        did,
        handle: await actorStorage.getHandle(),
        status: (await actorStorage.getAccountStatus()) || 'active',
        signingKey: credentials?.verificationMethods?.atproto || null,
        // What a PLC operation would publish. Unconfigured, that is this
        // request's own origin rather than the https:// the directory wants.
        pdsEndpoint: hostname
          ? `https://${hostname}`
          : `${url.protocol}//${url.host}`,
        plcUrl: plcUrl,
        readOnly: readOnly,
        error,
      }),
      error ? 400 : 200,
    );
  }

  /**
   * GET /account/identity - Published identity and account status
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountIdentity(request, url) {
    const session = await requireAccountSession(request, url);
    if ('response' in session) return session.response;
    return identityResponse(url, session.did);
  }

  /**
   * POST /account/status - Activate or deactivate the account
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountStatus(request, url) {
    const params = new URLSearchParams(await request.text());
    if (!isSameOriginPost(request, url)) {
      return new Response('Forbidden', { status: 403 });
    }

    const session = await requireAccountSession(request, url);
    if ('response' in session) return session.response;

    if (readOnly) {
      return identityResponse(url, session.did, 'This PDS is read-only.');
    }

    const action = params.get('action');
    if (action !== 'activate' && action !== 'deactivate') {
      return identityResponse(url, session.did, 'Unknown status action.');
    }

    // The same path com.atproto.server.{activate,deactivate}Account takes, so
    // the firehose sees an account event either way it was triggered.
    await setAccountActive(action === 'activate');

    return new Response(null, {
      status: 303,
      headers: {
        Location: '/account/identity',
        'Cache-Control': 'no-store',
      },
    });
  }

  /**
   * POST /account/sign-in - Verify the account password, start a cookie session
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountSignIn(request, url) {
    const params = new URLSearchParams(await request.text());
    if (!isSameOriginPost(request, url)) {
      return new Response('Forbidden', { status: 403 });
    }

    const identifier = (params.get('username') || '').trim();
    const password = params.get('password') || '';

    /** @param {string} error @param {number} status */
    const reject = (error, status) => signInResponse(url, { error, status });

    const did = await getDid();
    if (!did) return reject('This PDS is not initialized yet.', 400);

    const handle = await actorStorage.getHandle();
    const claimed = identifier.replace(/^@/, '');
    if (claimed !== did && claimed !== handle) {
      return reject('Invalid identifier', 401);
    }

    // Only the account password reaches these pages. App passwords hold a
    // restricted session scope, and the account screen is account management.
    if (!(await passwordConfigured())) {
      return reject(
        'Password login is not configured on this server. Set the password option (PDS_PASSWORD).',
        401,
      );
    }
    if (!(await verifyAccountPassword(password))) {
      return reject('Invalid password', 401);
    }

    const token = await createAccountJwt(did, jwtSecret, ACCOUNT_SESSION_TTL);
    return new Response(null, {
      status: 303,
      headers: {
        Location: '/account',
        'Cache-Control': 'no-store',
        'Set-Cookie': accountCookie(token, url.protocol === 'https:'),
      },
    });
  }

  /**
   * POST /account/sign-out - Clear the cookie session
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleAccountSignOut(request, url) {
    await drainRequestBody(request);
    if (!isSameOriginPost(request, url)) {
      return new Response('Forbidden', { status: 403 });
    }

    return new Response(null, {
      status: 303,
      headers: {
        Location: '/account',
        'Cache-Control': 'no-store',
        'Set-Cookie': accountCookie('', url.protocol === 'https:'),
      },
    });
  }

  return {
    routes: {
      '/account': {
        method: 'GET',
        handler: handleAccountPage,
      },
      '/account/sign-in': {
        method: 'POST',
        handler: handleAccountSignIn,
      },
      '/account/sign-out': {
        method: 'POST',
        handler: handleAccountSignOut,
      },
      '/account/apps': {
        method: 'GET',
        handler: handleAccountApps,
      },
      '/account/apps/revoke': {
        method: 'POST',
        handler: handleAccountAppRevoke,
      },
      '/account/app-passwords': {
        method: 'GET',
        handler: handleAccountAppPasswords,
      },
      '/account/app-passwords/create': {
        method: 'POST',
        handler: handleAccountAppPasswordCreate,
      },
      '/account/app-passwords/revoke': {
        method: 'POST',
        handler: handleAccountAppPasswordRevoke,
      },
      '/account/api/overview': {
        method: 'GET',
        handler: handleApiOverview,
      },
      '/account/api/version': {
        method: 'GET',
        handler: handleApiVersion,
      },
      '/account/api/activity': {
        method: 'GET',
        handler: handleApiActivity,
      },
      '/account/api/lexicon-status': {
        method: 'GET',
        handler: handleApiLexiconStatus,
      },
      '/account/api/lexicon-schema': {
        method: 'GET',
        handler: handleApiLexiconSchema,
      },
      '/account/api/apps': {
        method: 'GET',
        handler: handleApiApps,
      },
      '/account/api/apps/revoke': {
        method: 'POST',
        handler: handleApiAppsRevoke,
      },
      '/account/api/apps/alias': {
        method: 'POST',
        handler: handleApiAppsAlias,
      },
      '/account/api/settings/appearance': {
        method: 'POST',
        handler: handleApiSettingsAppearance,
      },
      '/account/api/settings/release-channel': {
        method: 'POST',
        handler: handleApiSettingsReleaseChannel,
      },
      '/account/api/password': {
        method: 'POST',
        handler: handleApiPassword,
      },
      '/account/api/sessions': {
        method: 'GET',
        handler: handleApiSessions,
      },
      '/account/api/sessions/revoke': {
        method: 'POST',
        handler: handleApiSessionsRevoke,
      },
      '/account/api/sessions/revoke-all': {
        method: 'POST',
        handler: handleApiSessionsRevokeAll,
      },
      '/account/api/app-passwords': {
        method: 'GET',
        handler: handleApiAppPasswords,
      },
      '/account/api/app-passwords/create': {
        method: 'POST',
        handler: handleApiAppPasswordsCreate,
      },
      '/account/api/app-passwords/revoke': {
        method: 'POST',
        handler: handleApiAppPasswordsRevoke,
      },
      '/account/api/passkeys': {
        method: 'GET',
        handler: handleApiPasskeys,
      },
      '/account/api/email': {
        method: 'GET',
        handler: handleApiEmail,
      },
      '/account/api/email/action': {
        method: 'POST',
        handler: handleApiEmailAction,
      },
      '/account/api/passkeys/revoke': {
        method: 'POST',
        handler: handleApiPasskeysRevoke,
      },
      '/account/api/identity': {
        method: 'GET',
        handler: handleApiIdentity,
      },
      '/account/api/identity/handle': {
        method: 'POST',
        handler: handleApiIdentityHandle,
      },
      '/account/api/status': {
        method: 'POST',
        handler: handleApiStatus,
      },
      '/account/api/records': {
        method: 'GET',
        handler: handleApiRecords,
      },
      '/account/api/blobs': {
        method: 'GET',
        handler: handleApiBlobs,
      },
      '/account/api/blobs/relink': {
        method: 'POST',
        handler: handleApiBlobsRelink,
      },
      // /account/api/backups{,/settings,/run} come from handlers/account-backup.js
      '/account/sessions': {
        method: 'GET',
        handler: handleAccountSessions,
      },
      '/account/sessions/revoke': {
        method: 'POST',
        handler: handleAccountSessionRevoke,
      },
      '/account/passkeys': {
        method: 'GET',
        handler: handleAccountPasskeys,
      },
      '/account/passkeys/register': {
        method: 'POST',
        handler: handleAccountPasskeyRegister,
      },
      '/account/passkeys/revoke': {
        method: 'POST',
        handler: handleAccountPasskeyRevoke,
      },
      '/account/passkey/challenge': {
        method: 'GET',
        handler: handleAccountPasskeyChallenge,
      },
      '/account/passkey/sign-in': {
        method: 'POST',
        handler: handleAccountPasskeySignIn,
      },
      '/account/email': {
        handler: handleAccountEmailRoute,
      },
      '/account/email/verify': {
        method: 'POST',
        handler: handleAccountEmailVerify,
      },
      '/account/email/cancel': {
        method: 'POST',
        handler: handleAccountEmailCancel,
      },
      '/account/email/remove': {
        method: 'POST',
        handler: handleAccountEmailRemove,
      },
      '/account/repo': {
        method: 'GET',
        handler: handleAccountRepo,
      },
      '/account/spaces': {
        method: 'GET',
        handler: handleAccountSpaces,
      },
      '/account/spaces/records': {
        method: 'GET',
        handler: handleAccountSpaceRecords,
      },
      '/account/repo/records': {
        method: 'GET',
        handler: handleAccountRepoRecords,
      },
      '/account/identity': {
        method: 'GET',
        handler: handleAccountIdentity,
      },
      '/account/status': {
        method: 'POST',
        handler: handleAccountStatus,
      },
    },
    readAccountSession,
    renewAccountSession,
    readAccountUiState,
    accountAppResponse,
    accountApi,
    listConnectedApps,
  };
}