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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
429 lines · 15.7 KB Raw History
import {
  CID,
  cborEncodeDagCbor,
  cidToString,
  createBlobCid,
  createCid,
} from '@pdsjs/core/repo';
import { beforeEach, describe, expect, it } from 'vitest';
import { createSiteHandler } from '../src/handler.js';
import { clearManifestCache } from '../src/manifest.js';

const DID = 'did:plc:handlertest';
const DOMAIN = 'example.com';

/**
 * Build a handler over an in-memory site. Returns the handler plus call
 * counters so tests can assert which storage reads happened.
 * @param {Object} [opts]
 * @param {Record<string, {content: string, mimeType?: string, contentType?: string, cacheControl?: string, originTemplate?: boolean}>} [opts.files]
 * @param {string} [opts.fallback]
 * @param {string} [opts.site]
 */
async function setup(opts = {}) {
  const siteName = opts.site || 'home';
  /** @type {Map<string, {data: Uint8Array, mimeType: string}>} */
  const blobStore = new Map();
  const fileEntries = [];
  const cids = /** @type {Record<string, string>} */ ({});
  for (const [path, spec] of Object.entries(opts.files || {})) {
    const data = new TextEncoder().encode(spec.content);
    const cidBytes = await createBlobCid(data);
    const cid = cidToString(cidBytes);
    cids[path] = cid;
    const mimeType = spec.mimeType ?? 'application/octet-stream';
    blobStore.set(cid, { data, mimeType });
    const entry = /** @type {Record<string, unknown>} */ ({
      path,
      blob: {
        $type: 'blob',
        ref: new CID(cidBytes),
        mimeType,
        size: data.length,
      },
    });
    if (spec.contentType) entry.contentType = spec.contentType;
    if (spec.cacheControl) entry.cacheControl = spec.cacheControl;
    if (spec.originTemplate) entry.originTemplate = true;
    fileEntries.push(entry);
  }
  const record = /** @type {Record<string, unknown>} */ ({
    $type: 'dev.pdsjs.site.deploy',
    files: fileEntries,
    createdAt: '2026-08-06T00:00:00.000Z',
  });
  if (opts.fallback) record.fallback = opts.fallback;
  const bytes = cborEncodeDagCbor(record);
  const recordCid = cidToString(await createCid(bytes));

  const calls = { getRecord: 0, blobGet: 0 };
  const handler = createSiteHandler({
    actorStorage: /** @type {any} */ ({
      getRecord: async (/** @type {string} */ uri) => {
        calls.getRecord++;
        return uri === `at://${DID}/dev.pdsjs.site.deploy/${siteName}`
          ? { cid: recordCid, value: bytes }
          : null;
      },
    }),
    blobs: /** @type {any} */ ({
      get: async (/** @type {string} */ _did, /** @type {string} */ cid) => {
        calls.blobGet++;
        return blobStore.get(cid) || null;
      },
    }),
    getDid: async () => DID,
    siteDomain: DOMAIN,
  });

  /** @param {string} urlStr @param {RequestInit} [init] */
  const fetch = (urlStr, init) => {
    const request = new Request(urlStr, init);
    return handler(request, new URL(urlStr));
  };
  return { fetch, calls, cids };
}

beforeEach(() => clearManifestCache());

