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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
504 lines · 15.9 KB Raw History
// The /account/api/sites endpoints: session-gated, backed entirely by the
// injected siteBrowser, write path blocked in read-only mode.

import { PersonalDataServer } from '@pdsjs/core';
import {
  CID,
  cborEncodeDagCbor,
  cidToString,
  createBlobCid,
  createCid,
} from '@pdsjs/core/repo';
import { describe, expect, it } from 'vitest';
import { createSiteAccountRoutes } from '../src/account.js';
import { createSiteBrowser } from '../src/browser.js';

const DID = 'did:plc:apitest';
const PASSWORD = 'account-api-password';
const PDS_HOST = 'pds.example.com';

async function fixtures() {
  const data = new TextEncoder().encode('<h1>hi</h1>');
  const value = cborEncodeDagCbor({
    $type: 'dev.pdsjs.site.deploy',
    files: [
      {
        path: 'index.html',
        blob: {
          $type: 'blob',
          ref: new CID(await createBlobCid(data)),
          mimeType: 'text/html',
          size: data.length,
        },
      },
    ],
    createdAt: '2026-08-06T00:00:00.000Z',
  });
  return {
    uri: `at://${DID}/dev.pdsjs.site.deploy/home`,
    cid: cidToString(await createCid(value)),
    value,
  };
}

/**
 * @param {{sites?: boolean, readOnly?: boolean, installer?: unknown, installs?: boolean}} [opts]
 */
async function makePds(opts = {}) {
  const record = await fixtures();
  const deleted = /** @type {string[]} */ ([]);
  const records = new Map([[record.uri, record]]);
  if (opts.installs !== false) {
    const installValue = cborEncodeDagCbor({
      $type: 'dev.pdsjs.app.install',
      name: 'Photos',
      manifest: {
        uri: `at://${DID}/dev.pdsjs.app.manifest/photos`,
        cid: 'bafym1',
      },
      site: 'home',
      settings: [
        { key: 'topo', label: 'Terrain', type: 'boolean', default: true },
        { key: 'rows', type: 'select', options: ['compact', 'roomy'] },
      ],
      config: { topo: true },
      icon: {
        $type: 'blob',
        ref: new CID(await createBlobCid(new Uint8Array([1]))),
        mimeType: 'image/svg+xml',
        size: 1,
      },
      createdAt: '2026-08-11T00:00:00.000Z',
    });
    records.set(`at://${DID}/dev.pdsjs.app.install/3installtid`, {
      uri: `at://${DID}/dev.pdsjs.app.install/3installtid`,
      cid: cidToString(await createCid(installValue)),
      value: installValue,
    });
  }
  const actorStorage = /** @type {any} */ ({
    getDid: async () => DID,
    getHandle: async () => 'user.example.com',
    getAccountStatus: async () => 'active',
    getRecord: async (/** @type {string} */ uri) => records.get(uri) || null,
    listRecords: async (/** @type {string} */ collection) => ({
      records: [...records.values()]
        .filter((row) => row.uri.includes(`/${collection}/`))
        .map((row) => ({ ...row })),
      cursor: null,
    }),
    putBlock: async () => {},
    putRecord: async (
      /** @type {string} */ uri,
      /** @type {string} */ cid,
      /** @type {string} */ _collection,
      /** @type {string} */ _rkey,
      /** @type {Uint8Array} */ value,
    ) => {
      records.set(uri, { uri, cid, value });
    },
    deleteRecord: async (/** @type {string} */ uri) => {
      deleted.push(uri);
      records.delete(uri);
    },
    unlinkBlobsFromRecord: async () => {},
  });
  const siteBrowser =
    opts.sites === false
      ? null
      : createSiteBrowser({
          actorStorage,
          getDid: async () => DID,
          siteDomain: 'example.com',
        });
  const pds = new PersonalDataServer({
    actorStorage,
    sharedStorage: /** @type {any} */ ({}),
    blobs: /** @type {any} */ ({}),
    jwtSecret: 'account-api-secret',
    hostname: PDS_HOST,
    password: PASSWORD,
    readOnly: opts.readOnly,
    extensions: [
      (host) => ({
        name: 'sites',
        collections: [
          'dev.pdsjs.site.deploy',
          'dev.pdsjs.query.def',
          'dev.pdsjs.app.install',
          'dev.pdsjs.app.manifest',
        ],
        routes: createSiteAccountRoutes({
          host,
          browser: siteBrowser,
          installer: /** @type {any} */ (opts.installer ?? null),
        }),
        features: { sites: Boolean(siteBrowser) },
      }),
    ],
  });
  // Commit and reap run their full course in integration; here the API
  // contract is under test, not the repo machinery.
  const commits = /** @type {Array<{action: string, uri: string}[]>} */ ([]);
  pds.createCommit = /** @type {any} */ (
    async (/** @type {string} */ _did, /** @type {any[]} */ writes) => {
      commits.push(writes);
    }
  );
  let reaped = 0;
  pds.reapOrphanedBlobs = /** @type {any} */ (
    async () => {
      reaped++;
      return [];
    }
  );
  return { pds, deleted, commits, reapCount: () => reaped };
}

