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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
809 lines · 27.3 KB Raw History
// @pdsjs/core/handlers/xrpc-identity - com.atproto.identity.*: handle
// resolution and renaming, DID credential recommendation, and the PLC
// operation flow (request a mailed code, sign, submit).
//
// A handle counts only when it points back to this DID, and where it sits decides
// how that is established. See classifyHandle.

import { derivePublicKey, timingSafeEqual } from '../crypto.js';
import {
  EMAIL_CODE_TTL_MS,
  generateEmailCode,
  hashEmailCode,
  readEmailState,
  renderPlcOperationEmail,
} from '../email.js';
import { drainRequestBody } from '../http.js';
import { generateKemKey, kemDidKey } from '../kem.js';
import {
  computePlcOperationCid,
  publicKeyToDidKey,
  signPlcOperationObject,
} from '../plc.js';
import { isValidDid, isValidHandle } from '../validation.js';

/**
 * @typedef {Object} IdentityContext
 * @property {import('../ports.js').ActorStoragePort} actorStorage
 * @property {import('../ports.js').SharedStoragePort} sharedStorage
 * @property {string} [hostname]
 * @property {string} [plcUrl]
 * @property {string} jwtSecret
 * @property {boolean} readOnly
 * @property {import('../ports.js').EmailPort|null} emailer
 * @property {import('../ports.js').Secp256k1SignerPort|null} k256Signer
 * @property {() => Promise<string|null>} getDid
 * @property {() => Promise<import('../crypto.js').SigningKey|null>} getSigningKey
 * @property {() => Promise<boolean>} [isDeactivated]
 * @property {(request: Request) => Promise<Response|null>} handleProxyHeader
 * @property {(type: 'account'|'identity'|'sync', body: Object) => Promise<void>} emitEvent
 * @property {(request: Request) => Promise<any|Response>} readJsonBounded
 * @property {() => Response} readOnlyError
 */

/**
 * A PLC log entry, as the directory returns it.
 * @typedef {Object} PlcOperation
 * @property {string} type
 * @property {string[]} [rotationKeys]
 * @property {Record<string, string>} [verificationMethods]
 * @property {string[]} [alsoKnownAs]
 * @property {Record<string, {type: string, endpoint: string}>} [services]
 * @property {string|null} [prev]
 * @property {string} [sig]
 */

/**
 * @typedef {Object} RecommendedDidCredentials
 * @property {string[]} rotationKeys
 * @property {string[]} alsoKnownAs
 * @property {{atproto: string, atproto_kem?: string}} verificationMethods
 * @property {{atproto_pds: {type: string, endpoint: string}}} services
 */

/**
 * What a KEM key backfill did. `published` put the key in the PLC document.
 * `stored` kept a key this server cannot publish, which the owner must add to
 * their DID document. `skipped` changed nothing.
 * @typedef {'published'|'stored'|'skipped'} KemBackfillOutcome
 */

/**
 * @param {IdentityContext} ctx
 * @returns {{
 *   ownDomain: (requestHost?: string|null) => string,
 *   classifyHandle: (handle: string, requestHost?: string|null) => 'self'|'internal'|'external',
 *   resolveHandleDid: (handle: string) => Promise<string|null>,
 *   renameHandle: (requested: unknown, hostname?: string|null) => Promise<{handle: string, changed: boolean}>,
 *   getRecommendedDidCredentials: (fallbackHostname?: string|null) => Promise<RecommendedDidCredentials|null>,
 *   backfillKemKey: () => Promise<KemBackfillOutcome>,
 *   routes: import('../pds.js').Routes,
 * }}
 */
