OverviewFilesCommitsIssuesDocsReleasesPeers

keet-daemon / test / probe-state-admission.js

117 lines · javascript ref:
// 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)