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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
324 lines · 11.1 KB Raw History
// Which server each call is addressed to, and with which credential. This is
// the part of the client that is easy to get wrong and invisible when it is:
// a read sent to the wrong host answers RepoNotFound rather than nothing.

import { beforeEach, describe, expect, it, vi } from 'vitest';

const authedFetch = vi.fn();
const credentialFetch = vi.fn();
const spaceCredential = vi.fn(async () => 'a-credential');
const repoHost = vi.fn(
  async (did) => `https://${did.split(':').pop()}.example`,
);

vi.mock('#/lib/oauth.js', () => ({ authedFetch }));
const SECRET = new Uint8Array(32).fill(7);
const wantMac = vi.fn(async () => ({
  headers: { 'atproto-space-want-mac': 'a-key-share' },
  sharedSecret: SECRET,
}));
vi.mock('#/lib/credential.js', () => ({
  credentialFetch,
  repoHost,
  spaceCredential,
  wantMac,
}));

const { cborEncodeDagCbor, cidToString, createCid } = await import(
  '@pdsjs/core/repo'
);
const {
  COMMIT_MAC_TYPE,
  computeCommitMac,
  computeRecordMac,
  formatMacHeader,
  RECORD_MAC_TYPE,
} = await import('@pdsjs/spaces/mac');

const {
  XrpcError,
  createRecord,
  createSpace,
  deleteRecord,
  getLatestCommit,
  getRecord,
  getSpace,
  listRecords,
  listRepos,
} = await import('#/lib/spaces.js');

const ALICE = 'did:plc:alice';
const BOB = 'did:plc:bob';
const SPACE = `at://${ALICE}/space/com.example.forum/general`;

/** @type {any} */
const session = {
  did: ALICE,
  pds: 'https://alice.example',
  accessToken: 't',
};

/** @param {unknown} body @param {number} [status] */
function answers(body, status = 200) {
  return Response.json(body, { status });
}

beforeEach(() => {
  vi.clearAllMocks();
  authedFetch.mockResolvedValue(answers({ records: [] }));
  credentialFetch.mockResolvedValue(answers({ records: [] }));
});

