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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
1416 lines · 37.6 KB Raw History

PDS Core Separation Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Separate pure PDS logic from Cloudflare-specific code to enable full unit test coverage and future portability.

Architecture: Core library (pds.js) contains pure functions, route handlers, and ATProto logic with dependency injection for storage/auth. Thin Cloudflare adapter (cloudflare.js) implements repository and auth interfaces using Durable Objects, SQLite, and R2.

Tech Stack: JavaScript ES modules, Vitest for testing, Cloudflare Workers/Durable Objects/R2 for production.


Task 1: Define Repository Interface

Files:

  • Create: src/repository.js
  • Test: test/repository.test.js

Step 1: Write the interface test

// test/repository.test.js
import { describe, it, expect } from 'vitest';
import { MockRepository } from '../src/repository.js';

describe('MockRepository', () => {
  it('implements all required methods', () => {
    const repo = new MockRepository();
    expect(typeof repo.putBlock).toBe('function');
    expect(typeof repo.getBlock).toBe('function');
    expect(typeof repo.hasBlock).toBe('function');
    expect(typeof repo.indexRecord).toBe('function');
    expect(typeof repo.getRecord).toBe('function');
    expect(typeof repo.listRecords).toBe('function');
    expect(typeof repo.deleteRecord).toBe('function');
    expect(typeof repo.getLatestCommit).toBe('function');
    expect(typeof repo.saveCommit).toBe('function');
  });
});

Step 2: Run test to verify it fails

Run: npm test -- test/repository.test.js Expected: FAIL with "Cannot find module '../src/repository.js'"

Step 3: Write minimal implementation

// src/repository.js
/**
 * Repository interface for PDS storage operations.
 * @typedef {Object} Repository
 * @property {(cid: string, data: Uint8Array) => Promise<void>} putBlock
 * @property {(cid: string) => Promise<Uint8Array|null>} getBlock
 * @property {(cid: string) => Promise<boolean>} hasBlock
 * @property {(uri: string, cid: string, collection: string, rkey: string, value: Uint8Array) => Promise<void>} indexRecord
 * @property {(uri: string) => Promise<{cid: string, value: Uint8Array}|null>} getRecord
 * @property {(collection: string, options?: {limit?: number, cursor?: string, reverse?: boolean}) => Promise<{records: Array<{uri: string, cid: string, value: Uint8Array}>, cursor?: string}>} listRecords
 * @property {(uri: string) => Promise<void>} deleteRecord
 * @property {() => Promise<{cid: string, rev: string}|null>} getLatestCommit
 * @property {(cid: string, rev: string, prev: string|null) => Promise<number>} saveCommit
 */

/**
 * In-memory mock repository for testing.
 */
export class MockRepository {
  constructor() {
    this.blocks = new Map();
    this.records = new Map();
    this.commits = [];
  }

  async putBlock(cid, data) {
    this.blocks.set(cid, data);
  }

  async getBlock(cid) {
    return this.blocks.get(cid) || null;
  }

  async hasBlock(cid) {
    return this.blocks.has(cid);
  }

  async indexRecord(uri, cid, collection, rkey, value) {
    this.records.set(uri, { cid, collection, rkey, value });
  }

  async getRecord(uri) {
    const record = this.records.get(uri);
    return record ? { cid: record.cid, value: record.value } : null;
  }

  async listRecords(collection, options = {}) {
    const { limit = 50, cursor, reverse = false } = options;
    let records = [...this.records.entries()]
      .filter(([, r]) => r.collection === collection)
      .map(([uri, r]) => ({ uri, cid: r.cid, value: r.value }));

    if (reverse) records.reverse();
    if (cursor) {
      const idx = records.findIndex(r => r.uri === cursor);
      if (idx >= 0) records = records.slice(idx + 1);
    }

    return {
      records: records.slice(0, limit),
      cursor: records.length > limit ? records[limit - 1].uri : undefined,
    };
  }

  async deleteRecord(uri) {
    this.records.delete(uri);
  }

  async getLatestCommit() {
    if (this.commits.length === 0) return null;
    const last = this.commits[this.commits.length - 1];
    return { cid: last.cid, rev: last.rev };
  }

  async saveCommit(cid, rev, prev) {
    const seq = this.commits.length + 1;
    this.commits.push({ cid, rev, prev, seq });
    return seq;
  }
}

Step 4: Run test to verify it passes

Run: npm test -- test/repository.test.js Expected: PASS

Step 5: Commit

git add src/repository.js test/repository.test.js
git commit -m "feat: add Repository interface and MockRepository"

Task 2: Add MockRepository CRUD Tests

Files:

  • Modify: test/repository.test.js

Step 1: Write block storage tests

Add to test/repository.test.js:

