chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// Blobs behind space records: the two read endpoints, and the link table entry
// that keeps orphan cleanup from deleting bytes a space record still points at.
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}`,
};
// Real-looking CIDv1 raw strings; nothing here hashes them.
const CID_A = 'bafyreicl6ujc6ncfktctxxroxognfn7d2fqavvrryoc2lv6m4i6hpbkfti';
const CID_B = 'bafyreig3yg2msah74sgvow25uxddqbabex3f3mh6hysess3w5kmgiv6zqy';
/** @param {string} cid */
function blobRef(cid) {
return { $type: 'blob', ref: { $link: cid }, mimeType: 'image/png', size: 3 };
}
/** @param {string} cid */
function postWith(cid) {
return { $type: COL, text: 'hi', image: blobRef(cid) };
}
/** @type {any} */ let routes;
/** @type {any} */ let store;
/** @type {any} */ let blobs;
beforeEach(async () => {
const keys = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'verify'],
);
store = createMemorySpaceStorage();
blobs = createMemoryBlobContext();
routes = createSpaceRoutes({
spaceStorage: store,
...blobs,
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 {string} path @param {any} body */
function post(path, body) {
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} path @param {Record<string, string>} params */
function get(path, params = {}) {
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), url, AUTH);
}
/** @param {string} rkey @param {string} cid */
function writePost(rkey, cid) {
return post('/xrpc/com.atproto.space.createRecord', {
space: SPACE,
repo: DID,
collection: COL,
rkey,
record: postWith(cid),
});
}
const uriFor = (/** @type {string} */ rkey) => `${SPACE}/${DID}/${COL}/${rkey}`;
describe('blob links on the space write path', () => {
it('links a blob the written record references', async () => {
await writePost('a', CID_A);
expect([...blobs.links.get(uriFor('a'))]).toEqual([CID_A]);
});
it('replaces the links when a record is rewritten', async () => {
await writePost('a', CID_A);
await post('/xrpc/com.atproto.space.putRecord', {
space: SPACE,
repo: DID,
collection: COL,
rkey: 'a',
record: postWith(CID_B),
});
expect([...blobs.links.get(uriFor('a'))]).toEqual([CID_B]);
});
it('drops the links when a record is deleted', async () => {
await writePost('a', CID_A);
await post('/xrpc/com.atproto.space.deleteRecord', {
space: SPACE,
repo: DID,
collection: COL,
rkey: 'a',
});
expect(blobs.links.has(uriFor('a'))).toBe(false);
});
it('links every blob an applyWrites batch leaves behind', async () => {
await post('/xrpc/com.atproto.space.applyWrites', {
space: SPACE,
repo: DID,
writes: [
{
$type: 'com.atproto.space.applyWrites#create',
collection: COL,
rkey: 'a',
value: postWith(CID_A),
},
{
$type: 'com.atproto.space.applyWrites#create',
collection: COL,
rkey: 'b',
value: postWith(CID_B),
},
],
});
expect([...blobs.links.get(uriFor('a'))]).toEqual([CID_A]);
expect([...blobs.links.get(uriFor('b'))]).toEqual([CID_B]);
});
it('links a blob under the rkey the server minted', async () => {
const { uri } = await (
await post('/xrpc/com.atproto.space.createRecord', {
space: SPACE,
repo: DID,
collection: COL,
record: postWith(CID_A),
})
).json();
expect([...blobs.links.get(uri)]).toEqual([CID_A]);
});
});
describe('listBlobs', () => {
it('lists the blobs the space references', async () => {
await writePost('a', CID_A);
await writePost('b', CID_B);
const { cids } = await (
await get('/xrpc/com.atproto.space.listBlobs', { space: SPACE })
).json();
expect(cids.sort()).toEqual([CID_A, CID_B].sort());
});
it('filters by the rev that first referenced a blob', async () => {
await writePost('a', CID_A);
const { rev } = /** @type {any} */ (await store.getSpaceRepo(SPACE));
await writePost('b', CID_B);
const { cids } = await (
await get('/xrpc/com.atproto.space.listBlobs', {
space: SPACE,
since: rev,
})
).json();
expect(cids).toEqual([CID_B]);
});
it('answers empty for a space with no records', async () => {
const res = await get('/xrpc/com.atproto.space.listBlobs', {
space: SPACE,
});
expect(await res.json()).toEqual({ cids: [] });
});
});
describe('getBlob', () => {
it('serves a blob a record in the space references', async () => {
blobs.put(CID_A, new Uint8Array([1, 2, 3]), 'image/png');
await writePost('a', CID_A);
const res = await get('/xrpc/com.atproto.space.getBlob', {
space: SPACE,
cid: CID_A,
});
expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toBe('image/png');
expect(new Uint8Array(await res.arrayBuffer())).toEqual(
new Uint8Array([1, 2, 3]),
);
});
it('refuses a blob no record in the space references', async () => {
// Held by the account, but behind a public record rather than this space.
blobs.put(CID_B, new Uint8Array([9]), 'image/png');
await writePost('a', CID_A);
const res = await get('/xrpc/com.atproto.space.getBlob', {
space: SPACE,
cid: CID_B,
});
expect(res.status).toBe(404);
expect((await res.json()).error).toBe('BlobNotFound');
});
it('404s a referenced blob whose bytes are gone', async () => {
await writePost('a', CID_A);
const res = await get('/xrpc/com.atproto.space.getBlob', {
space: SPACE,
cid: CID_A,
});
expect(res.status).toBe(404);
});
it('requires a cid', async () => {
const res = await get('/xrpc/com.atproto.space.getBlob', { space: SPACE });
expect(res.status).toBe(400);
});
});