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, '&')
4883 .replace(/</g, '<')
4884 .replace(/>/g, '>')
4885 .replace(/"/g, '"');
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}