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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
1297 lines · 45.6 KB Raw History
/**
 * Git hosting operations over the host's storage: the repository listing,
 * creation and settings, the check reads and the check requests. The
 * dev.pdsjs.git XRPC methods serve these.
 *
 * This module must stay free of node-only imports; it runs on Cloudflare
 * Workers.
 */

import {
  cborDecode,
  cborEncodeDagCbor,
  cidToString,
  createBlobCid,
  createCid,
  toDisplayValue,
} from '@pdsjs/core/repo';
import { parseIdentityRecord } from './authors.js';
import { resolveRepoLocation } from './identity.js';
import { GIT_IDENTITY_COLLECTION, GIT_SPACE_TYPE } from './lexicon.js';
import { createGitMerger } from './merger.js';
import {
  createRepoKey,
  isValidRepoName,
  repoNameOf,
  rkeyOf,
} from './record.js';
import { linkedPackagesOf, PACKAGE_REGISTRIES, runnerOf } from './rules.js';

/** Where profiles are read from, absent a configured AppView. */
const DEFAULT_APP_VIEW = 'https://public.api.bsky.app';

/** The AppView answers at most this many actors per call. */
const PROFILE_BATCH = 25;

/** How long a profile read stands before the AppView is asked again. */
const PROFILE_TTL_MS = 5 * 60 * 1000;

/** A profile read slower than this costs the face, not the answer. */
const PROFILE_READ_TIMEOUT_MS = 5000;

/**
 * The config record a repository settings write produces.
 *
 * The record carries every repository setting, and one request covers one
 * setting. Fields the request leaves out are copied from the record it
 * replaces, including fields this server does not recognize.
 * @param {Object|null} existing - the config record being replaced
 * @param {Object} changes
 * @param {string} changes.name
 * @param {string[]} [changes.protectedBranches]
 * @param {string|null} [changes.runner] - null clears the field
 * @param {Array<{registry: string, name: string}>} [changes.packages] - an
 *   empty list clears the field
 * @returns {Record<string, unknown>}
 */
export function mergeGitConfig(existing, changes) {
  const previous = /** @type {{createdAt?: unknown}} */ (existing ?? {});
  /** @type {Record<string, unknown>} */
  const config = {
    ...previous,
    $type: 'dev.pdsjs.git.config',
    name: changes.name,
    createdAt:
      typeof previous.createdAt === 'string'
        ? previous.createdAt
        : new Date().toISOString(),
    updatedAt: new Date().toISOString(),
  };
  if (changes.protectedBranches !== undefined) {
    config.protectedBranches = changes.protectedBranches;
  }
  if (changes.runner === null) delete config.runner;
  else if (changes.runner !== undefined) config.runner = changes.runner;
  if (changes.packages !== undefined) {
    if (changes.packages.length === 0) delete config.packages;
    else config.packages = changes.packages;
  }
  return config;
}

/**
 * A refusal the XRPC methods name by code; the account routes report the
 * message alone.
 * @param {string} code
 * @param {string} message
 */
const refuse = (code, message) => Object.assign(new Error(message), { code });

/** Characters an atproto record key permits. */
const RKEY_SAFE = /[^A-Za-z0-9._~:-]/g;

/** Branches one repository page asks the runner about. */
const MAX_CHECKED_BRANCHES = 20;

/** Repositories read per page when a name is resolved. */
const MAX_REPOS = 100;

/** Pages of them read before the index stands as it is. */
const MAX_REPO_PAGES = 20;

/** A runner that answers slower than this costs its badge, not the page. */
const CHECK_READ_TIMEOUT_MS = 5000;

/**
 * The record key holding the latest check for one ref of one repository.
 * `latestCheckKey` in @pdsjs/git-ci is the same derivation, and is the copy
 * the runner writes with. Both must agree or a check never resolves.
 * @param {string} did - repository owner
 * @param {string} repoName
 * @param {string} ref - full ref name
 * @returns {string}
 */
export function latestCheckKey(did, repoName, ref) {
  return `${did}:${repoName}:${ref}`.replace(RKEY_SAFE, '_');
}

/**
 * The per-workflow entries of a latest-check record. A record written
 * before entries existed carries one workflow's fields at the top level;
 * it reads as one entry. `latestCheckEntries` in @pdsjs/git-ci is the same
 * normalization, and is the copy the runner writes against.
 * @param {Record<string, unknown>} value
 * @returns {Record<string, unknown>[]}
 */
export function latestCheckEntries(value) {
  if (Array.isArray(value.checks)) {
    return /** @type {Record<string, unknown>[]} */ (value.checks);
  }
  if (typeof value.status !== 'string') return [];
  return [{ workflow: 'ci', ...value }];
}

/**
 * The record key of a check request for one ref of one repository. The
 * request is in this account's own repo, so only the repository and the ref
 * name it. `checkRequestKey` in @pdsjs/git-ci is the same derivation, and is
 * the copy the runner reads with. Both must agree or asking twice leaves two
 * records and the runner checks twice.
 * @param {string} repoName
 * @param {string} ref - full ref name
 * @returns {string}
 */
export function checkRequestKey(repoName, ref) {
  return `${repoName}:${ref}`.replace(RKEY_SAFE, '_');
}

/**
 * The atproto PDS endpoint a DID document names.
 * @param {unknown} doc
 * @returns {string|null}
 */
export function pdsEndpointOf(doc) {
  const services = /** @type {{service?: unknown}} */ (doc ?? {}).service;
  if (!Array.isArray(services)) return null;
  for (const entry of services) {
    const service = /** @type {Record<string, unknown>} */ (entry ?? {});
    const id = typeof service.id === 'string' ? service.id : '';
    if (
      id.endsWith('#atproto_pds') &&
      typeof service.serviceEndpoint === 'string'
    ) {
      return service.serviceEndpoint.replace(/\/+$/, '');
    }
  }
  return null;
}

