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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
825 lines · 26.7 KB Raw History
/**
 * End-to-end test of private repositories over spaces: two Node PDS
 * instances, a stub PLC directory, and real git subprocesses driving the
 * helper through the space form of the remote URL.
 *
 * Alice hosts the repository in a space she owns. She pushes and clones with
 * her own session. Bob is a space member on his own PDS; his clone runs the
 * whole credential chain: a session on his PDS, a delegation token, a space
 * credential from Alice's PDS, and DPoP-proofed reads.
 *
 * Git runs asynchronously for the same reason as in git-remote.test.js: both
 * PDS instances live on this process's event loop.
 */

import { execFile } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import {
  chmodSync,
  mkdirSync,
  mkdtempSync,
  rmSync,
  writeFileSync,
} from 'node:fs';
import { createServer as createHttpServer } from 'node:http';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { derivePublicKey, hexToBytes } from '@pdsjs/core/crypto';
import { publicKeyToDidKey } from '@pdsjs/core/plc';
import { createServer } from '@pdsjs/node';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { GIT_REPO_COLLECTION } from '../src/index.js';

const execFileAsync = promisify(execFile);

const ALICE_PORT = 2476;
const BOB_PORT = 2477;
const PLC_PORT = 2478;
const ALICE_BASE = `http://127.0.0.1:${ALICE_PORT}`;
const BOB_BASE = `http://127.0.0.1:${BOB_PORT}`;
const PLC_URL = `http://127.0.0.1:${PLC_PORT}`;
const PASSWORD = 'test-password';

const REPO = 'secret-repo';
// A private repository's space: the git type, keyed by the repository.
const SPACE_TYPE = 'dev.pdsjs.git.space';
const SPACE_KEY = REPO;

/** @param {number} length */
function randomBase32(length) {
  const chars = 'abcdefghijklmnopqrstuvwxyz234567';
  return Array.from(randomBytes(length))
    .map((b) => chars[b % 32])
    .join('');
}

const ALICE_DID = `did:plc:${randomBase32(24)}`;
const BOB_DID = `did:plc:${randomBase32(24)}`;
const SPACE_URI = `at://${ALICE_DID}/space/${SPACE_TYPE}/${SPACE_KEY}`;
const REMOTE = `atproto://${ALICE_DID}/${REPO}`;

/** @type {Array<{close: () => Promise<unknown>}>} */
const servers = [];
/** @type {import('node:http').Server|null} */
let plcServer = null;
/** @type {Map<string, object>} */
const didDocs = new Map();
/** @type {string} */
let workDir = '';
/** @type {string} */
let srcDir = '';
/** @type {Record<string, string>} */
let baseEnv = {};
/** @type {string} */
let aliceJwt = '';
/** @type {string} */
let bobJwt = '';

/**
 * @param {string} cwd
 * @param {string[]} args
 * @param {Record<string, string>} [extraEnv]
 * @returns {Promise<string>} stdout
 */
async function runGit(cwd, args, extraEnv = {}) {
  const { stdout } = await execFileAsync('git', args, {
    cwd,
    env: { ...baseEnv, ...extraEnv },
    encoding: 'utf8',
  });
  return stdout;
}

/**
 * Boot a PDS, initialise its account, and publish the account's DID document
 * to the stub PLC.
 * @param {string} name
 * @param {number} port
 * @param {string} did
 * @returns {Promise<string>} the account's access JWT
 */
