+112
-1
src/pds.js
+112
-1
src/pds.js
···
224
224
return bytes
225
225
}
226
226
227
+
// === MERKLE SEARCH TREE ===
228
+
// Simple rebuild-on-write implementation
229
+
230
+
async function sha256(data) {
231
+
const hash = await crypto.subtle.digest('SHA-256', data)
232
+
return new Uint8Array(hash)
233
+
}
234
+
235
+
function getKeyDepth(key) {
236
+
// Count leading zeros in hash to determine tree depth
237
+
const keyBytes = new TextEncoder().encode(key)
238
+
// Sync hash for depth calculation (use first bytes of key as proxy)
239
+
let zeros = 0
240
+
for (const byte of keyBytes) {
241
+
if (byte === 0) zeros += 8
242
+
else {
243
+
for (let i = 7; i >= 0; i--) {
244
+
if ((byte >> i) & 1) break
245
+
zeros++
246
+
}
247
+
break
248
+
}
249
+
}
250
+
return Math.floor(zeros / 4)
251
+
}
252
+
253
+
class MST {
254
+
constructor(sql) {
255
+
this.sql = sql
256
+
}
257
+
258
+
async computeRoot() {
259
+
const records = this.sql.exec(`
260
+
SELECT collection, rkey, cid FROM records ORDER BY collection, rkey
261
+
`).toArray()
262
+
263
+
if (records.length === 0) {
264
+
return null
265
+
}
266
+
267
+
const entries = records.map(r => ({
268
+
key: `${r.collection}/${r.rkey}`,
269
+
cid: r.cid
270
+
}))
271
+
272
+
return this.buildTree(entries, 0)
273
+
}
274
+
275
+
async buildTree(entries, depth) {
276
+
if (entries.length === 0) return null
277
+
278
+
const node = { l: null, e: [] }
279
+
let leftEntries = []
280
+
281
+
for (const entry of entries) {
282
+
const keyDepth = getKeyDepth(entry.key)
283
+
284
+
if (keyDepth > depth) {
285
+
leftEntries.push(entry)
286
+
} else {
287
+
// Store accumulated left entries
288
+
if (leftEntries.length > 0) {
289
+
const leftCid = await this.buildTree(leftEntries, depth + 1)
290
+
if (node.e.length === 0) {
291
+
node.l = leftCid
292
+
} else {
293
+
node.e[node.e.length - 1].t = leftCid
294
+
}
295
+
leftEntries = []
296
+
}
297
+
node.e.push({ k: entry.key, v: entry.cid, t: null })
298
+
}
299
+
}
300
+
301
+
// Handle remaining left entries
302
+
if (leftEntries.length > 0) {
303
+
const leftCid = await this.buildTree(leftEntries, depth + 1)
304
+
if (node.e.length > 0) {
305
+
node.e[node.e.length - 1].t = leftCid
306
+
} else {
307
+
node.l = leftCid
308
+
}
309
+
}
310
+
311
+
// Encode and store node
312
+
const nodeBytes = cborEncode(node)
313
+
const nodeCid = await createCid(nodeBytes)
314
+
const cidStr = cidToString(nodeCid)
315
+
316
+
this.sql.exec(
317
+
`INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`,
318
+
cidStr,
319
+
nodeBytes
320
+
)
321
+
322
+
return cidStr
323
+
}
324
+
}
325
+
227
326
export class PersonalDataServer {
228
327
constructor(state, env) {
229
328
this.state = state
···
314
413
signature: bytesToHex(sig)
315
414
})
316
415
}
416
+
if (url.pathname === '/test/mst') {
417
+
// Insert some test records
418
+
this.sql.exec(`INSERT OR REPLACE INTO records VALUES (?, ?, ?, ?, ?)`,
419
+
'at://did:plc:test/app.bsky.feed.post/abc', 'cid1', 'app.bsky.feed.post', 'abc', new Uint8Array([1]))
420
+
this.sql.exec(`INSERT OR REPLACE INTO records VALUES (?, ?, ?, ?, ?)`,
421
+
'at://did:plc:test/app.bsky.feed.post/def', 'cid2', 'app.bsky.feed.post', 'def', new Uint8Array([2]))
422
+
423
+
const mst = new MST(this.sql)
424
+
const root = await mst.computeRoot()
425
+
return Response.json({ root })
426
+
}
317
427
if (url.pathname === '/init') {
318
428
const body = await request.json()
319
429
if (!body.did || !body.privateKey) {
···
351
461
// Export utilities for testing
352
462
export {
353
463
cborEncode, createCid, cidToString, base32Encode, createTid,
354
-
generateKeyPair, importPrivateKey, sign, bytesToHex, hexToBytes
464
+
generateKeyPair, importPrivateKey, sign, bytesToHex, hexToBytes,
465
+
getKeyDepth
355
466
}
+39
-1
test/pds.test.js
+39
-1
test/pds.test.js
···
2
2
import assert from 'node:assert'
3
3
import {
4
4
cborEncode, createCid, cidToString, base32Encode, createTid,
5
-
generateKeyPair, importPrivateKey, sign, bytesToHex, hexToBytes
5
+
generateKeyPair, importPrivateKey, sign, bytesToHex, hexToBytes,
6
+
getKeyDepth
6
7
} from '../src/pds.js'
7
8
8
9
describe('CBOR Encoding', () => {
···
183
184
assert.deepStrictEqual(back, original)
184
185
})
185
186
})
187
+
188
+
describe('MST Key Depth', () => {
189
+
test('returns a non-negative integer', () => {
190
+
const depth = getKeyDepth('app.bsky.feed.post/abc123')
191
+
assert.strictEqual(typeof depth, 'number')
192
+
assert.ok(depth >= 0)
193
+
})
194
+
195
+
test('is deterministic for same key', () => {
196
+
const key = 'app.bsky.feed.post/test123'
197
+
const depth1 = getKeyDepth(key)
198
+
const depth2 = getKeyDepth(key)
199
+
assert.strictEqual(depth1, depth2)
200
+
})
201
+
202
+
test('different keys can have different depths', () => {
203
+
// Generate many keys and check we get some variation
204
+
const depths = new Set()
205
+
for (let i = 0; i < 100; i++) {
206
+
depths.add(getKeyDepth(`collection/key${i}`))
207
+
}
208
+
// Should have at least 1 unique depth (realistically more)
209
+
assert.ok(depths.size >= 1)
210
+
})
211
+
212
+
test('handles empty string', () => {
213
+
const depth = getKeyDepth('')
214
+
assert.strictEqual(typeof depth, 'number')
215
+
assert.ok(depth >= 0)
216
+
})
217
+
218
+
test('handles unicode strings', () => {
219
+
const depth = getKeyDepth('app.bsky.feed.post/émoji🎉')
220
+
assert.strictEqual(typeof depth, 'number')
221
+
assert.ok(depth >= 0)
222
+
})
223
+
})