/**
 * The handle a DID document publishes, or the DID where it publishes none.
 * @param {unknown} doc
 * @param {string} did
 * @returns {string}
 */
export function handleOf(doc, did) {
  const aka = /** @type {{alsoKnownAs?: unknown}|null} */ (doc)?.alsoKnownAs;
  const name = (Array.isArray(aka) ? aka : []).find((entry) =>
    String(entry).startsWith('at://'),
  );
  return name ? String(name).slice('at://'.length) : did;
}

/** Pages of the runner's runs read before the rest are left. */
const CHECK_RUN_PAGES = 10;

/**
 * The runner's runs about one repository, newest first, each carrying the
 * record key that addresses its page. The runner keys runs by TID in one
 * collection, so the listing is filtered here rather than by its server.
 * @param {unknown[]} records - listRecords entries from the runner
 * @param {string} subjectUri - the repository record's AT-URI
 * @returns {Array<Record<string, unknown>>}
 */
export function checkRunsOf(records, subjectUri) {
  /** @type {Array<Record<string, unknown> & {startedAt: string}>} */
  const runs = [];
  for (const entry of records) {
    const row = /** @type {{value?: unknown, uri?: unknown}|null} */ (entry);
    const check = /** @type {Record<string, unknown>} */ (row?.value ?? {});
    const subject = /** @type {{uri?: unknown}} */ (check.subject ?? {});
    if (subject.uri !== subjectUri) continue;
    if (typeof check.sha !== 'string' || typeof check.startedAt !== 'string') {
      continue;
    }
    const rkey = String(row?.uri ?? '')
      .split('/')
      .pop();
    runs.push(
      /** @type {Record<string, unknown> & {startedAt: string}} */ (
        rkey ? { ...check, rkey } : check
      ),
    );
  }
  return runs.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
}

/** How long a resolved account is trusted before its document is read again. */
const CONTRIBUTOR_TTL_MS = 5 * 60 * 1000;

/**
 * The part of the space admin port git hosting needs.
 * @typedef {Pick<import('@pdsjs/core/ports').SpaceAdminPort, 'createSpace'|'createRecord'|'putRecord'|'getRecord'|'deleteRecord'|'removeMember'>} GitSpaceAdmin
 */

/**
 * @param {Object} ctx
 * @param {import('@pdsjs/core/ports').ExtensionHost} ctx.host
 * @param {boolean} ctx.httpEnabled - Whether smart HTTP serves under /git/, so the listing can show plain-git clone URLs
 * @param {import('@pdsjs/core/ports').SpaceBrowserPort|null} ctx.spaceBrowser - Lists repositories held in spaces. Absent, only the public repo is listed.
 * @param {GitSpaceAdmin|null} ctx.spaceAdmin - Creates and configures repositories in spaces. Absent, those requests refuse.
 * @param {string} [ctx.appView] - the AppView profiles are read from
 */
