// #133 fix: a regular 2nd device must NOT self-promote to indexer (that made wj62 N=2 = the quorum
// trap). It joins as a plain WRITER, converges, and can still hold the lease + sign — but the indexer
// set stays 1 (the creator = sole indexer, N=1). Only a designated anchor (opts.indexer) grows it.
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 '../lib/state-base.js'
import { RepoBase } from '../lib/repo-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 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:noindex2222222222222'
const POST = 'app.bsky.feed.post'
const tmp = () => { const d = '/tmp/hpp-noidx-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
test('a 2nd device joins as a PLAIN WRITER — indexer set stays 1 (N=1, no N=2 trap)', { timeout: 120000 }, async (t) => {
const testnet = await createTestnet(3)
const rootPub = await rootIdentityPublicKey(ROOT)
const devA = await deviceKeetKeyPair(DEVA); const proofA = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: devA.publicKey })
const devB = await deviceKeetKeyPair(DEVB); const proofB = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: devB.publicKey })
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) => new StateBaseV3(tmp(), 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); await A.ready(); await A.projectMembers(members)
const B = mk(devB, proofB); await B.ready()
t.teardown(async () => { try { await A.close(); await B.close() } catch {} ; await testnet.destroy() })
// B (a role-1 member) writes → admitted as a WRITER + converges (like multi-device).
const rbB = new RepoBase(B, { did: DID })
let admitted = false
for (let i = 0; i < 40 && !admitted; i++) { try { await rbB.putRecord(POST, '3b', { $type: POST, text: 'from B', createdAt: '2026-07-12T00:00:00.000Z' }) } catch {} ; await B.set('__ping__', { i }).catch(() => {}); await sleep(1500); admitted = await B.effectiveWritable().catch(() => false) }
t.ok(admitted, 'B admitted as a WRITER')
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, '3b'); if (!seen) await sleep(1000) }
t.ok(seen, 'A converged with B\'s record (writer admission works)')
// give the promote timer ample chances to (wrongly) fire; assert it does NOT grow the indexer set.
await sleep(3000); await A.base.update().catch(() => {}); await B.base.update().catch(() => {})
const idxA = A.base.linearizer.indexers.length
const idxB = B.base.linearizer.indexers.length
t.is(idxA, 1, 'indexer set stays 1 (creator A only) — B did NOT self-promote → no N=2 trap')
t.is(idxB, 1, 'B agrees: exactly one indexer')
const w0 = A.base.linearizer.indexers[0]
const soleIdxKey = b4a.toString(w0.core ? w0.core.key : w0.key, 'hex')
t.is(soleIdxKey, b4a.toString(A.base.key, 'hex'), 'the sole indexer is the bootstrap/creator core (base.key) — NO device self-promoted')
})