// Full 2-node multi-DID flow (the real deployment topology, offline).
//
// Two multi-DID hpp daemons (tabertron + ashatron analogs), each on ONE shared Corestore/swarm/wakeup:
// node1 hosts: DID-A (its own) + DID-C (co-owned "loser-club", node1's admin device)
// node2 hosts: DID-B (its own) + DID-C (co-owned "loser-club", node2's admin device)
// DID-C is CO-OWNED: one community root + shared #atproto key, two admin devices (one per node), a
// two-member roster. Proves: the co-owned DID converges bidirectionally across nodes (the non-creator
// node's device is ADMITTED and co-writes), each node's OWN DID stays isolated to that node, and each
// node holds exactly ONE storage lock for both of its DIDs. This is exactly what deploying loser-club
// into wj62's + ashatron's multi-DID hpps must do — validated before touching prod.
import '../lib/bare-globals.js'
import test from 'brittle'
import fs from 'bare-fs'
import b4a from 'b4a'
import { join } from 'bare-path'
import createTestnet from '@hyperswarm/testnet'
import { bootMultiDevice, provisionDevice } from '../lib/device-node.js'
import { deviceKeetKeyPair } from 'atproto-room/lib/membership.js'
// 7 distinct, valid BIP39 mnemonics (Trezor test vectors).
const M = {
rootA: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
deviceA: 'legal winner thank year wave sausage worth useful legal winner thank yellow',
rootB: 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong',
deviceB: 'letter advice cage absurd amount doctor acoustic avoid letter advice cage above',
communityRoot: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art',
device1: 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote',
device2: 'legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will'
}
const tmp = () => { const d = '/tmp/hpp-2node-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
async function bundle (rootMnem, devMnem, handle) {
const dk = await deviceKeetKeyPair(devMnem)
const b = await provisionDevice({ rootMnemonic: rootMnem, devicePublicKey: dk.publicKey, handle, pdsUrl: 'https://pds.local' })
return { entry: { deviceMnemonic: devMnem, did: b.did, signingKey: b.signingKey, deviceProof: b.deviceProof, rootIdentityKey: b.rootIdentityKey, handle }, keetPub: b4a.toString(dk.publicKey, 'hex'), did: b.did }
}
test('full 2-node flow: co-owned DID converges + admits across nodes; own DIDs isolated; one lock/node', { timeout: 180000 }, async (t) => {
const testnet = await createTestnet(3)
const swarmOpts = { bootstrap: testnet.bootstrap }
const s1 = tmp(); const s2 = tmp()
const A = await bundle(M.rootA, M.deviceA, 'a.local') // node1's own DID
const B = await bundle(M.rootB, M.deviceB, 'b.local') // node2's own DID
const C1 = await bundle(M.communityRoot, M.device1, 'loser-club.local') // co-owned, node1's device
const C2 = await bundle(M.communityRoot, M.device2, 'loser-club.local') // co-owned, node2's device
t.is(C1.did, C2.did, 'DID-C is co-owned — same DID on both nodes')
const membersC = [{ key: C1.keetPub, role: 1 }, { key: C2.keetPub, role: 1 }] // both admins
C1.entry.members = membersC; C2.entry.members = membersC
const n1 = await bootMultiDevice({ storage: s1, dids: [A.entry, C1.entry], swarmOpts, signIntervalMs: 1000 })
const n2 = await bootMultiDevice({ storage: s2, dids: [B.entry, C2.entry], swarmOpts, signIntervalMs: 1000 })
t.teardown(async () => { try { await n1.close() } catch {} ; try { await n2.close() } catch {} ; await testnet.destroy() })
t.is(n1.nodes.length, 2, 'node1 daemon serves 2 DIDs')
t.is(n2.nodes.length, 2, 'node2 daemon serves 2 DIDs')
const c1 = n1.byDid.get(C1.did) // node1's view of the co-owned DID
const c2 = n2.byDid.get(C2.did) // node2's view of the co-owned DID
// A write from each node's co-owned device. The non-creator node's write drives its admission
// (the doorbell) before it commits; the creator's write is immediate.
await c1.device.applyWrite({ action: 'create', collection: 'app.bsky.feed.post', rkey: 'from-node1', record: { $type: 'app.bsky.feed.post', text: 'node1 post', createdAt: new Date().toISOString() } })
await c2.device.applyWrite({ action: 'create', collection: 'app.bsky.feed.post', rkey: 'from-node2', record: { $type: 'app.bsky.feed.post', text: 'node2 post', createdAt: new Date().toISOString() } })
for (let i = 0; i < 30; i++) { await c1.sb.base.update().catch(() => {}); await c2.sb.base.update().catch(() => {}); await sleep(400) }
t.ok(c1.sb.base.writable && c2.sb.base.writable, 'both co-owner devices writable (creator + admitted member)')
const st1 = await c1.device.state(); const st2 = await c2.device.state()
const has = (st, rk) => st.records.some(r => r.rkey === rk)
t.ok(has(st1, 'from-node1') && has(st1, 'from-node2'), 'node1 co-owned DID converged BOTH nodes\' posts')
t.ok(has(st2, 'from-node1') && has(st2, 'from-node2'), 'node2 co-owned DID converged BOTH nodes\' posts')
// each node's OWN DID is isolated to that node
t.absent(n1.byDid.has(B.did), 'node1 does NOT host node2\'s own DID')
t.absent(n2.byDid.has(A.did), 'node2 does NOT host node1\'s own DID')
t.is((await n1.byDid.get(A.did).device.state()).did, A.did, 'node1 serves its own DID-A')
t.is((await n2.byDid.get(B.did).device.state()).did, B.did, 'node2 serves its own DID-B')
// one storage lock per node (both DIDs)
t.is(fs.readdirSync(join(s1, 'store')).filter(f => /CORESTORE/i.test(f)).length, 1, 'node1: ONE lock for both DIDs')
t.is(fs.readdirSync(join(s2, 'store')).filter(f => /CORESTORE/i.test(f)).length, 1, 'node2: ONE lock for both DIDs')
})