A minimal AT Protocol Personal Data Server written in JavaScript.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: add PDS core separation implementation plan

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+1416
+1416
docs/plans/2026-01-09-pds-core-separation.md
··· 1 + # PDS Core Separation Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Separate pure PDS logic from Cloudflare-specific code to enable full unit test coverage and future portability. 6 + 7 + **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. 8 + 9 + **Tech Stack:** JavaScript ES modules, Vitest for testing, Cloudflare Workers/Durable Objects/R2 for production. 10 + 11 + --- 12 + 13 + ### Task 1: Define Repository Interface 14 + 15 + **Files:** 16 + - Create: `src/repository.js` 17 + - Test: `test/repository.test.js` 18 + 19 + **Step 1: Write the interface test** 20 + 21 + ```javascript 22 + // test/repository.test.js 23 + import { describe, it, expect } from 'vitest'; 24 + import { MockRepository } from '../src/repository.js'; 25 + 26 + describe('MockRepository', () => { 27 + it('implements all required methods', () => { 28 + const repo = new MockRepository(); 29 + expect(typeof repo.putBlock).toBe('function'); 30 + expect(typeof repo.getBlock).toBe('function'); 31 + expect(typeof repo.hasBlock).toBe('function'); 32 + expect(typeof repo.indexRecord).toBe('function'); 33 + expect(typeof repo.getRecord).toBe('function'); 34 + expect(typeof repo.listRecords).toBe('function'); 35 + expect(typeof repo.deleteRecord).toBe('function'); 36 + expect(typeof repo.getLatestCommit).toBe('function'); 37 + expect(typeof repo.saveCommit).toBe('function'); 38 + }); 39 + }); 40 + ``` 41 + 42 + **Step 2: Run test to verify it fails** 43 + 44 + Run: `npm test -- test/repository.test.js` 45 + Expected: FAIL with "Cannot find module '../src/repository.js'" 46 + 47 + **Step 3: Write minimal implementation** 48 + 49 + ```javascript 50 + // src/repository.js 51 + /** 52 + * Repository interface for PDS storage operations. 53 + * @typedef {Object} Repository 54 + * @property {(cid: string, data: Uint8Array) => Promise<void>} putBlock 55 + * @property {(cid: string) => Promise<Uint8Array|null>} getBlock 56 + * @property {(cid: string) => Promise<boolean>} hasBlock 57 + * @property {(uri: string, cid: string, collection: string, rkey: string, value: Uint8Array) => Promise<void>} indexRecord 58 + * @property {(uri: string) => Promise<{cid: string, value: Uint8Array}|null>} getRecord 59 + * @property {(collection: string, options?: {limit?: number, cursor?: string, reverse?: boolean}) => Promise<{records: Array<{uri: string, cid: string, value: Uint8Array}>, cursor?: string}>} listRecords 60 + * @property {(uri: string) => Promise<void>} deleteRecord 61 + * @property {() => Promise<{cid: string, rev: string}|null>} getLatestCommit 62 + * @property {(cid: string, rev: string, prev: string|null) => Promise<number>} saveCommit 63 + */ 64 + 65 + /** 66 + * In-memory mock repository for testing. 67 + */ 68 + export class MockRepository { 69 + constructor() { 70 + this.blocks = new Map(); 71 + this.records = new Map(); 72 + this.commits = []; 73 + } 74 + 75 + async putBlock(cid, data) { 76 + this.blocks.set(cid, data); 77 + } 78 + 79 + async getBlock(cid) { 80 + return this.blocks.get(cid) || null; 81 + } 82 + 83 + async hasBlock(cid) { 84 + return this.blocks.has(cid); 85 + } 86 + 87 + async indexRecord(uri, cid, collection, rkey, value) { 88 + this.records.set(uri, { cid, collection, rkey, value }); 89 + } 90 + 91 + async getRecord(uri) { 92 + const record = this.records.get(uri); 93 + return record ? { cid: record.cid, value: record.value } : null; 94 + } 95 + 96 + async listRecords(collection, options = {}) { 97 + const { limit = 50, cursor, reverse = false } = options; 98 + let records = [...this.records.entries()] 99 + .filter(([, r]) => r.collection === collection) 100 + .map(([uri, r]) => ({ uri, cid: r.cid, value: r.value })); 101 + 102 + if (reverse) records.reverse(); 103 + if (cursor) { 104 + const idx = records.findIndex(r => r.uri === cursor); 105 + if (idx >= 0) records = records.slice(idx + 1); 106 + } 107 + 108 + return { 109 + records: records.slice(0, limit), 110 + cursor: records.length > limit ? records[limit - 1].uri : undefined, 111 + }; 112 + } 113 + 114 + async deleteRecord(uri) { 115 + this.records.delete(uri); 116 + } 117 + 118 + async getLatestCommit() { 119 + if (this.commits.length === 0) return null; 120 + const last = this.commits[this.commits.length - 1]; 121 + return { cid: last.cid, rev: last.rev }; 122 + } 123 + 124 + async saveCommit(cid, rev, prev) { 125 + const seq = this.commits.length + 1; 126 + this.commits.push({ cid, rev, prev, seq }); 127 + return seq; 128 + } 129 + } 130 + ``` 131 + 132 + **Step 4: Run test to verify it passes** 133 + 134 + Run: `npm test -- test/repository.test.js` 135 + Expected: PASS 136 + 137 + **Step 5: Commit** 138 + 139 + ```bash 140 + git add src/repository.js test/repository.test.js 141 + git commit -m "feat: add Repository interface and MockRepository" 142 + ``` 143 + 144 + --- 145 + 146 + ### Task 2: Add MockRepository CRUD Tests 147 + 148 + **Files:** 149 + - Modify: `test/repository.test.js` 150 + 151 + **Step 1: Write block storage tests** 152 + 153 + Add to `test/repository.test.js`: 154 + 155 + ```javascript 156 + describe('MockRepository block storage', () => { 157 + it('stores and retrieves blocks', async () => { 158 + const repo = new MockRepository(); 159 + const data = new Uint8Array([1, 2, 3, 4]); 160 + 161 + await repo.putBlock('cid123', data); 162 + const retrieved = await repo.getBlock('cid123'); 163 + 164 + expect(retrieved).toEqual(data); 165 + }); 166 + 167 + it('returns null for missing blocks', async () => { 168 + const repo = new MockRepository(); 169 + expect(await repo.getBlock('missing')).toBeNull(); 170 + }); 171 + 172 + it('checks block existence', async () => { 173 + const repo = new MockRepository(); 174 + await repo.putBlock('cid123', new Uint8Array([1])); 175 + 176 + expect(await repo.hasBlock('cid123')).toBe(true); 177 + expect(await repo.hasBlock('missing')).toBe(false); 178 + }); 179 + }); 180 + ``` 181 + 182 + **Step 2: Run tests** 183 + 184 + Run: `npm test -- test/repository.test.js` 185 + Expected: PASS 186 + 187 + **Step 3: Write record indexing tests** 188 + 189 + Add to `test/repository.test.js`: 190 + 191 + ```javascript 192 + describe('MockRepository record indexing', () => { 193 + it('indexes and retrieves records', async () => { 194 + const repo = new MockRepository(); 195 + const value = new Uint8Array([5, 6, 7]); 196 + 197 + await repo.indexRecord('at://did:plc:test/app.bsky.feed.post/abc', 'cid456', 'app.bsky.feed.post', 'abc', value); 198 + const record = await repo.getRecord('at://did:plc:test/app.bsky.feed.post/abc'); 199 + 200 + expect(record).toEqual({ cid: 'cid456', value }); 201 + }); 202 + 203 + it('returns null for missing records', async () => { 204 + const repo = new MockRepository(); 205 + expect(await repo.getRecord('at://missing')).toBeNull(); 206 + }); 207 + 208 + it('lists records by collection', async () => { 209 + const repo = new MockRepository(); 210 + await repo.indexRecord('at://did/col1/a', 'c1', 'col1', 'a', new Uint8Array([1])); 211 + await repo.indexRecord('at://did/col2/b', 'c2', 'col2', 'b', new Uint8Array([2])); 212 + await repo.indexRecord('at://did/col1/c', 'c3', 'col1', 'c', new Uint8Array([3])); 213 + 214 + const result = await repo.listRecords('col1'); 215 + expect(result.records.length).toBe(2); 216 + expect(result.records.every(r => r.uri.includes('col1'))).toBe(true); 217 + }); 218 + 219 + it('deletes records', async () => { 220 + const repo = new MockRepository(); 221 + await repo.indexRecord('at://did/col/a', 'c1', 'col', 'a', new Uint8Array([1])); 222 + 223 + await repo.deleteRecord('at://did/col/a'); 224 + expect(await repo.getRecord('at://did/col/a')).toBeNull(); 225 + }); 226 + }); 227 + ``` 228 + 229 + **Step 4: Run tests** 230 + 231 + Run: `npm test -- test/repository.test.js` 232 + Expected: PASS 233 + 234 + **Step 5: Commit** 235 + 236 + ```bash 237 + git add test/repository.test.js 238 + git commit -m "test: add MockRepository CRUD tests" 239 + ``` 240 + 241 + --- 242 + 243 + ### Task 3: Add Commit Tracking Tests 244 + 245 + **Files:** 246 + - Modify: `test/repository.test.js` 247 + 248 + **Step 1: Write commit tracking tests** 249 + 250 + Add to `test/repository.test.js`: 251 + 252 + ```javascript 253 + describe('MockRepository commit tracking', () => { 254 + it('returns null when no commits exist', async () => { 255 + const repo = new MockRepository(); 256 + expect(await repo.getLatestCommit()).toBeNull(); 257 + }); 258 + 259 + it('saves and retrieves commits', async () => { 260 + const repo = new MockRepository(); 261 + 262 + const seq1 = await repo.saveCommit('cid1', 'rev1', null); 263 + expect(seq1).toBe(1); 264 + 265 + const seq2 = await repo.saveCommit('cid2', 'rev2', 'cid1'); 266 + expect(seq2).toBe(2); 267 + 268 + const latest = await repo.getLatestCommit(); 269 + expect(latest).toEqual({ cid: 'cid2', rev: 'rev2' }); 270 + }); 271 + }); 272 + ``` 273 + 274 + **Step 2: Run tests** 275 + 276 + Run: `npm test -- test/repository.test.js` 277 + Expected: PASS 278 + 279 + **Step 3: Commit** 280 + 281 + ```bash 282 + git add test/repository.test.js 283 + git commit -m "test: add commit tracking tests" 284 + ``` 285 + 286 + --- 287 + 288 + ### Task 4: Define BlobStore Interface 289 + 290 + **Files:** 291 + - Modify: `src/repository.js` 292 + - Modify: `test/repository.test.js` 293 + 294 + **Step 1: Write BlobStore interface tests** 295 + 296 + Add to `test/repository.test.js`: 297 + 298 + ```javascript 299 + import { MockRepository, MockBlobStore } from '../src/repository.js'; 300 + 301 + describe('MockBlobStore', () => { 302 + it('implements all required methods', () => { 303 + const store = new MockBlobStore(); 304 + expect(typeof store.putBlob).toBe('function'); 305 + expect(typeof store.getBlob).toBe('function'); 306 + expect(typeof store.deleteBlob).toBe('function'); 307 + expect(typeof store.hasBlob).toBe('function'); 308 + }); 309 + 310 + it('stores and retrieves blobs', async () => { 311 + const store = new MockBlobStore(); 312 + const data = new Uint8Array([1, 2, 3, 4, 5]); 313 + 314 + await store.putBlob('blobcid123', data, 'image/png'); 315 + const result = await store.getBlob('blobcid123'); 316 + 317 + expect(result).toEqual({ data, mimeType: 'image/png' }); 318 + }); 319 + 320 + it('returns null for missing blobs', async () => { 321 + const store = new MockBlobStore(); 322 + expect(await store.getBlob('missing')).toBeNull(); 323 + }); 324 + 325 + it('deletes blobs', async () => { 326 + const store = new MockBlobStore(); 327 + await store.putBlob('blobcid', new Uint8Array([1]), 'text/plain'); 328 + 329 + await store.deleteBlob('blobcid'); 330 + expect(await store.getBlob('blobcid')).toBeNull(); 331 + }); 332 + 333 + it('checks blob existence', async () => { 334 + const store = new MockBlobStore(); 335 + await store.putBlob('exists', new Uint8Array([1]), 'text/plain'); 336 + 337 + expect(await store.hasBlob('exists')).toBe(true); 338 + expect(await store.hasBlob('missing')).toBe(false); 339 + }); 340 + }); 341 + ``` 342 + 343 + **Step 2: Run tests to verify they fail** 344 + 345 + Run: `npm test -- test/repository.test.js` 346 + Expected: FAIL with "MockBlobStore is not exported" 347 + 348 + **Step 3: Implement MockBlobStore** 349 + 350 + Add to `src/repository.js`: 351 + 352 + ```javascript 353 + /** 354 + * BlobStore interface for binary large object storage. 355 + * @typedef {Object} BlobStore 356 + * @property {(cid: string, data: Uint8Array, mimeType: string) => Promise<void>} putBlob 357 + * @property {(cid: string) => Promise<{data: Uint8Array, mimeType: string}|null>} getBlob 358 + * @property {(cid: string) => Promise<void>} deleteBlob 359 + * @property {(cid: string) => Promise<boolean>} hasBlob 360 + */ 361 + 362 + /** 363 + * In-memory mock blob store for testing. 364 + */ 365 + export class MockBlobStore { 366 + constructor() { 367 + this.blobs = new Map(); 368 + } 369 + 370 + async putBlob(cid, data, mimeType) { 371 + this.blobs.set(cid, { data, mimeType }); 372 + } 373 + 374 + async getBlob(cid) { 375 + return this.blobs.get(cid) || null; 376 + } 377 + 378 + async deleteBlob(cid) { 379 + this.blobs.delete(cid); 380 + } 381 + 382 + async hasBlob(cid) { 383 + return this.blobs.has(cid); 384 + } 385 + } 386 + ``` 387 + 388 + **Step 4: Run tests** 389 + 390 + Run: `npm test -- test/repository.test.js` 391 + Expected: PASS 392 + 393 + **Step 5: Commit** 394 + 395 + ```bash 396 + git add src/repository.js test/repository.test.js 397 + git commit -m "feat: add BlobStore interface and MockBlobStore" 398 + ``` 399 + 400 + --- 401 + 402 + ### Task 5: Define AuthProvider Interface 403 + 404 + **Files:** 405 + - Create: `src/auth.js` 406 + - Create: `test/auth.test.js` 407 + 408 + **Step 1: Write AuthProvider interface tests** 409 + 410 + ```javascript 411 + // test/auth.test.js 412 + import { describe, it, expect } from 'vitest'; 413 + import { MockAuthProvider } from '../src/auth.js'; 414 + 415 + describe('MockAuthProvider', () => { 416 + it('implements all required methods', () => { 417 + const auth = new MockAuthProvider(); 418 + expect(typeof auth.createSession).toBe('function'); 419 + expect(typeof auth.getSession).toBe('function'); 420 + expect(typeof auth.deleteSession).toBe('function'); 421 + expect(typeof auth.storeAuthorizationRequest).toBe('function'); 422 + expect(typeof auth.getAuthorizationRequest).toBe('function'); 423 + expect(typeof auth.storeToken).toBe('function'); 424 + expect(typeof auth.getToken).toBe('function'); 425 + expect(typeof auth.revokeToken).toBe('function'); 426 + }); 427 + }); 428 + ``` 429 + 430 + **Step 2: Run test to verify it fails** 431 + 432 + Run: `npm test -- test/auth.test.js` 433 + Expected: FAIL with "Cannot find module '../src/auth.js'" 434 + 435 + **Step 3: Write minimal implementation** 436 + 437 + ```javascript 438 + // src/auth.js 439 + /** 440 + * AuthProvider interface for session and token management. 441 + * @typedef {Object} AuthProvider 442 + * @property {(did: string) => Promise<{accessJwt: string, refreshJwt: string}>} createSession 443 + * @property {(accessJwt: string) => Promise<{did: string}|null>} getSession 444 + * @property {(refreshJwt: string) => Promise<void>} deleteSession 445 + * @property {(id: string, request: Object) => Promise<void>} storeAuthorizationRequest 446 + * @property {(id: string) => Promise<Object|null>} getAuthorizationRequest 447 + * @property {(tokenId: string, token: Object) => Promise<void>} storeToken 448 + * @property {(tokenId: string) => Promise<Object|null>} getToken 449 + * @property {(tokenId: string) => Promise<void>} revokeToken 450 + */ 451 + 452 + /** 453 + * In-memory mock auth provider for testing. 454 + */ 455 + export class MockAuthProvider { 456 + constructor(jwtSecret = 'test-secret') { 457 + this.jwtSecret = jwtSecret; 458 + this.sessions = new Map(); 459 + this.authRequests = new Map(); 460 + this.tokens = new Map(); 461 + } 462 + 463 + async createSession(did) { 464 + const accessJwt = `access-${did}-${Date.now()}`; 465 + const refreshJwt = `refresh-${did}-${Date.now()}`; 466 + this.sessions.set(accessJwt, { did, refreshJwt }); 467 + return { accessJwt, refreshJwt }; 468 + } 469 + 470 + async getSession(accessJwt) { 471 + const session = this.sessions.get(accessJwt); 472 + return session ? { did: session.did } : null; 473 + } 474 + 475 + async deleteSession(refreshJwt) { 476 + for (const [access, session] of this.sessions) { 477 + if (session.refreshJwt === refreshJwt) { 478 + this.sessions.delete(access); 479 + break; 480 + } 481 + } 482 + } 483 + 484 + async storeAuthorizationRequest(id, request) { 485 + this.authRequests.set(id, request); 486 + } 487 + 488 + async getAuthorizationRequest(id) { 489 + return this.authRequests.get(id) || null; 490 + } 491 + 492 + async storeToken(tokenId, token) { 493 + this.tokens.set(tokenId, token); 494 + } 495 + 496 + async getToken(tokenId) { 497 + return this.tokens.get(tokenId) || null; 498 + } 499 + 500 + async revokeToken(tokenId) { 501 + this.tokens.delete(tokenId); 502 + } 503 + } 504 + ``` 505 + 506 + **Step 4: Run test to verify it passes** 507 + 508 + Run: `npm test -- test/auth.test.js` 509 + Expected: PASS 510 + 511 + **Step 5: Commit** 512 + 513 + ```bash 514 + git add src/auth.js test/auth.test.js 515 + git commit -m "feat: add AuthProvider interface and MockAuthProvider" 516 + ``` 517 + 518 + --- 519 + 520 + ### Task 6: Add MockAuthProvider Functional Tests 521 + 522 + **Files:** 523 + - Modify: `test/auth.test.js` 524 + 525 + **Step 1: Write session management tests** 526 + 527 + Add to `test/auth.test.js`: 528 + 529 + ```javascript 530 + describe('MockAuthProvider sessions', () => { 531 + it('creates and retrieves sessions', async () => { 532 + const auth = new MockAuthProvider(); 533 + 534 + const session = await auth.createSession('did:plc:test123'); 535 + expect(session.accessJwt).toBeDefined(); 536 + expect(session.refreshJwt).toBeDefined(); 537 + 538 + const retrieved = await auth.getSession(session.accessJwt); 539 + expect(retrieved).toEqual({ did: 'did:plc:test123' }); 540 + }); 541 + 542 + it('returns null for invalid session', async () => { 543 + const auth = new MockAuthProvider(); 544 + expect(await auth.getSession('invalid-token')).toBeNull(); 545 + }); 546 + 547 + it('deletes sessions by refresh token', async () => { 548 + const auth = new MockAuthProvider(); 549 + const session = await auth.createSession('did:plc:test'); 550 + 551 + await auth.deleteSession(session.refreshJwt); 552 + expect(await auth.getSession(session.accessJwt)).toBeNull(); 553 + }); 554 + }); 555 + ``` 556 + 557 + **Step 2: Run tests** 558 + 559 + Run: `npm test -- test/auth.test.js` 560 + Expected: PASS 561 + 562 + **Step 3: Write OAuth storage tests** 563 + 564 + Add to `test/auth.test.js`: 565 + 566 + ```javascript 567 + describe('MockAuthProvider OAuth', () => { 568 + it('stores and retrieves authorization requests', async () => { 569 + const auth = new MockAuthProvider(); 570 + const request = { 571 + clientId: 'https://example.com', 572 + scope: 'atproto', 573 + codeChallenge: 'abc123', 574 + }; 575 + 576 + await auth.storeAuthorizationRequest('req-id-1', request); 577 + const retrieved = await auth.getAuthorizationRequest('req-id-1'); 578 + 579 + expect(retrieved).toEqual(request); 580 + }); 581 + 582 + it('returns null for missing auth requests', async () => { 583 + const auth = new MockAuthProvider(); 584 + expect(await auth.getAuthorizationRequest('missing')).toBeNull(); 585 + }); 586 + 587 + it('stores, retrieves, and revokes tokens', async () => { 588 + const auth = new MockAuthProvider(); 589 + const token = { 590 + did: 'did:plc:test', 591 + clientId: 'https://example.com', 592 + scope: 'atproto', 593 + }; 594 + 595 + await auth.storeToken('token-id-1', token); 596 + expect(await auth.getToken('token-id-1')).toEqual(token); 597 + 598 + await auth.revokeToken('token-id-1'); 599 + expect(await auth.getToken('token-id-1')).toBeNull(); 600 + }); 601 + }); 602 + ``` 603 + 604 + **Step 4: Run tests** 605 + 606 + Run: `npm test -- test/auth.test.js` 607 + Expected: PASS 608 + 609 + **Step 5: Commit** 610 + 611 + ```bash 612 + git add test/auth.test.js 613 + git commit -m "test: add MockAuthProvider functional tests" 614 + ``` 615 + 616 + --- 617 + 618 + ### Task 7: Create PDS Core Entry Point 619 + 620 + **Files:** 621 + - Modify: `src/pds.js` (add exports, create handler factory) 622 + - Create: `test/pds-core.test.js` 623 + 624 + **Step 1: Write core entry point test** 625 + 626 + ```javascript 627 + // test/pds-core.test.js 628 + import { describe, it, expect } from 'vitest'; 629 + import { createPdsHandler } from '../src/pds.js'; 630 + import { MockRepository, MockBlobStore } from '../src/repository.js'; 631 + import { MockAuthProvider } from '../src/auth.js'; 632 + 633 + describe('createPdsHandler', () => { 634 + it('returns a request handler function', () => { 635 + const handler = createPdsHandler({ 636 + did: 'did:plc:test', 637 + hostname: 'test.pds.example', 638 + repository: new MockRepository(), 639 + blobStore: new MockBlobStore(), 640 + authProvider: new MockAuthProvider(), 641 + }); 642 + 643 + expect(typeof handler).toBe('function'); 644 + }); 645 + }); 646 + ``` 647 + 648 + **Step 2: Run test to verify it fails** 649 + 650 + Run: `npm test -- test/pds-core.test.js` 651 + Expected: FAIL with "createPdsHandler is not exported" 652 + 653 + **Step 3: Add createPdsHandler export to pds.js** 654 + 655 + Add near the end of `src/pds.js` (before `export default`): 656 + 657 + ```javascript 658 + /** 659 + * Creates a PDS request handler with injected dependencies. 660 + * @param {Object} config 661 + * @param {string} config.did - The DID for this PDS 662 + * @param {string} config.hostname - The hostname for this PDS 663 + * @param {Repository} config.repository - Storage backend 664 + * @param {BlobStore} config.blobStore - Blob storage backend 665 + * @param {AuthProvider} config.authProvider - Auth/session backend 666 + * @returns {(request: Request) => Promise<Response>} 667 + */ 668 + export function createPdsHandler(config) { 669 + const { did, hostname, repository, blobStore, authProvider } = config; 670 + 671 + return async function handleRequest(request) { 672 + // TODO: Wire up to route handlers with injected dependencies 673 + return new Response('PDS handler stub', { status: 501 }); 674 + }; 675 + } 676 + ``` 677 + 678 + **Step 4: Run test to verify it passes** 679 + 680 + Run: `npm test -- test/pds-core.test.js` 681 + Expected: PASS 682 + 683 + **Step 5: Commit** 684 + 685 + ```bash 686 + git add src/pds.js test/pds-core.test.js 687 + git commit -m "feat: add createPdsHandler factory function" 688 + ``` 689 + 690 + --- 691 + 692 + ### Task 8: Extract Pure Functions Module 693 + 694 + **Files:** 695 + - Create: `src/atproto.js` (pure ATProto utilities) 696 + - Modify: `src/pds.js` (re-export from atproto.js) 697 + - Create: `test/atproto.test.js` 698 + 699 + **Step 1: Identify pure functions to extract** 700 + 701 + The following pure functions from `pds.js` (lines 324-1715) should move to `atproto.js`: 702 + - Encoding: `bytesToHex`, `hexToBytes`, `base32Encode`, `base32Decode`, `base64UrlEncode`, `base64UrlDecode` 703 + - CBOR: `cborEncode`, `cborEncodeDagCbor`, `cborDecode`, `varint` 704 + - CID: `createCid`, `createBlobCid`, `cidToString`, `cidToBytes` 705 + - TID: `createTid` 706 + - Crypto: `sign`, `generateKeyPair`, `importPrivateKey`, `computeJwkThumbprint` 707 + - JWT: `createAccessJwt`, `createRefreshJwt`, `verifyAccessJwt`, `verifyRefreshJwt`, `createServiceJwt` 708 + - MST: `getKeyDepth` 709 + - CAR: `buildCarFile` 710 + - MIME: `sniffMimeType`, `findBlobRefs` 711 + 712 + **Step 2: Create atproto.js with encoding utilities** 713 + 714 + ```javascript 715 + // src/atproto.js 716 + // Pure ATProto utilities - no platform dependencies 717 + 718 + // Re-export from pds.js for now (will move implementations later) 719 + export { 720 + bytesToHex, 721 + hexToBytes, 722 + base32Encode, 723 + base32Decode, 724 + base64UrlEncode, 725 + base64UrlDecode, 726 + cborEncode, 727 + cborEncodeDagCbor, 728 + cborDecode, 729 + varint, 730 + createCid, 731 + createBlobCid, 732 + cidToString, 733 + cidToBytes, 734 + createTid, 735 + sign, 736 + generateKeyPair, 737 + importPrivateKey, 738 + computeJwkThumbprint, 739 + createAccessJwt, 740 + createRefreshJwt, 741 + verifyAccessJwt, 742 + verifyRefreshJwt, 743 + createServiceJwt, 744 + getKeyDepth, 745 + buildCarFile, 746 + sniffMimeType, 747 + findBlobRefs, 748 + } from './pds.js'; 749 + ``` 750 + 751 + **Step 3: Write test for atproto exports** 752 + 753 + ```javascript 754 + // test/atproto.test.js 755 + import { describe, it, expect } from 'vitest'; 756 + import * as atproto from '../src/atproto.js'; 757 + 758 + describe('atproto exports', () => { 759 + it('exports encoding utilities', () => { 760 + expect(typeof atproto.bytesToHex).toBe('function'); 761 + expect(typeof atproto.hexToBytes).toBe('function'); 762 + expect(typeof atproto.base32Encode).toBe('function'); 763 + expect(typeof atproto.base32Decode).toBe('function'); 764 + expect(typeof atproto.base64UrlEncode).toBe('function'); 765 + expect(typeof atproto.base64UrlDecode).toBe('function'); 766 + }); 767 + 768 + it('exports CBOR utilities', () => { 769 + expect(typeof atproto.cborEncode).toBe('function'); 770 + expect(typeof atproto.cborEncodeDagCbor).toBe('function'); 771 + expect(typeof atproto.cborDecode).toBe('function'); 772 + }); 773 + 774 + it('exports CID utilities', () => { 775 + expect(typeof atproto.createCid).toBe('function'); 776 + expect(typeof atproto.createBlobCid).toBe('function'); 777 + expect(typeof atproto.cidToString).toBe('function'); 778 + expect(typeof atproto.cidToBytes).toBe('function'); 779 + }); 780 + 781 + it('exports crypto utilities', () => { 782 + expect(typeof atproto.sign).toBe('function'); 783 + expect(typeof atproto.generateKeyPair).toBe('function'); 784 + expect(typeof atproto.importPrivateKey).toBe('function'); 785 + }); 786 + 787 + it('exports JWT utilities', () => { 788 + expect(typeof atproto.createAccessJwt).toBe('function'); 789 + expect(typeof atproto.verifyAccessJwt).toBe('function'); 790 + expect(typeof atproto.createServiceJwt).toBe('function'); 791 + }); 792 + }); 793 + ``` 794 + 795 + **Step 4: Run tests** 796 + 797 + Run: `npm test -- test/atproto.test.js` 798 + Expected: PASS 799 + 800 + **Step 5: Commit** 801 + 802 + ```bash 803 + git add src/atproto.js test/atproto.test.js 804 + git commit -m "feat: create atproto.js pure utilities module" 805 + ``` 806 + 807 + --- 808 + 809 + ### Task 9: Create Cloudflare Adapter Skeleton 810 + 811 + **Files:** 812 + - Create: `src/cloudflare.js` 813 + - Modify: `wrangler.toml` (update main entry point) 814 + 815 + **Step 1: Create cloudflare.js adapter** 816 + 817 + ```javascript 818 + // src/cloudflare.js 819 + // Cloudflare-specific adapter implementing Repository, BlobStore, and AuthProvider 820 + import { PersonalDataServer } from './pds.js'; 821 + 822 + // Re-export the Durable Object class for Cloudflare 823 + export { PersonalDataServer }; 824 + 825 + // Re-export the default fetch handler 826 + export { default } from './pds.js'; 827 + ``` 828 + 829 + **Step 2: Verify syntax** 830 + 831 + Run: `node --check src/cloudflare.js` 832 + Expected: No output (success) 833 + 834 + **Step 3: Update wrangler.toml to use cloudflare.js** 835 + 836 + Change: 837 + ```toml 838 + main = "src/pds.js" 839 + ``` 840 + 841 + To: 842 + ```toml 843 + main = "src/cloudflare.js" 844 + ``` 845 + 846 + **Step 4: Run e2e tests to verify nothing broke** 847 + 848 + Run: `npm run test:e2e` 849 + Expected: All tests pass 850 + 851 + **Step 5: Commit** 852 + 853 + ```bash 854 + git add src/cloudflare.js wrangler.toml 855 + git commit -m "feat: create cloudflare.js adapter entry point" 856 + ``` 857 + 858 + --- 859 + 860 + ### Task 10: Implement CloudflareRepository 861 + 862 + **Files:** 863 + - Modify: `src/cloudflare.js` 864 + - Create: `test/cloudflare-repository.test.js` 865 + 866 + **Step 1: Write CloudflareRepository test** 867 + 868 + ```javascript 869 + // test/cloudflare-repository.test.js 870 + import { describe, it, expect } from 'vitest'; 871 + 872 + // Note: This test verifies interface compliance only 873 + // Full integration testing happens in e2e tests 874 + 875 + describe('CloudflareRepository interface', () => { 876 + it('should be tested via e2e tests with real Durable Objects', () => { 877 + // CloudflareRepository requires actual Cloudflare runtime 878 + // This is a placeholder to document that testing strategy 879 + expect(true).toBe(true); 880 + }); 881 + }); 882 + ``` 883 + 884 + **Step 2: Implement CloudflareRepository in cloudflare.js** 885 + 886 + Add to `src/cloudflare.js`: 887 + 888 + ```javascript 889 + /** 890 + * Repository implementation backed by Durable Object SQLite storage. 891 + */ 892 + export class CloudflareRepository { 893 + /** 894 + * @param {DurableObjectState['storage']['sql']} sql 895 + */ 896 + constructor(sql) { 897 + this.sql = sql; 898 + } 899 + 900 + async putBlock(cid, data) { 901 + this.sql.exec( 902 + 'INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)', 903 + cid, 904 + data 905 + ); 906 + } 907 + 908 + async getBlock(cid) { 909 + const row = this.sql.exec('SELECT data FROM blocks WHERE cid = ?', cid).one(); 910 + return row ? new Uint8Array(row.data) : null; 911 + } 912 + 913 + async hasBlock(cid) { 914 + const row = this.sql.exec('SELECT 1 FROM blocks WHERE cid = ?', cid).one(); 915 + return row !== null; 916 + } 917 + 918 + async indexRecord(uri, cid, collection, rkey, value) { 919 + this.sql.exec( 920 + 'INSERT OR REPLACE INTO records (uri, cid, collection, rkey, value) VALUES (?, ?, ?, ?, ?)', 921 + uri, cid, collection, rkey, value 922 + ); 923 + } 924 + 925 + async getRecord(uri) { 926 + const row = this.sql.exec('SELECT cid, value FROM records WHERE uri = ?', uri).one(); 927 + return row ? { cid: row.cid, value: new Uint8Array(row.value) } : null; 928 + } 929 + 930 + async listRecords(collection, options = {}) { 931 + const { limit = 50, cursor, reverse = false } = options; 932 + const order = reverse ? 'DESC' : 'ASC'; 933 + let query = `SELECT uri, cid, value FROM records WHERE collection = ? ORDER BY rkey ${order} LIMIT ?`; 934 + const params = [collection, limit + 1]; 935 + 936 + if (cursor) { 937 + query = `SELECT uri, cid, value FROM records WHERE collection = ? AND rkey ${reverse ? '<' : '>'} ? ORDER BY rkey ${order} LIMIT ?`; 938 + params.splice(1, 0, cursor); 939 + } 940 + 941 + const rows = [...this.sql.exec(query, ...params)]; 942 + const hasMore = rows.length > limit; 943 + const records = rows.slice(0, limit).map(r => ({ 944 + uri: r.uri, 945 + cid: r.cid, 946 + value: new Uint8Array(r.value), 947 + })); 948 + 949 + return { 950 + records, 951 + cursor: hasMore ? records[records.length - 1].uri.split('/').pop() : undefined, 952 + }; 953 + } 954 + 955 + async deleteRecord(uri) { 956 + this.sql.exec('DELETE FROM records WHERE uri = ?', uri); 957 + } 958 + 959 + async getLatestCommit() { 960 + const row = this.sql.exec('SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1').one(); 961 + return row ? { cid: row.cid, rev: row.rev } : null; 962 + } 963 + 964 + async saveCommit(cid, rev, prev) { 965 + this.sql.exec( 966 + 'INSERT INTO commits (cid, rev, prev) VALUES (?, ?, ?)', 967 + cid, rev, prev 968 + ); 969 + return this.sql.exec('SELECT last_insert_rowid() as seq').one().seq; 970 + } 971 + } 972 + ``` 973 + 974 + **Step 3: Run tests** 975 + 976 + Run: `npm test` 977 + Expected: All tests pass 978 + 979 + **Step 4: Run e2e tests to verify integration** 980 + 981 + Run: `npm run test:e2e` 982 + Expected: All tests pass 983 + 984 + **Step 5: Commit** 985 + 986 + ```bash 987 + git add src/cloudflare.js test/cloudflare-repository.test.js 988 + git commit -m "feat: implement CloudflareRepository" 989 + ``` 990 + 991 + --- 992 + 993 + ### Task 11: Implement CloudflareBlobStore 994 + 995 + **Files:** 996 + - Modify: `src/cloudflare.js` 997 + 998 + **Step 1: Implement CloudflareBlobStore** 999 + 1000 + Add to `src/cloudflare.js`: 1001 + 1002 + ```javascript 1003 + /** 1004 + * BlobStore implementation backed by Cloudflare R2. 1005 + */ 1006 + export class CloudflareBlobStore { 1007 + /** 1008 + * @param {R2Bucket} bucket 1009 + * @param {DurableObjectState['storage']['sql']} sql - For blob metadata 1010 + */ 1011 + constructor(bucket, sql) { 1012 + this.bucket = bucket; 1013 + this.sql = sql; 1014 + } 1015 + 1016 + async putBlob(cid, data, mimeType) { 1017 + await this.bucket.put(cid, data, { 1018 + httpMetadata: { contentType: mimeType }, 1019 + }); 1020 + this.sql.exec( 1021 + 'INSERT OR REPLACE INTO blobs (cid, mime_type, size, created_at) VALUES (?, ?, ?, ?)', 1022 + cid, mimeType, data.length, new Date().toISOString() 1023 + ); 1024 + } 1025 + 1026 + async getBlob(cid) { 1027 + const object = await this.bucket.get(cid); 1028 + if (!object) return null; 1029 + 1030 + const data = new Uint8Array(await object.arrayBuffer()); 1031 + const mimeType = object.httpMetadata?.contentType || 'application/octet-stream'; 1032 + return { data, mimeType }; 1033 + } 1034 + 1035 + async deleteBlob(cid) { 1036 + await this.bucket.delete(cid); 1037 + this.sql.exec('DELETE FROM blobs WHERE cid = ?', cid); 1038 + } 1039 + 1040 + async hasBlob(cid) { 1041 + const head = await this.bucket.head(cid); 1042 + return head !== null; 1043 + } 1044 + } 1045 + ``` 1046 + 1047 + **Step 2: Verify syntax** 1048 + 1049 + Run: `node --check src/cloudflare.js` 1050 + Expected: No output (success) 1051 + 1052 + **Step 3: Run e2e tests** 1053 + 1054 + Run: `npm run test:e2e` 1055 + Expected: All tests pass 1056 + 1057 + **Step 4: Commit** 1058 + 1059 + ```bash 1060 + git add src/cloudflare.js 1061 + git commit -m "feat: implement CloudflareBlobStore" 1062 + ``` 1063 + 1064 + --- 1065 + 1066 + ### Task 12: Implement CloudflareAuthProvider 1067 + 1068 + **Files:** 1069 + - Modify: `src/cloudflare.js` 1070 + 1071 + **Step 1: Implement CloudflareAuthProvider** 1072 + 1073 + Add to `src/cloudflare.js`: 1074 + 1075 + ```javascript 1076 + import { createAccessJwt, createRefreshJwt, verifyAccessJwt, verifyRefreshJwt } from './pds.js'; 1077 + 1078 + /** 1079 + * AuthProvider implementation backed by Durable Object SQLite. 1080 + */ 1081 + export class CloudflareAuthProvider { 1082 + /** 1083 + * @param {DurableObjectState['storage']['sql']} sql 1084 + * @param {string} jwtSecret 1085 + */ 1086 + constructor(sql, jwtSecret) { 1087 + this.sql = sql; 1088 + this.jwtSecret = jwtSecret; 1089 + } 1090 + 1091 + async createSession(did) { 1092 + const accessJwt = await createAccessJwt(did, this.jwtSecret); 1093 + const refreshJwt = await createRefreshJwt(did, this.jwtSecret); 1094 + return { accessJwt, refreshJwt }; 1095 + } 1096 + 1097 + async getSession(accessJwt) { 1098 + try { 1099 + const payload = await verifyAccessJwt(accessJwt, this.jwtSecret); 1100 + return { did: payload.sub }; 1101 + } catch { 1102 + return null; 1103 + } 1104 + } 1105 + 1106 + async deleteSession(refreshJwt) { 1107 + // Sessions are stateless JWTs - nothing to delete 1108 + // Could add to a revocation list if needed 1109 + } 1110 + 1111 + async storeAuthorizationRequest(id, request) { 1112 + this.sql.exec( 1113 + `INSERT INTO authorization_requests 1114 + (id, client_id, client_metadata, parameters, code_challenge, code_challenge_method, dpop_jkt, expires_at, created_at) 1115 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 1116 + id, 1117 + request.clientId, 1118 + JSON.stringify(request.clientMetadata || {}), 1119 + JSON.stringify(request.parameters || {}), 1120 + request.codeChallenge || null, 1121 + request.codeChallengeMethod || null, 1122 + request.dpopJkt || null, 1123 + request.expiresAt || new Date(Date.now() + 600000).toISOString(), 1124 + new Date().toISOString() 1125 + ); 1126 + } 1127 + 1128 + async getAuthorizationRequest(id) { 1129 + const row = this.sql.exec('SELECT * FROM authorization_requests WHERE id = ?', id).one(); 1130 + if (!row) return null; 1131 + 1132 + return { 1133 + id: row.id, 1134 + clientId: row.client_id, 1135 + clientMetadata: JSON.parse(row.client_metadata), 1136 + parameters: JSON.parse(row.parameters), 1137 + code: row.code, 1138 + codeChallenge: row.code_challenge, 1139 + codeChallengeMethod: row.code_challenge_method, 1140 + dpopJkt: row.dpop_jkt, 1141 + did: row.did, 1142 + expiresAt: row.expires_at, 1143 + createdAt: row.created_at, 1144 + }; 1145 + } 1146 + 1147 + async storeToken(tokenId, token) { 1148 + this.sql.exec( 1149 + `INSERT INTO tokens 1150 + (token_id, did, client_id, scope, dpop_jkt, expires_at, refresh_token, created_at, updated_at) 1151 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 1152 + tokenId, 1153 + token.did, 1154 + token.clientId, 1155 + token.scope || null, 1156 + token.dpopJkt || null, 1157 + token.expiresAt, 1158 + token.refreshToken || null, 1159 + new Date().toISOString(), 1160 + new Date().toISOString() 1161 + ); 1162 + } 1163 + 1164 + async getToken(tokenId) { 1165 + const row = this.sql.exec('SELECT * FROM tokens WHERE token_id = ?', tokenId).one(); 1166 + if (!row) return null; 1167 + 1168 + return { 1169 + tokenId: row.token_id, 1170 + did: row.did, 1171 + clientId: row.client_id, 1172 + scope: row.scope, 1173 + dpopJkt: row.dpop_jkt, 1174 + expiresAt: row.expires_at, 1175 + refreshToken: row.refresh_token, 1176 + }; 1177 + } 1178 + 1179 + async revokeToken(tokenId) { 1180 + this.sql.exec('DELETE FROM tokens WHERE token_id = ?', tokenId); 1181 + } 1182 + } 1183 + ``` 1184 + 1185 + **Step 2: Verify syntax** 1186 + 1187 + Run: `node --check src/cloudflare.js` 1188 + Expected: No output (success) 1189 + 1190 + **Step 3: Run e2e tests** 1191 + 1192 + Run: `npm run test:e2e` 1193 + Expected: All tests pass 1194 + 1195 + **Step 4: Commit** 1196 + 1197 + ```bash 1198 + git add src/cloudflare.js 1199 + git commit -m "feat: implement CloudflareAuthProvider" 1200 + ``` 1201 + 1202 + --- 1203 + 1204 + ### Task 13: Wire Up createPdsHandler with Route Handlers 1205 + 1206 + **Files:** 1207 + - Modify: `src/pds.js` 1208 + - Modify: `test/pds-core.test.js` 1209 + 1210 + **Step 1: Write integration test for handler routing** 1211 + 1212 + Add to `test/pds-core.test.js`: 1213 + 1214 + ```javascript 1215 + describe('createPdsHandler routing', () => { 1216 + it('handles .well-known/atproto-did', async () => { 1217 + const handler = createPdsHandler({ 1218 + did: 'did:plc:testuser123', 1219 + hostname: 'test.pds.example', 1220 + repository: new MockRepository(), 1221 + blobStore: new MockBlobStore(), 1222 + authProvider: new MockAuthProvider(), 1223 + }); 1224 + 1225 + const request = new Request('https://test.pds.example/.well-known/atproto-did'); 1226 + const response = await handler(request); 1227 + 1228 + expect(response.status).toBe(200); 1229 + const text = await response.text(); 1230 + expect(text).toBe('did:plc:testuser123'); 1231 + }); 1232 + 1233 + it('handles xrpc/com.atproto.server.describeServer', async () => { 1234 + const handler = createPdsHandler({ 1235 + did: 'did:plc:testuser123', 1236 + hostname: 'test.pds.example', 1237 + repository: new MockRepository(), 1238 + blobStore: new MockBlobStore(), 1239 + authProvider: new MockAuthProvider(), 1240 + }); 1241 + 1242 + const request = new Request('https://test.pds.example/xrpc/com.atproto.server.describeServer'); 1243 + const response = await handler(request); 1244 + 1245 + expect(response.status).toBe(200); 1246 + const json = await response.json(); 1247 + expect(json.did).toBe('did:plc:testuser123'); 1248 + expect(json.availableUserDomains).toContain('test.pds.example'); 1249 + }); 1250 + }); 1251 + ``` 1252 + 1253 + **Step 2: Run tests to verify they fail** 1254 + 1255 + Run: `npm test -- test/pds-core.test.js` 1256 + Expected: FAIL (handler returns 501 stub) 1257 + 1258 + **Step 3: Implement basic routing in createPdsHandler** 1259 + 1260 + 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: 1261 + 1262 + Update `createPdsHandler` in `src/pds.js`: 1263 + 1264 + ```javascript 1265 + export function createPdsHandler(config) { 1266 + const { did, hostname, repository, blobStore, authProvider } = config; 1267 + 1268 + return async function handleRequest(request) { 1269 + const url = new URL(request.url); 1270 + const path = url.pathname; 1271 + 1272 + // .well-known/atproto-did 1273 + if (path === '/.well-known/atproto-did') { 1274 + return new Response(did, { 1275 + headers: { 'Content-Type': 'text/plain' }, 1276 + }); 1277 + } 1278 + 1279 + // com.atproto.server.describeServer 1280 + if (path === '/xrpc/com.atproto.server.describeServer') { 1281 + return Response.json({ 1282 + did, 1283 + availableUserDomains: [hostname], 1284 + inviteCodeRequired: false, 1285 + phoneVerificationRequired: false, 1286 + links: {}, 1287 + }); 1288 + } 1289 + 1290 + return new Response('Not Found', { status: 404 }); 1291 + }; 1292 + } 1293 + ``` 1294 + 1295 + **Step 4: Run tests** 1296 + 1297 + Run: `npm test -- test/pds-core.test.js` 1298 + Expected: PASS 1299 + 1300 + **Step 5: Commit** 1301 + 1302 + ```bash 1303 + git add src/pds.js test/pds-core.test.js 1304 + git commit -m "feat: wire up basic routes in createPdsHandler" 1305 + ``` 1306 + 1307 + --- 1308 + 1309 + ### Task 14: Add Core Handler Tests for Repo Operations 1310 + 1311 + **Files:** 1312 + - Modify: `test/pds-core.test.js` 1313 + 1314 + **Step 1: Write describeRepo test** 1315 + 1316 + Add to `test/pds-core.test.js`: 1317 + 1318 + ```javascript 1319 + describe('createPdsHandler repo operations', () => { 1320 + it('handles com.atproto.repo.describeRepo', async () => { 1321 + const repo = new MockRepository(); 1322 + // Seed with a commit 1323 + await repo.saveCommit('bafyreiabc', 'tid123', null); 1324 + 1325 + const handler = createPdsHandler({ 1326 + did: 'did:plc:testuser123', 1327 + hostname: 'test.pds.example', 1328 + repository: repo, 1329 + blobStore: new MockBlobStore(), 1330 + authProvider: new MockAuthProvider(), 1331 + }); 1332 + 1333 + const request = new Request('https://test.pds.example/xrpc/com.atproto.repo.describeRepo?repo=did:plc:testuser123'); 1334 + const response = await handler(request); 1335 + 1336 + expect(response.status).toBe(200); 1337 + const json = await response.json(); 1338 + expect(json.did).toBe('did:plc:testuser123'); 1339 + expect(json.handle).toBe('test.pds.example'); 1340 + }); 1341 + }); 1342 + ``` 1343 + 1344 + **Step 2: Run tests to see failure** 1345 + 1346 + Run: `npm test -- test/pds-core.test.js` 1347 + Expected: FAIL (route not implemented) 1348 + 1349 + **Step 3: This identifies the next route to implement** 1350 + 1351 + 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`. 1352 + 1353 + **Step 4: Commit progress** 1354 + 1355 + ```bash 1356 + git add test/pds-core.test.js 1357 + git commit -m "test: add repo operation tests for createPdsHandler" 1358 + ``` 1359 + 1360 + --- 1361 + 1362 + ### Task 15: Run Full Test Suite and Measure Coverage 1363 + 1364 + **Files:** 1365 + - None (verification only) 1366 + 1367 + **Step 1: Run unit tests with coverage** 1368 + 1369 + Run: `npm run test:coverage` 1370 + Expected: Coverage report showing improvement from pure function tests 1371 + 1372 + **Step 2: Run e2e tests** 1373 + 1374 + Run: `npm run test:e2e` 1375 + Expected: All e2e tests pass 1376 + 1377 + **Step 3: Review coverage gaps** 1378 + 1379 + The coverage report will show which code paths still need tests. The goal is: 1380 + - Pure functions (atproto.js): High coverage via unit tests 1381 + - Route handlers (pds.js): Growing coverage via createPdsHandler tests 1382 + - Cloudflare adapters (cloudflare.js): Coverage via e2e tests 1383 + 1384 + **Step 4: Document coverage baseline** 1385 + 1386 + Record current coverage numbers for comparison as more handlers are migrated. 1387 + 1388 + **Step 5: Commit** 1389 + 1390 + ```bash 1391 + git add . 1392 + git commit -m "chore: verify test coverage after core separation setup" 1393 + ``` 1394 + 1395 + --- 1396 + 1397 + ## Summary 1398 + 1399 + This plan establishes the foundation for separating PDS core logic from Cloudflare: 1400 + 1401 + 1. **Tasks 1-6**: Create Repository, BlobStore, and AuthProvider interfaces with mock implementations 1402 + 2. **Tasks 7-8**: Create `createPdsHandler` factory and extract pure utilities to `atproto.js` 1403 + 3. **Tasks 9-12**: Create `cloudflare.js` adapter with Cloudflare-specific implementations 1404 + 4. **Tasks 13-14**: Wire up route handlers to use dependency injection 1405 + 5. **Task 15**: Verify coverage improvements 1406 + 1407 + After completing these tasks, you'll have: 1408 + - A testable core (`createPdsHandler`) that works with mock dependencies 1409 + - Pure ATProto utilities (`atproto.js`) fully unit-testable 1410 + - Cloudflare adapter (`cloudflare.js`) as thin entry point 1411 + - Foundation for incrementally migrating remaining route handlers 1412 + 1413 + Future work (not in this plan): 1414 + - Migrate all route handlers to use injected dependencies 1415 + - Move more logic from PersonalDataServer methods to pure functions 1416 + - Add more createPdsHandler tests for each route