chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// clearRecords, and the collections no caller may write directly: the policy
// and member records the authority's PDS publishes for a space.
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';
import { makeSpaceRow } from '../src/space-row.js';
import { applyWrites } from '../src/writer.js';
const AUTHORITY = 'did:plc:aaaa';
const WRITER = 'did:plc:writer';
const TYPE = 'com.example.forum';
const SPACE = `at://${AUTHORITY}/space/${TYPE}/general`;
const COL = 'com.example.post';
const OTHER_COL = 'com.example.like';
/** @type {any} */ let store;
beforeEach(() => {
store = createMemorySpaceStorage();
});
/** @param {string} did */
async function routesAs(did) {
const keys = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'verify'],
);
return 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} routes
* @param {string} path
* @param {any} body
* @param {{did: string, scope: string}} auth
* @returns {Promise<Response>}
*/
function post(routes, path, body, auth) {
const url = new URL(`https://pds.example${path}`);
return routes[path].handler(
new Request(url, { method: 'POST', body: JSON.stringify(body) }),
url,
auth,
);
}
/** @param {string} did @param {string[]} collections */
function scope(did, collections) {
const params = collections.map((c) => `collection=${c}`).join('&');
return { did, scope: `space:${TYPE}?authority=${AUTHORITY}&${params}` };
}
/** @param {string} owner @param {Array<[string, string]>} records */
async function seed(owner, records) {
await applyWrites(store, {
space: SPACE,
owner,
writes: records.map(([collection, rkey]) => ({
action: 'create',
collection,
rkey,
record: { text: rkey },
})),
});
}
const listed = async () =>
(await store.listAllSpaceRecords(SPACE)).map(
(/** @type {any} */ r) => `${r.collection}/${r.rkey}`,
);
describe('clearRecords', () => {
it('deletes every record in the caller repo', async () => {
const routes = await routesAs(WRITER);
await seed(WRITER, [
[COL, 'a'],
[COL, 'b'],
[OTHER_COL, 'c'],
]);
const before = await store.getSpaceRepo(SPACE);
const res = await post(
routes,
'/xrpc/com.atproto.space.clearRecords',
{ space: SPACE, repo: WRITER },
scope(WRITER, [COL, OTHER_COL]),
);
expect(res.status).toBe(200);
expect(await listed()).toEqual([]);
expect((await store.getSpaceRepo(SPACE)).rev).not.toBe(before.rev);
});
it('needs a delete grant for every collection it holds', async () => {
const routes = await routesAs(WRITER);
await seed(WRITER, [
[COL, 'a'],
[OTHER_COL, 'c'],
]);
const res = await post(
routes,
'/xrpc/com.atproto.space.clearRecords',
{ space: SPACE, repo: WRITER },
scope(WRITER, [COL]),
);
expect(res.status).toBe(403);
expect(await listed()).toHaveLength(2);
});
it('clears only the caller repo', async () => {
const routes = await routesAs(WRITER);
const res = await post(
routes,
'/xrpc/com.atproto.space.clearRecords',
{ space: SPACE, repo: 'did:plc:someone' },
scope(WRITER, [COL]),
);
expect(res.status).toBe(403);
});
it('is a no-op on an empty repo', async () => {
const routes = await routesAs(WRITER);
const res = await post(
routes,
'/xrpc/com.atproto.space.clearRecords',
{ space: SPACE, repo: WRITER },
scope(WRITER, [COL]),
);
expect(res.status).toBe(200);
expect(await store.getSpaceRepo(SPACE)).toBeNull();
});
it('keeps the published policy and members in the authority repo', async () => {
const routes = await routesAs(AUTHORITY);
await store.putSpace(
makeSpaceRow(SPACE, {
isOwner: true,
policy: {
$type: 'com.atproto.memberspace.policy',
publishPolicy: true,
publishMembers: true,
},
}),
);
await seed(AUTHORITY, [
[COL, 'a'],
['com.atproto.space.policy', 'self'],
['com.atproto.memberspace.member', WRITER],
]);
const res = await post(
routes,
'/xrpc/com.atproto.space.clearRecords',
{ space: SPACE, repo: AUTHORITY },
scope(AUTHORITY, [COL]),
);
expect(res.status).toBe(200);
expect(await listed()).toEqual([
`com.atproto.memberspace.member/${WRITER}`,
'com.atproto.space.policy/self',
]);
});
});
describe('the published collections', () => {
for (const collection of [
'com.atproto.space.policy',
'com.atproto.memberspace.member',
]) {
it(`refuses a direct write to ${collection}`, async () => {
const routes = await routesAs(WRITER);
const auth = scope(WRITER, [collection]);
const single = await post(
routes,
'/xrpc/com.atproto.space.putRecord',
{ space: SPACE, repo: WRITER, collection, rkey: 'self', record: {} },
auth,
);
expect(single.status).toBe(400);
const batch = await post(
routes,
'/xrpc/com.atproto.space.applyWrites',
{
space: SPACE,
repo: WRITER,
writes: [{ action: 'create', collection, rkey: 'self', value: {} }],
},
auth,
);
expect(batch.status).toBe(400);
expect(await listed()).toEqual([]);
});
}
});