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 17 function parseArgs() { 18 18 const args = process.argv.slice(2) 19 19 const opts = { 20 - handle: null, 21 20 pds: null, 22 21 plcUrl: 'https://plc.directory', 23 22 relayUrl: 'https://bsky.network' 24 23 } 25 24 26 25 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]) { 26 + if (args[i] === '--pds' && args[i + 1]) { 30 27 opts.pds = args[++i] 31 28 } else if (args[i] === '--plc-url' && args[i + 1]) { 32 29 opts.plcUrl = args[++i] ··· 35 32 } 36 33 } 37 34 38 - if (!opts.handle || !opts.pds) { 39 - console.error('Usage: node scripts/setup.js --handle <handle> --pds <pds-url>') 35 + if (!opts.pds) { 36 + console.error('Usage: node scripts/setup.js --pds <pds-url>') 40 37 console.error('') 41 38 console.error('Options:') 42 - console.error(' --handle Handle name (e.g., "alice")') 43 39 console.error(' --pds PDS URL (e.g., "https://atproto-pds.chad-53c.workers.dev")') 44 40 console.error(' --plc-url PLC directory URL (default: https://plc.directory)') 45 41 console.error(' --relay-url Relay URL (default: https://bsky.network)') 46 42 process.exit(1) 47 43 } 44 + 45 + // Handle is just the PDS hostname 46 + opts.handle = new URL(opts.pds).host 48 47 49 48 return opts 50 49 } ··· 289 288 async function createGenesisOperation(opts) { 290 289 const { didKey, handle, pdsUrl, cryptoKey } = opts 291 290 292 - // Build the full handle 293 - const pdsHost = new URL(pdsUrl).host 294 - const fullHandle = `${handle}.${pdsHost}` 295 - 291 + // Handle is already the full hostname 296 292 const operation = { 297 293 type: 'plc_operation', 298 294 rotationKeys: [didKey], 299 295 verificationMethods: { 300 296 atproto: didKey 301 297 }, 302 - alsoKnownAs: [`at://${fullHandle}`], 298 + alsoKnownAs: [`at://${handle}`], 303 299 services: { 304 300 atproto_pds: { 305 301 type: 'AtprotoPersonalDataServer', ··· 312 308 // Sign the operation 313 309 operation.sig = await signPlcOperation(operation, cryptoKey) 314 310 315 - return { operation, fullHandle } 311 + return { operation, handle } 316 312 } 317 313 318 314 async function deriveDidFromOperation(operation) { ··· 429 425 430 426 console.log('PDS Federation Setup') 431 427 console.log('====================') 432 - console.log(`Handle: ${opts.handle}`) 433 428 console.log(`PDS: ${opts.pds}`) 434 429 console.log('') 435 430 ··· 442 437 443 438 // Step 2: Create genesis operation 444 439 console.log('Creating PLC genesis operation...') 445 - const { operation, fullHandle } = await createGenesisOperation({ 440 + const { operation, handle } = await createGenesisOperation({ 446 441 didKey, 447 442 handle: opts.handle, 448 443 pdsUrl: opts.pds, ··· 450 445 }) 451 446 const did = await deriveDidFromOperation(operation) 452 447 console.log(` DID: ${did}`) 453 - console.log(` Handle: ${fullHandle}`) 448 + console.log(` Handle: ${handle}`) 454 449 console.log('') 455 450 456 451 // Step 3: Register with PLC directory ··· 462 457 // Step 4: Initialize PDS 463 458 console.log(`Initializing PDS at ${opts.pds}...`) 464 459 const privateKeyHex = bytesToHex(keyPair.privateKey) 465 - await initializePds(opts.pds, did, privateKeyHex, fullHandle) 460 + await initializePds(opts.pds, did, privateKeyHex, handle) 466 461 console.log(' PDS initialized!') 467 462 console.log('') 468 463 ··· 477 472 478 473 // Step 6: Save credentials 479 474 const credentials = { 480 - handle: fullHandle, 475 + handle, 481 476 did, 482 477 privateKeyHex: bytesToHex(keyPair.privateKey), 483 478 didKey, ··· 485 480 createdAt: new Date().toISOString() 486 481 } 487 482 488 - const credentialsFile = `./credentials-${opts.handle}.json` 483 + const credentialsFile = `./credentials.json` 489 484 saveCredentials(credentialsFile, credentials) 490 485 491 486 // Final output 492 487 console.log('Setup Complete!') 493 488 console.log('===============') 494 - console.log(`Handle: ${fullHandle}`) 489 + console.log(`Handle: ${handle}`) 495 490 console.log(`DID: ${did}`) 496 491 console.log(`PDS: ${opts.pds}`) 497 492 console.log('')
+62 -12
src/pds.js
··· 216 216 } 217 217 return obj 218 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 + } 219 228 case 7: { // special 220 229 if (info === 20) return false 221 230 if (info === 21) return true ··· 1014 1023 rkey = rkey || createTid() 1015 1024 const uri = `at://${did}/${collection}/${rkey}` 1016 1025 1017 - // Encode and hash record 1018 - const recordBytes = cborEncode(record) 1026 + // Encode and hash record (must use DAG-CBOR for proper key ordering) 1027 + const recordBytes = cborEncodeDagCbor(record) 1019 1028 const recordCid = await createCid(recordBytes) 1020 1029 const recordCidStr = cidToString(recordCid) 1021 1030 ··· 1404 1413 if (rows.length === 0) { 1405 1414 return Response.json({ error: 'RecordNotFound', message: 'record not found' }, { status: 404 }) 1406 1415 } 1407 - // Get the record block 1408 1416 const recordCid = rows[0].cid 1409 - const blockRows = this.sql.exec( 1410 - `SELECT cid, data FROM blocks WHERE cid = ?`, recordCid 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 1411 1449 ).toArray() 1412 - if (blockRows.length === 0) { 1413 - return Response.json({ error: 'RecordNotFound', message: 'block not found' }, { status: 404 }) 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 + } 1414 1460 } 1415 - const blocks = blockRows.map(b => ({ 1416 - cid: b.cid, 1417 - data: new Uint8Array(b.data) 1418 - })) 1419 - const car = buildCarFile(recordCid, blocks) 1461 + 1462 + // Add record block 1463 + addBlock(recordCid) 1464 + 1465 + const car = buildCarFile(commitCid, blocks) 1420 1466 return new Response(car, { 1421 1467 headers: { 'content-type': 'application/vnd.ipld.car' } 1422 1468 }) ··· 1482 1528 } 1483 1529 1484 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 + } 1485 1535 return addCorsHeaders(response) 1486 1536 } 1487 1537 }