OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room / test / doorbell-bounded.test.js

204 lines · javascript ref:
// 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')
})