A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto pds

fix: relay compatibility improvements

- Add CBOR tag 42 decoding for CID links in cborDecode
- Use DAG-CBOR encoding for records (length-first key sorting)
- Return full proof chain in sync.getRecord (commit + MST + record)
- Skip CORS wrapping for WebSocket upgrade responses (status 101)
- Simplify setup script to use PDS hostname as handle

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

Changed files
+77 -32
scripts
src
+15 -20
scripts/setup.js
··· 17 function parseArgs() { 18 const args = process.argv.slice(2) 19 const opts = { 20 - handle: null, 21 pds: null, 22 plcUrl: 'https://plc.directory', 23 relayUrl: 'https://bsky.network' 24 } 25 26 for (let i = 0; i < args.length; i++) { 27 - if (args[i] === '--handle' && args[i + 1]) { 28 - opts.handle = args[++i] 29 - } else if (args[i] === '--pds' && args[i + 1]) { 30 opts.pds = args[++i] 31 } else if (args[i] === '--plc-url' && args[i + 1]) { 32 opts.plcUrl = args[++i] ··· 35 } 36 } 37 38 - if (!opts.handle || !opts.pds) { 39 - console.error('Usage: node scripts/setup.js --handle <handle> --pds <pds-url>') 40 console.error('') 41 console.error('Options:') 42 - console.error(' --handle Handle name (e.g., "alice")') 43 console.error(' --pds PDS URL (e.g., "https://atproto-pds.chad-53c.workers.dev")') 44 console.error(' --plc-url PLC directory URL (default: https://plc.directory)') 45 console.error(' --relay-url Relay URL (default: https://bsky.network)') 46 process.exit(1) 47 } 48 49 return opts 50 } ··· 289 async function createGenesisOperation(opts) { 290 const { didKey, handle, pdsUrl, cryptoKey } = opts 291 292 - // Build the full handle 293 - const pdsHost = new URL(pdsUrl).host 294 - const fullHandle = `${handle}.${pdsHost}` 295 - 296 const operation = { 297 type: 'plc_operation', 298 rotationKeys: [didKey], 299 verificationMethods: { 300 atproto: didKey 301 }, 302 - alsoKnownAs: [`at://${fullHandle}`], 303 services: { 304 atproto_pds: { 305 type: 'AtprotoPersonalDataServer', ··· 312 // Sign the operation 313 operation.sig = await signPlcOperation(operation, cryptoKey) 314 315 - return { operation, fullHandle } 316 } 317 318 async function deriveDidFromOperation(operation) { ··· 429 430 console.log('PDS Federation Setup') 431 console.log('====================') 432 - console.log(`Handle: ${opts.handle}`) 433 console.log(`PDS: ${opts.pds}`) 434 console.log('') 435 ··· 442 443 // Step 2: Create genesis operation 444 console.log('Creating PLC genesis operation...') 445 - const { operation, fullHandle } = await createGenesisOperation({ 446 didKey, 447 handle: opts.handle, 448 pdsUrl: opts.pds, ··· 450 }) 451 const did = await deriveDidFromOperation(operation) 452 console.log(` DID: ${did}`) 453 - console.log(` Handle: ${fullHandle}`) 454 console.log('') 455 456 // Step 3: Register with PLC directory ··· 462 // Step 4: Initialize PDS 463 console.log(`Initializing PDS at ${opts.pds}...`) 464 const privateKeyHex = bytesToHex(keyPair.privateKey) 465 - await initializePds(opts.pds, did, privateKeyHex, fullHandle) 466 console.log(' PDS initialized!') 467 console.log('') 468 ··· 477 478 // Step 6: Save credentials 479 const credentials = { 480 - handle: fullHandle, 481 did, 482 privateKeyHex: bytesToHex(keyPair.privateKey), 483 didKey, ··· 485 createdAt: new Date().toISOString() 486 } 487 488 - const credentialsFile = `./credentials-${opts.handle}.json` 489 saveCredentials(credentialsFile, credentials) 490 491 // Final output 492 console.log('Setup Complete!') 493 console.log('===============') 494 - console.log(`Handle: ${fullHandle}`) 495 console.log(`DID: ${did}`) 496 console.log(`PDS: ${opts.pds}`) 497 console.log('')
··· 17 function parseArgs() { 18 const args = process.argv.slice(2) 19 const opts = { 20 pds: null, 21 plcUrl: 'https://plc.directory', 22 relayUrl: 'https://bsky.network' 23 } 24 25 for (let i = 0; i < args.length; i++) { 26 + if (args[i] === '--pds' && args[i + 1]) { 27 opts.pds = args[++i] 28 } else if (args[i] === '--plc-url' && args[i + 1]) { 29 opts.plcUrl = args[++i] ··· 32 } 33 } 34 35 + if (!opts.pds) { 36 + console.error('Usage: node scripts/setup.js --pds <pds-url>') 37 console.error('') 38 console.error('Options:') 39 console.error(' --pds PDS URL (e.g., "https://atproto-pds.chad-53c.workers.dev")') 40 console.error(' --plc-url PLC directory URL (default: https://plc.directory)') 41 console.error(' --relay-url Relay URL (default: https://bsky.network)') 42 process.exit(1) 43 } 44 + 45 + // Handle is just the PDS hostname 46 + opts.handle = new URL(opts.pds).host 47 48 return opts 49 } ··· 288 async function createGenesisOperation(opts) { 289 const { didKey, handle, pdsUrl, cryptoKey } = opts 290 291 + // Handle is already the full hostname 292 const operation = { 293 type: 'plc_operation', 294 rotationKeys: [didKey], 295 verificationMethods: { 296 atproto: didKey 297 }, 298 + alsoKnownAs: [`at://${handle}`], 299 services: { 300 atproto_pds: { 301 type: 'AtprotoPersonalDataServer', ··· 308 // Sign the operation 309 operation.sig = await signPlcOperation(operation, cryptoKey) 310 311 + return { operation, handle } 312 } 313 314 async function deriveDidFromOperation(operation) { ··· 425 426 console.log('PDS Federation Setup') 427 console.log('====================') 428 console.log(`PDS: ${opts.pds}`) 429 console.log('') 430 ··· 437 438 // Step 2: Create genesis operation 439 console.log('Creating PLC genesis operation...') 440 + const { operation, handle } = await createGenesisOperation({ 441 didKey, 442 handle: opts.handle, 443 pdsUrl: opts.pds, ··· 445 }) 446 const did = await deriveDidFromOperation(operation) 447 console.log(` DID: ${did}`) 448 + console.log(` Handle: ${handle}`) 449 console.log('') 450 451 // Step 3: Register with PLC directory ··· 457 // Step 4: Initialize PDS 458 console.log(`Initializing PDS at ${opts.pds}...`) 459 const privateKeyHex = bytesToHex(keyPair.privateKey) 460 + await initializePds(opts.pds, did, privateKeyHex, handle) 461 console.log(' PDS initialized!') 462 console.log('') 463 ··· 472 473 // Step 6: Save credentials 474 const credentials = { 475 + handle, 476 did, 477 privateKeyHex: bytesToHex(keyPair.privateKey), 478 didKey, ··· 480 createdAt: new Date().toISOString() 481 } 482 483 + const credentialsFile = `./credentials.json` 484 saveCredentials(credentialsFile, credentials) 485 486 // Final output 487 console.log('Setup Complete!') 488 console.log('===============') 489 + console.log(`Handle: ${handle}`) 490 console.log(`DID: ${did}`) 491 console.log(`PDS: ${opts.pds}`) 492 console.log('')
+62 -12
src/pds.js
··· 216 } 217 return obj 218 } 219 case 7: { // special 220 if (info === 20) return false 221 if (info === 21) return true ··· 1014 rkey = rkey || createTid() 1015 const uri = `at://${did}/${collection}/${rkey}` 1016 1017 - // Encode and hash record 1018 - const recordBytes = cborEncode(record) 1019 const recordCid = await createCid(recordBytes) 1020 const recordCidStr = cidToString(recordCid) 1021 ··· 1404 if (rows.length === 0) { 1405 return Response.json({ error: 'RecordNotFound', message: 'record not found' }, { status: 404 }) 1406 } 1407 - // Get the record block 1408 const recordCid = rows[0].cid 1409 - const blockRows = this.sql.exec( 1410 - `SELECT cid, data FROM blocks WHERE cid = ?`, recordCid 1411 ).toArray() 1412 - if (blockRows.length === 0) { 1413 - return Response.json({ error: 'RecordNotFound', message: 'block not found' }, { status: 404 }) 1414 } 1415 - const blocks = blockRows.map(b => ({ 1416 - cid: b.cid, 1417 - data: new Uint8Array(b.data) 1418 - })) 1419 - const car = buildCarFile(recordCid, blocks) 1420 return new Response(car, { 1421 headers: { 'content-type': 'application/vnd.ipld.car' } 1422 }) ··· 1482 } 1483 1484 const response = await handleRequest(request, env) 1485 return addCorsHeaders(response) 1486 } 1487 }
··· 216 } 217 return obj 218 } 219 + case 6: { // tag 220 + // length is the tag number 221 + const taggedValue = read() 222 + if (length === CBOR_TAG_CID) { 223 + // CID link: byte string with 0x00 multibase prefix, return raw CID bytes 224 + return taggedValue.slice(1) // strip 0x00 prefix 225 + } 226 + return taggedValue 227 + } 228 case 7: { // special 229 if (info === 20) return false 230 if (info === 21) return true ··· 1023 rkey = rkey || createTid() 1024 const uri = `at://${did}/${collection}/${rkey}` 1025 1026 + // Encode and hash record (must use DAG-CBOR for proper key ordering) 1027 + const recordBytes = cborEncodeDagCbor(record) 1028 const recordCid = await createCid(recordBytes) 1029 const recordCidStr = cidToString(recordCid) 1030 ··· 1413 if (rows.length === 0) { 1414 return Response.json({ error: 'RecordNotFound', message: 'record not found' }, { status: 404 }) 1415 } 1416 const recordCid = rows[0].cid 1417 + 1418 + // Get latest commit 1419 + const commits = this.sql.exec( 1420 + `SELECT cid FROM commits ORDER BY seq DESC LIMIT 1` 1421 + ).toArray() 1422 + if (commits.length === 0) { 1423 + return Response.json({ error: 'RepoNotFound', message: 'no commits' }, { status: 404 }) 1424 + } 1425 + const commitCid = commits[0].cid 1426 + 1427 + // Build proof chain: commit -> MST path -> record 1428 + // Include commit block, all MST nodes on path to record, and record block 1429 + const blocks = [] 1430 + const included = new Set() 1431 + 1432 + const addBlock = (cidStr) => { 1433 + if (included.has(cidStr)) return 1434 + included.add(cidStr) 1435 + const blockRows = this.sql.exec( 1436 + `SELECT data FROM blocks WHERE cid = ?`, cidStr 1437 + ).toArray() 1438 + if (blockRows.length > 0) { 1439 + blocks.push({ cid: cidStr, data: new Uint8Array(blockRows[0].data) }) 1440 + } 1441 + } 1442 + 1443 + // Add commit block 1444 + addBlock(commitCid) 1445 + 1446 + // Get commit to find data root 1447 + const commitRows = this.sql.exec( 1448 + `SELECT data FROM blocks WHERE cid = ?`, commitCid 1449 ).toArray() 1450 + if (commitRows.length > 0) { 1451 + const commit = cborDecode(new Uint8Array(commitRows[0].data)) 1452 + if (commit.data) { 1453 + const dataRootCid = cidToString(commit.data) 1454 + // Collect MST path blocks (this includes all MST nodes) 1455 + const mstBlocks = this.collectMstBlocks(dataRootCid) 1456 + for (const block of mstBlocks) { 1457 + addBlock(block.cid) 1458 + } 1459 + } 1460 } 1461 + 1462 + // Add record block 1463 + addBlock(recordCid) 1464 + 1465 + const car = buildCarFile(commitCid, blocks) 1466 return new Response(car, { 1467 headers: { 'content-type': 'application/vnd.ipld.car' } 1468 }) ··· 1528 } 1529 1530 const response = await handleRequest(request, env) 1531 + // Don't wrap WebSocket upgrades - they need the webSocket property preserved 1532 + if (response.status === 101) { 1533 + return response 1534 + } 1535 return addCorsHeaders(response) 1536 } 1537 }