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