describe('MockRepository block storage', () => {
  it('stores and retrieves blocks', async () => {
    const repo = new MockRepository();
    const data = new Uint8Array([1, 2, 3, 4]);

    await repo.putBlock('cid123', data);
    const retrieved = await repo.getBlock('cid123');

    expect(retrieved).toEqual(data);
  });

  it('returns null for missing blocks', async () => {
    const repo = new MockRepository();
    expect(await repo.getBlock('missing')).toBeNull();
  });

  it('checks block existence', async () => {
    const repo = new MockRepository();
    await repo.putBlock('cid123', new Uint8Array([1]));

    expect(await repo.hasBlock('cid123')).toBe(true);
    expect(await repo.hasBlock('missing')).toBe(false);
  });
});

Step 2: Run tests

Run: npm test -- test/repository.test.js Expected: PASS

Step 3: Write record indexing tests

Add to test/repository.test.js:

describe('MockRepository record indexing', () => {
  it('indexes and retrieves records', async () => {
    const repo = new MockRepository();
    const value = new Uint8Array([5, 6, 7]);

    await repo.indexRecord('at://did:plc:test/app.bsky.feed.post/abc', 'cid456', 'app.bsky.feed.post', 'abc', value);
    const record = await repo.getRecord('at://did:plc:test/app.bsky.feed.post/abc');

    expect(record).toEqual({ cid: 'cid456', value });
  });

  it('returns null for missing records', async () => {
    const repo = new MockRepository();
    expect(await repo.getRecord('at://missing')).toBeNull();
  });

  it('lists records by collection', async () => {
    const repo = new MockRepository();
    await repo.indexRecord('at://did/col1/a', 'c1', 'col1', 'a', new Uint8Array([1]));
    await repo.indexRecord('at://did/col2/b', 'c2', 'col2', 'b', new Uint8Array([2]));
    await repo.indexRecord('at://did/col1/c', 'c3', 'col1', 'c', new Uint8Array([3]));

    const result = await repo.listRecords('col1');
    expect(result.records.length).toBe(2);
    expect(result.records.every(r => r.uri.includes('col1'))).toBe(true);
  });

  it('deletes records', async () => {
    const repo = new MockRepository();
    await repo.indexRecord('at://did/col/a', 'c1', 'col', 'a', new Uint8Array([1]));

    await repo.deleteRecord('at://did/col/a');
    expect(await repo.getRecord('at://did/col/a')).toBeNull();
  });
});

Step 4: Run tests

Run: npm test -- test/repository.test.js Expected: PASS

Step 5: Commit

git add test/repository.test.js
git commit -m "test: add MockRepository CRUD tests"

Task 3: Add Commit Tracking Tests

Files:

  • Modify: test/repository.test.js

Step 1: Write commit tracking tests

Add to test/repository.test.js:

describe('MockRepository commit tracking', () => {
  it('returns null when no commits exist', async () => {
    const repo = new MockRepository();
    expect(await repo.getLatestCommit()).toBeNull();
  });

  it('saves and retrieves commits', async () => {
    const repo = new MockRepository();

    const seq1 = await repo.saveCommit('cid1', 'rev1', null);
    expect(seq1).toBe(1);

    const seq2 = await repo.saveCommit('cid2', 'rev2', 'cid1');
    expect(seq2).toBe(2);

    const latest = await repo.getLatestCommit();
    expect(latest).toEqual({ cid: 'cid2', rev: 'rev2' });
  });
});

Step 2: Run tests

Run: npm test -- test/repository.test.js Expected: PASS

Step 3: Commit

git add test/repository.test.js
git commit -m "test: add commit tracking tests"

Task 4: Define BlobStore Interface

Files:

  • Modify: src/repository.js
  • Modify: test/repository.test.js

Step 1: Write BlobStore interface tests

Add to test/repository.test.js:

import { MockRepository, MockBlobStore } from '../src/repository.js';

describe('MockBlobStore', () => {
  it('implements all required methods', () => {
    const store = new MockBlobStore();
    expect(typeof store.putBlob).toBe('function');
    expect(typeof store.getBlob).toBe('function');
    expect(typeof store.deleteBlob).toBe('function');
    expect(typeof store.hasBlob).toBe('function');
  });

  it('stores and retrieves blobs', async () => {
    const store = new MockBlobStore();
    const data = new Uint8Array([1, 2, 3, 4, 5]);

    await store.putBlob('blobcid123', data, 'image/png');
    const result = await store.getBlob('blobcid123');

    expect(result).toEqual({ data, mimeType: 'image/png' });
  });

  it('returns null for missing blobs', async () => {
    const store = new MockBlobStore();
    expect(await store.getBlob('missing')).toBeNull();
  });

  it('deletes blobs', async () => {
    const store = new MockBlobStore();
    await store.putBlob('blobcid', new Uint8Array([1]), 'text/plain');

    await store.deleteBlob('blobcid');
    expect(await store.getBlob('blobcid')).toBeNull();
  });

  it('checks blob existence', async () => {
    const store = new MockBlobStore();
    await store.putBlob('exists', new Uint8Array([1]), 'text/plain');

    expect(await store.hasBlob('exists')).toBe(true);
    expect(await store.hasBlob('missing')).toBe(false);
  });
});

