chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import { PersonalDataServer } from '@pdsjs/core/pds';
import { 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 SPACE = `at://${AUTHORITY}/space/com.example.forum/general`;
const DID = 'did:plc:writer';
const COL = 'com.example.post';
// A space is reachable only from a scope naming it, so these carry the grant a
// real client would hold rather than a blanket one.
const AUTH = {
did: DID,
scope: `space:com.example.forum?authority=${AUTHORITY}&collection=${COL}`,
};
function setup() {
const spaceStorage = createMemorySpaceStorage();
return {
spaceStorage,
routes: createSpaceRoutes({
spaceStorage,
...createMemoryBlobContext(),
getDid: async () => DID,
getKemKey: async () => null,
getSigner: async () => ({ sign: async () => new Uint8Array(64) }),
resolveDid: async () => null,
verifier: { verify: async () => false },
}),
};
}
/**
* Invoke a route the way PersonalDataServer.handleRequest does.
* @param {any} route
* @param {any} body
* @param {{auth?: any, query?: string}} [opts]
*/
function call(route, body, opts = {}) {
const url = new URL(
`https://pds.example/xrpc/x${opts.query ? `?${opts.query}` : ''}`,
);
const request = new Request(url, {
method: body === undefined ? 'GET' : 'POST',
...(body === undefined
? {}
: {
body: JSON.stringify(body),
headers: { 'content-type': 'application/json' },
}),
});
return route.handler(request, url, 'auth' in opts ? opts.auth : AUTH);
}
describe('space route registration', () => {
it('is absent unless space routes are supplied', () => {
const pds = new PersonalDataServer(/** @type {any} */ ({}));
expect(pds.findRoute('/xrpc/com.atproto.space.createRecord')).toBeNull();
// The existing table is untouched.
expect(pds.findRoute('/xrpc/com.atproto.repo.createRecord')).not.toBeNull();
});
it('merges in when supplied, without displacing existing routes', () => {
const { routes } = setup();
const pds = new PersonalDataServer(
/** @type {any} */ ({
extensions: [() => ({ name: 'spaces', routes })],
}),
);
expect(
pds.findRoute('/xrpc/com.atproto.space.createRecord'),
).not.toBeNull();
expect(pds.findRoute('/xrpc/com.atproto.repo.createRecord')).not.toBeNull();
});
it('does not leak the merged table into other instances', () => {
const { routes } = setup();
new PersonalDataServer(
/** @type {any} */ ({
extensions: [() => ({ name: 'spaces', routes })],
}),
);
const plain = new PersonalDataServer(/** @type {any} */ ({}));
expect(plain.findRoute('/xrpc/com.atproto.space.createRecord')).toBeNull();
});
it('requires a storage port', () => {
expect(() => createSpaceRoutes(/** @type {any} */ ({}))).toThrow(
/spaceStorage/,
);
expect(() =>
createSpaceRoutes(/** @type {any} */ ({ spaceStorage: {} })),
).toThrow(/getDid/);
});
});
describe('createRecord', () => {
it('creates a record and returns its space URI', async () => {
const { routes, spaceStorage } = setup();
const res = await call(routes['/xrpc/com.atproto.space.createRecord'], {
space: SPACE,
repo: DID,
collection: COL,
rkey: 'a',
record: { $type: COL, text: 'hi' },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.uri).toBe(`${SPACE}/${DID}/${COL}/a`);
expect(body.cid).toMatch(/^b[a-z2-7]+$/);
const stored = await spaceStorage.getSpaceRecord(SPACE, COL, 'a');
expect(stored?.cid).toBe(body.cid);
});
it('generates an rkey when none is given', async () => {
const { routes } = setup();
const res = await call(routes['/xrpc/com.atproto.space.createRecord'], {
space: SPACE,
repo: DID,
collection: COL,
record: { $type: COL },
});
const body = await res.json();
expect(body.uri.startsWith(`${SPACE}/${DID}/${COL}/`)).toBe(true);
expect(body.uri.split('/').pop()).toHaveLength(13);
});
it('refuses to write another account’s repo', async () => {
const { routes } = setup();
const res = await call(routes['/xrpc/com.atproto.space.createRecord'], {
space: SPACE,
repo: 'did:plc:someone-else',
collection: COL,
record: {},
});
expect(res.status).toBe(403);
expect((await res.json()).error).toBe('Forbidden');
});
it('rejects a non-space URI as a request error, not a 500', async () => {
const { routes } = setup();
const res = await call(routes['/xrpc/com.atproto.space.createRecord'], {
space: 'at://did:plc:aaaa/com.example.post/a',
repo: DID,
collection: COL,
record: {},
});
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('InvalidSpaceUri');
});
it('surfaces a duplicate create as RecordAlreadyExists', async () => {
const { routes } = setup();
const input = {
space: SPACE,
repo: DID,
collection: COL,
rkey: 'a',
record: {},
};
await call(routes['/xrpc/com.atproto.space.createRecord'], input);
const res = await call(
routes['/xrpc/com.atproto.space.createRecord'],
input,
);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('RecordAlreadyExists');
});
it('rejects a malformed body', async () => {
const { routes } = setup();
const url = new URL('https://pds.example/xrpc/x');
const handler = /** @type {any} */ (
routes['/xrpc/com.atproto.space.createRecord']
).handler;
const res = await handler(
new Request(url, { method: 'POST', body: 'not json' }),
url,
AUTH,
);
expect(res.status).toBe(400);
});
});
describe('putRecord and deleteRecord', () => {
it('put creates then updates', async () => {
const { routes } = setup();
const input = (/** @type {number} */ v) => ({
space: SPACE,
repo: DID,
collection: COL,
rkey: 'a',
record: { v },
});
const first = await (
await call(routes['/xrpc/com.atproto.space.putRecord'], input(1))
).json();
const second = await (
await call(routes['/xrpc/com.atproto.space.putRecord'], input(2))
).json();
expect(second.cid).not.toBe(first.cid);
});
it('delete removes the record', async () => {
const { routes, spaceStorage } = setup();
await call(routes['/xrpc/com.atproto.space.createRecord'], {
space: SPACE,
repo: DID,
collection: COL,
rkey: 'a',
record: {},
});
const res = await call(routes['/xrpc/com.atproto.space.deleteRecord'], {
space: SPACE,
repo: DID,
collection: COL,
rkey: 'a',
});
expect(res.status).toBe(200);
expect(await spaceStorage.getSpaceRecord(SPACE, COL, 'a')).toBeNull();
});
it('deleting a missing record is RecordNotFound', async () => {
const { routes } = setup();
const res = await call(routes['/xrpc/com.atproto.space.deleteRecord'], {
space: SPACE,
repo: DID,
collection: COL,
rkey: 'nope',
});
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('RecordNotFound');
});
});
describe('applyWrites', () => {
it('applies a batch as one commit and returns typed results', async () => {
const { routes, spaceStorage } = setup();
const res = await call(routes['/xrpc/com.atproto.space.applyWrites'], {
space: SPACE,
repo: DID,
writes: [
{
$type: 'com.atproto.space.applyWrites#create',
collection: COL,
rkey: 'a',
value: { v: 1 },
},
{
$type: 'com.atproto.space.applyWrites#create',
collection: COL,
rkey: 'b',
value: { v: 2 },
},
],
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.results.map((/** @type {any} */ r) => r.$type)).toEqual([
'com.atproto.space.applyWrites#createResult',
'com.atproto.space.applyWrites#createResult',
]);
const ops = await spaceStorage.listSpaceOps(SPACE, null, 50);
expect(new Set(ops.map((o) => o.rev)).size).toBe(1);
});
it('emits a bare deleteResult', async () => {
const { routes } = setup();
await call(routes['/xrpc/com.atproto.space.createRecord'], {
space: SPACE,
repo: DID,
collection: COL,
rkey: 'a',
record: {},
});
const res = await call(routes['/xrpc/com.atproto.space.applyWrites'], {
space: SPACE,
repo: DID,
writes: [
{
$type: 'com.atproto.space.applyWrites#delete',
collection: COL,
rkey: 'a',
},
],
});
expect((await res.json()).results).toEqual([
{ $type: 'com.atproto.space.applyWrites#deleteResult' },
]);
});
it('rejects an unrecognised write type', async () => {
const { routes } = setup();
const res = await call(routes['/xrpc/com.atproto.space.applyWrites'], {
space: SPACE,
repo: DID,
writes: [{ $type: 'com.example.nope', collection: COL, rkey: 'a' }],
});
expect(res.status).toBe(400);
});
});
describe('listSpaces', () => {
it('lists spaces the user has written to', async () => {
const { routes } = setup();
await call(routes['/xrpc/com.atproto.space.createRecord'], {
space: SPACE,
repo: DID,
collection: COL,
record: {},
});
const res = await call(
routes['/xrpc/com.atproto.space.listSpaces'],
undefined,
);
const body = await res.json();
expect(body.spaces).toEqual([{ uri: SPACE, isOwner: false }]);
});
it('filters by space type', async () => {
const { routes } = setup();
await call(routes['/xrpc/com.atproto.space.createRecord'], {
space: SPACE,
repo: DID,
collection: COL,
record: {},
});
const miss = await (
await call(routes['/xrpc/com.atproto.space.listSpaces'], undefined, {
query: 'spaceType=com.other.kind',
})
).json();
expect(miss.spaces).toEqual([]);
const hit = await (
await call(routes['/xrpc/com.atproto.space.listSpaces'], undefined, {
query: 'spaceType=com.example.forum',
})
).json();
expect(hit.spaces).toHaveLength(1);
});
it('filters by space type under its earlier name too', async () => {
const { routes } = setup();
await call(routes['/xrpc/com.atproto.space.createRecord'], {
space: SPACE,
repo: DID,
collection: COL,
record: {},
});
const miss = await (
await call(routes['/xrpc/com.atproto.space.listSpaces'], undefined, {
query: 'type=com.other.kind',
})
).json();
expect(miss.spaces).toEqual([]);
const hit = await (
await call(routes['/xrpc/com.atproto.space.listSpaces'], undefined, {
query: 'type=com.example.forum',
})
).json();
expect(hit.spaces).toHaveLength(1);
});
it('leaves out a deleted space', async () => {
const { routes, spaceStorage } = setup();
await call(routes['/xrpc/com.atproto.space.createRecord'], {
space: SPACE,
repo: DID,
collection: COL,
record: {},
});
await spaceStorage.deleteSpace(SPACE, '2026-11-01T00:00:00.000Z');
const res = await call(
routes['/xrpc/com.atproto.space.listSpaces'],
undefined,
);
expect((await res.json()).spaces).toEqual([]);
});
it('rejects an out-of-range limit', async () => {
const { routes } = setup();
const res = await call(
routes['/xrpc/com.atproto.space.listSpaces'],
undefined,
{ query: 'limit=0' },
);
expect(res.status).toBe(400);
});
});