this repo has no description
1# pds.js Refactor Implementation Plan 2 3> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 5**Goal:** Improve pds.js maintainability through consolidated CBOR encoding, JSDoc documentation, route table pattern, and clarifying comments. 6 7**Architecture:** Single-file refactor preserving current dependency order. Extract shared `encodeHead` helper for CBOR encoders. Replace `PersonalDataServer.fetch` if/else chain with declarative route table. Add JSDoc to exported functions and "why" comments to protocol-specific logic. 8 9**Tech Stack:** JavaScript (ES modules), Cloudflare Workers, JSDoc 10 11--- 12 13## Task 1: Add CBOR Constants 14 15**Files:** 16- Modify: `src/pds.js:1-12` 17 18**Step 1: Write test for constants usage** 19 20No new test needed — existing CBOR tests will verify constants work correctly. 21 22**Step 2: Add constants section at top of file** 23 24Insert before the CID wrapper class: 25 26```javascript 27// === CONSTANTS === 28// CBOR primitive markers (RFC 8949) 29const CBOR_FALSE = 0xf4 30const CBOR_TRUE = 0xf5 31const CBOR_NULL = 0xf6 32 33// DAG-CBOR CID link tag 34const CBOR_TAG_CID = 42 35``` 36 37**Step 3: Update cborEncode to use constants** 38 39Replace in `cborEncode` function: 40- `parts.push(0xf6)``parts.push(CBOR_NULL)` 41- `parts.push(0xf5)``parts.push(CBOR_TRUE)` 42- `parts.push(0xf4)``parts.push(CBOR_FALSE)` 43 44**Step 4: Update cborEncodeDagCbor to use constants** 45 46Same replacements, plus: 47- `parts.push(0xd8, 42)``parts.push(0xd8, CBOR_TAG_CID)` 48 49**Step 5: Update cborEncodeMstNode to use constants** 50 51Same replacements for null/true/false and tag 42. 52 53**Step 6: Run tests to verify** 54 55Run: `npm test` 56Expected: All CBOR tests pass 57 58**Step 7: Commit** 59 60```bash 61git add src/pds.js 62git commit -m "refactor: extract CBOR constants for clarity" 63``` 64 65--- 66 67## Task 2: Extract Shared encodeHead Helper 68 69**Files:** 70- Modify: `src/pds.js` (CBOR ENCODING section) 71 72**Step 1: Write test for large integer encoding** 73 74Already exists — `test/pds.test.js` has "encodes large integers >= 2^31 without overflow" 75 76**Step 2: Extract shared encodeHead function** 77 78Add after constants section, before `cborEncode`: 79 80```javascript 81/** 82 * Encode CBOR type header (major type + length) 83 * @param {number[]} parts - Array to push bytes to 84 * @param {number} majorType - CBOR major type (0-7) 85 * @param {number} length - Value or length to encode 86 */ 87function encodeHead(parts, majorType, length) { 88 const mt = majorType << 5 89 if (length < 24) { 90 parts.push(mt | length) 91 } else if (length < 256) { 92 parts.push(mt | 24, length) 93 } else if (length < 65536) { 94 parts.push(mt | 25, length >> 8, length & 0xff) 95 } else if (length < 4294967296) { 96 // Use Math.floor instead of bitshift to avoid 32-bit signed integer overflow 97 parts.push(mt | 26, 98 Math.floor(length / 0x1000000) & 0xff, 99 Math.floor(length / 0x10000) & 0xff, 100 Math.floor(length / 0x100) & 0xff, 101 length & 0xff) 102 } 103} 104``` 105 106**Step 3: Update cborEncode to use shared helper** 107 108Remove the local `encodeHead` function. Replace calls: 109- `encodeHead(3, bytes.length)``encodeHead(parts, 3, bytes.length)` 110- Same pattern for all other calls 111 112**Step 4: Update cborEncodeDagCbor to use shared helper** 113 114Remove the local `encodeHead` function. Update all calls to pass `parts` as first argument. 115 116**Step 5: Update cborEncodeMstNode to use shared helper** 117 118Remove the local `encodeHead` function. Update all calls to pass `parts` as first argument. 119 120**Step 6: Run tests** 121 122Run: `npm test` 123Expected: All tests pass 124 125**Step 7: Commit** 126 127```bash 128git add src/pds.js 129git commit -m "refactor: consolidate CBOR encodeHead into shared helper" 130``` 131 132--- 133 134## Task 3: Add JSDoc to Exported Functions 135 136**Files:** 137- Modify: `src/pds.js` 138 139**Step 1: Add JSDoc to cborEncode** 140 141```javascript 142/** 143 * Encode a value as CBOR bytes (RFC 8949 deterministic encoding) 144 * @param {*} value - Value to encode (null, boolean, number, string, Uint8Array, array, or object) 145 * @returns {Uint8Array} CBOR-encoded bytes 146 */ 147export function cborEncode(value) { 148``` 149 150**Step 2: Add JSDoc to cborDecode** 151 152```javascript 153/** 154 * Decode CBOR bytes to a JavaScript value 155 * @param {Uint8Array} bytes - CBOR-encoded bytes 156 * @returns {*} Decoded value 157 */ 158export function cborDecode(bytes) { 159``` 160 161**Step 3: Add JSDoc to CID functions** 162 163```javascript 164/** 165 * Create a CIDv1 (dag-cbor + sha-256) from raw bytes 166 * @param {Uint8Array} bytes - Content to hash 167 * @returns {Promise<Uint8Array>} CID bytes (36 bytes: version + codec + multihash) 168 */ 169export async function createCid(bytes) { 170 171/** 172 * Convert CID bytes to base32lower string representation 173 * @param {Uint8Array} cid - CID bytes 174 * @returns {string} Base32lower-encoded CID with 'b' prefix 175 */ 176export function cidToString(cid) { 177 178/** 179 * Encode bytes as base32lower string 180 * @param {Uint8Array} bytes - Bytes to encode 181 * @returns {string} Base32lower-encoded string 182 */ 183export function base32Encode(bytes) { 184``` 185 186**Step 4: Add JSDoc to TID function** 187 188```javascript 189/** 190 * Generate a timestamp-based ID (TID) for record keys 191 * Monotonic within a process, sortable by time 192 * @returns {string} 13-character base32-sort encoded TID 193 */ 194export function createTid() { 195``` 196 197**Step 5: Add JSDoc to signing functions** 198 199```javascript 200/** 201 * Import a raw P-256 private key for signing 202 * @param {Uint8Array} privateKeyBytes - 32-byte raw private key 203 * @returns {Promise<CryptoKey>} Web Crypto key handle 204 */ 205export async function importPrivateKey(privateKeyBytes) { 206 207/** 208 * Sign data with ECDSA P-256, returning low-S normalized signature 209 * @param {CryptoKey} privateKey - Web Crypto key from importPrivateKey 210 * @param {Uint8Array} data - Data to sign 211 * @returns {Promise<Uint8Array>} 64-byte signature (r || s) 212 */ 213export async function sign(privateKey, data) { 214 215/** 216 * Generate a new P-256 key pair 217 * @returns {Promise<{privateKey: Uint8Array, publicKey: Uint8Array}>} 32-byte private key, 33-byte compressed public key 218 */ 219export async function generateKeyPair() { 220``` 221 222**Step 6: Add JSDoc to utility functions** 223 224```javascript 225/** 226 * Convert bytes to hexadecimal string 227 * @param {Uint8Array} bytes - Bytes to convert 228 * @returns {string} Hex string 229 */ 230export function bytesToHex(bytes) { 231 232/** 233 * Convert hexadecimal string to bytes 234 * @param {string} hex - Hex string 235 * @returns {Uint8Array} Decoded bytes 236 */ 237export function hexToBytes(hex) { 238 239/** 240 * Get MST tree depth for a key based on leading zeros in SHA-256 hash 241 * @param {string} key - Record key (collection/rkey) 242 * @returns {Promise<number>} Tree depth (leading zeros / 2) 243 */ 244export async function getKeyDepth(key) { 245 246/** 247 * Encode integer as unsigned varint 248 * @param {number} n - Non-negative integer 249 * @returns {Uint8Array} Varint-encoded bytes 250 */ 251export function varint(n) { 252 253/** 254 * Convert base32lower CID string to raw bytes 255 * @param {string} cidStr - CID string with 'b' prefix 256 * @returns {Uint8Array} CID bytes 257 */ 258export function cidToBytes(cidStr) { 259 260/** 261 * Decode base32lower string to bytes 262 * @param {string} str - Base32lower-encoded string 263 * @returns {Uint8Array} Decoded bytes 264 */ 265export function base32Decode(str) { 266 267/** 268 * Build a CAR (Content Addressable aRchive) file 269 * @param {string} rootCid - Root CID string 270 * @param {Array<{cid: string, data: Uint8Array}>} blocks - Blocks to include 271 * @returns {Uint8Array} CAR file bytes 272 */ 273export function buildCarFile(rootCid, blocks) { 274``` 275 276**Step 7: Run tests** 277 278Run: `npm test` 279Expected: All tests pass (JSDoc doesn't affect runtime) 280 281**Step 8: Commit** 282 283```bash 284git add src/pds.js 285git commit -m "docs: add JSDoc to exported functions" 286``` 287 288--- 289 290## Task 4: Add "Why" Comments to Protocol Logic 291 292**Files:** 293- Modify: `src/pds.js` 294 295**Step 1: Add comment to DAG-CBOR key sorting** 296 297In `cborEncodeDagCbor`, before the `keys.sort()` call: 298 299```javascript 300 // DAG-CBOR: sort keys by length first, then lexicographically 301 // (differs from standard CBOR which sorts lexicographically only) 302 const keys = Object.keys(val).filter(k => val[k] !== undefined) 303 keys.sort((a, b) => { 304``` 305 306**Step 2: Add comment to MST depth calculation** 307 308In `getKeyDepth`, before the return: 309 310```javascript 311 // MST depth = leading zeros in SHA-256 hash / 2 312 // This creates a probabilistic tree where ~50% of keys are at depth 0, 313 // ~25% at depth 1, etc., giving O(log n) lookups 314 const depth = Math.floor(zeros / 2) 315``` 316 317**Step 3: Add comment to low-S normalization** 318 319In `sign` function, before the if statement: 320 321```javascript 322 // Low-S normalization: Bitcoin/ATProto require S <= N/2 to prevent 323 // signature malleability (two valid signatures for same message) 324 if (sBigInt > P256_N_DIV_2) { 325``` 326 327**Step 4: Add comment to CID tag encoding** 328 329In `cborEncodeDagCbor`, at the CID encoding: 330 331```javascript 332 } else if (val instanceof CID) { 333 // CID links in DAG-CBOR use tag 42 + 0x00 multibase prefix 334 // The 0x00 prefix indicates "identity" multibase (raw bytes) 335 parts.push(0xd8, CBOR_TAG_CID) 336``` 337 338**Step 5: Run tests** 339 340Run: `npm test` 341Expected: All tests pass 342 343**Step 6: Commit** 344 345```bash 346git add src/pds.js 347git commit -m "docs: add 'why' comments to protocol-specific logic" 348``` 349 350--- 351 352## Task 5: Extract PersonalDataServer Route Table 353 354**Files:** 355- Modify: `src/pds.js` (PERSONAL DATA SERVER section) 356 357**Step 1: Define route table before class** 358 359Add before `export class PersonalDataServer`: 360 361```javascript 362/** 363 * Route handler function type 364 * @callback RouteHandler 365 * @param {PersonalDataServer} pds - PDS instance 366 * @param {Request} request - HTTP request 367 * @param {URL} url - Parsed URL 368 * @returns {Promise<Response>} HTTP response 369 */ 370 371/** 372 * @typedef {Object} Route 373 * @property {string} [method] - Required HTTP method (default: any) 374 * @property {RouteHandler} handler - Handler function 375 */ 376 377/** @type {Record<string, Route>} */ 378const pdsRoutes = { 379 '/.well-known/atproto-did': { 380 handler: (pds, req, url) => pds.handleAtprotoDid() 381 }, 382 '/init': { 383 method: 'POST', 384 handler: (pds, req, url) => pds.handleInit(req) 385 }, 386 '/status': { 387 handler: (pds, req, url) => pds.handleStatus() 388 }, 389 '/reset-repo': { 390 handler: (pds, req, url) => pds.handleResetRepo() 391 }, 392 '/forward-event': { 393 handler: (pds, req, url) => pds.handleForwardEvent(req) 394 }, 395 '/register-did': { 396 handler: (pds, req, url) => pds.handleRegisterDid(req) 397 }, 398 '/get-registered-dids': { 399 handler: (pds, req, url) => pds.handleGetRegisteredDids() 400 }, 401 '/repo-info': { 402 handler: (pds, req, url) => pds.handleRepoInfo() 403 }, 404 '/xrpc/com.atproto.server.describeServer': { 405 handler: (pds, req, url) => pds.handleDescribeServer(req) 406 }, 407 '/xrpc/com.atproto.sync.listRepos': { 408 handler: (pds, req, url) => pds.handleListRepos() 409 }, 410 '/xrpc/com.atproto.repo.createRecord': { 411 method: 'POST', 412 handler: (pds, req, url) => pds.handleCreateRecord(req) 413 }, 414 '/xrpc/com.atproto.repo.getRecord': { 415 handler: (pds, req, url) => pds.handleGetRecord(url) 416 }, 417 '/xrpc/com.atproto.sync.getLatestCommit': { 418 handler: (pds, req, url) => pds.handleGetLatestCommit() 419 }, 420 '/xrpc/com.atproto.sync.getRepoStatus': { 421 handler: (pds, req, url) => pds.handleGetRepoStatus() 422 }, 423 '/xrpc/com.atproto.sync.getRepo': { 424 handler: (pds, req, url) => pds.handleGetRepo() 425 }, 426 '/xrpc/com.atproto.sync.subscribeRepos': { 427 handler: (pds, req, url) => pds.handleSubscribeRepos(req, url) 428 } 429} 430``` 431 432**Step 2: Extract handleAtprotoDid method** 433 434Add to PersonalDataServer class: 435 436```javascript 437 async handleAtprotoDid() { 438 let did = await this.getDid() 439 if (!did) { 440 const registeredDids = await this.state.storage.get('registeredDids') || [] 441 did = registeredDids[0] 442 } 443 if (!did) { 444 return new Response('User not found', { status: 404 }) 445 } 446 return new Response(did, { headers: { 'Content-Type': 'text/plain' } }) 447 } 448``` 449 450**Step 3: Extract handleInit method** 451 452```javascript 453 async handleInit(request) { 454 const body = await request.json() 455 if (!body.did || !body.privateKey) { 456 return Response.json({ error: 'missing did or privateKey' }, { status: 400 }) 457 } 458 await this.initIdentity(body.did, body.privateKey, body.handle || null) 459 return Response.json({ ok: true, did: body.did, handle: body.handle || null }) 460 } 461``` 462 463**Step 4: Extract handleStatus method** 464 465```javascript 466 async handleStatus() { 467 const did = await this.getDid() 468 return Response.json({ initialized: !!did, did: did || null }) 469 } 470``` 471 472**Step 5: Extract handleResetRepo method** 473 474```javascript 475 async handleResetRepo() { 476 this.sql.exec(`DELETE FROM blocks`) 477 this.sql.exec(`DELETE FROM records`) 478 this.sql.exec(`DELETE FROM commits`) 479 this.sql.exec(`DELETE FROM seq_events`) 480 await this.state.storage.delete('head') 481 await this.state.storage.delete('rev') 482 return Response.json({ ok: true, message: 'repo data cleared' }) 483 } 484``` 485 486**Step 6: Extract handleForwardEvent method** 487 488```javascript 489 async handleForwardEvent(request) { 490 const evt = await request.json() 491 const numSockets = [...this.state.getWebSockets()].length 492 console.log(`forward-event: received event seq=${evt.seq}, ${numSockets} connected sockets`) 493 this.broadcastEvent({ 494 seq: evt.seq, 495 did: evt.did, 496 commit_cid: evt.commit_cid, 497 evt: new Uint8Array(Object.values(evt.evt)) 498 }) 499 return Response.json({ ok: true, sockets: numSockets }) 500 } 501``` 502 503**Step 7: Extract handleRegisterDid method** 504 505```javascript 506 async handleRegisterDid(request) { 507 const body = await request.json() 508 const registeredDids = await this.state.storage.get('registeredDids') || [] 509 if (!registeredDids.includes(body.did)) { 510 registeredDids.push(body.did) 511 await this.state.storage.put('registeredDids', registeredDids) 512 } 513 return Response.json({ ok: true }) 514 } 515``` 516 517**Step 8: Extract handleGetRegisteredDids method** 518 519```javascript 520 async handleGetRegisteredDids() { 521 const registeredDids = await this.state.storage.get('registeredDids') || [] 522 return Response.json({ dids: registeredDids }) 523 } 524``` 525 526**Step 9: Extract handleRepoInfo method** 527 528```javascript 529 async handleRepoInfo() { 530 const head = await this.state.storage.get('head') 531 const rev = await this.state.storage.get('rev') 532 return Response.json({ head: head || null, rev: rev || null }) 533 } 534``` 535 536**Step 10: Extract handleDescribeServer method** 537 538```javascript 539 handleDescribeServer(request) { 540 const hostname = request.headers.get('x-hostname') || 'localhost' 541 return Response.json({ 542 did: `did:web:${hostname}`, 543 availableUserDomains: [`.${hostname}`], 544 inviteCodeRequired: false, 545 phoneVerificationRequired: false, 546 links: {}, 547 contact: {} 548 }) 549 } 550``` 551 552**Step 11: Extract handleListRepos method** 553 554```javascript 555 async handleListRepos() { 556 const registeredDids = await this.state.storage.get('registeredDids') || [] 557 const did = await this.getDid() 558 const repos = did ? [{ did, head: null, rev: null }] : 559 registeredDids.map(d => ({ did: d, head: null, rev: null })) 560 return Response.json({ repos }) 561 } 562``` 563 564**Step 12: Extract handleCreateRecord method** 565 566```javascript 567 async handleCreateRecord(request) { 568 const body = await request.json() 569 if (!body.collection || !body.record) { 570 return Response.json({ error: 'missing collection or record' }, { status: 400 }) 571 } 572 try { 573 const result = await this.createRecord(body.collection, body.record, body.rkey) 574 return Response.json(result) 575 } catch (err) { 576 return Response.json({ error: err.message }, { status: 500 }) 577 } 578 } 579``` 580 581**Step 13: Extract handleGetRecord method** 582 583```javascript 584 async handleGetRecord(url) { 585 const collection = url.searchParams.get('collection') 586 const rkey = url.searchParams.get('rkey') 587 if (!collection || !rkey) { 588 return Response.json({ error: 'missing collection or rkey' }, { status: 400 }) 589 } 590 const did = await this.getDid() 591 const uri = `at://${did}/${collection}/${rkey}` 592 const rows = this.sql.exec( 593 `SELECT cid, value FROM records WHERE uri = ?`, uri 594 ).toArray() 595 if (rows.length === 0) { 596 return Response.json({ error: 'record not found' }, { status: 404 }) 597 } 598 const row = rows[0] 599 const value = cborDecode(new Uint8Array(row.value)) 600 return Response.json({ uri, cid: row.cid, value }) 601 } 602``` 603 604**Step 14: Extract handleGetLatestCommit method** 605 606```javascript 607 handleGetLatestCommit() { 608 const commits = this.sql.exec( 609 `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1` 610 ).toArray() 611 if (commits.length === 0) { 612 return Response.json({ error: 'RepoNotFound', message: 'repo not found' }, { status: 404 }) 613 } 614 return Response.json({ cid: commits[0].cid, rev: commits[0].rev }) 615 } 616``` 617 618**Step 15: Extract handleGetRepoStatus method** 619 620```javascript 621 async handleGetRepoStatus() { 622 const did = await this.getDid() 623 const commits = this.sql.exec( 624 `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1` 625 ).toArray() 626 if (commits.length === 0 || !did) { 627 return Response.json({ error: 'RepoNotFound', message: 'repo not found' }, { status: 404 }) 628 } 629 return Response.json({ did, active: true, status: 'active', rev: commits[0].rev }) 630 } 631``` 632 633**Step 16: Extract handleGetRepo method** 634 635```javascript 636 handleGetRepo() { 637 const commits = this.sql.exec( 638 `SELECT cid FROM commits ORDER BY seq DESC LIMIT 1` 639 ).toArray() 640 if (commits.length === 0) { 641 return Response.json({ error: 'repo not found' }, { status: 404 }) 642 } 643 const blocks = this.sql.exec(`SELECT cid, data FROM blocks`).toArray() 644 const blocksForCar = blocks.map(b => ({ 645 cid: b.cid, 646 data: new Uint8Array(b.data) 647 })) 648 const car = buildCarFile(commits[0].cid, blocksForCar) 649 return new Response(car, { 650 headers: { 'content-type': 'application/vnd.ipld.car' } 651 }) 652 } 653``` 654 655**Step 17: Extract handleSubscribeRepos method** 656 657```javascript 658 handleSubscribeRepos(request, url) { 659 const upgradeHeader = request.headers.get('Upgrade') 660 if (upgradeHeader !== 'websocket') { 661 return new Response('expected websocket', { status: 426 }) 662 } 663 const { 0: client, 1: server } = new WebSocketPair() 664 this.state.acceptWebSocket(server) 665 const cursor = url.searchParams.get('cursor') 666 if (cursor) { 667 const events = this.sql.exec( 668 `SELECT * FROM seq_events WHERE seq > ? ORDER BY seq`, 669 parseInt(cursor) 670 ).toArray() 671 for (const evt of events) { 672 server.send(this.formatEvent(evt)) 673 } 674 } 675 return new Response(null, { status: 101, webSocket: client }) 676 } 677``` 678 679**Step 18: Replace fetch method with router** 680 681```javascript 682 async fetch(request) { 683 const url = new URL(request.url) 684 const route = pdsRoutes[url.pathname] 685 686 if (!route) { 687 return Response.json({ error: 'not found' }, { status: 404 }) 688 } 689 if (route.method && request.method !== route.method) { 690 return Response.json({ error: 'method not allowed' }, { status: 405 }) 691 } 692 return route.handler(this, request, url) 693 } 694``` 695 696**Step 19: Run tests** 697 698Run: `npm test` 699Expected: All tests pass 700 701**Step 20: Commit** 702 703```bash 704git add src/pds.js 705git commit -m "refactor: extract PersonalDataServer route table" 706``` 707 708--- 709 710## Summary 711 712After completing all tasks, the file will have: 713- Named constants for CBOR markers and CID tag 714- Single shared `encodeHead` helper (no duplication) 715- JSDoc on all 15 exported functions 716- "Why" comments on 4 protocol-specific code sections 717- Declarative route table with 16 focused handler methods 718- Same dependency order, same single file