describe('createSiteHandler', () => {
  it('throws on missing ctx entries', () => {
    expect(() => createSiteHandler(/** @type {any} */ ({}))).toThrow(
      /actorStorage/,
    );
  });

  it('returns null for hostnames outside the site domain', async () => {
    const { fetch } = await setup({
      files: { 'index.html': { content: 'hi', mimeType: 'text/html' } },
    });
    expect(await fetch('https://pds.other.dev/index.html')).toBeNull();
    expect(await fetch('https://other.org/')).toBeNull();
  });

  it('serves the apex site at the apex and by subdomain rkey', async () => {
    const { fetch } = await setup({
      files: {
        'index.html': { content: '<h1>hi</h1>', mimeType: 'text/html' },
      },
    });
    const apex = await fetch('https://example.com/');
    expect(apex?.status).toBe(200);
    expect(await apex?.text()).toBe('<h1>hi</h1>');
    expect(apex?.headers.get('content-type')).toBe('text/html');

    const sub = await fetch('https://home.example.com/index.html');
    expect(sub?.status).toBe(200);
  });

  it('404s an unknown site and multi-label subdomains', async () => {
    const { fetch } = await setup({
      files: { 'index.html': { content: 'hi', mimeType: 'text/html' } },
    });
    expect((await fetch('https://nope.example.com/'))?.status).toBe(404);
    expect((await fetch('https://a.b.example.com/'))?.status).toBe(404);
  });

  it('redirects www to the apex when no www site is deployed', async () => {
    const { fetch } = await setup({
      files: { 'index.html': { content: 'hi', mimeType: 'text/html' } },
    });
    const response = await fetch('https://www.example.com/a/b?q=1');
    expect(response?.status).toBe(301);
    expect(response?.headers.get('location')).toBe(
      'https://example.com/a/b?q=1',
    );
  });

  it('serves a deployed www site instead of redirecting', async () => {
    const { fetch } = await setup({
      site: 'www',
      files: {
        'index.html': { content: 'the www site', mimeType: 'text/html' },
      },
    });
    const response = await fetch('https://www.example.com/');
    expect(response?.status).toBe(200);
    expect(await response?.text()).toBe('the www site');
  });

  it('fills the origin token in an origin-templated file', async () => {
    const { fetch } = await setup({
      files: {
        'client-metadata.json': {
          content:
            '{"client_id":"__PDSJS_ORIGIN__/client-metadata.json","redirect_uris":["__PDSJS_ORIGIN__/callback"]}',
          mimeType: 'application/json',
          originTemplate: true,
        },
      },
    });
    const res = await fetch(`https://${DOMAIN}/client-metadata.json`);
    expect(res?.status).toBe(200);
    // The blob cid no longer names the transformed bytes.
    expect(res?.headers.get('ETag')).toBeNull();
    const body = await res?.json();
    expect(body.client_id).toBe(`https://${DOMAIN}/client-metadata.json`);
    expect(body.redirect_uris).toEqual([`https://${DOMAIN}/callback`]);

    const head = await fetch(`https://${DOMAIN}/client-metadata.json`, {
      method: 'HEAD',
    });
    expect(Number(head?.headers.get('Content-Length'))).toBeGreaterThan(0);
  });

  it('answers handle verification on site hostnames', async () => {
    const { fetch } = await setup({ files: {} });
    const response = await fetch('https://example.com/.well-known/atproto-did');
    expect(response?.status).toBe(200);
    expect(await response?.text()).toBe(DID);
    // A browser app reads this from the handle's own hostname.
    expect(response?.headers.get('access-control-allow-origin')).toBe('*');
  });

  it('answers a preflight for handle verification', async () => {
    const { fetch } = await setup({ files: {} });
    const response = await fetch(
      'https://example.com/.well-known/atproto-did',
      { method: 'OPTIONS' },
    );
    expect(response?.status).toBe(204);
    expect(response?.headers.get('access-control-allow-origin')).toBe('*');
  });

  it('405s non-GET/HEAD including OPTIONS, with no CORS headers', async () => {
    const { fetch } = await setup({
      files: { 'index.html': { content: 'hi', mimeType: 'text/html' } },
    });
    for (const method of ['POST', 'PUT', 'DELETE', 'OPTIONS']) {
      const response = await fetch('https://example.com/', { method });
      expect(response?.status).toBe(405);
      expect(response?.headers.get('allow')).toBe('GET, HEAD');
      expect(response?.headers.get('access-control-allow-origin')).toBeNull();
    }
  });

  it('rejects traversal and undecodable paths', async () => {
    const { fetch } = await setup({
      files: { 'index.html': { content: 'hi', mimeType: 'text/html' } },
    });
    // Dot segments, literal or percent-encoded, never reach the handler:
    // WHATWG URL parsing collapses both, so these are ordinary misses. The
    // resolvePath guard behind them is defense-in-depth for non-URL callers.
    expect((await fetch('https://example.com/../secrets'))?.status).toBe(404);
    expect((await fetch('https://example.com/a/%2e%2e/b'))?.status).toBe(404);
    expect((await fetch('https://example.com/%zz'))?.status).toBe(400);
    expect((await fetch('https://example.com/a%5cb'))?.status).toBe(400);
  });

  it('serves directory indexes and redirects slashless directories', async () => {
    const { fetch } = await setup({
      files: {
        'index.html': { content: 'root', mimeType: 'text/html' },
        'docs/index.html': { content: 'docs', mimeType: 'text/html' },
      },
    });
    expect(await (await fetch('https://example.com/docs/'))?.text()).toBe(
      'docs',
    );
    const redirect = await fetch('https://example.com/docs?q=1');
    expect(redirect?.status).toBe(301);
    expect(redirect?.headers.get('location')).toBe('/docs/?q=1');
  });

  it('serves the SPA fallback with status 200', async () => {
    const { fetch } = await setup({
      files: { 'index.html': { content: 'app', mimeType: 'text/html' } },
      fallback: 'index.html',
    });
    const response = await fetch('https://example.com/some/deep/route');
    expect(response?.status).toBe(200);
    expect(await response?.text()).toBe('app');
  });

  it('404s unmatched paths without a fallback', async () => {
    const { fetch } = await setup({
      files: { 'index.html': { content: 'hi', mimeType: 'text/html' } },
    });
    const response = await fetch('https://example.com/missing.png');
    expect(response?.status).toBe(404);
    expect(response?.headers.get('cache-control')).toBe('no-store');
  });

  it('serves files with an open CORS origin', async () => {
    const { fetch } = await setup({
      files: { 'index.html': { content: 'hi', mimeType: 'text/html' } },
    });
    const response = await fetch('https://example.com/');
    expect(response?.headers.get('access-control-allow-origin')).toBe('*');
  });

  it('sends a strong ETag and answers If-None-Match with a blobless 304', async () => {
    const { fetch, calls, cids } = await setup({
      files: { 'index.html': { content: 'hi', mimeType: 'text/html' } },
    });
    const first = await fetch('https://example.com/');
    const etag = first?.headers.get('etag');
    expect(etag).toBe(`"${cids['index.html']}"`);
    expect(calls.blobGet).toBe(1);

    const conditional = await fetch('https://example.com/', {
      headers: { 'If-None-Match': /** @type {string} */ (etag) },
    });
    expect(conditional?.status).toBe(304);
    expect(conditional?.headers.get('etag')).toBe(etag);
    expect(calls.blobGet).toBe(1);

    const weak = await fetch('https://example.com/', {
      headers: { 'If-None-Match': `W/${etag}, "other"` },
    });
    expect(weak?.status).toBe(304);
    expect(calls.blobGet).toBe(1);
  });

  it('answers HEAD from the manifest without reading the blob', async () => {
    const { fetch, calls } = await setup({
      files: { 'index.html': { content: 'hello', mimeType: 'text/html' } },
    });
    const response = await fetch('https://example.com/', { method: 'HEAD' });
    expect(response?.status).toBe(200);
    expect(response?.headers.get('content-length')).toBe('5');
    expect(await response?.text()).toBe('');
    expect(calls.blobGet).toBe(0);
  });

  it('applies MIME precedence: manifest override, stored mime, extension map', async () => {
    const { fetch } = await setup({
      files: {
        'styled.css': {
          content: 'a{}',
          mimeType: 'text/css',
          contentType: 'text/css; charset=iso-8859-1',
        },
        'plain.css': { content: 'b{}', mimeType: 'text/css' },
        'sniffed.js': {
          content: 'let x',
          mimeType: 'application/octet-stream',
        },
        'mystery.bin': { content: '?', mimeType: 'application/octet-stream' },
      },
    });
    const styled = await fetch('https://example.com/styled.css');
    expect(styled?.headers.get('content-type')).toBe(
      'text/css; charset=iso-8859-1',
    );
    const plain = await fetch('https://example.com/plain.css');
    expect(plain?.headers.get('content-type')).toBe('text/css');
    const sniffed = await fetch('https://example.com/sniffed.js');
    expect(sniffed?.headers.get('content-type')).toBe(
      'text/javascript; charset=utf-8',
    );
    const mystery = await fetch('https://example.com/mystery.bin');
    expect(mystery?.headers.get('content-type')).toBe(
      'application/octet-stream',
    );
  });

  it('applies cacheControl overrides and the revalidating default', async () => {
    const { fetch } = await setup({
      files: {
        'app.hash123.js': {
          content: 'x',
          mimeType: 'text/javascript',
          cacheControl: 'public, max-age=31536000, immutable',
        },
        'index.html': { content: 'hi', mimeType: 'text/html' },
      },
    });
    const hashed = await fetch('https://example.com/app.hash123.js');
    expect(hashed?.headers.get('cache-control')).toBe(
      'public, max-age=31536000, immutable',
    );
    const index = await fetch('https://example.com/');
    expect(index?.headers.get('cache-control')).toBe(
      'public, max-age=60, must-revalidate',
    );
    expect(index?.headers.get('x-content-type-options')).toBe('nosniff');
    expect(index?.headers.get('strict-transport-security')).toBe(
      'max-age=31536000',
    );
    expect(index?.headers.get('content-security-policy')).toBeNull();
    expect(index?.headers.get('content-disposition')).toBeNull();
  });

  it('500s in plain text when serving throws', async () => {
    const handler = createSiteHandler({
      actorStorage: /** @type {any} */ ({
        getRecord: async () => {
          throw new Error('storage exploded');
        },
      }),
      blobs: /** @type {any} */ ({ get: async () => null }),
      getDid: async () => DID,
      siteDomain: DOMAIN,
    });
    const url = 'https://example.com/';
    const response = await handler(new Request(url), new URL(url));
    expect(response?.status).toBe(500);
    expect(response?.headers.get('content-type')).toContain('text/plain');
  });
});

