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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
540 lines · 16.0 KB Raw History
// The extension seam: a feature package mounts on the server through a
// factory core calls with a host, and contributes routes, a handler for
// paths outside XRPC, a write guard and a commit tap.
import { describe, expect, it, vi } from 'vitest';
import { createAccountJwt } from '../packages/core/src/auth.js';
import { PersonalDataServer } from '../packages/core/src/pds.js';

const HOSTNAME = 'pds.example.com';
const BASE = `https://${HOSTNAME}`;
const JWT_SECRET = 'test-secret';

/**
 * @param {Object} [options]
 * @param {import('../packages/core/src/ports.js').ExtensionFactory[]} [options.extensions]
 * @param {import('../packages/core/src/ports.js').RecordWriteGuardPort} [options.writeGuard]
 * @param {import('../packages/core/src/ports.js').CommitTapPort} [options.onCommit]
 */
function createServer({ extensions, writeGuard, onCommit } = {}) {
  return new PersonalDataServer({
    actorStorage: /** @type {any} */ ({
      async getDid() {
        return 'did:plc:extensiontest';
      },
      async getPrivateKey() {
        return null;
      },
      async getRecord() {
        return null;
      },
    }),
    sharedStorage: /** @type {any} */ ({}),
    blobs: /** @type {any} */ ({}),
    jwtSecret: JWT_SECRET,
    hostname: HOSTNAME,
    extensions,
    writeGuard,
    onCommit,
  });
}

describe('extension routes', () => {
  it('serves a route the extension registers, with CORS decoration', async () => {
    const pds = createServer({
      extensions: [
        () => ({
          name: 'demo',
          routes: {
            '/xrpc/dev.example.demo.ping': {
              handler: async () => Response.json({ pong: true }),
            },
          },
        }),
      ],
    });
    const res = await pds.fetch(
      new Request(`${BASE}/xrpc/dev.example.demo.ping`),
    );
    expect(res.status).toBe(200);
    expect(await res.json()).toEqual({ pong: true });
    expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*');
  });

  it('lists an extension XRPC method in describe', async () => {
    const pds = createServer({
      extensions: [
        () => ({
          name: 'demo',
          routes: {
            '/xrpc/dev.example.demo.ping': {
              handler: async () => Response.json({}),
            },
          },
        }),
      ],
    });
    const res = await pds.fetch(
      new Request(`${BASE}/xrpc/community.lexicon.service.describe`),
    );
    const { methods } = await res.json();
    expect(methods).toContainEqual({
      $type: 'community.lexicon.service.describe#nsid',
      value: 'dev.example.demo.ping',
    });
  });

  it('refuses a route core already serves', () => {
    expect(() =>
      createServer({
        extensions: [
          () => ({
            name: 'demo',
            routes: {
              '/xrpc/_health': { handler: async () => Response.json({}) },
            },
          }),
        ],
      }),
    ).toThrow(/demo registers \/xrpc\/_health/);
  });

  it('refuses a route another extension registered', () => {
    const factory = () => ({
      name: 'twin',
      routes: {
        '/xrpc/dev.example.twin': { handler: async () => Response.json({}) },
      },
    });
    expect(() => createServer({ extensions: [factory, factory] })).toThrow(
      /twin registers \/xrpc\/dev.example.twin/,
    );
  });
});