describe('reading a repo', () => {
  it("sends the signed-in account's own repo to its own PDS", async () => {
    await listRecords(session, { space: SPACE });
    expect(credentialFetch).not.toHaveBeenCalled();
    const url = new URL(authedFetch.mock.calls[0][1]);
    expect(url.origin).toBe('https://alice.example');
    expect(url.pathname).toBe('/xrpc/com.atproto.space.listRecords');
    expect(url.searchParams.get('space')).toBe(SPACE);
    // Naming the repo is what a host reads as "somebody else's", so the call
    // for one's own leaves it out.
    expect(url.searchParams.get('repo')).toBeNull();
  });

  it("sends another member's repo to that member's host, with a credential", async () => {
    await listRecords(session, { space: SPACE, repo: BOB });
    expect(authedFetch).not.toHaveBeenCalled();
    expect(spaceCredential).toHaveBeenCalledWith(session, SPACE);
    const [credential, url] = credentialFetch.mock.calls[0];
    expect(credential).toBe('a-credential');
    expect(new URL(url).origin).toBe('https://bob.example');
    expect(new URL(url).searchParams.get('repo')).toBe(BOB);
  });

  it("checks another member's record against the MAC it comes with", async () => {
    const value = { $type: 'com.example.post', text: 'hi' };
    const cid = cidToString(await createCid(cborEncodeDagCbor(value)));
    const uri = `${SPACE}/${BOB}/com.example.post/one`;
    const mac = formatMacHeader(
      RECORD_MAC_TYPE,
      await computeRecordMac(SECRET, uri, cid),
    );
    credentialFetch.mockResolvedValue(
      Response.json(
        { uri, cid, value },
        { headers: { 'atproto-space-mac': mac } },
      ),
    );
    const record = await getRecord(
      session,
      { space: SPACE, repo: BOB },
      'com.example.post',
      'one',
    );
    expect(record.value).toEqual(value);
    expect(wantMac).toHaveBeenCalledWith(BOB, RECORD_MAC_TYPE);
    expect(credentialFetch.mock.calls[0][2]).toEqual({
      headers: { 'atproto-space-want-mac': 'a-key-share' },
    });

    credentialFetch.mockResolvedValue(
      Response.json(
        { uri, cid, value: { ...value, text: 'changed' } },
        { headers: { 'atproto-space-mac': mac } },
      ),
    );
    await expect(
      getRecord(
        session,
        { space: SPACE, repo: BOB },
        'com.example.post',
        'one',
      ),
    ).rejects.toMatchObject({ name: 'BadSpaceMac' });
  });

  it("checks another member's commit against its MAC", async () => {
    const commit = {
      ver: 1,
      repo: BOB,
      space: SPACE,
      rev: '3l',
      data: new Uint8Array(32).fill(1),
    };
    const mac = formatMacHeader(
      COMMIT_MAC_TYPE,
      await computeCommitMac(SECRET, commit),
    );
    const json = { ...commit, data: { $bytes: btoa('\x01'.repeat(32)) } };
    credentialFetch.mockResolvedValue(
      Response.json(
        { commit: json },
        { headers: { 'atproto-space-mac': mac } },
      ),
    );
    expect(await getLatestCommit(session, { space: SPACE, repo: BOB })).toEqual(
      json,
    );

    credentialFetch.mockResolvedValue(Response.json({ commit: json }));
    await expect(
      getLatestCommit(session, { space: SPACE, repo: BOB }),
    ).rejects.toMatchObject({ name: 'BadSpaceMac' });

    // A repo with no commit has nothing to MAC.
    credentialFetch.mockResolvedValue(Response.json({}));
    expect(await getLatestCommit(session, { space: SPACE, repo: BOB })).toBe(
      null,
    );
  });

  it('addresses a repo named as the signed-in account as its own', async () => {
    await listRecords(session, { space: SPACE, repo: ALICE });
    expect(credentialFetch).not.toHaveBeenCalled();
    expect(authedFetch).toHaveBeenCalledOnce();
  });

  it('leaves an unset option out of the query rather than sending it empty', async () => {
    await listRecords(session, { space: SPACE }, { limit: 10 });
    const url = new URL(authedFetch.mock.calls[0][1]);
    expect(url.searchParams.get('limit')).toBe('10');
    expect(url.searchParams.has('collection')).toBe(false);
    expect(url.searchParams.has('reverse')).toBe(false);
  });
});

