this repo has no description
1// ╔══════════════════════════════════════════════════════════════════════════════╗ 2// ║ ║ 3// ║ ██████╗ ██████╗ ███████╗ Personal Data Server ║ 4// ║ ██╔══██╗██╔══██╗██╔════╝ for AT Protocol ║ 5// ║ ██████╔╝██║ ██║███████╗ ║ 6// ║ ██╔═══╝ ██║ ██║╚════██║ ║ 7// ║ ██║ ██████╔╝███████║ ║ 8// ║ ╚═╝ ╚═════╝ ╚══════╝ ║ 9// ║ ║ 10// ╠══════════════════════════════════════════════════════════════════════════════╣ 11// ║ ║ 12// ║ A single-file ATProto PDS for Cloudflare Workers + Durable Objects ║ 13// ║ ║ 14// ║ Features: ║ 15// ║ • CBOR/DAG-CBOR encoding for content-addressed data ║ 16// ║ • CID generation (CIDv1 with dag-cbor + sha-256) ║ 17// ║ • Merkle Search Tree (MST) for repository structure ║ 18// ║ • P-256 signing with low-S normalization ║ 19// ║ • JWT authentication (access, refresh, service tokens) ║ 20// ║ • CAR file building for repo sync ║ 21// ║ • R2 blob storage with MIME detection ║ 22// ║ • SQLite persistence via Durable Objects ║ 23// ║ ║ 24// ║ @see https://atproto.com ║ 25// ║ ║ 26// ╚══════════════════════════════════════════════════════════════════════════════╝ 27 28// ╔══════════════════════════════════════════════════════════════════════════════╗ 29// ║ TYPES & CONSTANTS ║ 30// ║ Environment bindings, SQL row types, protocol constants ║ 31// ╚══════════════════════════════════════════════════════════════════════════════╝ 32 33// CBOR primitive markers (RFC 8949) 34const CBOR_FALSE = 0xf4; 35const CBOR_TRUE = 0xf5; 36const CBOR_NULL = 0xf6; 37 38// DAG-CBOR CID link tag 39const CBOR_TAG_CID = 42; 40 41// CID codec constants 42const CODEC_DAG_CBOR = 0x71; 43const CODEC_RAW = 0x55; 44 45// TID generation constants 46const TID_CHARS = '234567abcdefghijklmnopqrstuvwxyz'; 47let lastTimestamp = 0; 48const clockId = Math.floor(Math.random() * 1024); 49 50// P-256 curve order N (for low-S signature normalization) 51const P256_N = BigInt( 52 '0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551', 53); 54const P256_N_DIV_2 = P256_N / 2n; 55 56// Crawler notification throttle 57const CRAWL_NOTIFY_THRESHOLD = 20 * 60 * 1000; // 20 minutes (matches official PDS) 58let lastCrawlNotify = 0; 59 60/** 61 * Cloudflare Workers environment bindings 62 * @typedef {Object} Env 63 * @property {string} JWT_SECRET - Secret for signing/verifying session JWTs 64 * @property {string} [RELAY_HOST] - Relay host to notify of repo updates (e.g., bsky.network) 65 * @property {string} [APPVIEW_URL] - AppView URL for proxying app.bsky.* requests 66 * @property {string} [APPVIEW_DID] - AppView DID for service auth 67 * @property {string} [PDS_PASSWORD] - Password for createSession authentication 68 * @property {DurableObjectNamespace} PDS - Durable Object namespace for PDS instances 69 * @property {R2Bucket} [BLOB_BUCKET] - R2 bucket for blob storage (legacy name) 70 * @property {R2Bucket} [BLOBS] - R2 bucket for blob storage 71 */ 72 73/** 74 * Row from the `blocks` table - stores raw CBOR-encoded data blocks 75 * @typedef {Object} BlockRow 76 * @property {string} cid - Content ID (CIDv1 base32lower) 77 * @property {ArrayBuffer} data - Raw block data (CBOR-encoded) 78 */ 79 80/** 81 * Row from the `records` table - indexes AT Protocol records 82 * @typedef {Object} RecordRow 83 * @property {string} uri - AT URI (at://did/collection/rkey) 84 * @property {string} cid - Content ID of the record block 85 * @property {string} collection - Collection NSID (e.g., app.bsky.feed.post) 86 * @property {string} rkey - Record key within collection 87 * @property {ArrayBuffer} value - CBOR-encoded record value 88 */ 89 90/** 91 * Row from the `commits` table - tracks repo commit history 92 * @typedef {Object} CommitRow 93 * @property {string} cid - Content ID of the signed commit block 94 * @property {string} rev - Revision TID for ordering 95 * @property {string|null} prev - Previous commit CID (null for first commit) 96 */ 97 98/** 99 * Row from the `seq_events` table - stores firehose events for subscribeRepos 100 * @typedef {Object} SeqEventRow 101 * @property {number} seq - Sequence number for cursor-based pagination 102 * @property {string} did - DID of the repo that changed 103 * @property {string} commit_cid - CID of the commit 104 * @property {ArrayBuffer|Uint8Array} evt - CBOR-encoded event with ops, blocks, rev, time 105 */ 106 107/** 108 * Row from the `blob` table - tracks uploaded blob metadata 109 * @typedef {Object} BlobRow 110 * @property {string} cid - Content ID of the blob (raw codec) 111 * @property {string} mimeType - MIME type (sniffed or from Content-Type header) 112 * @property {number} size - Size in bytes 113 * @property {string} createdAt - ISO timestamp of upload 114 */ 115 116/** 117 * Decoded JWT payload for session tokens 118 * @typedef {Object} JwtPayload 119 * @property {string} [scope] - Token scope (e.g., "com.atproto.access") 120 * @property {string} sub - Subject DID (the authenticated user) 121 * @property {string} [aud] - Audience (for refresh tokens, should match sub) 122 * @property {number} [iat] - Issued-at timestamp (Unix seconds) 123 * @property {number} [exp] - Expiration timestamp (Unix seconds) 124 * @property {string} [jti] - Unique token identifier 125 */ 126 127// ╔══════════════════════════════════════════════════════════════════════════════╗ 128// ║ UTILITIES ║ 129// ║ Error responses, byte conversion, base encoding ║ 130// ╚══════════════════════════════════════════════════════════════════════════════╝ 131 132/** 133 * @param {string} error - Error code 134 * @param {string} message - Error message 135 * @param {number} status - HTTP status code 136 * @returns {Response} 137 */ 138function errorResponse(error, message, status) { 139 return Response.json({ error, message }, { status }); 140} 141 142/** 143 * Convert bytes to hexadecimal string 144 * @param {Uint8Array} bytes - Bytes to convert 145 * @returns {string} Hex string 146 */ 147export function bytesToHex(bytes) { 148 return Array.from(bytes) 149 .map((b) => b.toString(16).padStart(2, '0')) 150 .join(''); 151} 152 153/** 154 * Convert hexadecimal string to bytes 155 * @param {string} hex - Hex string 156 * @returns {Uint8Array} Decoded bytes 157 */ 158export function hexToBytes(hex) { 159 const bytes = new Uint8Array(hex.length / 2); 160 for (let i = 0; i < hex.length; i += 2) { 161 bytes[i / 2] = parseInt(hex.substr(i, 2), 16); 162 } 163 return bytes; 164} 165 166/** 167 * @param {Uint8Array} bytes 168 * @returns {bigint} 169 */ 170function bytesToBigInt(bytes) { 171 let result = 0n; 172 for (const byte of bytes) { 173 result = (result << 8n) | BigInt(byte); 174 } 175 return result; 176} 177 178/** 179 * @param {bigint} n 180 * @param {number} length 181 * @returns {Uint8Array} 182 */ 183function bigIntToBytes(n, length) { 184 const bytes = new Uint8Array(length); 185 for (let i = length - 1; i >= 0; i--) { 186 bytes[i] = Number(n & 0xffn); 187 n >>= 8n; 188 } 189 return bytes; 190} 191 192/** 193 * Encode bytes as base32lower string 194 * @param {Uint8Array} bytes - Bytes to encode 195 * @returns {string} Base32lower-encoded string 196 */ 197export function base32Encode(bytes) { 198 const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; 199 let result = ''; 200 let bits = 0; 201 let value = 0; 202 203 for (const byte of bytes) { 204 value = (value << 8) | byte; 205 bits += 8; 206 while (bits >= 5) { 207 bits -= 5; 208 result += alphabet[(value >> bits) & 31]; 209 } 210 } 211 212 if (bits > 0) { 213 result += alphabet[(value << (5 - bits)) & 31]; 214 } 215 216 return result; 217} 218 219/** 220 * Decode base32lower string to bytes 221 * @param {string} str - Base32lower-encoded string 222 * @returns {Uint8Array} Decoded bytes 223 */ 224export function base32Decode(str) { 225 const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; 226 let bits = 0; 227 let value = 0; 228 const output = []; 229 230 for (const char of str) { 231 const idx = alphabet.indexOf(char); 232 if (idx === -1) continue; 233 value = (value << 5) | idx; 234 bits += 5; 235 if (bits >= 8) { 236 bits -= 8; 237 output.push((value >> bits) & 0xff); 238 } 239 } 240 241 return new Uint8Array(output); 242} 243 244/** 245 * Encode bytes as base64url string (no padding) 246 * @param {Uint8Array} bytes - Bytes to encode 247 * @returns {string} Base64url-encoded string 248 */ 249export function base64UrlEncode(bytes) { 250 let binary = ''; 251 for (const byte of bytes) { 252 binary += String.fromCharCode(byte); 253 } 254 const base64 = btoa(binary); 255 return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); 256} 257 258/** 259 * Decode base64url string to bytes 260 * @param {string} str - Base64url-encoded string 261 * @returns {Uint8Array} Decoded bytes 262 */ 263export function base64UrlDecode(str) { 264 const base64 = str.replace(/-/g, '+').replace(/_/g, '/'); 265 const pad = base64.length % 4; 266 const padded = pad ? base64 + '='.repeat(4 - pad) : base64; 267 const binary = atob(padded); 268 const bytes = new Uint8Array(binary.length); 269 for (let i = 0; i < binary.length; i++) { 270 bytes[i] = binary.charCodeAt(i); 271 } 272 return bytes; 273} 274 275/** 276 * Encode integer as unsigned varint 277 * @param {number} n - Non-negative integer 278 * @returns {Uint8Array} Varint-encoded bytes 279 */ 280export function varint(n) { 281 const bytes = []; 282 while (n >= 0x80) { 283 bytes.push((n & 0x7f) | 0x80); 284 n >>>= 7; 285 } 286 bytes.push(n); 287 return new Uint8Array(bytes); 288} 289 290// === CID WRAPPER === 291// Explicit CID type for DAG-CBOR encoding (avoids fragile heuristic detection) 292 293class CID { 294 /** @param {Uint8Array} bytes */ 295 constructor(bytes) { 296 if (!(bytes instanceof Uint8Array)) { 297 throw new Error('CID must be constructed with Uint8Array'); 298 } 299 this.bytes = bytes; 300 } 301} 302 303// ╔══════════════════════════════════════════════════════════════════════════════╗ 304// ║ CBOR ENCODING ║ 305// ║ RFC 8949 CBOR and DAG-CBOR for content-addressed data ║ 306// ╚══════════════════════════════════════════════════════════════════════════════╝ 307 308/** 309 * Encode CBOR type header (major type + length) 310 * @param {number[]} parts - Array to push bytes to 311 * @param {number} majorType - CBOR major type (0-7) 312 * @param {number} length - Value or length to encode 313 */ 314function encodeHead(parts, majorType, length) { 315 const mt = majorType << 5; 316 if (length < 24) { 317 parts.push(mt | length); 318 } else if (length < 256) { 319 parts.push(mt | 24, length); 320 } else if (length < 65536) { 321 parts.push(mt | 25, length >> 8, length & 0xff); 322 } else if (length < 4294967296) { 323 // Use Math.floor instead of bitshift to avoid 32-bit signed integer overflow 324 parts.push( 325 mt | 26, 326 Math.floor(length / 0x1000000) & 0xff, 327 Math.floor(length / 0x10000) & 0xff, 328 Math.floor(length / 0x100) & 0xff, 329 length & 0xff, 330 ); 331 } 332} 333 334/** 335 * Encode a value as CBOR bytes (RFC 8949 deterministic encoding) 336 * @param {*} value - Value to encode (null, boolean, number, string, Uint8Array, array, or object) 337 * @returns {Uint8Array} CBOR-encoded bytes 338 */ 339export function cborEncode(value) { 340 /** @type {number[]} */ 341 const parts = []; 342 343 /** @param {*} val */ 344 function encode(val) { 345 if (val === null) { 346 parts.push(CBOR_NULL); 347 } else if (val === true) { 348 parts.push(CBOR_TRUE); 349 } else if (val === false) { 350 parts.push(CBOR_FALSE); 351 } else if (typeof val === 'number') { 352 encodeInteger(val); 353 } else if (typeof val === 'string') { 354 const bytes = new TextEncoder().encode(val); 355 encodeHead(parts, 3, bytes.length); // major type 3 = text string 356 parts.push(...bytes); 357 } else if (val instanceof Uint8Array) { 358 encodeHead(parts, 2, val.length); // major type 2 = byte string 359 parts.push(...val); 360 } else if (Array.isArray(val)) { 361 encodeHead(parts, 4, val.length); // major type 4 = array 362 for (const item of val) encode(item); 363 } else if (typeof val === 'object') { 364 // Sort keys for deterministic encoding 365 const keys = Object.keys(val).sort(); 366 encodeHead(parts, 5, keys.length); // major type 5 = map 367 for (const key of keys) { 368 encode(key); 369 encode(val[key]); 370 } 371 } 372 } 373 374 /** @param {number} n */ 375 function encodeInteger(n) { 376 if (n >= 0) { 377 encodeHead(parts, 0, n); // major type 0 = unsigned int 378 } else { 379 encodeHead(parts, 1, -n - 1); // major type 1 = negative int 380 } 381 } 382 383 encode(value); 384 return new Uint8Array(parts); 385} 386 387/** 388 * DAG-CBOR encoder that handles CIDs with tag 42 389 * @param {*} value 390 * @returns {Uint8Array} 391 */ 392function cborEncodeDagCbor(value) { 393 /** @type {number[]} */ 394 const parts = []; 395 396 /** @param {*} val */ 397 function encode(val) { 398 if (val === null) { 399 parts.push(CBOR_NULL); 400 } else if (val === true) { 401 parts.push(CBOR_TRUE); 402 } else if (val === false) { 403 parts.push(CBOR_FALSE); 404 } else if (typeof val === 'number') { 405 if (Number.isInteger(val) && val >= 0) { 406 encodeHead(parts, 0, val); 407 } else if (Number.isInteger(val) && val < 0) { 408 encodeHead(parts, 1, -val - 1); 409 } 410 } else if (typeof val === 'string') { 411 const bytes = new TextEncoder().encode(val); 412 encodeHead(parts, 3, bytes.length); 413 parts.push(...bytes); 414 } else if (val instanceof CID) { 415 // CID links in DAG-CBOR use tag 42 + 0x00 multibase prefix 416 // The 0x00 prefix indicates "identity" multibase (raw bytes) 417 parts.push(0xd8, CBOR_TAG_CID); 418 encodeHead(parts, 2, val.bytes.length + 1); // +1 for 0x00 prefix 419 parts.push(0x00); 420 parts.push(...val.bytes); 421 } else if (val instanceof Uint8Array) { 422 // Regular byte string 423 encodeHead(parts, 2, val.length); 424 parts.push(...val); 425 } else if (Array.isArray(val)) { 426 encodeHead(parts, 4, val.length); 427 for (const item of val) encode(item); 428 } else if (typeof val === 'object') { 429 // DAG-CBOR: sort keys by length first, then lexicographically 430 // (differs from standard CBOR which sorts lexicographically only) 431 const keys = Object.keys(val).filter((k) => val[k] !== undefined); 432 keys.sort((a, b) => { 433 if (a.length !== b.length) return a.length - b.length; 434 return a < b ? -1 : a > b ? 1 : 0; 435 }); 436 encodeHead(parts, 5, keys.length); 437 for (const key of keys) { 438 const keyBytes = new TextEncoder().encode(key); 439 encodeHead(parts, 3, keyBytes.length); 440 parts.push(...keyBytes); 441 encode(val[key]); 442 } 443 } 444 } 445 446 encode(value); 447 return new Uint8Array(parts); 448} 449 450/** 451 * Decode CBOR bytes to a JavaScript value 452 * @param {Uint8Array} bytes - CBOR-encoded bytes 453 * @returns {*} Decoded value 454 */ 455export function cborDecode(bytes) { 456 let offset = 0; 457 458 /** @returns {*} */ 459 function read() { 460 const initial = bytes[offset++]; 461 const major = initial >> 5; 462 const info = initial & 0x1f; 463 464 let length = info; 465 if (info === 24) length = bytes[offset++]; 466 else if (info === 25) { 467 length = (bytes[offset++] << 8) | bytes[offset++]; 468 } else if (info === 26) { 469 // Use multiplication instead of bitshift to avoid 32-bit signed integer overflow 470 length = 471 bytes[offset++] * 0x1000000 + 472 bytes[offset++] * 0x10000 + 473 bytes[offset++] * 0x100 + 474 bytes[offset++]; 475 } 476 477 switch (major) { 478 case 0: 479 return length; // unsigned int 480 case 1: 481 return -1 - length; // negative int 482 case 2: { 483 // byte string 484 const data = bytes.slice(offset, offset + length); 485 offset += length; 486 return data; 487 } 488 case 3: { 489 // text string 490 const data = new TextDecoder().decode( 491 bytes.slice(offset, offset + length), 492 ); 493 offset += length; 494 return data; 495 } 496 case 4: { 497 // array 498 const arr = []; 499 for (let i = 0; i < length; i++) arr.push(read()); 500 return arr; 501 } 502 case 5: { 503 // map 504 /** @type {Record<string, *>} */ 505 const obj = {}; 506 for (let i = 0; i < length; i++) { 507 const key = /** @type {string} */ (read()); 508 obj[key] = read(); 509 } 510 return obj; 511 } 512 case 6: { 513 // tag 514 // length is the tag number 515 const taggedValue = read(); 516 if (length === CBOR_TAG_CID) { 517 // CID link: byte string with 0x00 multibase prefix, return raw CID bytes 518 return taggedValue.slice(1); // strip 0x00 prefix 519 } 520 return taggedValue; 521 } 522 case 7: { 523 // special 524 if (info === 20) return false; 525 if (info === 21) return true; 526 if (info === 22) return null; 527 return undefined; 528 } 529 } 530 } 531 532 return read(); 533} 534 535// ╔══════════════════════════════════════════════════════════════════════════════╗ 536// ║ CONTENT IDENTIFIERS ║ 537// ║ CIDs (content hashes) and TIDs (timestamp IDs) ║ 538// ╚══════════════════════════════════════════════════════════════════════════════╝ 539 540/** 541 * Create a CIDv1 with SHA-256 hash 542 * @param {Uint8Array} bytes - Content to hash 543 * @param {number} codec - Codec identifier (0x71 for dag-cbor, 0x55 for raw) 544 * @returns {Promise<Uint8Array>} CID bytes (36 bytes: version + codec + multihash) 545 */ 546async function createCidWithCodec(bytes, codec) { 547 const hash = await crypto.subtle.digest('SHA-256', /** @type {BufferSource} */(bytes)); 548 const hashBytes = new Uint8Array(hash); 549 550 // CIDv1: version(1) + codec + multihash(sha256) 551 // Multihash: hash-type(0x12) + length(0x20=32) + digest 552 const cid = new Uint8Array(2 + 2 + 32); 553 cid[0] = 0x01; // CIDv1 554 cid[1] = codec; 555 cid[2] = 0x12; // sha-256 556 cid[3] = 0x20; // 32 bytes 557 cid.set(hashBytes, 4); 558 559 return cid; 560} 561 562/** 563 * Create CID for DAG-CBOR encoded data (records, commits) 564 * @param {Uint8Array} bytes - DAG-CBOR encoded content 565 * @returns {Promise<Uint8Array>} CID bytes 566 */ 567export async function createCid(bytes) { 568 return createCidWithCodec(bytes, CODEC_DAG_CBOR); 569} 570 571/** 572 * Create CID for raw blob data (images, videos) 573 * @param {Uint8Array} bytes - Raw binary content 574 * @returns {Promise<Uint8Array>} CID bytes 575 */ 576export async function createBlobCid(bytes) { 577 return createCidWithCodec(bytes, CODEC_RAW); 578} 579 580/** 581 * Convert CID bytes to base32lower string representation 582 * @param {Uint8Array} cid - CID bytes 583 * @returns {string} Base32lower-encoded CID with 'b' prefix 584 */ 585export function cidToString(cid) { 586 // base32lower encoding for CIDv1 587 return `b${base32Encode(cid)}`; 588} 589 590/** 591 * Convert base32lower CID string to raw bytes 592 * @param {string} cidStr - CID string with 'b' prefix 593 * @returns {Uint8Array} CID bytes 594 */ 595export function cidToBytes(cidStr) { 596 // Decode base32lower CID string to bytes 597 if (!cidStr.startsWith('b')) throw new Error('expected base32lower CID'); 598 return base32Decode(cidStr.slice(1)); 599} 600 601/** 602 * Generate a timestamp-based ID (TID) for record keys 603 * Monotonic within a process, sortable by time 604 * @returns {string} 13-character base32-sort encoded TID 605 */ 606export function createTid() { 607 let timestamp = Date.now() * 1000; // microseconds 608 609 // Ensure monotonic 610 if (timestamp <= lastTimestamp) { 611 timestamp = lastTimestamp + 1; 612 } 613 lastTimestamp = timestamp; 614 615 // 13 chars: 11 for timestamp (64 bits but only ~53 used), 2 for clock ID 616 let tid = ''; 617 618 // Encode timestamp (high bits first for sortability) 619 let ts = timestamp; 620 for (let i = 0; i < 11; i++) { 621 tid = TID_CHARS[ts & 31] + tid; 622 ts = Math.floor(ts / 32); 623 } 624 625 // Append clock ID (2 chars) 626 tid += TID_CHARS[(clockId >> 5) & 31]; 627 tid += TID_CHARS[clockId & 31]; 628 629 return tid; 630} 631 632// ╔══════════════════════════════════════════════════════════════════════════════╗ 633// ║ CRYPTOGRAPHY ║ 634// ║ P-256 signing with low-S normalization, key management ║ 635// ╚══════════════════════════════════════════════════════════════════════════════╝ 636 637/** 638 * @param {BufferSource} data 639 * @returns {Promise<Uint8Array>} 640 */ 641async function sha256(data) { 642 const hash = await crypto.subtle.digest('SHA-256', data); 643 return new Uint8Array(hash); 644} 645 646/** 647 * Import a raw P-256 private key for signing 648 * @param {Uint8Array} privateKeyBytes - 32-byte raw private key 649 * @returns {Promise<CryptoKey>} Web Crypto key handle 650 */ 651export async function importPrivateKey(privateKeyBytes) { 652 // Validate private key length (P-256 requires exactly 32 bytes) 653 if ( 654 !(privateKeyBytes instanceof Uint8Array) || 655 privateKeyBytes.length !== 32 656 ) { 657 throw new Error( 658 `Invalid private key: expected 32 bytes, got ${privateKeyBytes?.length ?? 'non-Uint8Array'}`, 659 ); 660 } 661 662 // PKCS#8 wrapper for raw P-256 private key 663 const pkcs8Prefix = new Uint8Array([ 664 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 665 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 666 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20, 667 ]); 668 669 const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32); 670 pkcs8.set(pkcs8Prefix); 671 pkcs8.set(privateKeyBytes, pkcs8Prefix.length); 672 673 return crypto.subtle.importKey( 674 'pkcs8', 675 /** @type {BufferSource} */(pkcs8), 676 { name: 'ECDSA', namedCurve: 'P-256' }, 677 false, 678 ['sign'], 679 ); 680} 681 682/** 683 * Sign data with ECDSA P-256, returning low-S normalized signature 684 * @param {CryptoKey} privateKey - Web Crypto key from importPrivateKey 685 * @param {Uint8Array} data - Data to sign 686 * @returns {Promise<Uint8Array>} 64-byte signature (r || s) 687 */ 688export async function sign(privateKey, data) { 689 const signature = await crypto.subtle.sign( 690 { name: 'ECDSA', hash: 'SHA-256' }, 691 privateKey, 692 /** @type {BufferSource} */(data), 693 ); 694 const sig = new Uint8Array(signature); 695 696 const r = sig.slice(0, 32); 697 const s = sig.slice(32, 64); 698 const sBigInt = bytesToBigInt(s); 699 700 // Low-S normalization: Bitcoin/ATProto require S <= N/2 to prevent 701 // signature malleability (two valid signatures for same message) 702 if (sBigInt > P256_N_DIV_2) { 703 const newS = P256_N - sBigInt; 704 const newSBytes = bigIntToBytes(newS, 32); 705 const normalized = new Uint8Array(64); 706 normalized.set(r, 0); 707 normalized.set(newSBytes, 32); 708 return normalized; 709 } 710 711 return sig; 712} 713 714/** 715 * Generate a new P-256 key pair 716 * @returns {Promise<{privateKey: Uint8Array, publicKey: Uint8Array}>} 32-byte private key, 33-byte compressed public key 717 */ 718export async function generateKeyPair() { 719 const keyPair = await crypto.subtle.generateKey( 720 { name: 'ECDSA', namedCurve: 'P-256' }, 721 true, 722 ['sign', 'verify'], 723 ); 724 725 // Export private key as raw bytes 726 const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey); 727 const privateBytes = base64UrlDecode(/** @type {string} */(privateJwk.d)); 728 729 // Export public key as compressed point 730 const publicRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey); 731 const publicBytes = new Uint8Array(publicRaw); 732 const compressed = compressPublicKey(publicBytes); 733 734 return { privateKey: privateBytes, publicKey: compressed }; 735} 736 737/** 738 * @param {Uint8Array} uncompressed 739 * @returns {Uint8Array} 740 */ 741function compressPublicKey(uncompressed) { 742 // uncompressed is 65 bytes: 0x04 + x(32) + y(32) 743 // compressed is 33 bytes: prefix(02 or 03) + x(32) 744 const x = uncompressed.slice(1, 33); 745 const y = uncompressed.slice(33, 65); 746 const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03; 747 const compressed = new Uint8Array(33); 748 compressed[0] = prefix; 749 compressed.set(x, 1); 750 return compressed; 751} 752 753// ╔══════════════════════════════════════════════════════════════════════════════╗ 754// ║ AUTHENTICATION ║ 755// ║ JWT creation/verification for sessions and service auth ║ 756// ╚══════════════════════════════════════════════════════════════════════════════╝ 757 758/** 759 * Create HMAC-SHA256 signature for JWT 760 * @param {string} data - Data to sign (header.payload) 761 * @param {string} secret - Secret key 762 * @returns {Promise<string>} Base64url-encoded signature 763 */ 764async function hmacSign(data, secret) { 765 const key = await crypto.subtle.importKey( 766 'raw', 767 /** @type {BufferSource} */(new TextEncoder().encode(secret)), 768 { name: 'HMAC', hash: 'SHA-256' }, 769 false, 770 ['sign'], 771 ); 772 const sig = await crypto.subtle.sign( 773 'HMAC', 774 key, 775 /** @type {BufferSource} */(new TextEncoder().encode(data)), 776 ); 777 return base64UrlEncode(new Uint8Array(sig)); 778} 779 780/** 781 * Create an access JWT for ATProto 782 * @param {string} did - User's DID (subject and audience) 783 * @param {string} secret - JWT signing secret 784 * @param {number} [expiresIn=7200] - Expiration in seconds (default 2 hours) 785 * @returns {Promise<string>} Signed JWT 786 */ 787export async function createAccessJwt(did, secret, expiresIn = 7200) { 788 const header = { typ: 'at+jwt', alg: 'HS256' }; 789 const now = Math.floor(Date.now() / 1000); 790 const payload = { 791 scope: 'com.atproto.access', 792 sub: did, 793 aud: did, 794 iat: now, 795 exp: now + expiresIn, 796 }; 797 798 const headerB64 = base64UrlEncode( 799 new TextEncoder().encode(JSON.stringify(header)), 800 ); 801 const payloadB64 = base64UrlEncode( 802 new TextEncoder().encode(JSON.stringify(payload)), 803 ); 804 const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret); 805 806 return `${headerB64}.${payloadB64}.${signature}`; 807} 808 809/** 810 * Create a refresh JWT for ATProto 811 * @param {string} did - User's DID (subject and audience) 812 * @param {string} secret - JWT signing secret 813 * @param {number} [expiresIn=86400] - Expiration in seconds (default 24 hours) 814 * @returns {Promise<string>} Signed JWT 815 */ 816export async function createRefreshJwt(did, secret, expiresIn = 86400) { 817 const header = { typ: 'refresh+jwt', alg: 'HS256' }; 818 const now = Math.floor(Date.now() / 1000); 819 // Generate random jti (token ID) 820 const jtiBytes = new Uint8Array(32); 821 crypto.getRandomValues(jtiBytes); 822 const jti = base64UrlEncode(jtiBytes); 823 824 const payload = { 825 scope: 'com.atproto.refresh', 826 sub: did, 827 aud: did, 828 jti, 829 iat: now, 830 exp: now + expiresIn, 831 }; 832 833 const headerB64 = base64UrlEncode( 834 new TextEncoder().encode(JSON.stringify(header)), 835 ); 836 const payloadB64 = base64UrlEncode( 837 new TextEncoder().encode(JSON.stringify(payload)), 838 ); 839 const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret); 840 841 return `${headerB64}.${payloadB64}.${signature}`; 842} 843 844/** 845 * Verify and decode a JWT (shared logic) 846 * @param {string} jwt - JWT string to verify 847 * @param {string} secret - JWT signing secret 848 * @param {string} expectedType - Expected token type (e.g., 'at+jwt', 'refresh+jwt') 849 * @returns {Promise<{header: {typ: string, alg: string}, payload: JwtPayload}>} Decoded header and payload 850 * @throws {Error} If token is invalid, expired, or wrong type 851 */ 852async function verifyJwt(jwt, secret, expectedType) { 853 const parts = jwt.split('.'); 854 if (parts.length !== 3) { 855 throw new Error('Invalid JWT format'); 856 } 857 858 const [headerB64, payloadB64, signatureB64] = parts; 859 860 // Verify signature 861 const expectedSig = await hmacSign(`${headerB64}.${payloadB64}`, secret); 862 if (signatureB64 !== expectedSig) { 863 throw new Error('Invalid signature'); 864 } 865 866 // Decode header and payload 867 const header = JSON.parse( 868 new TextDecoder().decode(base64UrlDecode(headerB64)), 869 ); 870 const payload = JSON.parse( 871 new TextDecoder().decode(base64UrlDecode(payloadB64)), 872 ); 873 874 // Check token type 875 if (header.typ !== expectedType) { 876 throw new Error(`Invalid token type: expected ${expectedType}`); 877 } 878 879 // Check expiration 880 const now = Math.floor(Date.now() / 1000); 881 if (payload.exp && payload.exp < now) { 882 throw new Error('Token expired'); 883 } 884 885 return { header, payload }; 886} 887 888/** 889 * Verify and decode an access JWT 890 * @param {string} jwt - JWT string to verify 891 * @param {string} secret - JWT signing secret 892 * @returns {Promise<JwtPayload>} Decoded payload 893 * @throws {Error} If token is invalid, expired, or wrong type 894 */ 895export async function verifyAccessJwt(jwt, secret) { 896 const { payload } = await verifyJwt(jwt, secret, 'at+jwt'); 897 return payload; 898} 899 900/** 901 * Verify and decode a refresh JWT 902 * @param {string} jwt - JWT string to verify 903 * @param {string} secret - JWT signing secret 904 * @returns {Promise<JwtPayload>} Decoded payload 905 * @throws {Error} If token is invalid, expired, or wrong type 906 */ 907export async function verifyRefreshJwt(jwt, secret) { 908 const { payload } = await verifyJwt(jwt, secret, 'refresh+jwt'); 909 910 // Validate audience matches subject (token intended for this user) 911 if (payload.aud && payload.aud !== payload.sub) { 912 throw new Error('Invalid audience'); 913 } 914 915 return payload; 916} 917 918/** 919 * Create a service auth JWT signed with ES256 (P-256) 920 * Used for proxying requests to AppView 921 * @param {Object} params - JWT parameters 922 * @param {string} params.iss - Issuer DID (PDS DID) 923 * @param {string} params.aud - Audience DID (AppView DID) 924 * @param {string|null} params.lxm - Lexicon method being called 925 * @param {CryptoKey} params.signingKey - P-256 private key from importPrivateKey 926 * @returns {Promise<string>} Signed JWT 927 */ 928export async function createServiceJwt({ iss, aud, lxm, signingKey }) { 929 const header = { typ: 'JWT', alg: 'ES256' }; 930 const now = Math.floor(Date.now() / 1000); 931 932 // Generate random jti 933 const jtiBytes = new Uint8Array(16); 934 crypto.getRandomValues(jtiBytes); 935 const jti = bytesToHex(jtiBytes); 936 937 /** @type {{ iss: string, aud: string, exp: number, iat: number, jti: string, lxm?: string }} */ 938 const payload = { 939 iss, 940 aud, 941 exp: now + 60, // 1 minute expiration 942 iat: now, 943 jti, 944 }; 945 if (lxm) payload.lxm = lxm; 946 947 const headerB64 = base64UrlEncode( 948 new TextEncoder().encode(JSON.stringify(header)), 949 ); 950 const payloadB64 = base64UrlEncode( 951 new TextEncoder().encode(JSON.stringify(payload)), 952 ); 953 const toSign = new TextEncoder().encode(`${headerB64}.${payloadB64}`); 954 955 const sig = await sign(signingKey, toSign); 956 const sigB64 = base64UrlEncode(sig); 957 958 return `${headerB64}.${payloadB64}.${sigB64}`; 959} 960 961// ╔══════════════════════════════════════════════════════════════════════════════╗ 962// ║ MERKLE SEARCH TREE ║ 963// ║ MST for ATProto repository structure ║ 964// ╚══════════════════════════════════════════════════════════════════════════════╝ 965 966// Cache for key depths (SHA-256 is expensive) 967const keyDepthCache = new Map(); 968 969/** 970 * Get MST tree depth for a key based on leading zeros in SHA-256 hash 971 * @param {string} key - Record key (collection/rkey) 972 * @returns {Promise<number>} Tree depth (leading zeros / 2) 973 */ 974export async function getKeyDepth(key) { 975 // Count leading zeros in SHA-256 hash, divide by 2 976 if (keyDepthCache.has(key)) return keyDepthCache.get(key); 977 978 const keyBytes = new TextEncoder().encode(key); 979 const hash = await sha256(keyBytes); 980 981 let zeros = 0; 982 for (const byte of hash) { 983 if (byte === 0) { 984 zeros += 8; 985 } else { 986 // Count leading zeros in this byte 987 for (let i = 7; i >= 0; i--) { 988 if ((byte >> i) & 1) break; 989 zeros++; 990 } 991 break; 992 } 993 } 994 995 // MST depth = leading zeros in SHA-256 hash / 2 996 // This creates a probabilistic tree where ~50% of keys are at depth 0, 997 // ~25% at depth 1, etc., giving O(log n) lookups 998 const depth = Math.floor(zeros / 2); 999 keyDepthCache.set(key, depth); 1000 return depth; 1001} 1002 1003/** 1004 * Compute common prefix length between two byte arrays 1005 * @param {Uint8Array} a 1006 * @param {Uint8Array} b 1007 * @returns {number} 1008 */ 1009function commonPrefixLen(a, b) { 1010 const minLen = Math.min(a.length, b.length); 1011 for (let i = 0; i < minLen; i++) { 1012 if (a[i] !== b[i]) return i; 1013 } 1014 return minLen; 1015} 1016 1017class MST { 1018 /** @param {SqlStorage} sql */ 1019 constructor(sql) { 1020 this.sql = sql; 1021 } 1022 1023 async computeRoot() { 1024 const records = this.sql 1025 .exec(` 1026 SELECT collection, rkey, cid FROM records ORDER BY collection, rkey 1027 `) 1028 .toArray(); 1029 1030 if (records.length === 0) { 1031 return null; 1032 } 1033 1034 // Build entries with pre-computed depths (heights) 1035 // In ATProto MST, "height" determines which layer a key belongs to 1036 // Layer 0 is at the BOTTOM, root is at the highest layer 1037 const entries = []; 1038 let maxDepth = 0; 1039 for (const r of records) { 1040 const key = `${r.collection}/${r.rkey}`; 1041 const depth = await getKeyDepth(key); 1042 maxDepth = Math.max(maxDepth, depth); 1043 entries.push({ 1044 key, 1045 keyBytes: new TextEncoder().encode(key), 1046 cid: /** @type {string} */ (r.cid), 1047 depth, 1048 }); 1049 } 1050 1051 // Start building from the root (highest layer) going down to layer 0 1052 return this.buildTree(entries, maxDepth); 1053 } 1054 1055 /** 1056 * @param {Array<{key: string, keyBytes: Uint8Array, cid: string, depth: number}>} entries 1057 * @param {number} layer 1058 * @returns {Promise<string|null>} 1059 */ 1060 async buildTree(entries, layer) { 1061 if (entries.length === 0) return null; 1062 1063 // Separate entries for this layer vs lower layers (subtrees) 1064 // Keys with depth == layer stay at this node 1065 // Keys with depth < layer go into subtrees (going down toward layer 0) 1066 /** @type {Array<{type: 'subtree', cid: string|null} | {type: 'entry', entry: {key: string, keyBytes: Uint8Array, cid: string, depth: number}}>} */ 1067 const thisLayer = []; 1068 /** @type {Array<{key: string, keyBytes: Uint8Array, cid: string, depth: number}>} */ 1069 let leftSubtree = []; 1070 1071 for (const entry of entries) { 1072 if (entry.depth < layer) { 1073 // This entry belongs to a lower layer - accumulate for subtree 1074 leftSubtree.push(entry); 1075 } else { 1076 // This entry belongs at current layer (depth == layer) 1077 // Process accumulated left subtree first 1078 if (leftSubtree.length > 0) { 1079 const leftCid = await this.buildTree(leftSubtree, layer - 1); 1080 thisLayer.push({ type: 'subtree', cid: leftCid }); 1081 leftSubtree = []; 1082 } 1083 thisLayer.push({ type: 'entry', entry }); 1084 } 1085 } 1086 1087 // Handle remaining left subtree 1088 if (leftSubtree.length > 0) { 1089 const leftCid = await this.buildTree(leftSubtree, layer - 1); 1090 thisLayer.push({ type: 'subtree', cid: leftCid }); 1091 } 1092 1093 // Build node with proper ATProto format 1094 /** @type {{ e: Array<{p: number, k: Uint8Array, v: CID, t: CID|null}>, l?: CID|null }} */ 1095 const node = { e: [] }; 1096 /** @type {string|null} */ 1097 let leftCid = null; 1098 let prevKeyBytes = new Uint8Array(0); 1099 1100 for (let i = 0; i < thisLayer.length; i++) { 1101 const item = thisLayer[i]; 1102 1103 if (item.type === 'subtree') { 1104 if (node.e.length === 0) { 1105 leftCid = item.cid; 1106 } else { 1107 // Attach to previous entry's 't' field 1108 if (item.cid !== null) { 1109 node.e[node.e.length - 1].t = new CID(cidToBytes(item.cid)); 1110 } 1111 } 1112 } else { 1113 // Entry - compute prefix compression 1114 const keyBytes = item.entry.keyBytes; 1115 const prefixLen = commonPrefixLen(prevKeyBytes, keyBytes); 1116 const keySuffix = keyBytes.slice(prefixLen); 1117 1118 // ATProto requires t field to be present (can be null) 1119 const e = { 1120 p: prefixLen, 1121 k: keySuffix, 1122 v: new CID(cidToBytes(item.entry.cid)), 1123 t: null, // Will be updated if there's a subtree 1124 }; 1125 1126 node.e.push(e); 1127 prevKeyBytes = /** @type {Uint8Array<ArrayBuffer>} */ (keyBytes); 1128 } 1129 } 1130 1131 // ATProto requires l field to be present (can be null) 1132 node.l = leftCid ? new CID(cidToBytes(leftCid)) : null; 1133 1134 // Encode node with proper MST CBOR format 1135 const nodeBytes = cborEncodeDagCbor(node); 1136 const nodeCid = await createCid(nodeBytes); 1137 const cidStr = cidToString(nodeCid); 1138 1139 this.sql.exec( 1140 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, 1141 cidStr, 1142 nodeBytes, 1143 ); 1144 1145 return cidStr; 1146 } 1147} 1148 1149// ╔══════════════════════════════════════════════════════════════════════════════╗ 1150// ║ CAR FILES ║ 1151// ║ Content Addressable aRchive format for repo sync ║ 1152// ╚══════════════════════════════════════════════════════════════════════════════╝ 1153 1154/** 1155 * Build a CAR (Content Addressable aRchive) file 1156 * @param {string} rootCid - Root CID string 1157 * @param {Array<{cid: string, data: Uint8Array}>} blocks - Blocks to include 1158 * @returns {Uint8Array} CAR file bytes 1159 */ 1160export function buildCarFile(rootCid, blocks) { 1161 const parts = []; 1162 1163 // Header: { version: 1, roots: [rootCid] } 1164 const rootCidBytes = cidToBytes(rootCid); 1165 const header = cborEncodeDagCbor({ 1166 version: 1, 1167 roots: [new CID(rootCidBytes)], 1168 }); 1169 parts.push(varint(header.length)); 1170 parts.push(header); 1171 1172 // Blocks: varint(len) + cid + data 1173 for (const block of blocks) { 1174 const cidBytes = cidToBytes(block.cid); 1175 const blockLen = cidBytes.length + block.data.length; 1176 parts.push(varint(blockLen)); 1177 parts.push(cidBytes); 1178 parts.push(block.data); 1179 } 1180 1181 // Concatenate all parts 1182 const totalLen = parts.reduce((sum, p) => sum + p.length, 0); 1183 const car = new Uint8Array(totalLen); 1184 let offset = 0; 1185 for (const part of parts) { 1186 car.set(part, offset); 1187 offset += part.length; 1188 } 1189 1190 return car; 1191} 1192 1193// ╔══════════════════════════════════════════════════════════════════════════════╗ 1194// ║ BLOB HANDLING ║ 1195// ║ MIME detection, blob reference scanning ║ 1196// ╚══════════════════════════════════════════════════════════════════════════════╝ 1197 1198/** 1199 * Sniff MIME type from file magic bytes 1200 * @param {Uint8Array|ArrayBuffer} bytes - File bytes (only first 12 needed) 1201 * @returns {string|null} Detected MIME type or null if unknown 1202 */ 1203export function sniffMimeType(bytes) { 1204 const arr = new Uint8Array(bytes.slice(0, 12)); 1205 1206 // JPEG: FF D8 FF 1207 if (arr[0] === 0xff && arr[1] === 0xd8 && arr[2] === 0xff) { 1208 return 'image/jpeg'; 1209 } 1210 1211 // PNG: 89 50 4E 47 0D 0A 1A 0A 1212 if ( 1213 arr[0] === 0x89 && 1214 arr[1] === 0x50 && 1215 arr[2] === 0x4e && 1216 arr[3] === 0x47 && 1217 arr[4] === 0x0d && 1218 arr[5] === 0x0a && 1219 arr[6] === 0x1a && 1220 arr[7] === 0x0a 1221 ) { 1222 return 'image/png'; 1223 } 1224 1225 // GIF: 47 49 46 38 (GIF8) 1226 if ( 1227 arr[0] === 0x47 && 1228 arr[1] === 0x49 && 1229 arr[2] === 0x46 && 1230 arr[3] === 0x38 1231 ) { 1232 return 'image/gif'; 1233 } 1234 1235 // WebP: RIFF....WEBP 1236 if ( 1237 arr[0] === 0x52 && 1238 arr[1] === 0x49 && 1239 arr[2] === 0x46 && 1240 arr[3] === 0x46 && 1241 arr[8] === 0x57 && 1242 arr[9] === 0x45 && 1243 arr[10] === 0x42 && 1244 arr[11] === 0x50 1245 ) { 1246 return 'image/webp'; 1247 } 1248 1249 // ISOBMFF container: ....ftyp at byte 4 (MP4, AVIF, HEIC, etc.) 1250 if ( 1251 arr[4] === 0x66 && 1252 arr[5] === 0x74 && 1253 arr[6] === 0x79 && 1254 arr[7] === 0x70 1255 ) { 1256 // Check brand code at bytes 8-11 1257 const brand = String.fromCharCode(arr[8], arr[9], arr[10], arr[11]); 1258 if (brand === 'avif') { 1259 return 'image/avif'; 1260 } 1261 if (brand === 'heic' || brand === 'heix' || brand === 'mif1') { 1262 return 'image/heic'; 1263 } 1264 return 'video/mp4'; 1265 } 1266 1267 return null; 1268} 1269 1270/** 1271 * Find all blob CID references in a record 1272 * @param {*} obj - Record value to scan 1273 * @param {string[]} refs - Accumulator array (internal) 1274 * @returns {string[]} Array of blob CID strings 1275 */ 1276export function findBlobRefs(obj, refs = []) { 1277 if (!obj || typeof obj !== 'object') { 1278 return refs; 1279 } 1280 1281 // Check if this object is a blob ref 1282 if (obj.$type === 'blob' && obj.ref?.$link) { 1283 refs.push(obj.ref.$link); 1284 } 1285 1286 // Recurse into arrays and objects 1287 if (Array.isArray(obj)) { 1288 for (const item of obj) { 1289 findBlobRefs(item, refs); 1290 } 1291 } else { 1292 for (const value of Object.values(obj)) { 1293 findBlobRefs(value, refs); 1294 } 1295 } 1296 1297 return refs; 1298} 1299 1300// ╔══════════════════════════════════════════════════════════════════════════════╗ 1301// ║ RELAY NOTIFICATION ║ 1302// ║ Notify relays to crawl after repo updates ║ 1303// ╚══════════════════════════════════════════════════════════════════════════════╝ 1304 1305/** 1306 * Notify relays to come crawl us after writes (like official PDS) 1307 * @param {{ RELAY_HOST?: string }} env 1308 * @param {string} hostname 1309 */ 1310async function notifyCrawlers(env, hostname) { 1311 const now = Date.now(); 1312 if (now - lastCrawlNotify < CRAWL_NOTIFY_THRESHOLD) { 1313 return; // Throttle notifications 1314 } 1315 1316 const relayHost = env.RELAY_HOST; 1317 if (!relayHost) return; 1318 1319 lastCrawlNotify = now; 1320 1321 // Fire and forget - don't block writes on relay notification 1322 fetch(`${relayHost}/xrpc/com.atproto.sync.requestCrawl`, { 1323 method: 'POST', 1324 headers: { 'Content-Type': 'application/json' }, 1325 body: JSON.stringify({ hostname }), 1326 }).catch(() => { 1327 // Silently ignore relay notification failures 1328 }); 1329} 1330 1331// ╔══════════════════════════════════════════════════════════════════════════════╗ 1332// ║ ROUTING ║ 1333// ║ XRPC endpoint definitions ║ 1334// ╚══════════════════════════════════════════════════════════════════════════════╝ 1335 1336/** 1337 * Route handler function type 1338 * @callback RouteHandler 1339 * @param {PersonalDataServer} pds - PDS instance 1340 * @param {Request} request - HTTP request 1341 * @param {URL} url - Parsed URL 1342 * @returns {Response | Promise<Response>} HTTP response 1343 */ 1344 1345/** 1346 * Route definition for the PDS router 1347 * @typedef {Object} Route 1348 * @property {string} [method] - Required HTTP method (default: any) 1349 * @property {RouteHandler} handler - Handler function 1350 */ 1351 1352/** @type {Record<string, Route>} */ 1353const pdsRoutes = { 1354 '/.well-known/atproto-did': { 1355 handler: (pds, _req, _url) => pds.handleAtprotoDid(), 1356 }, 1357 '/init': { 1358 method: 'POST', 1359 handler: (pds, req, _url) => pds.handleInit(req), 1360 }, 1361 '/status': { 1362 handler: (pds, _req, _url) => pds.handleStatus(), 1363 }, 1364 '/reset-repo': { 1365 handler: (pds, _req, _url) => pds.handleResetRepo(), 1366 }, 1367 '/forward-event': { 1368 handler: (pds, req, _url) => pds.handleForwardEvent(req), 1369 }, 1370 '/register-did': { 1371 handler: (pds, req, _url) => pds.handleRegisterDid(req), 1372 }, 1373 '/get-registered-dids': { 1374 handler: (pds, _req, _url) => pds.handleGetRegisteredDids(), 1375 }, 1376 '/register-handle': { 1377 method: 'POST', 1378 handler: (pds, req, _url) => pds.handleRegisterHandle(req), 1379 }, 1380 '/resolve-handle': { 1381 handler: (pds, _req, url) => pds.handleResolveHandle(url), 1382 }, 1383 '/repo-info': { 1384 handler: (pds, _req, _url) => pds.handleRepoInfo(), 1385 }, 1386 '/xrpc/com.atproto.server.describeServer': { 1387 handler: (pds, req, _url) => pds.handleDescribeServer(req), 1388 }, 1389 '/xrpc/com.atproto.server.createSession': { 1390 method: 'POST', 1391 handler: (pds, req, _url) => pds.handleCreateSession(req), 1392 }, 1393 '/xrpc/com.atproto.server.getSession': { 1394 handler: (pds, req, _url) => pds.handleGetSession(req), 1395 }, 1396 '/xrpc/com.atproto.server.refreshSession': { 1397 method: 'POST', 1398 handler: (pds, req, _url) => pds.handleRefreshSession(req), 1399 }, 1400 '/xrpc/app.bsky.actor.getPreferences': { 1401 handler: (pds, req, _url) => pds.handleGetPreferences(req), 1402 }, 1403 '/xrpc/app.bsky.actor.putPreferences': { 1404 method: 'POST', 1405 handler: (pds, req, _url) => pds.handlePutPreferences(req), 1406 }, 1407 '/xrpc/com.atproto.sync.listRepos': { 1408 handler: (pds, _req, _url) => pds.handleListRepos(), 1409 }, 1410 '/xrpc/com.atproto.repo.createRecord': { 1411 method: 'POST', 1412 handler: (pds, req, _url) => pds.handleCreateRecord(req), 1413 }, 1414 '/xrpc/com.atproto.repo.deleteRecord': { 1415 method: 'POST', 1416 handler: (pds, req, _url) => pds.handleDeleteRecord(req), 1417 }, 1418 '/xrpc/com.atproto.repo.putRecord': { 1419 method: 'POST', 1420 handler: (pds, req, _url) => pds.handlePutRecord(req), 1421 }, 1422 '/xrpc/com.atproto.repo.applyWrites': { 1423 method: 'POST', 1424 handler: (pds, req, _url) => pds.handleApplyWrites(req), 1425 }, 1426 '/xrpc/com.atproto.repo.getRecord': { 1427 handler: (pds, _req, url) => pds.handleGetRecord(url), 1428 }, 1429 '/xrpc/com.atproto.repo.describeRepo': { 1430 handler: (pds, _req, _url) => pds.handleDescribeRepo(), 1431 }, 1432 '/xrpc/com.atproto.repo.listRecords': { 1433 handler: (pds, _req, url) => pds.handleListRecords(url), 1434 }, 1435 '/xrpc/com.atproto.repo.uploadBlob': { 1436 method: 'POST', 1437 handler: (pds, req, _url) => pds.handleUploadBlob(req), 1438 }, 1439 '/xrpc/com.atproto.sync.getLatestCommit': { 1440 handler: (pds, _req, _url) => pds.handleGetLatestCommit(), 1441 }, 1442 '/xrpc/com.atproto.sync.getRepoStatus': { 1443 handler: (pds, _req, _url) => pds.handleGetRepoStatus(), 1444 }, 1445 '/xrpc/com.atproto.sync.getRepo': { 1446 handler: (pds, _req, _url) => pds.handleGetRepo(), 1447 }, 1448 '/xrpc/com.atproto.sync.getRecord': { 1449 handler: (pds, _req, url) => pds.handleSyncGetRecord(url), 1450 }, 1451 '/xrpc/com.atproto.sync.getBlob': { 1452 handler: (pds, _req, url) => pds.handleGetBlob(url), 1453 }, 1454 '/xrpc/com.atproto.sync.listBlobs': { 1455 handler: (pds, _req, url) => pds.handleListBlobs(url), 1456 }, 1457 '/xrpc/com.atproto.sync.subscribeRepos': { 1458 handler: (pds, req, url) => pds.handleSubscribeRepos(req, url), 1459 }, 1460}; 1461 1462// ╔══════════════════════════════════════════════════════════════════════════════╗ 1463// ║ PERSONAL DATA SERVER ║ 1464// ║ Durable Object class implementing ATProto PDS ║ 1465// ╚══════════════════════════════════════════════════════════════════════════════╝ 1466 1467export class PersonalDataServer { 1468 /** @type {string | undefined} */ 1469 _did; 1470 1471 /** 1472 * @param {DurableObjectState} state 1473 * @param {Env} env 1474 */ 1475 constructor(state, env) { 1476 this.state = state; 1477 this.sql = state.storage.sql; 1478 this.env = env; 1479 1480 // Initialize schema 1481 this.sql.exec(` 1482 CREATE TABLE IF NOT EXISTS blocks ( 1483 cid TEXT PRIMARY KEY, 1484 data BLOB NOT NULL 1485 ); 1486 1487 CREATE TABLE IF NOT EXISTS records ( 1488 uri TEXT PRIMARY KEY, 1489 cid TEXT NOT NULL, 1490 collection TEXT NOT NULL, 1491 rkey TEXT NOT NULL, 1492 value BLOB NOT NULL 1493 ); 1494 1495 CREATE TABLE IF NOT EXISTS commits ( 1496 seq INTEGER PRIMARY KEY AUTOINCREMENT, 1497 cid TEXT NOT NULL, 1498 rev TEXT NOT NULL, 1499 prev TEXT 1500 ); 1501 1502 CREATE TABLE IF NOT EXISTS seq_events ( 1503 seq INTEGER PRIMARY KEY AUTOINCREMENT, 1504 did TEXT NOT NULL, 1505 commit_cid TEXT NOT NULL, 1506 evt BLOB NOT NULL 1507 ); 1508 1509 CREATE TABLE IF NOT EXISTS blob ( 1510 cid TEXT PRIMARY KEY, 1511 mimeType TEXT NOT NULL, 1512 size INTEGER NOT NULL, 1513 createdAt TEXT NOT NULL 1514 ); 1515 1516 CREATE TABLE IF NOT EXISTS record_blob ( 1517 blobCid TEXT NOT NULL, 1518 recordUri TEXT NOT NULL, 1519 PRIMARY KEY (blobCid, recordUri) 1520 ); 1521 1522 CREATE INDEX IF NOT EXISTS idx_record_blob_uri ON record_blob(recordUri); 1523 1524 CREATE INDEX IF NOT EXISTS idx_records_collection ON records(collection, rkey); 1525 `); 1526 } 1527 1528 /** 1529 * @param {string} did 1530 * @param {string} privateKeyHex 1531 * @param {string|null} [handle] 1532 */ 1533 async initIdentity(did, privateKeyHex, handle = null) { 1534 await this.state.storage.put('did', did); 1535 await this.state.storage.put('privateKey', privateKeyHex); 1536 if (handle) { 1537 await this.state.storage.put('handle', handle); 1538 } 1539 1540 // Schedule blob cleanup alarm (runs daily) 1541 const currentAlarm = await this.state.storage.getAlarm(); 1542 if (!currentAlarm) { 1543 await this.state.storage.setAlarm(Date.now() + 24 * 60 * 60 * 1000); 1544 } 1545 } 1546 1547 async getDid() { 1548 if (!this._did) { 1549 this._did = await this.state.storage.get('did'); 1550 } 1551 return this._did; 1552 } 1553 1554 async getHandle() { 1555 return this.state.storage.get('handle'); 1556 } 1557 1558 async getSigningKey() { 1559 const hex = await this.state.storage.get('privateKey'); 1560 if (!hex) return null; 1561 return importPrivateKey(hexToBytes(/** @type {string} */(hex))); 1562 } 1563 1564 /** 1565 * Collect MST node blocks for a given root CID 1566 * @param {string} rootCidStr 1567 * @returns {Array<{cid: string, data: Uint8Array}>} 1568 */ 1569 collectMstBlocks(rootCidStr) { 1570 /** @type {Array<{cid: string, data: Uint8Array}>} */ 1571 const blocks = []; 1572 const visited = new Set(); 1573 1574 /** @param {string} cidStr */ 1575 const collect = (cidStr) => { 1576 if (visited.has(cidStr)) return; 1577 visited.add(cidStr); 1578 1579 const rows = /** @type {BlockRow[]} */ (this.sql 1580 .exec(`SELECT data FROM blocks WHERE cid = ?`, cidStr) 1581 .toArray()); 1582 if (rows.length === 0) return; 1583 1584 const data = new Uint8Array(rows[0].data); 1585 blocks.push({ cid: cidStr, data }); // Keep as string, buildCarFile will convert 1586 1587 // Decode and follow child CIDs (MST nodes have 'l' and 'e' with 't' subtrees) 1588 try { 1589 const node = cborDecode(data); 1590 if (node.l) collect(cidToString(node.l)); 1591 if (node.e) { 1592 for (const entry of node.e) { 1593 if (entry.t) collect(cidToString(entry.t)); 1594 } 1595 } 1596 } catch (_e) { 1597 // Not an MST node, ignore 1598 } 1599 }; 1600 1601 collect(rootCidStr); 1602 return blocks; 1603 } 1604 1605 /** 1606 * @param {string} collection 1607 * @param {Record<string, *>} record 1608 * @param {string|null} [rkey] 1609 * @returns {Promise<{uri: string, cid: string, commit: string}>} 1610 */ 1611 async createRecord(collection, record, rkey = null) { 1612 const did = await this.getDid(); 1613 if (!did) throw new Error('PDS not initialized'); 1614 1615 rkey = rkey || createTid(); 1616 const uri = `at://${did}/${collection}/${rkey}`; 1617 1618 // Encode and hash record (must use DAG-CBOR for proper key ordering) 1619 const recordBytes = cborEncodeDagCbor(record); 1620 const recordCid = await createCid(recordBytes); 1621 const recordCidStr = cidToString(recordCid); 1622 1623 // Store block 1624 this.sql.exec( 1625 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, 1626 recordCidStr, 1627 recordBytes, 1628 ); 1629 1630 // Store record index 1631 this.sql.exec( 1632 `INSERT OR REPLACE INTO records (uri, cid, collection, rkey, value) VALUES (?, ?, ?, ?, ?)`, 1633 uri, 1634 recordCidStr, 1635 collection, 1636 rkey, 1637 recordBytes, 1638 ); 1639 1640 // Associate blobs with this record (delete old associations first for updates) 1641 this.sql.exec('DELETE FROM record_blob WHERE recordUri = ?', uri); 1642 1643 const blobRefs = findBlobRefs(record); 1644 for (const blobCid of blobRefs) { 1645 // Verify blob exists 1646 const blobExists = this.sql 1647 .exec('SELECT cid FROM blob WHERE cid = ?', blobCid) 1648 .toArray(); 1649 1650 if (blobExists.length === 0) { 1651 throw new Error(`BlobNotFound: ${blobCid}`); 1652 } 1653 1654 // Create association 1655 this.sql.exec( 1656 'INSERT INTO record_blob (blobCid, recordUri) VALUES (?, ?)', 1657 blobCid, 1658 uri, 1659 ); 1660 } 1661 1662 // Rebuild MST 1663 const mst = new MST(this.sql); 1664 const dataRoot = await mst.computeRoot(); 1665 1666 // Get previous commit 1667 const prevCommits = this.sql 1668 .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`) 1669 .toArray(); 1670 const prevCommit = prevCommits.length > 0 ? prevCommits[0] : null; 1671 1672 // Create commit 1673 const rev = createTid(); 1674 // Build commit with CIDs wrapped in CID class (for dag-cbor tag 42 encoding) 1675 const commit = { 1676 did, 1677 version: 3, 1678 data: new CID(cidToBytes(/** @type {string} */(dataRoot))), // CID wrapped for explicit encoding 1679 rev, 1680 prev: prevCommit?.cid ? new CID(cidToBytes(/** @type {string} */(prevCommit.cid))) : null, 1681 }; 1682 1683 // Sign commit (using dag-cbor encoder for CIDs) 1684 const commitBytes = cborEncodeDagCbor(commit); 1685 const signingKey = await this.getSigningKey(); 1686 if (!signingKey) throw new Error('No signing key'); 1687 const sig = await sign(signingKey, commitBytes); 1688 1689 const signedCommit = { ...commit, sig }; 1690 const signedBytes = cborEncodeDagCbor(signedCommit); 1691 const commitCid = await createCid(signedBytes); 1692 const commitCidStr = cidToString(commitCid); 1693 1694 // Store commit block 1695 this.sql.exec( 1696 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, 1697 commitCidStr, 1698 signedBytes, 1699 ); 1700 1701 // Store commit reference 1702 this.sql.exec( 1703 `INSERT INTO commits (cid, rev, prev) VALUES (?, ?, ?)`, 1704 commitCidStr, 1705 rev, 1706 prevCommit?.cid || null, 1707 ); 1708 1709 // Update head and rev for listRepos 1710 await this.state.storage.put('head', commitCidStr); 1711 await this.state.storage.put('rev', rev); 1712 1713 // Collect blocks for the event (record + commit + MST nodes) 1714 // Build a mini CAR with just the new blocks - use string CIDs 1715 const newBlocks = []; 1716 // Add record block 1717 newBlocks.push({ cid: recordCidStr, data: recordBytes }); 1718 // Add commit block 1719 newBlocks.push({ cid: commitCidStr, data: signedBytes }); 1720 // Add MST node blocks (get all blocks referenced by commit.data) 1721 const mstBlocks = this.collectMstBlocks(/** @type {string} */(dataRoot)); 1722 newBlocks.push(...mstBlocks); 1723 1724 // Sequence event with blocks - store complete event data including rev and time 1725 // blocks must be a full CAR file with header (roots = [commitCid]) 1726 const eventTime = new Date().toISOString(); 1727 const evt = cborEncode({ 1728 ops: [ 1729 { action: 'create', path: `${collection}/${rkey}`, cid: recordCidStr }, 1730 ], 1731 blocks: buildCarFile(commitCidStr, newBlocks), // Full CAR with header 1732 rev, // Store the actual commit revision 1733 time: eventTime, // Store the actual event time 1734 }); 1735 this.sql.exec( 1736 `INSERT INTO seq_events (did, commit_cid, evt) VALUES (?, ?, ?)`, 1737 did, 1738 commitCidStr, 1739 evt, 1740 ); 1741 1742 // Broadcast to subscribers (both local and via default DO for relay) 1743 const evtRows = /** @type {SeqEventRow[]} */ (this.sql 1744 .exec(`SELECT * FROM seq_events ORDER BY seq DESC LIMIT 1`) 1745 .toArray()); 1746 if (evtRows.length > 0) { 1747 this.broadcastEvent(evtRows[0]); 1748 // Also forward to default DO for relay subscribers 1749 if (this.env?.PDS) { 1750 const defaultId = this.env.PDS.idFromName('default'); 1751 const defaultPds = this.env.PDS.get(defaultId); 1752 // Convert ArrayBuffer to array for JSON serialization 1753 const row = evtRows[0]; 1754 const evtArray = Array.from(new Uint8Array(row.evt)); 1755 // Fire and forget but log errors 1756 defaultPds 1757 .fetch( 1758 new Request('http://internal/forward-event', { 1759 method: 'POST', 1760 body: JSON.stringify({ ...row, evt: evtArray }), 1761 }), 1762 ) 1763 .catch(() => { }); // Ignore forward errors 1764 } 1765 } 1766 1767 return { uri, cid: recordCidStr, commit: commitCidStr }; 1768 } 1769 1770 /** 1771 * @param {string} collection 1772 * @param {string} rkey 1773 */ 1774 async deleteRecord(collection, rkey) { 1775 const did = await this.getDid(); 1776 if (!did) throw new Error('PDS not initialized'); 1777 1778 const uri = `at://${did}/${collection}/${rkey}`; 1779 1780 // Check if record exists 1781 const existing = this.sql 1782 .exec(`SELECT cid FROM records WHERE uri = ?`, uri) 1783 .toArray(); 1784 if (existing.length === 0) { 1785 return { error: 'RecordNotFound', message: 'record not found' }; 1786 } 1787 1788 // Delete from records table 1789 this.sql.exec(`DELETE FROM records WHERE uri = ?`, uri); 1790 1791 // Get blobs associated with this record 1792 const associatedBlobs = this.sql 1793 .exec('SELECT blobCid FROM record_blob WHERE recordUri = ?', uri) 1794 .toArray(); 1795 1796 // Remove associations for this record 1797 this.sql.exec('DELETE FROM record_blob WHERE recordUri = ?', uri); 1798 1799 // Check each blob for orphan status and delete if unreferenced 1800 for (const { blobCid } of associatedBlobs) { 1801 const stillReferenced = this.sql 1802 .exec('SELECT 1 FROM record_blob WHERE blobCid = ? LIMIT 1', blobCid) 1803 .toArray(); 1804 1805 if (stillReferenced.length === 0) { 1806 // Blob is orphaned, delete from R2 and database 1807 await this.env?.BLOBS?.delete(`${did}/${blobCid}`); 1808 this.sql.exec('DELETE FROM blob WHERE cid = ?', blobCid); 1809 } 1810 } 1811 1812 // Rebuild MST 1813 const mst = new MST(this.sql); 1814 const dataRoot = await mst.computeRoot(); 1815 1816 // Get previous commit 1817 const prevCommits = this.sql 1818 .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`) 1819 .toArray(); 1820 const prevCommit = prevCommits.length > 0 ? prevCommits[0] : null; 1821 1822 // Create commit 1823 const rev = createTid(); 1824 const commit = { 1825 did, 1826 version: 3, 1827 data: dataRoot ? new CID(cidToBytes(/** @type {string} */(dataRoot))) : null, 1828 rev, 1829 prev: prevCommit?.cid ? new CID(cidToBytes(/** @type {string} */(prevCommit.cid))) : null, 1830 }; 1831 1832 // Sign commit 1833 const commitBytes = cborEncodeDagCbor(commit); 1834 const signingKey = await this.getSigningKey(); 1835 if (!signingKey) throw new Error('No signing key'); 1836 const sig = await sign(signingKey, commitBytes); 1837 1838 const signedCommit = { ...commit, sig }; 1839 const signedBytes = cborEncodeDagCbor(signedCommit); 1840 const commitCid = await createCid(signedBytes); 1841 const commitCidStr = cidToString(commitCid); 1842 1843 // Store commit block 1844 this.sql.exec( 1845 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, 1846 commitCidStr, 1847 signedBytes, 1848 ); 1849 1850 // Store commit reference 1851 this.sql.exec( 1852 `INSERT INTO commits (cid, rev, prev) VALUES (?, ?, ?)`, 1853 commitCidStr, 1854 rev, 1855 prevCommit?.cid || null, 1856 ); 1857 1858 // Update head and rev 1859 await this.state.storage.put('head', commitCidStr); 1860 await this.state.storage.put('rev', rev); 1861 1862 // Collect blocks for the event (commit + MST nodes, no record block) 1863 const newBlocks = []; 1864 newBlocks.push({ cid: commitCidStr, data: signedBytes }); 1865 if (dataRoot) { 1866 const mstBlocks = this.collectMstBlocks(/** @type {string} */(dataRoot)); 1867 newBlocks.push(...mstBlocks); 1868 } 1869 1870 // Sequence event with delete action 1871 const eventTime = new Date().toISOString(); 1872 const evt = cborEncode({ 1873 ops: [{ action: 'delete', path: `${collection}/${rkey}`, cid: null }], 1874 blocks: buildCarFile(commitCidStr, newBlocks), 1875 rev, 1876 time: eventTime, 1877 }); 1878 this.sql.exec( 1879 `INSERT INTO seq_events (did, commit_cid, evt) VALUES (?, ?, ?)`, 1880 did, 1881 commitCidStr, 1882 evt, 1883 ); 1884 1885 // Broadcast to subscribers 1886 const evtRows = /** @type {SeqEventRow[]} */ (this.sql 1887 .exec(`SELECT * FROM seq_events ORDER BY seq DESC LIMIT 1`) 1888 .toArray()); 1889 if (evtRows.length > 0) { 1890 this.broadcastEvent(evtRows[0]); 1891 // Forward to default DO for relay subscribers 1892 if (this.env?.PDS) { 1893 const defaultId = this.env.PDS.idFromName('default'); 1894 const defaultPds = this.env.PDS.get(defaultId); 1895 const row = evtRows[0]; 1896 const evtArray = Array.from(new Uint8Array(row.evt)); 1897 defaultPds 1898 .fetch( 1899 new Request('http://internal/forward-event', { 1900 method: 'POST', 1901 body: JSON.stringify({ ...row, evt: evtArray }), 1902 }), 1903 ) 1904 .catch(() => { }); // Ignore forward errors 1905 } 1906 } 1907 1908 return { ok: true }; 1909 } 1910 1911 /** 1912 * @param {SeqEventRow} evt 1913 * @returns {Uint8Array} 1914 */ 1915 formatEvent(evt) { 1916 // AT Protocol frame format: header + body 1917 // Use DAG-CBOR encoding for body (CIDs need tag 42 + 0x00 prefix) 1918 const header = cborEncode({ op: 1, t: '#commit' }); 1919 1920 // Decode stored event to get ops, blocks, rev, and time 1921 const evtData = cborDecode(new Uint8Array(evt.evt)); 1922 /** @type {Array<{action: string, path: string, cid: CID|null}>} */ 1923 const ops = evtData.ops.map((/** @type {{action: string, path: string, cid?: string}} */ op) => ({ 1924 ...op, 1925 cid: op.cid ? new CID(cidToBytes(op.cid)) : null, // Wrap in CID class for tag 42 encoding 1926 })); 1927 // Get blocks from stored event (already in CAR format) 1928 const blocks = evtData.blocks || new Uint8Array(0); 1929 1930 const body = cborEncodeDagCbor({ 1931 seq: evt.seq, 1932 rebase: false, 1933 tooBig: false, 1934 repo: evt.did, 1935 commit: new CID(cidToBytes(evt.commit_cid)), // Wrap in CID class for tag 42 encoding 1936 rev: evtData.rev, // Use stored rev from commit creation 1937 since: null, 1938 blocks: blocks instanceof Uint8Array ? blocks : new Uint8Array(blocks), 1939 ops, 1940 blobs: [], 1941 time: evtData.time, // Use stored time from event creation 1942 }); 1943 1944 // Concatenate header + body 1945 const frame = new Uint8Array(header.length + body.length); 1946 frame.set(header); 1947 frame.set(body, header.length); 1948 return frame; 1949 } 1950 1951 /** 1952 * @param {WebSocket} ws 1953 * @param {string | ArrayBuffer} message 1954 */ 1955 async webSocketMessage(ws, message) { 1956 // Handle ping 1957 if (message === 'ping') ws.send('pong'); 1958 } 1959 1960 /** 1961 * @param {WebSocket} _ws 1962 * @param {number} _code 1963 * @param {string} _reason 1964 */ 1965 async webSocketClose(_ws, _code, _reason) { 1966 // Durable Object will hibernate when no connections remain 1967 } 1968 1969 /** 1970 * @param {SeqEventRow} evt 1971 */ 1972 broadcastEvent(evt) { 1973 const frame = this.formatEvent(evt); 1974 for (const ws of this.state.getWebSockets()) { 1975 try { 1976 ws.send(frame); 1977 } catch (_e) { 1978 // Client disconnected 1979 } 1980 } 1981 } 1982 1983 async handleAtprotoDid() { 1984 let did = await this.getDid(); 1985 if (!did) { 1986 /** @type {string[]} */ 1987 const registeredDids = 1988 (await this.state.storage.get('registeredDids')) || []; 1989 did = registeredDids[0]; 1990 } 1991 if (!did) { 1992 return new Response('User not found', { status: 404 }); 1993 } 1994 return new Response(/** @type {string} */(did), { headers: { 'Content-Type': 'text/plain' } }); 1995 } 1996 1997 /** @param {Request} request */ 1998 async handleInit(request) { 1999 const body = await request.json(); 2000 if (!body.did || !body.privateKey) { 2001 return errorResponse('InvalidRequest', 'missing did or privateKey', 400); 2002 } 2003 await this.initIdentity(body.did, body.privateKey, body.handle || null); 2004 return Response.json({ 2005 ok: true, 2006 did: body.did, 2007 handle: body.handle || null, 2008 }); 2009 } 2010 2011 async handleStatus() { 2012 const did = await this.getDid(); 2013 return Response.json({ initialized: !!did, did: did || null }); 2014 } 2015 2016 async handleResetRepo() { 2017 this.sql.exec(`DELETE FROM blocks`); 2018 this.sql.exec(`DELETE FROM records`); 2019 this.sql.exec(`DELETE FROM commits`); 2020 this.sql.exec(`DELETE FROM seq_events`); 2021 await this.state.storage.delete('head'); 2022 await this.state.storage.delete('rev'); 2023 return Response.json({ ok: true, message: 'repo data cleared' }); 2024 } 2025 2026 /** @param {Request} request */ 2027 async handleForwardEvent(request) { 2028 const evt = await request.json(); 2029 const numSockets = [...this.state.getWebSockets()].length; 2030 this.broadcastEvent({ 2031 seq: evt.seq, 2032 did: evt.did, 2033 commit_cid: evt.commit_cid, 2034 evt: new Uint8Array(Object.values(evt.evt)), 2035 }); 2036 return Response.json({ ok: true, sockets: numSockets }); 2037 } 2038 2039 /** @param {Request} request */ 2040 async handleRegisterDid(request) { 2041 const body = await request.json(); 2042 /** @type {string[]} */ 2043 const registeredDids = 2044 (await this.state.storage.get('registeredDids')) || []; 2045 if (!registeredDids.includes(body.did)) { 2046 registeredDids.push(body.did); 2047 await this.state.storage.put('registeredDids', registeredDids); 2048 } 2049 return Response.json({ ok: true }); 2050 } 2051 2052 async handleGetRegisteredDids() { 2053 const registeredDids = 2054 (await this.state.storage.get('registeredDids')) || []; 2055 return Response.json({ dids: registeredDids }); 2056 } 2057 2058 /** @param {Request} request */ 2059 async handleRegisterHandle(request) { 2060 const body = await request.json(); 2061 const { handle, did } = body; 2062 if (!handle || !did) { 2063 return errorResponse('InvalidRequest', 'missing handle or did', 400); 2064 } 2065 /** @type {Record<string, string>} */ 2066 const handleMap = (await this.state.storage.get('handleMap')) || {}; 2067 handleMap[handle] = did; 2068 await this.state.storage.put('handleMap', handleMap); 2069 return Response.json({ ok: true }); 2070 } 2071 2072 /** @param {URL} url */ 2073 async handleResolveHandle(url) { 2074 const handle = url.searchParams.get('handle'); 2075 if (!handle) { 2076 return errorResponse('InvalidRequest', 'missing handle', 400); 2077 } 2078 /** @type {Record<string, string>} */ 2079 const handleMap = (await this.state.storage.get('handleMap')) || {}; 2080 const did = handleMap[handle]; 2081 if (!did) { 2082 return errorResponse('NotFound', 'handle not found', 404); 2083 } 2084 return Response.json({ did }); 2085 } 2086 2087 async handleRepoInfo() { 2088 const head = await this.state.storage.get('head'); 2089 const rev = await this.state.storage.get('rev'); 2090 return Response.json({ head: head || null, rev: rev || null }); 2091 } 2092 2093 /** @param {Request} request */ 2094 handleDescribeServer(request) { 2095 const hostname = request.headers.get('x-hostname') || 'localhost'; 2096 return Response.json({ 2097 did: `did:web:${hostname}`, 2098 availableUserDomains: [`.${hostname}`], 2099 inviteCodeRequired: false, 2100 phoneVerificationRequired: false, 2101 links: {}, 2102 contact: {}, 2103 }); 2104 } 2105 2106 /** @param {Request} request */ 2107 async handleCreateSession(request) { 2108 const body = await request.json(); 2109 const { identifier, password } = body; 2110 2111 if (!identifier || !password) { 2112 return errorResponse( 2113 'InvalidRequest', 2114 'Missing identifier or password', 2115 400, 2116 ); 2117 } 2118 2119 // Check password against env var 2120 const expectedPassword = this.env?.PDS_PASSWORD; 2121 if (!expectedPassword || password !== expectedPassword) { 2122 return errorResponse( 2123 'AuthRequired', 2124 'Invalid identifier or password', 2125 401, 2126 ); 2127 } 2128 2129 // Resolve identifier to DID 2130 let did = identifier; 2131 if (!identifier.startsWith('did:')) { 2132 // Try to resolve handle 2133 /** @type {Record<string, string>} */ 2134 const handleMap = (await this.state.storage.get('handleMap')) || {}; 2135 did = handleMap[identifier]; 2136 if (!did) { 2137 return errorResponse('InvalidRequest', 'Unable to resolve handle', 400); 2138 } 2139 } 2140 2141 // Get handle for response 2142 const handle = await this.getHandleForDid(did); 2143 2144 // Create tokens 2145 const jwtSecret = this.env?.JWT_SECRET; 2146 if (!jwtSecret) { 2147 return errorResponse( 2148 'InternalServerError', 2149 'Server not configured for authentication', 2150 500, 2151 ); 2152 } 2153 2154 const accessJwt = await createAccessJwt(did, jwtSecret); 2155 const refreshJwt = await createRefreshJwt(did, jwtSecret); 2156 2157 return Response.json({ 2158 accessJwt, 2159 refreshJwt, 2160 handle: handle || did, 2161 did, 2162 active: true, 2163 }); 2164 } 2165 2166 /** @param {Request} request */ 2167 async handleGetSession(request) { 2168 const authHeader = request.headers.get('Authorization'); 2169 if (!authHeader || !authHeader.startsWith('Bearer ')) { 2170 return errorResponse( 2171 'AuthRequired', 2172 'Missing or invalid authorization header', 2173 401, 2174 ); 2175 } 2176 2177 const token = authHeader.slice(7); // Remove 'Bearer ' 2178 const jwtSecret = this.env?.JWT_SECRET; 2179 if (!jwtSecret) { 2180 return errorResponse( 2181 'InternalServerError', 2182 'Server not configured for authentication', 2183 500, 2184 ); 2185 } 2186 2187 try { 2188 const payload = await verifyAccessJwt(token, jwtSecret); 2189 const did = payload.sub; 2190 const handle = await this.getHandleForDid(did); 2191 2192 return Response.json({ 2193 handle: handle || did, 2194 did, 2195 active: true, 2196 }); 2197 } catch (err) { 2198 const message = err instanceof Error ? err.message : String(err); 2199 return errorResponse('InvalidToken', message, 401); 2200 } 2201 } 2202 2203 /** @param {Request} request */ 2204 async handleRefreshSession(request) { 2205 const authHeader = request.headers.get('Authorization'); 2206 if (!authHeader || !authHeader.startsWith('Bearer ')) { 2207 return errorResponse( 2208 'AuthRequired', 2209 'Missing or invalid authorization header', 2210 401, 2211 ); 2212 } 2213 2214 const token = authHeader.slice(7); // Remove 'Bearer ' 2215 const jwtSecret = this.env?.JWT_SECRET; 2216 if (!jwtSecret) { 2217 return errorResponse( 2218 'InternalServerError', 2219 'Server not configured for authentication', 2220 500, 2221 ); 2222 } 2223 2224 try { 2225 const payload = await verifyRefreshJwt(token, jwtSecret); 2226 const did = payload.sub; 2227 const handle = await this.getHandleForDid(did); 2228 2229 // Issue fresh tokens 2230 const accessJwt = await createAccessJwt(did, jwtSecret); 2231 const refreshJwt = await createRefreshJwt(did, jwtSecret); 2232 2233 return Response.json({ 2234 accessJwt, 2235 refreshJwt, 2236 handle: handle || did, 2237 did, 2238 active: true, 2239 }); 2240 } catch (err) { 2241 const message = err instanceof Error ? err.message : String(err); 2242 if (message === 'Token expired') { 2243 return errorResponse('ExpiredToken', 'Refresh token has expired', 400); 2244 } 2245 return errorResponse('InvalidToken', message, 400); 2246 } 2247 } 2248 2249 /** @param {Request} _request */ 2250 async handleGetPreferences(_request) { 2251 // Preferences are stored per-user in their DO 2252 const preferences = (await this.state.storage.get('preferences')) || []; 2253 return Response.json({ preferences }); 2254 } 2255 2256 /** @param {Request} request */ 2257 async handlePutPreferences(request) { 2258 const body = await request.json(); 2259 const { preferences } = body; 2260 if (!Array.isArray(preferences)) { 2261 return errorResponse( 2262 'InvalidRequest', 2263 'preferences must be an array', 2264 400, 2265 ); 2266 } 2267 await this.state.storage.put('preferences', preferences); 2268 return Response.json({}); 2269 } 2270 2271 /** 2272 * @param {string} did 2273 * @returns {Promise<string|null>} 2274 */ 2275 async getHandleForDid(did) { 2276 // Check if this DID has a handle registered 2277 /** @type {Record<string, string>} */ 2278 const handleMap = (await this.state.storage.get('handleMap')) || {}; 2279 for (const [handle, mappedDid] of Object.entries(handleMap)) { 2280 if (mappedDid === did) return handle; 2281 } 2282 // Check instance's own handle 2283 const instanceDid = await this.getDid(); 2284 if (instanceDid === did) { 2285 return /** @type {string|null} */ (await this.state.storage.get('handle')); 2286 } 2287 return null; 2288 } 2289 2290 /** 2291 * @param {string} did 2292 * @param {string|null} lxm 2293 */ 2294 async createServiceAuthForAppView(did, lxm) { 2295 const signingKey = await this.getSigningKey(); 2296 if (!signingKey) throw new Error('No signing key available'); 2297 return createServiceJwt({ 2298 iss: did, 2299 aud: 'did:web:api.bsky.app', 2300 lxm, 2301 signingKey, 2302 }); 2303 } 2304 2305 /** 2306 * @param {Request} request 2307 * @param {string} userDid 2308 */ 2309 async handleAppViewProxy(request, userDid) { 2310 const url = new URL(request.url); 2311 // Extract lexicon method from path: /xrpc/app.bsky.actor.getPreferences -> app.bsky.actor.getPreferences 2312 const lxm = url.pathname.replace('/xrpc/', ''); 2313 2314 // Create service auth JWT 2315 const serviceJwt = await this.createServiceAuthForAppView(userDid, lxm); 2316 2317 // Build AppView URL 2318 const appViewUrl = new URL( 2319 url.pathname + url.search, 2320 'https://api.bsky.app', 2321 ); 2322 2323 // Forward request with service auth 2324 const headers = new Headers(); 2325 headers.set('Authorization', `Bearer ${serviceJwt}`); 2326 headers.set( 2327 'Content-Type', 2328 request.headers.get('Content-Type') || 'application/json', 2329 ); 2330 const acceptHeader = request.headers.get('Accept'); 2331 if (acceptHeader) { 2332 headers.set('Accept', acceptHeader); 2333 } 2334 const acceptLangHeader = request.headers.get('Accept-Language'); 2335 if (acceptLangHeader) { 2336 headers.set('Accept-Language', acceptLangHeader); 2337 } 2338 2339 const proxyReq = new Request(appViewUrl.toString(), { 2340 method: request.method, 2341 headers, 2342 body: 2343 request.method !== 'GET' && request.method !== 'HEAD' 2344 ? request.body 2345 : undefined, 2346 }); 2347 2348 try { 2349 const response = await fetch(proxyReq); 2350 // Return the response with CORS headers 2351 const responseHeaders = new Headers(response.headers); 2352 responseHeaders.set('Access-Control-Allow-Origin', '*'); 2353 return new Response(response.body, { 2354 status: response.status, 2355 statusText: response.statusText, 2356 headers: responseHeaders, 2357 }); 2358 } catch (err) { 2359 const message = err instanceof Error ? err.message : String(err); 2360 return errorResponse( 2361 'UpstreamFailure', 2362 `Failed to reach AppView: ${message}`, 2363 502, 2364 ); 2365 } 2366 } 2367 2368 async handleListRepos() { 2369 /** @type {string[]} */ 2370 const registeredDids = 2371 (await this.state.storage.get('registeredDids')) || []; 2372 const did = await this.getDid(); 2373 const repos = did 2374 ? [{ did, head: null, rev: null }] 2375 : registeredDids.map((/** @type {string} */ d) => ({ did: d, head: null, rev: null })); 2376 return Response.json({ repos }); 2377 } 2378 2379 /** @param {Request} request */ 2380 async handleCreateRecord(request) { 2381 const body = await request.json(); 2382 if (!body.collection || !body.record) { 2383 return errorResponse( 2384 'InvalidRequest', 2385 'missing collection or record', 2386 400, 2387 ); 2388 } 2389 try { 2390 const result = await this.createRecord( 2391 body.collection, 2392 body.record, 2393 body.rkey, 2394 ); 2395 const head = await this.state.storage.get('head'); 2396 const rev = await this.state.storage.get('rev'); 2397 return Response.json({ 2398 uri: result.uri, 2399 cid: result.cid, 2400 commit: { cid: head, rev }, 2401 validationStatus: 'valid', 2402 }); 2403 } catch (err) { 2404 const message = err instanceof Error ? err.message : String(err); 2405 return errorResponse('InternalError', message, 500); 2406 } 2407 } 2408 2409 /** @param {Request} request */ 2410 async handleDeleteRecord(request) { 2411 const body = await request.json(); 2412 if (!body.collection || !body.rkey) { 2413 return errorResponse('InvalidRequest', 'missing collection or rkey', 400); 2414 } 2415 try { 2416 const result = await this.deleteRecord(body.collection, body.rkey); 2417 if (result.error) { 2418 return Response.json(result, { status: 404 }); 2419 } 2420 return Response.json({}); 2421 } catch (err) { 2422 const message = err instanceof Error ? err.message : String(err); 2423 return errorResponse('InternalError', message, 500); 2424 } 2425 } 2426 2427 /** @param {Request} request */ 2428 async handlePutRecord(request) { 2429 const body = await request.json(); 2430 if (!body.collection || !body.rkey || !body.record) { 2431 return errorResponse( 2432 'InvalidRequest', 2433 'missing collection, rkey, or record', 2434 400, 2435 ); 2436 } 2437 try { 2438 // putRecord is like createRecord but with a specific rkey (upsert) 2439 const result = await this.createRecord( 2440 body.collection, 2441 body.record, 2442 body.rkey, 2443 ); 2444 const head = await this.state.storage.get('head'); 2445 const rev = await this.state.storage.get('rev'); 2446 return Response.json({ 2447 uri: result.uri, 2448 cid: result.cid, 2449 commit: { cid: head, rev }, 2450 validationStatus: 'valid', 2451 }); 2452 } catch (err) { 2453 const message = err instanceof Error ? err.message : String(err); 2454 return errorResponse('InternalError', message, 500); 2455 } 2456 } 2457 2458 /** @param {Request} request */ 2459 async handleApplyWrites(request) { 2460 const body = await request.json(); 2461 if (!body.writes || !Array.isArray(body.writes)) { 2462 return errorResponse('InvalidRequest', 'missing writes array', 400); 2463 } 2464 try { 2465 const results = []; 2466 for (const write of body.writes) { 2467 const type = write.$type; 2468 if (type === 'com.atproto.repo.applyWrites#create') { 2469 const result = await this.createRecord( 2470 write.collection, 2471 write.value, 2472 write.rkey, 2473 ); 2474 results.push({ 2475 $type: 'com.atproto.repo.applyWrites#createResult', 2476 uri: result.uri, 2477 cid: result.cid, 2478 validationStatus: 'valid', 2479 }); 2480 } else if (type === 'com.atproto.repo.applyWrites#update') { 2481 const result = await this.createRecord( 2482 write.collection, 2483 write.value, 2484 write.rkey, 2485 ); 2486 results.push({ 2487 $type: 'com.atproto.repo.applyWrites#updateResult', 2488 uri: result.uri, 2489 cid: result.cid, 2490 validationStatus: 'valid', 2491 }); 2492 } else if (type === 'com.atproto.repo.applyWrites#delete') { 2493 await this.deleteRecord(write.collection, write.rkey); 2494 results.push({ 2495 $type: 'com.atproto.repo.applyWrites#deleteResult', 2496 }); 2497 } else { 2498 return errorResponse( 2499 'InvalidRequest', 2500 `Unknown write operation type: ${type}`, 2501 400, 2502 ); 2503 } 2504 } 2505 // Return commit info 2506 const head = await this.state.storage.get('head'); 2507 const rev = await this.state.storage.get('rev'); 2508 return Response.json({ commit: { cid: head, rev }, results }); 2509 } catch (err) { 2510 const message = err instanceof Error ? err.message : String(err); 2511 return errorResponse('InternalError', message, 500); 2512 } 2513 } 2514 2515 /** @param {URL} url */ 2516 async handleGetRecord(url) { 2517 const collection = url.searchParams.get('collection'); 2518 const rkey = url.searchParams.get('rkey'); 2519 if (!collection || !rkey) { 2520 return errorResponse('InvalidRequest', 'missing collection or rkey', 400); 2521 } 2522 const did = await this.getDid(); 2523 const uri = `at://${did}/${collection}/${rkey}`; 2524 const rows = /** @type {RecordRow[]} */ (this.sql 2525 .exec(`SELECT cid, value FROM records WHERE uri = ?`, uri) 2526 .toArray()); 2527 if (rows.length === 0) { 2528 return errorResponse('RecordNotFound', 'record not found', 404); 2529 } 2530 const row = rows[0]; 2531 const value = cborDecode(new Uint8Array(row.value)); 2532 return Response.json({ uri, cid: row.cid, value }); 2533 } 2534 2535 async handleDescribeRepo() { 2536 const did = await this.getDid(); 2537 if (!did) { 2538 return errorResponse('RepoNotFound', 'repo not found', 404); 2539 } 2540 const handle = await this.state.storage.get('handle'); 2541 // Get unique collections 2542 const collections = this.sql 2543 .exec(`SELECT DISTINCT collection FROM records`) 2544 .toArray() 2545 .map((r) => r.collection); 2546 2547 return Response.json({ 2548 handle: handle || did, 2549 did, 2550 didDoc: {}, 2551 collections, 2552 handleIsCorrect: !!handle, 2553 }); 2554 } 2555 2556 /** @param {URL} url */ 2557 async handleListRecords(url) { 2558 const collection = url.searchParams.get('collection'); 2559 if (!collection) { 2560 return errorResponse('InvalidRequest', 'missing collection', 400); 2561 } 2562 const limit = Math.min( 2563 parseInt(url.searchParams.get('limit') || '50', 10), 2564 100, 2565 ); 2566 const reverse = url.searchParams.get('reverse') === 'true'; 2567 const _cursor = url.searchParams.get('cursor'); 2568 2569 const _did = await this.getDid(); 2570 const query = `SELECT uri, cid, value FROM records WHERE collection = ? ORDER BY rkey ${reverse ? 'DESC' : 'ASC'} LIMIT ?`; 2571 const params = [collection, limit + 1]; 2572 2573 const rows = /** @type {RecordRow[]} */ (this.sql.exec(query, ...params).toArray()); 2574 const hasMore = rows.length > limit; 2575 const records = rows.slice(0, limit).map((r) => ({ 2576 uri: r.uri, 2577 cid: r.cid, 2578 value: cborDecode(new Uint8Array(r.value)), 2579 })); 2580 2581 return Response.json({ 2582 records, 2583 cursor: hasMore ? records[records.length - 1]?.uri : undefined, 2584 }); 2585 } 2586 2587 handleGetLatestCommit() { 2588 const commits = /** @type {CommitRow[]} */ (this.sql 2589 .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`) 2590 .toArray()); 2591 if (commits.length === 0) { 2592 return errorResponse('RepoNotFound', 'repo not found', 404); 2593 } 2594 return Response.json({ cid: commits[0].cid, rev: commits[0].rev }); 2595 } 2596 2597 async handleGetRepoStatus() { 2598 const did = await this.getDid(); 2599 const commits = /** @type {CommitRow[]} */ (this.sql 2600 .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`) 2601 .toArray()); 2602 if (commits.length === 0 || !did) { 2603 return errorResponse('RepoNotFound', 'repo not found', 404); 2604 } 2605 return Response.json({ 2606 did, 2607 active: true, 2608 status: 'active', 2609 rev: commits[0].rev, 2610 }); 2611 } 2612 2613 handleGetRepo() { 2614 const commits = /** @type {CommitRow[]} */ (this.sql 2615 .exec(`SELECT cid FROM commits ORDER BY seq DESC LIMIT 1`) 2616 .toArray()); 2617 if (commits.length === 0) { 2618 return errorResponse('RepoNotFound', 'repo not found', 404); 2619 } 2620 2621 // Only include blocks reachable from the current commit 2622 const commitCid = commits[0].cid; 2623 const neededCids = new Set(); 2624 2625 // Helper to get block data 2626 /** @param {string} cid */ 2627 const getBlock = (cid) => { 2628 const rows = /** @type {BlockRow[]} */ (this.sql 2629 .exec(`SELECT data FROM blocks WHERE cid = ?`, cid) 2630 .toArray()); 2631 return rows.length > 0 ? new Uint8Array(rows[0].data) : null; 2632 }; 2633 2634 // Collect all reachable blocks starting from commit 2635 /** @param {string} cid */ 2636 const collectBlocks = (cid) => { 2637 if (neededCids.has(cid)) return; 2638 neededCids.add(cid); 2639 2640 const data = getBlock(cid); 2641 if (!data) return; 2642 2643 // Decode CBOR to find CID references 2644 try { 2645 const decoded = cborDecode(data); 2646 if (decoded && typeof decoded === 'object') { 2647 // Commit object - follow 'data' (MST root) 2648 if (decoded.data instanceof Uint8Array) { 2649 collectBlocks(cidToString(decoded.data)); 2650 } 2651 // MST node - follow 'l' and entries' 'v' and 't' 2652 if (decoded.l instanceof Uint8Array) { 2653 collectBlocks(cidToString(decoded.l)); 2654 } 2655 if (Array.isArray(decoded.e)) { 2656 for (const entry of decoded.e) { 2657 if (entry.v instanceof Uint8Array) { 2658 collectBlocks(cidToString(entry.v)); 2659 } 2660 if (entry.t instanceof Uint8Array) { 2661 collectBlocks(cidToString(entry.t)); 2662 } 2663 } 2664 } 2665 } 2666 } catch (_e) { 2667 // Not a structured block, that's fine 2668 } 2669 }; 2670 2671 collectBlocks(commitCid); 2672 2673 // Build CAR with only needed blocks 2674 const blocksForCar = []; 2675 for (const cid of neededCids) { 2676 const data = getBlock(cid); 2677 if (data) { 2678 blocksForCar.push({ cid, data }); 2679 } 2680 } 2681 2682 const car = buildCarFile(commitCid, blocksForCar); 2683 return new Response(/** @type {BodyInit} */(car), { 2684 headers: { 'content-type': 'application/vnd.ipld.car' }, 2685 }); 2686 } 2687 2688 /** @param {URL} url */ 2689 async handleSyncGetRecord(url) { 2690 const collection = url.searchParams.get('collection'); 2691 const rkey = url.searchParams.get('rkey'); 2692 if (!collection || !rkey) { 2693 return errorResponse('InvalidRequest', 'missing collection or rkey', 400); 2694 } 2695 const did = await this.getDid(); 2696 const uri = `at://${did}/${collection}/${rkey}`; 2697 const rows = /** @type {RecordRow[]} */ (this.sql 2698 .exec(`SELECT cid FROM records WHERE uri = ?`, uri) 2699 .toArray()); 2700 if (rows.length === 0) { 2701 return errorResponse('RecordNotFound', 'record not found', 404); 2702 } 2703 const recordCid = rows[0].cid; 2704 2705 // Get latest commit 2706 const commits = /** @type {CommitRow[]} */ (this.sql 2707 .exec(`SELECT cid FROM commits ORDER BY seq DESC LIMIT 1`) 2708 .toArray()); 2709 if (commits.length === 0) { 2710 return errorResponse('RepoNotFound', 'no commits', 404); 2711 } 2712 const commitCid = commits[0].cid; 2713 2714 // Build proof chain: commit -> MST path -> record 2715 // Include commit block, all MST nodes on path to record, and record block 2716 /** @type {Array<{cid: string, data: Uint8Array}>} */ 2717 const blocks = []; 2718 const included = new Set(); 2719 2720 /** @param {string} cidStr */ 2721 const addBlock = (cidStr) => { 2722 if (included.has(cidStr)) return; 2723 included.add(cidStr); 2724 const blockRows = /** @type {BlockRow[]} */ (this.sql 2725 .exec(`SELECT data FROM blocks WHERE cid = ?`, cidStr) 2726 .toArray()); 2727 if (blockRows.length > 0) { 2728 blocks.push({ cid: cidStr, data: new Uint8Array(blockRows[0].data) }); 2729 } 2730 }; 2731 2732 // Add commit block 2733 addBlock(commitCid); 2734 2735 // Get commit to find data root 2736 const commitRows = /** @type {BlockRow[]} */ (this.sql 2737 .exec(`SELECT data FROM blocks WHERE cid = ?`, commitCid) 2738 .toArray()); 2739 if (commitRows.length > 0) { 2740 const commit = cborDecode(new Uint8Array(commitRows[0].data)); 2741 if (commit.data) { 2742 const dataRootCid = cidToString(commit.data); 2743 // Collect MST path blocks (this includes all MST nodes) 2744 const mstBlocks = this.collectMstBlocks(dataRootCid); 2745 for (const block of mstBlocks) { 2746 addBlock(block.cid); 2747 } 2748 } 2749 } 2750 2751 // Add record block 2752 addBlock(recordCid); 2753 2754 const car = buildCarFile(commitCid, blocks); 2755 return new Response(/** @type {BodyInit} */(car), { 2756 headers: { 'content-type': 'application/vnd.ipld.car' }, 2757 }); 2758 } 2759 2760 /** @param {Request} request */ 2761 async handleUploadBlob(request) { 2762 // Require auth 2763 const authHeader = request.headers.get('Authorization'); 2764 if (!authHeader || !authHeader.startsWith('Bearer ')) { 2765 return errorResponse( 2766 'AuthRequired', 2767 'Missing or invalid authorization header', 2768 401, 2769 ); 2770 } 2771 2772 const token = authHeader.slice(7); 2773 const jwtSecret = this.env?.JWT_SECRET; 2774 if (!jwtSecret) { 2775 return errorResponse( 2776 'InternalServerError', 2777 'Server not configured for authentication', 2778 500, 2779 ); 2780 } 2781 2782 try { 2783 await verifyAccessJwt(token, jwtSecret); 2784 } catch (err) { 2785 const message = err instanceof Error ? err.message : String(err); 2786 return errorResponse('InvalidToken', message, 401); 2787 } 2788 2789 const did = await this.getDid(); 2790 if (!did) { 2791 return errorResponse('InvalidRequest', 'PDS not initialized', 400); 2792 } 2793 2794 // Read body as ArrayBuffer 2795 const bodyBytes = await request.arrayBuffer(); 2796 const size = bodyBytes.byteLength; 2797 2798 // Check size limits 2799 if (size === 0) { 2800 return errorResponse('InvalidRequest', 'Empty blobs are not allowed', 400); 2801 } 2802 const MAX_BLOB_SIZE = 50 * 1024 * 1024; 2803 if (size > MAX_BLOB_SIZE) { 2804 return errorResponse( 2805 'BlobTooLarge', 2806 `Blob size ${size} exceeds maximum ${MAX_BLOB_SIZE}`, 2807 400, 2808 ); 2809 } 2810 2811 // Sniff MIME type, fall back to Content-Type header 2812 const contentType = 2813 request.headers.get('Content-Type') || 'application/octet-stream'; 2814 const sniffed = sniffMimeType(bodyBytes); 2815 const mimeType = sniffed || contentType; 2816 2817 // Compute CID using raw codec for blobs 2818 const cid = await createBlobCid(new Uint8Array(bodyBytes)); 2819 const cidStr = cidToString(cid); 2820 2821 // Upload to R2 (idempotent - same CID always has same content) 2822 const r2Key = `${did}/${cidStr}`; 2823 await this.env?.BLOBS?.put(r2Key, bodyBytes, { 2824 httpMetadata: { contentType: mimeType }, 2825 }); 2826 2827 // Insert metadata (INSERT OR IGNORE handles concurrent uploads) 2828 const createdAt = new Date().toISOString(); 2829 this.sql.exec( 2830 'INSERT OR IGNORE INTO blob (cid, mimeType, size, createdAt) VALUES (?, ?, ?, ?)', 2831 cidStr, 2832 mimeType, 2833 size, 2834 createdAt, 2835 ); 2836 2837 // Return BlobRef 2838 return Response.json({ 2839 blob: { 2840 $type: 'blob', 2841 ref: { $link: cidStr }, 2842 mimeType, 2843 size, 2844 }, 2845 }); 2846 } 2847 2848 /** @param {URL} url */ 2849 async handleGetBlob(url) { 2850 const did = url.searchParams.get('did'); 2851 const cid = url.searchParams.get('cid'); 2852 2853 if (!did || !cid) { 2854 return errorResponse('InvalidRequest', 'missing did or cid parameter', 400); 2855 } 2856 2857 // Validate CID format (CIDv1 base32lower: starts with 'b', 59 chars total) 2858 if (!/^b[a-z2-7]{58}$/.test(cid)) { 2859 return errorResponse('InvalidRequest', 'invalid CID format', 400); 2860 } 2861 2862 // Verify DID matches this DO 2863 const myDid = await this.getDid(); 2864 if (did !== myDid) { 2865 return errorResponse('InvalidRequest', 'DID does not match this repo', 400); 2866 } 2867 2868 // Look up blob metadata 2869 const rows = this.sql 2870 .exec('SELECT mimeType, size FROM blob WHERE cid = ?', cid) 2871 .toArray(); 2872 2873 if (rows.length === 0) { 2874 return errorResponse('BlobNotFound', 'blob not found', 404); 2875 } 2876 2877 const { mimeType, size } = rows[0]; 2878 2879 // Fetch from R2 2880 const r2Key = `${did}/${cid}`; 2881 const object = await this.env?.BLOBS?.get(r2Key); 2882 2883 if (!object) { 2884 return errorResponse('BlobNotFound', 'blob not found in storage', 404); 2885 } 2886 2887 // Return blob with security headers 2888 return new Response(object.body, { 2889 headers: { 2890 'Content-Type': /** @type {string} */ (mimeType), 2891 'Content-Length': String(size), 2892 'X-Content-Type-Options': 'nosniff', 2893 'Content-Security-Policy': "default-src 'none'; sandbox", 2894 'Cache-Control': 'public, max-age=31536000, immutable', 2895 }, 2896 }); 2897 } 2898 2899 /** @param {URL} url */ 2900 async handleListBlobs(url) { 2901 const did = url.searchParams.get('did'); 2902 const cursor = url.searchParams.get('cursor'); 2903 const limit = Math.min(Number(url.searchParams.get('limit')) || 500, 1000); 2904 2905 if (!did) { 2906 return errorResponse('InvalidRequest', 'missing did parameter', 400); 2907 } 2908 2909 // Verify DID matches this DO 2910 const myDid = await this.getDid(); 2911 if (did !== myDid) { 2912 return errorResponse('InvalidRequest', 'DID does not match this repo', 400); 2913 } 2914 2915 // Query blobs with pagination (cursor is createdAt::cid for uniqueness) 2916 let query = 'SELECT cid, createdAt FROM blob'; 2917 const params = []; 2918 2919 if (cursor) { 2920 const [cursorTime, cursorCid] = cursor.split('::'); 2921 query += ' WHERE (createdAt > ? OR (createdAt = ? AND cid > ?))'; 2922 params.push(cursorTime, cursorTime, cursorCid); 2923 } 2924 2925 query += ' ORDER BY createdAt ASC, cid ASC LIMIT ?'; 2926 params.push(limit + 1); // Fetch one extra to detect if there's more 2927 2928 const rows = this.sql.exec(query, ...params).toArray(); 2929 2930 // Determine if there's a next page 2931 let nextCursor = null; 2932 if (rows.length > limit) { 2933 rows.pop(); // Remove the extra row 2934 const last = rows[rows.length - 1]; 2935 nextCursor = `${last.createdAt}::${last.cid}`; 2936 } 2937 2938 return Response.json({ 2939 cids: rows.map((r) => r.cid), 2940 cursor: nextCursor, 2941 }); 2942 } 2943 2944 /** 2945 * @param {Request} request 2946 * @param {URL} url 2947 */ 2948 handleSubscribeRepos(request, url) { 2949 const upgradeHeader = request.headers.get('Upgrade'); 2950 if (upgradeHeader !== 'websocket') { 2951 return new Response('expected websocket', { status: 426 }); 2952 } 2953 const { 0: client, 1: server } = new WebSocketPair(); 2954 this.state.acceptWebSocket(server); 2955 const cursor = url.searchParams.get('cursor'); 2956 if (cursor) { 2957 const events = /** @type {SeqEventRow[]} */ (this.sql 2958 .exec( 2959 `SELECT * FROM seq_events WHERE seq > ? ORDER BY seq`, 2960 parseInt(cursor, 10), 2961 ) 2962 .toArray()); 2963 for (const evt of events) { 2964 server.send(this.formatEvent(evt)); 2965 } 2966 } 2967 return new Response(null, { status: 101, webSocket: client }); 2968 } 2969 2970 /** @param {Request} request */ 2971 async fetch(request) { 2972 const url = new URL(request.url); 2973 const route = pdsRoutes[url.pathname]; 2974 2975 // Check for local route first 2976 if (route) { 2977 if (route.method && request.method !== route.method) { 2978 return errorResponse('MethodNotAllowed', 'method not allowed', 405); 2979 } 2980 return route.handler(this, request, url); 2981 } 2982 2983 // Handle app.bsky.* proxy requests (only if no local route) 2984 if (url.pathname.startsWith('/xrpc/app.bsky.')) { 2985 const userDid = request.headers.get('x-authed-did'); 2986 if (!userDid) { 2987 return errorResponse('Unauthorized', 'Missing auth context', 401); 2988 } 2989 return this.handleAppViewProxy(request, userDid); 2990 } 2991 2992 return errorResponse('NotFound', 'not found', 404); 2993 } 2994 2995 async alarm() { 2996 await this.cleanupOrphanedBlobs(); 2997 // Reschedule for next day 2998 await this.state.storage.setAlarm(Date.now() + 24 * 60 * 60 * 1000); 2999 } 3000 3001 async cleanupOrphanedBlobs() { 3002 const did = await this.getDid(); 3003 if (!did) return; 3004 3005 // Find orphans: blobs not in record_blob, older than 24h 3006 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); 3007 3008 const orphans = this.sql 3009 .exec( 3010 `SELECT b.cid FROM blob b 3011 LEFT JOIN record_blob rb ON b.cid = rb.blobCid 3012 WHERE rb.blobCid IS NULL AND b.createdAt < ?`, 3013 cutoff, 3014 ) 3015 .toArray(); 3016 3017 for (const { cid } of orphans) { 3018 await this.env?.BLOBS?.delete(`${did}/${cid}`); 3019 this.sql.exec('DELETE FROM blob WHERE cid = ?', cid); 3020 } 3021 3022 } 3023} 3024 3025// ╔══════════════════════════════════════════════════════════════════════════════╗ 3026// ║ WORKERS ENTRY POINT ║ 3027// ║ Request handling, CORS, auth middleware ║ 3028// ╚══════════════════════════════════════════════════════════════════════════════╝ 3029 3030const corsHeaders = { 3031 'Access-Control-Allow-Origin': '*', 3032 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 3033 'Access-Control-Allow-Headers': 3034 'Content-Type, Authorization, atproto-accept-labelers, atproto-proxy, x-bsky-topics', 3035}; 3036 3037/** 3038 * @param {Response} response 3039 * @returns {Response} 3040 */ 3041function addCorsHeaders(response) { 3042 const newHeaders = new Headers(response.headers); 3043 for (const [key, value] of Object.entries(corsHeaders)) { 3044 newHeaders.set(key, value); 3045 } 3046 return new Response(response.body, { 3047 status: response.status, 3048 statusText: response.statusText, 3049 headers: newHeaders, 3050 }); 3051} 3052 3053export default { 3054 /** 3055 * @param {Request} request 3056 * @param {Env} env 3057 */ 3058 async fetch(request, env) { 3059 // Handle CORS preflight 3060 if (request.method === 'OPTIONS') { 3061 return new Response(null, { headers: corsHeaders }); 3062 } 3063 3064 const response = await handleRequest(request, env); 3065 // Don't wrap WebSocket upgrades - they need the webSocket property preserved 3066 if (response.status === 101) { 3067 return response; 3068 } 3069 return addCorsHeaders(response); 3070 }, 3071}; 3072 3073/** 3074 * Extract subdomain from hostname (e.g., "alice" from "alice.foo.workers.dev") 3075 * @param {string} hostname 3076 * @returns {string|null} 3077 */ 3078function getSubdomain(hostname) { 3079 const parts = hostname.split('.'); 3080 // workers.dev domains: [subdomain?].[worker-name].[account].workers.dev 3081 // If more than 4 parts, first part(s) are user subdomain 3082 if (parts.length > 4 && parts.slice(-2).join('.') === 'workers.dev') { 3083 return parts.slice(0, -4).join('.'); 3084 } 3085 // Custom domains: check if there's a subdomain before the base 3086 // For now, assume no subdomain on custom domains 3087 return null; 3088} 3089 3090/** 3091 * Verify auth and return DID from token 3092 * @param {Request} request - HTTP request with Authorization header 3093 * @param {Env} env - Environment with JWT_SECRET 3094 * @returns {Promise<{did: string} | {error: Response}>} DID or error response 3095 */ 3096async function requireAuth(request, env) { 3097 const authHeader = request.headers.get('Authorization'); 3098 if (!authHeader || !authHeader.startsWith('Bearer ')) { 3099 return { 3100 error: Response.json( 3101 { 3102 error: 'AuthRequired', 3103 message: 'Authentication required', 3104 }, 3105 { status: 401 }, 3106 ), 3107 }; 3108 } 3109 3110 const token = authHeader.slice(7); 3111 const jwtSecret = env?.JWT_SECRET; 3112 if (!jwtSecret) { 3113 return { 3114 error: Response.json( 3115 { 3116 error: 'InternalServerError', 3117 message: 'Server not configured for authentication', 3118 }, 3119 { status: 500 }, 3120 ), 3121 }; 3122 } 3123 3124 try { 3125 const payload = await verifyAccessJwt(token, jwtSecret); 3126 return { did: payload.sub }; 3127 } catch (err) { 3128 const message = err instanceof Error ? err.message : String(err); 3129 return { 3130 error: Response.json( 3131 { 3132 error: 'InvalidToken', 3133 message: message, 3134 }, 3135 { status: 401 }, 3136 ), 3137 }; 3138 } 3139} 3140 3141/** 3142 * @param {Request} request 3143 * @param {Env} env 3144 */ 3145async function handleAuthenticatedBlobUpload(request, env) { 3146 const auth = await requireAuth(request, env); 3147 if ('error' in auth) return auth.error; 3148 3149 // Route to the user's DO based on their DID from the token 3150 const id = env.PDS.idFromName(auth.did); 3151 const pds = env.PDS.get(id); 3152 return pds.fetch(request); 3153} 3154 3155/** 3156 * @param {Request} request 3157 * @param {Env} env 3158 */ 3159async function handleAuthenticatedRepoWrite(request, env) { 3160 const auth = await requireAuth(request, env); 3161 if ('error' in auth) return auth.error; 3162 3163 const body = await request.json(); 3164 const repo = body.repo; 3165 if (!repo) { 3166 return errorResponse('InvalidRequest', 'missing repo param', 400); 3167 } 3168 3169 if (auth.did !== repo) { 3170 return errorResponse('Forbidden', "Cannot modify another user's repo", 403); 3171 } 3172 3173 const id = env.PDS.idFromName(repo); 3174 const pds = env.PDS.get(id); 3175 const response = await pds.fetch( 3176 new Request(request.url, { 3177 method: 'POST', 3178 headers: request.headers, 3179 body: JSON.stringify(body), 3180 }), 3181 ); 3182 3183 // Notify relay of updates on successful writes 3184 if (response.ok) { 3185 const url = new URL(request.url); 3186 notifyCrawlers(env, url.hostname); 3187 } 3188 3189 return response; 3190} 3191 3192/** 3193 * @param {Request} request 3194 * @param {Env} env 3195 */ 3196async function handleRequest(request, env) { 3197 const url = new URL(request.url); 3198 const subdomain = getSubdomain(url.hostname); 3199 3200 // Handle resolution via subdomain or bare domain 3201 if (url.pathname === '/.well-known/atproto-did') { 3202 // Look up handle -> DID in default DO 3203 // Use subdomain if present, otherwise try bare hostname as handle 3204 const handleToResolve = subdomain || url.hostname; 3205 const defaultId = env.PDS.idFromName('default'); 3206 const defaultPds = env.PDS.get(defaultId); 3207 const resolveRes = await defaultPds.fetch( 3208 new Request( 3209 `http://internal/resolve-handle?handle=${encodeURIComponent(handleToResolve)}`, 3210 ), 3211 ); 3212 if (!resolveRes.ok) { 3213 return new Response('Handle not found', { status: 404 }); 3214 } 3215 const { did } = await resolveRes.json(); 3216 return new Response(did, { headers: { 'Content-Type': 'text/plain' } }); 3217 } 3218 3219 // describeServer - works on bare domain 3220 if (url.pathname === '/xrpc/com.atproto.server.describeServer') { 3221 const defaultId = env.PDS.idFromName('default'); 3222 const defaultPds = env.PDS.get(defaultId); 3223 const newReq = new Request(request.url, { 3224 method: request.method, 3225 headers: { 3226 ...Object.fromEntries(request.headers), 3227 'x-hostname': url.hostname, 3228 }, 3229 }); 3230 return defaultPds.fetch(newReq); 3231 } 3232 3233 // createSession - handle on default DO (has handleMap for identifier resolution) 3234 if (url.pathname === '/xrpc/com.atproto.server.createSession') { 3235 const defaultId = env.PDS.idFromName('default'); 3236 const defaultPds = env.PDS.get(defaultId); 3237 return defaultPds.fetch(request); 3238 } 3239 3240 // getSession - route to default DO 3241 if (url.pathname === '/xrpc/com.atproto.server.getSession') { 3242 const defaultId = env.PDS.idFromName('default'); 3243 const defaultPds = env.PDS.get(defaultId); 3244 return defaultPds.fetch(request); 3245 } 3246 3247 // refreshSession - route to default DO 3248 if (url.pathname === '/xrpc/com.atproto.server.refreshSession') { 3249 const defaultId = env.PDS.idFromName('default'); 3250 const defaultPds = env.PDS.get(defaultId); 3251 return defaultPds.fetch(request); 3252 } 3253 3254 // Proxy app.bsky.* endpoints to Bluesky AppView 3255 if (url.pathname.startsWith('/xrpc/app.bsky.')) { 3256 // Authenticate the user first 3257 const auth = await requireAuth(request, env); 3258 if ('error' in auth) return auth.error; 3259 3260 // Route to the user's DO instance to create service auth and proxy 3261 const id = env.PDS.idFromName(auth.did); 3262 const pds = env.PDS.get(id); 3263 return pds.fetch( 3264 new Request(request.url, { 3265 method: request.method, 3266 headers: { 3267 ...Object.fromEntries(request.headers), 3268 'x-authed-did': auth.did, // Pass the authenticated DID 3269 }, 3270 body: 3271 request.method !== 'GET' && request.method !== 'HEAD' 3272 ? request.body 3273 : undefined, 3274 }), 3275 ); 3276 } 3277 3278 // Handle registration routes - go to default DO 3279 if ( 3280 url.pathname === '/register-handle' || 3281 url.pathname === '/resolve-handle' 3282 ) { 3283 const defaultId = env.PDS.idFromName('default'); 3284 const defaultPds = env.PDS.get(defaultId); 3285 return defaultPds.fetch(request); 3286 } 3287 3288 // resolveHandle XRPC endpoint 3289 if (url.pathname === '/xrpc/com.atproto.identity.resolveHandle') { 3290 const handle = url.searchParams.get('handle'); 3291 if (!handle) { 3292 return errorResponse('InvalidRequest', 'missing handle param', 400); 3293 } 3294 const defaultId = env.PDS.idFromName('default'); 3295 const defaultPds = env.PDS.get(defaultId); 3296 const resolveRes = await defaultPds.fetch( 3297 new Request( 3298 `http://internal/resolve-handle?handle=${encodeURIComponent(handle)}`, 3299 ), 3300 ); 3301 if (!resolveRes.ok) { 3302 return errorResponse('InvalidRequest', 'Unable to resolve handle', 400); 3303 } 3304 const { did } = await resolveRes.json(); 3305 return Response.json({ did }); 3306 } 3307 3308 // subscribeRepos WebSocket - route to default instance for firehose 3309 if (url.pathname === '/xrpc/com.atproto.sync.subscribeRepos') { 3310 const defaultId = env.PDS.idFromName('default'); 3311 const defaultPds = env.PDS.get(defaultId); 3312 return defaultPds.fetch(request); 3313 } 3314 3315 // listRepos needs to aggregate from all registered DIDs 3316 if (url.pathname === '/xrpc/com.atproto.sync.listRepos') { 3317 const defaultId = env.PDS.idFromName('default'); 3318 const defaultPds = env.PDS.get(defaultId); 3319 const regRes = await defaultPds.fetch( 3320 new Request('http://internal/get-registered-dids'), 3321 ); 3322 const { dids } = await regRes.json(); 3323 3324 const repos = []; 3325 for (const did of dids) { 3326 const id = env.PDS.idFromName(did); 3327 const pds = env.PDS.get(id); 3328 const infoRes = await pds.fetch(new Request('http://internal/repo-info')); 3329 const info = await infoRes.json(); 3330 if (info.head) { 3331 repos.push({ did, head: info.head, rev: info.rev, active: true }); 3332 } 3333 } 3334 return Response.json({ repos, cursor: undefined }); 3335 } 3336 3337 // Repo endpoints use ?repo= param instead of ?did= 3338 if ( 3339 url.pathname === '/xrpc/com.atproto.repo.describeRepo' || 3340 url.pathname === '/xrpc/com.atproto.repo.listRecords' || 3341 url.pathname === '/xrpc/com.atproto.repo.getRecord' 3342 ) { 3343 const repo = url.searchParams.get('repo'); 3344 if (!repo) { 3345 return errorResponse('InvalidRequest', 'missing repo param', 400); 3346 } 3347 const id = env.PDS.idFromName(repo); 3348 const pds = env.PDS.get(id); 3349 return pds.fetch(request); 3350 } 3351 3352 // Sync endpoints use ?did= param 3353 if ( 3354 url.pathname === '/xrpc/com.atproto.sync.getLatestCommit' || 3355 url.pathname === '/xrpc/com.atproto.sync.getRepoStatus' || 3356 url.pathname === '/xrpc/com.atproto.sync.getRepo' || 3357 url.pathname === '/xrpc/com.atproto.sync.getRecord' || 3358 url.pathname === '/xrpc/com.atproto.sync.getBlob' || 3359 url.pathname === '/xrpc/com.atproto.sync.listBlobs' 3360 ) { 3361 const did = url.searchParams.get('did'); 3362 if (!did) { 3363 return errorResponse('InvalidRequest', 'missing did param', 400); 3364 } 3365 const id = env.PDS.idFromName(did); 3366 const pds = env.PDS.get(id); 3367 return pds.fetch(request); 3368 } 3369 3370 // Blob upload endpoint (binary body, uses DID from token) 3371 if (url.pathname === '/xrpc/com.atproto.repo.uploadBlob') { 3372 return handleAuthenticatedBlobUpload(request, env); 3373 } 3374 3375 // Authenticated repo write endpoints 3376 const repoWriteEndpoints = [ 3377 '/xrpc/com.atproto.repo.createRecord', 3378 '/xrpc/com.atproto.repo.deleteRecord', 3379 '/xrpc/com.atproto.repo.putRecord', 3380 '/xrpc/com.atproto.repo.applyWrites', 3381 ]; 3382 if (repoWriteEndpoints.includes(url.pathname)) { 3383 return handleAuthenticatedRepoWrite(request, env); 3384 } 3385 3386 // Health check endpoint 3387 if (url.pathname === '/xrpc/_health') { 3388 return Response.json({ version: '0.1.0' }); 3389 } 3390 3391 // Root path - ASCII art 3392 if (url.pathname === '/') { 3393 const ascii = ` 3394 ██████╗ ██████╗ ███████╗ ██╗ ███████╗ 3395 ██╔══██╗ ██╔══██╗ ██╔════╝ ██║ ██╔════╝ 3396 ██████╔╝ ██║ ██║ ███████╗ ██║ ███████╗ 3397 ██╔═══╝ ██║ ██║ ╚════██║ ██ ██║ ╚════██║ 3398 ██║ ██████╔╝ ███████║ ██╗ ╚█████╔╝ ███████║ 3399 ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚════╝ ╚══════╝ 3400 3401 ATProto PDS on Cloudflare Workers 3402`; 3403 return new Response(ascii, { 3404 headers: { 'Content-Type': 'text/plain; charset=utf-8' }, 3405 }); 3406 } 3407 3408 // On init, register this DID with the default instance (requires ?did= param, no auth yet) 3409 if (url.pathname === '/init' && request.method === 'POST') { 3410 const did = url.searchParams.get('did'); 3411 if (!did) { 3412 return errorResponse('InvalidRequest', 'missing did param', 400); 3413 } 3414 const body = await request.json(); 3415 3416 // Register with default instance for discovery 3417 const defaultId = env.PDS.idFromName('default'); 3418 const defaultPds = env.PDS.get(defaultId); 3419 await defaultPds.fetch( 3420 new Request('http://internal/register-did', { 3421 method: 'POST', 3422 body: JSON.stringify({ did }), 3423 }), 3424 ); 3425 3426 // Register handle if provided 3427 if (body.handle) { 3428 await defaultPds.fetch( 3429 new Request('http://internal/register-handle', { 3430 method: 'POST', 3431 body: JSON.stringify({ did, handle: body.handle }), 3432 }), 3433 ); 3434 } 3435 3436 // Forward to the actual PDS instance 3437 const id = env.PDS.idFromName(did); 3438 const pds = env.PDS.get(id); 3439 return pds.fetch( 3440 new Request(request.url, { 3441 method: 'POST', 3442 headers: request.headers, 3443 body: JSON.stringify(body), 3444 }), 3445 ); 3446 } 3447 3448 // Unknown endpoint 3449 return errorResponse('NotFound', 'Endpoint not found', 404); 3450}