// RESYNC (fresh-corestore re-join) — the heal for a POISONED StateBase autobase local writer.
//
// A poisoned local writer crashes autobase apply (SIGABRT in ApplyState._appendLocalNodes) at every
// boot. A fresh NAMESPACE re-derives the SAME writer (primaryKey-derived); only a fresh CORESTORE sheds
// it. This proves the mechanism the bin/resync-did directive drives: a device that re-opens its
// StateBase from a FRESH corestore (a) gets a DIFFERENT (clean) local writer key — the shedding — yet
// (b) joins the SAME base and RE-CONVERGES to the authoritative peer's view, and (c) is RE-ADMITTED
// (writable) via the attestation doorbell. Modeled on rejoin-convergence.test.js, but B rejoins from a
// fresh store (the resync side store) rather than its stale one. Plus a unit test for the index helpers.
import '../lib/bare-globals.js'
import test from 'brittle'
import crypto from 'hypercore-crypto'
import fs from 'bare-fs'
import b4a from 'b4a'
import createTestnet from '@hyperswarm/testnet'
import { StateBaseV3 } from 'atproto-room/lib/state-base.js'
import { RepoBase } from 'atproto-room/lib/repo-base.js'
import { makeAttest, makeVerifyAttest, attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from 'atproto-room/lib/membership.js'
import { resyncDid, readResyncIndex } from '../lib/device-node.js'
const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
const DEVA = 'legal winner thank year wave sausage worth useful legal winner thank yellow'
const DEVB = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const DID = 'did:plc:resync222222222222222222'
const POST = 'app.bsky.feed.post'
const tmp = () => { const d = '/tmp/hpp-resync-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
test('resync index helpers: resyncDid bumps a per-DID gen, readResyncIndex reads it back', async (t) => {
const storage = tmp()
t.alike(readResyncIndex(storage), {}, 'no index yet → {}')
const r1 = resyncDid(storage, DID)
t.is(r1.gen, 1, 'first resync → gen 1')
t.ok(r1.sidePath.includes('statebase-resync') && r1.sidePath.endsWith('-r1'), 'side path is <storage>/statebase-resync/<did16>-r1')
t.is(readResyncIndex(storage)[DID], 1, 'index persisted gen 1 for the DID')
const r2 = resyncDid(storage, DID)
t.is(r2.gen, 2, 'second resync bumps to gen 2 (a NEW fresh corestore)')
t.not(r1.sidePath, r2.sidePath, 'each gen is a distinct corestore path')
const other = resyncDid(storage, 'did:plc:other22222222222222222222')
t.is(other.gen, 1, 'a different DID is tracked independently (generic, per-DID)')
t.is(readResyncIndex(storage)[DID], 2, 'the first DID is unaffected by the second')
})
test('fresh-corestore re-join SHEDS the local writer + RE-CONVERGES + RE-ADMITS', { timeout: 240000 }, async (t) => {
const testnet = await createTestnet(3)
const rootPub = await rootIdentityPublicKey(ROOT)
const d1 = await deviceKeetKeyPair(DEVA); const p1 = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: d1.publicKey })
const d2 = await deviceKeetKeyPair(DEVB); const p2 = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: d2.publicKey })
// Lowest key = creator = sole indexer (quorum 1, advances alone); higher key = non-indexer rejoiner.
let a = { dev: d1, proof: p1 }, bb = { dev: d2, proof: p2 }
if (b4a.compare(a.dev.publicKey, bb.dev.publicKey) > 0) { const t2 = a; a = bb; bb = t2 }
const devA = a.dev, proofA = a.proof, devB = bb.dev, proofB = bb.proof
const members = [
{ key: b4a.toString(devA.publicKey, 'hex'), role: 1 },
{ key: b4a.toString(devB.publicKey, 'hex'), role: 1 }
]
const roomSeed = crypto.randomBytes(32); const roomKey = crypto.randomBytes(32); const encryptionKey = crypto.randomBytes(32)
const mk = (dev, proof, dir) => new StateBaseV3(dir, roomKey, {
roomSeed, encryptionKey, swarmOpts: { bootstrap: testnet.bootstrap },
selfKey: dev.publicKey, attest: makeAttest(dev, proof), verifyAttest: makeVerifyAttest(rootPub),
getMembers: async () => members
})
const A = mk(devA, proofA, tmp()); await A.ready(); await A.projectMembers(members)
let B = mk(devB, proofB, tmp()); await B.ready()
let closedB = false
t.teardown(async () => { try { await A.close() } catch {} ; if (!closedB) { try { await B.close() } catch {} } ; await testnet.destroy() })
// ── Phase 1: B is admitted + writable; A sees B's record (converged) ──
let rbB = new RepoBase(B, { did: DID })
let admitted = false
for (let i = 0; i < 40 && !admitted; i++) { try { await rbB.putRecord(POST, '3fromB', { $type: POST, text: 'from B', createdAt: '2026-07-04T00:00:00.000Z' }) } catch {} ; await B.set('__ping__', { i }).catch(() => {}); await sleep(1500); admitted = await B.effectiveWritable().catch(() => false) }
t.ok(admitted, 'phase1: B admitted + writable on its ORIGINAL corestore')
const origLocal = b4a.toString(B.base.local.key, 'hex')
const baseKey = b4a.toString(B.base.key, 'hex')
const rbA = new RepoBase(A, { did: DID })
let seen = null
for (let i = 0; i < 30 && !seen; i++) { await A.base.update().catch(() => {}); seen = await rbA.getRecord(POST, '3fromB'); if (!seen) await sleep(1000) }
t.ok(seen, 'phase1: A converged on B\'s record')
// ── Phase 2: B "poisoned" — take it offline, A advances (records B's original writer never signed) ──
await B.close(); closedB = true
for (let k = 0; k < 5; k++) { await rbA.putRecord(POST, '3A' + k, { $type: POST, text: 'A advanced ' + k, createdAt: '2026-07-05T00:00:00.000Z' }) }
await A.base.update().catch(() => {})
t.ok(await rbA.getRecord(POST, '3A4'), 'phase2: A advanced while B was down')
// ── Phase 3: RESYNC — B re-joins from a FRESH corestore (the side store), NOT its old one ──
B = mk(devB, proofB, tmp()); await B.ready(); closedB = false
const freshLocal = b4a.toString(B.base.local.key, 'hex')
t.not(freshLocal, origLocal, 'RESYNC: fresh corestore → a DIFFERENT (clean) local writer key — the poisoned writer is SHED')
t.is(b4a.toString(B.base.key, 'hex'), baseKey, 'RESYNC: still the SAME base (roomKey-derived) — a fresh replica, not a new room')
// ── Phase 4: the fresh replica RE-CONVERGES to A's authoritative view ──
const rbB2 = new RepoBase(B, { did: DID })
let converged = null
for (let i = 0; i < 45 && !converged; i++) { await B.base.update().catch(() => {}); await B.set('__resync_ping__', { i }).catch(() => {}); converged = await rbB2.getRecord(POST, '3A4'); if (!converged) await sleep(1000) }
t.ok(converged && converged.text === 'A advanced 4', 'phase4: the fresh-corestore replica RE-CONVERGED to A\'s advanced records')
t.ok(await rbB2.getRecord(POST, '3fromB'), 'phase4: the pre-resync history is present too (full view, nothing lost)')
// ── Phase 5: the fresh writer is RE-ADMITTED (writable) — "fully re-admitted, old tasks restored" ──
let readmitted = false
for (let i = 0; i < 40 && !readmitted; i++) { await B.set('__readmit_ping__', { i }).catch(() => {}); await B.base.update().catch(() => {}); readmitted = await B.effectiveWritable().catch(() => false); if (!readmitted) await sleep(1500) }
t.ok(readmitted, 'phase5: the fresh local writer was RE-ADMITTED (writable) via the attestation doorbell')
})