diff --git a/docs/plans/2026-01-09-pds-core-separation.md b/docs/plans/2026-01-09-pds-core-separation.md new file mode 100644 index 0000000..3d88bbb --- /dev/null +++ b/docs/plans/2026-01-09-pds-core-separation.md @@ -0,0 +1,1416 @@ +# 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** + +```javascript +// 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** + +```javascript +// src/repository.js +/** + * Repository interface for PDS storage operations. + * @typedef {Object} Repository + * @property {(cid: string, data: Uint8Array) => Promise} putBlock + * @property {(cid: string) => Promise} getBlock + * @property {(cid: string) => Promise} hasBlock + * @property {(uri: string, cid: string, collection: string, rkey: string, value: Uint8Array) => Promise} 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} deleteRecord + * @property {() => Promise<{cid: string, rev: string}|null>} getLatestCommit + * @property {(cid: string, rev: string, prev: string|null) => Promise} 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** + +```bash +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`: + +```javascript +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`: + +```javascript +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** + +```bash +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`: + +```javascript +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** + +```bash +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`: + +```javascript +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`: + +```javascript +/** + * BlobStore interface for binary large object storage. + * @typedef {Object} BlobStore + * @property {(cid: string, data: Uint8Array, mimeType: string) => Promise} putBlob + * @property {(cid: string) => Promise<{data: Uint8Array, mimeType: string}|null>} getBlob + * @property {(cid: string) => Promise} deleteBlob + * @property {(cid: string) => Promise} 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** + +```bash +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** + +```javascript +// 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** + +```javascript +// 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} deleteSession + * @property {(id: string, request: Object) => Promise} storeAuthorizationRequest + * @property {(id: string) => Promise} getAuthorizationRequest + * @property {(tokenId: string, token: Object) => Promise} storeToken + * @property {(tokenId: string) => Promise} getToken + * @property {(tokenId: string) => Promise} 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** + +```bash +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`: + +```javascript +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`: + +```javascript +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** + +```bash +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** + +```javascript +// 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`): + +```javascript +/** + * 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} + */ +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** + +```bash +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** + +```javascript +// 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** + +```javascript +// 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** + +```bash +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** + +```javascript +// 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: +```toml +main = "src/pds.js" +``` + +To: +```toml +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** + +```bash +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** + +```javascript +// 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`: + +```javascript +/** + * 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** + +```bash +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`: + +```javascript +/** + * 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** + +```bash +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`: + +```javascript +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** + +```bash +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`: + +```javascript +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`: + +```javascript +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** + +```bash +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`: + +```javascript +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** + +```bash +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** + +```bash +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