describe('the authority calls', () => {
  // Hosts disagree about whether listRepos needs authentication, so it is asked
  // the way every other authority call is: a credential for somebody else's.
  it("presents a credential to another account's authority", async () => {
    authedFetch.mockResolvedValue(answers({ repos: [] }));
    credentialFetch.mockResolvedValue(answers({ repos: [] }));
    await listRepos(session, `at://${BOB}/space/com.example.forum/general`);

    expect(repoHost).toHaveBeenCalledWith(BOB);
    expect(authedFetch).not.toHaveBeenCalled();
    const [credential, url] = credentialFetch.mock.calls[0];
    expect(credential).toBe('a-credential');
    expect(new URL(url).origin).toBe('https://bob.example');
  });

  // The reference admits no session here, not even the authority's own, so a
  // space this account hosts is asked for the same way as anybody else's.
  it('presents a credential even for its own authority', async () => {
    credentialFetch.mockResolvedValue(answers({ repos: [] }));
    await listRepos(session, SPACE);
    expect(authedFetch).not.toHaveBeenCalled();
    expect(new URL(credentialFetch.mock.calls[0][1]).pathname).toBe(
      '/xrpc/com.atproto.space.listRepos',
    );
  });

  // A host that answered is answering, whatever it said. Retrying anonymously
  // would turn its refusal into a second refusal and report the wrong one.
  it('does not retry anonymously when the host refused the credential', async () => {
    credentialFetch.mockResolvedValue(
      answers({ error: 'SpaceDeleted', message: 'gone' }, 400),
    );
    const anonymous = vi.fn();
    vi.stubGlobal('fetch', anonymous);
    const failure = await listRepos(session, SPACE).catch((e) => e);
    vi.unstubAllGlobals();

    expect(failure.name).toBe('SpaceDeleted');
    expect(anonymous).not.toHaveBeenCalled();
  });

  // A space this account has never been admitted to yields no credential. A
  // host that serves the method openly still answers, so the anonymous read is
  // worth making rather than reporting the space as unreadable.
  it('falls back to an anonymous read when no credential can be had', async () => {
    spaceCredential.mockRejectedValueOnce(new Error('UserNotAuthorized'));
    const anonymous = vi.fn(async () => answers({ repos: [] }));
    vi.stubGlobal('fetch', anonymous);
    await listRepos(session, `at://${BOB}/space/com.example.forum/general`);
    vi.unstubAllGlobals();

    expect(credentialFetch).not.toHaveBeenCalled();
    expect(new URL(anonymous.mock.calls[0][0]).origin).toBe(
      'https://bob.example',
    );
  });

  it('asks the authority for its policy with the owner session', async () => {
    authedFetch.mockResolvedValueOnce(answers({ uri: SPACE }));
    await expect(getSpace(session, SPACE)).resolves.toMatchObject({
      value: { uri: SPACE },
      source: { method: 'com.atproto.space.getSpace', auth: 'session' },
    });
    expect(new URL(authedFetch.mock.calls[0][1]).pathname).toBe(
      '/xrpc/com.atproto.space.getSpace',
    );
  });
});

describe('writing', () => {
  it('names the caller as the repo, which the host requires', async () => {
    authedFetch.mockResolvedValue(answers({ uri: 'at://x', cid: 'y' }));
    await createRecord(session, {
      space: SPACE,
      collection: 'com.example.post',
      record: {},
    });
    const init = authedFetch.mock.calls[0][2];
    expect(init.method).toBe('POST');
    expect(JSON.parse(init.body).repo).toBe(ALICE);
  });

  it("goes to the signed-in account's own PDS whatever the authority is", async () => {
    authedFetch.mockResolvedValue(answers({}));
    await deleteRecord(session, {
      space: `at://${BOB}/space/com.example.forum/general`,
      collection: 'com.example.post',
      rkey: '3k2a',
    });
    expect(authedFetch.mock.calls[0][1]).toBe(
      'https://alice.example/xrpc/com.atproto.space.deleteRecord',
    );
  });

  it('creates a space under the memberspace policy', async () => {
    authedFetch.mockResolvedValue(answers({ uri: SPACE }));
    await createSpace(session, { type: 'com.example.forum', skey: 'general' });
    expect(authedFetch.mock.calls[0][1]).toBe(
      'https://alice.example/xrpc/com.atproto.space.createSpace',
    );
    expect(JSON.parse(authedFetch.mock.calls[0][2].body)).toEqual({
      spaceType: 'com.example.forum',
      skey: 'general',
      policy: {
        $type: 'com.atproto.memberspace.policy',
        publicRead: false,
        publicWrite: false,
        publishPolicy: false,
        publishMembers: false,
      },
    });
  });
});

describe('an error answer', () => {
  it('carries the name the method returned, which a screen reads', async () => {
    authedFetch.mockResolvedValue(
      answers({ error: 'RecordNotFound', message: 'Record not found' }, 404),
    );
    const failure = await listRecords(session, { space: SPACE }).catch(
      (e) => e,
    );
    expect(failure).toBeInstanceOf(XrpcError);
    expect(failure.name).toBe('RecordNotFound');
    expect(failure.status).toBe(404);
  });

  it('says something useful when the body is not an XRPC error at all', async () => {
    authedFetch.mockResolvedValue(
      new Response('gateway timeout', { status: 504 }),
    );
    const failure = await listRecords(session, { space: SPACE }).catch(
      (e) => e,
    );
    expect(failure.name).toBe('RequestFailed');
    expect(failure.message).toContain('504');
  });
});