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