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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
1224 lines · 40.5 KB Raw History
// Signers here use @pdsjs/core/crypto's sign(), which normalizes to low-S.
// atproto rejects high-S signatures as malleable and raw crypto.subtle.sign
// emits them about half the time, so a test signing the raw way would verify
// fine under WebCrypto while the reference implementation rejected it.
import { compressPublicKey, sign } from '@pdsjs/core/crypto';
import { publicKeyToDidKey } from '@pdsjs/core/plc';
import { beforeEach, describe, expect, it } from 'vitest';
import { ATPROTO_KEY_ID, ATPROTO_PDS_ID } from '../src/authority.js';
import { checkUserAccess } from '../src/handlers/auth.js';
import {
  createSpaceSigHeaders,
  generateSpaceSigKey,
} from '../src/http-signature.js';
import {
  createMemoryBlobContext,
  createMemorySpaceStorage,
} from '../src/memory-storage.js';
import { createSpaceRoutes } from '../src/routes.js';
import { makeSpaceRow } from '../src/space-row.js';
import { verifyRecord } from '../src/star.js';
import { createSpaceToken } from '../src/token.js';
import { createVerifier } from '../src/verifier.js';
import { applyWrites } from '../src/writer.js';
import { makeKemKey, responseMac, wantMac } from './helpers/kem.js';

const AUTHORITY = 'did:plc:authority';
const USER = 'did:plc:user';
const SPACE = `at://${AUTHORITY}/space/com.example.forum/general`;
const COL = 'com.example.post';
// A realistic grant: whole-space read plus writes to one collection.
const SCOPE =
  `space:com.example.forum?authority=${'did:plc:authority'}` +
  `&collection=${'com.example.post'}`;

/** A P-256 identity whose did:key can go in a DID document. */
async function makeIdentity() {
  const keys = await crypto.subtle.generateKey(
    { name: 'ECDSA', namedCurve: 'P-256' },
    true,
    ['sign', 'verify'],
  );
  const raw = new Uint8Array(
    await crypto.subtle.exportKey('raw', keys.publicKey),
  );
  const didKey = publicKeyToDidKey(compressPublicKey(raw));
  const jwk = /** @type {any} */ (
    await crypto.subtle.exportKey('jwk', keys.publicKey)
  );
  return {
    didKey,
    multibase: didKey.slice('did:key:'.length),
    // What a client publishes for an attestation to be checked against.
    publicJwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y },
    signer: {
      /** @param {Uint8Array} bytes */
      sign: (bytes) => sign(keys.privateKey, bytes),
    },
  };
}

/**
 * Run `fn` with the client's metadata document served from its client_id.
 *
 * The attestation verifier resolves the client through the global fetch, the
 * same way the OAuth client authentication does.
 *
 * @template T
 * @param {string} clientId
 * @param {{keys: unknown[]}} jwks
 * @param {() => Promise<T>} fn
 * @returns {Promise<T>}
 */
async function withClientMetadata(clientId, jwks, fn) {
  const original = globalThis.fetch;
  globalThis.fetch = async (/** @type {any} */ input) => {
    if (String(input) === clientId) {
      return Response.json({ client_id: clientId, jwks });
    }
    throw new Error(`unexpected fetch: ${input}`);
  };
  try {
    return await fn();
  } finally {
    globalThis.fetch = original;
  }
}

/** @type {any} */
let authority;
/** @type {any} */
let user;
/** @type {any} */
let didDocs;
/** The application's key, which every credential here is bound to. */
/** @type {import('../src/http-signature.js').SpaceSigKey} */
let sigKey;
/** The user's KEM key, which their repo host MACs reads under. */
/** @type {{privateKey: Uint8Array, didKey: string}} */
let userKem;

/**
 * Build a PDS-side route table for one account.
 * @param {string} did
 * @param {any} identity
 * @param {any} storage
 */
function routesFor(did, identity, storage) {
  return createSpaceRoutes({
    spaceStorage: storage,
    ...createMemoryBlobContext(),
    getDid: async () => did,
    getSigner: async () => identity.signer,
    getKemKey: async () => (did === USER ? userKem : null),
    resolveDid: async (/** @type {string} */ d) => didDocs[d] ?? null,
    verifier: createVerifier(),
  });
}

/** @type {any} */
let authorityRoutes;
/** @type {any} */
let userRoutes;
/** @type {any} */
let authorityStore;
/** @type {any} */
let userStore;

beforeEach(async () => {
  authority = await makeIdentity();
  user = await makeIdentity();
  sigKey = await generateSpaceSigKey();
  userKem = await makeKemKey();

  didDocs = {
    [AUTHORITY]: {
      id: AUTHORITY,
      verificationMethod: [
        // `#atproto` signs the authority's credentials.
        {
          id: ATPROTO_KEY_ID,
          type: 'Multikey',
          publicKeyMultibase: authority.multibase,
        },
      ],
      service: [
        {
          id: ATPROTO_PDS_ID,
          type: 'AtprotoPersonalDataServer',
          serviceEndpoint: 'https://authority.example',
        },
      ],
    },
    [USER]: {
      id: USER,
      verificationMethod: [
        {
          id: '#atproto',
          type: 'Multikey',
          publicKeyMultibase: user.multibase,
        },
      ],
    },
  };

  authorityStore = createMemorySpaceStorage();
  userStore = createMemorySpaceStorage();
  authorityRoutes = routesFor(AUTHORITY, authority, authorityStore);
  userRoutes = routesFor(USER, user, userStore);

  // The authority owns the space.
  await authorityStore.putSpace(makeSpaceRow(SPACE, { isOwner: true }));
});