Step 2: Run tests to verify they fail

Run: npm test -- test/repository.test.js Expected: FAIL with "MockBlobStore is not exported"

Step 3: Implement MockBlobStore

Add to src/repository.js:

/**
 * BlobStore interface for binary large object storage.
 * @typedef {Object} BlobStore
 * @property {(cid: string, data: Uint8Array, mimeType: string) => Promise<void>} putBlob
 * @property {(cid: string) => Promise<{data: Uint8Array, mimeType: string}|null>} getBlob
 * @property {(cid: string) => Promise<void>} deleteBlob
 * @property {(cid: string) => Promise<boolean>} hasBlob
 */

/**
 * In-memory mock blob store for testing.
 */
export class MockBlobStore {
  constructor() {
    this.blobs = new Map();
  }

  async putBlob(cid, data, mimeType) {
    this.blobs.set(cid, { data, mimeType });
  }

  async getBlob(cid) {
    return this.blobs.get(cid) || null;
  }

  async deleteBlob(cid) {
    this.blobs.delete(cid);
  }

  async hasBlob(cid) {
    return this.blobs.has(cid);
  }
}

Step 4: Run tests

Run: npm test -- test/repository.test.js Expected: PASS

Step 5: Commit

git add src/repository.js test/repository.test.js
git commit -m "feat: add BlobStore interface and MockBlobStore"

Task 5: Define AuthProvider Interface

Files:

  • Create: src/auth.js
  • Create: test/auth.test.js

Step 1: Write AuthProvider interface tests

// test/auth.test.js
import { describe, it, expect } from 'vitest';
import { MockAuthProvider } from '../src/auth.js';

describe('MockAuthProvider', () => {
  it('implements all required methods', () => {
    const auth = new MockAuthProvider();
    expect(typeof auth.createSession).toBe('function');
    expect(typeof auth.getSession).toBe('function');
    expect(typeof auth.deleteSession).toBe('function');
    expect(typeof auth.storeAuthorizationRequest).toBe('function');
    expect(typeof auth.getAuthorizationRequest).toBe('function');
    expect(typeof auth.storeToken).toBe('function');
    expect(typeof auth.getToken).toBe('function');
    expect(typeof auth.revokeToken).toBe('function');
  });
});

Step 2: Run test to verify it fails

Run: npm test -- test/auth.test.js Expected: FAIL with "Cannot find module '../src/auth.js'"

Step 3: Write minimal implementation

// src/auth.js
/**
 * AuthProvider interface for session and token management.
 * @typedef {Object} AuthProvider
 * @property {(did: string) => Promise<{accessJwt: string, refreshJwt: string}>} createSession
 * @property {(accessJwt: string) => Promise<{did: string}|null>} getSession
 * @property {(refreshJwt: string) => Promise<void>} deleteSession
 * @property {(id: string, request: Object) => Promise<void>} storeAuthorizationRequest
 * @property {(id: string) => Promise<Object|null>} getAuthorizationRequest
 * @property {(tokenId: string, token: Object) => Promise<void>} storeToken
 * @property {(tokenId: string) => Promise<Object|null>} getToken
 * @property {(tokenId: string) => Promise<void>} revokeToken
 */

/**
 * In-memory mock auth provider for testing.
 */
export class MockAuthProvider {
  constructor(jwtSecret = 'test-secret') {
    this.jwtSecret = jwtSecret;
    this.sessions = new Map();
    this.authRequests = new Map();
    this.tokens = new Map();
  }

  async createSession(did) {
    const accessJwt = `access-${did}-${Date.now()}`;
    const refreshJwt = `refresh-${did}-${Date.now()}`;
    this.sessions.set(accessJwt, { did, refreshJwt });
    return { accessJwt, refreshJwt };
  }

  async getSession(accessJwt) {
    const session = this.sessions.get(accessJwt);
    return session ? { did: session.did } : null;
  }

  async deleteSession(refreshJwt) {
    for (const [access, session] of this.sessions) {
      if (session.refreshJwt === refreshJwt) {
        this.sessions.delete(access);
        break;
      }
    }
  }

  async storeAuthorizationRequest(id, request) {
    this.authRequests.set(id, request);
  }

  async getAuthorizationRequest(id) {
    return this.authRequests.get(id) || null;
  }

  async storeToken(tokenId, token) {
    this.tokens.set(tokenId, token);
  }

  async getToken(tokenId) {
    return this.tokens.get(tokenId) || null;
  }

  async revokeToken(tokenId) {
    this.tokens.delete(tokenId);
  }
}

Step 4: Run test to verify it passes

Run: npm test -- test/auth.test.js Expected: PASS

Step 5: Commit