describe('extension handlers', () => {
  it('answers before core dispatch and owns the whole response', async () => {
    const pds = createServer({
      extensions: [
        () => ({
          name: 'registry',
          prefixes: ['/registry'],
          handle: async () => new Response('from the extension'),
        }),
      ],
    });
    const hit = await pds.fetch(new Request(`${BASE}/registry/thing`));
    expect(await hit.text()).toBe('from the extension');
    expect(hit.headers.get('Access-Control-Allow-Origin')).toBeNull();

    const miss = await pds.fetch(new Request(`${BASE}/xrpc/_health`));
    expect(miss.status).toBe(200);
    expect(miss.headers.get('Access-Control-Allow-Origin')).toBe('*');
  });

  it('only sees requests under its own prefixes', async () => {
    const handle = vi.fn(async () => null);
    const pds = createServer({
      extensions: [() => ({ name: 'scoped', prefixes: ['/a', '/b/'], handle })],
    });
    for (const path of ['/a', '/a/x', '/b/y']) {
      await pds.fetch(new Request(BASE + path));
    }
    expect(handle).toHaveBeenCalledTimes(3);
    for (const path of ['/ab', '/xrpc/_health', '/', '/c/a']) {
      await pds.fetch(new Request(BASE + path));
    }
    expect(handle).toHaveBeenCalledTimes(3);
  });

  it('falls through to core when the handler returns null', async () => {
    const pds = createServer({
      extensions: [
        () => ({
          name: 'quiet',
          prefixes: ['/quiet'],
          handle: async () => null,
        }),
      ],
    });
    const res = await pds.fetch(new Request(`${BASE}/quiet/nothing`));
    expect(res.status).toBe(404);
  });

  it('needs prefixes to go with a handler', () => {
    expect(() =>
      createServer({
        extensions: [() => ({ name: 'bare', handle: async () => null })],
      }),
    ).toThrow(/bare has a handler but declares no prefixes/);
  });

  it('refuses a prefix core serves under', () => {
    for (const prefix of [
      '/xrpc',
      '/xrpc/com.atproto.server.createSession',
      '/oauth/token',
      '/account',
      '/.well-known',
      '/init',
      '/',
      'npm',
    ]) {
      expect(
        () =>
          createServer({
            extensions: [
              () => ({
                name: 'grab',
                prefixes: [prefix],
                handle: async () => null,
              }),
            ],
          }),
        prefix,
      ).toThrow(/grab declares prefix/);
    }
  });

  it('refuses a prefix that overlaps another extension', () => {
    /** @param {string} name @param {string} prefix */
    const claim = (name, prefix) => () => ({
      name,
      prefixes: [prefix],
      handle: async () => null,
    });
    expect(() =>
      createServer({
        extensions: [claim('one', '/reg'), claim('two', '/reg/x')],
      }),
    ).toThrow(/two declares prefix \/reg\/x, which overlaps \/reg/);
    expect(() =>
      createServer({
        extensions: [claim('one', '/reg/x'), claim('two', '/reg')],
      }),
    ).toThrow(/two declares prefix \/reg, which overlaps \/reg\/x/);
    // Siblings are fine.
    expect(() =>
      createServer({
        extensions: [claim('one', '/reg'), claim('two', '/registry')],
      }),
    ).not.toThrow();
  });
});

describe('extension route binding', () => {
  it('calls the handler without the server as this', async () => {
    /** @type {unknown} */
    let bound = 'unset';
    const pds = createServer({
      extensions: [
        () => ({
          name: 'probe',
          routes: {
            '/xrpc/dev.example.probe': {
              handler: function () {
                bound = this;
                return Response.json({});
              },
            },
          },
        }),
      ],
    });
    await pds.fetch(new Request(`${BASE}/xrpc/dev.example.probe`));
    expect(bound).toBeUndefined();
  });

  it('keeps the route method and auth requirement', async () => {
    const pds = createServer({
      extensions: [
        () => ({
          name: 'strict',
          routes: {
            '/xrpc/dev.example.strict': {
              method: 'POST',
              auth: 'required',
              handler: () => Response.json({}),
            },
          },
        }),
      ],
    });
    const wrongMethod = await pds.fetch(
      new Request(`${BASE}/xrpc/dev.example.strict`),
    );
    expect(wrongMethod.status).toBe(405);
    const anonymous = await pds.fetch(
      new Request(`${BASE}/xrpc/dev.example.strict`, { method: 'POST' }),
    );
    expect(anonymous.status).toBe(401);
  });
});