/** @param {PersonalDataServer} pds */
async function signInCookie(pds) {
  const response = await pds.fetch(
    new Request(`https://${PDS_HOST}/account/sign-in`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        username: DID,
        password: PASSWORD,
      }).toString(),
    }),
  );
  const header = response.headers.get('set-cookie') || '';
  const value = decodeURIComponent(
    header.match(/pdsjs_account=([^;]*)/)?.[1] ?? '',
  );
  expect(value).not.toBe('');
  return `pdsjs_account=${encodeURIComponent(value)}`;
}

/**
 * @param {PersonalDataServer} pds
 * @param {string} path
 * @param {{cookie?: string, method?: string, body?: unknown}} [opts]
 */
async function api(pds, path, opts = {}) {
  const response = await pds.fetch(
    new Request(`https://${PDS_HOST}${path}`, {
      method: opts.method || 'GET',
      headers: {
        ...(opts.cookie ? { Cookie: opts.cookie } : {}),
        ...(opts.body !== undefined
          ? { 'Content-Type': 'application/json' }
          : {}),
      },
      body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
    }),
  );
  return { status: response.status, body: await response.json() };
}

describe('/account/api/sites', () => {
  it('requires a session', async () => {
    const { pds } = await makePds();
    const { status } = await api(pds, '/account/api/sites');
    expect(status).toBe(401);
  });

  it('lists sites through the browser port', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const { status, body } = await api(pds, '/account/api/sites', { cookie });
    expect(status).toBe(200);
    expect(body).toMatchObject({
      enabled: true,
      domain: 'example.com',
      apexName: 'home',
      apexShadowed: false,
    });
    expect(body.sites).toHaveLength(1);
    expect(body.sites[0]).toMatchObject({
      name: 'home',
      url: 'https://example.com',
      fileCount: 1,
      servable: true,
    });
  });

  it('reports disabled without a browser port', async () => {
    const { pds } = await makePds({ sites: false });
    const cookie = await signInCookie(pds);
    const { body } = await api(pds, '/account/api/sites', { cookie });
    expect(body).toEqual({ enabled: false, sites: [] });
  });
});

describe('/account/api/sites/files', () => {
  it('lists the served files of a named site', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const { status, body } = await api(
      pds,
      '/account/api/sites/files?name=home',
      { cookie },
    );
    expect(status).toBe(200);
    expect(body.files).toHaveLength(1);
    expect(body.files[0]).toMatchObject({
      path: 'index.html',
      contentType: 'text/html',
    });
    expect(body.files[0].cid).toMatch(/^b/);
  });

  it('rejects unknown sites and missing names', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const missing = await api(pds, '/account/api/sites/files', { cookie });
    expect(missing.status).toBe(400);
    const unknown = await api(pds, '/account/api/sites/files?name=nope', {
      cookie,
    });
    expect(unknown.status).toBe(400);
    expect(unknown.body.error).toMatch(/No such site/);
  });
});