describe('repo blobs by CID', () => {
  it('serves media blobs under their stored mime, immutable and sandboxed', async () => {
    const { fetch, cids } = await setup({
      files: { 'photo.webp': { content: 'img-bytes', mimeType: 'image/webp' } },
    });
    const response = await fetch(
      `https://example.com/.blobs/${cids['photo.webp']}`,
    );
    expect(response?.status).toBe(200);
    expect(response?.headers.get('content-type')).toBe('image/webp');
    expect(response?.headers.get('cache-control')).toBe(
      'public, max-age=31536000, immutable',
    );
    expect(response?.headers.get('content-security-policy')).toContain(
      'sandbox',
    );
    expect(await response?.text()).toBe('img-bytes');
  });

  it('forces non-media mimes to octet-stream', async () => {
    const { fetch, cids } = await setup({
      files: {
        'page.html': { content: '<script>1</script>', mimeType: 'text/html' },
      },
    });
    const response = await fetch(
      `https://example.com/.blobs/${cids['page.html']}`,
    );
    expect(response?.headers.get('content-type')).toBe(
      'application/octet-stream',
    );
  });

  it('answers If-None-Match with 304 and no blob read', async () => {
    const { fetch, calls, cids } = await setup({
      files: { 'photo.webp': { content: 'img', mimeType: 'image/webp' } },
    });
    const cid = cids['photo.webp'];
    const response = await fetch(`https://example.com/.blobs/${cid}`, {
      headers: { 'If-None-Match': `"${cid}"` },
    });
    expect(response?.status).toBe(304);
    expect(response?.headers.get('content-type')).toBeNull();
    expect(calls.blobGet).toBe(0);
  });

  it('404s an unknown or malformed CID', async () => {
    const { fetch } = await setup({
      files: { 'photo.webp': { content: 'img', mimeType: 'image/webp' } },
    });
    const missing = await fetch(
      'https://example.com/.blobs/bafkreiaaaaaaaaaaaaaaaaaaaaaaaa',
    );
    expect(missing?.status).toBe(404);
    const malformed = await fetch('https://example.com/.blobs/NOT-A-CID');
    expect(malformed?.status).toBe(404);
  });
});