chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// applyWrites discriminates its writes two ways. The proposal made them a
// closed union tagged by `$type` after clients had shipped against an `action`
// string, so both have to be read.
import { sign } from '@pdsjs/core/crypto';
import { beforeEach, describe, expect, it } from 'vitest';
import {
createMemoryBlobContext,
createMemorySpaceStorage,
} from '../src/memory-storage.js';
import { createSpaceRoutes } from '../src/routes.js';
const AUTHORITY = 'did:plc:aaaa';
const DID = 'did:plc:writer';
const SPACE = `at://${AUTHORITY}/space/com.example.forum/general`;
const COL = 'com.example.post';
const AUTH = {
did: DID,
scope: `space:com.example.forum?authority=${AUTHORITY}&collection=${COL}`,
};
const PATH = '/xrpc/com.atproto.space.applyWrites';
/** @type {any} */ let routes;
/** @type {any} */ let store;
beforeEach(async () => {
const keys = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'verify'],
);
store = createMemorySpaceStorage();
routes = createSpaceRoutes({
spaceStorage: store,
...createMemoryBlobContext(),
getDid: async () => DID,
getKemKey: async () => null,
getSigner: async () => ({
/** @param {Uint8Array} bytes */
sign: (bytes) => sign(keys.privateKey, bytes),
}),
resolveDid: async () => null,
verifier: { verify: async () => true },
});
});
/** @param {any[]} writes */
function applyWrites(writes) {
const url = new URL(`https://pds.example${PATH}`);
return routes[PATH].handler(
new Request(url, {
method: 'POST',
body: JSON.stringify({ space: SPACE, repo: DID, writes }),
}),
url,
AUTH,
);
}
describe('applyWrites write shapes', () => {
it('reads a write tagged by $type', async () => {
const res = await applyWrites([
{
$type: 'com.atproto.space.applyWrites#create',
collection: COL,
rkey: 'a',
value: { text: 'hi' },
},
]);
expect(res.status).toBe(200);
expect(await store.getSpaceRecord(SPACE, COL, 'a')).toBeTruthy();
});
it('reads a write named by an action string', async () => {
const res = await applyWrites([
{ action: 'create', collection: COL, rkey: 'a', value: { text: 'hi' } },
]);
expect(res.status).toBe(200);
const body = await res.json();
// The result is a union either way; only the input spelling differs.
expect(body.results[0].$type).toBe(
'com.atproto.space.applyWrites#createResult',
);
expect(await store.getSpaceRecord(SPACE, COL, 'a')).toBeTruthy();
});
it('mints a key for a create that leaves rkey empty', async () => {
const res = await applyWrites([
{ action: 'create', collection: COL, rkey: '', value: { text: 'hi' } },
]);
const body = await res.json();
expect(body.results[0].uri.split('/').pop()).toHaveLength(13);
});
it('updates and deletes by action string', async () => {
await applyWrites([
{ action: 'create', collection: COL, rkey: 'a', value: { v: 1 } },
]);
await applyWrites([
{ action: 'update', collection: COL, rkey: 'a', value: { v: 2 } },
]);
expect(await store.getSpaceRecord(SPACE, COL, 'a')).toBeTruthy();
const res = await applyWrites([
{ action: 'delete', collection: COL, rkey: 'a' },
]);
expect(res.status).toBe(200);
expect(await store.getSpaceRecord(SPACE, COL, 'a')).toBeNull();
});
it('names what it could not place when neither spelling is present', async () => {
const res = await applyWrites([{ collection: COL, rkey: 'a', value: {} }]);
expect(res.status).toBe(400);
expect((await res.json()).message).toContain('(none)');
});
it('refuses an action it does not implement', async () => {
const res = await applyWrites([
{ action: 'upsert', collection: COL, rkey: 'a', value: {} },
]);
expect(res.status).toBe(400);
expect((await res.json()).message).toContain('upsert');
});
});
// putRecord carries an optional swapRecord, a pds.js extension: a CID names
// the version the record must be at, null requires that it does not exist.
describe('putRecord swapRecord', () => {
const PUT_PATH = '/xrpc/com.atproto.space.putRecord';
/** @param {Record<string, unknown>} body */
function putRecord(body) {
const url = new URL(`https://pds.example${PUT_PATH}`);
return routes[PUT_PATH].handler(
new Request(url, {
method: 'POST',
body: JSON.stringify({ space: SPACE, repo: DID, ...body }),
}),
url,
AUTH,
);
}
it('accepts a matching CID and rejects a stale one', async () => {
const first = await putRecord({
collection: COL,
rkey: 'a',
record: { v: 1 },
swapRecord: null,
});
expect(first.status).toBe(200);
const { cid } = await first.json();
const second = await putRecord({
collection: COL,
rkey: 'a',
record: { v: 2 },
swapRecord: cid,
});
expect(second.status).toBe(200);
const stale = await putRecord({
collection: COL,
rkey: 'a',
record: { v: 3 },
swapRecord: cid,
});
expect(stale.status).toBe(400);
expect((await stale.json()).error).toBe('InvalidSwap');
});
it('rejects swapRecord null when the record exists', async () => {
await putRecord({ collection: COL, rkey: 'a', record: { v: 1 } });
const res = await putRecord({
collection: COL,
rkey: 'a',
record: { v: 2 },
swapRecord: null,
});
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('InvalidSwap');
});
it('rejects a swapRecord that is not a CID string or null', async () => {
const res = await putRecord({
collection: COL,
rkey: 'a',
record: { v: 1 },
swapRecord: 7,
});
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('InvalidRequest');
});
it('writes without a check when swapRecord is absent', async () => {
await putRecord({ collection: COL, rkey: 'a', record: { v: 1 } });
const res = await putRecord({
collection: COL,
rkey: 'a',
record: { v: 2 },
});
expect(res.status).toBe(200);
});
});