git add src/auth.js test/auth.test.js
git commit -m "feat: add AuthProvider interface and MockAuthProvider"

Task 6: Add MockAuthProvider Functional Tests

Files:

  • Modify: test/auth.test.js

Step 1: Write session management tests

Add to test/auth.test.js:

describe('MockAuthProvider sessions', () => {
  it('creates and retrieves sessions', async () => {
    const auth = new MockAuthProvider();

    const session = await auth.createSession('did:plc:test123');
    expect(session.accessJwt).toBeDefined();
    expect(session.refreshJwt).toBeDefined();

    const retrieved = await auth.getSession(session.accessJwt);
    expect(retrieved).toEqual({ did: 'did:plc:test123' });
  });

  it('returns null for invalid session', async () => {
    const auth = new MockAuthProvider();
    expect(await auth.getSession('invalid-token')).toBeNull();
  });

  it('deletes sessions by refresh token', async () => {
    const auth = new MockAuthProvider();
    const session = await auth.createSession('did:plc:test');

    await auth.deleteSession(session.refreshJwt);
    expect(await auth.getSession(session.accessJwt)).toBeNull();
  });
});

Step 2: Run tests

Run: npm test -- test/auth.test.js Expected: PASS

Step 3: Write OAuth storage tests

Add to test/auth.test.js:

describe('MockAuthProvider OAuth', () => {
  it('stores and retrieves authorization requests', async () => {
    const auth = new MockAuthProvider();
    const request = {
      clientId: 'https://example.com',
      scope: 'atproto',
      codeChallenge: 'abc123',
    };

    await auth.storeAuthorizationRequest('req-id-1', request);
    const retrieved = await auth.getAuthorizationRequest('req-id-1');

    expect(retrieved).toEqual(request);
  });

  it('returns null for missing auth requests', async () => {
    const auth = new MockAuthProvider();
    expect(await auth.getAuthorizationRequest('missing')).toBeNull();
  });

  it('stores, retrieves, and revokes tokens', async () => {
    const auth = new MockAuthProvider();
    const token = {
      did: 'did:plc:test',
      clientId: 'https://example.com',
      scope: 'atproto',
    };

    await auth.storeToken('token-id-1', token);
    expect(await auth.getToken('token-id-1')).toEqual(token);

    await auth.revokeToken('token-id-1');
    expect(await auth.getToken('token-id-1')).toBeNull();
  });
});

Step 4: Run tests

Run: npm test -- test/auth.test.js Expected: PASS

Step 5: Commit

git add test/auth.test.js
git commit -m "test: add MockAuthProvider functional tests"

Task 7: Create PDS Core Entry Point

Files:

  • Modify: src/pds.js (add exports, create handler factory)
  • Create: test/pds-core.test.js

Step 1: Write core entry point test

// test/pds-core.test.js
import { describe, it, expect } from 'vitest';
import { createPdsHandler } from '../src/pds.js';
import { MockRepository, MockBlobStore } from '../src/repository.js';
import { MockAuthProvider } from '../src/auth.js';

describe('createPdsHandler', () => {
  it('returns a request handler function', () => {
    const handler = createPdsHandler({
      did: 'did:plc:test',
      hostname: 'test.pds.example',
      repository: new MockRepository(),
      blobStore: new MockBlobStore(),
      authProvider: new MockAuthProvider(),
    });

    expect(typeof handler).toBe('function');
  });
});

Step 2: Run test to verify it fails

Run: npm test -- test/pds-core.test.js Expected: FAIL with "createPdsHandler is not exported"

Step 3: Add createPdsHandler export to pds.js

Add near the end of src/pds.js (before export default):

/**
 * Creates a PDS request handler with injected dependencies.
 * @param {Object} config
 * @param {string} config.did - The DID for this PDS
 * @param {string} config.hostname - The hostname for this PDS
 * @param {Repository} config.repository - Storage backend
 * @param {BlobStore} config.blobStore - Blob storage backend
 * @param {AuthProvider} config.authProvider - Auth/session backend
 * @returns {(request: Request) => Promise<Response>}
 */
export function createPdsHandler(config) {
  const { did, hostname, repository, blobStore, authProvider } = config;

  return async function handleRequest(request) {
    // TODO: Wire up to route handlers with injected dependencies
    return new Response('PDS handler stub', { status: 501 });
  };
}

Step 4: Run test to verify it passes

Run: npm test -- test/pds-core.test.js Expected: PASS

Step 5: Commit

git add src/pds.js test/pds-core.test.js
git commit -m "feat: add createPdsHandler factory function"

Task 8: Extract Pure Functions Module

Files:

  • Create: src/atproto.js (pure ATProto utilities)
  • Modify: src/pds.js (re-export from atproto.js)
  • Create: test/atproto.test.js

Step 1: Identify pure functions to extract

