// projectMembers() BEFORE ready() must admit exactly like projectMembers() AFTER ready().
//
// THE BUG. projectMembers called before the base exists can only stash the roster — it early-returns on
// `!this._ready || !this.base || !this.base.writable`. The actual __members__ append then waited for
// whichever came first: a swarm connection, or a promote-timer tick up to promoteRetryMs later.
//
// Under network:false (the multi-DID daemon mode, where the daemon owns one swarm/store/wakeup and drives
// replication) the connection hook that would drive it sits inside `if (this.swarm)` and NEVER RUNS. So
// __members__ landed late, a member whose optimistic doorbell arrived first was refused by
// _verifyAdmissionInApply, and nothing re-rang it. Admission silently never happened.
//
// WHY IT RESISTED DIAGNOSIS: afterwards everything reads healthy — __members__ present, membersProjected
// true, roster loaded, creator writable — from the very first poll. The failure is invisible in the
// steady state, so it looked like a replication or daemon-wiring fault, and the original repro's own
// header blamed device-node. It is neither: same wiring, same DID count, the ONLY variable was ordering.
//
// The two-DID before-ready case is the sharpest evidence and is asserted below: two DIDs sharing ONE
// store, ONE swarm, ONE wakeup and ONE connection handler, and only the FIRST failed. Nothing shared can
// explain that, which is what localises the bug to per-StateBase open-time state.
//
// Structure copied from multi-did.test.js so the baseline is genuinely known-good.
// Run: bare test/members-projection-order.test.mjs
import '../lib/bare-globals.js'
import test from 'brittle'
import crypto from 'hypercore-crypto'
import fs from 'bare-fs'
import b4a from 'b4a'
import Corestore from 'corestore'
import Hyperswarm from 'hyperswarm'
import ProtomuxWakeup from 'protomux-wakeup'
import createTestnet from '@hyperswarm/testnet'
import { StateBaseV3 } from '../lib/state-base.js'
import { makeAttest, makeVerifyAttest, attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from '../lib/membership.js'
const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
const MNEMS = [
'legal winner thank year wave sausage worth useful legal winner thank yellow',
'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong',
'letter advice cage absurd amount doctor acoustic avoid letter advice cage above',
'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote'
]
const tmp = () => { const d = '/tmp/hpp-projorder-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
async function mkDid (rootPub, m0, m1) {
const cd = await deviceKeetKeyPair(m0); const cp = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: cd.publicKey })
const md = await deviceKeetKeyPair(m1); const mp = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: md.publicKey })
let creator = { dev: cd, proof: cp }; let member = { dev: md, proof: mp }
if (b4a.compare(creator.dev.publicKey, member.dev.publicKey) > 0) { const t = creator; creator = member; member = t } // creator = lowest key
const members = [
{ key: b4a.toString(creator.dev.publicKey, 'hex'), role: 1 },
{ key: b4a.toString(member.dev.publicKey, 'hex'), role: 1 }
]
return { rootPub, creator, member, members, roomSeed: crypto.randomBytes(32), roomKey: crypto.randomBytes(32), encryptionKey: crypto.randomBytes(32) }
}
// network:false — the daemon owns replication, so StateBaseV3's own connection hook never fires.
function mkSb (store, wakeup, did, who) {
const dev = did[who]
return new StateBaseV3(null, did.roomKey, {
store, wakeup, network: false,
roomSeed: did.roomSeed, encryptionKey: did.encryptionKey,
selfKey: dev.dev.publicKey, attest: makeAttest(dev.dev, dev.proof), verifyAttest: makeVerifyAttest(did.rootPub),
getMembers: async () => did.members
})
}
function wireDaemon (swarm, store, wakeup, sbs) {
swarm.on('connection', (conn) => {
conn.on('error', () => {})
try { wakeup.addStream(conn) } catch {}
try { store.replicate(conn) } catch {}
})
for (const sb of sbs) swarm.join(sb.base.discoveryKey, { server: true, client: true })
}
// Run the whole two-machine scenario, projecting the roster either side of ready(). Returns per-DID
// admission. `order` is the ONLY thing that varies.
async function run (t, { nDids, order }) {
const testnet = await createTestnet(3)
const rootPub = await rootIdentityPublicKey(ROOT)
const dids = [await mkDid(rootPub, MNEMS[0], MNEMS[1])]
if (nDids > 1) dids.push(await mkDid(rootPub, MNEMS[2], MNEMS[3]))
const store1 = new Corestore(tmp()); await store1.ready()
const swarm1 = new Hyperswarm({ bootstrap: testnet.bootstrap })
const wakeup1 = new ProtomuxWakeup()
const creators = []
for (const d of dids) {
const sb = mkSb(store1, wakeup1, d, 'creator')
if (order === 'before') await sb.projectMembers(d.members)
await sb.ready()
if (order === 'after') await sb.projectMembers(d.members)
creators.push(sb)
}
wireDaemon(swarm1, store1, wakeup1, creators)
await swarm1.flush().catch(() => {})
const store2 = new Corestore(tmp()); await store2.ready()
const swarm2 = new Hyperswarm({ bootstrap: testnet.bootstrap })
const wakeup2 = new ProtomuxWakeup()
const members = []
for (const d of dids) { const sb = mkSb(store2, wakeup2, d, 'member'); await sb.ready(); members.push(sb) }
wireDaemon(swarm2, store2, wakeup2, members)
await swarm2.flush().catch(() => {})
t.teardown(async () => {
for (const sb of [...creators, ...members]) { try { await sb.close() } catch {} }
try { await swarm1.destroy(); await swarm2.destroy() } catch {}
try { await store1.close(); await store2.close() } catch {}
await testnet.destroy()
})
// ONE optimistic write per member — the encrypted doorbell. Deliberately not retried: the bug is that
// a single doorbell arriving before __members__ is refused and never re-rung, so retrying here would
// mask it.
for (let i = 0; i < members.length; i++) { try { await members[i].set('post/1', { i }) } catch {} }
await sleep(1500)
return members.map((sb) => !!(sb.base && sb.base.writable))
}
test('CONTROL: projectMembers AFTER ready() admits (the known-good ordering)', async (t) => {
t.alike(await run(t, { nDids: 1, order: 'after' }), [true], 'single DID admits')
})
test('projectMembers BEFORE ready() admits too — ordering must not decide admission', async (t) => {
// Before the fix this was [false]: the roster was stashed, __members__ landed only on a later
// promote-timer tick, and the member's doorbell had already been refused.
t.alike(await run(t, { nDids: 1, order: 'before' }), [true], 'a roster retained before ready() is projected by ready() itself')
})
test('two DIDs sharing one store/swarm/wakeup: BOTH admit regardless of ordering', async (t) => {
// The sharpest case. Before the fix this was [false, true] — identical wiring, identical connection
// handler, and only the FIRST DID failed, which is what proved the cause was per-StateBase open-time
// state rather than anything shared.
t.alike(await run(t, { nDids: 2, order: 'before' }), [true, true], 'neither DID is left behind')
t.alike(await run(t, { nDids: 2, order: 'after' }), [true, true], 'and the known-good ordering still works')
})