// An outsider must not write into the room to ask to be let in.
//
// Production, wj62 (hyper-social #40): a writer the room had REMOVED kept requesting admission and had
// appended 26,953 blocks to a core that can never be admitted — still climbing across two reads minutes
// apart, and accounting for HALF of every hpp daemon's log volume on both servers:
//
// writer 4d163d63c795 authored 26218 -> 26953 removed:true
// tabertron: 1000 of the last 2000 log lines = ADMIT attempt from=4d163d63c795 (stortron: same)
//
// The loop feeds itself: `requestWriter` appends an optimistic doorbell block and re-appends while it
// waits; `ensureAdmitted` is driven by the sign tick; the tick fires on every base advance — including
// the advance the doorbell's own append caused.
//
// The fix is not a slower doorbell, it is keet's rule. `RoomFirewall.allow()` resolves an incoming
// peer through `@keet/devices-by-swarm-key` and admits only an ACTIVE device; membership is written by
// an EXISTING member via addDevice/acceptJoinRequest. An outsider never writes to the room at all, so
// there is nothing to rate-limit. Two properties, and neither is about time:
//
// 1. a device the room does not list appends NOTHING, however often it is asked to try
// 2. a device the room DOES list still rings — that is the Phase-2 case (an admitted writer whose
// indexer never signed a checkpoint it persisted) and it must keep working
//
// Run: bare test/doorbell-bounded.test.js
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 { 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 tmp = () => { const d = '/tmp/hpp-doorbell-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const hex = (k) => b4a.toString(k, 'hex')
const ABI_KEY = (writerHex) => '__abi__/' + writerHex
const localLen = (sb) => (sb.base && sb.base.local ? sb.base.local.length : 0)
// projectMembers is event-driven + self-terminating: it lands on a connection or a promote tick, not
// on the await. A member cannot author anything before the room HAS a roster, so wait for the fact.
const waitMembers = async (sb, ms = 90000) => {
const end = Date.now() + ms
while (Date.now() < end) { const m = await sb.getNow('__members__').catch(() => null); if (m && Object.keys(m).length) return m; await sleep(500) }
return null
}
// A is the creator and the only member; B is attested by the same root but deliberately NOT listed —
// the terminal state shared by the removed writer and by the phone (`verify: no __members__ in view`).
async function room (testnet, { includeB = false } = {}) {
const rootPub = await rootIdentityPublicKey(ROOT)
const da = await deviceKeetKeyPair(DEVA); const pa = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: da.publicKey })
const db = await deviceKeetKeyPair(DEVB); const pb = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: db.publicKey })
const members = [{ key: hex(da.publicKey), role: 1 }]
if (includeB) members.push({ key: hex(db.publicKey), role: 1 })
const roomSeed = crypto.randomBytes(32); const roomKey = crypto.randomBytes(32); const encryptionKey = crypto.randomBytes(32)
const mk = (dev, proof, extra = {}) => new StateBaseV3(tmp(), roomKey, {
roomSeed, encryptionKey, swarmOpts: { bootstrap: testnet.bootstrap },
selfKey: dev.publicKey, attest: makeAttest(dev, proof), verifyAttest: makeVerifyAttest(rootPub),
getMembers: async () => members, ...extra
})
return { da, pa, db, pb, members, mk }
}
test('a device the room does not list appends NOTHING', { timeout: 300000 }, async (t) => {
const testnet = await createTestnet(3)
t.teardown(() => testnet.destroy())
const { da, pa, db, pb, mk, members } = await room(testnet)
const a = mk(da, pa, { forceCreator: true })
await a.ready(); t.teardown(async () => { try { await a.close() } catch {} })
await a.projectMembers(members).catch(() => {}) // explicit: projectMembers(null) only re-projects a RETAINED roster
const b = mk(db, pb, { joiner: true })
await b.ready(); t.teardown(async () => { try { await b.close() } catch {} })
t.absent(b.base.writable, 'precondition: B is not admitted')
// B must have CONVERGED the roster and be absent from it — that is the flooding node's real state
// (long-lived, once a writer, now excluded). An unconverged view is "no information", not a refusal.
t.ok(await waitMembers(b), 'B has replicated the roster (and is not in it)')
const before = localLen(b)
for (let i = 0; i < 25; i++) { await b.ensureAdmitted({ timeoutMs: 1000 }).catch(() => {}); await sleep(20) }
const appended = localLen(b) - before
t.absent(b.base.writable, 'still not admitted — nothing here grants membership')
t.is(appended, 0, 'it wrote NOTHING into the room (25 attempts; production wrote 26,953 blocks)')
})
test('a member-authored addMember lets the joiner in — the room decides, not the outsider', { timeout: 300000 }, async (t) => {
const testnet = await createTestnet(3)
t.teardown(() => testnet.destroy())
const { da, pa, db, pb, mk, members } = await room(testnet)
const a = mk(da, pa, { forceCreator: true })
await a.ready(); t.teardown(async () => { try { await a.close() } catch {} })
await a.projectMembers(members).catch(() => {}) // explicit: projectMembers(null) only re-projects a RETAINED roster
const b = mk(db, pb, { joiner: true })
await b.ready(); t.teardown(async () => { try { await b.close() } catch {} })
await b.ensureAdmitted({ timeoutMs: 1000 }).catch(() => {})
t.absent(b.base.writable, 'B cannot let itself in')
t.ok(await waitMembers(a), 'the creator has projected its roster (a member can only author once the room has one)')
// The EXISTING member authors the membership — keet's addDevice, not a request from B.
await a.addMember(hex(db.publicKey), 1)
let admitted = false
const deadline = Date.now() + 120000
while (Date.now() < deadline && !admitted) {
await b.ensureAdmitted({ timeoutMs: 5000 }).catch(() => {})
admitted = !!b.base.writable
if (!admitted) await sleep(500)
}
t.ok(admitted, 'once the room lists B, B is admitted — the doorbell still works for a real member')
const seen = await b.getNow('__members__')
t.ok(seen && seen[hex(db.publicKey)] != null, 'and B appears in __members__ on B\'s own replica')
})
test('a non-member cannot author a membership', { timeout: 300000 }, async (t) => {
// The authorization is the AUTHOR's role, verified in apply — the same gate REMOVE_DEVICE uses.
// Without it, "the room decides" would just move the free-for-all one level up.
const testnet = await createTestnet(3)
t.teardown(() => testnet.destroy())
const { da, pa, db, pb, mk, members } = await room(testnet)
const a = mk(da, pa, { forceCreator: true })
await a.ready(); t.teardown(async () => { try { await a.close() } catch {} })
await a.projectMembers(members).catch(() => {}) // explicit: projectMembers(null) only re-projects a RETAINED roster
const b = mk(db, pb, { joiner: true })
await b.ready(); t.teardown(async () => { try { await b.close() } catch {} })
const victim = hex(crypto.randomBytes(32))
await b.addMember(victim, 1).catch(() => {}) // B is not a member — must not take effect
await sleep(3000)
const onA = await a.getNow('__members__')
t.absent(onA && onA[victim] != null, 'the creator\'s view did NOT gain the member a non-member proposed')
})
test('an admitted device still rings — the Phase-2 reboot case is preserved', { timeout: 300000 }, async (t) => {
// A writer admitted at runtime can reboot writable=false when no indexer signed a checkpoint it
// persisted. It IS listed, so it must still ring; gating on "listed" must not break this.
const testnet = await createTestnet(3)
t.teardown(() => testnet.destroy())
const { da, pa, db, pb, mk, members } = await room(testnet, { includeB: true })
const a = mk(da, pa, { forceCreator: true })
await a.ready(); t.teardown(async () => { try { await a.close() } catch {} })
await a.projectMembers(members).catch(() => {}) // explicit: projectMembers(null) only re-projects a RETAINED roster
const b = mk(db, pb, { joiner: true })
await b.ready(); t.teardown(async () => { try { await b.close() } catch {} })
t.ok(await waitMembers(a), 'the roster is projected and lists B')
const deadline = Date.now() + 120000
while (Date.now() < deadline && !b.base.writable) { await b.ensureAdmitted({ timeoutMs: 5000 }).catch(() => {}); await sleep(400) }
// Becoming writable IS the proof that it rang: admission requires an admit op from B, so a gate that
// wrongly silenced a listed device would leave it read-only forever. (base.local is the wrong probe —
// an optimistic block is not there until it is acked.)
t.ok(b.base.writable, 'a LISTED device still rings and is admitted — the Phase-2 reboot case survives')
})
test('addMember is REFUSED while any writer predates the op — the ABI gate', { timeout: 300000 }, async (t) => {
// ADD_MEMBER changes what apply DOES, and apply must be deterministic across the room: a node that
// predates it inserts `__add_member__` as an ordinary entry while a current node inserts nothing —
// one log, two views. Old nodes cannot be retrofitted, so the AUTHOR refuses to emit it.
//
// The evidence is a per-writer stamp, and its ABSENCE is exactly what a pre-ABI node looks like — so
// the gate must fail CLOSED on a missing stamp. One node is enough to prove both directions, and it
// keeps the assertion away from the two-writer admission race.
const testnet = await createTestnet(3)
t.teardown(() => testnet.destroy())
const { da, pa, mk, members } = await room(testnet)
const a = mk(da, pa, { forceCreator: true })
await a.ready(); t.teardown(async () => { try { await a.close() } catch {} })
await a.projectMembers(members).catch(() => {})
t.ok(a.base.writable, 'the creator is a writer')
// No stamp yet — fail closed, and say which writer is missing.
const localHex = a.base.local.key.toString('hex')
t.ok((await a._writersBelowAbi()).includes(localHex.slice(0, 12)), 'an unstamped writer counts as below the ABI')
let threw = null
try { await a.addMember(hex(crypto.randomBytes(32)), 1) } catch (e) { threw = e }
t.ok(threw && /room ABI/.test(threw.message), 'addMember REFUSED: ' + (threw && threw.message))
t.ok(threw && threw.message.includes(localHex.slice(0, 12)), 'and it names WHICH writer, so the check can be answered rather than bypassed')
// Stamped — the same call now proceeds.
await a._announceAbi()
t.is((await a._writersBelowAbi()).length, 0, 'every writer now announces the ABI')
await a.addMember(hex(crypto.randomBytes(32)), 1)
t.pass('addMember proceeds once the room supports it')
// force is an explicit operator override, never the default.
await a.del(ABI_KEY(localHex))
t.ok((await a._writersBelowAbi()).length > 0, 'stamp removed again')
await a.addMember(hex(crypto.randomBytes(32)), 1, { force: true })
t.pass('force overrides the gate for an operator who has verified the room out of band')
})