The following pure functions from pds.js (lines 324-1715) should move to atproto.js:

  • Encoding: bytesToHex, hexToBytes, base32Encode, base32Decode, base64UrlEncode, base64UrlDecode
  • CBOR: cborEncode, cborEncodeDagCbor, cborDecode, varint
  • CID: createCid, createBlobCid, cidToString, cidToBytes
  • TID: createTid
  • Crypto: sign, generateKeyPair, importPrivateKey, computeJwkThumbprint
  • JWT: createAccessJwt, createRefreshJwt, verifyAccessJwt, verifyRefreshJwt, createServiceJwt
  • MST: getKeyDepth
  • CAR: buildCarFile
  • MIME: sniffMimeType, findBlobRefs

Step 2: Create atproto.js with encoding utilities

// src/atproto.js
// Pure ATProto utilities - no platform dependencies

// Re-export from pds.js for now (will move implementations later)
export {
  bytesToHex,
  hexToBytes,
  base32Encode,
  base32Decode,
  base64UrlEncode,
  base64UrlDecode,
  cborEncode,
  cborEncodeDagCbor,
  cborDecode,
  varint,
  createCid,
  createBlobCid,
  cidToString,
  cidToBytes,
  createTid,
  sign,
  generateKeyPair,
  importPrivateKey,
  computeJwkThumbprint,
  createAccessJwt,
  createRefreshJwt,
  verifyAccessJwt,
  verifyRefreshJwt,
  createServiceJwt,
  getKeyDepth,
  buildCarFile,
  sniffMimeType,
  findBlobRefs,
} from './pds.js';

Step 3: Write test for atproto exports

// test/atproto.test.js
import { describe, it, expect } from 'vitest';
import * as atproto from '../src/atproto.js';

describe('atproto exports', () => {
  it('exports encoding utilities', () => {
    expect(typeof atproto.bytesToHex).toBe('function');
    expect(typeof atproto.hexToBytes).toBe('function');
    expect(typeof atproto.base32Encode).toBe('function');
    expect(typeof atproto.base32Decode).toBe('function');
    expect(typeof atproto.base64UrlEncode).toBe('function');
    expect(typeof atproto.base64UrlDecode).toBe('function');
  });

  it('exports CBOR utilities', () => {
    expect(typeof atproto.cborEncode).toBe('function');
    expect(typeof atproto.cborEncodeDagCbor).toBe('function');
    expect(typeof atproto.cborDecode).toBe('function');
  });

  it('exports CID utilities', () => {
    expect(typeof atproto.createCid).toBe('function');
    expect(typeof atproto.createBlobCid).toBe('function');
    expect(typeof atproto.cidToString).toBe('function');
    expect(typeof atproto.cidToBytes).toBe('function');
  });

  it('exports crypto utilities', () => {
    expect(typeof atproto.sign).toBe('function');
    expect(typeof atproto.generateKeyPair).toBe('function');
    expect(typeof atproto.importPrivateKey).toBe('function');
  });

  it('exports JWT utilities', () => {
    expect(typeof atproto.createAccessJwt).toBe('function');
    expect(typeof atproto.verifyAccessJwt).toBe('function');
    expect(typeof atproto.createServiceJwt).toBe('function');
  });
});

Step 4: Run tests

Run: npm test -- test/atproto.test.js Expected: PASS

Step 5: Commit

git add src/atproto.js test/atproto.test.js
git commit -m "feat: create atproto.js pure utilities module"

Task 9: Create Cloudflare Adapter Skeleton

Files:

  • Create: src/cloudflare.js
  • Modify: wrangler.toml (update main entry point)

Step 1: Create cloudflare.js adapter

// src/cloudflare.js
// Cloudflare-specific adapter implementing Repository, BlobStore, and AuthProvider
import { PersonalDataServer } from './pds.js';

// Re-export the Durable Object class for Cloudflare
export { PersonalDataServer };

// Re-export the default fetch handler
export { default } from './pds.js';

Step 2: Verify syntax

Run: node --check src/cloudflare.js Expected: No output (success)

Step 3: Update wrangler.toml to use cloudflare.js

Change:

main = "src/pds.js"

To:

main = "src/cloudflare.js"

Step 4: Run e2e tests to verify nothing broke

Run: npm run test:e2e Expected: All tests pass

Step 5: Commit

git add src/cloudflare.js wrangler.toml
git commit -m "feat: create cloudflare.js adapter entry point"

Task 10: Implement CloudflareRepository

Files:

  • Modify: src/cloudflare.js
  • Create: test/cloudflare-repository.test.js

Step 1: Write CloudflareRepository test

// test/cloudflare-repository.test.js
import { describe, it, expect } from 'vitest';

// Note: This test verifies interface compliance only
// Full integration testing happens in e2e tests

describe('CloudflareRepository interface', () => {
  it('should be tested via e2e tests with real Durable Objects', () => {
    // CloudflareRepository requires actual Cloudflare runtime
    // This is a placeholder to document that testing strategy
    expect(true).toBe(true);
  });
});

