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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
395 lines · 11.6 KB Raw History
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);
  });
});