this repo has no description
1// ╔══════════════════════════════════════════════════════════════════════════════╗
2// ║ ║
3// ║ ██████╗ ██████╗ ███████╗ Personal Data Server ║
4// ║ ██╔══██╗██╔══██╗██╔════╝ for AT Protocol ║
5// ║ ██████╔╝██║ ██║███████╗ ║
6// ║ ██╔═══╝ ██║ ██║╚════██║ ║
7// ║ ██║ ██████╔╝███████║ ║
8// ║ ╚═╝ ╚═════╝ ╚══════╝ ║
9// ║ ║
10// ╠══════════════════════════════════════════════════════════════════════════════╣
11// ║ ║
12// ║ A single-file ATProto PDS for Cloudflare Workers + Durable Objects ║
13// ║ ║
14// ║ Features: ║
15// ║ • CBOR/DAG-CBOR encoding for content-addressed data ║
16// ║ • CID generation (CIDv1 with dag-cbor + sha-256) ║
17// ║ • Merkle Search Tree (MST) for repository structure ║
18// ║ • P-256 signing with low-S normalization ║
19// ║ • JWT authentication (access, refresh, service tokens) ║
20// ║ • 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 '/reset-repo': {
1368 handler: (pds, _req, _url) => pds.handleResetRepo(),
1369 },
1370 '/forward-event': {
1371 handler: (pds, req, _url) => pds.handleForwardEvent(req),
1372 },
1373 '/register-did': {
1374 handler: (pds, req, _url) => pds.handleRegisterDid(req),
1375 },
1376 '/get-registered-dids': {
1377 handler: (pds, _req, _url) => pds.handleGetRegisteredDids(),
1378 },
1379 '/register-handle': {
1380 method: 'POST',
1381 handler: (pds, req, _url) => pds.handleRegisterHandle(req),
1382 },
1383 '/resolve-handle': {
1384 handler: (pds, _req, url) => pds.handleResolveHandle(url),
1385 },
1386 '/repo-info': {
1387 handler: (pds, _req, _url) => pds.handleRepoInfo(),
1388 },
1389 '/xrpc/com.atproto.server.describeServer': {
1390 handler: (pds, req, _url) => pds.handleDescribeServer(req),
1391 },
1392 '/xrpc/com.atproto.server.createSession': {
1393 method: 'POST',
1394 handler: (pds, req, _url) => pds.handleCreateSession(req),
1395 },
1396 '/xrpc/com.atproto.server.getSession': {
1397 handler: (pds, req, _url) => pds.handleGetSession(req),
1398 },
1399 '/xrpc/com.atproto.server.refreshSession': {
1400 method: 'POST',
1401 handler: (pds, req, _url) => pds.handleRefreshSession(req),
1402 },
1403 '/xrpc/app.bsky.actor.getPreferences': {
1404 handler: (pds, req, _url) => pds.handleGetPreferences(req),
1405 },
1406 '/xrpc/app.bsky.actor.putPreferences': {
1407 method: 'POST',
1408 handler: (pds, req, _url) => pds.handlePutPreferences(req),
1409 },
1410 '/xrpc/com.atproto.sync.listRepos': {
1411 handler: (pds, _req, _url) => pds.handleListRepos(),
1412 },
1413 '/xrpc/com.atproto.repo.createRecord': {
1414 method: 'POST',
1415 handler: (pds, req, _url) => pds.handleCreateRecord(req),
1416 },
1417 '/xrpc/com.atproto.repo.deleteRecord': {
1418 method: 'POST',
1419 handler: (pds, req, _url) => pds.handleDeleteRecord(req),
1420 },
1421 '/xrpc/com.atproto.repo.putRecord': {
1422 method: 'POST',
1423 handler: (pds, req, _url) => pds.handlePutRecord(req),
1424 },
1425 '/xrpc/com.atproto.repo.applyWrites': {
1426 method: 'POST',
1427 handler: (pds, req, _url) => pds.handleApplyWrites(req),
1428 },
1429 '/xrpc/com.atproto.repo.getRecord': {
1430 handler: (pds, _req, url) => pds.handleGetRecord(url),
1431 },
1432 '/xrpc/com.atproto.repo.describeRepo': {
1433 handler: (pds, _req, _url) => pds.handleDescribeRepo(),
1434 },
1435 '/xrpc/com.atproto.repo.listRecords': {
1436 handler: (pds, _req, url) => pds.handleListRecords(url),
1437 },
1438 '/xrpc/com.atproto.repo.uploadBlob': {
1439 method: 'POST',
1440 handler: (pds, req, _url) => pds.handleUploadBlob(req),
1441 },
1442 '/xrpc/com.atproto.sync.getLatestCommit': {
1443 handler: (pds, _req, _url) => pds.handleGetLatestCommit(),
1444 },
1445 '/xrpc/com.atproto.sync.getRepoStatus': {
1446 handler: (pds, _req, _url) => pds.handleGetRepoStatus(),
1447 },
1448 '/xrpc/com.atproto.sync.getRepo': {
1449 handler: (pds, _req, _url) => pds.handleGetRepo(),
1450 },
1451 '/xrpc/com.atproto.sync.getRecord': {
1452 handler: (pds, _req, url) => pds.handleSyncGetRecord(url),
1453 },
1454 '/xrpc/com.atproto.sync.getBlob': {
1455 handler: (pds, _req, url) => pds.handleGetBlob(url),
1456 },
1457 '/xrpc/com.atproto.sync.listBlobs': {
1458 handler: (pds, _req, url) => pds.handleListBlobs(url),
1459 },
1460 '/xrpc/com.atproto.sync.subscribeRepos': {
1461 handler: (pds, req, url) => pds.handleSubscribeRepos(req, url),
1462 },
1463};
1464
1465// ╔══════════════════════════════════════════════════════════════════════════════╗
1466// ║ PERSONAL DATA SERVER ║
1467// ║ Durable Object class implementing ATProto PDS ║
1468// ╚══════════════════════════════════════════════════════════════════════════════╝
1469
1470export class PersonalDataServer {
1471 /** @type {string | undefined} */
1472 _did;
1473
1474 /**
1475 * @param {DurableObjectState} state
1476 * @param {Env} env
1477 */
1478 constructor(state, env) {
1479 this.state = state;
1480 this.sql = state.storage.sql;
1481 this.env = env;
1482
1483 // Initialize schema
1484 this.sql.exec(`
1485 CREATE TABLE IF NOT EXISTS blocks (
1486 cid TEXT PRIMARY KEY,
1487 data BLOB NOT NULL
1488 );
1489
1490 CREATE TABLE IF NOT EXISTS records (
1491 uri TEXT PRIMARY KEY,
1492 cid TEXT NOT NULL,
1493 collection TEXT NOT NULL,
1494 rkey TEXT NOT NULL,
1495 value BLOB NOT NULL
1496 );
1497
1498 CREATE TABLE IF NOT EXISTS commits (
1499 seq INTEGER PRIMARY KEY AUTOINCREMENT,
1500 cid TEXT NOT NULL,
1501 rev TEXT NOT NULL,
1502 prev TEXT
1503 );
1504
1505 CREATE TABLE IF NOT EXISTS seq_events (
1506 seq INTEGER PRIMARY KEY AUTOINCREMENT,
1507 did TEXT NOT NULL,
1508 commit_cid TEXT NOT NULL,
1509 evt BLOB NOT NULL
1510 );
1511
1512 CREATE TABLE IF NOT EXISTS blob (
1513 cid TEXT PRIMARY KEY,
1514 mimeType TEXT NOT NULL,
1515 size INTEGER NOT NULL,
1516 createdAt TEXT NOT NULL
1517 );
1518
1519 CREATE TABLE IF NOT EXISTS record_blob (
1520 blobCid TEXT NOT NULL,
1521 recordUri TEXT NOT NULL,
1522 PRIMARY KEY (blobCid, recordUri)
1523 );
1524
1525 CREATE INDEX IF NOT EXISTS idx_record_blob_uri ON record_blob(recordUri);
1526
1527 CREATE INDEX IF NOT EXISTS idx_records_collection ON records(collection, rkey);
1528 `);
1529 }
1530
1531 /**
1532 * @param {string} did
1533 * @param {string} privateKeyHex
1534 * @param {string|null} [handle]
1535 */
1536 async initIdentity(did, privateKeyHex, handle = null) {
1537 await this.state.storage.put('did', did);
1538 await this.state.storage.put('privateKey', privateKeyHex);
1539 if (handle) {
1540 await this.state.storage.put('handle', handle);
1541 }
1542
1543 // Schedule blob cleanup alarm (runs daily)
1544 const currentAlarm = await this.state.storage.getAlarm();
1545 if (!currentAlarm) {
1546 await this.state.storage.setAlarm(Date.now() + 24 * 60 * 60 * 1000);
1547 }
1548 }
1549
1550 async getDid() {
1551 if (!this._did) {
1552 this._did = await this.state.storage.get('did');
1553 }
1554 return this._did;
1555 }
1556
1557 async getHandle() {
1558 return this.state.storage.get('handle');
1559 }
1560
1561 async getSigningKey() {
1562 const hex = await this.state.storage.get('privateKey');
1563 if (!hex) return null;
1564 return importPrivateKey(hexToBytes(/** @type {string} */ (hex)));
1565 }
1566
1567 /**
1568 * Collect MST node blocks for a given root CID
1569 * @param {string} rootCidStr
1570 * @returns {Array<{cid: string, data: Uint8Array}>}
1571 */
1572 collectMstBlocks(rootCidStr) {
1573 /** @type {Array<{cid: string, data: Uint8Array}>} */
1574 const blocks = [];
1575 const visited = new Set();
1576
1577 /** @param {string} cidStr */
1578 const collect = (cidStr) => {
1579 if (visited.has(cidStr)) return;
1580 visited.add(cidStr);
1581
1582 const rows = /** @type {BlockRow[]} */ (
1583 this.sql.exec(`SELECT data FROM blocks WHERE cid = ?`, cidStr).toArray()
1584 );
1585 if (rows.length === 0) return;
1586
1587 const data = new Uint8Array(rows[0].data);
1588 blocks.push({ cid: cidStr, data }); // Keep as string, buildCarFile will convert
1589
1590 // Decode and follow child CIDs (MST nodes have 'l' and 'e' with 't' subtrees)
1591 try {
1592 const node = cborDecode(data);
1593 if (node.l) collect(cidToString(node.l));
1594 if (node.e) {
1595 for (const entry of node.e) {
1596 if (entry.t) collect(cidToString(entry.t));
1597 }
1598 }
1599 } catch (_e) {
1600 // Not an MST node, ignore
1601 }
1602 };
1603
1604 collect(rootCidStr);
1605 return blocks;
1606 }
1607
1608 /**
1609 * @param {string} collection
1610 * @param {Record<string, *>} record
1611 * @param {string|null} [rkey]
1612 * @returns {Promise<{uri: string, cid: string, commit: string}>}
1613 */
1614 async createRecord(collection, record, rkey = null) {
1615 const did = await this.getDid();
1616 if (!did) throw new Error('PDS not initialized');
1617
1618 rkey = rkey || createTid();
1619 const uri = `at://${did}/${collection}/${rkey}`;
1620
1621 // Encode and hash record (must use DAG-CBOR for proper key ordering)
1622 const recordBytes = cborEncodeDagCbor(record);
1623 const recordCid = await createCid(recordBytes);
1624 const recordCidStr = cidToString(recordCid);
1625
1626 // Store block
1627 this.sql.exec(
1628 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`,
1629 recordCidStr,
1630 recordBytes,
1631 );
1632
1633 // Store record index
1634 this.sql.exec(
1635 `INSERT OR REPLACE INTO records (uri, cid, collection, rkey, value) VALUES (?, ?, ?, ?, ?)`,
1636 uri,
1637 recordCidStr,
1638 collection,
1639 rkey,
1640 recordBytes,
1641 );
1642
1643 // Associate blobs with this record (delete old associations first for updates)
1644 this.sql.exec('DELETE FROM record_blob WHERE recordUri = ?', uri);
1645
1646 const blobRefs = findBlobRefs(record);
1647 for (const blobCid of blobRefs) {
1648 // Verify blob exists
1649 const blobExists = this.sql
1650 .exec('SELECT cid FROM blob WHERE cid = ?', blobCid)
1651 .toArray();
1652
1653 if (blobExists.length === 0) {
1654 throw new Error(`BlobNotFound: ${blobCid}`);
1655 }
1656
1657 // Create association
1658 this.sql.exec(
1659 'INSERT INTO record_blob (blobCid, recordUri) VALUES (?, ?)',
1660 blobCid,
1661 uri,
1662 );
1663 }
1664
1665 // Rebuild MST
1666 const mst = new MST(this.sql);
1667 const dataRoot = await mst.computeRoot();
1668
1669 // Get previous commit
1670 const prevCommits = this.sql
1671 .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`)
1672 .toArray();
1673 const prevCommit = prevCommits.length > 0 ? prevCommits[0] : null;
1674
1675 // Create commit
1676 const rev = createTid();
1677 // Build commit with CIDs wrapped in CID class (for dag-cbor tag 42 encoding)
1678 const commit = {
1679 did,
1680 version: 3,
1681 data: new CID(cidToBytes(/** @type {string} */ (dataRoot))), // CID wrapped for explicit encoding
1682 rev,
1683 prev: prevCommit?.cid
1684 ? new CID(cidToBytes(/** @type {string} */ (prevCommit.cid)))
1685 : null,
1686 };
1687
1688 // Sign commit (using dag-cbor encoder for CIDs)
1689 const commitBytes = cborEncodeDagCbor(commit);
1690 const signingKey = await this.getSigningKey();
1691 if (!signingKey) throw new Error('No signing key');
1692 const sig = await sign(signingKey, commitBytes);
1693
1694 const signedCommit = { ...commit, sig };
1695 const signedBytes = cborEncodeDagCbor(signedCommit);
1696 const commitCid = await createCid(signedBytes);
1697 const commitCidStr = cidToString(commitCid);
1698
1699 // Store commit block
1700 this.sql.exec(
1701 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`,
1702 commitCidStr,
1703 signedBytes,
1704 );
1705
1706 // Store commit reference
1707 this.sql.exec(
1708 `INSERT INTO commits (cid, rev, prev) VALUES (?, ?, ?)`,
1709 commitCidStr,
1710 rev,
1711 prevCommit?.cid || null,
1712 );
1713
1714 // Update head and rev for listRepos
1715 await this.state.storage.put('head', commitCidStr);
1716 await this.state.storage.put('rev', rev);
1717
1718 // Collect blocks for the event (record + commit + MST nodes)
1719 // Build a mini CAR with just the new blocks - use string CIDs
1720 const newBlocks = [];
1721 // Add record block
1722 newBlocks.push({ cid: recordCidStr, data: recordBytes });
1723 // Add commit block
1724 newBlocks.push({ cid: commitCidStr, data: signedBytes });
1725 // Add MST node blocks (get all blocks referenced by commit.data)
1726 const mstBlocks = this.collectMstBlocks(/** @type {string} */ (dataRoot));
1727 newBlocks.push(...mstBlocks);
1728
1729 // Sequence event with blocks - store complete event data including rev and time
1730 // blocks must be a full CAR file with header (roots = [commitCid])
1731 const eventTime = new Date().toISOString();
1732 const evt = cborEncode({
1733 ops: [
1734 { action: 'create', path: `${collection}/${rkey}`, cid: recordCidStr },
1735 ],
1736 blocks: buildCarFile(commitCidStr, newBlocks), // Full CAR with header
1737 rev, // Store the actual commit revision
1738 time: eventTime, // Store the actual event time
1739 });
1740 this.sql.exec(
1741 `INSERT INTO seq_events (did, commit_cid, evt) VALUES (?, ?, ?)`,
1742 did,
1743 commitCidStr,
1744 evt,
1745 );
1746
1747 // Broadcast to subscribers (both local and via default DO for relay)
1748 const evtRows = /** @type {SeqEventRow[]} */ (
1749 this.sql
1750 .exec(`SELECT * FROM seq_events ORDER BY seq DESC LIMIT 1`)
1751 .toArray()
1752 );
1753 if (evtRows.length > 0) {
1754 this.broadcastEvent(evtRows[0]);
1755 // Also forward to default DO for relay subscribers
1756 if (this.env?.PDS) {
1757 const defaultId = this.env.PDS.idFromName('default');
1758 const defaultPds = this.env.PDS.get(defaultId);
1759 // Convert ArrayBuffer to array for JSON serialization
1760 const row = evtRows[0];
1761 const evtArray = Array.from(new Uint8Array(row.evt));
1762 // Fire and forget but log errors
1763 defaultPds
1764 .fetch(
1765 new Request('http://internal/forward-event', {
1766 method: 'POST',
1767 body: JSON.stringify({ ...row, evt: evtArray }),
1768 }),
1769 )
1770 .catch(() => {}); // Ignore forward errors
1771 }
1772 }
1773
1774 return { uri, cid: recordCidStr, commit: commitCidStr };
1775 }
1776
1777 /**
1778 * @param {string} collection
1779 * @param {string} rkey
1780 */
1781 async deleteRecord(collection, rkey) {
1782 const did = await this.getDid();
1783 if (!did) throw new Error('PDS not initialized');
1784
1785 const uri = `at://${did}/${collection}/${rkey}`;
1786
1787 // Check if record exists
1788 const existing = this.sql
1789 .exec(`SELECT cid FROM records WHERE uri = ?`, uri)
1790 .toArray();
1791 if (existing.length === 0) {
1792 return { error: 'RecordNotFound', message: 'record not found' };
1793 }
1794
1795 // Delete from records table
1796 this.sql.exec(`DELETE FROM records WHERE uri = ?`, uri);
1797
1798 // Get blobs associated with this record
1799 const associatedBlobs = this.sql
1800 .exec('SELECT blobCid FROM record_blob WHERE recordUri = ?', uri)
1801 .toArray();
1802
1803 // Remove associations for this record
1804 this.sql.exec('DELETE FROM record_blob WHERE recordUri = ?', uri);
1805
1806 // Check each blob for orphan status and delete if unreferenced
1807 for (const { blobCid } of associatedBlobs) {
1808 const stillReferenced = this.sql
1809 .exec('SELECT 1 FROM record_blob WHERE blobCid = ? LIMIT 1', blobCid)
1810 .toArray();
1811
1812 if (stillReferenced.length === 0) {
1813 // Blob is orphaned, delete from R2 and database
1814 await this.env?.BLOBS?.delete(`${did}/${blobCid}`);
1815 this.sql.exec('DELETE FROM blob WHERE cid = ?', blobCid);
1816 }
1817 }
1818
1819 // Rebuild MST
1820 const mst = new MST(this.sql);
1821 const dataRoot = await mst.computeRoot();
1822
1823 // Get previous commit
1824 const prevCommits = this.sql
1825 .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`)
1826 .toArray();
1827 const prevCommit = prevCommits.length > 0 ? prevCommits[0] : null;
1828
1829 // Create commit
1830 const rev = createTid();
1831 const commit = {
1832 did,
1833 version: 3,
1834 data: dataRoot
1835 ? new CID(cidToBytes(/** @type {string} */ (dataRoot)))
1836 : null,
1837 rev,
1838 prev: prevCommit?.cid
1839 ? new CID(cidToBytes(/** @type {string} */ (prevCommit.cid)))
1840 : null,
1841 };
1842
1843 // Sign commit
1844 const commitBytes = cborEncodeDagCbor(commit);
1845 const signingKey = await this.getSigningKey();
1846 if (!signingKey) throw new Error('No signing key');
1847 const sig = await sign(signingKey, commitBytes);
1848
1849 const signedCommit = { ...commit, sig };
1850 const signedBytes = cborEncodeDagCbor(signedCommit);
1851 const commitCid = await createCid(signedBytes);
1852 const commitCidStr = cidToString(commitCid);
1853
1854 // Store commit block
1855 this.sql.exec(
1856 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`,
1857 commitCidStr,
1858 signedBytes,
1859 );
1860
1861 // Store commit reference
1862 this.sql.exec(
1863 `INSERT INTO commits (cid, rev, prev) VALUES (?, ?, ?)`,
1864 commitCidStr,
1865 rev,
1866 prevCommit?.cid || null,
1867 );
1868
1869 // Update head and rev
1870 await this.state.storage.put('head', commitCidStr);
1871 await this.state.storage.put('rev', rev);
1872
1873 // Collect blocks for the event (commit + MST nodes, no record block)
1874 const newBlocks = [];
1875 newBlocks.push({ cid: commitCidStr, data: signedBytes });
1876 if (dataRoot) {
1877 const mstBlocks = this.collectMstBlocks(/** @type {string} */ (dataRoot));
1878 newBlocks.push(...mstBlocks);
1879 }
1880
1881 // Sequence event with delete action
1882 const eventTime = new Date().toISOString();
1883 const evt = cborEncode({
1884 ops: [{ action: 'delete', path: `${collection}/${rkey}`, cid: null }],
1885 blocks: buildCarFile(commitCidStr, newBlocks),
1886 rev,
1887 time: eventTime,
1888 });
1889 this.sql.exec(
1890 `INSERT INTO seq_events (did, commit_cid, evt) VALUES (?, ?, ?)`,
1891 did,
1892 commitCidStr,
1893 evt,
1894 );
1895
1896 // Broadcast to subscribers
1897 const evtRows = /** @type {SeqEventRow[]} */ (
1898 this.sql
1899 .exec(`SELECT * FROM seq_events ORDER BY seq DESC LIMIT 1`)
1900 .toArray()
1901 );
1902 if (evtRows.length > 0) {
1903 this.broadcastEvent(evtRows[0]);
1904 // Forward to default DO for relay subscribers
1905 if (this.env?.PDS) {
1906 const defaultId = this.env.PDS.idFromName('default');
1907 const defaultPds = this.env.PDS.get(defaultId);
1908 const row = evtRows[0];
1909 const evtArray = Array.from(new Uint8Array(row.evt));
1910 defaultPds
1911 .fetch(
1912 new Request('http://internal/forward-event', {
1913 method: 'POST',
1914 body: JSON.stringify({ ...row, evt: evtArray }),
1915 }),
1916 )
1917 .catch(() => {}); // Ignore forward errors
1918 }
1919 }
1920
1921 return { ok: true };
1922 }
1923
1924 /**
1925 * @param {SeqEventRow} evt
1926 * @returns {Uint8Array}
1927 */
1928 formatEvent(evt) {
1929 // AT Protocol frame format: header + body
1930 // Use DAG-CBOR encoding for body (CIDs need tag 42 + 0x00 prefix)
1931 const header = cborEncode({ op: 1, t: '#commit' });
1932
1933 // Decode stored event to get ops, blocks, rev, and time
1934 const evtData = cborDecode(new Uint8Array(evt.evt));
1935 /** @type {Array<{action: string, path: string, cid: CID|null}>} */
1936 const ops = evtData.ops.map(
1937 (/** @type {{action: string, path: string, cid?: string}} */ op) => ({
1938 ...op,
1939 cid: op.cid ? new CID(cidToBytes(op.cid)) : null, // Wrap in CID class for tag 42 encoding
1940 }),
1941 );
1942 // Get blocks from stored event (already in CAR format)
1943 const blocks = evtData.blocks || new Uint8Array(0);
1944
1945 const body = cborEncodeDagCbor({
1946 seq: evt.seq,
1947 rebase: false,
1948 tooBig: false,
1949 repo: evt.did,
1950 commit: new CID(cidToBytes(evt.commit_cid)), // Wrap in CID class for tag 42 encoding
1951 rev: evtData.rev, // Use stored rev from commit creation
1952 since: null,
1953 blocks: blocks instanceof Uint8Array ? blocks : new Uint8Array(blocks),
1954 ops,
1955 blobs: [],
1956 time: evtData.time, // Use stored time from event creation
1957 });
1958
1959 // Concatenate header + body
1960 const frame = new Uint8Array(header.length + body.length);
1961 frame.set(header);
1962 frame.set(body, header.length);
1963 return frame;
1964 }
1965
1966 /**
1967 * @param {WebSocket} ws
1968 * @param {string | ArrayBuffer} message
1969 */
1970 async webSocketMessage(ws, message) {
1971 // Handle ping
1972 if (message === 'ping') ws.send('pong');
1973 }
1974
1975 /**
1976 * @param {WebSocket} _ws
1977 * @param {number} _code
1978 * @param {string} _reason
1979 */
1980 async webSocketClose(_ws, _code, _reason) {
1981 // Durable Object will hibernate when no connections remain
1982 }
1983
1984 /**
1985 * @param {SeqEventRow} evt
1986 */
1987 broadcastEvent(evt) {
1988 const frame = this.formatEvent(evt);
1989 for (const ws of this.state.getWebSockets()) {
1990 try {
1991 ws.send(frame);
1992 } catch (_e) {
1993 // Client disconnected
1994 }
1995 }
1996 }
1997
1998 async handleAtprotoDid() {
1999 let did = await this.getDid();
2000 if (!did) {
2001 /** @type {string[]} */
2002 const registeredDids =
2003 (await this.state.storage.get('registeredDids')) || [];
2004 did = registeredDids[0];
2005 }
2006 if (!did) {
2007 return new Response('User not found', { status: 404 });
2008 }
2009 return new Response(/** @type {string} */ (did), {
2010 headers: { 'Content-Type': 'text/plain' },
2011 });
2012 }
2013
2014 /** @param {Request} request */
2015 async handleInit(request) {
2016 const body = await request.json();
2017 if (!body.did || !body.privateKey) {
2018 return errorResponse('InvalidRequest', 'missing did or privateKey', 400);
2019 }
2020 await this.initIdentity(body.did, body.privateKey, body.handle || null);
2021 return Response.json({
2022 ok: true,
2023 did: body.did,
2024 handle: body.handle || null,
2025 });
2026 }
2027
2028 async handleStatus() {
2029 const did = await this.getDid();
2030 return Response.json({ initialized: !!did, did: did || null });
2031 }
2032
2033 async handleResetRepo() {
2034 this.sql.exec(`DELETE FROM blocks`);
2035 this.sql.exec(`DELETE FROM records`);
2036 this.sql.exec(`DELETE FROM commits`);
2037 this.sql.exec(`DELETE FROM seq_events`);
2038 await this.state.storage.delete('head');
2039 await this.state.storage.delete('rev');
2040 return Response.json({ ok: true, message: 'repo data cleared' });
2041 }
2042
2043 /** @param {Request} request */
2044 async handleForwardEvent(request) {
2045 const evt = await request.json();
2046 const numSockets = [...this.state.getWebSockets()].length;
2047 this.broadcastEvent({
2048 seq: evt.seq,
2049 did: evt.did,
2050 commit_cid: evt.commit_cid,
2051 evt: new Uint8Array(Object.values(evt.evt)),
2052 });
2053 return Response.json({ ok: true, sockets: numSockets });
2054 }
2055
2056 /** @param {Request} request */
2057 async handleRegisterDid(request) {
2058 const body = await request.json();
2059 /** @type {string[]} */
2060 const registeredDids =
2061 (await this.state.storage.get('registeredDids')) || [];
2062 if (!registeredDids.includes(body.did)) {
2063 registeredDids.push(body.did);
2064 await this.state.storage.put('registeredDids', registeredDids);
2065 }
2066 return Response.json({ ok: true });
2067 }
2068
2069 async handleGetRegisteredDids() {
2070 const registeredDids =
2071 (await this.state.storage.get('registeredDids')) || [];
2072 return Response.json({ dids: registeredDids });
2073 }
2074
2075 /** @param {Request} request */
2076 async handleRegisterHandle(request) {
2077 const body = await request.json();
2078 const { handle, did } = body;
2079 if (!handle || !did) {
2080 return errorResponse('InvalidRequest', 'missing handle or did', 400);
2081 }
2082 /** @type {Record<string, string>} */
2083 const handleMap = (await this.state.storage.get('handleMap')) || {};
2084 handleMap[handle] = did;
2085 await this.state.storage.put('handleMap', handleMap);
2086 return Response.json({ ok: true });
2087 }
2088
2089 /** @param {URL} url */
2090 async handleResolveHandle(url) {
2091 const handle = url.searchParams.get('handle');
2092 if (!handle) {
2093 return errorResponse('InvalidRequest', 'missing handle', 400);
2094 }
2095 /** @type {Record<string, string>} */
2096 const handleMap = (await this.state.storage.get('handleMap')) || {};
2097 const did = handleMap[handle];
2098 if (!did) {
2099 return errorResponse('NotFound', 'handle not found', 404);
2100 }
2101 return Response.json({ did });
2102 }
2103
2104 async handleRepoInfo() {
2105 const head = await this.state.storage.get('head');
2106 const rev = await this.state.storage.get('rev');
2107 return Response.json({ head: head || null, rev: rev || null });
2108 }
2109
2110 /** @param {Request} request */
2111 handleDescribeServer(request) {
2112 const hostname = request.headers.get('x-hostname') || 'localhost';
2113 return Response.json({
2114 did: `did:web:${hostname}`,
2115 availableUserDomains: [`.${hostname}`],
2116 inviteCodeRequired: false,
2117 phoneVerificationRequired: false,
2118 links: {},
2119 contact: {},
2120 });
2121 }
2122
2123 /** @param {Request} request */
2124 async handleCreateSession(request) {
2125 const body = await request.json();
2126 const { identifier, password } = body;
2127
2128 if (!identifier || !password) {
2129 return errorResponse(
2130 'InvalidRequest',
2131 'Missing identifier or password',
2132 400,
2133 );
2134 }
2135
2136 // Check password against env var
2137 const expectedPassword = this.env?.PDS_PASSWORD;
2138 if (!expectedPassword || password !== expectedPassword) {
2139 return errorResponse(
2140 'AuthRequired',
2141 'Invalid identifier or password',
2142 401,
2143 );
2144 }
2145
2146 // Resolve identifier to DID
2147 let did = identifier;
2148 if (!identifier.startsWith('did:')) {
2149 // Try to resolve handle
2150 /** @type {Record<string, string>} */
2151 const handleMap = (await this.state.storage.get('handleMap')) || {};
2152 did = handleMap[identifier];
2153 if (!did) {
2154 return errorResponse('InvalidRequest', 'Unable to resolve handle', 400);
2155 }
2156 }
2157
2158 // Get handle for response
2159 const handle = await this.getHandleForDid(did);
2160
2161 // Create tokens
2162 const jwtSecret = this.env?.JWT_SECRET;
2163 if (!jwtSecret) {
2164 return errorResponse(
2165 'InternalServerError',
2166 'Server not configured for authentication',
2167 500,
2168 );
2169 }
2170
2171 const accessJwt = await createAccessJwt(did, jwtSecret);
2172 const refreshJwt = await createRefreshJwt(did, jwtSecret);
2173
2174 return Response.json({
2175 accessJwt,
2176 refreshJwt,
2177 handle: handle || did,
2178 did,
2179 active: true,
2180 });
2181 }
2182
2183 /** @param {Request} request */
2184 async handleGetSession(request) {
2185 const authHeader = request.headers.get('Authorization');
2186 if (!authHeader || !authHeader.startsWith('Bearer ')) {
2187 return errorResponse(
2188 'AuthRequired',
2189 'Missing or invalid authorization header',
2190 401,
2191 );
2192 }
2193
2194 const token = authHeader.slice(7); // Remove 'Bearer '
2195 const jwtSecret = this.env?.JWT_SECRET;
2196 if (!jwtSecret) {
2197 return errorResponse(
2198 'InternalServerError',
2199 'Server not configured for authentication',
2200 500,
2201 );
2202 }
2203
2204 try {
2205 const payload = await verifyAccessJwt(token, jwtSecret);
2206 const did = payload.sub;
2207 const handle = await this.getHandleForDid(did);
2208
2209 return Response.json({
2210 handle: handle || did,
2211 did,
2212 active: true,
2213 });
2214 } catch (err) {
2215 const message = err instanceof Error ? err.message : String(err);
2216 return errorResponse('InvalidToken', message, 401);
2217 }
2218 }
2219
2220 /** @param {Request} request */
2221 async handleRefreshSession(request) {
2222 const authHeader = request.headers.get('Authorization');
2223 if (!authHeader || !authHeader.startsWith('Bearer ')) {
2224 return errorResponse(
2225 'AuthRequired',
2226 'Missing or invalid authorization header',
2227 401,
2228 );
2229 }
2230
2231 const token = authHeader.slice(7); // Remove 'Bearer '
2232 const jwtSecret = this.env?.JWT_SECRET;
2233 if (!jwtSecret) {
2234 return errorResponse(
2235 'InternalServerError',
2236 'Server not configured for authentication',
2237 500,
2238 );
2239 }
2240
2241 try {
2242 const payload = await verifyRefreshJwt(token, jwtSecret);
2243 const did = payload.sub;
2244 const handle = await this.getHandleForDid(did);
2245
2246 // Issue fresh tokens
2247 const accessJwt = await createAccessJwt(did, jwtSecret);
2248 const refreshJwt = await createRefreshJwt(did, jwtSecret);
2249
2250 return Response.json({
2251 accessJwt,
2252 refreshJwt,
2253 handle: handle || did,
2254 did,
2255 active: true,
2256 });
2257 } catch (err) {
2258 const message = err instanceof Error ? err.message : String(err);
2259 if (message === 'Token expired') {
2260 return errorResponse('ExpiredToken', 'Refresh token has expired', 400);
2261 }
2262 return errorResponse('InvalidToken', message, 400);
2263 }
2264 }
2265
2266 /** @param {Request} _request */
2267 async handleGetPreferences(_request) {
2268 // Preferences are stored per-user in their DO
2269 const preferences = (await this.state.storage.get('preferences')) || [];
2270 return Response.json({ preferences });
2271 }
2272
2273 /** @param {Request} request */
2274 async handlePutPreferences(request) {
2275 const body = await request.json();
2276 const { preferences } = body;
2277 if (!Array.isArray(preferences)) {
2278 return errorResponse(
2279 'InvalidRequest',
2280 'preferences must be an array',
2281 400,
2282 );
2283 }
2284 await this.state.storage.put('preferences', preferences);
2285 return Response.json({});
2286 }
2287
2288 /**
2289 * @param {string} did
2290 * @returns {Promise<string|null>}
2291 */
2292 async getHandleForDid(did) {
2293 // Check if this DID has a handle registered
2294 /** @type {Record<string, string>} */
2295 const handleMap = (await this.state.storage.get('handleMap')) || {};
2296 for (const [handle, mappedDid] of Object.entries(handleMap)) {
2297 if (mappedDid === did) return handle;
2298 }
2299 // Check instance's own handle
2300 const instanceDid = await this.getDid();
2301 if (instanceDid === did) {
2302 return /** @type {string|null} */ (
2303 await this.state.storage.get('handle')
2304 );
2305 }
2306 return null;
2307 }
2308
2309 /**
2310 * @param {string} did
2311 * @param {string|null} lxm
2312 */
2313 async createServiceAuthForAppView(did, lxm) {
2314 const signingKey = await this.getSigningKey();
2315 if (!signingKey) throw new Error('No signing key available');
2316 return createServiceJwt({
2317 iss: did,
2318 aud: 'did:web:api.bsky.app',
2319 lxm,
2320 signingKey,
2321 });
2322 }
2323
2324 /**
2325 * @param {Request} request
2326 * @param {string} userDid
2327 */
2328 async handleAppViewProxy(request, userDid) {
2329 const url = new URL(request.url);
2330 // Extract lexicon method from path: /xrpc/app.bsky.actor.getPreferences -> app.bsky.actor.getPreferences
2331 const lxm = url.pathname.replace('/xrpc/', '');
2332
2333 // Create service auth JWT
2334 const serviceJwt = await this.createServiceAuthForAppView(userDid, lxm);
2335
2336 // Build AppView URL
2337 const appViewUrl = new URL(
2338 url.pathname + url.search,
2339 'https://api.bsky.app',
2340 );
2341
2342 // Forward request with service auth
2343 const headers = new Headers();
2344 headers.set('Authorization', `Bearer ${serviceJwt}`);
2345 headers.set(
2346 'Content-Type',
2347 request.headers.get('Content-Type') || 'application/json',
2348 );
2349 const acceptHeader = request.headers.get('Accept');
2350 if (acceptHeader) {
2351 headers.set('Accept', acceptHeader);
2352 }
2353 const acceptLangHeader = request.headers.get('Accept-Language');
2354 if (acceptLangHeader) {
2355 headers.set('Accept-Language', acceptLangHeader);
2356 }
2357
2358 const proxyReq = new Request(appViewUrl.toString(), {
2359 method: request.method,
2360 headers,
2361 body:
2362 request.method !== 'GET' && request.method !== 'HEAD'
2363 ? request.body
2364 : undefined,
2365 });
2366
2367 try {
2368 const response = await fetch(proxyReq);
2369 // Return the response with CORS headers
2370 const responseHeaders = new Headers(response.headers);
2371 responseHeaders.set('Access-Control-Allow-Origin', '*');
2372 return new Response(response.body, {
2373 status: response.status,
2374 statusText: response.statusText,
2375 headers: responseHeaders,
2376 });
2377 } catch (err) {
2378 const message = err instanceof Error ? err.message : String(err);
2379 return errorResponse(
2380 'UpstreamFailure',
2381 `Failed to reach AppView: ${message}`,
2382 502,
2383 );
2384 }
2385 }
2386
2387 async handleListRepos() {
2388 /** @type {string[]} */
2389 const registeredDids =
2390 (await this.state.storage.get('registeredDids')) || [];
2391 const did = await this.getDid();
2392 const repos = did
2393 ? [{ did, head: null, rev: null }]
2394 : registeredDids.map((/** @type {string} */ d) => ({
2395 did: d,
2396 head: null,
2397 rev: null,
2398 }));
2399 return Response.json({ repos });
2400 }
2401
2402 /** @param {Request} request */
2403 async handleCreateRecord(request) {
2404 const body = await request.json();
2405 if (!body.collection || !body.record) {
2406 return errorResponse(
2407 'InvalidRequest',
2408 'missing collection or record',
2409 400,
2410 );
2411 }
2412 try {
2413 const result = await this.createRecord(
2414 body.collection,
2415 body.record,
2416 body.rkey,
2417 );
2418 const head = await this.state.storage.get('head');
2419 const rev = await this.state.storage.get('rev');
2420 return Response.json({
2421 uri: result.uri,
2422 cid: result.cid,
2423 commit: { cid: head, rev },
2424 validationStatus: 'valid',
2425 });
2426 } catch (err) {
2427 const message = err instanceof Error ? err.message : String(err);
2428 return errorResponse('InternalError', message, 500);
2429 }
2430 }
2431
2432 /** @param {Request} request */
2433 async handleDeleteRecord(request) {
2434 const body = await request.json();
2435 if (!body.collection || !body.rkey) {
2436 return errorResponse('InvalidRequest', 'missing collection or rkey', 400);
2437 }
2438 try {
2439 const result = await this.deleteRecord(body.collection, body.rkey);
2440 if (result.error) {
2441 return Response.json(result, { status: 404 });
2442 }
2443 return Response.json({});
2444 } catch (err) {
2445 const message = err instanceof Error ? err.message : String(err);
2446 return errorResponse('InternalError', message, 500);
2447 }
2448 }
2449
2450 /** @param {Request} request */
2451 async handlePutRecord(request) {
2452 const body = await request.json();
2453 if (!body.collection || !body.rkey || !body.record) {
2454 return errorResponse(
2455 'InvalidRequest',
2456 'missing collection, rkey, or record',
2457 400,
2458 );
2459 }
2460 try {
2461 // putRecord is like createRecord but with a specific rkey (upsert)
2462 const result = await this.createRecord(
2463 body.collection,
2464 body.record,
2465 body.rkey,
2466 );
2467 const head = await this.state.storage.get('head');
2468 const rev = await this.state.storage.get('rev');
2469 return Response.json({
2470 uri: result.uri,
2471 cid: result.cid,
2472 commit: { cid: head, rev },
2473 validationStatus: 'valid',
2474 });
2475 } catch (err) {
2476 const message = err instanceof Error ? err.message : String(err);
2477 return errorResponse('InternalError', message, 500);
2478 }
2479 }
2480
2481 /** @param {Request} request */
2482 async handleApplyWrites(request) {
2483 const body = await request.json();
2484 if (!body.writes || !Array.isArray(body.writes)) {
2485 return errorResponse('InvalidRequest', 'missing writes array', 400);
2486 }
2487 try {
2488 const results = [];
2489 for (const write of body.writes) {
2490 const type = write.$type;
2491 if (type === 'com.atproto.repo.applyWrites#create') {
2492 const result = await this.createRecord(
2493 write.collection,
2494 write.value,
2495 write.rkey,
2496 );
2497 results.push({
2498 $type: 'com.atproto.repo.applyWrites#createResult',
2499 uri: result.uri,
2500 cid: result.cid,
2501 validationStatus: 'valid',
2502 });
2503 } else if (type === 'com.atproto.repo.applyWrites#update') {
2504 const result = await this.createRecord(
2505 write.collection,
2506 write.value,
2507 write.rkey,
2508 );
2509 results.push({
2510 $type: 'com.atproto.repo.applyWrites#updateResult',
2511 uri: result.uri,
2512 cid: result.cid,
2513 validationStatus: 'valid',
2514 });
2515 } else if (type === 'com.atproto.repo.applyWrites#delete') {
2516 await this.deleteRecord(write.collection, write.rkey);
2517 results.push({
2518 $type: 'com.atproto.repo.applyWrites#deleteResult',
2519 });
2520 } else {
2521 return errorResponse(
2522 'InvalidRequest',
2523 `Unknown write operation type: ${type}`,
2524 400,
2525 );
2526 }
2527 }
2528 // Return commit info
2529 const head = await this.state.storage.get('head');
2530 const rev = await this.state.storage.get('rev');
2531 return Response.json({ commit: { cid: head, rev }, results });
2532 } catch (err) {
2533 const message = err instanceof Error ? err.message : String(err);
2534 return errorResponse('InternalError', message, 500);
2535 }
2536 }
2537
2538 /** @param {URL} url */
2539 async handleGetRecord(url) {
2540 const collection = url.searchParams.get('collection');
2541 const rkey = url.searchParams.get('rkey');
2542 if (!collection || !rkey) {
2543 return errorResponse('InvalidRequest', 'missing collection or rkey', 400);
2544 }
2545 const did = await this.getDid();
2546 const uri = `at://${did}/${collection}/${rkey}`;
2547 const rows = /** @type {RecordRow[]} */ (
2548 this.sql
2549 .exec(`SELECT cid, value FROM records WHERE uri = ?`, uri)
2550 .toArray()
2551 );
2552 if (rows.length === 0) {
2553 return errorResponse('RecordNotFound', 'record not found', 404);
2554 }
2555 const row = rows[0];
2556 const value = cborDecode(new Uint8Array(row.value));
2557 return Response.json({ uri, cid: row.cid, value });
2558 }
2559
2560 async handleDescribeRepo() {
2561 const did = await this.getDid();
2562 if (!did) {
2563 return errorResponse('RepoNotFound', 'repo not found', 404);
2564 }
2565 const handle = await this.state.storage.get('handle');
2566 // Get unique collections
2567 const collections = this.sql
2568 .exec(`SELECT DISTINCT collection FROM records`)
2569 .toArray()
2570 .map((r) => r.collection);
2571
2572 return Response.json({
2573 handle: handle || did,
2574 did,
2575 didDoc: {},
2576 collections,
2577 handleIsCorrect: !!handle,
2578 });
2579 }
2580
2581 /** @param {URL} url */
2582 async handleListRecords(url) {
2583 const collection = url.searchParams.get('collection');
2584 if (!collection) {
2585 return errorResponse('InvalidRequest', 'missing collection', 400);
2586 }
2587 const limit = Math.min(
2588 parseInt(url.searchParams.get('limit') || '50', 10),
2589 100,
2590 );
2591 const reverse = url.searchParams.get('reverse') === 'true';
2592 const _cursor = url.searchParams.get('cursor');
2593
2594 const _did = await this.getDid();
2595 const query = `SELECT uri, cid, value FROM records WHERE collection = ? ORDER BY rkey ${reverse ? 'DESC' : 'ASC'} LIMIT ?`;
2596 const params = [collection, limit + 1];
2597
2598 const rows = /** @type {RecordRow[]} */ (
2599 this.sql.exec(query, ...params).toArray()
2600 );
2601 const hasMore = rows.length > limit;
2602 const records = rows.slice(0, limit).map((r) => ({
2603 uri: r.uri,
2604 cid: r.cid,
2605 value: cborDecode(new Uint8Array(r.value)),
2606 }));
2607
2608 return Response.json({
2609 records,
2610 cursor: hasMore ? records[records.length - 1]?.uri : undefined,
2611 });
2612 }
2613
2614 handleGetLatestCommit() {
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) {
2621 return errorResponse('RepoNotFound', 'repo not found', 404);
2622 }
2623 return Response.json({ cid: commits[0].cid, rev: commits[0].rev });
2624 }
2625
2626 async handleGetRepoStatus() {
2627 const did = await this.getDid();
2628 const commits = /** @type {CommitRow[]} */ (
2629 this.sql
2630 .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`)
2631 .toArray()
2632 );
2633 if (commits.length === 0 || !did) {
2634 return errorResponse('RepoNotFound', 'repo not found', 404);
2635 }
2636 return Response.json({
2637 did,
2638 active: true,
2639 status: 'active',
2640 rev: commits[0].rev,
2641 });
2642 }
2643
2644 handleGetRepo() {
2645 const commits = /** @type {CommitRow[]} */ (
2646 this.sql
2647 .exec(`SELECT cid FROM commits ORDER BY seq DESC LIMIT 1`)
2648 .toArray()
2649 );
2650 if (commits.length === 0) {
2651 return errorResponse('RepoNotFound', 'repo not found', 404);
2652 }
2653
2654 // Only include blocks reachable from the current commit
2655 const commitCid = commits[0].cid;
2656 const neededCids = new Set();
2657
2658 // Helper to get block data
2659 /** @param {string} cid */
2660 const getBlock = (cid) => {
2661 const rows = /** @type {BlockRow[]} */ (
2662 this.sql.exec(`SELECT data FROM blocks WHERE cid = ?`, cid).toArray()
2663 );
2664 return rows.length > 0 ? new Uint8Array(rows[0].data) : null;
2665 };
2666
2667 // Collect all reachable blocks starting from commit
2668 /** @param {string} cid */
2669 const collectBlocks = (cid) => {
2670 if (neededCids.has(cid)) return;
2671 neededCids.add(cid);
2672
2673 const data = getBlock(cid);
2674 if (!data) return;
2675
2676 // Decode CBOR to find CID references
2677 try {
2678 const decoded = cborDecode(data);
2679 if (decoded && typeof decoded === 'object') {
2680 // Commit object - follow 'data' (MST root)
2681 if (decoded.data instanceof Uint8Array) {
2682 collectBlocks(cidToString(decoded.data));
2683 }
2684 // MST node - follow 'l' and entries' 'v' and 't'
2685 if (decoded.l instanceof Uint8Array) {
2686 collectBlocks(cidToString(decoded.l));
2687 }
2688 if (Array.isArray(decoded.e)) {
2689 for (const entry of decoded.e) {
2690 if (entry.v instanceof Uint8Array) {
2691 collectBlocks(cidToString(entry.v));
2692 }
2693 if (entry.t instanceof Uint8Array) {
2694 collectBlocks(cidToString(entry.t));
2695 }
2696 }
2697 }
2698 }
2699 } catch (_e) {
2700 // Not a structured block, that's fine
2701 }
2702 };
2703
2704 collectBlocks(commitCid);
2705
2706 // Build CAR with only needed blocks
2707 const blocksForCar = [];
2708 for (const cid of neededCids) {
2709 const data = getBlock(cid);
2710 if (data) {
2711 blocksForCar.push({ cid, data });
2712 }
2713 }
2714
2715 const car = buildCarFile(commitCid, blocksForCar);
2716 return new Response(/** @type {BodyInit} */ (car), {
2717 headers: { 'content-type': 'application/vnd.ipld.car' },
2718 });
2719 }
2720
2721 /** @param {URL} url */
2722 async handleSyncGetRecord(url) {
2723 const collection = url.searchParams.get('collection');
2724 const rkey = url.searchParams.get('rkey');
2725 if (!collection || !rkey) {
2726 return errorResponse('InvalidRequest', 'missing collection or rkey', 400);
2727 }
2728 const did = await this.getDid();
2729 const uri = `at://${did}/${collection}/${rkey}`;
2730 const rows = /** @type {RecordRow[]} */ (
2731 this.sql.exec(`SELECT cid FROM records WHERE uri = ?`, uri).toArray()
2732 );
2733 if (rows.length === 0) {
2734 return errorResponse('RecordNotFound', 'record not found', 404);
2735 }
2736 const recordCid = rows[0].cid;
2737
2738 // Get latest commit
2739 const commits = /** @type {CommitRow[]} */ (
2740 this.sql
2741 .exec(`SELECT cid FROM commits ORDER BY seq DESC LIMIT 1`)
2742 .toArray()
2743 );
2744 if (commits.length === 0) {
2745 return errorResponse('RepoNotFound', 'no commits', 404);
2746 }
2747 const commitCid = commits[0].cid;
2748
2749 // Build proof chain: commit -> MST path -> record
2750 // Include commit block, all MST nodes on path to record, and record block
2751 /** @type {Array<{cid: string, data: Uint8Array}>} */
2752 const blocks = [];
2753 const included = new Set();
2754
2755 /** @param {string} cidStr */
2756 const addBlock = (cidStr) => {
2757 if (included.has(cidStr)) return;
2758 included.add(cidStr);
2759 const blockRows = /** @type {BlockRow[]} */ (
2760 this.sql.exec(`SELECT data FROM blocks WHERE cid = ?`, cidStr).toArray()
2761 );
2762 if (blockRows.length > 0) {
2763 blocks.push({ cid: cidStr, data: new Uint8Array(blockRows[0].data) });
2764 }
2765 };
2766
2767 // Add commit block
2768 addBlock(commitCid);
2769
2770 // Get commit to find data root
2771 const commitRows = /** @type {BlockRow[]} */ (
2772 this.sql
2773 .exec(`SELECT data FROM blocks WHERE cid = ?`, commitCid)
2774 .toArray()
2775 );
2776 if (commitRows.length > 0) {
2777 const commit = cborDecode(new Uint8Array(commitRows[0].data));
2778 if (commit.data) {
2779 const dataRootCid = cidToString(commit.data);
2780 // Collect MST path blocks (this includes all MST nodes)
2781 const mstBlocks = this.collectMstBlocks(dataRootCid);
2782 for (const block of mstBlocks) {
2783 addBlock(block.cid);
2784 }
2785 }
2786 }
2787
2788 // Add record block
2789 addBlock(recordCid);
2790
2791 const car = buildCarFile(commitCid, blocks);
2792 return new Response(/** @type {BodyInit} */ (car), {
2793 headers: { 'content-type': 'application/vnd.ipld.car' },
2794 });
2795 }
2796
2797 /** @param {Request} request */
2798 async handleUploadBlob(request) {
2799 // Require auth
2800 const authHeader = request.headers.get('Authorization');
2801 if (!authHeader || !authHeader.startsWith('Bearer ')) {
2802 return errorResponse(
2803 'AuthRequired',
2804 'Missing or invalid authorization header',
2805 401,
2806 );
2807 }
2808
2809 const token = authHeader.slice(7);
2810 const jwtSecret = this.env?.JWT_SECRET;
2811 if (!jwtSecret) {
2812 return errorResponse(
2813 'InternalServerError',
2814 'Server not configured for authentication',
2815 500,
2816 );
2817 }
2818
2819 try {
2820 await verifyAccessJwt(token, jwtSecret);
2821 } catch (err) {
2822 const message = err instanceof Error ? err.message : String(err);
2823 return errorResponse('InvalidToken', message, 401);
2824 }
2825
2826 const did = await this.getDid();
2827 if (!did) {
2828 return errorResponse('InvalidRequest', 'PDS not initialized', 400);
2829 }
2830
2831 // Read body as ArrayBuffer
2832 const bodyBytes = await request.arrayBuffer();
2833 const size = bodyBytes.byteLength;
2834
2835 // Check size limits
2836 if (size === 0) {
2837 return errorResponse(
2838 'InvalidRequest',
2839 'Empty blobs are not allowed',
2840 400,
2841 );
2842 }
2843 const MAX_BLOB_SIZE = 50 * 1024 * 1024;
2844 if (size > MAX_BLOB_SIZE) {
2845 return errorResponse(
2846 'BlobTooLarge',
2847 `Blob size ${size} exceeds maximum ${MAX_BLOB_SIZE}`,
2848 400,
2849 );
2850 }
2851
2852 // Sniff MIME type, fall back to Content-Type header
2853 const contentType =
2854 request.headers.get('Content-Type') || 'application/octet-stream';
2855 const sniffed = sniffMimeType(bodyBytes);
2856 const mimeType = sniffed || contentType;
2857
2858 // Compute CID using raw codec for blobs
2859 const cid = await createBlobCid(new Uint8Array(bodyBytes));
2860 const cidStr = cidToString(cid);
2861
2862 // Upload to R2 (idempotent - same CID always has same content)
2863 const r2Key = `${did}/${cidStr}`;
2864 await this.env?.BLOBS?.put(r2Key, bodyBytes, {
2865 httpMetadata: { contentType: mimeType },
2866 });
2867
2868 // Insert metadata (INSERT OR IGNORE handles concurrent uploads)
2869 const createdAt = new Date().toISOString();
2870 this.sql.exec(
2871 'INSERT OR IGNORE INTO blob (cid, mimeType, size, createdAt) VALUES (?, ?, ?, ?)',
2872 cidStr,
2873 mimeType,
2874 size,
2875 createdAt,
2876 );
2877
2878 // Return BlobRef
2879 return Response.json({
2880 blob: {
2881 $type: 'blob',
2882 ref: { $link: cidStr },
2883 mimeType,
2884 size,
2885 },
2886 });
2887 }
2888
2889 /** @param {URL} url */
2890 async handleGetBlob(url) {
2891 const did = url.searchParams.get('did');
2892 const cid = url.searchParams.get('cid');
2893
2894 if (!did || !cid) {
2895 return errorResponse(
2896 'InvalidRequest',
2897 'missing did or cid parameter',
2898 400,
2899 );
2900 }
2901
2902 // Validate CID format (CIDv1 base32lower: starts with 'b', 59 chars total)
2903 if (!/^b[a-z2-7]{58}$/.test(cid)) {
2904 return errorResponse('InvalidRequest', 'invalid CID format', 400);
2905 }
2906
2907 // Verify DID matches this DO
2908 const myDid = await this.getDid();
2909 if (did !== myDid) {
2910 return errorResponse(
2911 'InvalidRequest',
2912 'DID does not match this repo',
2913 400,
2914 );
2915 }
2916
2917 // Look up blob metadata
2918 const rows = this.sql
2919 .exec('SELECT mimeType, size FROM blob WHERE cid = ?', cid)
2920 .toArray();
2921
2922 if (rows.length === 0) {
2923 return errorResponse('BlobNotFound', 'blob not found', 404);
2924 }
2925
2926 const { mimeType, size } = rows[0];
2927
2928 // Fetch from R2
2929 const r2Key = `${did}/${cid}`;
2930 const object = await this.env?.BLOBS?.get(r2Key);
2931
2932 if (!object) {
2933 return errorResponse('BlobNotFound', 'blob not found in storage', 404);
2934 }
2935
2936 // Return blob with security headers
2937 return new Response(object.body, {
2938 headers: {
2939 'Content-Type': /** @type {string} */ (mimeType),
2940 'Content-Length': String(size),
2941 'X-Content-Type-Options': 'nosniff',
2942 'Content-Security-Policy': "default-src 'none'; sandbox",
2943 'Cache-Control': 'public, max-age=31536000, immutable',
2944 },
2945 });
2946 }
2947
2948 /** @param {URL} url */
2949 async handleListBlobs(url) {
2950 const did = url.searchParams.get('did');
2951 const cursor = url.searchParams.get('cursor');
2952 const limit = Math.min(Number(url.searchParams.get('limit')) || 500, 1000);
2953
2954 if (!did) {
2955 return errorResponse('InvalidRequest', 'missing did parameter', 400);
2956 }
2957
2958 // Verify DID matches this DO
2959 const myDid = await this.getDid();
2960 if (did !== myDid) {
2961 return errorResponse(
2962 'InvalidRequest',
2963 'DID does not match this repo',
2964 400,
2965 );
2966 }
2967
2968 // Query blobs with pagination (cursor is createdAt::cid for uniqueness)
2969 let query = 'SELECT cid, createdAt FROM blob';
2970 const params = [];
2971
2972 if (cursor) {
2973 const [cursorTime, cursorCid] = cursor.split('::');
2974 query += ' WHERE (createdAt > ? OR (createdAt = ? AND cid > ?))';
2975 params.push(cursorTime, cursorTime, cursorCid);
2976 }
2977
2978 query += ' ORDER BY createdAt ASC, cid ASC LIMIT ?';
2979 params.push(limit + 1); // Fetch one extra to detect if there's more
2980
2981 const rows = this.sql.exec(query, ...params).toArray();
2982
2983 // Determine if there's a next page
2984 let nextCursor = null;
2985 if (rows.length > limit) {
2986 rows.pop(); // Remove the extra row
2987 const last = rows[rows.length - 1];
2988 nextCursor = `${last.createdAt}::${last.cid}`;
2989 }
2990
2991 return Response.json({
2992 cids: rows.map((r) => r.cid),
2993 cursor: nextCursor,
2994 });
2995 }
2996
2997 /**
2998 * @param {Request} request
2999 * @param {URL} url
3000 */
3001 handleSubscribeRepos(request, url) {
3002 const upgradeHeader = request.headers.get('Upgrade');
3003 if (upgradeHeader !== 'websocket') {
3004 return new Response('expected websocket', { status: 426 });
3005 }
3006 const { 0: client, 1: server } = new WebSocketPair();
3007 this.state.acceptWebSocket(server);
3008 const cursor = url.searchParams.get('cursor');
3009 if (cursor) {
3010 const events = /** @type {SeqEventRow[]} */ (
3011 this.sql
3012 .exec(
3013 `SELECT * FROM seq_events WHERE seq > ? ORDER BY seq`,
3014 parseInt(cursor, 10),
3015 )
3016 .toArray()
3017 );
3018 for (const evt of events) {
3019 server.send(this.formatEvent(evt));
3020 }
3021 }
3022 return new Response(null, { status: 101, webSocket: client });
3023 }
3024
3025 /** @param {Request} request */
3026 async fetch(request) {
3027 const url = new URL(request.url);
3028 const route = pdsRoutes[url.pathname];
3029
3030 // Check for local route first
3031 if (route) {
3032 if (route.method && request.method !== route.method) {
3033 return errorResponse('MethodNotAllowed', 'method not allowed', 405);
3034 }
3035 return route.handler(this, request, url);
3036 }
3037
3038 // Handle app.bsky.* proxy requests (only if no local route)
3039 if (url.pathname.startsWith('/xrpc/app.bsky.')) {
3040 const userDid = request.headers.get('x-authed-did');
3041 if (!userDid) {
3042 return errorResponse('Unauthorized', 'Missing auth context', 401);
3043 }
3044 return this.handleAppViewProxy(request, userDid);
3045 }
3046
3047 return errorResponse('NotFound', 'not found', 404);
3048 }
3049
3050 async alarm() {
3051 await this.cleanupOrphanedBlobs();
3052 // Reschedule for next day
3053 await this.state.storage.setAlarm(Date.now() + 24 * 60 * 60 * 1000);
3054 }
3055
3056 async cleanupOrphanedBlobs() {
3057 const did = await this.getDid();
3058 if (!did) return;
3059
3060 // Find orphans: blobs not in record_blob, older than 24h
3061 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
3062
3063 const orphans = this.sql
3064 .exec(
3065 `SELECT b.cid FROM blob b
3066 LEFT JOIN record_blob rb ON b.cid = rb.blobCid
3067 WHERE rb.blobCid IS NULL AND b.createdAt < ?`,
3068 cutoff,
3069 )
3070 .toArray();
3071
3072 for (const { cid } of orphans) {
3073 await this.env?.BLOBS?.delete(`${did}/${cid}`);
3074 this.sql.exec('DELETE FROM blob WHERE cid = ?', cid);
3075 }
3076 }
3077}
3078
3079// ╔══════════════════════════════════════════════════════════════════════════════╗
3080// ║ WORKERS ENTRY POINT ║
3081// ║ Request handling, CORS, auth middleware ║
3082// ╚══════════════════════════════════════════════════════════════════════════════╝
3083
3084const corsHeaders = {
3085 'Access-Control-Allow-Origin': '*',
3086 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
3087 'Access-Control-Allow-Headers':
3088 'Content-Type, Authorization, atproto-accept-labelers, atproto-proxy, x-bsky-topics',
3089};
3090
3091/**
3092 * @param {Response} response
3093 * @returns {Response}
3094 */
3095function addCorsHeaders(response) {
3096 const newHeaders = new Headers(response.headers);
3097 for (const [key, value] of Object.entries(corsHeaders)) {
3098 newHeaders.set(key, value);
3099 }
3100 return new Response(response.body, {
3101 status: response.status,
3102 statusText: response.statusText,
3103 headers: newHeaders,
3104 });
3105}
3106
3107export default {
3108 /**
3109 * @param {Request} request
3110 * @param {Env} env
3111 */
3112 async fetch(request, env) {
3113 // Handle CORS preflight
3114 if (request.method === 'OPTIONS') {
3115 return new Response(null, { headers: corsHeaders });
3116 }
3117
3118 const response = await handleRequest(request, env);
3119 // Don't wrap WebSocket upgrades - they need the webSocket property preserved
3120 if (response.status === 101) {
3121 return response;
3122 }
3123 return addCorsHeaders(response);
3124 },
3125};
3126
3127/**
3128 * Extract subdomain from hostname (e.g., "alice" from "alice.foo.workers.dev")
3129 * @param {string} hostname
3130 * @returns {string|null}
3131 */
3132function getSubdomain(hostname) {
3133 const parts = hostname.split('.');
3134 // workers.dev domains: [subdomain?].[worker-name].[account].workers.dev
3135 // If more than 4 parts, first part(s) are user subdomain
3136 if (parts.length > 4 && parts.slice(-2).join('.') === 'workers.dev') {
3137 return parts.slice(0, -4).join('.');
3138 }
3139 // Custom domains: check if there's a subdomain before the base
3140 // For now, assume no subdomain on custom domains
3141 return null;
3142}
3143
3144/**
3145 * Verify auth and return DID from token
3146 * @param {Request} request - HTTP request with Authorization header
3147 * @param {Env} env - Environment with JWT_SECRET
3148 * @returns {Promise<{did: string} | {error: Response}>} DID or error response
3149 */
3150async function requireAuth(request, env) {
3151 const authHeader = request.headers.get('Authorization');
3152 if (!authHeader || !authHeader.startsWith('Bearer ')) {
3153 return {
3154 error: Response.json(
3155 {
3156 error: 'AuthRequired',
3157 message: 'Authentication required',
3158 },
3159 { status: 401 },
3160 ),
3161 };
3162 }
3163
3164 const token = authHeader.slice(7);
3165 const jwtSecret = env?.JWT_SECRET;
3166 if (!jwtSecret) {
3167 return {
3168 error: Response.json(
3169 {
3170 error: 'InternalServerError',
3171 message: 'Server not configured for authentication',
3172 },
3173 { status: 500 },
3174 ),
3175 };
3176 }
3177
3178 try {
3179 const payload = await verifyAccessJwt(token, jwtSecret);
3180 return { did: payload.sub };
3181 } catch (err) {
3182 const message = err instanceof Error ? err.message : String(err);
3183 return {
3184 error: Response.json(
3185 {
3186 error: 'InvalidToken',
3187 message: message,
3188 },
3189 { status: 401 },
3190 ),
3191 };
3192 }
3193}
3194
3195/**
3196 * @param {Request} request
3197 * @param {Env} env
3198 */
3199async function handleAuthenticatedBlobUpload(request, env) {
3200 const auth = await requireAuth(request, env);
3201 if ('error' in auth) return auth.error;
3202
3203 // Route to the user's DO based on their DID from the token
3204 const id = env.PDS.idFromName(auth.did);
3205 const pds = env.PDS.get(id);
3206 return pds.fetch(request);
3207}
3208
3209/**
3210 * @param {Request} request
3211 * @param {Env} env
3212 */
3213async function handleAuthenticatedRepoWrite(request, env) {
3214 const auth = await requireAuth(request, env);
3215 if ('error' in auth) return auth.error;
3216
3217 const body = await request.json();
3218 const repo = body.repo;
3219 if (!repo) {
3220 return errorResponse('InvalidRequest', 'missing repo param', 400);
3221 }
3222
3223 if (auth.did !== repo) {
3224 return errorResponse('Forbidden', "Cannot modify another user's repo", 403);
3225 }
3226
3227 const id = env.PDS.idFromName(repo);
3228 const pds = env.PDS.get(id);
3229 const response = await pds.fetch(
3230 new Request(request.url, {
3231 method: 'POST',
3232 headers: request.headers,
3233 body: JSON.stringify(body),
3234 }),
3235 );
3236
3237 // Notify relay of updates on successful writes
3238 if (response.ok) {
3239 const url = new URL(request.url);
3240 notifyCrawlers(env, url.hostname);
3241 }
3242
3243 return response;
3244}
3245
3246/**
3247 * @param {Request} request
3248 * @param {Env} env
3249 */
3250async function handleRequest(request, env) {
3251 const url = new URL(request.url);
3252 const subdomain = getSubdomain(url.hostname);
3253
3254 // Handle resolution via subdomain or bare domain
3255 if (url.pathname === '/.well-known/atproto-did') {
3256 // Look up handle -> DID in default DO
3257 // Use subdomain if present, otherwise try bare hostname as handle
3258 const handleToResolve = subdomain || url.hostname;
3259 const defaultId = env.PDS.idFromName('default');
3260 const defaultPds = env.PDS.get(defaultId);
3261 const resolveRes = await defaultPds.fetch(
3262 new Request(
3263 `http://internal/resolve-handle?handle=${encodeURIComponent(handleToResolve)}`,
3264 ),
3265 );
3266 if (!resolveRes.ok) {
3267 return new Response('Handle not found', { status: 404 });
3268 }
3269 const { did } = await resolveRes.json();
3270 return new Response(did, { headers: { 'Content-Type': 'text/plain' } });
3271 }
3272
3273 // describeServer - works on bare domain
3274 if (url.pathname === '/xrpc/com.atproto.server.describeServer') {
3275 const defaultId = env.PDS.idFromName('default');
3276 const defaultPds = env.PDS.get(defaultId);
3277 const newReq = new Request(request.url, {
3278 method: request.method,
3279 headers: {
3280 ...Object.fromEntries(request.headers),
3281 'x-hostname': url.hostname,
3282 },
3283 });
3284 return defaultPds.fetch(newReq);
3285 }
3286
3287 // createSession - handle on default DO (has handleMap for identifier resolution)
3288 if (url.pathname === '/xrpc/com.atproto.server.createSession') {
3289 const defaultId = env.PDS.idFromName('default');
3290 const defaultPds = env.PDS.get(defaultId);
3291 return defaultPds.fetch(request);
3292 }
3293
3294 // getSession - route to default DO
3295 if (url.pathname === '/xrpc/com.atproto.server.getSession') {
3296 const defaultId = env.PDS.idFromName('default');
3297 const defaultPds = env.PDS.get(defaultId);
3298 return defaultPds.fetch(request);
3299 }
3300
3301 // refreshSession - route to default DO
3302 if (url.pathname === '/xrpc/com.atproto.server.refreshSession') {
3303 const defaultId = env.PDS.idFromName('default');
3304 const defaultPds = env.PDS.get(defaultId);
3305 return defaultPds.fetch(request);
3306 }
3307
3308 // Proxy app.bsky.* endpoints to Bluesky AppView
3309 if (url.pathname.startsWith('/xrpc/app.bsky.')) {
3310 // Authenticate the user first
3311 const auth = await requireAuth(request, env);
3312 if ('error' in auth) return auth.error;
3313
3314 // Route to the user's DO instance to create service auth and proxy
3315 const id = env.PDS.idFromName(auth.did);
3316 const pds = env.PDS.get(id);
3317 return pds.fetch(
3318 new Request(request.url, {
3319 method: request.method,
3320 headers: {
3321 ...Object.fromEntries(request.headers),
3322 'x-authed-did': auth.did, // Pass the authenticated DID
3323 },
3324 body:
3325 request.method !== 'GET' && request.method !== 'HEAD'
3326 ? request.body
3327 : undefined,
3328 }),
3329 );
3330 }
3331
3332 // Handle registration routes - go to default DO
3333 if (
3334 url.pathname === '/register-handle' ||
3335 url.pathname === '/resolve-handle'
3336 ) {
3337 const defaultId = env.PDS.idFromName('default');
3338 const defaultPds = env.PDS.get(defaultId);
3339 return defaultPds.fetch(request);
3340 }
3341
3342 // resolveHandle XRPC endpoint
3343 if (url.pathname === '/xrpc/com.atproto.identity.resolveHandle') {
3344 const handle = url.searchParams.get('handle');
3345 if (!handle) {
3346 return errorResponse('InvalidRequest', 'missing handle param', 400);
3347 }
3348 const defaultId = env.PDS.idFromName('default');
3349 const defaultPds = env.PDS.get(defaultId);
3350 const resolveRes = await defaultPds.fetch(
3351 new Request(
3352 `http://internal/resolve-handle?handle=${encodeURIComponent(handle)}`,
3353 ),
3354 );
3355 if (!resolveRes.ok) {
3356 return errorResponse('InvalidRequest', 'Unable to resolve handle', 400);
3357 }
3358 const { did } = await resolveRes.json();
3359 return Response.json({ did });
3360 }
3361
3362 // subscribeRepos WebSocket - route to default instance for firehose
3363 if (url.pathname === '/xrpc/com.atproto.sync.subscribeRepos') {
3364 const defaultId = env.PDS.idFromName('default');
3365 const defaultPds = env.PDS.get(defaultId);
3366 return defaultPds.fetch(request);
3367 }
3368
3369 // listRepos needs to aggregate from all registered DIDs
3370 if (url.pathname === '/xrpc/com.atproto.sync.listRepos') {
3371 const defaultId = env.PDS.idFromName('default');
3372 const defaultPds = env.PDS.get(defaultId);
3373 const regRes = await defaultPds.fetch(
3374 new Request('http://internal/get-registered-dids'),
3375 );
3376 const { dids } = await regRes.json();
3377
3378 const repos = [];
3379 for (const did of dids) {
3380 const id = env.PDS.idFromName(did);
3381 const pds = env.PDS.get(id);
3382 const infoRes = await pds.fetch(new Request('http://internal/repo-info'));
3383 const info = await infoRes.json();
3384 if (info.head) {
3385 repos.push({ did, head: info.head, rev: info.rev, active: true });
3386 }
3387 }
3388 return Response.json({ repos, cursor: undefined });
3389 }
3390
3391 // Repo endpoints use ?repo= param instead of ?did=
3392 if (
3393 url.pathname === '/xrpc/com.atproto.repo.describeRepo' ||
3394 url.pathname === '/xrpc/com.atproto.repo.listRecords' ||
3395 url.pathname === '/xrpc/com.atproto.repo.getRecord'
3396 ) {
3397 const repo = url.searchParams.get('repo');
3398 if (!repo) {
3399 return errorResponse('InvalidRequest', 'missing repo param', 400);
3400 }
3401 const id = env.PDS.idFromName(repo);
3402 const pds = env.PDS.get(id);
3403 return pds.fetch(request);
3404 }
3405
3406 // Sync endpoints use ?did= param
3407 if (
3408 url.pathname === '/xrpc/com.atproto.sync.getLatestCommit' ||
3409 url.pathname === '/xrpc/com.atproto.sync.getRepoStatus' ||
3410 url.pathname === '/xrpc/com.atproto.sync.getRepo' ||
3411 url.pathname === '/xrpc/com.atproto.sync.getRecord' ||
3412 url.pathname === '/xrpc/com.atproto.sync.getBlob' ||
3413 url.pathname === '/xrpc/com.atproto.sync.listBlobs'
3414 ) {
3415 const did = url.searchParams.get('did');
3416 if (!did) {
3417 return errorResponse('InvalidRequest', 'missing did param', 400);
3418 }
3419 const id = env.PDS.idFromName(did);
3420 const pds = env.PDS.get(id);
3421 return pds.fetch(request);
3422 }
3423
3424 // Blob upload endpoint (binary body, uses DID from token)
3425 if (url.pathname === '/xrpc/com.atproto.repo.uploadBlob') {
3426 return handleAuthenticatedBlobUpload(request, env);
3427 }
3428
3429 // Authenticated repo write endpoints
3430 const repoWriteEndpoints = [
3431 '/xrpc/com.atproto.repo.createRecord',
3432 '/xrpc/com.atproto.repo.deleteRecord',
3433 '/xrpc/com.atproto.repo.putRecord',
3434 '/xrpc/com.atproto.repo.applyWrites',
3435 ];
3436 if (repoWriteEndpoints.includes(url.pathname)) {
3437 return handleAuthenticatedRepoWrite(request, env);
3438 }
3439
3440 // Health check endpoint
3441 if (url.pathname === '/xrpc/_health') {
3442 return Response.json({ version: '0.1.0' });
3443 }
3444
3445 // Root path - ASCII art
3446 if (url.pathname === '/') {
3447 const ascii = `
3448 ██████╗ ██████╗ ███████╗ ██╗ ███████╗
3449 ██╔══██╗ ██╔══██╗ ██╔════╝ ██║ ██╔════╝
3450 ██████╔╝ ██║ ██║ ███████╗ ██║ ███████╗
3451 ██╔═══╝ ██║ ██║ ╚════██║ ██ ██║ ╚════██║
3452 ██║ ██████╔╝ ███████║ ██╗ ╚█████╔╝ ███████║
3453 ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚════╝ ╚══════╝
3454
3455 ATProto PDS on Cloudflare Workers
3456`;
3457 return new Response(ascii, {
3458 headers: { 'Content-Type': 'text/plain; charset=utf-8' },
3459 });
3460 }
3461
3462 // On init, register this DID with the default instance (requires ?did= param, no auth yet)
3463 if (url.pathname === '/init' && request.method === 'POST') {
3464 const did = url.searchParams.get('did');
3465 if (!did) {
3466 return errorResponse('InvalidRequest', 'missing did param', 400);
3467 }
3468 const body = await request.json();
3469
3470 // Register with default instance for discovery
3471 const defaultId = env.PDS.idFromName('default');
3472 const defaultPds = env.PDS.get(defaultId);
3473 await defaultPds.fetch(
3474 new Request('http://internal/register-did', {
3475 method: 'POST',
3476 body: JSON.stringify({ did }),
3477 }),
3478 );
3479
3480 // Register handle if provided
3481 if (body.handle) {
3482 await defaultPds.fetch(
3483 new Request('http://internal/register-handle', {
3484 method: 'POST',
3485 body: JSON.stringify({ did, handle: body.handle }),
3486 }),
3487 );
3488 }
3489
3490 // Forward to the actual PDS instance
3491 const id = env.PDS.idFromName(did);
3492 const pds = env.PDS.get(id);
3493 return pds.fetch(
3494 new Request(request.url, {
3495 method: 'POST',
3496 headers: request.headers,
3497 body: JSON.stringify(body),
3498 }),
3499 );
3500 }
3501
3502 // Unknown endpoint
3503 return errorResponse('NotFound', 'Endpoint not found', 404);
3504}