Step 2: Implement CloudflareRepository in cloudflare.js

Add to src/cloudflare.js:

/**
 * Repository implementation backed by Durable Object SQLite storage.
 */
export class CloudflareRepository {
  /**
   * @param {DurableObjectState['storage']['sql']} sql
   */
  constructor(sql) {
    this.sql = sql;
  }

  async putBlock(cid, data) {
    this.sql.exec(
      'INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)',
      cid,
      data
    );
  }

  async getBlock(cid) {
    const row = this.sql.exec('SELECT data FROM blocks WHERE cid = ?', cid).one();
    return row ? new Uint8Array(row.data) : null;
  }

  async hasBlock(cid) {
    const row = this.sql.exec('SELECT 1 FROM blocks WHERE cid = ?', cid).one();
    return row !== null;
  }

  async indexRecord(uri, cid, collection, rkey, value) {
    this.sql.exec(
      'INSERT OR REPLACE INTO records (uri, cid, collection, rkey, value) VALUES (?, ?, ?, ?, ?)',
      uri, cid, collection, rkey, value
    );
  }

  async getRecord(uri) {
    const row = this.sql.exec('SELECT cid, value FROM records WHERE uri = ?', uri).one();
    return row ? { cid: row.cid, value: new Uint8Array(row.value) } : null;
  }

  async listRecords(collection, options = {}) {
    const { limit = 50, cursor, reverse = false } = options;
    const order = reverse ? 'DESC' : 'ASC';
    let query = `SELECT uri, cid, value FROM records WHERE collection = ? ORDER BY rkey ${order} LIMIT ?`;
    const params = [collection, limit + 1];

    if (cursor) {
      query = `SELECT uri, cid, value FROM records WHERE collection = ? AND rkey ${reverse ? '<' : '>'} ? ORDER BY rkey ${order} LIMIT ?`;
      params.splice(1, 0, cursor);
    }

    const rows = [...this.sql.exec(query, ...params)];
    const hasMore = rows.length > limit;
    const records = rows.slice(0, limit).map(r => ({
      uri: r.uri,
      cid: r.cid,
      value: new Uint8Array(r.value),
    }));

    return {
      records,
      cursor: hasMore ? records[records.length - 1].uri.split('/').pop() : undefined,
    };
  }

  async deleteRecord(uri) {
    this.sql.exec('DELETE FROM records WHERE uri = ?', uri);
  }

  async getLatestCommit() {
    const row = this.sql.exec('SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1').one();
    return row ? { cid: row.cid, rev: row.rev } : null;
  }

  async saveCommit(cid, rev, prev) {
    this.sql.exec(
      'INSERT INTO commits (cid, rev, prev) VALUES (?, ?, ?)',
      cid, rev, prev
    );
    return this.sql.exec('SELECT last_insert_rowid() as seq').one().seq;
  }
}

Step 3: Run tests

Run: npm test Expected: All tests pass

Step 4: Run e2e tests to verify integration

Run: npm run test:e2e Expected: All tests pass

Step 5: Commit

git add src/cloudflare.js test/cloudflare-repository.test.js
git commit -m "feat: implement CloudflareRepository"

Task 11: Implement CloudflareBlobStore

Files:

  • Modify: src/cloudflare.js

Step 1: Implement CloudflareBlobStore

Add to src/cloudflare.js:

/**
 * BlobStore implementation backed by Cloudflare R2.
 */
export class CloudflareBlobStore {
  /**
   * @param {R2Bucket} bucket
   * @param {DurableObjectState['storage']['sql']} sql - For blob metadata
   */
  constructor(bucket, sql) {
    this.bucket = bucket;
    this.sql = sql;
  }

  async putBlob(cid, data, mimeType) {
    await this.bucket.put(cid, data, {
      httpMetadata: { contentType: mimeType },
    });
    this.sql.exec(
      'INSERT OR REPLACE INTO blobs (cid, mime_type, size, created_at) VALUES (?, ?, ?, ?)',
      cid, mimeType, data.length, new Date().toISOString()
    );
  }

  async getBlob(cid) {
    const object = await this.bucket.get(cid);
    if (!object) return null;

    const data = new Uint8Array(await object.arrayBuffer());
    const mimeType = object.httpMetadata?.contentType || 'application/octet-stream';
    return { data, mimeType };
  }

  async deleteBlob(cid) {
    await this.bucket.delete(cid);
    this.sql.exec('DELETE FROM blobs WHERE cid = ?', cid);
  }

  async hasBlob(cid) {
    const head = await this.bucket.head(cid);
    return head !== null;
  }
}

Step 2: Verify syntax

Run: node --check src/cloudflare.js Expected: No output (success)

Step 3: Run e2e tests

Run: npm run test:e2e Expected: All tests pass