describe('/account/api/installs', () => {
  it('lists installs with the snapshot the page renders', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const { status, body } = await api(pds, '/account/api/installs', {
      cookie,
    });
    expect(status).toBe(200);
    expect(body.installs).toHaveLength(1);
    expect(body.installs[0]).toMatchObject({ name: 'Photos', site: 'home' });
    expect(body.installs[0].iconUrl).toMatch(/getBlob/);
    expect(body.installs[0].settings).toHaveLength(2);
  });

  it('saves config against the snapshot schema and rejects what it refuses', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const uri = `at://${DID}/dev.pdsjs.app.install/3installtid`;
    const ok = await api(pds, '/account/api/installs/config', {
      cookie,
      method: 'POST',
      body: { uri, config: { topo: false, rows: 'compact' } },
    });
    expect(ok.status).toBe(200);
    const listed = await api(pds, '/account/api/installs', { cookie });
    expect(listed.body.installs[0].config).toEqual({
      topo: false,
      rows: 'compact',
    });

    for (const [body, error] of /** @type {Array<[Object, RegExp]>} */ ([
      [{ uri, config: { nope: 1 } }, /No such setting/],
      [{ uri, config: { topo: 'yes' } }, /true or false/],
      [{ uri, config: { rows: 'wide' } }, /not one of/],
      [
        { uri: `at://${DID}/dev.pdsjs.app.install/none`, config: {} },
        /No such install/,
      ],
      [
        { uri: 'at://did:plc:other/dev.pdsjs.app.install/x', config: {} },
        /Name the install/,
      ],
      [{ uri, config: [] }, /as an object/],
    ])) {
      const bad = await api(pds, '/account/api/installs/config', {
        cookie,
        method: 'POST',
        body,
      });
      expect(bad.status).toBe(400);
      expect(bad.body.error).toMatch(error);
    }
  });

  it('delegates preview, install, and rename to the installer port', async () => {
    const calls = /** @type {string[]} */ ([]);
    const installer = {
      preview: async (/** @type {string} */ uri) => {
        calls.push(`preview:${uri}`);
        return { name: 'Photos' };
      },
      install: async (
        /** @type {string} */ uri,
        /** @type {any} */ options,
      ) => {
        calls.push(`install:${uri}:${options.site}`);
        return { site: options.site || 'photos', name: 'Photos' };
      },
      rename: async (/** @type {string} */ uri, /** @type {string} */ name) => {
        calls.push(`rename:${name}`);
        return { site: name };
      },
      checkUpdate: async () => ({ currentCid: 'bafym2', changed: true }),
    };
    const { pds } = await makePds({ installer });
    const cookie = await signInCookie(pds);

    const preview = await api(pds, '/account/api/installs/preview', {
      cookie,
      method: 'POST',
      body: { uri: 'at://dev/dev.pdsjs.app.manifest/photos' },
    });
    expect(preview.status).toBe(200);
    expect(preview.body.name).toBe('Photos');

    const install = await api(pds, '/account/api/installs/install', {
      cookie,
      method: 'POST',
      body: {
        uri: 'at://dev/dev.pdsjs.app.manifest/photos',
        site: 'pics',
        config: { topo: false },
      },
    });
    expect(install.status).toBe(200);
    expect(install.body.site).toBe('pics');

    const rename = await api(pds, '/account/api/installs/rename', {
      cookie,
      method: 'POST',
      body: {
        uri: `at://${DID}/dev.pdsjs.app.install/3installtid`,
        site: 'pics',
      },
    });
    expect(rename.status).toBe(200);

    const updates = await api(pds, '/account/api/installs/updates', { cookie });
    expect(updates.status).toBe(200);
    expect(
      updates.body.updates[`at://${DID}/dev.pdsjs.app.install/3installtid`],
    ).toEqual({ currentCid: 'bafym2', changed: true });

    expect(calls).toEqual([
      'preview:at://dev/dev.pdsjs.app.manifest/photos',
      'install:at://dev/dev.pdsjs.app.manifest/photos:pics',
      'rename:pics',
    ]);
  });

  it('reports the feature off without an installer, and blocks writes read-only', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    for (const path of [
      '/account/api/installs/preview',
      '/account/api/installs/install',
      '/account/api/installs/rename',
    ]) {
      const res = await api(pds, path, {
        cookie,
        method: 'POST',
        body: { uri: 'at://x/dev.pdsjs.app.manifest/y', site: 'z' },
      });
      expect(res.status).toBe(400);
      expect(res.body.error).toMatch(/not enabled/);
    }
    const updates = await api(pds, '/account/api/installs/updates', { cookie });
    expect(updates.body.updates).toEqual({});

    const readOnly = await makePds({
      readOnly: true,
      installer: { install: async () => ({ site: 'x', name: 'x' }) },
    });
    const roCookie = await signInCookie(readOnly.pds);
    const blocked = await api(readOnly.pds, '/account/api/installs/install', {
      cookie: roCookie,
      method: 'POST',
      body: { uri: 'at://x/dev.pdsjs.app.manifest/y' },
    });
    expect(blocked.status).toBe(403);
  });

  it('rejects an update check with missing fields quietly and bad rename input loudly', async () => {
    const installer = {
      rename: async () => ({ site: 'x' }),
      checkUpdate: async () => {
        throw new Error('unreachable developer');
      },
    };
    const { pds } = await makePds({ installer });
    const cookie = await signInCookie(pds);
    // A developer that cannot be reached is omitted, not an error
    const updates = await api(pds, '/account/api/installs/updates', { cookie });
    expect(updates.status).toBe(200);
    expect(updates.body.updates).toEqual({});

    const noSite = await api(pds, '/account/api/installs/rename', {
      cookie,
      method: 'POST',
      body: { uri: `at://${DID}/dev.pdsjs.app.install/3installtid` },
    });
    expect(noSite.status).toBe(400);
    expect(noSite.body.error).toMatch(/new subdomain/);
    const wrongRepo = await api(pds, '/account/api/installs/rename', {
      cookie,
      method: 'POST',
      body: { uri: 'at://did:plc:other/dev.pdsjs.app.install/x', site: 'y' },
    });
    expect(wrongRepo.status).toBe(400);

    const noUri = await api(pds, '/account/api/installs/preview', {
      cookie,
      method: 'POST',
      body: {},
    });
    expect(noUri.status).toBe(400);
  });
});

