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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
207 lines · 5.8 KB Raw History
// 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([]);
    });
  }
});