A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto
pds
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// ║ • CAR file building for repo sync ║
21// ║ • R2 blob storage with MIME detection ║
22// ║ • SQLite persistence via Durable Objects ║
23// ║ ║
24// ║ @see https://atproto.com ║
25// ║ ║
26// ╚══════════════════════════════════════════════════════════════════════════════╝
27
28// ╔══════════════════════════════════════════════════════════════════════════════╗
29// ║ TYPES & CONSTANTS ║
30// ║ Environment bindings, SQL row types, protocol constants ║
31// ╚══════════════════════════════════════════════════════════════════════════════╝
32
33// CBOR primitive markers (RFC 8949)
34const CBOR_FALSE = 0xf4;
35const CBOR_TRUE = 0xf5;
36const CBOR_NULL = 0xf6;
37
38// DAG-CBOR CID link tag
39const CBOR_TAG_CID = 42;
40
41// CID codec constants
42const CODEC_DAG_CBOR = 0x71;
43const CODEC_RAW = 0x55;
44
45// TID generation constants
46const TID_CHARS = '234567abcdefghijklmnopqrstuvwxyz';
47let lastTimestamp = 0;
48const clockId = Math.floor(Math.random() * 1024);
49
50// P-256 curve order N (for low-S signature normalization)
51const P256_N = BigInt(
52 '0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551',
53);
54const P256_N_DIV_2 = P256_N / 2n;
55
56// Crawler notification throttle
57const CRAWL_NOTIFY_THRESHOLD = 20 * 60 * 1000; // 20 minutes (matches official PDS)
58let lastCrawlNotify = 0;
59
60/**
61 * Cloudflare Workers environment bindings
62 * @typedef {Object} Env
63 * @property {string} JWT_SECRET - Secret for signing/verifying session JWTs
64 * @property {string} [RELAY_HOST] - Relay host to notify of repo updates (e.g., bsky.network)
65 * @property {string} [APPVIEW_URL] - AppView URL for proxying app.bsky.* requests
66 * @property {string} [APPVIEW_DID] - AppView DID for service auth
67 * @property {string} [PDS_PASSWORD] - Password for createSession authentication
68 * @property {DurableObjectNamespace} PDS - Durable Object namespace for PDS instances
69 * @property {R2Bucket} [BLOB_BUCKET] - R2 bucket for blob storage (legacy name)
70 * @property {R2Bucket} [BLOBS] - R2 bucket for blob storage
71 */
72
73/**
74 * Row from the `blocks` table - stores raw CBOR-encoded data blocks
75 * @typedef {Object} BlockRow
76 * @property {string} cid - Content ID (CIDv1 base32lower)
77 * @property {ArrayBuffer} data - Raw block data (CBOR-encoded)
78 */
79
80/**
81 * Row from the `records` table - indexes AT Protocol records
82 * @typedef {Object} RecordRow
83 * @property {string} uri - AT URI (at://did/collection/rkey)
84 * @property {string} cid - Content ID of the record block
85 * @property {string} collection - Collection NSID (e.g., app.bsky.feed.post)
86 * @property {string} rkey - Record key within collection
87 * @property {ArrayBuffer} value - CBOR-encoded record value
88 */
89
90/**
91 * Row from the `commits` table - tracks repo commit history
92 * @typedef {Object} CommitRow
93 * @property {string} cid - Content ID of the signed commit block
94 * @property {string} rev - Revision TID for ordering
95 * @property {string|null} prev - Previous commit CID (null for first commit)
96 */
97
98/**
99 * Row from the `seq_events` table - stores firehose events for subscribeRepos
100 * @typedef {Object} SeqEventRow
101 * @property {number} seq - Sequence number for cursor-based pagination
102 * @property {string} did - DID of the repo that changed
103 * @property {string} commit_cid - CID of the commit
104 * @property {ArrayBuffer|Uint8Array} evt - CBOR-encoded event with ops, blocks, rev, time
105 */
106
107/**
108 * Row from the `blob` table - tracks uploaded blob metadata
109 * @typedef {Object} BlobRow
110 * @property {string} cid - Content ID of the blob (raw codec)
111 * @property {string} mimeType - MIME type (sniffed or from Content-Type header)
112 * @property {number} size - Size in bytes
113 * @property {string} createdAt - ISO timestamp of upload
114 */
115
116/**
117 * Decoded JWT payload for session tokens
118 * @typedef {Object} JwtPayload
119 * @property {string} [scope] - Token scope (e.g., "com.atproto.access")
120 * @property {string} sub - Subject DID (the authenticated user)
121 * @property {string} [aud] - Audience (for refresh tokens, should match sub)
122 * @property {number} [iat] - Issued-at timestamp (Unix seconds)
123 * @property {number} [exp] - Expiration timestamp (Unix seconds)
124 * @property {string} [jti] - Unique token identifier
125 */
126
127// ╔══════════════════════════════════════════════════════════════════════════════╗
128// ║ UTILITIES ║
129// ║ Error responses, byte conversion, base encoding ║
130// ╚══════════════════════════════════════════════════════════════════════════════╝
131
132/**
133 * @param {string} error - Error code
134 * @param {string} message - Error message
135 * @param {number} status - HTTP status code
136 * @returns {Response}
137 */
138function errorResponse(error, message, status) {
139 return Response.json({ error, message }, { status });
140}
141
142/**
143 * Convert bytes to hexadecimal string
144 * @param {Uint8Array} bytes - Bytes to convert
145 * @returns {string} Hex string
146 */
147export function bytesToHex(bytes) {
148 return Array.from(bytes)
149 .map((b) => b.toString(16).padStart(2, '0'))
150 .join('');
151}
152
153/**
154 * Convert hexadecimal string to bytes
155 * @param {string} hex - Hex string
156 * @returns {Uint8Array} Decoded bytes
157 */
158export function hexToBytes(hex) {
159 const bytes = new Uint8Array(hex.length / 2);
160 for (let i = 0; i < hex.length; i += 2) {
161 bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
162 }
163 return bytes;
164}
165
166/**
167 * @param {Uint8Array} bytes
168 * @returns {bigint}
169 */
170function bytesToBigInt(bytes) {
171 let result = 0n;
172 for (const byte of bytes) {
173 result = (result << 8n) | BigInt(byte);
174 }
175 return result;
176}
177
178/**
179 * @param {bigint} n
180 * @param {number} length
181 * @returns {Uint8Array}
182 */
183function bigIntToBytes(n, length) {
184 const bytes = new Uint8Array(length);
185 for (let i = length - 1; i >= 0; i--) {
186 bytes[i] = Number(n & 0xffn);
187 n >>= 8n;
188 }
189 return bytes;
190}
191
192/**
193 * Encode bytes as base32lower string
194 * @param {Uint8Array} bytes - Bytes to encode
195 * @returns {string} Base32lower-encoded string
196 */
197export function base32Encode(bytes) {
198 const alphabet = 'abcdefghijklmnopqrstuvwxyz234567';
199 let result = '';
200 let bits = 0;
201 let value = 0;
202
203 for (const byte of bytes) {
204 value = (value << 8) | byte;
205 bits += 8;
206 while (bits >= 5) {
207 bits -= 5;
208 result += alphabet[(value >> bits) & 31];
209 }
210 }
211
212 if (bits > 0) {
213 result += alphabet[(value << (5 - bits)) & 31];
214 }
215
216 return result;
217}
218
219/**
220 * Decode base32lower string to bytes
221 * @param {string} str - Base32lower-encoded string
222 * @returns {Uint8Array} Decoded bytes
223 */
224export function base32Decode(str) {
225 const alphabet = 'abcdefghijklmnopqrstuvwxyz234567';
226 let bits = 0;
227 let value = 0;
228 const output = [];
229
230 for (const char of str) {
231 const idx = alphabet.indexOf(char);
232 if (idx === -1) continue;
233 value = (value << 5) | idx;
234 bits += 5;
235 if (bits >= 8) {
236 bits -= 8;
237 output.push((value >> bits) & 0xff);
238 }
239 }
240
241 return new Uint8Array(output);
242}
243
244/**
245 * Encode bytes as base64url string (no padding)
246 * @param {Uint8Array} bytes - Bytes to encode
247 * @returns {string} Base64url-encoded string
248 */
249export function base64UrlEncode(bytes) {
250 let binary = '';
251 for (const byte of bytes) {
252 binary += String.fromCharCode(byte);
253 }
254 const base64 = btoa(binary);
255 return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
256}
257
258/**
259 * Decode base64url string to bytes
260 * @param {string} str - Base64url-encoded string
261 * @returns {Uint8Array} Decoded bytes
262 */
263export function base64UrlDecode(str) {
264 const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
265 const pad = base64.length % 4;
266 const padded = pad ? base64 + '='.repeat(4 - pad) : base64;
267 const binary = atob(padded);
268 const bytes = new Uint8Array(binary.length);
269 for (let i = 0; i < binary.length; i++) {
270 bytes[i] = binary.charCodeAt(i);
271 }
272 return bytes;
273}
274
275/**
276 * Encode integer as unsigned varint
277 * @param {number} n - Non-negative integer
278 * @returns {Uint8Array} Varint-encoded bytes
279 */
280export function varint(n) {
281 const bytes = [];
282 while (n >= 0x80) {
283 bytes.push((n & 0x7f) | 0x80);
284 n >>>= 7;
285 }
286 bytes.push(n);
287 return new Uint8Array(bytes);
288}
289
290// === CID WRAPPER ===
291// Explicit CID type for DAG-CBOR encoding (avoids fragile heuristic detection)
292
293class CID {
294 /** @param {Uint8Array} bytes */
295 constructor(bytes) {
296 if (!(bytes instanceof Uint8Array)) {
297 throw new Error('CID must be constructed with Uint8Array');
298 }
299 this.bytes = bytes;
300 }
301}
302
303// ╔══════════════════════════════════════════════════════════════════════════════╗
304// ║ CBOR ENCODING ║
305// ║ RFC 8949 CBOR and DAG-CBOR for content-addressed data ║
306// ╚══════════════════════════════════════════════════════════════════════════════╝
307
308/**
309 * Encode CBOR type header (major type + length)
310 * @param {number[]} parts - Array to push bytes to
311 * @param {number} majorType - CBOR major type (0-7)
312 * @param {number} length - Value or length to encode
313 */
314function encodeHead(parts, majorType, length) {
315 const mt = majorType << 5;
316 if (length < 24) {
317 parts.push(mt | length);
318 } else if (length < 256) {
319 parts.push(mt | 24, length);
320 } else if (length < 65536) {
321 parts.push(mt | 25, length >> 8, length & 0xff);
322 } else if (length < 4294967296) {
323 // Use Math.floor instead of bitshift to avoid 32-bit signed integer overflow
324 parts.push(
325 mt | 26,
326 Math.floor(length / 0x1000000) & 0xff,
327 Math.floor(length / 0x10000) & 0xff,
328 Math.floor(length / 0x100) & 0xff,
329 length & 0xff,
330 );
331 }
332}
333
334/**
335 * Encode a value as CBOR bytes (RFC 8949 deterministic encoding)
336 * @param {*} value - Value to encode (null, boolean, number, string, Uint8Array, array, or object)
337 * @returns {Uint8Array} CBOR-encoded bytes
338 */
339export function cborEncode(value) {
340 /** @type {number[]} */
341 const parts = [];
342
343 /** @param {*} val */
344 function encode(val) {
345 if (val === null) {
346 parts.push(CBOR_NULL);
347 } else if (val === true) {
348 parts.push(CBOR_TRUE);
349 } else if (val === false) {
350 parts.push(CBOR_FALSE);
351 } else if (typeof val === 'number') {
352 encodeInteger(val);
353 } else if (typeof val === 'string') {
354 const bytes = new TextEncoder().encode(val);
355 encodeHead(parts, 3, bytes.length); // major type 3 = text string
356 parts.push(...bytes);
357 } else if (val instanceof Uint8Array) {
358 encodeHead(parts, 2, val.length); // major type 2 = byte string
359 parts.push(...val);
360 } else if (Array.isArray(val)) {
361 encodeHead(parts, 4, val.length); // major type 4 = array
362 for (const item of val) encode(item);
363 } else if (typeof val === 'object') {
364 // Sort keys for deterministic encoding
365 const keys = Object.keys(val).sort();
366 encodeHead(parts, 5, keys.length); // major type 5 = map
367 for (const key of keys) {
368 encode(key);
369 encode(val[key]);
370 }
371 }
372 }
373
374 /** @param {number} n */
375 function encodeInteger(n) {
376 if (n >= 0) {
377 encodeHead(parts, 0, n); // major type 0 = unsigned int
378 } else {
379 encodeHead(parts, 1, -n - 1); // major type 1 = negative int
380 }
381 }
382
383 encode(value);
384 return new Uint8Array(parts);
385}
386
387/**
388 * DAG-CBOR encoder that handles CIDs with tag 42
389 * @param {*} value
390 * @returns {Uint8Array}
391 */
392function cborEncodeDagCbor(value) {
393 /** @type {number[]} */
394 const parts = [];
395
396 /** @param {*} val */
397 function encode(val) {
398 if (val === null) {
399 parts.push(CBOR_NULL);
400 } else if (val === true) {
401 parts.push(CBOR_TRUE);
402 } else if (val === false) {
403 parts.push(CBOR_FALSE);
404 } else if (typeof val === 'number') {
405 if (Number.isInteger(val) && val >= 0) {
406 encodeHead(parts, 0, val);
407 } else if (Number.isInteger(val) && val < 0) {
408 encodeHead(parts, 1, -val - 1);
409 }
410 } else if (typeof val === 'string') {
411 const bytes = new TextEncoder().encode(val);
412 encodeHead(parts, 3, bytes.length);
413 parts.push(...bytes);
414 } else if (val instanceof CID) {
415 // CID links in DAG-CBOR use tag 42 + 0x00 multibase prefix
416 // The 0x00 prefix indicates "identity" multibase (raw bytes)
417 parts.push(0xd8, CBOR_TAG_CID);
418 encodeHead(parts, 2, val.bytes.length + 1); // +1 for 0x00 prefix
419 parts.push(0x00);
420 parts.push(...val.bytes);
421 } else if (val instanceof Uint8Array) {
422 // Regular byte string
423 encodeHead(parts, 2, val.length);
424 parts.push(...val);
425 } else if (Array.isArray(val)) {
426 encodeHead(parts, 4, val.length);
427 for (const item of val) encode(item);
428 } else if (typeof val === 'object') {
429 // DAG-CBOR: sort keys by length first, then lexicographically
430 // (differs from standard CBOR which sorts lexicographically only)
431 const keys = Object.keys(val).filter((k) => val[k] !== undefined);
432 keys.sort((a, b) => {
433 if (a.length !== b.length) return a.length - b.length;
434 return a < b ? -1 : a > b ? 1 : 0;
435 });
436 encodeHead(parts, 5, keys.length);
437 for (const key of keys) {
438 const keyBytes = new TextEncoder().encode(key);
439 encodeHead(parts, 3, keyBytes.length);
440 parts.push(...keyBytes);
441 encode(val[key]);
442 }
443 }
444 }
445
446 encode(value);
447 return new Uint8Array(parts);
448}
449
450/**
451 * Decode CBOR bytes to a JavaScript value
452 * @param {Uint8Array} bytes - CBOR-encoded bytes
453 * @returns {*} Decoded value
454 */
455export function cborDecode(bytes) {
456 let offset = 0;
457
458 /** @returns {*} */
459 function read() {
460 const initial = bytes[offset++];
461 const major = initial >> 5;
462 const info = initial & 0x1f;
463
464 let length = info;
465 if (info === 24) length = bytes[offset++];
466 else if (info === 25) {
467 length = (bytes[offset++] << 8) | bytes[offset++];
468 } else if (info === 26) {
469 // Use multiplication instead of bitshift to avoid 32-bit signed integer overflow
470 length =
471 bytes[offset++] * 0x1000000 +
472 bytes[offset++] * 0x10000 +
473 bytes[offset++] * 0x100 +
474 bytes[offset++];
475 }
476
477 switch (major) {
478 case 0:
479 return length; // unsigned int
480 case 1:
481 return -1 - length; // negative int
482 case 2: {
483 // byte string
484 const data = bytes.slice(offset, offset + length);
485 offset += length;
486 return data;
487 }
488 case 3: {
489 // text string
490 const data = new TextDecoder().decode(
491 bytes.slice(offset, offset + length),
492 );
493 offset += length;
494 return data;
495 }
496 case 4: {
497 // array
498 const arr = [];
499 for (let i = 0; i < length; i++) arr.push(read());
500 return arr;
501 }
502 case 5: {
503 // map
504 /** @type {Record<string, *>} */
505 const obj = {};
506 for (let i = 0; i < length; i++) {
507 const key = /** @type {string} */ (read());
508 obj[key] = read();
509 }
510 return obj;
511 }
512 case 6: {
513 // tag
514 // length is the tag number
515 const taggedValue = read();
516 if (length === CBOR_TAG_CID) {
517 // CID link: byte string with 0x00 multibase prefix, return raw CID bytes
518 return taggedValue.slice(1); // strip 0x00 prefix
519 }
520 return taggedValue;
521 }
522 case 7: {
523 // special
524 if (info === 20) return false;
525 if (info === 21) return true;
526 if (info === 22) return null;
527 return undefined;
528 }
529 }
530 }
531
532 return read();
533}
534
535// ╔══════════════════════════════════════════════════════════════════════════════╗
536// ║ CONTENT IDENTIFIERS ║
537// ║ CIDs (content hashes) and TIDs (timestamp IDs) ║
538// ╚══════════════════════════════════════════════════════════════════════════════╝
539
540/**
541 * Create a CIDv1 with SHA-256 hash
542 * @param {Uint8Array} bytes - Content to hash
543 * @param {number} codec - Codec identifier (0x71 for dag-cbor, 0x55 for raw)
544 * @returns {Promise<Uint8Array>} CID bytes (36 bytes: version + codec + multihash)
545 */
546async function createCidWithCodec(bytes, codec) {
547 const hash = await crypto.subtle.digest(
548 'SHA-256',
549 /** @type {BufferSource} */ (bytes),
550 );
551 const hashBytes = new Uint8Array(hash);
552
553 // CIDv1: version(1) + codec + multihash(sha256)
554 // Multihash: hash-type(0x12) + length(0x20=32) + digest
555 const cid = new Uint8Array(2 + 2 + 32);
556 cid[0] = 0x01; // CIDv1
557 cid[1] = codec;
558 cid[2] = 0x12; // sha-256
559 cid[3] = 0x20; // 32 bytes
560 cid.set(hashBytes, 4);
561
562 return cid;
563}
564
565/**
566 * Create CID for DAG-CBOR encoded data (records, commits)
567 * @param {Uint8Array} bytes - DAG-CBOR encoded content
568 * @returns {Promise<Uint8Array>} CID bytes
569 */
570export async function createCid(bytes) {
571 return createCidWithCodec(bytes, CODEC_DAG_CBOR);
572}
573
574/**
575 * Create CID for raw blob data (images, videos)
576 * @param {Uint8Array} bytes - Raw binary content
577 * @returns {Promise<Uint8Array>} CID bytes
578 */
579export async function createBlobCid(bytes) {
580 return createCidWithCodec(bytes, CODEC_RAW);
581}
582
583/**
584 * Convert CID bytes to base32lower string representation
585 * @param {Uint8Array} cid - CID bytes
586 * @returns {string} Base32lower-encoded CID with 'b' prefix
587 */
588export function cidToString(cid) {
589 // base32lower encoding for CIDv1
590 return `b${base32Encode(cid)}`;
591}
592
593/**
594 * Convert base32lower CID string to raw bytes
595 * @param {string} cidStr - CID string with 'b' prefix
596 * @returns {Uint8Array} CID bytes
597 */
598export function cidToBytes(cidStr) {
599 // Decode base32lower CID string to bytes
600 if (!cidStr.startsWith('b')) throw new Error('expected base32lower CID');
601 return base32Decode(cidStr.slice(1));
602}
603
604/**
605 * Generate a timestamp-based ID (TID) for record keys
606 * Monotonic within a process, sortable by time
607 * @returns {string} 13-character base32-sort encoded TID
608 */
609export function createTid() {
610 let timestamp = Date.now() * 1000; // microseconds
611
612 // Ensure monotonic
613 if (timestamp <= lastTimestamp) {
614 timestamp = lastTimestamp + 1;
615 }
616 lastTimestamp = timestamp;
617
618 // 13 chars: 11 for timestamp (64 bits but only ~53 used), 2 for clock ID
619 let tid = '';
620
621 // Encode timestamp (high bits first for sortability)
622 let ts = timestamp;
623 for (let i = 0; i < 11; i++) {
624 tid = TID_CHARS[ts & 31] + tid;
625 ts = Math.floor(ts / 32);
626 }
627
628 // Append clock ID (2 chars)
629 tid += TID_CHARS[(clockId >> 5) & 31];
630 tid += TID_CHARS[clockId & 31];
631
632 return tid;
633}
634
635// ╔══════════════════════════════════════════════════════════════════════════════╗
636// ║ CRYPTOGRAPHY ║
637// ║ P-256 signing with low-S normalization, key management ║
638// ╚══════════════════════════════════════════════════════════════════════════════╝
639
640/**
641 * @param {BufferSource} data
642 * @returns {Promise<Uint8Array>}
643 */
644async function sha256(data) {
645 const hash = await crypto.subtle.digest('SHA-256', data);
646 return new Uint8Array(hash);
647}
648
649/**
650 * Import a raw P-256 private key for signing
651 * @param {Uint8Array} privateKeyBytes - 32-byte raw private key
652 * @returns {Promise<CryptoKey>} Web Crypto key handle
653 */
654export async function importPrivateKey(privateKeyBytes) {
655 // Validate private key length (P-256 requires exactly 32 bytes)
656 if (
657 !(privateKeyBytes instanceof Uint8Array) ||
658 privateKeyBytes.length !== 32
659 ) {
660 throw new Error(
661 `Invalid private key: expected 32 bytes, got ${privateKeyBytes?.length ?? 'non-Uint8Array'}`,
662 );
663 }
664
665 // PKCS#8 wrapper for raw P-256 private key
666 const pkcs8Prefix = new Uint8Array([
667 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48,
668 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03,
669 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20,
670 ]);
671
672 const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32);
673 pkcs8.set(pkcs8Prefix);
674 pkcs8.set(privateKeyBytes, pkcs8Prefix.length);
675
676 return crypto.subtle.importKey(
677 'pkcs8',
678 /** @type {BufferSource} */ (pkcs8),
679 { name: 'ECDSA', namedCurve: 'P-256' },
680 false,
681 ['sign'],
682 );
683}
684
685/**
686 * Sign data with ECDSA P-256, returning low-S normalized signature
687 * @param {CryptoKey} privateKey - Web Crypto key from importPrivateKey
688 * @param {Uint8Array} data - Data to sign
689 * @returns {Promise<Uint8Array>} 64-byte signature (r || s)
690 */
691export async function sign(privateKey, data) {
692 const signature = await crypto.subtle.sign(
693 { name: 'ECDSA', hash: 'SHA-256' },
694 privateKey,
695 /** @type {BufferSource} */ (data),
696 );
697 const sig = new Uint8Array(signature);
698
699 const r = sig.slice(0, 32);
700 const s = sig.slice(32, 64);
701 const sBigInt = bytesToBigInt(s);
702
703 // Low-S normalization: Bitcoin/ATProto require S <= N/2 to prevent
704 // signature malleability (two valid signatures for same message)
705 if (sBigInt > P256_N_DIV_2) {
706 const newS = P256_N - sBigInt;
707 const newSBytes = bigIntToBytes(newS, 32);
708 const normalized = new Uint8Array(64);
709 normalized.set(r, 0);
710 normalized.set(newSBytes, 32);
711 return normalized;
712 }
713
714 return sig;
715}
716
717/**
718 * Generate a new P-256 key pair
719 * @returns {Promise<{privateKey: Uint8Array, publicKey: Uint8Array}>} 32-byte private key, 33-byte compressed public key
720 */
721export async function generateKeyPair() {
722 const keyPair = await crypto.subtle.generateKey(
723 { name: 'ECDSA', namedCurve: 'P-256' },
724 true,
725 ['sign', 'verify'],
726 );
727
728 // Export private key as raw bytes
729 const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
730 const privateBytes = base64UrlDecode(/** @type {string} */ (privateJwk.d));
731
732 // Export public key as compressed point
733 const publicRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey);
734 const publicBytes = new Uint8Array(publicRaw);
735 const compressed = compressPublicKey(publicBytes);
736
737 return { privateKey: privateBytes, publicKey: compressed };
738}
739
740/**
741 * @param {Uint8Array} uncompressed
742 * @returns {Uint8Array}
743 */
744function compressPublicKey(uncompressed) {
745 // uncompressed is 65 bytes: 0x04 + x(32) + y(32)
746 // compressed is 33 bytes: prefix(02 or 03) + x(32)
747 const x = uncompressed.slice(1, 33);
748 const y = uncompressed.slice(33, 65);
749 const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03;
750 const compressed = new Uint8Array(33);
751 compressed[0] = prefix;
752 compressed.set(x, 1);
753 return compressed;
754}
755
756// ╔══════════════════════════════════════════════════════════════════════════════╗
757// ║ AUTHENTICATION ║
758// ║ JWT creation/verification for sessions and service auth ║
759// ╚══════════════════════════════════════════════════════════════════════════════╝
760
761/**
762 * Create HMAC-SHA256 signature for JWT
763 * @param {string} data - Data to sign (header.payload)
764 * @param {string} secret - Secret key
765 * @returns {Promise<string>} Base64url-encoded signature
766 */
767async function hmacSign(data, secret) {
768 const key = await crypto.subtle.importKey(
769 'raw',
770 /** @type {BufferSource} */ (new TextEncoder().encode(secret)),
771 { name: 'HMAC', hash: 'SHA-256' },
772 false,
773 ['sign'],
774 );
775 const sig = await crypto.subtle.sign(
776 'HMAC',
777 key,
778 /** @type {BufferSource} */ (new TextEncoder().encode(data)),
779 );
780 return base64UrlEncode(new Uint8Array(sig));
781}
782
783/**
784 * Create an access JWT for ATProto
785 * @param {string} did - User's DID (subject and audience)
786 * @param {string} secret - JWT signing secret
787 * @param {number} [expiresIn=7200] - Expiration in seconds (default 2 hours)
788 * @returns {Promise<string>} Signed JWT
789 */
790export async function createAccessJwt(did, secret, expiresIn = 7200) {
791 const header = { typ: 'at+jwt', alg: 'HS256' };
792 const now = Math.floor(Date.now() / 1000);
793 const payload = {
794 scope: 'com.atproto.access',
795 sub: did,
796 aud: did,
797 iat: now,
798 exp: now + expiresIn,
799 };
800
801 const headerB64 = base64UrlEncode(
802 new TextEncoder().encode(JSON.stringify(header)),
803 );
804 const payloadB64 = base64UrlEncode(
805 new TextEncoder().encode(JSON.stringify(payload)),
806 );
807 const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret);
808
809 return `${headerB64}.${payloadB64}.${signature}`;
810}
811
812/**
813 * Create a refresh JWT for ATProto
814 * @param {string} did - User's DID (subject and audience)
815 * @param {string} secret - JWT signing secret
816 * @param {number} [expiresIn=86400] - Expiration in seconds (default 24 hours)
817 * @returns {Promise<string>} Signed JWT
818 */
819export async function createRefreshJwt(did, secret, expiresIn = 86400) {
820 const header = { typ: 'refresh+jwt', alg: 'HS256' };
821 const now = Math.floor(Date.now() / 1000);
822 // Generate random jti (token ID)
823 const jtiBytes = new Uint8Array(32);
824 crypto.getRandomValues(jtiBytes);
825 const jti = base64UrlEncode(jtiBytes);
826
827 const payload = {
828 scope: 'com.atproto.refresh',
829 sub: did,
830 aud: did,
831 jti,
832 iat: now,
833 exp: now + expiresIn,
834 };
835
836 const headerB64 = base64UrlEncode(
837 new TextEncoder().encode(JSON.stringify(header)),
838 );
839 const payloadB64 = base64UrlEncode(
840 new TextEncoder().encode(JSON.stringify(payload)),
841 );
842 const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret);
843
844 return `${headerB64}.${payloadB64}.${signature}`;
845}
846
847/**
848 * Verify and decode a JWT (shared logic)
849 * @param {string} jwt - JWT string to verify
850 * @param {string} secret - JWT signing secret
851 * @param {string} expectedType - Expected token type (e.g., 'at+jwt', 'refresh+jwt')
852 * @returns {Promise<{header: {typ: string, alg: string}, payload: JwtPayload}>} Decoded header and payload
853 * @throws {Error} If token is invalid, expired, or wrong type
854 */
855async function verifyJwt(jwt, secret, expectedType) {
856 const parts = jwt.split('.');
857 if (parts.length !== 3) {
858 throw new Error('Invalid JWT format');
859 }
860
861 const [headerB64, payloadB64, signatureB64] = parts;
862
863 // Verify signature
864 const expectedSig = await hmacSign(`${headerB64}.${payloadB64}`, secret);
865 if (signatureB64 !== expectedSig) {
866 throw new Error('Invalid signature');
867 }
868
869 // Decode header and payload
870 const header = JSON.parse(
871 new TextDecoder().decode(base64UrlDecode(headerB64)),
872 );
873 const payload = JSON.parse(
874 new TextDecoder().decode(base64UrlDecode(payloadB64)),
875 );
876
877 // Check token type
878 if (header.typ !== expectedType) {
879 throw new Error(`Invalid token type: expected ${expectedType}`);
880 }
881
882 // Check expiration
883 const now = Math.floor(Date.now() / 1000);
884 if (payload.exp && payload.exp < now) {
885 throw new Error('Token expired');
886 }
887
888 return { header, payload };
889}
890
891/**
892 * Verify and decode an access JWT
893 * @param {string} jwt - JWT string to verify
894 * @param {string} secret - JWT signing secret
895 * @returns {Promise<JwtPayload>} Decoded payload
896 * @throws {Error} If token is invalid, expired, or wrong type
897 */
898export async function verifyAccessJwt(jwt, secret) {
899 const { payload } = await verifyJwt(jwt, secret, 'at+jwt');
900 return payload;
901}
902
903/**
904 * Verify and decode a refresh JWT
905 * @param {string} jwt - JWT string to verify
906 * @param {string} secret - JWT signing secret
907 * @returns {Promise<JwtPayload>} Decoded payload
908 * @throws {Error} If token is invalid, expired, or wrong type
909 */
910export async function verifyRefreshJwt(jwt, secret) {
911 const { payload } = await verifyJwt(jwt, secret, 'refresh+jwt');
912
913 // Validate audience matches subject (token intended for this user)
914 if (payload.aud && payload.aud !== payload.sub) {
915 throw new Error('Invalid audience');
916 }
917
918 return payload;
919}
920
921/**
922 * Create a service auth JWT signed with ES256 (P-256)
923 * Used for proxying requests to AppView
924 * @param {Object} params - JWT parameters
925 * @param {string} params.iss - Issuer DID (PDS DID)
926 * @param {string} params.aud - Audience DID (AppView DID)
927 * @param {string|null} params.lxm - Lexicon method being called
928 * @param {CryptoKey} params.signingKey - P-256 private key from importPrivateKey
929 * @returns {Promise<string>} Signed JWT
930 */
931export async function createServiceJwt({ iss, aud, lxm, signingKey }) {
932 const header = { typ: 'JWT', alg: 'ES256' };
933 const now = Math.floor(Date.now() / 1000);
934
935 // Generate random jti
936 const jtiBytes = new Uint8Array(16);
937 crypto.getRandomValues(jtiBytes);
938 const jti = bytesToHex(jtiBytes);
939
940 /** @type {{ iss: string, aud: string, exp: number, iat: number, jti: string, lxm?: string }} */
941 const payload = {
942 iss,
943 aud,
944 exp: now + 60, // 1 minute expiration
945 iat: now,
946 jti,
947 };
948 if (lxm) payload.lxm = lxm;
949
950 const headerB64 = base64UrlEncode(
951 new TextEncoder().encode(JSON.stringify(header)),
952 );
953 const payloadB64 = base64UrlEncode(
954 new TextEncoder().encode(JSON.stringify(payload)),
955 );
956 const toSign = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
957
958 const sig = await sign(signingKey, toSign);
959 const sigB64 = base64UrlEncode(sig);
960
961 return `${headerB64}.${payloadB64}.${sigB64}`;
962}
963
964// ╔══════════════════════════════════════════════════════════════════════════════╗
965// ║ MERKLE SEARCH TREE ║
966// ║ MST for ATProto repository structure ║
967// ╚══════════════════════════════════════════════════════════════════════════════╝
968
969// Cache for key depths (SHA-256 is expensive)
970const keyDepthCache = new Map();
971
972/**
973 * Get MST tree depth for a key based on leading zeros in SHA-256 hash
974 * @param {string} key - Record key (collection/rkey)
975 * @returns {Promise<number>} Tree depth (leading zeros / 2)
976 */
977export async function getKeyDepth(key) {
978 // Count leading zeros in SHA-256 hash, divide by 2
979 if (keyDepthCache.has(key)) return keyDepthCache.get(key);
980
981 const keyBytes = new TextEncoder().encode(key);
982 const hash = await sha256(keyBytes);
983
984 let zeros = 0;
985 for (const byte of hash) {
986 if (byte === 0) {
987 zeros += 8;
988 } else {
989 // Count leading zeros in this byte
990 for (let i = 7; i >= 0; i--) {
991 if ((byte >> i) & 1) break;
992 zeros++;
993 }
994 break;
995 }
996 }
997
998 // MST depth = leading zeros in SHA-256 hash / 2
999 // This creates a probabilistic tree where ~50% of keys are at depth 0,
1000 // ~25% at depth 1, etc., giving O(log n) lookups
1001 const depth = Math.floor(zeros / 2);
1002 keyDepthCache.set(key, depth);
1003 return depth;
1004}
1005
1006/**
1007 * Compute common prefix length between two byte arrays
1008 * @param {Uint8Array} a
1009 * @param {Uint8Array} b
1010 * @returns {number}
1011 */
1012function commonPrefixLen(a, b) {
1013 const minLen = Math.min(a.length, b.length);
1014 for (let i = 0; i < minLen; i++) {
1015 if (a[i] !== b[i]) return i;
1016 }
1017 return minLen;
1018}
1019
1020class MST {
1021 /** @param {SqlStorage} sql */
1022 constructor(sql) {
1023 this.sql = sql;
1024 }
1025
1026 async computeRoot() {
1027 const records = this.sql
1028 .exec(`
1029 SELECT collection, rkey, cid FROM records ORDER BY collection, rkey
1030 `)
1031 .toArray();
1032
1033 if (records.length === 0) {
1034 return null;
1035 }
1036
1037 // Build entries with pre-computed depths (heights)
1038 // In ATProto MST, "height" determines which layer a key belongs to
1039 // Layer 0 is at the BOTTOM, root is at the highest layer
1040 const entries = [];
1041 let maxDepth = 0;
1042 for (const r of records) {
1043 const key = `${r.collection}/${r.rkey}`;
1044 const depth = await getKeyDepth(key);
1045 maxDepth = Math.max(maxDepth, depth);
1046 entries.push({
1047 key,
1048 keyBytes: new TextEncoder().encode(key),
1049 cid: /** @type {string} */ (r.cid),
1050 depth,
1051 });
1052 }
1053
1054 // Start building from the root (highest layer) going down to layer 0
1055 return this.buildTree(entries, maxDepth);
1056 }
1057
1058 /**
1059 * @param {Array<{key: string, keyBytes: Uint8Array, cid: string, depth: number}>} entries
1060 * @param {number} layer
1061 * @returns {Promise<string|null>}
1062 */
1063 async buildTree(entries, layer) {
1064 if (entries.length === 0) return null;
1065
1066 // Separate entries for this layer vs lower layers (subtrees)
1067 // Keys with depth == layer stay at this node
1068 // Keys with depth < layer go into subtrees (going down toward layer 0)
1069 /** @type {Array<{type: 'subtree', cid: string|null} | {type: 'entry', entry: {key: string, keyBytes: Uint8Array, cid: string, depth: number}}>} */
1070 const thisLayer = [];
1071 /** @type {Array<{key: string, keyBytes: Uint8Array, cid: string, depth: number}>} */
1072 let leftSubtree = [];
1073
1074 for (const entry of entries) {
1075 if (entry.depth < layer) {
1076 // This entry belongs to a lower layer - accumulate for subtree
1077 leftSubtree.push(entry);
1078 } else {
1079 // This entry belongs at current layer (depth == layer)
1080 // Process accumulated left subtree first
1081 if (leftSubtree.length > 0) {
1082 const leftCid = await this.buildTree(leftSubtree, layer - 1);
1083 thisLayer.push({ type: 'subtree', cid: leftCid });
1084 leftSubtree = [];
1085 }
1086 thisLayer.push({ type: 'entry', entry });
1087 }
1088 }
1089
1090 // Handle remaining left subtree
1091 if (leftSubtree.length > 0) {
1092 const leftCid = await this.buildTree(leftSubtree, layer - 1);
1093 thisLayer.push({ type: 'subtree', cid: leftCid });
1094 }
1095
1096 // Build node with proper ATProto format
1097 /** @type {{ e: Array<{p: number, k: Uint8Array, v: CID, t: CID|null}>, l?: CID|null }} */
1098 const node = { e: [] };
1099 /** @type {string|null} */
1100 let leftCid = null;
1101 let prevKeyBytes = new Uint8Array(0);
1102
1103 for (let i = 0; i < thisLayer.length; i++) {
1104 const item = thisLayer[i];
1105
1106 if (item.type === 'subtree') {
1107 if (node.e.length === 0) {
1108 leftCid = item.cid;
1109 } else {
1110 // Attach to previous entry's 't' field
1111 if (item.cid !== null) {
1112 node.e[node.e.length - 1].t = new CID(cidToBytes(item.cid));
1113 }
1114 }
1115 } else {
1116 // Entry - compute prefix compression
1117 const keyBytes = item.entry.keyBytes;
1118 const prefixLen = commonPrefixLen(prevKeyBytes, keyBytes);
1119 const keySuffix = keyBytes.slice(prefixLen);
1120
1121 // ATProto requires t field to be present (can be null)
1122 const e = {
1123 p: prefixLen,
1124 k: keySuffix,
1125 v: new CID(cidToBytes(item.entry.cid)),
1126 t: null, // Will be updated if there's a subtree
1127 };
1128
1129 node.e.push(e);
1130 prevKeyBytes = /** @type {Uint8Array<ArrayBuffer>} */ (keyBytes);
1131 }
1132 }
1133
1134 // ATProto requires l field to be present (can be null)
1135 node.l = leftCid ? new CID(cidToBytes(leftCid)) : null;
1136
1137 // Encode node with proper MST CBOR format
1138 const nodeBytes = cborEncodeDagCbor(node);
1139 const nodeCid = await createCid(nodeBytes);
1140 const cidStr = cidToString(nodeCid);
1141
1142 this.sql.exec(
1143 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`,
1144 cidStr,
1145 nodeBytes,
1146 );
1147
1148 return cidStr;
1149 }
1150}
1151
1152// ╔══════════════════════════════════════════════════════════════════════════════╗
1153// ║ CAR FILES ║
1154// ║ Content Addressable aRchive format for repo sync ║
1155// ╚══════════════════════════════════════════════════════════════════════════════╝
1156
1157/**
1158 * Build a CAR (Content Addressable aRchive) file
1159 * @param {string} rootCid - Root CID string
1160 * @param {Array<{cid: string, data: Uint8Array}>} blocks - Blocks to include
1161 * @returns {Uint8Array} CAR file bytes
1162 */
1163export function buildCarFile(rootCid, blocks) {
1164 const parts = [];
1165
1166 // Header: { version: 1, roots: [rootCid] }
1167 const rootCidBytes = cidToBytes(rootCid);
1168 const header = cborEncodeDagCbor({
1169 version: 1,
1170 roots: [new CID(rootCidBytes)],
1171 });
1172 parts.push(varint(header.length));
1173 parts.push(header);
1174
1175 // Blocks: varint(len) + cid + data
1176 for (const block of blocks) {
1177 const cidBytes = cidToBytes(block.cid);
1178 const blockLen = cidBytes.length + block.data.length;
1179 parts.push(varint(blockLen));
1180 parts.push(cidBytes);
1181 parts.push(block.data);
1182 }
1183
1184 // Concatenate all parts
1185 const totalLen = parts.reduce((sum, p) => sum + p.length, 0);
1186 const car = new Uint8Array(totalLen);
1187 let offset = 0;
1188 for (const part of parts) {
1189 car.set(part, offset);
1190 offset += part.length;
1191 }
1192
1193 return car;
1194}
1195
1196// ╔══════════════════════════════════════════════════════════════════════════════╗
1197// ║ BLOB HANDLING ║
1198// ║ MIME detection, blob reference scanning ║
1199// ╚══════════════════════════════════════════════════════════════════════════════╝
1200
1201/**
1202 * Sniff MIME type from file magic bytes
1203 * @param {Uint8Array|ArrayBuffer} bytes - File bytes (only first 12 needed)
1204 * @returns {string|null} Detected MIME type or null if unknown
1205 */
1206export function sniffMimeType(bytes) {
1207 const arr = new Uint8Array(bytes.slice(0, 12));
1208
1209 // JPEG: FF D8 FF
1210 if (arr[0] === 0xff && arr[1] === 0xd8 && arr[2] === 0xff) {
1211 return 'image/jpeg';
1212 }
1213
1214 // PNG: 89 50 4E 47 0D 0A 1A 0A
1215 if (
1216 arr[0] === 0x89 &&
1217 arr[1] === 0x50 &&
1218 arr[2] === 0x4e &&
1219 arr[3] === 0x47 &&
1220 arr[4] === 0x0d &&
1221 arr[5] === 0x0a &&
1222 arr[6] === 0x1a &&
1223 arr[7] === 0x0a
1224 ) {
1225 return 'image/png';
1226 }
1227
1228 // GIF: 47 49 46 38 (GIF8)
1229 if (
1230 arr[0] === 0x47 &&
1231 arr[1] === 0x49 &&
1232 arr[2] === 0x46 &&
1233 arr[3] === 0x38
1234 ) {
1235 return 'image/gif';
1236 }
1237
1238 // WebP: RIFF....WEBP
1239 if (
1240 arr[0] === 0x52 &&
1241 arr[1] === 0x49 &&
1242 arr[2] === 0x46 &&
1243 arr[3] === 0x46 &&
1244 arr[8] === 0x57 &&
1245 arr[9] === 0x45 &&
1246 arr[10] === 0x42 &&
1247 arr[11] === 0x50
1248 ) {
1249 return 'image/webp';
1250 }
1251
1252 // ISOBMFF container: ....ftyp at byte 4 (MP4, AVIF, HEIC, etc.)
1253 if (
1254 arr[4] === 0x66 &&
1255 arr[5] === 0x74 &&
1256 arr[6] === 0x79 &&
1257 arr[7] === 0x70
1258 ) {
1259 // Check brand code at bytes 8-11
1260 const brand = String.fromCharCode(arr[8], arr[9], arr[10], arr[11]);
1261 if (brand === 'avif') {
1262 return 'image/avif';
1263 }
1264 if (brand === 'heic' || brand === 'heix' || brand === 'mif1') {
1265 return 'image/heic';
1266 }
1267 return 'video/mp4';
1268 }
1269
1270 return null;
1271}
1272
1273/**
1274 * Find all blob CID references in a record
1275 * @param {*} obj - Record value to scan
1276 * @param {string[]} refs - Accumulator array (internal)
1277 * @returns {string[]} Array of blob CID strings
1278 */
1279export function findBlobRefs(obj, refs = []) {
1280 if (!obj || typeof obj !== 'object') {
1281 return refs;
1282 }
1283
1284 // Check if this object is a blob ref
1285 if (obj.$type === 'blob' && obj.ref?.$link) {
1286 refs.push(obj.ref.$link);
1287 }
1288
1289 // Recurse into arrays and objects
1290 if (Array.isArray(obj)) {
1291 for (const item of obj) {
1292 findBlobRefs(item, refs);
1293 }
1294 } else {
1295 for (const value of Object.values(obj)) {
1296 findBlobRefs(value, refs);
1297 }
1298 }
1299
1300 return refs;
1301}
1302
1303// ╔══════════════════════════════════════════════════════════════════════════════╗
1304// ║ RELAY NOTIFICATION ║
1305// ║ Notify relays to crawl after repo updates ║
1306// ╚══════════════════════════════════════════════════════════════════════════════╝
1307
1308/**
1309 * Notify relays to come crawl us after writes (like official PDS)
1310 * @param {{ RELAY_HOST?: string }} env
1311 * @param {string} hostname
1312 */
1313async function notifyCrawlers(env, hostname) {
1314 const now = Date.now();
1315 if (now - lastCrawlNotify < CRAWL_NOTIFY_THRESHOLD) {
1316 return; // Throttle notifications
1317 }
1318
1319 const relayHost = env.RELAY_HOST;
1320 if (!relayHost) return;
1321
1322 lastCrawlNotify = now;
1323
1324 // Fire and forget - don't block writes on relay notification
1325 fetch(`${relayHost}/xrpc/com.atproto.sync.requestCrawl`, {
1326 method: 'POST',
1327 headers: { 'Content-Type': 'application/json' },
1328 body: JSON.stringify({ hostname }),
1329 }).catch(() => {
1330 // Silently ignore relay notification failures
1331 });
1332}
1333
1334// ╔══════════════════════════════════════════════════════════════════════════════╗
1335// ║ ROUTING ║
1336// ║ XRPC endpoint definitions ║
1337// ╚══════════════════════════════════════════════════════════════════════════════╝
1338
1339/**
1340 * Route handler function type
1341 * @callback RouteHandler
1342 * @param {PersonalDataServer} pds - PDS instance
1343 * @param {Request} request - HTTP request
1344 * @param {URL} url - Parsed URL
1345 * @returns {Response | Promise<Response>} HTTP response
1346 */
1347
1348/**
1349 * Route definition for the PDS router
1350 * @typedef {Object} Route
1351 * @property {string} [method] - Required HTTP method (default: any)
1352 * @property {RouteHandler} handler - Handler function
1353 */
1354
1355/** @type {Record<string, Route>} */
1356const pdsRoutes = {
1357 '/.well-known/atproto-did': {
1358 handler: (pds, _req, _url) => pds.handleAtprotoDid(),
1359 },
1360 '/init': {
1361 method: 'POST',
1362 handler: (pds, req, _url) => pds.handleInit(req),
1363 },
1364 '/status': {
1365 handler: (pds, _req, _url) => pds.handleStatus(),
1366 },
1367 '/forward-event': {
1368 handler: (pds, req, _url) => pds.handleForwardEvent(req),
1369 },
1370 '/register-did': {
1371 handler: (pds, req, _url) => pds.handleRegisterDid(req),
1372 },
1373 '/get-registered-dids': {
1374 handler: (pds, _req, _url) => pds.handleGetRegisteredDids(),
1375 },
1376 '/register-handle': {
1377 method: 'POST',
1378 handler: (pds, req, _url) => pds.handleRegisterHandle(req),
1379 },
1380 '/resolve-handle': {
1381 handler: (pds, _req, url) => pds.handleResolveHandle(url),
1382 },
1383 '/repo-info': {
1384 handler: (pds, _req, _url) => pds.handleRepoInfo(),
1385 },
1386 '/xrpc/com.atproto.server.describeServer': {
1387 handler: (pds, req, _url) => pds.handleDescribeServer(req),
1388 },
1389 '/xrpc/com.atproto.server.createSession': {
1390 method: 'POST',
1391 handler: (pds, req, _url) => pds.handleCreateSession(req),
1392 },
1393 '/xrpc/com.atproto.server.getSession': {
1394 handler: (pds, req, _url) => pds.handleGetSession(req),
1395 },
1396 '/xrpc/com.atproto.server.refreshSession': {
1397 method: 'POST',
1398 handler: (pds, req, _url) => pds.handleRefreshSession(req),
1399 },
1400 '/xrpc/app.bsky.actor.getPreferences': {
1401 handler: (pds, req, _url) => pds.handleGetPreferences(req),
1402 },
1403 '/xrpc/app.bsky.actor.putPreferences': {
1404 method: 'POST',
1405 handler: (pds, req, _url) => pds.handlePutPreferences(req),
1406 },
1407 '/xrpc/com.atproto.sync.listRepos': {
1408 handler: (pds, _req, _url) => pds.handleListRepos(),
1409 },
1410 '/xrpc/com.atproto.repo.createRecord': {
1411 method: 'POST',
1412 handler: (pds, req, _url) => pds.handleCreateRecord(req),
1413 },
1414 '/xrpc/com.atproto.repo.deleteRecord': {
1415 method: 'POST',
1416 handler: (pds, req, _url) => pds.handleDeleteRecord(req),
1417 },
1418 '/xrpc/com.atproto.repo.putRecord': {
1419 method: 'POST',
1420 handler: (pds, req, _url) => pds.handlePutRecord(req),
1421 },
1422 '/xrpc/com.atproto.repo.applyWrites': {
1423 method: 'POST',
1424 handler: (pds, req, _url) => pds.handleApplyWrites(req),
1425 },
1426 '/xrpc/com.atproto.repo.getRecord': {
1427 handler: (pds, _req, url) => pds.handleGetRecord(url),
1428 },
1429 '/xrpc/com.atproto.repo.describeRepo': {
1430 handler: (pds, _req, _url) => pds.handleDescribeRepo(),
1431 },
1432 '/xrpc/com.atproto.repo.listRecords': {
1433 handler: (pds, _req, url) => pds.handleListRecords(url),
1434 },
1435 '/xrpc/com.atproto.repo.uploadBlob': {
1436 method: 'POST',
1437 handler: (pds, req, _url) => pds.handleUploadBlob(req),
1438 },
1439 '/xrpc/com.atproto.sync.getLatestCommit': {
1440 handler: (pds, _req, _url) => pds.handleGetLatestCommit(),
1441 },
1442 '/xrpc/com.atproto.sync.getRepoStatus': {
1443 handler: (pds, _req, _url) => pds.handleGetRepoStatus(),
1444 },
1445 '/xrpc/com.atproto.sync.getRepo': {
1446 handler: (pds, _req, _url) => pds.handleGetRepo(),
1447 },
1448 '/xrpc/com.atproto.sync.getRecord': {
1449 handler: (pds, _req, url) => pds.handleSyncGetRecord(url),
1450 },
1451 '/xrpc/com.atproto.sync.getBlob': {
1452 handler: (pds, _req, url) => pds.handleGetBlob(url),
1453 },
1454 '/xrpc/com.atproto.sync.listBlobs': {
1455 handler: (pds, _req, url) => pds.handleListBlobs(url),
1456 },
1457 '/xrpc/com.atproto.sync.subscribeRepos': {
1458 handler: (pds, req, url) => pds.handleSubscribeRepos(req, url),
1459 },
1460};
1461
1462// ╔══════════════════════════════════════════════════════════════════════════════╗
1463// ║ PERSONAL DATA SERVER ║
1464// ║ Durable Object class implementing ATProto PDS ║
1465// ╚══════════════════════════════════════════════════════════════════════════════╝
1466
1467export class PersonalDataServer {
1468 /** @type {string | undefined} */
1469 _did;
1470
1471 /**
1472 * @param {DurableObjectState} state
1473 * @param {Env} env
1474 */
1475 constructor(state, env) {
1476 this.state = state;
1477 this.sql = state.storage.sql;
1478 this.env = env;
1479
1480 // Initialize schema
1481 this.sql.exec(`
1482 CREATE TABLE IF NOT EXISTS blocks (
1483 cid TEXT PRIMARY KEY,
1484 data BLOB NOT NULL
1485 );
1486
1487 CREATE TABLE IF NOT EXISTS records (
1488 uri TEXT PRIMARY KEY,
1489 cid TEXT NOT NULL,
1490 collection TEXT NOT NULL,
1491 rkey TEXT NOT NULL,
1492 value BLOB NOT NULL
1493 );
1494
1495 CREATE TABLE IF NOT EXISTS commits (
1496 seq INTEGER PRIMARY KEY AUTOINCREMENT,
1497 cid TEXT NOT NULL,
1498 rev TEXT NOT NULL,
1499 prev TEXT
1500 );
1501
1502 CREATE TABLE IF NOT EXISTS seq_events (
1503 seq INTEGER PRIMARY KEY AUTOINCREMENT,
1504 did TEXT NOT NULL,
1505 commit_cid TEXT NOT NULL,
1506 evt BLOB NOT NULL
1507 );
1508
1509 CREATE TABLE IF NOT EXISTS blob (
1510 cid TEXT PRIMARY KEY,
1511 mimeType TEXT NOT NULL,
1512 size INTEGER NOT NULL,
1513 createdAt TEXT NOT NULL
1514 );
1515
1516 CREATE TABLE IF NOT EXISTS record_blob (
1517 blobCid TEXT NOT NULL,
1518 recordUri TEXT NOT NULL,
1519 PRIMARY KEY (blobCid, recordUri)
1520 );
1521
1522 CREATE INDEX IF NOT EXISTS idx_record_blob_uri ON record_blob(recordUri);
1523
1524 CREATE INDEX IF NOT EXISTS idx_records_collection ON records(collection, rkey);
1525 `);
1526 }
1527
1528 /**
1529 * @param {string} did
1530 * @param {string} privateKeyHex
1531 * @param {string|null} [handle]
1532 */
1533 async initIdentity(did, privateKeyHex, handle = null) {
1534 await this.state.storage.put('did', did);
1535 await this.state.storage.put('privateKey', privateKeyHex);
1536 if (handle) {
1537 await this.state.storage.put('handle', handle);
1538 }
1539
1540 // Schedule blob cleanup alarm (runs daily)
1541 const currentAlarm = await this.state.storage.getAlarm();
1542 if (!currentAlarm) {
1543 await this.state.storage.setAlarm(Date.now() + 24 * 60 * 60 * 1000);
1544 }
1545 }
1546
1547 async getDid() {
1548 if (!this._did) {
1549 this._did = await this.state.storage.get('did');
1550 }
1551 return this._did;
1552 }
1553
1554 async getHandle() {
1555 return this.state.storage.get('handle');
1556 }
1557
1558 async getSigningKey() {
1559 const hex = await this.state.storage.get('privateKey');
1560 if (!hex) return null;
1561 return importPrivateKey(hexToBytes(/** @type {string} */ (hex)));
1562 }
1563
1564 /**
1565 * Collect MST node blocks for a given root CID
1566 * @param {string} rootCidStr
1567 * @returns {Array<{cid: string, data: Uint8Array}>}
1568 */
1569 collectMstBlocks(rootCidStr) {
1570 /** @type {Array<{cid: string, data: Uint8Array}>} */
1571 const blocks = [];
1572 const visited = new Set();
1573
1574 /** @param {string} cidStr */
1575 const collect = (cidStr) => {
1576 if (visited.has(cidStr)) return;
1577 visited.add(cidStr);
1578
1579 const rows = /** @type {BlockRow[]} */ (
1580 this.sql.exec(`SELECT data FROM blocks WHERE cid = ?`, cidStr).toArray()
1581 );
1582 if (rows.length === 0) return;
1583
1584 const data = new Uint8Array(rows[0].data);
1585 blocks.push({ cid: cidStr, data }); // Keep as string, buildCarFile will convert
1586
1587 // Decode and follow child CIDs (MST nodes have 'l' and 'e' with 't' subtrees)
1588 try {
1589 const node = cborDecode(data);
1590 if (node.l) collect(cidToString(node.l));
1591 if (node.e) {
1592 for (const entry of node.e) {
1593 if (entry.t) collect(cidToString(entry.t));
1594 }
1595 }
1596 } catch (_e) {
1597 // Not an MST node, ignore
1598 }
1599 };
1600
1601 collect(rootCidStr);
1602 return blocks;
1603 }
1604
1605 /**
1606 * @param {string} collection
1607 * @param {Record<string, *>} record
1608 * @param {string|null} [rkey]
1609 * @returns {Promise<{uri: string, cid: string, commit: string}>}
1610 */
1611 async createRecord(collection, record, rkey = null) {
1612 const did = await this.getDid();
1613 if (!did) throw new Error('PDS not initialized');
1614
1615 rkey = rkey || createTid();
1616 const uri = `at://${did}/${collection}/${rkey}`;
1617
1618 // Encode and hash record (must use DAG-CBOR for proper key ordering)
1619 const recordBytes = cborEncodeDagCbor(record);
1620 const recordCid = await createCid(recordBytes);
1621 const recordCidStr = cidToString(recordCid);
1622
1623 // Store block
1624 this.sql.exec(
1625 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`,
1626 recordCidStr,
1627 recordBytes,
1628 );
1629
1630 // Store record index
1631 this.sql.exec(
1632 `INSERT OR REPLACE INTO records (uri, cid, collection, rkey, value) VALUES (?, ?, ?, ?, ?)`,
1633 uri,
1634 recordCidStr,
1635 collection,
1636 rkey,
1637 recordBytes,
1638 );
1639
1640 // Associate blobs with this record (delete old associations first for updates)
1641 this.sql.exec('DELETE FROM record_blob WHERE recordUri = ?', uri);
1642
1643 const blobRefs = findBlobRefs(record);
1644 for (const blobCid of blobRefs) {
1645 // Verify blob exists
1646 const blobExists = this.sql
1647 .exec('SELECT cid FROM blob WHERE cid = ?', blobCid)
1648 .toArray();
1649
1650 if (blobExists.length === 0) {
1651 throw new Error(`BlobNotFound: ${blobCid}`);
1652 }
1653
1654 // Create association
1655 this.sql.exec(
1656 'INSERT INTO record_blob (blobCid, recordUri) VALUES (?, ?)',
1657 blobCid,
1658 uri,
1659 );
1660 }
1661
1662 // Rebuild MST
1663 const mst = new MST(this.sql);
1664 const dataRoot = await mst.computeRoot();
1665
1666 // Get previous commit
1667 const prevCommits = this.sql
1668 .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`)
1669 .toArray();
1670 const prevCommit = prevCommits.length > 0 ? prevCommits[0] : null;
1671
1672 // Create commit
1673 const rev = createTid();
1674 // Build commit with CIDs wrapped in CID class (for dag-cbor tag 42 encoding)
1675 const commit = {
1676 did,
1677 version: 3,
1678 data: new CID(cidToBytes(/** @type {string} */ (dataRoot))), // CID wrapped for explicit encoding
1679 rev,
1680 prev: prevCommit?.cid
1681 ? new CID(cidToBytes(/** @type {string} */ (prevCommit.cid)))
1682 : null,
1683 };
1684
1685 // Sign commit (using dag-cbor encoder for CIDs)
1686 const commitBytes = cborEncodeDagCbor(commit);
1687 const signingKey = await this.getSigningKey();
1688 if (!signingKey) throw new Error('No signing key');
1689 const sig = await sign(signingKey, commitBytes);
1690
1691 const signedCommit = { ...commit, sig };
1692 const signedBytes = cborEncodeDagCbor(signedCommit);
1693 const commitCid = await createCid(signedBytes);
1694 const commitCidStr = cidToString(commitCid);
1695
1696 // Store commit block
1697 this.sql.exec(
1698 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`,
1699 commitCidStr,
1700 signedBytes,
1701 );
1702
1703 // Store commit reference
1704 this.sql.exec(
1705 `INSERT INTO commits (cid, rev, prev) VALUES (?, ?, ?)`,
1706 commitCidStr,
1707 rev,
1708 prevCommit?.cid || null,
1709 );
1710
1711 // Update head and rev for listRepos
1712 await this.state.storage.put('head', commitCidStr);
1713 await this.state.storage.put('rev', rev);
1714
1715 // Collect blocks for the event (record + commit + MST nodes)
1716 // Build a mini CAR with just the new blocks - use string CIDs
1717 const newBlocks = [];
1718 // Add record block
1719 newBlocks.push({ cid: recordCidStr, data: recordBytes });
1720 // Add commit block
1721 newBlocks.push({ cid: commitCidStr, data: signedBytes });
1722 // Add MST node blocks (get all blocks referenced by commit.data)
1723 const mstBlocks = this.collectMstBlocks(/** @type {string} */ (dataRoot));
1724 newBlocks.push(...mstBlocks);
1725
1726 // Sequence event with blocks - store complete event data including rev and time
1727 // blocks must be a full CAR file with header (roots = [commitCid])
1728 const eventTime = new Date().toISOString();
1729 const evt = cborEncode({
1730 ops: [
1731 { action: 'create', path: `${collection}/${rkey}`, cid: recordCidStr },
1732 ],
1733 blocks: buildCarFile(commitCidStr, newBlocks), // Full CAR with header
1734 rev, // Store the actual commit revision
1735 time: eventTime, // Store the actual event time
1736 });
1737 this.sql.exec(
1738 `INSERT INTO seq_events (did, commit_cid, evt) VALUES (?, ?, ?)`,
1739 did,
1740 commitCidStr,
1741 evt,
1742 );
1743
1744 // Broadcast to subscribers (both local and via default DO for relay)
1745 const evtRows = /** @type {SeqEventRow[]} */ (
1746 this.sql
1747 .exec(`SELECT * FROM seq_events ORDER BY seq DESC LIMIT 1`)
1748 .toArray()
1749 );
1750 if (evtRows.length > 0) {
1751 this.broadcastEvent(evtRows[0]);
1752 // Also forward to default DO for relay subscribers
1753 if (this.env?.PDS) {
1754 const defaultId = this.env.PDS.idFromName('default');
1755 const defaultPds = this.env.PDS.get(defaultId);
1756 // Convert ArrayBuffer to array for JSON serialization
1757 const row = evtRows[0];
1758 const evtArray = Array.from(new Uint8Array(row.evt));
1759 // Fire and forget but log errors
1760 defaultPds
1761 .fetch(
1762 new Request('http://internal/forward-event', {
1763 method: 'POST',
1764 body: JSON.stringify({ ...row, evt: evtArray }),
1765 }),
1766 )
1767 .catch(() => {}); // Ignore forward errors
1768 }
1769 }
1770
1771 return { uri, cid: recordCidStr, commit: commitCidStr };
1772 }
1773
1774 /**
1775 * @param {string} collection
1776 * @param {string} rkey
1777 */
1778 async deleteRecord(collection, rkey) {
1779 const did = await this.getDid();
1780 if (!did) throw new Error('PDS not initialized');
1781
1782 const uri = `at://${did}/${collection}/${rkey}`;
1783
1784 // Check if record exists
1785 const existing = this.sql
1786 .exec(`SELECT cid FROM records WHERE uri = ?`, uri)
1787 .toArray();
1788 if (existing.length === 0) {
1789 return { error: 'RecordNotFound', message: 'record not found' };
1790 }
1791
1792 // Delete from records table
1793 this.sql.exec(`DELETE FROM records WHERE uri = ?`, uri);
1794
1795 // Get blobs associated with this record
1796 const associatedBlobs = this.sql
1797 .exec('SELECT blobCid FROM record_blob WHERE recordUri = ?', uri)
1798 .toArray();
1799
1800 // Remove associations for this record
1801 this.sql.exec('DELETE FROM record_blob WHERE recordUri = ?', uri);
1802
1803 // Check each blob for orphan status and delete if unreferenced
1804 for (const { blobCid } of associatedBlobs) {
1805 const stillReferenced = this.sql
1806 .exec('SELECT 1 FROM record_blob WHERE blobCid = ? LIMIT 1', blobCid)
1807 .toArray();
1808
1809 if (stillReferenced.length === 0) {
1810 // Blob is orphaned, delete from R2 and database
1811 await this.env?.BLOBS?.delete(`${did}/${blobCid}`);
1812 this.sql.exec('DELETE FROM blob WHERE cid = ?', blobCid);
1813 }
1814 }
1815
1816 // Rebuild MST
1817 const mst = new MST(this.sql);
1818 const dataRoot = await mst.computeRoot();
1819
1820 // Get previous commit
1821 const prevCommits = this.sql
1822 .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`)
1823 .toArray();
1824 const prevCommit = prevCommits.length > 0 ? prevCommits[0] : null;
1825
1826 // Create commit
1827 const rev = createTid();
1828 const commit = {
1829 did,
1830 version: 3,
1831 data: dataRoot
1832 ? new CID(cidToBytes(/** @type {string} */ (dataRoot)))
1833 : null,
1834 rev,
1835 prev: prevCommit?.cid
1836 ? new CID(cidToBytes(/** @type {string} */ (prevCommit.cid)))
1837 : null,
1838 };
1839
1840 // Sign commit
1841 const commitBytes = cborEncodeDagCbor(commit);
1842 const signingKey = await this.getSigningKey();
1843 if (!signingKey) throw new Error('No signing key');
1844 const sig = await sign(signingKey, commitBytes);
1845
1846 const signedCommit = { ...commit, sig };
1847 const signedBytes = cborEncodeDagCbor(signedCommit);
1848 const commitCid = await createCid(signedBytes);
1849 const commitCidStr = cidToString(commitCid);
1850
1851 // Store commit block
1852 this.sql.exec(
1853 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`,
1854 commitCidStr,
1855 signedBytes,
1856 );
1857
1858 // Store commit reference
1859 this.sql.exec(
1860 `INSERT INTO commits (cid, rev, prev) VALUES (?, ?, ?)`,
1861 commitCidStr,
1862 rev,
1863 prevCommit?.cid || null,
1864 );
1865
1866 // Update head and rev
1867 await this.state.storage.put('head', commitCidStr);
1868 await this.state.storage.put('rev', rev);
1869
1870 // Collect blocks for the event (commit + MST nodes, no record block)
1871 const newBlocks = [];
1872 newBlocks.push({ cid: commitCidStr, data: signedBytes });
1873 if (dataRoot) {
1874 const mstBlocks = this.collectMstBlocks(/** @type {string} */ (dataRoot));
1875 newBlocks.push(...mstBlocks);
1876 }
1877
1878 // Sequence event with delete action
1879 const eventTime = new Date().toISOString();
1880 const evt = cborEncode({
1881 ops: [{ action: 'delete', path: `${collection}/${rkey}`, cid: null }],
1882 blocks: buildCarFile(commitCidStr, newBlocks),
1883 rev,
1884 time: eventTime,
1885 });
1886 this.sql.exec(
1887 `INSERT INTO seq_events (did, commit_cid, evt) VALUES (?, ?, ?)`,
1888 did,
1889 commitCidStr,
1890 evt,
1891 );
1892
1893 // Broadcast to subscribers
1894 const evtRows = /** @type {SeqEventRow[]} */ (
1895 this.sql
1896 .exec(`SELECT * FROM seq_events ORDER BY seq DESC LIMIT 1`)
1897 .toArray()
1898 );
1899 if (evtRows.length > 0) {
1900 this.broadcastEvent(evtRows[0]);
1901 // Forward to default DO for relay subscribers
1902 if (this.env?.PDS) {
1903 const defaultId = this.env.PDS.idFromName('default');
1904 const defaultPds = this.env.PDS.get(defaultId);
1905 const row = evtRows[0];
1906 const evtArray = Array.from(new Uint8Array(row.evt));
1907 defaultPds
1908 .fetch(
1909 new Request('http://internal/forward-event', {
1910 method: 'POST',
1911 body: JSON.stringify({ ...row, evt: evtArray }),
1912 }),
1913 )
1914 .catch(() => {}); // Ignore forward errors
1915 }
1916 }
1917
1918 return { ok: true };
1919 }
1920
1921 /**
1922 * @param {SeqEventRow} evt
1923 * @returns {Uint8Array}
1924 */
1925 formatEvent(evt) {
1926 // AT Protocol frame format: header + body
1927 // Use DAG-CBOR encoding for body (CIDs need tag 42 + 0x00 prefix)
1928 const header = cborEncode({ op: 1, t: '#commit' });
1929
1930 // Decode stored event to get ops, blocks, rev, and time
1931 const evtData = cborDecode(new Uint8Array(evt.evt));
1932 /** @type {Array<{action: string, path: string, cid: CID|null}>} */
1933 const ops = evtData.ops.map(
1934 (/** @type {{action: string, path: string, cid?: string}} */ op) => ({
1935 ...op,
1936 cid: op.cid ? new CID(cidToBytes(op.cid)) : null, // Wrap in CID class for tag 42 encoding
1937 }),
1938 );
1939 // Get blocks from stored event (already in CAR format)
1940 const blocks = evtData.blocks || new Uint8Array(0);
1941
1942 const body = cborEncodeDagCbor({
1943 seq: evt.seq,
1944 rebase: false,
1945 tooBig: false,
1946 repo: evt.did,
1947 commit: new CID(cidToBytes(evt.commit_cid)), // Wrap in CID class for tag 42 encoding
1948 rev: evtData.rev, // Use stored rev from commit creation
1949 since: null,
1950 blocks: blocks instanceof Uint8Array ? blocks : new Uint8Array(blocks),
1951 ops,
1952 blobs: [],
1953 time: evtData.time, // Use stored time from event creation
1954 });
1955
1956 // Concatenate header + body
1957 const frame = new Uint8Array(header.length + body.length);
1958 frame.set(header);
1959 frame.set(body, header.length);
1960 return frame;
1961 }
1962
1963 /**
1964 * @param {WebSocket} ws
1965 * @param {string | ArrayBuffer} message
1966 */
1967 async webSocketMessage(ws, message) {
1968 // Handle ping
1969 if (message === 'ping') ws.send('pong');
1970 }
1971
1972 /**
1973 * @param {WebSocket} _ws
1974 * @param {number} _code
1975 * @param {string} _reason
1976 */
1977 async webSocketClose(_ws, _code, _reason) {
1978 // Durable Object will hibernate when no connections remain
1979 }
1980
1981 /**
1982 * @param {SeqEventRow} evt
1983 */
1984 broadcastEvent(evt) {
1985 const frame = this.formatEvent(evt);
1986 for (const ws of this.state.getWebSockets()) {
1987 try {
1988 ws.send(frame);
1989 } catch (_e) {
1990 // Client disconnected
1991 }
1992 }
1993 }
1994
1995 async handleAtprotoDid() {
1996 let did = await this.getDid();
1997 if (!did) {
1998 /** @type {string[]} */
1999 const registeredDids =
2000 (await this.state.storage.get('registeredDids')) || [];
2001 did = registeredDids[0];
2002 }
2003 if (!did) {
2004 return new Response('User not found', { status: 404 });
2005 }
2006 return new Response(/** @type {string} */ (did), {
2007 headers: { 'Content-Type': 'text/plain' },
2008 });
2009 }
2010
2011 /** @param {Request} request */
2012 async handleInit(request) {
2013 const body = await request.json();
2014 if (!body.did || !body.privateKey) {
2015 return errorResponse('InvalidRequest', 'missing did or privateKey', 400);
2016 }
2017 await this.initIdentity(body.did, body.privateKey, body.handle || null);
2018 return Response.json({
2019 ok: true,
2020 did: body.did,
2021 handle: body.handle || null,
2022 });
2023 }
2024
2025 async handleStatus() {
2026 const did = await this.getDid();
2027 return Response.json({ initialized: !!did, did: did || null });
2028 }
2029
2030 /** @param {Request} request */
2031 async handleForwardEvent(request) {
2032 const evt = await request.json();
2033 const numSockets = [...this.state.getWebSockets()].length;
2034 this.broadcastEvent({
2035 seq: evt.seq,
2036 did: evt.did,
2037 commit_cid: evt.commit_cid,
2038 evt: new Uint8Array(Object.values(evt.evt)),
2039 });
2040 return Response.json({ ok: true, sockets: numSockets });
2041 }
2042
2043 /** @param {Request} request */
2044 async handleRegisterDid(request) {
2045 const body = await request.json();
2046 /** @type {string[]} */
2047 const registeredDids =
2048 (await this.state.storage.get('registeredDids')) || [];
2049 if (!registeredDids.includes(body.did)) {
2050 registeredDids.push(body.did);
2051 await this.state.storage.put('registeredDids', registeredDids);
2052 }
2053 return Response.json({ ok: true });
2054 }
2055
2056 async handleGetRegisteredDids() {
2057 const registeredDids =
2058 (await this.state.storage.get('registeredDids')) || [];
2059 return Response.json({ dids: registeredDids });
2060 }
2061
2062 /** @param {Request} request */
2063 async handleRegisterHandle(request) {
2064 const body = await request.json();
2065 const { handle, did } = body;
2066 if (!handle || !did) {
2067 return errorResponse('InvalidRequest', 'missing handle or did', 400);
2068 }
2069 /** @type {Record<string, string>} */
2070 const handleMap = (await this.state.storage.get('handleMap')) || {};
2071 handleMap[handle] = did;
2072 await this.state.storage.put('handleMap', handleMap);
2073 return Response.json({ ok: true });
2074 }
2075
2076 /** @param {URL} url */
2077 async handleResolveHandle(url) {
2078 const handle = url.searchParams.get('handle');
2079 if (!handle) {
2080 return errorResponse('InvalidRequest', 'missing handle', 400);
2081 }
2082 /** @type {Record<string, string>} */
2083 const handleMap = (await this.state.storage.get('handleMap')) || {};
2084 const did = handleMap[handle];
2085 if (!did) {
2086 return errorResponse('NotFound', 'handle not found', 404);
2087 }
2088 return Response.json({ did });
2089 }
2090
2091 async handleRepoInfo() {
2092 const head = await this.state.storage.get('head');
2093 const rev = await this.state.storage.get('rev');
2094 return Response.json({ head: head || null, rev: rev || null });
2095 }
2096
2097 /** @param {Request} request */
2098 handleDescribeServer(request) {
2099 const hostname = request.headers.get('x-hostname') || 'localhost';
2100 return Response.json({
2101 did: `did:web:${hostname}`,
2102 availableUserDomains: [`.${hostname}`],
2103 inviteCodeRequired: false,
2104 phoneVerificationRequired: false,
2105 links: {},
2106 contact: {},
2107 });
2108 }
2109
2110 /** @param {Request} request */
2111 async handleCreateSession(request) {
2112 const body = await request.json();
2113 const { identifier, password } = body;
2114
2115 if (!identifier || !password) {
2116 return errorResponse(
2117 'InvalidRequest',
2118 'Missing identifier or password',
2119 400,
2120 );
2121 }
2122
2123 // Check password against env var
2124 const expectedPassword = this.env?.PDS_PASSWORD;
2125 if (!expectedPassword || password !== expectedPassword) {
2126 return errorResponse(
2127 'AuthRequired',
2128 'Invalid identifier or password',
2129 401,
2130 );
2131 }
2132
2133 // Resolve identifier to DID
2134 let did = identifier;
2135 if (!identifier.startsWith('did:')) {
2136 // Try to resolve handle
2137 /** @type {Record<string, string>} */
2138 const handleMap = (await this.state.storage.get('handleMap')) || {};
2139 did = handleMap[identifier];
2140 if (!did) {
2141 return errorResponse('InvalidRequest', 'Unable to resolve handle', 400);
2142 }
2143 }
2144
2145 // Get handle for response
2146 const handle = await this.getHandleForDid(did);
2147
2148 // Create tokens
2149 const jwtSecret = this.env?.JWT_SECRET;
2150 if (!jwtSecret) {
2151 return errorResponse(
2152 'InternalServerError',
2153 'Server not configured for authentication',
2154 500,
2155 );
2156 }
2157
2158 const accessJwt = await createAccessJwt(did, jwtSecret);
2159 const refreshJwt = await createRefreshJwt(did, jwtSecret);
2160
2161 return Response.json({
2162 accessJwt,
2163 refreshJwt,
2164 handle: handle || did,
2165 did,
2166 active: true,
2167 });
2168 }
2169
2170 /** @param {Request} request */
2171 async handleGetSession(request) {
2172 const authHeader = request.headers.get('Authorization');
2173 if (!authHeader || !authHeader.startsWith('Bearer ')) {
2174 return errorResponse(
2175 'AuthRequired',
2176 'Missing or invalid authorization header',
2177 401,
2178 );
2179 }
2180
2181 const token = authHeader.slice(7); // Remove 'Bearer '
2182 const jwtSecret = this.env?.JWT_SECRET;
2183 if (!jwtSecret) {
2184 return errorResponse(
2185 'InternalServerError',
2186 'Server not configured for authentication',
2187 500,
2188 );
2189 }
2190
2191 try {
2192 const payload = await verifyAccessJwt(token, jwtSecret);
2193 const did = payload.sub;
2194 const handle = await this.getHandleForDid(did);
2195
2196 return Response.json({
2197 handle: handle || did,
2198 did,
2199 active: true,
2200 });
2201 } catch (err) {
2202 const message = err instanceof Error ? err.message : String(err);
2203 return errorResponse('InvalidToken', message, 401);
2204 }
2205 }
2206
2207 /** @param {Request} request */
2208 async handleRefreshSession(request) {
2209 const authHeader = request.headers.get('Authorization');
2210 if (!authHeader || !authHeader.startsWith('Bearer ')) {
2211 return errorResponse(
2212 'AuthRequired',
2213 'Missing or invalid authorization header',
2214 401,
2215 );
2216 }
2217
2218 const token = authHeader.slice(7); // Remove 'Bearer '
2219 const jwtSecret = this.env?.JWT_SECRET;
2220 if (!jwtSecret) {
2221 return errorResponse(
2222 'InternalServerError',
2223 'Server not configured for authentication',
2224 500,
2225 );
2226 }
2227
2228 try {
2229 const payload = await verifyRefreshJwt(token, jwtSecret);
2230 const did = payload.sub;
2231 const handle = await this.getHandleForDid(did);
2232
2233 // Issue fresh tokens
2234 const accessJwt = await createAccessJwt(did, jwtSecret);
2235 const refreshJwt = await createRefreshJwt(did, jwtSecret);
2236
2237 return Response.json({
2238 accessJwt,
2239 refreshJwt,
2240 handle: handle || did,
2241 did,
2242 active: true,
2243 });
2244 } catch (err) {
2245 const message = err instanceof Error ? err.message : String(err);
2246 if (message === 'Token expired') {
2247 return errorResponse('ExpiredToken', 'Refresh token has expired', 400);
2248 }
2249 return errorResponse('InvalidToken', message, 400);
2250 }
2251 }
2252
2253 /** @param {Request} _request */
2254 async handleGetPreferences(_request) {
2255 // Preferences are stored per-user in their DO
2256 const preferences = (await this.state.storage.get('preferences')) || [];
2257 return Response.json({ preferences });
2258 }
2259
2260 /** @param {Request} request */
2261 async handlePutPreferences(request) {
2262 const body = await request.json();
2263 const { preferences } = body;
2264 if (!Array.isArray(preferences)) {
2265 return errorResponse(
2266 'InvalidRequest',
2267 'preferences must be an array',
2268 400,
2269 );
2270 }
2271 await this.state.storage.put('preferences', preferences);
2272 return Response.json({});
2273 }
2274
2275 /**
2276 * @param {string} did
2277 * @returns {Promise<string|null>}
2278 */
2279 async getHandleForDid(did) {
2280 // Check if this DID has a handle registered
2281 /** @type {Record<string, string>} */
2282 const handleMap = (await this.state.storage.get('handleMap')) || {};
2283 for (const [handle, mappedDid] of Object.entries(handleMap)) {
2284 if (mappedDid === did) return handle;
2285 }
2286 // Check instance's own handle
2287 const instanceDid = await this.getDid();
2288 if (instanceDid === did) {
2289 return /** @type {string|null} */ (
2290 await this.state.storage.get('handle')
2291 );
2292 }
2293 return null;
2294 }
2295
2296 /**
2297 * @param {string} did
2298 * @param {string|null} lxm
2299 */
2300 async createServiceAuthForAppView(did, lxm) {
2301 const signingKey = await this.getSigningKey();
2302 if (!signingKey) throw new Error('No signing key available');
2303 return createServiceJwt({
2304 iss: did,
2305 aud: 'did:web:api.bsky.app',
2306 lxm,
2307 signingKey,
2308 });
2309 }
2310
2311 /**
2312 * @param {Request} request
2313 * @param {string} userDid
2314 */
2315 async handleAppViewProxy(request, userDid) {
2316 const url = new URL(request.url);
2317 // Extract lexicon method from path: /xrpc/app.bsky.actor.getPreferences -> app.bsky.actor.getPreferences
2318 const lxm = url.pathname.replace('/xrpc/', '');
2319
2320 // Create service auth JWT
2321 const serviceJwt = await this.createServiceAuthForAppView(userDid, lxm);
2322
2323 // Build AppView URL
2324 const appViewUrl = new URL(
2325 url.pathname + url.search,
2326 'https://api.bsky.app',
2327 );
2328
2329 // Forward request with service auth
2330 const headers = new Headers();
2331 headers.set('Authorization', `Bearer ${serviceJwt}`);
2332 headers.set(
2333 'Content-Type',
2334 request.headers.get('Content-Type') || 'application/json',
2335 );
2336 const acceptHeader = request.headers.get('Accept');
2337 if (acceptHeader) {
2338 headers.set('Accept', acceptHeader);
2339 }
2340 const acceptLangHeader = request.headers.get('Accept-Language');
2341 if (acceptLangHeader) {
2342 headers.set('Accept-Language', acceptLangHeader);
2343 }
2344
2345 const proxyReq = new Request(appViewUrl.toString(), {
2346 method: request.method,
2347 headers,
2348 body:
2349 request.method !== 'GET' && request.method !== 'HEAD'
2350 ? request.body
2351 : undefined,
2352 });
2353
2354 try {
2355 const response = await fetch(proxyReq);
2356 // Return the response with CORS headers
2357 const responseHeaders = new Headers(response.headers);
2358 responseHeaders.set('Access-Control-Allow-Origin', '*');
2359 return new Response(response.body, {
2360 status: response.status,
2361 statusText: response.statusText,
2362 headers: responseHeaders,
2363 });
2364 } catch (err) {
2365 const message = err instanceof Error ? err.message : String(err);
2366 return errorResponse(
2367 'UpstreamFailure',
2368 `Failed to reach AppView: ${message}`,
2369 502,
2370 );
2371 }
2372 }
2373
2374 async handleListRepos() {
2375 /** @type {string[]} */
2376 const registeredDids =
2377 (await this.state.storage.get('registeredDids')) || [];
2378 const did = await this.getDid();
2379 const repos = did
2380 ? [{ did, head: null, rev: null }]
2381 : registeredDids.map((/** @type {string} */ d) => ({
2382 did: d,
2383 head: null,
2384 rev: null,
2385 }));
2386 return Response.json({ repos });
2387 }
2388
2389 /** @param {Request} request */
2390 async handleCreateRecord(request) {
2391 const body = await request.json();
2392 if (!body.collection || !body.record) {
2393 return errorResponse(
2394 'InvalidRequest',
2395 'missing collection or record',
2396 400,
2397 );
2398 }
2399 try {
2400 const result = await this.createRecord(
2401 body.collection,
2402 body.record,
2403 body.rkey,
2404 );
2405 const head = await this.state.storage.get('head');
2406 const rev = await this.state.storage.get('rev');
2407 return Response.json({
2408 uri: result.uri,
2409 cid: result.cid,
2410 commit: { cid: head, rev },
2411 validationStatus: 'valid',
2412 });
2413 } catch (err) {
2414 const message = err instanceof Error ? err.message : String(err);
2415 return errorResponse('InternalError', message, 500);
2416 }
2417 }
2418
2419 /** @param {Request} request */
2420 async handleDeleteRecord(request) {
2421 const body = await request.json();
2422 if (!body.collection || !body.rkey) {
2423 return errorResponse('InvalidRequest', 'missing collection or rkey', 400);
2424 }
2425 try {
2426 const result = await this.deleteRecord(body.collection, body.rkey);
2427 if (result.error) {
2428 return Response.json(result, { status: 404 });
2429 }
2430 return Response.json({});
2431 } catch (err) {
2432 const message = err instanceof Error ? err.message : String(err);
2433 return errorResponse('InternalError', message, 500);
2434 }
2435 }
2436
2437 /** @param {Request} request */
2438 async handlePutRecord(request) {
2439 const body = await request.json();
2440 if (!body.collection || !body.rkey || !body.record) {
2441 return errorResponse(
2442 'InvalidRequest',
2443 'missing collection, rkey, or record',
2444 400,
2445 );
2446 }
2447 try {
2448 // putRecord is like createRecord but with a specific rkey (upsert)
2449 const result = await this.createRecord(
2450 body.collection,
2451 body.record,
2452 body.rkey,
2453 );
2454 const head = await this.state.storage.get('head');
2455 const rev = await this.state.storage.get('rev');
2456 return Response.json({
2457 uri: result.uri,
2458 cid: result.cid,
2459 commit: { cid: head, rev },
2460 validationStatus: 'valid',
2461 });
2462 } catch (err) {
2463 const message = err instanceof Error ? err.message : String(err);
2464 return errorResponse('InternalError', message, 500);
2465 }
2466 }
2467
2468 /** @param {Request} request */
2469 async handleApplyWrites(request) {
2470 const body = await request.json();
2471 if (!body.writes || !Array.isArray(body.writes)) {
2472 return errorResponse('InvalidRequest', 'missing writes array', 400);
2473 }
2474 try {
2475 const results = [];
2476 for (const write of body.writes) {
2477 const type = write.$type;
2478 if (type === 'com.atproto.repo.applyWrites#create') {
2479 const result = await this.createRecord(
2480 write.collection,
2481 write.value,
2482 write.rkey,
2483 );
2484 results.push({
2485 $type: 'com.atproto.repo.applyWrites#createResult',
2486 uri: result.uri,
2487 cid: result.cid,
2488 validationStatus: 'valid',
2489 });
2490 } else if (type === 'com.atproto.repo.applyWrites#update') {
2491 const result = await this.createRecord(
2492 write.collection,
2493 write.value,
2494 write.rkey,
2495 );
2496 results.push({
2497 $type: 'com.atproto.repo.applyWrites#updateResult',
2498 uri: result.uri,
2499 cid: result.cid,
2500 validationStatus: 'valid',
2501 });
2502 } else if (type === 'com.atproto.repo.applyWrites#delete') {
2503 await this.deleteRecord(write.collection, write.rkey);
2504 results.push({
2505 $type: 'com.atproto.repo.applyWrites#deleteResult',
2506 });
2507 } else {
2508 return errorResponse(
2509 'InvalidRequest',
2510 `Unknown write operation type: ${type}`,
2511 400,
2512 );
2513 }
2514 }
2515 // Return commit info
2516 const head = await this.state.storage.get('head');
2517 const rev = await this.state.storage.get('rev');
2518 return Response.json({ commit: { cid: head, rev }, results });
2519 } catch (err) {
2520 const message = err instanceof Error ? err.message : String(err);
2521 return errorResponse('InternalError', message, 500);
2522 }
2523 }
2524
2525 /** @param {URL} url */
2526 async handleGetRecord(url) {
2527 const collection = url.searchParams.get('collection');
2528 const rkey = url.searchParams.get('rkey');
2529 if (!collection || !rkey) {
2530 return errorResponse('InvalidRequest', 'missing collection or rkey', 400);
2531 }
2532 const did = await this.getDid();
2533 const uri = `at://${did}/${collection}/${rkey}`;
2534 const rows = /** @type {RecordRow[]} */ (
2535 this.sql
2536 .exec(`SELECT cid, value FROM records WHERE uri = ?`, uri)
2537 .toArray()
2538 );
2539 if (rows.length === 0) {
2540 return errorResponse('RecordNotFound', 'record not found', 404);
2541 }
2542 const row = rows[0];
2543 const value = cborDecode(new Uint8Array(row.value));
2544 return Response.json({ uri, cid: row.cid, value });
2545 }
2546
2547 async handleDescribeRepo() {
2548 const did = await this.getDid();
2549 if (!did) {
2550 return errorResponse('RepoNotFound', 'repo not found', 404);
2551 }
2552 const handle = await this.state.storage.get('handle');
2553 // Get unique collections
2554 const collections = this.sql
2555 .exec(`SELECT DISTINCT collection FROM records`)
2556 .toArray()
2557 .map((r) => r.collection);
2558
2559 return Response.json({
2560 handle: handle || did,
2561 did,
2562 didDoc: {},
2563 collections,
2564 handleIsCorrect: !!handle,
2565 });
2566 }
2567
2568 /** @param {URL} url */
2569 async handleListRecords(url) {
2570 const collection = url.searchParams.get('collection');
2571 if (!collection) {
2572 return errorResponse('InvalidRequest', 'missing collection', 400);
2573 }
2574 const limit = Math.min(
2575 parseInt(url.searchParams.get('limit') || '50', 10),
2576 100,
2577 );
2578 const reverse = url.searchParams.get('reverse') === 'true';
2579 const _cursor = url.searchParams.get('cursor');
2580
2581 const _did = await this.getDid();
2582 const query = `SELECT uri, cid, value FROM records WHERE collection = ? ORDER BY rkey ${reverse ? 'DESC' : 'ASC'} LIMIT ?`;
2583 const params = [collection, limit + 1];
2584
2585 const rows = /** @type {RecordRow[]} */ (
2586 this.sql.exec(query, ...params).toArray()
2587 );
2588 const hasMore = rows.length > limit;
2589 const records = rows.slice(0, limit).map((r) => ({
2590 uri: r.uri,
2591 cid: r.cid,
2592 value: cborDecode(new Uint8Array(r.value)),
2593 }));
2594
2595 return Response.json({
2596 records,
2597 cursor: hasMore ? records[records.length - 1]?.uri : undefined,
2598 });
2599 }
2600
2601 handleGetLatestCommit() {
2602 const commits = /** @type {CommitRow[]} */ (
2603 this.sql
2604 .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`)
2605 .toArray()
2606 );
2607 if (commits.length === 0) {
2608 return errorResponse('RepoNotFound', 'repo not found', 404);
2609 }
2610 return Response.json({ cid: commits[0].cid, rev: commits[0].rev });
2611 }
2612
2613 async handleGetRepoStatus() {
2614 const did = await this.getDid();
2615 const commits = /** @type {CommitRow[]} */ (
2616 this.sql
2617 .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`)
2618 .toArray()
2619 );
2620 if (commits.length === 0 || !did) {
2621 return errorResponse('RepoNotFound', 'repo not found', 404);
2622 }
2623 return Response.json({
2624 did,
2625 active: true,
2626 status: 'active',
2627 rev: commits[0].rev,
2628 });
2629 }
2630
2631 handleGetRepo() {
2632 const commits = /** @type {CommitRow[]} */ (
2633 this.sql
2634 .exec(`SELECT cid FROM commits ORDER BY seq DESC LIMIT 1`)
2635 .toArray()
2636 );
2637 if (commits.length === 0) {
2638 return errorResponse('RepoNotFound', 'repo not found', 404);
2639 }
2640
2641 // Only include blocks reachable from the current commit
2642 const commitCid = commits[0].cid;
2643 const neededCids = new Set();
2644
2645 // Helper to get block data
2646 /** @param {string} cid */
2647 const getBlock = (cid) => {
2648 const rows = /** @type {BlockRow[]} */ (
2649 this.sql.exec(`SELECT data FROM blocks WHERE cid = ?`, cid).toArray()
2650 );
2651 return rows.length > 0 ? new Uint8Array(rows[0].data) : null;
2652 };
2653
2654 // Collect all reachable blocks starting from commit
2655 /** @param {string} cid */
2656 const collectBlocks = (cid) => {
2657 if (neededCids.has(cid)) return;
2658 neededCids.add(cid);
2659
2660 const data = getBlock(cid);
2661 if (!data) return;
2662
2663 // Decode CBOR to find CID references
2664 try {
2665 const decoded = cborDecode(data);
2666 if (decoded && typeof decoded === 'object') {
2667 // Commit object - follow 'data' (MST root)
2668 if (decoded.data instanceof Uint8Array) {
2669 collectBlocks(cidToString(decoded.data));
2670 }
2671 // MST node - follow 'l' and entries' 'v' and 't'
2672 if (decoded.l instanceof Uint8Array) {
2673 collectBlocks(cidToString(decoded.l));
2674 }
2675 if (Array.isArray(decoded.e)) {
2676 for (const entry of decoded.e) {
2677 if (entry.v instanceof Uint8Array) {
2678 collectBlocks(cidToString(entry.v));
2679 }
2680 if (entry.t instanceof Uint8Array) {
2681 collectBlocks(cidToString(entry.t));
2682 }
2683 }
2684 }
2685 }
2686 } catch (_e) {
2687 // Not a structured block, that's fine
2688 }
2689 };
2690
2691 collectBlocks(commitCid);
2692
2693 // Build CAR with only needed blocks
2694 const blocksForCar = [];
2695 for (const cid of neededCids) {
2696 const data = getBlock(cid);
2697 if (data) {
2698 blocksForCar.push({ cid, data });
2699 }
2700 }
2701
2702 const car = buildCarFile(commitCid, blocksForCar);
2703 return new Response(/** @type {BodyInit} */ (car), {
2704 headers: { 'content-type': 'application/vnd.ipld.car' },
2705 });
2706 }
2707
2708 /** @param {URL} url */
2709 async handleSyncGetRecord(url) {
2710 const collection = url.searchParams.get('collection');
2711 const rkey = url.searchParams.get('rkey');
2712 if (!collection || !rkey) {
2713 return errorResponse('InvalidRequest', 'missing collection or rkey', 400);
2714 }
2715 const did = await this.getDid();
2716 const uri = `at://${did}/${collection}/${rkey}`;
2717 const rows = /** @type {RecordRow[]} */ (
2718 this.sql.exec(`SELECT cid FROM records WHERE uri = ?`, uri).toArray()
2719 );
2720 if (rows.length === 0) {
2721 return errorResponse('RecordNotFound', 'record not found', 404);
2722 }
2723 const recordCid = rows[0].cid;
2724
2725 // Get latest commit
2726 const commits = /** @type {CommitRow[]} */ (
2727 this.sql
2728 .exec(`SELECT cid FROM commits ORDER BY seq DESC LIMIT 1`)
2729 .toArray()
2730 );
2731 if (commits.length === 0) {
2732 return errorResponse('RepoNotFound', 'no commits', 404);
2733 }
2734 const commitCid = commits[0].cid;
2735
2736 // Build proof chain: commit -> MST path -> record
2737 // Include commit block, all MST nodes on path to record, and record block
2738 /** @type {Array<{cid: string, data: Uint8Array}>} */
2739 const blocks = [];
2740 const included = new Set();
2741
2742 /** @param {string} cidStr */
2743 const addBlock = (cidStr) => {
2744 if (included.has(cidStr)) return;
2745 included.add(cidStr);
2746 const blockRows = /** @type {BlockRow[]} */ (
2747 this.sql.exec(`SELECT data FROM blocks WHERE cid = ?`, cidStr).toArray()
2748 );
2749 if (blockRows.length > 0) {
2750 blocks.push({ cid: cidStr, data: new Uint8Array(blockRows[0].data) });
2751 }
2752 };
2753
2754 // Add commit block
2755 addBlock(commitCid);
2756
2757 // Get commit to find data root
2758 const commitRows = /** @type {BlockRow[]} */ (
2759 this.sql
2760 .exec(`SELECT data FROM blocks WHERE cid = ?`, commitCid)
2761 .toArray()
2762 );
2763 if (commitRows.length > 0) {
2764 const commit = cborDecode(new Uint8Array(commitRows[0].data));
2765 if (commit.data) {
2766 const dataRootCid = cidToString(commit.data);
2767 // Collect MST path blocks (this includes all MST nodes)
2768 const mstBlocks = this.collectMstBlocks(dataRootCid);
2769 for (const block of mstBlocks) {
2770 addBlock(block.cid);
2771 }
2772 }
2773 }
2774
2775 // Add record block
2776 addBlock(recordCid);
2777
2778 const car = buildCarFile(commitCid, blocks);
2779 return new Response(/** @type {BodyInit} */ (car), {
2780 headers: { 'content-type': 'application/vnd.ipld.car' },
2781 });
2782 }
2783
2784 /** @param {Request} request */
2785 async handleUploadBlob(request) {
2786 // Require auth
2787 const authHeader = request.headers.get('Authorization');
2788 if (!authHeader || !authHeader.startsWith('Bearer ')) {
2789 return errorResponse(
2790 'AuthRequired',
2791 'Missing or invalid authorization header',
2792 401,
2793 );
2794 }
2795
2796 const token = authHeader.slice(7);
2797 const jwtSecret = this.env?.JWT_SECRET;
2798 if (!jwtSecret) {
2799 return errorResponse(
2800 'InternalServerError',
2801 'Server not configured for authentication',
2802 500,
2803 );
2804 }
2805
2806 try {
2807 await verifyAccessJwt(token, jwtSecret);
2808 } catch (err) {
2809 const message = err instanceof Error ? err.message : String(err);
2810 return errorResponse('InvalidToken', message, 401);
2811 }
2812
2813 const did = await this.getDid();
2814 if (!did) {
2815 return errorResponse('InvalidRequest', 'PDS not initialized', 400);
2816 }
2817
2818 // Read body as ArrayBuffer
2819 const bodyBytes = await request.arrayBuffer();
2820 const size = bodyBytes.byteLength;
2821
2822 // Check size limits
2823 if (size === 0) {
2824 return errorResponse(
2825 'InvalidRequest',
2826 'Empty blobs are not allowed',
2827 400,
2828 );
2829 }
2830 const MAX_BLOB_SIZE = 50 * 1024 * 1024;
2831 if (size > MAX_BLOB_SIZE) {
2832 return errorResponse(
2833 'BlobTooLarge',
2834 `Blob size ${size} exceeds maximum ${MAX_BLOB_SIZE}`,
2835 400,
2836 );
2837 }
2838
2839 // Sniff MIME type, fall back to Content-Type header
2840 const contentType =
2841 request.headers.get('Content-Type') || 'application/octet-stream';
2842 const sniffed = sniffMimeType(bodyBytes);
2843 const mimeType = sniffed || contentType;
2844
2845 // Compute CID using raw codec for blobs
2846 const cid = await createBlobCid(new Uint8Array(bodyBytes));
2847 const cidStr = cidToString(cid);
2848
2849 // Upload to R2 (idempotent - same CID always has same content)
2850 const r2Key = `${did}/${cidStr}`;
2851 await this.env?.BLOBS?.put(r2Key, bodyBytes, {
2852 httpMetadata: { contentType: mimeType },
2853 });
2854
2855 // Insert metadata (INSERT OR IGNORE handles concurrent uploads)
2856 const createdAt = new Date().toISOString();
2857 this.sql.exec(
2858 'INSERT OR IGNORE INTO blob (cid, mimeType, size, createdAt) VALUES (?, ?, ?, ?)',
2859 cidStr,
2860 mimeType,
2861 size,
2862 createdAt,
2863 );
2864
2865 // Return BlobRef
2866 return Response.json({
2867 blob: {
2868 $type: 'blob',
2869 ref: { $link: cidStr },
2870 mimeType,
2871 size,
2872 },
2873 });
2874 }
2875
2876 /** @param {URL} url */
2877 async handleGetBlob(url) {
2878 const did = url.searchParams.get('did');
2879 const cid = url.searchParams.get('cid');
2880
2881 if (!did || !cid) {
2882 return errorResponse(
2883 'InvalidRequest',
2884 'missing did or cid parameter',
2885 400,
2886 );
2887 }
2888
2889 // Validate CID format (CIDv1 base32lower: starts with 'b', 59 chars total)
2890 if (!/^b[a-z2-7]{58}$/.test(cid)) {
2891 return errorResponse('InvalidRequest', 'invalid CID format', 400);
2892 }
2893
2894 // Verify DID matches this DO
2895 const myDid = await this.getDid();
2896 if (did !== myDid) {
2897 return errorResponse(
2898 'InvalidRequest',
2899 'DID does not match this repo',
2900 400,
2901 );
2902 }
2903
2904 // Look up blob metadata
2905 const rows = this.sql
2906 .exec('SELECT mimeType, size FROM blob WHERE cid = ?', cid)
2907 .toArray();
2908
2909 if (rows.length === 0) {
2910 return errorResponse('BlobNotFound', 'blob not found', 404);
2911 }
2912
2913 const { mimeType, size } = rows[0];
2914
2915 // Fetch from R2
2916 const r2Key = `${did}/${cid}`;
2917 const object = await this.env?.BLOBS?.get(r2Key);
2918
2919 if (!object) {
2920 return errorResponse('BlobNotFound', 'blob not found in storage', 404);
2921 }
2922
2923 // Return blob with security headers
2924 return new Response(object.body, {
2925 headers: {
2926 'Content-Type': /** @type {string} */ (mimeType),
2927 'Content-Length': String(size),
2928 'X-Content-Type-Options': 'nosniff',
2929 'Content-Security-Policy': "default-src 'none'; sandbox",
2930 'Cache-Control': 'public, max-age=31536000, immutable',
2931 },
2932 });
2933 }
2934
2935 /** @param {URL} url */
2936 async handleListBlobs(url) {
2937 const did = url.searchParams.get('did');
2938 const cursor = url.searchParams.get('cursor');
2939 const limit = Math.min(Number(url.searchParams.get('limit')) || 500, 1000);
2940
2941 if (!did) {
2942 return errorResponse('InvalidRequest', 'missing did parameter', 400);
2943 }
2944
2945 // Verify DID matches this DO
2946 const myDid = await this.getDid();
2947 if (did !== myDid) {
2948 return errorResponse(
2949 'InvalidRequest',
2950 'DID does not match this repo',
2951 400,
2952 );
2953 }
2954
2955 // Query blobs with pagination (cursor is createdAt::cid for uniqueness)
2956 let query = 'SELECT cid, createdAt FROM blob';
2957 const params = [];
2958
2959 if (cursor) {
2960 const [cursorTime, cursorCid] = cursor.split('::');
2961 query += ' WHERE (createdAt > ? OR (createdAt = ? AND cid > ?))';
2962 params.push(cursorTime, cursorTime, cursorCid);
2963 }
2964
2965 query += ' ORDER BY createdAt ASC, cid ASC LIMIT ?';
2966 params.push(limit + 1); // Fetch one extra to detect if there's more
2967
2968 const rows = this.sql.exec(query, ...params).toArray();
2969
2970 // Determine if there's a next page
2971 let nextCursor = null;
2972 if (rows.length > limit) {
2973 rows.pop(); // Remove the extra row
2974 const last = rows[rows.length - 1];
2975 nextCursor = `${last.createdAt}::${last.cid}`;
2976 }
2977
2978 return Response.json({
2979 cids: rows.map((r) => r.cid),
2980 cursor: nextCursor,
2981 });
2982 }
2983
2984 /**
2985 * @param {Request} request
2986 * @param {URL} url
2987 */
2988 handleSubscribeRepos(request, url) {
2989 const upgradeHeader = request.headers.get('Upgrade');
2990 if (upgradeHeader !== 'websocket') {
2991 return new Response('expected websocket', { status: 426 });
2992 }
2993 const { 0: client, 1: server } = new WebSocketPair();
2994 this.state.acceptWebSocket(server);
2995 const cursor = url.searchParams.get('cursor');
2996 if (cursor) {
2997 const events = /** @type {SeqEventRow[]} */ (
2998 this.sql
2999 .exec(
3000 `SELECT * FROM seq_events WHERE seq > ? ORDER BY seq`,
3001 parseInt(cursor, 10),
3002 )
3003 .toArray()
3004 );
3005 for (const evt of events) {
3006 server.send(this.formatEvent(evt));
3007 }
3008 }
3009 return new Response(null, { status: 101, webSocket: client });
3010 }
3011
3012 /** @param {Request} request */
3013 async fetch(request) {
3014 const url = new URL(request.url);
3015 const route = pdsRoutes[url.pathname];
3016
3017 // Check for local route first
3018 if (route) {
3019 if (route.method && request.method !== route.method) {
3020 return errorResponse('MethodNotAllowed', 'method not allowed', 405);
3021 }
3022 return route.handler(this, request, url);
3023 }
3024
3025 // Handle app.bsky.* proxy requests (only if no local route)
3026 if (url.pathname.startsWith('/xrpc/app.bsky.')) {
3027 const userDid = request.headers.get('x-authed-did');
3028 if (!userDid) {
3029 return errorResponse('Unauthorized', 'Missing auth context', 401);
3030 }
3031 return this.handleAppViewProxy(request, userDid);
3032 }
3033
3034 return errorResponse('NotFound', 'not found', 404);
3035 }
3036
3037 async alarm() {
3038 await this.cleanupOrphanedBlobs();
3039 // Reschedule for next day
3040 await this.state.storage.setAlarm(Date.now() + 24 * 60 * 60 * 1000);
3041 }
3042
3043 async cleanupOrphanedBlobs() {
3044 const did = await this.getDid();
3045 if (!did) return;
3046
3047 // Find orphans: blobs not in record_blob, older than 24h
3048 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
3049
3050 const orphans = this.sql
3051 .exec(
3052 `SELECT b.cid FROM blob b
3053 LEFT JOIN record_blob rb ON b.cid = rb.blobCid
3054 WHERE rb.blobCid IS NULL AND b.createdAt < ?`,
3055 cutoff,
3056 )
3057 .toArray();
3058
3059 for (const { cid } of orphans) {
3060 await this.env?.BLOBS?.delete(`${did}/${cid}`);
3061 this.sql.exec('DELETE FROM blob WHERE cid = ?', cid);
3062 }
3063 }
3064}
3065
3066// ╔══════════════════════════════════════════════════════════════════════════════╗
3067// ║ WORKERS ENTRY POINT ║
3068// ║ Request handling, CORS, auth middleware ║
3069// ╚══════════════════════════════════════════════════════════════════════════════╝
3070
3071const corsHeaders = {
3072 'Access-Control-Allow-Origin': '*',
3073 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
3074 'Access-Control-Allow-Headers':
3075 'Content-Type, Authorization, atproto-accept-labelers, atproto-proxy, x-bsky-topics',
3076};
3077
3078/**
3079 * @param {Response} response
3080 * @returns {Response}
3081 */
3082function addCorsHeaders(response) {
3083 const newHeaders = new Headers(response.headers);
3084 for (const [key, value] of Object.entries(corsHeaders)) {
3085 newHeaders.set(key, value);
3086 }
3087 return new Response(response.body, {
3088 status: response.status,
3089 statusText: response.statusText,
3090 headers: newHeaders,
3091 });
3092}
3093
3094export default {
3095 /**
3096 * @param {Request} request
3097 * @param {Env} env
3098 */
3099 async fetch(request, env) {
3100 // Handle CORS preflight
3101 if (request.method === 'OPTIONS') {
3102 return new Response(null, { headers: corsHeaders });
3103 }
3104
3105 const response = await handleRequest(request, env);
3106 // Don't wrap WebSocket upgrades - they need the webSocket property preserved
3107 if (response.status === 101) {
3108 return response;
3109 }
3110 return addCorsHeaders(response);
3111 },
3112};
3113
3114/**
3115 * Extract subdomain from hostname (e.g., "alice" from "alice.foo.workers.dev")
3116 * @param {string} hostname
3117 * @returns {string|null}
3118 */
3119function getSubdomain(hostname) {
3120 const parts = hostname.split('.');
3121 // workers.dev domains: [subdomain?].[worker-name].[account].workers.dev
3122 // If more than 4 parts, first part(s) are user subdomain
3123 if (parts.length > 4 && parts.slice(-2).join('.') === 'workers.dev') {
3124 return parts.slice(0, -4).join('.');
3125 }
3126 // Custom domains: check if there's a subdomain before the base
3127 // For now, assume no subdomain on custom domains
3128 return null;
3129}
3130
3131/**
3132 * Verify auth and return DID from token
3133 * @param {Request} request - HTTP request with Authorization header
3134 * @param {Env} env - Environment with JWT_SECRET
3135 * @returns {Promise<{did: string} | {error: Response}>} DID or error response
3136 */
3137async function requireAuth(request, env) {
3138 const authHeader = request.headers.get('Authorization');
3139 if (!authHeader || !authHeader.startsWith('Bearer ')) {
3140 return {
3141 error: Response.json(
3142 {
3143 error: 'AuthRequired',
3144 message: 'Authentication required',
3145 },
3146 { status: 401 },
3147 ),
3148 };
3149 }
3150
3151 const token = authHeader.slice(7);
3152 const jwtSecret = env?.JWT_SECRET;
3153 if (!jwtSecret) {
3154 return {
3155 error: Response.json(
3156 {
3157 error: 'InternalServerError',
3158 message: 'Server not configured for authentication',
3159 },
3160 { status: 500 },
3161 ),
3162 };
3163 }
3164
3165 try {
3166 const payload = await verifyAccessJwt(token, jwtSecret);
3167 return { did: payload.sub };
3168 } catch (err) {
3169 const message = err instanceof Error ? err.message : String(err);
3170 return {
3171 error: Response.json(
3172 {
3173 error: 'InvalidToken',
3174 message: message,
3175 },
3176 { status: 401 },
3177 ),
3178 };
3179 }
3180}
3181
3182/**
3183 * @param {Request} request
3184 * @param {Env} env
3185 */
3186async function handleAuthenticatedBlobUpload(request, env) {
3187 const auth = await requireAuth(request, env);
3188 if ('error' in auth) return auth.error;
3189
3190 // Route to the user's DO based on their DID from the token
3191 const id = env.PDS.idFromName(auth.did);
3192 const pds = env.PDS.get(id);
3193 return pds.fetch(request);
3194}
3195
3196/**
3197 * @param {Request} request
3198 * @param {Env} env
3199 */
3200async function handleAuthenticatedRepoWrite(request, env) {
3201 const auth = await requireAuth(request, env);
3202 if ('error' in auth) return auth.error;
3203
3204 const body = await request.json();
3205 const repo = body.repo;
3206 if (!repo) {
3207 return errorResponse('InvalidRequest', 'missing repo param', 400);
3208 }
3209
3210 if (auth.did !== repo) {
3211 return errorResponse('Forbidden', "Cannot modify another user's repo", 403);
3212 }
3213
3214 const id = env.PDS.idFromName(repo);
3215 const pds = env.PDS.get(id);
3216 const response = await pds.fetch(
3217 new Request(request.url, {
3218 method: 'POST',
3219 headers: request.headers,
3220 body: JSON.stringify(body),
3221 }),
3222 );
3223
3224 // Notify relay of updates on successful writes
3225 if (response.ok) {
3226 const url = new URL(request.url);
3227 notifyCrawlers(env, url.hostname);
3228 }
3229
3230 return response;
3231}
3232
3233/**
3234 * @param {Request} request
3235 * @param {Env} env
3236 */
3237async function handleRequest(request, env) {
3238 const url = new URL(request.url);
3239 const subdomain = getSubdomain(url.hostname);
3240
3241 // Handle resolution via subdomain or bare domain
3242 if (url.pathname === '/.well-known/atproto-did') {
3243 // Look up handle -> DID in default DO
3244 // Use subdomain if present, otherwise try bare hostname as handle
3245 const handleToResolve = subdomain || url.hostname;
3246 const defaultId = env.PDS.idFromName('default');
3247 const defaultPds = env.PDS.get(defaultId);
3248 const resolveRes = await defaultPds.fetch(
3249 new Request(
3250 `http://internal/resolve-handle?handle=${encodeURIComponent(handleToResolve)}`,
3251 ),
3252 );
3253 if (!resolveRes.ok) {
3254 return new Response('Handle not found', { status: 404 });
3255 }
3256 const { did } = await resolveRes.json();
3257 return new Response(did, { headers: { 'Content-Type': 'text/plain' } });
3258 }
3259
3260 // describeServer - works on bare domain
3261 if (url.pathname === '/xrpc/com.atproto.server.describeServer') {
3262 const defaultId = env.PDS.idFromName('default');
3263 const defaultPds = env.PDS.get(defaultId);
3264 const newReq = new Request(request.url, {
3265 method: request.method,
3266 headers: {
3267 ...Object.fromEntries(request.headers),
3268 'x-hostname': url.hostname,
3269 },
3270 });
3271 return defaultPds.fetch(newReq);
3272 }
3273
3274 // createSession - handle on default DO (has handleMap for identifier resolution)
3275 if (url.pathname === '/xrpc/com.atproto.server.createSession') {
3276 const defaultId = env.PDS.idFromName('default');
3277 const defaultPds = env.PDS.get(defaultId);
3278 return defaultPds.fetch(request);
3279 }
3280
3281 // getSession - route to default DO
3282 if (url.pathname === '/xrpc/com.atproto.server.getSession') {
3283 const defaultId = env.PDS.idFromName('default');
3284 const defaultPds = env.PDS.get(defaultId);
3285 return defaultPds.fetch(request);
3286 }
3287
3288 // refreshSession - route to default DO
3289 if (url.pathname === '/xrpc/com.atproto.server.refreshSession') {
3290 const defaultId = env.PDS.idFromName('default');
3291 const defaultPds = env.PDS.get(defaultId);
3292 return defaultPds.fetch(request);
3293 }
3294
3295 // Proxy app.bsky.* endpoints to Bluesky AppView
3296 if (url.pathname.startsWith('/xrpc/app.bsky.')) {
3297 // Authenticate the user first
3298 const auth = await requireAuth(request, env);
3299 if ('error' in auth) return auth.error;
3300
3301 // Route to the user's DO instance to create service auth and proxy
3302 const id = env.PDS.idFromName(auth.did);
3303 const pds = env.PDS.get(id);
3304 return pds.fetch(
3305 new Request(request.url, {
3306 method: request.method,
3307 headers: {
3308 ...Object.fromEntries(request.headers),
3309 'x-authed-did': auth.did, // Pass the authenticated DID
3310 },
3311 body:
3312 request.method !== 'GET' && request.method !== 'HEAD'
3313 ? request.body
3314 : undefined,
3315 }),
3316 );
3317 }
3318
3319 // Handle registration routes - go to default DO
3320 if (
3321 url.pathname === '/register-handle' ||
3322 url.pathname === '/resolve-handle'
3323 ) {
3324 const defaultId = env.PDS.idFromName('default');
3325 const defaultPds = env.PDS.get(defaultId);
3326 return defaultPds.fetch(request);
3327 }
3328
3329 // resolveHandle XRPC endpoint
3330 if (url.pathname === '/xrpc/com.atproto.identity.resolveHandle') {
3331 const handle = url.searchParams.get('handle');
3332 if (!handle) {
3333 return errorResponse('InvalidRequest', 'missing handle param', 400);
3334 }
3335 const defaultId = env.PDS.idFromName('default');
3336 const defaultPds = env.PDS.get(defaultId);
3337 const resolveRes = await defaultPds.fetch(
3338 new Request(
3339 `http://internal/resolve-handle?handle=${encodeURIComponent(handle)}`,
3340 ),
3341 );
3342 if (!resolveRes.ok) {
3343 return errorResponse('InvalidRequest', 'Unable to resolve handle', 400);
3344 }
3345 const { did } = await resolveRes.json();
3346 return Response.json({ did });
3347 }
3348
3349 // subscribeRepos WebSocket - route to default instance for firehose
3350 if (url.pathname === '/xrpc/com.atproto.sync.subscribeRepos') {
3351 const defaultId = env.PDS.idFromName('default');
3352 const defaultPds = env.PDS.get(defaultId);
3353 return defaultPds.fetch(request);
3354 }
3355
3356 // listRepos needs to aggregate from all registered DIDs
3357 if (url.pathname === '/xrpc/com.atproto.sync.listRepos') {
3358 const defaultId = env.PDS.idFromName('default');
3359 const defaultPds = env.PDS.get(defaultId);
3360 const regRes = await defaultPds.fetch(
3361 new Request('http://internal/get-registered-dids'),
3362 );
3363 const { dids } = await regRes.json();
3364
3365 const repos = [];
3366 for (const did of dids) {
3367 const id = env.PDS.idFromName(did);
3368 const pds = env.PDS.get(id);
3369 const infoRes = await pds.fetch(new Request('http://internal/repo-info'));
3370 const info = await infoRes.json();
3371 if (info.head) {
3372 repos.push({ did, head: info.head, rev: info.rev, active: true });
3373 }
3374 }
3375 return Response.json({ repos, cursor: undefined });
3376 }
3377
3378 // Repo endpoints use ?repo= param instead of ?did=
3379 if (
3380 url.pathname === '/xrpc/com.atproto.repo.describeRepo' ||
3381 url.pathname === '/xrpc/com.atproto.repo.listRecords' ||
3382 url.pathname === '/xrpc/com.atproto.repo.getRecord'
3383 ) {
3384 const repo = url.searchParams.get('repo');
3385 if (!repo) {
3386 return errorResponse('InvalidRequest', 'missing repo param', 400);
3387 }
3388 const id = env.PDS.idFromName(repo);
3389 const pds = env.PDS.get(id);
3390 return pds.fetch(request);
3391 }
3392
3393 // Sync endpoints use ?did= param
3394 if (
3395 url.pathname === '/xrpc/com.atproto.sync.getLatestCommit' ||
3396 url.pathname === '/xrpc/com.atproto.sync.getRepoStatus' ||
3397 url.pathname === '/xrpc/com.atproto.sync.getRepo' ||
3398 url.pathname === '/xrpc/com.atproto.sync.getRecord' ||
3399 url.pathname === '/xrpc/com.atproto.sync.getBlob' ||
3400 url.pathname === '/xrpc/com.atproto.sync.listBlobs'
3401 ) {
3402 const did = url.searchParams.get('did');
3403 if (!did) {
3404 return errorResponse('InvalidRequest', 'missing did param', 400);
3405 }
3406 const id = env.PDS.idFromName(did);
3407 const pds = env.PDS.get(id);
3408 return pds.fetch(request);
3409 }
3410
3411 // Blob upload endpoint (binary body, uses DID from token)
3412 if (url.pathname === '/xrpc/com.atproto.repo.uploadBlob') {
3413 return handleAuthenticatedBlobUpload(request, env);
3414 }
3415
3416 // Authenticated repo write endpoints
3417 const repoWriteEndpoints = [
3418 '/xrpc/com.atproto.repo.createRecord',
3419 '/xrpc/com.atproto.repo.deleteRecord',
3420 '/xrpc/com.atproto.repo.putRecord',
3421 '/xrpc/com.atproto.repo.applyWrites',
3422 ];
3423 if (repoWriteEndpoints.includes(url.pathname)) {
3424 return handleAuthenticatedRepoWrite(request, env);
3425 }
3426
3427 // Health check endpoint
3428 if (url.pathname === '/xrpc/_health') {
3429 return Response.json({ version: '0.1.0' });
3430 }
3431
3432 // Root path - ASCII art
3433 if (url.pathname === '/') {
3434 const ascii = `
3435 ██████╗ ██████╗ ███████╗ ██╗ ███████╗
3436 ██╔══██╗ ██╔══██╗ ██╔════╝ ██║ ██╔════╝
3437 ██████╔╝ ██║ ██║ ███████╗ ██║ ███████╗
3438 ██╔═══╝ ██║ ██║ ╚════██║ ██ ██║ ╚════██║
3439 ██║ ██████╔╝ ███████║ ██╗ ╚█████╔╝ ███████║
3440 ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚════╝ ╚══════╝
3441
3442 ATProto PDS on Cloudflare Workers
3443`;
3444 return new Response(ascii, {
3445 headers: { 'Content-Type': 'text/plain; charset=utf-8' },
3446 });
3447 }
3448
3449 // On init, register this DID with the default instance (requires ?did= param, no auth yet)
3450 if (url.pathname === '/init' && request.method === 'POST') {
3451 const did = url.searchParams.get('did');
3452 if (!did) {
3453 return errorResponse('InvalidRequest', 'missing did param', 400);
3454 }
3455 const body = await request.json();
3456
3457 // Register with default instance for discovery
3458 const defaultId = env.PDS.idFromName('default');
3459 const defaultPds = env.PDS.get(defaultId);
3460 await defaultPds.fetch(
3461 new Request('http://internal/register-did', {
3462 method: 'POST',
3463 body: JSON.stringify({ did }),
3464 }),
3465 );
3466
3467 // Register handle if provided
3468 if (body.handle) {
3469 await defaultPds.fetch(
3470 new Request('http://internal/register-handle', {
3471 method: 'POST',
3472 body: JSON.stringify({ did, handle: body.handle }),
3473 }),
3474 );
3475 }
3476
3477 // Forward to the actual PDS instance
3478 const id = env.PDS.idFromName(did);
3479 const pds = env.PDS.get(id);
3480 return pds.fetch(
3481 new Request(request.url, {
3482 method: 'POST',
3483 headers: request.headers,
3484 body: JSON.stringify(body),
3485 }),
3486 );
3487 }
3488
3489 // Unknown endpoint
3490 return errorResponse('NotFound', 'Endpoint not found', 404);
3491}