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