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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
110 lines · 3.9 KB Raw History
// community.lexicon.service.describe is the only way a client learns whether an
// optional feature is served here. atproto publishes no method list, so without
// this a caller has to invoke a space method and read the failure, where an
// absent feature and a broken one look the same.
import { describe, expect, it } from 'vitest';
import { PersonalDataServer } from '../packages/core/src/pds.js';

const PATH = '/xrpc/community.lexicon.service.describe';

/** @param {import('../packages/core/src/pds.js').Routes} [spaceRoutes] */
function createPds(spaceRoutes) {
  // Nothing here reaches storage, so the ports can be bare stubs.
  return new PersonalDataServer({
    actorStorage: /** @type {any} */ ({}),
    sharedStorage: /** @type {any} */ ({}),
    blobs: /** @type {any} */ ({}),
    jwtSecret: 'test-secret',
    extensions: spaceRoutes
      ? [() => ({ name: 'spaces', routes: spaceRoutes })]
      : undefined,
  });
}

/** @param {PersonalDataServer} pds */
async function describeBody(pds) {
  const response = await pds.fetch(
    new Request(`https://pds.example.com${PATH}`),
  );
  expect(response.status).toBe(200);
  return response.json();
}

describe('community.lexicon.service.describe', () => {
  it('answers unauthenticated, naming the pds role', async () => {
    const body = await describeBody(createPds());
    expect(body.roles).toEqual(['pds']);
  });

  it('names each method with its union member', async () => {
    const body = await describeBody(createPds());
    for (const entry of body.methods) {
      expect(entry.$type).toBe('community.lexicon.service.describe#nsid');
      expect(entry.value).toMatch(/^[a-z][a-z0-9.]*\.[a-zA-Z]+$/);
    }
  });

  it('describes exactly the routed methods, sorted', async () => {
    const pds = createPds();
    const body = await describeBody(pds);

    const routed = Object.keys(pds.routes)
      .filter((p) => p.startsWith('/xrpc/'))
      .map((p) => p.slice('/xrpc/'.length))
      .filter((n) => n.includes('.'))
      .sort();

    expect(body.methods.map((/** @type {any} */ m) => m.value)).toEqual(routed);
  });

  it('describes itself', async () => {
    const body = await describeBody(createPds());
    expect(body.methods.map((/** @type {any} */ m) => m.value)).toContain(
      'community.lexicon.service.describe',
    );
  });

  it('omits _health, which is an endpoint rather than a method', async () => {
    const body = await describeBody(createPds());
    const values = body.methods.map((/** @type {any} */ m) => m.value);
    expect(values).not.toContain('_health');
    expect(values.some((/** @type {string} */ v) => v.startsWith('_'))).toBe(
      false,
    );
  });

  it('omits the proxied app.bsky methods it does not route', async () => {
    const body = await describeBody(createPds());
    const values = body.methods.map((/** @type {any} */ m) => m.value);
    // Served locally, so declared.
    expect(values).toContain('app.bsky.actor.getPreferences');
    // Forwarded to the AppView; claiming it would describe the AppView.
    expect(values).not.toContain('app.bsky.feed.getTimeline');
  });

  it('names no space method while spaces are off', async () => {
    const body = await describeBody(createPds());
    const values = body.methods.map((/** @type {any} */ m) => m.value);
    expect(values.some((/** @type {string} */ v) => v.includes('space'))).toBe(
      false,
    );
  });

  it('names the space methods once spaces are on', async () => {
    const body = await describeBody(
      createPds(
        /** @type {any} */ ({
          '/xrpc/com.atproto.space.listSpaces': {
            handler: async () => new Response(),
          },
          '/xrpc/com.atproto.space.createSpace': {
            handler: async () => new Response(),
          },
        }),
      ),
    );
    const values = body.methods.map((/** @type {any} */ m) => m.value);
    expect(values).toContain('com.atproto.space.listSpaces');
    expect(values).toContain('com.atproto.space.createSpace');
  });
});