chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// 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 { sign } from '@pdsjs/core/crypto';
import { beforeEach, describe, expect, it } from 'vitest';
import { verifyCommit } from '../src/commit.js';
import {
createMemoryBlobContext,
createMemorySpaceStorage,
} from '../src/memory-storage.js';
import { createSpaceRoutes } from '../src/routes.js';
import { readStar, verifyRecord, verifyRepoStar } from '../src/star.js';
import { applyWrites } from '../src/writer.js';
import { makeKemKey, responseMac, wantMac } from './helpers/kem.js';
const SPACE = 'at://did:plc:aaaa/space/com.example.forum/general';
const DID = 'did:plc:writer';
const COL = 'com.example.post';
const AUTH = { did: DID, scope: 'atproto' };
/** A real P-256 signer so commits can actually be verified. */
async function makeSigner() {
const keys = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'verify'],
);
return {
signer: {
/** @param {Uint8Array} bytes */
sign: (bytes) => sign(keys.privateKey, bytes),
},
verifier: {
/**
* @param {string} _didKey @param {Uint8Array} data @param {Uint8Array} sig
*/
verify: async (_didKey, data, sig) =>
crypto.subtle.verify(
{ name: 'ECDSA', hash: 'SHA-256' },
keys.publicKey,
/** @type {BufferSource} */ (sig),
/** @type {BufferSource} */ (data),
),
},
};
}
/** @type {any} */
let routes;
/** @type {any} */
let store;
/** @type {any} */
let blobContext;
/** @type {{privateKey: Uint8Array, didKey: string}} */
let kemKey;
beforeEach(async () => {
store = createMemorySpaceStorage();
const keys = await makeSigner();
blobContext = createMemoryBlobContext();
kemKey = await makeKemKey();
routes = createSpaceRoutes({
spaceStorage: store,
...blobContext,
getDid: async () => DID,
getSigner: async () => keys.signer,
getKemKey: async () => kemKey,
resolveDid: async () => null,
verifier: keys.verifier,
});
});
/**
* @param {string} path
* @param {Record<string, string>} params
* @param {Record<string, string>} [headers]
*/
function get(path, params = {}, headers = {}) {
const url = new URL(`https://pds.example${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
return routes[path].handler(new Request(url, { headers }), url, AUTH);
}
/** @param {string} s */
const fromB64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
/** @param {any} commit */
function fromJsonCommit(commit) {
return { ...commit, data: fromB64(commit.data.$bytes) };
}
/** @param {Array<[string, string, any]>} entries */
function seed(entries) {
return applyWrites(store, {
space: SPACE,
writes: entries.map(([collection, rkey, record]) => ({
action: /** @type {const} */ ('create'),
collection,
rkey,
record,
})),
});
}
describe('getRecord', () => {
it('returns the decoded record', async () => {
await seed([[COL, 'a', { $type: COL, text: 'hi' }]]);
const res = await get('/xrpc/com.atproto.space.getRecord', {
space: SPACE,
collection: COL,
rkey: 'a',
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.uri).toBe(`${SPACE}/${DID}/${COL}/a`);
expect(body.value).toEqual({ $type: COL, text: 'hi' });
});
it('404s a missing record', async () => {
const res = await get('/xrpc/com.atproto.space.getRecord', {
space: SPACE,
collection: COL,
rkey: 'nope',
});
expect(res.status).toBe(404);
expect((await res.json()).error).toBe('RecordNotFound');
});
it('404s a repo this server does not host', async () => {
const res = await get('/xrpc/com.atproto.space.getRecord', {
space: SPACE,
repo: 'did:plc:elsewhere',
collection: COL,
rkey: 'a',
});
expect(res.status).toBe(404);
expect((await res.json()).error).toBe('RepoNotFound');
});
it('rejects a non-space URI', async () => {
const res = await get('/xrpc/com.atproto.space.getRecord', {
space: 'at://did:plc:aaaa/com.example.post/a',
collection: COL,
rkey: 'a',
});
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('InvalidSpaceUri');
});
});
describe('listRecords', () => {
it('lists and paginates, newest first', async () => {
await seed([
[COL, 'a', { v: 1 }],
[COL, 'b', { v: 2 }],
]);
const first = await (
await get('/xrpc/com.atproto.space.listRecords', {
space: SPACE,
collection: COL,
limit: '1',
})
).json();
expect(first.records).toHaveLength(1);
expect(first.records[0]).toEqual({
collection: COL,
rkey: 'b',
cid: expect.any(String),
value: { v: 2 },
});
expect(first.cursor).toBeTruthy();
const second = await (
await get('/xrpc/com.atproto.space.listRecords', {
space: SPACE,
collection: COL,
limit: '1',
cursor: first.cursor,
})
).json();
expect(second.records[0].value).toEqual({ v: 1 });
});
it('gives the last record AT-URI as the cursor', async () => {
await seed([
[COL, 'a', { v: 1 }],
[COL, 'b', { v: 2 }],
]);
const { cursor } = await (
await get('/xrpc/com.atproto.space.listRecords', {
space: SPACE,
collection: COL,
limit: '1',
})
).json();
expect(cursor).toBe(`${SPACE}/${DID}/${COL}/b`);
});
it('pages from a cursor the caller builds', async () => {
await seed([
[COL, 'k1.x', { v: 1 }],
[COL, 'k2.x', { v: 2 }],
[COL, 'k2.y', { v: 3 }],
[COL, 'k3.x', { v: 4 }],
]);
/** @param {Record<string, string>} params */
const rkeys = async (params) => {
const { records } = await (
await get('/xrpc/com.atproto.space.listRecords', {
space: SPACE,
collection: COL,
...params,
})
).json();
return records.map((/** @type {any} */ r) => r.rkey);
};
const uri = `${SPACE}/${DID}/${COL}/`;
expect(await rkeys({ cursor: `${uri}k2/` })).toEqual([
'k2.y',
'k2.x',
'k1.x',
]);
expect(await rkeys({ cursor: `${uri}k2.x`, reverse: 'true' })).toEqual([
'k2.y',
'k3.x',
]);
});
it('puts every row or none past a cursor outside the repo', async () => {
await seed([[COL, 'a', { v: 1 }]]);
/** @param {string} cursor @param {boolean} reverse */
const count = async (cursor, reverse) => {
const { records } = await (
await get('/xrpc/com.atproto.space.listRecords', {
space: SPACE,
cursor,
...(reverse ? { reverse: 'true' } : {}),
})
).json();
return records.length;
};
expect(await count('at://zzz', false)).toBe(1);
expect(await count('at://zzz', true)).toBe(0);
expect(await count('at://a', false)).toBe(0);
expect(await count('at://a', true)).toBe(1);
});
it('lists across collections when none is named', async () => {
await seed([
[COL, 'a', { v: 1 }],
['com.example.other', 'b', { v: 2 }],
]);
const { records } = await (
await get('/xrpc/com.atproto.space.listRecords', { space: SPACE })
).json();
expect(records.map((/** @type {any} */ r) => r.collection).sort()).toEqual([
'com.example.other',
COL,
]);
});
it('omits values on request', async () => {
await seed([[COL, 'a', { v: 1 }]]);
const { records } = await (
await get('/xrpc/com.atproto.space.listRecords', {
space: SPACE,
excludeValues: 'true',
})
).json();
expect(records[0].value).toBeUndefined();
expect(records[0].cid).toEqual(expect.any(String));
});
it('rejects an out-of-range limit', async () => {
const res = await get('/xrpc/com.atproto.space.listRecords', {
space: SPACE,
collection: COL,
limit: '1001',
});
expect(res.status).toBe(400);
});
});
describe('getLatestCommit', () => {
it('returns nothing before any write', async () => {
const res = await get('/xrpc/com.atproto.space.getLatestCommit', {
space: SPACE,
});
expect(await res.json()).toEqual({});
});
it('answers the same commit under the getRepoState name', async () => {
await seed([[COL, 'a', { v: 1 }]]);
const canonical = await (
await get('/xrpc/com.atproto.space.getLatestCommit', { space: SPACE })
).json();
const alias = await (
await get('/xrpc/com.atproto.space.getRepoState', { space: SPACE })
).json();
expect(alias).toEqual(canonical);
});
it('returns a plain commit, with no MAC for the own session that asks for none', async () => {
const { rev } = await seed([[COL, 'a', { v: 1 }]]);
const res = await get('/xrpc/com.atproto.space.getLatestCommit', {
space: SPACE,
});
const { commit } = await res.json();
expect(Object.keys(commit).sort()).toEqual([
'data',
'repo',
'rev',
'space',
'ver',
]);
expect(commit).toMatchObject({ ver: 1, repo: DID, space: SPACE, rev });
// Bytes travel as atproto's $bytes JSON shape.
expect(typeof commit.data.$bytes).toBe('string');
expect(res.headers.get('atproto-space-mac')).toBeNull();
expect(res.headers.get('vary')).toContain('atproto-space-want-mac');
});
it('MACs the commit under the secret the Want-Mac key share derives', async () => {
await seed([[COL, 'a', { v: 1 }]]);
const want = await wantMac('atproto-space-repo', kemKey.didKey);
const res = await get(
'/xrpc/com.atproto.space.getLatestCommit',
{ space: SPACE },
{ 'atproto-space-want-mac': want.header },
);
expect(res.status).toBe(200);
expect(res.headers.get('access-control-expose-headers')).toContain(
'atproto-space-mac',
);
const { commit } = await res.json();
const params = {
space: SPACE,
repo: DID,
sharedSecret: want.sharedSecret,
mac: responseMac(res, 'atproto-space-repo'),
};
expect(await verifyCommit(fromJsonCommit(commit), params)).toBe(true);
// Another syncer's secret does not verify it.
const other = await wantMac('atproto-space-repo', kemKey.didKey);
expect(
await verifyCommit(fromJsonCommit(commit), {
...params,
sharedSecret: other.sharedSecret,
}),
).toBe(false);
});
it('refuses a malformed Want-Mac header', async () => {
await seed([[COL, 'a', { v: 1 }]]);
for (const header of [
'type="atproto-space-record", ver=1, key_share="did:key:zDnae"',
'type="atproto-space-repo", ver=2, key_share="did:key:zDnae"',
'type="atproto-space-repo", ver=1',
'type="atproto-space-repo", ver=1, key_share="did:key:z6Mkhello"',
'not a dictionary (',
]) {
const res = await get(
'/xrpc/com.atproto.space.getLatestCommit',
{ space: SPACE },
{ 'atproto-space-want-mac': header },
);
expect(res.status, header).toBe(400);
expect((await res.json()).error).toBe('BadSpaceMac');
}
});
});
describe('getRecord MAC', () => {
it('MACs the record uri and cid', async () => {
await seed([[COL, 'a', { $type: COL, text: 'hi' }]]);
const want = await wantMac('atproto-space-record', kemKey.didKey);
const res = await get(
'/xrpc/com.atproto.space.getRecord',
{ space: SPACE, collection: COL, rkey: 'a' },
{ 'atproto-space-want-mac': want.header },
);
const record = await res.json();
const uri = `${SPACE}/${DID}/${COL}/a`;
const params = {
uri,
sharedSecret: want.sharedSecret,
mac: responseMac(res, 'atproto-space-record'),
};
expect(await verifyRecord(record, params)).toBe(true);
expect(
await verifyRecord({ ...record, value: { text: 'changed' } }, params),
).toBe(false);
expect(
await verifyRecord(record, {
...params,
uri: `${SPACE}/${DID}/${COL}/b`,
}),
).toBe(false);
});
// A blob ref is a CID link, so the record's CID is the one any other
// implementation computes from the JSON it is served.
it('serves a record with a blob ref under the CID of its JSON', async () => {
const record = {
$type: COL,
image: {
$type: 'blob',
ref: {
$link: 'bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy',
},
mimeType: 'image/png',
size: 3,
},
};
await seed([[COL, 'a', record]]);
const want = await wantMac('atproto-space-record', kemKey.didKey);
const res = await get(
'/xrpc/com.atproto.space.getRecord',
{ space: SPACE, collection: COL, rkey: 'a' },
{ 'atproto-space-want-mac': want.header },
);
const body = await res.json();
expect(body.value).toEqual(record);
expect(
await verifyRecord(body, {
uri: `${SPACE}/${DID}/${COL}/a`,
sharedSecret: want.sharedSecret,
mac: responseMac(res, 'atproto-space-record'),
}),
).toBe(true);
});
it('refuses a repo MAC type on getRecord', async () => {
await seed([[COL, 'a', { v: 1 }]]);
const want = await wantMac('atproto-space-repo', kemKey.didKey);
const res = await get(
'/xrpc/com.atproto.space.getRecord',
{ space: SPACE, collection: COL, rkey: 'a' },
{ 'atproto-space-want-mac': want.header },
);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('BadSpaceMac');
});
});
describe('listRepoOps', () => {
it('inlines current record values and carries the head commit', async () => {
await seed([[COL, 'a', { v: 1 }]]);
const body = await (
await get('/xrpc/com.atproto.space.listRepoOps', { space: SPACE })
).json();
expect(body.ops).toHaveLength(1);
expect(body.ops[0].value).toEqual({ v: 1 });
expect(body.ops[0].prev).toBeNull();
expect(body.commit).toBeTruthy();
});
it('MACs the head commit it carries', async () => {
await seed([[COL, 'a', { v: 1 }]]);
const want = await wantMac('atproto-space-repo', kemKey.didKey);
const res = await get(
'/xrpc/com.atproto.space.listRepoOps',
{ space: SPACE },
{ 'atproto-space-want-mac': want.header },
);
const body = await res.json();
expect(
await verifyCommit(fromJsonCommit(body.commit), {
space: SPACE,
repo: DID,
sharedSecret: want.sharedSecret,
mac: responseMac(res, 'atproto-space-repo'),
}),
).toBe(true);
});
it('omits values when excludeValues is set', async () => {
await seed([[COL, 'a', { v: 1 }]]);
const body = await (
await get('/xrpc/com.atproto.space.listRepoOps', {
space: SPACE,
excludeValues: 'true',
})
).json();
expect(body.ops[0].value).toBeUndefined();
});
it('omits a superseded value but keeps the op', async () => {
await seed([[COL, 'a', { v: 1 }]]);
await applyWrites(store, {
space: SPACE,
writes: [
{ action: 'update', collection: COL, rkey: 'a', record: { v: 2 } },
],
});
const body = await (
await get('/xrpc/com.atproto.space.listRepoOps', { space: SPACE })
).json();
expect(body.ops).toHaveLength(2);
// The first op's value is stale: the stored record is the second's.
expect(body.ops[0].value).toBeUndefined();
expect(body.ops[1].value).toEqual({ v: 2 });
});
it('gives a cursor and no commit or MAC when more ops remain', async () => {
await seed([
[COL, 'a', { v: 1 }],
[COL, 'b', { v: 2 }],
]);
const want = await wantMac('atproto-space-repo', kemKey.didKey);
const res = await get(
'/xrpc/com.atproto.space.listRepoOps',
{ space: SPACE, limit: '1' },
{ 'atproto-space-want-mac': want.header },
);
const body = await res.json();
expect(body.ops).toHaveLength(1);
expect(body.cursor).toBeTruthy();
// The commit describes the head, so it must not appear on a backfill page.
expect(body.commit).toBeUndefined();
expect(res.headers.get('atproto-space-mac')).toBeNull();
});
it('deletes carry a null cid and no value', async () => {
await seed([[COL, 'a', { v: 1 }]]);
const { rev } = await applyWrites(store, {
space: SPACE,
writes: [{ action: 'delete', collection: COL, rkey: 'a' }],
});
const body = await (
await get('/xrpc/com.atproto.space.listRepoOps', {
space: SPACE,
since: '',
})
).json();
const del = body.ops.find((/** @type {any} */ o) => o.rev === rev);
expect(del.cid).toBeNull();
expect(del.value).toBeUndefined();
});
});
describe('getRepo', () => {
it('404s a repo that has never been written to', async () => {
const res = await get('/xrpc/com.atproto.space.getRepo', { space: SPACE });
expect(res.status).toBe(404);
expect((await res.json()).error).toBe('RepoNotFound');
});
it('serialises the commit then records in byte order of their paths', async () => {
// 'com.e.p/a' sorts before 'com.example.post/a' byte for byte.
await seed([
[COL, 'a', { v: 1 }],
['com.e.p', 'a', { v: 2 }],
]);
const res = await get('/xrpc/com.atproto.space.getRepo', { space: SPACE });
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/octet-stream');
const { meta, pairs } = readStar(new Uint8Array(await res.arrayBuffer()));
expect(meta.$type).toBe('atproto-space-repo');
const decoder = new TextDecoder();
expect(pairs.map((p) => decoder.decode(p.key))).toEqual([
'com.e.p/a',
`${COL}/a`,
]);
});
it('serves a repo that verifies against the MAC it carries', async () => {
const { rev } = await seed([
[COL, 'a', { v: 1 }],
[COL, 'b', { v: 2 }],
]);
const want = await wantMac('atproto-space-repo', kemKey.didKey);
const res = await get(
'/xrpc/com.atproto.space.getRepo',
{ space: SPACE },
{ 'atproto-space-want-mac': want.header },
);
const { commit, records } = await verifyRepoStar(
new Uint8Array(await res.arrayBuffer()),
{
space: SPACE,
repo: DID,
sharedSecret: want.sharedSecret,
mac: responseMac(res, 'atproto-space-repo'),
},
);
expect(commit.rev).toBe(rev);
expect(records.map((r) => r.record)).toEqual([{ v: 1 }, { v: 2 }]);
});
});