// Proof for RoomPairing (blind-pairing join + write-enrollment over Hyperswarm).
//
// Canonical Keet pattern: an invite code → blind-pairing over the DHT → the
// host addWriters the candidate DURING pairing. No HTTP, no --external-url.
// This is the P2P replacement for the bearer-invite (invitations.js) + the
// out-of-band enroll that previously rode HTTP.
import test from 'brittle'
import Corestore from 'corestore'
import Hyperswarm from 'hyperswarm'
import createTestnet from '@hyperswarm/testnet'
import { mkdtempSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { Room, jsonSchema } from '../index.js'
import { createRoomPairing, joinRoomPairing, resumeRoomPairing } from '../lib/room-pairing.js'
let n = 0
const tmp = () => mkdtempSync(join(tmpdir(), 'rp-' + (n++) + '-'))
const schema = jsonSchema({
async apply (batch, op) {
if (op.type === 'put') await batch.put('k:' + op.id, { id: op.id, by: op.by })
},
validate (op) {
if (op.type === 'put' && typeof op.id === 'undefined') throw new Error('put requires id')
}
})
async function waitFor (pred, ms = 25000) {
const t0 = Date.now()
while (Date.now() - t0 < ms) {
try { if (await pred()) return true } catch {}
await new Promise((r) => setTimeout(r, 100))
}
return false
}
async function makeNode (bootstrap) {
const store = new Corestore(tmp())
const swarm = new Hyperswarm({ bootstrap })
swarm.on('connection', (conn) => store.replicate(conn))
return { store, swarm }
}
test('contributor pairs over the swarm and becomes a writer (no HTTP)', async (t) => {
const testnet = await createTestnet(3, t.teardown)
// Host owns the room (creator → writable).
const h = await makeNode(testnet.bootstrap)
const host = new Room(h.store, { schema, namespace: 'room/test' })
await host.ready()
t.teardown(async () => { try { await host.close() } catch {}; try { await h.swarm.destroy() } catch {} })
t.ok(host.writable, 'host (creator) is writable')
await host.append({ type: 'put', id: 1, by: 'host' })
const pairing = createRoomPairing(host, { role: 'contributor', swarm: h.swarm })
t.teardown(async () => { try { await pairing.close() } catch {} })
t.ok(typeof pairing.invite === 'string' && pairing.invite.startsWith('roompair1:'), 'invite code minted')
// Candidate joins via the invite over the DHT.
const c = await makeNode(testnet.bootstrap)
t.teardown(async () => { try { await c.swarm.destroy() } catch {} })
const { room: cand } = await joinRoomPairing(pairing.invite, { corestore: c.store, swarm: c.swarm, schema })
t.teardown(async () => { try { await cand.close() } catch {} })
t.is(cand.keyHex, host.keyHex, 'candidate mounted the SAME room autobase (not a sibling)')
const becameWritable = await waitFor(() => cand.writable)
t.ok(becameWritable, 'candidate became a writer via pairing — over the swarm, no HTTP')
// Candidate writes; host sees it (bidirectional).
const id = Date.now()
await cand.append({ type: 'put', id, by: 'cand' })
const seen = await waitFor(async () => {
await host.base.update()
const v = await host.view.get('k:' + id)
return !!(v && v.value && v.value.by === 'cand')
})
t.ok(seen, 'host sees the candidate-authored op (bidirectional writes)')
})
test('reader pairs over the swarm and stays a read-only mirror', async (t) => {
const testnet = await createTestnet(3, t.teardown)
const h = await makeNode(testnet.bootstrap)
const host = new Room(h.store, { schema, namespace: 'room/test' })
await host.ready()
t.teardown(async () => { try { await host.close() } catch {}; try { await h.swarm.destroy() } catch {} })
await host.append({ type: 'put', id: 7, by: 'host' })
const pairing = createRoomPairing(host, { role: 'reader', swarm: h.swarm })
t.teardown(async () => { try { await pairing.close() } catch {} })
const c = await makeNode(testnet.bootstrap)
t.teardown(async () => { try { await c.swarm.destroy() } catch {} })
const { room: cand, role } = await joinRoomPairing(pairing.invite, { corestore: c.store, swarm: c.swarm, schema })
t.teardown(async () => { try { await cand.close() } catch {} })
t.is(role, 'reader', 'role echoed as reader')
t.is(cand.keyHex, host.keyHex, 'reader mounted the same room autobase')
// Reader replicates (read) the host's op...
const sawRead = await waitFor(async () => {
await cand.base.update()
const v = await cand.view.get('k:7')
return !!(v && v.value && v.value.by === 'host')
})
t.ok(sawRead, 'reader replicates the room read-only')
// ...but is NOT a writer.
await new Promise((r) => setTimeout(r, 1500))
t.absent(cand.writable, 'reader is NOT enrolled as a writer (read-only mirror)')
})
test('resumeRoomPairing re-registers a persisted invite (survives owner restart)', async (t) => {
const testnet = await createTestnet(3, t.teardown)
const h = await makeNode(testnet.bootstrap)
const host = new Room(h.store, { schema, namespace: 'room/test' })
await host.ready()
t.teardown(async () => { try { await host.close() } catch {}; try { await h.swarm.destroy() } catch {} })
await host.append({ type: 'put', id: 1, by: 'host' })
// Mint an invite, capture the persistable descriptor, then CLOSE the pairing
// (simulating an owner restart — the in-memory member is gone).
const pairing = createRoomPairing(host, { role: 'contributor', swarm: h.swarm })
const invite = pairing.invite
const descriptor = pairing.descriptor
t.ok(descriptor && descriptor.discoveryKey && descriptor.seed, 'descriptor is persistable')
await pairing.close()
// Re-register from the persisted descriptor (as boot would after restart).
const resumed = resumeRoomPairing(host, descriptor, h.swarm)
t.teardown(async () => { try { await resumed.close() } catch {} })
// A candidate using the ORIGINAL (already-shared) invite still pairs + writes.
const c = await makeNode(testnet.bootstrap)
t.teardown(async () => { try { await c.swarm.destroy() } catch {} })
const { room: cand } = await joinRoomPairing(invite, { corestore: c.store, swarm: c.swarm, schema })
t.teardown(async () => { try { await cand.close() } catch {} })
t.is(cand.keyHex, host.keyHex, 'candidate mounted the same room')
const became = await waitFor(() => cand.writable)
t.ok(became, 'candidate became a writer via the RESUMED pairing — persisted invite still works')
})