| … | … | @@ -0,0 +1,90 @@ |
| 1 | // DidPeerRouter regression — multi-DID reading over ONE shared swarm. Two holders serve real signed |
| 2 | // repos (RepoManager + FirehoseSequencer) on an in-process testnet; a reader must learn BOTH. |
| 3 | // CONTROL: per-DidPeer attach() collides on the single announce channel -> only the FIRST-added DID |
| 4 | // reads; the second never learns its cores (the bug). |
| 5 | // FIX: DidPeerRouter dispatches each announce by DID -> BOTH read. |
| 6 | // Run: node test/router.test.mjs |
| 7 | import { mkdtempSync, rmSync } from 'node:fs' |
| 8 | import { join } from 'node:path' |
| 9 | import { tmpdir } from 'node:os' |
| 10 | import createTestnet from '@hyperswarm/testnet' |
| 11 | import Hyperswarm from 'hyperswarm' |
| 12 | import { RepoStore } from 'atproto-store/lib/repo-store.js' |
| 13 | import { RepoManager } from 'atproto-store/lib/repo-manager.js' |
| 14 | import { FirehoseSequencer } from 'atproto-store/lib/sequencer.js' |
| 15 | import { openCorestore } from 'atproto-store/lib/corestore.js' |
| 16 | import { createAtprotoIdentityFromKey } from 'atproto-identity/lib/atproto-identity.js' |
| 17 | import { discoveryTopic } from 'atproto-identity/lib/discovery.js' |
| 18 | import { DidPeer, DidPeerRouter } from '../index.js' |
| 19 | |
| 20 | const assert = (c, m) => { if (!c) { console.error('FAIL:', m); cleanupSync(); process.exit(1) } console.log(' ok -', m) } |
| 21 | const iso = '2026-07-01T00:00:00.000Z' |
| 22 | const dirs = [] |
| 23 | const toClose = [] |
| 24 | function newDir (p) { const d = mkdtempSync(join(tmpdir(), p)); dirs.push(d); return d } |
| 25 | function cleanupSync () { for (const d of dirs) { try { rmSync(d, { recursive: true, force: true }) } catch {} } } |
| 26 | |
| 27 | const idA = createAtprotoIdentityFromKey(new Uint8Array(32).fill(9), { handle: 'a.test', pdsUrl: 'https://pds.local' }) |
| 28 | const idB = createAtprotoIdentityFromKey(new Uint8Array(32).fill(11), { handle: 'b.test', pdsUrl: 'https://pds.local' }) |
| 29 | |
| 30 | const testnet = await createTestnet(3) |
| 31 | const bootstrap = testnet.bootstrap |
| 32 | |
| 33 | // A holder authors a signed repo + sequences its commit, then serves the cores on its own swarm. |
| 34 | async function makeHolder (id, text) { |
| 35 | const store = openCorestore(newDir('holder-')) |
| 36 | const repoStore = await new RepoStore(store, { name: `atproto-repo:${id.did}` }).ready() |
| 37 | let micros = 1_700_000_000_000_000n |
| 38 | const mgr = await new RepoManager(repoStore, { did: id.did, privateKey: id.privateKey, clock: () => (micros += 1_000_000n) }).init() |
| 39 | const write = await mgr.applyWrite({ action: 'create', collection: 'app.bsky.feed.post', rkey: '1', record: { $type: 'app.bsky.feed.post', text, createdAt: iso } }) |
| 40 | const seq = await new FirehoseSequencer(store.get({ name: 'firehose' })).ready() |
| 41 | await seq.sequenceCommit(write, { time: iso }) |
| 42 | const peer = new DidPeer(store, { did: id.did, verifyKey: id.publicKey }) |
| 43 | await peer.provide({ repoKey: repoStore.core.key, firehoseKey: seq.core.key }) |
| 44 | const swarm = new Hyperswarm({ bootstrap }) |
| 45 | swarm.on('connection', (c) => peer.attach(c)) |
| 46 | await swarm.join(discoveryTopic(id.did), { server: true, client: true }).flushed() |
| 47 | toClose.push(async () => { try { await swarm.destroy() } catch {}; try { await peer.close() } catch {}; try { await store.close() } catch {} }) |
| 48 | return peer |
| 49 | } |
| 50 | |
| 51 | const holderA = await makeHolder(idA, 'hello from A') |
| 52 | const holderB = await makeHolder(idB, 'hello from B') |
| 53 | console.log('holders serving:', idA.did.slice(0, 20), '+', idB.did.slice(0, 20)) |
| 54 | |
| 55 | // --- CONTROL: per-peer attach() — only the FIRST-added DID reads --- |
| 56 | { |
| 57 | const store = openCorestore(newDir('ctrl-')) |
| 58 | const swarm = new Hyperswarm({ bootstrap }) |
| 59 | const cA = new DidPeer(store, { did: idA.did, verifyKey: idA.publicKey }) |
| 60 | const cB = new DidPeer(store, { did: idB.did, verifyKey: idB.publicKey }) |
| 61 | swarm.on('connection', (c) => cA.attach(c)) // cA registered FIRST -> grabs the channel on every conn |
| 62 | swarm.on('connection', (c) => cB.attach(c)) |
| 63 | await swarm.join(discoveryTopic(idA.did), { server: true, client: true }).flushed() |
| 64 | await swarm.join(discoveryTopic(idB.did), { server: true, client: true }).flushed() |
| 65 | const [chA, chB] = await Promise.all([cA.ready({ timeout: 15000 }), cB.ready({ timeout: 15000 })]) |
| 66 | assert(!!chA, 'CONTROL: the first-added DID (A) reads') |
| 67 | assert(!chB, 'CONTROL: per-peer attach() — the SECOND DID (B) never reads (reproduces the bug)') |
| 68 | await swarm.destroy(); await cA.close(); await cB.close(); await store.close() |
| 69 | } |
| 70 | |
| 71 | // --- FIX: DidPeerRouter dispatches by DID — BOTH read --- |
| 72 | { |
| 73 | const store = openCorestore(newDir('router-')) |
| 74 | const swarm = new Hyperswarm({ bootstrap }) |
| 75 | const router = new DidPeerRouter(store) |
| 76 | swarm.on('connection', (c) => router.onconnection(c)) |
| 77 | const pA = router.add(idA.did, { verifyKey: idA.publicKey }) |
| 78 | const pB = router.add(idB.did, { verifyKey: idB.publicKey }) |
| 79 | await swarm.join(discoveryTopic(idA.did), { server: true, client: true }).flushed() |
| 80 | await swarm.join(discoveryTopic(idB.did), { server: true, client: true }).flushed() |
| 81 | const [hA, hB] = await Promise.all([pA.ready({ timeout: 20000 }), pB.ready({ timeout: 20000 })]) |
| 82 | assert(!!hA, 'ROUTER: DID A reaches a verified head') |
| 83 | assert(!!hB, 'ROUTER: DID B reaches a verified head (both members read — the fix)') |
| 84 | await swarm.destroy(); await router.close(); await store.close() |
| 85 | } |
| 86 | |
| 87 | console.log('\nROUTER OK: per-peer attach reads only the first DID; DidPeerRouter reads BOTH') |
| 88 | for (const close of toClose) await close() |
| 89 | await testnet.destroy() |
| 90 | cleanupSync() |