From 3e816add497aaeafc8b247ecf953f5ef32f89bc3 Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Mon, 5 Jan 2026 13:14:42 -0800 Subject: [PATCH] docs: add pds.js refactor implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan covers: CBOR constants, shared encodeHead, JSDoc on exports, route table pattern, and protocol-specific "why" comments. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docs/plans/2026-01-05-pds-refactor.md | 718 ++++++++++++++++++++++++++ 1 file changed, 718 insertions(+) create mode 100644 docs/plans/2026-01-05-pds-refactor.md diff --git a/docs/plans/2026-01-05-pds-refactor.md b/docs/plans/2026-01-05-pds-refactor.md new file mode 100644 index 0000000..72571c0 --- /dev/null +++ b/docs/plans/2026-01-05-pds-refactor.md @@ -0,0 +1,718 @@ +# pds.js Refactor Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Improve pds.js maintainability through consolidated CBOR encoding, JSDoc documentation, route table pattern, and clarifying comments. + +**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. + +**Tech Stack:** JavaScript (ES modules), Cloudflare Workers, JSDoc + +--- + +## Task 1: Add CBOR Constants + +**Files:** +- Modify: `src/pds.js:1-12` + +**Step 1: Write test for constants usage** + +No new test needed — existing CBOR tests will verify constants work correctly. + +**Step 2: Add constants section at top of file** + +Insert before the CID wrapper class: + +```javascript +// === CONSTANTS === +// CBOR primitive markers (RFC 8949) +const CBOR_FALSE = 0xf4 +const CBOR_TRUE = 0xf5 +const CBOR_NULL = 0xf6 + +// DAG-CBOR CID link tag +const CBOR_TAG_CID = 42 +``` + +**Step 3: Update cborEncode to use constants** + +Replace in `cborEncode` function: +- `parts.push(0xf6)` → `parts.push(CBOR_NULL)` +- `parts.push(0xf5)` → `parts.push(CBOR_TRUE)` +- `parts.push(0xf4)` → `parts.push(CBOR_FALSE)` + +**Step 4: Update cborEncodeDagCbor to use constants** + +Same replacements, plus: +- `parts.push(0xd8, 42)` → `parts.push(0xd8, CBOR_TAG_CID)` + +**Step 5: Update cborEncodeMstNode to use constants** + +Same replacements for null/true/false and tag 42. + +**Step 6: Run tests to verify** + +Run: `npm test` +Expected: All CBOR tests pass + +**Step 7: Commit** + +```bash +git add src/pds.js +git commit -m "refactor: extract CBOR constants for clarity" +``` + +--- + +## Task 2: Extract Shared encodeHead Helper + +**Files:** +- Modify: `src/pds.js` (CBOR ENCODING section) + +**Step 1: Write test for large integer encoding** + +Already exists — `test/pds.test.js` has "encodes large integers >= 2^31 without overflow" + +**Step 2: Extract shared encodeHead function** + +Add after constants section, before `cborEncode`: + +```javascript +/** + * Encode CBOR type header (major type + length) + * @param {number[]} parts - Array to push bytes to + * @param {number} majorType - CBOR major type (0-7) + * @param {number} length - Value or length to encode + */ +function encodeHead(parts, majorType, length) { + const mt = majorType << 5 + if (length < 24) { + parts.push(mt | length) + } else if (length < 256) { + parts.push(mt | 24, length) + } else if (length < 65536) { + parts.push(mt | 25, length >> 8, length & 0xff) + } else if (length < 4294967296) { + // Use Math.floor instead of bitshift to avoid 32-bit signed integer overflow + parts.push(mt | 26, + Math.floor(length / 0x1000000) & 0xff, + Math.floor(length / 0x10000) & 0xff, + Math.floor(length / 0x100) & 0xff, + length & 0xff) + } +} +``` + +**Step 3: Update cborEncode to use shared helper** + +Remove the local `encodeHead` function. Replace calls: +- `encodeHead(3, bytes.length)` → `encodeHead(parts, 3, bytes.length)` +- Same pattern for all other calls + +**Step 4: Update cborEncodeDagCbor to use shared helper** + +Remove the local `encodeHead` function. Update all calls to pass `parts` as first argument. + +**Step 5: Update cborEncodeMstNode to use shared helper** + +Remove the local `encodeHead` function. Update all calls to pass `parts` as first argument. + +**Step 6: Run tests** + +Run: `npm test` +Expected: All tests pass + +**Step 7: Commit** + +```bash +git add src/pds.js +git commit -m "refactor: consolidate CBOR encodeHead into shared helper" +``` + +--- + +## Task 3: Add JSDoc to Exported Functions + +**Files:** +- Modify: `src/pds.js` + +**Step 1: Add JSDoc to cborEncode** + +```javascript +/** + * Encode a value as CBOR bytes (RFC 8949 deterministic encoding) + * @param {*} value - Value to encode (null, boolean, number, string, Uint8Array, array, or object) + * @returns {Uint8Array} CBOR-encoded bytes + */ +export function cborEncode(value) { +``` + +**Step 2: Add JSDoc to cborDecode** + +```javascript +/** + * Decode CBOR bytes to a JavaScript value + * @param {Uint8Array} bytes - CBOR-encoded bytes + * @returns {*} Decoded value + */ +export function cborDecode(bytes) { +``` + +**Step 3: Add JSDoc to CID functions** + +```javascript +/** + * Create a CIDv1 (dag-cbor + sha-256) from raw bytes + * @param {Uint8Array} bytes - Content to hash + * @returns {Promise} CID bytes (36 bytes: version + codec + multihash) + */ +export async function createCid(bytes) { + +/** + * Convert CID bytes to base32lower string representation + * @param {Uint8Array} cid - CID bytes + * @returns {string} Base32lower-encoded CID with 'b' prefix + */ +export function cidToString(cid) { + +/** + * Encode bytes as base32lower string + * @param {Uint8Array} bytes - Bytes to encode + * @returns {string} Base32lower-encoded string + */ +export function base32Encode(bytes) { +``` + +**Step 4: Add JSDoc to TID function** + +```javascript +/** + * Generate a timestamp-based ID (TID) for record keys + * Monotonic within a process, sortable by time + * @returns {string} 13-character base32-sort encoded TID + */ +export function createTid() { +``` + +**Step 5: Add JSDoc to signing functions** + +```javascript +/** + * Import a raw P-256 private key for signing + * @param {Uint8Array} privateKeyBytes - 32-byte raw private key + * @returns {Promise} Web Crypto key handle + */ +export async function importPrivateKey(privateKeyBytes) { + +/** + * Sign data with ECDSA P-256, returning low-S normalized signature + * @param {CryptoKey} privateKey - Web Crypto key from importPrivateKey + * @param {Uint8Array} data - Data to sign + * @returns {Promise} 64-byte signature (r || s) + */ +export async function sign(privateKey, data) { + +/** + * Generate a new P-256 key pair + * @returns {Promise<{privateKey: Uint8Array, publicKey: Uint8Array}>} 32-byte private key, 33-byte compressed public key + */ +export async function generateKeyPair() { +``` + +**Step 6: Add JSDoc to utility functions** + +```javascript +/** + * Convert bytes to hexadecimal string + * @param {Uint8Array} bytes - Bytes to convert + * @returns {string} Hex string + */ +export function bytesToHex(bytes) { + +/** + * Convert hexadecimal string to bytes + * @param {string} hex - Hex string + * @returns {Uint8Array} Decoded bytes + */ +export function hexToBytes(hex) { + +/** + * Get MST tree depth for a key based on leading zeros in SHA-256 hash + * @param {string} key - Record key (collection/rkey) + * @returns {Promise} Tree depth (leading zeros / 2) + */ +export async function getKeyDepth(key) { + +/** + * Encode integer as unsigned varint + * @param {number} n - Non-negative integer + * @returns {Uint8Array} Varint-encoded bytes + */ +export function varint(n) { + +/** + * Convert base32lower CID string to raw bytes + * @param {string} cidStr - CID string with 'b' prefix + * @returns {Uint8Array} CID bytes + */ +export function cidToBytes(cidStr) { + +/** + * Decode base32lower string to bytes + * @param {string} str - Base32lower-encoded string + * @returns {Uint8Array} Decoded bytes + */ +export function base32Decode(str) { + +/** + * Build a CAR (Content Addressable aRchive) file + * @param {string} rootCid - Root CID string + * @param {Array<{cid: string, data: Uint8Array}>} blocks - Blocks to include + * @returns {Uint8Array} CAR file bytes + */ +export function buildCarFile(rootCid, blocks) { +``` + +**Step 7: Run tests** + +Run: `npm test` +Expected: All tests pass (JSDoc doesn't affect runtime) + +**Step 8: Commit** + +```bash +git add src/pds.js +git commit -m "docs: add JSDoc to exported functions" +``` + +--- + +## Task 4: Add "Why" Comments to Protocol Logic + +**Files:** +- Modify: `src/pds.js` + +**Step 1: Add comment to DAG-CBOR key sorting** + +In `cborEncodeDagCbor`, before the `keys.sort()` call: + +```javascript + // DAG-CBOR: sort keys by length first, then lexicographically + // (differs from standard CBOR which sorts lexicographically only) + const keys = Object.keys(val).filter(k => val[k] !== undefined) + keys.sort((a, b) => { +``` + +**Step 2: Add comment to MST depth calculation** + +In `getKeyDepth`, before the return: + +```javascript + // MST depth = leading zeros in SHA-256 hash / 2 + // This creates a probabilistic tree where ~50% of keys are at depth 0, + // ~25% at depth 1, etc., giving O(log n) lookups + const depth = Math.floor(zeros / 2) +``` + +**Step 3: Add comment to low-S normalization** + +In `sign` function, before the if statement: + +```javascript + // Low-S normalization: Bitcoin/ATProto require S <= N/2 to prevent + // signature malleability (two valid signatures for same message) + if (sBigInt > P256_N_DIV_2) { +``` + +**Step 4: Add comment to CID tag encoding** + +In `cborEncodeDagCbor`, at the CID encoding: + +```javascript + } else if (val instanceof CID) { + // CID links in DAG-CBOR use tag 42 + 0x00 multibase prefix + // The 0x00 prefix indicates "identity" multibase (raw bytes) + parts.push(0xd8, CBOR_TAG_CID) +``` + +**Step 5: Run tests** + +Run: `npm test` +Expected: All tests pass + +**Step 6: Commit** + +```bash +git add src/pds.js +git commit -m "docs: add 'why' comments to protocol-specific logic" +``` + +--- + +## Task 5: Extract PersonalDataServer Route Table + +**Files:** +- Modify: `src/pds.js` (PERSONAL DATA SERVER section) + +**Step 1: Define route table before class** + +Add before `export class PersonalDataServer`: + +```javascript +/** + * Route handler function type + * @callback RouteHandler + * @param {PersonalDataServer} pds - PDS instance + * @param {Request} request - HTTP request + * @param {URL} url - Parsed URL + * @returns {Promise} HTTP response + */ + +/** + * @typedef {Object} Route + * @property {string} [method] - Required HTTP method (default: any) + * @property {RouteHandler} handler - Handler function + */ + +/** @type {Record} */ +const pdsRoutes = { + '/.well-known/atproto-did': { + handler: (pds, req, url) => pds.handleAtprotoDid() + }, + '/init': { + method: 'POST', + handler: (pds, req, url) => pds.handleInit(req) + }, + '/status': { + handler: (pds, req, url) => pds.handleStatus() + }, + '/reset-repo': { + handler: (pds, req, url) => pds.handleResetRepo() + }, + '/forward-event': { + handler: (pds, req, url) => pds.handleForwardEvent(req) + }, + '/register-did': { + handler: (pds, req, url) => pds.handleRegisterDid(req) + }, + '/get-registered-dids': { + handler: (pds, req, url) => pds.handleGetRegisteredDids() + }, + '/repo-info': { + handler: (pds, req, url) => pds.handleRepoInfo() + }, + '/xrpc/com.atproto.server.describeServer': { + handler: (pds, req, url) => pds.handleDescribeServer(req) + }, + '/xrpc/com.atproto.sync.listRepos': { + handler: (pds, req, url) => pds.handleListRepos() + }, + '/xrpc/com.atproto.repo.createRecord': { + method: 'POST', + handler: (pds, req, url) => pds.handleCreateRecord(req) + }, + '/xrpc/com.atproto.repo.getRecord': { + handler: (pds, req, url) => pds.handleGetRecord(url) + }, + '/xrpc/com.atproto.sync.getLatestCommit': { + handler: (pds, req, url) => pds.handleGetLatestCommit() + }, + '/xrpc/com.atproto.sync.getRepoStatus': { + handler: (pds, req, url) => pds.handleGetRepoStatus() + }, + '/xrpc/com.atproto.sync.getRepo': { + handler: (pds, req, url) => pds.handleGetRepo() + }, + '/xrpc/com.atproto.sync.subscribeRepos': { + handler: (pds, req, url) => pds.handleSubscribeRepos(req, url) + } +} +``` + +**Step 2: Extract handleAtprotoDid method** + +Add to PersonalDataServer class: + +```javascript + async handleAtprotoDid() { + let did = await this.getDid() + if (!did) { + const registeredDids = await this.state.storage.get('registeredDids') || [] + did = registeredDids[0] + } + if (!did) { + return new Response('User not found', { status: 404 }) + } + return new Response(did, { headers: { 'Content-Type': 'text/plain' } }) + } +``` + +**Step 3: Extract handleInit method** + +```javascript + async handleInit(request) { + const body = await request.json() + if (!body.did || !body.privateKey) { + return Response.json({ error: 'missing did or privateKey' }, { status: 400 }) + } + await this.initIdentity(body.did, body.privateKey, body.handle || null) + return Response.json({ ok: true, did: body.did, handle: body.handle || null }) + } +``` + +**Step 4: Extract handleStatus method** + +```javascript + async handleStatus() { + const did = await this.getDid() + return Response.json({ initialized: !!did, did: did || null }) + } +``` + +**Step 5: Extract handleResetRepo method** + +```javascript + async handleResetRepo() { + this.sql.exec(`DELETE FROM blocks`) + this.sql.exec(`DELETE FROM records`) + this.sql.exec(`DELETE FROM commits`) + this.sql.exec(`DELETE FROM seq_events`) + await this.state.storage.delete('head') + await this.state.storage.delete('rev') + return Response.json({ ok: true, message: 'repo data cleared' }) + } +``` + +**Step 6: Extract handleForwardEvent method** + +```javascript + async handleForwardEvent(request) { + const evt = await request.json() + const numSockets = [...this.state.getWebSockets()].length + console.log(`forward-event: received event seq=${evt.seq}, ${numSockets} connected sockets`) + this.broadcastEvent({ + seq: evt.seq, + did: evt.did, + commit_cid: evt.commit_cid, + evt: new Uint8Array(Object.values(evt.evt)) + }) + return Response.json({ ok: true, sockets: numSockets }) + } +``` + +**Step 7: Extract handleRegisterDid method** + +```javascript + async handleRegisterDid(request) { + const body = await request.json() + const registeredDids = await this.state.storage.get('registeredDids') || [] + if (!registeredDids.includes(body.did)) { + registeredDids.push(body.did) + await this.state.storage.put('registeredDids', registeredDids) + } + return Response.json({ ok: true }) + } +``` + +**Step 8: Extract handleGetRegisteredDids method** + +```javascript + async handleGetRegisteredDids() { + const registeredDids = await this.state.storage.get('registeredDids') || [] + return Response.json({ dids: registeredDids }) + } +``` + +**Step 9: Extract handleRepoInfo method** + +```javascript + async handleRepoInfo() { + const head = await this.state.storage.get('head') + const rev = await this.state.storage.get('rev') + return Response.json({ head: head || null, rev: rev || null }) + } +``` + +**Step 10: Extract handleDescribeServer method** + +```javascript + handleDescribeServer(request) { + const hostname = request.headers.get('x-hostname') || 'localhost' + return Response.json({ + did: `did:web:${hostname}`, + availableUserDomains: [`.${hostname}`], + inviteCodeRequired: false, + phoneVerificationRequired: false, + links: {}, + contact: {} + }) + } +``` + +**Step 11: Extract handleListRepos method** + +```javascript + async handleListRepos() { + const registeredDids = await this.state.storage.get('registeredDids') || [] + const did = await this.getDid() + const repos = did ? [{ did, head: null, rev: null }] : + registeredDids.map(d => ({ did: d, head: null, rev: null })) + return Response.json({ repos }) + } +``` + +**Step 12: Extract handleCreateRecord method** + +```javascript + async handleCreateRecord(request) { + const body = await request.json() + if (!body.collection || !body.record) { + return Response.json({ error: 'missing collection or record' }, { status: 400 }) + } + try { + const result = await this.createRecord(body.collection, body.record, body.rkey) + return Response.json(result) + } catch (err) { + return Response.json({ error: err.message }, { status: 500 }) + } + } +``` + +**Step 13: Extract handleGetRecord method** + +```javascript + async handleGetRecord(url) { + const collection = url.searchParams.get('collection') + const rkey = url.searchParams.get('rkey') + if (!collection || !rkey) { + return Response.json({ error: 'missing collection or rkey' }, { status: 400 }) + } + const did = await this.getDid() + const uri = `at://${did}/${collection}/${rkey}` + const rows = this.sql.exec( + `SELECT cid, value FROM records WHERE uri = ?`, uri + ).toArray() + if (rows.length === 0) { + return Response.json({ error: 'record not found' }, { status: 404 }) + } + const row = rows[0] + const value = cborDecode(new Uint8Array(row.value)) + return Response.json({ uri, cid: row.cid, value }) + } +``` + +**Step 14: Extract handleGetLatestCommit method** + +```javascript + handleGetLatestCommit() { + const commits = this.sql.exec( + `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1` + ).toArray() + if (commits.length === 0) { + return Response.json({ error: 'RepoNotFound', message: 'repo not found' }, { status: 404 }) + } + return Response.json({ cid: commits[0].cid, rev: commits[0].rev }) + } +``` + +**Step 15: Extract handleGetRepoStatus method** + +```javascript + async handleGetRepoStatus() { + const did = await this.getDid() + const commits = this.sql.exec( + `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1` + ).toArray() + if (commits.length === 0 || !did) { + return Response.json({ error: 'RepoNotFound', message: 'repo not found' }, { status: 404 }) + } + return Response.json({ did, active: true, status: 'active', rev: commits[0].rev }) + } +``` + +**Step 16: Extract handleGetRepo method** + +```javascript + handleGetRepo() { + const commits = this.sql.exec( + `SELECT cid FROM commits ORDER BY seq DESC LIMIT 1` + ).toArray() + if (commits.length === 0) { + return Response.json({ error: 'repo not found' }, { status: 404 }) + } + const blocks = this.sql.exec(`SELECT cid, data FROM blocks`).toArray() + const blocksForCar = blocks.map(b => ({ + cid: b.cid, + data: new Uint8Array(b.data) + })) + const car = buildCarFile(commits[0].cid, blocksForCar) + return new Response(car, { + headers: { 'content-type': 'application/vnd.ipld.car' } + }) + } +``` + +**Step 17: Extract handleSubscribeRepos method** + +```javascript + handleSubscribeRepos(request, url) { + const upgradeHeader = request.headers.get('Upgrade') + if (upgradeHeader !== 'websocket') { + return new Response('expected websocket', { status: 426 }) + } + const { 0: client, 1: server } = new WebSocketPair() + this.state.acceptWebSocket(server) + const cursor = url.searchParams.get('cursor') + if (cursor) { + const events = this.sql.exec( + `SELECT * FROM seq_events WHERE seq > ? ORDER BY seq`, + parseInt(cursor) + ).toArray() + for (const evt of events) { + server.send(this.formatEvent(evt)) + } + } + return new Response(null, { status: 101, webSocket: client }) + } +``` + +**Step 18: Replace fetch method with router** + +```javascript + async fetch(request) { + const url = new URL(request.url) + const route = pdsRoutes[url.pathname] + + if (!route) { + return Response.json({ error: 'not found' }, { status: 404 }) + } + if (route.method && request.method !== route.method) { + return Response.json({ error: 'method not allowed' }, { status: 405 }) + } + return route.handler(this, request, url) + } +``` + +**Step 19: Run tests** + +Run: `npm test` +Expected: All tests pass + +**Step 20: Commit** + +```bash +git add src/pds.js +git commit -m "refactor: extract PersonalDataServer route table" +``` + +--- + +## Summary + +After completing all tasks, the file will have: +- Named constants for CBOR markers and CID tag +- Single shared `encodeHead` helper (no duplication) +- JSDoc on all 15 exported functions +- "Why" comments on 4 protocol-specific code sections +- Declarative route table with 16 focused handler methods +- Same dependency order, same single file -- 2.51.2