export function createGitHosting({
  host,
  httpEnabled,
  spaceBrowser,
  spaceAdmin,
  appView = DEFAULT_APP_VIEW,
}) {
  const { actorStorage, blobs, blobUploadLimit, createCommit, plcUrl } = host;
  const reapOrphanedBlobs = host.reapOrphanedBlobs;
  // The server's composed write guard, which the git rules join. Every write
  // a git method makes into the public repo consults it, so a merge and a
  // settings change refuse what a protected branch refuses.
  const writeGuard = host.writeGuard;

  /**
   * @typedef {{did: string, handle: string, displayName: string, avatar: string}} Profile
   */

  /** Profiles by DID, each with when it was read. A DID the AppView does not know is kept as null, so it is not asked again for the TTL. */
  /** @type {Map<string, {profile: Profile|null, at: number}>} */
  const profiles = new Map();

  /**
   * The profiles behind a set of DIDs, from the cache or the AppView.
   * @param {string[]} dids
   * @returns {Promise<Map<string, Profile|null>>}
   */
  async function readProfiles(dids) {
    const now = Date.now();
    /** @type {Map<string, Profile|null>} */
    const found = new Map();
    const wanted = [];
    for (const did of new Set(dids)) {
      const held = profiles.get(did);
      if (held && now - held.at < PROFILE_TTL_MS) found.set(did, held.profile);
      else wanted.push(did);
    }
    for (let at = 0; at < wanted.length; at += PROFILE_BATCH) {
      const batch = wanted.slice(at, at + PROFILE_BATCH);
      const params = new URLSearchParams();
      for (const did of batch) params.append('actors', did);
      /** @type {Array<{did?: unknown, handle?: unknown, displayName?: unknown, avatar?: unknown}>} */
      let answered = [];
      try {
        const res = await fetch(
          `${appView}/xrpc/app.bsky.actor.getProfiles?${params}`,
          { signal: AbortSignal.timeout(PROFILE_READ_TIMEOUT_MS) },
        );
        if (res.ok) {
          answered =
            /** @type {{profiles?: typeof answered}} */ (await res.json())
              .profiles ?? [];
        }
      } catch {
        // An AppView that does not answer costs the faces, not the answer.
        continue;
      }
      for (const did of batch) found.set(did, null);
      for (const entry of answered) {
        if (typeof entry.did !== 'string') continue;
        found.set(entry.did, {
          did: entry.did,
          handle: typeof entry.handle === 'string' ? entry.handle : '',
          displayName:
            typeof entry.displayName === 'string' ? entry.displayName : '',
          avatar: typeof entry.avatar === 'string' ? entry.avatar : '',
        });
      }
      for (const did of batch) {
        profiles.set(did, { profile: found.get(did) ?? null, at: now });
      }
    }
    return found;
  }

  /**
   * The list-page summary of one git repo record's display value.
   * @param {{description?: unknown, defaultBranch?: unknown, refs?: unknown, bundles?: unknown, createdAt?: unknown, updatedAt?: unknown}} value
   */
  function summarizeGitRecord(value) {
    const refs = /** @type {Array<{name?: unknown, sha?: unknown}>} */ (
      Array.isArray(value.refs) ? value.refs : []
    ).filter(
      (ref) => typeof ref.name === 'string' && typeof ref.sha === 'string',
    );
    const bundles = /** @type {Array<{parts?: Array<{size?: unknown}>}>} */ (
      Array.isArray(value.bundles) ? value.bundles : []
    );
    let size = 0;
    let parts = 0;
    for (const bundle of bundles) {
      for (const part of bundle.parts ?? []) {
        parts += 1;
        if (typeof part.size === 'number') size += part.size;
      }
    }
    return {
      description:
        typeof value.description === 'string' ? value.description : null,
      defaultBranch:
        typeof value.defaultBranch === 'string' ? value.defaultBranch : null,
      refs,
      branches: refs.filter((ref) =>
        /** @type {string} */ (ref.name).startsWith('refs/heads/'),
      ).length,
      tags: refs.filter((ref) =>
        /** @type {string} */ (ref.name).startsWith('refs/tags/'),
      ).length,
      bundles: bundles.length,
      parts,
      size,
      createdAt: typeof value.createdAt === 'string' ? value.createdAt : null,
      updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : null,
    };
  }

  /**
   * The protected branch names a config record carries, for display; the
   * write guard is what enforces them.
   * @param {unknown} config
   * @returns {string[]}
   */
  function protectedBranchesOf(config) {
    const list = /** @type {{protectedBranches?: unknown}} */ (config ?? {})
      .protectedBranches;
    if (!Array.isArray(list)) return [];
    return list.filter((branch) => typeof branch === 'string');
  }

  /**
   * The config record's settings, as a repository listing reports them.
   * @param {unknown} config
   * @returns {{protectedBranches: string[], runner: string|null, packages: Array<{registry: string, name: string}>}}
   */
  function configSummary(config) {
    return {
      protectedBranches: protectedBranchesOf(config),
      runner: runnerOf(config),
      packages: linkedPackagesOf(config),
    };
  }

  /**
   * A record from the public repo, decoded raw.
   * @param {string} did @param {string} collection @param {string} rkey
   * @returns {Promise<Object|null>}
   */
  async function readPublicRecord(did, collection, rkey) {
    const record = await actorStorage.getRecord(
      `at://${did}/${collection}/${rkey}`,
    );
    if (!record) return null;
    return /** @type {Object} */ (
      cborDecode(
        record.value instanceof Uint8Array
          ? record.value
          : new Uint8Array(record.value),
      )
    );
  }

  /**
   * Write a record into the public repo through the ordinary commit path.
   * @param {string} did @param {string} collection @param {string} rkey
   * @param {Object} record
   * @returns {Promise<void>}
   */
  async function writePublicRecord(did, collection, rkey, record) {
    const uri = `at://${did}/${collection}/${rkey}`;
    const held = await actorStorage.getRecord(uri);
    // The guard reads the transition the way a bearer-token write would be
    // read, so a merge onto a protected branch is held to the same rule: a
    // fast-forward, never a force-push or a delete.
    if (writeGuard?.collections.includes(collection)) {
      const prevValue =
        held && held.value
          ? cborDecode(
              held.value instanceof Uint8Array
                ? held.value
                : new Uint8Array(held.value),
            )
          : null;
      await writeGuard.check({
        space: null,
        collection,
        rkey,
        prevValue,
        nextValue: record,
      });
    }
    const action = held ? 'update' : 'create';
    const encoded = cborEncodeDagCbor(record);
    const cid = cidToString(await createCid(encoded));
    await actorStorage.putBlock(cid, encoded);
    await actorStorage.putRecord(uri, cid, collection, rkey, encoded);
    await createCommit(did, [{ action, uri, cid }]);
  }

  /**
   * A DID's document, from the directory this server resolves against.
   * @param {string} did
   * @returns {Promise<unknown|null>}
   */
  async function readDidDocument(did) {
    const url = did.startsWith('did:web:')
      ? `https://${decodeURIComponent(did.slice('did:web:'.length))}/.well-known/did.json`
      : `${plcUrl}/${encodeURIComponent(did)}`;
    try {
      const res = await fetch(url, {
        signal: AbortSignal.timeout(CHECK_READ_TIMEOUT_MS),
      });
      if (!res.ok) return null;
      return await res.json();
    } catch {
      return null;
    }
  }

  /**
   * The PDS a DID publishes, from its DID document.
   * @param {string} did
   * @returns {Promise<string|null>}
   */
  async function resolvePdsEndpoint(did) {
    return pdsEndpointOf(await readDidDocument(did));
  }

  /** @type {Map<string, {at: number, who: import('./pull-requests.js').Contributor|null}>} */
  const contributors = new Map();

  /**
   * Where one account's records and blobs are read from, and what it is
   * called. An account with no document, or one naming no PDS, is null.
   * @param {string} did
   * @returns {Promise<import('./pull-requests.js').Contributor|null>}
   */
  async function resolveContributor(did) {
    const held = contributors.get(did);
    if (held && Date.now() - held.at < CONTRIBUTOR_TTL_MS) return held.who;
    const doc = await readDidDocument(did);
    const pds = pdsEndpointOf(doc);
    const who = pds ? { did, pds, handle: handleOf(doc, did) } : null;
    contributors.set(did, { at: Date.now(), who });
    return who;
  }

  /**
   * The record key of every repository this account holds, by name. Filled
   * from a listing, so it can be a name short and never wrong about one: a
   * repository the helper pushed never passes through this module.
   * @type {Map<string, string>}
   */
  const keysByName = new Map();

  /**
   * Read the listing and index it by name. Every page, because a name this
   * misses is a repository that answers as missing, where a listing that
   * stops short only shows fewer.
   */
  async function indexRepos() {
    /** @type {Map<string, string>} */
    const found = new Map();
    let cursor = null;
    for (let page = 0; page < MAX_REPO_PAGES; page += 1) {
      /** @type {{records: import('@pdsjs/core/ports').RecordResult[], cursor: string|null}} */
      const read = await actorStorage.listRecords(
        'dev.pdsjs.git.repo',
        cursor,
        MAX_REPOS,
      );
      for (const record of read.records) {
        const rkey = rkeyOf(record.uri);
        found.set(
          repoNameOf(
            cborDecode(
              record.value instanceof Uint8Array
                ? record.value
                : new Uint8Array(record.value),
            ),
            rkey,
          ),
          rkey,
        );
      }
      if (!read.cursor) break;
      cursor = read.cursor;
    }
    keysByName.clear();
    for (const [name, rkey] of found) keysByName.set(name, rkey);
  }

  /**
   * The repository one name addresses: its record key, its version and its
   * value.
   *
   * The name is an address and the key is an identity. Every public surface
   * takes a name; this is the one place that turns one into a key, and
   * everything below it works in keys. A rename moves the name and leaves
   * the key alone, so nothing that named the repository is rewritten.
   * @param {string} did
   * @param {string} name
   * @param {string|null} [space]
   * @returns {Promise<{rkey: string, cid: string, value: Object}|null>}
   */
  async function resolveRepo(did, name, space) {
    if (!name) return null;
    if (space) {
      if (!spaceBrowser) return null;
      const { records } = await spaceBrowser.listRecords(
        space,
        'dev.pdsjs.git.repo',
        MAX_REPOS,
      );
      const found = records.find(
        (record) => repoNameOf(record.value, record.rkey) === name,
      );
      return found
        ? {
            rkey: found.rkey,
            cid: found.cid,
            value: /** @type {Object} */ (found.value),
          }
        : null;
    }
    // Two passes: the index answers, and a name it does not hold, or holds
    // against a record that has since moved, is looked up again once.
    for (let pass = 0; pass < 2; pass += 1) {
      const rkey = keysByName.get(name);
      if (rkey !== undefined) {
        const record = await actorStorage.getRecord(
          `at://${did}/dev.pdsjs.git.repo/${rkey}`,
        );
        if (record) {
          const value = /** @type {Object} */ (
            cborDecode(
              record.value instanceof Uint8Array
                ? record.value
                : new Uint8Array(record.value),
            )
          );
          if (repoNameOf(value, rkey) === name) {
            return { rkey, cid: record.cid, value };
          }
        }
      }
      if (pass === 0) await indexRepos();
    }
    return null;
  }

  /**
   * Git repositories hosted in this account: dev.pdsjs.git.repo records
   * summarized for a listing, with clone URLs derived from the handle and
   * the request origin.
   * @param {string} did
   * @param {URL} url - the request URL, whose origin names the HTTP clone host
   * @param {Object} [options]
   * @param {boolean} [options.spaces] - whether to list the repositories held in spaces (default on)
   */
  async function listRepos(did, url, { spaces = true } = {}) {
    const authority = (await actorStorage.getHandle()) || did;
    const page = await actorStorage.listRecords(
      'dev.pdsjs.git.repo',
      null,
      100,
    );
    const repos = [];
    for (const record of page.records) {
      const value = /** @type {{name?: unknown}} */ (
        toDisplayValue(
          cborDecode(
            record.value instanceof Uint8Array
              ? record.value
              : new Uint8Array(record.value),
          ),
        )
      );
      const rkey = rkeyOf(record.uri);
      const name = repoNameOf(value, rkey);
      repos.push({
        name,
        uri: record.uri,
        cid: record.cid,
        space: null,
        spaceOwned: false,
        ...summarizeGitRecord(/** @type {any} */ (value)),
        // The config sits at the repository's own key, so the pair stays
        // together when the name moves.
        ...configSummary(
          await readPublicRecord(did, 'dev.pdsjs.git.config', rkey),
        ),
        cloneUrl: `atproto://${authority}/${name}`,
        httpCloneUrl: httpEnabled
          ? `${url.protocol}//${url.host}/git/${authority}/${name}`
          : null,
      });
    }

    // Repositories in spaces: this account's own record in each space it
    // holds, whether it is the space's authority or a member with a copy.
    // The remote URL names the space authority, which for a member copy is
    // another account, so it appears by DID rather than a handle.
    if (spaces && spaceBrowser) {
      for (const space of await spaceBrowser.listSpaces()) {
        if (space.deletedAt) continue;
        const { records } = await spaceBrowser.listRecords(
          space.uri,
          'dev.pdsjs.git.repo',
          100,
        );
        const configs = await spaceBrowser.listRecords(
          space.uri,
          'dev.pdsjs.git.config',
          100,
        );
        const configByRkey = new Map(
          configs.records.map((config) => [config.rkey, config.value]),
        );
        const spaceAuthority =
          space.spaceDid === did ? authority : space.spaceDid;
        for (const record of records) {
          const name = repoNameOf(record.value, record.rkey);
          repos.push({
            name,
            uri: `${space.uri}/${did}/dev.pdsjs.git.repo/${record.rkey}`,
            space: space.uri,
            spaceOwned: space.isOwner,
            ...summarizeGitRecord(/** @type {any} */ (record.value)),
            ...configSummary(configByRkey.get(record.rkey)),
            cloneUrl: `atproto://${spaceAuthority}/${name}`,
            httpCloneUrl: null,
          });
        }
      }
    }

    repos.sort((a, b) =>
      (b.updatedAt ?? b.createdAt ?? '').localeCompare(
        a.updatedAt ?? a.createdAt ?? '',
      ),
    );
    return { httpEnabled: Boolean(httpEnabled), repos };
  }

  /**
   * Create an empty repository, the way the hosted forges do: the record
   * exists at once with no refs and no bundles, so a page can hold the setup
   * instructions, and the first push fills it. A body naming a space, or a
   * type to create one from, makes it private.
   * @param {string} did
   * @param {Record<string, any>} body
   */
  async function createRepo(did, body) {
    const name = typeof body.name === 'string' ? body.name.trim() : '';
    if (!isValidRepoName(name)) {
      throw new Error('A repository name uses letters, digits, and . _ ~ -');
    }
    const description =
      typeof body.description === 'string' ? body.description.trim() : '';

    // The name is the address a clone URL and a page path carry, so one
    // repository holds it. The key is minted and says nothing about it.
    if (await nameTaken(did, name)) {
      throw refuse('RepoExists', `A repository named ${name} already exists.`);
    }
    const record = {
      $type: 'dev.pdsjs.git.repo',
      name,
      ...(description ? { description } : {}),
      refs: [],
      bundles: [],
      createdAt: new Date().toISOString(),
    };

    // A private repository is addressed by its space, which is keyed by the
    // name, so its record takes the name too and the pair is reachable by
    // anyone who knows it. A public one takes a minted key.
    if (body.private) {
      if (!spaceAdmin) throw new Error('Spaces are not enabled.');
      const space = await privateSpaceOf(did, name);
      await spaceAdmin.createRecord(space, 'dev.pdsjs.git.repo', name, record);
      return { name, space };
    }

    const rkey = createRepoKey();
    await writePublicRecord(did, 'dev.pdsjs.git.repo', rkey, record);
    keysByName.set(name, rkey);
    return { name, space: null };
  }

  /**
   * Whether this account already holds a repository of that name, in its own
   * repo or in a space.
   * @param {string} did
   * @param {string} name
   * @returns {Promise<boolean>}
   */
  async function nameTaken(did, name) {
    if (await resolveRepo(did, name, null)) return true;
    for (const space of (await spaceBrowser?.listSpaces()) ?? []) {
      if (space.deletedAt) continue;
      if (await resolveRepo(did, name, space.uri)) return true;
    }
    return false;
  }

  /**
   * Rename a repository: one field on its record.
   *
   * The key is the identity, so every statement about the repository, every
   * fork that names it upstream and every check still find it. What moves is
   * the address: a clone remote and a page path carry the name, so both are
   * re-pointed by hand.
   *
   * A repository in a space keeps the name it has. The space is what makes it
   * private and what an address resolves to, it is keyed by the name, and a
   * space key cannot move.
   * @param {string} did
   * @param {Record<string, any>} body
   */
  async function renameRepo(did, body) {
    const name = typeof body.name === 'string' ? body.name.trim() : '';
    const to = typeof body.newName === 'string' ? body.newName.trim() : '';
    if (!name || !to) {
      throw new Error('Name the repository and the name to give it.');
    }
    if (!isValidRepoName(to)) {
      throw new Error('A repository name uses letters, digits, and . _ ~ -');
    }
    if (typeof body.space === 'string' && body.space) {
      throw refuse(
        'SpaceRepository',
        'A private repository is addressed by the space holding it, which is keyed by its name, so the name stays.',
      );
    }

    const located = await resolveRepo(did, name, null);
    if (!located) throw refuse('RepoNotFound', `No repository named ${name}.`);
    if (to === name) return { name: to, rkey: located.rkey };
    if (await nameTaken(did, to)) {
      throw refuse('RepoExists', `A repository named ${to} already exists.`);
    }

    const { rkey } = located;
    await writePublicRecord(did, 'dev.pdsjs.git.repo', rkey, {
      ...located.value,
      name: to,
    });
    // The config carries the name it was written under, so the pair agrees.
    const config = await readPublicRecord(did, 'dev.pdsjs.git.config', rkey);
    if (config) {
      await writePublicRecord(did, 'dev.pdsjs.git.config', rkey, {
        ...config,
        name: to,
      });
    }
    keysByName.delete(name);
    keysByName.set(to, rkey);
    return { name: to, rkey };
  }

  /**
   * Delete a repository: the record, the config record beside it, and the
   * bundle blobs that neither of them holds any more.
   *
   * The guard reads the config for the branches it protects, so it is
   * consulted before either record goes. A repository holding a protected
   * branch refuses, and its owner drops the protection first.
   * @param {string} did
   * @param {Record<string, any>} body
   */
  async function deleteRepo(did, body) {
    const name = typeof body.name === 'string' ? body.name.trim() : '';
    if (!name) throw new Error('Name the repository to delete.');
    const space =
      typeof body.space === 'string' && body.space ? body.space : null;
    if (space && !spaceAdmin) throw new Error('Spaces are not enabled.');
    const admin = /** @type {NonNullable<typeof spaceAdmin>} */ (spaceAdmin);

    const located = await resolveRepo(did, name, space);
    if (!located) throw refuse('RepoNotFound', `No repository named ${name}.`);
    const { rkey } = located;

    if (writeGuard?.collections.includes('dev.pdsjs.git.repo')) {
      await writeGuard.check({
        space,
        collection: 'dev.pdsjs.git.repo',
        rkey,
        prevValue: located.value,
        nextValue: null,
      });
    }

    const config = space
      ? await admin.getRecord(space, 'dev.pdsjs.git.config', rkey)
      : await readPublicRecord(did, 'dev.pdsjs.git.config', rkey);

    if (space) {
      await admin.deleteRecord(space, 'dev.pdsjs.git.repo', rkey);
      if (config) await admin.deleteRecord(space, 'dev.pdsjs.git.config', rkey);
      // The space holds this repository alone and is keyed by its name, so
      // creating that name again reuses it. The member list goes with the
      // repository, or the next repository of that name admits people its
      // owner never added.
      for (const member of (await spaceBrowser?.listMembers(space)) ?? []) {
        if (member.did !== did) await admin.removeMember(space, member.did);
      }
    } else {
      /** @type {Array<{action: string, uri: string}>} */
      const ops = [];
      for (const collection of config
        ? ['dev.pdsjs.git.repo', 'dev.pdsjs.git.config']
        : ['dev.pdsjs.git.repo']) {
        const uri = `at://${did}/${collection}/${rkey}`;
        await actorStorage.unlinkBlobsFromRecord(uri);
        await actorStorage.deleteRecord(uri);
        ops.push({ action: 'delete', uri });
      }
      await createCommit(did, ops);
    }

    // The bundle chain was this repository's alone. Freeing it here rather
    // than at the hourly sweep is what makes the storage bar move when the
    // owner deletes something large.
    await reapOrphanedBlobs(did, Date.now() + 1000);
    keysByName.delete(name);
    return { name, space };
  }

  /**
   * The space a private repository lives in: one of the git type, keyed by
   * the repository's own name. That is the whole of what makes a repository
   * private, so a reader never has to know what a space is and the helper
   * finds it from the name alone. A space of that key that already exists is
   * reused, so a create that failed after making the space can be retried.
   * @param {string} did
   * @param {string} name
   * @returns {Promise<string>}
   */
  async function privateSpaceOf(did, name) {
    const type = GIT_SPACE_TYPE;
    try {
      return (
        await /** @type {NonNullable<typeof spaceAdmin>} */ (
          spaceAdmin
        ).createSpace({ type, skey: name })
      ).uri;
    } catch (err) {
      // The key is the whole address, so an existing space of that key reads
      // as the same one. The message is spaces' admin's own.
      const message = err instanceof Error ? err.message : String(err);
      const existing = `at://${did}/space/${type}/${name}`;
      if (message === `space already exists: ${existing}`) return existing;
      throw err;
    }
  }

  /**
   * Repository settings: description and default branch live on the repo
   * record; protected branches, the check runner and the linked packages
   * live in the config record beside it, which pushes never touch and the
   * write guard enforces.
   * @param {string} did
   * @param {Record<string, any>} body
   */
  async function updateSettings(did, body) {
    const name = typeof body.name === 'string' ? body.name.trim() : '';
    if (!name) throw new Error('Name the repository to configure.');
    const space =
      typeof body.space === 'string' && body.space ? body.space : null;
    if (space && !spaceAdmin) throw new Error('Spaces are not enabled.');

    // What the body says is checked before anything is read, so a malformed
    // request is refused whether or not the repository is there.
    /** @type {string[]|undefined} */
    let protectedBranches;
    /** @type {string|null|undefined} */
    let runner;
    /** @type {Array<{registry: string, name: string}>|undefined} */
    let packages;
    const configured =
      body.protectedBranches !== undefined ||
      body.runner !== undefined ||
      body.packages !== undefined;
    if (body.protectedBranches !== undefined) {
      if (
        !Array.isArray(body.protectedBranches) ||
        body.protectedBranches.some(
          (/** @type {unknown} */ branch) =>
            typeof branch !== 'string' ||
            branch.length === 0 ||
            /[\s*?[\\~^:]/.test(branch),
        )
      ) {
        throw new Error('protectedBranches must be a list of branch names.');
      }
      protectedBranches = [...new Set(body.protectedBranches)].sort();
    }
    if (body.runner !== undefined) {
      if (
        body.runner !== null &&
        (typeof body.runner !== 'string' || !body.runner.startsWith('did:'))
      ) {
        throw new Error('runner must be a DID, or null to clear it.');
      }
      runner = body.runner;
    }
    if (body.packages !== undefined) {
      if (!Array.isArray(body.packages)) {
        throw new Error('packages must be a list.');
      }
      packages = body.packages.map((/** @type {unknown} */ entry) => {
        const { registry, name: packageName } =
          /** @type {{registry?: unknown, name?: unknown}} */ (entry ?? {});
        if (
          typeof registry !== 'string' ||
          !PACKAGE_REGISTRIES.includes(registry) ||
          typeof packageName !== 'string' ||
          !packageName.trim()
        ) {
          throw new Error(
            `Each package names a registry (${PACKAGE_REGISTRIES.join(' or ')}) and a package name.`,
          );
        }
        return { registry, name: packageName.trim() };
      });
    }

    const located = await resolveRepo(did, name, space);
    if (!located) throw refuse('RepoNotFound', `No repository named ${name}.`);
    const { rkey } = located;

    if (body.description !== undefined || body.defaultBranch !== undefined) {
      const patched = /** @type {Record<string, unknown>} */ ({
        ...located.value,
      });
      if (body.description !== undefined) {
        const description =
          typeof body.description === 'string' ? body.description.trim() : '';
        if (description) patched.description = description;
        else delete patched.description;
      }
      if (body.defaultBranch !== undefined) {
        const branch =
          typeof body.defaultBranch === 'string'
            ? body.defaultBranch.trim()
            : '';
        if (branch) patched.defaultBranch = branch;
        else delete patched.defaultBranch;
      }
      if (space) {
        await /** @type {NonNullable<typeof spaceAdmin>} */ (
          spaceAdmin
        ).putRecord(space, 'dev.pdsjs.git.repo', rkey, patched);
      } else {
        await writePublicRecord(did, 'dev.pdsjs.git.repo', rkey, patched);
      }
    }

    if (configured) {
      // The config sits at the repository's own key, so a rename moves the
      // name and leaves the pair together.
      const existing = space
        ? await /** @type {NonNullable<typeof spaceAdmin>} */ (
            spaceAdmin
          ).getRecord(space, 'dev.pdsjs.git.config', rkey)
        : await readPublicRecord(did, 'dev.pdsjs.git.config', rkey);
      const config = mergeGitConfig(existing, {
        name,
        protectedBranches,
        runner,
        packages,
      });
      if (space) {
        await /** @type {NonNullable<typeof spaceAdmin>} */ (
          spaceAdmin
        ).putRecord(space, 'dev.pdsjs.git.config', rkey, config);
      } else {
        await writePublicRecord(did, 'dev.pdsjs.git.config', rkey, config);
      }
    }

    return { name, space, protectedBranches, runner, packages };
  }

  /**
   * The latest check on each branch of one repository.
   *
   * Checks live in the runner's repo on the runner's PDS, and atproto has no
   * reverse index, so this reads them where the config record says they are.
   * @param {string} did
   * @param {string} rkey - the repository's record key
   * @param {string|null} space
   */
  async function listChecks(did, rkey, space) {
    if (!rkey) throw new Error('Name the repository.');

    const config = space
      ? await /** @type {NonNullable<typeof spaceAdmin>} */ (
          spaceAdmin
        )?.getRecord(space, 'dev.pdsjs.git.config', rkey)
      : await readPublicRecord(did, 'dev.pdsjs.git.config', rkey);
    const runner = runnerOf(config);
    if (!runner) return { runner: null, checks: [] };

    const service = await resolvePdsEndpoint(runner);
    if (!service) return { runner, checks: [], unreachable: true };

    const repo = space
      ? await /** @type {NonNullable<typeof spaceAdmin>} */ (
          spaceAdmin
        )?.getRecord(space, 'dev.pdsjs.git.repo', rkey)
      : await readPublicRecord(did, 'dev.pdsjs.git.repo', rkey);
    const refs = /** @type {{refs?: unknown}} */ (repo ?? {}).refs;
    /** @type {string[]} */
    const branches = [];
    for (const entry of Array.isArray(refs) ? refs : []) {
      if (branches.length === MAX_CHECKED_BRANCHES) break;
      const ref = /** @type {{name?: unknown}} */ (entry ?? {}).name;
      if (typeof ref === 'string' && ref.startsWith('refs/heads/')) {
        branches.push(ref);
      }
    }

    const checks = await Promise.all(
      branches.map(async (ref) => {
        const params = new URLSearchParams({
          repo: runner,
          collection: 'dev.pdsjs.git.latestCheck',
          rkey: latestCheckKey(did, rkey, ref),
        });
        try {
          const res = await fetch(
            `${service}/xrpc/com.atproto.repo.getRecord?${params}`,
            { signal: AbortSignal.timeout(CHECK_READ_TIMEOUT_MS) },
          );
          if (!res.ok) return null;
          const body = /** @type {{value?: Record<string, unknown>}} */ (
            await res.json()
          );
          if (!body.value) return null;
          return latestCheckEntries(body.value).map((entry) => ({
            ...entry,
            ref,
          }));
        } catch {
          // A runner whose PDS is down costs the badge, not the page.
          return null;
        }
      }),
    );
    return { runner, service, checks: checks.filter(Boolean).flat() };
  }

  /**
   * Ask the runner to check a ref again.
   *
   * The runner reads the firehose and listens on no port, so the request is a
   * record in this account's own repo. Writing it is the whole operation: the
   * commit reaches the runner, and the check appears in listChecks when the
   * run publishes.
   * @param {string} did
   * @param {Record<string, any>} body
   */
  async function requestCheck(did, body) {
    const name = typeof body.name === 'string' ? body.name.trim() : '';
    if (!name) throw new Error('Name the repository to check.');
    const ref = typeof body.ref === 'string' ? body.ref.trim() : '';
    if (!ref.startsWith('refs/heads/')) {
      throw new Error('Name a branch to check, as refs/heads/<name>.');
    }
    if (body.space) {
      throw new Error(
        'A repository in a space has no public clone URL, so a runner cannot check it.',
      );
    }

    const located = await resolveRepo(did, name, null);
    if (!located) throw refuse('RepoNotFound', `No repository named ${name}.`);
    const { rkey } = located;

    const config = await readPublicRecord(did, 'dev.pdsjs.git.config', rkey);
    const runner = runnerOf(config);
    if (!runner) {
      throw refuse(
        'NoRunner',
        "Set a check runner in this repository's settings.",
      );
    }

    await writePublicRecord(
      did,
      'dev.pdsjs.git.checkRequest',
      checkRequestKey(rkey, ref),
      {
        $type: 'dev.pdsjs.git.checkRequest',
        repo: rkey,
        ref,
        // The runner tells a fresh request from a replayed one by the
        // record version. Asking twice must therefore change the record.
        requestedAt: new Date().toISOString(),
      },
    );
    return { runner, ref };
  }

  /**
   * The identities this account claims, each with the profile behind it,
   * and the account's own profile.
   * @param {string} did
   * @param {URL} url - the request URL, whose origin serves a claim's own face
   */
  async function listAuthors(did, url) {
    const record = await readPublicRecord(did, GIT_IDENTITY_COLLECTION, 'self');
    const claims = record ? parseIdentityRecord(record) : [];
    const resolved = await readProfiles([
      did,
      ...claims.map((claim) => claim.did).filter(Boolean),
    ]);
    return {
      owner: resolved.get(did) ?? null,
      claims: claims.map((claim) => ({
        name: claim.name,
        email: claim.email,
        label: claim.label,
        did: claim.did,
        avatar: claim.avatarCid
          ? `${url.origin}/xrpc/com.atproto.sync.getBlob?${new URLSearchParams({ did, cid: claim.avatarCid })}`
          : '',
        profile: claim.did ? (resolved.get(claim.did) ?? null) : null,
      })),
    };
  }

  /**
   * Put a pull request's work on this repository's canonical branch.
   *
   * The work is a branch in another account's copy, on their own server. Its
   * objects are read across the network and the ones this copy lacks ride in
   * a new bundle. Nothing is written to their repository.
   *
   * The merger is built here rather than by the platform: the pack format is
   * this package's, and everything it needs of the server is on the host.
   * The record write goes through the ordinary commit path, so the write
   * guard sees the merge and a protected branch still refuses anything but a
   * fast-forward.
   * @param {string} did @param {Record<string, unknown>} body
   */
  async function mergePull(did, body) {
    const name = typeof body.name === 'string' ? body.name.trim() : '';
    const from = typeof body.did === 'string' ? body.did.trim() : '';
    const ref = typeof body.ref === 'string' ? body.ref.trim() : '';
    if (!name || !from || !ref) {
      throw new Error('Name the repository, the account and the branch.');
    }

    const located = await resolveRepo(did, name, null);
    if (!located) throw refuse('RepoNotFound', `No repository named ${name}.`);

    const merger = createGitMerger({
      readRecord: readPublicRecord,
      writeRecord: writePublicRecord,
      readBlob: async (owner, cid) => {
        const held = await blobs.get(owner, cid);
        if (!held) return null;
        return held.data instanceof Uint8Array
          ? held.data
          : new Uint8Array(/** @type {ArrayBuffer} */ (held.data));
      },
      writeBlob: async (owner, data, mimeType) => {
        const cid = cidToString(await createBlobCid(data));
        await blobs.put(owner, cid, data, mimeType);
        await actorStorage.putBlob(cid, mimeType, data.length);
        return { ref: { $link: cid }, mimeType, size: data.length };
      },
      linkBlob: (cid, uri) => actorStorage.linkBlobToRecord(cid, uri),
      chunkSize: blobUploadLimit,
      // This server's own PLC, not the ambient environment's. The other
      // account's DID document is what says where their copy lives.
      locate: (owner) =>
        resolveRepoLocation(
          owner,
          plcUrl ? { ATPROTO_GIT_PLC_URL: plcUrl } : {},
        ),
      handle: () => actorStorage.getHandle(),
    });

    return merger.merge({
      did,
      rkey: located.rkey,
      name,
      from,
      ref,
      mode: body.mode === 'squash' ? 'squash' : 'merge',
      handle: typeof body.handle === 'string' ? body.handle : '',
      subject: typeof body.subject === 'string' ? body.subject : '',
      body: typeof body.body === 'string' ? body.body : '',
    });
  }

  /**
   * Every run the runner has published about one repository, newest first.
   * What the checks page lists; listChecks answers the latest per branch.
   * A repository in a space has no runner that can clone it, and no runs.
   * @param {string} did @param {string} rkey - the repository's record key
   * @param {string|null} space
   */
  async function listCheckRuns(did, rkey, space) {
    if (!rkey) throw new Error('Name the repository.');
    if (space) return { runner: null, runs: [] };
    const config = await readPublicRecord(did, 'dev.pdsjs.git.config', rkey);
    const runner = runnerOf(config);
    if (!runner) return { runner: null, runs: [] };
    const service = await resolvePdsEndpoint(runner);
    if (!service) return { runner, runs: [], unreachable: true };

    /** @type {unknown[]} */
    const records = [];
    let cursor = '';
    for (let page = 0; page < CHECK_RUN_PAGES; page++) {
      const params = new URLSearchParams({
        repo: runner,
        collection: 'dev.pdsjs.git.check',
        limit: '100',
      });
      if (cursor) params.set('cursor', cursor);
      try {
        const res = await fetch(
          `${service}/xrpc/com.atproto.repo.listRecords?${params}`,
          { signal: AbortSignal.timeout(CHECK_READ_TIMEOUT_MS) },
        );
        if (!res.ok) break;
        const body = /** @type {{records?: unknown[], cursor?: unknown}} */ (
          await res.json()
        );
        records.push(...(body.records ?? []));
        cursor = typeof body.cursor === 'string' ? body.cursor : '';
        if (!cursor) break;
      } catch {
        break;
      }
    }
    return {
      runner,
      service,
      runs: checkRunsOf(records, `at://${did}/dev.pdsjs.git.repo/${rkey}`),
    };
  }

  return {
    listRepos,
    createRepo,
    deleteRepo,
    renameRepo,
    resolveRepo,
    updateSettings,
    listChecks,
    listCheckRuns,
    requestCheck,
    listAuthors,
    mergePull,
    resolveContributor,
    readRecord: readPublicRecord,
  };
}