A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto pds
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( 548 'SHA-256', 549 /** @type {BufferSource} */ (bytes), 550 ); 551 const hashBytes = new Uint8Array(hash); 552 553 // CIDv1: version(1) + codec + multihash(sha256) 554 // Multihash: hash-type(0x12) + length(0x20=32) + digest 555 const cid = new Uint8Array(2 + 2 + 32); 556 cid[0] = 0x01; // CIDv1 557 cid[1] = codec; 558 cid[2] = 0x12; // sha-256 559 cid[3] = 0x20; // 32 bytes 560 cid.set(hashBytes, 4); 561 562 return cid; 563} 564 565/** 566 * Create CID for DAG-CBOR encoded data (records, commits) 567 * @param {Uint8Array} bytes - DAG-CBOR encoded content 568 * @returns {Promise<Uint8Array>} CID bytes 569 */ 570export async function createCid(bytes) { 571 return createCidWithCodec(bytes, CODEC_DAG_CBOR); 572} 573 574/** 575 * Create CID for raw blob data (images, videos) 576 * @param {Uint8Array} bytes - Raw binary content 577 * @returns {Promise<Uint8Array>} CID bytes 578 */ 579export async function createBlobCid(bytes) { 580 return createCidWithCodec(bytes, CODEC_RAW); 581} 582 583/** 584 * Convert CID bytes to base32lower string representation 585 * @param {Uint8Array} cid - CID bytes 586 * @returns {string} Base32lower-encoded CID with 'b' prefix 587 */ 588export function cidToString(cid) { 589 // base32lower encoding for CIDv1 590 return `b${base32Encode(cid)}`; 591} 592 593/** 594 * Convert base32lower CID string to raw bytes 595 * @param {string} cidStr - CID string with 'b' prefix 596 * @returns {Uint8Array} CID bytes 597 */ 598export function cidToBytes(cidStr) { 599 // Decode base32lower CID string to bytes 600 if (!cidStr.startsWith('b')) throw new Error('expected base32lower CID'); 601 return base32Decode(cidStr.slice(1)); 602} 603 604/** 605 * Generate a timestamp-based ID (TID) for record keys 606 * Monotonic within a process, sortable by time 607 * @returns {string} 13-character base32-sort encoded TID 608 */ 609export function createTid() { 610 let timestamp = Date.now() * 1000; // microseconds 611 612 // Ensure monotonic 613 if (timestamp <= lastTimestamp) { 614 timestamp = lastTimestamp + 1; 615 } 616 lastTimestamp = timestamp; 617 618 // 13 chars: 11 for timestamp (64 bits but only ~53 used), 2 for clock ID 619 let tid = ''; 620 621 // Encode timestamp (high bits first for sortability) 622 let ts = timestamp; 623 for (let i = 0; i < 11; i++) { 624 tid = TID_CHARS[ts & 31] + tid; 625 ts = Math.floor(ts / 32); 626 } 627 628 // Append clock ID (2 chars) 629 tid += TID_CHARS[(clockId >> 5) & 31]; 630 tid += TID_CHARS[clockId & 31]; 631 632 return tid; 633} 634 635// ╔══════════════════════════════════════════════════════════════════════════════╗ 636// ║ CRYPTOGRAPHY ║ 637// ║ P-256 signing with low-S normalization, key management ║ 638// ╚══════════════════════════════════════════════════════════════════════════════╝ 639 640/** 641 * @param {BufferSource} data 642 * @returns {Promise<Uint8Array>} 643 */ 644async function sha256(data) { 645 const hash = await crypto.subtle.digest('SHA-256', data); 646 return new Uint8Array(hash); 647} 648 649/** 650 * Import a raw P-256 private key for signing 651 * @param {Uint8Array} privateKeyBytes - 32-byte raw private key 652 * @returns {Promise<CryptoKey>} Web Crypto key handle 653 */ 654export async function importPrivateKey(privateKeyBytes) { 655 // Validate private key length (P-256 requires exactly 32 bytes) 656 if ( 657 !(privateKeyBytes instanceof Uint8Array) || 658 privateKeyBytes.length !== 32 659 ) { 660 throw new Error( 661 `Invalid private key: expected 32 bytes, got ${privateKeyBytes?.length ?? 'non-Uint8Array'}`, 662 ); 663 } 664 665 // PKCS#8 wrapper for raw P-256 private key 666 const pkcs8Prefix = new Uint8Array([ 667 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 668 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 669 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20, 670 ]); 671 672 const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32); 673 pkcs8.set(pkcs8Prefix); 674 pkcs8.set(privateKeyBytes, pkcs8Prefix.length); 675 676 return crypto.subtle.importKey( 677 'pkcs8', 678 /** @type {BufferSource} */ (pkcs8), 679 { name: 'ECDSA', namedCurve: 'P-256' }, 680 false, 681 ['sign'], 682 ); 683} 684 685/** 686 * Sign data with ECDSA P-256, returning low-S normalized signature 687 * @param {CryptoKey} privateKey - Web Crypto key from importPrivateKey 688 * @param {Uint8Array} data - Data to sign 689 * @returns {Promise<Uint8Array>} 64-byte signature (r || s) 690 */ 691export async function sign(privateKey, data) { 692 const signature = await crypto.subtle.sign( 693 { name: 'ECDSA', hash: 'SHA-256' }, 694 privateKey, 695 /** @type {BufferSource} */ (data), 696 ); 697 const sig = new Uint8Array(signature); 698 699 const r = sig.slice(0, 32); 700 const s = sig.slice(32, 64); 701 const sBigInt = bytesToBigInt(s); 702 703 // Low-S normalization: Bitcoin/ATProto require S <= N/2 to prevent 704 // signature malleability (two valid signatures for same message) 705 if (sBigInt > P256_N_DIV_2) { 706 const newS = P256_N - sBigInt; 707 const newSBytes = bigIntToBytes(newS, 32); 708 const normalized = new Uint8Array(64); 709 normalized.set(r, 0); 710 normalized.set(newSBytes, 32); 711 return normalized; 712 } 713 714 return sig; 715} 716 717/** 718 * Generate a new P-256 key pair 719 * @returns {Promise<{privateKey: Uint8Array, publicKey: Uint8Array}>} 32-byte private key, 33-byte compressed public key 720 */ 721export async function generateKeyPair() { 722 const keyPair = await crypto.subtle.generateKey( 723 { name: 'ECDSA', namedCurve: 'P-256' }, 724 true, 725 ['sign', 'verify'], 726 ); 727 728 // Export private key as raw bytes 729 const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey); 730 const privateBytes = base64UrlDecode(/** @type {string} */ (privateJwk.d)); 731 732 // Export public key as compressed point 733 const publicRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey); 734 const publicBytes = new Uint8Array(publicRaw); 735 const compressed = compressPublicKey(publicBytes); 736 737 return { privateKey: privateBytes, publicKey: compressed }; 738} 739 740/** 741 * @param {Uint8Array} uncompressed 742 * @returns {Uint8Array} 743 */ 744function compressPublicKey(uncompressed) { 745 // uncompressed is 65 bytes: 0x04 + x(32) + y(32) 746 // compressed is 33 bytes: prefix(02 or 03) + x(32) 747 const x = uncompressed.slice(1, 33); 748 const y = uncompressed.slice(33, 65); 749 const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03; 750 const compressed = new Uint8Array(33); 751 compressed[0] = prefix; 752 compressed.set(x, 1); 753 return compressed; 754} 755 756// ╔══════════════════════════════════════════════════════════════════════════════╗ 757// ║ AUTHENTICATION ║ 758// ║ JWT creation/verification for sessions and service auth ║ 759// ╚══════════════════════════════════════════════════════════════════════════════╝ 760 761/** 762 * Create HMAC-SHA256 signature for JWT 763 * @param {string} data - Data to sign (header.payload) 764 * @param {string} secret - Secret key 765 * @returns {Promise<string>} Base64url-encoded signature 766 */ 767async function hmacSign(data, secret) { 768 const key = await crypto.subtle.importKey( 769 'raw', 770 /** @type {BufferSource} */ (new TextEncoder().encode(secret)), 771 { name: 'HMAC', hash: 'SHA-256' }, 772 false, 773 ['sign'], 774 ); 775 const sig = await crypto.subtle.sign( 776 'HMAC', 777 key, 778 /** @type {BufferSource} */ (new TextEncoder().encode(data)), 779 ); 780 return base64UrlEncode(new Uint8Array(sig)); 781} 782 783/** 784 * Create an access JWT for ATProto 785 * @param {string} did - User's DID (subject and audience) 786 * @param {string} secret - JWT signing secret 787 * @param {number} [expiresIn=7200] - Expiration in seconds (default 2 hours) 788 * @returns {Promise<string>} Signed JWT 789 */ 790export async function createAccessJwt(did, secret, expiresIn = 7200) { 791 const header = { typ: 'at+jwt', alg: 'HS256' }; 792 const now = Math.floor(Date.now() / 1000); 793 const payload = { 794 scope: 'com.atproto.access', 795 sub: did, 796 aud: did, 797 iat: now, 798 exp: now + expiresIn, 799 }; 800 801 const headerB64 = base64UrlEncode( 802 new TextEncoder().encode(JSON.stringify(header)), 803 ); 804 const payloadB64 = base64UrlEncode( 805 new TextEncoder().encode(JSON.stringify(payload)), 806 ); 807 const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret); 808 809 return `${headerB64}.${payloadB64}.${signature}`; 810} 811 812/** 813 * Create a refresh JWT for ATProto 814 * @param {string} did - User's DID (subject and audience) 815 * @param {string} secret - JWT signing secret 816 * @param {number} [expiresIn=86400] - Expiration in seconds (default 24 hours) 817 * @returns {Promise<string>} Signed JWT 818 */ 819export async function createRefreshJwt(did, secret, expiresIn = 86400) { 820 const header = { typ: 'refresh+jwt', alg: 'HS256' }; 821 const now = Math.floor(Date.now() / 1000); 822 // Generate random jti (token ID) 823 const jtiBytes = new Uint8Array(32); 824 crypto.getRandomValues(jtiBytes); 825 const jti = base64UrlEncode(jtiBytes); 826 827 const payload = { 828 scope: 'com.atproto.refresh', 829 sub: did, 830 aud: did, 831 jti, 832 iat: now, 833 exp: now + expiresIn, 834 }; 835 836 const headerB64 = base64UrlEncode( 837 new TextEncoder().encode(JSON.stringify(header)), 838 ); 839 const payloadB64 = base64UrlEncode( 840 new TextEncoder().encode(JSON.stringify(payload)), 841 ); 842 const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret); 843 844 return `${headerB64}.${payloadB64}.${signature}`; 845} 846 847/** 848 * Verify and decode a JWT (shared logic) 849 * @param {string} jwt - JWT string to verify 850 * @param {string} secret - JWT signing secret 851 * @param {string} expectedType - Expected token type (e.g., 'at+jwt', 'refresh+jwt') 852 * @returns {Promise<{header: {typ: string, alg: string}, payload: JwtPayload}>} Decoded header and payload 853 * @throws {Error} If token is invalid, expired, or wrong type 854 */ 855async function verifyJwt(jwt, secret, expectedType) { 856 const parts = jwt.split('.'); 857 if (parts.length !== 3) { 858 throw new Error('Invalid JWT format'); 859 } 860 861 const [headerB64, payloadB64, signatureB64] = parts; 862 863 // Verify signature 864 const expectedSig = await hmacSign(`${headerB64}.${payloadB64}`, secret); 865 if (signatureB64 !== expectedSig) { 866 throw new Error('Invalid signature'); 867 } 868 869 // Decode header and payload 870 const header = JSON.parse( 871 new TextDecoder().decode(base64UrlDecode(headerB64)), 872 ); 873 const payload = JSON.parse( 874 new TextDecoder().decode(base64UrlDecode(payloadB64)), 875 ); 876 877 // Check token type 878 if (header.typ !== expectedType) { 879 throw new Error(`Invalid token type: expected ${expectedType}`); 880 } 881 882 // Check expiration 883 const now = Math.floor(Date.now() / 1000); 884 if (payload.exp && payload.exp < now) { 885 throw new Error('Token expired'); 886 } 887 888 return { header, payload }; 889} 890 891/** 892 * Verify and decode an access JWT 893 * @param {string} jwt - JWT string to verify 894 * @param {string} secret - JWT signing secret 895 * @returns {Promise<JwtPayload>} Decoded payload 896 * @throws {Error} If token is invalid, expired, or wrong type 897 */ 898export async function verifyAccessJwt(jwt, secret) { 899 const { payload } = await verifyJwt(jwt, secret, 'at+jwt'); 900 return payload; 901} 902 903/** 904 * Verify and decode a refresh JWT 905 * @param {string} jwt - JWT string to verify 906 * @param {string} secret - JWT signing secret 907 * @returns {Promise<JwtPayload>} Decoded payload 908 * @throws {Error} If token is invalid, expired, or wrong type 909 */ 910export async function verifyRefreshJwt(jwt, secret) { 911 const { payload } = await verifyJwt(jwt, secret, 'refresh+jwt'); 912 913 // Validate audience matches subject (token intended for this user) 914 if (payload.aud && payload.aud !== payload.sub) { 915 throw new Error('Invalid audience'); 916 } 917 918 return payload; 919} 920 921/** 922 * Create a service auth JWT signed with ES256 (P-256) 923 * Used for proxying requests to AppView 924 * @param {Object} params - JWT parameters 925 * @param {string} params.iss - Issuer DID (PDS DID) 926 * @param {string} params.aud - Audience DID (AppView DID) 927 * @param {string|null} params.lxm - Lexicon method being called 928 * @param {CryptoKey} params.signingKey - P-256 private key from importPrivateKey 929 * @returns {Promise<string>} Signed JWT 930 */ 931export async function createServiceJwt({ iss, aud, lxm, signingKey }) { 932 const header = { typ: 'JWT', alg: 'ES256' }; 933 const now = Math.floor(Date.now() / 1000); 934 935 // Generate random jti 936 const jtiBytes = new Uint8Array(16); 937 crypto.getRandomValues(jtiBytes); 938 const jti = bytesToHex(jtiBytes); 939 940 /** @type {{ iss: string, aud: string, exp: number, iat: number, jti: string, lxm?: string }} */ 941 const payload = { 942 iss, 943 aud, 944 exp: now + 60, // 1 minute expiration 945 iat: now, 946 jti, 947 }; 948 if (lxm) payload.lxm = lxm; 949 950 const headerB64 = base64UrlEncode( 951 new TextEncoder().encode(JSON.stringify(header)), 952 ); 953 const payloadB64 = base64UrlEncode( 954 new TextEncoder().encode(JSON.stringify(payload)), 955 ); 956 const toSign = new TextEncoder().encode(`${headerB64}.${payloadB64}`); 957 958 const sig = await sign(signingKey, toSign); 959 const sigB64 = base64UrlEncode(sig); 960 961 return `${headerB64}.${payloadB64}.${sigB64}`; 962} 963 964// ╔══════════════════════════════════════════════════════════════════════════════╗ 965// ║ MERKLE SEARCH TREE ║ 966// ║ MST for ATProto repository structure ║ 967// ╚══════════════════════════════════════════════════════════════════════════════╝ 968 969// Cache for key depths (SHA-256 is expensive) 970const keyDepthCache = new Map(); 971 972/** 973 * Get MST tree depth for a key based on leading zeros in SHA-256 hash 974 * @param {string} key - Record key (collection/rkey) 975 * @returns {Promise<number>} Tree depth (leading zeros / 2) 976 */ 977export async function getKeyDepth(key) { 978 // Count leading zeros in SHA-256 hash, divide by 2 979 if (keyDepthCache.has(key)) return keyDepthCache.get(key); 980 981 const keyBytes = new TextEncoder().encode(key); 982 const hash = await sha256(keyBytes); 983 984 let zeros = 0; 985 for (const byte of hash) { 986 if (byte === 0) { 987 zeros += 8; 988 } else { 989 // Count leading zeros in this byte 990 for (let i = 7; i >= 0; i--) { 991 if ((byte >> i) & 1) break; 992 zeros++; 993 } 994 break; 995 } 996 } 997 998 // MST depth = leading zeros in SHA-256 hash / 2 999 // This creates a probabilistic tree where ~50% of keys are at depth 0, 1000 // ~25% at depth 1, etc., giving O(log n) lookups 1001 const depth = Math.floor(zeros / 2); 1002 keyDepthCache.set(key, depth); 1003 return depth; 1004} 1005 1006/** 1007 * Compute common prefix length between two byte arrays 1008 * @param {Uint8Array} a 1009 * @param {Uint8Array} b 1010 * @returns {number} 1011 */ 1012function commonPrefixLen(a, b) { 1013 const minLen = Math.min(a.length, b.length); 1014 for (let i = 0; i < minLen; i++) { 1015 if (a[i] !== b[i]) return i; 1016 } 1017 return minLen; 1018} 1019 1020class MST { 1021 /** @param {SqlStorage} sql */ 1022 constructor(sql) { 1023 this.sql = sql; 1024 } 1025 1026 async computeRoot() { 1027 const records = this.sql 1028 .exec(` 1029 SELECT collection, rkey, cid FROM records ORDER BY collection, rkey 1030 `) 1031 .toArray(); 1032 1033 if (records.length === 0) { 1034 return null; 1035 } 1036 1037 // Build entries with pre-computed depths (heights) 1038 // In ATProto MST, "height" determines which layer a key belongs to 1039 // Layer 0 is at the BOTTOM, root is at the highest layer 1040 const entries = []; 1041 let maxDepth = 0; 1042 for (const r of records) { 1043 const key = `${r.collection}/${r.rkey}`; 1044 const depth = await getKeyDepth(key); 1045 maxDepth = Math.max(maxDepth, depth); 1046 entries.push({ 1047 key, 1048 keyBytes: new TextEncoder().encode(key), 1049 cid: /** @type {string} */ (r.cid), 1050 depth, 1051 }); 1052 } 1053 1054 // Start building from the root (highest layer) going down to layer 0 1055 return this.buildTree(entries, maxDepth); 1056 } 1057 1058 /** 1059 * @param {Array<{key: string, keyBytes: Uint8Array, cid: string, depth: number}>} entries 1060 * @param {number} layer 1061 * @returns {Promise<string|null>} 1062 */ 1063 async buildTree(entries, layer) { 1064 if (entries.length === 0) return null; 1065 1066 // Separate entries for this layer vs lower layers (subtrees) 1067 // Keys with depth == layer stay at this node 1068 // Keys with depth < layer go into subtrees (going down toward layer 0) 1069 /** @type {Array<{type: 'subtree', cid: string|null} | {type: 'entry', entry: {key: string, keyBytes: Uint8Array, cid: string, depth: number}}>} */ 1070 const thisLayer = []; 1071 /** @type {Array<{key: string, keyBytes: Uint8Array, cid: string, depth: number}>} */ 1072 let leftSubtree = []; 1073 1074 for (const entry of entries) { 1075 if (entry.depth < layer) { 1076 // This entry belongs to a lower layer - accumulate for subtree 1077 leftSubtree.push(entry); 1078 } else { 1079 // This entry belongs at current layer (depth == layer) 1080 // Process accumulated left subtree first 1081 if (leftSubtree.length > 0) { 1082 const leftCid = await this.buildTree(leftSubtree, layer - 1); 1083 thisLayer.push({ type: 'subtree', cid: leftCid }); 1084 leftSubtree = []; 1085 } 1086 thisLayer.push({ type: 'entry', entry }); 1087 } 1088 } 1089 1090 // Handle remaining left subtree 1091 if (leftSubtree.length > 0) { 1092 const leftCid = await this.buildTree(leftSubtree, layer - 1); 1093 thisLayer.push({ type: 'subtree', cid: leftCid }); 1094 } 1095 1096 // Build node with proper ATProto format 1097 /** @type {{ e: Array<{p: number, k: Uint8Array, v: CID, t: CID|null}>, l?: CID|null }} */ 1098 const node = { e: [] }; 1099 /** @type {string|null} */ 1100 let leftCid = null; 1101 let prevKeyBytes = new Uint8Array(0); 1102 1103 for (let i = 0; i < thisLayer.length; i++) { 1104 const item = thisLayer[i]; 1105 1106 if (item.type === 'subtree') { 1107 if (node.e.length === 0) { 1108 leftCid = item.cid; 1109 } else { 1110 // Attach to previous entry's 't' field 1111 if (item.cid !== null) { 1112 node.e[node.e.length - 1].t = new CID(cidToBytes(item.cid)); 1113 } 1114 } 1115 } else { 1116 // Entry - compute prefix compression 1117 const keyBytes = item.entry.keyBytes; 1118 const prefixLen = commonPrefixLen(prevKeyBytes, keyBytes); 1119 const keySuffix = keyBytes.slice(prefixLen); 1120 1121 // ATProto requires t field to be present (can be null) 1122 const e = { 1123 p: prefixLen, 1124 k: keySuffix, 1125 v: new CID(cidToBytes(item.entry.cid)), 1126 t: null, // Will be updated if there's a subtree 1127 }; 1128 1129 node.e.push(e); 1130 prevKeyBytes = /** @type {Uint8Array<ArrayBuffer>} */ (keyBytes); 1131 } 1132 } 1133 1134 // ATProto requires l field to be present (can be null) 1135 node.l = leftCid ? new CID(cidToBytes(leftCid)) : null; 1136 1137 // Encode node with proper MST CBOR format 1138 const nodeBytes = cborEncodeDagCbor(node); 1139 const nodeCid = await createCid(nodeBytes); 1140 const cidStr = cidToString(nodeCid); 1141 1142 this.sql.exec( 1143 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, 1144 cidStr, 1145 nodeBytes, 1146 ); 1147 1148 return cidStr; 1149 } 1150} 1151 1152// ╔══════════════════════════════════════════════════════════════════════════════╗ 1153// ║ CAR FILES ║ 1154// ║ Content Addressable aRchive format for repo sync ║ 1155// ╚══════════════════════════════════════════════════════════════════════════════╝ 1156 1157/** 1158 * Build a CAR (Content Addressable aRchive) file 1159 * @param {string} rootCid - Root CID string 1160 * @param {Array<{cid: string, data: Uint8Array}>} blocks - Blocks to include 1161 * @returns {Uint8Array} CAR file bytes 1162 */ 1163export function buildCarFile(rootCid, blocks) { 1164 const parts = []; 1165 1166 // Header: { version: 1, roots: [rootCid] } 1167 const rootCidBytes = cidToBytes(rootCid); 1168 const header = cborEncodeDagCbor({ 1169 version: 1, 1170 roots: [new CID(rootCidBytes)], 1171 }); 1172 parts.push(varint(header.length)); 1173 parts.push(header); 1174 1175 // Blocks: varint(len) + cid + data 1176 for (const block of blocks) { 1177 const cidBytes = cidToBytes(block.cid); 1178 const blockLen = cidBytes.length + block.data.length; 1179 parts.push(varint(blockLen)); 1180 parts.push(cidBytes); 1181 parts.push(block.data); 1182 } 1183 1184 // Concatenate all parts 1185 const totalLen = parts.reduce((sum, p) => sum + p.length, 0); 1186 const car = new Uint8Array(totalLen); 1187 let offset = 0; 1188 for (const part of parts) { 1189 car.set(part, offset); 1190 offset += part.length; 1191 } 1192 1193 return car; 1194} 1195 1196// ╔══════════════════════════════════════════════════════════════════════════════╗ 1197// ║ BLOB HANDLING ║ 1198// ║ MIME detection, blob reference scanning ║ 1199// ╚══════════════════════════════════════════════════════════════════════════════╝ 1200 1201/** 1202 * Sniff MIME type from file magic bytes 1203 * @param {Uint8Array|ArrayBuffer} bytes - File bytes (only first 12 needed) 1204 * @returns {string|null} Detected MIME type or null if unknown 1205 */ 1206export function sniffMimeType(bytes) { 1207 const arr = new Uint8Array(bytes.slice(0, 12)); 1208 1209 // JPEG: FF D8 FF 1210 if (arr[0] === 0xff && arr[1] === 0xd8 && arr[2] === 0xff) { 1211 return 'image/jpeg'; 1212 } 1213 1214 // PNG: 89 50 4E 47 0D 0A 1A 0A 1215 if ( 1216 arr[0] === 0x89 && 1217 arr[1] === 0x50 && 1218 arr[2] === 0x4e && 1219 arr[3] === 0x47 && 1220 arr[4] === 0x0d && 1221 arr[5] === 0x0a && 1222 arr[6] === 0x1a && 1223 arr[7] === 0x0a 1224 ) { 1225 return 'image/png'; 1226 } 1227 1228 // GIF: 47 49 46 38 (GIF8) 1229 if ( 1230 arr[0] === 0x47 && 1231 arr[1] === 0x49 && 1232 arr[2] === 0x46 && 1233 arr[3] === 0x38 1234 ) { 1235 return 'image/gif'; 1236 } 1237 1238 // WebP: RIFF....WEBP 1239 if ( 1240 arr[0] === 0x52 && 1241 arr[1] === 0x49 && 1242 arr[2] === 0x46 && 1243 arr[3] === 0x46 && 1244 arr[8] === 0x57 && 1245 arr[9] === 0x45 && 1246 arr[10] === 0x42 && 1247 arr[11] === 0x50 1248 ) { 1249 return 'image/webp'; 1250 } 1251 1252 // ISOBMFF container: ....ftyp at byte 4 (MP4, AVIF, HEIC, etc.) 1253 if ( 1254 arr[4] === 0x66 && 1255 arr[5] === 0x74 && 1256 arr[6] === 0x79 && 1257 arr[7] === 0x70 1258 ) { 1259 // Check brand code at bytes 8-11 1260 const brand = String.fromCharCode(arr[8], arr[9], arr[10], arr[11]); 1261 if (brand === 'avif') { 1262 return 'image/avif'; 1263 } 1264 if (brand === 'heic' || brand === 'heix' || brand === 'mif1') { 1265 return 'image/heic'; 1266 } 1267 return 'video/mp4'; 1268 } 1269 1270 return null; 1271} 1272 1273/** 1274 * Find all blob CID references in a record 1275 * @param {*} obj - Record value to scan 1276 * @param {string[]} refs - Accumulator array (internal) 1277 * @returns {string[]} Array of blob CID strings 1278 */ 1279export function findBlobRefs(obj, refs = []) { 1280 if (!obj || typeof obj !== 'object') { 1281 return refs; 1282 } 1283 1284 // Check if this object is a blob ref 1285 if (obj.$type === 'blob' && obj.ref?.$link) { 1286 refs.push(obj.ref.$link); 1287 } 1288 1289 // Recurse into arrays and objects 1290 if (Array.isArray(obj)) { 1291 for (const item of obj) { 1292 findBlobRefs(item, refs); 1293 } 1294 } else { 1295 for (const value of Object.values(obj)) { 1296 findBlobRefs(value, refs); 1297 } 1298 } 1299 1300 return refs; 1301} 1302 1303// ╔══════════════════════════════════════════════════════════════════════════════╗ 1304// ║ RELAY NOTIFICATION ║ 1305// ║ Notify relays to crawl after repo updates ║ 1306// ╚══════════════════════════════════════════════════════════════════════════════╝ 1307 1308/** 1309 * Notify relays to come crawl us after writes (like official PDS) 1310 * @param {{ RELAY_HOST?: string }} env 1311 * @param {string} hostname 1312 */ 1313async function notifyCrawlers(env, hostname) { 1314 const now = Date.now(); 1315 if (now - lastCrawlNotify < CRAWL_NOTIFY_THRESHOLD) { 1316 return; // Throttle notifications 1317 } 1318 1319 const relayHost = env.RELAY_HOST; 1320 if (!relayHost) return; 1321 1322 lastCrawlNotify = now; 1323 1324 // Fire and forget - don't block writes on relay notification 1325 fetch(`${relayHost}/xrpc/com.atproto.sync.requestCrawl`, { 1326 method: 'POST', 1327 headers: { 'Content-Type': 'application/json' }, 1328 body: JSON.stringify({ hostname }), 1329 }).catch(() => { 1330 // Silently ignore relay notification failures 1331 }); 1332} 1333 1334// ╔══════════════════════════════════════════════════════════════════════════════╗ 1335// ║ ROUTING ║ 1336// ║ XRPC endpoint definitions ║ 1337// ╚══════════════════════════════════════════════════════════════════════════════╝ 1338 1339/** 1340 * Route handler function type 1341 * @callback RouteHandler 1342 * @param {PersonalDataServer} pds - PDS instance 1343 * @param {Request} request - HTTP request 1344 * @param {URL} url - Parsed URL 1345 * @returns {Response | Promise<Response>} HTTP response 1346 */ 1347 1348/** 1349 * Route definition for the PDS router 1350 * @typedef {Object} Route 1351 * @property {string} [method] - Required HTTP method (default: any) 1352 * @property {RouteHandler} handler - Handler function 1353 */ 1354 1355/** @type {Record<string, Route>} */ 1356const pdsRoutes = { 1357 '/.well-known/atproto-did': { 1358 handler: (pds, _req, _url) => pds.handleAtprotoDid(), 1359 }, 1360 '/init': { 1361 method: 'POST', 1362 handler: (pds, req, _url) => pds.handleInit(req), 1363 }, 1364 '/status': { 1365 handler: (pds, _req, _url) => pds.handleStatus(), 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[]} */ ( 1580 this.sql.exec(`SELECT data FROM blocks WHERE cid = ?`, cidStr).toArray() 1581 ); 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 1681 ? new CID(cidToBytes(/** @type {string} */ (prevCommit.cid))) 1682 : null, 1683 }; 1684 1685 // Sign commit (using dag-cbor encoder for CIDs) 1686 const commitBytes = cborEncodeDagCbor(commit); 1687 const signingKey = await this.getSigningKey(); 1688 if (!signingKey) throw new Error('No signing key'); 1689 const sig = await sign(signingKey, commitBytes); 1690 1691 const signedCommit = { ...commit, sig }; 1692 const signedBytes = cborEncodeDagCbor(signedCommit); 1693 const commitCid = await createCid(signedBytes); 1694 const commitCidStr = cidToString(commitCid); 1695 1696 // Store commit block 1697 this.sql.exec( 1698 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, 1699 commitCidStr, 1700 signedBytes, 1701 ); 1702 1703 // Store commit reference 1704 this.sql.exec( 1705 `INSERT INTO commits (cid, rev, prev) VALUES (?, ?, ?)`, 1706 commitCidStr, 1707 rev, 1708 prevCommit?.cid || null, 1709 ); 1710 1711 // Update head and rev for listRepos 1712 await this.state.storage.put('head', commitCidStr); 1713 await this.state.storage.put('rev', rev); 1714 1715 // Collect blocks for the event (record + commit + MST nodes) 1716 // Build a mini CAR with just the new blocks - use string CIDs 1717 const newBlocks = []; 1718 // Add record block 1719 newBlocks.push({ cid: recordCidStr, data: recordBytes }); 1720 // Add commit block 1721 newBlocks.push({ cid: commitCidStr, data: signedBytes }); 1722 // Add MST node blocks (get all blocks referenced by commit.data) 1723 const mstBlocks = this.collectMstBlocks(/** @type {string} */ (dataRoot)); 1724 newBlocks.push(...mstBlocks); 1725 1726 // Sequence event with blocks - store complete event data including rev and time 1727 // blocks must be a full CAR file with header (roots = [commitCid]) 1728 const eventTime = new Date().toISOString(); 1729 const evt = cborEncode({ 1730 ops: [ 1731 { action: 'create', path: `${collection}/${rkey}`, cid: recordCidStr }, 1732 ], 1733 blocks: buildCarFile(commitCidStr, newBlocks), // Full CAR with header 1734 rev, // Store the actual commit revision 1735 time: eventTime, // Store the actual event time 1736 }); 1737 this.sql.exec( 1738 `INSERT INTO seq_events (did, commit_cid, evt) VALUES (?, ?, ?)`, 1739 did, 1740 commitCidStr, 1741 evt, 1742 ); 1743 1744 // Broadcast to subscribers (both local and via default DO for relay) 1745 const evtRows = /** @type {SeqEventRow[]} */ ( 1746 this.sql 1747 .exec(`SELECT * FROM seq_events ORDER BY seq DESC LIMIT 1`) 1748 .toArray() 1749 ); 1750 if (evtRows.length > 0) { 1751 this.broadcastEvent(evtRows[0]); 1752 // Also forward to default DO for relay subscribers 1753 if (this.env?.PDS) { 1754 const defaultId = this.env.PDS.idFromName('default'); 1755 const defaultPds = this.env.PDS.get(defaultId); 1756 // Convert ArrayBuffer to array for JSON serialization 1757 const row = evtRows[0]; 1758 const evtArray = Array.from(new Uint8Array(row.evt)); 1759 // Fire and forget but log errors 1760 defaultPds 1761 .fetch( 1762 new Request('http://internal/forward-event', { 1763 method: 'POST', 1764 body: JSON.stringify({ ...row, evt: evtArray }), 1765 }), 1766 ) 1767 .catch(() => {}); // Ignore forward errors 1768 } 1769 } 1770 1771 return { uri, cid: recordCidStr, commit: commitCidStr }; 1772 } 1773 1774 /** 1775 * @param {string} collection 1776 * @param {string} rkey 1777 */ 1778 async deleteRecord(collection, rkey) { 1779 const did = await this.getDid(); 1780 if (!did) throw new Error('PDS not initialized'); 1781 1782 const uri = `at://${did}/${collection}/${rkey}`; 1783 1784 // Check if record exists 1785 const existing = this.sql 1786 .exec(`SELECT cid FROM records WHERE uri = ?`, uri) 1787 .toArray(); 1788 if (existing.length === 0) { 1789 return { error: 'RecordNotFound', message: 'record not found' }; 1790 } 1791 1792 // Delete from records table 1793 this.sql.exec(`DELETE FROM records WHERE uri = ?`, uri); 1794 1795 // Get blobs associated with this record 1796 const associatedBlobs = this.sql 1797 .exec('SELECT blobCid FROM record_blob WHERE recordUri = ?', uri) 1798 .toArray(); 1799 1800 // Remove associations for this record 1801 this.sql.exec('DELETE FROM record_blob WHERE recordUri = ?', uri); 1802 1803 // Check each blob for orphan status and delete if unreferenced 1804 for (const { blobCid } of associatedBlobs) { 1805 const stillReferenced = this.sql 1806 .exec('SELECT 1 FROM record_blob WHERE blobCid = ? LIMIT 1', blobCid) 1807 .toArray(); 1808 1809 if (stillReferenced.length === 0) { 1810 // Blob is orphaned, delete from R2 and database 1811 await this.env?.BLOBS?.delete(`${did}/${blobCid}`); 1812 this.sql.exec('DELETE FROM blob WHERE cid = ?', blobCid); 1813 } 1814 } 1815 1816 // Rebuild MST 1817 const mst = new MST(this.sql); 1818 const dataRoot = await mst.computeRoot(); 1819 1820 // Get previous commit 1821 const prevCommits = this.sql 1822 .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`) 1823 .toArray(); 1824 const prevCommit = prevCommits.length > 0 ? prevCommits[0] : null; 1825 1826 // Create commit 1827 const rev = createTid(); 1828 const commit = { 1829 did, 1830 version: 3, 1831 data: dataRoot 1832 ? new CID(cidToBytes(/** @type {string} */ (dataRoot))) 1833 : null, 1834 rev, 1835 prev: prevCommit?.cid 1836 ? new CID(cidToBytes(/** @type {string} */ (prevCommit.cid))) 1837 : null, 1838 }; 1839 1840 // Sign commit 1841 const commitBytes = cborEncodeDagCbor(commit); 1842 const signingKey = await this.getSigningKey(); 1843 if (!signingKey) throw new Error('No signing key'); 1844 const sig = await sign(signingKey, commitBytes); 1845 1846 const signedCommit = { ...commit, sig }; 1847 const signedBytes = cborEncodeDagCbor(signedCommit); 1848 const commitCid = await createCid(signedBytes); 1849 const commitCidStr = cidToString(commitCid); 1850 1851 // Store commit block 1852 this.sql.exec( 1853 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, 1854 commitCidStr, 1855 signedBytes, 1856 ); 1857 1858 // Store commit reference 1859 this.sql.exec( 1860 `INSERT INTO commits (cid, rev, prev) VALUES (?, ?, ?)`, 1861 commitCidStr, 1862 rev, 1863 prevCommit?.cid || null, 1864 ); 1865 1866 // Update head and rev 1867 await this.state.storage.put('head', commitCidStr); 1868 await this.state.storage.put('rev', rev); 1869 1870 // Collect blocks for the event (commit + MST nodes, no record block) 1871 const newBlocks = []; 1872 newBlocks.push({ cid: commitCidStr, data: signedBytes }); 1873 if (dataRoot) { 1874 const mstBlocks = this.collectMstBlocks(/** @type {string} */ (dataRoot)); 1875 newBlocks.push(...mstBlocks); 1876 } 1877 1878 // Sequence event with delete action 1879 const eventTime = new Date().toISOString(); 1880 const evt = cborEncode({ 1881 ops: [{ action: 'delete', path: `${collection}/${rkey}`, cid: null }], 1882 blocks: buildCarFile(commitCidStr, newBlocks), 1883 rev, 1884 time: eventTime, 1885 }); 1886 this.sql.exec( 1887 `INSERT INTO seq_events (did, commit_cid, evt) VALUES (?, ?, ?)`, 1888 did, 1889 commitCidStr, 1890 evt, 1891 ); 1892 1893 // Broadcast to subscribers 1894 const evtRows = /** @type {SeqEventRow[]} */ ( 1895 this.sql 1896 .exec(`SELECT * FROM seq_events ORDER BY seq DESC LIMIT 1`) 1897 .toArray() 1898 ); 1899 if (evtRows.length > 0) { 1900 this.broadcastEvent(evtRows[0]); 1901 // Forward to default DO for relay subscribers 1902 if (this.env?.PDS) { 1903 const defaultId = this.env.PDS.idFromName('default'); 1904 const defaultPds = this.env.PDS.get(defaultId); 1905 const row = evtRows[0]; 1906 const evtArray = Array.from(new Uint8Array(row.evt)); 1907 defaultPds 1908 .fetch( 1909 new Request('http://internal/forward-event', { 1910 method: 'POST', 1911 body: JSON.stringify({ ...row, evt: evtArray }), 1912 }), 1913 ) 1914 .catch(() => {}); // Ignore forward errors 1915 } 1916 } 1917 1918 return { ok: true }; 1919 } 1920 1921 /** 1922 * @param {SeqEventRow} evt 1923 * @returns {Uint8Array} 1924 */ 1925 formatEvent(evt) { 1926 // AT Protocol frame format: header + body 1927 // Use DAG-CBOR encoding for body (CIDs need tag 42 + 0x00 prefix) 1928 const header = cborEncode({ op: 1, t: '#commit' }); 1929 1930 // Decode stored event to get ops, blocks, rev, and time 1931 const evtData = cborDecode(new Uint8Array(evt.evt)); 1932 /** @type {Array<{action: string, path: string, cid: CID|null}>} */ 1933 const ops = evtData.ops.map( 1934 (/** @type {{action: string, path: string, cid?: string}} */ op) => ({ 1935 ...op, 1936 cid: op.cid ? new CID(cidToBytes(op.cid)) : null, // Wrap in CID class for tag 42 encoding 1937 }), 1938 ); 1939 // Get blocks from stored event (already in CAR format) 1940 const blocks = evtData.blocks || new Uint8Array(0); 1941 1942 const body = cborEncodeDagCbor({ 1943 seq: evt.seq, 1944 rebase: false, 1945 tooBig: false, 1946 repo: evt.did, 1947 commit: new CID(cidToBytes(evt.commit_cid)), // Wrap in CID class for tag 42 encoding 1948 rev: evtData.rev, // Use stored rev from commit creation 1949 since: null, 1950 blocks: blocks instanceof Uint8Array ? blocks : new Uint8Array(blocks), 1951 ops, 1952 blobs: [], 1953 time: evtData.time, // Use stored time from event creation 1954 }); 1955 1956 // Concatenate header + body 1957 const frame = new Uint8Array(header.length + body.length); 1958 frame.set(header); 1959 frame.set(body, header.length); 1960 return frame; 1961 } 1962 1963 /** 1964 * @param {WebSocket} ws 1965 * @param {string | ArrayBuffer} message 1966 */ 1967 async webSocketMessage(ws, message) { 1968 // Handle ping 1969 if (message === 'ping') ws.send('pong'); 1970 } 1971 1972 /** 1973 * @param {WebSocket} _ws 1974 * @param {number} _code 1975 * @param {string} _reason 1976 */ 1977 async webSocketClose(_ws, _code, _reason) { 1978 // Durable Object will hibernate when no connections remain 1979 } 1980 1981 /** 1982 * @param {SeqEventRow} evt 1983 */ 1984 broadcastEvent(evt) { 1985 const frame = this.formatEvent(evt); 1986 for (const ws of this.state.getWebSockets()) { 1987 try { 1988 ws.send(frame); 1989 } catch (_e) { 1990 // Client disconnected 1991 } 1992 } 1993 } 1994 1995 async handleAtprotoDid() { 1996 let did = await this.getDid(); 1997 if (!did) { 1998 /** @type {string[]} */ 1999 const registeredDids = 2000 (await this.state.storage.get('registeredDids')) || []; 2001 did = registeredDids[0]; 2002 } 2003 if (!did) { 2004 return new Response('User not found', { status: 404 }); 2005 } 2006 return new Response(/** @type {string} */ (did), { 2007 headers: { 'Content-Type': 'text/plain' }, 2008 }); 2009 } 2010 2011 /** @param {Request} request */ 2012 async handleInit(request) { 2013 const body = await request.json(); 2014 if (!body.did || !body.privateKey) { 2015 return errorResponse('InvalidRequest', 'missing did or privateKey', 400); 2016 } 2017 await this.initIdentity(body.did, body.privateKey, body.handle || null); 2018 return Response.json({ 2019 ok: true, 2020 did: body.did, 2021 handle: body.handle || null, 2022 }); 2023 } 2024 2025 async handleStatus() { 2026 const did = await this.getDid(); 2027 return Response.json({ initialized: !!did, did: did || null }); 2028 } 2029 2030 /** @param {Request} request */ 2031 async handleForwardEvent(request) { 2032 const evt = await request.json(); 2033 const numSockets = [...this.state.getWebSockets()].length; 2034 this.broadcastEvent({ 2035 seq: evt.seq, 2036 did: evt.did, 2037 commit_cid: evt.commit_cid, 2038 evt: new Uint8Array(Object.values(evt.evt)), 2039 }); 2040 return Response.json({ ok: true, sockets: numSockets }); 2041 } 2042 2043 /** @param {Request} request */ 2044 async handleRegisterDid(request) { 2045 const body = await request.json(); 2046 /** @type {string[]} */ 2047 const registeredDids = 2048 (await this.state.storage.get('registeredDids')) || []; 2049 if (!registeredDids.includes(body.did)) { 2050 registeredDids.push(body.did); 2051 await this.state.storage.put('registeredDids', registeredDids); 2052 } 2053 return Response.json({ ok: true }); 2054 } 2055 2056 async handleGetRegisteredDids() { 2057 const registeredDids = 2058 (await this.state.storage.get('registeredDids')) || []; 2059 return Response.json({ dids: registeredDids }); 2060 } 2061 2062 /** @param {Request} request */ 2063 async handleRegisterHandle(request) { 2064 const body = await request.json(); 2065 const { handle, did } = body; 2066 if (!handle || !did) { 2067 return errorResponse('InvalidRequest', 'missing handle or did', 400); 2068 } 2069 /** @type {Record<string, string>} */ 2070 const handleMap = (await this.state.storage.get('handleMap')) || {}; 2071 handleMap[handle] = did; 2072 await this.state.storage.put('handleMap', handleMap); 2073 return Response.json({ ok: true }); 2074 } 2075 2076 /** @param {URL} url */ 2077 async handleResolveHandle(url) { 2078 const handle = url.searchParams.get('handle'); 2079 if (!handle) { 2080 return errorResponse('InvalidRequest', 'missing handle', 400); 2081 } 2082 /** @type {Record<string, string>} */ 2083 const handleMap = (await this.state.storage.get('handleMap')) || {}; 2084 const did = handleMap[handle]; 2085 if (!did) { 2086 return errorResponse('NotFound', 'handle not found', 404); 2087 } 2088 return Response.json({ did }); 2089 } 2090 2091 async handleRepoInfo() { 2092 const head = await this.state.storage.get('head'); 2093 const rev = await this.state.storage.get('rev'); 2094 return Response.json({ head: head || null, rev: rev || null }); 2095 } 2096 2097 /** @param {Request} request */ 2098 handleDescribeServer(request) { 2099 const hostname = request.headers.get('x-hostname') || 'localhost'; 2100 return Response.json({ 2101 did: `did:web:${hostname}`, 2102 availableUserDomains: [`.${hostname}`], 2103 inviteCodeRequired: false, 2104 phoneVerificationRequired: false, 2105 links: {}, 2106 contact: {}, 2107 }); 2108 } 2109 2110 /** @param {Request} request */ 2111 async handleCreateSession(request) { 2112 const body = await request.json(); 2113 const { identifier, password } = body; 2114 2115 if (!identifier || !password) { 2116 return errorResponse( 2117 'InvalidRequest', 2118 'Missing identifier or password', 2119 400, 2120 ); 2121 } 2122 2123 // Check password against env var 2124 const expectedPassword = this.env?.PDS_PASSWORD; 2125 if (!expectedPassword || password !== expectedPassword) { 2126 return errorResponse( 2127 'AuthRequired', 2128 'Invalid identifier or password', 2129 401, 2130 ); 2131 } 2132 2133 // Resolve identifier to DID 2134 let did = identifier; 2135 if (!identifier.startsWith('did:')) { 2136 // Try to resolve handle 2137 /** @type {Record<string, string>} */ 2138 const handleMap = (await this.state.storage.get('handleMap')) || {}; 2139 did = handleMap[identifier]; 2140 if (!did) { 2141 return errorResponse('InvalidRequest', 'Unable to resolve handle', 400); 2142 } 2143 } 2144 2145 // Get handle for response 2146 const handle = await this.getHandleForDid(did); 2147 2148 // Create tokens 2149 const jwtSecret = this.env?.JWT_SECRET; 2150 if (!jwtSecret) { 2151 return errorResponse( 2152 'InternalServerError', 2153 'Server not configured for authentication', 2154 500, 2155 ); 2156 } 2157 2158 const accessJwt = await createAccessJwt(did, jwtSecret); 2159 const refreshJwt = await createRefreshJwt(did, jwtSecret); 2160 2161 return Response.json({ 2162 accessJwt, 2163 refreshJwt, 2164 handle: handle || did, 2165 did, 2166 active: true, 2167 }); 2168 } 2169 2170 /** @param {Request} request */ 2171 async handleGetSession(request) { 2172 const authHeader = request.headers.get('Authorization'); 2173 if (!authHeader || !authHeader.startsWith('Bearer ')) { 2174 return errorResponse( 2175 'AuthRequired', 2176 'Missing or invalid authorization header', 2177 401, 2178 ); 2179 } 2180 2181 const token = authHeader.slice(7); // Remove 'Bearer ' 2182 const jwtSecret = this.env?.JWT_SECRET; 2183 if (!jwtSecret) { 2184 return errorResponse( 2185 'InternalServerError', 2186 'Server not configured for authentication', 2187 500, 2188 ); 2189 } 2190 2191 try { 2192 const payload = await verifyAccessJwt(token, jwtSecret); 2193 const did = payload.sub; 2194 const handle = await this.getHandleForDid(did); 2195 2196 return Response.json({ 2197 handle: handle || did, 2198 did, 2199 active: true, 2200 }); 2201 } catch (err) { 2202 const message = err instanceof Error ? err.message : String(err); 2203 return errorResponse('InvalidToken', message, 401); 2204 } 2205 } 2206 2207 /** @param {Request} request */ 2208 async handleRefreshSession(request) { 2209 const authHeader = request.headers.get('Authorization'); 2210 if (!authHeader || !authHeader.startsWith('Bearer ')) { 2211 return errorResponse( 2212 'AuthRequired', 2213 'Missing or invalid authorization header', 2214 401, 2215 ); 2216 } 2217 2218 const token = authHeader.slice(7); // Remove 'Bearer ' 2219 const jwtSecret = this.env?.JWT_SECRET; 2220 if (!jwtSecret) { 2221 return errorResponse( 2222 'InternalServerError', 2223 'Server not configured for authentication', 2224 500, 2225 ); 2226 } 2227 2228 try { 2229 const payload = await verifyRefreshJwt(token, jwtSecret); 2230 const did = payload.sub; 2231 const handle = await this.getHandleForDid(did); 2232 2233 // Issue fresh tokens 2234 const accessJwt = await createAccessJwt(did, jwtSecret); 2235 const refreshJwt = await createRefreshJwt(did, jwtSecret); 2236 2237 return Response.json({ 2238 accessJwt, 2239 refreshJwt, 2240 handle: handle || did, 2241 did, 2242 active: true, 2243 }); 2244 } catch (err) { 2245 const message = err instanceof Error ? err.message : String(err); 2246 if (message === 'Token expired') { 2247 return errorResponse('ExpiredToken', 'Refresh token has expired', 400); 2248 } 2249 return errorResponse('InvalidToken', message, 400); 2250 } 2251 } 2252 2253 /** @param {Request} _request */ 2254 async handleGetPreferences(_request) { 2255 // Preferences are stored per-user in their DO 2256 const preferences = (await this.state.storage.get('preferences')) || []; 2257 return Response.json({ preferences }); 2258 } 2259 2260 /** @param {Request} request */ 2261 async handlePutPreferences(request) { 2262 const body = await request.json(); 2263 const { preferences } = body; 2264 if (!Array.isArray(preferences)) { 2265 return errorResponse( 2266 'InvalidRequest', 2267 'preferences must be an array', 2268 400, 2269 ); 2270 } 2271 await this.state.storage.put('preferences', preferences); 2272 return Response.json({}); 2273 } 2274 2275 /** 2276 * @param {string} did 2277 * @returns {Promise<string|null>} 2278 */ 2279 async getHandleForDid(did) { 2280 // Check if this DID has a handle registered 2281 /** @type {Record<string, string>} */ 2282 const handleMap = (await this.state.storage.get('handleMap')) || {}; 2283 for (const [handle, mappedDid] of Object.entries(handleMap)) { 2284 if (mappedDid === did) return handle; 2285 } 2286 // Check instance's own handle 2287 const instanceDid = await this.getDid(); 2288 if (instanceDid === did) { 2289 return /** @type {string|null} */ ( 2290 await this.state.storage.get('handle') 2291 ); 2292 } 2293 return null; 2294 } 2295 2296 /** 2297 * @param {string} did 2298 * @param {string|null} lxm 2299 */ 2300 async createServiceAuthForAppView(did, lxm) { 2301 const signingKey = await this.getSigningKey(); 2302 if (!signingKey) throw new Error('No signing key available'); 2303 return createServiceJwt({ 2304 iss: did, 2305 aud: 'did:web:api.bsky.app', 2306 lxm, 2307 signingKey, 2308 }); 2309 } 2310 2311 /** 2312 * @param {Request} request 2313 * @param {string} userDid 2314 */ 2315 async handleAppViewProxy(request, userDid) { 2316 const url = new URL(request.url); 2317 // Extract lexicon method from path: /xrpc/app.bsky.actor.getPreferences -> app.bsky.actor.getPreferences 2318 const lxm = url.pathname.replace('/xrpc/', ''); 2319 2320 // Create service auth JWT 2321 const serviceJwt = await this.createServiceAuthForAppView(userDid, lxm); 2322 2323 // Build AppView URL 2324 const appViewUrl = new URL( 2325 url.pathname + url.search, 2326 'https://api.bsky.app', 2327 ); 2328 2329 // Forward request with service auth 2330 const headers = new Headers(); 2331 headers.set('Authorization', `Bearer ${serviceJwt}`); 2332 headers.set( 2333 'Content-Type', 2334 request.headers.get('Content-Type') || 'application/json', 2335 ); 2336 const acceptHeader = request.headers.get('Accept'); 2337 if (acceptHeader) { 2338 headers.set('Accept', acceptHeader); 2339 } 2340 const acceptLangHeader = request.headers.get('Accept-Language'); 2341 if (acceptLangHeader) { 2342 headers.set('Accept-Language', acceptLangHeader); 2343 } 2344 2345 const proxyReq = new Request(appViewUrl.toString(), { 2346 method: request.method, 2347 headers, 2348 body: 2349 request.method !== 'GET' && request.method !== 'HEAD' 2350 ? request.body 2351 : undefined, 2352 }); 2353 2354 try { 2355 const response = await fetch(proxyReq); 2356 // Return the response with CORS headers 2357 const responseHeaders = new Headers(response.headers); 2358 responseHeaders.set('Access-Control-Allow-Origin', '*'); 2359 return new Response(response.body, { 2360 status: response.status, 2361 statusText: response.statusText, 2362 headers: responseHeaders, 2363 }); 2364 } catch (err) { 2365 const message = err instanceof Error ? err.message : String(err); 2366 return errorResponse( 2367 'UpstreamFailure', 2368 `Failed to reach AppView: ${message}`, 2369 502, 2370 ); 2371 } 2372 } 2373 2374 async handleListRepos() { 2375 /** @type {string[]} */ 2376 const registeredDids = 2377 (await this.state.storage.get('registeredDids')) || []; 2378 const did = await this.getDid(); 2379 const repos = did 2380 ? [{ did, head: null, rev: null }] 2381 : registeredDids.map((/** @type {string} */ d) => ({ 2382 did: d, 2383 head: null, 2384 rev: null, 2385 })); 2386 return Response.json({ repos }); 2387 } 2388 2389 /** @param {Request} request */ 2390 async handleCreateRecord(request) { 2391 const body = await request.json(); 2392 if (!body.collection || !body.record) { 2393 return errorResponse( 2394 'InvalidRequest', 2395 'missing collection or record', 2396 400, 2397 ); 2398 } 2399 try { 2400 const result = await this.createRecord( 2401 body.collection, 2402 body.record, 2403 body.rkey, 2404 ); 2405 const head = await this.state.storage.get('head'); 2406 const rev = await this.state.storage.get('rev'); 2407 return Response.json({ 2408 uri: result.uri, 2409 cid: result.cid, 2410 commit: { cid: head, rev }, 2411 validationStatus: 'valid', 2412 }); 2413 } catch (err) { 2414 const message = err instanceof Error ? err.message : String(err); 2415 return errorResponse('InternalError', message, 500); 2416 } 2417 } 2418 2419 /** @param {Request} request */ 2420 async handleDeleteRecord(request) { 2421 const body = await request.json(); 2422 if (!body.collection || !body.rkey) { 2423 return errorResponse('InvalidRequest', 'missing collection or rkey', 400); 2424 } 2425 try { 2426 const result = await this.deleteRecord(body.collection, body.rkey); 2427 if (result.error) { 2428 return Response.json(result, { status: 404 }); 2429 } 2430 return Response.json({}); 2431 } catch (err) { 2432 const message = err instanceof Error ? err.message : String(err); 2433 return errorResponse('InternalError', message, 500); 2434 } 2435 } 2436 2437 /** @param {Request} request */ 2438 async handlePutRecord(request) { 2439 const body = await request.json(); 2440 if (!body.collection || !body.rkey || !body.record) { 2441 return errorResponse( 2442 'InvalidRequest', 2443 'missing collection, rkey, or record', 2444 400, 2445 ); 2446 } 2447 try { 2448 // putRecord is like createRecord but with a specific rkey (upsert) 2449 const result = await this.createRecord( 2450 body.collection, 2451 body.record, 2452 body.rkey, 2453 ); 2454 const head = await this.state.storage.get('head'); 2455 const rev = await this.state.storage.get('rev'); 2456 return Response.json({ 2457 uri: result.uri, 2458 cid: result.cid, 2459 commit: { cid: head, rev }, 2460 validationStatus: 'valid', 2461 }); 2462 } catch (err) { 2463 const message = err instanceof Error ? err.message : String(err); 2464 return errorResponse('InternalError', message, 500); 2465 } 2466 } 2467 2468 /** @param {Request} request */ 2469 async handleApplyWrites(request) { 2470 const body = await request.json(); 2471 if (!body.writes || !Array.isArray(body.writes)) { 2472 return errorResponse('InvalidRequest', 'missing writes array', 400); 2473 } 2474 try { 2475 const results = []; 2476 for (const write of body.writes) { 2477 const type = write.$type; 2478 if (type === 'com.atproto.repo.applyWrites#create') { 2479 const result = await this.createRecord( 2480 write.collection, 2481 write.value, 2482 write.rkey, 2483 ); 2484 results.push({ 2485 $type: 'com.atproto.repo.applyWrites#createResult', 2486 uri: result.uri, 2487 cid: result.cid, 2488 validationStatus: 'valid', 2489 }); 2490 } else if (type === 'com.atproto.repo.applyWrites#update') { 2491 const result = await this.createRecord( 2492 write.collection, 2493 write.value, 2494 write.rkey, 2495 ); 2496 results.push({ 2497 $type: 'com.atproto.repo.applyWrites#updateResult', 2498 uri: result.uri, 2499 cid: result.cid, 2500 validationStatus: 'valid', 2501 }); 2502 } else if (type === 'com.atproto.repo.applyWrites#delete') { 2503 await this.deleteRecord(write.collection, write.rkey); 2504 results.push({ 2505 $type: 'com.atproto.repo.applyWrites#deleteResult', 2506 }); 2507 } else { 2508 return errorResponse( 2509 'InvalidRequest', 2510 `Unknown write operation type: ${type}`, 2511 400, 2512 ); 2513 } 2514 } 2515 // Return commit info 2516 const head = await this.state.storage.get('head'); 2517 const rev = await this.state.storage.get('rev'); 2518 return Response.json({ commit: { cid: head, rev }, results }); 2519 } catch (err) { 2520 const message = err instanceof Error ? err.message : String(err); 2521 return errorResponse('InternalError', message, 500); 2522 } 2523 } 2524 2525 /** @param {URL} url */ 2526 async handleGetRecord(url) { 2527 const collection = url.searchParams.get('collection'); 2528 const rkey = url.searchParams.get('rkey'); 2529 if (!collection || !rkey) { 2530 return errorResponse('InvalidRequest', 'missing collection or rkey', 400); 2531 } 2532 const did = await this.getDid(); 2533 const uri = `at://${did}/${collection}/${rkey}`; 2534 const rows = /** @type {RecordRow[]} */ ( 2535 this.sql 2536 .exec(`SELECT cid, value FROM records WHERE uri = ?`, uri) 2537 .toArray() 2538 ); 2539 if (rows.length === 0) { 2540 return errorResponse('RecordNotFound', 'record not found', 404); 2541 } 2542 const row = rows[0]; 2543 const value = cborDecode(new Uint8Array(row.value)); 2544 return Response.json({ uri, cid: row.cid, value }); 2545 } 2546 2547 async handleDescribeRepo() { 2548 const did = await this.getDid(); 2549 if (!did) { 2550 return errorResponse('RepoNotFound', 'repo not found', 404); 2551 } 2552 const handle = await this.state.storage.get('handle'); 2553 // Get unique collections 2554 const collections = this.sql 2555 .exec(`SELECT DISTINCT collection FROM records`) 2556 .toArray() 2557 .map((r) => r.collection); 2558 2559 return Response.json({ 2560 handle: handle || did, 2561 did, 2562 didDoc: {}, 2563 collections, 2564 handleIsCorrect: !!handle, 2565 }); 2566 } 2567 2568 /** @param {URL} url */ 2569 async handleListRecords(url) { 2570 const collection = url.searchParams.get('collection'); 2571 if (!collection) { 2572 return errorResponse('InvalidRequest', 'missing collection', 400); 2573 } 2574 const limit = Math.min( 2575 parseInt(url.searchParams.get('limit') || '50', 10), 2576 100, 2577 ); 2578 const reverse = url.searchParams.get('reverse') === 'true'; 2579 const _cursor = url.searchParams.get('cursor'); 2580 2581 const _did = await this.getDid(); 2582 const query = `SELECT uri, cid, value FROM records WHERE collection = ? ORDER BY rkey ${reverse ? 'DESC' : 'ASC'} LIMIT ?`; 2583 const params = [collection, limit + 1]; 2584 2585 const rows = /** @type {RecordRow[]} */ ( 2586 this.sql.exec(query, ...params).toArray() 2587 ); 2588 const hasMore = rows.length > limit; 2589 const records = rows.slice(0, limit).map((r) => ({ 2590 uri: r.uri, 2591 cid: r.cid, 2592 value: cborDecode(new Uint8Array(r.value)), 2593 })); 2594 2595 return Response.json({ 2596 records, 2597 cursor: hasMore ? records[records.length - 1]?.uri : undefined, 2598 }); 2599 } 2600 2601 handleGetLatestCommit() { 2602 const commits = /** @type {CommitRow[]} */ ( 2603 this.sql 2604 .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`) 2605 .toArray() 2606 ); 2607 if (commits.length === 0) { 2608 return errorResponse('RepoNotFound', 'repo not found', 404); 2609 } 2610 return Response.json({ cid: commits[0].cid, rev: commits[0].rev }); 2611 } 2612 2613 async handleGetRepoStatus() { 2614 const did = await this.getDid(); 2615 const commits = /** @type {CommitRow[]} */ ( 2616 this.sql 2617 .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`) 2618 .toArray() 2619 ); 2620 if (commits.length === 0 || !did) { 2621 return errorResponse('RepoNotFound', 'repo not found', 404); 2622 } 2623 return Response.json({ 2624 did, 2625 active: true, 2626 status: 'active', 2627 rev: commits[0].rev, 2628 }); 2629 } 2630 2631 handleGetRepo() { 2632 const commits = /** @type {CommitRow[]} */ ( 2633 this.sql 2634 .exec(`SELECT cid FROM commits ORDER BY seq DESC LIMIT 1`) 2635 .toArray() 2636 ); 2637 if (commits.length === 0) { 2638 return errorResponse('RepoNotFound', 'repo not found', 404); 2639 } 2640 2641 // Only include blocks reachable from the current commit 2642 const commitCid = commits[0].cid; 2643 const neededCids = new Set(); 2644 2645 // Helper to get block data 2646 /** @param {string} cid */ 2647 const getBlock = (cid) => { 2648 const rows = /** @type {BlockRow[]} */ ( 2649 this.sql.exec(`SELECT data FROM blocks WHERE cid = ?`, cid).toArray() 2650 ); 2651 return rows.length > 0 ? new Uint8Array(rows[0].data) : null; 2652 }; 2653 2654 // Collect all reachable blocks starting from commit 2655 /** @param {string} cid */ 2656 const collectBlocks = (cid) => { 2657 if (neededCids.has(cid)) return; 2658 neededCids.add(cid); 2659 2660 const data = getBlock(cid); 2661 if (!data) return; 2662 2663 // Decode CBOR to find CID references 2664 try { 2665 const decoded = cborDecode(data); 2666 if (decoded && typeof decoded === 'object') { 2667 // Commit object - follow 'data' (MST root) 2668 if (decoded.data instanceof Uint8Array) { 2669 collectBlocks(cidToString(decoded.data)); 2670 } 2671 // MST node - follow 'l' and entries' 'v' and 't' 2672 if (decoded.l instanceof Uint8Array) { 2673 collectBlocks(cidToString(decoded.l)); 2674 } 2675 if (Array.isArray(decoded.e)) { 2676 for (const entry of decoded.e) { 2677 if (entry.v instanceof Uint8Array) { 2678 collectBlocks(cidToString(entry.v)); 2679 } 2680 if (entry.t instanceof Uint8Array) { 2681 collectBlocks(cidToString(entry.t)); 2682 } 2683 } 2684 } 2685 } 2686 } catch (_e) { 2687 // Not a structured block, that's fine 2688 } 2689 }; 2690 2691 collectBlocks(commitCid); 2692 2693 // Build CAR with only needed blocks 2694 const blocksForCar = []; 2695 for (const cid of neededCids) { 2696 const data = getBlock(cid); 2697 if (data) { 2698 blocksForCar.push({ cid, data }); 2699 } 2700 } 2701 2702 const car = buildCarFile(commitCid, blocksForCar); 2703 return new Response(/** @type {BodyInit} */ (car), { 2704 headers: { 'content-type': 'application/vnd.ipld.car' }, 2705 }); 2706 } 2707 2708 /** @param {URL} url */ 2709 async handleSyncGetRecord(url) { 2710 const collection = url.searchParams.get('collection'); 2711 const rkey = url.searchParams.get('rkey'); 2712 if (!collection || !rkey) { 2713 return errorResponse('InvalidRequest', 'missing collection or rkey', 400); 2714 } 2715 const did = await this.getDid(); 2716 const uri = `at://${did}/${collection}/${rkey}`; 2717 const rows = /** @type {RecordRow[]} */ ( 2718 this.sql.exec(`SELECT cid FROM records WHERE uri = ?`, uri).toArray() 2719 ); 2720 if (rows.length === 0) { 2721 return errorResponse('RecordNotFound', 'record not found', 404); 2722 } 2723 const recordCid = rows[0].cid; 2724 2725 // Get latest commit 2726 const commits = /** @type {CommitRow[]} */ ( 2727 this.sql 2728 .exec(`SELECT cid FROM commits ORDER BY seq DESC LIMIT 1`) 2729 .toArray() 2730 ); 2731 if (commits.length === 0) { 2732 return errorResponse('RepoNotFound', 'no commits', 404); 2733 } 2734 const commitCid = commits[0].cid; 2735 2736 // Build proof chain: commit -> MST path -> record 2737 // Include commit block, all MST nodes on path to record, and record block 2738 /** @type {Array<{cid: string, data: Uint8Array}>} */ 2739 const blocks = []; 2740 const included = new Set(); 2741 2742 /** @param {string} cidStr */ 2743 const addBlock = (cidStr) => { 2744 if (included.has(cidStr)) return; 2745 included.add(cidStr); 2746 const blockRows = /** @type {BlockRow[]} */ ( 2747 this.sql.exec(`SELECT data FROM blocks WHERE cid = ?`, cidStr).toArray() 2748 ); 2749 if (blockRows.length > 0) { 2750 blocks.push({ cid: cidStr, data: new Uint8Array(blockRows[0].data) }); 2751 } 2752 }; 2753 2754 // Add commit block 2755 addBlock(commitCid); 2756 2757 // Get commit to find data root 2758 const commitRows = /** @type {BlockRow[]} */ ( 2759 this.sql 2760 .exec(`SELECT data FROM blocks WHERE cid = ?`, commitCid) 2761 .toArray() 2762 ); 2763 if (commitRows.length > 0) { 2764 const commit = cborDecode(new Uint8Array(commitRows[0].data)); 2765 if (commit.data) { 2766 const dataRootCid = cidToString(commit.data); 2767 // Collect MST path blocks (this includes all MST nodes) 2768 const mstBlocks = this.collectMstBlocks(dataRootCid); 2769 for (const block of mstBlocks) { 2770 addBlock(block.cid); 2771 } 2772 } 2773 } 2774 2775 // Add record block 2776 addBlock(recordCid); 2777 2778 const car = buildCarFile(commitCid, blocks); 2779 return new Response(/** @type {BodyInit} */ (car), { 2780 headers: { 'content-type': 'application/vnd.ipld.car' }, 2781 }); 2782 } 2783 2784 /** @param {Request} request */ 2785 async handleUploadBlob(request) { 2786 // Require auth 2787 const authHeader = request.headers.get('Authorization'); 2788 if (!authHeader || !authHeader.startsWith('Bearer ')) { 2789 return errorResponse( 2790 'AuthRequired', 2791 'Missing or invalid authorization header', 2792 401, 2793 ); 2794 } 2795 2796 const token = authHeader.slice(7); 2797 const jwtSecret = this.env?.JWT_SECRET; 2798 if (!jwtSecret) { 2799 return errorResponse( 2800 'InternalServerError', 2801 'Server not configured for authentication', 2802 500, 2803 ); 2804 } 2805 2806 try { 2807 await verifyAccessJwt(token, jwtSecret); 2808 } catch (err) { 2809 const message = err instanceof Error ? err.message : String(err); 2810 return errorResponse('InvalidToken', message, 401); 2811 } 2812 2813 const did = await this.getDid(); 2814 if (!did) { 2815 return errorResponse('InvalidRequest', 'PDS not initialized', 400); 2816 } 2817 2818 // Read body as ArrayBuffer 2819 const bodyBytes = await request.arrayBuffer(); 2820 const size = bodyBytes.byteLength; 2821 2822 // Check size limits 2823 if (size === 0) { 2824 return errorResponse( 2825 'InvalidRequest', 2826 'Empty blobs are not allowed', 2827 400, 2828 ); 2829 } 2830 const MAX_BLOB_SIZE = 50 * 1024 * 1024; 2831 if (size > MAX_BLOB_SIZE) { 2832 return errorResponse( 2833 'BlobTooLarge', 2834 `Blob size ${size} exceeds maximum ${MAX_BLOB_SIZE}`, 2835 400, 2836 ); 2837 } 2838 2839 // Sniff MIME type, fall back to Content-Type header 2840 const contentType = 2841 request.headers.get('Content-Type') || 'application/octet-stream'; 2842 const sniffed = sniffMimeType(bodyBytes); 2843 const mimeType = sniffed || contentType; 2844 2845 // Compute CID using raw codec for blobs 2846 const cid = await createBlobCid(new Uint8Array(bodyBytes)); 2847 const cidStr = cidToString(cid); 2848 2849 // Upload to R2 (idempotent - same CID always has same content) 2850 const r2Key = `${did}/${cidStr}`; 2851 await this.env?.BLOBS?.put(r2Key, bodyBytes, { 2852 httpMetadata: { contentType: mimeType }, 2853 }); 2854 2855 // Insert metadata (INSERT OR IGNORE handles concurrent uploads) 2856 const createdAt = new Date().toISOString(); 2857 this.sql.exec( 2858 'INSERT OR IGNORE INTO blob (cid, mimeType, size, createdAt) VALUES (?, ?, ?, ?)', 2859 cidStr, 2860 mimeType, 2861 size, 2862 createdAt, 2863 ); 2864 2865 // Return BlobRef 2866 return Response.json({ 2867 blob: { 2868 $type: 'blob', 2869 ref: { $link: cidStr }, 2870 mimeType, 2871 size, 2872 }, 2873 }); 2874 } 2875 2876 /** @param {URL} url */ 2877 async handleGetBlob(url) { 2878 const did = url.searchParams.get('did'); 2879 const cid = url.searchParams.get('cid'); 2880 2881 if (!did || !cid) { 2882 return errorResponse( 2883 'InvalidRequest', 2884 'missing did or cid parameter', 2885 400, 2886 ); 2887 } 2888 2889 // Validate CID format (CIDv1 base32lower: starts with 'b', 59 chars total) 2890 if (!/^b[a-z2-7]{58}$/.test(cid)) { 2891 return errorResponse('InvalidRequest', 'invalid CID format', 400); 2892 } 2893 2894 // Verify DID matches this DO 2895 const myDid = await this.getDid(); 2896 if (did !== myDid) { 2897 return errorResponse( 2898 'InvalidRequest', 2899 'DID does not match this repo', 2900 400, 2901 ); 2902 } 2903 2904 // Look up blob metadata 2905 const rows = this.sql 2906 .exec('SELECT mimeType, size FROM blob WHERE cid = ?', cid) 2907 .toArray(); 2908 2909 if (rows.length === 0) { 2910 return errorResponse('BlobNotFound', 'blob not found', 404); 2911 } 2912 2913 const { mimeType, size } = rows[0]; 2914 2915 // Fetch from R2 2916 const r2Key = `${did}/${cid}`; 2917 const object = await this.env?.BLOBS?.get(r2Key); 2918 2919 if (!object) { 2920 return errorResponse('BlobNotFound', 'blob not found in storage', 404); 2921 } 2922 2923 // Return blob with security headers 2924 return new Response(object.body, { 2925 headers: { 2926 'Content-Type': /** @type {string} */ (mimeType), 2927 'Content-Length': String(size), 2928 'X-Content-Type-Options': 'nosniff', 2929 'Content-Security-Policy': "default-src 'none'; sandbox", 2930 'Cache-Control': 'public, max-age=31536000, immutable', 2931 }, 2932 }); 2933 } 2934 2935 /** @param {URL} url */ 2936 async handleListBlobs(url) { 2937 const did = url.searchParams.get('did'); 2938 const cursor = url.searchParams.get('cursor'); 2939 const limit = Math.min(Number(url.searchParams.get('limit')) || 500, 1000); 2940 2941 if (!did) { 2942 return errorResponse('InvalidRequest', 'missing did parameter', 400); 2943 } 2944 2945 // Verify DID matches this DO 2946 const myDid = await this.getDid(); 2947 if (did !== myDid) { 2948 return errorResponse( 2949 'InvalidRequest', 2950 'DID does not match this repo', 2951 400, 2952 ); 2953 } 2954 2955 // Query blobs with pagination (cursor is createdAt::cid for uniqueness) 2956 let query = 'SELECT cid, createdAt FROM blob'; 2957 const params = []; 2958 2959 if (cursor) { 2960 const [cursorTime, cursorCid] = cursor.split('::'); 2961 query += ' WHERE (createdAt > ? OR (createdAt = ? AND cid > ?))'; 2962 params.push(cursorTime, cursorTime, cursorCid); 2963 } 2964 2965 query += ' ORDER BY createdAt ASC, cid ASC LIMIT ?'; 2966 params.push(limit + 1); // Fetch one extra to detect if there's more 2967 2968 const rows = this.sql.exec(query, ...params).toArray(); 2969 2970 // Determine if there's a next page 2971 let nextCursor = null; 2972 if (rows.length > limit) { 2973 rows.pop(); // Remove the extra row 2974 const last = rows[rows.length - 1]; 2975 nextCursor = `${last.createdAt}::${last.cid}`; 2976 } 2977 2978 return Response.json({ 2979 cids: rows.map((r) => r.cid), 2980 cursor: nextCursor, 2981 }); 2982 } 2983 2984 /** 2985 * @param {Request} request 2986 * @param {URL} url 2987 */ 2988 handleSubscribeRepos(request, url) { 2989 const upgradeHeader = request.headers.get('Upgrade'); 2990 if (upgradeHeader !== 'websocket') { 2991 return new Response('expected websocket', { status: 426 }); 2992 } 2993 const { 0: client, 1: server } = new WebSocketPair(); 2994 this.state.acceptWebSocket(server); 2995 const cursor = url.searchParams.get('cursor'); 2996 if (cursor) { 2997 const events = /** @type {SeqEventRow[]} */ ( 2998 this.sql 2999 .exec( 3000 `SELECT * FROM seq_events WHERE seq > ? ORDER BY seq`, 3001 parseInt(cursor, 10), 3002 ) 3003 .toArray() 3004 ); 3005 for (const evt of events) { 3006 server.send(this.formatEvent(evt)); 3007 } 3008 } 3009 return new Response(null, { status: 101, webSocket: client }); 3010 } 3011 3012 /** @param {Request} request */ 3013 async fetch(request) { 3014 const url = new URL(request.url); 3015 const route = pdsRoutes[url.pathname]; 3016 3017 // Check for local route first 3018 if (route) { 3019 if (route.method && request.method !== route.method) { 3020 return errorResponse('MethodNotAllowed', 'method not allowed', 405); 3021 } 3022 return route.handler(this, request, url); 3023 } 3024 3025 // Handle app.bsky.* proxy requests (only if no local route) 3026 if (url.pathname.startsWith('/xrpc/app.bsky.')) { 3027 const userDid = request.headers.get('x-authed-did'); 3028 if (!userDid) { 3029 return errorResponse('Unauthorized', 'Missing auth context', 401); 3030 } 3031 return this.handleAppViewProxy(request, userDid); 3032 } 3033 3034 return errorResponse('NotFound', 'not found', 404); 3035 } 3036 3037 async alarm() { 3038 await this.cleanupOrphanedBlobs(); 3039 // Reschedule for next day 3040 await this.state.storage.setAlarm(Date.now() + 24 * 60 * 60 * 1000); 3041 } 3042 3043 async cleanupOrphanedBlobs() { 3044 const did = await this.getDid(); 3045 if (!did) return; 3046 3047 // Find orphans: blobs not in record_blob, older than 24h 3048 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); 3049 3050 const orphans = this.sql 3051 .exec( 3052 `SELECT b.cid FROM blob b 3053 LEFT JOIN record_blob rb ON b.cid = rb.blobCid 3054 WHERE rb.blobCid IS NULL AND b.createdAt < ?`, 3055 cutoff, 3056 ) 3057 .toArray(); 3058 3059 for (const { cid } of orphans) { 3060 await this.env?.BLOBS?.delete(`${did}/${cid}`); 3061 this.sql.exec('DELETE FROM blob WHERE cid = ?', cid); 3062 } 3063 } 3064} 3065 3066// ╔══════════════════════════════════════════════════════════════════════════════╗ 3067// ║ WORKERS ENTRY POINT ║ 3068// ║ Request handling, CORS, auth middleware ║ 3069// ╚══════════════════════════════════════════════════════════════════════════════╝ 3070 3071const corsHeaders = { 3072 'Access-Control-Allow-Origin': '*', 3073 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 3074 'Access-Control-Allow-Headers': 3075 'Content-Type, Authorization, atproto-accept-labelers, atproto-proxy, x-bsky-topics', 3076}; 3077 3078/** 3079 * @param {Response} response 3080 * @returns {Response} 3081 */ 3082function addCorsHeaders(response) { 3083 const newHeaders = new Headers(response.headers); 3084 for (const [key, value] of Object.entries(corsHeaders)) { 3085 newHeaders.set(key, value); 3086 } 3087 return new Response(response.body, { 3088 status: response.status, 3089 statusText: response.statusText, 3090 headers: newHeaders, 3091 }); 3092} 3093 3094export default { 3095 /** 3096 * @param {Request} request 3097 * @param {Env} env 3098 */ 3099 async fetch(request, env) { 3100 // Handle CORS preflight 3101 if (request.method === 'OPTIONS') { 3102 return new Response(null, { headers: corsHeaders }); 3103 } 3104 3105 const response = await handleRequest(request, env); 3106 // Don't wrap WebSocket upgrades - they need the webSocket property preserved 3107 if (response.status === 101) { 3108 return response; 3109 } 3110 return addCorsHeaders(response); 3111 }, 3112}; 3113 3114/** 3115 * Extract subdomain from hostname (e.g., "alice" from "alice.foo.workers.dev") 3116 * @param {string} hostname 3117 * @returns {string|null} 3118 */ 3119function getSubdomain(hostname) { 3120 const parts = hostname.split('.'); 3121 // workers.dev domains: [subdomain?].[worker-name].[account].workers.dev 3122 // If more than 4 parts, first part(s) are user subdomain 3123 if (parts.length > 4 && parts.slice(-2).join('.') === 'workers.dev') { 3124 return parts.slice(0, -4).join('.'); 3125 } 3126 // Custom domains: check if there's a subdomain before the base 3127 // For now, assume no subdomain on custom domains 3128 return null; 3129} 3130 3131/** 3132 * Verify auth and return DID from token 3133 * @param {Request} request - HTTP request with Authorization header 3134 * @param {Env} env - Environment with JWT_SECRET 3135 * @returns {Promise<{did: string} | {error: Response}>} DID or error response 3136 */ 3137async function requireAuth(request, env) { 3138 const authHeader = request.headers.get('Authorization'); 3139 if (!authHeader || !authHeader.startsWith('Bearer ')) { 3140 return { 3141 error: Response.json( 3142 { 3143 error: 'AuthRequired', 3144 message: 'Authentication required', 3145 }, 3146 { status: 401 }, 3147 ), 3148 }; 3149 } 3150 3151 const token = authHeader.slice(7); 3152 const jwtSecret = env?.JWT_SECRET; 3153 if (!jwtSecret) { 3154 return { 3155 error: Response.json( 3156 { 3157 error: 'InternalServerError', 3158 message: 'Server not configured for authentication', 3159 }, 3160 { status: 500 }, 3161 ), 3162 }; 3163 } 3164 3165 try { 3166 const payload = await verifyAccessJwt(token, jwtSecret); 3167 return { did: payload.sub }; 3168 } catch (err) { 3169 const message = err instanceof Error ? err.message : String(err); 3170 return { 3171 error: Response.json( 3172 { 3173 error: 'InvalidToken', 3174 message: message, 3175 }, 3176 { status: 401 }, 3177 ), 3178 }; 3179 } 3180} 3181 3182/** 3183 * @param {Request} request 3184 * @param {Env} env 3185 */ 3186async function handleAuthenticatedBlobUpload(request, env) { 3187 const auth = await requireAuth(request, env); 3188 if ('error' in auth) return auth.error; 3189 3190 // Route to the user's DO based on their DID from the token 3191 const id = env.PDS.idFromName(auth.did); 3192 const pds = env.PDS.get(id); 3193 return pds.fetch(request); 3194} 3195 3196/** 3197 * @param {Request} request 3198 * @param {Env} env 3199 */ 3200async function handleAuthenticatedRepoWrite(request, env) { 3201 const auth = await requireAuth(request, env); 3202 if ('error' in auth) return auth.error; 3203 3204 const body = await request.json(); 3205 const repo = body.repo; 3206 if (!repo) { 3207 return errorResponse('InvalidRequest', 'missing repo param', 400); 3208 } 3209 3210 if (auth.did !== repo) { 3211 return errorResponse('Forbidden', "Cannot modify another user's repo", 403); 3212 } 3213 3214 const id = env.PDS.idFromName(repo); 3215 const pds = env.PDS.get(id); 3216 const response = await pds.fetch( 3217 new Request(request.url, { 3218 method: 'POST', 3219 headers: request.headers, 3220 body: JSON.stringify(body), 3221 }), 3222 ); 3223 3224 // Notify relay of updates on successful writes 3225 if (response.ok) { 3226 const url = new URL(request.url); 3227 notifyCrawlers(env, url.hostname); 3228 } 3229 3230 return response; 3231} 3232 3233/** 3234 * @param {Request} request 3235 * @param {Env} env 3236 */ 3237async function handleRequest(request, env) { 3238 const url = new URL(request.url); 3239 const subdomain = getSubdomain(url.hostname); 3240 3241 // Handle resolution via subdomain or bare domain 3242 if (url.pathname === '/.well-known/atproto-did') { 3243 // Look up handle -> DID in default DO 3244 // Use subdomain if present, otherwise try bare hostname as handle 3245 const handleToResolve = subdomain || url.hostname; 3246 const defaultId = env.PDS.idFromName('default'); 3247 const defaultPds = env.PDS.get(defaultId); 3248 const resolveRes = await defaultPds.fetch( 3249 new Request( 3250 `http://internal/resolve-handle?handle=${encodeURIComponent(handleToResolve)}`, 3251 ), 3252 ); 3253 if (!resolveRes.ok) { 3254 return new Response('Handle not found', { status: 404 }); 3255 } 3256 const { did } = await resolveRes.json(); 3257 return new Response(did, { headers: { 'Content-Type': 'text/plain' } }); 3258 } 3259 3260 // describeServer - works on bare domain 3261 if (url.pathname === '/xrpc/com.atproto.server.describeServer') { 3262 const defaultId = env.PDS.idFromName('default'); 3263 const defaultPds = env.PDS.get(defaultId); 3264 const newReq = new Request(request.url, { 3265 method: request.method, 3266 headers: { 3267 ...Object.fromEntries(request.headers), 3268 'x-hostname': url.hostname, 3269 }, 3270 }); 3271 return defaultPds.fetch(newReq); 3272 } 3273 3274 // createSession - handle on default DO (has handleMap for identifier resolution) 3275 if (url.pathname === '/xrpc/com.atproto.server.createSession') { 3276 const defaultId = env.PDS.idFromName('default'); 3277 const defaultPds = env.PDS.get(defaultId); 3278 return defaultPds.fetch(request); 3279 } 3280 3281 // getSession - route to default DO 3282 if (url.pathname === '/xrpc/com.atproto.server.getSession') { 3283 const defaultId = env.PDS.idFromName('default'); 3284 const defaultPds = env.PDS.get(defaultId); 3285 return defaultPds.fetch(request); 3286 } 3287 3288 // refreshSession - route to default DO 3289 if (url.pathname === '/xrpc/com.atproto.server.refreshSession') { 3290 const defaultId = env.PDS.idFromName('default'); 3291 const defaultPds = env.PDS.get(defaultId); 3292 return defaultPds.fetch(request); 3293 } 3294 3295 // Proxy app.bsky.* endpoints to Bluesky AppView 3296 if (url.pathname.startsWith('/xrpc/app.bsky.')) { 3297 // Authenticate the user first 3298 const auth = await requireAuth(request, env); 3299 if ('error' in auth) return auth.error; 3300 3301 // Route to the user's DO instance to create service auth and proxy 3302 const id = env.PDS.idFromName(auth.did); 3303 const pds = env.PDS.get(id); 3304 return pds.fetch( 3305 new Request(request.url, { 3306 method: request.method, 3307 headers: { 3308 ...Object.fromEntries(request.headers), 3309 'x-authed-did': auth.did, // Pass the authenticated DID 3310 }, 3311 body: 3312 request.method !== 'GET' && request.method !== 'HEAD' 3313 ? request.body 3314 : undefined, 3315 }), 3316 ); 3317 } 3318 3319 // Handle registration routes - go to default DO 3320 if ( 3321 url.pathname === '/register-handle' || 3322 url.pathname === '/resolve-handle' 3323 ) { 3324 const defaultId = env.PDS.idFromName('default'); 3325 const defaultPds = env.PDS.get(defaultId); 3326 return defaultPds.fetch(request); 3327 } 3328 3329 // resolveHandle XRPC endpoint 3330 if (url.pathname === '/xrpc/com.atproto.identity.resolveHandle') { 3331 const handle = url.searchParams.get('handle'); 3332 if (!handle) { 3333 return errorResponse('InvalidRequest', 'missing handle param', 400); 3334 } 3335 const defaultId = env.PDS.idFromName('default'); 3336 const defaultPds = env.PDS.get(defaultId); 3337 const resolveRes = await defaultPds.fetch( 3338 new Request( 3339 `http://internal/resolve-handle?handle=${encodeURIComponent(handle)}`, 3340 ), 3341 ); 3342 if (!resolveRes.ok) { 3343 return errorResponse('InvalidRequest', 'Unable to resolve handle', 400); 3344 } 3345 const { did } = await resolveRes.json(); 3346 return Response.json({ did }); 3347 } 3348 3349 // subscribeRepos WebSocket - route to default instance for firehose 3350 if (url.pathname === '/xrpc/com.atproto.sync.subscribeRepos') { 3351 const defaultId = env.PDS.idFromName('default'); 3352 const defaultPds = env.PDS.get(defaultId); 3353 return defaultPds.fetch(request); 3354 } 3355 3356 // listRepos needs to aggregate from all registered DIDs 3357 if (url.pathname === '/xrpc/com.atproto.sync.listRepos') { 3358 const defaultId = env.PDS.idFromName('default'); 3359 const defaultPds = env.PDS.get(defaultId); 3360 const regRes = await defaultPds.fetch( 3361 new Request('http://internal/get-registered-dids'), 3362 ); 3363 const { dids } = await regRes.json(); 3364 3365 const repos = []; 3366 for (const did of dids) { 3367 const id = env.PDS.idFromName(did); 3368 const pds = env.PDS.get(id); 3369 const infoRes = await pds.fetch(new Request('http://internal/repo-info')); 3370 const info = await infoRes.json(); 3371 if (info.head) { 3372 repos.push({ did, head: info.head, rev: info.rev, active: true }); 3373 } 3374 } 3375 return Response.json({ repos, cursor: undefined }); 3376 } 3377 3378 // Repo endpoints use ?repo= param instead of ?did= 3379 if ( 3380 url.pathname === '/xrpc/com.atproto.repo.describeRepo' || 3381 url.pathname === '/xrpc/com.atproto.repo.listRecords' || 3382 url.pathname === '/xrpc/com.atproto.repo.getRecord' 3383 ) { 3384 const repo = url.searchParams.get('repo'); 3385 if (!repo) { 3386 return errorResponse('InvalidRequest', 'missing repo param', 400); 3387 } 3388 const id = env.PDS.idFromName(repo); 3389 const pds = env.PDS.get(id); 3390 return pds.fetch(request); 3391 } 3392 3393 // Sync endpoints use ?did= param 3394 if ( 3395 url.pathname === '/xrpc/com.atproto.sync.getLatestCommit' || 3396 url.pathname === '/xrpc/com.atproto.sync.getRepoStatus' || 3397 url.pathname === '/xrpc/com.atproto.sync.getRepo' || 3398 url.pathname === '/xrpc/com.atproto.sync.getRecord' || 3399 url.pathname === '/xrpc/com.atproto.sync.getBlob' || 3400 url.pathname === '/xrpc/com.atproto.sync.listBlobs' 3401 ) { 3402 const did = url.searchParams.get('did'); 3403 if (!did) { 3404 return errorResponse('InvalidRequest', 'missing did param', 400); 3405 } 3406 const id = env.PDS.idFromName(did); 3407 const pds = env.PDS.get(id); 3408 return pds.fetch(request); 3409 } 3410 3411 // Blob upload endpoint (binary body, uses DID from token) 3412 if (url.pathname === '/xrpc/com.atproto.repo.uploadBlob') { 3413 return handleAuthenticatedBlobUpload(request, env); 3414 } 3415 3416 // Authenticated repo write endpoints 3417 const repoWriteEndpoints = [ 3418 '/xrpc/com.atproto.repo.createRecord', 3419 '/xrpc/com.atproto.repo.deleteRecord', 3420 '/xrpc/com.atproto.repo.putRecord', 3421 '/xrpc/com.atproto.repo.applyWrites', 3422 ]; 3423 if (repoWriteEndpoints.includes(url.pathname)) { 3424 return handleAuthenticatedRepoWrite(request, env); 3425 } 3426 3427 // Health check endpoint 3428 if (url.pathname === '/xrpc/_health') { 3429 return Response.json({ version: '0.1.0' }); 3430 } 3431 3432 // Root path - ASCII art 3433 if (url.pathname === '/') { 3434 const ascii = ` 3435 ██████╗ ██████╗ ███████╗ ██╗ ███████╗ 3436 ██╔══██╗ ██╔══██╗ ██╔════╝ ██║ ██╔════╝ 3437 ██████╔╝ ██║ ██║ ███████╗ ██║ ███████╗ 3438 ██╔═══╝ ██║ ██║ ╚════██║ ██ ██║ ╚════██║ 3439 ██║ ██████╔╝ ███████║ ██╗ ╚█████╔╝ ███████║ 3440 ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚════╝ ╚══════╝ 3441 3442 ATProto PDS on Cloudflare Workers 3443`; 3444 return new Response(ascii, { 3445 headers: { 'Content-Type': 'text/plain; charset=utf-8' }, 3446 }); 3447 } 3448 3449 // On init, register this DID with the default instance (requires ?did= param, no auth yet) 3450 if (url.pathname === '/init' && request.method === 'POST') { 3451 const did = url.searchParams.get('did'); 3452 if (!did) { 3453 return errorResponse('InvalidRequest', 'missing did param', 400); 3454 } 3455 const body = await request.json(); 3456 3457 // Register with default instance for discovery 3458 const defaultId = env.PDS.idFromName('default'); 3459 const defaultPds = env.PDS.get(defaultId); 3460 await defaultPds.fetch( 3461 new Request('http://internal/register-did', { 3462 method: 'POST', 3463 body: JSON.stringify({ did }), 3464 }), 3465 ); 3466 3467 // Register handle if provided 3468 if (body.handle) { 3469 await defaultPds.fetch( 3470 new Request('http://internal/register-handle', { 3471 method: 'POST', 3472 body: JSON.stringify({ did, handle: body.handle }), 3473 }), 3474 ); 3475 } 3476 3477 // Forward to the actual PDS instance 3478 const id = env.PDS.idFromName(did); 3479 const pds = env.PDS.get(id); 3480 return pds.fetch( 3481 new Request(request.url, { 3482 method: 'POST', 3483 headers: request.headers, 3484 body: JSON.stringify(body), 3485 }), 3486 ); 3487 } 3488 3489 // Unknown endpoint 3490 return errorResponse('NotFound', 'Endpoint not found', 404); 3491}