diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..80bb5ce --- /dev/null +++ b/biome.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single" + } + } +} diff --git a/package-lock.json b/package-lock.json index 791555a..971347a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,173 @@ "name": "pds.js", "version": "0.1.0", "devDependencies": { + "@biomejs/biome": "^2.3.11", "wrangler": "^4.54.0" } }, + "node_modules/@biomejs/biome": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.11.tgz", + "integrity": "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.3.11", + "@biomejs/cli-darwin-x64": "2.3.11", + "@biomejs/cli-linux-arm64": "2.3.11", + "@biomejs/cli-linux-arm64-musl": "2.3.11", + "@biomejs/cli-linux-x64": "2.3.11", + "@biomejs/cli-linux-x64-musl": "2.3.11", + "@biomejs/cli-win32-arm64": "2.3.11", + "@biomejs/cli-win32-x64": "2.3.11" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.11.tgz", + "integrity": "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.11.tgz", + "integrity": "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.11.tgz", + "integrity": "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.11.tgz", + "integrity": "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.11.tgz", + "integrity": "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.11.tgz", + "integrity": "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.11.tgz", + "integrity": "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.11.tgz", + "integrity": "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.1.tgz", diff --git a/package.json b/package.json index 7a6d2f2..637168f 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,13 @@ "deploy": "wrangler deploy", "test": "node --test test/*.test.js", "test:e2e": "./test/e2e.sh", - "setup": "node scripts/setup.js" + "setup": "node scripts/setup.js", + "format": "biome format --write .", + "lint": "biome lint .", + "check": "biome check ." }, "devDependencies": { + "@biomejs/biome": "^2.3.11", "wrangler": "^4.54.0" } } diff --git a/scripts/setup.js b/scripts/setup.js index e558ff4..29aea0e 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -9,44 +9,52 @@ * Usage: node scripts/setup.js --handle alice --pds https://your-pds.workers.dev */ -import { webcrypto } from 'crypto' -import { writeFileSync } from 'fs' +import { webcrypto } from 'node:crypto'; +import { writeFileSync } from 'node:fs'; // === ARGUMENT PARSING === function parseArgs() { - const args = process.argv.slice(2) + const args = process.argv.slice(2); const opts = { handle: null, pds: null, plcUrl: 'https://plc.directory', - relayUrl: 'https://bsky.network' - } + relayUrl: 'https://bsky.network', + }; for (let i = 0; i < args.length; i++) { if (args[i] === '--handle' && args[i + 1]) { - opts.handle = args[++i] + opts.handle = args[++i]; } else if (args[i] === '--pds' && args[i + 1]) { - opts.pds = args[++i] + opts.pds = args[++i]; } else if (args[i] === '--plc-url' && args[i + 1]) { - opts.plcUrl = args[++i] + opts.plcUrl = args[++i]; } else if (args[i] === '--relay-url' && args[i + 1]) { - opts.relayUrl = args[++i] + opts.relayUrl = args[++i]; } } if (!opts.pds) { - console.error('Usage: node scripts/setup.js --pds [--handle ]') - console.error('') - console.error('Options:') - console.error(' --pds PDS URL (e.g., "https://atproto-pds.chad-53c.workers.dev")') - console.error(' --handle Subdomain handle (e.g., "alice") - optional, uses bare hostname if omitted') - console.error(' --plc-url PLC directory URL (default: https://plc.directory)') - console.error(' --relay-url Relay URL (default: https://bsky.network)') - process.exit(1) + console.error( + 'Usage: node scripts/setup.js --pds [--handle ]', + ); + console.error(''); + console.error('Options:'); + console.error( + ' --pds PDS URL (e.g., "https://atproto-pds.chad-53c.workers.dev")', + ); + console.error( + ' --handle Subdomain handle (e.g., "alice") - optional, uses bare hostname if omitted', + ); + console.error( + ' --plc-url PLC directory URL (default: https://plc.directory)', + ); + console.error(' --relay-url Relay URL (default: https://bsky.network)'); + process.exit(1); } - return opts + return opts; } // === KEY GENERATION === @@ -55,453 +63,469 @@ async function generateP256Keypair() { const keyPair = await webcrypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, true, - ['sign', 'verify'] - ) + ['sign', 'verify'], + ); // Export private key as raw 32 bytes - const privateJwk = await webcrypto.subtle.exportKey('jwk', keyPair.privateKey) - const privateBytes = base64UrlDecode(privateJwk.d) + const privateJwk = await webcrypto.subtle.exportKey( + 'jwk', + keyPair.privateKey, + ); + const privateBytes = base64UrlDecode(privateJwk.d); // Export public key as uncompressed point (65 bytes) - const publicRaw = await webcrypto.subtle.exportKey('raw', keyPair.publicKey) - const publicBytes = new Uint8Array(publicRaw) + const publicRaw = await webcrypto.subtle.exportKey('raw', keyPair.publicKey); + const publicBytes = new Uint8Array(publicRaw); // Compress public key to 33 bytes - const compressedPublic = compressPublicKey(publicBytes) + const compressedPublic = compressPublicKey(publicBytes); return { privateKey: privateBytes, publicKey: compressedPublic, - cryptoKey: keyPair.privateKey - } + cryptoKey: keyPair.privateKey, + }; } function compressPublicKey(uncompressed) { // uncompressed is 65 bytes: 0x04 + x(32) + y(32) - const x = uncompressed.slice(1, 33) - const y = uncompressed.slice(33, 65) - const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03 - const compressed = new Uint8Array(33) - compressed[0] = prefix - compressed.set(x, 1) - return compressed + const x = uncompressed.slice(1, 33); + const y = uncompressed.slice(33, 65); + const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03; + const compressed = new Uint8Array(33); + compressed[0] = prefix; + compressed.set(x, 1); + return compressed; } function base64UrlDecode(str) { - const base64 = str.replace(/-/g, '+').replace(/_/g, '/') - const binary = atob(base64) - const bytes = new Uint8Array(binary.length) + const base64 = str.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i) + bytes[i] = binary.charCodeAt(i); } - return bytes + return bytes; } function bytesToHex(bytes) { - return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); } // === DID:KEY ENCODING === // Multicodec prefix for P-256 public key (0x1200) -const P256_MULTICODEC = new Uint8Array([0x80, 0x24]) +const P256_MULTICODEC = new Uint8Array([0x80, 0x24]); function publicKeyToDidKey(compressedPublicKey) { // did:key format: "did:key:" + multibase(base58btc) of multicodec + key - const keyWithCodec = new Uint8Array(P256_MULTICODEC.length + compressedPublicKey.length) - keyWithCodec.set(P256_MULTICODEC) - keyWithCodec.set(compressedPublicKey, P256_MULTICODEC.length) + const keyWithCodec = new Uint8Array( + P256_MULTICODEC.length + compressedPublicKey.length, + ); + keyWithCodec.set(P256_MULTICODEC); + keyWithCodec.set(compressedPublicKey, P256_MULTICODEC.length); - return 'did:key:z' + base58btcEncode(keyWithCodec) + return `did:key:z${base58btcEncode(keyWithCodec)}`; } function base58btcEncode(bytes) { - const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' + const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; // Count leading zeros - let zeros = 0 + let zeros = 0; for (const b of bytes) { - if (b === 0) zeros++ - else break + if (b === 0) zeros++; + else break; } // Convert to base58 - const digits = [0] + const digits = [0]; for (const byte of bytes) { - let carry = byte + let carry = byte; for (let i = 0; i < digits.length; i++) { - carry += digits[i] << 8 - digits[i] = carry % 58 - carry = (carry / 58) | 0 + carry += digits[i] << 8; + digits[i] = carry % 58; + carry = (carry / 58) | 0; } while (carry > 0) { - digits.push(carry % 58) - carry = (carry / 58) | 0 + digits.push(carry % 58); + carry = (carry / 58) | 0; } } // Convert to string - let result = '1'.repeat(zeros) + let result = '1'.repeat(zeros); for (let i = digits.length - 1; i >= 0; i--) { - result += ALPHABET[digits[i]] + result += ALPHABET[digits[i]]; } - return result + return result; } // === CBOR ENCODING (dag-cbor compliant for PLC operations) === function cborEncodeKey(key) { // Encode a string key to CBOR bytes (for sorting) - const bytes = new TextEncoder().encode(key) - const parts = [] - const mt = 3 << 5 // major type 3 = text string + const bytes = new TextEncoder().encode(key); + const parts = []; + const mt = 3 << 5; // major type 3 = text string if (bytes.length < 24) { - parts.push(mt | bytes.length) + parts.push(mt | bytes.length); } else if (bytes.length < 256) { - parts.push(mt | 24, bytes.length) + parts.push(mt | 24, bytes.length); } else if (bytes.length < 65536) { - parts.push(mt | 25, bytes.length >> 8, bytes.length & 0xff) + parts.push(mt | 25, bytes.length >> 8, bytes.length & 0xff); } - parts.push(...bytes) - return new Uint8Array(parts) + parts.push(...bytes); + return new Uint8Array(parts); } function compareBytes(a, b) { // dag-cbor: bytewise lexicographic order of encoded keys - const minLen = Math.min(a.length, b.length) + const minLen = Math.min(a.length, b.length); for (let i = 0; i < minLen; i++) { - if (a[i] !== b[i]) return a[i] - b[i] + if (a[i] !== b[i]) return a[i] - b[i]; } - return a.length - b.length + return a.length - b.length; } function cborEncode(value) { - const parts = [] + const parts = []; function encode(val) { if (val === null) { - parts.push(0xf6) + parts.push(0xf6); } else if (typeof val === 'string') { - const bytes = new TextEncoder().encode(val) - encodeHead(3, bytes.length) - parts.push(...bytes) + const bytes = new TextEncoder().encode(val); + encodeHead(3, bytes.length); + parts.push(...bytes); } else if (typeof val === 'number') { if (Number.isInteger(val) && val >= 0) { - encodeHead(0, val) + encodeHead(0, val); } } else if (val instanceof Uint8Array) { - encodeHead(2, val.length) - parts.push(...val) + encodeHead(2, val.length); + parts.push(...val); } else if (Array.isArray(val)) { - encodeHead(4, val.length) - for (const item of val) encode(item) + encodeHead(4, val.length); + for (const item of val) encode(item); } else if (typeof val === 'object') { // dag-cbor: sort keys by their CBOR-encoded bytes (length first, then lexicographic) - const keys = Object.keys(val) - const keysSorted = keys.sort((a, b) => compareBytes(cborEncodeKey(a), cborEncodeKey(b))) - encodeHead(5, keysSorted.length) + const keys = Object.keys(val); + const keysSorted = keys.sort((a, b) => + compareBytes(cborEncodeKey(a), cborEncodeKey(b)), + ); + encodeHead(5, keysSorted.length); for (const key of keysSorted) { - encode(key) - encode(val[key]) + encode(key); + encode(val[key]); } } } function encodeHead(majorType, length) { - const mt = majorType << 5 + const mt = majorType << 5; if (length < 24) { - parts.push(mt | length) + parts.push(mt | length); } else if (length < 256) { - parts.push(mt | 24, length) + parts.push(mt | 24, length); } else if (length < 65536) { - parts.push(mt | 25, length >> 8, length & 0xff) + parts.push(mt | 25, length >> 8, length & 0xff); } } - encode(value) - return new Uint8Array(parts) + encode(value); + return new Uint8Array(parts); } // === HASHING === async function sha256(data) { - const hash = await webcrypto.subtle.digest('SHA-256', data) - return new Uint8Array(hash) + const hash = await webcrypto.subtle.digest('SHA-256', data); + return new Uint8Array(hash); } // === PLC OPERATIONS === async function signPlcOperation(operation, privateKey) { // Encode operation without sig field - const { sig, ...opWithoutSig } = operation - const encoded = cborEncode(opWithoutSig) + const { sig, ...opWithoutSig } = operation; + const encoded = cborEncode(opWithoutSig); // Sign with P-256 const signature = await webcrypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, privateKey, - encoded - ) + encoded, + ); // Convert to low-S form and base64url encode - const sigBytes = ensureLowS(new Uint8Array(signature)) - return base64UrlEncode(sigBytes) + const sigBytes = ensureLowS(new Uint8Array(signature)); + return base64UrlEncode(sigBytes); } function ensureLowS(sig) { // P-256 order N - const N = BigInt('0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551') - const halfN = N / 2n + const N = BigInt( + '0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551', + ); + const halfN = N / 2n; - const r = sig.slice(0, 32) - const s = sig.slice(32, 64) + const r = sig.slice(0, 32); + const s = sig.slice(32, 64); // Convert s to BigInt - let sInt = BigInt('0x' + bytesToHex(s)) + let sInt = BigInt(`0x${bytesToHex(s)}`); // If s > N/2, replace with N - s if (sInt > halfN) { - sInt = N - sInt - const newS = hexToBytes(sInt.toString(16).padStart(64, '0')) - const result = new Uint8Array(64) - result.set(r) - result.set(newS, 32) - return result + sInt = N - sInt; + const newS = hexToBytes(sInt.toString(16).padStart(64, '0')); + const result = new Uint8Array(64); + result.set(r); + result.set(newS, 32); + return result; } - return sig + return sig; } function hexToBytes(hex) { - const bytes = new Uint8Array(hex.length / 2) + const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { - bytes[i / 2] = parseInt(hex.substr(i, 2), 16) + bytes[i / 2] = parseInt(hex.substr(i, 2), 16); } - return bytes + return bytes; } function base64UrlEncode(bytes) { - const binary = String.fromCharCode(...bytes) - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + const binary = String.fromCharCode(...bytes); + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); } async function createGenesisOperation(opts) { - const { didKey, handle, pdsUrl, cryptoKey } = opts + const { didKey, handle, pdsUrl, cryptoKey } = opts; // Build full handle: subdomain.pds-hostname, or just pds-hostname if no subdomain - const pdsHost = new URL(pdsUrl).host - const fullHandle = handle ? `${handle}.${pdsHost}` : pdsHost + const pdsHost = new URL(pdsUrl).host; + const fullHandle = handle ? `${handle}.${pdsHost}` : pdsHost; const operation = { type: 'plc_operation', rotationKeys: [didKey], verificationMethods: { - atproto: didKey + atproto: didKey, }, alsoKnownAs: [`at://${fullHandle}`], services: { atproto_pds: { type: 'AtprotoPersonalDataServer', - endpoint: pdsUrl - } + endpoint: pdsUrl, + }, }, - prev: null - } + prev: null, + }; // Sign the operation - operation.sig = await signPlcOperation(operation, cryptoKey) + operation.sig = await signPlcOperation(operation, cryptoKey); - return { operation, fullHandle } + return { operation, fullHandle }; } async function deriveDidFromOperation(operation) { // DID is computed from the FULL operation INCLUDING the signature - const encoded = cborEncode(operation) - const hash = await sha256(encoded) + const encoded = cborEncode(operation); + const hash = await sha256(encoded); // DID is base32 of first 15 bytes of hash (= 24 base32 chars) - return 'did:plc:' + base32Encode(hash.slice(0, 15)) + return `did:plc:${base32Encode(hash.slice(0, 15))}`; } function base32Encode(bytes) { - const alphabet = 'abcdefghijklmnopqrstuvwxyz234567' - let result = '' - let bits = 0 - let value = 0 + const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; + let result = ''; + let bits = 0; + let value = 0; for (const byte of bytes) { - value = (value << 8) | byte - bits += 8 + value = (value << 8) | byte; + bits += 8; while (bits >= 5) { - bits -= 5 - result += alphabet[(value >> bits) & 31] + bits -= 5; + result += alphabet[(value >> bits) & 31]; } } if (bits > 0) { - result += alphabet[(value << (5 - bits)) & 31] + result += alphabet[(value << (5 - bits)) & 31]; } - return result + return result; } // === PLC DIRECTORY REGISTRATION === async function registerWithPlc(plcUrl, did, operation) { - const url = `${plcUrl}/${encodeURIComponent(did)}` + const url = `${plcUrl}/${encodeURIComponent(did)}`; const response = await fetch(url, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', }, - body: JSON.stringify(operation) - }) + body: JSON.stringify(operation), + }); if (!response.ok) { - const text = await response.text() - throw new Error(`PLC registration failed: ${response.status} ${text}`) + const text = await response.text(); + throw new Error(`PLC registration failed: ${response.status} ${text}`); } - return true + return true; } // === PDS INITIALIZATION === async function initializePds(pdsUrl, did, privateKeyHex, handle) { - const url = `${pdsUrl}/init?did=${encodeURIComponent(did)}` + const url = `${pdsUrl}/init?did=${encodeURIComponent(did)}`; const response = await fetch(url, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', }, body: JSON.stringify({ did, privateKey: privateKeyHex, - handle - }) - }) + handle, + }), + }); if (!response.ok) { - const text = await response.text() - throw new Error(`PDS initialization failed: ${response.status} ${text}`) + const text = await response.text(); + throw new Error(`PDS initialization failed: ${response.status} ${text}`); } - return response.json() + return response.json(); } // === HANDLE REGISTRATION === async function registerHandle(pdsUrl, handle, did) { - const url = `${pdsUrl}/register-handle` + const url = `${pdsUrl}/register-handle`; const response = await fetch(url, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', }, - body: JSON.stringify({ handle, did }) - }) + body: JSON.stringify({ handle, did }), + }); if (!response.ok) { - const text = await response.text() - throw new Error(`Handle registration failed: ${response.status} ${text}`) + const text = await response.text(); + throw new Error(`Handle registration failed: ${response.status} ${text}`); } - return true + return true; } // === RELAY NOTIFICATION === async function notifyRelay(relayUrl, pdsHostname) { - const url = `${relayUrl}/xrpc/com.atproto.sync.requestCrawl` + const url = `${relayUrl}/xrpc/com.atproto.sync.requestCrawl`; const response = await fetch(url, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', }, body: JSON.stringify({ - hostname: pdsHostname - }) - }) + hostname: pdsHostname, + }), + }); // Relay might return 200 or 202, both are OK if (!response.ok && response.status !== 202) { - const text = await response.text() - console.warn(` Warning: Relay notification returned ${response.status}: ${text}`) - return false + const text = await response.text(); + console.warn( + ` Warning: Relay notification returned ${response.status}: ${text}`, + ); + return false; } - return true + return true; } // === CREDENTIALS OUTPUT === function saveCredentials(filename, credentials) { - writeFileSync(filename, JSON.stringify(credentials, null, 2)) + writeFileSync(filename, JSON.stringify(credentials, null, 2)); } // === MAIN === async function main() { - const opts = parseArgs() + const opts = parseArgs(); - console.log('PDS Federation Setup') - console.log('====================') - console.log(`PDS: ${opts.pds}`) - console.log('') + console.log('PDS Federation Setup'); + console.log('===================='); + console.log(`PDS: ${opts.pds}`); + console.log(''); // Step 1: Generate keypair - console.log('Generating P-256 keypair...') - const keyPair = await generateP256Keypair() - const didKey = publicKeyToDidKey(keyPair.publicKey) - console.log(` did:key: ${didKey}`) - console.log('') + console.log('Generating P-256 keypair...'); + const keyPair = await generateP256Keypair(); + const didKey = publicKeyToDidKey(keyPair.publicKey); + console.log(` did:key: ${didKey}`); + console.log(''); // Step 2: Create genesis operation - console.log('Creating PLC genesis operation...') + console.log('Creating PLC genesis operation...'); const { operation, fullHandle } = await createGenesisOperation({ didKey, handle: opts.handle, pdsUrl: opts.pds, - cryptoKey: keyPair.cryptoKey - }) - const did = await deriveDidFromOperation(operation) - console.log(` DID: ${did}`) - console.log(` Handle: ${fullHandle}`) - console.log('') + cryptoKey: keyPair.cryptoKey, + }); + const did = await deriveDidFromOperation(operation); + console.log(` DID: ${did}`); + console.log(` Handle: ${fullHandle}`); + console.log(''); // Step 3: Register with PLC directory - console.log(`Registering with ${opts.plcUrl}...`) - await registerWithPlc(opts.plcUrl, did, operation) - console.log(' Registered successfully!') - console.log('') + console.log(`Registering with ${opts.plcUrl}...`); + await registerWithPlc(opts.plcUrl, did, operation); + console.log(' Registered successfully!'); + console.log(''); // Step 4: Initialize PDS - console.log(`Initializing PDS at ${opts.pds}...`) - const privateKeyHex = bytesToHex(keyPair.privateKey) - await initializePds(opts.pds, did, privateKeyHex, fullHandle) - console.log(' PDS initialized!') - console.log('') + console.log(`Initializing PDS at ${opts.pds}...`); + const privateKeyHex = bytesToHex(keyPair.privateKey); + await initializePds(opts.pds, did, privateKeyHex, fullHandle); + console.log(' PDS initialized!'); + console.log(''); // Step 4b: Register handle -> DID mapping (only for subdomain handles) if (opts.handle) { - console.log(`Registering handle mapping...`) - await registerHandle(opts.pds, opts.handle, did) - console.log(` Handle ${opts.handle} -> ${did}`) - console.log('') + console.log(`Registering handle mapping...`); + await registerHandle(opts.pds, opts.handle, did); + console.log(` Handle ${opts.handle} -> ${did}`); + console.log(''); } // Step 5: Notify relay - const pdsHostname = new URL(opts.pds).host - console.log(`Notifying relay at ${opts.relayUrl}...`) - const relayOk = await notifyRelay(opts.relayUrl, pdsHostname) + const pdsHostname = new URL(opts.pds).host; + console.log(`Notifying relay at ${opts.relayUrl}...`); + const relayOk = await notifyRelay(opts.relayUrl, pdsHostname); if (relayOk) { - console.log(' Relay notified!') + console.log(' Relay notified!'); } - console.log('') + console.log(''); // Step 6: Save credentials const credentials = { @@ -510,24 +534,24 @@ async function main() { privateKeyHex: bytesToHex(keyPair.privateKey), didKey, pdsUrl: opts.pds, - createdAt: new Date().toISOString() - } + createdAt: new Date().toISOString(), + }; - const credentialsFile = `./credentials-${opts.handle || new URL(opts.pds).host}.json` - saveCredentials(credentialsFile, credentials) + const credentialsFile = `./credentials-${opts.handle || new URL(opts.pds).host}.json`; + saveCredentials(credentialsFile, credentials); // Final output - console.log('Setup Complete!') - console.log('===============') - console.log(`Handle: ${fullHandle}`) - console.log(`DID: ${did}`) - console.log(`PDS: ${opts.pds}`) - console.log('') - console.log(`Credentials saved to: ${credentialsFile}`) - console.log('Keep this file safe - it contains your private key!') + console.log('Setup Complete!'); + console.log('==============='); + console.log(`Handle: ${fullHandle}`); + console.log(`DID: ${did}`); + console.log(`PDS: ${opts.pds}`); + console.log(''); + console.log(`Credentials saved to: ${credentialsFile}`); + console.log('Keep this file safe - it contains your private key!'); } -main().catch(err => { - console.error('Error:', err.message) - process.exit(1) -}) +main().catch((err) => { + console.error('Error:', err.message); + process.exit(1); +}); diff --git a/scripts/update-did.js b/scripts/update-did.js index 0cdc0c1..9aab3ee 100644 --- a/scripts/update-did.js +++ b/scripts/update-did.js @@ -6,210 +6,221 @@ * Usage: node scripts/update-did.js --credentials --new-handle --new-pds */ -import { webcrypto } from 'crypto' -import { readFileSync, writeFileSync } from 'fs' +import { webcrypto } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; // === ARGUMENT PARSING === function parseArgs() { - const args = process.argv.slice(2) + const args = process.argv.slice(2); const opts = { credentials: null, newHandle: null, newPds: null, - plcUrl: 'https://plc.directory' - } + plcUrl: 'https://plc.directory', + }; for (let i = 0; i < args.length; i++) { if (args[i] === '--credentials' && args[i + 1]) { - opts.credentials = args[++i] + opts.credentials = args[++i]; } else if (args[i] === '--new-handle' && args[i + 1]) { - opts.newHandle = args[++i] + opts.newHandle = args[++i]; } else if (args[i] === '--new-pds' && args[i + 1]) { - opts.newPds = args[++i] + opts.newPds = args[++i]; } else if (args[i] === '--plc-url' && args[i + 1]) { - opts.plcUrl = args[++i] + opts.plcUrl = args[++i]; } } if (!opts.credentials || !opts.newHandle || !opts.newPds) { - console.error('Usage: node scripts/update-did.js --credentials --new-handle --new-pds ') - process.exit(1) + console.error( + 'Usage: node scripts/update-did.js --credentials --new-handle --new-pds ', + ); + process.exit(1); } - return opts + return opts; } // === CRYPTO HELPERS === function hexToBytes(hex) { - const bytes = new Uint8Array(hex.length / 2) + const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { - bytes[i / 2] = parseInt(hex.substr(i, 2), 16) + bytes[i / 2] = parseInt(hex.substr(i, 2), 16); } - return bytes + return bytes; } function bytesToHex(bytes) { - return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); } async function importPrivateKey(privateKeyBytes) { const pkcs8Prefix = new Uint8Array([ 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, - 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20 - ]) + 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20, + ]); - const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32) - pkcs8.set(pkcs8Prefix) - pkcs8.set(privateKeyBytes, pkcs8Prefix.length) + const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32); + pkcs8.set(pkcs8Prefix); + pkcs8.set(privateKeyBytes, pkcs8Prefix.length); return webcrypto.subtle.importKey( 'pkcs8', pkcs8, { name: 'ECDSA', namedCurve: 'P-256' }, false, - ['sign'] - ) + ['sign'], + ); } // === CBOR ENCODING === function cborEncodeKey(key) { - const bytes = new TextEncoder().encode(key) - const parts = [] - const mt = 3 << 5 + const bytes = new TextEncoder().encode(key); + const parts = []; + const mt = 3 << 5; if (bytes.length < 24) { - parts.push(mt | bytes.length) + parts.push(mt | bytes.length); } else if (bytes.length < 256) { - parts.push(mt | 24, bytes.length) + parts.push(mt | 24, bytes.length); } - parts.push(...bytes) - return new Uint8Array(parts) + parts.push(...bytes); + return new Uint8Array(parts); } function compareBytes(a, b) { - const minLen = Math.min(a.length, b.length) + const minLen = Math.min(a.length, b.length); for (let i = 0; i < minLen; i++) { - if (a[i] !== b[i]) return a[i] - b[i] + if (a[i] !== b[i]) return a[i] - b[i]; } - return a.length - b.length + return a.length - b.length; } function cborEncode(value) { - const parts = [] + const parts = []; function encode(val) { if (val === null) { - parts.push(0xf6) + parts.push(0xf6); } else if (typeof val === 'string') { - const bytes = new TextEncoder().encode(val) - encodeHead(3, bytes.length) - parts.push(...bytes) + const bytes = new TextEncoder().encode(val); + encodeHead(3, bytes.length); + parts.push(...bytes); } else if (typeof val === 'number') { if (Number.isInteger(val) && val >= 0) { - encodeHead(0, val) + encodeHead(0, val); } } else if (val instanceof Uint8Array) { - encodeHead(2, val.length) - parts.push(...val) + encodeHead(2, val.length); + parts.push(...val); } else if (Array.isArray(val)) { - encodeHead(4, val.length) - for (const item of val) encode(item) + encodeHead(4, val.length); + for (const item of val) encode(item); } else if (typeof val === 'object') { - const keys = Object.keys(val) - const keysSorted = keys.sort((a, b) => compareBytes(cborEncodeKey(a), cborEncodeKey(b))) - encodeHead(5, keysSorted.length) + const keys = Object.keys(val); + const keysSorted = keys.sort((a, b) => + compareBytes(cborEncodeKey(a), cborEncodeKey(b)), + ); + encodeHead(5, keysSorted.length); for (const key of keysSorted) { - encode(key) - encode(val[key]) + encode(key); + encode(val[key]); } } } function encodeHead(majorType, length) { - const mt = majorType << 5 + const mt = majorType << 5; if (length < 24) { - parts.push(mt | length) + parts.push(mt | length); } else if (length < 256) { - parts.push(mt | 24, length) + parts.push(mt | 24, length); } else if (length < 65536) { - parts.push(mt | 25, length >> 8, length & 0xff) + parts.push(mt | 25, length >> 8, length & 0xff); } } - encode(value) - return new Uint8Array(parts) + encode(value); + return new Uint8Array(parts); } // === SIGNING === -const P256_N = BigInt('0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551') +const P256_N = BigInt( + '0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551', +); function ensureLowS(sig) { - const halfN = P256_N / 2n - const r = sig.slice(0, 32) - const s = sig.slice(32, 64) - let sInt = BigInt('0x' + bytesToHex(s)) + const halfN = P256_N / 2n; + const r = sig.slice(0, 32); + const s = sig.slice(32, 64); + let sInt = BigInt(`0x${bytesToHex(s)}`); if (sInt > halfN) { - sInt = P256_N - sInt - const newS = hexToBytes(sInt.toString(16).padStart(64, '0')) - const result = new Uint8Array(64) - result.set(r) - result.set(newS, 32) - return result + sInt = P256_N - sInt; + const newS = hexToBytes(sInt.toString(16).padStart(64, '0')); + const result = new Uint8Array(64); + result.set(r); + result.set(newS, 32); + return result; } - return sig + return sig; } function base64UrlEncode(bytes) { - const binary = String.fromCharCode(...bytes) - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + const binary = String.fromCharCode(...bytes); + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); } async function signPlcOperation(operation, privateKey) { - const { sig, ...opWithoutSig } = operation - const encoded = cborEncode(opWithoutSig) + const { sig, ...opWithoutSig } = operation; + const encoded = cborEncode(opWithoutSig); const signature = await webcrypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, privateKey, - encoded - ) + encoded, + ); - const sigBytes = ensureLowS(new Uint8Array(signature)) - return base64UrlEncode(sigBytes) + const sigBytes = ensureLowS(new Uint8Array(signature)); + return base64UrlEncode(sigBytes); } // === MAIN === async function main() { - const opts = parseArgs() + const opts = parseArgs(); // Load credentials - const creds = JSON.parse(readFileSync(opts.credentials, 'utf-8')) - console.log(`Updating DID: ${creds.did}`) - console.log(` Old handle: ${creds.handle}`) - console.log(` New handle: ${opts.newHandle}`) - console.log(` New PDS: ${opts.newPds}`) - console.log('') + const creds = JSON.parse(readFileSync(opts.credentials, 'utf-8')); + console.log(`Updating DID: ${creds.did}`); + console.log(` Old handle: ${creds.handle}`); + console.log(` New handle: ${opts.newHandle}`); + console.log(` New PDS: ${opts.newPds}`); + console.log(''); // Fetch current operation log - console.log('Fetching current PLC operation log...') - const logRes = await fetch(`${opts.plcUrl}/${creds.did}/log/audit`) + console.log('Fetching current PLC operation log...'); + const logRes = await fetch(`${opts.plcUrl}/${creds.did}/log/audit`); if (!logRes.ok) { - throw new Error(`Failed to fetch PLC log: ${logRes.status}`) + throw new Error(`Failed to fetch PLC log: ${logRes.status}`); } - const log = await logRes.json() - const lastOp = log[log.length - 1] - console.log(` Found ${log.length} operations`) - console.log(` Last CID: ${lastOp.cid}`) - console.log('') + const log = await logRes.json(); + const lastOp = log[log.length - 1]; + console.log(` Found ${log.length} operations`); + console.log(` Last CID: ${lastOp.cid}`); + console.log(''); // Import private key - const privateKey = await importPrivateKey(hexToBytes(creds.privateKeyHex)) + const privateKey = await importPrivateKey(hexToBytes(creds.privateKeyHex)); // Create new operation const newOp = { @@ -220,48 +231,48 @@ async function main() { services: { atproto_pds: { type: 'AtprotoPersonalDataServer', - endpoint: opts.newPds - } + endpoint: opts.newPds, + }, }, - prev: lastOp.cid - } + prev: lastOp.cid, + }; // Sign the operation - console.log('Signing new operation...') - newOp.sig = await signPlcOperation(newOp, privateKey) + console.log('Signing new operation...'); + newOp.sig = await signPlcOperation(newOp, privateKey); // Submit to PLC - console.log('Submitting to PLC directory...') + console.log('Submitting to PLC directory...'); const submitRes = await fetch(`${opts.plcUrl}/${creds.did}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(newOp) - }) + body: JSON.stringify(newOp), + }); if (!submitRes.ok) { - const text = await submitRes.text() - throw new Error(`PLC update failed: ${submitRes.status} ${text}`) + const text = await submitRes.text(); + throw new Error(`PLC update failed: ${submitRes.status} ${text}`); } - console.log(' Updated successfully!') - console.log('') + console.log(' Updated successfully!'); + console.log(''); // Update credentials file - creds.handle = opts.newHandle - creds.pdsUrl = opts.newPds - writeFileSync(opts.credentials, JSON.stringify(creds, null, 2)) - console.log(`Updated credentials file: ${opts.credentials}`) + creds.handle = opts.newHandle; + creds.pdsUrl = opts.newPds; + writeFileSync(opts.credentials, JSON.stringify(creds, null, 2)); + console.log(`Updated credentials file: ${opts.credentials}`); // Verify - console.log('') - console.log('Verifying...') - const verifyRes = await fetch(`${opts.plcUrl}/${creds.did}`) - const didDoc = await verifyRes.json() - console.log(` alsoKnownAs: ${didDoc.alsoKnownAs}`) - console.log(` PDS endpoint: ${didDoc.service[0].serviceEndpoint}`) + console.log(''); + console.log('Verifying...'); + const verifyRes = await fetch(`${opts.plcUrl}/${creds.did}`); + const didDoc = await verifyRes.json(); + console.log(` alsoKnownAs: ${didDoc.alsoKnownAs}`); + console.log(` PDS endpoint: ${didDoc.service[0].serviceEndpoint}`); } -main().catch(err => { - console.error('Error:', err.message) - process.exit(1) -}) +main().catch((err) => { + console.error('Error:', err.message); + process.exit(1); +}); diff --git a/src/pds.js b/src/pds.js index 48fe944..c20c9fe 100644 --- a/src/pds.js +++ b/src/pds.js @@ -16,42 +16,42 @@ // === CONSTANTS === // CBOR primitive markers (RFC 8949) -const CBOR_FALSE = 0xf4 -const CBOR_TRUE = 0xf5 -const CBOR_NULL = 0xf6 +const CBOR_FALSE = 0xf4; +const CBOR_TRUE = 0xf5; +const CBOR_NULL = 0xf6; // DAG-CBOR CID link tag -const CBOR_TAG_CID = 42 +const CBOR_TAG_CID = 42; // === ERROR HELPER === function errorResponse(error, message, status) { - return Response.json({ error, message }, { status }) + return Response.json({ error, message }, { status }); } // === CRAWLER NOTIFICATION === // Notify relays to come crawl us after writes (like official PDS) -let lastCrawlNotify = 0 -const CRAWL_NOTIFY_THRESHOLD = 20 * 60 * 1000 // 20 minutes (matches official PDS) +let lastCrawlNotify = 0; +const CRAWL_NOTIFY_THRESHOLD = 20 * 60 * 1000; // 20 minutes (matches official PDS) async function notifyCrawlers(env, hostname) { - const now = Date.now() + const now = Date.now(); if (now - lastCrawlNotify < CRAWL_NOTIFY_THRESHOLD) { - return // Throttle notifications + return; // Throttle notifications } - const relayHost = env.RELAY_HOST - if (!relayHost) return + const relayHost = env.RELAY_HOST; + if (!relayHost) return; - lastCrawlNotify = now + lastCrawlNotify = now; // Fire and forget - don't block writes on relay notification fetch(`${relayHost}/xrpc/com.atproto.sync.requestCrawl`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ hostname }) - }).catch(err => { - console.log('Failed to notify relay:', err.message) - }) + body: JSON.stringify({ hostname }), + }).catch((err) => { + console.log('Failed to notify relay:', err.message); + }); } // === CID WRAPPER === @@ -60,9 +60,9 @@ async function notifyCrawlers(env, hostname) { class CID { constructor(bytes) { if (!(bytes instanceof Uint8Array)) { - throw new Error('CID must be constructed with Uint8Array') + throw new Error('CID must be constructed with Uint8Array'); } - this.bytes = bytes + this.bytes = bytes; } } @@ -76,20 +76,22 @@ class CID { * @param {number} length - Value or length to encode */ function encodeHead(parts, majorType, length) { - const mt = majorType << 5 + const mt = majorType << 5; if (length < 24) { - parts.push(mt | length) + parts.push(mt | length); } else if (length < 256) { - parts.push(mt | 24, length) + parts.push(mt | 24, length); } else if (length < 65536) { - parts.push(mt | 25, length >> 8, length & 0xff) + parts.push(mt | 25, length >> 8, length & 0xff); } else if (length < 4294967296) { // Use Math.floor instead of bitshift to avoid 32-bit signed integer overflow - parts.push(mt | 26, + parts.push( + mt | 26, Math.floor(length / 0x1000000) & 0xff, Math.floor(length / 0x10000) & 0xff, Math.floor(length / 0x100) & 0xff, - length & 0xff) + length & 0xff, + ); } } @@ -99,105 +101,105 @@ function encodeHead(parts, majorType, length) { * @returns {Uint8Array} CBOR-encoded bytes */ export function cborEncode(value) { - const parts = [] + const parts = []; function encode(val) { if (val === null) { - parts.push(CBOR_NULL) + parts.push(CBOR_NULL); } else if (val === true) { - parts.push(CBOR_TRUE) + parts.push(CBOR_TRUE); } else if (val === false) { - parts.push(CBOR_FALSE) + parts.push(CBOR_FALSE); } else if (typeof val === 'number') { - encodeInteger(val) + encodeInteger(val); } else if (typeof val === 'string') { - const bytes = new TextEncoder().encode(val) - encodeHead(parts, 3, bytes.length) // major type 3 = text string - parts.push(...bytes) + const bytes = new TextEncoder().encode(val); + encodeHead(parts, 3, bytes.length); // major type 3 = text string + parts.push(...bytes); } else if (val instanceof Uint8Array) { - encodeHead(parts, 2, val.length) // major type 2 = byte string - parts.push(...val) + encodeHead(parts, 2, val.length); // major type 2 = byte string + parts.push(...val); } else if (Array.isArray(val)) { - encodeHead(parts, 4, val.length) // major type 4 = array - for (const item of val) encode(item) + encodeHead(parts, 4, val.length); // major type 4 = array + for (const item of val) encode(item); } else if (typeof val === 'object') { // Sort keys for deterministic encoding - const keys = Object.keys(val).sort() - encodeHead(parts, 5, keys.length) // major type 5 = map + const keys = Object.keys(val).sort(); + encodeHead(parts, 5, keys.length); // major type 5 = map for (const key of keys) { - encode(key) - encode(val[key]) + encode(key); + encode(val[key]); } } } function encodeInteger(n) { if (n >= 0) { - encodeHead(parts, 0, n) // major type 0 = unsigned int + encodeHead(parts, 0, n); // major type 0 = unsigned int } else { - encodeHead(parts, 1, -n - 1) // major type 1 = negative int + encodeHead(parts, 1, -n - 1); // major type 1 = negative int } } - encode(value) - return new Uint8Array(parts) + encode(value); + return new Uint8Array(parts); } // DAG-CBOR encoder that handles CIDs with tag 42 function cborEncodeDagCbor(value) { - const parts = [] + const parts = []; function encode(val) { if (val === null) { - parts.push(CBOR_NULL) + parts.push(CBOR_NULL); } else if (val === true) { - parts.push(CBOR_TRUE) + parts.push(CBOR_TRUE); } else if (val === false) { - parts.push(CBOR_FALSE) + parts.push(CBOR_FALSE); } else if (typeof val === 'number') { if (Number.isInteger(val) && val >= 0) { - encodeHead(parts, 0, val) + encodeHead(parts, 0, val); } else if (Number.isInteger(val) && val < 0) { - encodeHead(parts, 1, -val - 1) + encodeHead(parts, 1, -val - 1); } } else if (typeof val === 'string') { - const bytes = new TextEncoder().encode(val) - encodeHead(parts, 3, bytes.length) - parts.push(...bytes) + const bytes = new TextEncoder().encode(val); + encodeHead(parts, 3, bytes.length); + parts.push(...bytes); } else if (val instanceof CID) { // CID links in DAG-CBOR use tag 42 + 0x00 multibase prefix // The 0x00 prefix indicates "identity" multibase (raw bytes) - parts.push(0xd8, CBOR_TAG_CID) - encodeHead(parts, 2, val.bytes.length + 1) // +1 for 0x00 prefix - parts.push(0x00) - parts.push(...val.bytes) + parts.push(0xd8, CBOR_TAG_CID); + encodeHead(parts, 2, val.bytes.length + 1); // +1 for 0x00 prefix + parts.push(0x00); + parts.push(...val.bytes); } else if (val instanceof Uint8Array) { // Regular byte string - encodeHead(parts, 2, val.length) - parts.push(...val) + encodeHead(parts, 2, val.length); + parts.push(...val); } else if (Array.isArray(val)) { - encodeHead(parts, 4, val.length) - for (const item of val) encode(item) + encodeHead(parts, 4, val.length); + for (const item of val) encode(item); } else if (typeof val === 'object') { // DAG-CBOR: sort keys by length first, then lexicographically // (differs from standard CBOR which sorts lexicographically only) - const keys = Object.keys(val).filter(k => val[k] !== undefined) + const keys = Object.keys(val).filter((k) => val[k] !== undefined); keys.sort((a, b) => { - if (a.length !== b.length) return a.length - b.length - return a < b ? -1 : a > b ? 1 : 0 - }) - encodeHead(parts, 5, keys.length) + if (a.length !== b.length) return a.length - b.length; + return a < b ? -1 : a > b ? 1 : 0; + }); + encodeHead(parts, 5, keys.length); for (const key of keys) { - const keyBytes = new TextEncoder().encode(key) - encodeHead(parts, 3, keyBytes.length) - parts.push(...keyBytes) - encode(val[key]) + const keyBytes = new TextEncoder().encode(key); + encodeHead(parts, 3, keyBytes.length); + parts.push(...keyBytes); + encode(val[key]); } } } - encode(value) - return new Uint8Array(parts) + encode(value); + return new Uint8Array(parts); } /** @@ -206,66 +208,81 @@ function cborEncodeDagCbor(value) { * @returns {*} Decoded value */ export function cborDecode(bytes) { - let offset = 0 + let offset = 0; function read() { - const initial = bytes[offset++] - const major = initial >> 5 - const info = initial & 0x1f - - let length = info - if (info === 24) length = bytes[offset++] - else if (info === 25) { length = (bytes[offset++] << 8) | bytes[offset++] } - else if (info === 26) { + const initial = bytes[offset++]; + const major = initial >> 5; + const info = initial & 0x1f; + + let length = info; + if (info === 24) length = bytes[offset++]; + else if (info === 25) { + length = (bytes[offset++] << 8) | bytes[offset++]; + } else if (info === 26) { // Use multiplication instead of bitshift to avoid 32-bit signed integer overflow - length = bytes[offset++] * 0x1000000 + bytes[offset++] * 0x10000 + bytes[offset++] * 0x100 + bytes[offset++] + length = + bytes[offset++] * 0x1000000 + + bytes[offset++] * 0x10000 + + bytes[offset++] * 0x100 + + bytes[offset++]; } switch (major) { - case 0: return length // unsigned int - case 1: return -1 - length // negative int - case 2: { // byte string - const data = bytes.slice(offset, offset + length) - offset += length - return data + case 0: + return length; // unsigned int + case 1: + return -1 - length; // negative int + case 2: { + // byte string + const data = bytes.slice(offset, offset + length); + offset += length; + return data; } - case 3: { // text string - const data = new TextDecoder().decode(bytes.slice(offset, offset + length)) - offset += length - return data + case 3: { + // text string + const data = new TextDecoder().decode( + bytes.slice(offset, offset + length), + ); + offset += length; + return data; } - case 4: { // array - const arr = [] - for (let i = 0; i < length; i++) arr.push(read()) - return arr + case 4: { + // array + const arr = []; + for (let i = 0; i < length; i++) arr.push(read()); + return arr; } - case 5: { // map - const obj = {} + case 5: { + // map + const obj = {}; for (let i = 0; i < length; i++) { - const key = read() - obj[key] = read() + const key = read(); + obj[key] = read(); } - return obj + return obj; } - case 6: { // tag + case 6: { + // tag // length is the tag number - const taggedValue = read() + const taggedValue = read(); if (length === CBOR_TAG_CID) { // CID link: byte string with 0x00 multibase prefix, return raw CID bytes - return taggedValue.slice(1) // strip 0x00 prefix + return taggedValue.slice(1); // strip 0x00 prefix } - return taggedValue + return taggedValue; } - case 7: { // special - if (info === 20) return false - if (info === 21) return true - if (info === 22) return null - return undefined + case 7: { + // special + if (info === 20) return false; + if (info === 21) return true; + if (info === 22) return null; + return undefined; } } } - return read() + return read(); } // === CID GENERATION === @@ -277,19 +294,19 @@ export function cborDecode(bytes) { * @returns {Promise} CID bytes (36 bytes: version + codec + multihash) */ export async function createCid(bytes) { - const hash = await crypto.subtle.digest('SHA-256', bytes) - const hashBytes = new Uint8Array(hash) + const hash = await crypto.subtle.digest('SHA-256', bytes); + const hashBytes = new Uint8Array(hash); // CIDv1: version(1) + codec(dag-cbor=0x71) + multihash(sha256) // Multihash: hash-type(0x12) + length(0x20=32) + digest - const cid = new Uint8Array(2 + 2 + 32) - cid[0] = 0x01 // CIDv1 - cid[1] = 0x71 // dag-cbor codec - cid[2] = 0x12 // sha-256 - cid[3] = 0x20 // 32 bytes - cid.set(hashBytes, 4) - - return cid + const cid = new Uint8Array(2 + 2 + 32); + cid[0] = 0x01; // CIDv1 + cid[1] = 0x71; // dag-cbor codec + cid[2] = 0x12; // sha-256 + cid[3] = 0x20; // 32 bytes + cid.set(hashBytes, 4); + + return cid; } /** @@ -299,7 +316,7 @@ export async function createCid(bytes) { */ export function cidToString(cid) { // base32lower encoding for CIDv1 - return 'b' + base32Encode(cid) + return `b${base32Encode(cid)}`; } /** @@ -308,33 +325,33 @@ export function cidToString(cid) { * @returns {string} Base32lower-encoded string */ export function base32Encode(bytes) { - const alphabet = 'abcdefghijklmnopqrstuvwxyz234567' - let result = '' - let bits = 0 - let value = 0 + const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; + let result = ''; + let bits = 0; + let value = 0; for (const byte of bytes) { - value = (value << 8) | byte - bits += 8 + value = (value << 8) | byte; + bits += 8; while (bits >= 5) { - bits -= 5 - result += alphabet[(value >> bits) & 31] + bits -= 5; + result += alphabet[(value >> bits) & 31]; } } if (bits > 0) { - result += alphabet[(value << (5 - bits)) & 31] + result += alphabet[(value << (5 - bits)) & 31]; } - return result + return result; } // === TID GENERATION === // Timestamp-based IDs: base32-sort encoded microseconds + clock ID -const TID_CHARS = '234567abcdefghijklmnopqrstuvwxyz' -let lastTimestamp = 0 -let clockId = Math.floor(Math.random() * 1024) +const TID_CHARS = '234567abcdefghijklmnopqrstuvwxyz'; +let lastTimestamp = 0; +const clockId = Math.floor(Math.random() * 1024); /** * Generate a timestamp-based ID (TID) for record keys @@ -342,29 +359,29 @@ let clockId = Math.floor(Math.random() * 1024) * @returns {string} 13-character base32-sort encoded TID */ export function createTid() { - let timestamp = Date.now() * 1000 // microseconds + let timestamp = Date.now() * 1000; // microseconds // Ensure monotonic if (timestamp <= lastTimestamp) { - timestamp = lastTimestamp + 1 + timestamp = lastTimestamp + 1; } - lastTimestamp = timestamp + lastTimestamp = timestamp; // 13 chars: 11 for timestamp (64 bits but only ~53 used), 2 for clock ID - let tid = '' + let tid = ''; // Encode timestamp (high bits first for sortability) - let ts = timestamp + let ts = timestamp; for (let i = 0; i < 11; i++) { - tid = TID_CHARS[ts & 31] + tid - ts = Math.floor(ts / 32) + tid = TID_CHARS[ts & 31] + tid; + ts = Math.floor(ts / 32); } // Append clock ID (2 chars) - tid += TID_CHARS[(clockId >> 5) & 31] - tid += TID_CHARS[clockId & 31] + tid += TID_CHARS[(clockId >> 5) & 31]; + tid += TID_CHARS[clockId & 31]; - return tid + return tid; } // === P-256 SIGNING === @@ -377,49 +394,56 @@ export function createTid() { */ export async function importPrivateKey(privateKeyBytes) { // Validate private key length (P-256 requires exactly 32 bytes) - if (!(privateKeyBytes instanceof Uint8Array) || privateKeyBytes.length !== 32) { - throw new Error(`Invalid private key: expected 32 bytes, got ${privateKeyBytes?.length ?? 'non-Uint8Array'}`) + if ( + !(privateKeyBytes instanceof Uint8Array) || + privateKeyBytes.length !== 32 + ) { + throw new Error( + `Invalid private key: expected 32 bytes, got ${privateKeyBytes?.length ?? 'non-Uint8Array'}`, + ); } // PKCS#8 wrapper for raw P-256 private key const pkcs8Prefix = new Uint8Array([ 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, - 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20 - ]) + 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20, + ]); - const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32) - pkcs8.set(pkcs8Prefix) - pkcs8.set(privateKeyBytes, pkcs8Prefix.length) + const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32); + pkcs8.set(pkcs8Prefix); + pkcs8.set(privateKeyBytes, pkcs8Prefix.length); return crypto.subtle.importKey( 'pkcs8', pkcs8, { name: 'ECDSA', namedCurve: 'P-256' }, false, - ['sign'] - ) + ['sign'], + ); } // P-256 curve order N -const P256_N = BigInt('0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551') -const P256_N_DIV_2 = P256_N / 2n +const P256_N = BigInt( + '0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551', +); +const P256_N_DIV_2 = P256_N / 2n; function bytesToBigInt(bytes) { - let result = 0n + let result = 0n; for (const byte of bytes) { - result = (result << 8n) | BigInt(byte) + result = (result << 8n) | BigInt(byte); } - return result + return result; } function bigIntToBytes(n, length) { - const bytes = new Uint8Array(length) + const bytes = new Uint8Array(length); for (let i = length - 1; i >= 0; i--) { - bytes[i] = Number(n & 0xffn) - n >>= 8n + bytes[i] = Number(n & 0xffn); + n >>= 8n; } - return bytes + return bytes; } /** @@ -432,26 +456,26 @@ export async function sign(privateKey, data) { const signature = await crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, privateKey, - data - ) - const sig = new Uint8Array(signature) + data, + ); + const sig = new Uint8Array(signature); - const r = sig.slice(0, 32) - const s = sig.slice(32, 64) - const sBigInt = bytesToBigInt(s) + const r = sig.slice(0, 32); + const s = sig.slice(32, 64); + const sBigInt = bytesToBigInt(s); // Low-S normalization: Bitcoin/ATProto require S <= N/2 to prevent // signature malleability (two valid signatures for same message) if (sBigInt > P256_N_DIV_2) { - const newS = P256_N - sBigInt - const newSBytes = bigIntToBytes(newS, 32) - const normalized = new Uint8Array(64) - normalized.set(r, 0) - normalized.set(newSBytes, 32) - return normalized + const newS = P256_N - sBigInt; + const newSBytes = bigIntToBytes(newS, 32); + const normalized = new Uint8Array(64); + normalized.set(r, 0); + normalized.set(newSBytes, 32); + return normalized; } - return sig + return sig; } /** @@ -462,31 +486,31 @@ export async function generateKeyPair() { const keyPair = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, true, - ['sign', 'verify'] - ) + ['sign', 'verify'], + ); // Export private key as raw bytes - const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey) - const privateBytes = base64UrlDecode(privateJwk.d) + const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey); + const privateBytes = base64UrlDecode(privateJwk.d); // Export public key as compressed point - const publicRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey) - const publicBytes = new Uint8Array(publicRaw) - const compressed = compressPublicKey(publicBytes) + const publicRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey); + const publicBytes = new Uint8Array(publicRaw); + const compressed = compressPublicKey(publicBytes); - return { privateKey: privateBytes, publicKey: compressed } + return { privateKey: privateBytes, publicKey: compressed }; } function compressPublicKey(uncompressed) { // uncompressed is 65 bytes: 0x04 + x(32) + y(32) // compressed is 33 bytes: prefix(02 or 03) + x(32) - const x = uncompressed.slice(1, 33) - const y = uncompressed.slice(33, 65) - const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03 - const compressed = new Uint8Array(33) - compressed[0] = prefix - compressed.set(x, 1) - return compressed + const x = uncompressed.slice(1, 33); + const y = uncompressed.slice(33, 65); + const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03; + const compressed = new Uint8Array(33); + compressed[0] = prefix; + compressed.set(x, 1); + return compressed; } /** @@ -495,12 +519,12 @@ function compressPublicKey(uncompressed) { * @returns {string} Base64url-encoded string */ export function base64UrlEncode(bytes) { - let binary = '' + let binary = ''; for (const byte of bytes) { - binary += String.fromCharCode(byte) + binary += String.fromCharCode(byte); } - const base64 = btoa(binary) - return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') + const base64 = btoa(binary); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); } /** @@ -509,15 +533,15 @@ export function base64UrlEncode(bytes) { * @returns {Uint8Array} Decoded bytes */ export function base64UrlDecode(str) { - const base64 = str.replace(/-/g, '+').replace(/_/g, '/') - const pad = base64.length % 4 - const padded = pad ? base64 + '='.repeat(4 - pad) : base64 - const binary = atob(padded) - const bytes = new Uint8Array(binary.length) + const base64 = str.replace(/-/g, '+').replace(/_/g, '/'); + const pad = base64.length % 4; + const padded = pad ? base64 + '='.repeat(4 - pad) : base64; + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i) + bytes[i] = binary.charCodeAt(i); } - return bytes + return bytes; } /** @@ -532,10 +556,14 @@ async function hmacSign(data, secret) { new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, - ['sign'] - ) - const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data)) - return base64UrlEncode(new Uint8Array(sig)) + ['sign'], + ); + const sig = await crypto.subtle.sign( + 'HMAC', + key, + new TextEncoder().encode(data), + ); + return base64UrlEncode(new Uint8Array(sig)); } /** @@ -546,21 +574,25 @@ async function hmacSign(data, secret) { * @returns {Promise} Signed JWT */ export async function createAccessJwt(did, secret, expiresIn = 7200) { - const header = { typ: 'at+jwt', alg: 'HS256' } - const now = Math.floor(Date.now() / 1000) + const header = { typ: 'at+jwt', alg: 'HS256' }; + const now = Math.floor(Date.now() / 1000); const payload = { scope: 'com.atproto.access', sub: did, aud: did, iat: now, - exp: now + expiresIn - } - - const headerB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header))) - const payloadB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload))) - const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret) - - return `${headerB64}.${payloadB64}.${signature}` + exp: now + expiresIn, + }; + + const headerB64 = base64UrlEncode( + new TextEncoder().encode(JSON.stringify(header)), + ); + const payloadB64 = base64UrlEncode( + new TextEncoder().encode(JSON.stringify(payload)), + ); + const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret); + + return `${headerB64}.${payloadB64}.${signature}`; } /** @@ -571,12 +603,12 @@ export async function createAccessJwt(did, secret, expiresIn = 7200) { * @returns {Promise} Signed JWT */ export async function createRefreshJwt(did, secret, expiresIn = 7776000) { - const header = { typ: 'refresh+jwt', alg: 'HS256' } - const now = Math.floor(Date.now() / 1000) + const header = { typ: 'refresh+jwt', alg: 'HS256' }; + const now = Math.floor(Date.now() / 1000); // Generate random jti (token ID) - const jtiBytes = new Uint8Array(32) - crypto.getRandomValues(jtiBytes) - const jti = base64UrlEncode(jtiBytes) + const jtiBytes = new Uint8Array(32); + crypto.getRandomValues(jtiBytes); + const jti = base64UrlEncode(jtiBytes); const payload = { scope: 'com.atproto.refresh', @@ -584,14 +616,18 @@ export async function createRefreshJwt(did, secret, expiresIn = 7776000) { aud: did, jti, iat: now, - exp: now + expiresIn - } - - const headerB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header))) - const payloadB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload))) - const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret) - - return `${headerB64}.${payloadB64}.${signature}` + exp: now + expiresIn, + }; + + const headerB64 = base64UrlEncode( + new TextEncoder().encode(JSON.stringify(header)), + ); + const payloadB64 = base64UrlEncode( + new TextEncoder().encode(JSON.stringify(payload)), + ); + const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret); + + return `${headerB64}.${payloadB64}.${signature}`; } /** @@ -602,35 +638,39 @@ export async function createRefreshJwt(did, secret, expiresIn = 7776000) { * @throws {Error} If token is invalid, expired, or wrong type */ export async function verifyAccessJwt(jwt, secret) { - const parts = jwt.split('.') + const parts = jwt.split('.'); if (parts.length !== 3) { - throw new Error('Invalid JWT format') + throw new Error('Invalid JWT format'); } - const [headerB64, payloadB64, signatureB64] = parts + const [headerB64, payloadB64, signatureB64] = parts; // Verify signature - const expectedSig = await hmacSign(`${headerB64}.${payloadB64}`, secret) + const expectedSig = await hmacSign(`${headerB64}.${payloadB64}`, secret); if (signatureB64 !== expectedSig) { - throw new Error('Invalid signature') + throw new Error('Invalid signature'); } // Decode header and payload - const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(headerB64))) - const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64))) + const header = JSON.parse( + new TextDecoder().decode(base64UrlDecode(headerB64)), + ); + const payload = JSON.parse( + new TextDecoder().decode(base64UrlDecode(payloadB64)), + ); // Check token type if (header.typ !== 'at+jwt') { - throw new Error('Invalid token type: expected access token') + throw new Error('Invalid token type: expected access token'); } // Check expiration - const now = Math.floor(Date.now() / 1000) + const now = Math.floor(Date.now() / 1000); if (payload.exp && payload.exp < now) { - throw new Error('Token expired') + throw new Error('Token expired'); } - return payload + return payload; } /** @@ -644,31 +684,35 @@ export async function verifyAccessJwt(jwt, secret) { * @returns {Promise} Signed JWT */ export async function createServiceJwt({ iss, aud, lxm, signingKey }) { - const header = { typ: 'JWT', alg: 'ES256' } - const now = Math.floor(Date.now() / 1000) + const header = { typ: 'JWT', alg: 'ES256' }; + const now = Math.floor(Date.now() / 1000); // Generate random jti - const jtiBytes = new Uint8Array(16) - crypto.getRandomValues(jtiBytes) - const jti = bytesToHex(jtiBytes) + const jtiBytes = new Uint8Array(16); + crypto.getRandomValues(jtiBytes); + const jti = bytesToHex(jtiBytes); const payload = { iss, aud, exp: now + 60, // 1 minute expiration iat: now, - jti - } - if (lxm) payload.lxm = lxm + jti, + }; + if (lxm) payload.lxm = lxm; - const headerB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header))) - const payloadB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload))) - const toSign = new TextEncoder().encode(`${headerB64}.${payloadB64}`) + const headerB64 = base64UrlEncode( + new TextEncoder().encode(JSON.stringify(header)), + ); + const payloadB64 = base64UrlEncode( + new TextEncoder().encode(JSON.stringify(payload)), + ); + const toSign = new TextEncoder().encode(`${headerB64}.${payloadB64}`); - const sig = await sign(signingKey, toSign) - const sigB64 = base64UrlEncode(sig) + const sig = await sign(signingKey, toSign); + const sigB64 = base64UrlEncode(sig); - return `${headerB64}.${payloadB64}.${sigB64}` + return `${headerB64}.${payloadB64}.${sigB64}`; } /** @@ -677,7 +721,9 @@ export async function createServiceJwt({ iss, aud, lxm, signingKey }) { * @returns {string} Hex string */ export function bytesToHex(bytes) { - return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); } /** @@ -686,23 +732,23 @@ export function bytesToHex(bytes) { * @returns {Uint8Array} Decoded bytes */ export function hexToBytes(hex) { - const bytes = new Uint8Array(hex.length / 2) + const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { - bytes[i / 2] = parseInt(hex.substr(i, 2), 16) + bytes[i / 2] = parseInt(hex.substr(i, 2), 16); } - return bytes + return bytes; } // === MERKLE SEARCH TREE === // ATProto-compliant MST implementation async function sha256(data) { - const hash = await crypto.subtle.digest('SHA-256', data) - return new Uint8Array(hash) + const hash = await crypto.subtle.digest('SHA-256', data); + return new Uint8Array(hash); } // Cache for key depths (SHA-256 is expensive) -const keyDepthCache = new Map() +const keyDepthCache = new Map(); /** * Get MST tree depth for a key based on leading zeros in SHA-256 hash @@ -711,157 +757,159 @@ const keyDepthCache = new Map() */ export async function getKeyDepth(key) { // Count leading zeros in SHA-256 hash, divide by 2 - if (keyDepthCache.has(key)) return keyDepthCache.get(key) + if (keyDepthCache.has(key)) return keyDepthCache.get(key); - const keyBytes = new TextEncoder().encode(key) - const hash = await sha256(keyBytes) + const keyBytes = new TextEncoder().encode(key); + const hash = await sha256(keyBytes); - let zeros = 0 + let zeros = 0; for (const byte of hash) { if (byte === 0) { - zeros += 8 + zeros += 8; } else { // Count leading zeros in this byte for (let i = 7; i >= 0; i--) { - if ((byte >> i) & 1) break - zeros++ + if ((byte >> i) & 1) break; + zeros++; } - break + break; } } // MST depth = leading zeros in SHA-256 hash / 2 // This creates a probabilistic tree where ~50% of keys are at depth 0, // ~25% at depth 1, etc., giving O(log n) lookups - const depth = Math.floor(zeros / 2) - keyDepthCache.set(key, depth) - return depth + const depth = Math.floor(zeros / 2); + keyDepthCache.set(key, depth); + return depth; } // Compute common prefix length between two byte arrays function commonPrefixLen(a, b) { - const minLen = Math.min(a.length, b.length) + const minLen = Math.min(a.length, b.length); for (let i = 0; i < minLen; i++) { - if (a[i] !== b[i]) return i + if (a[i] !== b[i]) return i; } - return minLen + return minLen; } class MST { constructor(sql) { - this.sql = sql + this.sql = sql; } async computeRoot() { - const records = this.sql.exec(` + const records = this.sql + .exec(` SELECT collection, rkey, cid FROM records ORDER BY collection, rkey - `).toArray() + `) + .toArray(); if (records.length === 0) { - return null + return null; } // Build entries with pre-computed depths (heights) // In ATProto MST, "height" determines which layer a key belongs to // Layer 0 is at the BOTTOM, root is at the highest layer - const entries = [] - let maxDepth = 0 + const entries = []; + let maxDepth = 0; for (const r of records) { - const key = `${r.collection}/${r.rkey}` - const depth = await getKeyDepth(key) - maxDepth = Math.max(maxDepth, depth) + const key = `${r.collection}/${r.rkey}`; + const depth = await getKeyDepth(key); + maxDepth = Math.max(maxDepth, depth); entries.push({ key, keyBytes: new TextEncoder().encode(key), cid: r.cid, - depth - }) + depth, + }); } // Start building from the root (highest layer) going down to layer 0 - return this.buildTree(entries, maxDepth) + return this.buildTree(entries, maxDepth); } async buildTree(entries, layer) { - if (entries.length === 0) return null + if (entries.length === 0) return null; // Separate entries for this layer vs lower layers (subtrees) // Keys with depth == layer stay at this node // Keys with depth < layer go into subtrees (going down toward layer 0) - const thisLayer = [] - let leftSubtree = [] + const thisLayer = []; + let leftSubtree = []; for (const entry of entries) { if (entry.depth < layer) { // This entry belongs to a lower layer - accumulate for subtree - leftSubtree.push(entry) + leftSubtree.push(entry); } else { // This entry belongs at current layer (depth == layer) // Process accumulated left subtree first if (leftSubtree.length > 0) { - const leftCid = await this.buildTree(leftSubtree, layer - 1) - thisLayer.push({ type: 'subtree', cid: leftCid }) - leftSubtree = [] + const leftCid = await this.buildTree(leftSubtree, layer - 1); + thisLayer.push({ type: 'subtree', cid: leftCid }); + leftSubtree = []; } - thisLayer.push({ type: 'entry', entry }) + thisLayer.push({ type: 'entry', entry }); } } // Handle remaining left subtree if (leftSubtree.length > 0) { - const leftCid = await this.buildTree(leftSubtree, layer - 1) - thisLayer.push({ type: 'subtree', cid: leftCid }) + const leftCid = await this.buildTree(leftSubtree, layer - 1); + thisLayer.push({ type: 'subtree', cid: leftCid }); } // Build node with proper ATProto format - const node = { e: [] } - let leftCid = null - let prevKeyBytes = new Uint8Array(0) + const node = { e: [] }; + let leftCid = null; + let prevKeyBytes = new Uint8Array(0); for (let i = 0; i < thisLayer.length; i++) { - const item = thisLayer[i] + const item = thisLayer[i]; if (item.type === 'subtree') { if (node.e.length === 0) { - leftCid = item.cid + leftCid = item.cid; } else { // Attach to previous entry's 't' field - node.e[node.e.length - 1].t = new CID(cidToBytes(item.cid)) + node.e[node.e.length - 1].t = new CID(cidToBytes(item.cid)); } } else { // Entry - compute prefix compression - const keyBytes = item.entry.keyBytes - const prefixLen = commonPrefixLen(prevKeyBytes, keyBytes) - const keySuffix = keyBytes.slice(prefixLen) + const keyBytes = item.entry.keyBytes; + const prefixLen = commonPrefixLen(prevKeyBytes, keyBytes); + const keySuffix = keyBytes.slice(prefixLen); // ATProto requires t field to be present (can be null) const e = { p: prefixLen, k: keySuffix, v: new CID(cidToBytes(item.entry.cid)), - t: null // Will be updated if there's a subtree - } + t: null, // Will be updated if there's a subtree + }; - node.e.push(e) - prevKeyBytes = keyBytes + node.e.push(e); + prevKeyBytes = keyBytes; } } // ATProto requires l field to be present (can be null) - node.l = leftCid ? new CID(cidToBytes(leftCid)) : null + node.l = leftCid ? new CID(cidToBytes(leftCid)) : null; // Encode node with proper MST CBOR format - const nodeBytes = cborEncodeDagCbor(node) - const nodeCid = await createCid(nodeBytes) - const cidStr = cidToString(nodeCid) + const nodeBytes = cborEncodeDagCbor(node); + const nodeCid = await createCid(nodeBytes); + const cidStr = cidToString(nodeCid); this.sql.exec( `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, cidStr, - nodeBytes - ) + nodeBytes, + ); - return cidStr + return cidStr; } } @@ -873,13 +921,13 @@ class MST { * @returns {Uint8Array} Varint-encoded bytes */ export function varint(n) { - const bytes = [] + const bytes = []; while (n >= 0x80) { - bytes.push((n & 0x7f) | 0x80) - n >>>= 7 + bytes.push((n & 0x7f) | 0x80); + n >>>= 7; } - bytes.push(n) - return new Uint8Array(bytes) + bytes.push(n); + return new Uint8Array(bytes); } /** @@ -889,8 +937,8 @@ export function varint(n) { */ export function cidToBytes(cidStr) { // Decode base32lower CID string to bytes - if (!cidStr.startsWith('b')) throw new Error('expected base32lower CID') - return base32Decode(cidStr.slice(1)) + if (!cidStr.startsWith('b')) throw new Error('expected base32lower CID'); + return base32Decode(cidStr.slice(1)); } /** @@ -899,23 +947,23 @@ export function cidToBytes(cidStr) { * @returns {Uint8Array} Decoded bytes */ export function base32Decode(str) { - const alphabet = 'abcdefghijklmnopqrstuvwxyz234567' - let bits = 0 - let value = 0 - const output = [] + const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; + let bits = 0; + let value = 0; + const output = []; for (const char of str) { - const idx = alphabet.indexOf(char) - if (idx === -1) continue - value = (value << 5) | idx - bits += 5 + const idx = alphabet.indexOf(char); + if (idx === -1) continue; + value = (value << 5) | idx; + bits += 5; if (bits >= 8) { - bits -= 8 - output.push((value >> bits) & 0xff) + bits -= 8; + output.push((value >> bits) & 0xff); } } - return new Uint8Array(output) + return new Uint8Array(output); } /** @@ -925,33 +973,36 @@ export function base32Decode(str) { * @returns {Uint8Array} CAR file bytes */ export function buildCarFile(rootCid, blocks) { - const parts = [] + const parts = []; // Header: { version: 1, roots: [rootCid] } - const rootCidBytes = cidToBytes(rootCid) - const header = cborEncodeDagCbor({ version: 1, roots: [new CID(rootCidBytes)] }) - parts.push(varint(header.length)) - parts.push(header) + const rootCidBytes = cidToBytes(rootCid); + const header = cborEncodeDagCbor({ + version: 1, + roots: [new CID(rootCidBytes)], + }); + parts.push(varint(header.length)); + parts.push(header); // Blocks: varint(len) + cid + data for (const block of blocks) { - const cidBytes = cidToBytes(block.cid) - const blockLen = cidBytes.length + block.data.length - parts.push(varint(blockLen)) - parts.push(cidBytes) - parts.push(block.data) + const cidBytes = cidToBytes(block.cid); + const blockLen = cidBytes.length + block.data.length; + parts.push(varint(blockLen)); + parts.push(cidBytes); + parts.push(block.data); } // Concatenate all parts - const totalLen = parts.reduce((sum, p) => sum + p.length, 0) - const car = new Uint8Array(totalLen) - let offset = 0 + const totalLen = parts.reduce((sum, p) => sum + p.length, 0); + const car = new Uint8Array(totalLen); + let offset = 0; for (const part of parts) { - car.set(part, offset) - offset += part.length + car.set(part, offset); + offset += part.length; } - return car + return car; } /** @@ -972,104 +1023,104 @@ export function buildCarFile(rootCid, blocks) { /** @type {Record} */ const pdsRoutes = { '/.well-known/atproto-did': { - handler: (pds, req, url) => pds.handleAtprotoDid() + handler: (pds, _req, _url) => pds.handleAtprotoDid(), }, '/init': { method: 'POST', - handler: (pds, req, url) => pds.handleInit(req) + handler: (pds, req, _url) => pds.handleInit(req), }, '/status': { - handler: (pds, req, url) => pds.handleStatus() + handler: (pds, _req, _url) => pds.handleStatus(), }, '/reset-repo': { - handler: (pds, req, url) => pds.handleResetRepo() + handler: (pds, _req, _url) => pds.handleResetRepo(), }, '/forward-event': { - handler: (pds, req, url) => pds.handleForwardEvent(req) + handler: (pds, req, _url) => pds.handleForwardEvent(req), }, '/register-did': { - handler: (pds, req, url) => pds.handleRegisterDid(req) + handler: (pds, req, _url) => pds.handleRegisterDid(req), }, '/get-registered-dids': { - handler: (pds, req, url) => pds.handleGetRegisteredDids() + handler: (pds, _req, _url) => pds.handleGetRegisteredDids(), }, '/register-handle': { method: 'POST', - handler: (pds, req, url) => pds.handleRegisterHandle(req) + handler: (pds, req, _url) => pds.handleRegisterHandle(req), }, '/resolve-handle': { - handler: (pds, req, url) => pds.handleResolveHandle(url) + handler: (pds, _req, url) => pds.handleResolveHandle(url), }, '/repo-info': { - handler: (pds, req, url) => pds.handleRepoInfo() + handler: (pds, _req, _url) => pds.handleRepoInfo(), }, '/xrpc/com.atproto.server.describeServer': { - handler: (pds, req, url) => pds.handleDescribeServer(req) + handler: (pds, req, _url) => pds.handleDescribeServer(req), }, '/xrpc/com.atproto.server.createSession': { method: 'POST', - handler: (pds, req, url) => pds.handleCreateSession(req) + handler: (pds, req, _url) => pds.handleCreateSession(req), }, '/xrpc/com.atproto.server.getSession': { - handler: (pds, req, url) => pds.handleGetSession(req) + handler: (pds, req, _url) => pds.handleGetSession(req), }, '/xrpc/app.bsky.actor.getPreferences': { - handler: (pds, req, url) => pds.handleGetPreferences(req) + handler: (pds, req, _url) => pds.handleGetPreferences(req), }, '/xrpc/app.bsky.actor.putPreferences': { method: 'POST', - handler: (pds, req, url) => pds.handlePutPreferences(req) + handler: (pds, req, _url) => pds.handlePutPreferences(req), }, '/xrpc/com.atproto.sync.listRepos': { - handler: (pds, req, url) => pds.handleListRepos() + handler: (pds, _req, _url) => pds.handleListRepos(), }, '/xrpc/com.atproto.repo.createRecord': { method: 'POST', - handler: (pds, req, url) => pds.handleCreateRecord(req) + handler: (pds, req, _url) => pds.handleCreateRecord(req), }, '/xrpc/com.atproto.repo.deleteRecord': { method: 'POST', - handler: (pds, req, url) => pds.handleDeleteRecord(req) + handler: (pds, req, _url) => pds.handleDeleteRecord(req), }, '/xrpc/com.atproto.repo.putRecord': { method: 'POST', - handler: (pds, req, url) => pds.handlePutRecord(req) + handler: (pds, req, _url) => pds.handlePutRecord(req), }, '/xrpc/com.atproto.repo.applyWrites': { method: 'POST', - handler: (pds, req, url) => pds.handleApplyWrites(req) + handler: (pds, req, _url) => pds.handleApplyWrites(req), }, '/xrpc/com.atproto.repo.getRecord': { - handler: (pds, req, url) => pds.handleGetRecord(url) + handler: (pds, _req, url) => pds.handleGetRecord(url), }, '/xrpc/com.atproto.repo.describeRepo': { - handler: (pds, req, url) => pds.handleDescribeRepo() + handler: (pds, _req, _url) => pds.handleDescribeRepo(), }, '/xrpc/com.atproto.repo.listRecords': { - handler: (pds, req, url) => pds.handleListRecords(url) + handler: (pds, _req, url) => pds.handleListRecords(url), }, '/xrpc/com.atproto.sync.getLatestCommit': { - handler: (pds, req, url) => pds.handleGetLatestCommit() + handler: (pds, _req, _url) => pds.handleGetLatestCommit(), }, '/xrpc/com.atproto.sync.getRepoStatus': { - handler: (pds, req, url) => pds.handleGetRepoStatus() + handler: (pds, _req, _url) => pds.handleGetRepoStatus(), }, '/xrpc/com.atproto.sync.getRepo': { - handler: (pds, req, url) => pds.handleGetRepo() + handler: (pds, _req, _url) => pds.handleGetRepo(), }, '/xrpc/com.atproto.sync.getRecord': { - handler: (pds, req, url) => pds.handleSyncGetRecord(url) + handler: (pds, _req, url) => pds.handleSyncGetRecord(url), }, '/xrpc/com.atproto.sync.subscribeRepos': { - handler: (pds, req, url) => pds.handleSubscribeRepos(req, url) - } -} + handler: (pds, req, url) => pds.handleSubscribeRepos(req, url), + }, +}; export class PersonalDataServer { constructor(state, env) { - this.state = state - this.sql = state.storage.sql - this.env = env + this.state = state; + this.sql = state.storage.sql; + this.env = env; // Initialize schema this.sql.exec(` @@ -1101,1038 +1152,1155 @@ export class PersonalDataServer { ); CREATE INDEX IF NOT EXISTS idx_records_collection ON records(collection, rkey); - `) + `); } async initIdentity(did, privateKeyHex, handle = null) { - await this.state.storage.put('did', did) - await this.state.storage.put('privateKey', privateKeyHex) + await this.state.storage.put('did', did); + await this.state.storage.put('privateKey', privateKeyHex); if (handle) { - await this.state.storage.put('handle', handle) + await this.state.storage.put('handle', handle); } } async getDid() { if (!this._did) { - this._did = await this.state.storage.get('did') + this._did = await this.state.storage.get('did'); } - return this._did + return this._did; } async getHandle() { - return this.state.storage.get('handle') + return this.state.storage.get('handle'); } async getSigningKey() { - const hex = await this.state.storage.get('privateKey') - if (!hex) return null - return importPrivateKey(hexToBytes(hex)) + const hex = await this.state.storage.get('privateKey'); + if (!hex) return null; + return importPrivateKey(hexToBytes(hex)); } // Collect MST node blocks for a given root CID collectMstBlocks(rootCidStr) { - const blocks = [] - const visited = new Set() + const blocks = []; + const visited = new Set(); const collect = (cidStr) => { - if (visited.has(cidStr)) return - visited.add(cidStr) + if (visited.has(cidStr)) return; + visited.add(cidStr); - const rows = this.sql.exec( - `SELECT data FROM blocks WHERE cid = ?`, cidStr - ).toArray() - if (rows.length === 0) return + const rows = this.sql + .exec(`SELECT data FROM blocks WHERE cid = ?`, cidStr) + .toArray(); + if (rows.length === 0) return; - const data = new Uint8Array(rows[0].data) - blocks.push({ cid: cidStr, data }) // Keep as string, buildCarFile will convert + const data = new Uint8Array(rows[0].data); + blocks.push({ cid: cidStr, data }); // Keep as string, buildCarFile will convert // Decode and follow child CIDs (MST nodes have 'l' and 'e' with 't' subtrees) try { - const node = cborDecode(data) - if (node.l) collect(cidToString(node.l)) + const node = cborDecode(data); + if (node.l) collect(cidToString(node.l)); if (node.e) { for (const entry of node.e) { - if (entry.t) collect(cidToString(entry.t)) + if (entry.t) collect(cidToString(entry.t)); } } - } catch (e) { + } catch (_e) { // Not an MST node, ignore } - } + }; - collect(rootCidStr) - return blocks + collect(rootCidStr); + return blocks; } async createRecord(collection, record, rkey = null) { - const did = await this.getDid() - if (!did) throw new Error('PDS not initialized') + const did = await this.getDid(); + if (!did) throw new Error('PDS not initialized'); - rkey = rkey || createTid() - const uri = `at://${did}/${collection}/${rkey}` + rkey = rkey || createTid(); + const uri = `at://${did}/${collection}/${rkey}`; // Encode and hash record (must use DAG-CBOR for proper key ordering) - const recordBytes = cborEncodeDagCbor(record) - const recordCid = await createCid(recordBytes) - const recordCidStr = cidToString(recordCid) + const recordBytes = cborEncodeDagCbor(record); + const recordCid = await createCid(recordBytes); + const recordCidStr = cidToString(recordCid); // Store block this.sql.exec( `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, - recordCidStr, recordBytes - ) + recordCidStr, + recordBytes, + ); // Store record index this.sql.exec( `INSERT OR REPLACE INTO records (uri, cid, collection, rkey, value) VALUES (?, ?, ?, ?, ?)`, - uri, recordCidStr, collection, rkey, recordBytes - ) + uri, + recordCidStr, + collection, + rkey, + recordBytes, + ); // Rebuild MST - const mst = new MST(this.sql) - const dataRoot = await mst.computeRoot() + const mst = new MST(this.sql); + const dataRoot = await mst.computeRoot(); // Get previous commit - const prevCommits = this.sql.exec( - `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1` - ).toArray() - const prevCommit = prevCommits.length > 0 ? prevCommits[0] : null + const prevCommits = this.sql + .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`) + .toArray(); + const prevCommit = prevCommits.length > 0 ? prevCommits[0] : null; // Create commit - const rev = createTid() + const rev = createTid(); // Build commit with CIDs wrapped in CID class (for dag-cbor tag 42 encoding) const commit = { did, version: 3, - data: new CID(cidToBytes(dataRoot)), // CID wrapped for explicit encoding + data: new CID(cidToBytes(dataRoot)), // CID wrapped for explicit encoding rev, - prev: prevCommit?.cid ? new CID(cidToBytes(prevCommit.cid)) : null - } + prev: prevCommit?.cid ? new CID(cidToBytes(prevCommit.cid)) : null, + }; // Sign commit (using dag-cbor encoder for CIDs) - const commitBytes = cborEncodeDagCbor(commit) - const signingKey = await this.getSigningKey() - const sig = await sign(signingKey, commitBytes) + const commitBytes = cborEncodeDagCbor(commit); + const signingKey = await this.getSigningKey(); + const sig = await sign(signingKey, commitBytes); - const signedCommit = { ...commit, sig } - const signedBytes = cborEncodeDagCbor(signedCommit) - const commitCid = await createCid(signedBytes) - const commitCidStr = cidToString(commitCid) + const signedCommit = { ...commit, sig }; + const signedBytes = cborEncodeDagCbor(signedCommit); + const commitCid = await createCid(signedBytes); + const commitCidStr = cidToString(commitCid); // Store commit block this.sql.exec( `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, - commitCidStr, signedBytes - ) + commitCidStr, + signedBytes, + ); // Store commit reference this.sql.exec( `INSERT INTO commits (cid, rev, prev) VALUES (?, ?, ?)`, - commitCidStr, rev, prevCommit?.cid || null - ) + commitCidStr, + rev, + prevCommit?.cid || null, + ); // Update head and rev for listRepos - await this.state.storage.put('head', commitCidStr) - await this.state.storage.put('rev', rev) + await this.state.storage.put('head', commitCidStr); + await this.state.storage.put('rev', rev); // Collect blocks for the event (record + commit + MST nodes) // Build a mini CAR with just the new blocks - use string CIDs - const newBlocks = [] + const newBlocks = []; // Add record block - newBlocks.push({ cid: recordCidStr, data: recordBytes }) + newBlocks.push({ cid: recordCidStr, data: recordBytes }); // Add commit block - newBlocks.push({ cid: commitCidStr, data: signedBytes }) + newBlocks.push({ cid: commitCidStr, data: signedBytes }); // Add MST node blocks (get all blocks referenced by commit.data) - const mstBlocks = this.collectMstBlocks(dataRoot) - newBlocks.push(...mstBlocks) + const mstBlocks = this.collectMstBlocks(dataRoot); + newBlocks.push(...mstBlocks); // Sequence event with blocks - store complete event data including rev and time // blocks must be a full CAR file with header (roots = [commitCid]) - const eventTime = new Date().toISOString() + const eventTime = new Date().toISOString(); const evt = cborEncode({ - ops: [{ action: 'create', path: `${collection}/${rkey}`, cid: recordCidStr }], - blocks: buildCarFile(commitCidStr, newBlocks), // Full CAR with header - rev, // Store the actual commit revision - time: eventTime // Store the actual event time - }) + ops: [ + { action: 'create', path: `${collection}/${rkey}`, cid: recordCidStr }, + ], + blocks: buildCarFile(commitCidStr, newBlocks), // Full CAR with header + rev, // Store the actual commit revision + time: eventTime, // Store the actual event time + }); this.sql.exec( `INSERT INTO seq_events (did, commit_cid, evt) VALUES (?, ?, ?)`, - did, commitCidStr, evt - ) + did, + commitCidStr, + evt, + ); // Broadcast to subscribers (both local and via default DO for relay) - const evtRows = this.sql.exec( - `SELECT * FROM seq_events ORDER BY seq DESC LIMIT 1` - ).toArray() + const evtRows = this.sql + .exec(`SELECT * FROM seq_events ORDER BY seq DESC LIMIT 1`) + .toArray(); if (evtRows.length > 0) { - this.broadcastEvent(evtRows[0]) + this.broadcastEvent(evtRows[0]); // Also forward to default DO for relay subscribers if (this.env?.PDS) { - const defaultId = this.env.PDS.idFromName('default') - const defaultPds = this.env.PDS.get(defaultId) + const defaultId = this.env.PDS.idFromName('default'); + const defaultPds = this.env.PDS.get(defaultId); // Convert ArrayBuffer to array for JSON serialization - const row = evtRows[0] - const evtArray = Array.from(new Uint8Array(row.evt)) + const row = evtRows[0]; + const evtArray = Array.from(new Uint8Array(row.evt)); // Fire and forget but log errors - defaultPds.fetch(new Request('http://internal/forward-event', { - method: 'POST', - body: JSON.stringify({ ...row, evt: evtArray }) - })).then(r => r.json()).then(r => console.log('forward result:', r)).catch(e => console.log('forward error:', e)) + defaultPds + .fetch( + new Request('http://internal/forward-event', { + method: 'POST', + body: JSON.stringify({ ...row, evt: evtArray }), + }), + ) + .then((r) => r.json()) + .then((r) => console.log('forward result:', r)) + .catch((e) => console.log('forward error:', e)); } } - return { uri, cid: recordCidStr, commit: commitCidStr } + return { uri, cid: recordCidStr, commit: commitCidStr }; } async deleteRecord(collection, rkey) { - const did = await this.getDid() - if (!did) throw new Error('PDS not initialized') + const did = await this.getDid(); + if (!did) throw new Error('PDS not initialized'); - const uri = `at://${did}/${collection}/${rkey}` + const uri = `at://${did}/${collection}/${rkey}`; // Check if record exists - const existing = this.sql.exec( - `SELECT cid FROM records WHERE uri = ?`, uri - ).toArray() + const existing = this.sql + .exec(`SELECT cid FROM records WHERE uri = ?`, uri) + .toArray(); if (existing.length === 0) { - return { error: 'RecordNotFound', message: 'record not found' } + return { error: 'RecordNotFound', message: 'record not found' }; } // Delete from records table - this.sql.exec(`DELETE FROM records WHERE uri = ?`, uri) + this.sql.exec(`DELETE FROM records WHERE uri = ?`, uri); // Rebuild MST - const mst = new MST(this.sql) - const dataRoot = await mst.computeRoot() + const mst = new MST(this.sql); + const dataRoot = await mst.computeRoot(); // Get previous commit - const prevCommits = this.sql.exec( - `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1` - ).toArray() - const prevCommit = prevCommits.length > 0 ? prevCommits[0] : null + const prevCommits = this.sql + .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`) + .toArray(); + const prevCommit = prevCommits.length > 0 ? prevCommits[0] : null; // Create commit - const rev = createTid() + const rev = createTid(); const commit = { did, version: 3, data: dataRoot ? new CID(cidToBytes(dataRoot)) : null, rev, - prev: prevCommit?.cid ? new CID(cidToBytes(prevCommit.cid)) : null - } + prev: prevCommit?.cid ? new CID(cidToBytes(prevCommit.cid)) : null, + }; // Sign commit - const commitBytes = cborEncodeDagCbor(commit) - const signingKey = await this.getSigningKey() - const sig = await sign(signingKey, commitBytes) + const commitBytes = cborEncodeDagCbor(commit); + const signingKey = await this.getSigningKey(); + const sig = await sign(signingKey, commitBytes); - const signedCommit = { ...commit, sig } - const signedBytes = cborEncodeDagCbor(signedCommit) - const commitCid = await createCid(signedBytes) - const commitCidStr = cidToString(commitCid) + const signedCommit = { ...commit, sig }; + const signedBytes = cborEncodeDagCbor(signedCommit); + const commitCid = await createCid(signedBytes); + const commitCidStr = cidToString(commitCid); // Store commit block this.sql.exec( `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`, - commitCidStr, signedBytes - ) + commitCidStr, + signedBytes, + ); // Store commit reference this.sql.exec( `INSERT INTO commits (cid, rev, prev) VALUES (?, ?, ?)`, - commitCidStr, rev, prevCommit?.cid || null - ) + commitCidStr, + rev, + prevCommit?.cid || null, + ); // Update head and rev - await this.state.storage.put('head', commitCidStr) - await this.state.storage.put('rev', rev) + await this.state.storage.put('head', commitCidStr); + await this.state.storage.put('rev', rev); // Collect blocks for the event (commit + MST nodes, no record block) - const newBlocks = [] - newBlocks.push({ cid: commitCidStr, data: signedBytes }) + const newBlocks = []; + newBlocks.push({ cid: commitCidStr, data: signedBytes }); if (dataRoot) { - const mstBlocks = this.collectMstBlocks(dataRoot) - newBlocks.push(...mstBlocks) + const mstBlocks = this.collectMstBlocks(dataRoot); + newBlocks.push(...mstBlocks); } // Sequence event with delete action - const eventTime = new Date().toISOString() + const eventTime = new Date().toISOString(); const evt = cborEncode({ ops: [{ action: 'delete', path: `${collection}/${rkey}`, cid: null }], blocks: buildCarFile(commitCidStr, newBlocks), rev, - time: eventTime - }) + time: eventTime, + }); this.sql.exec( `INSERT INTO seq_events (did, commit_cid, evt) VALUES (?, ?, ?)`, - did, commitCidStr, evt - ) + did, + commitCidStr, + evt, + ); // Broadcast to subscribers - const evtRows = this.sql.exec( - `SELECT * FROM seq_events ORDER BY seq DESC LIMIT 1` - ).toArray() + const evtRows = this.sql + .exec(`SELECT * FROM seq_events ORDER BY seq DESC LIMIT 1`) + .toArray(); if (evtRows.length > 0) { - this.broadcastEvent(evtRows[0]) + this.broadcastEvent(evtRows[0]); // Forward to default DO for relay subscribers if (this.env?.PDS) { - const defaultId = this.env.PDS.idFromName('default') - const defaultPds = this.env.PDS.get(defaultId) - const row = evtRows[0] - const evtArray = Array.from(new Uint8Array(row.evt)) - defaultPds.fetch(new Request('http://internal/forward-event', { - method: 'POST', - body: JSON.stringify({ ...row, evt: evtArray }) - })).catch(e => console.log('forward error:', e)) + const defaultId = this.env.PDS.idFromName('default'); + const defaultPds = this.env.PDS.get(defaultId); + const row = evtRows[0]; + const evtArray = Array.from(new Uint8Array(row.evt)); + defaultPds + .fetch( + new Request('http://internal/forward-event', { + method: 'POST', + body: JSON.stringify({ ...row, evt: evtArray }), + }), + ) + .catch((e) => console.log('forward error:', e)); } } - return { ok: true } + return { ok: true }; } formatEvent(evt) { // AT Protocol frame format: header + body // Use DAG-CBOR encoding for body (CIDs need tag 42 + 0x00 prefix) - const header = cborEncode({ op: 1, t: '#commit' }) + const header = cborEncode({ op: 1, t: '#commit' }); // Decode stored event to get ops, blocks, rev, and time - const evtData = cborDecode(new Uint8Array(evt.evt)) - const ops = evtData.ops.map(op => ({ + const evtData = cborDecode(new Uint8Array(evt.evt)); + const ops = evtData.ops.map((op) => ({ ...op, - cid: op.cid ? new CID(cidToBytes(op.cid)) : null // Wrap in CID class for tag 42 encoding - })) + cid: op.cid ? new CID(cidToBytes(op.cid)) : null, // Wrap in CID class for tag 42 encoding + })); // Get blocks from stored event (already in CAR format) - const blocks = evtData.blocks || new Uint8Array(0) + const blocks = evtData.blocks || new Uint8Array(0); const body = cborEncodeDagCbor({ seq: evt.seq, rebase: false, tooBig: false, repo: evt.did, - commit: new CID(cidToBytes(evt.commit_cid)), // Wrap in CID class for tag 42 encoding - rev: evtData.rev, // Use stored rev from commit creation + commit: new CID(cidToBytes(evt.commit_cid)), // Wrap in CID class for tag 42 encoding + rev: evtData.rev, // Use stored rev from commit creation since: null, blocks: blocks instanceof Uint8Array ? blocks : new Uint8Array(blocks), ops, blobs: [], - time: evtData.time // Use stored time from event creation - }) + time: evtData.time, // Use stored time from event creation + }); // Concatenate header + body - const frame = new Uint8Array(header.length + body.length) - frame.set(header) - frame.set(body, header.length) - return frame + const frame = new Uint8Array(header.length + body.length); + frame.set(header); + frame.set(body, header.length); + return frame; } async webSocketMessage(ws, message) { // Handle ping - if (message === 'ping') ws.send('pong') + if (message === 'ping') ws.send('pong'); } - async webSocketClose(ws, code, reason) { + async webSocketClose(_ws, _code, _reason) { // Durable Object will hibernate when no connections remain } broadcastEvent(evt) { - const frame = this.formatEvent(evt) + const frame = this.formatEvent(evt); for (const ws of this.state.getWebSockets()) { try { - ws.send(frame) - } catch (e) { + ws.send(frame); + } catch (_e) { // Client disconnected } } } async handleAtprotoDid() { - let did = await this.getDid() + let did = await this.getDid(); if (!did) { - const registeredDids = await this.state.storage.get('registeredDids') || [] - did = registeredDids[0] + const registeredDids = + (await this.state.storage.get('registeredDids')) || []; + did = registeredDids[0]; } if (!did) { - return new Response('User not found', { status: 404 }) + return new Response('User not found', { status: 404 }); } - return new Response(did, { headers: { 'Content-Type': 'text/plain' } }) + return new Response(did, { headers: { 'Content-Type': 'text/plain' } }); } async handleInit(request) { - const body = await request.json() + const body = await request.json(); if (!body.did || !body.privateKey) { - return errorResponse('InvalidRequest', 'missing did or privateKey', 400) + return errorResponse('InvalidRequest', 'missing did or privateKey', 400); } - await this.initIdentity(body.did, body.privateKey, body.handle || null) - return Response.json({ ok: true, did: body.did, handle: body.handle || null }) + await this.initIdentity(body.did, body.privateKey, body.handle || null); + return Response.json({ + ok: true, + did: body.did, + handle: body.handle || null, + }); } async handleStatus() { - const did = await this.getDid() - return Response.json({ initialized: !!did, did: did || null }) + const did = await this.getDid(); + return Response.json({ initialized: !!did, did: did || null }); } async handleResetRepo() { - this.sql.exec(`DELETE FROM blocks`) - this.sql.exec(`DELETE FROM records`) - this.sql.exec(`DELETE FROM commits`) - this.sql.exec(`DELETE FROM seq_events`) - await this.state.storage.delete('head') - await this.state.storage.delete('rev') - return Response.json({ ok: true, message: 'repo data cleared' }) + this.sql.exec(`DELETE FROM blocks`); + this.sql.exec(`DELETE FROM records`); + this.sql.exec(`DELETE FROM commits`); + this.sql.exec(`DELETE FROM seq_events`); + await this.state.storage.delete('head'); + await this.state.storage.delete('rev'); + return Response.json({ ok: true, message: 'repo data cleared' }); } async handleForwardEvent(request) { - const evt = await request.json() - const numSockets = [...this.state.getWebSockets()].length - console.log(`forward-event: received event seq=${evt.seq}, ${numSockets} connected sockets`) + const evt = await request.json(); + const numSockets = [...this.state.getWebSockets()].length; + console.log( + `forward-event: received event seq=${evt.seq}, ${numSockets} connected sockets`, + ); this.broadcastEvent({ seq: evt.seq, did: evt.did, commit_cid: evt.commit_cid, - evt: new Uint8Array(Object.values(evt.evt)) - }) - return Response.json({ ok: true, sockets: numSockets }) + evt: new Uint8Array(Object.values(evt.evt)), + }); + return Response.json({ ok: true, sockets: numSockets }); } async handleRegisterDid(request) { - const body = await request.json() - const registeredDids = await this.state.storage.get('registeredDids') || [] + const body = await request.json(); + const registeredDids = + (await this.state.storage.get('registeredDids')) || []; if (!registeredDids.includes(body.did)) { - registeredDids.push(body.did) - await this.state.storage.put('registeredDids', registeredDids) + registeredDids.push(body.did); + await this.state.storage.put('registeredDids', registeredDids); } - return Response.json({ ok: true }) + return Response.json({ ok: true }); } async handleGetRegisteredDids() { - const registeredDids = await this.state.storage.get('registeredDids') || [] - return Response.json({ dids: registeredDids }) + const registeredDids = + (await this.state.storage.get('registeredDids')) || []; + return Response.json({ dids: registeredDids }); } async handleRegisterHandle(request) { - const body = await request.json() - const { handle, did } = body + const body = await request.json(); + const { handle, did } = body; if (!handle || !did) { - return errorResponse('InvalidRequest', 'missing handle or did', 400) + return errorResponse('InvalidRequest', 'missing handle or did', 400); } - const handleMap = await this.state.storage.get('handleMap') || {} - handleMap[handle] = did - await this.state.storage.put('handleMap', handleMap) - return Response.json({ ok: true }) + const handleMap = (await this.state.storage.get('handleMap')) || {}; + handleMap[handle] = did; + await this.state.storage.put('handleMap', handleMap); + return Response.json({ ok: true }); } async handleResolveHandle(url) { - const handle = url.searchParams.get('handle') + const handle = url.searchParams.get('handle'); if (!handle) { - return errorResponse('InvalidRequest', 'missing handle', 400) + return errorResponse('InvalidRequest', 'missing handle', 400); } - const handleMap = await this.state.storage.get('handleMap') || {} - const did = handleMap[handle] + const handleMap = (await this.state.storage.get('handleMap')) || {}; + const did = handleMap[handle]; if (!did) { - return errorResponse('NotFound', 'handle not found', 404) + return errorResponse('NotFound', 'handle not found', 404); } - return Response.json({ did }) + return Response.json({ did }); } async handleRepoInfo() { - const head = await this.state.storage.get('head') - const rev = await this.state.storage.get('rev') - return Response.json({ head: head || null, rev: rev || null }) + const head = await this.state.storage.get('head'); + const rev = await this.state.storage.get('rev'); + return Response.json({ head: head || null, rev: rev || null }); } handleDescribeServer(request) { - const hostname = request.headers.get('x-hostname') || 'localhost' + const hostname = request.headers.get('x-hostname') || 'localhost'; return Response.json({ did: `did:web:${hostname}`, availableUserDomains: [`.${hostname}`], inviteCodeRequired: false, phoneVerificationRequired: false, links: {}, - contact: {} - }) + contact: {}, + }); } async handleCreateSession(request) { - const body = await request.json() - const { identifier, password } = body + const body = await request.json(); + const { identifier, password } = body; if (!identifier || !password) { - return errorResponse('InvalidRequest', 'Missing identifier or password', 400) + return errorResponse( + 'InvalidRequest', + 'Missing identifier or password', + 400, + ); } // Check password against env var - const expectedPassword = this.env?.PDS_PASSWORD + const expectedPassword = this.env?.PDS_PASSWORD; if (!expectedPassword || password !== expectedPassword) { - return errorResponse('AuthenticationRequired', 'Invalid identifier or password', 401) + return errorResponse( + 'AuthenticationRequired', + 'Invalid identifier or password', + 401, + ); } // Resolve identifier to DID - let did = identifier + let did = identifier; if (!identifier.startsWith('did:')) { // Try to resolve handle - const handleMap = await this.state.storage.get('handleMap') || {} - did = handleMap[identifier] + const handleMap = (await this.state.storage.get('handleMap')) || {}; + did = handleMap[identifier]; if (!did) { - return errorResponse('InvalidRequest', 'Unable to resolve handle', 400) + return errorResponse('InvalidRequest', 'Unable to resolve handle', 400); } } // Get handle for response - const handle = await this.getHandleForDid(did) + const handle = await this.getHandleForDid(did); // Create tokens - const jwtSecret = this.env?.JWT_SECRET + const jwtSecret = this.env?.JWT_SECRET; if (!jwtSecret) { - return errorResponse('InternalServerError', 'Server not configured for authentication', 500) + return errorResponse( + 'InternalServerError', + 'Server not configured for authentication', + 500, + ); } - const accessJwt = await createAccessJwt(did, jwtSecret) - const refreshJwt = await createRefreshJwt(did, jwtSecret) + const accessJwt = await createAccessJwt(did, jwtSecret); + const refreshJwt = await createRefreshJwt(did, jwtSecret); return Response.json({ accessJwt, refreshJwt, handle: handle || did, did, - active: true - }) + active: true, + }); } async handleGetSession(request) { - const authHeader = request.headers.get('Authorization') + const authHeader = request.headers.get('Authorization'); if (!authHeader || !authHeader.startsWith('Bearer ')) { - return errorResponse('AuthenticationRequired', 'Missing or invalid authorization header', 401) + return errorResponse( + 'AuthenticationRequired', + 'Missing or invalid authorization header', + 401, + ); } - const token = authHeader.slice(7) // Remove 'Bearer ' - const jwtSecret = this.env?.JWT_SECRET + const token = authHeader.slice(7); // Remove 'Bearer ' + const jwtSecret = this.env?.JWT_SECRET; if (!jwtSecret) { - return errorResponse('InternalServerError', 'Server not configured for authentication', 500) + return errorResponse( + 'InternalServerError', + 'Server not configured for authentication', + 500, + ); } try { - const payload = await verifyAccessJwt(token, jwtSecret) - const did = payload.sub - const handle = await this.getHandleForDid(did) + const payload = await verifyAccessJwt(token, jwtSecret); + const did = payload.sub; + const handle = await this.getHandleForDid(did); return Response.json({ handle: handle || did, did, - active: true - }) + active: true, + }); } catch (err) { - return errorResponse('InvalidToken', err.message, 401) + return errorResponse('InvalidToken', err.message, 401); } } - async handleGetPreferences(request) { + async handleGetPreferences(_request) { // Preferences are stored per-user in their DO - const preferences = await this.state.storage.get('preferences') || [] - return Response.json({ preferences }) + const preferences = (await this.state.storage.get('preferences')) || []; + return Response.json({ preferences }); } async handlePutPreferences(request) { - const body = await request.json() - const { preferences } = body + const body = await request.json(); + const { preferences } = body; if (!Array.isArray(preferences)) { - return errorResponse('InvalidRequest', 'preferences must be an array', 400) + return errorResponse( + 'InvalidRequest', + 'preferences must be an array', + 400, + ); } - await this.state.storage.put('preferences', preferences) - return Response.json({}) + await this.state.storage.put('preferences', preferences); + return Response.json({}); } async getHandleForDid(did) { // Check if this DID has a handle registered - const handleMap = await this.state.storage.get('handleMap') || {} + const handleMap = (await this.state.storage.get('handleMap')) || {}; for (const [handle, mappedDid] of Object.entries(handleMap)) { - if (mappedDid === did) return handle + if (mappedDid === did) return handle; } // Check instance's own handle - const instanceDid = await this.getDid() + const instanceDid = await this.getDid(); if (instanceDid === did) { - return await this.state.storage.get('handle') + return await this.state.storage.get('handle'); } - return null + return null; } async createServiceAuthForAppView(did, lxm) { - const signingKey = await this.getSigningKey() + const signingKey = await this.getSigningKey(); return createServiceJwt({ iss: did, aud: 'did:web:api.bsky.app', lxm, - signingKey - }) + signingKey, + }); } async handleAppViewProxy(request, userDid) { - const url = new URL(request.url) + const url = new URL(request.url); // Extract lexicon method from path: /xrpc/app.bsky.actor.getPreferences -> app.bsky.actor.getPreferences - const lxm = url.pathname.replace('/xrpc/', '') + const lxm = url.pathname.replace('/xrpc/', ''); // Create service auth JWT - const serviceJwt = await this.createServiceAuthForAppView(userDid, lxm) + const serviceJwt = await this.createServiceAuthForAppView(userDid, lxm); // Build AppView URL - const appViewUrl = new URL(url.pathname + url.search, 'https://api.bsky.app') + const appViewUrl = new URL( + url.pathname + url.search, + 'https://api.bsky.app', + ); // Forward request with service auth - const headers = new Headers() - headers.set('Authorization', `Bearer ${serviceJwt}`) - headers.set('Content-Type', request.headers.get('Content-Type') || 'application/json') + const headers = new Headers(); + headers.set('Authorization', `Bearer ${serviceJwt}`); + headers.set( + 'Content-Type', + request.headers.get('Content-Type') || 'application/json', + ); if (request.headers.get('Accept')) { - headers.set('Accept', request.headers.get('Accept')) + headers.set('Accept', request.headers.get('Accept')); } if (request.headers.get('Accept-Language')) { - headers.set('Accept-Language', request.headers.get('Accept-Language')) + headers.set('Accept-Language', request.headers.get('Accept-Language')); } const proxyReq = new Request(appViewUrl.toString(), { method: request.method, headers, - body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : undefined, - }) + body: + request.method !== 'GET' && request.method !== 'HEAD' + ? request.body + : undefined, + }); try { - const response = await fetch(proxyReq) + const response = await fetch(proxyReq); // Return the response with CORS headers - const responseHeaders = new Headers(response.headers) - responseHeaders.set('Access-Control-Allow-Origin', '*') + const responseHeaders = new Headers(response.headers); + responseHeaders.set('Access-Control-Allow-Origin', '*'); return new Response(response.body, { status: response.status, statusText: response.statusText, - headers: responseHeaders - }) + headers: responseHeaders, + }); } catch (err) { - return errorResponse('UpstreamFailure', 'Failed to reach AppView: ' + err.message, 502) + return errorResponse( + 'UpstreamFailure', + `Failed to reach AppView: ${err.message}`, + 502, + ); } } async handleListRepos() { - const registeredDids = await this.state.storage.get('registeredDids') || [] - const did = await this.getDid() - const repos = did ? [{ did, head: null, rev: null }] : - registeredDids.map(d => ({ did: d, head: null, rev: null })) - return Response.json({ repos }) + const registeredDids = + (await this.state.storage.get('registeredDids')) || []; + const did = await this.getDid(); + const repos = did + ? [{ did, head: null, rev: null }] + : registeredDids.map((d) => ({ did: d, head: null, rev: null })); + return Response.json({ repos }); } async handleCreateRecord(request) { - const body = await request.json() + const body = await request.json(); if (!body.collection || !body.record) { - return errorResponse('InvalidRequest', 'missing collection or record', 400) + return errorResponse( + 'InvalidRequest', + 'missing collection or record', + 400, + ); } try { - const result = await this.createRecord(body.collection, body.record, body.rkey) - const head = await this.state.storage.get('head') - const rev = await this.state.storage.get('rev') + const result = await this.createRecord( + body.collection, + body.record, + body.rkey, + ); + const head = await this.state.storage.get('head'); + const rev = await this.state.storage.get('rev'); return Response.json({ uri: result.uri, cid: result.cid, commit: { cid: head, rev }, - validationStatus: 'valid' - }) + validationStatus: 'valid', + }); } catch (err) { - return errorResponse('InternalError', err.message, 500) + return errorResponse('InternalError', err.message, 500); } } async handleDeleteRecord(request) { - const body = await request.json() + const body = await request.json(); if (!body.collection || !body.rkey) { - return errorResponse('InvalidRequest', 'missing collection or rkey', 400) + return errorResponse('InvalidRequest', 'missing collection or rkey', 400); } try { - const result = await this.deleteRecord(body.collection, body.rkey) + const result = await this.deleteRecord(body.collection, body.rkey); if (result.error) { - return Response.json(result, { status: 404 }) + return Response.json(result, { status: 404 }); } - return Response.json({}) + return Response.json({}); } catch (err) { - return errorResponse('InternalError', err.message, 500) + return errorResponse('InternalError', err.message, 500); } } async handlePutRecord(request) { - const body = await request.json() + const body = await request.json(); if (!body.collection || !body.rkey || !body.record) { - return errorResponse('InvalidRequest', 'missing collection, rkey, or record', 400) + return errorResponse( + 'InvalidRequest', + 'missing collection, rkey, or record', + 400, + ); } try { // putRecord is like createRecord but with a specific rkey (upsert) - const result = await this.createRecord(body.collection, body.record, body.rkey) - const head = await this.state.storage.get('head') - const rev = await this.state.storage.get('rev') + const result = await this.createRecord( + body.collection, + body.record, + body.rkey, + ); + const head = await this.state.storage.get('head'); + const rev = await this.state.storage.get('rev'); return Response.json({ uri: result.uri, cid: result.cid, commit: { cid: head, rev }, - validationStatus: 'valid' - }) + validationStatus: 'valid', + }); } catch (err) { - return errorResponse('InternalError', err.message, 500) + return errorResponse('InternalError', err.message, 500); } } async handleApplyWrites(request) { - const body = await request.json() + const body = await request.json(); if (!body.writes || !Array.isArray(body.writes)) { - return errorResponse('InvalidRequest', 'missing writes array', 400) + return errorResponse('InvalidRequest', 'missing writes array', 400); } try { - const results = [] + const results = []; for (const write of body.writes) { - const type = write['$type'] + const type = write.$type; if (type === 'com.atproto.repo.applyWrites#create') { - const result = await this.createRecord(write.collection, write.value, write.rkey) + const result = await this.createRecord( + write.collection, + write.value, + write.rkey, + ); results.push({ $type: 'com.atproto.repo.applyWrites#createResult', uri: result.uri, cid: result.cid, - validationStatus: 'valid' - }) + validationStatus: 'valid', + }); } else if (type === 'com.atproto.repo.applyWrites#update') { - const result = await this.createRecord(write.collection, write.value, write.rkey) + const result = await this.createRecord( + write.collection, + write.value, + write.rkey, + ); results.push({ $type: 'com.atproto.repo.applyWrites#updateResult', uri: result.uri, cid: result.cid, - validationStatus: 'valid' - }) + validationStatus: 'valid', + }); } else if (type === 'com.atproto.repo.applyWrites#delete') { - await this.deleteRecord(write.collection, write.rkey) + await this.deleteRecord(write.collection, write.rkey); results.push({ - $type: 'com.atproto.repo.applyWrites#deleteResult' - }) + $type: 'com.atproto.repo.applyWrites#deleteResult', + }); } else { - return errorResponse('InvalidRequest', `Unknown write operation type: ${type}`, 400) + return errorResponse( + 'InvalidRequest', + `Unknown write operation type: ${type}`, + 400, + ); } } // Return commit info - const head = await this.state.storage.get('head') - const rev = await this.state.storage.get('rev') - return Response.json({ commit: { cid: head, rev }, results }) + const head = await this.state.storage.get('head'); + const rev = await this.state.storage.get('rev'); + return Response.json({ commit: { cid: head, rev }, results }); } catch (err) { - return errorResponse('InternalError', err.message, 500) + return errorResponse('InternalError', err.message, 500); } } async handleGetRecord(url) { - const collection = url.searchParams.get('collection') - const rkey = url.searchParams.get('rkey') + const collection = url.searchParams.get('collection'); + const rkey = url.searchParams.get('rkey'); if (!collection || !rkey) { - return errorResponse('InvalidRequest', 'missing collection or rkey', 400) + return errorResponse('InvalidRequest', 'missing collection or rkey', 400); } - const did = await this.getDid() - const uri = `at://${did}/${collection}/${rkey}` - const rows = this.sql.exec( - `SELECT cid, value FROM records WHERE uri = ?`, uri - ).toArray() + const did = await this.getDid(); + const uri = `at://${did}/${collection}/${rkey}`; + const rows = this.sql + .exec(`SELECT cid, value FROM records WHERE uri = ?`, uri) + .toArray(); if (rows.length === 0) { - return errorResponse('RecordNotFound', 'record not found', 404) + return errorResponse('RecordNotFound', 'record not found', 404); } - const row = rows[0] - const value = cborDecode(new Uint8Array(row.value)) - return Response.json({ uri, cid: row.cid, value }) + const row = rows[0]; + const value = cborDecode(new Uint8Array(row.value)); + return Response.json({ uri, cid: row.cid, value }); } async handleDescribeRepo() { - const did = await this.getDid() + const did = await this.getDid(); if (!did) { - return errorResponse('RepoNotFound', 'repo not found', 404) + return errorResponse('RepoNotFound', 'repo not found', 404); } - const handle = await this.state.storage.get('handle') + const handle = await this.state.storage.get('handle'); // Get unique collections - const collections = this.sql.exec( - `SELECT DISTINCT collection FROM records` - ).toArray().map(r => r.collection) + const collections = this.sql + .exec(`SELECT DISTINCT collection FROM records`) + .toArray() + .map((r) => r.collection); return Response.json({ handle: handle || did, did, didDoc: {}, collections, - handleIsCorrect: !!handle - }) + handleIsCorrect: !!handle, + }); } async handleListRecords(url) { - const collection = url.searchParams.get('collection') + const collection = url.searchParams.get('collection'); if (!collection) { - return errorResponse('InvalidRequest', 'missing collection', 400) - } - const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 100) - const reverse = url.searchParams.get('reverse') === 'true' - const cursor = url.searchParams.get('cursor') - - const did = await this.getDid() - let query = `SELECT uri, cid, value FROM records WHERE collection = ? ORDER BY rkey ${reverse ? 'DESC' : 'ASC'} LIMIT ?` - const params = [collection, limit + 1] - - const rows = this.sql.exec(query, ...params).toArray() - const hasMore = rows.length > limit - const records = rows.slice(0, limit).map(r => ({ + return errorResponse('InvalidRequest', 'missing collection', 400); + } + const limit = Math.min( + parseInt(url.searchParams.get('limit') || '50', 10), + 100, + ); + const reverse = url.searchParams.get('reverse') === 'true'; + const _cursor = url.searchParams.get('cursor'); + + const _did = await this.getDid(); + const query = `SELECT uri, cid, value FROM records WHERE collection = ? ORDER BY rkey ${reverse ? 'DESC' : 'ASC'} LIMIT ?`; + const params = [collection, limit + 1]; + + const rows = this.sql.exec(query, ...params).toArray(); + const hasMore = rows.length > limit; + const records = rows.slice(0, limit).map((r) => ({ uri: r.uri, cid: r.cid, - value: cborDecode(new Uint8Array(r.value)) - })) + value: cborDecode(new Uint8Array(r.value)), + })); return Response.json({ records, - cursor: hasMore ? records[records.length - 1]?.uri : undefined - }) + cursor: hasMore ? records[records.length - 1]?.uri : undefined, + }); } handleGetLatestCommit() { - const commits = this.sql.exec( - `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1` - ).toArray() + const commits = this.sql + .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`) + .toArray(); if (commits.length === 0) { - return errorResponse('RepoNotFound', 'repo not found', 404) + return errorResponse('RepoNotFound', 'repo not found', 404); } - return Response.json({ cid: commits[0].cid, rev: commits[0].rev }) + return Response.json({ cid: commits[0].cid, rev: commits[0].rev }); } async handleGetRepoStatus() { - const did = await this.getDid() - const commits = this.sql.exec( - `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1` - ).toArray() + const did = await this.getDid(); + const commits = this.sql + .exec(`SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`) + .toArray(); if (commits.length === 0 || !did) { - return errorResponse('RepoNotFound', 'repo not found', 404) + return errorResponse('RepoNotFound', 'repo not found', 404); } - return Response.json({ did, active: true, status: 'active', rev: commits[0].rev }) + return Response.json({ + did, + active: true, + status: 'active', + rev: commits[0].rev, + }); } handleGetRepo() { - const commits = this.sql.exec( - `SELECT cid FROM commits ORDER BY seq DESC LIMIT 1` - ).toArray() + const commits = this.sql + .exec(`SELECT cid FROM commits ORDER BY seq DESC LIMIT 1`) + .toArray(); if (commits.length === 0) { - return errorResponse('RepoNotFound', 'repo not found', 404) + return errorResponse('RepoNotFound', 'repo not found', 404); } // Only include blocks reachable from the current commit - const commitCid = commits[0].cid - const neededCids = new Set() + const commitCid = commits[0].cid; + const neededCids = new Set(); // Helper to get block data const getBlock = (cid) => { - const rows = this.sql.exec(`SELECT data FROM blocks WHERE cid = ?`, cid).toArray() - return rows.length > 0 ? new Uint8Array(rows[0].data) : null - } + const rows = this.sql + .exec(`SELECT data FROM blocks WHERE cid = ?`, cid) + .toArray(); + return rows.length > 0 ? new Uint8Array(rows[0].data) : null; + }; // Collect all reachable blocks starting from commit const collectBlocks = (cid) => { - if (neededCids.has(cid)) return - neededCids.add(cid) + if (neededCids.has(cid)) return; + neededCids.add(cid); - const data = getBlock(cid) - if (!data) return + const data = getBlock(cid); + if (!data) return; // Decode CBOR to find CID references try { - const decoded = cborDecode(data) + const decoded = cborDecode(data); if (decoded && typeof decoded === 'object') { // Commit object - follow 'data' (MST root) if (decoded.data instanceof Uint8Array) { - collectBlocks(cidToString(decoded.data)) + collectBlocks(cidToString(decoded.data)); } // MST node - follow 'l' and entries' 'v' and 't' if (decoded.l instanceof Uint8Array) { - collectBlocks(cidToString(decoded.l)) + collectBlocks(cidToString(decoded.l)); } if (Array.isArray(decoded.e)) { for (const entry of decoded.e) { if (entry.v instanceof Uint8Array) { - collectBlocks(cidToString(entry.v)) + collectBlocks(cidToString(entry.v)); } if (entry.t instanceof Uint8Array) { - collectBlocks(cidToString(entry.t)) + collectBlocks(cidToString(entry.t)); } } } } - } catch (e) { + } catch (_e) { // Not a structured block, that's fine } - } + }; - collectBlocks(commitCid) + collectBlocks(commitCid); // Build CAR with only needed blocks - const blocksForCar = [] + const blocksForCar = []; for (const cid of neededCids) { - const data = getBlock(cid) + const data = getBlock(cid); if (data) { - blocksForCar.push({ cid, data }) + blocksForCar.push({ cid, data }); } } - const car = buildCarFile(commitCid, blocksForCar) + const car = buildCarFile(commitCid, blocksForCar); return new Response(car, { - headers: { 'content-type': 'application/vnd.ipld.car' } - }) + headers: { 'content-type': 'application/vnd.ipld.car' }, + }); } async handleSyncGetRecord(url) { - const collection = url.searchParams.get('collection') - const rkey = url.searchParams.get('rkey') + const collection = url.searchParams.get('collection'); + const rkey = url.searchParams.get('rkey'); if (!collection || !rkey) { - return errorResponse('InvalidRequest', 'missing collection or rkey', 400) + return errorResponse('InvalidRequest', 'missing collection or rkey', 400); } - const did = await this.getDid() - const uri = `at://${did}/${collection}/${rkey}` - const rows = this.sql.exec( - `SELECT cid FROM records WHERE uri = ?`, uri - ).toArray() + const did = await this.getDid(); + const uri = `at://${did}/${collection}/${rkey}`; + const rows = this.sql + .exec(`SELECT cid FROM records WHERE uri = ?`, uri) + .toArray(); if (rows.length === 0) { - return errorResponse('RecordNotFound', 'record not found', 404) + return errorResponse('RecordNotFound', 'record not found', 404); } - const recordCid = rows[0].cid + const recordCid = rows[0].cid; // Get latest commit - const commits = this.sql.exec( - `SELECT cid FROM commits ORDER BY seq DESC LIMIT 1` - ).toArray() + const commits = this.sql + .exec(`SELECT cid FROM commits ORDER BY seq DESC LIMIT 1`) + .toArray(); if (commits.length === 0) { - return errorResponse('RepoNotFound', 'no commits', 404) + return errorResponse('RepoNotFound', 'no commits', 404); } - const commitCid = commits[0].cid + const commitCid = commits[0].cid; // Build proof chain: commit -> MST path -> record // Include commit block, all MST nodes on path to record, and record block - const blocks = [] - const included = new Set() + const blocks = []; + const included = new Set(); const addBlock = (cidStr) => { - if (included.has(cidStr)) return - included.add(cidStr) - const blockRows = this.sql.exec( - `SELECT data FROM blocks WHERE cid = ?`, cidStr - ).toArray() + if (included.has(cidStr)) return; + included.add(cidStr); + const blockRows = this.sql + .exec(`SELECT data FROM blocks WHERE cid = ?`, cidStr) + .toArray(); if (blockRows.length > 0) { - blocks.push({ cid: cidStr, data: new Uint8Array(blockRows[0].data) }) + blocks.push({ cid: cidStr, data: new Uint8Array(blockRows[0].data) }); } - } + }; // Add commit block - addBlock(commitCid) + addBlock(commitCid); // Get commit to find data root - const commitRows = this.sql.exec( - `SELECT data FROM blocks WHERE cid = ?`, commitCid - ).toArray() + const commitRows = this.sql + .exec(`SELECT data FROM blocks WHERE cid = ?`, commitCid) + .toArray(); if (commitRows.length > 0) { - const commit = cborDecode(new Uint8Array(commitRows[0].data)) + const commit = cborDecode(new Uint8Array(commitRows[0].data)); if (commit.data) { - const dataRootCid = cidToString(commit.data) + const dataRootCid = cidToString(commit.data); // Collect MST path blocks (this includes all MST nodes) - const mstBlocks = this.collectMstBlocks(dataRootCid) + const mstBlocks = this.collectMstBlocks(dataRootCid); for (const block of mstBlocks) { - addBlock(block.cid) + addBlock(block.cid); } } } // Add record block - addBlock(recordCid) + addBlock(recordCid); - const car = buildCarFile(commitCid, blocks) + const car = buildCarFile(commitCid, blocks); return new Response(car, { - headers: { 'content-type': 'application/vnd.ipld.car' } - }) + headers: { 'content-type': 'application/vnd.ipld.car' }, + }); } handleSubscribeRepos(request, url) { - const upgradeHeader = request.headers.get('Upgrade') + const upgradeHeader = request.headers.get('Upgrade'); if (upgradeHeader !== 'websocket') { - return new Response('expected websocket', { status: 426 }) + return new Response('expected websocket', { status: 426 }); } - const { 0: client, 1: server } = new WebSocketPair() - this.state.acceptWebSocket(server) - const cursor = url.searchParams.get('cursor') + const { 0: client, 1: server } = new WebSocketPair(); + this.state.acceptWebSocket(server); + const cursor = url.searchParams.get('cursor'); if (cursor) { - const events = this.sql.exec( - `SELECT * FROM seq_events WHERE seq > ? ORDER BY seq`, - parseInt(cursor) - ).toArray() + const events = this.sql + .exec( + `SELECT * FROM seq_events WHERE seq > ? ORDER BY seq`, + parseInt(cursor, 10), + ) + .toArray(); for (const evt of events) { - server.send(this.formatEvent(evt)) + server.send(this.formatEvent(evt)); } } - return new Response(null, { status: 101, webSocket: client }) + return new Response(null, { status: 101, webSocket: client }); } async fetch(request) { - const url = new URL(request.url) - const route = pdsRoutes[url.pathname] + const url = new URL(request.url); + const route = pdsRoutes[url.pathname]; // Check for local route first if (route) { if (route.method && request.method !== route.method) { - return errorResponse('MethodNotAllowed', 'method not allowed', 405) + return errorResponse('MethodNotAllowed', 'method not allowed', 405); } - return route.handler(this, request, url) + return route.handler(this, request, url); } // Handle app.bsky.* proxy requests (only if no local route) if (url.pathname.startsWith('/xrpc/app.bsky.')) { - const userDid = request.headers.get('x-authed-did') + const userDid = request.headers.get('x-authed-did'); if (!userDid) { - return errorResponse('Unauthorized', 'Missing auth context', 401) + return errorResponse('Unauthorized', 'Missing auth context', 401); } - return this.handleAppViewProxy(request, userDid) + return this.handleAppViewProxy(request, userDid); } - return errorResponse('NotFound', 'not found', 404) + return errorResponse('NotFound', 'not found', 404); } } const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, atproto-accept-labelers, atproto-proxy, x-bsky-topics', -} + 'Access-Control-Allow-Headers': + 'Content-Type, Authorization, atproto-accept-labelers, atproto-proxy, x-bsky-topics', +}; function addCorsHeaders(response) { - const newHeaders = new Headers(response.headers) + const newHeaders = new Headers(response.headers); for (const [key, value] of Object.entries(corsHeaders)) { - newHeaders.set(key, value) + newHeaders.set(key, value); } return new Response(response.body, { status: response.status, statusText: response.statusText, - headers: newHeaders - }) + headers: newHeaders, + }); } export default { async fetch(request, env) { // Handle CORS preflight if (request.method === 'OPTIONS') { - return new Response(null, { headers: corsHeaders }) + return new Response(null, { headers: corsHeaders }); } - const response = await handleRequest(request, env) + const response = await handleRequest(request, env); // Don't wrap WebSocket upgrades - they need the webSocket property preserved if (response.status === 101) { - return response + return response; } - return addCorsHeaders(response) - } -} + return addCorsHeaders(response); + }, +}; // Extract subdomain from hostname (e.g., "alice" from "alice.foo.workers.dev") function getSubdomain(hostname) { - const parts = hostname.split('.') + const parts = hostname.split('.'); // workers.dev domains: [subdomain?].[worker-name].[account].workers.dev // If more than 4 parts, first part(s) are user subdomain if (parts.length > 4 && parts.slice(-2).join('.') === 'workers.dev') { - return parts.slice(0, -4).join('.') + return parts.slice(0, -4).join('.'); } // Custom domains: check if there's a subdomain before the base // For now, assume no subdomain on custom domains - return null + return null; } /** @@ -2142,213 +2310,245 @@ function getSubdomain(hostname) { * @returns {Promise<{did: string} | {error: Response}>} DID or error response */ async function requireAuth(request, env) { - const authHeader = request.headers.get('Authorization') + const authHeader = request.headers.get('Authorization'); if (!authHeader || !authHeader.startsWith('Bearer ')) { return { - error: Response.json({ - error: 'AuthenticationRequired', - message: 'Authentication required' - }, { status: 401 }) - } - } - - const token = authHeader.slice(7) - const jwtSecret = env?.JWT_SECRET + error: Response.json( + { + error: 'AuthenticationRequired', + message: 'Authentication required', + }, + { status: 401 }, + ), + }; + } + + const token = authHeader.slice(7); + const jwtSecret = env?.JWT_SECRET; if (!jwtSecret) { return { - error: Response.json({ - error: 'InternalServerError', - message: 'Server not configured for authentication' - }, { status: 500 }) - } + error: Response.json( + { + error: 'InternalServerError', + message: 'Server not configured for authentication', + }, + { status: 500 }, + ), + }; } try { - const payload = await verifyAccessJwt(token, jwtSecret) - return { did: payload.sub } + const payload = await verifyAccessJwt(token, jwtSecret); + return { did: payload.sub }; } catch (err) { return { - error: Response.json({ - error: 'InvalidToken', - message: err.message - }, { status: 401 }) - } + error: Response.json( + { + error: 'InvalidToken', + message: err.message, + }, + { status: 401 }, + ), + }; } } async function handleAuthenticatedRepoWrite(request, env) { - const auth = await requireAuth(request, env) - if (auth.error) return auth.error + const auth = await requireAuth(request, env); + if (auth.error) return auth.error; - const body = await request.json() - const repo = body.repo + const body = await request.json(); + const repo = body.repo; if (!repo) { - return errorResponse('InvalidRequest', 'missing repo param', 400) + return errorResponse('InvalidRequest', 'missing repo param', 400); } if (auth.did !== repo) { - return errorResponse('Forbidden', 'Cannot modify another user\'s repo', 403) + return errorResponse('Forbidden', "Cannot modify another user's repo", 403); } - const id = env.PDS.idFromName(repo) - const pds = env.PDS.get(id) - const response = await pds.fetch(new Request(request.url, { - method: 'POST', - headers: request.headers, - body: JSON.stringify(body) - })) + const id = env.PDS.idFromName(repo); + const pds = env.PDS.get(id); + const response = await pds.fetch( + new Request(request.url, { + method: 'POST', + headers: request.headers, + body: JSON.stringify(body), + }), + ); // Notify relay of updates on successful writes if (response.ok) { - const url = new URL(request.url) - notifyCrawlers(env, url.hostname) + const url = new URL(request.url); + notifyCrawlers(env, url.hostname); } - return response + return response; } async function handleRequest(request, env) { - const url = new URL(request.url) - const subdomain = getSubdomain(url.hostname) + const url = new URL(request.url); + const subdomain = getSubdomain(url.hostname); // Handle resolution via subdomain or bare domain if (url.pathname === '/.well-known/atproto-did') { // Look up handle -> DID in default DO // Use subdomain if present, otherwise try bare hostname as handle - const handleToResolve = subdomain || url.hostname - const defaultId = env.PDS.idFromName('default') - const defaultPds = env.PDS.get(defaultId) + const handleToResolve = subdomain || url.hostname; + const defaultId = env.PDS.idFromName('default'); + const defaultPds = env.PDS.get(defaultId); const resolveRes = await defaultPds.fetch( - new Request(`http://internal/resolve-handle?handle=${encodeURIComponent(handleToResolve)}`) - ) + new Request( + `http://internal/resolve-handle?handle=${encodeURIComponent(handleToResolve)}`, + ), + ); if (!resolveRes.ok) { - return new Response('Handle not found', { status: 404 }) + return new Response('Handle not found', { status: 404 }); } - const { did } = await resolveRes.json() - return new Response(did, { headers: { 'Content-Type': 'text/plain' } }) + const { did } = await resolveRes.json(); + return new Response(did, { headers: { 'Content-Type': 'text/plain' } }); } // describeServer - works on bare domain if (url.pathname === '/xrpc/com.atproto.server.describeServer') { - const defaultId = env.PDS.idFromName('default') - const defaultPds = env.PDS.get(defaultId) + const defaultId = env.PDS.idFromName('default'); + const defaultPds = env.PDS.get(defaultId); const newReq = new Request(request.url, { method: request.method, - headers: { ...Object.fromEntries(request.headers), 'x-hostname': url.hostname } - }) - return defaultPds.fetch(newReq) + headers: { + ...Object.fromEntries(request.headers), + 'x-hostname': url.hostname, + }, + }); + return defaultPds.fetch(newReq); } // createSession - handle on default DO (has handleMap for identifier resolution) if (url.pathname === '/xrpc/com.atproto.server.createSession') { - const defaultId = env.PDS.idFromName('default') - const defaultPds = env.PDS.get(defaultId) - return defaultPds.fetch(request) + const defaultId = env.PDS.idFromName('default'); + const defaultPds = env.PDS.get(defaultId); + return defaultPds.fetch(request); } // getSession - route to default DO if (url.pathname === '/xrpc/com.atproto.server.getSession') { - const defaultId = env.PDS.idFromName('default') - const defaultPds = env.PDS.get(defaultId) - return defaultPds.fetch(request) + const defaultId = env.PDS.idFromName('default'); + const defaultPds = env.PDS.get(defaultId); + return defaultPds.fetch(request); } // Proxy app.bsky.* endpoints to Bluesky AppView if (url.pathname.startsWith('/xrpc/app.bsky.')) { // Authenticate the user first - const auth = await requireAuth(request, env) - if (auth.error) return auth.error + const auth = await requireAuth(request, env); + if (auth.error) return auth.error; // Route to the user's DO instance to create service auth and proxy - const id = env.PDS.idFromName(auth.did) - const pds = env.PDS.get(id) - return pds.fetch(new Request(request.url, { - method: request.method, - headers: { - ...Object.fromEntries(request.headers), - 'x-authed-did': auth.did // Pass the authenticated DID - }, - body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : undefined, - })) + const id = env.PDS.idFromName(auth.did); + const pds = env.PDS.get(id); + return pds.fetch( + new Request(request.url, { + method: request.method, + headers: { + ...Object.fromEntries(request.headers), + 'x-authed-did': auth.did, // Pass the authenticated DID + }, + body: + request.method !== 'GET' && request.method !== 'HEAD' + ? request.body + : undefined, + }), + ); } // Handle registration routes - go to default DO - if (url.pathname === '/register-handle' || url.pathname === '/resolve-handle') { - const defaultId = env.PDS.idFromName('default') - const defaultPds = env.PDS.get(defaultId) - return defaultPds.fetch(request) + if ( + url.pathname === '/register-handle' || + url.pathname === '/resolve-handle' + ) { + const defaultId = env.PDS.idFromName('default'); + const defaultPds = env.PDS.get(defaultId); + return defaultPds.fetch(request); } // resolveHandle XRPC endpoint if (url.pathname === '/xrpc/com.atproto.identity.resolveHandle') { - const handle = url.searchParams.get('handle') + const handle = url.searchParams.get('handle'); if (!handle) { - return errorResponse('InvalidRequest', 'missing handle param', 400) + return errorResponse('InvalidRequest', 'missing handle param', 400); } - const defaultId = env.PDS.idFromName('default') - const defaultPds = env.PDS.get(defaultId) + const defaultId = env.PDS.idFromName('default'); + const defaultPds = env.PDS.get(defaultId); const resolveRes = await defaultPds.fetch( - new Request(`http://internal/resolve-handle?handle=${encodeURIComponent(handle)}`) - ) + new Request( + `http://internal/resolve-handle?handle=${encodeURIComponent(handle)}`, + ), + ); if (!resolveRes.ok) { - return errorResponse('InvalidRequest', 'Unable to resolve handle', 400) + return errorResponse('InvalidRequest', 'Unable to resolve handle', 400); } - const { did } = await resolveRes.json() - return Response.json({ did }) + const { did } = await resolveRes.json(); + return Response.json({ did }); } // subscribeRepos WebSocket - route to default instance for firehose if (url.pathname === '/xrpc/com.atproto.sync.subscribeRepos') { - const defaultId = env.PDS.idFromName('default') - const defaultPds = env.PDS.get(defaultId) - return defaultPds.fetch(request) + const defaultId = env.PDS.idFromName('default'); + const defaultPds = env.PDS.get(defaultId); + return defaultPds.fetch(request); } // listRepos needs to aggregate from all registered DIDs if (url.pathname === '/xrpc/com.atproto.sync.listRepos') { - const defaultId = env.PDS.idFromName('default') - const defaultPds = env.PDS.get(defaultId) - const regRes = await defaultPds.fetch(new Request('http://internal/get-registered-dids')) - const { dids } = await regRes.json() - - const repos = [] + const defaultId = env.PDS.idFromName('default'); + const defaultPds = env.PDS.get(defaultId); + const regRes = await defaultPds.fetch( + new Request('http://internal/get-registered-dids'), + ); + const { dids } = await regRes.json(); + + const repos = []; for (const did of dids) { - const id = env.PDS.idFromName(did) - const pds = env.PDS.get(id) - const infoRes = await pds.fetch(new Request('http://internal/repo-info')) - const info = await infoRes.json() + const id = env.PDS.idFromName(did); + const pds = env.PDS.get(id); + const infoRes = await pds.fetch(new Request('http://internal/repo-info')); + const info = await infoRes.json(); if (info.head) { - repos.push({ did, head: info.head, rev: info.rev, active: true }) + repos.push({ did, head: info.head, rev: info.rev, active: true }); } } - return Response.json({ repos, cursor: undefined }) + return Response.json({ repos, cursor: undefined }); } // Repo endpoints use ?repo= param instead of ?did= - if (url.pathname === '/xrpc/com.atproto.repo.describeRepo' || - url.pathname === '/xrpc/com.atproto.repo.listRecords' || - url.pathname === '/xrpc/com.atproto.repo.getRecord') { - const repo = url.searchParams.get('repo') + if ( + url.pathname === '/xrpc/com.atproto.repo.describeRepo' || + url.pathname === '/xrpc/com.atproto.repo.listRecords' || + url.pathname === '/xrpc/com.atproto.repo.getRecord' + ) { + const repo = url.searchParams.get('repo'); if (!repo) { - return errorResponse('InvalidRequest', 'missing repo param', 400) + return errorResponse('InvalidRequest', 'missing repo param', 400); } - const id = env.PDS.idFromName(repo) - const pds = env.PDS.get(id) - return pds.fetch(request) + const id = env.PDS.idFromName(repo); + const pds = env.PDS.get(id); + return pds.fetch(request); } // Sync endpoints use ?did= param - if (url.pathname === '/xrpc/com.atproto.sync.getLatestCommit' || - url.pathname === '/xrpc/com.atproto.sync.getRepoStatus' || - url.pathname === '/xrpc/com.atproto.sync.getRepo' || - url.pathname === '/xrpc/com.atproto.sync.getRecord') { - const did = url.searchParams.get('did') + if ( + url.pathname === '/xrpc/com.atproto.sync.getLatestCommit' || + url.pathname === '/xrpc/com.atproto.sync.getRepoStatus' || + url.pathname === '/xrpc/com.atproto.sync.getRepo' || + url.pathname === '/xrpc/com.atproto.sync.getRecord' + ) { + const did = url.searchParams.get('did'); if (!did) { - return errorResponse('InvalidRequest', 'missing did param', 400) + return errorResponse('InvalidRequest', 'missing did param', 400); } - const id = env.PDS.idFromName(did) - const pds = env.PDS.get(id) - return pds.fetch(request) + const id = env.PDS.idFromName(did); + const pds = env.PDS.get(id); + return pds.fetch(request); } // Authenticated repo write endpoints @@ -2356,15 +2556,15 @@ async function handleRequest(request, env) { '/xrpc/com.atproto.repo.createRecord', '/xrpc/com.atproto.repo.deleteRecord', '/xrpc/com.atproto.repo.putRecord', - '/xrpc/com.atproto.repo.applyWrites' - ] + '/xrpc/com.atproto.repo.applyWrites', + ]; if (repoWriteEndpoints.includes(url.pathname)) { - return handleAuthenticatedRepoWrite(request, env) + return handleAuthenticatedRepoWrite(request, env); } // Health check endpoint if (url.pathname === '/xrpc/_health') { - return Response.json({ version: '0.1.0' }) + return Response.json({ version: '0.1.0' }); } // Root path - ASCII art @@ -2378,44 +2578,52 @@ async function handleRequest(request, env) { ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚════╝ ╚══════╝ ATProto PDS on Cloudflare Workers -` - return new Response(ascii, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }) +`; + return new Response(ascii, { + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }); } // On init, register this DID with the default instance (requires ?did= param, no auth yet) if (url.pathname === '/init' && request.method === 'POST') { - const did = url.searchParams.get('did') + const did = url.searchParams.get('did'); if (!did) { - return errorResponse('InvalidRequest', 'missing did param', 400) + return errorResponse('InvalidRequest', 'missing did param', 400); } - const body = await request.json() + const body = await request.json(); // Register with default instance for discovery - const defaultId = env.PDS.idFromName('default') - const defaultPds = env.PDS.get(defaultId) - await defaultPds.fetch(new Request('http://internal/register-did', { - method: 'POST', - body: JSON.stringify({ did }) - })) + const defaultId = env.PDS.idFromName('default'); + const defaultPds = env.PDS.get(defaultId); + await defaultPds.fetch( + new Request('http://internal/register-did', { + method: 'POST', + body: JSON.stringify({ did }), + }), + ); // Register handle if provided if (body.handle) { - await defaultPds.fetch(new Request('http://internal/register-handle', { - method: 'POST', - body: JSON.stringify({ did, handle: body.handle }) - })) + await defaultPds.fetch( + new Request('http://internal/register-handle', { + method: 'POST', + body: JSON.stringify({ did, handle: body.handle }), + }), + ); } // Forward to the actual PDS instance - const id = env.PDS.idFromName(did) - const pds = env.PDS.get(id) - return pds.fetch(new Request(request.url, { - method: 'POST', - headers: request.headers, - body: JSON.stringify(body) - })) + const id = env.PDS.idFromName(did); + const pds = env.PDS.get(id); + return pds.fetch( + new Request(request.url, { + method: 'POST', + headers: request.headers, + body: JSON.stringify(body), + }), + ); } // Unknown endpoint - return errorResponse('NotFound', 'Endpoint not found', 404) + return errorResponse('NotFound', 'Endpoint not found', 404); } diff --git a/test/pds.test.js b/test/pds.test.js index 4d383a2..fcaccda 100644 --- a/test/pds.test.js +++ b/test/pds.test.js @@ -1,476 +1,496 @@ -import { test, describe } from 'node:test' -import assert from 'node:assert' +import assert from 'node:assert'; +import { describe, test } from 'node:test'; import { - cborEncode, cborDecode, createCid, cidToString, cidToBytes, base32Encode, createTid, - generateKeyPair, importPrivateKey, sign, bytesToHex, hexToBytes, - getKeyDepth, varint, base32Decode, buildCarFile, - base64UrlEncode, base64UrlDecode, - createAccessJwt, createRefreshJwt, verifyAccessJwt -} from '../src/pds.js' + base32Decode, + base32Encode, + base64UrlDecode, + base64UrlEncode, + buildCarFile, + bytesToHex, + cborDecode, + cborEncode, + cidToString, + createAccessJwt, + createCid, + createRefreshJwt, + createTid, + generateKeyPair, + getKeyDepth, + hexToBytes, + importPrivateKey, + sign, + varint, + verifyAccessJwt, +} from '../src/pds.js'; describe('CBOR Encoding', () => { test('encodes simple map', () => { - const encoded = cborEncode({ hello: 'world', num: 42 }) + const encoded = cborEncode({ hello: 'world', num: 42 }); // Expected: a2 65 68 65 6c 6c 6f 65 77 6f 72 6c 64 63 6e 75 6d 18 2a const expected = new Uint8Array([ - 0xa2, 0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x65, 0x77, 0x6f, 0x72, 0x6c, 0x64, - 0x63, 0x6e, 0x75, 0x6d, 0x18, 0x2a - ]) - assert.deepStrictEqual(encoded, expected) - }) + 0xa2, 0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x65, 0x77, 0x6f, 0x72, 0x6c, + 0x64, 0x63, 0x6e, 0x75, 0x6d, 0x18, 0x2a, + ]); + assert.deepStrictEqual(encoded, expected); + }); test('encodes null', () => { - const encoded = cborEncode(null) - assert.deepStrictEqual(encoded, new Uint8Array([0xf6])) - }) + const encoded = cborEncode(null); + assert.deepStrictEqual(encoded, new Uint8Array([0xf6])); + }); test('encodes booleans', () => { - assert.deepStrictEqual(cborEncode(true), new Uint8Array([0xf5])) - assert.deepStrictEqual(cborEncode(false), new Uint8Array([0xf4])) - }) + assert.deepStrictEqual(cborEncode(true), new Uint8Array([0xf5])); + assert.deepStrictEqual(cborEncode(false), new Uint8Array([0xf4])); + }); test('encodes small integers', () => { - assert.deepStrictEqual(cborEncode(0), new Uint8Array([0x00])) - assert.deepStrictEqual(cborEncode(1), new Uint8Array([0x01])) - assert.deepStrictEqual(cborEncode(23), new Uint8Array([0x17])) - }) + assert.deepStrictEqual(cborEncode(0), new Uint8Array([0x00])); + assert.deepStrictEqual(cborEncode(1), new Uint8Array([0x01])); + assert.deepStrictEqual(cborEncode(23), new Uint8Array([0x17])); + }); test('encodes integers >= 24', () => { - assert.deepStrictEqual(cborEncode(24), new Uint8Array([0x18, 0x18])) - assert.deepStrictEqual(cborEncode(255), new Uint8Array([0x18, 0xff])) - }) + assert.deepStrictEqual(cborEncode(24), new Uint8Array([0x18, 0x18])); + assert.deepStrictEqual(cborEncode(255), new Uint8Array([0x18, 0xff])); + }); test('encodes negative integers', () => { - assert.deepStrictEqual(cborEncode(-1), new Uint8Array([0x20])) - assert.deepStrictEqual(cborEncode(-10), new Uint8Array([0x29])) - }) + assert.deepStrictEqual(cborEncode(-1), new Uint8Array([0x20])); + assert.deepStrictEqual(cborEncode(-10), new Uint8Array([0x29])); + }); test('encodes strings', () => { - const encoded = cborEncode('hello') + const encoded = cborEncode('hello'); // 0x65 = text string of length 5 - assert.deepStrictEqual(encoded, new Uint8Array([0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f])) - }) + assert.deepStrictEqual( + encoded, + new Uint8Array([0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f]), + ); + }); test('encodes byte strings', () => { - const bytes = new Uint8Array([1, 2, 3]) - const encoded = cborEncode(bytes) + const bytes = new Uint8Array([1, 2, 3]); + const encoded = cborEncode(bytes); // 0x43 = byte string of length 3 - assert.deepStrictEqual(encoded, new Uint8Array([0x43, 1, 2, 3])) - }) + assert.deepStrictEqual(encoded, new Uint8Array([0x43, 1, 2, 3])); + }); test('encodes arrays', () => { - const encoded = cborEncode([1, 2, 3]) + const encoded = cborEncode([1, 2, 3]); // 0x83 = array of length 3 - assert.deepStrictEqual(encoded, new Uint8Array([0x83, 0x01, 0x02, 0x03])) - }) + assert.deepStrictEqual(encoded, new Uint8Array([0x83, 0x01, 0x02, 0x03])); + }); test('sorts map keys deterministically', () => { - const encoded1 = cborEncode({ z: 1, a: 2 }) - const encoded2 = cborEncode({ a: 2, z: 1 }) - assert.deepStrictEqual(encoded1, encoded2) + const encoded1 = cborEncode({ z: 1, a: 2 }); + const encoded2 = cborEncode({ a: 2, z: 1 }); + assert.deepStrictEqual(encoded1, encoded2); // First key should be 'a' (0x61) - assert.strictEqual(encoded1[1], 0x61) - }) + assert.strictEqual(encoded1[1], 0x61); + }); test('encodes large integers >= 2^31 without overflow', () => { // 2^31 would overflow with bitshift operators (treated as signed 32-bit) - const twoTo31 = 2147483648 - const encoded = cborEncode(twoTo31) - const decoded = cborDecode(encoded) - assert.strictEqual(decoded, twoTo31) + const twoTo31 = 2147483648; + const encoded = cborEncode(twoTo31); + const decoded = cborDecode(encoded); + assert.strictEqual(decoded, twoTo31); // 2^32 - 1 (max unsigned 32-bit) - const maxU32 = 4294967295 - const encoded2 = cborEncode(maxU32) - const decoded2 = cborDecode(encoded2) - assert.strictEqual(decoded2, maxU32) - }) + const maxU32 = 4294967295; + const encoded2 = cborEncode(maxU32); + const decoded2 = cborDecode(encoded2); + assert.strictEqual(decoded2, maxU32); + }); test('encodes 2^31 with correct byte format', () => { // 2147483648 = 0x80000000 // CBOR: major type 0 (unsigned int), additional info 26 (4-byte follows) - const encoded = cborEncode(2147483648) - assert.strictEqual(encoded[0], 0x1a) // type 0 | info 26 - assert.strictEqual(encoded[1], 0x80) - assert.strictEqual(encoded[2], 0x00) - assert.strictEqual(encoded[3], 0x00) - assert.strictEqual(encoded[4], 0x00) - }) -}) + const encoded = cborEncode(2147483648); + assert.strictEqual(encoded[0], 0x1a); // type 0 | info 26 + assert.strictEqual(encoded[1], 0x80); + assert.strictEqual(encoded[2], 0x00); + assert.strictEqual(encoded[3], 0x00); + assert.strictEqual(encoded[4], 0x00); + }); +}); describe('Base32 Encoding', () => { test('encodes bytes to base32lower', () => { - const bytes = new Uint8Array([0x01, 0x71, 0x12, 0x20]) - const encoded = base32Encode(bytes) - assert.strictEqual(typeof encoded, 'string') - assert.match(encoded, /^[a-z2-7]+$/) - }) -}) + const bytes = new Uint8Array([0x01, 0x71, 0x12, 0x20]); + const encoded = base32Encode(bytes); + assert.strictEqual(typeof encoded, 'string'); + assert.match(encoded, /^[a-z2-7]+$/); + }); +}); describe('CID Generation', () => { test('creates CIDv1 with dag-cbor codec', async () => { - const data = cborEncode({ test: 'data' }) - const cid = await createCid(data) + const data = cborEncode({ test: 'data' }); + const cid = await createCid(data); - assert.strictEqual(cid.length, 36) // 2 prefix + 2 multihash header + 32 hash - assert.strictEqual(cid[0], 0x01) // CIDv1 - assert.strictEqual(cid[1], 0x71) // dag-cbor - assert.strictEqual(cid[2], 0x12) // sha-256 - assert.strictEqual(cid[3], 0x20) // 32 bytes - }) + assert.strictEqual(cid.length, 36); // 2 prefix + 2 multihash header + 32 hash + assert.strictEqual(cid[0], 0x01); // CIDv1 + assert.strictEqual(cid[1], 0x71); // dag-cbor + assert.strictEqual(cid[2], 0x12); // sha-256 + assert.strictEqual(cid[3], 0x20); // 32 bytes + }); test('cidToString returns base32lower with b prefix', async () => { - const data = cborEncode({ test: 'data' }) - const cid = await createCid(data) - const cidStr = cidToString(cid) + const data = cborEncode({ test: 'data' }); + const cid = await createCid(data); + const cidStr = cidToString(cid); - assert.strictEqual(cidStr[0], 'b') - assert.match(cidStr, /^b[a-z2-7]+$/) - }) + assert.strictEqual(cidStr[0], 'b'); + assert.match(cidStr, /^b[a-z2-7]+$/); + }); test('same input produces same CID', async () => { - const data1 = cborEncode({ test: 'data' }) - const data2 = cborEncode({ test: 'data' }) - const cid1 = cidToString(await createCid(data1)) - const cid2 = cidToString(await createCid(data2)) + const data1 = cborEncode({ test: 'data' }); + const data2 = cborEncode({ test: 'data' }); + const cid1 = cidToString(await createCid(data1)); + const cid2 = cidToString(await createCid(data2)); - assert.strictEqual(cid1, cid2) - }) + assert.strictEqual(cid1, cid2); + }); test('different input produces different CID', async () => { - const cid1 = cidToString(await createCid(cborEncode({ a: 1 }))) - const cid2 = cidToString(await createCid(cborEncode({ a: 2 }))) + const cid1 = cidToString(await createCid(cborEncode({ a: 1 }))); + const cid2 = cidToString(await createCid(cborEncode({ a: 2 }))); - assert.notStrictEqual(cid1, cid2) - }) -}) + assert.notStrictEqual(cid1, cid2); + }); +}); describe('TID Generation', () => { test('creates 13-character TIDs', () => { - const tid = createTid() - assert.strictEqual(tid.length, 13) - }) + const tid = createTid(); + assert.strictEqual(tid.length, 13); + }); test('uses valid base32-sort characters', () => { - const tid = createTid() - assert.match(tid, /^[234567abcdefghijklmnopqrstuvwxyz]+$/) - }) + const tid = createTid(); + assert.match(tid, /^[234567abcdefghijklmnopqrstuvwxyz]+$/); + }); test('generates monotonically increasing TIDs', () => { - const tid1 = createTid() - const tid2 = createTid() - const tid3 = createTid() + const tid1 = createTid(); + const tid2 = createTid(); + const tid3 = createTid(); - assert.ok(tid1 < tid2, `${tid1} should be less than ${tid2}`) - assert.ok(tid2 < tid3, `${tid2} should be less than ${tid3}`) - }) + assert.ok(tid1 < tid2, `${tid1} should be less than ${tid2}`); + assert.ok(tid2 < tid3, `${tid2} should be less than ${tid3}`); + }); test('generates unique TIDs', () => { - const tids = new Set() + const tids = new Set(); for (let i = 0; i < 100; i++) { - tids.add(createTid()) + tids.add(createTid()); } - assert.strictEqual(tids.size, 100) - }) -}) + assert.strictEqual(tids.size, 100); + }); +}); describe('P-256 Signing', () => { test('generates key pair with correct sizes', async () => { - const kp = await generateKeyPair() + const kp = await generateKeyPair(); - assert.strictEqual(kp.privateKey.length, 32) - assert.strictEqual(kp.publicKey.length, 33) // compressed - assert.ok(kp.publicKey[0] === 0x02 || kp.publicKey[0] === 0x03) - }) + assert.strictEqual(kp.privateKey.length, 32); + assert.strictEqual(kp.publicKey.length, 33); // compressed + assert.ok(kp.publicKey[0] === 0x02 || kp.publicKey[0] === 0x03); + }); test('can sign data with generated key', async () => { - const kp = await generateKeyPair() - const key = await importPrivateKey(kp.privateKey) - const data = new TextEncoder().encode('test message') - const sig = await sign(key, data) + const kp = await generateKeyPair(); + const key = await importPrivateKey(kp.privateKey); + const data = new TextEncoder().encode('test message'); + const sig = await sign(key, data); - assert.strictEqual(sig.length, 64) // r (32) + s (32) - }) + assert.strictEqual(sig.length, 64); // r (32) + s (32) + }); test('different messages produce different signatures', async () => { - const kp = await generateKeyPair() - const key = await importPrivateKey(kp.privateKey) + const kp = await generateKeyPair(); + const key = await importPrivateKey(kp.privateKey); - const sig1 = await sign(key, new TextEncoder().encode('message 1')) - const sig2 = await sign(key, new TextEncoder().encode('message 2')) + const sig1 = await sign(key, new TextEncoder().encode('message 1')); + const sig2 = await sign(key, new TextEncoder().encode('message 2')); - assert.notDeepStrictEqual(sig1, sig2) - }) + assert.notDeepStrictEqual(sig1, sig2); + }); test('bytesToHex and hexToBytes roundtrip', () => { - const original = new Uint8Array([0x00, 0x0f, 0xf0, 0xff, 0xab, 0xcd]) - const hex = bytesToHex(original) - const back = hexToBytes(hex) + const original = new Uint8Array([0x00, 0x0f, 0xf0, 0xff, 0xab, 0xcd]); + const hex = bytesToHex(original); + const back = hexToBytes(hex); - assert.strictEqual(hex, '000ff0ffabcd') - assert.deepStrictEqual(back, original) - }) + assert.strictEqual(hex, '000ff0ffabcd'); + assert.deepStrictEqual(back, original); + }); test('importPrivateKey rejects invalid key lengths', async () => { // Too short await assert.rejects( () => importPrivateKey(new Uint8Array(31)), - /expected 32 bytes, got 31/ - ) + /expected 32 bytes, got 31/, + ); // Too long await assert.rejects( () => importPrivateKey(new Uint8Array(33)), - /expected 32 bytes, got 33/ - ) + /expected 32 bytes, got 33/, + ); // Empty await assert.rejects( () => importPrivateKey(new Uint8Array(0)), - /expected 32 bytes, got 0/ - ) - }) + /expected 32 bytes, got 0/, + ); + }); test('importPrivateKey rejects non-Uint8Array input', async () => { // Arrays have .length but aren't Uint8Array await assert.rejects( () => importPrivateKey([1, 2, 3]), - /Invalid private key/ - ) + /Invalid private key/, + ); // Strings don't work either await assert.rejects( () => importPrivateKey('not bytes'), - /Invalid private key/ - ) + /Invalid private key/, + ); // null/undefined - await assert.rejects( - () => importPrivateKey(null), - /Invalid private key/ - ) - }) -}) + await assert.rejects(() => importPrivateKey(null), /Invalid private key/); + }); +}); describe('MST Key Depth', () => { test('returns a non-negative integer', async () => { - const depth = await getKeyDepth('app.bsky.feed.post/abc123') - assert.strictEqual(typeof depth, 'number') - assert.ok(depth >= 0) - }) + const depth = await getKeyDepth('app.bsky.feed.post/abc123'); + assert.strictEqual(typeof depth, 'number'); + assert.ok(depth >= 0); + }); test('is deterministic for same key', async () => { - const key = 'app.bsky.feed.post/test123' - const depth1 = await getKeyDepth(key) - const depth2 = await getKeyDepth(key) - assert.strictEqual(depth1, depth2) - }) + const key = 'app.bsky.feed.post/test123'; + const depth1 = await getKeyDepth(key); + const depth2 = await getKeyDepth(key); + assert.strictEqual(depth1, depth2); + }); test('different keys can have different depths', async () => { // Generate many keys and check we get some variation - const depths = new Set() + const depths = new Set(); for (let i = 0; i < 100; i++) { - depths.add(await getKeyDepth(`collection/key${i}`)) + depths.add(await getKeyDepth(`collection/key${i}`)); } // Should have at least 1 unique depth (realistically more) - assert.ok(depths.size >= 1) - }) + assert.ok(depths.size >= 1); + }); test('handles empty string', async () => { - const depth = await getKeyDepth('') - assert.strictEqual(typeof depth, 'number') - assert.ok(depth >= 0) - }) + const depth = await getKeyDepth(''); + assert.strictEqual(typeof depth, 'number'); + assert.ok(depth >= 0); + }); test('handles unicode strings', async () => { - const depth = await getKeyDepth('app.bsky.feed.post/émoji🎉') - assert.strictEqual(typeof depth, 'number') - assert.ok(depth >= 0) - }) -}) + const depth = await getKeyDepth('app.bsky.feed.post/émoji🎉'); + assert.strictEqual(typeof depth, 'number'); + assert.ok(depth >= 0); + }); +}); describe('CBOR Decoding', () => { test('decodes what encode produces (roundtrip)', () => { - const original = { hello: 'world', num: 42 } - const encoded = cborEncode(original) - const decoded = cborDecode(encoded) - assert.deepStrictEqual(decoded, original) - }) + const original = { hello: 'world', num: 42 }; + const encoded = cborEncode(original); + const decoded = cborDecode(encoded); + assert.deepStrictEqual(decoded, original); + }); test('decodes null', () => { - const encoded = cborEncode(null) - const decoded = cborDecode(encoded) - assert.strictEqual(decoded, null) - }) + const encoded = cborEncode(null); + const decoded = cborDecode(encoded); + assert.strictEqual(decoded, null); + }); test('decodes booleans', () => { - assert.strictEqual(cborDecode(cborEncode(true)), true) - assert.strictEqual(cborDecode(cborEncode(false)), false) - }) + assert.strictEqual(cborDecode(cborEncode(true)), true); + assert.strictEqual(cborDecode(cborEncode(false)), false); + }); test('decodes integers', () => { - assert.strictEqual(cborDecode(cborEncode(0)), 0) - assert.strictEqual(cborDecode(cborEncode(42)), 42) - assert.strictEqual(cborDecode(cborEncode(255)), 255) - assert.strictEqual(cborDecode(cborEncode(-1)), -1) - assert.strictEqual(cborDecode(cborEncode(-10)), -10) - }) + assert.strictEqual(cborDecode(cborEncode(0)), 0); + assert.strictEqual(cborDecode(cborEncode(42)), 42); + assert.strictEqual(cborDecode(cborEncode(255)), 255); + assert.strictEqual(cborDecode(cborEncode(-1)), -1); + assert.strictEqual(cborDecode(cborEncode(-10)), -10); + }); test('decodes strings', () => { - assert.strictEqual(cborDecode(cborEncode('hello')), 'hello') - assert.strictEqual(cborDecode(cborEncode('')), '') - }) + assert.strictEqual(cborDecode(cborEncode('hello')), 'hello'); + assert.strictEqual(cborDecode(cborEncode('')), ''); + }); test('decodes arrays', () => { - assert.deepStrictEqual(cborDecode(cborEncode([1, 2, 3])), [1, 2, 3]) - assert.deepStrictEqual(cborDecode(cborEncode([])), []) - }) + assert.deepStrictEqual(cborDecode(cborEncode([1, 2, 3])), [1, 2, 3]); + assert.deepStrictEqual(cborDecode(cborEncode([])), []); + }); test('decodes nested structures', () => { - const original = { arr: [1, { nested: true }], str: 'test' } - const decoded = cborDecode(cborEncode(original)) - assert.deepStrictEqual(decoded, original) - }) -}) + const original = { arr: [1, { nested: true }], str: 'test' }; + const decoded = cborDecode(cborEncode(original)); + assert.deepStrictEqual(decoded, original); + }); +}); describe('CAR File Builder', () => { test('varint encodes small numbers', () => { - assert.deepStrictEqual(varint(0), new Uint8Array([0])) - assert.deepStrictEqual(varint(1), new Uint8Array([1])) - assert.deepStrictEqual(varint(127), new Uint8Array([127])) - }) + assert.deepStrictEqual(varint(0), new Uint8Array([0])); + assert.deepStrictEqual(varint(1), new Uint8Array([1])); + assert.deepStrictEqual(varint(127), new Uint8Array([127])); + }); test('varint encodes multi-byte numbers', () => { // 128 = 0x80 -> [0x80 | 0x00, 0x01] = [0x80, 0x01] - assert.deepStrictEqual(varint(128), new Uint8Array([0x80, 0x01])) + assert.deepStrictEqual(varint(128), new Uint8Array([0x80, 0x01])); // 300 = 0x12c -> [0xac, 0x02] - assert.deepStrictEqual(varint(300), new Uint8Array([0xac, 0x02])) - }) + assert.deepStrictEqual(varint(300), new Uint8Array([0xac, 0x02])); + }); test('base32 encode/decode roundtrip', () => { - const original = new Uint8Array([0x01, 0x71, 0x12, 0x20, 0xab, 0xcd]) - const encoded = base32Encode(original) - const decoded = base32Decode(encoded) - assert.deepStrictEqual(decoded, original) - }) + const original = new Uint8Array([0x01, 0x71, 0x12, 0x20, 0xab, 0xcd]); + const encoded = base32Encode(original); + const decoded = base32Decode(encoded); + assert.deepStrictEqual(decoded, original); + }); test('buildCarFile produces valid structure', async () => { - const data = cborEncode({ test: 'data' }) - const cid = await createCid(data) - const cidStr = cidToString(cid) + const data = cborEncode({ test: 'data' }); + const cid = await createCid(data); + const cidStr = cidToString(cid); - const car = buildCarFile(cidStr, [{ cid: cidStr, data }]) + const car = buildCarFile(cidStr, [{ cid: cidStr, data }]); - assert.ok(car instanceof Uint8Array) - assert.ok(car.length > 0) + assert.ok(car instanceof Uint8Array); + assert.ok(car.length > 0); // First byte should be varint of header length - assert.ok(car[0] > 0) - }) -}) + assert.ok(car[0] > 0); + }); +}); describe('JWT Base64URL', () => { test('base64UrlEncode encodes bytes correctly', () => { - const input = new TextEncoder().encode('hello world') - const encoded = base64UrlEncode(input) - assert.strictEqual(encoded, 'aGVsbG8gd29ybGQ') - assert.ok(!encoded.includes('+')) - assert.ok(!encoded.includes('/')) - assert.ok(!encoded.includes('=')) - }) + const input = new TextEncoder().encode('hello world'); + const encoded = base64UrlEncode(input); + assert.strictEqual(encoded, 'aGVsbG8gd29ybGQ'); + assert.ok(!encoded.includes('+')); + assert.ok(!encoded.includes('/')); + assert.ok(!encoded.includes('=')); + }); test('base64UrlDecode decodes string correctly', () => { - const decoded = base64UrlDecode('aGVsbG8gd29ybGQ') - const str = new TextDecoder().decode(decoded) - assert.strictEqual(str, 'hello world') - }) + const decoded = base64UrlDecode('aGVsbG8gd29ybGQ'); + const str = new TextDecoder().decode(decoded); + assert.strictEqual(str, 'hello world'); + }); test('base64url roundtrip', () => { - const original = new Uint8Array([0, 1, 2, 255, 254, 253]) - const encoded = base64UrlEncode(original) - const decoded = base64UrlDecode(encoded) - assert.deepStrictEqual(decoded, original) - }) -}) + const original = new Uint8Array([0, 1, 2, 255, 254, 253]); + const encoded = base64UrlEncode(original); + const decoded = base64UrlDecode(encoded); + assert.deepStrictEqual(decoded, original); + }); +}); describe('JWT Creation', () => { test('createAccessJwt creates valid JWT structure', async () => { - const did = 'did:web:test.example' - const secret = 'test-secret-key' - const jwt = await createAccessJwt(did, secret) + const did = 'did:web:test.example'; + const secret = 'test-secret-key'; + const jwt = await createAccessJwt(did, secret); - const parts = jwt.split('.') - assert.strictEqual(parts.length, 3) + const parts = jwt.split('.'); + assert.strictEqual(parts.length, 3); // Decode header - const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[0]))) - assert.strictEqual(header.typ, 'at+jwt') - assert.strictEqual(header.alg, 'HS256') + const header = JSON.parse( + new TextDecoder().decode(base64UrlDecode(parts[0])), + ); + assert.strictEqual(header.typ, 'at+jwt'); + assert.strictEqual(header.alg, 'HS256'); // Decode payload - const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[1]))) - assert.strictEqual(payload.scope, 'com.atproto.access') - assert.strictEqual(payload.sub, did) - assert.strictEqual(payload.aud, did) - assert.ok(payload.iat > 0) - assert.ok(payload.exp > payload.iat) - }) + const payload = JSON.parse( + new TextDecoder().decode(base64UrlDecode(parts[1])), + ); + assert.strictEqual(payload.scope, 'com.atproto.access'); + assert.strictEqual(payload.sub, did); + assert.strictEqual(payload.aud, did); + assert.ok(payload.iat > 0); + assert.ok(payload.exp > payload.iat); + }); test('createRefreshJwt creates valid JWT with jti', async () => { - const did = 'did:web:test.example' - const secret = 'test-secret-key' - const jwt = await createRefreshJwt(did, secret) - - const parts = jwt.split('.') - const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[0]))) - assert.strictEqual(header.typ, 'refresh+jwt') - - const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[1]))) - assert.strictEqual(payload.scope, 'com.atproto.refresh') - assert.ok(payload.jti) // has unique token ID - }) -}) + const did = 'did:web:test.example'; + const secret = 'test-secret-key'; + const jwt = await createRefreshJwt(did, secret); + + const parts = jwt.split('.'); + const header = JSON.parse( + new TextDecoder().decode(base64UrlDecode(parts[0])), + ); + assert.strictEqual(header.typ, 'refresh+jwt'); + + const payload = JSON.parse( + new TextDecoder().decode(base64UrlDecode(parts[1])), + ); + assert.strictEqual(payload.scope, 'com.atproto.refresh'); + assert.ok(payload.jti); // has unique token ID + }); +}); describe('JWT Verification', () => { test('verifyAccessJwt returns payload for valid token', async () => { - const did = 'did:web:test.example' - const secret = 'test-secret-key' - const jwt = await createAccessJwt(did, secret) + const did = 'did:web:test.example'; + const secret = 'test-secret-key'; + const jwt = await createAccessJwt(did, secret); - const payload = await verifyAccessJwt(jwt, secret) - assert.strictEqual(payload.sub, did) - assert.strictEqual(payload.scope, 'com.atproto.access') - }) + const payload = await verifyAccessJwt(jwt, secret); + assert.strictEqual(payload.sub, did); + assert.strictEqual(payload.scope, 'com.atproto.access'); + }); test('verifyAccessJwt throws for wrong secret', async () => { - const did = 'did:web:test.example' - const jwt = await createAccessJwt(did, 'correct-secret') + const did = 'did:web:test.example'; + const jwt = await createAccessJwt(did, 'correct-secret'); await assert.rejects( () => verifyAccessJwt(jwt, 'wrong-secret'), - /invalid signature/i - ) - }) + /invalid signature/i, + ); + }); test('verifyAccessJwt throws for expired token', async () => { - const did = 'did:web:test.example' - const secret = 'test-secret-key' + const did = 'did:web:test.example'; + const secret = 'test-secret-key'; // Create token that expired 1 second ago - const jwt = await createAccessJwt(did, secret, -1) + const jwt = await createAccessJwt(did, secret, -1); - await assert.rejects( - () => verifyAccessJwt(jwt, secret), - /expired/i - ) - }) + await assert.rejects(() => verifyAccessJwt(jwt, secret), /expired/i); + }); test('verifyAccessJwt throws for refresh token', async () => { - const did = 'did:web:test.example' - const secret = 'test-secret-key' - const jwt = await createRefreshJwt(did, secret) + const did = 'did:web:test.example'; + const secret = 'test-secret-key'; + const jwt = await createRefreshJwt(did, secret); await assert.rejects( () => verifyAccessJwt(jwt, secret), - /invalid token type/i - ) - }) -}) + /invalid token type/i, + ); + }); +});