describe('extension host xrpc', () => {
  it('drops the credentials a request carries before dispatch', async () => {
    /** @type {string[]} */
    const seen = [];
    /** @type {import('../packages/core/src/ports.js').ExtensionHost|null} */
    let host = null;
    createServer({
      extensions: [
        (h) => {
          host = h;
          return {
            name: 'echo',
            routes: {
              '/xrpc/dev.example.echo': {
                handler: (request) => {
                  for (const name of [
                    'authorization',
                    'cookie',
                    'dpop',
                    'x-keep',
                  ]) {
                    if (request.headers.has(name)) seen.push(name);
                  }
                  return Response.json({});
                },
              },
            },
          };
        },
      ],
    });
    const seenHost =
      /** @type {import('../packages/core/src/ports.js').ExtensionHost} */ (
        /** @type {unknown} */ (host)
      );
    const res = await seenHost.xrpc(
      new Request(`${BASE}/xrpc/dev.example.echo`, {
        headers: {
          authorization: 'Bearer secret',
          cookie: 'pdsjs_account=secret',
          dpop: 'proof',
          'x-keep': '1',
        },
      }),
    );
    expect(res.status).toBe(200);
    expect(seen).toEqual(['x-keep']);
  });
});

describe('extension host', () => {
  it('late-binds getDid, xrpc and createCommit into the server', async () => {
    /** @type {import('../packages/core/src/ports.js').ExtensionHost|null} */
    let host = null;
    const pds = createServer({
      extensions: [
        (h) => {
          host = h;
          return { name: 'probe' };
        },
      ],
    });
    const seen =
      /** @type {import('../packages/core/src/ports.js').ExtensionHost} */ (
        /** @type {unknown} */ (host)
      );
    expect(await seen.getDid()).toBe('did:plc:extensiontest');
    expect(seen.hostname).toBe(HOSTNAME);
    expect(seen.spaceStorage).toBeUndefined();
    expect(seen.spaceBrowser).toBeUndefined();
    expect(seen.didResolver).toBeUndefined();
    expect(seen.writeGuard.collections).toEqual([]);
    await expect(seen.getSigner()).rejects.toThrow(/no signing key/);
    expect(seen.plcUrl).toBe('https://plc.directory');
    expect(seen.blobUploadLimit).toBe(50 * 1024 * 1024);
    expect(seen.readOnly).toBe(false);
    const res = await seen.xrpc(new Request(`${BASE}/xrpc/_health`));
    expect(res.status).toBe(200);
    // accountApi runs behind the owner's session, which this request lacks.
    const denied = await seen.accountApi(
      new Request(`${BASE}/account/api/x`),
      new URL(`${BASE}/account/api/x`),
      async () => ({}),
    );
    expect(denied.status).toBe(401);
    expect(
      await seen.readAccountSession(new Request(`${BASE}/xrpc/x`)),
    ).toBeNull();
    const jwt = await createAccountJwt('did:plc:extensiontest', JWT_SECRET);
    expect(
      await seen.readAccountSession(
        new Request(`${BASE}/xrpc/x`, {
          headers: { cookie: `pdsjs_account=${encodeURIComponent(jwt)}` },
        }),
      ),
    ).toBe('did:plc:extensiontest');
    expect(
      await seen.readJsonBounded(
        new Request(`${BASE}/xrpc/x`, {
          method: 'POST',
          body: JSON.stringify({ a: 1 }),
        }),
      ),
    ).toEqual({ a: 1 });
    expect(pds.extensions.map((e) => e.name)).toEqual(['probe']);
  });
});

