// Cross-machine scope-isolation proof (post-mortem #35 → #37 fix).
//
// The post-mortem: a scope-peer that mounted the single shared `orgs`
// autobase replicated ALL of the primary's orgs (and cloned 97 repos),
// because hyper-orgs used ONE autobase for every org. The #37 fix moves
// to ONE autobase PER org: a peer joins ONE org via an invite and mounts
// ONLY that org's autobase, so other orgs never reach its disk.
//
// This harness proves the fix over the real Hyperswarm DHT (tabertron ↔
// ashatron), not just the in-process replicateAndSync unit test
// (hyper-orgs/test/scope-isolation.test.js + the in-process daemon test
// 'two daemons mirror per-org records…' in basic.test.js). It catches
// what in-memory replication cannot: NAT punch-through, multi-host
// connections, swarm topic derivation, daemon boot ordering.
//
// Storage is ephemeral (under /tmp). Daemon binds port 5006 to stay
// clear of the production daemon on 5005.
//
// 2-step flow (each command runs on a different machine):
//
// # Step 1 — on tabertron (PRIMARY). Boots a daemon, creates 3 orgs
// # (alpha/beta/gamma), mints a PEER invite for alpha ONLY, prints the
// # invite + swarm topic, then KEEPS RUNNING so the mirror can
// # replicate. Ctrl+C when done.
// bare test/scope-isolation-cross-machine.js primary \
// --storage /tmp/pgv3-scope-tb-primary --port 5006
// # Outputs:
// # ALPHA_INVITE=<invitation>
// # SWARM_TOPIC=<hex>
//
// # Step 2 — on ashatron (MIRROR), with the two values above:
// bare test/scope-isolation-cross-machine.js mirror \
// --storage /tmp/pgv3-scope-ash-mirror --port 5006 \
// --invite <invitation> --swarm-topic <hex>
// # Mirror joins ONLY alpha, waits up to 60s for it to replicate,
// # then prints what it saw and exits PASS/FAIL.
//
// PASS = isolation holds (mirror sees ONLY alpha; beta/gamma never
// mounted, so they're structurally invisible).
// FAIL = scope leak (mirror somehow sees beta or gamma).
import { mkdirSync } from '#node:fs'
import { join } from '#node:path'
import process from '#node:process'
import b4a from 'b4a'
import { startPearGit } from '../index.js'
const args = parseArgs(process.argv.slice(2))
const cmd = args._[0]
async function main () {
if (cmd === 'primary') return runPrimary()
if (cmd === 'mirror') return runMirror()
console.error('usage: scope-isolation-cross-machine.js {primary|mirror} [opts]')
console.error(' primary --storage <dir> --port <n>')
console.error(' mirror --storage <dir> --port <n> --invite <invitation> --swarm-topic <hex>')
process.exit(2)
}
function parseArgs (argv) {
const out = { _: [] }
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a.startsWith('--')) {
const key = a.slice(2)
const val = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true
out[key] = val
} else out._.push(a)
}
return out
}
function requireArg (name, value) {
if (!value || value === true) {
console.error(`--${name} required`)
process.exit(2)
}
return value
}
function ensureStorage (storage) {
// mkdir -p. Caller picks a path that doesn't collide with another
// daemon's storage; test paths should be under /tmp.
mkdirSync(storage, { recursive: true })
}
async function waitFor (pred, timeoutMs = 60000, pollMs = 500) {
const t0 = Date.now()
while (Date.now() - t0 < timeoutMs) {
try { if (await pred()) return true } catch {}
await new Promise((r) => setTimeout(r, pollMs))
}
return false
}
async function runPrimary () {
const storage = requireArg('storage', args.storage)
const port = parseInt(requireArg('port', args.port), 10)
ensureStorage(storage)
console.log(`[primary] booting pear-git-v3 daemon — storage=${storage} port=${port}`)
const node = await startPearGit({
storage,
uds: join(storage, 'svc.sock'),
http: { port },
deploy: false,
sync: false
})
// Three orgs, each its OWN autobase (the post-mortem setup; the mirror
// is meant to scope to just one).
await node.orgs.create('alpha', 'group', { displayName: 'Alpha' })
await node.orgs.create('beta', 'group', { displayName: 'Beta' })
await node.orgs.create('gamma', 'group', { displayName: 'Gamma' })
await node.orgs.sync() // per-org: sync() iterates every room (base getter throws on >1)
// PEER invite for alpha ONLY — holding it lets the mirror mount alpha's
// autobase and nothing else.
const { invitation } = await node.orgs.createOrgInvite('alpha', { role: 'reader', swarm: node.svc.swarm.swarm })
// The daemon joins ONE swarm topic at boot (identity-derived). The
// mirror needs the same topic to find this daemon on the DHT.
const swarmTopic = node.svc.swarm && node.svc.swarm.topic
? b4a.toString(node.svc.swarm.topic, 'hex')
: null
console.log('')
console.log('[primary] 3 orgs created: alpha, beta, gamma (separate autobases)')
console.log('[primary] alpha PEER invite + swarm topic — paste into the mirror command:')
console.log('')
console.log(`ALPHA_INVITE=${invitation}`)
console.log(`SWARM_TOPIC=${swarmTopic}`)
console.log('')
console.log('[primary] daemon staying up. Ctrl+C when the mirror finishes.')
await new Promise((resolve) => {
const onSig = () => resolve()
process.once('SIGINT', onSig)
process.once('SIGTERM', onSig)
})
console.log('[primary] shutting down')
try { await node.close() } catch {}
}
async function runMirror () {
const storage = requireArg('storage', args.storage)
const port = parseInt(requireArg('port', args.port), 10)
const invite = requireArg('invite', args.invite)
const topicHex = requireArg('swarm-topic', args['swarm-topic'])
ensureStorage(storage)
console.log(`[mirror] booting pear-git-v3 daemon — storage=${storage} port=${port}`)
console.log(`[mirror] joining swarm topic ${topicHex.slice(0, 16)}…`)
const node = await startPearGit({
storage,
uds: join(storage, 'svc.sock'),
http: { port },
swarm: { topic: b4a.from(topicHex, 'hex') },
deploy: false,
sync: false
})
// Per-org-autobase flow: consume the alpha invite → mount ONLY alpha.
// No mounts.orgs (that single-autobase surface is gone).
console.log('[mirror] joining alpha via invite (mounts ONLY alpha\'s autobase)…')
const joined = await node.orgs.joinOrg(invite, { swarm: node.svc.swarm.swarm })
console.log(`[mirror] joined "${joined.name}" (role=${joined.role})`)
// Replication over the shared swarm should deliver alpha's record.
console.log('[mirror] waiting up to 60s for alpha to replicate…')
const sawAlpha = await waitFor(async () => {
await node.orgs.sync()
return !!(await node.orgs.get('alpha'))
})
if (!sawAlpha) {
console.error('[mirror] INCONCLUSIVE — never saw alpha. Replication likely never started.')
console.error(' Check the primary is running and reachable on the DHT.')
try { await node.close() } catch {}
process.exit(2)
}
console.log('[mirror] alpha replicated')
// Give any (buggy) leak a chance to surface.
await new Promise((r) => setTimeout(r, 8000))
await node.orgs.sync().catch(() => {})
const alpha = await node.orgs.get('alpha')
const beta = await node.orgs.get('beta')
const gamma = await node.orgs.get('gamma')
console.log('')
console.log('[mirror] org visibility after replication settled:')
console.log(` alpha: ${alpha ? 'visible (expected)' : 'MISSING'}`)
console.log(` beta: ${beta ? 'VISIBLE — SCOPE LEAK' : 'hidden (expected)'}`)
console.log(` gamma: ${gamma ? 'VISIBLE — SCOPE LEAK' : 'hidden (expected)'}`)
console.log(` mirror tracks orgs: ${JSON.stringify(node.orgs.listOrgKeys().map((o) => o.name))}`)
const pass = !!alpha && !beta && !gamma
console.log('')
console.log(pass
? '[mirror] PASS — scope isolation holds (only alpha; beta/gamma never mounted)'
: '[mirror] FAIL — scope leak (post-mortem #35 bug)')
try { await node.close() } catch {}
process.exit(pass ? 0 : 1)
}
main().catch((err) => { console.error(err); process.exit(1) })