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 if (!clientId) 3848 return errorResponse('invalid_request', 'client_id required', 400); 3849 if (!redirectUri) 3850 return errorResponse('invalid_request', 'redirect_uri required', 400); 3851 if (responseType !== 'code') 3852 return errorResponse( 3853 'unsupported_response_type', 3854 'response_type must be code', 3855 400, 3856 ); 3857 if (!codeChallenge || codeChallengeMethod !== 'S256') { 3858 return errorResponse('invalid_request', 'PKCE with S256 required', 400); 3859 } 3860 3861 let clientMetadata; 3862 try { 3863 clientMetadata = await getClientMetadata(clientId); 3864 } catch (err) { 3865 return errorResponse('invalid_client', err.message, 400); 3866 } 3867 3868 // Validate redirect_uri against registered URIs 3869 // For loopback clients (RFC 8252), allow any path on the same origin 3870 const isLoopback = 3871 clientId.startsWith('http://localhost') || 3872 clientId.startsWith('http://127.0.0.1'); 3873 const redirectUriValid = clientMetadata.redirect_uris.some((uri) => { 3874 if (isLoopback) { 3875 // For loopback, check origin match (any path allowed) 3876 try { 3877 const registered = new URL(uri); 3878 const requested = new URL(redirectUri); 3879 return registered.origin === requested.origin; 3880 } catch { 3881 return false; 3882 } 3883 } 3884 return uri === redirectUri; 3885 }); 3886 if (!redirectUriValid) { 3887 return errorResponse( 3888 'invalid_request', 3889 'redirect_uri not registered for this client', 3890 400, 3891 ); 3892 } 3893 3894 const requestId = crypto.randomUUID(); 3895 const requestUri = `urn:ietf:params:oauth:request_uri:${requestId}`; 3896 const expiresIn = 600; 3897 const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); 3898 3899 this.sql.exec( 3900 `INSERT INTO authorization_requests ( 3901 id, client_id, client_metadata, parameters, 3902 code_challenge, code_challenge_method, dpop_jkt, 3903 expires_at, created_at 3904 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 3905 requestId, 3906 clientId, 3907 JSON.stringify(clientMetadata), 3908 JSON.stringify({ 3909 redirect_uri: redirectUri, 3910 scope, 3911 state, 3912 response_mode: responseMode, 3913 login_hint: loginHint, 3914 }), 3915 codeChallenge, 3916 codeChallengeMethod, 3917 dpop.jkt, 3918 expiresAt, 3919 new Date().toISOString(), 3920 ); 3921 3922 return Response.json({ request_uri: requestUri, expires_in: expiresIn }); 3923 } 3924 3925 /** 3926 * Handle OAuth Authorize endpoint - displays consent UI (GET) or processes approval (POST). 3927 * @param {Request} request - The incoming request 3928 * @param {URL} url - Parsed request URL 3929 * @returns {Promise<Response>} HTML consent page or redirect 3930 */ 3931 async handleOAuthAuthorize(request, url) { 3932 if (request.method === 'GET') { 3933 return this.handleOAuthAuthorizeGet(url); 3934 } else if (request.method === 'POST') { 3935 return this.handleOAuthAuthorizePost(request, url); 3936 } 3937 return errorResponse('MethodNotAllowed', 'Method not allowed', 405); 3938 } 3939 3940 /** 3941 * Handle GET /oauth/authorize - displays the consent UI. 3942 * Validates the request_uri from PAR and renders a login/consent form. 3943 * @param {URL} url - Parsed request URL 3944 * @returns {Promise<Response>} HTML consent page 3945 */ 3946 async handleOAuthAuthorizeGet(url) { 3947 const requestUri = url.searchParams.get('request_uri'); 3948 const clientId = url.searchParams.get('client_id'); 3949 3950 if (!requestUri || !clientId) { 3951 return new Response('Missing parameters', { status: 400 }); 3952 } 3953 3954 const match = requestUri.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); 3955 if (!match) return new Response('Invalid request_uri', { status: 400 }); 3956 3957 const rows = this.sql 3958 .exec( 3959 `SELECT * FROM authorization_requests WHERE id = ? AND client_id = ?`, 3960 match[1], 3961 clientId, 3962 ) 3963 .toArray(); 3964 const authRequest = rows[0]; 3965 3966 if (!authRequest) return new Response('Request not found', { status: 400 }); 3967 if (new Date(/** @type {string} */ (authRequest.expires_at)) < new Date()) 3968 return new Response('Request expired', { status: 400 }); 3969 if (authRequest.code) 3970 return new Response('Request already used', { status: 400 }); 3971 3972 const clientMetadata = JSON.parse( 3973 /** @type {string} */ (authRequest.client_metadata), 3974 ); 3975 const parameters = JSON.parse( 3976 /** @type {string} */ (authRequest.parameters), 3977 ); 3978 3979 return new Response( 3980 renderConsentPage({ 3981 clientName: clientMetadata.client_name || clientId, 3982 clientId: clientId || '', 3983 scope: parameters.scope || 'atproto', 3984 requestUri: requestUri || '', 3985 }), 3986 { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }, 3987 ); 3988 } 3989 3990 /** 3991 * Handle POST /oauth/authorize - processes user approval/denial. 3992 * Validates password, generates authorization code on approval, redirects to client. 3993 * @param {Request} request - The incoming request 3994 * @param {URL} url - Parsed request URL 3995 * @returns {Promise<Response>} Redirect to client redirect_uri with code or error 3996 */ 3997 async handleOAuthAuthorizePost(request, url) { 3998 const issuer = `${url.protocol}//${url.host}`; 3999 const body = await request.text(); 4000 const params = new URLSearchParams(body); 4001 4002 const requestUri = params.get('request_uri'); 4003 const clientId = params.get('client_id'); 4004 const password = params.get('password'); 4005 const action = params.get('action'); 4006 4007 const match = requestUri?.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); 4008 if (!match) return new Response('Invalid request_uri', { status: 400 }); 4009 4010 const authRows = this.sql 4011 .exec( 4012 `SELECT * FROM authorization_requests WHERE id = ? AND client_id = ?`, 4013 match[1], 4014 clientId, 4015 ) 4016 .toArray(); 4017 const authRequest = authRows[0]; 4018 if (!authRequest) return new Response('Request not found', { status: 400 }); 4019 4020 const clientMetadata = JSON.parse( 4021 /** @type {string} */ (authRequest.client_metadata), 4022 ); 4023 const parameters = JSON.parse( 4024 /** @type {string} */ (authRequest.parameters), 4025 ); 4026 4027 if (action === 'deny') { 4028 this.sql.exec( 4029 `DELETE FROM authorization_requests WHERE id = ?`, 4030 match[1], 4031 ); 4032 const errorUrl = new URL(parameters.redirect_uri); 4033 errorUrl.searchParams.set('error', 'access_denied'); 4034 if (parameters.state) 4035 errorUrl.searchParams.set('state', parameters.state); 4036 errorUrl.searchParams.set('iss', issuer); 4037 return Response.redirect(errorUrl.toString(), 302); 4038 } 4039 4040 // Timing-safe password comparison 4041 const expectedPwd = this.env?.PDS_PASSWORD; 4042 const passwordValid = 4043 password && expectedPwd && (await timingSafeEqual(password, expectedPwd)); 4044 if (!passwordValid) { 4045 return new Response( 4046 renderConsentPage({ 4047 clientName: clientMetadata.client_name || clientId, 4048 clientId: clientId || '', 4049 scope: parameters.scope || 'atproto', 4050 requestUri: requestUri || '', 4051 error: 'Invalid password', 4052 }), 4053 { 4054 status: 200, 4055 headers: { 'Content-Type': 'text/html; charset=utf-8' }, 4056 }, 4057 ); 4058 } 4059 4060 const code = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32))); 4061 4062 // Resolve DID from login_hint (can be handle or DID) 4063 let did = parameters.login_hint; 4064 if (did && !did.startsWith('did:')) { 4065 // It's a handle, resolve to DID 4066 const handleMap = /** @type {Record<string, string>} */ ( 4067 (await this.state.storage.get('handleMap')) || {} 4068 ); 4069 did = handleMap[did]; 4070 } 4071 if (!did) { 4072 return new Response('Could not resolve user', { status: 400 }); 4073 } 4074 4075 this.sql.exec( 4076 `UPDATE authorization_requests SET code = ?, did = ? WHERE id = ?`, 4077 code, 4078 did, 4079 match[1], 4080 ); 4081 4082 const successUrl = new URL(parameters.redirect_uri); 4083 if (parameters.response_mode === 'fragment') { 4084 const fragParams = new URLSearchParams(); 4085 fragParams.set('code', code); 4086 if (parameters.state) fragParams.set('state', parameters.state); 4087 fragParams.set('iss', issuer); 4088 successUrl.hash = fragParams.toString(); 4089 } else { 4090 successUrl.searchParams.set('code', code); 4091 if (parameters.state) 4092 successUrl.searchParams.set('state', parameters.state); 4093 successUrl.searchParams.set('iss', issuer); 4094 } 4095 return Response.redirect(successUrl.toString(), 302); 4096 } 4097 4098 /** 4099 * Handle token endpoint - exchanges authorization codes for tokens. 4100 * Supports authorization_code and refresh_token grant types. 4101 * @param {Request} request - The incoming request 4102 * @param {URL} url - Parsed request URL 4103 * @returns {Promise<Response>} JSON response with access_token, token_type, expires_in, refresh_token, scope 4104 */ 4105 async handleOAuthToken(request, url) { 4106 const issuer = `${url.protocol}//${url.host}`; 4107 4108 const dpopResult = await this.validateRequiredDpop( 4109 request, 4110 'POST', 4111 `${issuer}/oauth/token`, 4112 ); 4113 if ('error' in dpopResult) return dpopResult.error; 4114 const { dpop } = dpopResult; 4115 4116 const contentType = request.headers.get('content-type') || ''; 4117 const body = await request.text(); 4118 /** @type {URLSearchParams | Map<string, string>} */ 4119 let params; 4120 if (contentType.includes('application/json')) { 4121 try { 4122 const json = JSON.parse(body); 4123 params = new Map(Object.entries(json)); 4124 } catch { 4125 return errorResponse('invalid_request', 'Invalid JSON body', 400); 4126 } 4127 } else { 4128 params = new URLSearchParams(body); 4129 } 4130 const grantType = params.get('grant_type'); 4131 4132 if (grantType === 'authorization_code') { 4133 return this.handleAuthCodeGrant(params, dpop, issuer); 4134 } else if (grantType === 'refresh_token') { 4135 return this.handleRefreshGrant(params, dpop, issuer); 4136 } 4137 return errorResponse( 4138 'unsupported_grant_type', 4139 'Grant type not supported', 4140 400, 4141 ); 4142 } 4143 4144 /** 4145 * Handle authorization_code grant type. 4146 * Validates the code, PKCE verifier, and DPoP binding, then issues tokens. 4147 * @param {URLSearchParams | Map<string, string>} params - Token request parameters 4148 * @param {DpopProofResult} dpop - Parsed DPoP proof 4149 * @param {string} issuer - The PDS issuer URL 4150 * @returns {Promise<Response>} JSON token response 4151 */ 4152 async handleAuthCodeGrant(params, dpop, issuer) { 4153 const code = params.get('code'); 4154 const redirectUri = params.get('redirect_uri'); 4155 const clientId = params.get('client_id'); 4156 const codeVerifier = params.get('code_verifier'); 4157 4158 if (!code || !redirectUri || !clientId || !codeVerifier) { 4159 return errorResponse( 4160 'invalid_request', 4161 'Missing required parameters', 4162 400, 4163 ); 4164 } 4165 4166 const authRows = this.sql 4167 .exec(`SELECT * FROM authorization_requests WHERE code = ?`, code) 4168 .toArray(); 4169 const authRequest = authRows[0]; 4170 if (!authRequest) 4171 return errorResponse('invalid_grant', 'Invalid code', 400); 4172 if (authRequest.client_id !== clientId) 4173 return errorResponse('invalid_grant', 'Client mismatch', 400); 4174 if (authRequest.dpop_jkt !== dpop.jkt) 4175 return errorResponse('invalid_dpop_proof', 'DPoP key mismatch', 400); 4176 4177 const parameters = JSON.parse( 4178 /** @type {string} */ (authRequest.parameters), 4179 ); 4180 if (parameters.redirect_uri !== redirectUri) 4181 return errorResponse('invalid_grant', 'Redirect URI mismatch', 400); 4182 4183 // Verify PKCE 4184 const challengeHash = await crypto.subtle.digest( 4185 'SHA-256', 4186 new TextEncoder().encode(codeVerifier), 4187 ); 4188 const computedChallenge = base64UrlEncode(new Uint8Array(challengeHash)); 4189 if (computedChallenge !== authRequest.code_challenge) { 4190 return errorResponse('invalid_grant', 'Invalid code_verifier', 400); 4191 } 4192 4193 this.sql.exec( 4194 `DELETE FROM authorization_requests WHERE id = ?`, 4195 authRequest.id, 4196 ); 4197 4198 const tokenId = crypto.randomUUID(); 4199 const refreshToken = base64UrlEncode( 4200 crypto.getRandomValues(new Uint8Array(32)), 4201 ); 4202 const scope = parameters.scope || 'atproto'; 4203 const now = new Date(); 4204 const expiresIn = 3600; 4205 const subjectDid = /** @type {string} */ (authRequest.did); 4206 4207 const accessToken = await this.createOAuthAccessToken({ 4208 issuer, 4209 subject: subjectDid, 4210 clientId, 4211 scope, 4212 tokenId, 4213 dpopJkt: dpop.jkt, 4214 expiresIn, 4215 }); 4216 4217 this.sql.exec( 4218 `INSERT INTO tokens (token_id, did, client_id, scope, dpop_jkt, expires_at, refresh_token, created_at, updated_at) 4219 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 4220 tokenId, 4221 subjectDid, 4222 clientId, 4223 scope, 4224 dpop.jkt, 4225 new Date(now.getTime() + expiresIn * 1000).toISOString(), 4226 refreshToken, 4227 now.toISOString(), 4228 now.toISOString(), 4229 ); 4230 4231 return Response.json({ 4232 access_token: accessToken, 4233 token_type: 'DPoP', 4234 expires_in: expiresIn, 4235 refresh_token: refreshToken, 4236 scope, 4237 sub: subjectDid, 4238 }); 4239 } 4240 4241 /** 4242 * Handle refresh_token grant type. 4243 * Validates the refresh token, DPoP binding, and 24hr lifetime, then rotates tokens. 4244 * @param {URLSearchParams | Map<string, string>} params - Token request parameters 4245 * @param {DpopProofResult} dpop - Parsed DPoP proof 4246 * @param {string} issuer - The PDS issuer URL 4247 * @returns {Promise<Response>} JSON token response with new access and refresh tokens 4248 */ 4249 async handleRefreshGrant(params, dpop, issuer) { 4250 const refreshToken = params.get('refresh_token'); 4251 const clientId = params.get('client_id'); 4252 4253 if (!refreshToken || !clientId) 4254 return errorResponse( 4255 'invalid_request', 4256 'Missing required parameters', 4257 400, 4258 ); 4259 4260 const tokenRows = this.sql 4261 .exec(`SELECT * FROM tokens WHERE refresh_token = ?`, refreshToken) 4262 .toArray(); 4263 const token = tokenRows[0]; 4264 4265 if (!token) 4266 return errorResponse('invalid_grant', 'Invalid refresh token', 400); 4267 if (token.client_id !== clientId) 4268 return errorResponse('invalid_grant', 'Client mismatch', 400); 4269 if (token.dpop_jkt !== dpop.jkt) 4270 return errorResponse('invalid_dpop_proof', 'DPoP key mismatch', 400); 4271 4272 // Check 24hr lifetime 4273 const createdAt = new Date(/** @type {string} */ (token.created_at)); 4274 if (Date.now() - createdAt.getTime() > 24 * 60 * 60 * 1000) { 4275 this.sql.exec(`DELETE FROM tokens WHERE token_id = ?`, token.token_id); 4276 return errorResponse('invalid_grant', 'Refresh token expired', 400); 4277 } 4278 4279 const newTokenId = crypto.randomUUID(); 4280 const newRefreshToken = base64UrlEncode( 4281 crypto.getRandomValues(new Uint8Array(32)), 4282 ); 4283 const now = new Date(); 4284 const expiresIn = 3600; 4285 const tokenDid = /** @type {string} */ (token.did); 4286 const tokenScope = /** @type {string} */ (token.scope); 4287 4288 const accessToken = await this.createOAuthAccessToken({ 4289 issuer, 4290 subject: tokenDid, 4291 clientId, 4292 scope: tokenScope, 4293 tokenId: newTokenId, 4294 dpopJkt: dpop.jkt, 4295 expiresIn, 4296 }); 4297 4298 this.sql.exec( 4299 `UPDATE tokens SET token_id = ?, refresh_token = ?, expires_at = ?, updated_at = ? WHERE token_id = ?`, 4300 newTokenId, 4301 newRefreshToken, 4302 new Date(now.getTime() + expiresIn * 1000).toISOString(), 4303 now.toISOString(), 4304 token.token_id, 4305 ); 4306 4307 return Response.json({ 4308 access_token: accessToken, 4309 token_type: 'DPoP', 4310 expires_in: expiresIn, 4311 refresh_token: newRefreshToken, 4312 scope: tokenScope, 4313 sub: tokenDid, 4314 }); 4315 } 4316 4317 /** 4318 * Create a DPoP-bound access token (at+jwt). 4319 * @param {AccessTokenParams} params 4320 * @returns {Promise<string>} The signed JWT access token 4321 */ 4322 async createOAuthAccessToken({ 4323 issuer, 4324 subject, 4325 clientId, 4326 scope, 4327 tokenId, 4328 dpopJkt, 4329 expiresIn, 4330 }) { 4331 const now = Math.floor(Date.now() / 1000); 4332 const header = { typ: 'at+jwt', alg: 'ES256', kid: 'pds-oauth-key' }; 4333 const payload = { 4334 iss: issuer, 4335 sub: subject, 4336 aud: issuer, 4337 client_id: clientId, 4338 scope, 4339 jti: tokenId, 4340 iat: now, 4341 exp: now + expiresIn, 4342 cnf: { jkt: dpopJkt }, 4343 }; 4344 4345 const privateKeyHex = await this.getOAuthPrivateKey(); 4346 const privateKey = await importPrivateKey(hexToBytes(privateKeyHex)); 4347 4348 const headerB64 = base64UrlEncode( 4349 new TextEncoder().encode(JSON.stringify(header)), 4350 ); 4351 const payloadB64 = base64UrlEncode( 4352 new TextEncoder().encode(JSON.stringify(payload)), 4353 ); 4354 const sigInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`); 4355 const sig = await sign(privateKey, sigInput); 4356 4357 return `${headerB64}.${payloadB64}.${base64UrlEncode(sig)}`; 4358 } 4359 4360 /** 4361 * Handle token revocation endpoint (RFC 7009). 4362 * Revokes access tokens and refresh tokens by client_id. 4363 * @param {Request} request - The incoming request 4364 * @param {URL} url - Parsed request URL 4365 * @returns {Promise<Response>} Empty 200 response on success 4366 */ 4367 async handleOAuthRevoke(request, url) { 4368 const issuer = `${url.protocol}//${url.host}`; 4369 4370 // Optional DPoP verification - if present, verify it 4371 const dpopHeader = request.headers.get('DPoP'); 4372 if (dpopHeader) { 4373 try { 4374 const dpop = await parseDpopProof( 4375 dpopHeader, 4376 'POST', 4377 `${issuer}/oauth/revoke`, 4378 ); 4379 // Check for DPoP replay attack 4380 if (!this.checkAndStoreDpopJti(dpop.jti, dpop.iat)) { 4381 return errorResponse( 4382 'invalid_dpop_proof', 4383 'DPoP proof replay detected', 4384 400, 4385 ); 4386 } 4387 } catch (err) { 4388 return errorResponse('invalid_dpop_proof', err.message, 400); 4389 } 4390 } 4391 4392 /** @type {Record<string, string>} */ 4393 let data; 4394 try { 4395 data = await parseRequestBody(request); 4396 } catch { 4397 return errorResponse('invalid_request', 'Invalid JSON body', 400); 4398 } 4399 4400 const validation = validateRequiredParams(data, ['token', 'client_id']); 4401 if (!validation.valid) { 4402 return errorResponse( 4403 'invalid_request', 4404 'Missing required parameters', 4405 400, 4406 ); 4407 } 4408 const { token, client_id: clientId } = data; 4409 4410 this.sql.exec( 4411 `DELETE FROM tokens WHERE client_id = ? AND (refresh_token = ? OR token_id = ?)`, 4412 clientId, 4413 token, 4414 token, 4415 ); 4416 4417 return new Response(null, { status: 200 }); 4418 } 4419} 4420 4421// ╔══════════════════════════════════════════════════════════════════════════════╗ 4422// ║ WORKERS ENTRY POINT ║ 4423// ║ Request handling, CORS, auth middleware ║ 4424// ╚══════════════════════════════════════════════════════════════════════════════╝ 4425 4426const corsHeaders = { 4427 'Access-Control-Allow-Origin': '*', 4428 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 4429 'Access-Control-Allow-Headers': 4430 'Content-Type, Authorization, DPoP, atproto-accept-labelers, atproto-proxy, x-bsky-topics', 4431}; 4432 4433/** 4434 * @param {Response} response 4435 * @returns {Response} 4436 */ 4437function addCorsHeaders(response) { 4438 const newHeaders = new Headers(response.headers); 4439 for (const [key, value] of Object.entries(corsHeaders)) { 4440 newHeaders.set(key, value); 4441 } 4442 return new Response(response.body, { 4443 status: response.status, 4444 statusText: response.statusText, 4445 headers: newHeaders, 4446 }); 4447} 4448 4449export default { 4450 /** 4451 * @param {Request} request 4452 * @param {Env} env 4453 */ 4454 async fetch(request, env) { 4455 // Handle CORS preflight 4456 if (request.method === 'OPTIONS') { 4457 return new Response(null, { headers: corsHeaders }); 4458 } 4459 4460 const response = await handleRequest(request, env); 4461 // Don't wrap WebSocket upgrades - they need the webSocket property preserved 4462 if (response.status === 101) { 4463 return response; 4464 } 4465 return addCorsHeaders(response); 4466 }, 4467}; 4468 4469/** 4470 * Extract subdomain from hostname (e.g., "alice" from "alice.foo.workers.dev") 4471 * @param {string} hostname 4472 * @returns {string|null} 4473 */ 4474function getSubdomain(hostname) { 4475 const parts = hostname.split('.'); 4476 // workers.dev domains: [subdomain?].[worker-name].[account].workers.dev 4477 // If more than 4 parts, first part(s) are user subdomain 4478 if (parts.length > 4 && parts.slice(-2).join('.') === 'workers.dev') { 4479 return parts.slice(0, -4).join('.'); 4480 } 4481 // Custom domains: check if there's a subdomain before the base 4482 // For now, assume no subdomain on custom domains 4483 return null; 4484} 4485 4486/** 4487 * Verify auth and return DID from token. 4488 * Supports both legacy Bearer tokens (JWT with symmetric key) and OAuth DPoP tokens. 4489 * @param {Request} request - HTTP request with Authorization header 4490 * @param {Env} env - Environment with JWT_SECRET 4491 * @param {{ fetch: (req: Request) => Promise<Response> }} [pds] - PDS stub for OAuth token verification (optional) 4492 * @returns {Promise<{did: string, scope?: string} | {error: Response}>} DID (and scope for OAuth) or error response 4493 */ 4494async function requireAuth(request, env, pds = undefined) { 4495 const authHeader = request.headers.get('Authorization'); 4496 if (!authHeader) { 4497 return { 4498 error: errorResponse('AuthRequired', 'Authentication required', 401), 4499 }; 4500 } 4501 4502 // Legacy Bearer token (symmetric JWT) 4503 if (authHeader.startsWith('Bearer ')) { 4504 const token = authHeader.slice(7); 4505 const jwtSecret = env?.JWT_SECRET; 4506 if (!jwtSecret) { 4507 return { 4508 error: errorResponse( 4509 'InternalServerError', 4510 'Server not configured for authentication', 4511 500, 4512 ), 4513 }; 4514 } 4515 4516 try { 4517 const payload = await verifyAccessJwt(token, jwtSecret); 4518 return { did: payload.sub }; 4519 } catch (err) { 4520 const message = err instanceof Error ? err.message : String(err); 4521 return { error: errorResponse('InvalidToken', message, 401) }; 4522 } 4523 } 4524 4525 // OAuth DPoP token 4526 if (authHeader.startsWith('DPoP ')) { 4527 if (!pds) { 4528 return { 4529 error: errorResponse( 4530 'InternalServerError', 4531 'DPoP tokens not supported on this endpoint', 4532 500, 4533 ), 4534 }; 4535 } 4536 4537 try { 4538 const result = await verifyOAuthAccessToken( 4539 request, 4540 authHeader.slice(5), 4541 pds, 4542 ); 4543 return result; 4544 } catch (err) { 4545 const message = err instanceof Error ? err.message : String(err); 4546 return { error: errorResponse('InvalidToken', message, 401) }; 4547 } 4548 } 4549 4550 return { 4551 error: errorResponse('AuthRequired', 'Invalid authorization type', 401), 4552 }; 4553} 4554 4555/** 4556 * Verify an OAuth DPoP-bound access token. 4557 * Validates the JWT signature, expiration, DPoP binding, and proof. 4558 * @param {Request} request - The incoming request (for DPoP validation) 4559 * @param {string} token - The access token JWT 4560 * @param {{ fetch: (req: Request) => Promise<Response> }} pdsStub - The PDS stub with fetch method 4561 * @returns {Promise<{did: string, scope?: string}>} The authenticated user's DID and scope 4562 * @throws {Error} If verification fails 4563 */ 4564async function verifyOAuthAccessToken(request, token, pdsStub) { 4565 const parts = token.split('.'); 4566 if (parts.length !== 3) throw new Error('Invalid token format'); 4567 4568 const header = JSON.parse( 4569 new TextDecoder().decode(base64UrlDecode(parts[0])), 4570 ); 4571 if (header.typ !== 'at+jwt') throw new Error('Invalid token type'); 4572 4573 // Verify signature with PDS public key (fetch from DO) 4574 const keyRes = await pdsStub.fetch( 4575 new Request('http://internal/oauth-public-key'), 4576 ); 4577 const publicKeyJwk = await keyRes.json(); 4578 const publicKey = await crypto.subtle.importKey( 4579 'jwk', 4580 publicKeyJwk, 4581 { name: 'ECDSA', namedCurve: 'P-256' }, 4582 false, 4583 ['verify'], 4584 ); 4585 4586 const signatureInput = new TextEncoder().encode(`${parts[0]}.${parts[1]}`); 4587 const signature = base64UrlDecode(parts[2]); 4588 4589 const valid = await crypto.subtle.verify( 4590 { name: 'ECDSA', hash: 'SHA-256' }, 4591 publicKey, 4592 /** @type {BufferSource} */ (signature), 4593 /** @type {BufferSource} */ (signatureInput), 4594 ); 4595 if (!valid) throw new Error('Invalid token signature'); 4596 4597 const payload = JSON.parse( 4598 new TextDecoder().decode(base64UrlDecode(parts[1])), 4599 ); 4600 4601 if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) { 4602 throw new Error('Token expired'); 4603 } 4604 4605 if (!payload.cnf?.jkt) throw new Error('Token missing DPoP binding'); 4606 4607 const dpopHeader = request.headers.get('DPoP'); 4608 if (!dpopHeader) throw new Error('DPoP proof required'); 4609 4610 const url = new URL(request.url); 4611 const dpop = await parseDpopProof( 4612 dpopHeader, 4613 request.method, 4614 `${url.protocol}//${url.host}${url.pathname}`, 4615 payload.cnf.jkt, 4616 token, 4617 ); 4618 4619 // Check for DPoP jti replay 4620 const jtiRes = await pdsStub.fetch( 4621 new Request('http://internal/check-dpop-jti', { 4622 method: 'POST', 4623 body: JSON.stringify({ jti: dpop.jti, iat: dpop.iat }), 4624 }), 4625 ); 4626 const { fresh } = await jtiRes.json(); 4627 if (!fresh) throw new Error('DPoP proof replay detected'); 4628 4629 return { did: payload.sub, scope: payload.scope }; 4630} 4631 4632// ╔══════════════════════════════════════════════════════════════════════════════╗ 4633// ║ SCOPES ║ 4634// ║ OAuth scope parsing and permission checking ║ 4635// ╚══════════════════════════════════════════════════════════════════════════════╝ 4636 4637/** 4638 * Parse a repo scope string into collection and actions. 4639 * Official format: repo:collection?action=create&action=update 4640 * Or: repo?collection=foo&action=create 4641 * Without actions defaults to all: create, update, delete 4642 * @param {string} scope - The scope string to parse 4643 * @returns {{ collection: string, actions: string[] } | null} Parsed scope or null if invalid 4644 */ 4645export function parseRepoScope(scope) { 4646 if (!scope.startsWith('repo:') && !scope.startsWith('repo?')) return null; 4647 4648 const ALL_ACTIONS = ['create', 'update', 'delete']; 4649 let collection; 4650 let actions; 4651 4652 const questionIdx = scope.indexOf('?'); 4653 if (questionIdx === -1) { 4654 // repo:collection (no query params = all actions) 4655 collection = scope.slice(5); 4656 actions = ALL_ACTIONS; 4657 } else { 4658 // Parse query parameters 4659 const queryString = scope.slice(questionIdx + 1); 4660 const params = new URLSearchParams(queryString); 4661 const pathPart = scope.startsWith('repo:') 4662 ? scope.slice(5, questionIdx) 4663 : ''; 4664 4665 collection = pathPart || params.get('collection'); 4666 actions = params.getAll('action'); 4667 if (actions.length === 0) actions = ALL_ACTIONS; 4668 } 4669 4670 if (!collection) return null; 4671 4672 // Validate actions 4673 const validActions = [ 4674 ...new Set(actions.filter((a) => ALL_ACTIONS.includes(a))), 4675 ]; 4676 if (validActions.length === 0) return null; 4677 4678 return { collection, actions: validActions }; 4679} 4680 4681/** 4682 * Parse a blob scope string into its components. 4683 * Format: blob:<mime>[,<mime>...] 4684 * @param {string} scope - The scope string to parse 4685 * @returns {{ accept: string[] } | null} Parsed scope or null if invalid 4686 */ 4687export function parseBlobScope(scope) { 4688 if (!scope.startsWith('blob:')) return null; 4689 4690 const mimeStr = scope.slice(5); // Remove 'blob:' 4691 if (!mimeStr) return null; 4692 4693 const accept = mimeStr.split(',').filter((m) => m); 4694 if (accept.length === 0) return null; 4695 4696 return { accept }; 4697} 4698 4699/** 4700 * Check if a MIME pattern matches an actual MIME type. 4701 * @param {string} pattern - MIME pattern (e.g., 'image/\*', '\*\/\*', 'image/png') 4702 * @param {string} mime - Actual MIME type to check 4703 * @returns {boolean} Whether the pattern matches 4704 */ 4705export function matchesMime(pattern, mime) { 4706 const p = pattern.toLowerCase(); 4707 const m = mime.toLowerCase(); 4708 4709 if (p === '*/*') return true; 4710 4711 if (p.endsWith('/*')) { 4712 const pType = p.slice(0, -2); 4713 const mType = m.split('/')[0]; 4714 return pType === mType; 4715 } 4716 4717 return p === m; 4718} 4719 4720/** 4721 * Error thrown when a required scope is missing. 4722 */ 4723class ScopeMissingError extends Error { 4724 /** 4725 * @param {string} scope - The missing scope 4726 */ 4727 constructor(scope) { 4728 super(`Missing required scope "${scope}"`); 4729 this.name = 'ScopeMissingError'; 4730 this.scope = scope; 4731 this.status = 403; 4732 } 4733} 4734 4735/** 4736 * Parses and checks OAuth scope permissions. 4737 */ 4738export class ScopePermissions { 4739 /** 4740 * @param {string | undefined} scopeString - Space-separated scope string 4741 */ 4742 constructor(scopeString) { 4743 /** @type {Set<string>} */ 4744 this.scopes = new Set( 4745 scopeString ? scopeString.split(' ').filter((s) => s) : [], 4746 ); 4747 4748 /** @type {Array<{ collection: string, actions: string[] }>} */ 4749 this.repoPermissions = []; 4750 4751 /** @type {Array<{ accept: string[] }>} */ 4752 this.blobPermissions = []; 4753 4754 for (const scope of this.scopes) { 4755 const repo = parseRepoScope(scope); 4756 if (repo) this.repoPermissions.push(repo); 4757 4758 const blob = parseBlobScope(scope); 4759 if (blob) this.blobPermissions.push(blob); 4760 } 4761 } 4762 4763 /** 4764 * Check if full access is granted (atproto or transition:generic). 4765 * @returns {boolean} 4766 */ 4767 hasFullAccess() { 4768 return this.scopes.has('atproto') || this.scopes.has('transition:generic'); 4769 } 4770 4771 /** 4772 * Check if a repo operation is allowed. 4773 * @param {string} collection - The collection NSID 4774 * @param {string} action - The action (create, update, delete) 4775 * @returns {boolean} 4776 */ 4777 allowsRepo(collection, action) { 4778 if (this.hasFullAccess()) return true; 4779 4780 for (const perm of this.repoPermissions) { 4781 const collectionMatch = 4782 perm.collection === '*' || perm.collection === collection; 4783 const actionMatch = perm.actions.includes(action); 4784 if (collectionMatch && actionMatch) return true; 4785 } 4786 4787 return false; 4788 } 4789 4790 /** 4791 * Assert that a repo operation is allowed, throwing if not. 4792 * @param {string} collection - The collection NSID 4793 * @param {string} action - The action (create, update, delete) 4794 * @throws {ScopeMissingError} 4795 */ 4796 assertRepo(collection, action) { 4797 if (!this.allowsRepo(collection, action)) { 4798 throw new ScopeMissingError(`repo:${collection}?action=${action}`); 4799 } 4800 } 4801 4802 /** 4803 * Check if a blob operation is allowed. 4804 * @param {string} mime - The MIME type of the blob 4805 * @returns {boolean} 4806 */ 4807 allowsBlob(mime) { 4808 if (this.hasFullAccess()) return true; 4809 4810 for (const perm of this.blobPermissions) { 4811 for (const pattern of perm.accept) { 4812 if (matchesMime(pattern, mime)) return true; 4813 } 4814 } 4815 4816 return false; 4817 } 4818 4819 /** 4820 * Assert that a blob operation is allowed, throwing if not. 4821 * @param {string} mime - The MIME type of the blob 4822 * @throws {ScopeMissingError} 4823 */ 4824 assertBlob(mime) { 4825 if (!this.allowsBlob(mime)) { 4826 throw new ScopeMissingError(`blob:${mime}`); 4827 } 4828 } 4829} 4830 4831// ╔══════════════════════════════════════════════════════════════════════════════╗ 4832// ║ CONSENT PAGE DISPLAY ║ 4833// ║ OAuth consent page rendering with scope visualization ║ 4834// ╚══════════════════════════════════════════════════════════════════════════════╝ 4835 4836/** 4837 * Parse scope string into display-friendly structure. 4838 * @param {string} scope - Space-separated scope string 4839 * @returns {{ hasAtproto: boolean, hasTransitionGeneric: boolean, repoPermissions: Map<string, {create: boolean, update: boolean, delete: boolean}>, blobPermissions: string[] }} 4840 */ 4841export function parseScopesForDisplay(scope) { 4842 const scopes = scope.split(' ').filter((s) => s); 4843 4844 const repoPermissions = new Map(); 4845 4846 for (const s of scopes) { 4847 const repo = parseRepoScope(s); 4848 if (repo) { 4849 const existing = repoPermissions.get(repo.collection) || { 4850 create: false, 4851 update: false, 4852 delete: false, 4853 }; 4854 for (const action of repo.actions) { 4855 existing[action] = true; 4856 } 4857 repoPermissions.set(repo.collection, existing); 4858 } 4859 } 4860 4861 const blobPermissions = []; 4862 for (const s of scopes) { 4863 const blob = parseBlobScope(s); 4864 if (blob) blobPermissions.push(...blob.accept); 4865 } 4866 4867 return { 4868 hasAtproto: scopes.includes('atproto'), 4869 hasTransitionGeneric: scopes.includes('transition:generic'), 4870 repoPermissions, 4871 blobPermissions, 4872 }; 4873} 4874 4875/** 4876 * Escape HTML special characters. 4877 * @param {string} s 4878 * @returns {string} 4879 */ 4880function escapeHtml(s) { 4881 return s 4882 .replace(/&/g, '&amp;') 4883 .replace(/</g, '&lt;') 4884 .replace(/>/g, '&gt;') 4885 .replace(/"/g, '&quot;'); 4886} 4887 4888/** 4889 * Render repo permissions as HTML table. 4890 * @param {Map<string, {create: boolean, update: boolean, delete: boolean}>} repoPermissions 4891 * @returns {string} HTML string 4892 */ 4893function renderRepoTable(repoPermissions) { 4894 if (repoPermissions.size === 0) return ''; 4895 4896 let rows = ''; 4897 for (const [collection, actions] of repoPermissions) { 4898 const displayCollection = collection === '*' ? '* (any)' : collection; 4899 rows += `<tr> 4900 <td>${escapeHtml(displayCollection)}</td> 4901 <td class="check">${actions.create ? '✓' : ''}</td> 4902 <td class="check">${actions.update ? '✓' : ''}</td> 4903 <td class="check">${actions.delete ? '✓' : ''}</td> 4904 </tr>`; 4905 } 4906 4907 return `<div class="permissions-section"> 4908 <div class="section-label">Repository permissions:</div> 4909 <table class="permissions-table"> 4910 <thead><tr><th>Collection</th><th title="Create">C</th><th title="Update">U</th><th title="Delete">D</th></tr></thead> 4911 <tbody>${rows}</tbody> 4912 </table> 4913 </div>`; 4914} 4915 4916/** 4917 * Render blob permissions as HTML list. 4918 * @param {string[]} blobPermissions 4919 * @returns {string} HTML string 4920 */ 4921function renderBlobList(blobPermissions) { 4922 if (blobPermissions.length === 0) return ''; 4923 4924 const items = blobPermissions 4925 .map( 4926 (mime) => 4927 `<li>${escapeHtml(mime === '*/*' ? 'All file types' : mime)}</li>`, 4928 ) 4929 .join(''); 4930 4931 return `<div class="permissions-section"> 4932 <div class="section-label">Upload permissions:</div> 4933 <ul class="blob-list">${items}</ul> 4934 </div>`; 4935} 4936 4937/** 4938 * Render full permissions display based on parsed scopes. 4939 * @param {{ hasAtproto: boolean, hasTransitionGeneric: boolean, repoPermissions: Map<string, {create: boolean, update: boolean, delete: boolean}>, blobPermissions: string[] }} parsed 4940 * @returns {string} HTML string 4941 */ 4942function renderPermissionsHtml(parsed) { 4943 if (parsed.hasTransitionGeneric) { 4944 return `<div class="warning">⚠️ Full repository access requested<br> 4945 <small>This app can create, update, and delete any data in your repository.</small></div>`; 4946 } 4947 4948 if ( 4949 parsed.repoPermissions.size === 0 && 4950 parsed.blobPermissions.length === 0 4951 ) { 4952 return ''; 4953 } 4954 4955 return ( 4956 renderRepoTable(parsed.repoPermissions) + 4957 renderBlobList(parsed.blobPermissions) 4958 ); 4959} 4960 4961/** 4962 * Render the OAuth consent page HTML. 4963 * @param {{ clientName: string, clientId: string, scope: string, requestUri: string, error?: string }} params 4964 * @returns {string} HTML page content 4965 */ 4966function renderConsentPage({ 4967 clientName, 4968 clientId, 4969 scope, 4970 requestUri, 4971 error = '', 4972}) { 4973 const parsed = parseScopesForDisplay(scope); 4974 const isIdentityOnly = 4975 parsed.repoPermissions.size === 0 && 4976 parsed.blobPermissions.length === 0 && 4977 !parsed.hasTransitionGeneric; 4978 4979 return `<!DOCTYPE html> 4980<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> 4981<title>Authorize</title> 4982<style> 4983*{box-sizing:border-box} 4984body{font-family:system-ui,sans-serif;max-width:400px;margin:40px auto;padding:20px;background:#1a1a1a;color:#e0e0e0} 4985h2{color:#fff;margin-bottom:24px} 4986p{color:#b0b0b0;line-height:1.5} 4987b{color:#fff} 4988.error{color:#ff6b6b;background:#2d1f1f;padding:12px;margin:12px 0;border-radius:6px;border:1px solid #4a2020} 4989label{display:block;margin:16px 0 6px;color:#b0b0b0;font-size:14px} 4990input[type="password"]{width:100%;padding:12px;background:#2a2a2a;border:1px solid #404040;border-radius:6px;color:#fff;font-size:16px} 4991input[type="password"]:focus{outline:none;border-color:#4a9eff;box-shadow:0 0 0 2px rgba(74,158,255,0.2)} 4992.actions{display:flex;gap:12px;margin-top:24px} 4993button{flex:1;padding:12px 20px;border-radius:6px;font-size:16px;font-weight:500;cursor:pointer;transition:background 0.15s} 4994.deny{background:#2a2a2a;color:#e0e0e0;border:1px solid #404040} 4995.deny:hover{background:#333} 4996.approve{background:#2563eb;color:#fff;border:none} 4997.approve:hover{background:#1d4ed8} 4998.permissions-section{margin:16px 0} 4999.section-label{color:#b0b0b0;font-size:13px;margin-bottom:8px} 5000.permissions-table{width:100%;border-collapse:collapse;font-size:13px} 5001.permissions-table th{color:#808080;font-weight:normal;text-align:left;padding:4px 8px;border-bottom:1px solid #333} 5002.permissions-table th:not(:first-child){text-align:center;width:32px} 5003.permissions-table td{padding:4px 8px;border-bottom:1px solid #2a2a2a} 5004.permissions-table td:not(:first-child){text-align:center} 5005.permissions-table .check{color:#4ade80} 5006.blob-list{margin:0;padding-left:20px;color:#e0e0e0;font-size:13px} 5007.blob-list li{margin:4px 0} 5008.warning{background:#3d2f00;border:1px solid #5c4a00;border-radius:6px;padding:12px;color:#fbbf24;margin:16px 0} 5009.warning small{color:#d4a000;display:block;margin-top:4px} 5010</style></head> 5011<body><h2>Sign in to authorize</h2> 5012<p><b>${escapeHtml(clientName)}</b> ${isIdentityOnly ? 'wants to uniquely identify you through your account.' : 'wants to access your account.'}</p> 5013${renderPermissionsHtml(parsed)} 5014${error ? `<p class="error">${escapeHtml(error)}</p>` : ''} 5015<form method="POST" action="/oauth/authorize"> 5016<input type="hidden" name="request_uri" value="${escapeHtml(requestUri)}"> 5017<input type="hidden" name="client_id" value="${escapeHtml(clientId)}"> 5018<label>Password</label><input type="password" name="password" required autofocus> 5019<div class="actions"><button type="submit" name="action" value="deny" class="deny" formnovalidate>Deny</button> 5020<button type="submit" name="action" value="approve" class="approve">Authorize</button></div> 5021</form></body></html>`; 5022} 5023 5024/** 5025 * @param {Request} request 5026 * @param {Env} env 5027 */ 5028async function handleAuthenticatedBlobUpload(request, env) { 5029 // Get default PDS for OAuth token verification 5030 const defaultPds = getDefaultPds(env); 5031 const auth = await requireAuth(request, env, defaultPds); 5032 if ('error' in auth) return auth.error; 5033 5034 // Validate scope for blob upload 5035 if (auth.scope !== undefined) { 5036 const contentType = 5037 request.headers.get('content-type') || 'application/octet-stream'; 5038 const permissions = new ScopePermissions(auth.scope); 5039 if (!permissions.allowsBlob(contentType)) { 5040 return errorResponse( 5041 'Forbidden', 5042 `Missing required scope "blob:${contentType}"`, 5043 403, 5044 ); 5045 } 5046 } 5047 // Legacy tokens without scope are trusted (backward compat) 5048 5049 // Route to the user's DO based on their DID from the token 5050 const id = env.PDS.idFromName(auth.did); 5051 const pds = env.PDS.get(id); 5052 // Pass x-authed-did so DO knows auth was already done (avoids DPoP replay detection) 5053 return pds.fetch( 5054 new Request(request.url, { 5055 method: request.method, 5056 headers: { 5057 ...Object.fromEntries(request.headers), 5058 'x-authed-did': auth.did, 5059 }, 5060 body: request.body, 5061 }), 5062 ); 5063} 5064 5065/** 5066 * @param {Request} request 5067 * @param {Env} env 5068 */ 5069async function handleAuthenticatedRepoWrite(request, env) { 5070 // Get default PDS for OAuth token verification 5071 const defaultPds = getDefaultPds(env); 5072 const auth = await requireAuth(request, env, defaultPds); 5073 if ('error' in auth) return auth.error; 5074 5075 const body = await request.json(); 5076 const repo = body.repo; 5077 if (!repo) { 5078 return errorResponse('InvalidRequest', 'missing repo param', 400); 5079 } 5080 5081 if (auth.did !== repo) { 5082 return errorResponse('Forbidden', "Cannot modify another user's repo", 403); 5083 } 5084 5085 // Granular scope validation for OAuth tokens 5086 if (auth.scope !== undefined) { 5087 const permissions = new ScopePermissions(auth.scope); 5088 const url = new URL(request.url); 5089 const endpoint = url.pathname; 5090 5091 if (endpoint === '/xrpc/com.atproto.repo.createRecord') { 5092 const collection = body.collection; 5093 if (!collection) { 5094 return errorResponse('InvalidRequest', 'missing collection param', 400); 5095 } 5096 if (!permissions.allowsRepo(collection, 'create')) { 5097 return errorResponse( 5098 'Forbidden', 5099 `Missing required scope "repo:${collection}:create"`, 5100 403, 5101 ); 5102 } 5103 } else if (endpoint === '/xrpc/com.atproto.repo.putRecord') { 5104 const collection = body.collection; 5105 if (!collection) { 5106 return errorResponse('InvalidRequest', 'missing collection param', 400); 5107 } 5108 // putRecord requires both create and update permissions 5109 if ( 5110 !permissions.allowsRepo(collection, 'create') || 5111 !permissions.allowsRepo(collection, 'update') 5112 ) { 5113 const missing = !permissions.allowsRepo(collection, 'create') 5114 ? 'create' 5115 : 'update'; 5116 return errorResponse( 5117 'Forbidden', 5118 `Missing required scope "repo:${collection}:${missing}"`, 5119 403, 5120 ); 5121 } 5122 } else if (endpoint === '/xrpc/com.atproto.repo.deleteRecord') { 5123 const collection = body.collection; 5124 if (!collection) { 5125 return errorResponse('InvalidRequest', 'missing collection param', 400); 5126 } 5127 if (!permissions.allowsRepo(collection, 'delete')) { 5128 return errorResponse( 5129 'Forbidden', 5130 `Missing required scope "repo:${collection}:delete"`, 5131 403, 5132 ); 5133 } 5134 } else if (endpoint === '/xrpc/com.atproto.repo.applyWrites') { 5135 const writes = body.writes || []; 5136 for (const write of writes) { 5137 const collection = write.collection; 5138 if (!collection) continue; 5139 5140 let action; 5141 if (write.$type === 'com.atproto.repo.applyWrites#create') { 5142 action = 'create'; 5143 } else if (write.$type === 'com.atproto.repo.applyWrites#update') { 5144 action = 'update'; 5145 } else if (write.$type === 'com.atproto.repo.applyWrites#delete') { 5146 action = 'delete'; 5147 } else { 5148 continue; 5149 } 5150 5151 if (!permissions.allowsRepo(collection, action)) { 5152 return errorResponse( 5153 'Forbidden', 5154 `Missing required scope "repo:${collection}:${action}"`, 5155 403, 5156 ); 5157 } 5158 } 5159 } 5160 } 5161 // Legacy tokens without scope are trusted (backward compat) 5162 5163 const id = env.PDS.idFromName(repo); 5164 const pds = env.PDS.get(id); 5165 const response = await pds.fetch( 5166 new Request(request.url, { 5167 method: 'POST', 5168 headers: request.headers, 5169 body: JSON.stringify(body), 5170 }), 5171 ); 5172 5173 // Notify relay of updates on successful writes 5174 if (response.ok) { 5175 const url = new URL(request.url); 5176 notifyCrawlers(env, url.hostname); 5177 } 5178 5179 return response; 5180} 5181 5182/** 5183 * @param {Request} request 5184 * @param {Env} env 5185 */ 5186async function handleRequest(request, env) { 5187 const url = new URL(request.url); 5188 const subdomain = getSubdomain(url.hostname); 5189 5190 // Handle resolution via subdomain or bare domain 5191 if (url.pathname === '/.well-known/atproto-did') { 5192 // Look up handle -> DID in default DO 5193 // Use subdomain if present, otherwise try bare hostname as handle 5194 const handleToResolve = subdomain || url.hostname; 5195 const defaultPds = getDefaultPds(env); 5196 const resolveRes = await defaultPds.fetch( 5197 new Request( 5198 `http://internal/resolve-handle?handle=${encodeURIComponent(handleToResolve)}`, 5199 ), 5200 ); 5201 if (!resolveRes.ok) { 5202 return new Response('Handle not found', { status: 404 }); 5203 } 5204 const { did } = await resolveRes.json(); 5205 return new Response(did, { headers: { 'Content-Type': 'text/plain' } }); 5206 } 5207 5208 // describeServer - works on bare domain 5209 if (url.pathname === '/xrpc/com.atproto.server.describeServer') { 5210 const defaultPds = getDefaultPds(env); 5211 const newReq = new Request(request.url, { 5212 method: request.method, 5213 headers: { 5214 ...Object.fromEntries(request.headers), 5215 'x-hostname': url.hostname, 5216 }, 5217 }); 5218 return defaultPds.fetch(newReq); 5219 } 5220 5221 // Session endpoints - route to default DO (has handleMap for identifier resolution) 5222 const sessionEndpoints = [ 5223 '/xrpc/com.atproto.server.createSession', 5224 '/xrpc/com.atproto.server.getSession', 5225 '/xrpc/com.atproto.server.refreshSession', 5226 ]; 5227 if (sessionEndpoints.includes(url.pathname)) { 5228 const defaultPds = getDefaultPds(env); 5229 return defaultPds.fetch(request); 5230 } 5231 5232 // Proxy app.bsky.* endpoints to Bluesky AppView 5233 if (url.pathname.startsWith('/xrpc/app.bsky.')) { 5234 // Get default PDS for OAuth token verification 5235 const defaultPds = getDefaultPds(env); 5236 // Authenticate the user first 5237 const auth = await requireAuth(request, env, defaultPds); 5238 if ('error' in auth) return auth.error; 5239 5240 // Route to the user's DO instance to create service auth and proxy 5241 const id = env.PDS.idFromName(auth.did); 5242 const pds = env.PDS.get(id); 5243 return pds.fetch( 5244 new Request(request.url, { 5245 method: request.method, 5246 headers: { 5247 ...Object.fromEntries(request.headers), 5248 'x-authed-did': auth.did, // Pass the authenticated DID 5249 }, 5250 body: 5251 request.method !== 'GET' && request.method !== 'HEAD' 5252 ? request.body 5253 : undefined, 5254 }), 5255 ); 5256 } 5257 5258 // Handle registration routes - go to default DO 5259 if ( 5260 url.pathname === '/register-handle' || 5261 url.pathname === '/resolve-handle' 5262 ) { 5263 const defaultPds = getDefaultPds(env); 5264 return defaultPds.fetch(request); 5265 } 5266 5267 // resolveHandle XRPC endpoint 5268 if (url.pathname === '/xrpc/com.atproto.identity.resolveHandle') { 5269 const handle = url.searchParams.get('handle'); 5270 if (!handle) { 5271 return errorResponse('InvalidRequest', 'missing handle param', 400); 5272 } 5273 const defaultPds = getDefaultPds(env); 5274 const resolveRes = await defaultPds.fetch( 5275 new Request( 5276 `http://internal/resolve-handle?handle=${encodeURIComponent(handle)}`, 5277 ), 5278 ); 5279 if (!resolveRes.ok) { 5280 return errorResponse('InvalidRequest', 'Unable to resolve handle', 400); 5281 } 5282 const { did } = await resolveRes.json(); 5283 return Response.json({ did }); 5284 } 5285 5286 // subscribeRepos WebSocket - route to default instance for firehose 5287 if (url.pathname === '/xrpc/com.atproto.sync.subscribeRepos') { 5288 const defaultPds = getDefaultPds(env); 5289 return defaultPds.fetch(request); 5290 } 5291 5292 // listRepos needs to aggregate from all registered DIDs 5293 if (url.pathname === '/xrpc/com.atproto.sync.listRepos') { 5294 const defaultPds = getDefaultPds(env); 5295 const regRes = await defaultPds.fetch( 5296 new Request('http://internal/get-registered-dids'), 5297 ); 5298 const { dids } = await regRes.json(); 5299 5300 const repos = []; 5301 for (const did of dids) { 5302 const id = env.PDS.idFromName(did); 5303 const pds = env.PDS.get(id); 5304 const infoRes = await pds.fetch(new Request('http://internal/repo-info')); 5305 const info = await infoRes.json(); 5306 if (info.head) { 5307 repos.push({ did, head: info.head, rev: info.rev, active: true }); 5308 } 5309 } 5310 return Response.json({ repos, cursor: undefined }); 5311 } 5312 5313 // Repo endpoints use ?repo= param instead of ?did= 5314 if ( 5315 url.pathname === '/xrpc/com.atproto.repo.describeRepo' || 5316 url.pathname === '/xrpc/com.atproto.repo.listRecords' || 5317 url.pathname === '/xrpc/com.atproto.repo.getRecord' 5318 ) { 5319 const repo = url.searchParams.get('repo'); 5320 if (!repo) { 5321 return errorResponse('InvalidRequest', 'missing repo param', 400); 5322 } 5323 5324 // Check for atproto-proxy header - if present, proxy to specified service 5325 const proxyHeader = request.headers.get('atproto-proxy'); 5326 if (proxyHeader) { 5327 const parsed = parseAtprotoProxyHeader(proxyHeader); 5328 if (!parsed) { 5329 // Header present but malformed 5330 return errorResponse( 5331 'InvalidRequest', 5332 `Malformed atproto-proxy header: ${proxyHeader}`, 5333 400, 5334 ); 5335 } 5336 const serviceUrl = getKnownServiceUrl(parsed.did, parsed.serviceId); 5337 if (serviceUrl) { 5338 return proxyToService(request, serviceUrl); 5339 } 5340 // Unknown service - could add DID resolution here in the future 5341 return errorResponse( 5342 'InvalidRequest', 5343 `Unknown proxy service: ${proxyHeader}`, 5344 400, 5345 ); 5346 } 5347 5348 // No proxy header - handle locally (returns appropriate error if DID not found) 5349 const id = env.PDS.idFromName(repo); 5350 const pds = env.PDS.get(id); 5351 return pds.fetch(request); 5352 } 5353 5354 // Sync endpoints use ?did= param 5355 if ( 5356 url.pathname === '/xrpc/com.atproto.sync.getLatestCommit' || 5357 url.pathname === '/xrpc/com.atproto.sync.getRepoStatus' || 5358 url.pathname === '/xrpc/com.atproto.sync.getRepo' || 5359 url.pathname === '/xrpc/com.atproto.sync.getRecord' || 5360 url.pathname === '/xrpc/com.atproto.sync.getBlob' || 5361 url.pathname === '/xrpc/com.atproto.sync.listBlobs' 5362 ) { 5363 const did = url.searchParams.get('did'); 5364 if (!did) { 5365 return errorResponse('InvalidRequest', 'missing did param', 400); 5366 } 5367 const id = env.PDS.idFromName(did); 5368 const pds = env.PDS.get(id); 5369 return pds.fetch(request); 5370 } 5371 5372 // Blob upload endpoint (binary body, uses DID from token) 5373 if (url.pathname === '/xrpc/com.atproto.repo.uploadBlob') { 5374 return handleAuthenticatedBlobUpload(request, env); 5375 } 5376 5377 // Authenticated repo write endpoints 5378 const repoWriteEndpoints = [ 5379 '/xrpc/com.atproto.repo.createRecord', 5380 '/xrpc/com.atproto.repo.deleteRecord', 5381 '/xrpc/com.atproto.repo.putRecord', 5382 '/xrpc/com.atproto.repo.applyWrites', 5383 ]; 5384 if (repoWriteEndpoints.includes(url.pathname)) { 5385 return handleAuthenticatedRepoWrite(request, env); 5386 } 5387 5388 // Health check endpoint 5389 if (url.pathname === '/xrpc/_health') { 5390 return Response.json({ version: VERSION }); 5391 } 5392 5393 // Root path - ASCII art 5394 if (url.pathname === '/') { 5395 const ascii = ` 5396 ██████╗ ██████╗ ███████╗ ██╗ ███████╗ 5397 ██╔══██╗ ██╔══██╗ ██╔════╝ ██║ ██╔════╝ 5398 ██████╔╝ ██║ ██║ ███████╗ ██║ ███████╗ 5399 ██╔═══╝ ██║ ██║ ╚════██║ ██ ██║ ╚════██║ 5400 ██║ ██████╔╝ ███████║ ██╗ ╚█████╔╝ ███████║ 5401 ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚════╝ ╚══════╝ 5402 5403 ATProto PDS on Cloudflare Workers 5404`; 5405 return new Response(ascii, { 5406 headers: { 'Content-Type': 'text/plain; charset=utf-8' }, 5407 }); 5408 } 5409 5410 // On init, register this DID with the default instance (requires ?did= param, no auth yet) 5411 if (url.pathname === '/init' && request.method === 'POST') { 5412 const did = url.searchParams.get('did'); 5413 if (!did) { 5414 return errorResponse('InvalidRequest', 'missing did param', 400); 5415 } 5416 const body = await request.json(); 5417 5418 // Register with default instance for discovery 5419 const defaultPds = getDefaultPds(env); 5420 await defaultPds.fetch( 5421 new Request('http://internal/register-did', { 5422 method: 'POST', 5423 body: JSON.stringify({ did }), 5424 }), 5425 ); 5426 5427 // Register handle if provided 5428 if (body.handle) { 5429 await defaultPds.fetch( 5430 new Request('http://internal/register-handle', { 5431 method: 'POST', 5432 body: JSON.stringify({ did, handle: body.handle }), 5433 }), 5434 ); 5435 } 5436 5437 // Also initialize default instance with identity for OAuth (single-user PDS) 5438 await defaultPds.fetch( 5439 new Request('http://internal/init', { 5440 method: 'POST', 5441 body: JSON.stringify(body), 5442 }), 5443 ); 5444 5445 // Forward to the actual PDS instance 5446 const id = env.PDS.idFromName(did); 5447 const pds = env.PDS.get(id); 5448 return pds.fetch( 5449 new Request(request.url, { 5450 method: 'POST', 5451 headers: request.headers, 5452 body: JSON.stringify(body), 5453 }), 5454 ); 5455 } 5456 5457 // OAuth endpoints - route to default PDS instance 5458 const oauthEndpoints = [ 5459 '/.well-known/oauth-authorization-server', 5460 '/.well-known/oauth-protected-resource', 5461 '/oauth/jwks', 5462 '/oauth/par', 5463 '/oauth/authorize', 5464 '/oauth/token', 5465 '/oauth/revoke', 5466 ]; 5467 if (oauthEndpoints.includes(url.pathname)) { 5468 const defaultPds = getDefaultPds(env); 5469 return defaultPds.fetch(request); 5470 } 5471 5472 // Unknown endpoint 5473 return errorResponse('NotFound', 'Endpoint not found', 404); 5474}