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