export function createIdentityHandlers(ctx) {
  const {
    actorStorage,
    sharedStorage,
    hostname: configuredHostname,
    plcUrl,
    jwtSecret,
    readOnly,
    emailer,
    k256Signer,
    getDid,
    getSigningKey,
    handleProxyHeader,
    emitEvent,
    readJsonBounded,
    readOnlyError,
  } = ctx;

  /**
   * com.atproto.identity.resolveHandle
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleResolveHandle(request, url) {
    const proxyResponse = await handleProxyHeader(request);
    if (proxyResponse) return proxyResponse;

    const handle = url.searchParams.get('handle');
    if (!handle) {
      return Response.json(
        { error: 'InvalidRequest', message: 'Missing handle parameter' },
        { status: 400 },
      );
    }

    const did = await sharedStorage.resolveHandle(handle);
    if (did) {
      return Response.json({ did });
    }

    const ourHandle = await actorStorage.getHandle();
    const ourDid = await getDid();
    if (ourHandle === handle && ourDid) {
      return Response.json({ did: ourDid });
    }

    return Response.json(
      { error: 'InvalidRequest', message: 'Handle not found' },
      { status: 400 },
    );
  }

  /**
   * The domain this server answers on: its configured hostname, or the host the
   * request arrived on when none is set. Lowercased, without a port.
   * @param {string|null} [requestHost]
   * @returns {string}
   */
  function ownDomain(requestHost = null) {
    return (configuredHostname || requestHost || '')
      .split(':')[0]
      .toLowerCase();
  }

  /**
   * Where a requested handle sits relative to this PDS, which decides how it is
   * verified:
   *   - 'self': the server's own address — self-verifying (it serves its own
   *     `/.well-known/atproto-did`), so it is taken as-is.
   *   - 'internal': a subdomain of that address — a single-user PDS answers only
   *     at its own host, so such a name would never resolve; not allowed here.
   *   - 'external': a domain elsewhere — must already resolve to this DID.
   * @param {string} handle
   * @param {string|null} [requestHost]
   * @returns {'self'|'internal'|'external'}
   */
  function classifyHandle(handle, requestHost = null) {
    const domain = ownDomain(requestHost);
    if (!domain) return 'external';
    if (handle === domain) return 'self';
    if (handle.endsWith(`.${domain}`)) return 'internal';
    return 'external';
  }

  /**
   * Resolve a handle to a DID the way the network does, so a handle on a domain
   * this server does not host can be verified before it is written into the DID
   * document: the DNS TXT record at `_atproto.<handle>` first, then the HTTPS
   * well-known. Returns the DID, or null when it points nowhere we can see.
   * @param {string} handle
   * @returns {Promise<string|null>}
   */
  async function resolveHandleDid(handle) {
    // DNS-over-HTTPS TXT lookup for _atproto.<handle> ("did=did:...")
    try {
      const name = encodeURIComponent(`_atproto.${handle}`);
      const res = await fetch(
        `https://cloudflare-dns.com/dns-query?name=${name}&type=TXT`,
        { headers: { Accept: 'application/dns-json' } },
      );
      if (res.ok) {
        const data = await res.json();
        for (const answer of data.Answer ?? []) {
          if (answer.type !== 16) continue; // 16 = TXT
          const value = String(answer.data ?? '').replace(/^"|"$/g, '');
          const match = value.match(/^did=(.+)$/);
          if (match && isValidDid(match[1])) return match[1];
        }
      }
    } catch {
      // DNS unreachable: fall through to the well-known method
    }

    // HTTPS well-known: https://<handle>/.well-known/atproto-did returns the DID
    try {
      const res = await fetch(`https://${handle}/.well-known/atproto-did`, {
        redirect: 'error',
      });
      if (res.ok) {
        const did = (await res.text()).trim();
        if (isValidDid(did)) return did;
      }
    } catch {
      // Not served, or not reachable
    }

    return null;
  }

  /**
   * Change the account handle: point the DID document's alsoKnownAs at the new
   * handle with a did:plc operation, persist it, and announce an #identity
   * firehose event so consumers re-resolve. Shared by the XRPC endpoint and the
   * account page; throws with a caller-facing message on any failure.
   * @param {unknown} requested - The requested handle
   * @param {string|null} [hostname] - Hostname for the recommended PDS endpoint
   * @returns {Promise<{handle: string, changed: boolean}>}
   */
  async function renameHandle(requested, hostname = null) {
    // atproto stores handles lowercased; normalize before validating so a
    // mixed-case request resolves the same as the value we persist.
    const handle =
      typeof requested === 'string' ? requested.toLowerCase() : requested;
    if (!isValidHandle(handle)) throw new Error('Invalid handle');

    const did = await getDid();
    const signingKey = await getSigningKey();
    if (!did || !signingKey) throw new Error('PDS not initialized');

    // Refuse a handle another account on this server already holds.
    const owner = await sharedStorage.resolveHandle(handle);
    if (owner && owner !== did) throw new Error('Handle is already in use');

    // Nothing to do when the handle is unchanged.
    const current = await actorStorage.getHandle();
    if (current === handle) return { handle, changed: false };

    // A handle only counts if it points back to this DID. Where it sits decides
    // how that is established (see classifyHandle). A subdomain of our own
    // address is refused outright; any other outside domain must already resolve
    // to this DID. This is also what stops an arbitrary value like "blah.blah".
    const scope = classifyHandle(handle, hostname);
    if (scope === 'internal') {
      const domain = ownDomain(hostname);
      throw new Error(
        `${handle} is a subdomain of this server's own address (${domain}). A ` +
          `single-user PDS only answers at ${domain} itself, so this name would ` +
          `never resolve unless you separately served ` +
          `https://${handle}/.well-known/atproto-did returning ${did}. To change ` +
          `your username, use a domain you own and verify it with DNS instead.`,
      );
    }
    if (scope === 'external') {
      const resolved = await resolveHandleDid(handle);
      if (resolved !== did) {
        throw new Error(
          `${handle} does not point to this account yet. Add a DNS TXT record ` +
            `at _atproto.${handle} with the value "did=${did}", or serve ${did} ` +
            `from https://${handle}/.well-known/atproto-did, then try again.`,
        );
      }
    }
    // scope === 'self' → the server's own address, taken as-is

    // did:web documents are maintained out of band; only did:plc identities get
    // an operation submitted on their behalf here.
    if (did.startsWith('did:plc:')) {
      const recommended = await getRecommendedDidCredentials(hostname);
      if (!recommended) throw new Error('PDS not initialized');

      // Chain onto the current operation in the DID's PLC log.
      let prev = null;
      try {
        const logRes = await fetch(
          `${plcUrl}/${encodeURIComponent(did)}/log/last`,
        );
        if (logRes.ok) {
          const last = await logRes.json();
          prev = last?.cid ?? (await computePlcOperationCid(last));
        }
      } catch {
        // No existing log entry: nothing to chain onto
      }

      const operation = await signPlcOperationObject(
        {
          type: 'plc_operation',
          rotationKeys: recommended.rotationKeys,
          verificationMethods: recommended.verificationMethods,
          alsoKnownAs: [`at://${handle}`],
          services: recommended.services,
          prev,
        },
        signingKey,
      );

      const res = await fetch(`${plcUrl}/${encodeURIComponent(did)}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(operation),
      });
      if (!res.ok) {
        const text = await res.text();
        throw new Error(
          `PLC directory rejected operation: ${res.status} ${text}`,
        );
      }
    }

    // Persist the handle and refresh the handle -> DID resolution mapping,
    // preserving the account's original creation time. If this fails after the
    // PLC op above, the caller can safely retry.
    const existing = await sharedStorage.getActor(did);
    await actorStorage.setHandle(handle);
    await sharedStorage.putActor(
      did,
      handle,
      existing?.createdAt ?? Date.now(),
    );

    // Tell the firehose the identity changed so consumers re-resolve.
    await emitEvent('identity', { handle });

    return { handle, changed: true };
  }

  /**
   * com.atproto.identity.updateHandle
   * @param {Request} request
   * @param {URL} url
   * @param {import('../pds.js').Auth|null} auth
   * @returns {Promise<Response>}
   */
  async function handleUpdateHandle(request, url, auth) {
    if (readOnly) return readOnlyError();

    const body = await readJsonBounded(request);
    if (body instanceof Response) return body;

    if (!auth) {
      return Response.json(
        { error: 'AuthenticationRequired', message: 'Authentication required' },
        { status: 401 },
      );
    }

    try {
      await renameHandle(body.handle, url.hostname);
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      return Response.json(
        { error: 'InvalidRequest', message },
        { status: 400 },
      );
    }

    return Response.json({});
  }

  /**
   * Build the DID credentials this PDS recommends for its account.
   * @param {string|null} [fallbackHostname] - Hostname to publish when none is
   *   configured, so an unconfigured server does not recommend
   *   `https://undefined` as its endpoint
   * @returns {Promise<RecommendedDidCredentials|null>}
   */
  async function getRecommendedDidCredentials(fallbackHostname = null) {
    const privateKey = await actorStorage.getPrivateKey();
    const handle = await actorStorage.getHandle();
    if (!privateKey) return null;

    const { curve, publicKey } = await derivePublicKey(privateKey, k256Signer);
    const didKey = publicKeyToDidKey(publicKey, curve);

    // Preserve the DID's existing rotation keys alongside this server's. Those
    // keys are what can recover or repoint the identity; dropping them would
    // mean that losing this server's key locks the owner out of their DID
    // forever. This server's key goes first so it is always included even if
    // the DID is already at the PLC limit of five, and so this server can sign
    // its own operations; the owner's recovery keys follow. A handle change or
    // a migration onto this server both flow through here, so both are safe.
    const did = await getDid();
    const existing = did ? await fetchRotationKeys(did) : [];
    const rotationKeys = [didKey, ...existing]
      .filter((key, index, all) => all.indexOf(key) === index)
      .slice(0, 5);

    const kemKey = await actorStorage.getKemKey();
    return {
      rotationKeys,
      alsoKnownAs: handle ? [`at://${handle}`] : [],
      verificationMethods: kemKey
        ? { atproto: didKey, atproto_kem: await kemDidKey(kemKey) }
        : { atproto: didKey },
      services: {
        atproto_pds: {
          type: 'AtprotoPersonalDataServer',
          endpoint: `https://${configuredHostname || fallbackHostname}`,
        },
      },
    };
  }

  /**
   * The rotation keys currently registered for a DID in the PLC directory.
   * Best-effort: any failure returns an empty list so credential recommendation
   * degrades to this server's key alone rather than throwing.
   * @param {string} did
   * @returns {Promise<string[]>}
   */
  async function fetchRotationKeys(did) {
    if (!did.startsWith('did:plc:')) return [];
    try {
      const res = await fetch(`${plcUrl}/${encodeURIComponent(did)}/data`);
      if (!res.ok) return [];
      const data = await res.json();
      return Array.isArray(data?.rotationKeys) ? data.rotationKeys : [];
    } catch {
      return [];
    }
  }

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

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

  /**
   * com.atproto.identity.requestPlcOperationSignature
   *
   * Mails a confirmation code, which signPlcOperation then requires. A server
   * with no sender, or an account with no confirmed address, has no way to
   * reach anyone: it issues no code and the session stays the only
   * authorization, which is how this endpoint behaved before email existed.
   * @param {Request} request
   * @param {URL} url
   * @param {import('../pds.js').Auth|null} auth
   * @returns {Promise<Response>}
   */
  async function handleRequestPlcOperationSignature(request, url, auth) {
    await drainRequestBody(request);

    if (!auth) {
      return Response.json(
        { error: 'AuthenticationRequired', message: 'Authentication required' },
        { status: 401 },
      );
    }

    // A PLC operation can move the account to another server or swap its
    // keys, so the reference PDS mails a code before signing one. Here that
    // needs both a sender and an address proven reachable; short of that the
    // session remains the only authorization, as it was before email existed.
    if (!(await canConfirmByEmail())) return Response.json({});

    const state = await readEmailState(actorStorage);
    const address = /** @type {string} */ (state.address);
    const code = generateEmailCode();
    await actorStorage.setPlcToken({
      codeHash: await hashEmailCode(address, code, jwtSecret),
      expiresAt: Date.now() + EMAIL_CODE_TTL_MS,
    });

    try {
      const message = renderPlcOperationEmail({ code, hostname: url.host });
      await /** @type {import('../ports.js').EmailPort} */ (emailer).send({
        to: address,
        ...message,
      });
    } catch (err) {
      await actorStorage.setPlcToken(null);
      console.error('Failed to send PLC confirmation email:', err);
      return Response.json(
        {
          error: 'InternalServerError',
          message: 'Could not send the confirmation code',
        },
        { status: 502 },
      );
    }

    return Response.json({});
  }

  /**
   * Whether this server can prove a request reached the account holder: it
   * needs a sender and an address that completed a code exchange.
   * @returns {Promise<boolean>}
   */
  async function canConfirmByEmail() {
    if (!emailer || !actorStorage.setPlcToken) return false;
    const state = await readEmailState(actorStorage);
    return Boolean(state.verified && state.address);
  }

  /**
   * Check the code mailed by requestPlcOperationSignature and spend it.
   * @param {string} token - The code the caller supplied
   * @returns {Promise<Response|null>} An error response, or null when allowed
   */
  async function checkPlcToken(token) {
    if (!(await canConfirmByEmail())) return null;

    const stored = await actorStorage.getPlcToken?.();
    if (!stored || stored.expiresAt <= Date.now()) {
      return Response.json(
        {
          error: 'InvalidToken',
          message:
            'Ask for a confirmation code first with requestPlcOperationSignature',
        },
        { status: 400 },
      );
    }

    const state = await readEmailState(actorStorage);
    const candidate = await hashEmailCode(
      /** @type {string} */ (state.address),
      (token || '').trim(),
      jwtSecret,
    );
    if (!(await timingSafeEqual(candidate, stored.codeHash))) {
      return Response.json(
        { error: 'InvalidToken', message: 'Confirmation code is invalid' },
        { status: 400 },
      );
    }

    // Spent: one code signs one operation
    await actorStorage.setPlcToken(null);
    return null;
  }

  /**
   * com.atproto.identity.signPlcOperation
   * @param {Request} request
   * @param {URL} url
   * @param {import('../pds.js').Auth|null} auth
   * @returns {Promise<Response>}
   */
  async function handleSignPlcOperation(request, url, auth) {
    const body = await readJsonBounded(request);
    if (body instanceof Response) return body;

    if (!auth) {
      return Response.json(
        { error: 'AuthenticationRequired', message: 'Authentication required' },
        { status: 401 },
      );
    }

    const tokenError = await checkPlcToken(body.token);
    if (tokenError) return tokenError;

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

    // Chain onto the current operation in the DID's PLC log
    let prev = null;
    try {
      const logRes = await fetch(
        `${plcUrl}/${encodeURIComponent(did)}/log/last`,
      );
      if (logRes.ok) {
        const last = await logRes.json();
        prev = last?.cid ?? (await computePlcOperationCid(last));
      }
    } catch {
      // No existing log entry: this signs a genesis operation
    }

    // Caller-supplied fields win over the recommended defaults
    const operation = {
      type: 'plc_operation',
      rotationKeys: body.rotationKeys ?? recommended.rotationKeys,
      verificationMethods:
        body.verificationMethods ?? recommended.verificationMethods,
      alsoKnownAs: body.alsoKnownAs ?? recommended.alsoKnownAs,
      services: body.services ?? recommended.services,
      prev,
    };

    const signed = await signPlcOperationObject(operation, signingKey);
    return Response.json({ operation: signed });
  }

  /**
   * com.atproto.identity.submitPlcOperation
   * @param {Request} request
   * @param {URL} _url
   * @param {import('../pds.js').Auth|null} auth
   * @returns {Promise<Response>}
   */
  async function handleSubmitPlcOperation(request, _url, auth) {
    const body = await readJsonBounded(request);
    if (body instanceof Response) return body;

    if (!auth) {
      return Response.json(
        { error: 'AuthenticationRequired', message: 'Authentication required' },
        { status: 401 },
      );
    }

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

    if (!body.operation) {
      return Response.json(
        { error: 'InvalidRequest', message: 'operation is required' },
        { status: 400 },
      );
    }

    const kemKey = await actorStorage.getKemKey();
    if (
      kemKey &&
      body.operation.verificationMethods?.atproto_kem !==
        (await kemDidKey(kemKey))
    ) {
      return Response.json(
        { error: 'InvalidRequest', message: 'Incorrect KEM key' },
        { status: 400 },
      );
    }

    const res = await fetch(`${plcUrl}/${encodeURIComponent(did)}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body.operation),
    });

    if (!res.ok) {
      const text = await res.text();
      return Response.json(
        {
          error: 'InvalidRequest',
          message: `PLC directory rejected operation: ${res.status} ${text}`,
        },
        { status: 400 },
      );
    }

    // The DID document just changed (e.g. rotation/signing keys, PDS endpoint).
    // Announce an #identity event so consumers re-resolve rather than keep a
    // stale signing key that would fail service-token verification.
    const handle = await actorStorage.getHandle();
    await emitEvent('identity', { handle: handle || null });

    return Response.json({});
  }

  /**
   * Give an account created before KEM keys existed its `#atproto_kem` key.
   *
   * The key is stored only after the PLC directory accepts it, so a failed
   * publication throws and the next run tries again. An identity this server
   * cannot update still gets a key, and its owner must publish it.
   * @returns {Promise<KemBackfillOutcome>}
   */
  async function backfillKemKey() {
    if (readOnly) return 'skipped';
    const did = await getDid();
    if (!did || (await actorStorage.getKemKey())) return 'skipped';
    // An account that moved away is no longer this server's to update.
    if (await ctx.isDeactivated?.()) return 'skipped';
    const signingKey = await getSigningKey();
    const privateKey = await actorStorage.getPrivateKey();
    if (!signingKey || !privateKey) return 'skipped';

    const kemKey = await generateKemKey();
    const last = did.startsWith('did:plc:') ? await fetchLastOp(did) : null;
    const { curve, publicKey } = await derivePublicKey(privateKey, k256Signer);
    const serverKey = publicKeyToDidKey(publicKey, curve);
    const endpoint = configuredHostname
      ? `https://${configuredHostname}`
      : null;
    const canPublish =
      last !== null &&
      last.type === 'plc_operation' &&
      Array.isArray(last.rotationKeys) &&
      last.rotationKeys.includes(serverKey) &&
      (endpoint === null || last.services?.atproto_pds?.endpoint === endpoint);

    if (canPublish) {
      const operation = await signPlcOperationObject(
        {
          ...last,
          verificationMethods: {
            ...last.verificationMethods,
            atproto_kem: kemKey.didKey,
          },
          prev: await computePlcOperationCid(last),
        },
        signingKey,
      );
      const res = await fetch(`${plcUrl}/${encodeURIComponent(did)}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(operation),
      });
      if (!res.ok) {
        const text = await res.text();
        throw new Error(
          `PLC directory rejected the KEM key: ${res.status} ${text}`,
        );
      }
    } else {
      console.warn(
        `Cannot publish a KEM key for ${did}: its DID document must be updated to include #atproto_kem ${kemKey.didKey}`,
      );
    }

    await actorStorage.setKemKey(kemKey.privateKey);
    if (canPublish) {
      const handle = await actorStorage.getHandle();
      await emitEvent('identity', { handle: handle || null });
      return 'published';
    }
    return 'stored';
  }

  /**
   * The latest operation in a DID's PLC log.
   * @param {string} did
   * @returns {Promise<PlcOperation>}
   * @throws when the directory cannot be read, so the caller retries later
   */
  async function fetchLastOp(did) {
    const res = await fetch(`${plcUrl}/${encodeURIComponent(did)}/log/last`);
    if (!res.ok) {
      throw new Error(`Could not read the PLC log for ${did}: ${res.status}`);
    }
    return /** @type {PlcOperation} */ (await res.json());
  }

  return {
    ownDomain,
    classifyHandle,
    resolveHandleDid,
    renameHandle,
    getRecommendedDidCredentials,
    backfillKemKey,
    routes: {
      '/xrpc/com.atproto.identity.resolveHandle': {
        handler: handleResolveHandle,
      },
      '/xrpc/com.atproto.identity.updateHandle': {
        method: 'POST',
        auth: 'required',
        handler: handleUpdateHandle,
      },
      '/xrpc/com.atproto.identity.getRecommendedDidCredentials': {
        auth: 'required',
        handler: handleGetRecommendedDidCredentials,
      },
      '/xrpc/com.atproto.identity.requestPlcOperationSignature': {
        method: 'POST',
        auth: 'required',
        handler: handleRequestPlcOperationSignature,
      },
      '/xrpc/com.atproto.identity.signPlcOperation': {
        method: 'POST',
        auth: 'required',
        handler: handleSignPlcOperation,
      },
      '/xrpc/com.atproto.identity.submitPlcOperation': {
        method: 'POST',
        auth: 'required',
        handler: handleSubmitPlcOperation,
      },
    },
  };
}