describe('/account/api/sites/delete', () => {
  it('deletes the record, commits, and reaps orphans', async () => {
    const { pds, deleted, commits, reapCount } = await makePds();
    const cookie = await signInCookie(pds);
    const { status } = await api(pds, '/account/api/sites/delete', {
      cookie,
      method: 'POST',
      body: { name: 'home' },
    });
    expect(status).toBe(200);
    expect(deleted).toEqual([`at://${DID}/dev.pdsjs.site.deploy/home`]);
    expect(commits).toEqual([
      [{ action: 'delete', uri: `at://${DID}/dev.pdsjs.site.deploy/home` }],
    ]);
    expect(reapCount()).toBe(1);
  });

  it('rejects unknown sites and missing names', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const missing = await api(pds, '/account/api/sites/delete', {
      cookie,
      method: 'POST',
      body: { name: 'nope' },
    });
    expect(missing.status).toBe(400);
    expect(missing.body.error).toMatch(/No such site/);

    const unnamed = await api(pds, '/account/api/sites/delete', {
      cookie,
      method: 'POST',
      body: {},
    });
    expect(unnamed.status).toBe(400);
  });

  it('is blocked in read-only mode while listing still works', async () => {
    const { pds, deleted } = await makePds({ readOnly: true });
    const cookie = await signInCookie(pds);
    const list = await api(pds, '/account/api/sites', { cookie });
    expect(list.status).toBe(200);

    const del = await api(pds, '/account/api/sites/delete', {
      cookie,
      method: 'POST',
      body: { name: 'home' },
    });
    expect(del.status).toBe(403);
    expect(deleted).toHaveLength(0);
  });
});