# 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