async function bootPds(name, port, did) {
  const dir = join(workDir, name);
  mkdirSync(join(dir, 'blobs'), { recursive: true });
  const server = await createServer({
    port,
    dbPath: join(dir, 'pds.db'),
    blobsDir: join(dir, 'blobs'),
    jwtSecret: `test-secret-${name}`,
    hostname: `127.0.0.1:${port}`,
    password: PASSWORD,
    plcUrl: PLC_URL,
    spaces: true,
  });
  await server.listen();
  servers.push(server);

  const base = `http://127.0.0.1:${port}`;
  const privateKey = randomBytes(32).toString('hex');
  const init = await fetch(`${base}/init?did=${did}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      did,
      privateKey,
      handle: `${name}.test`,
      password: PASSWORD,
    }),
  });
  expect(init.ok).toBe(true);

  const { publicKey } = await derivePublicKey(hexToBytes(privateKey));
  const multibase = publicKeyToDidKey(publicKey, 'p256').slice(
    'did:key:'.length,
  );
  didDocs.set(did, {
    '@context': ['https://www.w3.org/ns/did/v1'],
    id: did,
    alsoKnownAs: [`at://${name}.test`],
    verificationMethod: [
      {
        id: `${did}#atproto`,
        type: 'Multikey',
        controller: did,
        publicKeyMultibase: multibase,
      },
    ],
    service: [
      {
        id: '#atproto_pds',
        type: 'AtprotoPersonalDataServer',
        serviceEndpoint: base,
      },
    ],
  });

  const res = await fetch(`${base}/xrpc/com.atproto.server.createSession`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ identifier: did, password: PASSWORD }),
  });
  expect(res.ok).toBe(true);
  const { accessJwt } = /** @type {{accessJwt: string}} */ (await res.json());

  // The account's KEM key, which credential reads of its space repos ask for
  // a MAC against.
  const recommended = await fetch(
    `${base}/xrpc/com.atproto.identity.getRecommendedDidCredentials`,
    { headers: { Authorization: `Bearer ${accessJwt}` } },
  );
  expect(recommended.ok).toBe(true);
  const { verificationMethods } =
    /** @type {{verificationMethods: {atproto_kem: string}}} */ (
      await recommended.json()
    );
  const doc =
    /** @type {{verificationMethod: Array<Record<string, string>>}} */ (
      didDocs.get(did)
    );
  doc.verificationMethod.push({
    id: `${did}#atproto_kem`,
    type: 'Multikey',
    controller: did,
    publicKeyMultibase: verificationMethods.atproto_kem.slice(
      'did:key:'.length,
    ),
  });
  return accessJwt;
}

/**
 * @param {string} base
 * @param {string} nsid
 * @param {unknown} body
 * @param {string} jwt
 */
async function xrpcPost(base, nsid, body, jwt) {
  const res = await fetch(`${base}/xrpc/${nsid}`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${jwt}`,
    },
    body: JSON.stringify(body),
  });
  expect(res.ok, `${nsid}: ${res.status}`).toBe(true);
  return res.json();
}

beforeAll(async () => {
  workDir = mkdtempSync(join(tmpdir(), 'pdsjs-git-space-'));

  plcServer = createHttpServer((req, res) => {
    const doc = didDocs.get(decodeURIComponent((req.url ?? '/').slice(1)));
    if (!doc) {
      res.writeHead(404).end('{}');
      return;
    }
    res.writeHead(200, { 'content-type': 'application/json' });
    res.end(JSON.stringify(doc));
  });
  await new Promise((resolve) =>
    /** @type {import('node:http').Server} */ (plcServer).listen(PLC_PORT, () =>
      resolve(undefined),
    ),
  );

  aliceJwt = await bootPds('alice', ALICE_PORT, ALICE_DID);
  bobJwt = await bootPds('bob', BOB_PORT, BOB_DID);

  await xrpcPost(
    ALICE_BASE,
    'com.atproto.space.createSpace',
    {
      spaceType: SPACE_TYPE,
      skey: SPACE_KEY,
      policy: { $type: 'com.atproto.memberspace.policy' },
    },
    aliceJwt,
  );
  await xrpcPost(
    ALICE_BASE,
    'com.atproto.memberspace.putMember',
    { space: SPACE_URI, did: BOB_DID, read: true, write: true },
    aliceJwt,
  );

  // PATH shim so git's atproto:// scheme resolves to our helper CLI
  const binDir = join(workDir, 'bin');
  mkdirSync(binDir);
  const cliPath = join(
    dirname(fileURLToPath(import.meta.url)),
    '..',
    'src',
    'cli.js',
  );
  const shim = join(binDir, 'git-remote-atproto');
  writeFileSync(shim, `#!/bin/sh\nexec node ${cliPath} "$@"\n`);
  chmodSync(shim, 0o755);

  baseEnv = {
    .../** @type {Record<string, string>} */ (process.env),
    PATH: `${binDir}:${process.env.PATH}`,
    ATPROTO_GIT_SERVICE: ALICE_BASE,
    ATPROTO_GIT_PLC_URL: PLC_URL,
    ATPROTO_GIT_CHUNK_SIZE: '1024',
    GIT_TERMINAL_PROMPT: '0',
    GIT_AUTHOR_NAME: 'Test',
    GIT_AUTHOR_EMAIL: 'test@example.com',
    GIT_COMMITTER_NAME: 'Test',
    GIT_COMMITTER_EMAIL: 'test@example.com',
  };

  srcDir = join(workDir, 'src-repo');
  mkdirSync(srcDir);
  await runGit(srcDir, ['init', '-b', 'main']);
  writeFileSync(join(srcDir, 'README.md'), '# private repo over a space\n');
  writeFileSync(join(srcDir, 'data.bin'), randomBytes(4096));
  await runGit(srcDir, ['add', '.']);
  await runGit(srcDir, ['commit', '-m', 'initial commit']);
  await runGit(srcDir, ['remote', 'add', 'origin', REMOTE]);
}, 60_000);

