A minimal AT Protocol Personal Data Server written in JavaScript.
atproto pds
46
fork

Configure Feed

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

at main 718 lines 20 kB view raw view rendered
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