~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
229 lines · 6.7 KB Raw History
// 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);
  });
});