// COMPOSED 2-NODE TRANSITION — the clean prod notary-revival sequence, proven in a 2-node setting.
// CRE (lowest key) = the poisoned creator. PEER (healthy member) = revives the notary by rebuilding.
// KEY FINDING (verified in a companion run): if CRE stays on the swarm still serving the OLD genesis core
// while PEER re-authors a fresh one, they COLLIDE (same deterministic key, different content → 3 conflicts,
// finalization blocked). So the prod sequence must RETIRE the old base (CRE fully off the swarm) BEFORE the
// rebuild — then the re-authored genesis is alone on the topic and new posts finalize. This test proves
// that clean path; HA re-add of CRE afterwards (a FRESH device join to the rebuilt base) is a follow-up.
import '../lib/bare-globals.js'
import test from 'brittle'
import fs from 'bare-fs'
import b4a from 'b4a'
import createTestnet from '@hyperswarm/testnet'
import { bootMultiDevice, provisionDevice, resyncDid } from '../lib/device-node.js'
import { deviceKeetKeyPair } from 'atproto-room/lib/membership.js'
const M = {
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-2nodetrans-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
const post = (rk, text) => ({ action: 'create', collection: 'app.bsky.feed.post', rkey: rk, record: { $type: 'app.bsky.feed.post', text, createdAt: new Date().toISOString() } })
const has = (st, rk) => st.records.some(r => r.rkey === rk)
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('2-node transition: retire the old base, then the healthy peer REBUILDS to revive the notary (posts finalize)', { timeout: 360000 }, async (t) => {
const testnet = await createTestnet(3)
const swarmOpts = { bootstrap: testnet.bootstrap }
const sCre = tmp(); const sPeer = tmp()
const d1 = await bundle(M.communityRoot, M.device1, 'club.local')
const d2 = await bundle(M.communityRoot, M.device2, 'club.local')
const creatorLowest = d1.keetPub < d2.keetPub
const CRE = creatorLowest ? d1 : d2
const PEER = creatorLowest ? d2 : d1
const DID = CRE.did
const members = [{ key: d1.keetPub, role: 1 }, { key: d2.keetPub, role: 1 }]
CRE.entry.members = members; PEER.entry.members = members
let nCre = await bootMultiDevice({ storage: sCre, dids: [CRE.entry], swarmOpts, signIntervalMs: 1000 })
let nPeer = await bootMultiDevice({ storage: sPeer, dids: [PEER.entry], swarmOpts, signIntervalMs: 1000 })
let creClosed = false; let peerClosed = false
t.teardown(async () => { if (!creClosed) { try { await nCre.close() } catch {} } ; if (!peerClosed) { try { await nPeer.close() } catch {} } ; await testnet.destroy() })
const cre = () => nCre.byDid.get(DID)
let peer = nPeer.byDid.get(DID)
// ── Phase 1: converge some records ──
await cre().device.applyWrite(post('r-creator', 'from creator'))
let peerWritten = false
for (let i = 0; i < 60 && !peerWritten; i++) { await cre().sb.base.update().catch(() => {}); await peer.sb.base.update().catch(() => {}); try { await peer.device.applyWrite(post('r-peer1', 'from peer')); peerWritten = true } catch { await peer.sb.set('__ping__', { i }).catch(() => {}); await sleep(1000) } }
t.ok(peerWritten, 'phase1: peer admitted + wrote')
for (let i = 0; i < 40; i++) { await cre().sb.base.update().catch(() => {}); await peer.sb.base.update().catch(() => {}); await sleep(400) }
t.ok(has(await peer.device.state(), 'r-creator') && has(await peer.device.state(), 'r-peer1'), 'phase1: peer converged both')
// ── Phase 2: RETIRE the old base — CRE fully LEAVES the swarm (stops hosting the DID). This is
// load-bearing: a companion run confirmed that if CRE stays on the swarm still serving the OLD genesis
// core, the healthy peer's RE-AUTHORED rebuild COLLIDES with it (same deterministic key, different
// content) → 3 conflicts + finalization blocked. So the prod rebuild sequence must take the old base
// fully off the swarm first (stortron drops wj62), NOT merely resync it. ──
await nCre.close(); creClosed = true
t.pass('phase2: old base retired (CRE off the swarm) — prerequisite for a clean re-authored rebuild')
// ── Phase 3: capture PEER's authoritative snapshot, then REBUILD PEER fresh (forceCreator + adopt)
// — with the old base already retired (Phase 2), the re-authored genesis is alone on the topic. ──
await peer.device.restore().catch(() => {})
const records = await peer.device.repo.listRecords()
const h = peer.device.head
const authHead = { rev: h.rev, commitCid: h.commitCid, dataCid: h.dataCid, signedCommit: h.signedCommit }
await nPeer.close(); peerClosed = true
const sPeer2 = tmp() // fresh storage — the rebuild
nPeer = await bootMultiDevice({ storage: sPeer2, dids: [{ ...PEER.entry, forceCreator: true, adopt: { records, head: authHead } }], swarmOpts, signIntervalMs: 1000 })
peerClosed = false
peer = nPeer.byDid.get(DID)
let rebuilt = null
for (let i = 0; i < 40 && !rebuilt; i++) { await peer.sb.base.update().catch(() => {}); const st = await peer.device.state(); if (has(st, 'r-creator') && has(st, 'r-peer1')) rebuilt = st; else await sleep(500) }
t.ok(rebuilt, 'phase3: rebuilt notary has the record set (adopted)')
t.ok(peer.sb.base.writable, 'phase3: rebuilt notary is writable')
// ── Phase 4: a new post must FINALIZE on the rebuilt notary (old base retired → no collision). ──
const beforeIdx = peer.sb.base.indexedLength
await peer.device.applyWrite(post('r-new', 'post after rebuild, old base retired'))
let finalized = false
for (let i = 0; i < 80 && !finalized; i++) { await peer.sb.base.update().catch(() => {}); if (peer.sb.base.indexedLength > beforeIdx && has(await peer.device.state(), 'r-new')) finalized = true; else await sleep(750) }
t.ok(finalized, 'phase4: a new post FINALIZED on the rebuilt notary (old base retired first) — the clean prod sequence (indexedLength advanced past ' + beforeIdx + ')')
// ── Phase 5 (HA RE-ADD): bring the retired node back as a FRESH device — fresh storage + resync
// (force-non-creator) joining the REBUILT base. Its old base is gone, so there is nothing to collide;
// it must converge READ-ONLY to the rebuilt authoritative view, including the post-rebuild record. ──
const sCre2 = tmp() // fresh storage — the re-added node is a NEW device, not its old (retired) base
resyncDid(sCre2, DID) // force-non-creator so the lowest-key node does NOT try to re-be the genesis
nCre = await bootMultiDevice({ storage: sCre2, dids: [CRE.entry], swarmOpts, signIntervalMs: 1000 })
creClosed = false
let readded = null
for (let i = 0; i < 90 && !readded; i++) { await cre().sb.base.update().catch(() => {}); const st = await cre().device.state(); if (has(st, 'r-creator') && has(st, 'r-peer1') && has(st, 'r-new')) readded = st; else await sleep(750) }
t.ok(readded, 'phase5: HA re-add — the retired node rejoined as a FRESH device and converged to the REBUILT base (incl. the post-rebuild r-new), no collision')
t.absent(cre().sb.base.writable, 'phase5: the re-added node is a read-only replica (write-participation via re-admission is a separate step)')
})