Step 4: Commit

git add src/cloudflare.js
git commit -m "feat: implement CloudflareBlobStore"

Task 12: Implement CloudflareAuthProvider

Files:

  • Modify: src/cloudflare.js

Step 1: Implement CloudflareAuthProvider

Add to src/cloudflare.js:

import { createAccessJwt, createRefreshJwt, verifyAccessJwt, verifyRefreshJwt } from './pds.js';

/**
 * AuthProvider implementation backed by Durable Object SQLite.
 */
export class CloudflareAuthProvider {
  /**
   * @param {DurableObjectState['storage']['sql']} sql
   * @param {string} jwtSecret
   */
  constructor(sql, jwtSecret) {
    this.sql = sql;
    this.jwtSecret = jwtSecret;
  }

  async createSession(did) {
    const accessJwt = await createAccessJwt(did, this.jwtSecret);
    const refreshJwt = await createRefreshJwt(did, this.jwtSecret);
    return { accessJwt, refreshJwt };
  }

  async getSession(accessJwt) {
    try {
      const payload = await verifyAccessJwt(accessJwt, this.jwtSecret);
      return { did: payload.sub };
    } catch {
      return null;
    }
  }

  async deleteSession(refreshJwt) {
    // Sessions are stateless JWTs - nothing to delete
    // Could add to a revocation list if needed
  }

  async storeAuthorizationRequest(id, request) {
    this.sql.exec(
      `INSERT INTO authorization_requests
       (id, client_id, client_metadata, parameters, code_challenge, code_challenge_method, dpop_jkt, expires_at, created_at)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      id,
      request.clientId,
      JSON.stringify(request.clientMetadata || {}),
      JSON.stringify(request.parameters || {}),
      request.codeChallenge || null,
      request.codeChallengeMethod || null,
      request.dpopJkt || null,
      request.expiresAt || new Date(Date.now() + 600000).toISOString(),
      new Date().toISOString()
    );
  }

  async getAuthorizationRequest(id) {
    const row = this.sql.exec('SELECT * FROM authorization_requests WHERE id = ?', id).one();
    if (!row) return null;

    return {
      id: row.id,
      clientId: row.client_id,
      clientMetadata: JSON.parse(row.client_metadata),
      parameters: JSON.parse(row.parameters),
      code: row.code,
      codeChallenge: row.code_challenge,
      codeChallengeMethod: row.code_challenge_method,
      dpopJkt: row.dpop_jkt,
      did: row.did,
      expiresAt: row.expires_at,
      createdAt: row.created_at,
    };
  }

  async storeToken(tokenId, token) {
    this.sql.exec(
      `INSERT INTO tokens
       (token_id, did, client_id, scope, dpop_jkt, expires_at, refresh_token, created_at, updated_at)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      tokenId,
      token.did,
      token.clientId,
      token.scope || null,
      token.dpopJkt || null,
      token.expiresAt,
      token.refreshToken || null,
      new Date().toISOString(),
      new Date().toISOString()
    );
  }

  async getToken(tokenId) {
    const row = this.sql.exec('SELECT * FROM tokens WHERE token_id = ?', tokenId).one();
    if (!row) return null;

    return {
      tokenId: row.token_id,
      did: row.did,
      clientId: row.client_id,
      scope: row.scope,
      dpopJkt: row.dpop_jkt,
      expiresAt: row.expires_at,
      refreshToken: row.refresh_token,
    };
  }

  async revokeToken(tokenId) {
    this.sql.exec('DELETE FROM tokens WHERE token_id = ?', tokenId);
  }
}

Step 2: Verify syntax

Run: node --check src/cloudflare.js Expected: No output (success)

Step 3: Run e2e tests

Run: npm run test:e2e Expected: All tests pass

Step 4: Commit

git add src/cloudflare.js
git commit -m "feat: implement CloudflareAuthProvider"

Task 13: Wire Up createPdsHandler with Route Handlers

Files:

  • Modify: src/pds.js
  • Modify: test/pds-core.test.js

Step 1: Write integration test for handler routing

Add to test/pds-core.test.js:

describe('createPdsHandler routing', () => {
  it('handles .well-known/atproto-did', async () => {
    const handler = createPdsHandler({
      did: 'did:plc:testuser123',
      hostname: 'test.pds.example',
      repository: new MockRepository(),
      blobStore: new MockBlobStore(),
      authProvider: new MockAuthProvider(),
    });

    const request = new Request('https://test.pds.example/.well-known/atproto-did');
    const response = await handler(request);

    expect(response.status).toBe(200);
    const text = await response.text();
    expect(text).toBe('did:plc:testuser123');
  });

  it('handles xrpc/com.atproto.server.describeServer', async () => {
    const handler = createPdsHandler({
      did: 'did:plc:testuser123',
      hostname: 'test.pds.example',
      repository: new MockRepository(),
      blobStore: new MockBlobStore(),
      authProvider: new MockAuthProvider(),
    });

    const request = new Request('https://test.pds.example/xrpc/com.atproto.server.describeServer');
    const response = await handler(request);

    expect(response.status).toBe(200);
    const json = await response.json();
    expect(json.did).toBe('did:plc:testuser123');
    expect(json.availableUserDomains).toContain('test.pds.example');
  });
});