afterAll(async () => {
  for (const server of servers) await server.close();
  if (plcServer) await new Promise((resolve) => plcServer?.close(resolve));
  rmSync(workDir, { recursive: true, force: true });
});

const aliceEnv = () => ({
  ATPROTO_GIT_IDENTIFIER: ALICE_DID,
  ATPROTO_GIT_PASSWORD: PASSWORD,
});

const bobEnv = () => ({
  ATPROTO_GIT_IDENTIFIER: BOB_DID,
  ATPROTO_GIT_PASSWORD: PASSWORD,
  ATPROTO_GIT_USER_SERVICE: BOB_BASE,
});

describe('private git repository over a space', () => {
  it('the owner pushes into the space', async () => {
    await runGit(srcDir, ['push', 'origin', 'main'], aliceEnv());

    const params = new URLSearchParams({
      space: SPACE_URI,
      collection: GIT_REPO_COLLECTION,
      rkey: REPO,
    });
    const res = await fetch(
      `${ALICE_BASE}/xrpc/com.atproto.space.getRecord?${params}`,
      { headers: { Authorization: `Bearer ${aliceJwt}` } },
    );
    expect(res.ok).toBe(true);
    const record = /** @type {{value: {name: string, refs: unknown[]}}} */ (
      await res.json()
    ).value;
    expect(record.name).toBe(REPO);
    expect(record.refs).toHaveLength(1);
  });

  it('the public blob endpoints refuse the private bundle chunks', async () => {
    const params = new URLSearchParams({
      space: SPACE_URI,
      collection: GIT_REPO_COLLECTION,
      rkey: REPO,
    });
    const record =
      /** @type {{value: {bundles: Array<{parts: Array<{ref: {$link: string}}>}>}}} */ (
        await (
          await fetch(
            `${ALICE_BASE}/xrpc/com.atproto.space.getRecord?${params}`,
            { headers: { Authorization: `Bearer ${aliceJwt}` } },
          )
        ).json()
      ).value;
    const chunkCid = record.bundles[0].parts[0].ref.$link;

    const blobParams = new URLSearchParams({ did: ALICE_DID, cid: chunkCid });
    const publicBlob = await fetch(
      `${ALICE_BASE}/xrpc/com.atproto.sync.getBlob?${blobParams}`,
    );
    expect(publicBlob.status).toBe(404);

    const listing = /** @type {{cids: string[]}} */ (
      await (
        await fetch(`${ALICE_BASE}/xrpc/com.atproto.sync.listBlobs?limit=1000`)
      ).json()
    );
    expect(listing.cids).not.toContain(chunkCid);

    // The same chunk stays readable inside the space.
    const spaceBlobParams = new URLSearchParams({
      space: SPACE_URI,
      cid: chunkCid,
    });
    const spaceBlob = await fetch(
      `${ALICE_BASE}/xrpc/com.atproto.space.getBlob?${spaceBlobParams}`,
      { headers: { Authorization: `Bearer ${aliceJwt}` } },
    );
    expect(spaceBlob.ok).toBe(true);
  });

  it('a publicly referenced blob is still served publicly', async () => {
    const upload = await fetch(
      `${ALICE_BASE}/xrpc/com.atproto.repo.uploadBlob`,
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/octet-stream',
          Authorization: `Bearer ${aliceJwt}`,
        },
        body: randomBytes(64),
      },
    );
    expect(upload.ok).toBe(true);
    const { blob } = /** @type {{blob: {ref: {$link: string}}}} */ (
      await upload.json()
    );

    const put = await fetch(`${ALICE_BASE}/xrpc/com.atproto.repo.putRecord`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${aliceJwt}`,
      },
      body: JSON.stringify({
        repo: ALICE_DID,
        collection: 'com.example.attachment',
        rkey: 'pub',
        record: { $type: 'com.example.attachment', file: blob },
      }),
    });
    expect(put.ok).toBe(true);

    const blobParams = new URLSearchParams({
      did: ALICE_DID,
      cid: blob.ref.$link,
    });
    const res = await fetch(
      `${ALICE_BASE}/xrpc/com.atproto.sync.getBlob?${blobParams}`,
    );
    expect(res.ok).toBe(true);
  });

  it('the record is not in the public repo', async () => {
    const params = new URLSearchParams({
      repo: ALICE_DID,
      collection: GIT_REPO_COLLECTION,
      rkey: REPO,
    });
    const res = await fetch(
      `${ALICE_BASE}/xrpc/com.atproto.repo.getRecord?${params}`,
    );
    expect(res.status).toBe(404);
  });

  it('reading the space record needs authentication', async () => {
    const params = new URLSearchParams({
      space: SPACE_URI,
      collection: GIT_REPO_COLLECTION,
      rkey: REPO,
    });
    const res = await fetch(
      `${ALICE_BASE}/xrpc/com.atproto.space.getRecord?${params}`,
    );
    expect(res.status).toBe(401);
  });

  it('the owner clones with their own session', async () => {
    const cloneDir = join(workDir, 'clone-alice');
    await runGit(workDir, ['clone', REMOTE, cloneDir], aliceEnv());
    const readme = await runGit(cloneDir, ['show', 'HEAD:README.md']);
    expect(readme).toContain('private repo over a space');
  });

  it('a member clones through the credential chain', async () => {
    const cloneDir = join(workDir, 'clone-bob');
    await runGit(workDir, ['clone', REMOTE, cloneDir], bobEnv());
    const readme = await runGit(cloneDir, ['show', 'HEAD:README.md']);
    expect(readme).toContain('private repo over a space');
  });

  it('a member pulls a later push', async () => {
    writeFileSync(join(srcDir, 'update.txt'), 'second commit\n');
    await runGit(srcDir, ['add', '.']);
    await runGit(srcDir, ['commit', '-m', 'second commit']);
    await runGit(srcDir, ['push', 'origin', 'main'], aliceEnv());

    const cloneDir = join(workDir, 'clone-bob');
    await runGit(cloneDir, ['pull', 'origin', 'main'], bobEnv());
    const update = await runGit(cloneDir, ['show', 'HEAD:update.txt']);
    expect(update).toContain('second commit');
  });

  it('a member pushes to their own record on their own PDS', async () => {
    const cloneDir = join(workDir, 'clone-bob');
    const aliceParams = new URLSearchParams({
      space: SPACE_URI,
      collection: GIT_REPO_COLLECTION,
      rkey: REPO,
    });
    const canonicalBefore = await (
      await fetch(
        `${ALICE_BASE}/xrpc/com.atproto.space.getRecord?${aliceParams}`,
        { headers: { Authorization: `Bearer ${aliceJwt}` } },
      )
    ).json();

    writeFileSync(join(cloneDir, 'bob-note.txt'), 'from bob\n');
    await runGit(cloneDir, ['add', '.']);
    await runGit(cloneDir, ['commit', '-m', 'bob note']);
    await runGit(cloneDir, ['push', 'origin', 'main'], bobEnv());

    const bobRes = await fetch(
      `${BOB_BASE}/xrpc/com.atproto.space.getRecord?${aliceParams}`,
      { headers: { Authorization: `Bearer ${bobJwt}` } },
    );
    expect(bobRes.ok).toBe(true);
    const bobRecord = /** @type {{value: {refs: Array<{sha: string}>}}} */ (
      await bobRes.json()
    ).value;
    expect(bobRecord.refs[0].sha).toBe(
      (await runGit(cloneDir, ['rev-parse', 'main'])).trim(),
    );

    const canonicalAfter = await (
      await fetch(
        `${ALICE_BASE}/xrpc/com.atproto.space.getRecord?${aliceParams}`,
        { headers: { Authorization: `Bearer ${aliceJwt}` } },
      )
    ).json();
    expect(canonicalAfter.cid).toBe(canonicalBefore.cid);
  });

  it("a fetch lists the member's branches under their handle", async () => {
    const cloneDir = join(workDir, 'clone-alice');
    await runGit(cloneDir, ['fetch', 'origin'], aliceEnv());
    const branches = await runGit(cloneDir, ['branch', '-r']);
    expect(branches).toContain('origin/bob.test/main');
    const note = await runGit(cloneDir, [
      'show',
      'origin/bob.test/main:bob-note.txt',
    ]);
    expect(note).toContain('from bob');
  });

  it("a fresh clone checks out the space owner's main", async () => {
    const cloneDir = join(workDir, 'clone-fresh');
    await runGit(workDir, ['clone', REMOTE, cloneDir], aliceEnv());
    expect((await runGit(cloneDir, ['branch', '--show-current'])).trim()).toBe(
      'main',
    );
    await expect(
      runGit(cloneDir, ['show', 'HEAD:bob-note.txt']),
    ).rejects.toThrow();
    expect(await runGit(cloneDir, ['branch', '-r'])).toContain(
      'origin/bob.test/main',
    );
  });

  it('the account page sees, browses, and manages the private repo', async () => {
    const signIn = await fetch(`${ALICE_BASE}/account/sign-in`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        Origin: ALICE_BASE,
      },
      body: new URLSearchParams({
        username: ALICE_DID,
        password: PASSWORD,
      }).toString(),
      redirect: 'manual',
    });
    const cookie = (signIn.headers.get('set-cookie') ?? '').split(';')[0];
    expect(cookie).toBeTruthy();

    // The repositories listing carries the space repo with its space URI.
    const repos =
      /** @type {{repos: Array<{name: string, space: string|null, spaceOwned: boolean, cloneUrl: string}>}} */ (
        await (
          await fetch(`${ALICE_BASE}/xrpc/dev.pdsjs.git.listRepos`, {
            headers: { cookie },
          })
        ).json()
      ).repos;
    const spaceRepo = repos.find((r) => r.name === REPO);
    expect(spaceRepo?.space).toBe(SPACE_URI);
    expect(spaceRepo?.spaceOwned).toBe(true);
    // The clone URL names the authority by handle, and reads like a public
    // repository's: the helper finds the space from the name.
    expect(spaceRepo?.cloneUrl).toBe(`atproto://alice.test/${REPO}`);

    // File browsing reads the space record and blobs from storage.
    const treeParams = new URLSearchParams({ repo: REPO, space: SPACE_URI });
    const tree = /** @type {{entries: Array<{name: string}>}} */ (
      await (
        await fetch(`${ALICE_BASE}/xrpc/dev.pdsjs.git.getTree?${treeParams}`, {
          headers: { cookie },
        })
      ).json()
    );
    expect(tree.entries.map((e) => e.name)).toContain('README.md');

    // Commit history reads straight from the bundle chain's commit objects.
    const logParams = new URLSearchParams({ repo: REPO, space: SPACE_URI });
    const log =
      /** @type {{commits: Array<{message: string, author: string}>}} */ (
        await (
          await fetch(
            `${ALICE_BASE}/xrpc/dev.pdsjs.git.listCommits?${logParams}`,
            {
              headers: { cookie },
            },
          )
        ).json()
      );
    expect(log.commits.map((c) => c.message.trim()).sort()).toEqual([
      'initial commit',
      'second commit',
    ]);
    expect(log.commits[0].author).toContain('Test');

    // Member management round-trip.
    const memberParams = new URLSearchParams({ space: SPACE_URI });
    const listMembers = async () =>
      /** @type {{members: Array<{did: string}>}} */ (
        await (
          await fetch(
            `${ALICE_BASE}/account/api/spaces/members?${memberParams}`,
            { headers: { cookie } },
          )
        ).json()
      ).members.map((member) => member.did);
    expect(await listMembers()).toContain(BOB_DID);

    const extra = `did:plc:${randomBase32(24)}`;
    const add = await fetch(`${ALICE_BASE}/account/api/spaces/members/add`, {
      method: 'POST',
      headers: { cookie, 'Content-Type': 'application/json' },
      body: JSON.stringify({ space: SPACE_URI, actor: extra }),
    });
    expect(add.ok).toBe(true);
    expect(await listMembers()).toContain(extra);

    const remove = await fetch(
      `${ALICE_BASE}/account/api/spaces/members/remove`,
      {
        method: 'POST',
        headers: { cookie, 'Content-Type': 'application/json' },
        body: JSON.stringify({ space: SPACE_URI, did: extra }),
      },
    );
    expect(remove.ok).toBe(true);
    expect(await listMembers()).not.toContain(extra);

    // Creating a space from the account page, the new-repository flow.
    const create = await fetch(`${ALICE_BASE}/account/api/spaces/create`, {
      method: 'POST',
      headers: { cookie, 'Content-Type': 'application/json' },
      body: JSON.stringify({ type: 'com.example.second', skey: 'another' }),
    });
    expect(create.ok).toBe(true);
    const { uri } = await create.json();
    expect(uri).toBe(`at://${ALICE_DID}/space/com.example.second/another`);
    const dup = await fetch(`${ALICE_BASE}/account/api/spaces/create`, {
      method: 'POST',
      headers: { cookie, 'Content-Type': 'application/json' },
      body: JSON.stringify({ type: 'com.example.second', skey: 'another' }),
    });
    expect(dup.ok).toBe(false);
  });

  it('a protected branch refuses force pushes and deletion, and takes fast-forwards', async () => {
    const signIn = await fetch(`${ALICE_BASE}/account/sign-in`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        Origin: ALICE_BASE,
      },
      body: new URLSearchParams({
        username: ALICE_DID,
        password: PASSWORD,
      }).toString(),
      redirect: 'manual',
    });
    const cookie = (signIn.headers.get('set-cookie') ?? '').split(';')[0];

    const settings = await fetch(
      `${ALICE_BASE}/xrpc/dev.pdsjs.git.updateSettings`,
      {
        method: 'POST',
        headers: { cookie, 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name: REPO,
          space: SPACE_URI,
          description: 'guarded',
          protectedBranches: ['main'],
        }),
      },
    );
    expect(settings.ok).toBe(true);

    // The settings rewrite of the repo record must round-trip its blob refs:
    // a fresh clone still downloads and replays the chain.
    const checkDir = join(workDir, 'clone-guarded');
    await runGit(workDir, ['clone', REMOTE, checkDir], aliceEnv());

    // A fast-forward push is accepted.
    writeFileSync(join(checkDir, 'more.txt'), 'more\n');
    await runGit(checkDir, ['add', '.']);
    await runGit(checkDir, ['commit', '-m', 'fast forward']);
    await runGit(checkDir, ['push', 'origin', 'main'], aliceEnv());

    // A force push of rewritten history is refused by the server.
    await runGit(checkDir, ['commit', '--amend', '-m', 'rewritten']);
    await expect(
      runGit(checkDir, ['push', '--force', 'origin', 'main'], aliceEnv()),
    ).rejects.toThrow(/protected|fast-forward/);

    // So is deleting the branch.
    await expect(
      runGit(checkDir, ['push', 'origin', ':main'], aliceEnv()),
    ).rejects.toThrow(/protected/);

    // The listing reports both settings.
    const repos =
      /** @type {{repos: Array<{name: string, space: string|null, description: string|null, protectedBranches: string[]}>}} */ (
        await (
          await fetch(`${ALICE_BASE}/xrpc/dev.pdsjs.git.listRepos`, {
            headers: { cookie },
          })
        ).json()
      ).repos;
    const guarded = repos.find((r) => r.name === REPO && r.space === SPACE_URI);
    expect(guarded?.description).toBe('guarded');
    expect(guarded?.protectedBranches).toEqual(['main']);
  });

  it('a repository created empty from the account page takes its first push', async () => {
    const signIn = await fetch(`${ALICE_BASE}/account/sign-in`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        Origin: ALICE_BASE,
      },
      body: new URLSearchParams({
        username: ALICE_DID,
        password: PASSWORD,
      }).toString(),
      redirect: 'manual',
    });
    const cookie = (signIn.headers.get('set-cookie') ?? '').split(';')[0];

    // Private, with a new space, the way the dialog submits it.
    const createdPrivate = await fetch(
      `${ALICE_BASE}/xrpc/dev.pdsjs.git.createRepo`,
      {
        method: 'POST',
        headers: { cookie, 'Content-Type': 'application/json' },
        body: JSON.stringify({ name: 'fresh-repo', private: true }),
      },
    );
    expect(createdPrivate.ok).toBe(true);
    const { space } = await createdPrivate.json();
    expect(space).toBe(
      `at://${ALICE_DID}/space/dev.pdsjs.git.space/fresh-repo`,
    );

    // Public too, and a duplicate name is refused.
    const createdPublic = await fetch(
      `${ALICE_BASE}/xrpc/dev.pdsjs.git.createRepo`,
      {
        method: 'POST',
        headers: { cookie, 'Content-Type': 'application/json' },
        body: JSON.stringify({ name: 'fresh-public' }),
      },
    );
    expect(createdPublic.ok).toBe(true);
    const dup = await fetch(`${ALICE_BASE}/xrpc/dev.pdsjs.git.createRepo`, {
      method: 'POST',
      headers: { cookie, 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: 'fresh-public' }),
    });
    expect(dup.ok).toBe(false);

    // Both list before any push, with nothing in them.
    const repos =
      /** @type {{repos: Array<{name: string, space: string|null, branches: number}>}} */ (
        await (
          await fetch(`${ALICE_BASE}/xrpc/dev.pdsjs.git.listRepos`, {
            headers: { cookie },
          })
        ).json()
      ).repos;
    const fresh = repos.find((r) => r.name === 'fresh-repo');
    expect(fresh?.space).toBe(space);
    expect(fresh?.branches).toBe(0);
    expect(
      repos.find((r) => r.name === 'fresh-public' && r.space === null),
    ).toBeTruthy();

    // The first push lands in the pre-created record through the CAS.
    const pushDir = join(workDir, 'fresh-src');
    mkdirSync(pushDir);
    await runGit(pushDir, ['init', '-b', 'main']);
    writeFileSync(join(pushDir, 'hello.txt'), 'hello\n');
    await runGit(pushDir, ['add', '.']);
    await runGit(pushDir, ['commit', '-m', 'first']);
    await runGit(
      pushDir,
      ['push', `atproto://${ALICE_DID}/fresh-repo`, 'main'],
      aliceEnv(),
    );

    const after =
      /** @type {{repos: Array<{name: string, branches: number}>}} */ (
        await (
          await fetch(`${ALICE_BASE}/xrpc/dev.pdsjs.git.listRepos`, {
            headers: { cookie },
          })
        ).json()
      ).repos;
    expect(after.find((r) => r.name === 'fresh-repo')?.branches).toBe(1);

    // Protection holds on the public path too.
    await runGit(
      pushDir,
      ['push', `atproto://${ALICE_DID}/fresh-public`, 'main'],
      aliceEnv(),
    );
    const protect = await fetch(
      `${ALICE_BASE}/xrpc/dev.pdsjs.git.updateSettings`,
      {
        method: 'POST',
        headers: { cookie, 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name: 'fresh-public',
          protectedBranches: ['main'],
        }),
      },
    );
    expect(protect.ok).toBe(true);
    await runGit(pushDir, ['commit', '--amend', '-m', 'rewritten']);
    await expect(
      runGit(
        pushDir,
        ['push', '--force', `atproto://${ALICE_DID}/fresh-public`, 'main'],
        aliceEnv(),
      ),
    ).rejects.toThrow(/protected|fast-forward/);
  });

  it('an anonymous clone is refused', async () => {
    const cloneDir = join(workDir, 'clone-anon');
    await expect(
      runGit(workDir, ['clone', REMOTE, cloneDir], {
        ATPROTO_GIT_PASSWORD: '',
        HOME: join(workDir, 'no-home'),
        XDG_CONFIG_HOME: join(workDir, 'no-home'),
      }),
    ).rejects.toThrow(/no credentials|exit code/i);
  });
});