this repo has no description
1// === CONSTANTS ===
2// CBOR primitive markers (RFC 8949)
3const CBOR_FALSE = 0xf4
4const CBOR_TRUE = 0xf5
5const CBOR_NULL = 0xf6
6
7// DAG-CBOR CID link tag
8const CBOR_TAG_CID = 42
9
10// === CID WRAPPER ===
11// Explicit CID type for DAG-CBOR encoding (avoids fragile heuristic detection)
12
13class CID {
14 constructor(bytes) {
15 if (!(bytes instanceof Uint8Array)) {
16 throw new Error('CID must be constructed with Uint8Array')
17 }
18 this.bytes = bytes
19 }
20}
21
22// === CBOR ENCODING ===
23// Minimal deterministic CBOR (RFC 8949) - sorted keys, minimal integers
24
25/**
26 * Encode CBOR type header (major type + length)
27 * @param {number[]} parts - Array to push bytes to
28 * @param {number} majorType - CBOR major type (0-7)
29 * @param {number} length - Value or length to encode
30 */
31function encodeHead(parts, majorType, length) {
32 const mt = majorType << 5
33 if (length < 24) {
34 parts.push(mt | length)
35 } else if (length < 256) {
36 parts.push(mt | 24, length)
37 } else if (length < 65536) {
38 parts.push(mt | 25, length >> 8, length & 0xff)
39 } else if (length < 4294967296) {
40 // Use Math.floor instead of bitshift to avoid 32-bit signed integer overflow
41 parts.push(mt | 26,
42 Math.floor(length / 0x1000000) & 0xff,
43 Math.floor(length / 0x10000) & 0xff,
44 Math.floor(length / 0x100) & 0xff,
45 length & 0xff)
46 }
47}
48
49/**
50 * Encode a value as CBOR bytes (RFC 8949 deterministic encoding)
51 * @param {*} value - Value to encode (null, boolean, number, string, Uint8Array, array, or object)
52 * @returns {Uint8Array} CBOR-encoded bytes
53 */
54export function cborEncode(value) {
55 const parts = []
56
57 function encode(val) {
58 if (val === null) {
59 parts.push(CBOR_NULL)
60 } else if (val === true) {
61 parts.push(CBOR_TRUE)
62 } else if (val === false) {
63 parts.push(CBOR_FALSE)
64 } else if (typeof val === 'number') {
65 encodeInteger(val)
66 } else if (typeof val === 'string') {
67 const bytes = new TextEncoder().encode(val)
68 encodeHead(parts, 3, bytes.length) // major type 3 = text string
69 parts.push(...bytes)
70 } else if (val instanceof Uint8Array) {
71 encodeHead(parts, 2, val.length) // major type 2 = byte string
72 parts.push(...val)
73 } else if (Array.isArray(val)) {
74 encodeHead(parts, 4, val.length) // major type 4 = array
75 for (const item of val) encode(item)
76 } else if (typeof val === 'object') {
77 // Sort keys for deterministic encoding
78 const keys = Object.keys(val).sort()
79 encodeHead(parts, 5, keys.length) // major type 5 = map
80 for (const key of keys) {
81 encode(key)
82 encode(val[key])
83 }
84 }
85 }
86
87 function encodeInteger(n) {
88 if (n >= 0) {
89 encodeHead(parts, 0, n) // major type 0 = unsigned int
90 } else {
91 encodeHead(parts, 1, -n - 1) // major type 1 = negative int
92 }
93 }
94
95 encode(value)
96 return new Uint8Array(parts)
97}
98
99// DAG-CBOR encoder that handles CIDs with tag 42
100function cborEncodeDagCbor(value) {
101 const parts = []
102
103 function encode(val) {
104 if (val === null) {
105 parts.push(CBOR_NULL)
106 } else if (val === true) {
107 parts.push(CBOR_TRUE)
108 } else if (val === false) {
109 parts.push(CBOR_FALSE)
110 } else if (typeof val === 'number') {
111 if (Number.isInteger(val) && val >= 0) {
112 encodeHead(parts, 0, val)
113 } else if (Number.isInteger(val) && val < 0) {
114 encodeHead(parts, 1, -val - 1)
115 }
116 } else if (typeof val === 'string') {
117 const bytes = new TextEncoder().encode(val)
118 encodeHead(parts, 3, bytes.length)
119 parts.push(...bytes)
120 } else if (val instanceof CID) {
121 // CID links in DAG-CBOR use tag 42 + 0x00 multibase prefix
122 // The 0x00 prefix indicates "identity" multibase (raw bytes)
123 parts.push(0xd8, CBOR_TAG_CID)
124 encodeHead(parts, 2, val.bytes.length + 1) // +1 for 0x00 prefix
125 parts.push(0x00)
126 parts.push(...val.bytes)
127 } else if (val instanceof Uint8Array) {
128 // Regular byte string
129 encodeHead(parts, 2, val.length)
130 parts.push(...val)
131 } else if (Array.isArray(val)) {
132 encodeHead(parts, 4, val.length)
133 for (const item of val) encode(item)
134 } else if (typeof val === 'object') {
135 // DAG-CBOR: sort keys by length first, then lexicographically
136 // (differs from standard CBOR which sorts lexicographically only)
137 const keys = Object.keys(val).filter(k => val[k] !== undefined)
138 keys.sort((a, b) => {
139 if (a.length !== b.length) return a.length - b.length
140 return a < b ? -1 : a > b ? 1 : 0
141 })
142 encodeHead(parts, 5, keys.length)
143 for (const key of keys) {
144 const keyBytes = new TextEncoder().encode(key)
145 encodeHead(parts, 3, keyBytes.length)
146 parts.push(...keyBytes)
147 encode(val[key])
148 }
149 }
150 }
151
152 encode(value)
153 return new Uint8Array(parts)
154}
155
156/**
157 * Decode CBOR bytes to a JavaScript value
158 * @param {Uint8Array} bytes - CBOR-encoded bytes
159 * @returns {*} Decoded value
160 */
161export function cborDecode(bytes) {
162 let offset = 0
163
164 function read() {
165 const initial = bytes[offset++]
166 const major = initial >> 5
167 const info = initial & 0x1f
168
169 let length = info
170 if (info === 24) length = bytes[offset++]
171 else if (info === 25) { length = (bytes[offset++] << 8) | bytes[offset++] }
172 else if (info === 26) {
173 // Use multiplication instead of bitshift to avoid 32-bit signed integer overflow
174 length = bytes[offset++] * 0x1000000 + bytes[offset++] * 0x10000 + bytes[offset++] * 0x100 + bytes[offset++]
175 }
176
177 switch (major) {
178 case 0: return length // unsigned int
179 case 1: return -1 - length // negative int
180 case 2: { // byte string
181 const data = bytes.slice(offset, offset + length)
182 offset += length
183 return data
184 }
185 case 3: { // text string
186 const data = new TextDecoder().decode(bytes.slice(offset, offset + length))
187 offset += length
188 return data
189 }
190 case 4: { // array
191 const arr = []
192 for (let i = 0; i < length; i++) arr.push(read())
193 return arr
194 }
195 case 5: { // map
196 const obj = {}
197 for (let i = 0; i < length; i++) {
198 const key = read()
199 obj[key] = read()
200 }
201 return obj
202 }
203 case 7: { // special
204 if (info === 20) return false
205 if (info === 21) return true
206 if (info === 22) return null
207 return undefined
208 }
209 }
210 }
211
212 return read()
213}
214
215// === CID GENERATION ===
216// dag-cbor (0x71) + sha-256 (0x12) + 32 bytes
217
218/**
219 * Create a CIDv1 (dag-cbor + sha-256) from raw bytes
220 * @param {Uint8Array} bytes - Content to hash
221 * @returns {Promise<Uint8Array>} CID bytes (36 bytes: version + codec + multihash)
222 */
223export async function createCid(bytes) {
224 const hash = await crypto.subtle.digest('SHA-256', bytes)
225 const hashBytes = new Uint8Array(hash)
226
227 // CIDv1: version(1) + codec(dag-cbor=0x71) + multihash(sha256)
228 // Multihash: hash-type(0x12) + length(0x20=32) + digest
229 const cid = new Uint8Array(2 + 2 + 32)
230 cid[0] = 0x01 // CIDv1
231 cid[1] = 0x71 // dag-cbor codec
232 cid[2] = 0x12 // sha-256
233 cid[3] = 0x20 // 32 bytes
234 cid.set(hashBytes, 4)
235
236 return cid
237}
238
239/**
240 * Convert CID bytes to base32lower string representation
241 * @param {Uint8Array} cid - CID bytes
242 * @returns {string} Base32lower-encoded CID with 'b' prefix
243 */
244export function cidToString(cid) {
245 // base32lower encoding for CIDv1
246 return 'b' + base32Encode(cid)
247}
248
249/**
250 * Encode bytes as base32lower string
251 * @param {Uint8Array} bytes - Bytes to encode
252 * @returns {string} Base32lower-encoded string
253 */
254export function base32Encode(bytes) {
255 const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'
256 let result = ''
257 let bits = 0
258 let value = 0
259
260 for (const byte of bytes) {
261 value = (value << 8) | byte
262 bits += 8
263 while (bits >= 5) {
264 bits -= 5
265 result += alphabet[(value >> bits) & 31]
266 }
267 }
268
269 if (bits > 0) {
270 result += alphabet[(value << (5 - bits)) & 31]
271 }
272
273 return result
274}
275
276// === TID GENERATION ===
277// Timestamp-based IDs: base32-sort encoded microseconds + clock ID
278
279const TID_CHARS = '234567abcdefghijklmnopqrstuvwxyz'
280let lastTimestamp = 0
281let clockId = Math.floor(Math.random() * 1024)
282
283/**
284 * Generate a timestamp-based ID (TID) for record keys
285 * Monotonic within a process, sortable by time
286 * @returns {string} 13-character base32-sort encoded TID
287 */
288export function createTid() {
289 let timestamp = Date.now() * 1000 // microseconds
290
291 // Ensure monotonic
292 if (timestamp <= lastTimestamp) {
293 timestamp = lastTimestamp + 1
294 }
295 lastTimestamp = timestamp
296
297 // 13 chars: 11 for timestamp (64 bits but only ~53 used), 2 for clock ID
298 let tid = ''
299
300 // Encode timestamp (high bits first for sortability)
301 let ts = timestamp
302 for (let i = 0; i < 11; i++) {
303 tid = TID_CHARS[ts & 31] + tid
304 ts = Math.floor(ts / 32)
305 }
306
307 // Append clock ID (2 chars)
308 tid += TID_CHARS[(clockId >> 5) & 31]
309 tid += TID_CHARS[clockId & 31]
310
311 return tid
312}
313
314// === P-256 SIGNING ===
315// Web Crypto ECDSA with P-256 curve
316
317/**
318 * Import a raw P-256 private key for signing
319 * @param {Uint8Array} privateKeyBytes - 32-byte raw private key
320 * @returns {Promise<CryptoKey>} Web Crypto key handle
321 */
322export async function importPrivateKey(privateKeyBytes) {
323 // Validate private key length (P-256 requires exactly 32 bytes)
324 if (!(privateKeyBytes instanceof Uint8Array) || privateKeyBytes.length !== 32) {
325 throw new Error(`Invalid private key: expected 32 bytes, got ${privateKeyBytes?.length ?? 'non-Uint8Array'}`)
326 }
327
328 // PKCS#8 wrapper for raw P-256 private key
329 const pkcs8Prefix = new Uint8Array([
330 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48,
331 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03,
332 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20
333 ])
334
335 const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32)
336 pkcs8.set(pkcs8Prefix)
337 pkcs8.set(privateKeyBytes, pkcs8Prefix.length)
338
339 return crypto.subtle.importKey(
340 'pkcs8',
341 pkcs8,
342 { name: 'ECDSA', namedCurve: 'P-256' },
343 false,
344 ['sign']
345 )
346}
347
348// P-256 curve order N
349const P256_N = BigInt('0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551')
350const P256_N_DIV_2 = P256_N / 2n
351
352function bytesToBigInt(bytes) {
353 let result = 0n
354 for (const byte of bytes) {
355 result = (result << 8n) | BigInt(byte)
356 }
357 return result
358}
359
360function bigIntToBytes(n, length) {
361 const bytes = new Uint8Array(length)
362 for (let i = length - 1; i >= 0; i--) {
363 bytes[i] = Number(n & 0xffn)
364 n >>= 8n
365 }
366 return bytes
367}
368
369/**
370 * Sign data with ECDSA P-256, returning low-S normalized signature
371 * @param {CryptoKey} privateKey - Web Crypto key from importPrivateKey
372 * @param {Uint8Array} data - Data to sign
373 * @returns {Promise<Uint8Array>} 64-byte signature (r || s)
374 */
375export async function sign(privateKey, data) {
376 const signature = await crypto.subtle.sign(
377 { name: 'ECDSA', hash: 'SHA-256' },
378 privateKey,
379 data
380 )
381 const sig = new Uint8Array(signature)
382
383 const r = sig.slice(0, 32)
384 const s = sig.slice(32, 64)
385 const sBigInt = bytesToBigInt(s)
386
387 // Low-S normalization: Bitcoin/ATProto require S <= N/2 to prevent
388 // signature malleability (two valid signatures for same message)
389 if (sBigInt > P256_N_DIV_2) {
390 const newS = P256_N - sBigInt
391 const newSBytes = bigIntToBytes(newS, 32)
392 const normalized = new Uint8Array(64)
393 normalized.set(r, 0)
394 normalized.set(newSBytes, 32)
395 return normalized
396 }
397
398 return sig
399}
400
401/**
402 * Generate a new P-256 key pair
403 * @returns {Promise<{privateKey: Uint8Array, publicKey: Uint8Array}>} 32-byte private key, 33-byte compressed public key
404 */
405export async function generateKeyPair() {
406 const keyPair = await crypto.subtle.generateKey(
407 { name: 'ECDSA', namedCurve: 'P-256' },
408 true,
409 ['sign', 'verify']
410 )
411
412 // Export private key as raw bytes
413 const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey)
414 const privateBytes = base64UrlDecode(privateJwk.d)
415
416 // Export public key as compressed point
417 const publicRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey)
418 const publicBytes = new Uint8Array(publicRaw)
419 const compressed = compressPublicKey(publicBytes)
420
421 return { privateKey: privateBytes, publicKey: compressed }
422}
423
424function compressPublicKey(uncompressed) {
425 // uncompressed is 65 bytes: 0x04 + x(32) + y(32)
426 // compressed is 33 bytes: prefix(02 or 03) + x(32)
427 const x = uncompressed.slice(1, 33)
428 const y = uncompressed.slice(33, 65)
429 const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03
430 const compressed = new Uint8Array(33)
431 compressed[0] = prefix
432 compressed.set(x, 1)
433 return compressed
434}
435
436function base64UrlDecode(str) {
437 const base64 = str.replace(/-/g, '+').replace(/_/g, '/')
438 const binary = atob(base64)
439 const bytes = new Uint8Array(binary.length)
440 for (let i = 0; i < binary.length; i++) {
441 bytes[i] = binary.charCodeAt(i)
442 }
443 return bytes
444}
445
446/**
447 * Convert bytes to hexadecimal string
448 * @param {Uint8Array} bytes - Bytes to convert
449 * @returns {string} Hex string
450 */
451export function bytesToHex(bytes) {
452 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
453}
454
455/**
456 * Convert hexadecimal string to bytes
457 * @param {string} hex - Hex string
458 * @returns {Uint8Array} Decoded bytes
459 */
460export function hexToBytes(hex) {
461 const bytes = new Uint8Array(hex.length / 2)
462 for (let i = 0; i < hex.length; i += 2) {
463 bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
464 }
465 return bytes
466}
467
468// === MERKLE SEARCH TREE ===
469// ATProto-compliant MST implementation
470
471async function sha256(data) {
472 const hash = await crypto.subtle.digest('SHA-256', data)
473 return new Uint8Array(hash)
474}
475
476// Cache for key depths (SHA-256 is expensive)
477const keyDepthCache = new Map()
478
479/**
480 * Get MST tree depth for a key based on leading zeros in SHA-256 hash
481 * @param {string} key - Record key (collection/rkey)
482 * @returns {Promise<number>} Tree depth (leading zeros / 2)
483 */
484export async function getKeyDepth(key) {
485 // Count leading zeros in SHA-256 hash, divide by 2
486 if (keyDepthCache.has(key)) return keyDepthCache.get(key)
487
488 const keyBytes = new TextEncoder().encode(key)
489 const hash = await sha256(keyBytes)
490
491 let zeros = 0
492 for (const byte of hash) {
493 if (byte === 0) {
494 zeros += 8
495 } else {
496 // Count leading zeros in this byte
497 for (let i = 7; i >= 0; i--) {
498 if ((byte >> i) & 1) break
499 zeros++
500 }
501 break
502 }
503 }
504
505 // MST depth = leading zeros in SHA-256 hash / 2
506 // This creates a probabilistic tree where ~50% of keys are at depth 0,
507 // ~25% at depth 1, etc., giving O(log n) lookups
508 const depth = Math.floor(zeros / 2)
509 keyDepthCache.set(key, depth)
510 return depth
511}
512
513// Compute common prefix length between two byte arrays
514function commonPrefixLen(a, b) {
515 const minLen = Math.min(a.length, b.length)
516 for (let i = 0; i < minLen; i++) {
517 if (a[i] !== b[i]) return i
518 }
519 return minLen
520}
521
522class MST {
523 constructor(sql) {
524 this.sql = sql
525 }
526
527 async computeRoot() {
528 const records = this.sql.exec(`
529 SELECT collection, rkey, cid FROM records ORDER BY collection, rkey
530 `).toArray()
531
532 if (records.length === 0) {
533 return null
534 }
535
536 // Build entries with pre-computed depths
537 const entries = []
538 for (const r of records) {
539 const key = `${r.collection}/${r.rkey}`
540 entries.push({
541 key,
542 keyBytes: new TextEncoder().encode(key),
543 cid: r.cid,
544 depth: await getKeyDepth(key)
545 })
546 }
547
548 return this.buildTree(entries, 0)
549 }
550
551 async buildTree(entries, layer) {
552 if (entries.length === 0) return null
553
554 // Separate entries for this layer vs deeper layers
555 const thisLayer = []
556 let leftSubtree = []
557
558 for (const entry of entries) {
559 if (entry.depth > layer) {
560 leftSubtree.push(entry)
561 } else {
562 // Process accumulated left subtree
563 if (leftSubtree.length > 0) {
564 const leftCid = await this.buildTree(leftSubtree, layer + 1)
565 thisLayer.push({ type: 'subtree', cid: leftCid })
566 leftSubtree = []
567 }
568 thisLayer.push({ type: 'entry', entry })
569 }
570 }
571
572 // Handle remaining left subtree
573 if (leftSubtree.length > 0) {
574 const leftCid = await this.buildTree(leftSubtree, layer + 1)
575 thisLayer.push({ type: 'subtree', cid: leftCid })
576 }
577
578 // Build node with proper ATProto format
579 const node = { e: [] }
580 let leftCid = null
581 let prevKeyBytes = new Uint8Array(0)
582
583 for (let i = 0; i < thisLayer.length; i++) {
584 const item = thisLayer[i]
585
586 if (item.type === 'subtree') {
587 if (node.e.length === 0) {
588 leftCid = item.cid
589 } else {
590 // Attach to previous entry's 't' field
591 node.e[node.e.length - 1].t = new CID(cidToBytes(item.cid))
592 }
593 } else {
594 // Entry - compute prefix compression
595 const keyBytes = item.entry.keyBytes
596 const prefixLen = commonPrefixLen(prevKeyBytes, keyBytes)
597 const keySuffix = keyBytes.slice(prefixLen)
598
599 const e = {
600 p: prefixLen,
601 k: keySuffix,
602 v: new CID(cidToBytes(item.entry.cid)),
603 t: null // Always include t field (set later if subtree exists)
604 }
605
606 node.e.push(e)
607 prevKeyBytes = keyBytes
608 }
609 }
610
611 // Always include left pointer (can be null)
612 node.l = leftCid ? new CID(cidToBytes(leftCid)) : null
613
614 // Encode node with proper MST CBOR format
615 const nodeBytes = cborEncodeMstNode(node)
616 const nodeCid = await createCid(nodeBytes)
617 const cidStr = cidToString(nodeCid)
618
619 this.sql.exec(
620 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`,
621 cidStr,
622 nodeBytes
623 )
624
625 return cidStr
626 }
627}
628
629// Special CBOR encoder for MST nodes (CIDs as raw bytes with tag 42)
630function cborEncodeMstNode(node) {
631 const parts = []
632
633 function encode(val) {
634 if (val === null || val === undefined) {
635 parts.push(CBOR_NULL)
636 } else if (typeof val === 'number') {
637 encodeHead(parts, 0, val) // unsigned int
638 } else if (val instanceof CID) {
639 // CID - encode with CBOR tag 42 + 0x00 prefix (DAG-CBOR CID link)
640 parts.push(0xd8, CBOR_TAG_CID)
641 encodeHead(parts, 2, val.bytes.length + 1) // +1 for 0x00 prefix
642 parts.push(0x00) // multibase identity prefix
643 parts.push(...val.bytes)
644 } else if (val instanceof Uint8Array) {
645 // Regular bytes
646 encodeHead(parts, 2, val.length)
647 parts.push(...val)
648 } else if (Array.isArray(val)) {
649 encodeHead(parts, 4, val.length)
650 for (const item of val) encode(item)
651 } else if (typeof val === 'object') {
652 // Sort keys for deterministic encoding (DAG-CBOR style)
653 // Include null values, only exclude undefined
654 const keys = Object.keys(val).filter(k => val[k] !== undefined)
655 keys.sort((a, b) => {
656 // DAG-CBOR: sort by length first, then lexicographically
657 if (a.length !== b.length) return a.length - b.length
658 return a < b ? -1 : a > b ? 1 : 0
659 })
660 encodeHead(parts, 5, keys.length)
661 for (const key of keys) {
662 // Encode key as text string
663 const keyBytes = new TextEncoder().encode(key)
664 encodeHead(parts, 3, keyBytes.length)
665 parts.push(...keyBytes)
666 // Encode value
667 encode(val[key])
668 }
669 }
670 }
671
672 encode(node)
673 return new Uint8Array(parts)
674}
675
676// === CAR FILE BUILDER ===
677
678/**
679 * Encode integer as unsigned varint
680 * @param {number} n - Non-negative integer
681 * @returns {Uint8Array} Varint-encoded bytes
682 */
683export function varint(n) {
684 const bytes = []
685 while (n >= 0x80) {
686 bytes.push((n & 0x7f) | 0x80)
687 n >>>= 7
688 }
689 bytes.push(n)
690 return new Uint8Array(bytes)
691}
692
693/**
694 * Convert base32lower CID string to raw bytes
695 * @param {string} cidStr - CID string with 'b' prefix
696 * @returns {Uint8Array} CID bytes
697 */
698export function cidToBytes(cidStr) {
699 // Decode base32lower CID string to bytes
700 if (!cidStr.startsWith('b')) throw new Error('expected base32lower CID')
701 return base32Decode(cidStr.slice(1))
702}
703
704/**
705 * Decode base32lower string to bytes
706 * @param {string} str - Base32lower-encoded string
707 * @returns {Uint8Array} Decoded bytes
708 */
709export function base32Decode(str) {
710 const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'
711 let bits = 0
712 let value = 0
713 const output = []
714
715 for (const char of str) {
716 const idx = alphabet.indexOf(char)
717 if (idx === -1) continue
718 value = (value << 5) | idx
719 bits += 5
720 if (bits >= 8) {
721 bits -= 8
722 output.push((value >> bits) & 0xff)
723 }
724 }
725
726 return new Uint8Array(output)
727}
728
729// Encode CAR header with proper DAG-CBOR CID links
730function cborEncodeCarHeader(obj) {
731 const parts = []
732
733 function encodeHead(majorType, value) {
734 if (value < 24) {
735 parts.push((majorType << 5) | value)
736 } else if (value < 256) {
737 parts.push((majorType << 5) | 24, value)
738 } else if (value < 65536) {
739 parts.push((majorType << 5) | 25, value >> 8, value & 0xff)
740 }
741 }
742
743 function encodeCidLink(cidBytes) {
744 // DAG-CBOR CID link: tag(42) + byte string with 0x00 prefix
745 parts.push(0xd8, 42) // tag 42
746 const withPrefix = new Uint8Array(cidBytes.length + 1)
747 withPrefix[0] = 0x00 // multibase identity prefix
748 withPrefix.set(cidBytes, 1)
749 encodeHead(2, withPrefix.length)
750 parts.push(...withPrefix)
751 }
752
753 // Encode { roots: [...], version: 1 }
754 // Sort keys: "roots" (5 chars) comes after "version" (7 chars)? No - shorter first
755 // "roots" = 5 chars, "version" = 7 chars, so "roots" first
756 encodeHead(5, 2) // map with 2 entries
757
758 // Key "roots"
759 const rootsKey = new TextEncoder().encode('roots')
760 encodeHead(3, rootsKey.length)
761 parts.push(...rootsKey)
762
763 // Value: array of CID links
764 encodeHead(4, obj.roots.length)
765 for (const cid of obj.roots) {
766 encodeCidLink(cid)
767 }
768
769 // Key "version"
770 const versionKey = new TextEncoder().encode('version')
771 encodeHead(3, versionKey.length)
772 parts.push(...versionKey)
773
774 // Value: 1
775 parts.push(0x01)
776
777 return new Uint8Array(parts)
778}
779
780/**
781 * Build a CAR (Content Addressable aRchive) file
782 * @param {string} rootCid - Root CID string
783 * @param {Array<{cid: string, data: Uint8Array}>} blocks - Blocks to include
784 * @returns {Uint8Array} CAR file bytes
785 */
786export function buildCarFile(rootCid, blocks) {
787 const parts = []
788
789 // Header: { version: 1, roots: [rootCid] }
790 // CIDs in header must be DAG-CBOR links (tag 42 + 0x00 prefix + CID bytes)
791 const rootCidBytes = cidToBytes(rootCid)
792 const header = cborEncodeCarHeader({ version: 1, roots: [rootCidBytes] })
793 parts.push(varint(header.length))
794 parts.push(header)
795
796 // Blocks: varint(len) + cid + data
797 for (const block of blocks) {
798 const cidBytes = cidToBytes(block.cid)
799 const blockLen = cidBytes.length + block.data.length
800 parts.push(varint(blockLen))
801 parts.push(cidBytes)
802 parts.push(block.data)
803 }
804
805 // Concatenate all parts
806 const totalLen = parts.reduce((sum, p) => sum + p.length, 0)
807 const car = new Uint8Array(totalLen)
808 let offset = 0
809 for (const part of parts) {
810 car.set(part, offset)
811 offset += part.length
812 }
813
814 return car
815}
816
817/**
818 * Route handler function type
819 * @callback RouteHandler
820 * @param {PersonalDataServer} pds - PDS instance
821 * @param {Request} request - HTTP request
822 * @param {URL} url - Parsed URL
823 * @returns {Promise<Response>} HTTP response
824 */
825
826/**
827 * @typedef {Object} Route
828 * @property {string} [method] - Required HTTP method (default: any)
829 * @property {RouteHandler} handler - Handler function
830 */
831
832/** @type {Record<string, Route>} */
833const pdsRoutes = {
834 '/.well-known/atproto-did': {
835 handler: (pds, req, url) => pds.handleAtprotoDid()
836 },
837 '/init': {
838 method: 'POST',
839 handler: (pds, req, url) => pds.handleInit(req)
840 },
841 '/status': {
842 handler: (pds, req, url) => pds.handleStatus()
843 },
844 '/reset-repo': {
845 handler: (pds, req, url) => pds.handleResetRepo()
846 },
847 '/forward-event': {
848 handler: (pds, req, url) => pds.handleForwardEvent(req)
849 },
850 '/register-did': {
851 handler: (pds, req, url) => pds.handleRegisterDid(req)
852 },
853 '/get-registered-dids': {
854 handler: (pds, req, url) => pds.handleGetRegisteredDids()
855 },
856 '/repo-info': {
857 handler: (pds, req, url) => pds.handleRepoInfo()
858 },
859 '/xrpc/com.atproto.server.describeServer': {
860 handler: (pds, req, url) => pds.handleDescribeServer(req)
861 },
862 '/xrpc/com.atproto.sync.listRepos': {
863 handler: (pds, req, url) => pds.handleListRepos()
864 },
865 '/xrpc/com.atproto.repo.createRecord': {
866 method: 'POST',
867 handler: (pds, req, url) => pds.handleCreateRecord(req)
868 },
869 '/xrpc/com.atproto.repo.getRecord': {
870 handler: (pds, req, url) => pds.handleGetRecord(url)
871 },
872 '/xrpc/com.atproto.sync.getLatestCommit': {
873 handler: (pds, req, url) => pds.handleGetLatestCommit()
874 },
875 '/xrpc/com.atproto.sync.getRepoStatus': {
876 handler: (pds, req, url) => pds.handleGetRepoStatus()
877 },
878 '/xrpc/com.atproto.sync.getRepo': {
879 handler: (pds, req, url) => pds.handleGetRepo()
880 },
881 '/xrpc/com.atproto.sync.subscribeRepos': {
882 handler: (pds, req, url) => pds.handleSubscribeRepos(req, url)
883 }
884}
885
886export class PersonalDataServer {
887 constructor(state, env) {
888 this.state = state
889 this.sql = state.storage.sql
890 this.env = env
891
892 // Initialize schema
893 this.sql.exec(`
894 CREATE TABLE IF NOT EXISTS blocks (
895 cid TEXT PRIMARY KEY,
896 data BLOB NOT NULL
897 );
898
899 CREATE TABLE IF NOT EXISTS records (
900 uri TEXT PRIMARY KEY,
901 cid TEXT NOT NULL,
902 collection TEXT NOT NULL,
903 rkey TEXT NOT NULL,
904 value BLOB NOT NULL
905 );
906
907 CREATE TABLE IF NOT EXISTS commits (
908 seq INTEGER PRIMARY KEY AUTOINCREMENT,
909 cid TEXT NOT NULL,
910 rev TEXT NOT NULL,
911 prev TEXT
912 );
913
914 CREATE TABLE IF NOT EXISTS seq_events (
915 seq INTEGER PRIMARY KEY AUTOINCREMENT,
916 did TEXT NOT NULL,
917 commit_cid TEXT NOT NULL,
918 evt BLOB NOT NULL
919 );
920
921 CREATE INDEX IF NOT EXISTS idx_records_collection ON records(collection, rkey);
922 `)
923 }
924
925 async initIdentity(did, privateKeyHex, handle = null) {
926 await this.state.storage.put('did', did)
927 await this.state.storage.put('privateKey', privateKeyHex)
928 if (handle) {
929 await this.state.storage.put('handle', handle)
930 }
931 }
932
933 async getDid() {
934 if (!this._did) {
935 this._did = await this.state.storage.get('did')
936 }
937 return this._did
938 }
939
940 async getHandle() {
941 return this.state.storage.get('handle')
942 }
943
944 async getSigningKey() {
945 const hex = await this.state.storage.get('privateKey')
946 if (!hex) return null
947 return importPrivateKey(hexToBytes(hex))
948 }
949
950 // Collect MST node blocks for a given root CID
951 collectMstBlocks(rootCidStr) {
952 const blocks = []
953 const visited = new Set()
954
955 const collect = (cidStr) => {
956 if (visited.has(cidStr)) return
957 visited.add(cidStr)
958
959 const rows = this.sql.exec(
960 `SELECT data FROM blocks WHERE cid = ?`, cidStr
961 ).toArray()
962 if (rows.length === 0) return
963
964 const data = new Uint8Array(rows[0].data)
965 blocks.push({ cid: cidStr, data }) // Keep as string, buildCarFile will convert
966
967 // Decode and follow child CIDs (MST nodes have 'l' and 'e' with 't' subtrees)
968 try {
969 const node = cborDecode(data)
970 if (node.l) collect(cidToString(node.l))
971 if (node.e) {
972 for (const entry of node.e) {
973 if (entry.t) collect(cidToString(entry.t))
974 }
975 }
976 } catch (e) {
977 // Not an MST node, ignore
978 }
979 }
980
981 collect(rootCidStr)
982 return blocks
983 }
984
985 async createRecord(collection, record, rkey = null) {
986 const did = await this.getDid()
987 if (!did) throw new Error('PDS not initialized')
988
989 rkey = rkey || createTid()
990 const uri = `at://${did}/${collection}/${rkey}`
991
992 // Encode and hash record
993 const recordBytes = cborEncode(record)
994 const recordCid = await createCid(recordBytes)
995 const recordCidStr = cidToString(recordCid)
996
997 // Store block
998 this.sql.exec(
999 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`,
1000 recordCidStr, recordBytes
1001 )
1002
1003 // Store record index
1004 this.sql.exec(
1005 `INSERT OR REPLACE INTO records (uri, cid, collection, rkey, value) VALUES (?, ?, ?, ?, ?)`,
1006 uri, recordCidStr, collection, rkey, recordBytes
1007 )
1008
1009 // Rebuild MST
1010 const mst = new MST(this.sql)
1011 const dataRoot = await mst.computeRoot()
1012
1013 // Get previous commit
1014 const prevCommits = this.sql.exec(
1015 `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`
1016 ).toArray()
1017 const prevCommit = prevCommits.length > 0 ? prevCommits[0] : null
1018
1019 // Create commit
1020 const rev = createTid()
1021 // Build commit with CIDs wrapped in CID class (for dag-cbor tag 42 encoding)
1022 const commit = {
1023 did,
1024 version: 3,
1025 data: new CID(cidToBytes(dataRoot)), // CID wrapped for explicit encoding
1026 rev,
1027 prev: prevCommit?.cid ? new CID(cidToBytes(prevCommit.cid)) : null
1028 }
1029
1030 // Sign commit (using dag-cbor encoder for CIDs)
1031 const commitBytes = cborEncodeDagCbor(commit)
1032 const signingKey = await this.getSigningKey()
1033 const sig = await sign(signingKey, commitBytes)
1034
1035 const signedCommit = { ...commit, sig }
1036 const signedBytes = cborEncodeDagCbor(signedCommit)
1037 const commitCid = await createCid(signedBytes)
1038 const commitCidStr = cidToString(commitCid)
1039
1040 // Store commit block
1041 this.sql.exec(
1042 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`,
1043 commitCidStr, signedBytes
1044 )
1045
1046 // Store commit reference
1047 this.sql.exec(
1048 `INSERT INTO commits (cid, rev, prev) VALUES (?, ?, ?)`,
1049 commitCidStr, rev, prevCommit?.cid || null
1050 )
1051
1052 // Update head and rev for listRepos
1053 await this.state.storage.put('head', commitCidStr)
1054 await this.state.storage.put('rev', rev)
1055
1056 // Collect blocks for the event (record + commit + MST nodes)
1057 // Build a mini CAR with just the new blocks - use string CIDs
1058 const newBlocks = []
1059 // Add record block
1060 newBlocks.push({ cid: recordCidStr, data: recordBytes })
1061 // Add commit block
1062 newBlocks.push({ cid: commitCidStr, data: signedBytes })
1063 // Add MST node blocks (get all blocks referenced by commit.data)
1064 const mstBlocks = this.collectMstBlocks(dataRoot)
1065 newBlocks.push(...mstBlocks)
1066
1067 // Sequence event with blocks - store complete event data including rev and time
1068 // blocks must be a full CAR file with header (roots = [commitCid])
1069 const eventTime = new Date().toISOString()
1070 const evt = cborEncode({
1071 ops: [{ action: 'create', path: `${collection}/${rkey}`, cid: recordCidStr }],
1072 blocks: buildCarFile(commitCidStr, newBlocks), // Full CAR with header
1073 rev, // Store the actual commit revision
1074 time: eventTime // Store the actual event time
1075 })
1076 this.sql.exec(
1077 `INSERT INTO seq_events (did, commit_cid, evt) VALUES (?, ?, ?)`,
1078 did, commitCidStr, evt
1079 )
1080
1081 // Broadcast to subscribers (both local and via default DO for relay)
1082 const evtRows = this.sql.exec(
1083 `SELECT * FROM seq_events ORDER BY seq DESC LIMIT 1`
1084 ).toArray()
1085 if (evtRows.length > 0) {
1086 this.broadcastEvent(evtRows[0])
1087 // Also forward to default DO for relay subscribers
1088 if (this.env?.PDS) {
1089 const defaultId = this.env.PDS.idFromName('default')
1090 const defaultPds = this.env.PDS.get(defaultId)
1091 // Convert ArrayBuffer to array for JSON serialization
1092 const row = evtRows[0]
1093 const evtArray = Array.from(new Uint8Array(row.evt))
1094 // Fire and forget but log errors
1095 defaultPds.fetch(new Request('http://internal/forward-event', {
1096 method: 'POST',
1097 body: JSON.stringify({ ...row, evt: evtArray })
1098 })).then(r => r.json()).then(r => console.log('forward result:', r)).catch(e => console.log('forward error:', e))
1099 }
1100 }
1101
1102 return { uri, cid: recordCidStr, commit: commitCidStr }
1103 }
1104
1105 formatEvent(evt) {
1106 // AT Protocol frame format: header + body
1107 // Use DAG-CBOR encoding for body (CIDs need tag 42 + 0x00 prefix)
1108 const header = cborEncode({ op: 1, t: '#commit' })
1109
1110 // Decode stored event to get ops, blocks, rev, and time
1111 const evtData = cborDecode(new Uint8Array(evt.evt))
1112 const ops = evtData.ops.map(op => ({
1113 ...op,
1114 cid: op.cid ? new CID(cidToBytes(op.cid)) : null // Wrap in CID class for tag 42 encoding
1115 }))
1116 // Get blocks from stored event (already in CAR format)
1117 const blocks = evtData.blocks || new Uint8Array(0)
1118
1119 const body = cborEncodeDagCbor({
1120 seq: evt.seq,
1121 rebase: false,
1122 tooBig: false,
1123 repo: evt.did,
1124 commit: new CID(cidToBytes(evt.commit_cid)), // Wrap in CID class for tag 42 encoding
1125 rev: evtData.rev, // Use stored rev from commit creation
1126 since: null,
1127 blocks: blocks instanceof Uint8Array ? blocks : new Uint8Array(blocks),
1128 ops,
1129 blobs: [],
1130 time: evtData.time // Use stored time from event creation
1131 })
1132
1133 // Concatenate header + body
1134 const frame = new Uint8Array(header.length + body.length)
1135 frame.set(header)
1136 frame.set(body, header.length)
1137 return frame
1138 }
1139
1140 async webSocketMessage(ws, message) {
1141 // Handle ping
1142 if (message === 'ping') ws.send('pong')
1143 }
1144
1145 async webSocketClose(ws, code, reason) {
1146 // Durable Object will hibernate when no connections remain
1147 }
1148
1149 broadcastEvent(evt) {
1150 const frame = this.formatEvent(evt)
1151 for (const ws of this.state.getWebSockets()) {
1152 try {
1153 ws.send(frame)
1154 } catch (e) {
1155 // Client disconnected
1156 }
1157 }
1158 }
1159
1160 async handleAtprotoDid() {
1161 let did = await this.getDid()
1162 if (!did) {
1163 const registeredDids = await this.state.storage.get('registeredDids') || []
1164 did = registeredDids[0]
1165 }
1166 if (!did) {
1167 return new Response('User not found', { status: 404 })
1168 }
1169 return new Response(did, { headers: { 'Content-Type': 'text/plain' } })
1170 }
1171
1172 async handleInit(request) {
1173 const body = await request.json()
1174 if (!body.did || !body.privateKey) {
1175 return Response.json({ error: 'missing did or privateKey' }, { status: 400 })
1176 }
1177 await this.initIdentity(body.did, body.privateKey, body.handle || null)
1178 return Response.json({ ok: true, did: body.did, handle: body.handle || null })
1179 }
1180
1181 async handleStatus() {
1182 const did = await this.getDid()
1183 return Response.json({ initialized: !!did, did: did || null })
1184 }
1185
1186 async handleResetRepo() {
1187 this.sql.exec(`DELETE FROM blocks`)
1188 this.sql.exec(`DELETE FROM records`)
1189 this.sql.exec(`DELETE FROM commits`)
1190 this.sql.exec(`DELETE FROM seq_events`)
1191 await this.state.storage.delete('head')
1192 await this.state.storage.delete('rev')
1193 return Response.json({ ok: true, message: 'repo data cleared' })
1194 }
1195
1196 async handleForwardEvent(request) {
1197 const evt = await request.json()
1198 const numSockets = [...this.state.getWebSockets()].length
1199 console.log(`forward-event: received event seq=${evt.seq}, ${numSockets} connected sockets`)
1200 this.broadcastEvent({
1201 seq: evt.seq,
1202 did: evt.did,
1203 commit_cid: evt.commit_cid,
1204 evt: new Uint8Array(Object.values(evt.evt))
1205 })
1206 return Response.json({ ok: true, sockets: numSockets })
1207 }
1208
1209 async handleRegisterDid(request) {
1210 const body = await request.json()
1211 const registeredDids = await this.state.storage.get('registeredDids') || []
1212 if (!registeredDids.includes(body.did)) {
1213 registeredDids.push(body.did)
1214 await this.state.storage.put('registeredDids', registeredDids)
1215 }
1216 return Response.json({ ok: true })
1217 }
1218
1219 async handleGetRegisteredDids() {
1220 const registeredDids = await this.state.storage.get('registeredDids') || []
1221 return Response.json({ dids: registeredDids })
1222 }
1223
1224 async handleRepoInfo() {
1225 const head = await this.state.storage.get('head')
1226 const rev = await this.state.storage.get('rev')
1227 return Response.json({ head: head || null, rev: rev || null })
1228 }
1229
1230 handleDescribeServer(request) {
1231 const hostname = request.headers.get('x-hostname') || 'localhost'
1232 return Response.json({
1233 did: `did:web:${hostname}`,
1234 availableUserDomains: [`.${hostname}`],
1235 inviteCodeRequired: false,
1236 phoneVerificationRequired: false,
1237 links: {},
1238 contact: {}
1239 })
1240 }
1241
1242 async handleListRepos() {
1243 const registeredDids = await this.state.storage.get('registeredDids') || []
1244 const did = await this.getDid()
1245 const repos = did ? [{ did, head: null, rev: null }] :
1246 registeredDids.map(d => ({ did: d, head: null, rev: null }))
1247 return Response.json({ repos })
1248 }
1249
1250 async handleCreateRecord(request) {
1251 const body = await request.json()
1252 if (!body.collection || !body.record) {
1253 return Response.json({ error: 'missing collection or record' }, { status: 400 })
1254 }
1255 try {
1256 const result = await this.createRecord(body.collection, body.record, body.rkey)
1257 return Response.json(result)
1258 } catch (err) {
1259 return Response.json({ error: err.message }, { status: 500 })
1260 }
1261 }
1262
1263 async handleGetRecord(url) {
1264 const collection = url.searchParams.get('collection')
1265 const rkey = url.searchParams.get('rkey')
1266 if (!collection || !rkey) {
1267 return Response.json({ error: 'missing collection or rkey' }, { status: 400 })
1268 }
1269 const did = await this.getDid()
1270 const uri = `at://${did}/${collection}/${rkey}`
1271 const rows = this.sql.exec(
1272 `SELECT cid, value FROM records WHERE uri = ?`, uri
1273 ).toArray()
1274 if (rows.length === 0) {
1275 return Response.json({ error: 'record not found' }, { status: 404 })
1276 }
1277 const row = rows[0]
1278 const value = cborDecode(new Uint8Array(row.value))
1279 return Response.json({ uri, cid: row.cid, value })
1280 }
1281
1282 handleGetLatestCommit() {
1283 const commits = this.sql.exec(
1284 `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`
1285 ).toArray()
1286 if (commits.length === 0) {
1287 return Response.json({ error: 'RepoNotFound', message: 'repo not found' }, { status: 404 })
1288 }
1289 return Response.json({ cid: commits[0].cid, rev: commits[0].rev })
1290 }
1291
1292 async handleGetRepoStatus() {
1293 const did = await this.getDid()
1294 const commits = this.sql.exec(
1295 `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`
1296 ).toArray()
1297 if (commits.length === 0 || !did) {
1298 return Response.json({ error: 'RepoNotFound', message: 'repo not found' }, { status: 404 })
1299 }
1300 return Response.json({ did, active: true, status: 'active', rev: commits[0].rev })
1301 }
1302
1303 handleGetRepo() {
1304 const commits = this.sql.exec(
1305 `SELECT cid FROM commits ORDER BY seq DESC LIMIT 1`
1306 ).toArray()
1307 if (commits.length === 0) {
1308 return Response.json({ error: 'repo not found' }, { status: 404 })
1309 }
1310 const blocks = this.sql.exec(`SELECT cid, data FROM blocks`).toArray()
1311 const blocksForCar = blocks.map(b => ({
1312 cid: b.cid,
1313 data: new Uint8Array(b.data)
1314 }))
1315 const car = buildCarFile(commits[0].cid, blocksForCar)
1316 return new Response(car, {
1317 headers: { 'content-type': 'application/vnd.ipld.car' }
1318 })
1319 }
1320
1321 handleSubscribeRepos(request, url) {
1322 const upgradeHeader = request.headers.get('Upgrade')
1323 if (upgradeHeader !== 'websocket') {
1324 return new Response('expected websocket', { status: 426 })
1325 }
1326 const { 0: client, 1: server } = new WebSocketPair()
1327 this.state.acceptWebSocket(server)
1328 const cursor = url.searchParams.get('cursor')
1329 if (cursor) {
1330 const events = this.sql.exec(
1331 `SELECT * FROM seq_events WHERE seq > ? ORDER BY seq`,
1332 parseInt(cursor)
1333 ).toArray()
1334 for (const evt of events) {
1335 server.send(this.formatEvent(evt))
1336 }
1337 }
1338 return new Response(null, { status: 101, webSocket: client })
1339 }
1340
1341 async fetch(request) {
1342 const url = new URL(request.url)
1343 const route = pdsRoutes[url.pathname]
1344
1345 if (!route) {
1346 return Response.json({ error: 'not found' }, { status: 404 })
1347 }
1348 if (route.method && request.method !== route.method) {
1349 return Response.json({ error: 'method not allowed' }, { status: 405 })
1350 }
1351 return route.handler(this, request, url)
1352 }
1353}
1354
1355export default {
1356 async fetch(request, env) {
1357 const url = new URL(request.url)
1358
1359 // Endpoints that don't require ?did= param (for relay/federation)
1360 if (url.pathname === '/.well-known/atproto-did' ||
1361 url.pathname === '/xrpc/com.atproto.server.describeServer') {
1362 const did = url.searchParams.get('did') || 'default'
1363 const id = env.PDS.idFromName(did)
1364 const pds = env.PDS.get(id)
1365 // Pass hostname for describeServer
1366 const newReq = new Request(request.url, {
1367 method: request.method,
1368 headers: { ...Object.fromEntries(request.headers), 'x-hostname': url.hostname }
1369 })
1370 return pds.fetch(newReq)
1371 }
1372
1373 // subscribeRepos WebSocket - route to default instance for firehose
1374 if (url.pathname === '/xrpc/com.atproto.sync.subscribeRepos') {
1375 const defaultId = env.PDS.idFromName('default')
1376 const defaultPds = env.PDS.get(defaultId)
1377 return defaultPds.fetch(request)
1378 }
1379
1380 // listRepos needs to aggregate from all registered DIDs
1381 if (url.pathname === '/xrpc/com.atproto.sync.listRepos') {
1382 const defaultId = env.PDS.idFromName('default')
1383 const defaultPds = env.PDS.get(defaultId)
1384 const regRes = await defaultPds.fetch(new Request('http://internal/get-registered-dids'))
1385 const { dids } = await regRes.json()
1386
1387 const repos = []
1388 for (const did of dids) {
1389 const id = env.PDS.idFromName(did)
1390 const pds = env.PDS.get(id)
1391 const infoRes = await pds.fetch(new Request('http://internal/repo-info'))
1392 const info = await infoRes.json()
1393 if (info.head) {
1394 repos.push({ did, head: info.head, rev: info.rev, active: true })
1395 }
1396 }
1397 return Response.json({ repos, cursor: undefined })
1398 }
1399
1400 const did = url.searchParams.get('did')
1401 if (!did) {
1402 return new Response('missing did param', { status: 400 })
1403 }
1404
1405 // On init, also register this DID with the default instance
1406 if (url.pathname === '/init' && request.method === 'POST') {
1407 const body = await request.json()
1408
1409 // Register with default instance for discovery
1410 const defaultId = env.PDS.idFromName('default')
1411 const defaultPds = env.PDS.get(defaultId)
1412 await defaultPds.fetch(new Request('http://internal/register-did', {
1413 method: 'POST',
1414 body: JSON.stringify({ did })
1415 }))
1416
1417 // Forward to the actual PDS instance
1418 const id = env.PDS.idFromName(did)
1419 const pds = env.PDS.get(id)
1420 return pds.fetch(new Request(request.url, {
1421 method: 'POST',
1422 headers: request.headers,
1423 body: JSON.stringify(body)
1424 }))
1425 }
1426
1427 const id = env.PDS.idFromName(did)
1428 const pds = env.PDS.get(id)
1429 return pds.fetch(request)
1430 }
1431}