A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto pds

chore: add Biome for linting and formatting

- Add @biomejs/biome with 2-space indent, single quotes
- Add npm scripts: format, lint, check
- Auto-fix all lint issues

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+24
biome.json
···
··· 1 + { 2 + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", 3 + "vcs": { 4 + "enabled": true, 5 + "clientKind": "git", 6 + "useIgnoreFile": true 7 + }, 8 + "formatter": { 9 + "enabled": true, 10 + "indentStyle": "space", 11 + "indentWidth": 2 12 + }, 13 + "linter": { 14 + "enabled": true, 15 + "rules": { 16 + "recommended": true 17 + } 18 + }, 19 + "javascript": { 20 + "formatter": { 21 + "quoteStyle": "single" 22 + } 23 + } 24 + }
+164
package-lock.json
··· 8 "name": "pds.js", 9 "version": "0.1.0", 10 "devDependencies": { 11 "wrangler": "^4.54.0" 12 } 13 }, 14 "node_modules/@cloudflare/kv-asset-handler": {
··· 8 "name": "pds.js", 9 "version": "0.1.0", 10 "devDependencies": { 11 + "@biomejs/biome": "^2.3.11", 12 "wrangler": "^4.54.0" 13 + } 14 + }, 15 + "node_modules/@biomejs/biome": { 16 + "version": "2.3.11", 17 + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.11.tgz", 18 + "integrity": "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==", 19 + "dev": true, 20 + "license": "MIT OR Apache-2.0", 21 + "bin": { 22 + "biome": "bin/biome" 23 + }, 24 + "engines": { 25 + "node": ">=14.21.3" 26 + }, 27 + "funding": { 28 + "type": "opencollective", 29 + "url": "https://opencollective.com/biome" 30 + }, 31 + "optionalDependencies": { 32 + "@biomejs/cli-darwin-arm64": "2.3.11", 33 + "@biomejs/cli-darwin-x64": "2.3.11", 34 + "@biomejs/cli-linux-arm64": "2.3.11", 35 + "@biomejs/cli-linux-arm64-musl": "2.3.11", 36 + "@biomejs/cli-linux-x64": "2.3.11", 37 + "@biomejs/cli-linux-x64-musl": "2.3.11", 38 + "@biomejs/cli-win32-arm64": "2.3.11", 39 + "@biomejs/cli-win32-x64": "2.3.11" 40 + } 41 + }, 42 + "node_modules/@biomejs/cli-darwin-arm64": { 43 + "version": "2.3.11", 44 + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.11.tgz", 45 + "integrity": "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==", 46 + "cpu": [ 47 + "arm64" 48 + ], 49 + "dev": true, 50 + "license": "MIT OR Apache-2.0", 51 + "optional": true, 52 + "os": [ 53 + "darwin" 54 + ], 55 + "engines": { 56 + "node": ">=14.21.3" 57 + } 58 + }, 59 + "node_modules/@biomejs/cli-darwin-x64": { 60 + "version": "2.3.11", 61 + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.11.tgz", 62 + "integrity": "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==", 63 + "cpu": [ 64 + "x64" 65 + ], 66 + "dev": true, 67 + "license": "MIT OR Apache-2.0", 68 + "optional": true, 69 + "os": [ 70 + "darwin" 71 + ], 72 + "engines": { 73 + "node": ">=14.21.3" 74 + } 75 + }, 76 + "node_modules/@biomejs/cli-linux-arm64": { 77 + "version": "2.3.11", 78 + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.11.tgz", 79 + "integrity": "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==", 80 + "cpu": [ 81 + "arm64" 82 + ], 83 + "dev": true, 84 + "license": "MIT OR Apache-2.0", 85 + "optional": true, 86 + "os": [ 87 + "linux" 88 + ], 89 + "engines": { 90 + "node": ">=14.21.3" 91 + } 92 + }, 93 + "node_modules/@biomejs/cli-linux-arm64-musl": { 94 + "version": "2.3.11", 95 + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.11.tgz", 96 + "integrity": "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==", 97 + "cpu": [ 98 + "arm64" 99 + ], 100 + "dev": true, 101 + "license": "MIT OR Apache-2.0", 102 + "optional": true, 103 + "os": [ 104 + "linux" 105 + ], 106 + "engines": { 107 + "node": ">=14.21.3" 108 + } 109 + }, 110 + "node_modules/@biomejs/cli-linux-x64": { 111 + "version": "2.3.11", 112 + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.11.tgz", 113 + "integrity": "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==", 114 + "cpu": [ 115 + "x64" 116 + ], 117 + "dev": true, 118 + "license": "MIT OR Apache-2.0", 119 + "optional": true, 120 + "os": [ 121 + "linux" 122 + ], 123 + "engines": { 124 + "node": ">=14.21.3" 125 + } 126 + }, 127 + "node_modules/@biomejs/cli-linux-x64-musl": { 128 + "version": "2.3.11", 129 + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.11.tgz", 130 + "integrity": "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==", 131 + "cpu": [ 132 + "x64" 133 + ], 134 + "dev": true, 135 + "license": "MIT OR Apache-2.0", 136 + "optional": true, 137 + "os": [ 138 + "linux" 139 + ], 140 + "engines": { 141 + "node": ">=14.21.3" 142 + } 143 + }, 144 + "node_modules/@biomejs/cli-win32-arm64": { 145 + "version": "2.3.11", 146 + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.11.tgz", 147 + "integrity": "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==", 148 + "cpu": [ 149 + "arm64" 150 + ], 151 + "dev": true, 152 + "license": "MIT OR Apache-2.0", 153 + "optional": true, 154 + "os": [ 155 + "win32" 156 + ], 157 + "engines": { 158 + "node": ">=14.21.3" 159 + } 160 + }, 161 + "node_modules/@biomejs/cli-win32-x64": { 162 + "version": "2.3.11", 163 + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.11.tgz", 164 + "integrity": "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==", 165 + "cpu": [ 166 + "x64" 167 + ], 168 + "dev": true, 169 + "license": "MIT OR Apache-2.0", 170 + "optional": true, 171 + "os": [ 172 + "win32" 173 + ], 174 + "engines": { 175 + "node": ">=14.21.3" 176 } 177 }, 178 "node_modules/@cloudflare/kv-asset-handler": {
+5 -1
package.json
··· 8 "deploy": "wrangler deploy", 9 "test": "node --test test/*.test.js", 10 "test:e2e": "./test/e2e.sh", 11 - "setup": "node scripts/setup.js" 12 }, 13 "devDependencies": { 14 "wrangler": "^4.54.0" 15 } 16 }
··· 8 "deploy": "wrangler deploy", 9 "test": "node --test test/*.test.js", 10 "test:e2e": "./test/e2e.sh", 11 + "setup": "node scripts/setup.js", 12 + "format": "biome format --write .", 13 + "lint": "biome lint .", 14 + "check": "biome check ." 15 }, 16 "devDependencies": { 17 + "@biomejs/biome": "^2.3.11", 18 "wrangler": "^4.54.0" 19 } 20 }
+246 -222
scripts/setup.js
··· 9 * Usage: node scripts/setup.js --handle alice --pds https://your-pds.workers.dev 10 */ 11 12 - import { webcrypto } from 'crypto' 13 - import { writeFileSync } from 'fs' 14 15 // === ARGUMENT PARSING === 16 17 function parseArgs() { 18 - const args = process.argv.slice(2) 19 const opts = { 20 handle: null, 21 pds: null, 22 plcUrl: 'https://plc.directory', 23 - relayUrl: 'https://bsky.network' 24 - } 25 26 for (let i = 0; i < args.length; i++) { 27 if (args[i] === '--handle' && args[i + 1]) { 28 - opts.handle = args[++i] 29 } else if (args[i] === '--pds' && args[i + 1]) { 30 - opts.pds = args[++i] 31 } else if (args[i] === '--plc-url' && args[i + 1]) { 32 - opts.plcUrl = args[++i] 33 } else if (args[i] === '--relay-url' && args[i + 1]) { 34 - opts.relayUrl = args[++i] 35 } 36 } 37 38 if (!opts.pds) { 39 - console.error('Usage: node scripts/setup.js --pds <pds-url> [--handle <subdomain>]') 40 - console.error('') 41 - console.error('Options:') 42 - console.error(' --pds PDS URL (e.g., "https://atproto-pds.chad-53c.workers.dev")') 43 - console.error(' --handle Subdomain handle (e.g., "alice") - optional, uses bare hostname if omitted') 44 - console.error(' --plc-url PLC directory URL (default: https://plc.directory)') 45 - console.error(' --relay-url Relay URL (default: https://bsky.network)') 46 - process.exit(1) 47 } 48 49 - return opts 50 } 51 52 // === KEY GENERATION === ··· 55 const keyPair = await webcrypto.subtle.generateKey( 56 { name: 'ECDSA', namedCurve: 'P-256' }, 57 true, 58 - ['sign', 'verify'] 59 - ) 60 61 // Export private key as raw 32 bytes 62 - const privateJwk = await webcrypto.subtle.exportKey('jwk', keyPair.privateKey) 63 - const privateBytes = base64UrlDecode(privateJwk.d) 64 65 // Export public key as uncompressed point (65 bytes) 66 - const publicRaw = await webcrypto.subtle.exportKey('raw', keyPair.publicKey) 67 - const publicBytes = new Uint8Array(publicRaw) 68 69 // Compress public key to 33 bytes 70 - const compressedPublic = compressPublicKey(publicBytes) 71 72 return { 73 privateKey: privateBytes, 74 publicKey: compressedPublic, 75 - cryptoKey: keyPair.privateKey 76 - } 77 } 78 79 function compressPublicKey(uncompressed) { 80 // uncompressed is 65 bytes: 0x04 + x(32) + y(32) 81 - const x = uncompressed.slice(1, 33) 82 - const y = uncompressed.slice(33, 65) 83 - const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03 84 - const compressed = new Uint8Array(33) 85 - compressed[0] = prefix 86 - compressed.set(x, 1) 87 - return compressed 88 } 89 90 function base64UrlDecode(str) { 91 - const base64 = str.replace(/-/g, '+').replace(/_/g, '/') 92 - const binary = atob(base64) 93 - const bytes = new Uint8Array(binary.length) 94 for (let i = 0; i < binary.length; i++) { 95 - bytes[i] = binary.charCodeAt(i) 96 } 97 - return bytes 98 } 99 100 function bytesToHex(bytes) { 101 - return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') 102 } 103 104 // === DID:KEY ENCODING === 105 106 // Multicodec prefix for P-256 public key (0x1200) 107 - const P256_MULTICODEC = new Uint8Array([0x80, 0x24]) 108 109 function publicKeyToDidKey(compressedPublicKey) { 110 // did:key format: "did:key:" + multibase(base58btc) of multicodec + key 111 - const keyWithCodec = new Uint8Array(P256_MULTICODEC.length + compressedPublicKey.length) 112 - keyWithCodec.set(P256_MULTICODEC) 113 - keyWithCodec.set(compressedPublicKey, P256_MULTICODEC.length) 114 115 - return 'did:key:z' + base58btcEncode(keyWithCodec) 116 } 117 118 function base58btcEncode(bytes) { 119 - const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' 120 121 // Count leading zeros 122 - let zeros = 0 123 for (const b of bytes) { 124 - if (b === 0) zeros++ 125 - else break 126 } 127 128 // Convert to base58 129 - const digits = [0] 130 for (const byte of bytes) { 131 - let carry = byte 132 for (let i = 0; i < digits.length; i++) { 133 - carry += digits[i] << 8 134 - digits[i] = carry % 58 135 - carry = (carry / 58) | 0 136 } 137 while (carry > 0) { 138 - digits.push(carry % 58) 139 - carry = (carry / 58) | 0 140 } 141 } 142 143 // Convert to string 144 - let result = '1'.repeat(zeros) 145 for (let i = digits.length - 1; i >= 0; i--) { 146 - result += ALPHABET[digits[i]] 147 } 148 149 - return result 150 } 151 152 // === CBOR ENCODING (dag-cbor compliant for PLC operations) === 153 154 function cborEncodeKey(key) { 155 // Encode a string key to CBOR bytes (for sorting) 156 - const bytes = new TextEncoder().encode(key) 157 - const parts = [] 158 - const mt = 3 << 5 // major type 3 = text string 159 if (bytes.length < 24) { 160 - parts.push(mt | bytes.length) 161 } else if (bytes.length < 256) { 162 - parts.push(mt | 24, bytes.length) 163 } else if (bytes.length < 65536) { 164 - parts.push(mt | 25, bytes.length >> 8, bytes.length & 0xff) 165 } 166 - parts.push(...bytes) 167 - return new Uint8Array(parts) 168 } 169 170 function compareBytes(a, b) { 171 // dag-cbor: bytewise lexicographic order of encoded keys 172 - const minLen = Math.min(a.length, b.length) 173 for (let i = 0; i < minLen; i++) { 174 - if (a[i] !== b[i]) return a[i] - b[i] 175 } 176 - return a.length - b.length 177 } 178 179 function cborEncode(value) { 180 - const parts = [] 181 182 function encode(val) { 183 if (val === null) { 184 - parts.push(0xf6) 185 } else if (typeof val === 'string') { 186 - const bytes = new TextEncoder().encode(val) 187 - encodeHead(3, bytes.length) 188 - parts.push(...bytes) 189 } else if (typeof val === 'number') { 190 if (Number.isInteger(val) && val >= 0) { 191 - encodeHead(0, val) 192 } 193 } else if (val instanceof Uint8Array) { 194 - encodeHead(2, val.length) 195 - parts.push(...val) 196 } else if (Array.isArray(val)) { 197 - encodeHead(4, val.length) 198 - for (const item of val) encode(item) 199 } else if (typeof val === 'object') { 200 // dag-cbor: sort keys by their CBOR-encoded bytes (length first, then lexicographic) 201 - const keys = Object.keys(val) 202 - const keysSorted = keys.sort((a, b) => compareBytes(cborEncodeKey(a), cborEncodeKey(b))) 203 - encodeHead(5, keysSorted.length) 204 for (const key of keysSorted) { 205 - encode(key) 206 - encode(val[key]) 207 } 208 } 209 } 210 211 function encodeHead(majorType, length) { 212 - const mt = majorType << 5 213 if (length < 24) { 214 - parts.push(mt | length) 215 } else if (length < 256) { 216 - parts.push(mt | 24, length) 217 } else if (length < 65536) { 218 - parts.push(mt | 25, length >> 8, length & 0xff) 219 } 220 } 221 222 - encode(value) 223 - return new Uint8Array(parts) 224 } 225 226 // === HASHING === 227 228 async function sha256(data) { 229 - const hash = await webcrypto.subtle.digest('SHA-256', data) 230 - return new Uint8Array(hash) 231 } 232 233 // === PLC OPERATIONS === 234 235 async function signPlcOperation(operation, privateKey) { 236 // Encode operation without sig field 237 - const { sig, ...opWithoutSig } = operation 238 - const encoded = cborEncode(opWithoutSig) 239 240 // Sign with P-256 241 const signature = await webcrypto.subtle.sign( 242 { name: 'ECDSA', hash: 'SHA-256' }, 243 privateKey, 244 - encoded 245 - ) 246 247 // Convert to low-S form and base64url encode 248 - const sigBytes = ensureLowS(new Uint8Array(signature)) 249 - return base64UrlEncode(sigBytes) 250 } 251 252 function ensureLowS(sig) { 253 // P-256 order N 254 - const N = BigInt('0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551') 255 - const halfN = N / 2n 256 257 - const r = sig.slice(0, 32) 258 - const s = sig.slice(32, 64) 259 260 // Convert s to BigInt 261 - let sInt = BigInt('0x' + bytesToHex(s)) 262 263 // If s > N/2, replace with N - s 264 if (sInt > halfN) { 265 - sInt = N - sInt 266 - const newS = hexToBytes(sInt.toString(16).padStart(64, '0')) 267 - const result = new Uint8Array(64) 268 - result.set(r) 269 - result.set(newS, 32) 270 - return result 271 } 272 273 - return sig 274 } 275 276 function hexToBytes(hex) { 277 - const bytes = new Uint8Array(hex.length / 2) 278 for (let i = 0; i < hex.length; i += 2) { 279 - bytes[i / 2] = parseInt(hex.substr(i, 2), 16) 280 } 281 - return bytes 282 } 283 284 function base64UrlEncode(bytes) { 285 - const binary = String.fromCharCode(...bytes) 286 - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') 287 } 288 289 async function createGenesisOperation(opts) { 290 - const { didKey, handle, pdsUrl, cryptoKey } = opts 291 292 // Build full handle: subdomain.pds-hostname, or just pds-hostname if no subdomain 293 - const pdsHost = new URL(pdsUrl).host 294 - const fullHandle = handle ? `${handle}.${pdsHost}` : pdsHost 295 296 const operation = { 297 type: 'plc_operation', 298 rotationKeys: [didKey], 299 verificationMethods: { 300 - atproto: didKey 301 }, 302 alsoKnownAs: [`at://${fullHandle}`], 303 services: { 304 atproto_pds: { 305 type: 'AtprotoPersonalDataServer', 306 - endpoint: pdsUrl 307 - } 308 }, 309 - prev: null 310 - } 311 312 // Sign the operation 313 - operation.sig = await signPlcOperation(operation, cryptoKey) 314 315 - return { operation, fullHandle } 316 } 317 318 async function deriveDidFromOperation(operation) { 319 // DID is computed from the FULL operation INCLUDING the signature 320 - const encoded = cborEncode(operation) 321 - const hash = await sha256(encoded) 322 // DID is base32 of first 15 bytes of hash (= 24 base32 chars) 323 - return 'did:plc:' + base32Encode(hash.slice(0, 15)) 324 } 325 326 function base32Encode(bytes) { 327 - const alphabet = 'abcdefghijklmnopqrstuvwxyz234567' 328 - let result = '' 329 - let bits = 0 330 - let value = 0 331 332 for (const byte of bytes) { 333 - value = (value << 8) | byte 334 - bits += 8 335 while (bits >= 5) { 336 - bits -= 5 337 - result += alphabet[(value >> bits) & 31] 338 } 339 } 340 341 if (bits > 0) { 342 - result += alphabet[(value << (5 - bits)) & 31] 343 } 344 345 - return result 346 } 347 348 // === PLC DIRECTORY REGISTRATION === 349 350 async function registerWithPlc(plcUrl, did, operation) { 351 - const url = `${plcUrl}/${encodeURIComponent(did)}` 352 353 const response = await fetch(url, { 354 method: 'POST', 355 headers: { 356 - 'Content-Type': 'application/json' 357 }, 358 - body: JSON.stringify(operation) 359 - }) 360 361 if (!response.ok) { 362 - const text = await response.text() 363 - throw new Error(`PLC registration failed: ${response.status} ${text}`) 364 } 365 366 - return true 367 } 368 369 // === PDS INITIALIZATION === 370 371 async function initializePds(pdsUrl, did, privateKeyHex, handle) { 372 - const url = `${pdsUrl}/init?did=${encodeURIComponent(did)}` 373 374 const response = await fetch(url, { 375 method: 'POST', 376 headers: { 377 - 'Content-Type': 'application/json' 378 }, 379 body: JSON.stringify({ 380 did, 381 privateKey: privateKeyHex, 382 - handle 383 - }) 384 - }) 385 386 if (!response.ok) { 387 - const text = await response.text() 388 - throw new Error(`PDS initialization failed: ${response.status} ${text}`) 389 } 390 391 - return response.json() 392 } 393 394 // === HANDLE REGISTRATION === 395 396 async function registerHandle(pdsUrl, handle, did) { 397 - const url = `${pdsUrl}/register-handle` 398 399 const response = await fetch(url, { 400 method: 'POST', 401 headers: { 402 - 'Content-Type': 'application/json' 403 }, 404 - body: JSON.stringify({ handle, did }) 405 - }) 406 407 if (!response.ok) { 408 - const text = await response.text() 409 - throw new Error(`Handle registration failed: ${response.status} ${text}`) 410 } 411 412 - return true 413 } 414 415 // === RELAY NOTIFICATION === 416 417 async function notifyRelay(relayUrl, pdsHostname) { 418 - const url = `${relayUrl}/xrpc/com.atproto.sync.requestCrawl` 419 420 const response = await fetch(url, { 421 method: 'POST', 422 headers: { 423 - 'Content-Type': 'application/json' 424 }, 425 body: JSON.stringify({ 426 - hostname: pdsHostname 427 - }) 428 - }) 429 430 // Relay might return 200 or 202, both are OK 431 if (!response.ok && response.status !== 202) { 432 - const text = await response.text() 433 - console.warn(` Warning: Relay notification returned ${response.status}: ${text}`) 434 - return false 435 } 436 437 - return true 438 } 439 440 // === CREDENTIALS OUTPUT === 441 442 function saveCredentials(filename, credentials) { 443 - writeFileSync(filename, JSON.stringify(credentials, null, 2)) 444 } 445 446 // === MAIN === 447 448 async function main() { 449 - const opts = parseArgs() 450 451 - console.log('PDS Federation Setup') 452 - console.log('====================') 453 - console.log(`PDS: ${opts.pds}`) 454 - console.log('') 455 456 // Step 1: Generate keypair 457 - console.log('Generating P-256 keypair...') 458 - const keyPair = await generateP256Keypair() 459 - const didKey = publicKeyToDidKey(keyPair.publicKey) 460 - console.log(` did:key: ${didKey}`) 461 - console.log('') 462 463 // Step 2: Create genesis operation 464 - console.log('Creating PLC genesis operation...') 465 const { operation, fullHandle } = await createGenesisOperation({ 466 didKey, 467 handle: opts.handle, 468 pdsUrl: opts.pds, 469 - cryptoKey: keyPair.cryptoKey 470 - }) 471 - const did = await deriveDidFromOperation(operation) 472 - console.log(` DID: ${did}`) 473 - console.log(` Handle: ${fullHandle}`) 474 - console.log('') 475 476 // Step 3: Register with PLC directory 477 - console.log(`Registering with ${opts.plcUrl}...`) 478 - await registerWithPlc(opts.plcUrl, did, operation) 479 - console.log(' Registered successfully!') 480 - console.log('') 481 482 // Step 4: Initialize PDS 483 - console.log(`Initializing PDS at ${opts.pds}...`) 484 - const privateKeyHex = bytesToHex(keyPair.privateKey) 485 - await initializePds(opts.pds, did, privateKeyHex, fullHandle) 486 - console.log(' PDS initialized!') 487 - console.log('') 488 489 // Step 4b: Register handle -> DID mapping (only for subdomain handles) 490 if (opts.handle) { 491 - console.log(`Registering handle mapping...`) 492 - await registerHandle(opts.pds, opts.handle, did) 493 - console.log(` Handle ${opts.handle} -> ${did}`) 494 - console.log('') 495 } 496 497 // Step 5: Notify relay 498 - const pdsHostname = new URL(opts.pds).host 499 - console.log(`Notifying relay at ${opts.relayUrl}...`) 500 - const relayOk = await notifyRelay(opts.relayUrl, pdsHostname) 501 if (relayOk) { 502 - console.log(' Relay notified!') 503 } 504 - console.log('') 505 506 // Step 6: Save credentials 507 const credentials = { ··· 510 privateKeyHex: bytesToHex(keyPair.privateKey), 511 didKey, 512 pdsUrl: opts.pds, 513 - createdAt: new Date().toISOString() 514 - } 515 516 - const credentialsFile = `./credentials-${opts.handle || new URL(opts.pds).host}.json` 517 - saveCredentials(credentialsFile, credentials) 518 519 // Final output 520 - console.log('Setup Complete!') 521 - console.log('===============') 522 - console.log(`Handle: ${fullHandle}`) 523 - console.log(`DID: ${did}`) 524 - console.log(`PDS: ${opts.pds}`) 525 - console.log('') 526 - console.log(`Credentials saved to: ${credentialsFile}`) 527 - console.log('Keep this file safe - it contains your private key!') 528 } 529 530 - main().catch(err => { 531 - console.error('Error:', err.message) 532 - process.exit(1) 533 - })
··· 9 * Usage: node scripts/setup.js --handle alice --pds https://your-pds.workers.dev 10 */ 11 12 + import { webcrypto } from 'node:crypto'; 13 + import { writeFileSync } from 'node:fs'; 14 15 // === ARGUMENT PARSING === 16 17 function parseArgs() { 18 + const args = process.argv.slice(2); 19 const opts = { 20 handle: null, 21 pds: null, 22 plcUrl: 'https://plc.directory', 23 + relayUrl: 'https://bsky.network', 24 + }; 25 26 for (let i = 0; i < args.length; i++) { 27 if (args[i] === '--handle' && args[i + 1]) { 28 + opts.handle = args[++i]; 29 } else if (args[i] === '--pds' && args[i + 1]) { 30 + opts.pds = args[++i]; 31 } else if (args[i] === '--plc-url' && args[i + 1]) { 32 + opts.plcUrl = args[++i]; 33 } else if (args[i] === '--relay-url' && args[i + 1]) { 34 + opts.relayUrl = args[++i]; 35 } 36 } 37 38 if (!opts.pds) { 39 + console.error( 40 + 'Usage: node scripts/setup.js --pds <pds-url> [--handle <subdomain>]', 41 + ); 42 + console.error(''); 43 + console.error('Options:'); 44 + console.error( 45 + ' --pds PDS URL (e.g., "https://atproto-pds.chad-53c.workers.dev")', 46 + ); 47 + console.error( 48 + ' --handle Subdomain handle (e.g., "alice") - optional, uses bare hostname if omitted', 49 + ); 50 + console.error( 51 + ' --plc-url PLC directory URL (default: https://plc.directory)', 52 + ); 53 + console.error(' --relay-url Relay URL (default: https://bsky.network)'); 54 + process.exit(1); 55 } 56 57 + return opts; 58 } 59 60 // === KEY GENERATION === ··· 63 const keyPair = await webcrypto.subtle.generateKey( 64 { name: 'ECDSA', namedCurve: 'P-256' }, 65 true, 66 + ['sign', 'verify'], 67 + ); 68 69 // Export private key as raw 32 bytes 70 + const privateJwk = await webcrypto.subtle.exportKey( 71 + 'jwk', 72 + keyPair.privateKey, 73 + ); 74 + const privateBytes = base64UrlDecode(privateJwk.d); 75 76 // Export public key as uncompressed point (65 bytes) 77 + const publicRaw = await webcrypto.subtle.exportKey('raw', keyPair.publicKey); 78 + const publicBytes = new Uint8Array(publicRaw); 79 80 // Compress public key to 33 bytes 81 + const compressedPublic = compressPublicKey(publicBytes); 82 83 return { 84 privateKey: privateBytes, 85 publicKey: compressedPublic, 86 + cryptoKey: keyPair.privateKey, 87 + }; 88 } 89 90 function compressPublicKey(uncompressed) { 91 // uncompressed is 65 bytes: 0x04 + x(32) + y(32) 92 + const x = uncompressed.slice(1, 33); 93 + const y = uncompressed.slice(33, 65); 94 + const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03; 95 + const compressed = new Uint8Array(33); 96 + compressed[0] = prefix; 97 + compressed.set(x, 1); 98 + return compressed; 99 } 100 101 function base64UrlDecode(str) { 102 + const base64 = str.replace(/-/g, '+').replace(/_/g, '/'); 103 + const binary = atob(base64); 104 + const bytes = new Uint8Array(binary.length); 105 for (let i = 0; i < binary.length; i++) { 106 + bytes[i] = binary.charCodeAt(i); 107 } 108 + return bytes; 109 } 110 111 function bytesToHex(bytes) { 112 + return Array.from(bytes) 113 + .map((b) => b.toString(16).padStart(2, '0')) 114 + .join(''); 115 } 116 117 // === DID:KEY ENCODING === 118 119 // Multicodec prefix for P-256 public key (0x1200) 120 + const P256_MULTICODEC = new Uint8Array([0x80, 0x24]); 121 122 function publicKeyToDidKey(compressedPublicKey) { 123 // did:key format: "did:key:" + multibase(base58btc) of multicodec + key 124 + const keyWithCodec = new Uint8Array( 125 + P256_MULTICODEC.length + compressedPublicKey.length, 126 + ); 127 + keyWithCodec.set(P256_MULTICODEC); 128 + keyWithCodec.set(compressedPublicKey, P256_MULTICODEC.length); 129 130 + return `did:key:z${base58btcEncode(keyWithCodec)}`; 131 } 132 133 function base58btcEncode(bytes) { 134 + const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; 135 136 // Count leading zeros 137 + let zeros = 0; 138 for (const b of bytes) { 139 + if (b === 0) zeros++; 140 + else break; 141 } 142 143 // Convert to base58 144 + const digits = [0]; 145 for (const byte of bytes) { 146 + let carry = byte; 147 for (let i = 0; i < digits.length; i++) { 148 + carry += digits[i] << 8; 149 + digits[i] = carry % 58; 150 + carry = (carry / 58) | 0; 151 } 152 while (carry > 0) { 153 + digits.push(carry % 58); 154 + carry = (carry / 58) | 0; 155 } 156 } 157 158 // Convert to string 159 + let result = '1'.repeat(zeros); 160 for (let i = digits.length - 1; i >= 0; i--) { 161 + result += ALPHABET[digits[i]]; 162 } 163 164 + return result; 165 } 166 167 // === CBOR ENCODING (dag-cbor compliant for PLC operations) === 168 169 function cborEncodeKey(key) { 170 // Encode a string key to CBOR bytes (for sorting) 171 + const bytes = new TextEncoder().encode(key); 172 + const parts = []; 173 + const mt = 3 << 5; // major type 3 = text string 174 if (bytes.length < 24) { 175 + parts.push(mt | bytes.length); 176 } else if (bytes.length < 256) { 177 + parts.push(mt | 24, bytes.length); 178 } else if (bytes.length < 65536) { 179 + parts.push(mt | 25, bytes.length >> 8, bytes.length & 0xff); 180 } 181 + parts.push(...bytes); 182 + return new Uint8Array(parts); 183 } 184 185 function compareBytes(a, b) { 186 // dag-cbor: bytewise lexicographic order of encoded keys 187 + const minLen = Math.min(a.length, b.length); 188 for (let i = 0; i < minLen; i++) { 189 + if (a[i] !== b[i]) return a[i] - b[i]; 190 } 191 + return a.length - b.length; 192 } 193 194 function cborEncode(value) { 195 + const parts = []; 196 197 function encode(val) { 198 if (val === null) { 199 + parts.push(0xf6); 200 } else if (typeof val === 'string') { 201 + const bytes = new TextEncoder().encode(val); 202 + encodeHead(3, bytes.length); 203 + parts.push(...bytes); 204 } else if (typeof val === 'number') { 205 if (Number.isInteger(val) && val >= 0) { 206 + encodeHead(0, val); 207 } 208 } else if (val instanceof Uint8Array) { 209 + encodeHead(2, val.length); 210 + parts.push(...val); 211 } else if (Array.isArray(val)) { 212 + encodeHead(4, val.length); 213 + for (const item of val) encode(item); 214 } else if (typeof val === 'object') { 215 // dag-cbor: sort keys by their CBOR-encoded bytes (length first, then lexicographic) 216 + const keys = Object.keys(val); 217 + const keysSorted = keys.sort((a, b) => 218 + compareBytes(cborEncodeKey(a), cborEncodeKey(b)), 219 + ); 220 + encodeHead(5, keysSorted.length); 221 for (const key of keysSorted) { 222 + encode(key); 223 + encode(val[key]); 224 } 225 } 226 } 227 228 function encodeHead(majorType, length) { 229 + const mt = majorType << 5; 230 if (length < 24) { 231 + parts.push(mt | length); 232 } else if (length < 256) { 233 + parts.push(mt | 24, length); 234 } else if (length < 65536) { 235 + parts.push(mt | 25, length >> 8, length & 0xff); 236 } 237 } 238 239 + encode(value); 240 + return new Uint8Array(parts); 241 } 242 243 // === HASHING === 244 245 async function sha256(data) { 246 + const hash = await webcrypto.subtle.digest('SHA-256', data); 247 + return new Uint8Array(hash); 248 } 249 250 // === PLC OPERATIONS === 251 252 async function signPlcOperation(operation, privateKey) { 253 // Encode operation without sig field 254 + const { sig, ...opWithoutSig } = operation; 255 + const encoded = cborEncode(opWithoutSig); 256 257 // Sign with P-256 258 const signature = await webcrypto.subtle.sign( 259 { name: 'ECDSA', hash: 'SHA-256' }, 260 privateKey, 261 + encoded, 262 + ); 263 264 // Convert to low-S form and base64url encode 265 + const sigBytes = ensureLowS(new Uint8Array(signature)); 266 + return base64UrlEncode(sigBytes); 267 } 268 269 function ensureLowS(sig) { 270 // P-256 order N 271 + const N = BigInt( 272 + '0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551', 273 + ); 274 + const halfN = N / 2n; 275 276 + const r = sig.slice(0, 32); 277 + const s = sig.slice(32, 64); 278 279 // Convert s to BigInt 280 + let sInt = BigInt(`0x${bytesToHex(s)}`); 281 282 // If s > N/2, replace with N - s 283 if (sInt > halfN) { 284 + sInt = N - sInt; 285 + const newS = hexToBytes(sInt.toString(16).padStart(64, '0')); 286 + const result = new Uint8Array(64); 287 + result.set(r); 288 + result.set(newS, 32); 289 + return result; 290 } 291 292 + return sig; 293 } 294 295 function hexToBytes(hex) { 296 + const bytes = new Uint8Array(hex.length / 2); 297 for (let i = 0; i < hex.length; i += 2) { 298 + bytes[i / 2] = parseInt(hex.substr(i, 2), 16); 299 } 300 + return bytes; 301 } 302 303 function base64UrlEncode(bytes) { 304 + const binary = String.fromCharCode(...bytes); 305 + return btoa(binary) 306 + .replace(/\+/g, '-') 307 + .replace(/\//g, '_') 308 + .replace(/=+$/, ''); 309 } 310 311 async function createGenesisOperation(opts) { 312 + const { didKey, handle, pdsUrl, cryptoKey } = opts; 313 314 // Build full handle: subdomain.pds-hostname, or just pds-hostname if no subdomain 315 + const pdsHost = new URL(pdsUrl).host; 316 + const fullHandle = handle ? `${handle}.${pdsHost}` : pdsHost; 317 318 const operation = { 319 type: 'plc_operation', 320 rotationKeys: [didKey], 321 verificationMethods: { 322 + atproto: didKey, 323 }, 324 alsoKnownAs: [`at://${fullHandle}`], 325 services: { 326 atproto_pds: { 327 type: 'AtprotoPersonalDataServer', 328 + endpoint: pdsUrl, 329 + }, 330 }, 331 + prev: null, 332 + }; 333 334 // Sign the operation 335 + operation.sig = await signPlcOperation(operation, cryptoKey); 336 337 + return { operation, fullHandle }; 338 } 339 340 async function deriveDidFromOperation(operation) { 341 // DID is computed from the FULL operation INCLUDING the signature 342 + const encoded = cborEncode(operation); 343 + const hash = await sha256(encoded); 344 // DID is base32 of first 15 bytes of hash (= 24 base32 chars) 345 + return `did:plc:${base32Encode(hash.slice(0, 15))}`; 346 } 347 348 function base32Encode(bytes) { 349 + const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; 350 + let result = ''; 351 + let bits = 0; 352 + let value = 0; 353 354 for (const byte of bytes) { 355 + value = (value << 8) | byte; 356 + bits += 8; 357 while (bits >= 5) { 358 + bits -= 5; 359 + result += alphabet[(value >> bits) & 31]; 360 } 361 } 362 363 if (bits > 0) { 364 + result += alphabet[(value << (5 - bits)) & 31]; 365 } 366 367 + return result; 368 } 369 370 // === PLC DIRECTORY REGISTRATION === 371 372 async function registerWithPlc(plcUrl, did, operation) { 373 + const url = `${plcUrl}/${encodeURIComponent(did)}`; 374 375 const response = await fetch(url, { 376 method: 'POST', 377 headers: { 378 + 'Content-Type': 'application/json', 379 }, 380 + body: JSON.stringify(operation), 381 + }); 382 383 if (!response.ok) { 384 + const text = await response.text(); 385 + throw new Error(`PLC registration failed: ${response.status} ${text}`); 386 } 387 388 + return true; 389 } 390 391 // === PDS INITIALIZATION === 392 393 async function initializePds(pdsUrl, did, privateKeyHex, handle) { 394 + const url = `${pdsUrl}/init?did=${encodeURIComponent(did)}`; 395 396 const response = await fetch(url, { 397 method: 'POST', 398 headers: { 399 + 'Content-Type': 'application/json', 400 }, 401 body: JSON.stringify({ 402 did, 403 privateKey: privateKeyHex, 404 + handle, 405 + }), 406 + }); 407 408 if (!response.ok) { 409 + const text = await response.text(); 410 + throw new Error(`PDS initialization failed: ${response.status} ${text}`); 411 } 412 413 + return response.json(); 414 } 415 416 // === HANDLE REGISTRATION === 417 418 async function registerHandle(pdsUrl, handle, did) { 419 + const url = `${pdsUrl}/register-handle`; 420 421 const response = await fetch(url, { 422 method: 'POST', 423 headers: { 424 + 'Content-Type': 'application/json', 425 }, 426 + body: JSON.stringify({ handle, did }), 427 + }); 428 429 if (!response.ok) { 430 + const text = await response.text(); 431 + throw new Error(`Handle registration failed: ${response.status} ${text}`); 432 } 433 434 + return true; 435 } 436 437 // === RELAY NOTIFICATION === 438 439 async function notifyRelay(relayUrl, pdsHostname) { 440 + const url = `${relayUrl}/xrpc/com.atproto.sync.requestCrawl`; 441 442 const response = await fetch(url, { 443 method: 'POST', 444 headers: { 445 + 'Content-Type': 'application/json', 446 }, 447 body: JSON.stringify({ 448 + hostname: pdsHostname, 449 + }), 450 + }); 451 452 // Relay might return 200 or 202, both are OK 453 if (!response.ok && response.status !== 202) { 454 + const text = await response.text(); 455 + console.warn( 456 + ` Warning: Relay notification returned ${response.status}: ${text}`, 457 + ); 458 + return false; 459 } 460 461 + return true; 462 } 463 464 // === CREDENTIALS OUTPUT === 465 466 function saveCredentials(filename, credentials) { 467 + writeFileSync(filename, JSON.stringify(credentials, null, 2)); 468 } 469 470 // === MAIN === 471 472 async function main() { 473 + const opts = parseArgs(); 474 475 + console.log('PDS Federation Setup'); 476 + console.log('===================='); 477 + console.log(`PDS: ${opts.pds}`); 478 + console.log(''); 479 480 // Step 1: Generate keypair 481 + console.log('Generating P-256 keypair...'); 482 + const keyPair = await generateP256Keypair(); 483 + const didKey = publicKeyToDidKey(keyPair.publicKey); 484 + console.log(` did:key: ${didKey}`); 485 + console.log(''); 486 487 // Step 2: Create genesis operation 488 + console.log('Creating PLC genesis operation...'); 489 const { operation, fullHandle } = await createGenesisOperation({ 490 didKey, 491 handle: opts.handle, 492 pdsUrl: opts.pds, 493 + cryptoKey: keyPair.cryptoKey, 494 + }); 495 + const did = await deriveDidFromOperation(operation); 496 + console.log(` DID: ${did}`); 497 + console.log(` Handle: ${fullHandle}`); 498 + console.log(''); 499 500 // Step 3: Register with PLC directory 501 + console.log(`Registering with ${opts.plcUrl}...`); 502 + await registerWithPlc(opts.plcUrl, did, operation); 503 + console.log(' Registered successfully!'); 504 + console.log(''); 505 506 // Step 4: Initialize PDS 507 + console.log(`Initializing PDS at ${opts.pds}...`); 508 + const privateKeyHex = bytesToHex(keyPair.privateKey); 509 + await initializePds(opts.pds, did, privateKeyHex, fullHandle); 510 + console.log(' PDS initialized!'); 511 + console.log(''); 512 513 // Step 4b: Register handle -> DID mapping (only for subdomain handles) 514 if (opts.handle) { 515 + console.log(`Registering handle mapping...`); 516 + await registerHandle(opts.pds, opts.handle, did); 517 + console.log(` Handle ${opts.handle} -> ${did}`); 518 + console.log(''); 519 } 520 521 // Step 5: Notify relay 522 + const pdsHostname = new URL(opts.pds).host; 523 + console.log(`Notifying relay at ${opts.relayUrl}...`); 524 + const relayOk = await notifyRelay(opts.relayUrl, pdsHostname); 525 if (relayOk) { 526 + console.log(' Relay notified!'); 527 } 528 + console.log(''); 529 530 // Step 6: Save credentials 531 const credentials = { ··· 534 privateKeyHex: bytesToHex(keyPair.privateKey), 535 didKey, 536 pdsUrl: opts.pds, 537 + createdAt: new Date().toISOString(), 538 + }; 539 540 + const credentialsFile = `./credentials-${opts.handle || new URL(opts.pds).host}.json`; 541 + saveCredentials(credentialsFile, credentials); 542 543 // Final output 544 + console.log('Setup Complete!'); 545 + console.log('==============='); 546 + console.log(`Handle: ${fullHandle}`); 547 + console.log(`DID: ${did}`); 548 + console.log(`PDS: ${opts.pds}`); 549 + console.log(''); 550 + console.log(`Credentials saved to: ${credentialsFile}`); 551 + console.log('Keep this file safe - it contains your private key!'); 552 } 553 554 + main().catch((err) => { 555 + console.error('Error:', err.message); 556 + process.exit(1); 557 + });
+128 -117
scripts/update-did.js
··· 6 * Usage: node scripts/update-did.js --credentials <file> --new-handle <handle> --new-pds <url> 7 */ 8 9 - import { webcrypto } from 'crypto' 10 - import { readFileSync, writeFileSync } from 'fs' 11 12 // === ARGUMENT PARSING === 13 14 function parseArgs() { 15 - const args = process.argv.slice(2) 16 const opts = { 17 credentials: null, 18 newHandle: null, 19 newPds: null, 20 - plcUrl: 'https://plc.directory' 21 - } 22 23 for (let i = 0; i < args.length; i++) { 24 if (args[i] === '--credentials' && args[i + 1]) { 25 - opts.credentials = args[++i] 26 } else if (args[i] === '--new-handle' && args[i + 1]) { 27 - opts.newHandle = args[++i] 28 } else if (args[i] === '--new-pds' && args[i + 1]) { 29 - opts.newPds = args[++i] 30 } else if (args[i] === '--plc-url' && args[i + 1]) { 31 - opts.plcUrl = args[++i] 32 } 33 } 34 35 if (!opts.credentials || !opts.newHandle || !opts.newPds) { 36 - console.error('Usage: node scripts/update-did.js --credentials <file> --new-handle <handle> --new-pds <url>') 37 - process.exit(1) 38 } 39 40 - return opts 41 } 42 43 // === CRYPTO HELPERS === 44 45 function hexToBytes(hex) { 46 - const bytes = new Uint8Array(hex.length / 2) 47 for (let i = 0; i < hex.length; i += 2) { 48 - bytes[i / 2] = parseInt(hex.substr(i, 2), 16) 49 } 50 - return bytes 51 } 52 53 function bytesToHex(bytes) { 54 - return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') 55 } 56 57 async function importPrivateKey(privateKeyBytes) { 58 const pkcs8Prefix = new Uint8Array([ 59 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 60 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 61 - 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20 62 - ]) 63 64 - const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32) 65 - pkcs8.set(pkcs8Prefix) 66 - pkcs8.set(privateKeyBytes, pkcs8Prefix.length) 67 68 return webcrypto.subtle.importKey( 69 'pkcs8', 70 pkcs8, 71 { name: 'ECDSA', namedCurve: 'P-256' }, 72 false, 73 - ['sign'] 74 - ) 75 } 76 77 // === CBOR ENCODING === 78 79 function cborEncodeKey(key) { 80 - const bytes = new TextEncoder().encode(key) 81 - const parts = [] 82 - const mt = 3 << 5 83 if (bytes.length < 24) { 84 - parts.push(mt | bytes.length) 85 } else if (bytes.length < 256) { 86 - parts.push(mt | 24, bytes.length) 87 } 88 - parts.push(...bytes) 89 - return new Uint8Array(parts) 90 } 91 92 function compareBytes(a, b) { 93 - const minLen = Math.min(a.length, b.length) 94 for (let i = 0; i < minLen; i++) { 95 - if (a[i] !== b[i]) return a[i] - b[i] 96 } 97 - return a.length - b.length 98 } 99 100 function cborEncode(value) { 101 - const parts = [] 102 103 function encode(val) { 104 if (val === null) { 105 - parts.push(0xf6) 106 } else if (typeof val === 'string') { 107 - const bytes = new TextEncoder().encode(val) 108 - encodeHead(3, bytes.length) 109 - parts.push(...bytes) 110 } else if (typeof val === 'number') { 111 if (Number.isInteger(val) && val >= 0) { 112 - encodeHead(0, val) 113 } 114 } else if (val instanceof Uint8Array) { 115 - encodeHead(2, val.length) 116 - parts.push(...val) 117 } else if (Array.isArray(val)) { 118 - encodeHead(4, val.length) 119 - for (const item of val) encode(item) 120 } else if (typeof val === 'object') { 121 - const keys = Object.keys(val) 122 - const keysSorted = keys.sort((a, b) => compareBytes(cborEncodeKey(a), cborEncodeKey(b))) 123 - encodeHead(5, keysSorted.length) 124 for (const key of keysSorted) { 125 - encode(key) 126 - encode(val[key]) 127 } 128 } 129 } 130 131 function encodeHead(majorType, length) { 132 - const mt = majorType << 5 133 if (length < 24) { 134 - parts.push(mt | length) 135 } else if (length < 256) { 136 - parts.push(mt | 24, length) 137 } else if (length < 65536) { 138 - parts.push(mt | 25, length >> 8, length & 0xff) 139 } 140 } 141 142 - encode(value) 143 - return new Uint8Array(parts) 144 } 145 146 // === SIGNING === 147 148 - const P256_N = BigInt('0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551') 149 150 function ensureLowS(sig) { 151 - const halfN = P256_N / 2n 152 - const r = sig.slice(0, 32) 153 - const s = sig.slice(32, 64) 154 - let sInt = BigInt('0x' + bytesToHex(s)) 155 156 if (sInt > halfN) { 157 - sInt = P256_N - sInt 158 - const newS = hexToBytes(sInt.toString(16).padStart(64, '0')) 159 - const result = new Uint8Array(64) 160 - result.set(r) 161 - result.set(newS, 32) 162 - return result 163 } 164 - return sig 165 } 166 167 function base64UrlEncode(bytes) { 168 - const binary = String.fromCharCode(...bytes) 169 - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') 170 } 171 172 async function signPlcOperation(operation, privateKey) { 173 - const { sig, ...opWithoutSig } = operation 174 - const encoded = cborEncode(opWithoutSig) 175 176 const signature = await webcrypto.subtle.sign( 177 { name: 'ECDSA', hash: 'SHA-256' }, 178 privateKey, 179 - encoded 180 - ) 181 182 - const sigBytes = ensureLowS(new Uint8Array(signature)) 183 - return base64UrlEncode(sigBytes) 184 } 185 186 // === MAIN === 187 188 async function main() { 189 - const opts = parseArgs() 190 191 // Load credentials 192 - const creds = JSON.parse(readFileSync(opts.credentials, 'utf-8')) 193 - console.log(`Updating DID: ${creds.did}`) 194 - console.log(` Old handle: ${creds.handle}`) 195 - console.log(` New handle: ${opts.newHandle}`) 196 - console.log(` New PDS: ${opts.newPds}`) 197 - console.log('') 198 199 // Fetch current operation log 200 - console.log('Fetching current PLC operation log...') 201 - const logRes = await fetch(`${opts.plcUrl}/${creds.did}/log/audit`) 202 if (!logRes.ok) { 203 - throw new Error(`Failed to fetch PLC log: ${logRes.status}`) 204 } 205 - const log = await logRes.json() 206 - const lastOp = log[log.length - 1] 207 - console.log(` Found ${log.length} operations`) 208 - console.log(` Last CID: ${lastOp.cid}`) 209 - console.log('') 210 211 // Import private key 212 - const privateKey = await importPrivateKey(hexToBytes(creds.privateKeyHex)) 213 214 // Create new operation 215 const newOp = { ··· 220 services: { 221 atproto_pds: { 222 type: 'AtprotoPersonalDataServer', 223 - endpoint: opts.newPds 224 - } 225 }, 226 - prev: lastOp.cid 227 - } 228 229 // Sign the operation 230 - console.log('Signing new operation...') 231 - newOp.sig = await signPlcOperation(newOp, privateKey) 232 233 // Submit to PLC 234 - console.log('Submitting to PLC directory...') 235 const submitRes = await fetch(`${opts.plcUrl}/${creds.did}`, { 236 method: 'POST', 237 headers: { 'Content-Type': 'application/json' }, 238 - body: JSON.stringify(newOp) 239 - }) 240 241 if (!submitRes.ok) { 242 - const text = await submitRes.text() 243 - throw new Error(`PLC update failed: ${submitRes.status} ${text}`) 244 } 245 246 - console.log(' Updated successfully!') 247 - console.log('') 248 249 // Update credentials file 250 - creds.handle = opts.newHandle 251 - creds.pdsUrl = opts.newPds 252 - writeFileSync(opts.credentials, JSON.stringify(creds, null, 2)) 253 - console.log(`Updated credentials file: ${opts.credentials}`) 254 255 // Verify 256 - console.log('') 257 - console.log('Verifying...') 258 - const verifyRes = await fetch(`${opts.plcUrl}/${creds.did}`) 259 - const didDoc = await verifyRes.json() 260 - console.log(` alsoKnownAs: ${didDoc.alsoKnownAs}`) 261 - console.log(` PDS endpoint: ${didDoc.service[0].serviceEndpoint}`) 262 } 263 264 - main().catch(err => { 265 - console.error('Error:', err.message) 266 - process.exit(1) 267 - })
··· 6 * Usage: node scripts/update-did.js --credentials <file> --new-handle <handle> --new-pds <url> 7 */ 8 9 + import { webcrypto } from 'node:crypto'; 10 + import { readFileSync, writeFileSync } from 'node:fs'; 11 12 // === ARGUMENT PARSING === 13 14 function parseArgs() { 15 + const args = process.argv.slice(2); 16 const opts = { 17 credentials: null, 18 newHandle: null, 19 newPds: null, 20 + plcUrl: 'https://plc.directory', 21 + }; 22 23 for (let i = 0; i < args.length; i++) { 24 if (args[i] === '--credentials' && args[i + 1]) { 25 + opts.credentials = args[++i]; 26 } else if (args[i] === '--new-handle' && args[i + 1]) { 27 + opts.newHandle = args[++i]; 28 } else if (args[i] === '--new-pds' && args[i + 1]) { 29 + opts.newPds = args[++i]; 30 } else if (args[i] === '--plc-url' && args[i + 1]) { 31 + opts.plcUrl = args[++i]; 32 } 33 } 34 35 if (!opts.credentials || !opts.newHandle || !opts.newPds) { 36 + console.error( 37 + 'Usage: node scripts/update-did.js --credentials <file> --new-handle <handle> --new-pds <url>', 38 + ); 39 + process.exit(1); 40 } 41 42 + return opts; 43 } 44 45 // === CRYPTO HELPERS === 46 47 function hexToBytes(hex) { 48 + const bytes = new Uint8Array(hex.length / 2); 49 for (let i = 0; i < hex.length; i += 2) { 50 + bytes[i / 2] = parseInt(hex.substr(i, 2), 16); 51 } 52 + return bytes; 53 } 54 55 function bytesToHex(bytes) { 56 + return Array.from(bytes) 57 + .map((b) => b.toString(16).padStart(2, '0')) 58 + .join(''); 59 } 60 61 async function importPrivateKey(privateKeyBytes) { 62 const pkcs8Prefix = new Uint8Array([ 63 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 64 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 65 + 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20, 66 + ]); 67 68 + const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32); 69 + pkcs8.set(pkcs8Prefix); 70 + pkcs8.set(privateKeyBytes, pkcs8Prefix.length); 71 72 return webcrypto.subtle.importKey( 73 'pkcs8', 74 pkcs8, 75 { name: 'ECDSA', namedCurve: 'P-256' }, 76 false, 77 + ['sign'], 78 + ); 79 } 80 81 // === CBOR ENCODING === 82 83 function cborEncodeKey(key) { 84 + const bytes = new TextEncoder().encode(key); 85 + const parts = []; 86 + const mt = 3 << 5; 87 if (bytes.length < 24) { 88 + parts.push(mt | bytes.length); 89 } else if (bytes.length < 256) { 90 + parts.push(mt | 24, bytes.length); 91 } 92 + parts.push(...bytes); 93 + return new Uint8Array(parts); 94 } 95 96 function compareBytes(a, b) { 97 + const minLen = Math.min(a.length, b.length); 98 for (let i = 0; i < minLen; i++) { 99 + if (a[i] !== b[i]) return a[i] - b[i]; 100 } 101 + return a.length - b.length; 102 } 103 104 function cborEncode(value) { 105 + const parts = []; 106 107 function encode(val) { 108 if (val === null) { 109 + parts.push(0xf6); 110 } else if (typeof val === 'string') { 111 + const bytes = new TextEncoder().encode(val); 112 + encodeHead(3, bytes.length); 113 + parts.push(...bytes); 114 } else if (typeof val === 'number') { 115 if (Number.isInteger(val) && val >= 0) { 116 + encodeHead(0, val); 117 } 118 } else if (val instanceof Uint8Array) { 119 + encodeHead(2, val.length); 120 + parts.push(...val); 121 } else if (Array.isArray(val)) { 122 + encodeHead(4, val.length); 123 + for (const item of val) encode(item); 124 } else if (typeof val === 'object') { 125 + const keys = Object.keys(val); 126 + const keysSorted = keys.sort((a, b) => 127 + compareBytes(cborEncodeKey(a), cborEncodeKey(b)), 128 + ); 129 + encodeHead(5, keysSorted.length); 130 for (const key of keysSorted) { 131 + encode(key); 132 + encode(val[key]); 133 } 134 } 135 } 136 137 function encodeHead(majorType, length) { 138 + const mt = majorType << 5; 139 if (length < 24) { 140 + parts.push(mt | length); 141 } else if (length < 256) { 142 + parts.push(mt | 24, length); 143 } else if (length < 65536) { 144 + parts.push(mt | 25, length >> 8, length & 0xff); 145 } 146 } 147 148 + encode(value); 149 + return new Uint8Array(parts); 150 } 151 152 // === SIGNING === 153 154 + const P256_N = BigInt( 155 + '0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551', 156 + ); 157 158 function ensureLowS(sig) { 159 + const halfN = P256_N / 2n; 160 + const r = sig.slice(0, 32); 161 + const s = sig.slice(32, 64); 162 + let sInt = BigInt(`0x${bytesToHex(s)}`); 163 164 if (sInt > halfN) { 165 + sInt = P256_N - sInt; 166 + const newS = hexToBytes(sInt.toString(16).padStart(64, '0')); 167 + const result = new Uint8Array(64); 168 + result.set(r); 169 + result.set(newS, 32); 170 + return result; 171 } 172 + return sig; 173 } 174 175 function base64UrlEncode(bytes) { 176 + const binary = String.fromCharCode(...bytes); 177 + return btoa(binary) 178 + .replace(/\+/g, '-') 179 + .replace(/\//g, '_') 180 + .replace(/=+$/, ''); 181 } 182 183 async function signPlcOperation(operation, privateKey) { 184 + const { sig, ...opWithoutSig } = operation; 185 + const encoded = cborEncode(opWithoutSig); 186 187 const signature = await webcrypto.subtle.sign( 188 { name: 'ECDSA', hash: 'SHA-256' }, 189 privateKey, 190 + encoded, 191 + ); 192 193 + const sigBytes = ensureLowS(new Uint8Array(signature)); 194 + return base64UrlEncode(sigBytes); 195 } 196 197 // === MAIN === 198 199 async function main() { 200 + const opts = parseArgs(); 201 202 // Load credentials 203 + const creds = JSON.parse(readFileSync(opts.credentials, 'utf-8')); 204 + console.log(`Updating DID: ${creds.did}`); 205 + console.log(` Old handle: ${creds.handle}`); 206 + console.log(` New handle: ${opts.newHandle}`); 207 + console.log(` New PDS: ${opts.newPds}`); 208 + console.log(''); 209 210 // Fetch current operation log 211 + console.log('Fetching current PLC operation log...'); 212 + const logRes = await fetch(`${opts.plcUrl}/${creds.did}/log/audit`); 213 if (!logRes.ok) { 214 + throw new Error(`Failed to fetch PLC log: ${logRes.status}`); 215 } 216 + const log = await logRes.json(); 217 + const lastOp = log[log.length - 1]; 218 + console.log(` Found ${log.length} operations`); 219 + console.log(` Last CID: ${lastOp.cid}`); 220 + console.log(''); 221 222 // Import private key 223 + const privateKey = await importPrivateKey(hexToBytes(creds.privateKeyHex)); 224 225 // Create new operation 226 const newOp = { ··· 231 services: { 232 atproto_pds: { 233 type: 'AtprotoPersonalDataServer', 234 + endpoint: opts.newPds, 235 + }, 236 }, 237 + prev: lastOp.cid, 238 + }; 239 240 // Sign the operation 241 + console.log('Signing new operation...'); 242 + newOp.sig = await signPlcOperation(newOp, privateKey); 243 244 // Submit to PLC 245 + console.log('Submitting to PLC directory...'); 246 const submitRes = await fetch(`${opts.plcUrl}/${creds.did}`, { 247 method: 'POST', 248 headers: { 'Content-Type': 'application/json' }, 249 + body: JSON.stringify(newOp), 250 + }); 251 252 if (!submitRes.ok) { 253 + const text = await submitRes.text(); 254 + throw new Error(`PLC update failed: ${submitRes.status} ${text}`); 255 } 256 257 + console.log(' Updated successfully!'); 258 + console.log(''); 259 260 // Update credentials file 261 + creds.handle = opts.newHandle; 262 + creds.pdsUrl = opts.newPds; 263 + writeFileSync(opts.credentials, JSON.stringify(creds, null, 2)); 264 + console.log(`Updated credentials file: ${opts.credentials}`); 265 266 // Verify 267 + console.log(''); 268 + console.log('Verifying...'); 269 + const verifyRes = await fetch(`${opts.plcUrl}/${creds.did}`); 270 + const didDoc = await verifyRes.json(); 271 + console.log(` alsoKnownAs: ${didDoc.alsoKnownAs}`); 272 + console.log(` PDS endpoint: ${didDoc.service[0].serviceEndpoint}`); 273 } 274 275 + main().catch((err) => { 276 + console.error('Error:', err.message); 277 + process.exit(1); 278 + });
+1182 -974
src/pds.js
··· 16 17 // === CONSTANTS === 18 // CBOR primitive markers (RFC 8949) 19 - const CBOR_FALSE = 0xf4 20 - const CBOR_TRUE = 0xf5 21 - const CBOR_NULL = 0xf6 22 23 // DAG-CBOR CID link tag 24 - const CBOR_TAG_CID = 42 25 26 // === ERROR HELPER === 27 function errorResponse(error, message, status) { 28 - return Response.json({ error, message }, { status }) 29 } 30 31 // === CRAWLER NOTIFICATION === 32 // Notify relays to come crawl us after writes (like official PDS) 33 - let lastCrawlNotify = 0 34 - const CRAWL_NOTIFY_THRESHOLD = 20 * 60 * 1000 // 20 minutes (matches official PDS) 35 36 async function notifyCrawlers(env, hostname) { 37 - const now = Date.now() 38 if (now - lastCrawlNotify < CRAWL_NOTIFY_THRESHOLD) { 39 - return // Throttle notifications 40 } 41 42 - const relayHost = env.RELAY_HOST 43 - if (!relayHost) return 44 45 - lastCrawlNotify = now 46 47 // Fire and forget - don't block writes on relay notification 48 fetch(`${relayHost}/xrpc/com.atproto.sync.requestCrawl`, { 49 method: 'POST', 50 headers: { 'Content-Type': 'application/json' }, 51 - body: JSON.stringify({ hostname }) 52 - }).catch(err => { 53 - console.log('Failed to notify relay:', err.message) 54 - }) 55 } 56 57 // === CID WRAPPER === ··· 60 class CID { 61 constructor(bytes) { 62 if (!(bytes instanceof Uint8Array)) { 63 - throw new Error('CID must be constructed with Uint8Array') 64 } 65 - this.bytes = bytes 66 } 67 } 68 ··· 76 * @param {number} length - Value or length to encode 77 */ 78 function encodeHead(parts, majorType, length) { 79 - const mt = majorType << 5 80 if (length < 24) { 81 - parts.push(mt | length) 82 } else if (length < 256) { 83 - parts.push(mt | 24, length) 84 } else if (length < 65536) { 85 - parts.push(mt | 25, length >> 8, length & 0xff) 86 } else if (length < 4294967296) { 87 // Use Math.floor instead of bitshift to avoid 32-bit signed integer overflow 88 - parts.push(mt | 26, 89 Math.floor(length / 0x1000000) & 0xff, 90 Math.floor(length / 0x10000) & 0xff, 91 Math.floor(length / 0x100) & 0xff, 92 - length & 0xff) 93 } 94 } 95 ··· 99 * @returns {Uint8Array} CBOR-encoded bytes 100 */ 101 export function cborEncode(value) { 102 - const parts = [] 103 104 function encode(val) { 105 if (val === null) { 106 - parts.push(CBOR_NULL) 107 } else if (val === true) { 108 - parts.push(CBOR_TRUE) 109 } else if (val === false) { 110 - parts.push(CBOR_FALSE) 111 } else if (typeof val === 'number') { 112 - encodeInteger(val) 113 } else if (typeof val === 'string') { 114 - const bytes = new TextEncoder().encode(val) 115 - encodeHead(parts, 3, bytes.length) // major type 3 = text string 116 - parts.push(...bytes) 117 } else if (val instanceof Uint8Array) { 118 - encodeHead(parts, 2, val.length) // major type 2 = byte string 119 - parts.push(...val) 120 } else if (Array.isArray(val)) { 121 - encodeHead(parts, 4, val.length) // major type 4 = array 122 - for (const item of val) encode(item) 123 } else if (typeof val === 'object') { 124 // Sort keys for deterministic encoding 125 - const keys = Object.keys(val).sort() 126 - encodeHead(parts, 5, keys.length) // major type 5 = map 127 for (const key of keys) { 128 - encode(key) 129 - encode(val[key]) 130 } 131 } 132 } 133 134 function encodeInteger(n) { 135 if (n >= 0) { 136 - encodeHead(parts, 0, n) // major type 0 = unsigned int 137 } else { 138 - encodeHead(parts, 1, -n - 1) // major type 1 = negative int 139 } 140 } 141 142 - encode(value) 143 - return new Uint8Array(parts) 144 } 145 146 // DAG-CBOR encoder that handles CIDs with tag 42 147 function cborEncodeDagCbor(value) { 148 - const parts = [] 149 150 function encode(val) { 151 if (val === null) { 152 - parts.push(CBOR_NULL) 153 } else if (val === true) { 154 - parts.push(CBOR_TRUE) 155 } else if (val === false) { 156 - parts.push(CBOR_FALSE) 157 } else if (typeof val === 'number') { 158 if (Number.isInteger(val) && val >= 0) { 159 - encodeHead(parts, 0, val) 160 } else if (Number.isInteger(val) && val < 0) { 161 - encodeHead(parts, 1, -val - 1) 162 } 163 } else if (typeof val === 'string') { 164 - const bytes = new TextEncoder().encode(val) 165 - encodeHead(parts, 3, bytes.length) 166 - parts.push(...bytes) 167 } else if (val instanceof CID) { 168 // CID links in DAG-CBOR use tag 42 + 0x00 multibase prefix 169 // The 0x00 prefix indicates "identity" multibase (raw bytes) 170 - parts.push(0xd8, CBOR_TAG_CID) 171 - encodeHead(parts, 2, val.bytes.length + 1) // +1 for 0x00 prefix 172 - parts.push(0x00) 173 - parts.push(...val.bytes) 174 } else if (val instanceof Uint8Array) { 175 // Regular byte string 176 - encodeHead(parts, 2, val.length) 177 - parts.push(...val) 178 } else if (Array.isArray(val)) { 179 - encodeHead(parts, 4, val.length) 180 - for (const item of val) encode(item) 181 } else if (typeof val === 'object') { 182 // DAG-CBOR: sort keys by length first, then lexicographically 183 // (differs from standard CBOR which sorts lexicographically only) 184 - const keys = Object.keys(val).filter(k => val[k] !== undefined) 185 keys.sort((a, b) => { 186 - if (a.length !== b.length) return a.length - b.length 187 - return a < b ? -1 : a > b ? 1 : 0 188 - }) 189 - encodeHead(parts, 5, keys.length) 190 for (const key of keys) { 191 - const keyBytes = new TextEncoder().encode(key) 192 - encodeHead(parts, 3, keyBytes.length) 193 - parts.push(...keyBytes) 194 - encode(val[key]) 195 } 196 } 197 } 198 199 - encode(value) 200 - return new Uint8Array(parts) 201 } 202 203 /** ··· 206 * @returns {*} Decoded value 207 */ 208 export function cborDecode(bytes) { 209 - let offset = 0 210 211 function read() { 212 - const initial = bytes[offset++] 213 - const major = initial >> 5 214 - const info = initial & 0x1f 215 216 - let length = info 217 - if (info === 24) length = bytes[offset++] 218 - else if (info === 25) { length = (bytes[offset++] << 8) | bytes[offset++] } 219 - else if (info === 26) { 220 // Use multiplication instead of bitshift to avoid 32-bit signed integer overflow 221 - length = bytes[offset++] * 0x1000000 + bytes[offset++] * 0x10000 + bytes[offset++] * 0x100 + bytes[offset++] 222 } 223 224 switch (major) { 225 - case 0: return length // unsigned int 226 - case 1: return -1 - length // negative int 227 - case 2: { // byte string 228 - const data = bytes.slice(offset, offset + length) 229 - offset += length 230 - return data 231 } 232 - case 3: { // text string 233 - const data = new TextDecoder().decode(bytes.slice(offset, offset + length)) 234 - offset += length 235 - return data 236 } 237 - case 4: { // array 238 - const arr = [] 239 - for (let i = 0; i < length; i++) arr.push(read()) 240 - return arr 241 } 242 - case 5: { // map 243 - const obj = {} 244 for (let i = 0; i < length; i++) { 245 - const key = read() 246 - obj[key] = read() 247 } 248 - return obj 249 } 250 - case 6: { // tag 251 // length is the tag number 252 - const taggedValue = read() 253 if (length === CBOR_TAG_CID) { 254 // CID link: byte string with 0x00 multibase prefix, return raw CID bytes 255 - return taggedValue.slice(1) // strip 0x00 prefix 256 } 257 - return taggedValue 258 } 259 - case 7: { // special 260 - if (info === 20) return false 261 - if (info === 21) return true 262 - if (info === 22) return null 263 - return undefined 264 } 265 } 266 } 267 268 - return read() 269 } 270 271 // === CID GENERATION === ··· 277 * @returns {Promise<Uint8Array>} CID bytes (36 bytes: version + codec + multihash) 278 */ 279 export async function createCid(bytes) { 280 - const hash = await crypto.subtle.digest('SHA-256', bytes) 281 - const hashBytes = new Uint8Array(hash) 282 283 // CIDv1: version(1) + codec(dag-cbor=0x71) + multihash(sha256) 284 // Multihash: hash-type(0x12) + length(0x20=32) + digest 285 - const cid = new Uint8Array(2 + 2 + 32) 286 - cid[0] = 0x01 // CIDv1 287 - cid[1] = 0x71 // dag-cbor codec 288 - cid[2] = 0x12 // sha-256 289 - cid[3] = 0x20 // 32 bytes 290 - cid.set(hashBytes, 4) 291 292 - return cid 293 } 294 295 /** ··· 299 */ 300 export function cidToString(cid) { 301 // base32lower encoding for CIDv1 302 - return 'b' + base32Encode(cid) 303 } 304 305 /** ··· 308 * @returns {string} Base32lower-encoded string 309 */ 310 export function base32Encode(bytes) { 311 - const alphabet = 'abcdefghijklmnopqrstuvwxyz234567' 312 - let result = '' 313 - let bits = 0 314 - let value = 0 315 316 for (const byte of bytes) { 317 - value = (value << 8) | byte 318 - bits += 8 319 while (bits >= 5) { 320 - bits -= 5 321 - result += alphabet[(value >> bits) & 31] 322 } 323 } 324 325 if (bits > 0) { 326 - result += alphabet[(value << (5 - bits)) & 31] 327 } 328 329 - return result 330 } 331 332 // === TID GENERATION === 333 // Timestamp-based IDs: base32-sort encoded microseconds + clock ID 334 335 - const TID_CHARS = '234567abcdefghijklmnopqrstuvwxyz' 336 - let lastTimestamp = 0 337 - let clockId = Math.floor(Math.random() * 1024) 338 339 /** 340 * Generate a timestamp-based ID (TID) for record keys ··· 342 * @returns {string} 13-character base32-sort encoded TID 343 */ 344 export function createTid() { 345 - let timestamp = Date.now() * 1000 // microseconds 346 347 // Ensure monotonic 348 if (timestamp <= lastTimestamp) { 349 - timestamp = lastTimestamp + 1 350 } 351 - lastTimestamp = timestamp 352 353 // 13 chars: 11 for timestamp (64 bits but only ~53 used), 2 for clock ID 354 - let tid = '' 355 356 // Encode timestamp (high bits first for sortability) 357 - let ts = timestamp 358 for (let i = 0; i < 11; i++) { 359 - tid = TID_CHARS[ts & 31] + tid 360 - ts = Math.floor(ts / 32) 361 } 362 363 // Append clock ID (2 chars) 364 - tid += TID_CHARS[(clockId >> 5) & 31] 365 - tid += TID_CHARS[clockId & 31] 366 367 - return tid 368 } 369 370 // === P-256 SIGNING === ··· 377 */ 378 export async function importPrivateKey(privateKeyBytes) { 379 // Validate private key length (P-256 requires exactly 32 bytes) 380 - if (!(privateKeyBytes instanceof Uint8Array) || privateKeyBytes.length !== 32) { 381 - throw new Error(`Invalid private key: expected 32 bytes, got ${privateKeyBytes?.length ?? 'non-Uint8Array'}`) 382 } 383 384 // PKCS#8 wrapper for raw P-256 private key 385 const pkcs8Prefix = new Uint8Array([ 386 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 387 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 388 - 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20 389 - ]) 390 391 - const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32) 392 - pkcs8.set(pkcs8Prefix) 393 - pkcs8.set(privateKeyBytes, pkcs8Prefix.length) 394 395 return crypto.subtle.importKey( 396 'pkcs8', 397 pkcs8, 398 { name: 'ECDSA', namedCurve: 'P-256' }, 399 false, 400 - ['sign'] 401 - ) 402 } 403 404 // P-256 curve order N 405 - const P256_N = BigInt('0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551') 406 - const P256_N_DIV_2 = P256_N / 2n 407 408 function bytesToBigInt(bytes) { 409 - let result = 0n 410 for (const byte of bytes) { 411 - result = (result << 8n) | BigInt(byte) 412 } 413 - return result 414 } 415 416 function bigIntToBytes(n, length) { 417 - const bytes = new Uint8Array(length) 418 for (let i = length - 1; i >= 0; i--) { 419 - bytes[i] = Number(n & 0xffn) 420 - n >>= 8n 421 } 422 - return bytes 423 } 424 425 /** ··· 432 const signature = await crypto.subtle.sign( 433 { name: 'ECDSA', hash: 'SHA-256' }, 434 privateKey, 435 - data 436 - ) 437 - const sig = new Uint8Array(signature) 438 439 - const r = sig.slice(0, 32) 440 - const s = sig.slice(32, 64) 441 - const sBigInt = bytesToBigInt(s) 442 443 // Low-S normalization: Bitcoin/ATProto require S <= N/2 to prevent 444 // signature malleability (two valid signatures for same message) 445 if (sBigInt > P256_N_DIV_2) { 446 - const newS = P256_N - sBigInt 447 - const newSBytes = bigIntToBytes(newS, 32) 448 - const normalized = new Uint8Array(64) 449 - normalized.set(r, 0) 450 - normalized.set(newSBytes, 32) 451 - return normalized 452 } 453 454 - return sig 455 } 456 457 /** ··· 462 const keyPair = await crypto.subtle.generateKey( 463 { name: 'ECDSA', namedCurve: 'P-256' }, 464 true, 465 - ['sign', 'verify'] 466 - ) 467 468 // Export private key as raw bytes 469 - const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey) 470 - const privateBytes = base64UrlDecode(privateJwk.d) 471 472 // Export public key as compressed point 473 - const publicRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey) 474 - const publicBytes = new Uint8Array(publicRaw) 475 - const compressed = compressPublicKey(publicBytes) 476 477 - return { privateKey: privateBytes, publicKey: compressed } 478 } 479 480 function compressPublicKey(uncompressed) { 481 // uncompressed is 65 bytes: 0x04 + x(32) + y(32) 482 // compressed is 33 bytes: prefix(02 or 03) + x(32) 483 - const x = uncompressed.slice(1, 33) 484 - const y = uncompressed.slice(33, 65) 485 - const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03 486 - const compressed = new Uint8Array(33) 487 - compressed[0] = prefix 488 - compressed.set(x, 1) 489 - return compressed 490 } 491 492 /** ··· 495 * @returns {string} Base64url-encoded string 496 */ 497 export function base64UrlEncode(bytes) { 498 - let binary = '' 499 for (const byte of bytes) { 500 - binary += String.fromCharCode(byte) 501 } 502 - const base64 = btoa(binary) 503 - return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') 504 } 505 506 /** ··· 509 * @returns {Uint8Array} Decoded bytes 510 */ 511 export function base64UrlDecode(str) { 512 - const base64 = str.replace(/-/g, '+').replace(/_/g, '/') 513 - const pad = base64.length % 4 514 - const padded = pad ? base64 + '='.repeat(4 - pad) : base64 515 - const binary = atob(padded) 516 - const bytes = new Uint8Array(binary.length) 517 for (let i = 0; i < binary.length; i++) { 518 - bytes[i] = binary.charCodeAt(i) 519 } 520 - return bytes 521 } 522 523 /** ··· 532 new TextEncoder().encode(secret), 533 { name: 'HMAC', hash: 'SHA-256' }, 534 false, 535 - ['sign'] 536 - ) 537 - const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data)) 538 - return base64UrlEncode(new Uint8Array(sig)) 539 } 540 541 /** ··· 546 * @returns {Promise<string>} Signed JWT 547 */ 548 export async function createAccessJwt(did, secret, expiresIn = 7200) { 549 - const header = { typ: 'at+jwt', alg: 'HS256' } 550 - const now = Math.floor(Date.now() / 1000) 551 const payload = { 552 scope: 'com.atproto.access', 553 sub: did, 554 aud: did, 555 iat: now, 556 - exp: now + expiresIn 557 - } 558 559 - const headerB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header))) 560 - const payloadB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload))) 561 - const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret) 562 563 - return `${headerB64}.${payloadB64}.${signature}` 564 } 565 566 /** ··· 571 * @returns {Promise<string>} Signed JWT 572 */ 573 export async function createRefreshJwt(did, secret, expiresIn = 7776000) { 574 - const header = { typ: 'refresh+jwt', alg: 'HS256' } 575 - const now = Math.floor(Date.now() / 1000) 576 // Generate random jti (token ID) 577 - const jtiBytes = new Uint8Array(32) 578 - crypto.getRandomValues(jtiBytes) 579 - const jti = base64UrlEncode(jtiBytes) 580 581 const payload = { 582 scope: 'com.atproto.refresh', ··· 584 aud: did, 585 jti, 586 iat: now, 587 - exp: now + expiresIn 588 - } 589 590 - const headerB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header))) 591 - const payloadB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload))) 592 - const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret) 593 594 - return `${headerB64}.${payloadB64}.${signature}` 595 } 596 597 /** ··· 602 * @throws {Error} If token is invalid, expired, or wrong type 603 */ 604 export async function verifyAccessJwt(jwt, secret) { 605 - const parts = jwt.split('.') 606 if (parts.length !== 3) { 607 - throw new Error('Invalid JWT format') 608 } 609 610 - const [headerB64, payloadB64, signatureB64] = parts 611 612 // Verify signature 613 - const expectedSig = await hmacSign(`${headerB64}.${payloadB64}`, secret) 614 if (signatureB64 !== expectedSig) { 615 - throw new Error('Invalid signature') 616 } 617 618 // Decode header and payload 619 - const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(headerB64))) 620 - const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64))) 621 622 // Check token type 623 if (header.typ !== 'at+jwt') { 624 - throw new Error('Invalid token type: expected access token') 625 } 626 627 // Check expiration 628 - const now = Math.floor(Date.now() / 1000) 629 if (payload.exp && payload.exp < now) { 630 - throw new Error('Token expired') 631 } 632 633 - return payload 634 } 635 636 /** ··· 644 * @returns {Promise<string>} Signed JWT 645 */ 646 export async function createServiceJwt({ iss, aud, lxm, signingKey }) { 647 - const header = { typ: 'JWT', alg: 'ES256' } 648 - const now = Math.floor(Date.now() / 1000) 649 650 // Generate random jti 651 - const jtiBytes = new Uint8Array(16) 652 - crypto.getRandomValues(jtiBytes) 653 - const jti = bytesToHex(jtiBytes) 654 655 const payload = { 656 iss, 657 aud, 658 exp: now + 60, // 1 minute expiration 659 iat: now, 660 - jti 661 - } 662 - if (lxm) payload.lxm = lxm 663 664 - const headerB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header))) 665 - const payloadB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload))) 666 - const toSign = new TextEncoder().encode(`${headerB64}.${payloadB64}`) 667 668 - const sig = await sign(signingKey, toSign) 669 - const sigB64 = base64UrlEncode(sig) 670 671 - return `${headerB64}.${payloadB64}.${sigB64}` 672 } 673 674 /** ··· 677 * @returns {string} Hex string 678 */ 679 export function bytesToHex(bytes) { 680 - return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') 681 } 682 683 /** ··· 686 * @returns {Uint8Array} Decoded bytes 687 */ 688 export function hexToBytes(hex) { 689 - const bytes = new Uint8Array(hex.length / 2) 690 for (let i = 0; i < hex.length; i += 2) { 691 - bytes[i / 2] = parseInt(hex.substr(i, 2), 16) 692 } 693 - return bytes 694 } 695 696 // === MERKLE SEARCH TREE === 697 // ATProto-compliant MST implementation 698 699 async function sha256(data) { 700 - const hash = await crypto.subtle.digest('SHA-256', data) 701 - return new Uint8Array(hash) 702 } 703 704 // Cache for key depths (SHA-256 is expensive) 705 - const keyDepthCache = new Map() 706 707 /** 708 * Get MST tree depth for a key based on leading zeros in SHA-256 hash ··· 711 */ 712 export async function getKeyDepth(key) { 713 // Count leading zeros in SHA-256 hash, divide by 2 714 - if (keyDepthCache.has(key)) return keyDepthCache.get(key) 715 716 - const keyBytes = new TextEncoder().encode(key) 717 - const hash = await sha256(keyBytes) 718 719 - let zeros = 0 720 for (const byte of hash) { 721 if (byte === 0) { 722 - zeros += 8 723 } else { 724 // Count leading zeros in this byte 725 for (let i = 7; i >= 0; i--) { 726 - if ((byte >> i) & 1) break 727 - zeros++ 728 } 729 - break 730 } 731 } 732 733 // MST depth = leading zeros in SHA-256 hash / 2 734 // This creates a probabilistic tree where ~50% of keys are at depth 0, 735 // ~25% at depth 1, etc., giving O(log n) lookups 736 - const depth = Math.floor(zeros / 2) 737 - keyDepthCache.set(key, depth) 738 - return depth 739 } 740 741 // Compute common prefix length between two byte arrays 742 function commonPrefixLen(a, b) { 743 - const minLen = Math.min(a.length, b.length) 744 for (let i = 0; i < minLen; i++) { 745 - if (a[i] !== b[i]) return i 746 } 747 - return minLen 748 } 749 750 class MST { 751 constructor(sql) { 752 - this.sql = sql 753 } 754 755 async computeRoot() { 756 - const records = this.sql.exec(` 757 SELECT collection, rkey, cid FROM records ORDER BY collection, rkey 758 - `).toArray() 759 760 if (records.length === 0) { 761 - return null 762 } 763 764 // Build entries with pre-computed depths (heights) 765 // In ATProto MST, "height" determines which layer a key belongs to 766 // Layer 0 is at the BOTTOM, root is at the highest layer 767 - const entries = [] 768 - let maxDepth = 0 769 for (const r of records) { 770 - const key = `${r.collection}/${r.rkey}` 771 - const depth = await getKeyDepth(key) 772 - maxDepth = Math.max(maxDepth, depth) 773 entries.push({ 774 key, 775 keyBytes: new TextEncoder().encode(key), 776 cid: r.cid, 777 - depth 778 - }) 779 } 780 781 // Start building from the root (highest layer) going down to layer 0 782 - return this.buildTree(entries, maxDepth) 783 } 784 785 async buildTree(entries, layer) { 786 - if (entries.length === 0) return null 787 788 // Separate entries for this layer vs lower layers (subtrees) 789 // Keys with depth == layer stay at this node 790 // Keys with depth < layer go into subtrees (going down toward layer 0) 791 - const thisLayer = [] 792 - let leftSubtree = [] 793 794 for (const entry of entries) { 795 if (entry.depth < layer) { 796 // This entry belongs to a lower layer - accumulate for subtree 797 - leftSubtree.push(entry) 798 } else { 799 // This entry belongs at current layer (depth == layer) 800 // Process accumulated left subtree first 801 if (leftSubtree.length > 0) { 802 - const leftCid = await this.buildTree(leftSubtree, layer - 1) 803 - thisLayer.push({ type: 'subtree', cid: leftCid }) 804 - leftSubtree = [] 805 } 806 - thisLayer.push({ type: 'entry', entry }) 807 } 808 } 809 810 // Handle remaining left subtree 811 if (leftSubtree.length > 0) { 812 - const leftCid = await this.buildTree(leftSubtree, layer - 1) 813 - thisLayer.push({ type: 'subtree', cid: leftCid }) 814 } 815 816 // Build node with proper ATProto format 817 - const node = { e: [] } 818 - let leftCid = null 819 - let prevKeyBytes = new Uint8Array(0) 820 821 for (let i = 0; i < thisLayer.length; i++) { 822 - const item = thisLayer[i] 823 824 if (item.type === 'subtree') { 825 if (node.e.length === 0) { 826 - leftCid = item.cid 827 } else { 828 // Attach to previous entry's 't' field 829 - node.e[node.e.length - 1].t = new CID(cidToBytes(item.cid)) 830 } 831 } else { 832 // Entry - compute prefix compression 833 - const keyBytes = item.entry.keyBytes 834 - const prefixLen = commonPrefixLen(prevKeyBytes, keyBytes) 835 - const keySuffix = keyBytes.slice(prefixLen) 836 837 // ATProto requires t field to be present (can be null) 838 const e = { 839 p: prefixLen, 840 k: keySuffix, 841 v: new CID(cidToBytes(item.entry.cid)), 842 - t: null // Will be updated if there's a subtree 843 - } 844 845 - node.e.push(e) 846 - prevKeyBytes = keyBytes 847 } 848 } 849 850 // ATProto requires l field to be present (can be null) 851 - node.l = leftCid ? new CID(cidToBytes(leftCid)) : null 852 853 // Encode node with proper MST CBOR format 854 - const nodeBytes = cborEncodeDagCbor(node) 855 - const nodeCid = await createCid(nodeBytes) 856 - const cidStr = cidToString(nodeCid) 857 858 this.sql.exec( 859 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, 860 cidStr, 861 - nodeBytes 862 - ) 863 864 - return cidStr 865 } 866 } 867 ··· 873 * @returns {Uint8Array} Varint-encoded bytes 874 */ 875 export function varint(n) { 876 - const bytes = [] 877 while (n >= 0x80) { 878 - bytes.push((n & 0x7f) | 0x80) 879 - n >>>= 7 880 } 881 - bytes.push(n) 882 - return new Uint8Array(bytes) 883 } 884 885 /** ··· 889 */ 890 export function cidToBytes(cidStr) { 891 // Decode base32lower CID string to bytes 892 - if (!cidStr.startsWith('b')) throw new Error('expected base32lower CID') 893 - return base32Decode(cidStr.slice(1)) 894 } 895 896 /** ··· 899 * @returns {Uint8Array} Decoded bytes 900 */ 901 export function base32Decode(str) { 902 - const alphabet = 'abcdefghijklmnopqrstuvwxyz234567' 903 - let bits = 0 904 - let value = 0 905 - const output = [] 906 907 for (const char of str) { 908 - const idx = alphabet.indexOf(char) 909 - if (idx === -1) continue 910 - value = (value << 5) | idx 911 - bits += 5 912 if (bits >= 8) { 913 - bits -= 8 914 - output.push((value >> bits) & 0xff) 915 } 916 } 917 918 - return new Uint8Array(output) 919 } 920 921 /** ··· 925 * @returns {Uint8Array} CAR file bytes 926 */ 927 export function buildCarFile(rootCid, blocks) { 928 - const parts = [] 929 930 // Header: { version: 1, roots: [rootCid] } 931 - const rootCidBytes = cidToBytes(rootCid) 932 - const header = cborEncodeDagCbor({ version: 1, roots: [new CID(rootCidBytes)] }) 933 - parts.push(varint(header.length)) 934 - parts.push(header) 935 936 // Blocks: varint(len) + cid + data 937 for (const block of blocks) { 938 - const cidBytes = cidToBytes(block.cid) 939 - const blockLen = cidBytes.length + block.data.length 940 - parts.push(varint(blockLen)) 941 - parts.push(cidBytes) 942 - parts.push(block.data) 943 } 944 945 // Concatenate all parts 946 - const totalLen = parts.reduce((sum, p) => sum + p.length, 0) 947 - const car = new Uint8Array(totalLen) 948 - let offset = 0 949 for (const part of parts) { 950 - car.set(part, offset) 951 - offset += part.length 952 } 953 954 - return car 955 } 956 957 /** ··· 972 /** @type {Record<string, Route>} */ 973 const pdsRoutes = { 974 '/.well-known/atproto-did': { 975 - handler: (pds, req, url) => pds.handleAtprotoDid() 976 }, 977 '/init': { 978 method: 'POST', 979 - handler: (pds, req, url) => pds.handleInit(req) 980 }, 981 '/status': { 982 - handler: (pds, req, url) => pds.handleStatus() 983 }, 984 '/reset-repo': { 985 - handler: (pds, req, url) => pds.handleResetRepo() 986 }, 987 '/forward-event': { 988 - handler: (pds, req, url) => pds.handleForwardEvent(req) 989 }, 990 '/register-did': { 991 - handler: (pds, req, url) => pds.handleRegisterDid(req) 992 }, 993 '/get-registered-dids': { 994 - handler: (pds, req, url) => pds.handleGetRegisteredDids() 995 }, 996 '/register-handle': { 997 method: 'POST', 998 - handler: (pds, req, url) => pds.handleRegisterHandle(req) 999 }, 1000 '/resolve-handle': { 1001 - handler: (pds, req, url) => pds.handleResolveHandle(url) 1002 }, 1003 '/repo-info': { 1004 - handler: (pds, req, url) => pds.handleRepoInfo() 1005 }, 1006 '/xrpc/com.atproto.server.describeServer': { 1007 - handler: (pds, req, url) => pds.handleDescribeServer(req) 1008 }, 1009 '/xrpc/com.atproto.server.createSession': { 1010 method: 'POST', 1011 - handler: (pds, req, url) => pds.handleCreateSession(req) 1012 }, 1013 '/xrpc/com.atproto.server.getSession': { 1014 - handler: (pds, req, url) => pds.handleGetSession(req) 1015 }, 1016 '/xrpc/app.bsky.actor.getPreferences': { 1017 - handler: (pds, req, url) => pds.handleGetPreferences(req) 1018 }, 1019 '/xrpc/app.bsky.actor.putPreferences': { 1020 method: 'POST', 1021 - handler: (pds, req, url) => pds.handlePutPreferences(req) 1022 }, 1023 '/xrpc/com.atproto.sync.listRepos': { 1024 - handler: (pds, req, url) => pds.handleListRepos() 1025 }, 1026 '/xrpc/com.atproto.repo.createRecord': { 1027 method: 'POST', 1028 - handler: (pds, req, url) => pds.handleCreateRecord(req) 1029 }, 1030 '/xrpc/com.atproto.repo.deleteRecord': { 1031 method: 'POST', 1032 - handler: (pds, req, url) => pds.handleDeleteRecord(req) 1033 }, 1034 '/xrpc/com.atproto.repo.putRecord': { 1035 method: 'POST', 1036 - handler: (pds, req, url) => pds.handlePutRecord(req) 1037 }, 1038 '/xrpc/com.atproto.repo.applyWrites': { 1039 method: 'POST', 1040 - handler: (pds, req, url) => pds.handleApplyWrites(req) 1041 }, 1042 '/xrpc/com.atproto.repo.getRecord': { 1043 - handler: (pds, req, url) => pds.handleGetRecord(url) 1044 }, 1045 '/xrpc/com.atproto.repo.describeRepo': { 1046 - handler: (pds, req, url) => pds.handleDescribeRepo() 1047 }, 1048 '/xrpc/com.atproto.repo.listRecords': { 1049 - handler: (pds, req, url) => pds.handleListRecords(url) 1050 }, 1051 '/xrpc/com.atproto.sync.getLatestCommit': { 1052 - handler: (pds, req, url) => pds.handleGetLatestCommit() 1053 }, 1054 '/xrpc/com.atproto.sync.getRepoStatus': { 1055 - handler: (pds, req, url) => pds.handleGetRepoStatus() 1056 }, 1057 '/xrpc/com.atproto.sync.getRepo': { 1058 - handler: (pds, req, url) => pds.handleGetRepo() 1059 }, 1060 '/xrpc/com.atproto.sync.getRecord': { 1061 - handler: (pds, req, url) => pds.handleSyncGetRecord(url) 1062 }, 1063 '/xrpc/com.atproto.sync.subscribeRepos': { 1064 - handler: (pds, req, url) => pds.handleSubscribeRepos(req, url) 1065 - } 1066 - } 1067 1068 export class PersonalDataServer { 1069 constructor(state, env) { 1070 - this.state = state 1071 - this.sql = state.storage.sql 1072 - this.env = env 1073 1074 // Initialize schema 1075 this.sql.exec(` ··· 1101 ); 1102 1103 CREATE INDEX IF NOT EXISTS idx_records_collection ON records(collection, rkey); 1104 - `) 1105 } 1106 1107 async initIdentity(did, privateKeyHex, handle = null) { 1108 - await this.state.storage.put('did', did) 1109 - await this.state.storage.put('privateKey', privateKeyHex) 1110 if (handle) { 1111 - await this.state.storage.put('handle', handle) 1112 } 1113 } 1114 1115 async getDid() { 1116 if (!this._did) { 1117 - this._did = await this.state.storage.get('did') 1118 } 1119 - return this._did 1120 } 1121 1122 async getHandle() { 1123 - return this.state.storage.get('handle') 1124 } 1125 1126 async getSigningKey() { 1127 - const hex = await this.state.storage.get('privateKey') 1128 - if (!hex) return null 1129 - return importPrivateKey(hexToBytes(hex)) 1130 } 1131 1132 // Collect MST node blocks for a given root CID 1133 collectMstBlocks(rootCidStr) { 1134 - const blocks = [] 1135 - const visited = new Set() 1136 1137 const collect = (cidStr) => { 1138 - if (visited.has(cidStr)) return 1139 - visited.add(cidStr) 1140 1141 - const rows = this.sql.exec( 1142 - `SELECT data FROM blocks WHERE cid = ?`, cidStr 1143 - ).toArray() 1144 - if (rows.length === 0) return 1145 1146 - const data = new Uint8Array(rows[0].data) 1147 - blocks.push({ cid: cidStr, data }) // Keep as string, buildCarFile will convert 1148 1149 // Decode and follow child CIDs (MST nodes have 'l' and 'e' with 't' subtrees) 1150 try { 1151 - const node = cborDecode(data) 1152 - if (node.l) collect(cidToString(node.l)) 1153 if (node.e) { 1154 for (const entry of node.e) { 1155 - if (entry.t) collect(cidToString(entry.t)) 1156 } 1157 } 1158 - } catch (e) { 1159 // Not an MST node, ignore 1160 } 1161 - } 1162 1163 - collect(rootCidStr) 1164 - return blocks 1165 } 1166 1167 async createRecord(collection, record, rkey = null) { 1168 - const did = await this.getDid() 1169 - if (!did) throw new Error('PDS not initialized') 1170 1171 - rkey = rkey || createTid() 1172 - const uri = `at://${did}/${collection}/${rkey}` 1173 1174 // Encode and hash record (must use DAG-CBOR for proper key ordering) 1175 - const recordBytes = cborEncodeDagCbor(record) 1176 - const recordCid = await createCid(recordBytes) 1177 - const recordCidStr = cidToString(recordCid) 1178 1179 // Store block 1180 this.sql.exec( 1181 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, 1182 - recordCidStr, recordBytes 1183 - ) 1184 1185 // Store record index 1186 this.sql.exec( 1187 `INSERT OR REPLACE INTO records (uri, cid, collection, rkey, value) VALUES (?, ?, ?, ?, ?)`, 1188 - uri, recordCidStr, collection, rkey, recordBytes 1189 - ) 1190 1191 // Rebuild MST 1192 - const mst = new MST(this.sql) 1193 - const dataRoot = await mst.computeRoot() 1194 1195 // Get previous commit 1196 - const prevCommits = this.sql.exec( 1197 - `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1` 1198 - ).toArray() 1199 - const prevCommit = prevCommits.length > 0 ? prevCommits[0] : null 1200 1201 // Create commit 1202 - const rev = createTid() 1203 // Build commit with CIDs wrapped in CID class (for dag-cbor tag 42 encoding) 1204 const commit = { 1205 did, 1206 version: 3, 1207 - data: new CID(cidToBytes(dataRoot)), // CID wrapped for explicit encoding 1208 rev, 1209 - prev: prevCommit?.cid ? new CID(cidToBytes(prevCommit.cid)) : null 1210 - } 1211 1212 // Sign commit (using dag-cbor encoder for CIDs) 1213 - const commitBytes = cborEncodeDagCbor(commit) 1214 - const signingKey = await this.getSigningKey() 1215 - const sig = await sign(signingKey, commitBytes) 1216 1217 - const signedCommit = { ...commit, sig } 1218 - const signedBytes = cborEncodeDagCbor(signedCommit) 1219 - const commitCid = await createCid(signedBytes) 1220 - const commitCidStr = cidToString(commitCid) 1221 1222 // Store commit block 1223 this.sql.exec( 1224 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, 1225 - commitCidStr, signedBytes 1226 - ) 1227 1228 // Store commit reference 1229 this.sql.exec( 1230 `INSERT INTO commits (cid, rev, prev) VALUES (?, ?, ?)`, 1231 - commitCidStr, rev, prevCommit?.cid || null 1232 - ) 1233 1234 // Update head and rev for listRepos 1235 - await this.state.storage.put('head', commitCidStr) 1236 - await this.state.storage.put('rev', rev) 1237 1238 // Collect blocks for the event (record + commit + MST nodes) 1239 // Build a mini CAR with just the new blocks - use string CIDs 1240 - const newBlocks = [] 1241 // Add record block 1242 - newBlocks.push({ cid: recordCidStr, data: recordBytes }) 1243 // Add commit block 1244 - newBlocks.push({ cid: commitCidStr, data: signedBytes }) 1245 // Add MST node blocks (get all blocks referenced by commit.data) 1246 - const mstBlocks = this.collectMstBlocks(dataRoot) 1247 - newBlocks.push(...mstBlocks) 1248 1249 // Sequence event with blocks - store complete event data including rev and time 1250 // blocks must be a full CAR file with header (roots = [commitCid]) 1251 - const eventTime = new Date().toISOString() 1252 const evt = cborEncode({ 1253 - ops: [{ action: 'create', path: `${collection}/${rkey}`, cid: recordCidStr }], 1254 - blocks: buildCarFile(commitCidStr, newBlocks), // Full CAR with header 1255 - rev, // Store the actual commit revision 1256 - time: eventTime // Store the actual event time 1257 - }) 1258 this.sql.exec( 1259 `INSERT INTO seq_events (did, commit_cid, evt) VALUES (?, ?, ?)`, 1260 - did, commitCidStr, evt 1261 - ) 1262 1263 // Broadcast to subscribers (both local and via default DO for relay) 1264 - const evtRows = this.sql.exec( 1265 - `SELECT * FROM seq_events ORDER BY seq DESC LIMIT 1` 1266 - ).toArray() 1267 if (evtRows.length > 0) { 1268 - this.broadcastEvent(evtRows[0]) 1269 // Also forward to default DO for relay subscribers 1270 if (this.env?.PDS) { 1271 - const defaultId = this.env.PDS.idFromName('default') 1272 - const defaultPds = this.env.PDS.get(defaultId) 1273 // Convert ArrayBuffer to array for JSON serialization 1274 - const row = evtRows[0] 1275 - const evtArray = Array.from(new Uint8Array(row.evt)) 1276 // Fire and forget but log errors 1277 - defaultPds.fetch(new Request('http://internal/forward-event', { 1278 - method: 'POST', 1279 - body: JSON.stringify({ ...row, evt: evtArray }) 1280 - })).then(r => r.json()).then(r => console.log('forward result:', r)).catch(e => console.log('forward error:', e)) 1281 } 1282 } 1283 1284 - return { uri, cid: recordCidStr, commit: commitCidStr } 1285 } 1286 1287 async deleteRecord(collection, rkey) { 1288 - const did = await this.getDid() 1289 - if (!did) throw new Error('PDS not initialized') 1290 1291 - const uri = `at://${did}/${collection}/${rkey}` 1292 1293 // Check if record exists 1294 - const existing = this.sql.exec( 1295 - `SELECT cid FROM records WHERE uri = ?`, uri 1296 - ).toArray() 1297 if (existing.length === 0) { 1298 - return { error: 'RecordNotFound', message: 'record not found' } 1299 } 1300 1301 // Delete from records table 1302 - this.sql.exec(`DELETE FROM records WHERE uri = ?`, uri) 1303 1304 // Rebuild MST 1305 - const mst = new MST(this.sql) 1306 - const dataRoot = await mst.computeRoot() 1307 1308 // Get previous commit 1309 - const prevCommits = this.sql.exec( 1310 - `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1` 1311 - ).toArray() 1312 - const prevCommit = prevCommits.length > 0 ? prevCommits[0] : null 1313 1314 // Create commit 1315 - const rev = createTid() 1316 const commit = { 1317 did, 1318 version: 3, 1319 data: dataRoot ? new CID(cidToBytes(dataRoot)) : null, 1320 rev, 1321 - prev: prevCommit?.cid ? new CID(cidToBytes(prevCommit.cid)) : null 1322 - } 1323 1324 // Sign commit 1325 - const commitBytes = cborEncodeDagCbor(commit) 1326 - const signingKey = await this.getSigningKey() 1327 - const sig = await sign(signingKey, commitBytes) 1328 1329 - const signedCommit = { ...commit, sig } 1330 - const signedBytes = cborEncodeDagCbor(signedCommit) 1331 - const commitCid = await createCid(signedBytes) 1332 - const commitCidStr = cidToString(commitCid) 1333 1334 // Store commit block 1335 this.sql.exec( 1336 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, 1337 - commitCidStr, signedBytes 1338 - ) 1339 1340 // Store commit reference 1341 this.sql.exec( 1342 `INSERT INTO commits (cid, rev, prev) VALUES (?, ?, ?)`, 1343 - commitCidStr, rev, prevCommit?.cid || null 1344 - ) 1345 1346 // Update head and rev 1347 - await this.state.storage.put('head', commitCidStr) 1348 - await this.state.storage.put('rev', rev) 1349 1350 // Collect blocks for the event (commit + MST nodes, no record block) 1351 - const newBlocks = [] 1352 - newBlocks.push({ cid: commitCidStr, data: signedBytes }) 1353 if (dataRoot) { 1354 - const mstBlocks = this.collectMstBlocks(dataRoot) 1355 - newBlocks.push(...mstBlocks) 1356 } 1357 1358 // Sequence event with delete action 1359 - const eventTime = new Date().toISOString() 1360 const evt = cborEncode({ 1361 ops: [{ action: 'delete', path: `${collection}/${rkey}`, cid: null }], 1362 blocks: buildCarFile(commitCidStr, newBlocks), 1363 rev, 1364 - time: eventTime 1365 - }) 1366 this.sql.exec( 1367 `INSERT INTO seq_events (did, commit_cid, evt) VALUES (?, ?, ?)`, 1368 - did, commitCidStr, evt 1369 - ) 1370 1371 // Broadcast to subscribers 1372 - const evtRows = this.sql.exec( 1373 - `SELECT * FROM seq_events ORDER BY seq DESC LIMIT 1` 1374 - ).toArray() 1375 if (evtRows.length > 0) { 1376 - this.broadcastEvent(evtRows[0]) 1377 // Forward to default DO for relay subscribers 1378 if (this.env?.PDS) { 1379 - const defaultId = this.env.PDS.idFromName('default') 1380 - const defaultPds = this.env.PDS.get(defaultId) 1381 - const row = evtRows[0] 1382 - const evtArray = Array.from(new Uint8Array(row.evt)) 1383 - defaultPds.fetch(new Request('http://internal/forward-event', { 1384 - method: 'POST', 1385 - body: JSON.stringify({ ...row, evt: evtArray }) 1386 - })).catch(e => console.log('forward error:', e)) 1387 } 1388 } 1389 1390 - return { ok: true } 1391 } 1392 1393 formatEvent(evt) { 1394 // AT Protocol frame format: header + body 1395 // Use DAG-CBOR encoding for body (CIDs need tag 42 + 0x00 prefix) 1396 - const header = cborEncode({ op: 1, t: '#commit' }) 1397 1398 // Decode stored event to get ops, blocks, rev, and time 1399 - const evtData = cborDecode(new Uint8Array(evt.evt)) 1400 - const ops = evtData.ops.map(op => ({ 1401 ...op, 1402 - cid: op.cid ? new CID(cidToBytes(op.cid)) : null // Wrap in CID class for tag 42 encoding 1403 - })) 1404 // Get blocks from stored event (already in CAR format) 1405 - const blocks = evtData.blocks || new Uint8Array(0) 1406 1407 const body = cborEncodeDagCbor({ 1408 seq: evt.seq, 1409 rebase: false, 1410 tooBig: false, 1411 repo: evt.did, 1412 - commit: new CID(cidToBytes(evt.commit_cid)), // Wrap in CID class for tag 42 encoding 1413 - rev: evtData.rev, // Use stored rev from commit creation 1414 since: null, 1415 blocks: blocks instanceof Uint8Array ? blocks : new Uint8Array(blocks), 1416 ops, 1417 blobs: [], 1418 - time: evtData.time // Use stored time from event creation 1419 - }) 1420 1421 // Concatenate header + body 1422 - const frame = new Uint8Array(header.length + body.length) 1423 - frame.set(header) 1424 - frame.set(body, header.length) 1425 - return frame 1426 } 1427 1428 async webSocketMessage(ws, message) { 1429 // Handle ping 1430 - if (message === 'ping') ws.send('pong') 1431 } 1432 1433 - async webSocketClose(ws, code, reason) { 1434 // Durable Object will hibernate when no connections remain 1435 } 1436 1437 broadcastEvent(evt) { 1438 - const frame = this.formatEvent(evt) 1439 for (const ws of this.state.getWebSockets()) { 1440 try { 1441 - ws.send(frame) 1442 - } catch (e) { 1443 // Client disconnected 1444 } 1445 } 1446 } 1447 1448 async handleAtprotoDid() { 1449 - let did = await this.getDid() 1450 if (!did) { 1451 - const registeredDids = await this.state.storage.get('registeredDids') || [] 1452 - did = registeredDids[0] 1453 } 1454 if (!did) { 1455 - return new Response('User not found', { status: 404 }) 1456 } 1457 - return new Response(did, { headers: { 'Content-Type': 'text/plain' } }) 1458 } 1459 1460 async handleInit(request) { 1461 - const body = await request.json() 1462 if (!body.did || !body.privateKey) { 1463 - return errorResponse('InvalidRequest', 'missing did or privateKey', 400) 1464 } 1465 - await this.initIdentity(body.did, body.privateKey, body.handle || null) 1466 - return Response.json({ ok: true, did: body.did, handle: body.handle || null }) 1467 } 1468 1469 async handleStatus() { 1470 - const did = await this.getDid() 1471 - return Response.json({ initialized: !!did, did: did || null }) 1472 } 1473 1474 async handleResetRepo() { 1475 - this.sql.exec(`DELETE FROM blocks`) 1476 - this.sql.exec(`DELETE FROM records`) 1477 - this.sql.exec(`DELETE FROM commits`) 1478 - this.sql.exec(`DELETE FROM seq_events`) 1479 - await this.state.storage.delete('head') 1480 - await this.state.storage.delete('rev') 1481 - return Response.json({ ok: true, message: 'repo data cleared' }) 1482 } 1483 1484 async handleForwardEvent(request) { 1485 - const evt = await request.json() 1486 - const numSockets = [...this.state.getWebSockets()].length 1487 - console.log(`forward-event: received event seq=${evt.seq}, ${numSockets} connected sockets`) 1488 this.broadcastEvent({ 1489 seq: evt.seq, 1490 did: evt.did, 1491 commit_cid: evt.commit_cid, 1492 - evt: new Uint8Array(Object.values(evt.evt)) 1493 - }) 1494 - return Response.json({ ok: true, sockets: numSockets }) 1495 } 1496 1497 async handleRegisterDid(request) { 1498 - const body = await request.json() 1499 - const registeredDids = await this.state.storage.get('registeredDids') || [] 1500 if (!registeredDids.includes(body.did)) { 1501 - registeredDids.push(body.did) 1502 - await this.state.storage.put('registeredDids', registeredDids) 1503 } 1504 - return Response.json({ ok: true }) 1505 } 1506 1507 async handleGetRegisteredDids() { 1508 - const registeredDids = await this.state.storage.get('registeredDids') || [] 1509 - return Response.json({ dids: registeredDids }) 1510 } 1511 1512 async handleRegisterHandle(request) { 1513 - const body = await request.json() 1514 - const { handle, did } = body 1515 if (!handle || !did) { 1516 - return errorResponse('InvalidRequest', 'missing handle or did', 400) 1517 } 1518 - const handleMap = await this.state.storage.get('handleMap') || {} 1519 - handleMap[handle] = did 1520 - await this.state.storage.put('handleMap', handleMap) 1521 - return Response.json({ ok: true }) 1522 } 1523 1524 async handleResolveHandle(url) { 1525 - const handle = url.searchParams.get('handle') 1526 if (!handle) { 1527 - return errorResponse('InvalidRequest', 'missing handle', 400) 1528 } 1529 - const handleMap = await this.state.storage.get('handleMap') || {} 1530 - const did = handleMap[handle] 1531 if (!did) { 1532 - return errorResponse('NotFound', 'handle not found', 404) 1533 } 1534 - return Response.json({ did }) 1535 } 1536 1537 async handleRepoInfo() { 1538 - const head = await this.state.storage.get('head') 1539 - const rev = await this.state.storage.get('rev') 1540 - return Response.json({ head: head || null, rev: rev || null }) 1541 } 1542 1543 handleDescribeServer(request) { 1544 - const hostname = request.headers.get('x-hostname') || 'localhost' 1545 return Response.json({ 1546 did: `did:web:${hostname}`, 1547 availableUserDomains: [`.${hostname}`], 1548 inviteCodeRequired: false, 1549 phoneVerificationRequired: false, 1550 links: {}, 1551 - contact: {} 1552 - }) 1553 } 1554 1555 async handleCreateSession(request) { 1556 - const body = await request.json() 1557 - const { identifier, password } = body 1558 1559 if (!identifier || !password) { 1560 - return errorResponse('InvalidRequest', 'Missing identifier or password', 400) 1561 } 1562 1563 // Check password against env var 1564 - const expectedPassword = this.env?.PDS_PASSWORD 1565 if (!expectedPassword || password !== expectedPassword) { 1566 - return errorResponse('AuthenticationRequired', 'Invalid identifier or password', 401) 1567 } 1568 1569 // Resolve identifier to DID 1570 - let did = identifier 1571 if (!identifier.startsWith('did:')) { 1572 // Try to resolve handle 1573 - const handleMap = await this.state.storage.get('handleMap') || {} 1574 - did = handleMap[identifier] 1575 if (!did) { 1576 - return errorResponse('InvalidRequest', 'Unable to resolve handle', 400) 1577 } 1578 } 1579 1580 // Get handle for response 1581 - const handle = await this.getHandleForDid(did) 1582 1583 // Create tokens 1584 - const jwtSecret = this.env?.JWT_SECRET 1585 if (!jwtSecret) { 1586 - return errorResponse('InternalServerError', 'Server not configured for authentication', 500) 1587 } 1588 1589 - const accessJwt = await createAccessJwt(did, jwtSecret) 1590 - const refreshJwt = await createRefreshJwt(did, jwtSecret) 1591 1592 return Response.json({ 1593 accessJwt, 1594 refreshJwt, 1595 handle: handle || did, 1596 did, 1597 - active: true 1598 - }) 1599 } 1600 1601 async handleGetSession(request) { 1602 - const authHeader = request.headers.get('Authorization') 1603 if (!authHeader || !authHeader.startsWith('Bearer ')) { 1604 - return errorResponse('AuthenticationRequired', 'Missing or invalid authorization header', 401) 1605 } 1606 1607 - const token = authHeader.slice(7) // Remove 'Bearer ' 1608 - const jwtSecret = this.env?.JWT_SECRET 1609 if (!jwtSecret) { 1610 - return errorResponse('InternalServerError', 'Server not configured for authentication', 500) 1611 } 1612 1613 try { 1614 - const payload = await verifyAccessJwt(token, jwtSecret) 1615 - const did = payload.sub 1616 - const handle = await this.getHandleForDid(did) 1617 1618 return Response.json({ 1619 handle: handle || did, 1620 did, 1621 - active: true 1622 - }) 1623 } catch (err) { 1624 - return errorResponse('InvalidToken', err.message, 401) 1625 } 1626 } 1627 1628 - async handleGetPreferences(request) { 1629 // Preferences are stored per-user in their DO 1630 - const preferences = await this.state.storage.get('preferences') || [] 1631 - return Response.json({ preferences }) 1632 } 1633 1634 async handlePutPreferences(request) { 1635 - const body = await request.json() 1636 - const { preferences } = body 1637 if (!Array.isArray(preferences)) { 1638 - return errorResponse('InvalidRequest', 'preferences must be an array', 400) 1639 } 1640 - await this.state.storage.put('preferences', preferences) 1641 - return Response.json({}) 1642 } 1643 1644 async getHandleForDid(did) { 1645 // Check if this DID has a handle registered 1646 - const handleMap = await this.state.storage.get('handleMap') || {} 1647 for (const [handle, mappedDid] of Object.entries(handleMap)) { 1648 - if (mappedDid === did) return handle 1649 } 1650 // Check instance's own handle 1651 - const instanceDid = await this.getDid() 1652 if (instanceDid === did) { 1653 - return await this.state.storage.get('handle') 1654 } 1655 - return null 1656 } 1657 1658 async createServiceAuthForAppView(did, lxm) { 1659 - const signingKey = await this.getSigningKey() 1660 return createServiceJwt({ 1661 iss: did, 1662 aud: 'did:web:api.bsky.app', 1663 lxm, 1664 - signingKey 1665 - }) 1666 } 1667 1668 async handleAppViewProxy(request, userDid) { 1669 - const url = new URL(request.url) 1670 // Extract lexicon method from path: /xrpc/app.bsky.actor.getPreferences -> app.bsky.actor.getPreferences 1671 - const lxm = url.pathname.replace('/xrpc/', '') 1672 1673 // Create service auth JWT 1674 - const serviceJwt = await this.createServiceAuthForAppView(userDid, lxm) 1675 1676 // Build AppView URL 1677 - const appViewUrl = new URL(url.pathname + url.search, 'https://api.bsky.app') 1678 1679 // Forward request with service auth 1680 - const headers = new Headers() 1681 - headers.set('Authorization', `Bearer ${serviceJwt}`) 1682 - headers.set('Content-Type', request.headers.get('Content-Type') || 'application/json') 1683 if (request.headers.get('Accept')) { 1684 - headers.set('Accept', request.headers.get('Accept')) 1685 } 1686 if (request.headers.get('Accept-Language')) { 1687 - headers.set('Accept-Language', request.headers.get('Accept-Language')) 1688 } 1689 1690 const proxyReq = new Request(appViewUrl.toString(), { 1691 method: request.method, 1692 headers, 1693 - body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : undefined, 1694 - }) 1695 1696 try { 1697 - const response = await fetch(proxyReq) 1698 // Return the response with CORS headers 1699 - const responseHeaders = new Headers(response.headers) 1700 - responseHeaders.set('Access-Control-Allow-Origin', '*') 1701 return new Response(response.body, { 1702 status: response.status, 1703 statusText: response.statusText, 1704 - headers: responseHeaders 1705 - }) 1706 } catch (err) { 1707 - return errorResponse('UpstreamFailure', 'Failed to reach AppView: ' + err.message, 502) 1708 } 1709 } 1710 1711 async handleListRepos() { 1712 - const registeredDids = await this.state.storage.get('registeredDids') || [] 1713 - const did = await this.getDid() 1714 - const repos = did ? [{ did, head: null, rev: null }] : 1715 - registeredDids.map(d => ({ did: d, head: null, rev: null })) 1716 - return Response.json({ repos }) 1717 } 1718 1719 async handleCreateRecord(request) { 1720 - const body = await request.json() 1721 if (!body.collection || !body.record) { 1722 - return errorResponse('InvalidRequest', 'missing collection or record', 400) 1723 } 1724 try { 1725 - const result = await this.createRecord(body.collection, body.record, body.rkey) 1726 - const head = await this.state.storage.get('head') 1727 - const rev = await this.state.storage.get('rev') 1728 return Response.json({ 1729 uri: result.uri, 1730 cid: result.cid, 1731 commit: { cid: head, rev }, 1732 - validationStatus: 'valid' 1733 - }) 1734 } catch (err) { 1735 - return errorResponse('InternalError', err.message, 500) 1736 } 1737 } 1738 1739 async handleDeleteRecord(request) { 1740 - const body = await request.json() 1741 if (!body.collection || !body.rkey) { 1742 - return errorResponse('InvalidRequest', 'missing collection or rkey', 400) 1743 } 1744 try { 1745 - const result = await this.deleteRecord(body.collection, body.rkey) 1746 if (result.error) { 1747 - return Response.json(result, { status: 404 }) 1748 } 1749 - return Response.json({}) 1750 } catch (err) { 1751 - return errorResponse('InternalError', err.message, 500) 1752 } 1753 } 1754 1755 async handlePutRecord(request) { 1756 - const body = await request.json() 1757 if (!body.collection || !body.rkey || !body.record) { 1758 - return errorResponse('InvalidRequest', 'missing collection, rkey, or record', 400) 1759 } 1760 try { 1761 // putRecord is like createRecord but with a specific rkey (upsert) 1762 - const result = await this.createRecord(body.collection, body.record, body.rkey) 1763 - const head = await this.state.storage.get('head') 1764 - const rev = await this.state.storage.get('rev') 1765 return Response.json({ 1766 uri: result.uri, 1767 cid: result.cid, 1768 commit: { cid: head, rev }, 1769 - validationStatus: 'valid' 1770 - }) 1771 } catch (err) { 1772 - return errorResponse('InternalError', err.message, 500) 1773 } 1774 } 1775 1776 async handleApplyWrites(request) { 1777 - const body = await request.json() 1778 if (!body.writes || !Array.isArray(body.writes)) { 1779 - return errorResponse('InvalidRequest', 'missing writes array', 400) 1780 } 1781 try { 1782 - const results = [] 1783 for (const write of body.writes) { 1784 - const type = write['$type'] 1785 if (type === 'com.atproto.repo.applyWrites#create') { 1786 - const result = await this.createRecord(write.collection, write.value, write.rkey) 1787 results.push({ 1788 $type: 'com.atproto.repo.applyWrites#createResult', 1789 uri: result.uri, 1790 cid: result.cid, 1791 - validationStatus: 'valid' 1792 - }) 1793 } else if (type === 'com.atproto.repo.applyWrites#update') { 1794 - const result = await this.createRecord(write.collection, write.value, write.rkey) 1795 results.push({ 1796 $type: 'com.atproto.repo.applyWrites#updateResult', 1797 uri: result.uri, 1798 cid: result.cid, 1799 - validationStatus: 'valid' 1800 - }) 1801 } else if (type === 'com.atproto.repo.applyWrites#delete') { 1802 - await this.deleteRecord(write.collection, write.rkey) 1803 results.push({ 1804 - $type: 'com.atproto.repo.applyWrites#deleteResult' 1805 - }) 1806 } else { 1807 - return errorResponse('InvalidRequest', `Unknown write operation type: ${type}`, 400) 1808 } 1809 } 1810 // Return commit info 1811 - const head = await this.state.storage.get('head') 1812 - const rev = await this.state.storage.get('rev') 1813 - return Response.json({ commit: { cid: head, rev }, results }) 1814 } catch (err) { 1815 - return errorResponse('InternalError', err.message, 500) 1816 } 1817 } 1818 1819 async handleGetRecord(url) { 1820 - const collection = url.searchParams.get('collection') 1821 - const rkey = url.searchParams.get('rkey') 1822 if (!collection || !rkey) { 1823 - return errorResponse('InvalidRequest', 'missing collection or rkey', 400) 1824 } 1825 - const did = await this.getDid() 1826 - const uri = `at://${did}/${collection}/${rkey}` 1827 - const rows = this.sql.exec( 1828 - `SELECT cid, value FROM records WHERE uri = ?`, uri 1829 - ).toArray() 1830 if (rows.length === 0) { 1831 - return errorResponse('RecordNotFound', 'record not found', 404) 1832 } 1833 - const row = rows[0] 1834 - const value = cborDecode(new Uint8Array(row.value)) 1835 - return Response.json({ uri, cid: row.cid, value }) 1836 } 1837 1838 async handleDescribeRepo() { 1839 - const did = await this.getDid() 1840 if (!did) { 1841 - return errorResponse('RepoNotFound', 'repo not found', 404) 1842 } 1843 - const handle = await this.state.storage.get('handle') 1844 // Get unique collections 1845 - const collections = this.sql.exec( 1846 - `SELECT DISTINCT collection FROM records` 1847 - ).toArray().map(r => r.collection) 1848 1849 return Response.json({ 1850 handle: handle || did, 1851 did, 1852 didDoc: {}, 1853 collections, 1854 - handleIsCorrect: !!handle 1855 - }) 1856 } 1857 1858 async handleListRecords(url) { 1859 - const collection = url.searchParams.get('collection') 1860 if (!collection) { 1861 - return errorResponse('InvalidRequest', 'missing collection', 400) 1862 } 1863 - const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 100) 1864 - const reverse = url.searchParams.get('reverse') === 'true' 1865 - const cursor = url.searchParams.get('cursor') 1866 1867 - const did = await this.getDid() 1868 - let query = `SELECT uri, cid, value FROM records WHERE collection = ? ORDER BY rkey ${reverse ? 'DESC' : 'ASC'} LIMIT ?` 1869 - const params = [collection, limit + 1] 1870 1871 - const rows = this.sql.exec(query, ...params).toArray() 1872 - const hasMore = rows.length > limit 1873 - const records = rows.slice(0, limit).map(r => ({ 1874 uri: r.uri, 1875 cid: r.cid, 1876 - value: cborDecode(new Uint8Array(r.value)) 1877 - })) 1878 1879 return Response.json({ 1880 records, 1881 - cursor: hasMore ? records[records.length - 1]?.uri : undefined 1882 - }) 1883 } 1884 1885 handleGetLatestCommit() { 1886 - const commits = this.sql.exec( 1887 - `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1` 1888 - ).toArray() 1889 if (commits.length === 0) { 1890 - return errorResponse('RepoNotFound', 'repo not found', 404) 1891 } 1892 - return Response.json({ cid: commits[0].cid, rev: commits[0].rev }) 1893 } 1894 1895 async handleGetRepoStatus() { 1896 - const did = await this.getDid() 1897 - const commits = this.sql.exec( 1898 - `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1` 1899 - ).toArray() 1900 if (commits.length === 0 || !did) { 1901 - return errorResponse('RepoNotFound', 'repo not found', 404) 1902 } 1903 - return Response.json({ did, active: true, status: 'active', rev: commits[0].rev }) 1904 } 1905 1906 handleGetRepo() { 1907 - const commits = this.sql.exec( 1908 - `SELECT cid FROM commits ORDER BY seq DESC LIMIT 1` 1909 - ).toArray() 1910 if (commits.length === 0) { 1911 - return errorResponse('RepoNotFound', 'repo not found', 404) 1912 } 1913 1914 // Only include blocks reachable from the current commit 1915 - const commitCid = commits[0].cid 1916 - const neededCids = new Set() 1917 1918 // Helper to get block data 1919 const getBlock = (cid) => { 1920 - const rows = this.sql.exec(`SELECT data FROM blocks WHERE cid = ?`, cid).toArray() 1921 - return rows.length > 0 ? new Uint8Array(rows[0].data) : null 1922 - } 1923 1924 // Collect all reachable blocks starting from commit 1925 const collectBlocks = (cid) => { 1926 - if (neededCids.has(cid)) return 1927 - neededCids.add(cid) 1928 1929 - const data = getBlock(cid) 1930 - if (!data) return 1931 1932 // Decode CBOR to find CID references 1933 try { 1934 - const decoded = cborDecode(data) 1935 if (decoded && typeof decoded === 'object') { 1936 // Commit object - follow 'data' (MST root) 1937 if (decoded.data instanceof Uint8Array) { 1938 - collectBlocks(cidToString(decoded.data)) 1939 } 1940 // MST node - follow 'l' and entries' 'v' and 't' 1941 if (decoded.l instanceof Uint8Array) { 1942 - collectBlocks(cidToString(decoded.l)) 1943 } 1944 if (Array.isArray(decoded.e)) { 1945 for (const entry of decoded.e) { 1946 if (entry.v instanceof Uint8Array) { 1947 - collectBlocks(cidToString(entry.v)) 1948 } 1949 if (entry.t instanceof Uint8Array) { 1950 - collectBlocks(cidToString(entry.t)) 1951 } 1952 } 1953 } 1954 } 1955 - } catch (e) { 1956 // Not a structured block, that's fine 1957 } 1958 - } 1959 1960 - collectBlocks(commitCid) 1961 1962 // Build CAR with only needed blocks 1963 - const blocksForCar = [] 1964 for (const cid of neededCids) { 1965 - const data = getBlock(cid) 1966 if (data) { 1967 - blocksForCar.push({ cid, data }) 1968 } 1969 } 1970 1971 - const car = buildCarFile(commitCid, blocksForCar) 1972 return new Response(car, { 1973 - headers: { 'content-type': 'application/vnd.ipld.car' } 1974 - }) 1975 } 1976 1977 async handleSyncGetRecord(url) { 1978 - const collection = url.searchParams.get('collection') 1979 - const rkey = url.searchParams.get('rkey') 1980 if (!collection || !rkey) { 1981 - return errorResponse('InvalidRequest', 'missing collection or rkey', 400) 1982 } 1983 - const did = await this.getDid() 1984 - const uri = `at://${did}/${collection}/${rkey}` 1985 - const rows = this.sql.exec( 1986 - `SELECT cid FROM records WHERE uri = ?`, uri 1987 - ).toArray() 1988 if (rows.length === 0) { 1989 - return errorResponse('RecordNotFound', 'record not found', 404) 1990 } 1991 - const recordCid = rows[0].cid 1992 1993 // Get latest commit 1994 - const commits = this.sql.exec( 1995 - `SELECT cid FROM commits ORDER BY seq DESC LIMIT 1` 1996 - ).toArray() 1997 if (commits.length === 0) { 1998 - return errorResponse('RepoNotFound', 'no commits', 404) 1999 } 2000 - const commitCid = commits[0].cid 2001 2002 // Build proof chain: commit -> MST path -> record 2003 // Include commit block, all MST nodes on path to record, and record block 2004 - const blocks = [] 2005 - const included = new Set() 2006 2007 const addBlock = (cidStr) => { 2008 - if (included.has(cidStr)) return 2009 - included.add(cidStr) 2010 - const blockRows = this.sql.exec( 2011 - `SELECT data FROM blocks WHERE cid = ?`, cidStr 2012 - ).toArray() 2013 if (blockRows.length > 0) { 2014 - blocks.push({ cid: cidStr, data: new Uint8Array(blockRows[0].data) }) 2015 } 2016 - } 2017 2018 // Add commit block 2019 - addBlock(commitCid) 2020 2021 // Get commit to find data root 2022 - const commitRows = this.sql.exec( 2023 - `SELECT data FROM blocks WHERE cid = ?`, commitCid 2024 - ).toArray() 2025 if (commitRows.length > 0) { 2026 - const commit = cborDecode(new Uint8Array(commitRows[0].data)) 2027 if (commit.data) { 2028 - const dataRootCid = cidToString(commit.data) 2029 // Collect MST path blocks (this includes all MST nodes) 2030 - const mstBlocks = this.collectMstBlocks(dataRootCid) 2031 for (const block of mstBlocks) { 2032 - addBlock(block.cid) 2033 } 2034 } 2035 } 2036 2037 // Add record block 2038 - addBlock(recordCid) 2039 2040 - const car = buildCarFile(commitCid, blocks) 2041 return new Response(car, { 2042 - headers: { 'content-type': 'application/vnd.ipld.car' } 2043 - }) 2044 } 2045 2046 handleSubscribeRepos(request, url) { 2047 - const upgradeHeader = request.headers.get('Upgrade') 2048 if (upgradeHeader !== 'websocket') { 2049 - return new Response('expected websocket', { status: 426 }) 2050 } 2051 - const { 0: client, 1: server } = new WebSocketPair() 2052 - this.state.acceptWebSocket(server) 2053 - const cursor = url.searchParams.get('cursor') 2054 if (cursor) { 2055 - const events = this.sql.exec( 2056 - `SELECT * FROM seq_events WHERE seq > ? ORDER BY seq`, 2057 - parseInt(cursor) 2058 - ).toArray() 2059 for (const evt of events) { 2060 - server.send(this.formatEvent(evt)) 2061 } 2062 } 2063 - return new Response(null, { status: 101, webSocket: client }) 2064 } 2065 2066 async fetch(request) { 2067 - const url = new URL(request.url) 2068 - const route = pdsRoutes[url.pathname] 2069 2070 // Check for local route first 2071 if (route) { 2072 if (route.method && request.method !== route.method) { 2073 - return errorResponse('MethodNotAllowed', 'method not allowed', 405) 2074 } 2075 - return route.handler(this, request, url) 2076 } 2077 2078 // Handle app.bsky.* proxy requests (only if no local route) 2079 if (url.pathname.startsWith('/xrpc/app.bsky.')) { 2080 - const userDid = request.headers.get('x-authed-did') 2081 if (!userDid) { 2082 - return errorResponse('Unauthorized', 'Missing auth context', 401) 2083 } 2084 - return this.handleAppViewProxy(request, userDid) 2085 } 2086 2087 - return errorResponse('NotFound', 'not found', 404) 2088 } 2089 } 2090 2091 const corsHeaders = { 2092 'Access-Control-Allow-Origin': '*', 2093 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 2094 - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, atproto-accept-labelers, atproto-proxy, x-bsky-topics', 2095 - } 2096 2097 function addCorsHeaders(response) { 2098 - const newHeaders = new Headers(response.headers) 2099 for (const [key, value] of Object.entries(corsHeaders)) { 2100 - newHeaders.set(key, value) 2101 } 2102 return new Response(response.body, { 2103 status: response.status, 2104 statusText: response.statusText, 2105 - headers: newHeaders 2106 - }) 2107 } 2108 2109 export default { 2110 async fetch(request, env) { 2111 // Handle CORS preflight 2112 if (request.method === 'OPTIONS') { 2113 - return new Response(null, { headers: corsHeaders }) 2114 } 2115 2116 - const response = await handleRequest(request, env) 2117 // Don't wrap WebSocket upgrades - they need the webSocket property preserved 2118 if (response.status === 101) { 2119 - return response 2120 } 2121 - return addCorsHeaders(response) 2122 - } 2123 - } 2124 2125 // Extract subdomain from hostname (e.g., "alice" from "alice.foo.workers.dev") 2126 function getSubdomain(hostname) { 2127 - const parts = hostname.split('.') 2128 // workers.dev domains: [subdomain?].[worker-name].[account].workers.dev 2129 // If more than 4 parts, first part(s) are user subdomain 2130 if (parts.length > 4 && parts.slice(-2).join('.') === 'workers.dev') { 2131 - return parts.slice(0, -4).join('.') 2132 } 2133 // Custom domains: check if there's a subdomain before the base 2134 // For now, assume no subdomain on custom domains 2135 - return null 2136 } 2137 2138 /** ··· 2142 * @returns {Promise<{did: string} | {error: Response}>} DID or error response 2143 */ 2144 async function requireAuth(request, env) { 2145 - const authHeader = request.headers.get('Authorization') 2146 if (!authHeader || !authHeader.startsWith('Bearer ')) { 2147 return { 2148 - error: Response.json({ 2149 - error: 'AuthenticationRequired', 2150 - message: 'Authentication required' 2151 - }, { status: 401 }) 2152 - } 2153 } 2154 2155 - const token = authHeader.slice(7) 2156 - const jwtSecret = env?.JWT_SECRET 2157 if (!jwtSecret) { 2158 return { 2159 - error: Response.json({ 2160 - error: 'InternalServerError', 2161 - message: 'Server not configured for authentication' 2162 - }, { status: 500 }) 2163 - } 2164 } 2165 2166 try { 2167 - const payload = await verifyAccessJwt(token, jwtSecret) 2168 - return { did: payload.sub } 2169 } catch (err) { 2170 return { 2171 - error: Response.json({ 2172 - error: 'InvalidToken', 2173 - message: err.message 2174 - }, { status: 401 }) 2175 - } 2176 } 2177 } 2178 2179 async function handleAuthenticatedRepoWrite(request, env) { 2180 - const auth = await requireAuth(request, env) 2181 - if (auth.error) return auth.error 2182 2183 - const body = await request.json() 2184 - const repo = body.repo 2185 if (!repo) { 2186 - return errorResponse('InvalidRequest', 'missing repo param', 400) 2187 } 2188 2189 if (auth.did !== repo) { 2190 - return errorResponse('Forbidden', 'Cannot modify another user\'s repo', 403) 2191 } 2192 2193 - const id = env.PDS.idFromName(repo) 2194 - const pds = env.PDS.get(id) 2195 - const response = await pds.fetch(new Request(request.url, { 2196 - method: 'POST', 2197 - headers: request.headers, 2198 - body: JSON.stringify(body) 2199 - })) 2200 2201 // Notify relay of updates on successful writes 2202 if (response.ok) { 2203 - const url = new URL(request.url) 2204 - notifyCrawlers(env, url.hostname) 2205 } 2206 2207 - return response 2208 } 2209 2210 async function handleRequest(request, env) { 2211 - const url = new URL(request.url) 2212 - const subdomain = getSubdomain(url.hostname) 2213 2214 // Handle resolution via subdomain or bare domain 2215 if (url.pathname === '/.well-known/atproto-did') { 2216 // Look up handle -> DID in default DO 2217 // Use subdomain if present, otherwise try bare hostname as handle 2218 - const handleToResolve = subdomain || url.hostname 2219 - const defaultId = env.PDS.idFromName('default') 2220 - const defaultPds = env.PDS.get(defaultId) 2221 const resolveRes = await defaultPds.fetch( 2222 - new Request(`http://internal/resolve-handle?handle=${encodeURIComponent(handleToResolve)}`) 2223 - ) 2224 if (!resolveRes.ok) { 2225 - return new Response('Handle not found', { status: 404 }) 2226 } 2227 - const { did } = await resolveRes.json() 2228 - return new Response(did, { headers: { 'Content-Type': 'text/plain' } }) 2229 } 2230 2231 // describeServer - works on bare domain 2232 if (url.pathname === '/xrpc/com.atproto.server.describeServer') { 2233 - const defaultId = env.PDS.idFromName('default') 2234 - const defaultPds = env.PDS.get(defaultId) 2235 const newReq = new Request(request.url, { 2236 method: request.method, 2237 - headers: { ...Object.fromEntries(request.headers), 'x-hostname': url.hostname } 2238 - }) 2239 - return defaultPds.fetch(newReq) 2240 } 2241 2242 // createSession - handle on default DO (has handleMap for identifier resolution) 2243 if (url.pathname === '/xrpc/com.atproto.server.createSession') { 2244 - const defaultId = env.PDS.idFromName('default') 2245 - const defaultPds = env.PDS.get(defaultId) 2246 - return defaultPds.fetch(request) 2247 } 2248 2249 // getSession - route to default DO 2250 if (url.pathname === '/xrpc/com.atproto.server.getSession') { 2251 - const defaultId = env.PDS.idFromName('default') 2252 - const defaultPds = env.PDS.get(defaultId) 2253 - return defaultPds.fetch(request) 2254 } 2255 2256 // Proxy app.bsky.* endpoints to Bluesky AppView 2257 if (url.pathname.startsWith('/xrpc/app.bsky.')) { 2258 // Authenticate the user first 2259 - const auth = await requireAuth(request, env) 2260 - if (auth.error) return auth.error 2261 2262 // Route to the user's DO instance to create service auth and proxy 2263 - const id = env.PDS.idFromName(auth.did) 2264 - const pds = env.PDS.get(id) 2265 - return pds.fetch(new Request(request.url, { 2266 - method: request.method, 2267 - headers: { 2268 - ...Object.fromEntries(request.headers), 2269 - 'x-authed-did': auth.did // Pass the authenticated DID 2270 - }, 2271 - body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : undefined, 2272 - })) 2273 } 2274 2275 // Handle registration routes - go to default DO 2276 - if (url.pathname === '/register-handle' || url.pathname === '/resolve-handle') { 2277 - const defaultId = env.PDS.idFromName('default') 2278 - const defaultPds = env.PDS.get(defaultId) 2279 - return defaultPds.fetch(request) 2280 } 2281 2282 // resolveHandle XRPC endpoint 2283 if (url.pathname === '/xrpc/com.atproto.identity.resolveHandle') { 2284 - const handle = url.searchParams.get('handle') 2285 if (!handle) { 2286 - return errorResponse('InvalidRequest', 'missing handle param', 400) 2287 } 2288 - const defaultId = env.PDS.idFromName('default') 2289 - const defaultPds = env.PDS.get(defaultId) 2290 const resolveRes = await defaultPds.fetch( 2291 - new Request(`http://internal/resolve-handle?handle=${encodeURIComponent(handle)}`) 2292 - ) 2293 if (!resolveRes.ok) { 2294 - return errorResponse('InvalidRequest', 'Unable to resolve handle', 400) 2295 } 2296 - const { did } = await resolveRes.json() 2297 - return Response.json({ did }) 2298 } 2299 2300 // subscribeRepos WebSocket - route to default instance for firehose 2301 if (url.pathname === '/xrpc/com.atproto.sync.subscribeRepos') { 2302 - const defaultId = env.PDS.idFromName('default') 2303 - const defaultPds = env.PDS.get(defaultId) 2304 - return defaultPds.fetch(request) 2305 } 2306 2307 // listRepos needs to aggregate from all registered DIDs 2308 if (url.pathname === '/xrpc/com.atproto.sync.listRepos') { 2309 - const defaultId = env.PDS.idFromName('default') 2310 - const defaultPds = env.PDS.get(defaultId) 2311 - const regRes = await defaultPds.fetch(new Request('http://internal/get-registered-dids')) 2312 - const { dids } = await regRes.json() 2313 2314 - const repos = [] 2315 for (const did of dids) { 2316 - const id = env.PDS.idFromName(did) 2317 - const pds = env.PDS.get(id) 2318 - const infoRes = await pds.fetch(new Request('http://internal/repo-info')) 2319 - const info = await infoRes.json() 2320 if (info.head) { 2321 - repos.push({ did, head: info.head, rev: info.rev, active: true }) 2322 } 2323 } 2324 - return Response.json({ repos, cursor: undefined }) 2325 } 2326 2327 // Repo endpoints use ?repo= param instead of ?did= 2328 - if (url.pathname === '/xrpc/com.atproto.repo.describeRepo' || 2329 - url.pathname === '/xrpc/com.atproto.repo.listRecords' || 2330 - url.pathname === '/xrpc/com.atproto.repo.getRecord') { 2331 - const repo = url.searchParams.get('repo') 2332 if (!repo) { 2333 - return errorResponse('InvalidRequest', 'missing repo param', 400) 2334 } 2335 - const id = env.PDS.idFromName(repo) 2336 - const pds = env.PDS.get(id) 2337 - return pds.fetch(request) 2338 } 2339 2340 // Sync endpoints use ?did= param 2341 - if (url.pathname === '/xrpc/com.atproto.sync.getLatestCommit' || 2342 - url.pathname === '/xrpc/com.atproto.sync.getRepoStatus' || 2343 - url.pathname === '/xrpc/com.atproto.sync.getRepo' || 2344 - url.pathname === '/xrpc/com.atproto.sync.getRecord') { 2345 - const did = url.searchParams.get('did') 2346 if (!did) { 2347 - return errorResponse('InvalidRequest', 'missing did param', 400) 2348 } 2349 - const id = env.PDS.idFromName(did) 2350 - const pds = env.PDS.get(id) 2351 - return pds.fetch(request) 2352 } 2353 2354 // Authenticated repo write endpoints ··· 2356 '/xrpc/com.atproto.repo.createRecord', 2357 '/xrpc/com.atproto.repo.deleteRecord', 2358 '/xrpc/com.atproto.repo.putRecord', 2359 - '/xrpc/com.atproto.repo.applyWrites' 2360 - ] 2361 if (repoWriteEndpoints.includes(url.pathname)) { 2362 - return handleAuthenticatedRepoWrite(request, env) 2363 } 2364 2365 // Health check endpoint 2366 if (url.pathname === '/xrpc/_health') { 2367 - return Response.json({ version: '0.1.0' }) 2368 } 2369 2370 // Root path - ASCII art ··· 2378 ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚════╝ ╚══════╝ 2379 2380 ATProto PDS on Cloudflare Workers 2381 - ` 2382 - return new Response(ascii, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }) 2383 } 2384 2385 // On init, register this DID with the default instance (requires ?did= param, no auth yet) 2386 if (url.pathname === '/init' && request.method === 'POST') { 2387 - const did = url.searchParams.get('did') 2388 if (!did) { 2389 - return errorResponse('InvalidRequest', 'missing did param', 400) 2390 } 2391 - const body = await request.json() 2392 2393 // Register with default instance for discovery 2394 - const defaultId = env.PDS.idFromName('default') 2395 - const defaultPds = env.PDS.get(defaultId) 2396 - await defaultPds.fetch(new Request('http://internal/register-did', { 2397 - method: 'POST', 2398 - body: JSON.stringify({ did }) 2399 - })) 2400 2401 // Register handle if provided 2402 if (body.handle) { 2403 - await defaultPds.fetch(new Request('http://internal/register-handle', { 2404 - method: 'POST', 2405 - body: JSON.stringify({ did, handle: body.handle }) 2406 - })) 2407 } 2408 2409 // Forward to the actual PDS instance 2410 - const id = env.PDS.idFromName(did) 2411 - const pds = env.PDS.get(id) 2412 - return pds.fetch(new Request(request.url, { 2413 - method: 'POST', 2414 - headers: request.headers, 2415 - body: JSON.stringify(body) 2416 - })) 2417 } 2418 2419 // Unknown endpoint 2420 - return errorResponse('NotFound', 'Endpoint not found', 404) 2421 }
··· 16 17 // === CONSTANTS === 18 // CBOR primitive markers (RFC 8949) 19 + const CBOR_FALSE = 0xf4; 20 + const CBOR_TRUE = 0xf5; 21 + const CBOR_NULL = 0xf6; 22 23 // DAG-CBOR CID link tag 24 + const CBOR_TAG_CID = 42; 25 26 // === ERROR HELPER === 27 function errorResponse(error, message, status) { 28 + return Response.json({ error, message }, { status }); 29 } 30 31 // === CRAWLER NOTIFICATION === 32 // Notify relays to come crawl us after writes (like official PDS) 33 + let lastCrawlNotify = 0; 34 + const CRAWL_NOTIFY_THRESHOLD = 20 * 60 * 1000; // 20 minutes (matches official PDS) 35 36 async function notifyCrawlers(env, hostname) { 37 + const now = Date.now(); 38 if (now - lastCrawlNotify < CRAWL_NOTIFY_THRESHOLD) { 39 + return; // Throttle notifications 40 } 41 42 + const relayHost = env.RELAY_HOST; 43 + if (!relayHost) return; 44 45 + lastCrawlNotify = now; 46 47 // Fire and forget - don't block writes on relay notification 48 fetch(`${relayHost}/xrpc/com.atproto.sync.requestCrawl`, { 49 method: 'POST', 50 headers: { 'Content-Type': 'application/json' }, 51 + body: JSON.stringify({ hostname }), 52 + }).catch((err) => { 53 + console.log('Failed to notify relay:', err.message); 54 + }); 55 } 56 57 // === CID WRAPPER === ··· 60 class CID { 61 constructor(bytes) { 62 if (!(bytes instanceof Uint8Array)) { 63 + throw new Error('CID must be constructed with Uint8Array'); 64 } 65 + this.bytes = bytes; 66 } 67 } 68 ··· 76 * @param {number} length - Value or length to encode 77 */ 78 function encodeHead(parts, majorType, length) { 79 + const mt = majorType << 5; 80 if (length < 24) { 81 + parts.push(mt | length); 82 } else if (length < 256) { 83 + parts.push(mt | 24, length); 84 } else if (length < 65536) { 85 + parts.push(mt | 25, length >> 8, length & 0xff); 86 } else if (length < 4294967296) { 87 // Use Math.floor instead of bitshift to avoid 32-bit signed integer overflow 88 + parts.push( 89 + mt | 26, 90 Math.floor(length / 0x1000000) & 0xff, 91 Math.floor(length / 0x10000) & 0xff, 92 Math.floor(length / 0x100) & 0xff, 93 + length & 0xff, 94 + ); 95 } 96 } 97 ··· 101 * @returns {Uint8Array} CBOR-encoded bytes 102 */ 103 export function cborEncode(value) { 104 + const parts = []; 105 106 function encode(val) { 107 if (val === null) { 108 + parts.push(CBOR_NULL); 109 } else if (val === true) { 110 + parts.push(CBOR_TRUE); 111 } else if (val === false) { 112 + parts.push(CBOR_FALSE); 113 } else if (typeof val === 'number') { 114 + encodeInteger(val); 115 } else if (typeof val === 'string') { 116 + const bytes = new TextEncoder().encode(val); 117 + encodeHead(parts, 3, bytes.length); // major type 3 = text string 118 + parts.push(...bytes); 119 } else if (val instanceof Uint8Array) { 120 + encodeHead(parts, 2, val.length); // major type 2 = byte string 121 + parts.push(...val); 122 } else if (Array.isArray(val)) { 123 + encodeHead(parts, 4, val.length); // major type 4 = array 124 + for (const item of val) encode(item); 125 } else if (typeof val === 'object') { 126 // Sort keys for deterministic encoding 127 + const keys = Object.keys(val).sort(); 128 + encodeHead(parts, 5, keys.length); // major type 5 = map 129 for (const key of keys) { 130 + encode(key); 131 + encode(val[key]); 132 } 133 } 134 } 135 136 function encodeInteger(n) { 137 if (n >= 0) { 138 + encodeHead(parts, 0, n); // major type 0 = unsigned int 139 } else { 140 + encodeHead(parts, 1, -n - 1); // major type 1 = negative int 141 } 142 } 143 144 + encode(value); 145 + return new Uint8Array(parts); 146 } 147 148 // DAG-CBOR encoder that handles CIDs with tag 42 149 function cborEncodeDagCbor(value) { 150 + const parts = []; 151 152 function encode(val) { 153 if (val === null) { 154 + parts.push(CBOR_NULL); 155 } else if (val === true) { 156 + parts.push(CBOR_TRUE); 157 } else if (val === false) { 158 + parts.push(CBOR_FALSE); 159 } else if (typeof val === 'number') { 160 if (Number.isInteger(val) && val >= 0) { 161 + encodeHead(parts, 0, val); 162 } else if (Number.isInteger(val) && val < 0) { 163 + encodeHead(parts, 1, -val - 1); 164 } 165 } else if (typeof val === 'string') { 166 + const bytes = new TextEncoder().encode(val); 167 + encodeHead(parts, 3, bytes.length); 168 + parts.push(...bytes); 169 } else if (val instanceof CID) { 170 // CID links in DAG-CBOR use tag 42 + 0x00 multibase prefix 171 // The 0x00 prefix indicates "identity" multibase (raw bytes) 172 + parts.push(0xd8, CBOR_TAG_CID); 173 + encodeHead(parts, 2, val.bytes.length + 1); // +1 for 0x00 prefix 174 + parts.push(0x00); 175 + parts.push(...val.bytes); 176 } else if (val instanceof Uint8Array) { 177 // Regular byte string 178 + encodeHead(parts, 2, val.length); 179 + parts.push(...val); 180 } else if (Array.isArray(val)) { 181 + encodeHead(parts, 4, val.length); 182 + for (const item of val) encode(item); 183 } else if (typeof val === 'object') { 184 // DAG-CBOR: sort keys by length first, then lexicographically 185 // (differs from standard CBOR which sorts lexicographically only) 186 + const keys = Object.keys(val).filter((k) => val[k] !== undefined); 187 keys.sort((a, b) => { 188 + if (a.length !== b.length) return a.length - b.length; 189 + return a < b ? -1 : a > b ? 1 : 0; 190 + }); 191 + encodeHead(parts, 5, keys.length); 192 for (const key of keys) { 193 + const keyBytes = new TextEncoder().encode(key); 194 + encodeHead(parts, 3, keyBytes.length); 195 + parts.push(...keyBytes); 196 + encode(val[key]); 197 } 198 } 199 } 200 201 + encode(value); 202 + return new Uint8Array(parts); 203 } 204 205 /** ··· 208 * @returns {*} Decoded value 209 */ 210 export function cborDecode(bytes) { 211 + let offset = 0; 212 213 function read() { 214 + const initial = bytes[offset++]; 215 + const major = initial >> 5; 216 + const info = initial & 0x1f; 217 218 + let length = info; 219 + if (info === 24) length = bytes[offset++]; 220 + else if (info === 25) { 221 + length = (bytes[offset++] << 8) | bytes[offset++]; 222 + } else if (info === 26) { 223 // Use multiplication instead of bitshift to avoid 32-bit signed integer overflow 224 + length = 225 + bytes[offset++] * 0x1000000 + 226 + bytes[offset++] * 0x10000 + 227 + bytes[offset++] * 0x100 + 228 + bytes[offset++]; 229 } 230 231 switch (major) { 232 + case 0: 233 + return length; // unsigned int 234 + case 1: 235 + return -1 - length; // negative int 236 + case 2: { 237 + // byte string 238 + const data = bytes.slice(offset, offset + length); 239 + offset += length; 240 + return data; 241 } 242 + case 3: { 243 + // text string 244 + const data = new TextDecoder().decode( 245 + bytes.slice(offset, offset + length), 246 + ); 247 + offset += length; 248 + return data; 249 } 250 + case 4: { 251 + // array 252 + const arr = []; 253 + for (let i = 0; i < length; i++) arr.push(read()); 254 + return arr; 255 } 256 + case 5: { 257 + // map 258 + const obj = {}; 259 for (let i = 0; i < length; i++) { 260 + const key = read(); 261 + obj[key] = read(); 262 } 263 + return obj; 264 } 265 + case 6: { 266 + // tag 267 // length is the tag number 268 + const taggedValue = read(); 269 if (length === CBOR_TAG_CID) { 270 // CID link: byte string with 0x00 multibase prefix, return raw CID bytes 271 + return taggedValue.slice(1); // strip 0x00 prefix 272 } 273 + return taggedValue; 274 } 275 + case 7: { 276 + // special 277 + if (info === 20) return false; 278 + if (info === 21) return true; 279 + if (info === 22) return null; 280 + return undefined; 281 } 282 } 283 } 284 285 + return read(); 286 } 287 288 // === CID GENERATION === ··· 294 * @returns {Promise<Uint8Array>} CID bytes (36 bytes: version + codec + multihash) 295 */ 296 export async function createCid(bytes) { 297 + const hash = await crypto.subtle.digest('SHA-256', bytes); 298 + const hashBytes = new Uint8Array(hash); 299 300 // CIDv1: version(1) + codec(dag-cbor=0x71) + multihash(sha256) 301 // Multihash: hash-type(0x12) + length(0x20=32) + digest 302 + const cid = new Uint8Array(2 + 2 + 32); 303 + cid[0] = 0x01; // CIDv1 304 + cid[1] = 0x71; // dag-cbor codec 305 + cid[2] = 0x12; // sha-256 306 + cid[3] = 0x20; // 32 bytes 307 + cid.set(hashBytes, 4); 308 309 + return cid; 310 } 311 312 /** ··· 316 */ 317 export function cidToString(cid) { 318 // base32lower encoding for CIDv1 319 + return `b${base32Encode(cid)}`; 320 } 321 322 /** ··· 325 * @returns {string} Base32lower-encoded string 326 */ 327 export function base32Encode(bytes) { 328 + const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; 329 + let result = ''; 330 + let bits = 0; 331 + let value = 0; 332 333 for (const byte of bytes) { 334 + value = (value << 8) | byte; 335 + bits += 8; 336 while (bits >= 5) { 337 + bits -= 5; 338 + result += alphabet[(value >> bits) & 31]; 339 } 340 } 341 342 if (bits > 0) { 343 + result += alphabet[(value << (5 - bits)) & 31]; 344 } 345 346 + return result; 347 } 348 349 // === TID GENERATION === 350 // Timestamp-based IDs: base32-sort encoded microseconds + clock ID 351 352 + const TID_CHARS = '234567abcdefghijklmnopqrstuvwxyz'; 353 + let lastTimestamp = 0; 354 + const clockId = Math.floor(Math.random() * 1024); 355 356 /** 357 * Generate a timestamp-based ID (TID) for record keys ··· 359 * @returns {string} 13-character base32-sort encoded TID 360 */ 361 export function createTid() { 362 + let timestamp = Date.now() * 1000; // microseconds 363 364 // Ensure monotonic 365 if (timestamp <= lastTimestamp) { 366 + timestamp = lastTimestamp + 1; 367 } 368 + lastTimestamp = timestamp; 369 370 // 13 chars: 11 for timestamp (64 bits but only ~53 used), 2 for clock ID 371 + let tid = ''; 372 373 // Encode timestamp (high bits first for sortability) 374 + let ts = timestamp; 375 for (let i = 0; i < 11; i++) { 376 + tid = TID_CHARS[ts & 31] + tid; 377 + ts = Math.floor(ts / 32); 378 } 379 380 // Append clock ID (2 chars) 381 + tid += TID_CHARS[(clockId >> 5) & 31]; 382 + tid += TID_CHARS[clockId & 31]; 383 384 + return tid; 385 } 386 387 // === P-256 SIGNING === ··· 394 */ 395 export async function importPrivateKey(privateKeyBytes) { 396 // Validate private key length (P-256 requires exactly 32 bytes) 397 + if ( 398 + !(privateKeyBytes instanceof Uint8Array) || 399 + privateKeyBytes.length !== 32 400 + ) { 401 + throw new Error( 402 + `Invalid private key: expected 32 bytes, got ${privateKeyBytes?.length ?? 'non-Uint8Array'}`, 403 + ); 404 } 405 406 // PKCS#8 wrapper for raw P-256 private key 407 const pkcs8Prefix = new Uint8Array([ 408 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 409 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 410 + 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20, 411 + ]); 412 413 + const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32); 414 + pkcs8.set(pkcs8Prefix); 415 + pkcs8.set(privateKeyBytes, pkcs8Prefix.length); 416 417 return crypto.subtle.importKey( 418 'pkcs8', 419 pkcs8, 420 { name: 'ECDSA', namedCurve: 'P-256' }, 421 false, 422 + ['sign'], 423 + ); 424 } 425 426 // P-256 curve order N 427 + const P256_N = BigInt( 428 + '0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551', 429 + ); 430 + const P256_N_DIV_2 = P256_N / 2n; 431 432 function bytesToBigInt(bytes) { 433 + let result = 0n; 434 for (const byte of bytes) { 435 + result = (result << 8n) | BigInt(byte); 436 } 437 + return result; 438 } 439 440 function bigIntToBytes(n, length) { 441 + const bytes = new Uint8Array(length); 442 for (let i = length - 1; i >= 0; i--) { 443 + bytes[i] = Number(n & 0xffn); 444 + n >>= 8n; 445 } 446 + return bytes; 447 } 448 449 /** ··· 456 const signature = await crypto.subtle.sign( 457 { name: 'ECDSA', hash: 'SHA-256' }, 458 privateKey, 459 + data, 460 + ); 461 + const sig = new Uint8Array(signature); 462 463 + const r = sig.slice(0, 32); 464 + const s = sig.slice(32, 64); 465 + const sBigInt = bytesToBigInt(s); 466 467 // Low-S normalization: Bitcoin/ATProto require S <= N/2 to prevent 468 // signature malleability (two valid signatures for same message) 469 if (sBigInt > P256_N_DIV_2) { 470 + const newS = P256_N - sBigInt; 471 + const newSBytes = bigIntToBytes(newS, 32); 472 + const normalized = new Uint8Array(64); 473 + normalized.set(r, 0); 474 + normalized.set(newSBytes, 32); 475 + return normalized; 476 } 477 478 + return sig; 479 } 480 481 /** ··· 486 const keyPair = await crypto.subtle.generateKey( 487 { name: 'ECDSA', namedCurve: 'P-256' }, 488 true, 489 + ['sign', 'verify'], 490 + ); 491 492 // Export private key as raw bytes 493 + const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey); 494 + const privateBytes = base64UrlDecode(privateJwk.d); 495 496 // Export public key as compressed point 497 + const publicRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey); 498 + const publicBytes = new Uint8Array(publicRaw); 499 + const compressed = compressPublicKey(publicBytes); 500 501 + return { privateKey: privateBytes, publicKey: compressed }; 502 } 503 504 function compressPublicKey(uncompressed) { 505 // uncompressed is 65 bytes: 0x04 + x(32) + y(32) 506 // compressed is 33 bytes: prefix(02 or 03) + x(32) 507 + const x = uncompressed.slice(1, 33); 508 + const y = uncompressed.slice(33, 65); 509 + const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03; 510 + const compressed = new Uint8Array(33); 511 + compressed[0] = prefix; 512 + compressed.set(x, 1); 513 + return compressed; 514 } 515 516 /** ··· 519 * @returns {string} Base64url-encoded string 520 */ 521 export function base64UrlEncode(bytes) { 522 + let binary = ''; 523 for (const byte of bytes) { 524 + binary += String.fromCharCode(byte); 525 } 526 + const base64 = btoa(binary); 527 + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); 528 } 529 530 /** ··· 533 * @returns {Uint8Array} Decoded bytes 534 */ 535 export function base64UrlDecode(str) { 536 + const base64 = str.replace(/-/g, '+').replace(/_/g, '/'); 537 + const pad = base64.length % 4; 538 + const padded = pad ? base64 + '='.repeat(4 - pad) : base64; 539 + const binary = atob(padded); 540 + const bytes = new Uint8Array(binary.length); 541 for (let i = 0; i < binary.length; i++) { 542 + bytes[i] = binary.charCodeAt(i); 543 } 544 + return bytes; 545 } 546 547 /** ··· 556 new TextEncoder().encode(secret), 557 { name: 'HMAC', hash: 'SHA-256' }, 558 false, 559 + ['sign'], 560 + ); 561 + const sig = await crypto.subtle.sign( 562 + 'HMAC', 563 + key, 564 + new TextEncoder().encode(data), 565 + ); 566 + return base64UrlEncode(new Uint8Array(sig)); 567 } 568 569 /** ··· 574 * @returns {Promise<string>} Signed JWT 575 */ 576 export async function createAccessJwt(did, secret, expiresIn = 7200) { 577 + const header = { typ: 'at+jwt', alg: 'HS256' }; 578 + const now = Math.floor(Date.now() / 1000); 579 const payload = { 580 scope: 'com.atproto.access', 581 sub: did, 582 aud: did, 583 iat: now, 584 + exp: now + expiresIn, 585 + }; 586 587 + const headerB64 = base64UrlEncode( 588 + new TextEncoder().encode(JSON.stringify(header)), 589 + ); 590 + const payloadB64 = base64UrlEncode( 591 + new TextEncoder().encode(JSON.stringify(payload)), 592 + ); 593 + const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret); 594 595 + return `${headerB64}.${payloadB64}.${signature}`; 596 } 597 598 /** ··· 603 * @returns {Promise<string>} Signed JWT 604 */ 605 export async function createRefreshJwt(did, secret, expiresIn = 7776000) { 606 + const header = { typ: 'refresh+jwt', alg: 'HS256' }; 607 + const now = Math.floor(Date.now() / 1000); 608 // Generate random jti (token ID) 609 + const jtiBytes = new Uint8Array(32); 610 + crypto.getRandomValues(jtiBytes); 611 + const jti = base64UrlEncode(jtiBytes); 612 613 const payload = { 614 scope: 'com.atproto.refresh', ··· 616 aud: did, 617 jti, 618 iat: now, 619 + exp: now + expiresIn, 620 + }; 621 622 + const headerB64 = base64UrlEncode( 623 + new TextEncoder().encode(JSON.stringify(header)), 624 + ); 625 + const payloadB64 = base64UrlEncode( 626 + new TextEncoder().encode(JSON.stringify(payload)), 627 + ); 628 + const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret); 629 630 + return `${headerB64}.${payloadB64}.${signature}`; 631 } 632 633 /** ··· 638 * @throws {Error} If token is invalid, expired, or wrong type 639 */ 640 export async function verifyAccessJwt(jwt, secret) { 641 + const parts = jwt.split('.'); 642 if (parts.length !== 3) { 643 + throw new Error('Invalid JWT format'); 644 } 645 646 + const [headerB64, payloadB64, signatureB64] = parts; 647 648 // Verify signature 649 + const expectedSig = await hmacSign(`${headerB64}.${payloadB64}`, secret); 650 if (signatureB64 !== expectedSig) { 651 + throw new Error('Invalid signature'); 652 } 653 654 // Decode header and payload 655 + const header = JSON.parse( 656 + new TextDecoder().decode(base64UrlDecode(headerB64)), 657 + ); 658 + const payload = JSON.parse( 659 + new TextDecoder().decode(base64UrlDecode(payloadB64)), 660 + ); 661 662 // Check token type 663 if (header.typ !== 'at+jwt') { 664 + throw new Error('Invalid token type: expected access token'); 665 } 666 667 // Check expiration 668 + const now = Math.floor(Date.now() / 1000); 669 if (payload.exp && payload.exp < now) { 670 + throw new Error('Token expired'); 671 } 672 673 + return payload; 674 } 675 676 /** ··· 684 * @returns {Promise<string>} Signed JWT 685 */ 686 export async function createServiceJwt({ iss, aud, lxm, signingKey }) { 687 + const header = { typ: 'JWT', alg: 'ES256' }; 688 + const now = Math.floor(Date.now() / 1000); 689 690 // Generate random jti 691 + const jtiBytes = new Uint8Array(16); 692 + crypto.getRandomValues(jtiBytes); 693 + const jti = bytesToHex(jtiBytes); 694 695 const payload = { 696 iss, 697 aud, 698 exp: now + 60, // 1 minute expiration 699 iat: now, 700 + jti, 701 + }; 702 + if (lxm) payload.lxm = lxm; 703 704 + const headerB64 = base64UrlEncode( 705 + new TextEncoder().encode(JSON.stringify(header)), 706 + ); 707 + const payloadB64 = base64UrlEncode( 708 + new TextEncoder().encode(JSON.stringify(payload)), 709 + ); 710 + const toSign = new TextEncoder().encode(`${headerB64}.${payloadB64}`); 711 712 + const sig = await sign(signingKey, toSign); 713 + const sigB64 = base64UrlEncode(sig); 714 715 + return `${headerB64}.${payloadB64}.${sigB64}`; 716 } 717 718 /** ··· 721 * @returns {string} Hex string 722 */ 723 export function bytesToHex(bytes) { 724 + return Array.from(bytes) 725 + .map((b) => b.toString(16).padStart(2, '0')) 726 + .join(''); 727 } 728 729 /** ··· 732 * @returns {Uint8Array} Decoded bytes 733 */ 734 export function hexToBytes(hex) { 735 + const bytes = new Uint8Array(hex.length / 2); 736 for (let i = 0; i < hex.length; i += 2) { 737 + bytes[i / 2] = parseInt(hex.substr(i, 2), 16); 738 } 739 + return bytes; 740 } 741 742 // === MERKLE SEARCH TREE === 743 // ATProto-compliant MST implementation 744 745 async function sha256(data) { 746 + const hash = await crypto.subtle.digest('SHA-256', data); 747 + return new Uint8Array(hash); 748 } 749 750 // Cache for key depths (SHA-256 is expensive) 751 + const keyDepthCache = new Map(); 752 753 /** 754 * Get MST tree depth for a key based on leading zeros in SHA-256 hash ··· 757 */ 758 export async function getKeyDepth(key) { 759 // Count leading zeros in SHA-256 hash, divide by 2 760 + if (keyDepthCache.has(key)) return keyDepthCache.get(key); 761 762 + const keyBytes = new TextEncoder().encode(key); 763 + const hash = await sha256(keyBytes); 764 765 + let zeros = 0; 766 for (const byte of hash) { 767 if (byte === 0) { 768 + zeros += 8; 769 } else { 770 // Count leading zeros in this byte 771 for (let i = 7; i >= 0; i--) { 772 + if ((byte >> i) & 1) break; 773 + zeros++; 774 } 775 + break; 776 } 777 } 778 779 // MST depth = leading zeros in SHA-256 hash / 2 780 // This creates a probabilistic tree where ~50% of keys are at depth 0, 781 // ~25% at depth 1, etc., giving O(log n) lookups 782 + const depth = Math.floor(zeros / 2); 783 + keyDepthCache.set(key, depth); 784 + return depth; 785 } 786 787 // Compute common prefix length between two byte arrays 788 function commonPrefixLen(a, b) { 789 + const minLen = Math.min(a.length, b.length); 790 for (let i = 0; i < minLen; i++) { 791 + if (a[i] !== b[i]) return i; 792 } 793 + return minLen; 794 } 795 796 class MST { 797 constructor(sql) { 798 + this.sql = sql; 799 } 800 801 async computeRoot() { 802 + const records = this.sql 803 + .exec(` 804 SELECT collection, rkey, cid FROM records ORDER BY collection, rkey 805 + `) 806 + .toArray(); 807 808 if (records.length === 0) { 809 + return null; 810 } 811 812 // Build entries with pre-computed depths (heights) 813 // In ATProto MST, "height" determines which layer a key belongs to 814 // Layer 0 is at the BOTTOM, root is at the highest layer 815 + const entries = []; 816 + let maxDepth = 0; 817 for (const r of records) { 818 + const key = `${r.collection}/${r.rkey}`; 819 + const depth = await getKeyDepth(key); 820 + maxDepth = Math.max(maxDepth, depth); 821 entries.push({ 822 key, 823 keyBytes: new TextEncoder().encode(key), 824 cid: r.cid, 825 + depth, 826 + }); 827 } 828 829 // Start building from the root (highest layer) going down to layer 0 830 + return this.buildTree(entries, maxDepth); 831 } 832 833 async buildTree(entries, layer) { 834 + if (entries.length === 0) return null; 835 836 // Separate entries for this layer vs lower layers (subtrees) 837 // Keys with depth == layer stay at this node 838 // Keys with depth < layer go into subtrees (going down toward layer 0) 839 + const thisLayer = []; 840 + let leftSubtree = []; 841 842 for (const entry of entries) { 843 if (entry.depth < layer) { 844 // This entry belongs to a lower layer - accumulate for subtree 845 + leftSubtree.push(entry); 846 } else { 847 // This entry belongs at current layer (depth == layer) 848 // Process accumulated left subtree first 849 if (leftSubtree.length > 0) { 850 + const leftCid = await this.buildTree(leftSubtree, layer - 1); 851 + thisLayer.push({ type: 'subtree', cid: leftCid }); 852 + leftSubtree = []; 853 } 854 + thisLayer.push({ type: 'entry', entry }); 855 } 856 } 857 858 // Handle remaining left subtree 859 if (leftSubtree.length > 0) { 860 + const leftCid = await this.buildTree(leftSubtree, layer - 1); 861 + thisLayer.push({ type: 'subtree', cid: leftCid }); 862 } 863 864 // Build node with proper ATProto format 865 + const node = { e: [] }; 866 + let leftCid = null; 867 + let prevKeyBytes = new Uint8Array(0); 868 869 for (let i = 0; i < thisLayer.length; i++) { 870 + const item = thisLayer[i]; 871 872 if (item.type === 'subtree') { 873 if (node.e.length === 0) { 874 + leftCid = item.cid; 875 } else { 876 // Attach to previous entry's 't' field 877 + node.e[node.e.length - 1].t = new CID(cidToBytes(item.cid)); 878 } 879 } else { 880 // Entry - compute prefix compression 881 + const keyBytes = item.entry.keyBytes; 882 + const prefixLen = commonPrefixLen(prevKeyBytes, keyBytes); 883 + const keySuffix = keyBytes.slice(prefixLen); 884 885 // ATProto requires t field to be present (can be null) 886 const e = { 887 p: prefixLen, 888 k: keySuffix, 889 v: new CID(cidToBytes(item.entry.cid)), 890 + t: null, // Will be updated if there's a subtree 891 + }; 892 893 + node.e.push(e); 894 + prevKeyBytes = keyBytes; 895 } 896 } 897 898 // ATProto requires l field to be present (can be null) 899 + node.l = leftCid ? new CID(cidToBytes(leftCid)) : null; 900 901 // Encode node with proper MST CBOR format 902 + const nodeBytes = cborEncodeDagCbor(node); 903 + const nodeCid = await createCid(nodeBytes); 904 + const cidStr = cidToString(nodeCid); 905 906 this.sql.exec( 907 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, 908 cidStr, 909 + nodeBytes, 910 + ); 911 912 + return cidStr; 913 } 914 } 915 ··· 921 * @returns {Uint8Array} Varint-encoded bytes 922 */ 923 export function varint(n) { 924 + const bytes = []; 925 while (n >= 0x80) { 926 + bytes.push((n & 0x7f) | 0x80); 927 + n >>>= 7; 928 } 929 + bytes.push(n); 930 + return new Uint8Array(bytes); 931 } 932 933 /** ··· 937 */ 938 export function cidToBytes(cidStr) { 939 // Decode base32lower CID string to bytes 940 + if (!cidStr.startsWith('b')) throw new Error('expected base32lower CID'); 941 + return base32Decode(cidStr.slice(1)); 942 } 943 944 /** ··· 947 * @returns {Uint8Array} Decoded bytes 948 */ 949 export function base32Decode(str) { 950 + const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; 951 + let bits = 0; 952 + let value = 0; 953 + const output = []; 954 955 for (const char of str) { 956 + const idx = alphabet.indexOf(char); 957 + if (idx === -1) continue; 958 + value = (value << 5) | idx; 959 + bits += 5; 960 if (bits >= 8) { 961 + bits -= 8; 962 + output.push((value >> bits) & 0xff); 963 } 964 } 965 966 + return new Uint8Array(output); 967 } 968 969 /** ··· 973 * @returns {Uint8Array} CAR file bytes 974 */ 975 export function buildCarFile(rootCid, blocks) { 976 + const parts = []; 977 978 // Header: { version: 1, roots: [rootCid] } 979 + const rootCidBytes = cidToBytes(rootCid); 980 + const header = cborEncodeDagCbor({ 981 + version: 1, 982 + roots: [new CID(rootCidBytes)], 983 + }); 984 + parts.push(varint(header.length)); 985 + parts.push(header); 986 987 // Blocks: varint(len) + cid + data 988 for (const block of blocks) { 989 + const cidBytes = cidToBytes(block.cid); 990 + const blockLen = cidBytes.length + block.data.length; 991 + parts.push(varint(blockLen)); 992 + parts.push(cidBytes); 993 + parts.push(block.data); 994 } 995 996 // Concatenate all parts 997 + const totalLen = parts.reduce((sum, p) => sum + p.length, 0); 998 + const car = new Uint8Array(totalLen); 999 + let offset = 0; 1000 for (const part of parts) { 1001 + car.set(part, offset); 1002 + offset += part.length; 1003 } 1004 1005 + return car; 1006 } 1007 1008 /** ··· 1023 /** @type {Record<string, Route>} */ 1024 const pdsRoutes = { 1025 '/.well-known/atproto-did': { 1026 + handler: (pds, _req, _url) => pds.handleAtprotoDid(), 1027 }, 1028 '/init': { 1029 method: 'POST', 1030 + handler: (pds, req, _url) => pds.handleInit(req), 1031 }, 1032 '/status': { 1033 + handler: (pds, _req, _url) => pds.handleStatus(), 1034 }, 1035 '/reset-repo': { 1036 + handler: (pds, _req, _url) => pds.handleResetRepo(), 1037 }, 1038 '/forward-event': { 1039 + handler: (pds, req, _url) => pds.handleForwardEvent(req), 1040 }, 1041 '/register-did': { 1042 + handler: (pds, req, _url) => pds.handleRegisterDid(req), 1043 }, 1044 '/get-registered-dids': { 1045 + handler: (pds, _req, _url) => pds.handleGetRegisteredDids(), 1046 }, 1047 '/register-handle': { 1048 method: 'POST', 1049 + handler: (pds, req, _url) => pds.handleRegisterHandle(req), 1050 }, 1051 '/resolve-handle': { 1052 + handler: (pds, _req, url) => pds.handleResolveHandle(url), 1053 }, 1054 '/repo-info': { 1055 + handler: (pds, _req, _url) => pds.handleRepoInfo(), 1056 }, 1057 '/xrpc/com.atproto.server.describeServer': { 1058 + handler: (pds, req, _url) => pds.handleDescribeServer(req), 1059 }, 1060 '/xrpc/com.atproto.server.createSession': { 1061 method: 'POST', 1062 + handler: (pds, req, _url) => pds.handleCreateSession(req), 1063 }, 1064 '/xrpc/com.atproto.server.getSession': { 1065 + handler: (pds, req, _url) => pds.handleGetSession(req), 1066 }, 1067 '/xrpc/app.bsky.actor.getPreferences': { 1068 + handler: (pds, req, _url) => pds.handleGetPreferences(req), 1069 }, 1070 '/xrpc/app.bsky.actor.putPreferences': { 1071 method: 'POST', 1072 + handler: (pds, req, _url) => pds.handlePutPreferences(req), 1073 }, 1074 '/xrpc/com.atproto.sync.listRepos': { 1075 + handler: (pds, _req, _url) => pds.handleListRepos(), 1076 }, 1077 '/xrpc/com.atproto.repo.createRecord': { 1078 method: 'POST', 1079 + handler: (pds, req, _url) => pds.handleCreateRecord(req), 1080 }, 1081 '/xrpc/com.atproto.repo.deleteRecord': { 1082 method: 'POST', 1083 + handler: (pds, req, _url) => pds.handleDeleteRecord(req), 1084 }, 1085 '/xrpc/com.atproto.repo.putRecord': { 1086 method: 'POST', 1087 + handler: (pds, req, _url) => pds.handlePutRecord(req), 1088 }, 1089 '/xrpc/com.atproto.repo.applyWrites': { 1090 method: 'POST', 1091 + handler: (pds, req, _url) => pds.handleApplyWrites(req), 1092 }, 1093 '/xrpc/com.atproto.repo.getRecord': { 1094 + handler: (pds, _req, url) => pds.handleGetRecord(url), 1095 }, 1096 '/xrpc/com.atproto.repo.describeRepo': { 1097 + handler: (pds, _req, _url) => pds.handleDescribeRepo(), 1098 }, 1099 '/xrpc/com.atproto.repo.listRecords': { 1100 + handler: (pds, _req, url) => pds.handleListRecords(url), 1101 }, 1102 '/xrpc/com.atproto.sync.getLatestCommit': { 1103 + handler: (pds, _req, _url) => pds.handleGetLatestCommit(), 1104 }, 1105 '/xrpc/com.atproto.sync.getRepoStatus': { 1106 + handler: (pds, _req, _url) => pds.handleGetRepoStatus(), 1107 }, 1108 '/xrpc/com.atproto.sync.getRepo': { 1109 + handler: (pds, _req, _url) => pds.handleGetRepo(), 1110 }, 1111 '/xrpc/com.atproto.sync.getRecord': { 1112 + handler: (pds, _req, url) => pds.handleSyncGetRecord(url), 1113 }, 1114 '/xrpc/com.atproto.sync.subscribeRepos': { 1115 + handler: (pds, req, url) => pds.handleSubscribeRepos(req, url), 1116 + }, 1117 + }; 1118 1119 export class PersonalDataServer { 1120 constructor(state, env) { 1121 + this.state = state; 1122 + this.sql = state.storage.sql; 1123 + this.env = env; 1124 1125 // Initialize schema 1126 this.sql.exec(` ··· 1152 ); 1153 1154 CREATE INDEX IF NOT EXISTS idx_records_collection ON records(collection, rkey); 1155 + `); 1156 } 1157 1158 async initIdentity(did, privateKeyHex, handle = null) { 1159 + await this.state.storage.put('did', did); 1160 + await this.state.storage.put('privateKey', privateKeyHex); 1161 if (handle) { 1162 + await this.state.storage.put('handle', handle); 1163 } 1164 } 1165 1166 async getDid() { 1167 if (!this._did) { 1168 + this._did = await this.state.storage.get('did'); 1169 } 1170 + return this._did; 1171 } 1172 1173 async getHandle() { 1174 + return this.state.storage.get('handle'); 1175 } 1176 1177 async getSigningKey() { 1178 + const hex = await this.state.storage.get('privateKey'); 1179 + if (!hex) return null; 1180 + return importPrivateKey(hexToBytes(hex)); 1181 } 1182 1183 // Collect MST node blocks for a given root CID 1184 collectMstBlocks(rootCidStr) { 1185 + const blocks = []; 1186 + const visited = new Set(); 1187 1188 const collect = (cidStr) => { 1189 + if (visited.has(cidStr)) return; 1190 + visited.add(cidStr); 1191 1192 + const rows = this.sql 1193 + .exec(`SELECT data FROM blocks WHERE cid = ?`, cidStr) 1194 + .toArray(); 1195 + if (rows.length === 0) return; 1196 1197 + const data = new Uint8Array(rows[0].data); 1198 + blocks.push({ cid: cidStr, data }); // Keep as string, buildCarFile will convert 1199 1200 // Decode and follow child CIDs (MST nodes have 'l' and 'e' with 't' subtrees) 1201 try { 1202 + const node = cborDecode(data); 1203 + if (node.l) collect(cidToString(node.l)); 1204 if (node.e) { 1205 for (const entry of node.e) { 1206 + if (entry.t) collect(cidToString(entry.t)); 1207 } 1208 } 1209 + } catch (_e) { 1210 // Not an MST node, ignore 1211 } 1212 + }; 1213 1214 + collect(rootCidStr); 1215 + return blocks; 1216 } 1217 1218 async createRecord(collection, record, rkey = null) { 1219 + const did = await this.getDid(); 1220 + if (!did) throw new Error('PDS not initialized'); 1221 1222 + rkey = rkey || createTid(); 1223 + const uri = `at://${did}/${collection}/${rkey}`; 1224 1225 // Encode and hash record (must use DAG-CBOR for proper key ordering) 1226 + const recordBytes = cborEncodeDagCbor(record); 1227 + const recordCid = await createCid(recordBytes); 1228 + const recordCidStr = cidToString(recordCid); 1229 1230 // Store block 1231 this.sql.exec( 1232 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, 1233 + recordCidStr, 1234 + recordBytes, 1235 + ); 1236 1237 // Store record index 1238 this.sql.exec( 1239 `INSERT OR REPLACE INTO records (uri, cid, collection, rkey, value) VALUES (?, ?, ?, ?, ?)`, 1240 + uri, 1241 + recordCidStr, 1242 + collection, 1243 + rkey, 1244 + recordBytes, 1245 + ); 1246 1247 // Rebuild MST 1248 + const mst = new MST(this.sql); 1249 + const dataRoot = await mst.computeRoot(); 1250 1251 // Get previous commit 1252 + const prevCommits = this.sql 1253 + .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`) 1254 + .toArray(); 1255 + const prevCommit = prevCommits.length > 0 ? prevCommits[0] : null; 1256 1257 // Create commit 1258 + const rev = createTid(); 1259 // Build commit with CIDs wrapped in CID class (for dag-cbor tag 42 encoding) 1260 const commit = { 1261 did, 1262 version: 3, 1263 + data: new CID(cidToBytes(dataRoot)), // CID wrapped for explicit encoding 1264 rev, 1265 + prev: prevCommit?.cid ? new CID(cidToBytes(prevCommit.cid)) : null, 1266 + }; 1267 1268 // Sign commit (using dag-cbor encoder for CIDs) 1269 + const commitBytes = cborEncodeDagCbor(commit); 1270 + const signingKey = await this.getSigningKey(); 1271 + const sig = await sign(signingKey, commitBytes); 1272 1273 + const signedCommit = { ...commit, sig }; 1274 + const signedBytes = cborEncodeDagCbor(signedCommit); 1275 + const commitCid = await createCid(signedBytes); 1276 + const commitCidStr = cidToString(commitCid); 1277 1278 // Store commit block 1279 this.sql.exec( 1280 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, 1281 + commitCidStr, 1282 + signedBytes, 1283 + ); 1284 1285 // Store commit reference 1286 this.sql.exec( 1287 `INSERT INTO commits (cid, rev, prev) VALUES (?, ?, ?)`, 1288 + commitCidStr, 1289 + rev, 1290 + prevCommit?.cid || null, 1291 + ); 1292 1293 // Update head and rev for listRepos 1294 + await this.state.storage.put('head', commitCidStr); 1295 + await this.state.storage.put('rev', rev); 1296 1297 // Collect blocks for the event (record + commit + MST nodes) 1298 // Build a mini CAR with just the new blocks - use string CIDs 1299 + const newBlocks = []; 1300 // Add record block 1301 + newBlocks.push({ cid: recordCidStr, data: recordBytes }); 1302 // Add commit block 1303 + newBlocks.push({ cid: commitCidStr, data: signedBytes }); 1304 // Add MST node blocks (get all blocks referenced by commit.data) 1305 + const mstBlocks = this.collectMstBlocks(dataRoot); 1306 + newBlocks.push(...mstBlocks); 1307 1308 // Sequence event with blocks - store complete event data including rev and time 1309 // blocks must be a full CAR file with header (roots = [commitCid]) 1310 + const eventTime = new Date().toISOString(); 1311 const evt = cborEncode({ 1312 + ops: [ 1313 + { action: 'create', path: `${collection}/${rkey}`, cid: recordCidStr }, 1314 + ], 1315 + blocks: buildCarFile(commitCidStr, newBlocks), // Full CAR with header 1316 + rev, // Store the actual commit revision 1317 + time: eventTime, // Store the actual event time 1318 + }); 1319 this.sql.exec( 1320 `INSERT INTO seq_events (did, commit_cid, evt) VALUES (?, ?, ?)`, 1321 + did, 1322 + commitCidStr, 1323 + evt, 1324 + ); 1325 1326 // Broadcast to subscribers (both local and via default DO for relay) 1327 + const evtRows = this.sql 1328 + .exec(`SELECT * FROM seq_events ORDER BY seq DESC LIMIT 1`) 1329 + .toArray(); 1330 if (evtRows.length > 0) { 1331 + this.broadcastEvent(evtRows[0]); 1332 // Also forward to default DO for relay subscribers 1333 if (this.env?.PDS) { 1334 + const defaultId = this.env.PDS.idFromName('default'); 1335 + const defaultPds = this.env.PDS.get(defaultId); 1336 // Convert ArrayBuffer to array for JSON serialization 1337 + const row = evtRows[0]; 1338 + const evtArray = Array.from(new Uint8Array(row.evt)); 1339 // Fire and forget but log errors 1340 + defaultPds 1341 + .fetch( 1342 + new Request('http://internal/forward-event', { 1343 + method: 'POST', 1344 + body: JSON.stringify({ ...row, evt: evtArray }), 1345 + }), 1346 + ) 1347 + .then((r) => r.json()) 1348 + .then((r) => console.log('forward result:', r)) 1349 + .catch((e) => console.log('forward error:', e)); 1350 } 1351 } 1352 1353 + return { uri, cid: recordCidStr, commit: commitCidStr }; 1354 } 1355 1356 async deleteRecord(collection, rkey) { 1357 + const did = await this.getDid(); 1358 + if (!did) throw new Error('PDS not initialized'); 1359 1360 + const uri = `at://${did}/${collection}/${rkey}`; 1361 1362 // Check if record exists 1363 + const existing = this.sql 1364 + .exec(`SELECT cid FROM records WHERE uri = ?`, uri) 1365 + .toArray(); 1366 if (existing.length === 0) { 1367 + return { error: 'RecordNotFound', message: 'record not found' }; 1368 } 1369 1370 // Delete from records table 1371 + this.sql.exec(`DELETE FROM records WHERE uri = ?`, uri); 1372 1373 // Rebuild MST 1374 + const mst = new MST(this.sql); 1375 + const dataRoot = await mst.computeRoot(); 1376 1377 // Get previous commit 1378 + const prevCommits = this.sql 1379 + .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`) 1380 + .toArray(); 1381 + const prevCommit = prevCommits.length > 0 ? prevCommits[0] : null; 1382 1383 // Create commit 1384 + const rev = createTid(); 1385 const commit = { 1386 did, 1387 version: 3, 1388 data: dataRoot ? new CID(cidToBytes(dataRoot)) : null, 1389 rev, 1390 + prev: prevCommit?.cid ? new CID(cidToBytes(prevCommit.cid)) : null, 1391 + }; 1392 1393 // Sign commit 1394 + const commitBytes = cborEncodeDagCbor(commit); 1395 + const signingKey = await this.getSigningKey(); 1396 + const sig = await sign(signingKey, commitBytes); 1397 1398 + const signedCommit = { ...commit, sig }; 1399 + const signedBytes = cborEncodeDagCbor(signedCommit); 1400 + const commitCid = await createCid(signedBytes); 1401 + const commitCidStr = cidToString(commitCid); 1402 1403 // Store commit block 1404 this.sql.exec( 1405 `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, 1406 + commitCidStr, 1407 + signedBytes, 1408 + ); 1409 1410 // Store commit reference 1411 this.sql.exec( 1412 `INSERT INTO commits (cid, rev, prev) VALUES (?, ?, ?)`, 1413 + commitCidStr, 1414 + rev, 1415 + prevCommit?.cid || null, 1416 + ); 1417 1418 // Update head and rev 1419 + await this.state.storage.put('head', commitCidStr); 1420 + await this.state.storage.put('rev', rev); 1421 1422 // Collect blocks for the event (commit + MST nodes, no record block) 1423 + const newBlocks = []; 1424 + newBlocks.push({ cid: commitCidStr, data: signedBytes }); 1425 if (dataRoot) { 1426 + const mstBlocks = this.collectMstBlocks(dataRoot); 1427 + newBlocks.push(...mstBlocks); 1428 } 1429 1430 // Sequence event with delete action 1431 + const eventTime = new Date().toISOString(); 1432 const evt = cborEncode({ 1433 ops: [{ action: 'delete', path: `${collection}/${rkey}`, cid: null }], 1434 blocks: buildCarFile(commitCidStr, newBlocks), 1435 rev, 1436 + time: eventTime, 1437 + }); 1438 this.sql.exec( 1439 `INSERT INTO seq_events (did, commit_cid, evt) VALUES (?, ?, ?)`, 1440 + did, 1441 + commitCidStr, 1442 + evt, 1443 + ); 1444 1445 // Broadcast to subscribers 1446 + const evtRows = this.sql 1447 + .exec(`SELECT * FROM seq_events ORDER BY seq DESC LIMIT 1`) 1448 + .toArray(); 1449 if (evtRows.length > 0) { 1450 + this.broadcastEvent(evtRows[0]); 1451 // Forward to default DO for relay subscribers 1452 if (this.env?.PDS) { 1453 + const defaultId = this.env.PDS.idFromName('default'); 1454 + const defaultPds = this.env.PDS.get(defaultId); 1455 + const row = evtRows[0]; 1456 + const evtArray = Array.from(new Uint8Array(row.evt)); 1457 + defaultPds 1458 + .fetch( 1459 + new Request('http://internal/forward-event', { 1460 + method: 'POST', 1461 + body: JSON.stringify({ ...row, evt: evtArray }), 1462 + }), 1463 + ) 1464 + .catch((e) => console.log('forward error:', e)); 1465 } 1466 } 1467 1468 + return { ok: true }; 1469 } 1470 1471 formatEvent(evt) { 1472 // AT Protocol frame format: header + body 1473 // Use DAG-CBOR encoding for body (CIDs need tag 42 + 0x00 prefix) 1474 + const header = cborEncode({ op: 1, t: '#commit' }); 1475 1476 // Decode stored event to get ops, blocks, rev, and time 1477 + const evtData = cborDecode(new Uint8Array(evt.evt)); 1478 + const ops = evtData.ops.map((op) => ({ 1479 ...op, 1480 + cid: op.cid ? new CID(cidToBytes(op.cid)) : null, // Wrap in CID class for tag 42 encoding 1481 + })); 1482 // Get blocks from stored event (already in CAR format) 1483 + const blocks = evtData.blocks || new Uint8Array(0); 1484 1485 const body = cborEncodeDagCbor({ 1486 seq: evt.seq, 1487 rebase: false, 1488 tooBig: false, 1489 repo: evt.did, 1490 + commit: new CID(cidToBytes(evt.commit_cid)), // Wrap in CID class for tag 42 encoding 1491 + rev: evtData.rev, // Use stored rev from commit creation 1492 since: null, 1493 blocks: blocks instanceof Uint8Array ? blocks : new Uint8Array(blocks), 1494 ops, 1495 blobs: [], 1496 + time: evtData.time, // Use stored time from event creation 1497 + }); 1498 1499 // Concatenate header + body 1500 + const frame = new Uint8Array(header.length + body.length); 1501 + frame.set(header); 1502 + frame.set(body, header.length); 1503 + return frame; 1504 } 1505 1506 async webSocketMessage(ws, message) { 1507 // Handle ping 1508 + if (message === 'ping') ws.send('pong'); 1509 } 1510 1511 + async webSocketClose(_ws, _code, _reason) { 1512 // Durable Object will hibernate when no connections remain 1513 } 1514 1515 broadcastEvent(evt) { 1516 + const frame = this.formatEvent(evt); 1517 for (const ws of this.state.getWebSockets()) { 1518 try { 1519 + ws.send(frame); 1520 + } catch (_e) { 1521 // Client disconnected 1522 } 1523 } 1524 } 1525 1526 async handleAtprotoDid() { 1527 + let did = await this.getDid(); 1528 if (!did) { 1529 + const registeredDids = 1530 + (await this.state.storage.get('registeredDids')) || []; 1531 + did = registeredDids[0]; 1532 } 1533 if (!did) { 1534 + return new Response('User not found', { status: 404 }); 1535 } 1536 + return new Response(did, { headers: { 'Content-Type': 'text/plain' } }); 1537 } 1538 1539 async handleInit(request) { 1540 + const body = await request.json(); 1541 if (!body.did || !body.privateKey) { 1542 + return errorResponse('InvalidRequest', 'missing did or privateKey', 400); 1543 } 1544 + await this.initIdentity(body.did, body.privateKey, body.handle || null); 1545 + return Response.json({ 1546 + ok: true, 1547 + did: body.did, 1548 + handle: body.handle || null, 1549 + }); 1550 } 1551 1552 async handleStatus() { 1553 + const did = await this.getDid(); 1554 + return Response.json({ initialized: !!did, did: did || null }); 1555 } 1556 1557 async handleResetRepo() { 1558 + this.sql.exec(`DELETE FROM blocks`); 1559 + this.sql.exec(`DELETE FROM records`); 1560 + this.sql.exec(`DELETE FROM commits`); 1561 + this.sql.exec(`DELETE FROM seq_events`); 1562 + await this.state.storage.delete('head'); 1563 + await this.state.storage.delete('rev'); 1564 + return Response.json({ ok: true, message: 'repo data cleared' }); 1565 } 1566 1567 async handleForwardEvent(request) { 1568 + const evt = await request.json(); 1569 + const numSockets = [...this.state.getWebSockets()].length; 1570 + console.log( 1571 + `forward-event: received event seq=${evt.seq}, ${numSockets} connected sockets`, 1572 + ); 1573 this.broadcastEvent({ 1574 seq: evt.seq, 1575 did: evt.did, 1576 commit_cid: evt.commit_cid, 1577 + evt: new Uint8Array(Object.values(evt.evt)), 1578 + }); 1579 + return Response.json({ ok: true, sockets: numSockets }); 1580 } 1581 1582 async handleRegisterDid(request) { 1583 + const body = await request.json(); 1584 + const registeredDids = 1585 + (await this.state.storage.get('registeredDids')) || []; 1586 if (!registeredDids.includes(body.did)) { 1587 + registeredDids.push(body.did); 1588 + await this.state.storage.put('registeredDids', registeredDids); 1589 } 1590 + return Response.json({ ok: true }); 1591 } 1592 1593 async handleGetRegisteredDids() { 1594 + const registeredDids = 1595 + (await this.state.storage.get('registeredDids')) || []; 1596 + return Response.json({ dids: registeredDids }); 1597 } 1598 1599 async handleRegisterHandle(request) { 1600 + const body = await request.json(); 1601 + const { handle, did } = body; 1602 if (!handle || !did) { 1603 + return errorResponse('InvalidRequest', 'missing handle or did', 400); 1604 } 1605 + const handleMap = (await this.state.storage.get('handleMap')) || {}; 1606 + handleMap[handle] = did; 1607 + await this.state.storage.put('handleMap', handleMap); 1608 + return Response.json({ ok: true }); 1609 } 1610 1611 async handleResolveHandle(url) { 1612 + const handle = url.searchParams.get('handle'); 1613 if (!handle) { 1614 + return errorResponse('InvalidRequest', 'missing handle', 400); 1615 } 1616 + const handleMap = (await this.state.storage.get('handleMap')) || {}; 1617 + const did = handleMap[handle]; 1618 if (!did) { 1619 + return errorResponse('NotFound', 'handle not found', 404); 1620 } 1621 + return Response.json({ did }); 1622 } 1623 1624 async handleRepoInfo() { 1625 + const head = await this.state.storage.get('head'); 1626 + const rev = await this.state.storage.get('rev'); 1627 + return Response.json({ head: head || null, rev: rev || null }); 1628 } 1629 1630 handleDescribeServer(request) { 1631 + const hostname = request.headers.get('x-hostname') || 'localhost'; 1632 return Response.json({ 1633 did: `did:web:${hostname}`, 1634 availableUserDomains: [`.${hostname}`], 1635 inviteCodeRequired: false, 1636 phoneVerificationRequired: false, 1637 links: {}, 1638 + contact: {}, 1639 + }); 1640 } 1641 1642 async handleCreateSession(request) { 1643 + const body = await request.json(); 1644 + const { identifier, password } = body; 1645 1646 if (!identifier || !password) { 1647 + return errorResponse( 1648 + 'InvalidRequest', 1649 + 'Missing identifier or password', 1650 + 400, 1651 + ); 1652 } 1653 1654 // Check password against env var 1655 + const expectedPassword = this.env?.PDS_PASSWORD; 1656 if (!expectedPassword || password !== expectedPassword) { 1657 + return errorResponse( 1658 + 'AuthenticationRequired', 1659 + 'Invalid identifier or password', 1660 + 401, 1661 + ); 1662 } 1663 1664 // Resolve identifier to DID 1665 + let did = identifier; 1666 if (!identifier.startsWith('did:')) { 1667 // Try to resolve handle 1668 + const handleMap = (await this.state.storage.get('handleMap')) || {}; 1669 + did = handleMap[identifier]; 1670 if (!did) { 1671 + return errorResponse('InvalidRequest', 'Unable to resolve handle', 400); 1672 } 1673 } 1674 1675 // Get handle for response 1676 + const handle = await this.getHandleForDid(did); 1677 1678 // Create tokens 1679 + const jwtSecret = this.env?.JWT_SECRET; 1680 if (!jwtSecret) { 1681 + return errorResponse( 1682 + 'InternalServerError', 1683 + 'Server not configured for authentication', 1684 + 500, 1685 + ); 1686 } 1687 1688 + const accessJwt = await createAccessJwt(did, jwtSecret); 1689 + const refreshJwt = await createRefreshJwt(did, jwtSecret); 1690 1691 return Response.json({ 1692 accessJwt, 1693 refreshJwt, 1694 handle: handle || did, 1695 did, 1696 + active: true, 1697 + }); 1698 } 1699 1700 async handleGetSession(request) { 1701 + const authHeader = request.headers.get('Authorization'); 1702 if (!authHeader || !authHeader.startsWith('Bearer ')) { 1703 + return errorResponse( 1704 + 'AuthenticationRequired', 1705 + 'Missing or invalid authorization header', 1706 + 401, 1707 + ); 1708 } 1709 1710 + const token = authHeader.slice(7); // Remove 'Bearer ' 1711 + const jwtSecret = this.env?.JWT_SECRET; 1712 if (!jwtSecret) { 1713 + return errorResponse( 1714 + 'InternalServerError', 1715 + 'Server not configured for authentication', 1716 + 500, 1717 + ); 1718 } 1719 1720 try { 1721 + const payload = await verifyAccessJwt(token, jwtSecret); 1722 + const did = payload.sub; 1723 + const handle = await this.getHandleForDid(did); 1724 1725 return Response.json({ 1726 handle: handle || did, 1727 did, 1728 + active: true, 1729 + }); 1730 } catch (err) { 1731 + return errorResponse('InvalidToken', err.message, 401); 1732 } 1733 } 1734 1735 + async handleGetPreferences(_request) { 1736 // Preferences are stored per-user in their DO 1737 + const preferences = (await this.state.storage.get('preferences')) || []; 1738 + return Response.json({ preferences }); 1739 } 1740 1741 async handlePutPreferences(request) { 1742 + const body = await request.json(); 1743 + const { preferences } = body; 1744 if (!Array.isArray(preferences)) { 1745 + return errorResponse( 1746 + 'InvalidRequest', 1747 + 'preferences must be an array', 1748 + 400, 1749 + ); 1750 } 1751 + await this.state.storage.put('preferences', preferences); 1752 + return Response.json({}); 1753 } 1754 1755 async getHandleForDid(did) { 1756 // Check if this DID has a handle registered 1757 + const handleMap = (await this.state.storage.get('handleMap')) || {}; 1758 for (const [handle, mappedDid] of Object.entries(handleMap)) { 1759 + if (mappedDid === did) return handle; 1760 } 1761 // Check instance's own handle 1762 + const instanceDid = await this.getDid(); 1763 if (instanceDid === did) { 1764 + return await this.state.storage.get('handle'); 1765 } 1766 + return null; 1767 } 1768 1769 async createServiceAuthForAppView(did, lxm) { 1770 + const signingKey = await this.getSigningKey(); 1771 return createServiceJwt({ 1772 iss: did, 1773 aud: 'did:web:api.bsky.app', 1774 lxm, 1775 + signingKey, 1776 + }); 1777 } 1778 1779 async handleAppViewProxy(request, userDid) { 1780 + const url = new URL(request.url); 1781 // Extract lexicon method from path: /xrpc/app.bsky.actor.getPreferences -> app.bsky.actor.getPreferences 1782 + const lxm = url.pathname.replace('/xrpc/', ''); 1783 1784 // Create service auth JWT 1785 + const serviceJwt = await this.createServiceAuthForAppView(userDid, lxm); 1786 1787 // Build AppView URL 1788 + const appViewUrl = new URL( 1789 + url.pathname + url.search, 1790 + 'https://api.bsky.app', 1791 + ); 1792 1793 // Forward request with service auth 1794 + const headers = new Headers(); 1795 + headers.set('Authorization', `Bearer ${serviceJwt}`); 1796 + headers.set( 1797 + 'Content-Type', 1798 + request.headers.get('Content-Type') || 'application/json', 1799 + ); 1800 if (request.headers.get('Accept')) { 1801 + headers.set('Accept', request.headers.get('Accept')); 1802 } 1803 if (request.headers.get('Accept-Language')) { 1804 + headers.set('Accept-Language', request.headers.get('Accept-Language')); 1805 } 1806 1807 const proxyReq = new Request(appViewUrl.toString(), { 1808 method: request.method, 1809 headers, 1810 + body: 1811 + request.method !== 'GET' && request.method !== 'HEAD' 1812 + ? request.body 1813 + : undefined, 1814 + }); 1815 1816 try { 1817 + const response = await fetch(proxyReq); 1818 // Return the response with CORS headers 1819 + const responseHeaders = new Headers(response.headers); 1820 + responseHeaders.set('Access-Control-Allow-Origin', '*'); 1821 return new Response(response.body, { 1822 status: response.status, 1823 statusText: response.statusText, 1824 + headers: responseHeaders, 1825 + }); 1826 } catch (err) { 1827 + return errorResponse( 1828 + 'UpstreamFailure', 1829 + `Failed to reach AppView: ${err.message}`, 1830 + 502, 1831 + ); 1832 } 1833 } 1834 1835 async handleListRepos() { 1836 + const registeredDids = 1837 + (await this.state.storage.get('registeredDids')) || []; 1838 + const did = await this.getDid(); 1839 + const repos = did 1840 + ? [{ did, head: null, rev: null }] 1841 + : registeredDids.map((d) => ({ did: d, head: null, rev: null })); 1842 + return Response.json({ repos }); 1843 } 1844 1845 async handleCreateRecord(request) { 1846 + const body = await request.json(); 1847 if (!body.collection || !body.record) { 1848 + return errorResponse( 1849 + 'InvalidRequest', 1850 + 'missing collection or record', 1851 + 400, 1852 + ); 1853 } 1854 try { 1855 + const result = await this.createRecord( 1856 + body.collection, 1857 + body.record, 1858 + body.rkey, 1859 + ); 1860 + const head = await this.state.storage.get('head'); 1861 + const rev = await this.state.storage.get('rev'); 1862 return Response.json({ 1863 uri: result.uri, 1864 cid: result.cid, 1865 commit: { cid: head, rev }, 1866 + validationStatus: 'valid', 1867 + }); 1868 } catch (err) { 1869 + return errorResponse('InternalError', err.message, 500); 1870 } 1871 } 1872 1873 async handleDeleteRecord(request) { 1874 + const body = await request.json(); 1875 if (!body.collection || !body.rkey) { 1876 + return errorResponse('InvalidRequest', 'missing collection or rkey', 400); 1877 } 1878 try { 1879 + const result = await this.deleteRecord(body.collection, body.rkey); 1880 if (result.error) { 1881 + return Response.json(result, { status: 404 }); 1882 } 1883 + return Response.json({}); 1884 } catch (err) { 1885 + return errorResponse('InternalError', err.message, 500); 1886 } 1887 } 1888 1889 async handlePutRecord(request) { 1890 + const body = await request.json(); 1891 if (!body.collection || !body.rkey || !body.record) { 1892 + return errorResponse( 1893 + 'InvalidRequest', 1894 + 'missing collection, rkey, or record', 1895 + 400, 1896 + ); 1897 } 1898 try { 1899 // putRecord is like createRecord but with a specific rkey (upsert) 1900 + const result = await this.createRecord( 1901 + body.collection, 1902 + body.record, 1903 + body.rkey, 1904 + ); 1905 + const head = await this.state.storage.get('head'); 1906 + const rev = await this.state.storage.get('rev'); 1907 return Response.json({ 1908 uri: result.uri, 1909 cid: result.cid, 1910 commit: { cid: head, rev }, 1911 + validationStatus: 'valid', 1912 + }); 1913 } catch (err) { 1914 + return errorResponse('InternalError', err.message, 500); 1915 } 1916 } 1917 1918 async handleApplyWrites(request) { 1919 + const body = await request.json(); 1920 if (!body.writes || !Array.isArray(body.writes)) { 1921 + return errorResponse('InvalidRequest', 'missing writes array', 400); 1922 } 1923 try { 1924 + const results = []; 1925 for (const write of body.writes) { 1926 + const type = write.$type; 1927 if (type === 'com.atproto.repo.applyWrites#create') { 1928 + const result = await this.createRecord( 1929 + write.collection, 1930 + write.value, 1931 + write.rkey, 1932 + ); 1933 results.push({ 1934 $type: 'com.atproto.repo.applyWrites#createResult', 1935 uri: result.uri, 1936 cid: result.cid, 1937 + validationStatus: 'valid', 1938 + }); 1939 } else if (type === 'com.atproto.repo.applyWrites#update') { 1940 + const result = await this.createRecord( 1941 + write.collection, 1942 + write.value, 1943 + write.rkey, 1944 + ); 1945 results.push({ 1946 $type: 'com.atproto.repo.applyWrites#updateResult', 1947 uri: result.uri, 1948 cid: result.cid, 1949 + validationStatus: 'valid', 1950 + }); 1951 } else if (type === 'com.atproto.repo.applyWrites#delete') { 1952 + await this.deleteRecord(write.collection, write.rkey); 1953 results.push({ 1954 + $type: 'com.atproto.repo.applyWrites#deleteResult', 1955 + }); 1956 } else { 1957 + return errorResponse( 1958 + 'InvalidRequest', 1959 + `Unknown write operation type: ${type}`, 1960 + 400, 1961 + ); 1962 } 1963 } 1964 // Return commit info 1965 + const head = await this.state.storage.get('head'); 1966 + const rev = await this.state.storage.get('rev'); 1967 + return Response.json({ commit: { cid: head, rev }, results }); 1968 } catch (err) { 1969 + return errorResponse('InternalError', err.message, 500); 1970 } 1971 } 1972 1973 async handleGetRecord(url) { 1974 + const collection = url.searchParams.get('collection'); 1975 + const rkey = url.searchParams.get('rkey'); 1976 if (!collection || !rkey) { 1977 + return errorResponse('InvalidRequest', 'missing collection or rkey', 400); 1978 } 1979 + const did = await this.getDid(); 1980 + const uri = `at://${did}/${collection}/${rkey}`; 1981 + const rows = this.sql 1982 + .exec(`SELECT cid, value FROM records WHERE uri = ?`, uri) 1983 + .toArray(); 1984 if (rows.length === 0) { 1985 + return errorResponse('RecordNotFound', 'record not found', 404); 1986 } 1987 + const row = rows[0]; 1988 + const value = cborDecode(new Uint8Array(row.value)); 1989 + return Response.json({ uri, cid: row.cid, value }); 1990 } 1991 1992 async handleDescribeRepo() { 1993 + const did = await this.getDid(); 1994 if (!did) { 1995 + return errorResponse('RepoNotFound', 'repo not found', 404); 1996 } 1997 + const handle = await this.state.storage.get('handle'); 1998 // Get unique collections 1999 + const collections = this.sql 2000 + .exec(`SELECT DISTINCT collection FROM records`) 2001 + .toArray() 2002 + .map((r) => r.collection); 2003 2004 return Response.json({ 2005 handle: handle || did, 2006 did, 2007 didDoc: {}, 2008 collections, 2009 + handleIsCorrect: !!handle, 2010 + }); 2011 } 2012 2013 async handleListRecords(url) { 2014 + const collection = url.searchParams.get('collection'); 2015 if (!collection) { 2016 + return errorResponse('InvalidRequest', 'missing collection', 400); 2017 } 2018 + const limit = Math.min( 2019 + parseInt(url.searchParams.get('limit') || '50', 10), 2020 + 100, 2021 + ); 2022 + const reverse = url.searchParams.get('reverse') === 'true'; 2023 + const _cursor = url.searchParams.get('cursor'); 2024 2025 + const _did = await this.getDid(); 2026 + const query = `SELECT uri, cid, value FROM records WHERE collection = ? ORDER BY rkey ${reverse ? 'DESC' : 'ASC'} LIMIT ?`; 2027 + const params = [collection, limit + 1]; 2028 2029 + const rows = this.sql.exec(query, ...params).toArray(); 2030 + const hasMore = rows.length > limit; 2031 + const records = rows.slice(0, limit).map((r) => ({ 2032 uri: r.uri, 2033 cid: r.cid, 2034 + value: cborDecode(new Uint8Array(r.value)), 2035 + })); 2036 2037 return Response.json({ 2038 records, 2039 + cursor: hasMore ? records[records.length - 1]?.uri : undefined, 2040 + }); 2041 } 2042 2043 handleGetLatestCommit() { 2044 + const commits = this.sql 2045 + .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`) 2046 + .toArray(); 2047 if (commits.length === 0) { 2048 + return errorResponse('RepoNotFound', 'repo not found', 404); 2049 } 2050 + return Response.json({ cid: commits[0].cid, rev: commits[0].rev }); 2051 } 2052 2053 async handleGetRepoStatus() { 2054 + const did = await this.getDid(); 2055 + const commits = this.sql 2056 + .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`) 2057 + .toArray(); 2058 if (commits.length === 0 || !did) { 2059 + return errorResponse('RepoNotFound', 'repo not found', 404); 2060 } 2061 + return Response.json({ 2062 + did, 2063 + active: true, 2064 + status: 'active', 2065 + rev: commits[0].rev, 2066 + }); 2067 } 2068 2069 handleGetRepo() { 2070 + const commits = this.sql 2071 + .exec(`SELECT cid FROM commits ORDER BY seq DESC LIMIT 1`) 2072 + .toArray(); 2073 if (commits.length === 0) { 2074 + return errorResponse('RepoNotFound', 'repo not found', 404); 2075 } 2076 2077 // Only include blocks reachable from the current commit 2078 + const commitCid = commits[0].cid; 2079 + const neededCids = new Set(); 2080 2081 // Helper to get block data 2082 const getBlock = (cid) => { 2083 + const rows = this.sql 2084 + .exec(`SELECT data FROM blocks WHERE cid = ?`, cid) 2085 + .toArray(); 2086 + return rows.length > 0 ? new Uint8Array(rows[0].data) : null; 2087 + }; 2088 2089 // Collect all reachable blocks starting from commit 2090 const collectBlocks = (cid) => { 2091 + if (neededCids.has(cid)) return; 2092 + neededCids.add(cid); 2093 2094 + const data = getBlock(cid); 2095 + if (!data) return; 2096 2097 // Decode CBOR to find CID references 2098 try { 2099 + const decoded = cborDecode(data); 2100 if (decoded && typeof decoded === 'object') { 2101 // Commit object - follow 'data' (MST root) 2102 if (decoded.data instanceof Uint8Array) { 2103 + collectBlocks(cidToString(decoded.data)); 2104 } 2105 // MST node - follow 'l' and entries' 'v' and 't' 2106 if (decoded.l instanceof Uint8Array) { 2107 + collectBlocks(cidToString(decoded.l)); 2108 } 2109 if (Array.isArray(decoded.e)) { 2110 for (const entry of decoded.e) { 2111 if (entry.v instanceof Uint8Array) { 2112 + collectBlocks(cidToString(entry.v)); 2113 } 2114 if (entry.t instanceof Uint8Array) { 2115 + collectBlocks(cidToString(entry.t)); 2116 } 2117 } 2118 } 2119 } 2120 + } catch (_e) { 2121 // Not a structured block, that's fine 2122 } 2123 + }; 2124 2125 + collectBlocks(commitCid); 2126 2127 // Build CAR with only needed blocks 2128 + const blocksForCar = []; 2129 for (const cid of neededCids) { 2130 + const data = getBlock(cid); 2131 if (data) { 2132 + blocksForCar.push({ cid, data }); 2133 } 2134 } 2135 2136 + const car = buildCarFile(commitCid, blocksForCar); 2137 return new Response(car, { 2138 + headers: { 'content-type': 'application/vnd.ipld.car' }, 2139 + }); 2140 } 2141 2142 async handleSyncGetRecord(url) { 2143 + const collection = url.searchParams.get('collection'); 2144 + const rkey = url.searchParams.get('rkey'); 2145 if (!collection || !rkey) { 2146 + return errorResponse('InvalidRequest', 'missing collection or rkey', 400); 2147 } 2148 + const did = await this.getDid(); 2149 + const uri = `at://${did}/${collection}/${rkey}`; 2150 + const rows = this.sql 2151 + .exec(`SELECT cid FROM records WHERE uri = ?`, uri) 2152 + .toArray(); 2153 if (rows.length === 0) { 2154 + return errorResponse('RecordNotFound', 'record not found', 404); 2155 } 2156 + const recordCid = rows[0].cid; 2157 2158 // Get latest commit 2159 + const commits = this.sql 2160 + .exec(`SELECT cid FROM commits ORDER BY seq DESC LIMIT 1`) 2161 + .toArray(); 2162 if (commits.length === 0) { 2163 + return errorResponse('RepoNotFound', 'no commits', 404); 2164 } 2165 + const commitCid = commits[0].cid; 2166 2167 // Build proof chain: commit -> MST path -> record 2168 // Include commit block, all MST nodes on path to record, and record block 2169 + const blocks = []; 2170 + const included = new Set(); 2171 2172 const addBlock = (cidStr) => { 2173 + if (included.has(cidStr)) return; 2174 + included.add(cidStr); 2175 + const blockRows = this.sql 2176 + .exec(`SELECT data FROM blocks WHERE cid = ?`, cidStr) 2177 + .toArray(); 2178 if (blockRows.length > 0) { 2179 + blocks.push({ cid: cidStr, data: new Uint8Array(blockRows[0].data) }); 2180 } 2181 + }; 2182 2183 // Add commit block 2184 + addBlock(commitCid); 2185 2186 // Get commit to find data root 2187 + const commitRows = this.sql 2188 + .exec(`SELECT data FROM blocks WHERE cid = ?`, commitCid) 2189 + .toArray(); 2190 if (commitRows.length > 0) { 2191 + const commit = cborDecode(new Uint8Array(commitRows[0].data)); 2192 if (commit.data) { 2193 + const dataRootCid = cidToString(commit.data); 2194 // Collect MST path blocks (this includes all MST nodes) 2195 + const mstBlocks = this.collectMstBlocks(dataRootCid); 2196 for (const block of mstBlocks) { 2197 + addBlock(block.cid); 2198 } 2199 } 2200 } 2201 2202 // Add record block 2203 + addBlock(recordCid); 2204 2205 + const car = buildCarFile(commitCid, blocks); 2206 return new Response(car, { 2207 + headers: { 'content-type': 'application/vnd.ipld.car' }, 2208 + }); 2209 } 2210 2211 handleSubscribeRepos(request, url) { 2212 + const upgradeHeader = request.headers.get('Upgrade'); 2213 if (upgradeHeader !== 'websocket') { 2214 + return new Response('expected websocket', { status: 426 }); 2215 } 2216 + const { 0: client, 1: server } = new WebSocketPair(); 2217 + this.state.acceptWebSocket(server); 2218 + const cursor = url.searchParams.get('cursor'); 2219 if (cursor) { 2220 + const events = this.sql 2221 + .exec( 2222 + `SELECT * FROM seq_events WHERE seq > ? ORDER BY seq`, 2223 + parseInt(cursor, 10), 2224 + ) 2225 + .toArray(); 2226 for (const evt of events) { 2227 + server.send(this.formatEvent(evt)); 2228 } 2229 } 2230 + return new Response(null, { status: 101, webSocket: client }); 2231 } 2232 2233 async fetch(request) { 2234 + const url = new URL(request.url); 2235 + const route = pdsRoutes[url.pathname]; 2236 2237 // Check for local route first 2238 if (route) { 2239 if (route.method && request.method !== route.method) { 2240 + return errorResponse('MethodNotAllowed', 'method not allowed', 405); 2241 } 2242 + return route.handler(this, request, url); 2243 } 2244 2245 // Handle app.bsky.* proxy requests (only if no local route) 2246 if (url.pathname.startsWith('/xrpc/app.bsky.')) { 2247 + const userDid = request.headers.get('x-authed-did'); 2248 if (!userDid) { 2249 + return errorResponse('Unauthorized', 'Missing auth context', 401); 2250 } 2251 + return this.handleAppViewProxy(request, userDid); 2252 } 2253 2254 + return errorResponse('NotFound', 'not found', 404); 2255 } 2256 } 2257 2258 const corsHeaders = { 2259 'Access-Control-Allow-Origin': '*', 2260 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 2261 + 'Access-Control-Allow-Headers': 2262 + 'Content-Type, Authorization, atproto-accept-labelers, atproto-proxy, x-bsky-topics', 2263 + }; 2264 2265 function addCorsHeaders(response) { 2266 + const newHeaders = new Headers(response.headers); 2267 for (const [key, value] of Object.entries(corsHeaders)) { 2268 + newHeaders.set(key, value); 2269 } 2270 return new Response(response.body, { 2271 status: response.status, 2272 statusText: response.statusText, 2273 + headers: newHeaders, 2274 + }); 2275 } 2276 2277 export default { 2278 async fetch(request, env) { 2279 // Handle CORS preflight 2280 if (request.method === 'OPTIONS') { 2281 + return new Response(null, { headers: corsHeaders }); 2282 } 2283 2284 + const response = await handleRequest(request, env); 2285 // Don't wrap WebSocket upgrades - they need the webSocket property preserved 2286 if (response.status === 101) { 2287 + return response; 2288 } 2289 + return addCorsHeaders(response); 2290 + }, 2291 + }; 2292 2293 // Extract subdomain from hostname (e.g., "alice" from "alice.foo.workers.dev") 2294 function getSubdomain(hostname) { 2295 + const parts = hostname.split('.'); 2296 // workers.dev domains: [subdomain?].[worker-name].[account].workers.dev 2297 // If more than 4 parts, first part(s) are user subdomain 2298 if (parts.length > 4 && parts.slice(-2).join('.') === 'workers.dev') { 2299 + return parts.slice(0, -4).join('.'); 2300 } 2301 // Custom domains: check if there's a subdomain before the base 2302 // For now, assume no subdomain on custom domains 2303 + return null; 2304 } 2305 2306 /** ··· 2310 * @returns {Promise<{did: string} | {error: Response}>} DID or error response 2311 */ 2312 async function requireAuth(request, env) { 2313 + const authHeader = request.headers.get('Authorization'); 2314 if (!authHeader || !authHeader.startsWith('Bearer ')) { 2315 return { 2316 + error: Response.json( 2317 + { 2318 + error: 'AuthenticationRequired', 2319 + message: 'Authentication required', 2320 + }, 2321 + { status: 401 }, 2322 + ), 2323 + }; 2324 } 2325 2326 + const token = authHeader.slice(7); 2327 + const jwtSecret = env?.JWT_SECRET; 2328 if (!jwtSecret) { 2329 return { 2330 + error: Response.json( 2331 + { 2332 + error: 'InternalServerError', 2333 + message: 'Server not configured for authentication', 2334 + }, 2335 + { status: 500 }, 2336 + ), 2337 + }; 2338 } 2339 2340 try { 2341 + const payload = await verifyAccessJwt(token, jwtSecret); 2342 + return { did: payload.sub }; 2343 } catch (err) { 2344 return { 2345 + error: Response.json( 2346 + { 2347 + error: 'InvalidToken', 2348 + message: err.message, 2349 + }, 2350 + { status: 401 }, 2351 + ), 2352 + }; 2353 } 2354 } 2355 2356 async function handleAuthenticatedRepoWrite(request, env) { 2357 + const auth = await requireAuth(request, env); 2358 + if (auth.error) return auth.error; 2359 2360 + const body = await request.json(); 2361 + const repo = body.repo; 2362 if (!repo) { 2363 + return errorResponse('InvalidRequest', 'missing repo param', 400); 2364 } 2365 2366 if (auth.did !== repo) { 2367 + return errorResponse('Forbidden', "Cannot modify another user's repo", 403); 2368 } 2369 2370 + const id = env.PDS.idFromName(repo); 2371 + const pds = env.PDS.get(id); 2372 + const response = await pds.fetch( 2373 + new Request(request.url, { 2374 + method: 'POST', 2375 + headers: request.headers, 2376 + body: JSON.stringify(body), 2377 + }), 2378 + ); 2379 2380 // Notify relay of updates on successful writes 2381 if (response.ok) { 2382 + const url = new URL(request.url); 2383 + notifyCrawlers(env, url.hostname); 2384 } 2385 2386 + return response; 2387 } 2388 2389 async function handleRequest(request, env) { 2390 + const url = new URL(request.url); 2391 + const subdomain = getSubdomain(url.hostname); 2392 2393 // Handle resolution via subdomain or bare domain 2394 if (url.pathname === '/.well-known/atproto-did') { 2395 // Look up handle -> DID in default DO 2396 // Use subdomain if present, otherwise try bare hostname as handle 2397 + const handleToResolve = subdomain || url.hostname; 2398 + const defaultId = env.PDS.idFromName('default'); 2399 + const defaultPds = env.PDS.get(defaultId); 2400 const resolveRes = await defaultPds.fetch( 2401 + new Request( 2402 + `http://internal/resolve-handle?handle=${encodeURIComponent(handleToResolve)}`, 2403 + ), 2404 + ); 2405 if (!resolveRes.ok) { 2406 + return new Response('Handle not found', { status: 404 }); 2407 } 2408 + const { did } = await resolveRes.json(); 2409 + return new Response(did, { headers: { 'Content-Type': 'text/plain' } }); 2410 } 2411 2412 // describeServer - works on bare domain 2413 if (url.pathname === '/xrpc/com.atproto.server.describeServer') { 2414 + const defaultId = env.PDS.idFromName('default'); 2415 + const defaultPds = env.PDS.get(defaultId); 2416 const newReq = new Request(request.url, { 2417 method: request.method, 2418 + headers: { 2419 + ...Object.fromEntries(request.headers), 2420 + 'x-hostname': url.hostname, 2421 + }, 2422 + }); 2423 + return defaultPds.fetch(newReq); 2424 } 2425 2426 // createSession - handle on default DO (has handleMap for identifier resolution) 2427 if (url.pathname === '/xrpc/com.atproto.server.createSession') { 2428 + const defaultId = env.PDS.idFromName('default'); 2429 + const defaultPds = env.PDS.get(defaultId); 2430 + return defaultPds.fetch(request); 2431 } 2432 2433 // getSession - route to default DO 2434 if (url.pathname === '/xrpc/com.atproto.server.getSession') { 2435 + const defaultId = env.PDS.idFromName('default'); 2436 + const defaultPds = env.PDS.get(defaultId); 2437 + return defaultPds.fetch(request); 2438 } 2439 2440 // Proxy app.bsky.* endpoints to Bluesky AppView 2441 if (url.pathname.startsWith('/xrpc/app.bsky.')) { 2442 // Authenticate the user first 2443 + const auth = await requireAuth(request, env); 2444 + if (auth.error) return auth.error; 2445 2446 // Route to the user's DO instance to create service auth and proxy 2447 + const id = env.PDS.idFromName(auth.did); 2448 + const pds = env.PDS.get(id); 2449 + return pds.fetch( 2450 + new Request(request.url, { 2451 + method: request.method, 2452 + headers: { 2453 + ...Object.fromEntries(request.headers), 2454 + 'x-authed-did': auth.did, // Pass the authenticated DID 2455 + }, 2456 + body: 2457 + request.method !== 'GET' && request.method !== 'HEAD' 2458 + ? request.body 2459 + : undefined, 2460 + }), 2461 + ); 2462 } 2463 2464 // Handle registration routes - go to default DO 2465 + if ( 2466 + url.pathname === '/register-handle' || 2467 + url.pathname === '/resolve-handle' 2468 + ) { 2469 + const defaultId = env.PDS.idFromName('default'); 2470 + const defaultPds = env.PDS.get(defaultId); 2471 + return defaultPds.fetch(request); 2472 } 2473 2474 // resolveHandle XRPC endpoint 2475 if (url.pathname === '/xrpc/com.atproto.identity.resolveHandle') { 2476 + const handle = url.searchParams.get('handle'); 2477 if (!handle) { 2478 + return errorResponse('InvalidRequest', 'missing handle param', 400); 2479 } 2480 + const defaultId = env.PDS.idFromName('default'); 2481 + const defaultPds = env.PDS.get(defaultId); 2482 const resolveRes = await defaultPds.fetch( 2483 + new Request( 2484 + `http://internal/resolve-handle?handle=${encodeURIComponent(handle)}`, 2485 + ), 2486 + ); 2487 if (!resolveRes.ok) { 2488 + return errorResponse('InvalidRequest', 'Unable to resolve handle', 400); 2489 } 2490 + const { did } = await resolveRes.json(); 2491 + return Response.json({ did }); 2492 } 2493 2494 // subscribeRepos WebSocket - route to default instance for firehose 2495 if (url.pathname === '/xrpc/com.atproto.sync.subscribeRepos') { 2496 + const defaultId = env.PDS.idFromName('default'); 2497 + const defaultPds = env.PDS.get(defaultId); 2498 + return defaultPds.fetch(request); 2499 } 2500 2501 // listRepos needs to aggregate from all registered DIDs 2502 if (url.pathname === '/xrpc/com.atproto.sync.listRepos') { 2503 + const defaultId = env.PDS.idFromName('default'); 2504 + const defaultPds = env.PDS.get(defaultId); 2505 + const regRes = await defaultPds.fetch( 2506 + new Request('http://internal/get-registered-dids'), 2507 + ); 2508 + const { dids } = await regRes.json(); 2509 2510 + const repos = []; 2511 for (const did of dids) { 2512 + const id = env.PDS.idFromName(did); 2513 + const pds = env.PDS.get(id); 2514 + const infoRes = await pds.fetch(new Request('http://internal/repo-info')); 2515 + const info = await infoRes.json(); 2516 if (info.head) { 2517 + repos.push({ did, head: info.head, rev: info.rev, active: true }); 2518 } 2519 } 2520 + return Response.json({ repos, cursor: undefined }); 2521 } 2522 2523 // Repo endpoints use ?repo= param instead of ?did= 2524 + if ( 2525 + url.pathname === '/xrpc/com.atproto.repo.describeRepo' || 2526 + url.pathname === '/xrpc/com.atproto.repo.listRecords' || 2527 + url.pathname === '/xrpc/com.atproto.repo.getRecord' 2528 + ) { 2529 + const repo = url.searchParams.get('repo'); 2530 if (!repo) { 2531 + return errorResponse('InvalidRequest', 'missing repo param', 400); 2532 } 2533 + const id = env.PDS.idFromName(repo); 2534 + const pds = env.PDS.get(id); 2535 + return pds.fetch(request); 2536 } 2537 2538 // Sync endpoints use ?did= param 2539 + if ( 2540 + url.pathname === '/xrpc/com.atproto.sync.getLatestCommit' || 2541 + url.pathname === '/xrpc/com.atproto.sync.getRepoStatus' || 2542 + url.pathname === '/xrpc/com.atproto.sync.getRepo' || 2543 + url.pathname === '/xrpc/com.atproto.sync.getRecord' 2544 + ) { 2545 + const did = url.searchParams.get('did'); 2546 if (!did) { 2547 + return errorResponse('InvalidRequest', 'missing did param', 400); 2548 } 2549 + const id = env.PDS.idFromName(did); 2550 + const pds = env.PDS.get(id); 2551 + return pds.fetch(request); 2552 } 2553 2554 // Authenticated repo write endpoints ··· 2556 '/xrpc/com.atproto.repo.createRecord', 2557 '/xrpc/com.atproto.repo.deleteRecord', 2558 '/xrpc/com.atproto.repo.putRecord', 2559 + '/xrpc/com.atproto.repo.applyWrites', 2560 + ]; 2561 if (repoWriteEndpoints.includes(url.pathname)) { 2562 + return handleAuthenticatedRepoWrite(request, env); 2563 } 2564 2565 // Health check endpoint 2566 if (url.pathname === '/xrpc/_health') { 2567 + return Response.json({ version: '0.1.0' }); 2568 } 2569 2570 // Root path - ASCII art ··· 2578 ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚════╝ ╚══════╝ 2579 2580 ATProto PDS on Cloudflare Workers 2581 + `; 2582 + return new Response(ascii, { 2583 + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, 2584 + }); 2585 } 2586 2587 // On init, register this DID with the default instance (requires ?did= param, no auth yet) 2588 if (url.pathname === '/init' && request.method === 'POST') { 2589 + const did = url.searchParams.get('did'); 2590 if (!did) { 2591 + return errorResponse('InvalidRequest', 'missing did param', 400); 2592 } 2593 + const body = await request.json(); 2594 2595 // Register with default instance for discovery 2596 + const defaultId = env.PDS.idFromName('default'); 2597 + const defaultPds = env.PDS.get(defaultId); 2598 + await defaultPds.fetch( 2599 + new Request('http://internal/register-did', { 2600 + method: 'POST', 2601 + body: JSON.stringify({ did }), 2602 + }), 2603 + ); 2604 2605 // Register handle if provided 2606 if (body.handle) { 2607 + await defaultPds.fetch( 2608 + new Request('http://internal/register-handle', { 2609 + method: 'POST', 2610 + body: JSON.stringify({ did, handle: body.handle }), 2611 + }), 2612 + ); 2613 } 2614 2615 // Forward to the actual PDS instance 2616 + const id = env.PDS.idFromName(did); 2617 + const pds = env.PDS.get(id); 2618 + return pds.fetch( 2619 + new Request(request.url, { 2620 + method: 'POST', 2621 + headers: request.headers, 2622 + body: JSON.stringify(body), 2623 + }), 2624 + ); 2625 } 2626 2627 // Unknown endpoint 2628 + return errorResponse('NotFound', 'Endpoint not found', 404); 2629 }
+312 -292
test/pds.test.js
··· 1 - import { test, describe } from 'node:test' 2 - import assert from 'node:assert' 3 import { 4 - cborEncode, cborDecode, createCid, cidToString, cidToBytes, base32Encode, createTid, 5 - generateKeyPair, importPrivateKey, sign, bytesToHex, hexToBytes, 6 - getKeyDepth, varint, base32Decode, buildCarFile, 7 - base64UrlEncode, base64UrlDecode, 8 - createAccessJwt, createRefreshJwt, verifyAccessJwt 9 - } from '../src/pds.js' 10 11 describe('CBOR Encoding', () => { 12 test('encodes simple map', () => { 13 - const encoded = cborEncode({ hello: 'world', num: 42 }) 14 // Expected: a2 65 68 65 6c 6c 6f 65 77 6f 72 6c 64 63 6e 75 6d 18 2a 15 const expected = new Uint8Array([ 16 - 0xa2, 0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x65, 0x77, 0x6f, 0x72, 0x6c, 0x64, 17 - 0x63, 0x6e, 0x75, 0x6d, 0x18, 0x2a 18 - ]) 19 - assert.deepStrictEqual(encoded, expected) 20 - }) 21 22 test('encodes null', () => { 23 - const encoded = cborEncode(null) 24 - assert.deepStrictEqual(encoded, new Uint8Array([0xf6])) 25 - }) 26 27 test('encodes booleans', () => { 28 - assert.deepStrictEqual(cborEncode(true), new Uint8Array([0xf5])) 29 - assert.deepStrictEqual(cborEncode(false), new Uint8Array([0xf4])) 30 - }) 31 32 test('encodes small integers', () => { 33 - assert.deepStrictEqual(cborEncode(0), new Uint8Array([0x00])) 34 - assert.deepStrictEqual(cborEncode(1), new Uint8Array([0x01])) 35 - assert.deepStrictEqual(cborEncode(23), new Uint8Array([0x17])) 36 - }) 37 38 test('encodes integers >= 24', () => { 39 - assert.deepStrictEqual(cborEncode(24), new Uint8Array([0x18, 0x18])) 40 - assert.deepStrictEqual(cborEncode(255), new Uint8Array([0x18, 0xff])) 41 - }) 42 43 test('encodes negative integers', () => { 44 - assert.deepStrictEqual(cborEncode(-1), new Uint8Array([0x20])) 45 - assert.deepStrictEqual(cborEncode(-10), new Uint8Array([0x29])) 46 - }) 47 48 test('encodes strings', () => { 49 - const encoded = cborEncode('hello') 50 // 0x65 = text string of length 5 51 - assert.deepStrictEqual(encoded, new Uint8Array([0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f])) 52 - }) 53 54 test('encodes byte strings', () => { 55 - const bytes = new Uint8Array([1, 2, 3]) 56 - const encoded = cborEncode(bytes) 57 // 0x43 = byte string of length 3 58 - assert.deepStrictEqual(encoded, new Uint8Array([0x43, 1, 2, 3])) 59 - }) 60 61 test('encodes arrays', () => { 62 - const encoded = cborEncode([1, 2, 3]) 63 // 0x83 = array of length 3 64 - assert.deepStrictEqual(encoded, new Uint8Array([0x83, 0x01, 0x02, 0x03])) 65 - }) 66 67 test('sorts map keys deterministically', () => { 68 - const encoded1 = cborEncode({ z: 1, a: 2 }) 69 - const encoded2 = cborEncode({ a: 2, z: 1 }) 70 - assert.deepStrictEqual(encoded1, encoded2) 71 // First key should be 'a' (0x61) 72 - assert.strictEqual(encoded1[1], 0x61) 73 - }) 74 75 test('encodes large integers >= 2^31 without overflow', () => { 76 // 2^31 would overflow with bitshift operators (treated as signed 32-bit) 77 - const twoTo31 = 2147483648 78 - const encoded = cborEncode(twoTo31) 79 - const decoded = cborDecode(encoded) 80 - assert.strictEqual(decoded, twoTo31) 81 82 // 2^32 - 1 (max unsigned 32-bit) 83 - const maxU32 = 4294967295 84 - const encoded2 = cborEncode(maxU32) 85 - const decoded2 = cborDecode(encoded2) 86 - assert.strictEqual(decoded2, maxU32) 87 - }) 88 89 test('encodes 2^31 with correct byte format', () => { 90 // 2147483648 = 0x80000000 91 // CBOR: major type 0 (unsigned int), additional info 26 (4-byte follows) 92 - const encoded = cborEncode(2147483648) 93 - assert.strictEqual(encoded[0], 0x1a) // type 0 | info 26 94 - assert.strictEqual(encoded[1], 0x80) 95 - assert.strictEqual(encoded[2], 0x00) 96 - assert.strictEqual(encoded[3], 0x00) 97 - assert.strictEqual(encoded[4], 0x00) 98 - }) 99 - }) 100 101 describe('Base32 Encoding', () => { 102 test('encodes bytes to base32lower', () => { 103 - const bytes = new Uint8Array([0x01, 0x71, 0x12, 0x20]) 104 - const encoded = base32Encode(bytes) 105 - assert.strictEqual(typeof encoded, 'string') 106 - assert.match(encoded, /^[a-z2-7]+$/) 107 - }) 108 - }) 109 110 describe('CID Generation', () => { 111 test('creates CIDv1 with dag-cbor codec', async () => { 112 - const data = cborEncode({ test: 'data' }) 113 - const cid = await createCid(data) 114 115 - assert.strictEqual(cid.length, 36) // 2 prefix + 2 multihash header + 32 hash 116 - assert.strictEqual(cid[0], 0x01) // CIDv1 117 - assert.strictEqual(cid[1], 0x71) // dag-cbor 118 - assert.strictEqual(cid[2], 0x12) // sha-256 119 - assert.strictEqual(cid[3], 0x20) // 32 bytes 120 - }) 121 122 test('cidToString returns base32lower with b prefix', async () => { 123 - const data = cborEncode({ test: 'data' }) 124 - const cid = await createCid(data) 125 - const cidStr = cidToString(cid) 126 127 - assert.strictEqual(cidStr[0], 'b') 128 - assert.match(cidStr, /^b[a-z2-7]+$/) 129 - }) 130 131 test('same input produces same CID', async () => { 132 - const data1 = cborEncode({ test: 'data' }) 133 - const data2 = cborEncode({ test: 'data' }) 134 - const cid1 = cidToString(await createCid(data1)) 135 - const cid2 = cidToString(await createCid(data2)) 136 137 - assert.strictEqual(cid1, cid2) 138 - }) 139 140 test('different input produces different CID', async () => { 141 - const cid1 = cidToString(await createCid(cborEncode({ a: 1 }))) 142 - const cid2 = cidToString(await createCid(cborEncode({ a: 2 }))) 143 144 - assert.notStrictEqual(cid1, cid2) 145 - }) 146 - }) 147 148 describe('TID Generation', () => { 149 test('creates 13-character TIDs', () => { 150 - const tid = createTid() 151 - assert.strictEqual(tid.length, 13) 152 - }) 153 154 test('uses valid base32-sort characters', () => { 155 - const tid = createTid() 156 - assert.match(tid, /^[234567abcdefghijklmnopqrstuvwxyz]+$/) 157 - }) 158 159 test('generates monotonically increasing TIDs', () => { 160 - const tid1 = createTid() 161 - const tid2 = createTid() 162 - const tid3 = createTid() 163 164 - assert.ok(tid1 < tid2, `${tid1} should be less than ${tid2}`) 165 - assert.ok(tid2 < tid3, `${tid2} should be less than ${tid3}`) 166 - }) 167 168 test('generates unique TIDs', () => { 169 - const tids = new Set() 170 for (let i = 0; i < 100; i++) { 171 - tids.add(createTid()) 172 } 173 - assert.strictEqual(tids.size, 100) 174 - }) 175 - }) 176 177 describe('P-256 Signing', () => { 178 test('generates key pair with correct sizes', async () => { 179 - const kp = await generateKeyPair() 180 181 - assert.strictEqual(kp.privateKey.length, 32) 182 - assert.strictEqual(kp.publicKey.length, 33) // compressed 183 - assert.ok(kp.publicKey[0] === 0x02 || kp.publicKey[0] === 0x03) 184 - }) 185 186 test('can sign data with generated key', async () => { 187 - const kp = await generateKeyPair() 188 - const key = await importPrivateKey(kp.privateKey) 189 - const data = new TextEncoder().encode('test message') 190 - const sig = await sign(key, data) 191 192 - assert.strictEqual(sig.length, 64) // r (32) + s (32) 193 - }) 194 195 test('different messages produce different signatures', async () => { 196 - const kp = await generateKeyPair() 197 - const key = await importPrivateKey(kp.privateKey) 198 199 - const sig1 = await sign(key, new TextEncoder().encode('message 1')) 200 - const sig2 = await sign(key, new TextEncoder().encode('message 2')) 201 202 - assert.notDeepStrictEqual(sig1, sig2) 203 - }) 204 205 test('bytesToHex and hexToBytes roundtrip', () => { 206 - const original = new Uint8Array([0x00, 0x0f, 0xf0, 0xff, 0xab, 0xcd]) 207 - const hex = bytesToHex(original) 208 - const back = hexToBytes(hex) 209 210 - assert.strictEqual(hex, '000ff0ffabcd') 211 - assert.deepStrictEqual(back, original) 212 - }) 213 214 test('importPrivateKey rejects invalid key lengths', async () => { 215 // Too short 216 await assert.rejects( 217 () => importPrivateKey(new Uint8Array(31)), 218 - /expected 32 bytes, got 31/ 219 - ) 220 221 // Too long 222 await assert.rejects( 223 () => importPrivateKey(new Uint8Array(33)), 224 - /expected 32 bytes, got 33/ 225 - ) 226 227 // Empty 228 await assert.rejects( 229 () => importPrivateKey(new Uint8Array(0)), 230 - /expected 32 bytes, got 0/ 231 - ) 232 - }) 233 234 test('importPrivateKey rejects non-Uint8Array input', async () => { 235 // Arrays have .length but aren't Uint8Array 236 await assert.rejects( 237 () => importPrivateKey([1, 2, 3]), 238 - /Invalid private key/ 239 - ) 240 241 // Strings don't work either 242 await assert.rejects( 243 () => importPrivateKey('not bytes'), 244 - /Invalid private key/ 245 - ) 246 247 // null/undefined 248 - await assert.rejects( 249 - () => importPrivateKey(null), 250 - /Invalid private key/ 251 - ) 252 - }) 253 - }) 254 255 describe('MST Key Depth', () => { 256 test('returns a non-negative integer', async () => { 257 - const depth = await getKeyDepth('app.bsky.feed.post/abc123') 258 - assert.strictEqual(typeof depth, 'number') 259 - assert.ok(depth >= 0) 260 - }) 261 262 test('is deterministic for same key', async () => { 263 - const key = 'app.bsky.feed.post/test123' 264 - const depth1 = await getKeyDepth(key) 265 - const depth2 = await getKeyDepth(key) 266 - assert.strictEqual(depth1, depth2) 267 - }) 268 269 test('different keys can have different depths', async () => { 270 // Generate many keys and check we get some variation 271 - const depths = new Set() 272 for (let i = 0; i < 100; i++) { 273 - depths.add(await getKeyDepth(`collection/key${i}`)) 274 } 275 // Should have at least 1 unique depth (realistically more) 276 - assert.ok(depths.size >= 1) 277 - }) 278 279 test('handles empty string', async () => { 280 - const depth = await getKeyDepth('') 281 - assert.strictEqual(typeof depth, 'number') 282 - assert.ok(depth >= 0) 283 - }) 284 285 test('handles unicode strings', async () => { 286 - const depth = await getKeyDepth('app.bsky.feed.post/émoji🎉') 287 - assert.strictEqual(typeof depth, 'number') 288 - assert.ok(depth >= 0) 289 - }) 290 - }) 291 292 describe('CBOR Decoding', () => { 293 test('decodes what encode produces (roundtrip)', () => { 294 - const original = { hello: 'world', num: 42 } 295 - const encoded = cborEncode(original) 296 - const decoded = cborDecode(encoded) 297 - assert.deepStrictEqual(decoded, original) 298 - }) 299 300 test('decodes null', () => { 301 - const encoded = cborEncode(null) 302 - const decoded = cborDecode(encoded) 303 - assert.strictEqual(decoded, null) 304 - }) 305 306 test('decodes booleans', () => { 307 - assert.strictEqual(cborDecode(cborEncode(true)), true) 308 - assert.strictEqual(cborDecode(cborEncode(false)), false) 309 - }) 310 311 test('decodes integers', () => { 312 - assert.strictEqual(cborDecode(cborEncode(0)), 0) 313 - assert.strictEqual(cborDecode(cborEncode(42)), 42) 314 - assert.strictEqual(cborDecode(cborEncode(255)), 255) 315 - assert.strictEqual(cborDecode(cborEncode(-1)), -1) 316 - assert.strictEqual(cborDecode(cborEncode(-10)), -10) 317 - }) 318 319 test('decodes strings', () => { 320 - assert.strictEqual(cborDecode(cborEncode('hello')), 'hello') 321 - assert.strictEqual(cborDecode(cborEncode('')), '') 322 - }) 323 324 test('decodes arrays', () => { 325 - assert.deepStrictEqual(cborDecode(cborEncode([1, 2, 3])), [1, 2, 3]) 326 - assert.deepStrictEqual(cborDecode(cborEncode([])), []) 327 - }) 328 329 test('decodes nested structures', () => { 330 - const original = { arr: [1, { nested: true }], str: 'test' } 331 - const decoded = cborDecode(cborEncode(original)) 332 - assert.deepStrictEqual(decoded, original) 333 - }) 334 - }) 335 336 describe('CAR File Builder', () => { 337 test('varint encodes small numbers', () => { 338 - assert.deepStrictEqual(varint(0), new Uint8Array([0])) 339 - assert.deepStrictEqual(varint(1), new Uint8Array([1])) 340 - assert.deepStrictEqual(varint(127), new Uint8Array([127])) 341 - }) 342 343 test('varint encodes multi-byte numbers', () => { 344 // 128 = 0x80 -> [0x80 | 0x00, 0x01] = [0x80, 0x01] 345 - assert.deepStrictEqual(varint(128), new Uint8Array([0x80, 0x01])) 346 // 300 = 0x12c -> [0xac, 0x02] 347 - assert.deepStrictEqual(varint(300), new Uint8Array([0xac, 0x02])) 348 - }) 349 350 test('base32 encode/decode roundtrip', () => { 351 - const original = new Uint8Array([0x01, 0x71, 0x12, 0x20, 0xab, 0xcd]) 352 - const encoded = base32Encode(original) 353 - const decoded = base32Decode(encoded) 354 - assert.deepStrictEqual(decoded, original) 355 - }) 356 357 test('buildCarFile produces valid structure', async () => { 358 - const data = cborEncode({ test: 'data' }) 359 - const cid = await createCid(data) 360 - const cidStr = cidToString(cid) 361 362 - const car = buildCarFile(cidStr, [{ cid: cidStr, data }]) 363 364 - assert.ok(car instanceof Uint8Array) 365 - assert.ok(car.length > 0) 366 // First byte should be varint of header length 367 - assert.ok(car[0] > 0) 368 - }) 369 - }) 370 371 describe('JWT Base64URL', () => { 372 test('base64UrlEncode encodes bytes correctly', () => { 373 - const input = new TextEncoder().encode('hello world') 374 - const encoded = base64UrlEncode(input) 375 - assert.strictEqual(encoded, 'aGVsbG8gd29ybGQ') 376 - assert.ok(!encoded.includes('+')) 377 - assert.ok(!encoded.includes('/')) 378 - assert.ok(!encoded.includes('=')) 379 - }) 380 381 test('base64UrlDecode decodes string correctly', () => { 382 - const decoded = base64UrlDecode('aGVsbG8gd29ybGQ') 383 - const str = new TextDecoder().decode(decoded) 384 - assert.strictEqual(str, 'hello world') 385 - }) 386 387 test('base64url roundtrip', () => { 388 - const original = new Uint8Array([0, 1, 2, 255, 254, 253]) 389 - const encoded = base64UrlEncode(original) 390 - const decoded = base64UrlDecode(encoded) 391 - assert.deepStrictEqual(decoded, original) 392 - }) 393 - }) 394 395 describe('JWT Creation', () => { 396 test('createAccessJwt creates valid JWT structure', async () => { 397 - const did = 'did:web:test.example' 398 - const secret = 'test-secret-key' 399 - const jwt = await createAccessJwt(did, secret) 400 401 - const parts = jwt.split('.') 402 - assert.strictEqual(parts.length, 3) 403 404 // Decode header 405 - const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[0]))) 406 - assert.strictEqual(header.typ, 'at+jwt') 407 - assert.strictEqual(header.alg, 'HS256') 408 409 // Decode payload 410 - const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[1]))) 411 - assert.strictEqual(payload.scope, 'com.atproto.access') 412 - assert.strictEqual(payload.sub, did) 413 - assert.strictEqual(payload.aud, did) 414 - assert.ok(payload.iat > 0) 415 - assert.ok(payload.exp > payload.iat) 416 - }) 417 418 test('createRefreshJwt creates valid JWT with jti', async () => { 419 - const did = 'did:web:test.example' 420 - const secret = 'test-secret-key' 421 - const jwt = await createRefreshJwt(did, secret) 422 423 - const parts = jwt.split('.') 424 - const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[0]))) 425 - assert.strictEqual(header.typ, 'refresh+jwt') 426 427 - const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[1]))) 428 - assert.strictEqual(payload.scope, 'com.atproto.refresh') 429 - assert.ok(payload.jti) // has unique token ID 430 - }) 431 - }) 432 433 describe('JWT Verification', () => { 434 test('verifyAccessJwt returns payload for valid token', async () => { 435 - const did = 'did:web:test.example' 436 - const secret = 'test-secret-key' 437 - const jwt = await createAccessJwt(did, secret) 438 439 - const payload = await verifyAccessJwt(jwt, secret) 440 - assert.strictEqual(payload.sub, did) 441 - assert.strictEqual(payload.scope, 'com.atproto.access') 442 - }) 443 444 test('verifyAccessJwt throws for wrong secret', async () => { 445 - const did = 'did:web:test.example' 446 - const jwt = await createAccessJwt(did, 'correct-secret') 447 448 await assert.rejects( 449 () => verifyAccessJwt(jwt, 'wrong-secret'), 450 - /invalid signature/i 451 - ) 452 - }) 453 454 test('verifyAccessJwt throws for expired token', async () => { 455 - const did = 'did:web:test.example' 456 - const secret = 'test-secret-key' 457 // Create token that expired 1 second ago 458 - const jwt = await createAccessJwt(did, secret, -1) 459 460 - await assert.rejects( 461 - () => verifyAccessJwt(jwt, secret), 462 - /expired/i 463 - ) 464 - }) 465 466 test('verifyAccessJwt throws for refresh token', async () => { 467 - const did = 'did:web:test.example' 468 - const secret = 'test-secret-key' 469 - const jwt = await createRefreshJwt(did, secret) 470 471 await assert.rejects( 472 () => verifyAccessJwt(jwt, secret), 473 - /invalid token type/i 474 - ) 475 - }) 476 - })
··· 1 + import assert from 'node:assert'; 2 + import { describe, test } from 'node:test'; 3 import { 4 + base32Decode, 5 + base32Encode, 6 + base64UrlDecode, 7 + base64UrlEncode, 8 + buildCarFile, 9 + bytesToHex, 10 + cborDecode, 11 + cborEncode, 12 + cidToString, 13 + createAccessJwt, 14 + createCid, 15 + createRefreshJwt, 16 + createTid, 17 + generateKeyPair, 18 + getKeyDepth, 19 + hexToBytes, 20 + importPrivateKey, 21 + sign, 22 + varint, 23 + verifyAccessJwt, 24 + } from '../src/pds.js'; 25 26 describe('CBOR Encoding', () => { 27 test('encodes simple map', () => { 28 + const encoded = cborEncode({ hello: 'world', num: 42 }); 29 // Expected: a2 65 68 65 6c 6c 6f 65 77 6f 72 6c 64 63 6e 75 6d 18 2a 30 const expected = new Uint8Array([ 31 + 0xa2, 0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x65, 0x77, 0x6f, 0x72, 0x6c, 32 + 0x64, 0x63, 0x6e, 0x75, 0x6d, 0x18, 0x2a, 33 + ]); 34 + assert.deepStrictEqual(encoded, expected); 35 + }); 36 37 test('encodes null', () => { 38 + const encoded = cborEncode(null); 39 + assert.deepStrictEqual(encoded, new Uint8Array([0xf6])); 40 + }); 41 42 test('encodes booleans', () => { 43 + assert.deepStrictEqual(cborEncode(true), new Uint8Array([0xf5])); 44 + assert.deepStrictEqual(cborEncode(false), new Uint8Array([0xf4])); 45 + }); 46 47 test('encodes small integers', () => { 48 + assert.deepStrictEqual(cborEncode(0), new Uint8Array([0x00])); 49 + assert.deepStrictEqual(cborEncode(1), new Uint8Array([0x01])); 50 + assert.deepStrictEqual(cborEncode(23), new Uint8Array([0x17])); 51 + }); 52 53 test('encodes integers >= 24', () => { 54 + assert.deepStrictEqual(cborEncode(24), new Uint8Array([0x18, 0x18])); 55 + assert.deepStrictEqual(cborEncode(255), new Uint8Array([0x18, 0xff])); 56 + }); 57 58 test('encodes negative integers', () => { 59 + assert.deepStrictEqual(cborEncode(-1), new Uint8Array([0x20])); 60 + assert.deepStrictEqual(cborEncode(-10), new Uint8Array([0x29])); 61 + }); 62 63 test('encodes strings', () => { 64 + const encoded = cborEncode('hello'); 65 // 0x65 = text string of length 5 66 + assert.deepStrictEqual( 67 + encoded, 68 + new Uint8Array([0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f]), 69 + ); 70 + }); 71 72 test('encodes byte strings', () => { 73 + const bytes = new Uint8Array([1, 2, 3]); 74 + const encoded = cborEncode(bytes); 75 // 0x43 = byte string of length 3 76 + assert.deepStrictEqual(encoded, new Uint8Array([0x43, 1, 2, 3])); 77 + }); 78 79 test('encodes arrays', () => { 80 + const encoded = cborEncode([1, 2, 3]); 81 // 0x83 = array of length 3 82 + assert.deepStrictEqual(encoded, new Uint8Array([0x83, 0x01, 0x02, 0x03])); 83 + }); 84 85 test('sorts map keys deterministically', () => { 86 + const encoded1 = cborEncode({ z: 1, a: 2 }); 87 + const encoded2 = cborEncode({ a: 2, z: 1 }); 88 + assert.deepStrictEqual(encoded1, encoded2); 89 // First key should be 'a' (0x61) 90 + assert.strictEqual(encoded1[1], 0x61); 91 + }); 92 93 test('encodes large integers >= 2^31 without overflow', () => { 94 // 2^31 would overflow with bitshift operators (treated as signed 32-bit) 95 + const twoTo31 = 2147483648; 96 + const encoded = cborEncode(twoTo31); 97 + const decoded = cborDecode(encoded); 98 + assert.strictEqual(decoded, twoTo31); 99 100 // 2^32 - 1 (max unsigned 32-bit) 101 + const maxU32 = 4294967295; 102 + const encoded2 = cborEncode(maxU32); 103 + const decoded2 = cborDecode(encoded2); 104 + assert.strictEqual(decoded2, maxU32); 105 + }); 106 107 test('encodes 2^31 with correct byte format', () => { 108 // 2147483648 = 0x80000000 109 // CBOR: major type 0 (unsigned int), additional info 26 (4-byte follows) 110 + const encoded = cborEncode(2147483648); 111 + assert.strictEqual(encoded[0], 0x1a); // type 0 | info 26 112 + assert.strictEqual(encoded[1], 0x80); 113 + assert.strictEqual(encoded[2], 0x00); 114 + assert.strictEqual(encoded[3], 0x00); 115 + assert.strictEqual(encoded[4], 0x00); 116 + }); 117 + }); 118 119 describe('Base32 Encoding', () => { 120 test('encodes bytes to base32lower', () => { 121 + const bytes = new Uint8Array([0x01, 0x71, 0x12, 0x20]); 122 + const encoded = base32Encode(bytes); 123 + assert.strictEqual(typeof encoded, 'string'); 124 + assert.match(encoded, /^[a-z2-7]+$/); 125 + }); 126 + }); 127 128 describe('CID Generation', () => { 129 test('creates CIDv1 with dag-cbor codec', async () => { 130 + const data = cborEncode({ test: 'data' }); 131 + const cid = await createCid(data); 132 133 + assert.strictEqual(cid.length, 36); // 2 prefix + 2 multihash header + 32 hash 134 + assert.strictEqual(cid[0], 0x01); // CIDv1 135 + assert.strictEqual(cid[1], 0x71); // dag-cbor 136 + assert.strictEqual(cid[2], 0x12); // sha-256 137 + assert.strictEqual(cid[3], 0x20); // 32 bytes 138 + }); 139 140 test('cidToString returns base32lower with b prefix', async () => { 141 + const data = cborEncode({ test: 'data' }); 142 + const cid = await createCid(data); 143 + const cidStr = cidToString(cid); 144 145 + assert.strictEqual(cidStr[0], 'b'); 146 + assert.match(cidStr, /^b[a-z2-7]+$/); 147 + }); 148 149 test('same input produces same CID', async () => { 150 + const data1 = cborEncode({ test: 'data' }); 151 + const data2 = cborEncode({ test: 'data' }); 152 + const cid1 = cidToString(await createCid(data1)); 153 + const cid2 = cidToString(await createCid(data2)); 154 155 + assert.strictEqual(cid1, cid2); 156 + }); 157 158 test('different input produces different CID', async () => { 159 + const cid1 = cidToString(await createCid(cborEncode({ a: 1 }))); 160 + const cid2 = cidToString(await createCid(cborEncode({ a: 2 }))); 161 162 + assert.notStrictEqual(cid1, cid2); 163 + }); 164 + }); 165 166 describe('TID Generation', () => { 167 test('creates 13-character TIDs', () => { 168 + const tid = createTid(); 169 + assert.strictEqual(tid.length, 13); 170 + }); 171 172 test('uses valid base32-sort characters', () => { 173 + const tid = createTid(); 174 + assert.match(tid, /^[234567abcdefghijklmnopqrstuvwxyz]+$/); 175 + }); 176 177 test('generates monotonically increasing TIDs', () => { 178 + const tid1 = createTid(); 179 + const tid2 = createTid(); 180 + const tid3 = createTid(); 181 182 + assert.ok(tid1 < tid2, `${tid1} should be less than ${tid2}`); 183 + assert.ok(tid2 < tid3, `${tid2} should be less than ${tid3}`); 184 + }); 185 186 test('generates unique TIDs', () => { 187 + const tids = new Set(); 188 for (let i = 0; i < 100; i++) { 189 + tids.add(createTid()); 190 } 191 + assert.strictEqual(tids.size, 100); 192 + }); 193 + }); 194 195 describe('P-256 Signing', () => { 196 test('generates key pair with correct sizes', async () => { 197 + const kp = await generateKeyPair(); 198 199 + assert.strictEqual(kp.privateKey.length, 32); 200 + assert.strictEqual(kp.publicKey.length, 33); // compressed 201 + assert.ok(kp.publicKey[0] === 0x02 || kp.publicKey[0] === 0x03); 202 + }); 203 204 test('can sign data with generated key', async () => { 205 + const kp = await generateKeyPair(); 206 + const key = await importPrivateKey(kp.privateKey); 207 + const data = new TextEncoder().encode('test message'); 208 + const sig = await sign(key, data); 209 210 + assert.strictEqual(sig.length, 64); // r (32) + s (32) 211 + }); 212 213 test('different messages produce different signatures', async () => { 214 + const kp = await generateKeyPair(); 215 + const key = await importPrivateKey(kp.privateKey); 216 217 + const sig1 = await sign(key, new TextEncoder().encode('message 1')); 218 + const sig2 = await sign(key, new TextEncoder().encode('message 2')); 219 220 + assert.notDeepStrictEqual(sig1, sig2); 221 + }); 222 223 test('bytesToHex and hexToBytes roundtrip', () => { 224 + const original = new Uint8Array([0x00, 0x0f, 0xf0, 0xff, 0xab, 0xcd]); 225 + const hex = bytesToHex(original); 226 + const back = hexToBytes(hex); 227 228 + assert.strictEqual(hex, '000ff0ffabcd'); 229 + assert.deepStrictEqual(back, original); 230 + }); 231 232 test('importPrivateKey rejects invalid key lengths', async () => { 233 // Too short 234 await assert.rejects( 235 () => importPrivateKey(new Uint8Array(31)), 236 + /expected 32 bytes, got 31/, 237 + ); 238 239 // Too long 240 await assert.rejects( 241 () => importPrivateKey(new Uint8Array(33)), 242 + /expected 32 bytes, got 33/, 243 + ); 244 245 // Empty 246 await assert.rejects( 247 () => importPrivateKey(new Uint8Array(0)), 248 + /expected 32 bytes, got 0/, 249 + ); 250 + }); 251 252 test('importPrivateKey rejects non-Uint8Array input', async () => { 253 // Arrays have .length but aren't Uint8Array 254 await assert.rejects( 255 () => importPrivateKey([1, 2, 3]), 256 + /Invalid private key/, 257 + ); 258 259 // Strings don't work either 260 await assert.rejects( 261 () => importPrivateKey('not bytes'), 262 + /Invalid private key/, 263 + ); 264 265 // null/undefined 266 + await assert.rejects(() => importPrivateKey(null), /Invalid private key/); 267 + }); 268 + }); 269 270 describe('MST Key Depth', () => { 271 test('returns a non-negative integer', async () => { 272 + const depth = await getKeyDepth('app.bsky.feed.post/abc123'); 273 + assert.strictEqual(typeof depth, 'number'); 274 + assert.ok(depth >= 0); 275 + }); 276 277 test('is deterministic for same key', async () => { 278 + const key = 'app.bsky.feed.post/test123'; 279 + const depth1 = await getKeyDepth(key); 280 + const depth2 = await getKeyDepth(key); 281 + assert.strictEqual(depth1, depth2); 282 + }); 283 284 test('different keys can have different depths', async () => { 285 // Generate many keys and check we get some variation 286 + const depths = new Set(); 287 for (let i = 0; i < 100; i++) { 288 + depths.add(await getKeyDepth(`collection/key${i}`)); 289 } 290 // Should have at least 1 unique depth (realistically more) 291 + assert.ok(depths.size >= 1); 292 + }); 293 294 test('handles empty string', async () => { 295 + const depth = await getKeyDepth(''); 296 + assert.strictEqual(typeof depth, 'number'); 297 + assert.ok(depth >= 0); 298 + }); 299 300 test('handles unicode strings', async () => { 301 + const depth = await getKeyDepth('app.bsky.feed.post/émoji🎉'); 302 + assert.strictEqual(typeof depth, 'number'); 303 + assert.ok(depth >= 0); 304 + }); 305 + }); 306 307 describe('CBOR Decoding', () => { 308 test('decodes what encode produces (roundtrip)', () => { 309 + const original = { hello: 'world', num: 42 }; 310 + const encoded = cborEncode(original); 311 + const decoded = cborDecode(encoded); 312 + assert.deepStrictEqual(decoded, original); 313 + }); 314 315 test('decodes null', () => { 316 + const encoded = cborEncode(null); 317 + const decoded = cborDecode(encoded); 318 + assert.strictEqual(decoded, null); 319 + }); 320 321 test('decodes booleans', () => { 322 + assert.strictEqual(cborDecode(cborEncode(true)), true); 323 + assert.strictEqual(cborDecode(cborEncode(false)), false); 324 + }); 325 326 test('decodes integers', () => { 327 + assert.strictEqual(cborDecode(cborEncode(0)), 0); 328 + assert.strictEqual(cborDecode(cborEncode(42)), 42); 329 + assert.strictEqual(cborDecode(cborEncode(255)), 255); 330 + assert.strictEqual(cborDecode(cborEncode(-1)), -1); 331 + assert.strictEqual(cborDecode(cborEncode(-10)), -10); 332 + }); 333 334 test('decodes strings', () => { 335 + assert.strictEqual(cborDecode(cborEncode('hello')), 'hello'); 336 + assert.strictEqual(cborDecode(cborEncode('')), ''); 337 + }); 338 339 test('decodes arrays', () => { 340 + assert.deepStrictEqual(cborDecode(cborEncode([1, 2, 3])), [1, 2, 3]); 341 + assert.deepStrictEqual(cborDecode(cborEncode([])), []); 342 + }); 343 344 test('decodes nested structures', () => { 345 + const original = { arr: [1, { nested: true }], str: 'test' }; 346 + const decoded = cborDecode(cborEncode(original)); 347 + assert.deepStrictEqual(decoded, original); 348 + }); 349 + }); 350 351 describe('CAR File Builder', () => { 352 test('varint encodes small numbers', () => { 353 + assert.deepStrictEqual(varint(0), new Uint8Array([0])); 354 + assert.deepStrictEqual(varint(1), new Uint8Array([1])); 355 + assert.deepStrictEqual(varint(127), new Uint8Array([127])); 356 + }); 357 358 test('varint encodes multi-byte numbers', () => { 359 // 128 = 0x80 -> [0x80 | 0x00, 0x01] = [0x80, 0x01] 360 + assert.deepStrictEqual(varint(128), new Uint8Array([0x80, 0x01])); 361 // 300 = 0x12c -> [0xac, 0x02] 362 + assert.deepStrictEqual(varint(300), new Uint8Array([0xac, 0x02])); 363 + }); 364 365 test('base32 encode/decode roundtrip', () => { 366 + const original = new Uint8Array([0x01, 0x71, 0x12, 0x20, 0xab, 0xcd]); 367 + const encoded = base32Encode(original); 368 + const decoded = base32Decode(encoded); 369 + assert.deepStrictEqual(decoded, original); 370 + }); 371 372 test('buildCarFile produces valid structure', async () => { 373 + const data = cborEncode({ test: 'data' }); 374 + const cid = await createCid(data); 375 + const cidStr = cidToString(cid); 376 377 + const car = buildCarFile(cidStr, [{ cid: cidStr, data }]); 378 379 + assert.ok(car instanceof Uint8Array); 380 + assert.ok(car.length > 0); 381 // First byte should be varint of header length 382 + assert.ok(car[0] > 0); 383 + }); 384 + }); 385 386 describe('JWT Base64URL', () => { 387 test('base64UrlEncode encodes bytes correctly', () => { 388 + const input = new TextEncoder().encode('hello world'); 389 + const encoded = base64UrlEncode(input); 390 + assert.strictEqual(encoded, 'aGVsbG8gd29ybGQ'); 391 + assert.ok(!encoded.includes('+')); 392 + assert.ok(!encoded.includes('/')); 393 + assert.ok(!encoded.includes('=')); 394 + }); 395 396 test('base64UrlDecode decodes string correctly', () => { 397 + const decoded = base64UrlDecode('aGVsbG8gd29ybGQ'); 398 + const str = new TextDecoder().decode(decoded); 399 + assert.strictEqual(str, 'hello world'); 400 + }); 401 402 test('base64url roundtrip', () => { 403 + const original = new Uint8Array([0, 1, 2, 255, 254, 253]); 404 + const encoded = base64UrlEncode(original); 405 + const decoded = base64UrlDecode(encoded); 406 + assert.deepStrictEqual(decoded, original); 407 + }); 408 + }); 409 410 describe('JWT Creation', () => { 411 test('createAccessJwt creates valid JWT structure', async () => { 412 + const did = 'did:web:test.example'; 413 + const secret = 'test-secret-key'; 414 + const jwt = await createAccessJwt(did, secret); 415 416 + const parts = jwt.split('.'); 417 + assert.strictEqual(parts.length, 3); 418 419 // Decode header 420 + const header = JSON.parse( 421 + new TextDecoder().decode(base64UrlDecode(parts[0])), 422 + ); 423 + assert.strictEqual(header.typ, 'at+jwt'); 424 + assert.strictEqual(header.alg, 'HS256'); 425 426 // Decode payload 427 + const payload = JSON.parse( 428 + new TextDecoder().decode(base64UrlDecode(parts[1])), 429 + ); 430 + assert.strictEqual(payload.scope, 'com.atproto.access'); 431 + assert.strictEqual(payload.sub, did); 432 + assert.strictEqual(payload.aud, did); 433 + assert.ok(payload.iat > 0); 434 + assert.ok(payload.exp > payload.iat); 435 + }); 436 437 test('createRefreshJwt creates valid JWT with jti', async () => { 438 + const did = 'did:web:test.example'; 439 + const secret = 'test-secret-key'; 440 + const jwt = await createRefreshJwt(did, secret); 441 442 + const parts = jwt.split('.'); 443 + const header = JSON.parse( 444 + new TextDecoder().decode(base64UrlDecode(parts[0])), 445 + ); 446 + assert.strictEqual(header.typ, 'refresh+jwt'); 447 448 + const payload = JSON.parse( 449 + new TextDecoder().decode(base64UrlDecode(parts[1])), 450 + ); 451 + assert.strictEqual(payload.scope, 'com.atproto.refresh'); 452 + assert.ok(payload.jti); // has unique token ID 453 + }); 454 + }); 455 456 describe('JWT Verification', () => { 457 test('verifyAccessJwt returns payload for valid token', async () => { 458 + const did = 'did:web:test.example'; 459 + const secret = 'test-secret-key'; 460 + const jwt = await createAccessJwt(did, secret); 461 462 + const payload = await verifyAccessJwt(jwt, secret); 463 + assert.strictEqual(payload.sub, did); 464 + assert.strictEqual(payload.scope, 'com.atproto.access'); 465 + }); 466 467 test('verifyAccessJwt throws for wrong secret', async () => { 468 + const did = 'did:web:test.example'; 469 + const jwt = await createAccessJwt(did, 'correct-secret'); 470 471 await assert.rejects( 472 () => verifyAccessJwt(jwt, 'wrong-secret'), 473 + /invalid signature/i, 474 + ); 475 + }); 476 477 test('verifyAccessJwt throws for expired token', async () => { 478 + const did = 'did:web:test.example'; 479 + const secret = 'test-secret-key'; 480 // Create token that expired 1 second ago 481 + const jwt = await createAccessJwt(did, secret, -1); 482 483 + await assert.rejects(() => verifyAccessJwt(jwt, secret), /expired/i); 484 + }); 485 486 test('verifyAccessJwt throws for refresh token', async () => { 487 + const did = 'did:web:test.example'; 488 + const secret = 'test-secret-key'; 489 + const jwt = await createRefreshJwt(did, secret); 490 491 await assert.rejects( 492 () => verifyAccessJwt(jwt, secret), 493 + /invalid token type/i, 494 + ); 495 + }); 496 + });