this repo has no description
1// === CONSTANTS === 2// CBOR primitive markers (RFC 8949) 3const CBOR_FALSE = 0xf4 4const CBOR_TRUE = 0xf5 5const CBOR_NULL = 0xf6 6 7// DAG-CBOR CID link tag 8const CBOR_TAG_CID = 42 9 10// === CID WRAPPER === 11// Explicit CID type for DAG-CBOR encoding (avoids fragile heuristic detection) 12 13class CID { 14 constructor(bytes) { 15 if (!(bytes instanceof Uint8Array)) { 16 throw new Error('CID must be constructed with Uint8Array') 17 } 18 this.bytes = bytes 19 } 20} 21 22// === CBOR ENCODING === 23// Minimal deterministic CBOR (RFC 8949) - sorted keys, minimal integers 24 25/** 26 * Encode CBOR type header (major type + length) 27 * @param {number[]} parts - Array to push bytes to 28 * @param {number} majorType - CBOR major type (0-7) 29 * @param {number} length - Value or length to encode 30 */ 31function encodeHead(parts, majorType, length) { 32 const mt = majorType << 5 33 if (length < 24) { 34 parts.push(mt | length) 35 } else if (length < 256) { 36 parts.push(mt | 24, length) 37 } else if (length < 65536) { 38 parts.push(mt | 25, length >> 8, length & 0xff) 39 } else if (length < 4294967296) { 40 // Use Math.floor instead of bitshift to avoid 32-bit signed integer overflow 41 parts.push(mt | 26, 42 Math.floor(length / 0x1000000) & 0xff, 43 Math.floor(length / 0x10000) & 0xff, 44 Math.floor(length / 0x100) & 0xff, 45 length & 0xff) 46 } 47} 48 49/** 50 * Encode a value as CBOR bytes (RFC 8949 deterministic encoding) 51 * @param {*} value - Value to encode (null, boolean, number, string, Uint8Array, array, or object) 52 * @returns {Uint8Array} CBOR-encoded bytes 53 */ 54export function cborEncode(value) { 55 const parts = [] 56 57 function encode(val) { 58 if (val === null) { 59 parts.push(CBOR_NULL) 60 } else if (val === true) { 61 parts.push(CBOR_TRUE) 62 } else if (val === false) { 63 parts.push(CBOR_FALSE) 64 } else if (typeof val === 'number') { 65 encodeInteger(val) 66 } else if (typeof val === 'string') { 67 const bytes = new TextEncoder().encode(val) 68 encodeHead(parts, 3, bytes.length) // major type 3 = text string 69 parts.push(...bytes) 70 } else if (val instanceof Uint8Array) { 71 encodeHead(parts, 2, val.length) // major type 2 = byte string 72 parts.push(...val) 73 } else if (Array.isArray(val)) { 74 encodeHead(parts, 4, val.length) // major type 4 = array 75 for (const item of val) encode(item) 76 } else if (typeof val === 'object') { 77 // Sort keys for deterministic encoding 78 const keys = Object.keys(val).sort() 79 encodeHead(parts, 5, keys.length) // major type 5 = map 80 for (const key of keys) { 81 encode(key) 82 encode(val[key]) 83 } 84 } 85 } 86 87 function encodeInteger(n) { 88 if (n >= 0) { 89 encodeHead(parts, 0, n) // major type 0 = unsigned int 90 } else { 91 encodeHead(parts, 1, -n - 1) // major type 1 = negative int 92 } 93 } 94 95 encode(value) 96 return new Uint8Array(parts) 97} 98 99// DAG-CBOR encoder that handles CIDs with tag 42 100function cborEncodeDagCbor(value) { 101 const parts = [] 102 103 function encode(val) { 104 if (val === null) { 105 parts.push(CBOR_NULL) 106 } else if (val === true) { 107 parts.push(CBOR_TRUE) 108 } else if (val === false) { 109 parts.push(CBOR_FALSE) 110 } else if (typeof val === 'number') { 111 if (Number.isInteger(val) && val >= 0) { 112 encodeHead(parts, 0, val) 113 } else if (Number.isInteger(val) && val < 0) { 114 encodeHead(parts, 1, -val - 1) 115 } 116 } else if (typeof val === 'string') { 117 const bytes = new TextEncoder().encode(val) 118 encodeHead(parts, 3, bytes.length) 119 parts.push(...bytes) 120 } else if (val instanceof CID) { 121 // CID links in DAG-CBOR use tag 42 + 0x00 multibase prefix 122 // The 0x00 prefix indicates "identity" multibase (raw bytes) 123 parts.push(0xd8, CBOR_TAG_CID) 124 encodeHead(parts, 2, val.bytes.length + 1) // +1 for 0x00 prefix 125 parts.push(0x00) 126 parts.push(...val.bytes) 127 } else if (val instanceof Uint8Array) { 128 // Regular byte string 129 encodeHead(parts, 2, val.length) 130 parts.push(...val) 131 } else if (Array.isArray(val)) { 132 encodeHead(parts, 4, val.length) 133 for (const item of val) encode(item) 134 } else if (typeof val === 'object') { 135 // DAG-CBOR: sort keys by length first, then lexicographically 136 // (differs from standard CBOR which sorts lexicographically only) 137 const keys = Object.keys(val).filter(k => val[k] !== undefined) 138 keys.sort((a, b) => { 139 if (a.length !== b.length) return a.length - b.length 140 return a < b ? -1 : a > b ? 1 : 0 141 }) 142 encodeHead(parts, 5, keys.length) 143 for (const key of keys) { 144 const keyBytes = new TextEncoder().encode(key) 145 encodeHead(parts, 3, keyBytes.length) 146 parts.push(...keyBytes) 147 encode(val[key]) 148 } 149 } 150 } 151 152 encode(value) 153 return new Uint8Array(parts) 154} 155 156/** 157 * Decode CBOR bytes to a JavaScript value 158 * @param {Uint8Array} bytes - CBOR-encoded bytes 159 * @returns {*} Decoded value 160 */ 161export function cborDecode(bytes) { 162 let offset = 0 163 164 function read() { 165 const initial = bytes[offset++] 166 const major = initial >> 5 167 const info = initial & 0x1f 168 169 let length = info 170 if (info === 24) length = bytes[offset++] 171 else if (info === 25) { length = (bytes[offset++] << 8) | bytes[offset++] } 172 else if (info === 26) { 173 // Use multiplication instead of bitshift to avoid 32-bit signed integer overflow 174 length = bytes[offset++] * 0x1000000 + bytes[offset++] * 0x10000 + bytes[offset++] * 0x100 + bytes[offset++] 175 } 176 177 switch (major) { 178 case 0: return length // unsigned int 179 case 1: return -1 - length // negative int 180 case 2: { // byte string 181 const data = bytes.slice(offset, offset + length) 182 offset += length 183 return data 184 } 185 case 3: { // text string 186 const data = new TextDecoder().decode(bytes.slice(offset, offset + length)) 187 offset += length 188 return data 189 } 190 case 4: { // array 191 const arr = [] 192 for (let i = 0; i < length; i++) arr.push(read()) 193 return arr 194 } 195 case 5: { // map 196 const obj = {} 197 for (let i = 0; i < length; i++) { 198 const key = read() 199 obj[key] = read() 200 } 201 return obj 202 } 203 case 7: { // special 204 if (info === 20) return false 205 if (info === 21) return true 206 if (info === 22) return null 207 return undefined 208 } 209 } 210 } 211 212 return read() 213} 214 215// === CID GENERATION === 216// dag-cbor (0x71) + sha-256 (0x12) + 32 bytes 217 218/** 219 * Create a CIDv1 (dag-cbor + sha-256) from raw bytes 220 * @param {Uint8Array} bytes - Content to hash 221 * @returns {Promise<Uint8Array>} CID bytes (36 bytes: version + codec + multihash) 222 */ 223export async function createCid(bytes) { 224 const hash = await crypto.subtle.digest('SHA-256', bytes) 225 const hashBytes = new Uint8Array(hash) 226 227 // CIDv1: version(1) + codec(dag-cbor=0x71) + multihash(sha256) 228 // Multihash: hash-type(0x12) + length(0x20=32) + digest 229 const cid = new Uint8Array(2 + 2 + 32) 230 cid[0] = 0x01 // CIDv1 231 cid[1] = 0x71 // dag-cbor codec 232 cid[2] = 0x12 // sha-256 233 cid[3] = 0x20 // 32 bytes 234 cid.set(hashBytes, 4) 235 236 return cid 237} 238 239/** 240 * Convert CID bytes to base32lower string representation 241 * @param {Uint8Array} cid - CID bytes 242 * @returns {string} Base32lower-encoded CID with 'b' prefix 243 */ 244export function cidToString(cid) { 245 // base32lower encoding for CIDv1 246 return 'b' + base32Encode(cid) 247} 248 249/** 250 * Encode bytes as base32lower string 251 * @param {Uint8Array} bytes - Bytes to encode 252 * @returns {string} Base32lower-encoded string 253 */ 254export function base32Encode(bytes) { 255 const alphabet = 'abcdefghijklmnopqrstuvwxyz234567' 256 let result = '' 257 let bits = 0 258 let value = 0 259 260 for (const byte of bytes) { 261 value = (value << 8) | byte 262 bits += 8 263 while (bits >= 5) { 264 bits -= 5 265 result += alphabet[(value >> bits) & 31] 266 } 267 } 268 269 if (bits > 0) { 270 result += alphabet[(value << (5 - bits)) & 31] 271 } 272 273 return result 274} 275 276// === TID GENERATION === 277// Timestamp-based IDs: base32-sort encoded microseconds + clock ID 278 279const TID_CHARS = '234567abcdefghijklmnopqrstuvwxyz' 280let lastTimestamp = 0 281let clockId = Math.floor(Math.random() * 1024) 282 283/** 284 * Generate a timestamp-based ID (TID) for record keys 285 * Monotonic within a process, sortable by time 286 * @returns {string} 13-character base32-sort encoded TID 287 */ 288export function createTid() { 289 let timestamp = Date.now() * 1000 // microseconds 290 291 // Ensure monotonic 292 if (timestamp <= lastTimestamp) { 293 timestamp = lastTimestamp + 1 294 } 295 lastTimestamp = timestamp 296 297 // 13 chars: 11 for timestamp (64 bits but only ~53 used), 2 for clock ID 298 let tid = '' 299 300 // Encode timestamp (high bits first for sortability) 301 let ts = timestamp 302 for (let i = 0; i < 11; i++) { 303 tid = TID_CHARS[ts & 31] + tid 304 ts = Math.floor(ts / 32) 305 } 306 307 // Append clock ID (2 chars) 308 tid += TID_CHARS[(clockId >> 5) & 31] 309 tid += TID_CHARS[clockId & 31] 310 311 return tid 312} 313 314// === P-256 SIGNING === 315// Web Crypto ECDSA with P-256 curve 316 317/** 318 * Import a raw P-256 private key for signing 319 * @param {Uint8Array} privateKeyBytes - 32-byte raw private key 320 * @returns {Promise<CryptoKey>} Web Crypto key handle 321 */ 322export async function importPrivateKey(privateKeyBytes) { 323 // Validate private key length (P-256 requires exactly 32 bytes) 324 if (!(privateKeyBytes instanceof Uint8Array) || privateKeyBytes.length !== 32) { 325 throw new Error(`Invalid private key: expected 32 bytes, got ${privateKeyBytes?.length ?? 'non-Uint8Array'}`) 326 } 327 328 // PKCS#8 wrapper for raw P-256 private key 329 const pkcs8Prefix = new Uint8Array([ 330 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 331 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 332 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20 333 ]) 334 335 const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32) 336 pkcs8.set(pkcs8Prefix) 337 pkcs8.set(privateKeyBytes, pkcs8Prefix.length) 338 339 return crypto.subtle.importKey( 340 'pkcs8', 341 pkcs8, 342 { name: 'ECDSA', namedCurve: 'P-256' }, 343 false, 344 ['sign'] 345 ) 346} 347 348// P-256 curve order N 349const P256_N = BigInt('0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551') 350const P256_N_DIV_2 = P256_N / 2n 351 352function bytesToBigInt(bytes) { 353 let result = 0n 354 for (const byte of bytes) { 355 result = (result << 8n) | BigInt(byte) 356 } 357 return result 358} 359 360function bigIntToBytes(n, length) { 361 const bytes = new Uint8Array(length) 362 for (let i = length - 1; i >= 0; i--) { 363 bytes[i] = Number(n & 0xffn) 364 n >>= 8n 365 } 366 return bytes 367} 368 369/** 370 * Sign data with ECDSA P-256, returning low-S normalized signature 371 * @param {CryptoKey} privateKey - Web Crypto key from importPrivateKey 372 * @param {Uint8Array} data - Data to sign 373 * @returns {Promise<Uint8Array>} 64-byte signature (r || s) 374 */ 375export async function sign(privateKey, data) { 376 const signature = await crypto.subtle.sign( 377 { name: 'ECDSA', hash: 'SHA-256' }, 378 privateKey, 379 data 380 ) 381 const sig = new Uint8Array(signature) 382 383 const r = sig.slice(0, 32) 384 const s = sig.slice(32, 64) 385 const sBigInt = bytesToBigInt(s) 386 387 // Low-S normalization: Bitcoin/ATProto require S <= N/2 to prevent 388 // signature malleability (two valid signatures for same message) 389 if (sBigInt > P256_N_DIV_2) { 390 const newS = P256_N - sBigInt 391 const newSBytes = bigIntToBytes(newS, 32) 392 const normalized = new Uint8Array(64) 393 normalized.set(r, 0) 394 normalized.set(newSBytes, 32) 395 return normalized 396 } 397 398 return sig 399} 400 401/** 402 * Generate a new P-256 key pair 403 * @returns {Promise<{privateKey: Uint8Array, publicKey: Uint8Array}>} 32-byte private key, 33-byte compressed public key 404 */ 405export async function generateKeyPair() { 406 const keyPair = await crypto.subtle.generateKey( 407 { name: 'ECDSA', namedCurve: 'P-256' }, 408 true, 409 ['sign', 'verify'] 410 ) 411 412 // Export private key as raw bytes 413 const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey) 414 const privateBytes = base64UrlDecode(privateJwk.d) 415 416 // Export public key as compressed point 417 const publicRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey) 418 const publicBytes = new Uint8Array(publicRaw) 419 const compressed = compressPublicKey(publicBytes) 420 421 return { privateKey: privateBytes, publicKey: compressed } 422} 423 424function compressPublicKey(uncompressed) { 425 // uncompressed is 65 bytes: 0x04 + x(32) + y(32) 426 // compressed is 33 bytes: prefix(02 or 03) + x(32) 427 const x = uncompressed.slice(1, 33) 428 const y = uncompressed.slice(33, 65) 429 const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03 430 const compressed = new Uint8Array(33) 431 compressed[0] = prefix 432 compressed.set(x, 1) 433 return compressed 434} 435 436function base64UrlDecode(str) { 437 const base64 = str.replace(/-/g, '+').replace(/_/g, '/') 438 const binary = atob(base64) 439 const bytes = new Uint8Array(binary.length) 440 for (let i = 0; i < binary.length; i++) { 441 bytes[i] = binary.charCodeAt(i) 442 } 443 return bytes 444} 445 446/** 447 * Convert bytes to hexadecimal string 448 * @param {Uint8Array} bytes - Bytes to convert 449 * @returns {string} Hex string 450 */ 451export function bytesToHex(bytes) { 452 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') 453} 454 455/** 456 * Convert hexadecimal string to bytes 457 * @param {string} hex - Hex string 458 * @returns {Uint8Array} Decoded bytes 459 */ 460export function hexToBytes(hex) { 461 const bytes = new Uint8Array(hex.length / 2) 462 for (let i = 0; i < hex.length; i += 2) { 463 bytes[i / 2] = parseInt(hex.substr(i, 2), 16) 464 } 465 return bytes 466} 467 468// === MERKLE SEARCH TREE === 469// ATProto-compliant MST implementation 470 471async function sha256(data) { 472 const hash = await crypto.subtle.digest('SHA-256', data) 473 return new Uint8Array(hash) 474} 475 476// Cache for key depths (SHA-256 is expensive) 477const keyDepthCache = new Map() 478 479/** 480 * Get MST tree depth for a key based on leading zeros in SHA-256 hash 481 * @param {string} key - Record key (collection/rkey) 482 * @returns {Promise<number>} Tree depth (leading zeros / 2) 483 */ 484export async function getKeyDepth(key) { 485 // Count leading zeros in SHA-256 hash, divide by 2 486 if (keyDepthCache.has(key)) return keyDepthCache.get(key) 487 488 const keyBytes = new TextEncoder().encode(key) 489 const hash = await sha256(keyBytes) 490 491 let zeros = 0 492 for (const byte of hash) { 493 if (byte === 0) { 494 zeros += 8 495 } else { 496 // Count leading zeros in this byte 497 for (let i = 7; i >= 0; i--) { 498 if ((byte >> i) & 1) break 499 zeros++ 500 } 501 break 502 } 503 } 504 505 // MST depth = leading zeros in SHA-256 hash / 2 506 // This creates a probabilistic tree where ~50% of keys are at depth 0, 507 // ~25% at depth 1, etc., giving O(log n) lookups 508 const depth = Math.floor(zeros / 2) 509 keyDepthCache.set(key, depth) 510 return depth 511} 512 513// Compute common prefix length between two byte arrays 514function commonPrefixLen(a, b) { 515 const minLen = Math.min(a.length, b.length) 516 for (let i = 0; i < minLen; i++) { 517 if (a[i] !== b[i]) return i 518 } 519 return minLen 520} 521 522class MST { 523 constructor(sql) { 524 this.sql = sql 525 } 526 527 async computeRoot() { 528 const records = this.sql.exec(` 529 SELECT collection, rkey, cid FROM records ORDER BY collection, rkey 530 `).toArray() 531 532 if (records.length === 0) { 533 return null 534 } 535 536 // Build entries with pre-computed depths 537 const entries = [] 538 for (const r of records) { 539 const key = `${r.collection}/${r.rkey}` 540 entries.push({ 541 key, 542 keyBytes: new TextEncoder().encode(key), 543 cid: r.cid, 544 depth: await getKeyDepth(key) 545 }) 546 } 547 548 return this.buildTree(entries, 0) 549 } 550 551 async buildTree(entries, layer) { 552 if (entries.length === 0) return null 553 554 // Separate entries for this layer vs deeper layers 555 const thisLayer = [] 556 let leftSubtree = [] 557 558 for (const entry of entries) { 559 if (entry.depth > layer) { 560 leftSubtree.push(entry) 561 } else { 562 // Process accumulated left subtree 563 if (leftSubtree.length > 0) { 564 const leftCid = await this.buildTree(leftSubtree, layer + 1) 565 thisLayer.push({ type: 'subtree', cid: leftCid }) 566 leftSubtree = [] 567 } 568 thisLayer.push({ type: 'entry', entry }) 569 } 570 } 571 572 // Handle remaining left subtree 573 if (leftSubtree.length > 0) { 574 const leftCid = await this.buildTree(leftSubtree, layer + 1) 575 thisLayer.push({ type: 'subtree', cid: leftCid }) 576 } 577 578 // Build node with proper ATProto format 579 const node = { e: [] } 580 let leftCid = null 581 let prevKeyBytes = new Uint8Array(0) 582 583 for (let i = 0; i < thisLayer.length; i++) { 584 const item = thisLayer[i] 585 586 if (item.type === 'subtree') { 587 if (node.e.length === 0) { 588 leftCid = item.cid 589 } else { 590 // Attach to previous entry's 't' field 591 node.e[node.e.length - 1].t = new CID(cidToBytes(item.cid)) 592 } 593 } else { 594 // Entry - compute prefix compression 595 const keyBytes = item.entry.keyBytes 596 const prefixLen = commonPrefixLen(prevKeyBytes, keyBytes) 597 const keySuffix = keyBytes.slice(prefixLen) 598 599 const e = { 600 p: prefixLen, 601 k: keySuffix, 602 v: new CID(cidToBytes(item.entry.cid)), 603 t: null // Always include t field (set later if subtree exists) 604 } 605 606 node.e.push(e) 607 prevKeyBytes = keyBytes 608 } 609 } 610 611 // Always include left pointer (can be null) 612 node.l = leftCid ? new CID(cidToBytes(leftCid)) : null 613 614 // Encode node with proper MST CBOR format 615 const nodeBytes = cborEncodeMstNode(node) 616 const nodeCid = await createCid(nodeBytes) 617 const cidStr = cidToString(nodeCid) 618 619 this.sql.exec( 620 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, 621 cidStr, 622 nodeBytes 623 ) 624 625 return cidStr 626 } 627} 628 629// Special CBOR encoder for MST nodes (CIDs as raw bytes with tag 42) 630function cborEncodeMstNode(node) { 631 const parts = [] 632 633 function encode(val) { 634 if (val === null || val === undefined) { 635 parts.push(CBOR_NULL) 636 } else if (typeof val === 'number') { 637 encodeHead(parts, 0, val) // unsigned int 638 } else if (val instanceof CID) { 639 // CID - encode with CBOR tag 42 + 0x00 prefix (DAG-CBOR CID link) 640 parts.push(0xd8, CBOR_TAG_CID) 641 encodeHead(parts, 2, val.bytes.length + 1) // +1 for 0x00 prefix 642 parts.push(0x00) // multibase identity prefix 643 parts.push(...val.bytes) 644 } else if (val instanceof Uint8Array) { 645 // Regular bytes 646 encodeHead(parts, 2, val.length) 647 parts.push(...val) 648 } else if (Array.isArray(val)) { 649 encodeHead(parts, 4, val.length) 650 for (const item of val) encode(item) 651 } else if (typeof val === 'object') { 652 // Sort keys for deterministic encoding (DAG-CBOR style) 653 // Include null values, only exclude undefined 654 const keys = Object.keys(val).filter(k => val[k] !== undefined) 655 keys.sort((a, b) => { 656 // DAG-CBOR: sort by length first, then lexicographically 657 if (a.length !== b.length) return a.length - b.length 658 return a < b ? -1 : a > b ? 1 : 0 659 }) 660 encodeHead(parts, 5, keys.length) 661 for (const key of keys) { 662 // Encode key as text string 663 const keyBytes = new TextEncoder().encode(key) 664 encodeHead(parts, 3, keyBytes.length) 665 parts.push(...keyBytes) 666 // Encode value 667 encode(val[key]) 668 } 669 } 670 } 671 672 encode(node) 673 return new Uint8Array(parts) 674} 675 676// === CAR FILE BUILDER === 677 678/** 679 * Encode integer as unsigned varint 680 * @param {number} n - Non-negative integer 681 * @returns {Uint8Array} Varint-encoded bytes 682 */ 683export function varint(n) { 684 const bytes = [] 685 while (n >= 0x80) { 686 bytes.push((n & 0x7f) | 0x80) 687 n >>>= 7 688 } 689 bytes.push(n) 690 return new Uint8Array(bytes) 691} 692 693/** 694 * Convert base32lower CID string to raw bytes 695 * @param {string} cidStr - CID string with 'b' prefix 696 * @returns {Uint8Array} CID bytes 697 */ 698export function cidToBytes(cidStr) { 699 // Decode base32lower CID string to bytes 700 if (!cidStr.startsWith('b')) throw new Error('expected base32lower CID') 701 return base32Decode(cidStr.slice(1)) 702} 703 704/** 705 * Decode base32lower string to bytes 706 * @param {string} str - Base32lower-encoded string 707 * @returns {Uint8Array} Decoded bytes 708 */ 709export function base32Decode(str) { 710 const alphabet = 'abcdefghijklmnopqrstuvwxyz234567' 711 let bits = 0 712 let value = 0 713 const output = [] 714 715 for (const char of str) { 716 const idx = alphabet.indexOf(char) 717 if (idx === -1) continue 718 value = (value << 5) | idx 719 bits += 5 720 if (bits >= 8) { 721 bits -= 8 722 output.push((value >> bits) & 0xff) 723 } 724 } 725 726 return new Uint8Array(output) 727} 728 729// Encode CAR header with proper DAG-CBOR CID links 730function cborEncodeCarHeader(obj) { 731 const parts = [] 732 733 function encodeHead(majorType, value) { 734 if (value < 24) { 735 parts.push((majorType << 5) | value) 736 } else if (value < 256) { 737 parts.push((majorType << 5) | 24, value) 738 } else if (value < 65536) { 739 parts.push((majorType << 5) | 25, value >> 8, value & 0xff) 740 } 741 } 742 743 function encodeCidLink(cidBytes) { 744 // DAG-CBOR CID link: tag(42) + byte string with 0x00 prefix 745 parts.push(0xd8, 42) // tag 42 746 const withPrefix = new Uint8Array(cidBytes.length + 1) 747 withPrefix[0] = 0x00 // multibase identity prefix 748 withPrefix.set(cidBytes, 1) 749 encodeHead(2, withPrefix.length) 750 parts.push(...withPrefix) 751 } 752 753 // Encode { roots: [...], version: 1 } 754 // Sort keys: "roots" (5 chars) comes after "version" (7 chars)? No - shorter first 755 // "roots" = 5 chars, "version" = 7 chars, so "roots" first 756 encodeHead(5, 2) // map with 2 entries 757 758 // Key "roots" 759 const rootsKey = new TextEncoder().encode('roots') 760 encodeHead(3, rootsKey.length) 761 parts.push(...rootsKey) 762 763 // Value: array of CID links 764 encodeHead(4, obj.roots.length) 765 for (const cid of obj.roots) { 766 encodeCidLink(cid) 767 } 768 769 // Key "version" 770 const versionKey = new TextEncoder().encode('version') 771 encodeHead(3, versionKey.length) 772 parts.push(...versionKey) 773 774 // Value: 1 775 parts.push(0x01) 776 777 return new Uint8Array(parts) 778} 779 780/** 781 * Build a CAR (Content Addressable aRchive) file 782 * @param {string} rootCid - Root CID string 783 * @param {Array<{cid: string, data: Uint8Array}>} blocks - Blocks to include 784 * @returns {Uint8Array} CAR file bytes 785 */ 786export function buildCarFile(rootCid, blocks) { 787 const parts = [] 788 789 // Header: { version: 1, roots: [rootCid] } 790 // CIDs in header must be DAG-CBOR links (tag 42 + 0x00 prefix + CID bytes) 791 const rootCidBytes = cidToBytes(rootCid) 792 const header = cborEncodeCarHeader({ version: 1, roots: [rootCidBytes] }) 793 parts.push(varint(header.length)) 794 parts.push(header) 795 796 // Blocks: varint(len) + cid + data 797 for (const block of blocks) { 798 const cidBytes = cidToBytes(block.cid) 799 const blockLen = cidBytes.length + block.data.length 800 parts.push(varint(blockLen)) 801 parts.push(cidBytes) 802 parts.push(block.data) 803 } 804 805 // Concatenate all parts 806 const totalLen = parts.reduce((sum, p) => sum + p.length, 0) 807 const car = new Uint8Array(totalLen) 808 let offset = 0 809 for (const part of parts) { 810 car.set(part, offset) 811 offset += part.length 812 } 813 814 return car 815} 816 817/** 818 * Route handler function type 819 * @callback RouteHandler 820 * @param {PersonalDataServer} pds - PDS instance 821 * @param {Request} request - HTTP request 822 * @param {URL} url - Parsed URL 823 * @returns {Promise<Response>} HTTP response 824 */ 825 826/** 827 * @typedef {Object} Route 828 * @property {string} [method] - Required HTTP method (default: any) 829 * @property {RouteHandler} handler - Handler function 830 */ 831 832/** @type {Record<string, Route>} */ 833const pdsRoutes = { 834 '/.well-known/atproto-did': { 835 handler: (pds, req, url) => pds.handleAtprotoDid() 836 }, 837 '/init': { 838 method: 'POST', 839 handler: (pds, req, url) => pds.handleInit(req) 840 }, 841 '/status': { 842 handler: (pds, req, url) => pds.handleStatus() 843 }, 844 '/reset-repo': { 845 handler: (pds, req, url) => pds.handleResetRepo() 846 }, 847 '/forward-event': { 848 handler: (pds, req, url) => pds.handleForwardEvent(req) 849 }, 850 '/register-did': { 851 handler: (pds, req, url) => pds.handleRegisterDid(req) 852 }, 853 '/get-registered-dids': { 854 handler: (pds, req, url) => pds.handleGetRegisteredDids() 855 }, 856 '/repo-info': { 857 handler: (pds, req, url) => pds.handleRepoInfo() 858 }, 859 '/xrpc/com.atproto.server.describeServer': { 860 handler: (pds, req, url) => pds.handleDescribeServer(req) 861 }, 862 '/xrpc/com.atproto.sync.listRepos': { 863 handler: (pds, req, url) => pds.handleListRepos() 864 }, 865 '/xrpc/com.atproto.repo.createRecord': { 866 method: 'POST', 867 handler: (pds, req, url) => pds.handleCreateRecord(req) 868 }, 869 '/xrpc/com.atproto.repo.getRecord': { 870 handler: (pds, req, url) => pds.handleGetRecord(url) 871 }, 872 '/xrpc/com.atproto.sync.getLatestCommit': { 873 handler: (pds, req, url) => pds.handleGetLatestCommit() 874 }, 875 '/xrpc/com.atproto.sync.getRepoStatus': { 876 handler: (pds, req, url) => pds.handleGetRepoStatus() 877 }, 878 '/xrpc/com.atproto.sync.getRepo': { 879 handler: (pds, req, url) => pds.handleGetRepo() 880 }, 881 '/xrpc/com.atproto.sync.subscribeRepos': { 882 handler: (pds, req, url) => pds.handleSubscribeRepos(req, url) 883 } 884} 885 886export class PersonalDataServer { 887 constructor(state, env) { 888 this.state = state 889 this.sql = state.storage.sql 890 this.env = env 891 892 // Initialize schema 893 this.sql.exec(` 894 CREATE TABLE IF NOT EXISTS blocks ( 895 cid TEXT PRIMARY KEY, 896 data BLOB NOT NULL 897 ); 898 899 CREATE TABLE IF NOT EXISTS records ( 900 uri TEXT PRIMARY KEY, 901 cid TEXT NOT NULL, 902 collection TEXT NOT NULL, 903 rkey TEXT NOT NULL, 904 value BLOB NOT NULL 905 ); 906 907 CREATE TABLE IF NOT EXISTS commits ( 908 seq INTEGER PRIMARY KEY AUTOINCREMENT, 909 cid TEXT NOT NULL, 910 rev TEXT NOT NULL, 911 prev TEXT 912 ); 913 914 CREATE TABLE IF NOT EXISTS seq_events ( 915 seq INTEGER PRIMARY KEY AUTOINCREMENT, 916 did TEXT NOT NULL, 917 commit_cid TEXT NOT NULL, 918 evt BLOB NOT NULL 919 ); 920 921 CREATE INDEX IF NOT EXISTS idx_records_collection ON records(collection, rkey); 922 `) 923 } 924 925 async initIdentity(did, privateKeyHex, handle = null) { 926 await this.state.storage.put('did', did) 927 await this.state.storage.put('privateKey', privateKeyHex) 928 if (handle) { 929 await this.state.storage.put('handle', handle) 930 } 931 } 932 933 async getDid() { 934 if (!this._did) { 935 this._did = await this.state.storage.get('did') 936 } 937 return this._did 938 } 939 940 async getHandle() { 941 return this.state.storage.get('handle') 942 } 943 944 async getSigningKey() { 945 const hex = await this.state.storage.get('privateKey') 946 if (!hex) return null 947 return importPrivateKey(hexToBytes(hex)) 948 } 949 950 // Collect MST node blocks for a given root CID 951 collectMstBlocks(rootCidStr) { 952 const blocks = [] 953 const visited = new Set() 954 955 const collect = (cidStr) => { 956 if (visited.has(cidStr)) return 957 visited.add(cidStr) 958 959 const rows = this.sql.exec( 960 `SELECT data FROM blocks WHERE cid = ?`, cidStr 961 ).toArray() 962 if (rows.length === 0) return 963 964 const data = new Uint8Array(rows[0].data) 965 blocks.push({ cid: cidStr, data }) // Keep as string, buildCarFile will convert 966 967 // Decode and follow child CIDs (MST nodes have 'l' and 'e' with 't' subtrees) 968 try { 969 const node = cborDecode(data) 970 if (node.l) collect(cidToString(node.l)) 971 if (node.e) { 972 for (const entry of node.e) { 973 if (entry.t) collect(cidToString(entry.t)) 974 } 975 } 976 } catch (e) { 977 // Not an MST node, ignore 978 } 979 } 980 981 collect(rootCidStr) 982 return blocks 983 } 984 985 async createRecord(collection, record, rkey = null) { 986 const did = await this.getDid() 987 if (!did) throw new Error('PDS not initialized') 988 989 rkey = rkey || createTid() 990 const uri = `at://${did}/${collection}/${rkey}` 991 992 // Encode and hash record 993 const recordBytes = cborEncode(record) 994 const recordCid = await createCid(recordBytes) 995 const recordCidStr = cidToString(recordCid) 996 997 // Store block 998 this.sql.exec( 999 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, 1000 recordCidStr, recordBytes 1001 ) 1002 1003 // Store record index 1004 this.sql.exec( 1005 `INSERT OR REPLACE INTO records (uri, cid, collection, rkey, value) VALUES (?, ?, ?, ?, ?)`, 1006 uri, recordCidStr, collection, rkey, recordBytes 1007 ) 1008 1009 // Rebuild MST 1010 const mst = new MST(this.sql) 1011 const dataRoot = await mst.computeRoot() 1012 1013 // Get previous commit 1014 const prevCommits = this.sql.exec( 1015 `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1` 1016 ).toArray() 1017 const prevCommit = prevCommits.length > 0 ? prevCommits[0] : null 1018 1019 // Create commit 1020 const rev = createTid() 1021 // Build commit with CIDs wrapped in CID class (for dag-cbor tag 42 encoding) 1022 const commit = { 1023 did, 1024 version: 3, 1025 data: new CID(cidToBytes(dataRoot)), // CID wrapped for explicit encoding 1026 rev, 1027 prev: prevCommit?.cid ? new CID(cidToBytes(prevCommit.cid)) : null 1028 } 1029 1030 // Sign commit (using dag-cbor encoder for CIDs) 1031 const commitBytes = cborEncodeDagCbor(commit) 1032 const signingKey = await this.getSigningKey() 1033 const sig = await sign(signingKey, commitBytes) 1034 1035 const signedCommit = { ...commit, sig } 1036 const signedBytes = cborEncodeDagCbor(signedCommit) 1037 const commitCid = await createCid(signedBytes) 1038 const commitCidStr = cidToString(commitCid) 1039 1040 // Store commit block 1041 this.sql.exec( 1042 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, 1043 commitCidStr, signedBytes 1044 ) 1045 1046 // Store commit reference 1047 this.sql.exec( 1048 `INSERT INTO commits (cid, rev, prev) VALUES (?, ?, ?)`, 1049 commitCidStr, rev, prevCommit?.cid || null 1050 ) 1051 1052 // Update head and rev for listRepos 1053 await this.state.storage.put('head', commitCidStr) 1054 await this.state.storage.put('rev', rev) 1055 1056 // Collect blocks for the event (record + commit + MST nodes) 1057 // Build a mini CAR with just the new blocks - use string CIDs 1058 const newBlocks = [] 1059 // Add record block 1060 newBlocks.push({ cid: recordCidStr, data: recordBytes }) 1061 // Add commit block 1062 newBlocks.push({ cid: commitCidStr, data: signedBytes }) 1063 // Add MST node blocks (get all blocks referenced by commit.data) 1064 const mstBlocks = this.collectMstBlocks(dataRoot) 1065 newBlocks.push(...mstBlocks) 1066 1067 // Sequence event with blocks - store complete event data including rev and time 1068 // blocks must be a full CAR file with header (roots = [commitCid]) 1069 const eventTime = new Date().toISOString() 1070 const evt = cborEncode({ 1071 ops: [{ action: 'create', path: `${collection}/${rkey}`, cid: recordCidStr }], 1072 blocks: buildCarFile(commitCidStr, newBlocks), // Full CAR with header 1073 rev, // Store the actual commit revision 1074 time: eventTime // Store the actual event time 1075 }) 1076 this.sql.exec( 1077 `INSERT INTO seq_events (did, commit_cid, evt) VALUES (?, ?, ?)`, 1078 did, commitCidStr, evt 1079 ) 1080 1081 // Broadcast to subscribers (both local and via default DO for relay) 1082 const evtRows = this.sql.exec( 1083 `SELECT * FROM seq_events ORDER BY seq DESC LIMIT 1` 1084 ).toArray() 1085 if (evtRows.length > 0) { 1086 this.broadcastEvent(evtRows[0]) 1087 // Also forward to default DO for relay subscribers 1088 if (this.env?.PDS) { 1089 const defaultId = this.env.PDS.idFromName('default') 1090 const defaultPds = this.env.PDS.get(defaultId) 1091 // Convert ArrayBuffer to array for JSON serialization 1092 const row = evtRows[0] 1093 const evtArray = Array.from(new Uint8Array(row.evt)) 1094 // Fire and forget but log errors 1095 defaultPds.fetch(new Request('http://internal/forward-event', { 1096 method: 'POST', 1097 body: JSON.stringify({ ...row, evt: evtArray }) 1098 })).then(r => r.json()).then(r => console.log('forward result:', r)).catch(e => console.log('forward error:', e)) 1099 } 1100 } 1101 1102 return { uri, cid: recordCidStr, commit: commitCidStr } 1103 } 1104 1105 formatEvent(evt) { 1106 // AT Protocol frame format: header + body 1107 // Use DAG-CBOR encoding for body (CIDs need tag 42 + 0x00 prefix) 1108 const header = cborEncode({ op: 1, t: '#commit' }) 1109 1110 // Decode stored event to get ops, blocks, rev, and time 1111 const evtData = cborDecode(new Uint8Array(evt.evt)) 1112 const ops = evtData.ops.map(op => ({ 1113 ...op, 1114 cid: op.cid ? new CID(cidToBytes(op.cid)) : null // Wrap in CID class for tag 42 encoding 1115 })) 1116 // Get blocks from stored event (already in CAR format) 1117 const blocks = evtData.blocks || new Uint8Array(0) 1118 1119 const body = cborEncodeDagCbor({ 1120 seq: evt.seq, 1121 rebase: false, 1122 tooBig: false, 1123 repo: evt.did, 1124 commit: new CID(cidToBytes(evt.commit_cid)), // Wrap in CID class for tag 42 encoding 1125 rev: evtData.rev, // Use stored rev from commit creation 1126 since: null, 1127 blocks: blocks instanceof Uint8Array ? blocks : new Uint8Array(blocks), 1128 ops, 1129 blobs: [], 1130 time: evtData.time // Use stored time from event creation 1131 }) 1132 1133 // Concatenate header + body 1134 const frame = new Uint8Array(header.length + body.length) 1135 frame.set(header) 1136 frame.set(body, header.length) 1137 return frame 1138 } 1139 1140 async webSocketMessage(ws, message) { 1141 // Handle ping 1142 if (message === 'ping') ws.send('pong') 1143 } 1144 1145 async webSocketClose(ws, code, reason) { 1146 // Durable Object will hibernate when no connections remain 1147 } 1148 1149 broadcastEvent(evt) { 1150 const frame = this.formatEvent(evt) 1151 for (const ws of this.state.getWebSockets()) { 1152 try { 1153 ws.send(frame) 1154 } catch (e) { 1155 // Client disconnected 1156 } 1157 } 1158 } 1159 1160 async handleAtprotoDid() { 1161 let did = await this.getDid() 1162 if (!did) { 1163 const registeredDids = await this.state.storage.get('registeredDids') || [] 1164 did = registeredDids[0] 1165 } 1166 if (!did) { 1167 return new Response('User not found', { status: 404 }) 1168 } 1169 return new Response(did, { headers: { 'Content-Type': 'text/plain' } }) 1170 } 1171 1172 async handleInit(request) { 1173 const body = await request.json() 1174 if (!body.did || !body.privateKey) { 1175 return Response.json({ error: 'missing did or privateKey' }, { status: 400 }) 1176 } 1177 await this.initIdentity(body.did, body.privateKey, body.handle || null) 1178 return Response.json({ ok: true, did: body.did, handle: body.handle || null }) 1179 } 1180 1181 async handleStatus() { 1182 const did = await this.getDid() 1183 return Response.json({ initialized: !!did, did: did || null }) 1184 } 1185 1186 async handleResetRepo() { 1187 this.sql.exec(`DELETE FROM blocks`) 1188 this.sql.exec(`DELETE FROM records`) 1189 this.sql.exec(`DELETE FROM commits`) 1190 this.sql.exec(`DELETE FROM seq_events`) 1191 await this.state.storage.delete('head') 1192 await this.state.storage.delete('rev') 1193 return Response.json({ ok: true, message: 'repo data cleared' }) 1194 } 1195 1196 async handleForwardEvent(request) { 1197 const evt = await request.json() 1198 const numSockets = [...this.state.getWebSockets()].length 1199 console.log(`forward-event: received event seq=${evt.seq}, ${numSockets} connected sockets`) 1200 this.broadcastEvent({ 1201 seq: evt.seq, 1202 did: evt.did, 1203 commit_cid: evt.commit_cid, 1204 evt: new Uint8Array(Object.values(evt.evt)) 1205 }) 1206 return Response.json({ ok: true, sockets: numSockets }) 1207 } 1208 1209 async handleRegisterDid(request) { 1210 const body = await request.json() 1211 const registeredDids = await this.state.storage.get('registeredDids') || [] 1212 if (!registeredDids.includes(body.did)) { 1213 registeredDids.push(body.did) 1214 await this.state.storage.put('registeredDids', registeredDids) 1215 } 1216 return Response.json({ ok: true }) 1217 } 1218 1219 async handleGetRegisteredDids() { 1220 const registeredDids = await this.state.storage.get('registeredDids') || [] 1221 return Response.json({ dids: registeredDids }) 1222 } 1223 1224 async handleRepoInfo() { 1225 const head = await this.state.storage.get('head') 1226 const rev = await this.state.storage.get('rev') 1227 return Response.json({ head: head || null, rev: rev || null }) 1228 } 1229 1230 handleDescribeServer(request) { 1231 const hostname = request.headers.get('x-hostname') || 'localhost' 1232 return Response.json({ 1233 did: `did:web:${hostname}`, 1234 availableUserDomains: [`.${hostname}`], 1235 inviteCodeRequired: false, 1236 phoneVerificationRequired: false, 1237 links: {}, 1238 contact: {} 1239 }) 1240 } 1241 1242 async handleListRepos() { 1243 const registeredDids = await this.state.storage.get('registeredDids') || [] 1244 const did = await this.getDid() 1245 const repos = did ? [{ did, head: null, rev: null }] : 1246 registeredDids.map(d => ({ did: d, head: null, rev: null })) 1247 return Response.json({ repos }) 1248 } 1249 1250 async handleCreateRecord(request) { 1251 const body = await request.json() 1252 if (!body.collection || !body.record) { 1253 return Response.json({ error: 'missing collection or record' }, { status: 400 }) 1254 } 1255 try { 1256 const result = await this.createRecord(body.collection, body.record, body.rkey) 1257 return Response.json(result) 1258 } catch (err) { 1259 return Response.json({ error: err.message }, { status: 500 }) 1260 } 1261 } 1262 1263 async handleGetRecord(url) { 1264 const collection = url.searchParams.get('collection') 1265 const rkey = url.searchParams.get('rkey') 1266 if (!collection || !rkey) { 1267 return Response.json({ error: 'missing collection or rkey' }, { status: 400 }) 1268 } 1269 const did = await this.getDid() 1270 const uri = `at://${did}/${collection}/${rkey}` 1271 const rows = this.sql.exec( 1272 `SELECT cid, value FROM records WHERE uri = ?`, uri 1273 ).toArray() 1274 if (rows.length === 0) { 1275 return Response.json({ error: 'record not found' }, { status: 404 }) 1276 } 1277 const row = rows[0] 1278 const value = cborDecode(new Uint8Array(row.value)) 1279 return Response.json({ uri, cid: row.cid, value }) 1280 } 1281 1282 handleGetLatestCommit() { 1283 const commits = this.sql.exec( 1284 `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1` 1285 ).toArray() 1286 if (commits.length === 0) { 1287 return Response.json({ error: 'RepoNotFound', message: 'repo not found' }, { status: 404 }) 1288 } 1289 return Response.json({ cid: commits[0].cid, rev: commits[0].rev }) 1290 } 1291 1292 async handleGetRepoStatus() { 1293 const did = await this.getDid() 1294 const commits = this.sql.exec( 1295 `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1` 1296 ).toArray() 1297 if (commits.length === 0 || !did) { 1298 return Response.json({ error: 'RepoNotFound', message: 'repo not found' }, { status: 404 }) 1299 } 1300 return Response.json({ did, active: true, status: 'active', rev: commits[0].rev }) 1301 } 1302 1303 handleGetRepo() { 1304 const commits = this.sql.exec( 1305 `SELECT cid FROM commits ORDER BY seq DESC LIMIT 1` 1306 ).toArray() 1307 if (commits.length === 0) { 1308 return Response.json({ error: 'repo not found' }, { status: 404 }) 1309 } 1310 const blocks = this.sql.exec(`SELECT cid, data FROM blocks`).toArray() 1311 const blocksForCar = blocks.map(b => ({ 1312 cid: b.cid, 1313 data: new Uint8Array(b.data) 1314 })) 1315 const car = buildCarFile(commits[0].cid, blocksForCar) 1316 return new Response(car, { 1317 headers: { 'content-type': 'application/vnd.ipld.car' } 1318 }) 1319 } 1320 1321 handleSubscribeRepos(request, url) { 1322 const upgradeHeader = request.headers.get('Upgrade') 1323 if (upgradeHeader !== 'websocket') { 1324 return new Response('expected websocket', { status: 426 }) 1325 } 1326 const { 0: client, 1: server } = new WebSocketPair() 1327 this.state.acceptWebSocket(server) 1328 const cursor = url.searchParams.get('cursor') 1329 if (cursor) { 1330 const events = this.sql.exec( 1331 `SELECT * FROM seq_events WHERE seq > ? ORDER BY seq`, 1332 parseInt(cursor) 1333 ).toArray() 1334 for (const evt of events) { 1335 server.send(this.formatEvent(evt)) 1336 } 1337 } 1338 return new Response(null, { status: 101, webSocket: client }) 1339 } 1340 1341 async fetch(request) { 1342 const url = new URL(request.url) 1343 const route = pdsRoutes[url.pathname] 1344 1345 if (!route) { 1346 return Response.json({ error: 'not found' }, { status: 404 }) 1347 } 1348 if (route.method && request.method !== route.method) { 1349 return Response.json({ error: 'method not allowed' }, { status: 405 }) 1350 } 1351 return route.handler(this, request, url) 1352 } 1353} 1354 1355export default { 1356 async fetch(request, env) { 1357 const url = new URL(request.url) 1358 1359 // Endpoints that don't require ?did= param (for relay/federation) 1360 if (url.pathname === '/.well-known/atproto-did' || 1361 url.pathname === '/xrpc/com.atproto.server.describeServer') { 1362 const did = url.searchParams.get('did') || 'default' 1363 const id = env.PDS.idFromName(did) 1364 const pds = env.PDS.get(id) 1365 // Pass hostname for describeServer 1366 const newReq = new Request(request.url, { 1367 method: request.method, 1368 headers: { ...Object.fromEntries(request.headers), 'x-hostname': url.hostname } 1369 }) 1370 return pds.fetch(newReq) 1371 } 1372 1373 // subscribeRepos WebSocket - route to default instance for firehose 1374 if (url.pathname === '/xrpc/com.atproto.sync.subscribeRepos') { 1375 const defaultId = env.PDS.idFromName('default') 1376 const defaultPds = env.PDS.get(defaultId) 1377 return defaultPds.fetch(request) 1378 } 1379 1380 // listRepos needs to aggregate from all registered DIDs 1381 if (url.pathname === '/xrpc/com.atproto.sync.listRepos') { 1382 const defaultId = env.PDS.idFromName('default') 1383 const defaultPds = env.PDS.get(defaultId) 1384 const regRes = await defaultPds.fetch(new Request('http://internal/get-registered-dids')) 1385 const { dids } = await regRes.json() 1386 1387 const repos = [] 1388 for (const did of dids) { 1389 const id = env.PDS.idFromName(did) 1390 const pds = env.PDS.get(id) 1391 const infoRes = await pds.fetch(new Request('http://internal/repo-info')) 1392 const info = await infoRes.json() 1393 if (info.head) { 1394 repos.push({ did, head: info.head, rev: info.rev, active: true }) 1395 } 1396 } 1397 return Response.json({ repos, cursor: undefined }) 1398 } 1399 1400 const did = url.searchParams.get('did') 1401 if (!did) { 1402 return new Response('missing did param', { status: 400 }) 1403 } 1404 1405 // On init, also register this DID with the default instance 1406 if (url.pathname === '/init' && request.method === 'POST') { 1407 const body = await request.json() 1408 1409 // Register with default instance for discovery 1410 const defaultId = env.PDS.idFromName('default') 1411 const defaultPds = env.PDS.get(defaultId) 1412 await defaultPds.fetch(new Request('http://internal/register-did', { 1413 method: 'POST', 1414 body: JSON.stringify({ did }) 1415 })) 1416 1417 // Forward to the actual PDS instance 1418 const id = env.PDS.idFromName(did) 1419 const pds = env.PDS.get(id) 1420 return pds.fetch(new Request(request.url, { 1421 method: 'POST', 1422 headers: request.headers, 1423 body: JSON.stringify(body) 1424 })) 1425 } 1426 1427 const id = env.PDS.idFromName(did) 1428 const pds = env.PDS.get(id) 1429 return pds.fetch(request) 1430 } 1431}