/** @param {any} routes @param {string} path @param {Record<string,string>} params @param {Record<string,string>} [headers] */
function get(routes, path, params = {}, headers = {}) {
  const url = new URL(`https://pds.example${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const request = new Request(url, { headers });
  return routes[path].handler(request, url, {
    did: params._did ?? USER,
    scope: params._scope ?? SCOPE,
  });
}

/** @param {any} routes @param {string} path @param {any} body @param {Record<string,string>} [headers] */
function post(routes, path, body, headers = {}) {
  const url = new URL(`https://pds.example${path}`);
  const request = new Request(url, {
    method: 'POST',
    body: JSON.stringify(body),
    headers,
  });
  return routes[path].handler(request, url, null);
}

/**
 * A request presenting a space credential: the Atproto-Space-Credential scheme,
 * and a signature over it and the audience by the key the credential is bound
 * to. The audience is the repo the request names, or else the authority.
 * @param {import('../src/http-signature.js').SpaceSigKey} holder
 * @param {string} credential
 * @param {URL} url
 * @param {string} [audience]
 * @param {Record<string, string>} [extra] - further request headers
 * @returns {Promise<Request>}
 */
async function presenting(holder, credential, url, audience, extra = {}) {
  return new Request(url, {
    headers: {
      ...(await createSpaceSigHeaders(holder, {
        authorization: `Atproto-Space-Credential ${credential}`,
        audience: audience ?? url.searchParams.get('repo') ?? AUTHORITY,
      })),
      ...extra,
    },
  });
}

/**
 * Ask the authority for a credential.
 * @param {Record<string, unknown>} body
 */
function requestCredential(body) {
  return post(authorityRoutes, '/xrpc/com.atproto.space.getSpaceCredential', {
    credentialKid: sigKey.did,
    ...body,
  });
}

/** The full chain: delegation token -> credential bound to `holder`'s key. */
async function getCredential(holder = sigKey, space = SPACE) {
  const { token } = await (
    await get(
      userRoutes,
      '/xrpc/com.atproto.space.getDelegationToken',
      { space },
      {},
    )
  ).json();
  const res = await requestCredential({
    space,
    credentialKid: holder.did,
    delegationToken: token,
  });
  return { res, token };
}

describe('getDelegationToken', () => {
  it('mints a token addressed to the authority space host', async () => {
    const res = await get(
      userRoutes,
      '/xrpc/com.atproto.space.getDelegationToken',
      {
        space: SPACE,
      },
    );
    expect(res.status).toBe(200);
    const { token } = await res.json();
    const payload = JSON.parse(
      new TextDecoder().decode(
        Uint8Array.from(
          atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')),
          (c) => c.charCodeAt(0),
        ),
      ),
    );
    expect(payload.iss).toBe(USER);
    expect(payload.sub).toBe(SPACE);
    expect(payload.aud).toBe(`${AUTHORITY}${ATPROTO_PDS_ID}`);
    expect(payload.exp - payload.iat).toBe(60);
  });

  it('refuses to mint for another account', async () => {
    const url = new URL(
      'https://pds.example/xrpc/com.atproto.space.getDelegationToken',
    );
    url.searchParams.set('space', SPACE);
    const res = await userRoutes[
      '/xrpc/com.atproto.space.getDelegationToken'
    ].handler(new Request(url), url, {
      did: 'did:plc:someone-else',
      scope: SCOPE,
    });
    expect(res.status).toBe(403);
  });
});

/** A memberspace anyone may read and write. */
const PUBLIC = {
  $type: 'com.atproto.memberspace.policy',
  publicRead: true,
  publicWrite: true,
};

describe('getSpaceCredential', () => {
  // A refusal answers as a space that does not exist, so it discloses
  // nothing about the space to someone without access to it.
  it('answers a non-member of a memberspace that the space is not there', async () => {
    const { res } = await getCredential();
    expect(res.status).toBe(404);
    expect((await res.json()).error).toBe('SpaceNotFound');
  });

  it('checks the user before the app', async () => {
    await authorityStore.putSpace(
      makeSpaceRow(SPACE, {
        isOwner: true,
        policy: {
          $type: 'com.atproto.memberspace.policy',
          allowedApps: ['https://allowed.example/client-metadata.json'],
        },
      }),
    );
    const { res } = await getCredential();
    expect((await res.json()).error).toBe('SpaceNotFound');
  });

  it('admits nobody but the authority to a personalspace', async () => {
    await authorityStore.putMember(SPACE, USER, { read: true, write: true });
    await authorityStore.putSpace(
      makeSpaceRow(SPACE, {
        isOwner: true,
        policy: { $type: 'com.atproto.personalspace.policy' },
      }),
    );
    const { res } = await getCredential();
    expect((await res.json()).error).toBe('SpaceNotFound');
  });

  /** A delegation token from the member, signed by `signer`. */
  async function memberToken(signer = user.signer, claims = {}) {
    await authorityStore.putMember(SPACE, USER, { read: true, write: true });
    return createSpaceToken(
      'delegation',
      {
        iss: USER,
        sub: SPACE,
        aud: `${AUTHORITY}${ATPROTO_PDS_ID}`,
        ...claims,
      },
      signer,
    );
  }

  /** @param {string} jwt */
  const payloadOf = (jwt) =>
    JSON.parse(
      new TextDecoder().decode(
        Uint8Array.from(
          atob(jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')),
          (c) => c.charCodeAt(0),
        ),
      ),
    );

  it('binds the credential to the key the body names', async () => {
    const res = await requestCredential({
      space: SPACE,
      delegationToken: await memberToken(),
    });
    expect(res.status).toBe(200);
    const payload = payloadOf((await res.json()).credential);
    expect(payload.cnf).toEqual({ kid: sigKey.did });
    expect(payload.exp - payload.iat).toBe(600);
  });

  it('refuses a credentialKid that is not a P-256 did:key', async () => {
    const delegationToken = await memberToken();
    for (const credentialKid of [
      undefined,
      'not-a-did',
      // secp256k1
      'did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme',
    ]) {
      const res = await requestCredential({
        space: SPACE,
        delegationToken,
        credentialKid,
      });
      expect(res.status, String(credentialKid)).toBe(400);
      expect((await res.json()).error).toBe('InvalidRequest');
    }
  });

  it('issues a credential to the authority for its own space', async () => {
    // No member row for the authority, and the policy is member-list. The
    // authority is the only party who can add one, so a policy that locked it
    // out would leave it unable to open its own space.
    const token = await createSpaceToken(
      'delegation',
      { iss: AUTHORITY, sub: SPACE, aud: `${AUTHORITY}${ATPROTO_PDS_ID}` },
      authority.signer,
    );
    const res = await requestCredential({
      space: SPACE,
      delegationToken: token,
    });
    expect(res.status).toBe(200);
    expect(typeof (await res.json()).credential).toBe('string');
  });

  it('issues a credential to a member', async () => {
    await authorityStore.putMember(SPACE, USER, { read: true, write: true });
    const { res } = await getCredential();
    expect(res.status).toBe(200);
    const { credential } = await res.json();
    expect(typeof credential).toBe('string');
    const header = JSON.parse(
      new TextDecoder().decode(
        Uint8Array.from(atob(credential.split('.')[0]), (c) => c.charCodeAt(0)),
      ),
    );
    expect(header.typ).toBe('atproto-space-credential+jwt');
    expect(header.kid).toBe(ATPROTO_KEY_ID);
    const payload = JSON.parse(
      new TextDecoder().decode(
        Uint8Array.from(atob(credential.split('.')[1]), (c) => c.charCodeAt(0)),
      ),
    );
    // The binding, without which the credential is a bearer token for the space.
    expect(payload.cnf).toEqual({ kid: sigKey.did });
    expect(payload.aud).toBeUndefined();
  });

  it('issues to anyone under a public policy', async () => {
    await authorityStore.putSpace(
      makeSpaceRow(SPACE, {
        isOwner: true,
        policy: PUBLIC,
      }),
    );
    const { res } = await getCredential();
    expect(res.status).toBe(200);
  });

  it('issues without a delegation token under a public read policy', async () => {
    await authorityStore.putSpace(
      makeSpaceRow(SPACE, { isOwner: true, policy: PUBLIC }),
    );
    const res = await requestCredential({ space: SPACE });
    expect(res.status).toBe(200);
    const payload = payloadOf((await res.json()).credential);
    expect(payload.sub).toBe(SPACE);
    expect(payload.cnf).toEqual({ kid: sigKey.did });
  });

  // Without a delegation token, a space that is not public answers the same as
  // one that does not exist.
  it('answers SpaceNotFound without a delegation token', async () => {
    const res = await requestCredential({ space: SPACE });
    expect(res.status).toBe(404);
    expect((await res.json()).error).toBe('SpaceNotFound');
    const missing = await requestCredential({
      space: `at://${AUTHORITY}/space/com.example.forum/missing`,
    });
    expect(missing.status).toBe(404);
    expect((await missing.json()).error).toBe('SpaceNotFound');
  });

  it('answers SpaceNotFound for a space another authority holds', async () => {
    const res = await requestCredential({
      space: 'at://did:plc:elsewhere/space/com.example.forum/general',
    });
    expect(res.status).toBe(404);
    expect((await res.json()).error).toBe('SpaceNotFound');
  });

  it('rejects a delegation token signed by the wrong key', async () => {
    await authorityStore.putMember(SPACE, USER, { read: true, write: true });
    const impostor = await makeIdentity();
    const token = await createSpaceToken(
      'delegation',
      { iss: USER, sub: SPACE, aud: `${AUTHORITY}${ATPROTO_PDS_ID}` },
      impostor.signer,
    );
    const res = await post(
      authorityRoutes,
      '/xrpc/com.atproto.space.getSpaceCredential',
      { space: SPACE, credentialKid: sigKey.did, delegationToken: token },
    );
    expect(res.status).toBe(400);
    expect((await res.json()).error).toBe('InvalidDelegationToken');
  });

  it('rejects a delegation token whose header contradicts the issuer key', async () => {
    await authorityStore.putMember(SPACE, USER, { read: true, write: true });
    // A correct signature under a P-256 key, stamped ES256K. The reference
    // implementation refuses the mismatch, so this server has to as well.
    const token = await createSpaceToken(
      'delegation',
      {
        iss: USER,
        sub: SPACE,
        aud: `${AUTHORITY}${ATPROTO_PDS_ID}`,
        alg: 'ES256K',
      },
      user.signer,
    );
    const res = await post(
      authorityRoutes,
      '/xrpc/com.atproto.space.getSpaceCredential',
      { space: SPACE, credentialKid: sigKey.did, delegationToken: token },
    );
    expect(res.status).toBe(400);
    expect((await res.json()).error).toBe('InvalidDelegationToken');
  });

  /**
   * An authority whose resolver holds a stale document for the user: the key
   * they rotated away from, until a caller forces a refresh.
   * @param {any} rotated - the identity the user rotated to
   */
  function authorityHoldingStaleKey(rotated) {
    const stale = didDocs[USER];
    const fresh = {
      ...stale,
      verificationMethod: [
        {
          id: '#atproto',
          type: 'Multikey',
          publicKeyMultibase: rotated.multibase,
        },
      ],
    };
    return createSpaceRoutes({
      spaceStorage: authorityStore,
      ...createMemoryBlobContext(),
      getDid: async () => AUTHORITY,
      getSigner: async () => authority.signer,
      getKemKey: async () => null,
      resolveDid: /** @type {import('@pdsjs/core/ports').DidResolverPort} */ (
        async (d, opts) => {
          if (d !== USER) return didDocs[d] ?? null;
          return opts?.forceRefresh ? fresh : stale;
        }
      ),
      verifier: createVerifier(),
    });
  }

  it('accepts a token signed by the key the issuer rotated to', async () => {
    await authorityStore.putMember(SPACE, USER, { read: true, write: true });
    const rotated = await makeIdentity();
    const token = await createSpaceToken(
      'delegation',
      { iss: USER, sub: SPACE, aud: `${AUTHORITY}${ATPROTO_PDS_ID}` },
      rotated.signer,
    );
    const res = await post(
      authorityHoldingStaleKey(rotated),
      '/xrpc/com.atproto.space.getSpaceCredential',
      { space: SPACE, credentialKid: sigKey.did, delegationToken: token },
    );
    expect(res.status).toBe(200);
  });

  it('refuses a token that matches neither the held key nor the current one', async () => {
    await authorityStore.putMember(SPACE, USER, { read: true, write: true });
    const rotated = await makeIdentity();
    const impostor = await makeIdentity();
    const token = await createSpaceToken(
      'delegation',
      { iss: USER, sub: SPACE, aud: `${AUTHORITY}${ATPROTO_PDS_ID}` },
      impostor.signer,
    );
    const res = await post(
      authorityHoldingStaleKey(rotated),
      '/xrpc/com.atproto.space.getSpaceCredential',
      { space: SPACE, credentialKid: sigKey.did, delegationToken: token },
    );
    expect(res.status).toBe(400);
    expect((await res.json()).error).toBe('InvalidDelegationToken');
  });

  it('rejects a token addressed to a different authority', async () => {
    await authorityStore.putMember(SPACE, USER, { read: true, write: true });
    const token = await createSpaceToken(
      'delegation',
      { iss: USER, sub: SPACE, aud: 'did:plc:elsewhere#atproto_pds' },
      user.signer,
    );
    const res = await post(
      authorityRoutes,
      '/xrpc/com.atproto.space.getSpaceCredential',
      { space: SPACE, credentialKid: sigKey.did, delegationToken: token },
    );
    expect(res.status).toBe(400);
    expect((await res.json()).error).toBe('InvalidDelegationToken');
  });

  it('rejects a token whose subject is a different space', async () => {
    await authorityStore.putMember(SPACE, USER, { read: true, write: true });
    const token = await createSpaceToken(
      'delegation',
      {
        iss: USER,
        sub: `at://${AUTHORITY}/space/com.example.forum/other`,
        aud: `${AUTHORITY}${ATPROTO_PDS_ID}`,
      },
      user.signer,
    );
    const res = await post(
      authorityRoutes,
      '/xrpc/com.atproto.space.getSpaceCredential',
      { space: SPACE, credentialKid: sigKey.did, delegationToken: token },
    );
    expect(res.status).toBe(400);
    expect((await res.json()).error).toBe('InvalidDelegationToken');
  });

  it('spends the delegation token, so it buys exactly one credential', async () => {
    await authorityStore.putMember(SPACE, USER, { read: true, write: true });
    const { token } = await (
      await get(userRoutes, '/xrpc/com.atproto.space.getDelegationToken', {
        space: SPACE,
      })
    ).json();

    const exchange = () =>
      requestCredential({ space: SPACE, delegationToken: token });
    expect((await exchange()).status).toBe(200);
    const second = await exchange();
    expect(second.status).toBe(400);
    expect((await second.json()).error).toBe('InvalidDelegationToken');
  });

  it('answers SpaceDeleted for a deleted space, with or without a token', async () => {
    await authorityStore.putMember(SPACE, USER, { read: true, write: true });
    await authorityStore.deleteSpace(SPACE, new Date().toISOString());
    const { res } = await getCredential();
    expect(res.status).toBe(400);
    expect((await res.json()).error).toBe('SpaceDeleted');
    const bare = await requestCredential({ space: SPACE });
    expect((await bare.json()).error).toBe('SpaceDeleted');
  });

  it('enforces an app allow list', async () => {
    await authorityStore.putMember(SPACE, USER, { read: true, write: true });
    await authorityStore.putSpace(
      makeSpaceRow(SPACE, {
        isOwner: true,
        policy: {
          $type: 'com.atproto.memberspace.policy',
          allowedApps: ['https://allowed.example/client-metadata.json'],
        },
      }),
    );
    const { res } = await getCredential();
    expect(res.status).toBe(403);
    expect((await res.json()).error).toBe('AppNotAuthorized');
  });

  it('authorizes an attested client on the allow list', async () => {
    const clientId = 'https://allowed.example/client-metadata.json';
    await authorityStore.putMember(SPACE, USER, { read: true, write: true });
    await authorityStore.putSpace(
      makeSpaceRow(SPACE, {
        isOwner: true,
        policy: {
          $type: 'com.atproto.memberspace.policy',
          allowedApps: [clientId],
        },
      }),
    );
    const app = await makeIdentity();
    const attestation = await createSpaceToken(
      'clientAttestation',
      {
        iss: clientId,
        sub: clientId,
        aud: `${AUTHORITY}${ATPROTO_PDS_ID}`,
        kid: 'key-1',
      },
      app.signer,
    );

    const { token } = await (
      await get(userRoutes, '/xrpc/com.atproto.space.getDelegationToken', {
        space: SPACE,
      })
    ).json();
    const res = await withClientMetadata(
      clientId,
      { keys: [{ ...app.publicJwk, kid: 'key-1' }] },
      async () =>
        requestCredential({
          space: SPACE,
          delegationToken: token,
          clientAttestation: attestation,
        }),
    );
    expect(res.status).toBe(200);
  });

  it('refuses an attestation signed by a key the client does not publish', async () => {
    const clientId = 'https://allowed.example/client-metadata.json';
    await authorityStore.putMember(SPACE, USER, { read: true, write: true });
    await authorityStore.putSpace(
      makeSpaceRow(SPACE, {
        isOwner: true,
        policy: {
          $type: 'com.atproto.memberspace.policy',
          allowedApps: [clientId],
        },
      }),
    );
    const app = await makeIdentity();
    const impostor = await makeIdentity();
    const attestation = await createSpaceToken(
      'clientAttestation',
      {
        iss: clientId,
        sub: clientId,
        aud: `${AUTHORITY}${ATPROTO_PDS_ID}`,
        kid: 'key-1',
      },
      impostor.signer,
    );

    const { token } = await (
      await get(userRoutes, '/xrpc/com.atproto.space.getDelegationToken', {
        space: SPACE,
      })
    ).json();
    const res = await withClientMetadata(
      clientId,
      { keys: [{ ...app.publicJwk, kid: 'key-1' }] },
      async () =>
        requestCredential({
          space: SPACE,
          delegationToken: token,
          clientAttestation: attestation,
        }),
    );
    expect(res.status).toBe(400);
    expect((await res.json()).error).toBe('InvalidClientAttestation');
  });

  it('refuses under a managedspace whose managing app cannot be reached', async () => {
    await authorityStore.putMember(SPACE, USER, { read: true, write: true });
    await authorityStore.putSpace(
      makeSpaceRow(SPACE, {
        isOwner: true,
        policy: {
          $type: 'com.atproto.managedspace.policy',
          managingApp: 'did:web:app.example#service',
        },
      }),
    );
    const { res } = await getCredential();
    expect(res.status).toBe(404);
    expect((await res.json()).error).toBe('SpaceNotFound');
  });
});

describe('reading with a credential', () => {
  /** Issue a real credential for the space. */
  async function credential() {
    await authorityStore.putMember(SPACE, USER, { read: true, write: true });
    const { res } = await getCredential();
    return (await res.json()).credential;
  }

  it('lets a third party read a repo it does not own', async () => {
    await applyWrites(userStore, {
      space: SPACE,
      writes: [
        { action: 'create', collection: COL, rkey: 'a', record: { v: 1 } },
      ],
    });
    const cred = await credential();

    const url = new URL('https://pds.example/xrpc/com.atproto.space.getRecord');
    url.searchParams.set('space', SPACE);
    url.searchParams.set('collection', COL);
    url.searchParams.set('rkey', 'a');
    const want = await wantMac('atproto-space-record', userKem.didKey);
    const res = await userRoutes['/xrpc/com.atproto.space.getRecord'].handler(
      await presenting(sigKey, cred, url, USER, {
        'atproto-space-want-mac': want.header,
      }),
      url,
      // No PDS session: the credential is the only thing granting access.
      null,
    );
    expect(res.status).toBe(200);
    const record = await res.json();
    expect(record.value).toEqual({ v: 1 });
    expect(
      await verifyRecord(record, {
        uri: `${SPACE}/${USER}/${COL}/a`,
        sharedSecret: want.sharedSecret,
        mac: responseMac(res, 'atproto-space-record'),
      }),
    ).toBe(true);
  });

  it('refuses a credential read that asks for no MAC', async () => {
    await applyWrites(userStore, {
      space: SPACE,
      writes: [
        { action: 'create', collection: COL, rkey: 'a', record: { v: 1 } },
      ],
    });
    const cred = await credential();
    for (const path of [
      '/xrpc/com.atproto.space.getRecord',
      '/xrpc/com.atproto.space.getLatestCommit',
      '/xrpc/com.atproto.space.listRepoOps',
      '/xrpc/com.atproto.space.getRepo',
    ]) {
      const url = new URL(`https://pds.example${path}`);
      url.searchParams.set('space', SPACE);
      url.searchParams.set('collection', COL);
      url.searchParams.set('rkey', 'a');
      const res = await userRoutes[path].handler(
        await presenting(sigKey, cred, url, USER),
        url,
        null,
      );
      expect(res.status, path).toBe(400);
      expect((await res.json()).error).toBe('BadSpaceMac');
    }
  });

  it('lists records to a credential without a MAC', async () => {
    const cred = await credential();
    const url = new URL(
      'https://pds.example/xrpc/com.atproto.space.listRecords',
    );
    url.searchParams.set('space', SPACE);
    const res = await userRoutes['/xrpc/com.atproto.space.listRecords'].handler(
      await presenting(sigKey, cred, url, USER),
      url,
      null,
    );
    expect(res.status).toBe(200);
  });

  it('refuses a credential presented without a signature', async () => {
    const cred = await credential();
    const url = new URL('https://pds.example/xrpc/com.atproto.space.getRecord');
    url.searchParams.set('space', SPACE);
    url.searchParams.set('collection', COL);
    url.searchParams.set('rkey', 'a');
    const res = await userRoutes['/xrpc/com.atproto.space.getRecord'].handler(
      new Request(url, {
        headers: {
          authorization: `Atproto-Space-Credential ${cred}`,
          'atproto-space-audience': USER,
        },
      }),
      url,
      null,
    );
    expect(res.status).toBe(401);
    expect((await res.json()).error).toBe('BadSpaceSignature');
  });

  it('refuses a credential presented as a bearer token', async () => {
    const cred = await credential();
    const url = new URL('https://pds.example/xrpc/com.atproto.space.getRecord');
    url.searchParams.set('space', SPACE);
    url.searchParams.set('collection', COL);
    url.searchParams.set('rkey', 'a');
    const res = await userRoutes['/xrpc/com.atproto.space.getRecord'].handler(
      new Request(url, { headers: { authorization: `Bearer ${cred}` } }),
      url,
      null,
    );
    expect(res.status).toBe(401);
    expect((await res.json()).error).toBe('AuthenticationRequired');
  });

  // The property the binding exists for: a repo host handed a credential to
  // serve its own repo cannot turn round and use it on another host.
  it('refuses a signature by a key the credential is not bound to', async () => {
    const cred = await credential();
    const thief = await generateSpaceSigKey();
    const url = new URL('https://pds.example/xrpc/com.atproto.space.getRecord');
    url.searchParams.set('space', SPACE);
    url.searchParams.set('collection', COL);
    url.searchParams.set('rkey', 'a');
    const res = await userRoutes['/xrpc/com.atproto.space.getRecord'].handler(
      await presenting(thief, cred, url, USER),
      url,
      null,
    );
    expect(res.status).toBe(401);
    expect((await res.json()).error).toBe('BadSpaceSignature');
  });

  it('refuses a request addressed to another account', async () => {
    const cred = await credential();
    const url = new URL('https://pds.example/xrpc/com.atproto.space.getRecord');
    url.searchParams.set('space', SPACE);
    url.searchParams.set('collection', COL);
    url.searchParams.set('rkey', 'a');
    const res = await userRoutes['/xrpc/com.atproto.space.getRecord'].handler(
      await presenting(sigKey, cred, url, 'did:plc:somebody-else'),
      url,
      null,
    );
    expect(res.status).toBe(401);
    expect((await res.json()).error).toBe('BadSpaceAudience');
  });

  it('refuses an audience sent twice', async () => {
    const cred = await credential();
    const url = new URL('https://pds.example/xrpc/com.atproto.space.getRecord');
    url.searchParams.set('space', SPACE);
    url.searchParams.set('collection', COL);
    url.searchParams.set('rkey', 'a');
    const request = await presenting(sigKey, cred, url, USER);
    request.headers.append('atproto-space-audience', USER);
    const res = await userRoutes['/xrpc/com.atproto.space.getRecord'].handler(
      request,
      url,
      null,
    );
    expect(res.status).toBe(401);
    expect((await res.json()).error).toBe('BadSpaceSignature');
  });

  // Nothing in the signature covers the request itself, so one made for this
  // credential and audience serves every request that pairs them.
  it('accepts a signature reused on a second request', async () => {
    const cred = await credential();
    const url = new URL('https://pds.example/xrpc/com.atproto.space.getRecord');
    url.searchParams.set('space', SPACE);
    url.searchParams.set('collection', COL);
    url.searchParams.set('rkey', 'a');
    const request = await presenting(sigKey, cred, url, USER);
    for (const attempt of [request.clone(), request]) {
      const res = await userRoutes['/xrpc/com.atproto.space.getRecord'].handler(
        attempt,
        url,
        null,
      );
      expect(res.status).not.toBe(401);
    }
  });

  it('refuses a credential the authority revoked', async () => {
    const cred = await credential();
    const { jti } = JSON.parse(atob(cred.split('.')[1]));
    await userStore.putCredentialRevocations(
      SPACE,
      [jti],
      new Date(Date.now() + 60_000).toISOString(),
    );
    const url = new URL('https://pds.example/xrpc/com.atproto.space.getRecord');
    url.searchParams.set('space', SPACE);
    url.searchParams.set('collection', COL);
    url.searchParams.set('rkey', 'a');
    const res = await userRoutes['/xrpc/com.atproto.space.getRecord'].handler(
      await presenting(sigKey, cred, url, USER),
      url,
      null,
    );
    expect(res.status).toBe(401);
    expect((await res.json()).error).toBe('CredentialRevoked');
  });

  it('refuses a read with neither session nor credential', async () => {
    const url = new URL('https://pds.example/xrpc/com.atproto.space.getRecord');
    url.searchParams.set('space', SPACE);
    url.searchParams.set('collection', COL);
    url.searchParams.set('rkey', 'a');
    const res = await userRoutes['/xrpc/com.atproto.space.getRecord'].handler(
      new Request(url),
      url,
      null,
    );
    expect(res.status).toBe(401);
  });

  it('refuses a credential minted for a different space', async () => {
    const other = `at://${AUTHORITY}/space/com.example.forum/other`;
    await authorityStore.putSpace(
      makeSpaceRow(other, { isOwner: true, policy: PUBLIC }),
    );
    const cred = (await (await getCredential(sigKey, other)).res.json())
      .credential;

    const url = new URL('https://pds.example/xrpc/com.atproto.space.getRecord');
    url.searchParams.set('space', SPACE);
    url.searchParams.set('collection', COL);
    url.searchParams.set('rkey', 'a');
    const res = await userRoutes['/xrpc/com.atproto.space.getRecord'].handler(
      await presenting(sigKey, cred, url, USER),
      url,
      null,
    );
    expect(res.status).toBe(401);
    expect((await res.json()).error).toBe('BadJwtSub');
  });
});

describe('listRepos', () => {
  /** Issue a real credential for a space, as a member would hold. */
  async function credentialFor(space = SPACE) {
    await authorityStore.putMember(space, USER, { read: true, write: true });
    const { res } = await getCredential(sigKey, space);
    return (await res.json()).credential;
  }

  /** @param {string|null} cred */
  async function listRepos(cred, space = SPACE) {
    const url = new URL('https://pds.example/xrpc/com.atproto.space.listRepos');
    url.searchParams.set('space', space);
    return authorityRoutes['/xrpc/com.atproto.space.listRepos'].handler(
      cred ? await presenting(sigKey, cred, url) : new Request(url),
      url,
      null,
    );
  }

  beforeEach(async () => {
    await authorityStore.putSpaceWriter(
      SPACE,
      USER,
      '3lkaaa',
      new Uint8Array(32).fill(3),
      '3lksss',
    );
  });

  it('lists the writer set to a credential holder', async () => {
    const res = await listRepos(await credentialFor());
    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.repos).toHaveLength(1);
    expect(body.repos[0].did).toBe(USER);
    expect(body.repos[0].repoRev).toBe('3lkaaa');
    expect(body.repos[0].spaceRev).toBe('3lksss');
    expect(typeof body.repos[0].data.$bytes).toBe('string');
    expect(body.cursor).toBe('3lksss');
  });

  it('ends on an empty page without a cursor', async () => {
    const cred = await credentialFor();
    const url = new URL('https://pds.example/xrpc/com.atproto.space.listRepos');
    url.searchParams.set('space', SPACE);
    url.searchParams.set('cursor', '3lksss');
    const res = await authorityRoutes[
      '/xrpc/com.atproto.space.listRepos'
    ].handler(await presenting(sigKey, cred, url), url, null);
    const body = await res.json();
    expect(body.repos).toEqual([]);
    expect(body.cursor).toBeUndefined();
  });

  it('refuses a request addressed to the repo host rather than the authority', async () => {
    const cred = await credentialFor();
    const url = new URL('https://pds.example/xrpc/com.atproto.space.listRepos');
    url.searchParams.set('space', SPACE);
    const res = await authorityRoutes[
      '/xrpc/com.atproto.space.listRepos'
    ].handler(await presenting(sigKey, cred, url, USER), url, null);
    expect(res.status).toBe(401);
    expect((await res.json()).error).toBe('BadSpaceAudience');
  });

  // The writer set names who is in a space and when each of them last wrote.
  // Serving it openly discloses the membership of a private space to anyone
  // who can name it, and a space URI is guessable: the DID is public, the type
  // is a published NSID, and `self` is a common record key.
  it('refuses a caller presenting nothing', async () => {
    const res = await listRepos(null);
    expect(res.status).toBe(401);
    expect((await res.json()).error).toBe('AuthenticationRequired');
  });

  it('refuses a credential presented without a signature', async () => {
    const cred = await credentialFor();
    const url = new URL('https://pds.example/xrpc/com.atproto.space.listRepos');
    url.searchParams.set('space', SPACE);
    const res = await authorityRoutes[
      '/xrpc/com.atproto.space.listRepos'
    ].handler(
      new Request(url, {
        headers: { authorization: `Atproto-Space-Credential ${cred}` },
      }),
      url,
      null,
    );
    expect(res.status).toBe(401);
    expect((await res.json()).error).toBe('BadSpaceSignature');
  });

  // A credential is bound to one space by its `sub`, so holding one for a space
  // this server also hosts says nothing about another.
  it('refuses a credential issued for a different space', async () => {
    const other = `at://${AUTHORITY}/space/com.example.other/skey`;
    await authorityStore.putSpace(makeSpaceRow(other, { isOwner: true }));
    const res = await listRepos(await credentialFor(other), SPACE);
    expect(res.status).toBe(401);
  });

  // The credential is checked before the space is looked up, so a caller with
  // none cannot learn which spaces this server is the authority for.
  it('answers a space it does not host the same as one it does', async () => {
    const res = await listRepos(
      null,
      'at://did:plc:someoneelse/space/com.example.forum/skey',
    );
    expect(res.status).toBe(401);
    expect((await res.json()).error).toBe('AuthenticationRequired');
  });
});

describe('scope enforcement', () => {
  it('refuses a delegation token without a covering scope', async () => {
    const url = new URL(
      'https://pds.example/xrpc/com.atproto.space.getDelegationToken',
    );
    url.searchParams.set('space', SPACE);
    const res = await userRoutes[
      '/xrpc/com.atproto.space.getDelegationToken'
    ].handler(new Request(url), url, {
      did: USER,
      scope: 'repo:com.example.post',
    });
    expect(res.status).toBe(403);
    expect((await res.json()).error).toBe('InvalidToken');
  });

  it('refuses a delegation token to a read_self-only grant', async () => {
    const url = new URL(
      'https://pds.example/xrpc/com.atproto.space.getDelegationToken',
    );
    url.searchParams.set('space', SPACE);
    const res = await userRoutes[
      '/xrpc/com.atproto.space.getDelegationToken'
    ].handler(new Request(url), url, {
      did: USER,
      scope: `space:com.example.forum?authority=${AUTHORITY}&action=read_self`,
    });
    expect(res.status).toBe(403);
  });

  it('refuses a write to a collection outside the grant', async () => {
    const url = new URL(
      'https://pds.example/xrpc/com.atproto.space.createRecord',
    );
    const res = await userRoutes[
      '/xrpc/com.atproto.space.createRecord'
    ].handler(
      new Request(url, {
        method: 'POST',
        body: JSON.stringify({
          space: SPACE,
          repo: USER,
          collection: 'com.example.other',
          record: {},
        }),
      }),
      url,
      { did: USER, scope: SCOPE },
    );
    expect(res.status).toBe(403);
    expect((await res.json()).error).toBe('InvalidToken');
  });

  it('permits a write inside the grant', async () => {
    const url = new URL(
      'https://pds.example/xrpc/com.atproto.space.createRecord',
    );
    const res = await userRoutes[
      '/xrpc/com.atproto.space.createRecord'
    ].handler(
      new Request(url, {
        method: 'POST',
        body: JSON.stringify({
          space: SPACE,
          repo: USER,
          collection: COL,
          record: {},
        }),
      }),
      url,
      { did: USER, scope: SCOPE },
    );
    expect(res.status).toBe(200);
  });
});

describe('checkUserAccess', () => {
  it('asks the managing app with com.atproto.managedspace.checkUserAccess', async () => {
    /** @type {string[]} */
    const asked = [];
    const authorized = await checkUserAccess({
      managingApp: 'did:web:app.example#forum',
      authorityDid: AUTHORITY,
      space: SPACE,
      access: 'read',
      userDid: USER,
      clientId: 'https://app.example/client-metadata.json',
      resolveDid: async () => ({
        id: 'did:web:app.example',
        service: [
          {
            id: '#forum',
            type: 'Forum',
            serviceEndpoint: 'https://app.example',
          },
        ],
      }),
      getSigner: async () => authority.signer,
      fetch: async (/** @type {any} */ url) => {
        asked.push(String(url));
        return Response.json({ authorized: true });
      },
    });
    expect(authorized).toBe(true);
    const url = new URL(asked[0]);
    expect(url.pathname).toBe('/xrpc/com.atproto.managedspace.checkUserAccess');
    expect(url.searchParams.get('user')).toBe(USER);
    expect(url.searchParams.get('clientId')).toBe(
      'https://app.example/client-metadata.json',
    );
  });
});