Step 2: Run tests to verify they fail

Run: npm test -- test/pds-core.test.js Expected: FAIL (handler returns 501 stub)

Step 3: Implement basic routing in createPdsHandler

This is a larger refactor - the key insight is to make route handlers accept dependencies as parameters rather than accessing this. Start with the simplest routes:

Update createPdsHandler in src/pds.js:

export function createPdsHandler(config) {
  const { did, hostname, repository, blobStore, authProvider } = config;

  return async function handleRequest(request) {
    const url = new URL(request.url);
    const path = url.pathname;

    // .well-known/atproto-did
    if (path === '/.well-known/atproto-did') {
      return new Response(did, {
        headers: { 'Content-Type': 'text/plain' },
      });
    }

    // com.atproto.server.describeServer
    if (path === '/xrpc/com.atproto.server.describeServer') {
      return Response.json({
        did,
        availableUserDomains: [hostname],
        inviteCodeRequired: false,
        phoneVerificationRequired: false,
        links: {},
      });
    }

    return new Response('Not Found', { status: 404 });
  };
}

Step 4: Run tests

Run: npm test -- test/pds-core.test.js Expected: PASS

Step 5: Commit

git add src/pds.js test/pds-core.test.js
git commit -m "feat: wire up basic routes in createPdsHandler"

Task 14: Add Core Handler Tests for Repo Operations

Files:

  • Modify: test/pds-core.test.js

Step 1: Write describeRepo test

Add to test/pds-core.test.js:

describe('createPdsHandler repo operations', () => {
  it('handles com.atproto.repo.describeRepo', async () => {
    const repo = new MockRepository();
    // Seed with a commit
    await repo.saveCommit('bafyreiabc', 'tid123', null);

    const handler = createPdsHandler({
      did: 'did:plc:testuser123',
      hostname: 'test.pds.example',
      repository: repo,
      blobStore: new MockBlobStore(),
      authProvider: new MockAuthProvider(),
    });

    const request = new Request('https://test.pds.example/xrpc/com.atproto.repo.describeRepo?repo=did:plc:testuser123');
    const response = await handler(request);

    expect(response.status).toBe(200);
    const json = await response.json();
    expect(json.did).toBe('did:plc:testuser123');
    expect(json.handle).toBe('test.pds.example');
  });
});

Step 2: Run tests to see failure

Run: npm test -- test/pds-core.test.js Expected: FAIL (route not implemented)

Step 3: This identifies the next route to implement

Continue adding routes to createPdsHandler following the same pattern. Each route handler that currently uses this.sql or this.env needs to be refactored to use the injected repository, blobStore, and authProvider.

Step 4: Commit progress

git add test/pds-core.test.js
git commit -m "test: add repo operation tests for createPdsHandler"

Task 15: Run Full Test Suite and Measure Coverage

Files:

  • None (verification only)

Step 1: Run unit tests with coverage

Run: npm run test:coverage Expected: Coverage report showing improvement from pure function tests

Step 2: Run e2e tests

Run: npm run test:e2e Expected: All e2e tests pass

Step 3: Review coverage gaps

The coverage report will show which code paths still need tests. The goal is:

  • Pure functions (atproto.js): High coverage via unit tests
  • Route handlers (pds.js): Growing coverage via createPdsHandler tests
  • Cloudflare adapters (cloudflare.js): Coverage via e2e tests

Step 4: Document coverage baseline

Record current coverage numbers for comparison as more handlers are migrated.

Step 5: Commit

git add .
git commit -m "chore: verify test coverage after core separation setup"

Summary

This plan establishes the foundation for separating PDS core logic from Cloudflare:

  1. Tasks 1-6: Create Repository, BlobStore, and AuthProvider interfaces with mock implementations
  2. Tasks 7-8: Create createPdsHandler factory and extract pure utilities to atproto.js
  3. Tasks 9-12: Create cloudflare.js adapter with Cloudflare-specific implementations
  4. Tasks 13-14: Wire up route handlers to use dependency injection
  5. Task 15: Verify coverage improvements

After completing these tasks, you'll have:

  • A testable core (createPdsHandler) that works with mock dependencies
  • Pure ATProto utilities (atproto.js) fully unit-testable
  • Cloudflare adapter (cloudflare.js) as thin entry point
  • Foundation for incrementally migrating remaining route handlers

Future work (not in this plan):

  • Migrate all route handlers to use injected dependencies
  • Move more logic from PersonalDataServer methods to pure functions
  • Add more createPdsHandler tests for each route