// Probe (room-state spec P2): membership-gated writer admission.
//
// Three daemons open the SAME room's StateBase, each with a signing identity:
// - C = MOD → elected creator, mints, is a writer/indexer
// - P = PEER → an active keet member → MUST be admitted as a writer
// - X = OUTSIDER (knows the roomId, NOT in the member list) → MUST be REFUSED
// Expected: C admits P (its write linearizes onto C's view); C refuses X (X can
// replicate/read but never becomes writable; its writes never land). This is
// the security property the old unauthenticated _exchangeWriters lacked.
//
// Run: bare test/probe-state-admission.js
// PASS = P becomes writable AND P's write reaches C; X stays NON-writable.
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import crypto from 'hypercore-crypto'
import createTestnet from 'hyperdht/testnet.js'
import { StateBase } from '../lib/state-base.js'
const ROOM_ID = 'probe-admit-' + Math.random().toString(36).slice(2, 10)
const roomTag = crypto.hash(Buffer.from(ROOM_ID, 'utf-8'))
const DEADLINE_MS = 30000
const C = crypto.keyPair() // mod / creator
const P = crypto.keyPair() // peer member
const X = crypto.keyPair() // outsider (not a member)
// X is deliberately ABSENT from the member list.
const MEMBERS = [
{ key: C.publicKey.toString('hex'), role: 2 }, // mod
{ key: P.publicKey.toString('hex'), role: 3 } // peer
]
const getMembers = async () => MEMBERS
const dirs = {}
for (const n of ['C', 'P', 'X']) {
dirs[n] = path.join(os.tmpdir(), 'probe-admit-' + n + '-' + Date.now())
fs.mkdirSync(dirs[n], { recursive: true })
}
console.log('roomId:', ROOM_ID)
console.log('C(mod):', C.publicKey.toString('hex').slice(0, 12) + '… P(peer):', P.publicKey.toString('hex').slice(0, 12) + '… X(outsider):', X.publicKey.toString('hex').slice(0, 12) + '…')
console.log('\nspinning up local DHT testnet...')
const testnet = await createTestnet(3)
const swarmOpts = { bootstrap: testnet.bootstrap }
const sbC = new StateBase(dirs.C, roomTag, { swarmOpts, selfKeyPair: C, getMembers })
const sbP = new StateBase(dirs.P, roomTag, { swarmOpts, selfKeyPair: P, getMembers })
const sbX = new StateBase(dirs.X, roomTag, { swarmOpts, selfKeyPair: X, getMembers })
let exitCode = 1
function fail (m) { console.error('\n✗ FAIL:', m) }
try {
console.log('\n[C] ready (creator mints)…'); await sbC.ready()
console.log('[P] ready (member, awaits admission)…'); await sbP.ready()
console.log('[X] ready (outsider, awaits — should be refused)…'); await sbX.ready()
if (!sbC.stateBaseKey) throw new Error('creator did not mint')
console.log('\npolling for admission outcomes…')
const start = Date.now()
let lastLog = 0
let pWroteOk = false
while (Date.now() - start < DEADLINE_MS) {
const pW = !!(sbP.base && sbP.base.writable)
const xW = !!(sbX.base && sbX.base.writable)
// Once P is writable, write through it and confirm C sees it.
if (pW && !pWroteOk) {
try {
await sbP.set('p:flag', 'admitted')
pWroteOk = true
} catch (e) { /* not writable yet, retry */ }
}
let cSeesP = null
if (pWroteOk) cSeesP = await sbC.get('p:flag').catch(() => null)
// Require X to have ADOPTED the base (so its non-writability is the gate
// refusing it, not merely "no base yet") while staying non-writable.
const xAdopted = !!(sbX.base && sbX.stateBaseKey)
if (pW && pWroteOk && cSeesP && cSeesP.value === 'admitted' && xAdopted && !xW) {
console.log('\n✓ P admitted (writable, write reached C); X adopted the base but is REFUSED (non-writable)')
exitCode = 0
break
}
const now = Date.now()
if (now - lastLog > 3000) {
lastLog = now
console.log(' P.writable=', pW, ' P.wrote=', pWroteOk, ' C.sees(p:flag)=', cSeesP ? cSeesP.value : null, ' X.writable=', xW)
}
await new Promise(r => setTimeout(r, 500))
}
if (exitCode !== 0) {
fail('expected P admitted + write-through and X refused; got P.writable=' +
!!(sbP.base && sbP.base.writable) + ' X.writable=' + !!(sbX.base && sbX.base.writable))
}
// Hard assertion: X must NEVER be writable (the security property).
if (sbX.base && sbX.base.writable) { fail('SECURITY: outsider X became writable'); exitCode = 1 }
} catch (err) {
fail(err.message); if (err.stack) console.error(err.stack)
} finally {
await Promise.race([
(async () => { for (const sb of [sbC, sbP, sbX]) await sb.close().catch(() => {}); await testnet.destroy().catch(() => {}) })(),
new Promise(r => setTimeout(r, 3000))
])
for (const n of ['C', 'P', 'X']) { try { fs.rmSync(dirs[n], { recursive: true, force: true }) } catch {} }
}
console.log(exitCode === 0 ? '\nPASS' : '\nFAIL')
Bare.exit(exitCode)