describe('extension collections', () => {
  const uri = (/** @type {string} */ collection) =>
    `at://did:plc:extensiontest/${collection}/1`;

  /** @param {string[]|undefined} collections */
  function mount(collections) {
    /** @type {import('../packages/core/src/ports.js').ExtensionHost|null} */
    let host = null;
    createServer({
      extensions: [
        (h) => {
          host = h;
          return { name: 'scoped', collections };
        },
      ],
    });
    return /** @type {import('../packages/core/src/ports.js').ExtensionHost} */ (
      /** @type {unknown} */ (host)
    );
  }

  it('lets an extension read its own collections and refuses the rest', async () => {
    const host = mount(['app.example.post']);
    await expect(
      host.actorStorage.getRecord(uri('app.example.post')),
    ).resolves.toBeNull();
    await expect(
      host.actorStorage.getRecord(uri('app.example.like')),
    ).rejects.toThrow(
      'Extension scoped may not touch app.example.like: it declares app.example.post',
    );
  });

  it('refuses every record call to an extension that declares none', async () => {
    const host = mount(undefined);
    await expect(host.actorStorage.getRecord(uri('a.b'))).rejects.toThrow(
      /declares no collections/,
    );
    // The account's own fields are not records.
    expect(await host.actorStorage.getDid()).toBe('did:plc:extensiontest');
  });

  it('refuses a commit that names a collection outside the scope', async () => {
    const host = mount(['app.example.post']);
    await expect(
      host.createCommit('did:plc:extensiontest', [
        { action: 'delete', uri: uri('app.example.like') },
      ]),
    ).rejects.toThrow(/may not touch app.example.like/);
  });

  it('reads the declaration from the mounted extension, not the factory', async () => {
    const host = mount(['*']);
    await expect(
      host.actorStorage.getRecord(uri('any.thing')),
    ).resolves.toBeNull();
  });
});

describe('write guards', () => {
  /** @param {string} collection */
  const guard = (collection) => ({
    collections: [collection],
    check: vi.fn(async () => {}),
  });

  it('is the platform guard alone when no extension adds one', () => {
    const platform = guard('app.example.one');
    const pds = createServer({ writeGuard: platform });
    expect(pds.writeGuard).toBe(platform);
  });

  it('consults each guard for writes in its own collections', async () => {
    const platform = guard('app.example.one');
    const extension = guard('app.example.two');
    const pds = createServer({
      writeGuard: platform,
      extensions: [() => ({ name: 'two', writeGuard: extension })],
    });
    const composed = /** @type {NonNullable<typeof pds.writeGuard>} */ (
      pds.writeGuard
    );
    expect(composed.collections).toEqual([
      'app.example.one',
      'app.example.two',
    ]);

    const write = {
      space: null,
      collection: 'app.example.two',
      rkey: 'a',
      prevValue: null,
      nextValue: {},
    };
    await composed.check(write);
    expect(platform.check).not.toHaveBeenCalled();
    expect(extension.check).toHaveBeenCalledWith(write);
  });

  it('lets any guard refuse the write', async () => {
    const refusing = {
      collections: ['app.example.one'],
      check: async () => {
        throw new Error('refused');
      },
    };
    const pds = createServer({
      writeGuard: guard('app.example.one'),
      extensions: [() => ({ name: 'refuser', writeGuard: refusing })],
    });
    await expect(
      /** @type {NonNullable<typeof pds.writeGuard>} */ (pds.writeGuard).check({
        space: null,
        collection: 'app.example.one',
        rkey: 'a',
        prevValue: null,
        nextValue: {},
      }),
    ).rejects.toThrow('refused');
  });
});

describe('commit taps', () => {
  const event = { did: 'did:plc:extensiontest', seq: 1, rev: '3l', ops: [] };

  it('runs the platform tap and every extension tap', async () => {
    const platform = vi.fn();
    const extension = vi.fn();
    const pds = createServer({
      onCommit: platform,
      extensions: [() => ({ name: 'tap', onCommit: extension })],
    });
    await pds.notifyCommitTaps(event);
    expect(platform).toHaveBeenCalledWith(event);
    expect(extension).toHaveBeenCalledWith(event);
  });

  it('logs a failing tap and still runs the rest', async () => {
    const error = vi.spyOn(console, 'error').mockImplementation(() => {});
    const after = vi.fn();
    const pds = createServer({
      extensions: [
        () => ({
          name: 'broken',
          onCommit: () => {
            throw new Error('tap failed');
          },
        }),
        () => ({ name: 'fine', onCommit: after }),
      ],
    });
    await pds.notifyCommitTaps(event);
    expect(after).toHaveBeenCalledWith(event);
    expect(error).toHaveBeenCalledWith(
      'onCommit subscriber failed:',
      expect.any(Error),
    );
    error.mockRestore();
  });
});