chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// 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');
});
});