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