this repo has no description
1// ╔══════════════════════════════════════════════════════════════════════════════╗ 2// ║ ║ 3// ║ ██████╗ ██████╗ ███████╗ Personal Data Server ║ 4// ║ ██╔══██╗██╔══██╗██╔════╝ for AT Protocol ║ 5// ║ ██████╔╝██║ ██║███████╗ ║ 6// ║ ██╔═══╝ ██║ ██║╚════██║ ║ 7// ║ ██║ ██████╔╝███████║ ║ 8// ║ ╚═╝ ╚═════╝ ╚══════╝ ║ 9// ║ ║ 10// ╠══════════════════════════════════════════════════════════════════════════════╣ 11// ║ ║ 12// ║ A single-file ATProto PDS for Cloudflare Workers + Durable Objects ║ 13// ║ ║ 14// ║ Features: ║ 15// ║ • CBOR/DAG-CBOR encoding for content-addressed data ║ 16// ║ • CID generation (CIDv1 with dag-cbor + sha-256) ║ 17// ║ • Merkle Search Tree (MST) for repository structure ║ 18// ║ • P-256 signing with low-S normalization ║ 19// ║ • JWT authentication (access, refresh, service tokens) ║ 20// ║ • OAuth 2.0 with DPoP, PKCE, and token management ║ 21// ║ • CAR file building for repo sync ║ 22// ║ • R2 blob storage with MIME detection ║ 23// ║ • SQLite persistence via Durable Objects ║ 24// ║ ║ 25// ║ @see https://atproto.com ║ 26// ║ ║ 27// ╚══════════════════════════════════════════════════════════════════════════════╝ 28 29// ╔══════════════════════════════════════════════════════════════════════════════╗ 30// ║ TYPES & CONSTANTS ║ 31// ║ Environment bindings, SQL row types, protocol constants ║ 32// ╚══════════════════════════════════════════════════════════════════════════════╝ 33 34// PDS version (keep in sync with package.json) 35const VERSION = '0.3.0'; 36 37// CBOR primitive markers (RFC 8949) 38const CBOR_FALSE = 0xf4; 39const CBOR_TRUE = 0xf5; 40const CBOR_NULL = 0xf6; 41 42// DAG-CBOR CID link tag 43const CBOR_TAG_CID = 42; 44 45// CID codec constants 46const CODEC_DAG_CBOR = 0x71; 47const CODEC_RAW = 0x55; 48 49// TID generation constants 50const TID_CHARS = '234567abcdefghijklmnopqrstuvwxyz'; 51let lastTimestamp = 0; 52const clockId = Math.floor(Math.random() * 1024); 53 54// P-256 curve order N (for low-S signature normalization) 55const P256_N = BigInt( 56 '0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551', 57); 58const P256_N_DIV_2 = P256_N / 2n; 59 60// Crawler notification throttle 61const CRAWL_NOTIFY_THRESHOLD = 20 * 60 * 1000; // 20 minutes (matches official PDS) 62let lastCrawlNotify = 0; 63 64/** 65 * Cloudflare Workers environment bindings 66 * @typedef {Object} Env 67 * @property {string} JWT_SECRET - Secret for signing/verifying session JWTs 68 * @property {string} [RELAY_HOST] - Relay host to notify of repo updates (e.g., bsky.network) 69 * @property {string} [APPVIEW_URL] - AppView URL for proxying app.bsky.* requests 70 * @property {string} [APPVIEW_DID] - AppView DID for service auth 71 * @property {string} [PDS_PASSWORD] - Password for createSession authentication 72 * @property {DurableObjectNamespace} PDS - Durable Object namespace for PDS instances 73 * @property {R2Bucket} [BLOB_BUCKET] - R2 bucket for blob storage (legacy name) 74 * @property {R2Bucket} [BLOBS] - R2 bucket for blob storage 75 */ 76 77/** 78 * Row from the `blocks` table - stores raw CBOR-encoded data blocks 79 * @typedef {Object} BlockRow 80 * @property {string} cid - Content ID (CIDv1 base32lower) 81 * @property {ArrayBuffer} data - Raw block data (CBOR-encoded) 82 */ 83 84/** 85 * Row from the `records` table - indexes AT Protocol records 86 * @typedef {Object} RecordRow 87 * @property {string} uri - AT URI (at://did/collection/rkey) 88 * @property {string} cid - Content ID of the record block 89 * @property {string} collection - Collection NSID (e.g., app.bsky.feed.post) 90 * @property {string} rkey - Record key within collection 91 * @property {ArrayBuffer} value - CBOR-encoded record value 92 */ 93 94/** 95 * Row from the `commits` table - tracks repo commit history 96 * @typedef {Object} CommitRow 97 * @property {string} cid - Content ID of the signed commit block 98 * @property {string} rev - Revision TID for ordering 99 * @property {string|null} prev - Previous commit CID (null for first commit) 100 */ 101 102/** 103 * Row from the `seq_events` table - stores firehose events for subscribeRepos 104 * @typedef {Object} SeqEventRow 105 * @property {number} seq - Sequence number for cursor-based pagination 106 * @property {string} did - DID of the repo that changed 107 * @property {string} commit_cid - CID of the commit 108 * @property {ArrayBuffer|Uint8Array} evt - CBOR-encoded event with ops, blocks, rev, time 109 */ 110 111/** 112 * Row from the `blobs` table - tracks uploaded blob metadata 113 * @typedef {Object} BlobRow 114 * @property {string} cid - Content ID of the blob (raw codec) 115 * @property {string} mime_type - MIME type (sniffed or from Content-Type header) 116 * @property {number} size - Size in bytes 117 * @property {string} created_at - ISO timestamp of upload 118 */ 119 120/** 121 * Decoded JWT payload for session tokens 122 * @typedef {Object} JwtPayload 123 * @property {string} [scope] - Token scope (e.g., "com.atproto.access") 124 * @property {string} sub - Subject DID (the authenticated user) 125 * @property {string} [aud] - Audience (for refresh tokens, should match sub) 126 * @property {number} [iat] - Issued-at timestamp (Unix seconds) 127 * @property {number} [exp] - Expiration timestamp (Unix seconds) 128 * @property {string} [jti] - Unique token identifier 129 */ 130 131/** 132 * OAuth client metadata from client_id URL 133 * @typedef {Object} ClientMetadata 134 * @property {string} client_id - The client identifier (must match the URL used to fetch metadata) 135 * @property {string} [client_name] - Human-readable client name 136 * @property {string[]} redirect_uris - Allowed redirect URIs 137 * @property {string[]} grant_types - Supported grant types 138 * @property {string[]} response_types - Supported response types 139 * @property {string} [token_endpoint_auth_method] - Token endpoint auth method 140 * @property {boolean} [dpop_bound_access_tokens] - Whether client requires DPoP-bound tokens 141 * @property {string} [scope] - Default scope 142 */ 143 144/** 145 * Parsed and validated DPoP proof 146 * @typedef {Object} DpopProofResult 147 * @property {string} jkt - The JWK thumbprint of the DPoP key 148 * @property {string} jti - The unique identifier from the DPoP proof 149 * @property {number} iat - The issued-at timestamp from the DPoP proof 150 * @property {{ kty: string, crv: string, x: string, y: string }} jwk - The public key from the proof 151 */ 152 153/** 154 * Parameters for creating a DPoP-bound access token 155 * @typedef {Object} AccessTokenParams 156 * @property {string} issuer - The PDS issuer URL 157 * @property {string} subject - The DID of the authenticated user 158 * @property {string} clientId - The OAuth client_id 159 * @property {string} scope - The granted scope 160 * @property {string} tokenId - Unique token identifier (jti) 161 * @property {string} dpopJkt - The DPoP key thumbprint for token binding 162 * @property {number} expiresIn - Token lifetime in seconds 163 */ 164 165// ╔══════════════════════════════════════════════════════════════════════════════╗ 166// ║ UTILITIES ║ 167// ║ Error responses, byte conversion, base encoding ║ 168// ╚══════════════════════════════════════════════════════════════════════════════╝ 169 170/** 171 * @param {string} error - Error code 172 * @param {string} message - Error message 173 * @param {number} status - HTTP status code 174 * @returns {Response} 175 */ 176function errorResponse(error, message, status) { 177 return Response.json({ error, message }, { status }); 178} 179 180/** 181 * Get the default PDS Durable Object stub. 182 * @param {Env} env - Environment bindings 183 * @returns {{ fetch: (req: Request) => Promise<Response> }} Default PDS stub 184 */ 185function getDefaultPds(env) { 186 const id = env.PDS.idFromName('default'); 187 return env.PDS.get(id); 188} 189 190/** 191 * Parse request body supporting both JSON and form-encoded formats. 192 * @param {Request} request - The incoming request 193 * @returns {Promise<Record<string, string>>} Parsed body data 194 * @throws {Error} If JSON parsing fails 195 */ 196async function parseRequestBody(request) { 197 const contentType = request.headers.get('content-type') || ''; 198 const body = await request.text(); 199 if (contentType.includes('application/json')) { 200 return JSON.parse(body); 201 } 202 const params = new URLSearchParams(body); 203 return Object.fromEntries(params.entries()); 204} 205 206/** 207 * Validate that required parameters are present in data object. 208 * @param {Record<string, unknown>} data - Data object to validate 209 * @param {string[]} required - List of required parameter names 210 * @returns {{ valid: true } | { valid: false, missing: string[] }} Validation result 211 */ 212function validateRequiredParams(data, required) { 213 const missing = required.filter((key) => !data[key]); 214 if (missing.length > 0) { 215 return { valid: false, missing }; 216 } 217 return { valid: true }; 218} 219 220/** 221 * Convert bytes to hexadecimal string 222 * @param {Uint8Array} bytes - Bytes to convert 223 * @returns {string} Hex string 224 */ 225export function bytesToHex(bytes) { 226 return Array.from(bytes) 227 .map((b) => b.toString(16).padStart(2, '0')) 228 .join(''); 229} 230 231/** 232 * Convert hexadecimal string to bytes 233 * @param {string} hex - Hex string 234 * @returns {Uint8Array} Decoded bytes 235 */ 236export function hexToBytes(hex) { 237 const bytes = new Uint8Array(hex.length / 2); 238 for (let i = 0; i < hex.length; i += 2) { 239 bytes[i / 2] = parseInt(hex.substr(i, 2), 16); 240 } 241 return bytes; 242} 243 244/** 245 * @param {Uint8Array} bytes 246 * @returns {bigint} 247 */ 248function bytesToBigInt(bytes) { 249 let result = 0n; 250 for (const byte of bytes) { 251 result = (result << 8n) | BigInt(byte); 252 } 253 return result; 254} 255 256/** 257 * @param {bigint} n 258 * @param {number} length 259 * @returns {Uint8Array} 260 */ 261function bigIntToBytes(n, length) { 262 const bytes = new Uint8Array(length); 263 for (let i = length - 1; i >= 0; i--) { 264 bytes[i] = Number(n & 0xffn); 265 n >>= 8n; 266 } 267 return bytes; 268} 269 270/** 271 * Encode bytes as base32lower string 272 * @param {Uint8Array} bytes - Bytes to encode 273 * @returns {string} Base32lower-encoded string 274 */ 275export function base32Encode(bytes) { 276 const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; 277 let result = ''; 278 let bits = 0; 279 let value = 0; 280 281 for (const byte of bytes) { 282 value = (value << 8) | byte; 283 bits += 8; 284 while (bits >= 5) { 285 bits -= 5; 286 result += alphabet[(value >> bits) & 31]; 287 } 288 } 289 290 if (bits > 0) { 291 result += alphabet[(value << (5 - bits)) & 31]; 292 } 293 294 return result; 295} 296 297/** 298 * Decode base32lower string to bytes 299 * @param {string} str - Base32lower-encoded string 300 * @returns {Uint8Array} Decoded bytes 301 */ 302export function base32Decode(str) { 303 const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; 304 let bits = 0; 305 let value = 0; 306 const output = []; 307 308 for (const char of str) { 309 const idx = alphabet.indexOf(char); 310 if (idx === -1) continue; 311 value = (value << 5) | idx; 312 bits += 5; 313 if (bits >= 8) { 314 bits -= 8; 315 output.push((value >> bits) & 0xff); 316 } 317 } 318 319 return new Uint8Array(output); 320} 321 322/** 323 * Encode bytes as base64url string (no padding) 324 * @param {Uint8Array} bytes - Bytes to encode 325 * @returns {string} Base64url-encoded string 326 */ 327export function base64UrlEncode(bytes) { 328 let binary = ''; 329 for (const byte of bytes) { 330 binary += String.fromCharCode(byte); 331 } 332 const base64 = btoa(binary); 333 return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); 334} 335 336/** 337 * Decode base64url string to bytes 338 * @param {string} str - Base64url-encoded string 339 * @returns {Uint8Array} Decoded bytes 340 */ 341export function base64UrlDecode(str) { 342 const base64 = str.replace(/-/g, '+').replace(/_/g, '/'); 343 const pad = base64.length % 4; 344 const padded = pad ? base64 + '='.repeat(4 - pad) : base64; 345 const binary = atob(padded); 346 const bytes = new Uint8Array(binary.length); 347 for (let i = 0; i < binary.length; i++) { 348 bytes[i] = binary.charCodeAt(i); 349 } 350 return bytes; 351} 352 353/** 354 * Timing-safe string comparison using constant-time comparison. 355 * Compares hashes of strings to prevent timing attacks. 356 * @param {string} a - First string to compare 357 * @param {string} b - Second string to compare 358 * @returns {Promise<boolean>} True if strings are equal 359 */ 360export async function timingSafeEqual(a, b) { 361 const encoder = new TextEncoder(); 362 const aBytes = encoder.encode(a); 363 const bBytes = encoder.encode(b); 364 365 // Hash both to ensure constant-time comparison regardless of length 366 const [aHash, bHash] = await Promise.all([ 367 crypto.subtle.digest('SHA-256', aBytes), 368 crypto.subtle.digest('SHA-256', bBytes), 369 ]); 370 371 const aArr = new Uint8Array(aHash); 372 const bArr = new Uint8Array(bHash); 373 374 // Constant-time comparison 375 let result = 0; 376 for (let i = 0; i < aArr.length; i++) { 377 result |= aArr[i] ^ bArr[i]; 378 } 379 return result === 0; 380} 381 382/** 383 * Compute JWK thumbprint (SHA-256) per RFC 7638. 384 * Creates a canonical JSON representation of EC key required members 385 * and returns the base64url-encoded SHA-256 hash. 386 * @param {{ kty: string, crv: string, x: string, y: string }} jwk - The EC public key in JWK format 387 * @returns {Promise<string>} The base64url-encoded thumbprint 388 */ 389export async function computeJwkThumbprint(jwk) { 390 // RFC 7638: members must be in lexicographic order 391 const thumbprintInput = JSON.stringify({ 392 crv: jwk.crv, 393 kty: jwk.kty, 394 x: jwk.x, 395 y: jwk.y, 396 }); 397 const hash = await crypto.subtle.digest( 398 'SHA-256', 399 new TextEncoder().encode(thumbprintInput), 400 ); 401 return base64UrlEncode(new Uint8Array(hash)); 402} 403 404/** 405 * Check if a client_id represents a loopback client (localhost development). 406 * Loopback clients are allowed without pre-registration per AT Protocol OAuth spec. 407 * @param {string} clientId - The client_id to check 408 * @returns {boolean} True if the client_id is a loopback address 409 */ 410export function isLoopbackClient(clientId) { 411 try { 412 const url = new URL(clientId); 413 const host = url.hostname.toLowerCase(); 414 return host === 'localhost' || host === '127.0.0.1' || host === '[::1]'; 415 } catch { 416 return false; 417 } 418} 419 420/** 421 * Generate permissive client metadata for a loopback client. 422 * @param {string} clientId - The loopback client_id 423 * @returns {ClientMetadata} Generated client metadata 424 */ 425export function getLoopbackClientMetadata(clientId) { 426 return { 427 client_id: clientId, 428 client_name: 'Loopback Client', 429 redirect_uris: [clientId], 430 grant_types: ['authorization_code', 'refresh_token'], 431 response_types: ['code'], 432 token_endpoint_auth_method: 'none', 433 dpop_bound_access_tokens: true, 434 scope: 'atproto', 435 }; 436} 437 438/** 439 * Validate client metadata against AT Protocol OAuth requirements. 440 * @param {ClientMetadata} metadata - The client metadata to validate 441 * @param {string} expectedClientId - The expected client_id (the URL used to fetch metadata) 442 * @throws {Error} If validation fails 443 */ 444export function validateClientMetadata(metadata, expectedClientId) { 445 if (!metadata.client_id) throw new Error('client_id is required'); 446 if (metadata.client_id !== expectedClientId) 447 throw new Error('client_id mismatch'); 448 if ( 449 !Array.isArray(metadata.redirect_uris) || 450 metadata.redirect_uris.length === 0 451 ) { 452 throw new Error('redirect_uris is required'); 453 } 454 if (!metadata.grant_types?.includes('authorization_code')) { 455 throw new Error('grant_types must include authorization_code'); 456 } 457} 458 459/** @type {Map<string, { metadata: ClientMetadata, expiresAt: number }>} */ 460const clientMetadataCache = new Map(); 461 462/** 463 * Fetch and validate client metadata from a client_id URL. 464 * Caches results for 10 minutes. Loopback clients return synthetic metadata. 465 * @param {string} clientId - The client_id (URL to fetch metadata from) 466 * @returns {Promise<ClientMetadata>} The validated client metadata 467 * @throws {Error} If fetching or validation fails 468 */ 469async function getClientMetadata(clientId) { 470 const cached = clientMetadataCache.get(clientId); 471 if (cached && Date.now() < cached.expiresAt) return cached.metadata; 472 473 if (isLoopbackClient(clientId)) { 474 const metadata = getLoopbackClientMetadata(clientId); 475 clientMetadataCache.set(clientId, { 476 metadata, 477 expiresAt: Date.now() + 600000, 478 }); 479 return metadata; 480 } 481 482 const response = await fetch(clientId, { 483 headers: { Accept: 'application/json' }, 484 }); 485 if (!response.ok) 486 throw new Error(`Failed to fetch client metadata: ${response.status}`); 487 488 const metadata = await response.json(); 489 validateClientMetadata(metadata, clientId); 490 clientMetadataCache.set(clientId, { 491 metadata, 492 expiresAt: Date.now() + 600000, 493 }); 494 return metadata; 495} 496 497/** 498 * Parse and validate a DPoP proof JWT. 499 * Verifies the signature, checks claims (htm, htu, iat, jti), and optionally 500 * validates key binding (expectedJkt) and access token hash (ath). 501 * @param {string} proof - The DPoP proof JWT 502 * @param {string} method - The expected HTTP method (htm claim) 503 * @param {string} url - The expected request URL (htu claim) 504 * @param {string|null} [expectedJkt=null] - If provided, verify the key matches this thumbprint 505 * @param {string|null} [accessToken=null] - If provided, verify the ath claim matches this token's hash 506 * @returns {Promise<DpopProofResult>} The parsed proof with jkt, jti, and jwk 507 * @throws {Error} If validation fails 508 */ 509async function parseDpopProof( 510 proof, 511 method, 512 url, 513 expectedJkt = null, 514 accessToken = null, 515) { 516 const parts = proof.split('.'); 517 if (parts.length !== 3) throw new Error('Invalid DPoP proof format'); 518 519 const header = JSON.parse( 520 new TextDecoder().decode(base64UrlDecode(parts[0])), 521 ); 522 const payload = JSON.parse( 523 new TextDecoder().decode(base64UrlDecode(parts[1])), 524 ); 525 526 if (header.typ !== 'dpop+jwt') 527 throw new Error('DPoP proof must have typ dpop+jwt'); 528 if (header.alg !== 'ES256') throw new Error('DPoP proof must use ES256'); 529 if (!header.jwk || header.jwk.kty !== 'EC') 530 throw new Error('DPoP proof must contain EC key'); 531 532 // Verify signature 533 const publicKey = await crypto.subtle.importKey( 534 'jwk', 535 header.jwk, 536 { name: 'ECDSA', namedCurve: 'P-256' }, 537 false, 538 ['verify'], 539 ); 540 541 const signatureInput = new TextEncoder().encode(`${parts[0]}.${parts[1]}`); 542 const signature = base64UrlDecode(parts[2]); 543 544 const valid = await crypto.subtle.verify( 545 { name: 'ECDSA', hash: 'SHA-256' }, 546 publicKey, 547 /** @type {BufferSource} */ (signature), 548 /** @type {BufferSource} */ (signatureInput), 549 ); 550 if (!valid) throw new Error('DPoP proof signature invalid'); 551 552 // Validate claims 553 if (payload.htm !== method) throw new Error('DPoP htm mismatch'); 554 555 /** @param {string} u */ 556 const normalizeUrl = (u) => u.replace(/\/$/, '').split('?')[0].toLowerCase(); 557 if (normalizeUrl(payload.htu) !== normalizeUrl(url)) 558 throw new Error('DPoP htu mismatch'); 559 560 const now = Math.floor(Date.now() / 1000); 561 if (!payload.iat || payload.iat > now + 60 || payload.iat < now - 300) { 562 throw new Error('DPoP proof expired or invalid iat'); 563 } 564 565 if (!payload.jti) throw new Error('DPoP proof missing jti'); 566 567 const jkt = await computeJwkThumbprint(header.jwk); 568 if (expectedJkt && jkt !== expectedJkt) throw new Error('DPoP key mismatch'); 569 570 if (accessToken) { 571 const tokenHash = await crypto.subtle.digest( 572 'SHA-256', 573 new TextEncoder().encode(accessToken), 574 ); 575 const expectedAth = base64UrlEncode(new Uint8Array(tokenHash)); 576 if (payload.ath !== expectedAth) throw new Error('DPoP ath mismatch'); 577 } 578 579 return { jkt, jti: payload.jti, iat: payload.iat, jwk: header.jwk }; 580} 581 582/** 583 * Encode integer as unsigned varint 584 * @param {number} n - Non-negative integer 585 * @returns {Uint8Array} Varint-encoded bytes 586 */ 587export function varint(n) { 588 const bytes = []; 589 while (n >= 0x80) { 590 bytes.push((n & 0x7f) | 0x80); 591 n >>>= 7; 592 } 593 bytes.push(n); 594 return new Uint8Array(bytes); 595} 596 597// === CID WRAPPER === 598// Explicit CID type for DAG-CBOR encoding (avoids fragile heuristic detection) 599 600class CID { 601 /** @param {Uint8Array} bytes */ 602 constructor(bytes) { 603 if (!(bytes instanceof Uint8Array)) { 604 throw new Error('CID must be constructed with Uint8Array'); 605 } 606 this.bytes = bytes; 607 } 608} 609 610// ╔══════════════════════════════════════════════════════════════════════════════╗ 611// ║ CBOR ENCODING ║ 612// ║ RFC 8949 CBOR and DAG-CBOR for content-addressed data ║ 613// ╚══════════════════════════════════════════════════════════════════════════════╝ 614 615/** 616 * Encode CBOR type header (major type + length) 617 * @param {number[]} parts - Array to push bytes to 618 * @param {number} majorType - CBOR major type (0-7) 619 * @param {number} length - Value or length to encode 620 */ 621function encodeHead(parts, majorType, length) { 622 const mt = majorType << 5; 623 if (length < 24) { 624 parts.push(mt | length); 625 } else if (length < 256) { 626 parts.push(mt | 24, length); 627 } else if (length < 65536) { 628 parts.push(mt | 25, length >> 8, length & 0xff); 629 } else if (length < 4294967296) { 630 // Use Math.floor instead of bitshift to avoid 32-bit signed integer overflow 631 parts.push( 632 mt | 26, 633 Math.floor(length / 0x1000000) & 0xff, 634 Math.floor(length / 0x10000) & 0xff, 635 Math.floor(length / 0x100) & 0xff, 636 length & 0xff, 637 ); 638 } 639} 640 641/** 642 * Encode a value as CBOR bytes (RFC 8949 deterministic encoding) 643 * @param {*} value - Value to encode (null, boolean, number, string, Uint8Array, array, or object) 644 * @returns {Uint8Array} CBOR-encoded bytes 645 */ 646export function cborEncode(value) { 647 /** @type {number[]} */ 648 const parts = []; 649 650 /** @param {*} val */ 651 function encode(val) { 652 if (val === null) { 653 parts.push(CBOR_NULL); 654 } else if (val === true) { 655 parts.push(CBOR_TRUE); 656 } else if (val === false) { 657 parts.push(CBOR_FALSE); 658 } else if (typeof val === 'number') { 659 encodeInteger(val); 660 } else if (typeof val === 'string') { 661 const bytes = new TextEncoder().encode(val); 662 encodeHead(parts, 3, bytes.length); // major type 3 = text string 663 parts.push(...bytes); 664 } else if (val instanceof Uint8Array) { 665 encodeHead(parts, 2, val.length); // major type 2 = byte string 666 parts.push(...val); 667 } else if (Array.isArray(val)) { 668 encodeHead(parts, 4, val.length); // major type 4 = array 669 for (const item of val) encode(item); 670 } else if (typeof val === 'object') { 671 // Sort keys for deterministic encoding 672 const keys = Object.keys(val).sort(); 673 encodeHead(parts, 5, keys.length); // major type 5 = map 674 for (const key of keys) { 675 encode(key); 676 encode(val[key]); 677 } 678 } 679 } 680 681 /** @param {number} n */ 682 function encodeInteger(n) { 683 if (n >= 0) { 684 encodeHead(parts, 0, n); // major type 0 = unsigned int 685 } else { 686 encodeHead(parts, 1, -n - 1); // major type 1 = negative int 687 } 688 } 689 690 encode(value); 691 return new Uint8Array(parts); 692} 693 694/** 695 * DAG-CBOR encoder that handles CIDs with tag 42 696 * @param {*} value 697 * @returns {Uint8Array} 698 */ 699function cborEncodeDagCbor(value) { 700 /** @type {number[]} */ 701 const parts = []; 702 703 /** @param {*} val */ 704 function encode(val) { 705 if (val === null) { 706 parts.push(CBOR_NULL); 707 } else if (val === true) { 708 parts.push(CBOR_TRUE); 709 } else if (val === false) { 710 parts.push(CBOR_FALSE); 711 } else if (typeof val === 'number') { 712 if (Number.isInteger(val) && val >= 0) { 713 encodeHead(parts, 0, val); 714 } else if (Number.isInteger(val) && val < 0) { 715 encodeHead(parts, 1, -val - 1); 716 } 717 } else if (typeof val === 'string') { 718 const bytes = new TextEncoder().encode(val); 719 encodeHead(parts, 3, bytes.length); 720 parts.push(...bytes); 721 } else if (val instanceof CID) { 722 // CID links in DAG-CBOR use tag 42 + 0x00 multibase prefix 723 // The 0x00 prefix indicates "identity" multibase (raw bytes) 724 parts.push(0xd8, CBOR_TAG_CID); 725 encodeHead(parts, 2, val.bytes.length + 1); // +1 for 0x00 prefix 726 parts.push(0x00); 727 parts.push(...val.bytes); 728 } else if (val instanceof Uint8Array) { 729 // Regular byte string 730 encodeHead(parts, 2, val.length); 731 parts.push(...val); 732 } else if (Array.isArray(val)) { 733 encodeHead(parts, 4, val.length); 734 for (const item of val) encode(item); 735 } else if (typeof val === 'object') { 736 // DAG-CBOR: sort keys by length first, then lexicographically 737 // (differs from standard CBOR which sorts lexicographically only) 738 const keys = Object.keys(val).filter((k) => val[k] !== undefined); 739 keys.sort((a, b) => { 740 if (a.length !== b.length) return a.length - b.length; 741 return a < b ? -1 : a > b ? 1 : 0; 742 }); 743 encodeHead(parts, 5, keys.length); 744 for (const key of keys) { 745 const keyBytes = new TextEncoder().encode(key); 746 encodeHead(parts, 3, keyBytes.length); 747 parts.push(...keyBytes); 748 encode(val[key]); 749 } 750 } 751 } 752 753 encode(value); 754 return new Uint8Array(parts); 755} 756 757/** 758 * Decode CBOR bytes to a JavaScript value 759 * @param {Uint8Array} bytes - CBOR-encoded bytes 760 * @returns {*} Decoded value 761 */ 762export function cborDecode(bytes) { 763 let offset = 0; 764 765 /** @returns {*} */ 766 function read() { 767 const initial = bytes[offset++]; 768 const major = initial >> 5; 769 const info = initial & 0x1f; 770 771 let length = info; 772 if (info === 24) length = bytes[offset++]; 773 else if (info === 25) { 774 length = (bytes[offset++] << 8) | bytes[offset++]; 775 } else if (info === 26) { 776 // Use multiplication instead of bitshift to avoid 32-bit signed integer overflow 777 length = 778 bytes[offset++] * 0x1000000 + 779 bytes[offset++] * 0x10000 + 780 bytes[offset++] * 0x100 + 781 bytes[offset++]; 782 } 783 784 switch (major) { 785 case 0: 786 return length; // unsigned int 787 case 1: 788 return -1 - length; // negative int 789 case 2: { 790 // byte string 791 const data = bytes.slice(offset, offset + length); 792 offset += length; 793 return data; 794 } 795 case 3: { 796 // text string 797 const data = new TextDecoder().decode( 798 bytes.slice(offset, offset + length), 799 ); 800 offset += length; 801 return data; 802 } 803 case 4: { 804 // array 805 const arr = []; 806 for (let i = 0; i < length; i++) arr.push(read()); 807 return arr; 808 } 809 case 5: { 810 // map 811 /** @type {Record<string, *>} */ 812 const obj = {}; 813 for (let i = 0; i < length; i++) { 814 const key = /** @type {string} */ (read()); 815 obj[key] = read(); 816 } 817 return obj; 818 } 819 case 6: { 820 // tag 821 // length is the tag number 822 const taggedValue = read(); 823 if (length === CBOR_TAG_CID) { 824 // CID link: byte string with 0x00 multibase prefix, return raw CID bytes 825 return taggedValue.slice(1); // strip 0x00 prefix 826 } 827 return taggedValue; 828 } 829 case 7: { 830 // special 831 if (info === 20) return false; 832 if (info === 21) return true; 833 if (info === 22) return null; 834 return undefined; 835 } 836 } 837 } 838 839 return read(); 840} 841 842// ╔══════════════════════════════════════════════════════════════════════════════╗ 843// ║ CONTENT IDENTIFIERS ║ 844// ║ CIDs (content hashes) and TIDs (timestamp IDs) ║ 845// ╚══════════════════════════════════════════════════════════════════════════════╝ 846 847/** 848 * Create a CIDv1 with SHA-256 hash 849 * @param {Uint8Array} bytes - Content to hash 850 * @param {number} codec - Codec identifier (0x71 for dag-cbor, 0x55 for raw) 851 * @returns {Promise<Uint8Array>} CID bytes (36 bytes: version + codec + multihash) 852 */ 853async function createCidWithCodec(bytes, codec) { 854 const hash = await crypto.subtle.digest( 855 'SHA-256', 856 /** @type {BufferSource} */ (bytes), 857 ); 858 const hashBytes = new Uint8Array(hash); 859 860 // CIDv1: version(1) + codec + multihash(sha256) 861 // Multihash: hash-type(0x12) + length(0x20=32) + digest 862 const cid = new Uint8Array(2 + 2 + 32); 863 cid[0] = 0x01; // CIDv1 864 cid[1] = codec; 865 cid[2] = 0x12; // sha-256 866 cid[3] = 0x20; // 32 bytes 867 cid.set(hashBytes, 4); 868 869 return cid; 870} 871 872/** 873 * Create CID for DAG-CBOR encoded data (records, commits) 874 * @param {Uint8Array} bytes - DAG-CBOR encoded content 875 * @returns {Promise<Uint8Array>} CID bytes 876 */ 877export async function createCid(bytes) { 878 return createCidWithCodec(bytes, CODEC_DAG_CBOR); 879} 880 881/** 882 * Create CID for raw blob data (images, videos) 883 * @param {Uint8Array} bytes - Raw binary content 884 * @returns {Promise<Uint8Array>} CID bytes 885 */ 886export async function createBlobCid(bytes) { 887 return createCidWithCodec(bytes, CODEC_RAW); 888} 889 890/** 891 * Convert CID bytes to base32lower string representation 892 * @param {Uint8Array} cid - CID bytes 893 * @returns {string} Base32lower-encoded CID with 'b' prefix 894 */ 895export function cidToString(cid) { 896 // base32lower encoding for CIDv1 897 return `b${base32Encode(cid)}`; 898} 899 900/** 901 * Convert base32lower CID string to raw bytes 902 * @param {string} cidStr - CID string with 'b' prefix 903 * @returns {Uint8Array} CID bytes 904 */ 905export function cidToBytes(cidStr) { 906 // Decode base32lower CID string to bytes 907 if (!cidStr.startsWith('b')) throw new Error('expected base32lower CID'); 908 return base32Decode(cidStr.slice(1)); 909} 910 911/** 912 * Generate a timestamp-based ID (TID) for record keys 913 * Monotonic within a process, sortable by time 914 * @returns {string} 13-character base32-sort encoded TID 915 */ 916export function createTid() { 917 let timestamp = Date.now() * 1000; // microseconds 918 919 // Ensure monotonic 920 if (timestamp <= lastTimestamp) { 921 timestamp = lastTimestamp + 1; 922 } 923 lastTimestamp = timestamp; 924 925 // 13 chars: 11 for timestamp (64 bits but only ~53 used), 2 for clock ID 926 let tid = ''; 927 928 // Encode timestamp (high bits first for sortability) 929 let ts = timestamp; 930 for (let i = 0; i < 11; i++) { 931 tid = TID_CHARS[ts & 31] + tid; 932 ts = Math.floor(ts / 32); 933 } 934 935 // Append clock ID (2 chars) 936 tid += TID_CHARS[(clockId >> 5) & 31]; 937 tid += TID_CHARS[clockId & 31]; 938 939 return tid; 940} 941 942// ╔══════════════════════════════════════════════════════════════════════════════╗ 943// ║ CRYPTOGRAPHY ║ 944// ║ P-256 signing with low-S normalization, key management ║ 945// ╚══════════════════════════════════════════════════════════════════════════════╝ 946 947/** 948 * @param {BufferSource} data 949 * @returns {Promise<Uint8Array>} 950 */ 951async function sha256(data) { 952 const hash = await crypto.subtle.digest('SHA-256', data); 953 return new Uint8Array(hash); 954} 955 956/** 957 * Import a raw P-256 private key for signing 958 * @param {Uint8Array} privateKeyBytes - 32-byte raw private key 959 * @returns {Promise<CryptoKey>} Web Crypto key handle 960 */ 961export async function importPrivateKey(privateKeyBytes) { 962 // Validate private key length (P-256 requires exactly 32 bytes) 963 if ( 964 !(privateKeyBytes instanceof Uint8Array) || 965 privateKeyBytes.length !== 32 966 ) { 967 throw new Error( 968 `Invalid private key: expected 32 bytes, got ${privateKeyBytes?.length ?? 'non-Uint8Array'}`, 969 ); 970 } 971 972 // PKCS#8 wrapper for raw P-256 private key 973 const pkcs8Prefix = new Uint8Array([ 974 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 975 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 976 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20, 977 ]); 978 979 const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32); 980 pkcs8.set(pkcs8Prefix); 981 pkcs8.set(privateKeyBytes, pkcs8Prefix.length); 982 983 return crypto.subtle.importKey( 984 'pkcs8', 985 /** @type {BufferSource} */ (pkcs8), 986 { name: 'ECDSA', namedCurve: 'P-256' }, 987 false, 988 ['sign'], 989 ); 990} 991 992/** 993 * Sign data with ECDSA P-256, returning low-S normalized signature 994 * @param {CryptoKey} privateKey - Web Crypto key from importPrivateKey 995 * @param {Uint8Array} data - Data to sign 996 * @returns {Promise<Uint8Array>} 64-byte signature (r || s) 997 */ 998export async function sign(privateKey, data) { 999 const signature = await crypto.subtle.sign( 1000 { name: 'ECDSA', hash: 'SHA-256' }, 1001 privateKey, 1002 /** @type {BufferSource} */ (data), 1003 ); 1004 const sig = new Uint8Array(signature); 1005 1006 const r = sig.slice(0, 32); 1007 const s = sig.slice(32, 64); 1008 const sBigInt = bytesToBigInt(s); 1009 1010 // Low-S normalization: Bitcoin/ATProto require S <= N/2 to prevent 1011 // signature malleability (two valid signatures for same message) 1012 if (sBigInt > P256_N_DIV_2) { 1013 const newS = P256_N - sBigInt; 1014 const newSBytes = bigIntToBytes(newS, 32); 1015 const normalized = new Uint8Array(64); 1016 normalized.set(r, 0); 1017 normalized.set(newSBytes, 32); 1018 return normalized; 1019 } 1020 1021 return sig; 1022} 1023 1024/** 1025 * Generate a new P-256 key pair 1026 * @returns {Promise<{privateKey: Uint8Array, publicKey: Uint8Array}>} 32-byte private key, 33-byte compressed public key 1027 */ 1028export async function generateKeyPair() { 1029 const keyPair = await crypto.subtle.generateKey( 1030 { name: 'ECDSA', namedCurve: 'P-256' }, 1031 true, 1032 ['sign', 'verify'], 1033 ); 1034 1035 // Export private key as raw bytes 1036 const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey); 1037 const privateBytes = base64UrlDecode(/** @type {string} */ (privateJwk.d)); 1038 1039 // Export public key as compressed point 1040 const publicRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey); 1041 const publicBytes = new Uint8Array(publicRaw); 1042 const compressed = compressPublicKey(publicBytes); 1043 1044 return { privateKey: privateBytes, publicKey: compressed }; 1045} 1046 1047/** 1048 * @param {Uint8Array} uncompressed 1049 * @returns {Uint8Array} 1050 */ 1051function compressPublicKey(uncompressed) { 1052 // uncompressed is 65 bytes: 0x04 + x(32) + y(32) 1053 // compressed is 33 bytes: prefix(02 or 03) + x(32) 1054 const x = uncompressed.slice(1, 33); 1055 const y = uncompressed.slice(33, 65); 1056 const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03; 1057 const compressed = new Uint8Array(33); 1058 compressed[0] = prefix; 1059 compressed.set(x, 1); 1060 return compressed; 1061} 1062 1063// ╔══════════════════════════════════════════════════════════════════════════════╗ 1064// ║ AUTHENTICATION ║ 1065// ║ JWT creation/verification for sessions and service auth ║ 1066// ╚══════════════════════════════════════════════════════════════════════════════╝ 1067 1068/** 1069 * Create HMAC-SHA256 signature for JWT 1070 * @param {string} data - Data to sign (header.payload) 1071 * @param {string} secret - Secret key 1072 * @returns {Promise<string>} Base64url-encoded signature 1073 */ 1074async function hmacSign(data, secret) { 1075 const key = await crypto.subtle.importKey( 1076 'raw', 1077 /** @type {BufferSource} */ (new TextEncoder().encode(secret)), 1078 { name: 'HMAC', hash: 'SHA-256' }, 1079 false, 1080 ['sign'], 1081 ); 1082 const sig = await crypto.subtle.sign( 1083 'HMAC', 1084 key, 1085 /** @type {BufferSource} */ (new TextEncoder().encode(data)), 1086 ); 1087 return base64UrlEncode(new Uint8Array(sig)); 1088} 1089 1090/** 1091 * Create an access JWT for ATProto 1092 * @param {string} did - User's DID (subject and audience) 1093 * @param {string} secret - JWT signing secret 1094 * @param {number} [expiresIn=7200] - Expiration in seconds (default 2 hours) 1095 * @returns {Promise<string>} Signed JWT 1096 */ 1097export async function createAccessJwt(did, secret, expiresIn = 7200) { 1098 const header = { typ: 'at+jwt', alg: 'HS256' }; 1099 const now = Math.floor(Date.now() / 1000); 1100 const payload = { 1101 scope: 'com.atproto.access', 1102 sub: did, 1103 aud: did, 1104 iat: now, 1105 exp: now + expiresIn, 1106 }; 1107 1108 const headerB64 = base64UrlEncode( 1109 new TextEncoder().encode(JSON.stringify(header)), 1110 ); 1111 const payloadB64 = base64UrlEncode( 1112 new TextEncoder().encode(JSON.stringify(payload)), 1113 ); 1114 const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret); 1115 1116 return `${headerB64}.${payloadB64}.${signature}`; 1117} 1118 1119/** 1120 * Create a refresh JWT for ATProto 1121 * @param {string} did - User's DID (subject and audience) 1122 * @param {string} secret - JWT signing secret 1123 * @param {number} [expiresIn=86400] - Expiration in seconds (default 24 hours) 1124 * @returns {Promise<string>} Signed JWT 1125 */ 1126export async function createRefreshJwt(did, secret, expiresIn = 86400) { 1127 const header = { typ: 'refresh+jwt', alg: 'HS256' }; 1128 const now = Math.floor(Date.now() / 1000); 1129 // Generate random jti (token ID) 1130 const jtiBytes = new Uint8Array(32); 1131 crypto.getRandomValues(jtiBytes); 1132 const jti = base64UrlEncode(jtiBytes); 1133 1134 const payload = { 1135 scope: 'com.atproto.refresh', 1136 sub: did, 1137 aud: did, 1138 jti, 1139 iat: now, 1140 exp: now + expiresIn, 1141 }; 1142 1143 const headerB64 = base64UrlEncode( 1144 new TextEncoder().encode(JSON.stringify(header)), 1145 ); 1146 const payloadB64 = base64UrlEncode( 1147 new TextEncoder().encode(JSON.stringify(payload)), 1148 ); 1149 const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret); 1150 1151 return `${headerB64}.${payloadB64}.${signature}`; 1152} 1153 1154/** 1155 * Verify and decode a JWT (shared logic) 1156 * @param {string} jwt - JWT string to verify 1157 * @param {string} secret - JWT signing secret 1158 * @param {string} expectedType - Expected token type (e.g., 'at+jwt', 'refresh+jwt') 1159 * @returns {Promise<{header: {typ: string, alg: string}, payload: JwtPayload}>} Decoded header and payload 1160 * @throws {Error} If token is invalid, expired, or wrong type 1161 */ 1162async function verifyJwt(jwt, secret, expectedType) { 1163 const parts = jwt.split('.'); 1164 if (parts.length !== 3) { 1165 throw new Error('Invalid JWT format'); 1166 } 1167 1168 const [headerB64, payloadB64, signatureB64] = parts; 1169 1170 // Verify signature 1171 const expectedSig = await hmacSign(`${headerB64}.${payloadB64}`, secret); 1172 if (signatureB64 !== expectedSig) { 1173 throw new Error('Invalid signature'); 1174 } 1175 1176 // Decode header and payload 1177 const header = JSON.parse( 1178 new TextDecoder().decode(base64UrlDecode(headerB64)), 1179 ); 1180 const payload = JSON.parse( 1181 new TextDecoder().decode(base64UrlDecode(payloadB64)), 1182 ); 1183 1184 // Check token type 1185 if (header.typ !== expectedType) { 1186 throw new Error(`Invalid token type: expected ${expectedType}`); 1187 } 1188 1189 // Check expiration 1190 const now = Math.floor(Date.now() / 1000); 1191 if (payload.exp && payload.exp < now) { 1192 throw new Error('Token expired'); 1193 } 1194 1195 return { header, payload }; 1196} 1197 1198/** 1199 * Verify and decode an access JWT 1200 * @param {string} jwt - JWT string to verify 1201 * @param {string} secret - JWT signing secret 1202 * @returns {Promise<JwtPayload>} Decoded payload 1203 * @throws {Error} If token is invalid, expired, or wrong type 1204 */ 1205export async function verifyAccessJwt(jwt, secret) { 1206 const { payload } = await verifyJwt(jwt, secret, 'at+jwt'); 1207 return payload; 1208} 1209 1210/** 1211 * Verify and decode a refresh JWT 1212 * @param {string} jwt - JWT string to verify 1213 * @param {string} secret - JWT signing secret 1214 * @returns {Promise<JwtPayload>} Decoded payload 1215 * @throws {Error} If token is invalid, expired, or wrong type 1216 */ 1217export async function verifyRefreshJwt(jwt, secret) { 1218 const { payload } = await verifyJwt(jwt, secret, 'refresh+jwt'); 1219 1220 // Validate audience matches subject (token intended for this user) 1221 if (payload.aud && payload.aud !== payload.sub) { 1222 throw new Error('Invalid audience'); 1223 } 1224 1225 return payload; 1226} 1227 1228/** 1229 * Create a service auth JWT signed with ES256 (P-256) 1230 * Used for proxying requests to AppView 1231 * @param {Object} params - JWT parameters 1232 * @param {string} params.iss - Issuer DID (PDS DID) 1233 * @param {string} params.aud - Audience DID (AppView DID) 1234 * @param {string|null} params.lxm - Lexicon method being called 1235 * @param {CryptoKey} params.signingKey - P-256 private key from importPrivateKey 1236 * @returns {Promise<string>} Signed JWT 1237 */ 1238export async function createServiceJwt({ iss, aud, lxm, signingKey }) { 1239 const header = { typ: 'JWT', alg: 'ES256' }; 1240 const now = Math.floor(Date.now() / 1000); 1241 1242 // Generate random jti 1243 const jtiBytes = new Uint8Array(16); 1244 crypto.getRandomValues(jtiBytes); 1245 const jti = bytesToHex(jtiBytes); 1246 1247 /** @type {{ iss: string, aud: string, exp: number, iat: number, jti: string, lxm?: string }} */ 1248 const payload = { 1249 iss, 1250 aud, 1251 exp: now + 60, // 1 minute expiration 1252 iat: now, 1253 jti, 1254 }; 1255 if (lxm) payload.lxm = lxm; 1256 1257 const headerB64 = base64UrlEncode( 1258 new TextEncoder().encode(JSON.stringify(header)), 1259 ); 1260 const payloadB64 = base64UrlEncode( 1261 new TextEncoder().encode(JSON.stringify(payload)), 1262 ); 1263 const toSign = new TextEncoder().encode(`${headerB64}.${payloadB64}`); 1264 1265 const sig = await sign(signingKey, toSign); 1266 const sigB64 = base64UrlEncode(sig); 1267 1268 return `${headerB64}.${payloadB64}.${sigB64}`; 1269} 1270 1271// ╔══════════════════════════════════════════════════════════════════════════════╗ 1272// ║ MERKLE SEARCH TREE ║ 1273// ║ MST for ATProto repository structure ║ 1274// ╚══════════════════════════════════════════════════════════════════════════════╝ 1275 1276// Cache for key depths (SHA-256 is expensive) 1277const keyDepthCache = new Map(); 1278 1279/** 1280 * Get MST tree depth for a key based on leading zeros in SHA-256 hash 1281 * @param {string} key - Record key (collection/rkey) 1282 * @returns {Promise<number>} Tree depth (leading zeros / 2) 1283 */ 1284export async function getKeyDepth(key) { 1285 // Count leading zeros in SHA-256 hash, divide by 2 1286 if (keyDepthCache.has(key)) return keyDepthCache.get(key); 1287 1288 const keyBytes = new TextEncoder().encode(key); 1289 const hash = await sha256(keyBytes); 1290 1291 let zeros = 0; 1292 for (const byte of hash) { 1293 if (byte === 0) { 1294 zeros += 8; 1295 } else { 1296 // Count leading zeros in this byte 1297 for (let i = 7; i >= 0; i--) { 1298 if ((byte >> i) & 1) break; 1299 zeros++; 1300 } 1301 break; 1302 } 1303 } 1304 1305 // MST depth = leading zeros in SHA-256 hash / 2 1306 // This creates a probabilistic tree where ~50% of keys are at depth 0, 1307 // ~25% at depth 1, etc., giving O(log n) lookups 1308 const depth = Math.floor(zeros / 2); 1309 keyDepthCache.set(key, depth); 1310 return depth; 1311} 1312 1313/** 1314 * Compute common prefix length between two byte arrays 1315 * @param {Uint8Array} a 1316 * @param {Uint8Array} b 1317 * @returns {number} 1318 */ 1319function commonPrefixLen(a, b) { 1320 const minLen = Math.min(a.length, b.length); 1321 for (let i = 0; i < minLen; i++) { 1322 if (a[i] !== b[i]) return i; 1323 } 1324 return minLen; 1325} 1326 1327class MST { 1328 /** @param {SqlStorage} sql */ 1329 constructor(sql) { 1330 this.sql = sql; 1331 } 1332 1333 async computeRoot() { 1334 const records = this.sql 1335 .exec(` 1336 SELECT collection, rkey, cid FROM records ORDER BY collection, rkey 1337 `) 1338 .toArray(); 1339 1340 if (records.length === 0) { 1341 return null; 1342 } 1343 1344 // Build entries with pre-computed depths (heights) 1345 // In ATProto MST, "height" determines which layer a key belongs to 1346 // Layer 0 is at the BOTTOM, root is at the highest layer 1347 const entries = []; 1348 let maxDepth = 0; 1349 for (const r of records) { 1350 const key = `${r.collection}/${r.rkey}`; 1351 const depth = await getKeyDepth(key); 1352 maxDepth = Math.max(maxDepth, depth); 1353 entries.push({ 1354 key, 1355 keyBytes: new TextEncoder().encode(key), 1356 cid: /** @type {string} */ (r.cid), 1357 depth, 1358 }); 1359 } 1360 1361 // Start building from the root (highest layer) going down to layer 0 1362 return this.buildTree(entries, maxDepth); 1363 } 1364 1365 /** 1366 * @param {Array<{key: string, keyBytes: Uint8Array, cid: string, depth: number}>} entries 1367 * @param {number} layer 1368 * @returns {Promise<string|null>} 1369 */ 1370 async buildTree(entries, layer) { 1371 if (entries.length === 0) return null; 1372 1373 // Separate entries for this layer vs lower layers (subtrees) 1374 // Keys with depth == layer stay at this node 1375 // Keys with depth < layer go into subtrees (going down toward layer 0) 1376 /** @type {Array<{type: 'subtree', cid: string|null} | {type: 'entry', entry: {key: string, keyBytes: Uint8Array, cid: string, depth: number}}>} */ 1377 const thisLayer = []; 1378 /** @type {Array<{key: string, keyBytes: Uint8Array, cid: string, depth: number}>} */ 1379 let leftSubtree = []; 1380 1381 for (const entry of entries) { 1382 if (entry.depth < layer) { 1383 // This entry belongs to a lower layer - accumulate for subtree 1384 leftSubtree.push(entry); 1385 } else { 1386 // This entry belongs at current layer (depth == layer) 1387 // Process accumulated left subtree first 1388 if (leftSubtree.length > 0) { 1389 const leftCid = await this.buildTree(leftSubtree, layer - 1); 1390 thisLayer.push({ type: 'subtree', cid: leftCid }); 1391 leftSubtree = []; 1392 } 1393 thisLayer.push({ type: 'entry', entry }); 1394 } 1395 } 1396 1397 // Handle remaining left subtree 1398 if (leftSubtree.length > 0) { 1399 const leftCid = await this.buildTree(leftSubtree, layer - 1); 1400 thisLayer.push({ type: 'subtree', cid: leftCid }); 1401 } 1402 1403 // Build node with proper ATProto format 1404 /** @type {{ e: Array<{p: number, k: Uint8Array, v: CID, t: CID|null}>, l?: CID|null }} */ 1405 const node = { e: [] }; 1406 /** @type {string|null} */ 1407 let leftCid = null; 1408 let prevKeyBytes = new Uint8Array(0); 1409 1410 for (let i = 0; i < thisLayer.length; i++) { 1411 const item = thisLayer[i]; 1412 1413 if (item.type === 'subtree') { 1414 if (node.e.length === 0) { 1415 leftCid = item.cid; 1416 } else { 1417 // Attach to previous entry's 't' field 1418 if (item.cid !== null) { 1419 node.e[node.e.length - 1].t = new CID(cidToBytes(item.cid)); 1420 } 1421 } 1422 } else { 1423 // Entry - compute prefix compression 1424 const keyBytes = item.entry.keyBytes; 1425 const prefixLen = commonPrefixLen(prevKeyBytes, keyBytes); 1426 const keySuffix = keyBytes.slice(prefixLen); 1427 1428 // ATProto requires t field to be present (can be null) 1429 const e = { 1430 p: prefixLen, 1431 k: keySuffix, 1432 v: new CID(cidToBytes(item.entry.cid)), 1433 t: null, // Will be updated if there's a subtree 1434 }; 1435 1436 node.e.push(e); 1437 prevKeyBytes = /** @type {Uint8Array<ArrayBuffer>} */ (keyBytes); 1438 } 1439 } 1440 1441 // ATProto requires l field to be present (can be null) 1442 node.l = leftCid ? new CID(cidToBytes(leftCid)) : null; 1443 1444 // Encode node with proper MST CBOR format 1445 const nodeBytes = cborEncodeDagCbor(node); 1446 const nodeCid = await createCid(nodeBytes); 1447 const cidStr = cidToString(nodeCid); 1448 1449 this.sql.exec( 1450 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, 1451 cidStr, 1452 nodeBytes, 1453 ); 1454 1455 return cidStr; 1456 } 1457} 1458 1459// ╔══════════════════════════════════════════════════════════════════════════════╗ 1460// ║ CAR FILES ║ 1461// ║ Content Addressable aRchive format for repo sync ║ 1462// ╚══════════════════════════════════════════════════════════════════════════════╝ 1463 1464/** 1465 * Build a CAR (Content Addressable aRchive) file 1466 * @param {string} rootCid - Root CID string 1467 * @param {Array<{cid: string, data: Uint8Array}>} blocks - Blocks to include 1468 * @returns {Uint8Array} CAR file bytes 1469 */ 1470export function buildCarFile(rootCid, blocks) { 1471 const parts = []; 1472 1473 // Header: { version: 1, roots: [rootCid] } 1474 const rootCidBytes = cidToBytes(rootCid); 1475 const header = cborEncodeDagCbor({ 1476 version: 1, 1477 roots: [new CID(rootCidBytes)], 1478 }); 1479 parts.push(varint(header.length)); 1480 parts.push(header); 1481 1482 // Blocks: varint(len) + cid + data 1483 for (const block of blocks) { 1484 const cidBytes = cidToBytes(block.cid); 1485 const blockLen = cidBytes.length + block.data.length; 1486 parts.push(varint(blockLen)); 1487 parts.push(cidBytes); 1488 parts.push(block.data); 1489 } 1490 1491 // Concatenate all parts 1492 const totalLen = parts.reduce((sum, p) => sum + p.length, 0); 1493 const car = new Uint8Array(totalLen); 1494 let offset = 0; 1495 for (const part of parts) { 1496 car.set(part, offset); 1497 offset += part.length; 1498 } 1499 1500 return car; 1501} 1502 1503// ╔══════════════════════════════════════════════════════════════════════════════╗ 1504// ║ BLOB HANDLING ║ 1505// ║ MIME detection, blob reference scanning ║ 1506// ╚══════════════════════════════════════════════════════════════════════════════╝ 1507 1508/** 1509 * Sniff MIME type from file magic bytes 1510 * @param {Uint8Array|ArrayBuffer} bytes - File bytes (only first 12 needed) 1511 * @returns {string|null} Detected MIME type or null if unknown 1512 */ 1513export function sniffMimeType(bytes) { 1514 const arr = new Uint8Array(bytes.slice(0, 12)); 1515 1516 // JPEG: FF D8 FF 1517 if (arr[0] === 0xff && arr[1] === 0xd8 && arr[2] === 0xff) { 1518 return 'image/jpeg'; 1519 } 1520 1521 // PNG: 89 50 4E 47 0D 0A 1A 0A 1522 if ( 1523 arr[0] === 0x89 && 1524 arr[1] === 0x50 && 1525 arr[2] === 0x4e && 1526 arr[3] === 0x47 && 1527 arr[4] === 0x0d && 1528 arr[5] === 0x0a && 1529 arr[6] === 0x1a && 1530 arr[7] === 0x0a 1531 ) { 1532 return 'image/png'; 1533 } 1534 1535 // GIF: 47 49 46 38 (GIF8) 1536 if ( 1537 arr[0] === 0x47 && 1538 arr[1] === 0x49 && 1539 arr[2] === 0x46 && 1540 arr[3] === 0x38 1541 ) { 1542 return 'image/gif'; 1543 } 1544 1545 // WebP: RIFF....WEBP 1546 if ( 1547 arr[0] === 0x52 && 1548 arr[1] === 0x49 && 1549 arr[2] === 0x46 && 1550 arr[3] === 0x46 && 1551 arr[8] === 0x57 && 1552 arr[9] === 0x45 && 1553 arr[10] === 0x42 && 1554 arr[11] === 0x50 1555 ) { 1556 return 'image/webp'; 1557 } 1558 1559 // ISOBMFF container: ....ftyp at byte 4 (MP4, AVIF, HEIC, etc.) 1560 if ( 1561 arr[4] === 0x66 && 1562 arr[5] === 0x74 && 1563 arr[6] === 0x79 && 1564 arr[7] === 0x70 1565 ) { 1566 // Check brand code at bytes 8-11 1567 const brand = String.fromCharCode(arr[8], arr[9], arr[10], arr[11]); 1568 if (brand === 'avif') { 1569 return 'image/avif'; 1570 } 1571 if (brand === 'heic' || brand === 'heix' || brand === 'mif1') { 1572 return 'image/heic'; 1573 } 1574 return 'video/mp4'; 1575 } 1576 1577 return null; 1578} 1579 1580/** 1581 * Find all blob CID references in a record 1582 * @param {*} obj - Record value to scan 1583 * @param {string[]} refs - Accumulator array (internal) 1584 * @returns {string[]} Array of blob CID strings 1585 */ 1586export function findBlobRefs(obj, refs = []) { 1587 if (!obj || typeof obj !== 'object') { 1588 return refs; 1589 } 1590 1591 // Check if this object is a blob ref 1592 if (obj.$type === 'blob' && obj.ref?.$link) { 1593 refs.push(obj.ref.$link); 1594 } 1595 1596 // Recurse into arrays and objects 1597 if (Array.isArray(obj)) { 1598 for (const item of obj) { 1599 findBlobRefs(item, refs); 1600 } 1601 } else { 1602 for (const value of Object.values(obj)) { 1603 findBlobRefs(value, refs); 1604 } 1605 } 1606 1607 return refs; 1608} 1609 1610// ╔══════════════════════════════════════════════════════════════════════════════╗ 1611// ║ RELAY NOTIFICATION ║ 1612// ║ Notify relays to crawl after repo updates ║ 1613// ╚══════════════════════════════════════════════════════════════════════════════╝ 1614 1615/** 1616 * Notify relays to come crawl us after writes (like official PDS) 1617 * @param {{ RELAY_HOST?: string }} env 1618 * @param {string} hostname 1619 */ 1620async function notifyCrawlers(env, hostname) { 1621 const now = Date.now(); 1622 if (now - lastCrawlNotify < CRAWL_NOTIFY_THRESHOLD) { 1623 return; // Throttle notifications 1624 } 1625 1626 const relayHost = env.RELAY_HOST; 1627 if (!relayHost) return; 1628 1629 lastCrawlNotify = now; 1630 1631 // Fire and forget - don't block writes on relay notification 1632 fetch(`${relayHost}/xrpc/com.atproto.sync.requestCrawl`, { 1633 method: 'POST', 1634 headers: { 'Content-Type': 'application/json' }, 1635 body: JSON.stringify({ hostname }), 1636 }).catch(() => { 1637 // Silently ignore relay notification failures 1638 }); 1639} 1640 1641// ╔══════════════════════════════════════════════════════════════════════════════╗ 1642// ║ ROUTING ║ 1643// ║ XRPC endpoint definitions ║ 1644// ╚══════════════════════════════════════════════════════════════════════════════╝ 1645 1646/** 1647 * Route handler function type 1648 * @callback RouteHandler 1649 * @param {PersonalDataServer} pds - PDS instance 1650 * @param {Request} request - HTTP request 1651 * @param {URL} url - Parsed URL 1652 * @returns {Response | Promise<Response>} HTTP response 1653 */ 1654 1655/** 1656 * Route definition for the PDS router 1657 * @typedef {Object} Route 1658 * @property {string} [method] - Required HTTP method (default: any) 1659 * @property {RouteHandler} handler - Handler function 1660 */ 1661 1662/** @type {Record<string, Route>} */ 1663const pdsRoutes = { 1664 '/.well-known/atproto-did': { 1665 handler: (pds, _req, _url) => pds.handleAtprotoDid(), 1666 }, 1667 '/init': { 1668 method: 'POST', 1669 handler: (pds, req, _url) => pds.handleInit(req), 1670 }, 1671 '/status': { 1672 handler: (pds, _req, _url) => pds.handleStatus(), 1673 }, 1674 '/forward-event': { 1675 handler: (pds, req, _url) => pds.handleForwardEvent(req), 1676 }, 1677 '/register-did': { 1678 handler: (pds, req, _url) => pds.handleRegisterDid(req), 1679 }, 1680 '/get-registered-dids': { 1681 handler: (pds, _req, _url) => pds.handleGetRegisteredDids(), 1682 }, 1683 '/register-handle': { 1684 method: 'POST', 1685 handler: (pds, req, _url) => pds.handleRegisterHandle(req), 1686 }, 1687 '/resolve-handle': { 1688 handler: (pds, _req, url) => pds.handleResolveHandle(url), 1689 }, 1690 '/repo-info': { 1691 handler: (pds, _req, _url) => pds.handleRepoInfo(), 1692 }, 1693 '/oauth-public-key': { 1694 handler: async (pds) => Response.json(await pds.getPublicKeyJwk()), 1695 }, 1696 '/check-dpop-jti': { 1697 method: 'POST', 1698 handler: async (pds, req) => { 1699 const { jti, iat } = await req.json(); 1700 const fresh = pds.checkAndStoreDpopJti(jti, iat); 1701 return Response.json({ fresh }); 1702 }, 1703 }, 1704 '/xrpc/com.atproto.server.describeServer': { 1705 handler: (pds, req, _url) => pds.handleDescribeServer(req), 1706 }, 1707 '/xrpc/com.atproto.server.createSession': { 1708 method: 'POST', 1709 handler: (pds, req, _url) => pds.handleCreateSession(req), 1710 }, 1711 '/xrpc/com.atproto.server.getSession': { 1712 handler: (pds, req, _url) => pds.handleGetSession(req), 1713 }, 1714 '/xrpc/com.atproto.server.refreshSession': { 1715 method: 'POST', 1716 handler: (pds, req, _url) => pds.handleRefreshSession(req), 1717 }, 1718 '/xrpc/app.bsky.actor.getPreferences': { 1719 handler: (pds, req, _url) => pds.handleGetPreferences(req), 1720 }, 1721 '/xrpc/app.bsky.actor.putPreferences': { 1722 method: 'POST', 1723 handler: (pds, req, _url) => pds.handlePutPreferences(req), 1724 }, 1725 '/xrpc/com.atproto.sync.listRepos': { 1726 handler: (pds, _req, _url) => pds.handleListRepos(), 1727 }, 1728 '/xrpc/com.atproto.repo.createRecord': { 1729 method: 'POST', 1730 handler: (pds, req, _url) => pds.handleCreateRecord(req), 1731 }, 1732 '/xrpc/com.atproto.repo.deleteRecord': { 1733 method: 'POST', 1734 handler: (pds, req, _url) => pds.handleDeleteRecord(req), 1735 }, 1736 '/xrpc/com.atproto.repo.putRecord': { 1737 method: 'POST', 1738 handler: (pds, req, _url) => pds.handlePutRecord(req), 1739 }, 1740 '/xrpc/com.atproto.repo.applyWrites': { 1741 method: 'POST', 1742 handler: (pds, req, _url) => pds.handleApplyWrites(req), 1743 }, 1744 '/xrpc/com.atproto.repo.getRecord': { 1745 handler: (pds, _req, url) => pds.handleGetRecord(url), 1746 }, 1747 '/xrpc/com.atproto.repo.describeRepo': { 1748 handler: (pds, _req, _url) => pds.handleDescribeRepo(), 1749 }, 1750 '/xrpc/com.atproto.repo.listRecords': { 1751 handler: (pds, _req, url) => pds.handleListRecords(url), 1752 }, 1753 '/xrpc/com.atproto.repo.uploadBlob': { 1754 method: 'POST', 1755 handler: (pds, req, _url) => pds.handleUploadBlob(req), 1756 }, 1757 '/xrpc/com.atproto.sync.getLatestCommit': { 1758 handler: (pds, _req, _url) => pds.handleGetLatestCommit(), 1759 }, 1760 '/xrpc/com.atproto.sync.getRepoStatus': { 1761 handler: (pds, _req, _url) => pds.handleGetRepoStatus(), 1762 }, 1763 '/xrpc/com.atproto.sync.getRepo': { 1764 handler: (pds, _req, _url) => pds.handleGetRepo(), 1765 }, 1766 '/xrpc/com.atproto.sync.getRecord': { 1767 handler: (pds, _req, url) => pds.handleSyncGetRecord(url), 1768 }, 1769 '/xrpc/com.atproto.sync.getBlob': { 1770 handler: (pds, _req, url) => pds.handleGetBlob(url), 1771 }, 1772 '/xrpc/com.atproto.sync.listBlobs': { 1773 handler: (pds, _req, url) => pds.handleListBlobs(url), 1774 }, 1775 '/xrpc/com.atproto.sync.subscribeRepos': { 1776 handler: (pds, req, url) => pds.handleSubscribeRepos(req, url), 1777 }, 1778 // OAuth endpoints 1779 '/.well-known/oauth-authorization-server': { 1780 handler: (pds, _req, url) => pds.handleOAuthAuthServerMetadata(url), 1781 }, 1782 '/.well-known/oauth-protected-resource': { 1783 handler: (pds, _req, url) => pds.handleOAuthProtectedResource(url), 1784 }, 1785 '/oauth/jwks': { 1786 handler: (pds, _req, _url) => pds.handleOAuthJwks(), 1787 }, 1788 '/oauth/par': { 1789 method: 'POST', 1790 handler: (pds, req, url) => pds.handleOAuthPar(req, url), 1791 }, 1792 '/oauth/authorize': { 1793 handler: (pds, req, url) => pds.handleOAuthAuthorize(req, url), 1794 }, 1795 '/oauth/token': { 1796 method: 'POST', 1797 handler: (pds, req, url) => pds.handleOAuthToken(req, url), 1798 }, 1799 '/oauth/revoke': { 1800 method: 'POST', 1801 handler: (pds, req, url) => pds.handleOAuthRevoke(req, url), 1802 }, 1803}; 1804 1805// ╔══════════════════════════════════════════════════════════════════════════════╗ 1806// ║ PERSONAL DATA SERVER ║ 1807// ║ Durable Object class implementing ATProto PDS ║ 1808// ╚══════════════════════════════════════════════════════════════════════════════╝ 1809 1810export class PersonalDataServer { 1811 /** @type {string | undefined} */ 1812 _did; 1813 1814 /** 1815 * @param {DurableObjectState} state 1816 * @param {Env} env 1817 */ 1818 constructor(state, env) { 1819 this.state = state; 1820 this.sql = state.storage.sql; 1821 this.env = env; 1822 1823 // Initialize schema 1824 this.sql.exec(` 1825 CREATE TABLE IF NOT EXISTS blocks ( 1826 cid TEXT PRIMARY KEY, 1827 data BLOB NOT NULL 1828 ); 1829 1830 CREATE TABLE IF NOT EXISTS records ( 1831 uri TEXT PRIMARY KEY, 1832 cid TEXT NOT NULL, 1833 collection TEXT NOT NULL, 1834 rkey TEXT NOT NULL, 1835 value BLOB NOT NULL 1836 ); 1837 1838 CREATE TABLE IF NOT EXISTS commits ( 1839 seq INTEGER PRIMARY KEY AUTOINCREMENT, 1840 cid TEXT NOT NULL, 1841 rev TEXT NOT NULL, 1842 prev TEXT 1843 ); 1844 1845 CREATE TABLE IF NOT EXISTS seq_events ( 1846 seq INTEGER PRIMARY KEY AUTOINCREMENT, 1847 did TEXT NOT NULL, 1848 commit_cid TEXT NOT NULL, 1849 evt BLOB NOT NULL 1850 ); 1851 1852 CREATE TABLE IF NOT EXISTS blobs ( 1853 cid TEXT PRIMARY KEY, 1854 mime_type TEXT NOT NULL, 1855 size INTEGER NOT NULL, 1856 created_at TEXT NOT NULL 1857 ); 1858 1859 CREATE TABLE IF NOT EXISTS record_blobs ( 1860 blob_cid TEXT NOT NULL, 1861 record_uri TEXT NOT NULL, 1862 PRIMARY KEY (blob_cid, record_uri) 1863 ); 1864 1865 CREATE INDEX IF NOT EXISTS idx_record_blobs_record_uri ON record_blobs(record_uri); 1866 1867 CREATE INDEX IF NOT EXISTS idx_records_collection ON records(collection, rkey); 1868 1869 CREATE TABLE IF NOT EXISTS authorization_requests ( 1870 id TEXT PRIMARY KEY, 1871 client_id TEXT NOT NULL, 1872 client_metadata TEXT NOT NULL, 1873 parameters TEXT NOT NULL, 1874 code TEXT, 1875 code_challenge TEXT, 1876 code_challenge_method TEXT, 1877 dpop_jkt TEXT, 1878 did TEXT, 1879 expires_at TEXT NOT NULL, 1880 created_at TEXT NOT NULL 1881 ); 1882 1883 CREATE INDEX IF NOT EXISTS idx_authorization_requests_code 1884 ON authorization_requests(code) WHERE code IS NOT NULL; 1885 1886 CREATE TABLE IF NOT EXISTS tokens ( 1887 id INTEGER PRIMARY KEY AUTOINCREMENT, 1888 token_id TEXT UNIQUE NOT NULL, 1889 did TEXT NOT NULL, 1890 client_id TEXT NOT NULL, 1891 scope TEXT, 1892 dpop_jkt TEXT, 1893 expires_at TEXT NOT NULL, 1894 refresh_token TEXT UNIQUE, 1895 created_at TEXT NOT NULL, 1896 updated_at TEXT NOT NULL 1897 ); 1898 1899 CREATE INDEX IF NOT EXISTS idx_tokens_did ON tokens(did); 1900 1901 CREATE TABLE IF NOT EXISTS dpop_jtis ( 1902 jti TEXT PRIMARY KEY, 1903 expires_at TEXT NOT NULL 1904 ); 1905 1906 CREATE INDEX IF NOT EXISTS idx_dpop_jtis_expires ON dpop_jtis(expires_at); 1907 `); 1908 } 1909 1910 /** 1911 * @param {string} did 1912 * @param {string} privateKeyHex 1913 * @param {string|null} [handle] 1914 */ 1915 async initIdentity(did, privateKeyHex, handle = null) { 1916 await this.state.storage.put('did', did); 1917 await this.state.storage.put('privateKey', privateKeyHex); 1918 if (handle) { 1919 await this.state.storage.put('handle', handle); 1920 } 1921 1922 // Schedule blob cleanup alarm (runs daily) 1923 const currentAlarm = await this.state.storage.getAlarm(); 1924 if (!currentAlarm) { 1925 await this.state.storage.setAlarm(Date.now() + 24 * 60 * 60 * 1000); 1926 } 1927 } 1928 1929 async getDid() { 1930 if (!this._did) { 1931 this._did = await this.state.storage.get('did'); 1932 } 1933 return this._did; 1934 } 1935 1936 async getHandle() { 1937 return this.state.storage.get('handle'); 1938 } 1939 1940 async getSigningKey() { 1941 const hex = await this.state.storage.get('privateKey'); 1942 if (!hex) return null; 1943 return importPrivateKey(hexToBytes(/** @type {string} */ (hex))); 1944 } 1945 1946 /** 1947 * Collect MST node blocks for a given root CID 1948 * @param {string} rootCidStr 1949 * @returns {Array<{cid: string, data: Uint8Array}>} 1950 */ 1951 collectMstBlocks(rootCidStr) { 1952 /** @type {Array<{cid: string, data: Uint8Array}>} */ 1953 const blocks = []; 1954 const visited = new Set(); 1955 1956 /** @param {string} cidStr */ 1957 const collect = (cidStr) => { 1958 if (visited.has(cidStr)) return; 1959 visited.add(cidStr); 1960 1961 const rows = /** @type {BlockRow[]} */ ( 1962 this.sql.exec(`SELECT data FROM blocks WHERE cid = ?`, cidStr).toArray() 1963 ); 1964 if (rows.length === 0) return; 1965 1966 const data = new Uint8Array(rows[0].data); 1967 blocks.push({ cid: cidStr, data }); // Keep as string, buildCarFile will convert 1968 1969 // Decode and follow child CIDs (MST nodes have 'l' and 'e' with 't' subtrees) 1970 try { 1971 const node = cborDecode(data); 1972 if (node.l) collect(cidToString(node.l)); 1973 if (node.e) { 1974 for (const entry of node.e) { 1975 if (entry.t) collect(cidToString(entry.t)); 1976 } 1977 } 1978 } catch (_e) { 1979 // Not an MST node, ignore 1980 } 1981 }; 1982 1983 collect(rootCidStr); 1984 return blocks; 1985 } 1986 1987 /** 1988 * @param {string} collection 1989 * @param {Record<string, *>} record 1990 * @param {string|null} [rkey] 1991 * @returns {Promise<{uri: string, cid: string, commit: string}>} 1992 */ 1993 async createRecord(collection, record, rkey = null) { 1994 const did = await this.getDid(); 1995 if (!did) throw new Error('PDS not initialized'); 1996 1997 rkey = rkey || createTid(); 1998 const uri = `at://${did}/${collection}/${rkey}`; 1999 2000 // Encode and hash record (must use DAG-CBOR for proper key ordering) 2001 const recordBytes = cborEncodeDagCbor(record); 2002 const recordCid = await createCid(recordBytes); 2003 const recordCidStr = cidToString(recordCid); 2004 2005 // Store block 2006 this.sql.exec( 2007 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, 2008 recordCidStr, 2009 recordBytes, 2010 ); 2011 2012 // Store record index 2013 this.sql.exec( 2014 `INSERT OR REPLACE INTO records (uri, cid, collection, rkey, value) VALUES (?, ?, ?, ?, ?)`, 2015 uri, 2016 recordCidStr, 2017 collection, 2018 rkey, 2019 recordBytes, 2020 ); 2021 2022 // Associate blobs with this record (delete old associations first for updates) 2023 this.sql.exec('DELETE FROM record_blobs WHERE record_uri = ?', uri); 2024 2025 const blobRefs = findBlobRefs(record); 2026 for (const blobCid of blobRefs) { 2027 // Verify blob exists 2028 const blobExists = this.sql 2029 .exec('SELECT cid FROM blobs WHERE cid = ?', blobCid) 2030 .toArray(); 2031 2032 if (blobExists.length === 0) { 2033 throw new Error(`BlobNotFound: ${blobCid}`); 2034 } 2035 2036 // Create association 2037 this.sql.exec( 2038 'INSERT INTO record_blobs (blob_cid, record_uri) VALUES (?, ?)', 2039 blobCid, 2040 uri, 2041 ); 2042 } 2043 2044 // Rebuild MST 2045 const mst = new MST(this.sql); 2046 const dataRoot = await mst.computeRoot(); 2047 2048 // Get previous commit 2049 const prevCommits = this.sql 2050 .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`) 2051 .toArray(); 2052 const prevCommit = prevCommits.length > 0 ? prevCommits[0] : null; 2053 2054 // Create commit 2055 const rev = createTid(); 2056 // Build commit with CIDs wrapped in CID class (for dag-cbor tag 42 encoding) 2057 const commit = { 2058 did, 2059 version: 3, 2060 data: new CID(cidToBytes(/** @type {string} */ (dataRoot))), // CID wrapped for explicit encoding 2061 rev, 2062 prev: prevCommit?.cid 2063 ? new CID(cidToBytes(/** @type {string} */ (prevCommit.cid))) 2064 : null, 2065 }; 2066 2067 // Sign commit (using dag-cbor encoder for CIDs) 2068 const commitBytes = cborEncodeDagCbor(commit); 2069 const signingKey = await this.getSigningKey(); 2070 if (!signingKey) throw new Error('No signing key'); 2071 const sig = await sign(signingKey, commitBytes); 2072 2073 const signedCommit = { ...commit, sig }; 2074 const signedBytes = cborEncodeDagCbor(signedCommit); 2075 const commitCid = await createCid(signedBytes); 2076 const commitCidStr = cidToString(commitCid); 2077 2078 // Store commit block 2079 this.sql.exec( 2080 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, 2081 commitCidStr, 2082 signedBytes, 2083 ); 2084 2085 // Store commit reference 2086 this.sql.exec( 2087 `INSERT INTO commits (cid, rev, prev) VALUES (?, ?, ?)`, 2088 commitCidStr, 2089 rev, 2090 prevCommit?.cid || null, 2091 ); 2092 2093 // Update head and rev for listRepos 2094 await this.state.storage.put('head', commitCidStr); 2095 await this.state.storage.put('rev', rev); 2096 2097 // Collect blocks for the event (record + commit + MST nodes) 2098 // Build a mini CAR with just the new blocks - use string CIDs 2099 const newBlocks = []; 2100 // Add record block 2101 newBlocks.push({ cid: recordCidStr, data: recordBytes }); 2102 // Add commit block 2103 newBlocks.push({ cid: commitCidStr, data: signedBytes }); 2104 // Add MST node blocks (get all blocks referenced by commit.data) 2105 const mstBlocks = this.collectMstBlocks(/** @type {string} */ (dataRoot)); 2106 newBlocks.push(...mstBlocks); 2107 2108 // Sequence event with blocks - store complete event data including rev and time 2109 // blocks must be a full CAR file with header (roots = [commitCid]) 2110 const eventTime = new Date().toISOString(); 2111 const evt = cborEncode({ 2112 ops: [ 2113 { action: 'create', path: `${collection}/${rkey}`, cid: recordCidStr }, 2114 ], 2115 blocks: buildCarFile(commitCidStr, newBlocks), // Full CAR with header 2116 rev, // Store the actual commit revision 2117 time: eventTime, // Store the actual event time 2118 }); 2119 this.sql.exec( 2120 `INSERT INTO seq_events (did, commit_cid, evt) VALUES (?, ?, ?)`, 2121 did, 2122 commitCidStr, 2123 evt, 2124 ); 2125 2126 // Broadcast to subscribers (both local and via default DO for relay) 2127 const evtRows = /** @type {SeqEventRow[]} */ ( 2128 this.sql 2129 .exec(`SELECT * FROM seq_events ORDER BY seq DESC LIMIT 1`) 2130 .toArray() 2131 ); 2132 if (evtRows.length > 0) { 2133 this.broadcastEvent(evtRows[0]); 2134 // Also forward to default DO for relay subscribers 2135 if (this.env?.PDS) { 2136 const defaultId = this.env.PDS.idFromName('default'); 2137 const defaultPds = this.env.PDS.get(defaultId); 2138 // Convert ArrayBuffer to array for JSON serialization 2139 const row = evtRows[0]; 2140 const evtArray = Array.from(new Uint8Array(row.evt)); 2141 // Fire and forget but log errors 2142 defaultPds 2143 .fetch( 2144 new Request('http://internal/forward-event', { 2145 method: 'POST', 2146 body: JSON.stringify({ ...row, evt: evtArray }), 2147 }), 2148 ) 2149 .catch(() => {}); // Ignore forward errors 2150 } 2151 } 2152 2153 return { uri, cid: recordCidStr, commit: commitCidStr }; 2154 } 2155 2156 /** 2157 * @param {string} collection 2158 * @param {string} rkey 2159 */ 2160 async deleteRecord(collection, rkey) { 2161 const did = await this.getDid(); 2162 if (!did) throw new Error('PDS not initialized'); 2163 2164 const uri = `at://${did}/${collection}/${rkey}`; 2165 2166 // Check if record exists 2167 const existing = this.sql 2168 .exec(`SELECT cid FROM records WHERE uri = ?`, uri) 2169 .toArray(); 2170 if (existing.length === 0) { 2171 return { error: 'RecordNotFound', message: 'record not found' }; 2172 } 2173 2174 // Delete from records table 2175 this.sql.exec(`DELETE FROM records WHERE uri = ?`, uri); 2176 2177 // Get blobs associated with this record 2178 const associatedBlobs = this.sql 2179 .exec('SELECT blob_cid FROM record_blobs WHERE record_uri = ?', uri) 2180 .toArray(); 2181 2182 // Remove associations for this record 2183 this.sql.exec('DELETE FROM record_blobs WHERE record_uri = ?', uri); 2184 2185 // Check each blob for orphan status and delete if unreferenced 2186 for (const { blob_cid } of associatedBlobs) { 2187 const stillReferenced = this.sql 2188 .exec('SELECT 1 FROM record_blobs WHERE blob_cid = ? LIMIT 1', blob_cid) 2189 .toArray(); 2190 2191 if (stillReferenced.length === 0) { 2192 // Blob is orphaned, delete from R2 and database 2193 await this.env?.BLOBS?.delete(`${did}/${blob_cid}`); 2194 this.sql.exec('DELETE FROM blobs WHERE cid = ?', blob_cid); 2195 } 2196 } 2197 2198 // Rebuild MST 2199 const mst = new MST(this.sql); 2200 const dataRoot = await mst.computeRoot(); 2201 2202 // Get previous commit 2203 const prevCommits = this.sql 2204 .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`) 2205 .toArray(); 2206 const prevCommit = prevCommits.length > 0 ? prevCommits[0] : null; 2207 2208 // Create commit 2209 const rev = createTid(); 2210 const commit = { 2211 did, 2212 version: 3, 2213 data: dataRoot 2214 ? new CID(cidToBytes(/** @type {string} */ (dataRoot))) 2215 : null, 2216 rev, 2217 prev: prevCommit?.cid 2218 ? new CID(cidToBytes(/** @type {string} */ (prevCommit.cid))) 2219 : null, 2220 }; 2221 2222 // Sign commit 2223 const commitBytes = cborEncodeDagCbor(commit); 2224 const signingKey = await this.getSigningKey(); 2225 if (!signingKey) throw new Error('No signing key'); 2226 const sig = await sign(signingKey, commitBytes); 2227 2228 const signedCommit = { ...commit, sig }; 2229 const signedBytes = cborEncodeDagCbor(signedCommit); 2230 const commitCid = await createCid(signedBytes); 2231 const commitCidStr = cidToString(commitCid); 2232 2233 // Store commit block 2234 this.sql.exec( 2235 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, 2236 commitCidStr, 2237 signedBytes, 2238 ); 2239 2240 // Store commit reference 2241 this.sql.exec( 2242 `INSERT INTO commits (cid, rev, prev) VALUES (?, ?, ?)`, 2243 commitCidStr, 2244 rev, 2245 prevCommit?.cid || null, 2246 ); 2247 2248 // Update head and rev 2249 await this.state.storage.put('head', commitCidStr); 2250 await this.state.storage.put('rev', rev); 2251 2252 // Collect blocks for the event (commit + MST nodes, no record block) 2253 const newBlocks = []; 2254 newBlocks.push({ cid: commitCidStr, data: signedBytes }); 2255 if (dataRoot) { 2256 const mstBlocks = this.collectMstBlocks(/** @type {string} */ (dataRoot)); 2257 newBlocks.push(...mstBlocks); 2258 } 2259 2260 // Sequence event with delete action 2261 const eventTime = new Date().toISOString(); 2262 const evt = cborEncode({ 2263 ops: [{ action: 'delete', path: `${collection}/${rkey}`, cid: null }], 2264 blocks: buildCarFile(commitCidStr, newBlocks), 2265 rev, 2266 time: eventTime, 2267 }); 2268 this.sql.exec( 2269 `INSERT INTO seq_events (did, commit_cid, evt) VALUES (?, ?, ?)`, 2270 did, 2271 commitCidStr, 2272 evt, 2273 ); 2274 2275 // Broadcast to subscribers 2276 const evtRows = /** @type {SeqEventRow[]} */ ( 2277 this.sql 2278 .exec(`SELECT * FROM seq_events ORDER BY seq DESC LIMIT 1`) 2279 .toArray() 2280 ); 2281 if (evtRows.length > 0) { 2282 this.broadcastEvent(evtRows[0]); 2283 // Forward to default DO for relay subscribers 2284 if (this.env?.PDS) { 2285 const defaultId = this.env.PDS.idFromName('default'); 2286 const defaultPds = this.env.PDS.get(defaultId); 2287 const row = evtRows[0]; 2288 const evtArray = Array.from(new Uint8Array(row.evt)); 2289 defaultPds 2290 .fetch( 2291 new Request('http://internal/forward-event', { 2292 method: 'POST', 2293 body: JSON.stringify({ ...row, evt: evtArray }), 2294 }), 2295 ) 2296 .catch(() => {}); // Ignore forward errors 2297 } 2298 } 2299 2300 return { ok: true }; 2301 } 2302 2303 /** 2304 * @param {SeqEventRow} evt 2305 * @returns {Uint8Array} 2306 */ 2307 formatEvent(evt) { 2308 // AT Protocol frame format: header + body 2309 // Use DAG-CBOR encoding for body (CIDs need tag 42 + 0x00 prefix) 2310 const header = cborEncode({ op: 1, t: '#commit' }); 2311 2312 // Decode stored event to get ops, blocks, rev, and time 2313 const evtData = cborDecode(new Uint8Array(evt.evt)); 2314 /** @type {Array<{action: string, path: string, cid: CID|null}>} */ 2315 const ops = evtData.ops.map( 2316 (/** @type {{action: string, path: string, cid?: string}} */ op) => ({ 2317 ...op, 2318 cid: op.cid ? new CID(cidToBytes(op.cid)) : null, // Wrap in CID class for tag 42 encoding 2319 }), 2320 ); 2321 // Get blocks from stored event (already in CAR format) 2322 const blocks = evtData.blocks || new Uint8Array(0); 2323 2324 const body = cborEncodeDagCbor({ 2325 seq: evt.seq, 2326 rebase: false, 2327 tooBig: false, 2328 repo: evt.did, 2329 commit: new CID(cidToBytes(evt.commit_cid)), // Wrap in CID class for tag 42 encoding 2330 rev: evtData.rev, // Use stored rev from commit creation 2331 since: null, 2332 blocks: blocks instanceof Uint8Array ? blocks : new Uint8Array(blocks), 2333 ops, 2334 blobs: [], 2335 time: evtData.time, // Use stored time from event creation 2336 }); 2337 2338 // Concatenate header + body 2339 const frame = new Uint8Array(header.length + body.length); 2340 frame.set(header); 2341 frame.set(body, header.length); 2342 return frame; 2343 } 2344 2345 /** 2346 * @param {WebSocket} ws 2347 * @param {string | ArrayBuffer} message 2348 */ 2349 async webSocketMessage(ws, message) { 2350 // Handle ping 2351 if (message === 'ping') ws.send('pong'); 2352 } 2353 2354 /** 2355 * @param {WebSocket} _ws 2356 * @param {number} _code 2357 * @param {string} _reason 2358 */ 2359 async webSocketClose(_ws, _code, _reason) { 2360 // Durable Object will hibernate when no connections remain 2361 } 2362 2363 /** 2364 * @param {SeqEventRow} evt 2365 */ 2366 broadcastEvent(evt) { 2367 const frame = this.formatEvent(evt); 2368 for (const ws of this.state.getWebSockets()) { 2369 try { 2370 ws.send(frame); 2371 } catch (_e) { 2372 // Client disconnected 2373 } 2374 } 2375 } 2376 2377 async handleAtprotoDid() { 2378 let did = await this.getDid(); 2379 if (!did) { 2380 /** @type {string[]} */ 2381 const registeredDids = 2382 (await this.state.storage.get('registeredDids')) || []; 2383 did = registeredDids[0]; 2384 } 2385 if (!did) { 2386 return new Response('User not found', { status: 404 }); 2387 } 2388 return new Response(/** @type {string} */ (did), { 2389 headers: { 'Content-Type': 'text/plain' }, 2390 }); 2391 } 2392 2393 /** @param {Request} request */ 2394 async handleInit(request) { 2395 const body = await request.json(); 2396 if (!body.did || !body.privateKey) { 2397 return errorResponse('InvalidRequest', 'missing did or privateKey', 400); 2398 } 2399 await this.initIdentity(body.did, body.privateKey, body.handle || null); 2400 return Response.json({ 2401 ok: true, 2402 did: body.did, 2403 handle: body.handle || null, 2404 }); 2405 } 2406 2407 async handleStatus() { 2408 const did = await this.getDid(); 2409 return Response.json({ initialized: !!did, did: did || null }); 2410 } 2411 2412 /** @param {Request} request */ 2413 async handleForwardEvent(request) { 2414 const evt = await request.json(); 2415 const numSockets = [...this.state.getWebSockets()].length; 2416 this.broadcastEvent({ 2417 seq: evt.seq, 2418 did: evt.did, 2419 commit_cid: evt.commit_cid, 2420 evt: new Uint8Array(Object.values(evt.evt)), 2421 }); 2422 return Response.json({ ok: true, sockets: numSockets }); 2423 } 2424 2425 /** @param {Request} request */ 2426 async handleRegisterDid(request) { 2427 const body = await request.json(); 2428 /** @type {string[]} */ 2429 const registeredDids = 2430 (await this.state.storage.get('registeredDids')) || []; 2431 if (!registeredDids.includes(body.did)) { 2432 registeredDids.push(body.did); 2433 await this.state.storage.put('registeredDids', registeredDids); 2434 } 2435 return Response.json({ ok: true }); 2436 } 2437 2438 async handleGetRegisteredDids() { 2439 const registeredDids = 2440 (await this.state.storage.get('registeredDids')) || []; 2441 return Response.json({ dids: registeredDids }); 2442 } 2443 2444 /** @param {Request} request */ 2445 async handleRegisterHandle(request) { 2446 const body = await request.json(); 2447 const { handle, did } = body; 2448 if (!handle || !did) { 2449 return errorResponse('InvalidRequest', 'missing handle or did', 400); 2450 } 2451 /** @type {Record<string, string>} */ 2452 const handleMap = (await this.state.storage.get('handleMap')) || {}; 2453 handleMap[handle] = did; 2454 await this.state.storage.put('handleMap', handleMap); 2455 return Response.json({ ok: true }); 2456 } 2457 2458 /** @param {URL} url */ 2459 async handleResolveHandle(url) { 2460 const handle = url.searchParams.get('handle'); 2461 if (!handle) { 2462 return errorResponse('InvalidRequest', 'missing handle', 400); 2463 } 2464 /** @type {Record<string, string>} */ 2465 const handleMap = (await this.state.storage.get('handleMap')) || {}; 2466 const did = handleMap[handle]; 2467 if (!did) { 2468 return errorResponse('NotFound', 'handle not found', 404); 2469 } 2470 return Response.json({ did }); 2471 } 2472 2473 async handleRepoInfo() { 2474 const head = await this.state.storage.get('head'); 2475 const rev = await this.state.storage.get('rev'); 2476 return Response.json({ head: head || null, rev: rev || null }); 2477 } 2478 2479 /** @param {Request} request */ 2480 handleDescribeServer(request) { 2481 const hostname = request.headers.get('x-hostname') || 'localhost'; 2482 return Response.json({ 2483 did: `did:web:${hostname}`, 2484 availableUserDomains: [`.${hostname}`], 2485 inviteCodeRequired: false, 2486 phoneVerificationRequired: false, 2487 links: {}, 2488 contact: {}, 2489 }); 2490 } 2491 2492 /** @param {Request} request */ 2493 async handleCreateSession(request) { 2494 const body = await request.json(); 2495 const { identifier, password } = body; 2496 2497 if (!identifier || !password) { 2498 return errorResponse( 2499 'InvalidRequest', 2500 'Missing identifier or password', 2501 400, 2502 ); 2503 } 2504 2505 // Check password against env var (timing-safe comparison) 2506 const expectedPassword = this.env?.PDS_PASSWORD; 2507 if ( 2508 !expectedPassword || 2509 !(await timingSafeEqual(password, expectedPassword)) 2510 ) { 2511 return errorResponse( 2512 'AuthRequired', 2513 'Invalid identifier or password', 2514 401, 2515 ); 2516 } 2517 2518 // Resolve identifier to DID 2519 let did = identifier; 2520 if (!identifier.startsWith('did:')) { 2521 // Try to resolve handle 2522 /** @type {Record<string, string>} */ 2523 const handleMap = (await this.state.storage.get('handleMap')) || {}; 2524 did = handleMap[identifier]; 2525 if (!did) { 2526 return errorResponse('InvalidRequest', 'Unable to resolve handle', 400); 2527 } 2528 } 2529 2530 // Get handle for response 2531 const handle = await this.getHandleForDid(did); 2532 2533 // Create tokens 2534 const jwtSecret = this.env?.JWT_SECRET; 2535 if (!jwtSecret) { 2536 return errorResponse( 2537 'InternalServerError', 2538 'Server not configured for authentication', 2539 500, 2540 ); 2541 } 2542 2543 const accessJwt = await createAccessJwt(did, jwtSecret); 2544 const refreshJwt = await createRefreshJwt(did, jwtSecret); 2545 2546 return Response.json({ 2547 accessJwt, 2548 refreshJwt, 2549 handle: handle || did, 2550 did, 2551 active: true, 2552 }); 2553 } 2554 2555 /** @param {Request} request */ 2556 async handleGetSession(request) { 2557 const authHeader = request.headers.get('Authorization'); 2558 if (!authHeader) { 2559 return errorResponse( 2560 'AuthRequired', 2561 'Missing or invalid authorization header', 2562 401, 2563 ); 2564 } 2565 2566 let did; 2567 2568 // OAuth DPoP token 2569 if (authHeader.startsWith('DPoP ')) { 2570 try { 2571 const result = await verifyOAuthAccessToken( 2572 request, 2573 authHeader.slice(5), 2574 this, 2575 ); 2576 did = result.did; 2577 } catch (err) { 2578 const message = err instanceof Error ? err.message : String(err); 2579 return errorResponse('InvalidToken', message, 401); 2580 } 2581 } 2582 // Legacy Bearer token 2583 else if (authHeader.startsWith('Bearer ')) { 2584 const token = authHeader.slice(7); 2585 const jwtSecret = this.env?.JWT_SECRET; 2586 if (!jwtSecret) { 2587 return errorResponse( 2588 'InternalServerError', 2589 'Server not configured for authentication', 2590 500, 2591 ); 2592 } 2593 2594 try { 2595 const payload = await verifyAccessJwt(token, jwtSecret); 2596 did = payload.sub; 2597 } catch (err) { 2598 const message = err instanceof Error ? err.message : String(err); 2599 return errorResponse('InvalidToken', message, 401); 2600 } 2601 } else { 2602 return errorResponse( 2603 'AuthRequired', 2604 'Invalid authorization header format', 2605 401, 2606 ); 2607 } 2608 2609 const handle = await this.getHandleForDid(did); 2610 return Response.json({ 2611 handle: handle || did, 2612 did, 2613 active: true, 2614 }); 2615 } 2616 2617 /** @param {Request} request */ 2618 async handleRefreshSession(request) { 2619 const authHeader = request.headers.get('Authorization'); 2620 if (!authHeader || !authHeader.startsWith('Bearer ')) { 2621 return errorResponse( 2622 'AuthRequired', 2623 'Missing or invalid authorization header', 2624 401, 2625 ); 2626 } 2627 2628 const token = authHeader.slice(7); // Remove 'Bearer ' 2629 const jwtSecret = this.env?.JWT_SECRET; 2630 if (!jwtSecret) { 2631 return errorResponse( 2632 'InternalServerError', 2633 'Server not configured for authentication', 2634 500, 2635 ); 2636 } 2637 2638 try { 2639 const payload = await verifyRefreshJwt(token, jwtSecret); 2640 const did = payload.sub; 2641 const handle = await this.getHandleForDid(did); 2642 2643 // Issue fresh tokens 2644 const accessJwt = await createAccessJwt(did, jwtSecret); 2645 const refreshJwt = await createRefreshJwt(did, jwtSecret); 2646 2647 return Response.json({ 2648 accessJwt, 2649 refreshJwt, 2650 handle: handle || did, 2651 did, 2652 active: true, 2653 }); 2654 } catch (err) { 2655 const message = err instanceof Error ? err.message : String(err); 2656 if (message === 'Token expired') { 2657 return errorResponse('ExpiredToken', 'Refresh token has expired', 400); 2658 } 2659 return errorResponse('InvalidToken', message, 400); 2660 } 2661 } 2662 2663 /** @param {Request} _request */ 2664 async handleGetPreferences(_request) { 2665 // Preferences are stored per-user in their DO 2666 const preferences = (await this.state.storage.get('preferences')) || []; 2667 return Response.json({ preferences }); 2668 } 2669 2670 /** @param {Request} request */ 2671 async handlePutPreferences(request) { 2672 const body = await request.json(); 2673 const { preferences } = body; 2674 if (!Array.isArray(preferences)) { 2675 return errorResponse( 2676 'InvalidRequest', 2677 'preferences must be an array', 2678 400, 2679 ); 2680 } 2681 await this.state.storage.put('preferences', preferences); 2682 return Response.json({}); 2683 } 2684 2685 /** 2686 * @param {string} did 2687 * @returns {Promise<string|null>} 2688 */ 2689 async getHandleForDid(did) { 2690 // Check if this DID has a handle registered 2691 /** @type {Record<string, string>} */ 2692 const handleMap = (await this.state.storage.get('handleMap')) || {}; 2693 for (const [handle, mappedDid] of Object.entries(handleMap)) { 2694 if (mappedDid === did) return handle; 2695 } 2696 // Check instance's own handle 2697 const instanceDid = await this.getDid(); 2698 if (instanceDid === did) { 2699 return /** @type {string|null} */ ( 2700 await this.state.storage.get('handle') 2701 ); 2702 } 2703 return null; 2704 } 2705 2706 /** 2707 * @param {string} did 2708 * @param {string|null} lxm 2709 */ 2710 async createServiceAuthForAppView(did, lxm) { 2711 const signingKey = await this.getSigningKey(); 2712 if (!signingKey) throw new Error('No signing key available'); 2713 return createServiceJwt({ 2714 iss: did, 2715 aud: 'did:web:api.bsky.app', 2716 lxm, 2717 signingKey, 2718 }); 2719 } 2720 2721 /** 2722 * @param {Request} request 2723 * @param {string} userDid 2724 */ 2725 async handleAppViewProxy(request, userDid) { 2726 const url = new URL(request.url); 2727 // Extract lexicon method from path: /xrpc/app.bsky.actor.getPreferences -> app.bsky.actor.getPreferences 2728 const lxm = url.pathname.replace('/xrpc/', ''); 2729 2730 // Create service auth JWT 2731 const serviceJwt = await this.createServiceAuthForAppView(userDid, lxm); 2732 2733 // Build AppView URL 2734 const appViewUrl = new URL( 2735 url.pathname + url.search, 2736 'https://api.bsky.app', 2737 ); 2738 2739 // Forward request with service auth 2740 const headers = new Headers(); 2741 headers.set('Authorization', `Bearer ${serviceJwt}`); 2742 headers.set( 2743 'Content-Type', 2744 request.headers.get('Content-Type') || 'application/json', 2745 ); 2746 const acceptHeader = request.headers.get('Accept'); 2747 if (acceptHeader) { 2748 headers.set('Accept', acceptHeader); 2749 } 2750 const acceptLangHeader = request.headers.get('Accept-Language'); 2751 if (acceptLangHeader) { 2752 headers.set('Accept-Language', acceptLangHeader); 2753 } 2754 2755 const proxyReq = new Request(appViewUrl.toString(), { 2756 method: request.method, 2757 headers, 2758 body: 2759 request.method !== 'GET' && request.method !== 'HEAD' 2760 ? request.body 2761 : undefined, 2762 }); 2763 2764 try { 2765 const response = await fetch(proxyReq); 2766 // Return the response with CORS headers 2767 const responseHeaders = new Headers(response.headers); 2768 responseHeaders.set('Access-Control-Allow-Origin', '*'); 2769 return new Response(response.body, { 2770 status: response.status, 2771 statusText: response.statusText, 2772 headers: responseHeaders, 2773 }); 2774 } catch (err) { 2775 const message = err instanceof Error ? err.message : String(err); 2776 return errorResponse( 2777 'UpstreamFailure', 2778 `Failed to reach AppView: ${message}`, 2779 502, 2780 ); 2781 } 2782 } 2783 2784 async handleListRepos() { 2785 /** @type {string[]} */ 2786 const registeredDids = 2787 (await this.state.storage.get('registeredDids')) || []; 2788 const did = await this.getDid(); 2789 const repos = did 2790 ? [{ did, head: null, rev: null }] 2791 : registeredDids.map((/** @type {string} */ d) => ({ 2792 did: d, 2793 head: null, 2794 rev: null, 2795 })); 2796 return Response.json({ repos }); 2797 } 2798 2799 /** @param {Request} request */ 2800 async handleCreateRecord(request) { 2801 const body = await request.json(); 2802 if (!body.collection || !body.record) { 2803 return errorResponse( 2804 'InvalidRequest', 2805 'missing collection or record', 2806 400, 2807 ); 2808 } 2809 try { 2810 const result = await this.createRecord( 2811 body.collection, 2812 body.record, 2813 body.rkey, 2814 ); 2815 const head = await this.state.storage.get('head'); 2816 const rev = await this.state.storage.get('rev'); 2817 return Response.json({ 2818 uri: result.uri, 2819 cid: result.cid, 2820 commit: { cid: head, rev }, 2821 validationStatus: 'valid', 2822 }); 2823 } catch (err) { 2824 const message = err instanceof Error ? err.message : String(err); 2825 return errorResponse('InternalError', message, 500); 2826 } 2827 } 2828 2829 /** @param {Request} request */ 2830 async handleDeleteRecord(request) { 2831 const body = await request.json(); 2832 if (!body.collection || !body.rkey) { 2833 return errorResponse('InvalidRequest', 'missing collection or rkey', 400); 2834 } 2835 try { 2836 const result = await this.deleteRecord(body.collection, body.rkey); 2837 if (result.error) { 2838 return errorResponse(result.error, result.message, 404); 2839 } 2840 return Response.json({}); 2841 } catch (err) { 2842 const message = err instanceof Error ? err.message : String(err); 2843 return errorResponse('InternalError', message, 500); 2844 } 2845 } 2846 2847 /** @param {Request} request */ 2848 async handlePutRecord(request) { 2849 const body = await request.json(); 2850 if (!body.collection || !body.rkey || !body.record) { 2851 return errorResponse( 2852 'InvalidRequest', 2853 'missing collection, rkey, or record', 2854 400, 2855 ); 2856 } 2857 try { 2858 // putRecord is like createRecord but with a specific rkey (upsert) 2859 const result = await this.createRecord( 2860 body.collection, 2861 body.record, 2862 body.rkey, 2863 ); 2864 const head = await this.state.storage.get('head'); 2865 const rev = await this.state.storage.get('rev'); 2866 return Response.json({ 2867 uri: result.uri, 2868 cid: result.cid, 2869 commit: { cid: head, rev }, 2870 validationStatus: 'valid', 2871 }); 2872 } catch (err) { 2873 const message = err instanceof Error ? err.message : String(err); 2874 return errorResponse('InternalError', message, 500); 2875 } 2876 } 2877 2878 /** @param {Request} request */ 2879 async handleApplyWrites(request) { 2880 const body = await request.json(); 2881 if (!body.writes || !Array.isArray(body.writes)) { 2882 return errorResponse('InvalidRequest', 'missing writes array', 400); 2883 } 2884 try { 2885 const results = []; 2886 for (const write of body.writes) { 2887 const type = write.$type; 2888 if (type === 'com.atproto.repo.applyWrites#create') { 2889 const result = await this.createRecord( 2890 write.collection, 2891 write.value, 2892 write.rkey, 2893 ); 2894 results.push({ 2895 $type: 'com.atproto.repo.applyWrites#createResult', 2896 uri: result.uri, 2897 cid: result.cid, 2898 validationStatus: 'valid', 2899 }); 2900 } else if (type === 'com.atproto.repo.applyWrites#update') { 2901 const result = await this.createRecord( 2902 write.collection, 2903 write.value, 2904 write.rkey, 2905 ); 2906 results.push({ 2907 $type: 'com.atproto.repo.applyWrites#updateResult', 2908 uri: result.uri, 2909 cid: result.cid, 2910 validationStatus: 'valid', 2911 }); 2912 } else if (type === 'com.atproto.repo.applyWrites#delete') { 2913 await this.deleteRecord(write.collection, write.rkey); 2914 results.push({ 2915 $type: 'com.atproto.repo.applyWrites#deleteResult', 2916 }); 2917 } else { 2918 return errorResponse( 2919 'InvalidRequest', 2920 `Unknown write operation type: ${type}`, 2921 400, 2922 ); 2923 } 2924 } 2925 // Return commit info 2926 const head = await this.state.storage.get('head'); 2927 const rev = await this.state.storage.get('rev'); 2928 return Response.json({ commit: { cid: head, rev }, results }); 2929 } catch (err) { 2930 const message = err instanceof Error ? err.message : String(err); 2931 return errorResponse('InternalError', message, 500); 2932 } 2933 } 2934 2935 /** @param {URL} url */ 2936 async handleGetRecord(url) { 2937 const collection = url.searchParams.get('collection'); 2938 const rkey = url.searchParams.get('rkey'); 2939 if (!collection || !rkey) { 2940 return errorResponse('InvalidRequest', 'missing collection or rkey', 400); 2941 } 2942 const did = await this.getDid(); 2943 const uri = `at://${did}/${collection}/${rkey}`; 2944 const rows = /** @type {RecordRow[]} */ ( 2945 this.sql 2946 .exec(`SELECT cid, value FROM records WHERE uri = ?`, uri) 2947 .toArray() 2948 ); 2949 if (rows.length === 0) { 2950 return errorResponse('RecordNotFound', 'record not found', 404); 2951 } 2952 const row = rows[0]; 2953 const value = cborDecode(new Uint8Array(row.value)); 2954 return Response.json({ uri, cid: row.cid, value }); 2955 } 2956 2957 async handleDescribeRepo() { 2958 const did = await this.getDid(); 2959 if (!did) { 2960 return errorResponse('RepoNotFound', 'repo not found', 404); 2961 } 2962 const handle = await this.state.storage.get('handle'); 2963 // Get unique collections 2964 const collections = this.sql 2965 .exec(`SELECT DISTINCT collection FROM records`) 2966 .toArray() 2967 .map((r) => r.collection); 2968 2969 return Response.json({ 2970 handle: handle || did, 2971 did, 2972 didDoc: {}, 2973 collections, 2974 handleIsCorrect: !!handle, 2975 }); 2976 } 2977 2978 /** @param {URL} url */ 2979 async handleListRecords(url) { 2980 const collection = url.searchParams.get('collection'); 2981 if (!collection) { 2982 return errorResponse('InvalidRequest', 'missing collection', 400); 2983 } 2984 const limit = Math.min( 2985 parseInt(url.searchParams.get('limit') || '50', 10), 2986 100, 2987 ); 2988 const reverse = url.searchParams.get('reverse') === 'true'; 2989 const _cursor = url.searchParams.get('cursor'); 2990 2991 const _did = await this.getDid(); 2992 const query = `SELECT uri, cid, value FROM records WHERE collection = ? ORDER BY rkey ${reverse ? 'DESC' : 'ASC'} LIMIT ?`; 2993 const params = [collection, limit + 1]; 2994 2995 const rows = /** @type {RecordRow[]} */ ( 2996 this.sql.exec(query, ...params).toArray() 2997 ); 2998 const hasMore = rows.length > limit; 2999 const records = rows.slice(0, limit).map((r) => ({ 3000 uri: r.uri, 3001 cid: r.cid, 3002 value: cborDecode(new Uint8Array(r.value)), 3003 })); 3004 3005 return Response.json({ 3006 records, 3007 cursor: hasMore ? records[records.length - 1]?.uri : undefined, 3008 }); 3009 } 3010 3011 handleGetLatestCommit() { 3012 const commits = /** @type {CommitRow[]} */ ( 3013 this.sql 3014 .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`) 3015 .toArray() 3016 ); 3017 if (commits.length === 0) { 3018 return errorResponse('RepoNotFound', 'repo not found', 404); 3019 } 3020 return Response.json({ cid: commits[0].cid, rev: commits[0].rev }); 3021 } 3022 3023 async handleGetRepoStatus() { 3024 const did = await this.getDid(); 3025 const commits = /** @type {CommitRow[]} */ ( 3026 this.sql 3027 .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`) 3028 .toArray() 3029 ); 3030 if (commits.length === 0 || !did) { 3031 return errorResponse('RepoNotFound', 'repo not found', 404); 3032 } 3033 return Response.json({ 3034 did, 3035 active: true, 3036 status: 'active', 3037 rev: commits[0].rev, 3038 }); 3039 } 3040 3041 handleGetRepo() { 3042 const commits = /** @type {CommitRow[]} */ ( 3043 this.sql 3044 .exec(`SELECT cid FROM commits ORDER BY seq DESC LIMIT 1`) 3045 .toArray() 3046 ); 3047 if (commits.length === 0) { 3048 return errorResponse('RepoNotFound', 'repo not found', 404); 3049 } 3050 3051 // Only include blocks reachable from the current commit 3052 const commitCid = commits[0].cid; 3053 const neededCids = new Set(); 3054 3055 // Helper to get block data 3056 /** @param {string} cid */ 3057 const getBlock = (cid) => { 3058 const rows = /** @type {BlockRow[]} */ ( 3059 this.sql.exec(`SELECT data FROM blocks WHERE cid = ?`, cid).toArray() 3060 ); 3061 return rows.length > 0 ? new Uint8Array(rows[0].data) : null; 3062 }; 3063 3064 // Collect all reachable blocks starting from commit 3065 /** @param {string} cid */ 3066 const collectBlocks = (cid) => { 3067 if (neededCids.has(cid)) return; 3068 neededCids.add(cid); 3069 3070 const data = getBlock(cid); 3071 if (!data) return; 3072 3073 // Decode CBOR to find CID references 3074 try { 3075 const decoded = cborDecode(data); 3076 if (decoded && typeof decoded === 'object') { 3077 // Commit object - follow 'data' (MST root) 3078 if (decoded.data instanceof Uint8Array) { 3079 collectBlocks(cidToString(decoded.data)); 3080 } 3081 // MST node - follow 'l' and entries' 'v' and 't' 3082 if (decoded.l instanceof Uint8Array) { 3083 collectBlocks(cidToString(decoded.l)); 3084 } 3085 if (Array.isArray(decoded.e)) { 3086 for (const entry of decoded.e) { 3087 if (entry.v instanceof Uint8Array) { 3088 collectBlocks(cidToString(entry.v)); 3089 } 3090 if (entry.t instanceof Uint8Array) { 3091 collectBlocks(cidToString(entry.t)); 3092 } 3093 } 3094 } 3095 } 3096 } catch (_e) { 3097 // Not a structured block, that's fine 3098 } 3099 }; 3100 3101 collectBlocks(commitCid); 3102 3103 // Build CAR with only needed blocks 3104 const blocksForCar = []; 3105 for (const cid of neededCids) { 3106 const data = getBlock(cid); 3107 if (data) { 3108 blocksForCar.push({ cid, data }); 3109 } 3110 } 3111 3112 const car = buildCarFile(commitCid, blocksForCar); 3113 return new Response(/** @type {BodyInit} */ (car), { 3114 headers: { 'content-type': 'application/vnd.ipld.car' }, 3115 }); 3116 } 3117 3118 /** @param {URL} url */ 3119 async handleSyncGetRecord(url) { 3120 const collection = url.searchParams.get('collection'); 3121 const rkey = url.searchParams.get('rkey'); 3122 if (!collection || !rkey) { 3123 return errorResponse('InvalidRequest', 'missing collection or rkey', 400); 3124 } 3125 const did = await this.getDid(); 3126 const uri = `at://${did}/${collection}/${rkey}`; 3127 const rows = /** @type {RecordRow[]} */ ( 3128 this.sql.exec(`SELECT cid FROM records WHERE uri = ?`, uri).toArray() 3129 ); 3130 if (rows.length === 0) { 3131 return errorResponse('RecordNotFound', 'record not found', 404); 3132 } 3133 const recordCid = rows[0].cid; 3134 3135 // Get latest commit 3136 const commits = /** @type {CommitRow[]} */ ( 3137 this.sql 3138 .exec(`SELECT cid FROM commits ORDER BY seq DESC LIMIT 1`) 3139 .toArray() 3140 ); 3141 if (commits.length === 0) { 3142 return errorResponse('RepoNotFound', 'no commits', 404); 3143 } 3144 const commitCid = commits[0].cid; 3145 3146 // Build proof chain: commit -> MST path -> record 3147 // Include commit block, all MST nodes on path to record, and record block 3148 /** @type {Array<{cid: string, data: Uint8Array}>} */ 3149 const blocks = []; 3150 const included = new Set(); 3151 3152 /** @param {string} cidStr */ 3153 const addBlock = (cidStr) => { 3154 if (included.has(cidStr)) return; 3155 included.add(cidStr); 3156 const blockRows = /** @type {BlockRow[]} */ ( 3157 this.sql.exec(`SELECT data FROM blocks WHERE cid = ?`, cidStr).toArray() 3158 ); 3159 if (blockRows.length > 0) { 3160 blocks.push({ cid: cidStr, data: new Uint8Array(blockRows[0].data) }); 3161 } 3162 }; 3163 3164 // Add commit block 3165 addBlock(commitCid); 3166 3167 // Get commit to find data root 3168 const commitRows = /** @type {BlockRow[]} */ ( 3169 this.sql 3170 .exec(`SELECT data FROM blocks WHERE cid = ?`, commitCid) 3171 .toArray() 3172 ); 3173 if (commitRows.length > 0) { 3174 const commit = cborDecode(new Uint8Array(commitRows[0].data)); 3175 if (commit.data) { 3176 const dataRootCid = cidToString(commit.data); 3177 // Collect MST path blocks (this includes all MST nodes) 3178 const mstBlocks = this.collectMstBlocks(dataRootCid); 3179 for (const block of mstBlocks) { 3180 addBlock(block.cid); 3181 } 3182 } 3183 } 3184 3185 // Add record block 3186 addBlock(recordCid); 3187 3188 const car = buildCarFile(commitCid, blocks); 3189 return new Response(/** @type {BodyInit} */ (car), { 3190 headers: { 'content-type': 'application/vnd.ipld.car' }, 3191 }); 3192 } 3193 3194 /** @param {Request} request */ 3195 async handleUploadBlob(request) { 3196 // Check if auth was already done by outer handler (OAuth/DPoP flow) 3197 const authedDid = request.headers.get('x-authed-did'); 3198 if (!authedDid) { 3199 // Fallback to legacy Bearer token auth 3200 const authHeader = request.headers.get('Authorization'); 3201 if (!authHeader || !authHeader.startsWith('Bearer ')) { 3202 return errorResponse( 3203 'AuthRequired', 3204 'Missing or invalid authorization header', 3205 401, 3206 ); 3207 } 3208 3209 const token = authHeader.slice(7); 3210 const jwtSecret = this.env?.JWT_SECRET; 3211 if (!jwtSecret) { 3212 return errorResponse( 3213 'InternalServerError', 3214 'Server not configured for authentication', 3215 500, 3216 ); 3217 } 3218 3219 try { 3220 await verifyAccessJwt(token, jwtSecret); 3221 } catch (err) { 3222 const message = err instanceof Error ? err.message : String(err); 3223 return errorResponse('InvalidToken', message, 401); 3224 } 3225 } 3226 3227 const did = await this.getDid(); 3228 if (!did) { 3229 return errorResponse('InvalidRequest', 'PDS not initialized', 400); 3230 } 3231 3232 // Read body as ArrayBuffer 3233 const bodyBytes = await request.arrayBuffer(); 3234 const size = bodyBytes.byteLength; 3235 3236 // Check size limits 3237 if (size === 0) { 3238 return errorResponse( 3239 'InvalidRequest', 3240 'Empty blobs are not allowed', 3241 400, 3242 ); 3243 } 3244 const MAX_BLOB_SIZE = 50 * 1024 * 1024; 3245 if (size > MAX_BLOB_SIZE) { 3246 return errorResponse( 3247 'BlobTooLarge', 3248 `Blob size ${size} exceeds maximum ${MAX_BLOB_SIZE}`, 3249 400, 3250 ); 3251 } 3252 3253 // Sniff MIME type, fall back to Content-Type header 3254 const contentType = 3255 request.headers.get('Content-Type') || 'application/octet-stream'; 3256 const sniffed = sniffMimeType(bodyBytes); 3257 const mimeType = sniffed || contentType; 3258 3259 // Compute CID using raw codec for blobs 3260 const cid = await createBlobCid(new Uint8Array(bodyBytes)); 3261 const cidStr = cidToString(cid); 3262 3263 // Upload to R2 (idempotent - same CID always has same content) 3264 const r2Key = `${did}/${cidStr}`; 3265 await this.env?.BLOBS?.put(r2Key, bodyBytes, { 3266 httpMetadata: { contentType: mimeType }, 3267 }); 3268 3269 // Insert metadata (INSERT OR IGNORE handles concurrent uploads) 3270 const created_at = new Date().toISOString(); 3271 this.sql.exec( 3272 'INSERT OR IGNORE INTO blobs (cid, mime_type, size, created_at) VALUES (?, ?, ?, ?)', 3273 cidStr, 3274 mimeType, 3275 size, 3276 created_at, 3277 ); 3278 3279 // Return BlobRef 3280 return Response.json({ 3281 blob: { 3282 $type: 'blob', 3283 ref: { $link: cidStr }, 3284 mimeType, 3285 size, 3286 }, 3287 }); 3288 } 3289 3290 /** @param {URL} url */ 3291 async handleGetBlob(url) { 3292 const did = url.searchParams.get('did'); 3293 const cid = url.searchParams.get('cid'); 3294 3295 if (!did || !cid) { 3296 return errorResponse( 3297 'InvalidRequest', 3298 'missing did or cid parameter', 3299 400, 3300 ); 3301 } 3302 3303 // Validate CID format (CIDv1 base32lower: starts with 'b', 59 chars total) 3304 if (!/^b[a-z2-7]{58}$/.test(cid)) { 3305 return errorResponse('InvalidRequest', 'invalid CID format', 400); 3306 } 3307 3308 // Verify DID matches this DO 3309 const myDid = await this.getDid(); 3310 if (did !== myDid) { 3311 return errorResponse( 3312 'InvalidRequest', 3313 'DID does not match this repo', 3314 400, 3315 ); 3316 } 3317 3318 // Look up blob metadata 3319 const rows = this.sql 3320 .exec('SELECT mime_type, size FROM blobs WHERE cid = ?', cid) 3321 .toArray(); 3322 3323 if (rows.length === 0) { 3324 return errorResponse('BlobNotFound', 'blob not found', 404); 3325 } 3326 3327 const { mime_type, size } = rows[0]; 3328 3329 // Fetch from R2 3330 const r2Key = `${did}/${cid}`; 3331 const object = await this.env?.BLOBS?.get(r2Key); 3332 3333 if (!object) { 3334 return errorResponse('BlobNotFound', 'blob not found in storage', 404); 3335 } 3336 3337 // Return blob with security headers 3338 return new Response(object.body, { 3339 headers: { 3340 'Content-Type': /** @type {string} */ (mime_type), 3341 'Content-Length': String(size), 3342 'X-Content-Type-Options': 'nosniff', 3343 'Content-Security-Policy': "default-src 'none'; sandbox", 3344 'Cache-Control': 'public, max-age=31536000, immutable', 3345 }, 3346 }); 3347 } 3348 3349 /** @param {URL} url */ 3350 async handleListBlobs(url) { 3351 const did = url.searchParams.get('did'); 3352 const cursor = url.searchParams.get('cursor'); 3353 const limit = Math.min(Number(url.searchParams.get('limit')) || 500, 1000); 3354 3355 if (!did) { 3356 return errorResponse('InvalidRequest', 'missing did parameter', 400); 3357 } 3358 3359 // Verify DID matches this DO 3360 const myDid = await this.getDid(); 3361 if (did !== myDid) { 3362 return errorResponse( 3363 'InvalidRequest', 3364 'DID does not match this repo', 3365 400, 3366 ); 3367 } 3368 3369 // Query blobs with pagination (cursor is created_at::cid for uniqueness) 3370 let query = 'SELECT cid, created_at FROM blobs'; 3371 const params = []; 3372 3373 if (cursor) { 3374 const [cursorTime, cursorCid] = cursor.split('::'); 3375 query += ' WHERE (created_at > ? OR (created_at = ? AND cid > ?))'; 3376 params.push(cursorTime, cursorTime, cursorCid); 3377 } 3378 3379 query += ' ORDER BY created_at ASC, cid ASC LIMIT ?'; 3380 params.push(limit + 1); // Fetch one extra to detect if there's more 3381 3382 const rows = this.sql.exec(query, ...params).toArray(); 3383 3384 // Determine if there's a next page 3385 let nextCursor = null; 3386 if (rows.length > limit) { 3387 rows.pop(); // Remove the extra row 3388 const last = rows[rows.length - 1]; 3389 nextCursor = `${last.created_at}::${last.cid}`; 3390 } 3391 3392 return Response.json({ 3393 cids: rows.map((r) => r.cid), 3394 cursor: nextCursor, 3395 }); 3396 } 3397 3398 /** 3399 * @param {Request} request 3400 * @param {URL} url 3401 */ 3402 handleSubscribeRepos(request, url) { 3403 const upgradeHeader = request.headers.get('Upgrade'); 3404 if (upgradeHeader !== 'websocket') { 3405 return new Response('expected websocket', { status: 426 }); 3406 } 3407 const { 0: client, 1: server } = new WebSocketPair(); 3408 this.state.acceptWebSocket(server); 3409 const cursor = url.searchParams.get('cursor'); 3410 if (cursor) { 3411 const events = /** @type {SeqEventRow[]} */ ( 3412 this.sql 3413 .exec( 3414 `SELECT * FROM seq_events WHERE seq > ? ORDER BY seq`, 3415 parseInt(cursor, 10), 3416 ) 3417 .toArray() 3418 ); 3419 for (const evt of events) { 3420 server.send(this.formatEvent(evt)); 3421 } 3422 } 3423 return new Response(null, { status: 101, webSocket: client }); 3424 } 3425 3426 /** @param {Request} request */ 3427 async fetch(request) { 3428 const url = new URL(request.url); 3429 const route = pdsRoutes[url.pathname]; 3430 3431 // Check for local route first 3432 if (route) { 3433 if (route.method && request.method !== route.method) { 3434 return errorResponse('MethodNotAllowed', 'method not allowed', 405); 3435 } 3436 return route.handler(this, request, url); 3437 } 3438 3439 // Handle app.bsky.* proxy requests (only if no local route) 3440 if (url.pathname.startsWith('/xrpc/app.bsky.')) { 3441 const userDid = request.headers.get('x-authed-did'); 3442 if (!userDid) { 3443 return errorResponse('Unauthorized', 'Missing auth context', 401); 3444 } 3445 return this.handleAppViewProxy(request, userDid); 3446 } 3447 3448 return errorResponse('NotFound', 'not found', 404); 3449 } 3450 3451 async alarm() { 3452 await this.cleanupOrphanedBlobs(); 3453 // Reschedule for next day 3454 await this.state.storage.setAlarm(Date.now() + 24 * 60 * 60 * 1000); 3455 } 3456 3457 async cleanupOrphanedBlobs() { 3458 const did = await this.getDid(); 3459 if (!did) return; 3460 3461 // Find orphans: blobs not in record_blobs, older than 24h 3462 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); 3463 3464 const orphans = this.sql 3465 .exec( 3466 `SELECT b.cid FROM blobs b 3467 LEFT JOIN record_blobs rb ON b.cid = rb.blob_cid 3468 WHERE rb.blob_cid IS NULL AND b.created_at < ?`, 3469 cutoff, 3470 ) 3471 .toArray(); 3472 3473 for (const { cid } of orphans) { 3474 await this.env?.BLOBS?.delete(`${did}/${cid}`); 3475 this.sql.exec('DELETE FROM blobs WHERE cid = ?', cid); 3476 } 3477 } 3478 3479 // ╔═════════════════════════════════════════════════════════════════════════════╗ 3480 // ║ OAUTH HANDLERS ║ 3481 // ║ OAuth 2.0 authorization server with DPoP, PKCE, and token management ║ 3482 // ╚═════════════════════════════════════════════════════════════════════════════╝ 3483 3484 /** 3485 * Check if a DPoP jti has been used and mark it as used. 3486 * Returns true if the jti is fresh (not seen before), false if it's a replay. 3487 * Also cleans up expired jtis. 3488 * @param {string} jti - The DPoP proof jti to check 3489 * @param {number} iat - The iat claim from the DPoP proof (unix timestamp) 3490 * @returns {boolean} True if jti is fresh, false if replay 3491 */ 3492 checkAndStoreDpopJti(jti, iat) { 3493 // Clean up expired jtis (older than 5 minutes) 3494 const cutoff = new Date(Date.now() - 5 * 60 * 1000).toISOString(); 3495 this.sql.exec(`DELETE FROM dpop_jtis WHERE expires_at < ?`, cutoff); 3496 3497 // Check if jti already exists 3498 const existing = this.sql 3499 .exec(`SELECT 1 FROM dpop_jtis WHERE jti = ?`, jti) 3500 .toArray(); 3501 if (existing.length > 0) { 3502 return false; // Replay attack 3503 } 3504 3505 // Store jti with expiration (iat + 5 minutes) 3506 const expiresAt = new Date((iat + 300) * 1000).toISOString(); 3507 this.sql.exec( 3508 `INSERT INTO dpop_jtis (jti, expires_at) VALUES (?, ?)`, 3509 jti, 3510 expiresAt, 3511 ); 3512 return true; 3513 } 3514 3515 /** 3516 * Clean up expired authorization requests. 3517 * Should be called periodically to prevent table bloat. 3518 * @returns {number} Number of expired requests deleted 3519 */ 3520 cleanupExpiredAuthorizationRequests() { 3521 const now = new Date().toISOString(); 3522 const result = this.sql.exec( 3523 `DELETE FROM authorization_requests WHERE expires_at < ?`, 3524 now, 3525 ); 3526 return result.rowsWritten; 3527 } 3528 3529 /** 3530 * Validate a required DPoP proof header, parse it, and check for replay attacks. 3531 * @param {Request} request - The incoming request 3532 * @param {string} method - Expected HTTP method 3533 * @param {string} uri - Expected request URI 3534 * @returns {Promise<{ dpop: DpopProofResult } | { error: Response }>} The parsed DPoP proof or error response 3535 */ 3536 async validateRequiredDpop(request, method, uri) { 3537 const dpopHeader = request.headers.get('DPoP'); 3538 if (!dpopHeader) { 3539 return { 3540 error: errorResponse('invalid_dpop_proof', 'DPoP proof required', 400), 3541 }; 3542 } 3543 3544 let dpop; 3545 try { 3546 dpop = await parseDpopProof(dpopHeader, method, uri); 3547 } catch (err) { 3548 return { error: errorResponse('invalid_dpop_proof', err.message, 400) }; 3549 } 3550 3551 if (!this.checkAndStoreDpopJti(dpop.jti, dpop.iat)) { 3552 return { 3553 error: errorResponse( 3554 'invalid_dpop_proof', 3555 'DPoP proof replay detected', 3556 400, 3557 ), 3558 }; 3559 } 3560 3561 return { dpop }; 3562 } 3563 3564 /** 3565 * Get or create the OAuth signing key for this PDS instance. 3566 * Lazily generates a new key if one doesn't exist. 3567 * @returns {Promise<string>} The private key as hex string 3568 */ 3569 async getOAuthPrivateKey() { 3570 let privateKeyHex = /** @type {string|undefined} */ ( 3571 await this.state.storage.get('oauthPrivateKey') 3572 ); 3573 if (!privateKeyHex) { 3574 // Generate a new OAuth signing key 3575 const keyPair = await crypto.subtle.generateKey( 3576 { name: 'ECDSA', namedCurve: 'P-256' }, 3577 true, 3578 ['sign', 'verify'], 3579 ); 3580 const rawKey = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey); 3581 // Extract the 32-byte private key from PKCS#8 (last 32 bytes after the prefix) 3582 const keyBytes = new Uint8Array(rawKey).slice(-32); 3583 privateKeyHex = bytesToHex(keyBytes); 3584 await this.state.storage.put('oauthPrivateKey', privateKeyHex); 3585 } 3586 return privateKeyHex; 3587 } 3588 3589 /** 3590 * Get the PDS signing key as a public JWK. 3591 * Exports only the public components (kty, crv, x, y) for use in JWKS. 3592 * @returns {Promise<{ kty: string, crv: string, x: string, y: string }>} The public key in JWK format 3593 * @throws {Error} If the PDS is not initialized 3594 */ 3595 async getPublicKeyJwk() { 3596 const privateKeyHex = await this.getOAuthPrivateKey(); 3597 if (!privateKeyHex) throw new Error('PDS not initialized'); 3598 3599 // Import key with extractable=true to export public components 3600 const privateKeyBytes = hexToBytes(privateKeyHex); 3601 const pkcs8Prefix = new Uint8Array([ 3602 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 3603 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 3604 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20, 3605 ]); 3606 const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32); 3607 pkcs8.set(pkcs8Prefix); 3608 pkcs8.set(privateKeyBytes, pkcs8Prefix.length); 3609 3610 const privateKey = await crypto.subtle.importKey( 3611 'pkcs8', 3612 pkcs8, 3613 { name: 'ECDSA', namedCurve: 'P-256' }, 3614 true, 3615 ['sign'], 3616 ); 3617 const jwk = await crypto.subtle.exportKey('jwk', privateKey); 3618 return { 3619 kty: /** @type {string} */ (jwk.kty), 3620 crv: /** @type {string} */ (jwk.crv), 3621 x: /** @type {string} */ (jwk.x), 3622 y: /** @type {string} */ (jwk.y), 3623 }; 3624 } 3625 3626 /** 3627 * Handle OAuth Authorization Server Metadata endpoint. 3628 * @param {URL} url - Parsed request URL 3629 * @returns {Response} JSON response with OAuth AS metadata 3630 */ 3631 handleOAuthAuthServerMetadata(url) { 3632 const issuer = `${url.protocol}//${url.host}`; 3633 return Response.json({ 3634 issuer, 3635 authorization_endpoint: `${issuer}/oauth/authorize`, 3636 token_endpoint: `${issuer}/oauth/token`, 3637 revocation_endpoint: `${issuer}/oauth/revoke`, 3638 pushed_authorization_request_endpoint: `${issuer}/oauth/par`, 3639 jwks_uri: `${issuer}/oauth/jwks`, 3640 scopes_supported: ['atproto'], 3641 subject_types_supported: ['public'], 3642 response_types_supported: ['code'], 3643 response_modes_supported: ['query', 'fragment'], 3644 grant_types_supported: ['authorization_code', 'refresh_token'], 3645 code_challenge_methods_supported: ['S256'], 3646 token_endpoint_auth_methods_supported: ['none'], 3647 dpop_signing_alg_values_supported: ['ES256'], 3648 require_pushed_authorization_requests: true, 3649 authorization_response_iss_parameter_supported: true, 3650 client_id_metadata_document_supported: true, 3651 protected_resources: [issuer], 3652 }); 3653 } 3654 3655 /** 3656 * Handle OAuth Protected Resource Metadata endpoint. 3657 * @param {URL} url - Parsed request URL 3658 * @returns {Response} JSON response with OAuth PR metadata 3659 */ 3660 handleOAuthProtectedResource(url) { 3661 const resource = `${url.protocol}//${url.host}`; 3662 return Response.json({ 3663 resource, 3664 authorization_servers: [resource], 3665 bearer_methods_supported: ['header'], 3666 scopes_supported: ['atproto'], 3667 }); 3668 } 3669 3670 /** 3671 * Handle OAuth JWKS endpoint. 3672 * @returns {Promise<Response>} JSON response with JWKS 3673 */ 3674 async handleOAuthJwks() { 3675 const publicKeyJwk = await this.getPublicKeyJwk(); 3676 return Response.json({ 3677 keys: [ 3678 { ...publicKeyJwk, kid: 'pds-oauth-key', use: 'sig', alg: 'ES256' }, 3679 ], 3680 }); 3681 } 3682 3683 /** 3684 * Handle Pushed Authorization Request (PAR) endpoint. 3685 * Validates DPoP proof, client metadata, PKCE parameters, and stores the authorization request. 3686 * @param {Request} request - The incoming request 3687 * @param {URL} url - Parsed request URL 3688 * @returns {Promise<Response>} JSON response with request_uri and expires_in 3689 */ 3690 async handleOAuthPar(request, url) { 3691 // Opportunistically clean up expired authorization requests 3692 this.cleanupExpiredAuthorizationRequests(); 3693 3694 const issuer = `${url.protocol}//${url.host}`; 3695 3696 const dpopResult = await this.validateRequiredDpop( 3697 request, 3698 'POST', 3699 `${issuer}/oauth/par`, 3700 ); 3701 if ('error' in dpopResult) return dpopResult.error; 3702 const { dpop } = dpopResult; 3703 3704 // Parse body - support both JSON and form-encoded 3705 /** @type {Record<string, string|undefined>} */ 3706 let data; 3707 try { 3708 data = await parseRequestBody(request); 3709 } catch { 3710 return errorResponse('invalid_request', 'Invalid JSON body', 400); 3711 } 3712 3713 const clientId = data.client_id; 3714 const redirectUri = data.redirect_uri; 3715 const responseType = data.response_type; 3716 const responseMode = data.response_mode; 3717 const scope = data.scope; 3718 const state = data.state; 3719 const codeChallenge = data.code_challenge; 3720 const codeChallengeMethod = data.code_challenge_method; 3721 const loginHint = data.login_hint; 3722 3723 if (!clientId) 3724 return errorResponse('invalid_request', 'client_id required', 400); 3725 if (!redirectUri) 3726 return errorResponse('invalid_request', 'redirect_uri required', 400); 3727 if (responseType !== 'code') 3728 return errorResponse( 3729 'unsupported_response_type', 3730 'response_type must be code', 3731 400, 3732 ); 3733 if (!codeChallenge || codeChallengeMethod !== 'S256') { 3734 return errorResponse('invalid_request', 'PKCE with S256 required', 400); 3735 } 3736 3737 let clientMetadata; 3738 try { 3739 clientMetadata = await getClientMetadata(clientId); 3740 } catch (err) { 3741 return errorResponse('invalid_client', err.message, 400); 3742 } 3743 3744 // Validate redirect_uri against registered URIs 3745 // For loopback clients (RFC 8252), allow any path on the same origin 3746 const isLoopback = 3747 clientId.startsWith('http://localhost') || 3748 clientId.startsWith('http://127.0.0.1'); 3749 const redirectUriValid = clientMetadata.redirect_uris.some((uri) => { 3750 if (isLoopback) { 3751 // For loopback, check origin match (any path allowed) 3752 try { 3753 const registered = new URL(uri); 3754 const requested = new URL(redirectUri); 3755 return registered.origin === requested.origin; 3756 } catch { 3757 return false; 3758 } 3759 } 3760 return uri === redirectUri; 3761 }); 3762 if (!redirectUriValid) { 3763 return errorResponse( 3764 'invalid_request', 3765 'redirect_uri not registered for this client', 3766 400, 3767 ); 3768 } 3769 3770 const requestId = crypto.randomUUID(); 3771 const requestUri = `urn:ietf:params:oauth:request_uri:${requestId}`; 3772 const expiresIn = 600; 3773 const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); 3774 3775 this.sql.exec( 3776 `INSERT INTO authorization_requests ( 3777 id, client_id, client_metadata, parameters, 3778 code_challenge, code_challenge_method, dpop_jkt, 3779 expires_at, created_at 3780 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 3781 requestId, 3782 clientId, 3783 JSON.stringify(clientMetadata), 3784 JSON.stringify({ 3785 redirect_uri: redirectUri, 3786 scope, 3787 state, 3788 response_mode: responseMode, 3789 login_hint: loginHint, 3790 }), 3791 codeChallenge, 3792 codeChallengeMethod, 3793 dpop.jkt, 3794 expiresAt, 3795 new Date().toISOString(), 3796 ); 3797 3798 return Response.json({ request_uri: requestUri, expires_in: expiresIn }); 3799 } 3800 3801 /** 3802 * Handle OAuth Authorize endpoint - displays consent UI (GET) or processes approval (POST). 3803 * @param {Request} request - The incoming request 3804 * @param {URL} url - Parsed request URL 3805 * @returns {Promise<Response>} HTML consent page or redirect 3806 */ 3807 async handleOAuthAuthorize(request, url) { 3808 if (request.method === 'GET') { 3809 return this.handleOAuthAuthorizeGet(url); 3810 } else if (request.method === 'POST') { 3811 return this.handleOAuthAuthorizePost(request, url); 3812 } 3813 return errorResponse('MethodNotAllowed', 'Method not allowed', 405); 3814 } 3815 3816 /** 3817 * Handle GET /oauth/authorize - displays the consent UI. 3818 * Validates the request_uri from PAR and renders a login/consent form. 3819 * @param {URL} url - Parsed request URL 3820 * @returns {Promise<Response>} HTML consent page 3821 */ 3822 async handleOAuthAuthorizeGet(url) { 3823 const requestUri = url.searchParams.get('request_uri'); 3824 const clientId = url.searchParams.get('client_id'); 3825 3826 if (!requestUri || !clientId) { 3827 return new Response('Missing parameters', { status: 400 }); 3828 } 3829 3830 const match = requestUri.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); 3831 if (!match) return new Response('Invalid request_uri', { status: 400 }); 3832 3833 const rows = this.sql 3834 .exec( 3835 `SELECT * FROM authorization_requests WHERE id = ? AND client_id = ?`, 3836 match[1], 3837 clientId, 3838 ) 3839 .toArray(); 3840 const authRequest = rows[0]; 3841 3842 if (!authRequest) return new Response('Request not found', { status: 400 }); 3843 if (new Date(/** @type {string} */ (authRequest.expires_at)) < new Date()) 3844 return new Response('Request expired', { status: 400 }); 3845 if (authRequest.code) 3846 return new Response('Request already used', { status: 400 }); 3847 3848 const clientMetadata = JSON.parse( 3849 /** @type {string} */ (authRequest.client_metadata), 3850 ); 3851 const parameters = JSON.parse( 3852 /** @type {string} */ (authRequest.parameters), 3853 ); 3854 3855 return new Response( 3856 renderConsentPage({ 3857 clientName: clientMetadata.client_name || clientId, 3858 clientId: clientId || '', 3859 scope: parameters.scope || 'atproto', 3860 requestUri: requestUri || '', 3861 }), 3862 { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }, 3863 ); 3864 } 3865 3866 /** 3867 * Handle POST /oauth/authorize - processes user approval/denial. 3868 * Validates password, generates authorization code on approval, redirects to client. 3869 * @param {Request} request - The incoming request 3870 * @param {URL} url - Parsed request URL 3871 * @returns {Promise<Response>} Redirect to client redirect_uri with code or error 3872 */ 3873 async handleOAuthAuthorizePost(request, url) { 3874 const issuer = `${url.protocol}//${url.host}`; 3875 const body = await request.text(); 3876 const params = new URLSearchParams(body); 3877 3878 const requestUri = params.get('request_uri'); 3879 const clientId = params.get('client_id'); 3880 const password = params.get('password'); 3881 const action = params.get('action'); 3882 3883 const match = requestUri?.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); 3884 if (!match) return new Response('Invalid request_uri', { status: 400 }); 3885 3886 const authRows = this.sql 3887 .exec( 3888 `SELECT * FROM authorization_requests WHERE id = ? AND client_id = ?`, 3889 match[1], 3890 clientId, 3891 ) 3892 .toArray(); 3893 const authRequest = authRows[0]; 3894 if (!authRequest) return new Response('Request not found', { status: 400 }); 3895 3896 const clientMetadata = JSON.parse( 3897 /** @type {string} */ (authRequest.client_metadata), 3898 ); 3899 const parameters = JSON.parse( 3900 /** @type {string} */ (authRequest.parameters), 3901 ); 3902 3903 if (action === 'deny') { 3904 this.sql.exec( 3905 `DELETE FROM authorization_requests WHERE id = ?`, 3906 match[1], 3907 ); 3908 const errorUrl = new URL(parameters.redirect_uri); 3909 errorUrl.searchParams.set('error', 'access_denied'); 3910 if (parameters.state) 3911 errorUrl.searchParams.set('state', parameters.state); 3912 errorUrl.searchParams.set('iss', issuer); 3913 return Response.redirect(errorUrl.toString(), 302); 3914 } 3915 3916 // Timing-safe password comparison 3917 const expectedPwd = this.env?.PDS_PASSWORD; 3918 const passwordValid = 3919 password && expectedPwd && (await timingSafeEqual(password, expectedPwd)); 3920 if (!passwordValid) { 3921 return new Response( 3922 renderConsentPage({ 3923 clientName: clientMetadata.client_name || clientId, 3924 clientId: clientId || '', 3925 scope: parameters.scope || 'atproto', 3926 requestUri: requestUri || '', 3927 error: 'Invalid password', 3928 }), 3929 { 3930 status: 200, 3931 headers: { 'Content-Type': 'text/html; charset=utf-8' }, 3932 }, 3933 ); 3934 } 3935 3936 const code = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32))); 3937 3938 // Resolve DID from login_hint (can be handle or DID) 3939 let did = parameters.login_hint; 3940 if (did && !did.startsWith('did:')) { 3941 // It's a handle, resolve to DID 3942 const handleMap = /** @type {Record<string, string>} */ ( 3943 (await this.state.storage.get('handleMap')) || {} 3944 ); 3945 did = handleMap[did]; 3946 } 3947 if (!did) { 3948 return new Response('Could not resolve user', { status: 400 }); 3949 } 3950 3951 this.sql.exec( 3952 `UPDATE authorization_requests SET code = ?, did = ? WHERE id = ?`, 3953 code, 3954 did, 3955 match[1], 3956 ); 3957 3958 const successUrl = new URL(parameters.redirect_uri); 3959 if (parameters.response_mode === 'fragment') { 3960 const fragParams = new URLSearchParams(); 3961 fragParams.set('code', code); 3962 if (parameters.state) fragParams.set('state', parameters.state); 3963 fragParams.set('iss', issuer); 3964 successUrl.hash = fragParams.toString(); 3965 } else { 3966 successUrl.searchParams.set('code', code); 3967 if (parameters.state) 3968 successUrl.searchParams.set('state', parameters.state); 3969 successUrl.searchParams.set('iss', issuer); 3970 } 3971 return Response.redirect(successUrl.toString(), 302); 3972 } 3973 3974 /** 3975 * Handle token endpoint - exchanges authorization codes for tokens. 3976 * Supports authorization_code and refresh_token grant types. 3977 * @param {Request} request - The incoming request 3978 * @param {URL} url - Parsed request URL 3979 * @returns {Promise<Response>} JSON response with access_token, token_type, expires_in, refresh_token, scope 3980 */ 3981 async handleOAuthToken(request, url) { 3982 const issuer = `${url.protocol}//${url.host}`; 3983 3984 const dpopResult = await this.validateRequiredDpop( 3985 request, 3986 'POST', 3987 `${issuer}/oauth/token`, 3988 ); 3989 if ('error' in dpopResult) return dpopResult.error; 3990 const { dpop } = dpopResult; 3991 3992 const contentType = request.headers.get('content-type') || ''; 3993 const body = await request.text(); 3994 /** @type {URLSearchParams | Map<string, string>} */ 3995 let params; 3996 if (contentType.includes('application/json')) { 3997 try { 3998 const json = JSON.parse(body); 3999 params = new Map(Object.entries(json)); 4000 } catch { 4001 return errorResponse('invalid_request', 'Invalid JSON body', 400); 4002 } 4003 } else { 4004 params = new URLSearchParams(body); 4005 } 4006 const grantType = params.get('grant_type'); 4007 4008 if (grantType === 'authorization_code') { 4009 return this.handleAuthCodeGrant(params, dpop, issuer); 4010 } else if (grantType === 'refresh_token') { 4011 return this.handleRefreshGrant(params, dpop, issuer); 4012 } 4013 return errorResponse( 4014 'unsupported_grant_type', 4015 'Grant type not supported', 4016 400, 4017 ); 4018 } 4019 4020 /** 4021 * Handle authorization_code grant type. 4022 * Validates the code, PKCE verifier, and DPoP binding, then issues tokens. 4023 * @param {URLSearchParams | Map<string, string>} params - Token request parameters 4024 * @param {DpopProofResult} dpop - Parsed DPoP proof 4025 * @param {string} issuer - The PDS issuer URL 4026 * @returns {Promise<Response>} JSON token response 4027 */ 4028 async handleAuthCodeGrant(params, dpop, issuer) { 4029 const code = params.get('code'); 4030 const redirectUri = params.get('redirect_uri'); 4031 const clientId = params.get('client_id'); 4032 const codeVerifier = params.get('code_verifier'); 4033 4034 if (!code || !redirectUri || !clientId || !codeVerifier) { 4035 return errorResponse( 4036 'invalid_request', 4037 'Missing required parameters', 4038 400, 4039 ); 4040 } 4041 4042 const authRows = this.sql 4043 .exec(`SELECT * FROM authorization_requests WHERE code = ?`, code) 4044 .toArray(); 4045 const authRequest = authRows[0]; 4046 if (!authRequest) 4047 return errorResponse('invalid_grant', 'Invalid code', 400); 4048 if (authRequest.client_id !== clientId) 4049 return errorResponse('invalid_grant', 'Client mismatch', 400); 4050 if (authRequest.dpop_jkt !== dpop.jkt) 4051 return errorResponse('invalid_dpop_proof', 'DPoP key mismatch', 400); 4052 4053 const parameters = JSON.parse( 4054 /** @type {string} */ (authRequest.parameters), 4055 ); 4056 if (parameters.redirect_uri !== redirectUri) 4057 return errorResponse('invalid_grant', 'Redirect URI mismatch', 400); 4058 4059 // Verify PKCE 4060 const challengeHash = await crypto.subtle.digest( 4061 'SHA-256', 4062 new TextEncoder().encode(codeVerifier), 4063 ); 4064 const computedChallenge = base64UrlEncode(new Uint8Array(challengeHash)); 4065 if (computedChallenge !== authRequest.code_challenge) { 4066 return errorResponse('invalid_grant', 'Invalid code_verifier', 400); 4067 } 4068 4069 this.sql.exec( 4070 `DELETE FROM authorization_requests WHERE id = ?`, 4071 authRequest.id, 4072 ); 4073 4074 const tokenId = crypto.randomUUID(); 4075 const refreshToken = base64UrlEncode( 4076 crypto.getRandomValues(new Uint8Array(32)), 4077 ); 4078 const scope = parameters.scope || 'atproto'; 4079 const now = new Date(); 4080 const expiresIn = 3600; 4081 const subjectDid = /** @type {string} */ (authRequest.did); 4082 4083 const accessToken = await this.createOAuthAccessToken({ 4084 issuer, 4085 subject: subjectDid, 4086 clientId, 4087 scope, 4088 tokenId, 4089 dpopJkt: dpop.jkt, 4090 expiresIn, 4091 }); 4092 4093 this.sql.exec( 4094 `INSERT INTO tokens (token_id, did, client_id, scope, dpop_jkt, expires_at, refresh_token, created_at, updated_at) 4095 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 4096 tokenId, 4097 subjectDid, 4098 clientId, 4099 scope, 4100 dpop.jkt, 4101 new Date(now.getTime() + expiresIn * 1000).toISOString(), 4102 refreshToken, 4103 now.toISOString(), 4104 now.toISOString(), 4105 ); 4106 4107 return Response.json({ 4108 access_token: accessToken, 4109 token_type: 'DPoP', 4110 expires_in: expiresIn, 4111 refresh_token: refreshToken, 4112 scope, 4113 sub: subjectDid, 4114 }); 4115 } 4116 4117 /** 4118 * Handle refresh_token grant type. 4119 * Validates the refresh token, DPoP binding, and 24hr lifetime, then rotates tokens. 4120 * @param {URLSearchParams | Map<string, string>} params - Token request parameters 4121 * @param {DpopProofResult} dpop - Parsed DPoP proof 4122 * @param {string} issuer - The PDS issuer URL 4123 * @returns {Promise<Response>} JSON token response with new access and refresh tokens 4124 */ 4125 async handleRefreshGrant(params, dpop, issuer) { 4126 const refreshToken = params.get('refresh_token'); 4127 const clientId = params.get('client_id'); 4128 4129 if (!refreshToken || !clientId) 4130 return errorResponse( 4131 'invalid_request', 4132 'Missing required parameters', 4133 400, 4134 ); 4135 4136 const tokenRows = this.sql 4137 .exec(`SELECT * FROM tokens WHERE refresh_token = ?`, refreshToken) 4138 .toArray(); 4139 const token = tokenRows[0]; 4140 4141 if (!token) 4142 return errorResponse('invalid_grant', 'Invalid refresh token', 400); 4143 if (token.client_id !== clientId) 4144 return errorResponse('invalid_grant', 'Client mismatch', 400); 4145 if (token.dpop_jkt !== dpop.jkt) 4146 return errorResponse('invalid_dpop_proof', 'DPoP key mismatch', 400); 4147 4148 // Check 24hr lifetime 4149 const createdAt = new Date(/** @type {string} */ (token.created_at)); 4150 if (Date.now() - createdAt.getTime() > 24 * 60 * 60 * 1000) { 4151 this.sql.exec(`DELETE FROM tokens WHERE token_id = ?`, token.token_id); 4152 return errorResponse('invalid_grant', 'Refresh token expired', 400); 4153 } 4154 4155 const newTokenId = crypto.randomUUID(); 4156 const newRefreshToken = base64UrlEncode( 4157 crypto.getRandomValues(new Uint8Array(32)), 4158 ); 4159 const now = new Date(); 4160 const expiresIn = 3600; 4161 const tokenDid = /** @type {string} */ (token.did); 4162 const tokenScope = /** @type {string} */ (token.scope); 4163 4164 const accessToken = await this.createOAuthAccessToken({ 4165 issuer, 4166 subject: tokenDid, 4167 clientId, 4168 scope: tokenScope, 4169 tokenId: newTokenId, 4170 dpopJkt: dpop.jkt, 4171 expiresIn, 4172 }); 4173 4174 this.sql.exec( 4175 `UPDATE tokens SET token_id = ?, refresh_token = ?, expires_at = ?, updated_at = ? WHERE token_id = ?`, 4176 newTokenId, 4177 newRefreshToken, 4178 new Date(now.getTime() + expiresIn * 1000).toISOString(), 4179 now.toISOString(), 4180 token.token_id, 4181 ); 4182 4183 return Response.json({ 4184 access_token: accessToken, 4185 token_type: 'DPoP', 4186 expires_in: expiresIn, 4187 refresh_token: newRefreshToken, 4188 scope: tokenScope, 4189 sub: tokenDid, 4190 }); 4191 } 4192 4193 /** 4194 * Create a DPoP-bound access token (at+jwt). 4195 * @param {AccessTokenParams} params 4196 * @returns {Promise<string>} The signed JWT access token 4197 */ 4198 async createOAuthAccessToken({ 4199 issuer, 4200 subject, 4201 clientId, 4202 scope, 4203 tokenId, 4204 dpopJkt, 4205 expiresIn, 4206 }) { 4207 const now = Math.floor(Date.now() / 1000); 4208 const header = { typ: 'at+jwt', alg: 'ES256', kid: 'pds-oauth-key' }; 4209 const payload = { 4210 iss: issuer, 4211 sub: subject, 4212 aud: issuer, 4213 client_id: clientId, 4214 scope, 4215 jti: tokenId, 4216 iat: now, 4217 exp: now + expiresIn, 4218 cnf: { jkt: dpopJkt }, 4219 }; 4220 4221 const privateKeyHex = await this.getOAuthPrivateKey(); 4222 const privateKey = await importPrivateKey(hexToBytes(privateKeyHex)); 4223 4224 const headerB64 = base64UrlEncode( 4225 new TextEncoder().encode(JSON.stringify(header)), 4226 ); 4227 const payloadB64 = base64UrlEncode( 4228 new TextEncoder().encode(JSON.stringify(payload)), 4229 ); 4230 const sigInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`); 4231 const sig = await sign(privateKey, sigInput); 4232 4233 return `${headerB64}.${payloadB64}.${base64UrlEncode(sig)}`; 4234 } 4235 4236 /** 4237 * Handle token revocation endpoint (RFC 7009). 4238 * Revokes access tokens and refresh tokens by client_id. 4239 * @param {Request} request - The incoming request 4240 * @param {URL} url - Parsed request URL 4241 * @returns {Promise<Response>} Empty 200 response on success 4242 */ 4243 async handleOAuthRevoke(request, url) { 4244 const issuer = `${url.protocol}//${url.host}`; 4245 4246 // Optional DPoP verification - if present, verify it 4247 const dpopHeader = request.headers.get('DPoP'); 4248 if (dpopHeader) { 4249 try { 4250 const dpop = await parseDpopProof( 4251 dpopHeader, 4252 'POST', 4253 `${issuer}/oauth/revoke`, 4254 ); 4255 // Check for DPoP replay attack 4256 if (!this.checkAndStoreDpopJti(dpop.jti, dpop.iat)) { 4257 return errorResponse( 4258 'invalid_dpop_proof', 4259 'DPoP proof replay detected', 4260 400, 4261 ); 4262 } 4263 } catch (err) { 4264 return errorResponse('invalid_dpop_proof', err.message, 400); 4265 } 4266 } 4267 4268 /** @type {Record<string, string>} */ 4269 let data; 4270 try { 4271 data = await parseRequestBody(request); 4272 } catch { 4273 return errorResponse('invalid_request', 'Invalid JSON body', 400); 4274 } 4275 4276 const validation = validateRequiredParams(data, ['token', 'client_id']); 4277 if (!validation.valid) { 4278 return errorResponse( 4279 'invalid_request', 4280 'Missing required parameters', 4281 400, 4282 ); 4283 } 4284 const { token, client_id: clientId } = data; 4285 4286 this.sql.exec( 4287 `DELETE FROM tokens WHERE client_id = ? AND (refresh_token = ? OR token_id = ?)`, 4288 clientId, 4289 token, 4290 token, 4291 ); 4292 4293 return new Response(null, { status: 200 }); 4294 } 4295} 4296 4297// ╔══════════════════════════════════════════════════════════════════════════════╗ 4298// ║ WORKERS ENTRY POINT ║ 4299// ║ Request handling, CORS, auth middleware ║ 4300// ╚══════════════════════════════════════════════════════════════════════════════╝ 4301 4302const corsHeaders = { 4303 'Access-Control-Allow-Origin': '*', 4304 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 4305 'Access-Control-Allow-Headers': 4306 'Content-Type, Authorization, DPoP, atproto-accept-labelers, atproto-proxy, x-bsky-topics', 4307}; 4308 4309/** 4310 * @param {Response} response 4311 * @returns {Response} 4312 */ 4313function addCorsHeaders(response) { 4314 const newHeaders = new Headers(response.headers); 4315 for (const [key, value] of Object.entries(corsHeaders)) { 4316 newHeaders.set(key, value); 4317 } 4318 return new Response(response.body, { 4319 status: response.status, 4320 statusText: response.statusText, 4321 headers: newHeaders, 4322 }); 4323} 4324 4325export default { 4326 /** 4327 * @param {Request} request 4328 * @param {Env} env 4329 */ 4330 async fetch(request, env) { 4331 // Handle CORS preflight 4332 if (request.method === 'OPTIONS') { 4333 return new Response(null, { headers: corsHeaders }); 4334 } 4335 4336 const response = await handleRequest(request, env); 4337 // Don't wrap WebSocket upgrades - they need the webSocket property preserved 4338 if (response.status === 101) { 4339 return response; 4340 } 4341 return addCorsHeaders(response); 4342 }, 4343}; 4344 4345/** 4346 * Extract subdomain from hostname (e.g., "alice" from "alice.foo.workers.dev") 4347 * @param {string} hostname 4348 * @returns {string|null} 4349 */ 4350function getSubdomain(hostname) { 4351 const parts = hostname.split('.'); 4352 // workers.dev domains: [subdomain?].[worker-name].[account].workers.dev 4353 // If more than 4 parts, first part(s) are user subdomain 4354 if (parts.length > 4 && parts.slice(-2).join('.') === 'workers.dev') { 4355 return parts.slice(0, -4).join('.'); 4356 } 4357 // Custom domains: check if there's a subdomain before the base 4358 // For now, assume no subdomain on custom domains 4359 return null; 4360} 4361 4362/** 4363 * Verify auth and return DID from token. 4364 * Supports both legacy Bearer tokens (JWT with symmetric key) and OAuth DPoP tokens. 4365 * @param {Request} request - HTTP request with Authorization header 4366 * @param {Env} env - Environment with JWT_SECRET 4367 * @param {{ fetch: (req: Request) => Promise<Response> }} [pds] - PDS stub for OAuth token verification (optional) 4368 * @returns {Promise<{did: string, scope?: string} | {error: Response}>} DID (and scope for OAuth) or error response 4369 */ 4370async function requireAuth(request, env, pds = undefined) { 4371 const authHeader = request.headers.get('Authorization'); 4372 if (!authHeader) { 4373 return { 4374 error: errorResponse('AuthRequired', 'Authentication required', 401), 4375 }; 4376 } 4377 4378 // Legacy Bearer token (symmetric JWT) 4379 if (authHeader.startsWith('Bearer ')) { 4380 const token = authHeader.slice(7); 4381 const jwtSecret = env?.JWT_SECRET; 4382 if (!jwtSecret) { 4383 return { 4384 error: errorResponse( 4385 'InternalServerError', 4386 'Server not configured for authentication', 4387 500, 4388 ), 4389 }; 4390 } 4391 4392 try { 4393 const payload = await verifyAccessJwt(token, jwtSecret); 4394 return { did: payload.sub }; 4395 } catch (err) { 4396 const message = err instanceof Error ? err.message : String(err); 4397 return { error: errorResponse('InvalidToken', message, 401) }; 4398 } 4399 } 4400 4401 // OAuth DPoP token 4402 if (authHeader.startsWith('DPoP ')) { 4403 if (!pds) { 4404 return { 4405 error: errorResponse( 4406 'InternalServerError', 4407 'DPoP tokens not supported on this endpoint', 4408 500, 4409 ), 4410 }; 4411 } 4412 4413 try { 4414 const result = await verifyOAuthAccessToken( 4415 request, 4416 authHeader.slice(5), 4417 pds, 4418 ); 4419 return result; 4420 } catch (err) { 4421 const message = err instanceof Error ? err.message : String(err); 4422 return { error: errorResponse('InvalidToken', message, 401) }; 4423 } 4424 } 4425 4426 return { 4427 error: errorResponse('AuthRequired', 'Invalid authorization type', 401), 4428 }; 4429} 4430 4431/** 4432 * Verify an OAuth DPoP-bound access token. 4433 * Validates the JWT signature, expiration, DPoP binding, and proof. 4434 * @param {Request} request - The incoming request (for DPoP validation) 4435 * @param {string} token - The access token JWT 4436 * @param {{ fetch: (req: Request) => Promise<Response> }} pdsStub - The PDS stub with fetch method 4437 * @returns {Promise<{did: string, scope?: string}>} The authenticated user's DID and scope 4438 * @throws {Error} If verification fails 4439 */ 4440async function verifyOAuthAccessToken(request, token, pdsStub) { 4441 const parts = token.split('.'); 4442 if (parts.length !== 3) throw new Error('Invalid token format'); 4443 4444 const header = JSON.parse( 4445 new TextDecoder().decode(base64UrlDecode(parts[0])), 4446 ); 4447 if (header.typ !== 'at+jwt') throw new Error('Invalid token type'); 4448 4449 // Verify signature with PDS public key (fetch from DO) 4450 const keyRes = await pdsStub.fetch( 4451 new Request('http://internal/oauth-public-key'), 4452 ); 4453 const publicKeyJwk = await keyRes.json(); 4454 const publicKey = await crypto.subtle.importKey( 4455 'jwk', 4456 publicKeyJwk, 4457 { name: 'ECDSA', namedCurve: 'P-256' }, 4458 false, 4459 ['verify'], 4460 ); 4461 4462 const signatureInput = new TextEncoder().encode(`${parts[0]}.${parts[1]}`); 4463 const signature = base64UrlDecode(parts[2]); 4464 4465 const valid = await crypto.subtle.verify( 4466 { name: 'ECDSA', hash: 'SHA-256' }, 4467 publicKey, 4468 /** @type {BufferSource} */ (signature), 4469 /** @type {BufferSource} */ (signatureInput), 4470 ); 4471 if (!valid) throw new Error('Invalid token signature'); 4472 4473 const payload = JSON.parse( 4474 new TextDecoder().decode(base64UrlDecode(parts[1])), 4475 ); 4476 4477 if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) { 4478 throw new Error('Token expired'); 4479 } 4480 4481 if (!payload.cnf?.jkt) throw new Error('Token missing DPoP binding'); 4482 4483 const dpopHeader = request.headers.get('DPoP'); 4484 if (!dpopHeader) throw new Error('DPoP proof required'); 4485 4486 const url = new URL(request.url); 4487 const dpop = await parseDpopProof( 4488 dpopHeader, 4489 request.method, 4490 `${url.protocol}//${url.host}${url.pathname}`, 4491 payload.cnf.jkt, 4492 token, 4493 ); 4494 4495 // Check for DPoP jti replay 4496 const jtiRes = await pdsStub.fetch( 4497 new Request('http://internal/check-dpop-jti', { 4498 method: 'POST', 4499 body: JSON.stringify({ jti: dpop.jti, iat: dpop.iat }), 4500 }), 4501 ); 4502 const { fresh } = await jtiRes.json(); 4503 if (!fresh) throw new Error('DPoP proof replay detected'); 4504 4505 return { did: payload.sub, scope: payload.scope }; 4506} 4507 4508// ╔══════════════════════════════════════════════════════════════════════════════╗ 4509// ║ SCOPES ║ 4510// ║ OAuth scope parsing and permission checking ║ 4511// ╚══════════════════════════════════════════════════════════════════════════════╝ 4512 4513/** 4514 * Parse a repo scope string into collection and actions. 4515 * Official format: repo:collection?action=create&action=update 4516 * Or: repo?collection=foo&action=create 4517 * Without actions defaults to all: create, update, delete 4518 * @param {string} scope - The scope string to parse 4519 * @returns {{ collection: string, actions: string[] } | null} Parsed scope or null if invalid 4520 */ 4521export function parseRepoScope(scope) { 4522 if (!scope.startsWith('repo:') && !scope.startsWith('repo?')) return null; 4523 4524 const ALL_ACTIONS = ['create', 'update', 'delete']; 4525 let collection; 4526 let actions; 4527 4528 const questionIdx = scope.indexOf('?'); 4529 if (questionIdx === -1) { 4530 // repo:collection (no query params = all actions) 4531 collection = scope.slice(5); 4532 actions = ALL_ACTIONS; 4533 } else { 4534 // Parse query parameters 4535 const queryString = scope.slice(questionIdx + 1); 4536 const params = new URLSearchParams(queryString); 4537 const pathPart = scope.startsWith('repo:') 4538 ? scope.slice(5, questionIdx) 4539 : ''; 4540 4541 collection = pathPart || params.get('collection'); 4542 actions = params.getAll('action'); 4543 if (actions.length === 0) actions = ALL_ACTIONS; 4544 } 4545 4546 if (!collection) return null; 4547 4548 // Validate actions 4549 const validActions = [ 4550 ...new Set(actions.filter((a) => ALL_ACTIONS.includes(a))), 4551 ]; 4552 if (validActions.length === 0) return null; 4553 4554 return { collection, actions: validActions }; 4555} 4556 4557/** 4558 * Parse a blob scope string into its components. 4559 * Format: blob:<mime>[,<mime>...] 4560 * @param {string} scope - The scope string to parse 4561 * @returns {{ accept: string[] } | null} Parsed scope or null if invalid 4562 */ 4563export function parseBlobScope(scope) { 4564 if (!scope.startsWith('blob:')) return null; 4565 4566 const mimeStr = scope.slice(5); // Remove 'blob:' 4567 if (!mimeStr) return null; 4568 4569 const accept = mimeStr.split(',').filter((m) => m); 4570 if (accept.length === 0) return null; 4571 4572 return { accept }; 4573} 4574 4575/** 4576 * Check if a MIME pattern matches an actual MIME type. 4577 * @param {string} pattern - MIME pattern (e.g., 'image/\*', '\*\/\*', 'image/png') 4578 * @param {string} mime - Actual MIME type to check 4579 * @returns {boolean} Whether the pattern matches 4580 */ 4581export function matchesMime(pattern, mime) { 4582 const p = pattern.toLowerCase(); 4583 const m = mime.toLowerCase(); 4584 4585 if (p === '*/*') return true; 4586 4587 if (p.endsWith('/*')) { 4588 const pType = p.slice(0, -2); 4589 const mType = m.split('/')[0]; 4590 return pType === mType; 4591 } 4592 4593 return p === m; 4594} 4595 4596/** 4597 * Error thrown when a required scope is missing. 4598 */ 4599class ScopeMissingError extends Error { 4600 /** 4601 * @param {string} scope - The missing scope 4602 */ 4603 constructor(scope) { 4604 super(`Missing required scope "${scope}"`); 4605 this.name = 'ScopeMissingError'; 4606 this.scope = scope; 4607 this.status = 403; 4608 } 4609} 4610 4611/** 4612 * Parses and checks OAuth scope permissions. 4613 */ 4614export class ScopePermissions { 4615 /** 4616 * @param {string | undefined} scopeString - Space-separated scope string 4617 */ 4618 constructor(scopeString) { 4619 /** @type {Set<string>} */ 4620 this.scopes = new Set( 4621 scopeString ? scopeString.split(' ').filter((s) => s) : [], 4622 ); 4623 4624 /** @type {Array<{ collection: string, actions: string[] }>} */ 4625 this.repoPermissions = []; 4626 4627 /** @type {Array<{ accept: string[] }>} */ 4628 this.blobPermissions = []; 4629 4630 for (const scope of this.scopes) { 4631 const repo = parseRepoScope(scope); 4632 if (repo) this.repoPermissions.push(repo); 4633 4634 const blob = parseBlobScope(scope); 4635 if (blob) this.blobPermissions.push(blob); 4636 } 4637 } 4638 4639 /** 4640 * Check if full access is granted (atproto or transition:generic). 4641 * @returns {boolean} 4642 */ 4643 hasFullAccess() { 4644 return this.scopes.has('atproto') || this.scopes.has('transition:generic'); 4645 } 4646 4647 /** 4648 * Check if a repo operation is allowed. 4649 * @param {string} collection - The collection NSID 4650 * @param {string} action - The action (create, update, delete) 4651 * @returns {boolean} 4652 */ 4653 allowsRepo(collection, action) { 4654 if (this.hasFullAccess()) return true; 4655 4656 for (const perm of this.repoPermissions) { 4657 const collectionMatch = 4658 perm.collection === '*' || perm.collection === collection; 4659 const actionMatch = perm.actions.includes(action); 4660 if (collectionMatch && actionMatch) return true; 4661 } 4662 4663 return false; 4664 } 4665 4666 /** 4667 * Assert that a repo operation is allowed, throwing if not. 4668 * @param {string} collection - The collection NSID 4669 * @param {string} action - The action (create, update, delete) 4670 * @throws {ScopeMissingError} 4671 */ 4672 assertRepo(collection, action) { 4673 if (!this.allowsRepo(collection, action)) { 4674 throw new ScopeMissingError(`repo:${collection}?action=${action}`); 4675 } 4676 } 4677 4678 /** 4679 * Check if a blob operation is allowed. 4680 * @param {string} mime - The MIME type of the blob 4681 * @returns {boolean} 4682 */ 4683 allowsBlob(mime) { 4684 if (this.hasFullAccess()) return true; 4685 4686 for (const perm of this.blobPermissions) { 4687 for (const pattern of perm.accept) { 4688 if (matchesMime(pattern, mime)) return true; 4689 } 4690 } 4691 4692 return false; 4693 } 4694 4695 /** 4696 * Assert that a blob operation is allowed, throwing if not. 4697 * @param {string} mime - The MIME type of the blob 4698 * @throws {ScopeMissingError} 4699 */ 4700 assertBlob(mime) { 4701 if (!this.allowsBlob(mime)) { 4702 throw new ScopeMissingError(`blob:${mime}`); 4703 } 4704 } 4705} 4706 4707// ╔══════════════════════════════════════════════════════════════════════════════╗ 4708// ║ CONSENT PAGE DISPLAY ║ 4709// ║ OAuth consent page rendering with scope visualization ║ 4710// ╚══════════════════════════════════════════════════════════════════════════════╝ 4711 4712/** 4713 * Parse scope string into display-friendly structure. 4714 * @param {string} scope - Space-separated scope string 4715 * @returns {{ hasAtproto: boolean, hasTransitionGeneric: boolean, repoPermissions: Map<string, {create: boolean, update: boolean, delete: boolean}>, blobPermissions: string[] }} 4716 */ 4717export function parseScopesForDisplay(scope) { 4718 const scopes = scope.split(' ').filter((s) => s); 4719 4720 const repoPermissions = new Map(); 4721 4722 for (const s of scopes) { 4723 const repo = parseRepoScope(s); 4724 if (repo) { 4725 const existing = repoPermissions.get(repo.collection) || { 4726 create: false, 4727 update: false, 4728 delete: false, 4729 }; 4730 for (const action of repo.actions) { 4731 existing[action] = true; 4732 } 4733 repoPermissions.set(repo.collection, existing); 4734 } 4735 } 4736 4737 const blobPermissions = []; 4738 for (const s of scopes) { 4739 const blob = parseBlobScope(s); 4740 if (blob) blobPermissions.push(...blob.accept); 4741 } 4742 4743 return { 4744 hasAtproto: scopes.includes('atproto'), 4745 hasTransitionGeneric: scopes.includes('transition:generic'), 4746 repoPermissions, 4747 blobPermissions, 4748 }; 4749} 4750 4751/** 4752 * Escape HTML special characters. 4753 * @param {string} s 4754 * @returns {string} 4755 */ 4756function escapeHtml(s) { 4757 return s 4758 .replace(/&/g, '&amp;') 4759 .replace(/</g, '&lt;') 4760 .replace(/>/g, '&gt;') 4761 .replace(/"/g, '&quot;'); 4762} 4763 4764/** 4765 * Render repo permissions as HTML table. 4766 * @param {Map<string, {create: boolean, update: boolean, delete: boolean}>} repoPermissions 4767 * @returns {string} HTML string 4768 */ 4769function renderRepoTable(repoPermissions) { 4770 if (repoPermissions.size === 0) return ''; 4771 4772 let rows = ''; 4773 for (const [collection, actions] of repoPermissions) { 4774 const displayCollection = collection === '*' ? '* (any)' : collection; 4775 rows += `<tr> 4776 <td>${escapeHtml(displayCollection)}</td> 4777 <td class="check">${actions.create ? '✓' : ''}</td> 4778 <td class="check">${actions.update ? '✓' : ''}</td> 4779 <td class="check">${actions.delete ? '✓' : ''}</td> 4780 </tr>`; 4781 } 4782 4783 return `<div class="permissions-section"> 4784 <div class="section-label">Repository permissions:</div> 4785 <table class="permissions-table"> 4786 <thead><tr><th>Collection</th><th title="Create">C</th><th title="Update">U</th><th title="Delete">D</th></tr></thead> 4787 <tbody>${rows}</tbody> 4788 </table> 4789 </div>`; 4790} 4791 4792/** 4793 * Render blob permissions as HTML list. 4794 * @param {string[]} blobPermissions 4795 * @returns {string} HTML string 4796 */ 4797function renderBlobList(blobPermissions) { 4798 if (blobPermissions.length === 0) return ''; 4799 4800 const items = blobPermissions 4801 .map( 4802 (mime) => 4803 `<li>${escapeHtml(mime === '*/*' ? 'All file types' : mime)}</li>`, 4804 ) 4805 .join(''); 4806 4807 return `<div class="permissions-section"> 4808 <div class="section-label">Upload permissions:</div> 4809 <ul class="blob-list">${items}</ul> 4810 </div>`; 4811} 4812 4813/** 4814 * Render full permissions display based on parsed scopes. 4815 * @param {{ hasAtproto: boolean, hasTransitionGeneric: boolean, repoPermissions: Map<string, {create: boolean, update: boolean, delete: boolean}>, blobPermissions: string[] }} parsed 4816 * @returns {string} HTML string 4817 */ 4818function renderPermissionsHtml(parsed) { 4819 if (parsed.hasTransitionGeneric) { 4820 return `<div class="warning">⚠️ Full repository access requested<br> 4821 <small>This app can create, update, and delete any data in your repository.</small></div>`; 4822 } 4823 4824 if ( 4825 parsed.repoPermissions.size === 0 && 4826 parsed.blobPermissions.length === 0 4827 ) { 4828 return ''; 4829 } 4830 4831 return ( 4832 renderRepoTable(parsed.repoPermissions) + 4833 renderBlobList(parsed.blobPermissions) 4834 ); 4835} 4836 4837/** 4838 * Render the OAuth consent page HTML. 4839 * @param {{ clientName: string, clientId: string, scope: string, requestUri: string, error?: string }} params 4840 * @returns {string} HTML page content 4841 */ 4842function renderConsentPage({ 4843 clientName, 4844 clientId, 4845 scope, 4846 requestUri, 4847 error = '', 4848}) { 4849 const parsed = parseScopesForDisplay(scope); 4850 const isIdentityOnly = 4851 parsed.repoPermissions.size === 0 && 4852 parsed.blobPermissions.length === 0 && 4853 !parsed.hasTransitionGeneric; 4854 4855 return `<!DOCTYPE html> 4856<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> 4857<title>Authorize</title> 4858<style> 4859*{box-sizing:border-box} 4860body{font-family:system-ui,sans-serif;max-width:400px;margin:40px auto;padding:20px;background:#1a1a1a;color:#e0e0e0} 4861h2{color:#fff;margin-bottom:24px} 4862p{color:#b0b0b0;line-height:1.5} 4863b{color:#fff} 4864.error{color:#ff6b6b;background:#2d1f1f;padding:12px;margin:12px 0;border-radius:6px;border:1px solid #4a2020} 4865label{display:block;margin:16px 0 6px;color:#b0b0b0;font-size:14px} 4866input[type="password"]{width:100%;padding:12px;background:#2a2a2a;border:1px solid #404040;border-radius:6px;color:#fff;font-size:16px} 4867input[type="password"]:focus{outline:none;border-color:#4a9eff;box-shadow:0 0 0 2px rgba(74,158,255,0.2)} 4868.actions{display:flex;gap:12px;margin-top:24px} 4869button{flex:1;padding:12px 20px;border-radius:6px;font-size:16px;font-weight:500;cursor:pointer;transition:background 0.15s} 4870.deny{background:#2a2a2a;color:#e0e0e0;border:1px solid #404040} 4871.deny:hover{background:#333} 4872.approve{background:#2563eb;color:#fff;border:none} 4873.approve:hover{background:#1d4ed8} 4874.permissions-section{margin:16px 0} 4875.section-label{color:#b0b0b0;font-size:13px;margin-bottom:8px} 4876.permissions-table{width:100%;border-collapse:collapse;font-size:13px} 4877.permissions-table th{color:#808080;font-weight:normal;text-align:left;padding:4px 8px;border-bottom:1px solid #333} 4878.permissions-table th:not(:first-child){text-align:center;width:32px} 4879.permissions-table td{padding:4px 8px;border-bottom:1px solid #2a2a2a} 4880.permissions-table td:not(:first-child){text-align:center} 4881.permissions-table .check{color:#4ade80} 4882.blob-list{margin:0;padding-left:20px;color:#e0e0e0;font-size:13px} 4883.blob-list li{margin:4px 0} 4884.warning{background:#3d2f00;border:1px solid #5c4a00;border-radius:6px;padding:12px;color:#fbbf24;margin:16px 0} 4885.warning small{color:#d4a000;display:block;margin-top:4px} 4886</style></head> 4887<body><h2>Sign in to authorize</h2> 4888<p><b>${escapeHtml(clientName)}</b> ${isIdentityOnly ? 'wants to uniquely identify you through your account.' : 'wants to access your account.'}</p> 4889${renderPermissionsHtml(parsed)} 4890${error ? `<p class="error">${escapeHtml(error)}</p>` : ''} 4891<form method="POST" action="/oauth/authorize"> 4892<input type="hidden" name="request_uri" value="${escapeHtml(requestUri)}"> 4893<input type="hidden" name="client_id" value="${escapeHtml(clientId)}"> 4894<label>Password</label><input type="password" name="password" required autofocus> 4895<div class="actions"><button type="submit" name="action" value="deny" class="deny" formnovalidate>Deny</button> 4896<button type="submit" name="action" value="approve" class="approve">Authorize</button></div> 4897</form></body></html>`; 4898} 4899 4900/** 4901 * @param {Request} request 4902 * @param {Env} env 4903 */ 4904async function handleAuthenticatedBlobUpload(request, env) { 4905 // Get default PDS for OAuth token verification 4906 const defaultPds = getDefaultPds(env); 4907 const auth = await requireAuth(request, env, defaultPds); 4908 if ('error' in auth) return auth.error; 4909 4910 // Validate scope for blob upload 4911 if (auth.scope !== undefined) { 4912 const contentType = 4913 request.headers.get('content-type') || 'application/octet-stream'; 4914 const permissions = new ScopePermissions(auth.scope); 4915 if (!permissions.allowsBlob(contentType)) { 4916 return errorResponse( 4917 'Forbidden', 4918 `Missing required scope "blob:${contentType}"`, 4919 403, 4920 ); 4921 } 4922 } 4923 // Legacy tokens without scope are trusted (backward compat) 4924 4925 // Route to the user's DO based on their DID from the token 4926 const id = env.PDS.idFromName(auth.did); 4927 const pds = env.PDS.get(id); 4928 // Pass x-authed-did so DO knows auth was already done (avoids DPoP replay detection) 4929 return pds.fetch( 4930 new Request(request.url, { 4931 method: request.method, 4932 headers: { 4933 ...Object.fromEntries(request.headers), 4934 'x-authed-did': auth.did, 4935 }, 4936 body: request.body, 4937 }), 4938 ); 4939} 4940 4941/** 4942 * @param {Request} request 4943 * @param {Env} env 4944 */ 4945async function handleAuthenticatedRepoWrite(request, env) { 4946 // Get default PDS for OAuth token verification 4947 const defaultPds = getDefaultPds(env); 4948 const auth = await requireAuth(request, env, defaultPds); 4949 if ('error' in auth) return auth.error; 4950 4951 const body = await request.json(); 4952 const repo = body.repo; 4953 if (!repo) { 4954 return errorResponse('InvalidRequest', 'missing repo param', 400); 4955 } 4956 4957 if (auth.did !== repo) { 4958 return errorResponse('Forbidden', "Cannot modify another user's repo", 403); 4959 } 4960 4961 // Granular scope validation for OAuth tokens 4962 if (auth.scope !== undefined) { 4963 const permissions = new ScopePermissions(auth.scope); 4964 const url = new URL(request.url); 4965 const endpoint = url.pathname; 4966 4967 if (endpoint === '/xrpc/com.atproto.repo.createRecord') { 4968 const collection = body.collection; 4969 if (!collection) { 4970 return errorResponse('InvalidRequest', 'missing collection param', 400); 4971 } 4972 if (!permissions.allowsRepo(collection, 'create')) { 4973 return errorResponse( 4974 'Forbidden', 4975 `Missing required scope "repo:${collection}:create"`, 4976 403, 4977 ); 4978 } 4979 } else if (endpoint === '/xrpc/com.atproto.repo.putRecord') { 4980 const collection = body.collection; 4981 if (!collection) { 4982 return errorResponse('InvalidRequest', 'missing collection param', 400); 4983 } 4984 // putRecord requires both create and update permissions 4985 if ( 4986 !permissions.allowsRepo(collection, 'create') || 4987 !permissions.allowsRepo(collection, 'update') 4988 ) { 4989 const missing = !permissions.allowsRepo(collection, 'create') 4990 ? 'create' 4991 : 'update'; 4992 return errorResponse( 4993 'Forbidden', 4994 `Missing required scope "repo:${collection}:${missing}"`, 4995 403, 4996 ); 4997 } 4998 } else if (endpoint === '/xrpc/com.atproto.repo.deleteRecord') { 4999 const collection = body.collection; 5000 if (!collection) { 5001 return errorResponse('InvalidRequest', 'missing collection param', 400); 5002 } 5003 if (!permissions.allowsRepo(collection, 'delete')) { 5004 return errorResponse( 5005 'Forbidden', 5006 `Missing required scope "repo:${collection}:delete"`, 5007 403, 5008 ); 5009 } 5010 } else if (endpoint === '/xrpc/com.atproto.repo.applyWrites') { 5011 const writes = body.writes || []; 5012 for (const write of writes) { 5013 const collection = write.collection; 5014 if (!collection) continue; 5015 5016 let action; 5017 if (write.$type === 'com.atproto.repo.applyWrites#create') { 5018 action = 'create'; 5019 } else if (write.$type === 'com.atproto.repo.applyWrites#update') { 5020 action = 'update'; 5021 } else if (write.$type === 'com.atproto.repo.applyWrites#delete') { 5022 action = 'delete'; 5023 } else { 5024 continue; 5025 } 5026 5027 if (!permissions.allowsRepo(collection, action)) { 5028 return errorResponse( 5029 'Forbidden', 5030 `Missing required scope "repo:${collection}:${action}"`, 5031 403, 5032 ); 5033 } 5034 } 5035 } 5036 } 5037 // Legacy tokens without scope are trusted (backward compat) 5038 5039 const id = env.PDS.idFromName(repo); 5040 const pds = env.PDS.get(id); 5041 const response = await pds.fetch( 5042 new Request(request.url, { 5043 method: 'POST', 5044 headers: request.headers, 5045 body: JSON.stringify(body), 5046 }), 5047 ); 5048 5049 // Notify relay of updates on successful writes 5050 if (response.ok) { 5051 const url = new URL(request.url); 5052 notifyCrawlers(env, url.hostname); 5053 } 5054 5055 return response; 5056} 5057 5058/** 5059 * @param {Request} request 5060 * @param {Env} env 5061 */ 5062async function handleRequest(request, env) { 5063 const url = new URL(request.url); 5064 const subdomain = getSubdomain(url.hostname); 5065 5066 // Handle resolution via subdomain or bare domain 5067 if (url.pathname === '/.well-known/atproto-did') { 5068 // Look up handle -> DID in default DO 5069 // Use subdomain if present, otherwise try bare hostname as handle 5070 const handleToResolve = subdomain || url.hostname; 5071 const defaultPds = getDefaultPds(env); 5072 const resolveRes = await defaultPds.fetch( 5073 new Request( 5074 `http://internal/resolve-handle?handle=${encodeURIComponent(handleToResolve)}`, 5075 ), 5076 ); 5077 if (!resolveRes.ok) { 5078 return new Response('Handle not found', { status: 404 }); 5079 } 5080 const { did } = await resolveRes.json(); 5081 return new Response(did, { headers: { 'Content-Type': 'text/plain' } }); 5082 } 5083 5084 // describeServer - works on bare domain 5085 if (url.pathname === '/xrpc/com.atproto.server.describeServer') { 5086 const defaultPds = getDefaultPds(env); 5087 const newReq = new Request(request.url, { 5088 method: request.method, 5089 headers: { 5090 ...Object.fromEntries(request.headers), 5091 'x-hostname': url.hostname, 5092 }, 5093 }); 5094 return defaultPds.fetch(newReq); 5095 } 5096 5097 // Session endpoints - route to default DO (has handleMap for identifier resolution) 5098 const sessionEndpoints = [ 5099 '/xrpc/com.atproto.server.createSession', 5100 '/xrpc/com.atproto.server.getSession', 5101 '/xrpc/com.atproto.server.refreshSession', 5102 ]; 5103 if (sessionEndpoints.includes(url.pathname)) { 5104 const defaultPds = getDefaultPds(env); 5105 return defaultPds.fetch(request); 5106 } 5107 5108 // Proxy app.bsky.* endpoints to Bluesky AppView 5109 if (url.pathname.startsWith('/xrpc/app.bsky.')) { 5110 // Get default PDS for OAuth token verification 5111 const defaultPds = getDefaultPds(env); 5112 // Authenticate the user first 5113 const auth = await requireAuth(request, env, defaultPds); 5114 if ('error' in auth) return auth.error; 5115 5116 // Route to the user's DO instance to create service auth and proxy 5117 const id = env.PDS.idFromName(auth.did); 5118 const pds = env.PDS.get(id); 5119 return pds.fetch( 5120 new Request(request.url, { 5121 method: request.method, 5122 headers: { 5123 ...Object.fromEntries(request.headers), 5124 'x-authed-did': auth.did, // Pass the authenticated DID 5125 }, 5126 body: 5127 request.method !== 'GET' && request.method !== 'HEAD' 5128 ? request.body 5129 : undefined, 5130 }), 5131 ); 5132 } 5133 5134 // Handle registration routes - go to default DO 5135 if ( 5136 url.pathname === '/register-handle' || 5137 url.pathname === '/resolve-handle' 5138 ) { 5139 const defaultPds = getDefaultPds(env); 5140 return defaultPds.fetch(request); 5141 } 5142 5143 // resolveHandle XRPC endpoint 5144 if (url.pathname === '/xrpc/com.atproto.identity.resolveHandle') { 5145 const handle = url.searchParams.get('handle'); 5146 if (!handle) { 5147 return errorResponse('InvalidRequest', 'missing handle param', 400); 5148 } 5149 const defaultPds = getDefaultPds(env); 5150 const resolveRes = await defaultPds.fetch( 5151 new Request( 5152 `http://internal/resolve-handle?handle=${encodeURIComponent(handle)}`, 5153 ), 5154 ); 5155 if (!resolveRes.ok) { 5156 return errorResponse('InvalidRequest', 'Unable to resolve handle', 400); 5157 } 5158 const { did } = await resolveRes.json(); 5159 return Response.json({ did }); 5160 } 5161 5162 // subscribeRepos WebSocket - route to default instance for firehose 5163 if (url.pathname === '/xrpc/com.atproto.sync.subscribeRepos') { 5164 const defaultPds = getDefaultPds(env); 5165 return defaultPds.fetch(request); 5166 } 5167 5168 // listRepos needs to aggregate from all registered DIDs 5169 if (url.pathname === '/xrpc/com.atproto.sync.listRepos') { 5170 const defaultPds = getDefaultPds(env); 5171 const regRes = await defaultPds.fetch( 5172 new Request('http://internal/get-registered-dids'), 5173 ); 5174 const { dids } = await regRes.json(); 5175 5176 const repos = []; 5177 for (const did of dids) { 5178 const id = env.PDS.idFromName(did); 5179 const pds = env.PDS.get(id); 5180 const infoRes = await pds.fetch(new Request('http://internal/repo-info')); 5181 const info = await infoRes.json(); 5182 if (info.head) { 5183 repos.push({ did, head: info.head, rev: info.rev, active: true }); 5184 } 5185 } 5186 return Response.json({ repos, cursor: undefined }); 5187 } 5188 5189 // Repo endpoints use ?repo= param instead of ?did= 5190 if ( 5191 url.pathname === '/xrpc/com.atproto.repo.describeRepo' || 5192 url.pathname === '/xrpc/com.atproto.repo.listRecords' || 5193 url.pathname === '/xrpc/com.atproto.repo.getRecord' 5194 ) { 5195 const repo = url.searchParams.get('repo'); 5196 if (!repo) { 5197 return errorResponse('InvalidRequest', 'missing repo param', 400); 5198 } 5199 const id = env.PDS.idFromName(repo); 5200 const pds = env.PDS.get(id); 5201 return pds.fetch(request); 5202 } 5203 5204 // Sync endpoints use ?did= param 5205 if ( 5206 url.pathname === '/xrpc/com.atproto.sync.getLatestCommit' || 5207 url.pathname === '/xrpc/com.atproto.sync.getRepoStatus' || 5208 url.pathname === '/xrpc/com.atproto.sync.getRepo' || 5209 url.pathname === '/xrpc/com.atproto.sync.getRecord' || 5210 url.pathname === '/xrpc/com.atproto.sync.getBlob' || 5211 url.pathname === '/xrpc/com.atproto.sync.listBlobs' 5212 ) { 5213 const did = url.searchParams.get('did'); 5214 if (!did) { 5215 return errorResponse('InvalidRequest', 'missing did param', 400); 5216 } 5217 const id = env.PDS.idFromName(did); 5218 const pds = env.PDS.get(id); 5219 return pds.fetch(request); 5220 } 5221 5222 // Blob upload endpoint (binary body, uses DID from token) 5223 if (url.pathname === '/xrpc/com.atproto.repo.uploadBlob') { 5224 return handleAuthenticatedBlobUpload(request, env); 5225 } 5226 5227 // Authenticated repo write endpoints 5228 const repoWriteEndpoints = [ 5229 '/xrpc/com.atproto.repo.createRecord', 5230 '/xrpc/com.atproto.repo.deleteRecord', 5231 '/xrpc/com.atproto.repo.putRecord', 5232 '/xrpc/com.atproto.repo.applyWrites', 5233 ]; 5234 if (repoWriteEndpoints.includes(url.pathname)) { 5235 return handleAuthenticatedRepoWrite(request, env); 5236 } 5237 5238 // Health check endpoint 5239 if (url.pathname === '/xrpc/_health') { 5240 return Response.json({ version: VERSION }); 5241 } 5242 5243 // Root path - ASCII art 5244 if (url.pathname === '/') { 5245 const ascii = ` 5246 ██████╗ ██████╗ ███████╗ ██╗ ███████╗ 5247 ██╔══██╗ ██╔══██╗ ██╔════╝ ██║ ██╔════╝ 5248 ██████╔╝ ██║ ██║ ███████╗ ██║ ███████╗ 5249 ██╔═══╝ ██║ ██║ ╚════██║ ██ ██║ ╚════██║ 5250 ██║ ██████╔╝ ███████║ ██╗ ╚█████╔╝ ███████║ 5251 ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚════╝ ╚══════╝ 5252 5253 ATProto PDS on Cloudflare Workers 5254`; 5255 return new Response(ascii, { 5256 headers: { 'Content-Type': 'text/plain; charset=utf-8' }, 5257 }); 5258 } 5259 5260 // On init, register this DID with the default instance (requires ?did= param, no auth yet) 5261 if (url.pathname === '/init' && request.method === 'POST') { 5262 const did = url.searchParams.get('did'); 5263 if (!did) { 5264 return errorResponse('InvalidRequest', 'missing did param', 400); 5265 } 5266 const body = await request.json(); 5267 5268 // Register with default instance for discovery 5269 const defaultPds = getDefaultPds(env); 5270 await defaultPds.fetch( 5271 new Request('http://internal/register-did', { 5272 method: 'POST', 5273 body: JSON.stringify({ did }), 5274 }), 5275 ); 5276 5277 // Register handle if provided 5278 if (body.handle) { 5279 await defaultPds.fetch( 5280 new Request('http://internal/register-handle', { 5281 method: 'POST', 5282 body: JSON.stringify({ did, handle: body.handle }), 5283 }), 5284 ); 5285 } 5286 5287 // Also initialize default instance with identity for OAuth (single-user PDS) 5288 await defaultPds.fetch( 5289 new Request('http://internal/init', { 5290 method: 'POST', 5291 body: JSON.stringify(body), 5292 }), 5293 ); 5294 5295 // Forward to the actual PDS instance 5296 const id = env.PDS.idFromName(did); 5297 const pds = env.PDS.get(id); 5298 return pds.fetch( 5299 new Request(request.url, { 5300 method: 'POST', 5301 headers: request.headers, 5302 body: JSON.stringify(body), 5303 }), 5304 ); 5305 } 5306 5307 // OAuth endpoints - route to default PDS instance 5308 const oauthEndpoints = [ 5309 '/.well-known/oauth-authorization-server', 5310 '/.well-known/oauth-protected-resource', 5311 '/oauth/jwks', 5312 '/oauth/par', 5313 '/oauth/authorize', 5314 '/oauth/token', 5315 '/oauth/revoke', 5316 ]; 5317 if (oauthEndpoints.includes(url.pathname)) { 5318 const defaultPds = getDefaultPds(env); 5319 return defaultPds.fetch(request); 5320 } 5321 5322 // Unknown endpoint 5323 return errorResponse('NotFound', 'Endpoint not found', 404); 5324}