import test from 'brittle'
import { RelayNode } from 'p2p-hiverelay/core/relay-node/index.js'
import { AccessControl } from 'p2p-hiverelay/core/relay-node/access-control.js'
import { MDNSDiscovery } from 'p2p-hiverelay/core/relay-node/mdns-discovery.js'
import path from 'path'
import { tmpdir } from 'os'
import { randomBytes } from 'crypto'
import { mkdirSync } from 'fs'
import { mkdir, readFile } from 'fs/promises'
import { EventEmitter } from 'events'
import b4a from 'b4a'
function tmpStorage () {
const storage = path.join(tmpdir(), 'hiverelay-private-test-' + randomBytes(8).toString('hex'))
mkdirSync(storage, { recursive: true })
return storage
}
// ─── Access Control Tests ───────────────────────────────────────
test('AccessControl - add and check devices', async (t) => {
const storage = tmpStorage()
await mkdir(storage, { recursive: true })
const ac = new AccessControl(storage)
await ac.load()
const pubkey = randomBytes(32).toString('hex')
t.is(ac.isAllowed(pubkey), false, 'unknown device not allowed')
await ac.addDevice(pubkey, 'my-laptop')
t.is(ac.isAllowed(pubkey), true, 'added device is allowed')
const devices = ac.listDevices()
t.is(devices.length, 1)
t.is(devices[0].name, 'my-laptop')
t.is(devices[0].pubkey, pubkey)
ac.destroy()
})
test('AccessControl - remove device', async (t) => {
const storage = tmpStorage()
await mkdir(storage, { recursive: true })
const ac = new AccessControl(storage)
await ac.load()
const pubkey = randomBytes(32).toString('hex')
await ac.addDevice(pubkey, 'phone')
t.is(ac.isAllowed(pubkey), true)
await ac.removeDevice(pubkey)
t.is(ac.isAllowed(pubkey), false, 'removed device no longer allowed')
t.is(ac.listDevices().length, 0)
ac.destroy()
})
test('AccessControl - persist and reload', async (t) => {
const storage = tmpStorage()
await mkdir(storage, { recursive: true })
const pubkey1 = randomBytes(32).toString('hex')
const pubkey2 = randomBytes(32).toString('hex')
// Save
const ac1 = new AccessControl(storage)
await ac1.load()
await ac1.addDevice(pubkey1, 'laptop')
await ac1.addDevice(pubkey2, 'phone')
await ac1.save()
ac1.destroy()
// Reload
const ac2 = new AccessControl(storage)
await ac2.load()
t.is(ac2.isAllowed(pubkey1), true, 'persisted device 1 loaded')
t.is(ac2.isAllowed(pubkey2), true, 'persisted device 2 loaded')
t.is(ac2.listDevices().length, 2)
ac2.destroy()
})
test('AccessControl - max devices limit', async (t) => {
const storage = tmpStorage()
await mkdir(storage, { recursive: true })
const ac = new AccessControl(storage, { maxDevices: 2 })
await ac.load()
await ac.addDevice(randomBytes(32).toString('hex'), 'dev1')
await ac.addDevice(randomBytes(32).toString('hex'), 'dev2')
try {
await ac.addDevice(randomBytes(32).toString('hex'), 'dev3')
t.fail('should throw on max devices')
} catch (err) {
t.ok(err.message.includes('Maximum devices'), 'throws on limit')
}
ac.destroy()
})
test('AccessControl - accepts Buffer pubkeys', async (t) => {
const storage = tmpStorage()
await mkdir(storage, { recursive: true })
const ac = new AccessControl(storage)
await ac.load()
const pubkeyBuf = randomBytes(32)
const pubkeyHex = pubkeyBuf.toString('hex')
await ac.addDevice(pubkeyHex, 'test')
t.is(ac.isAllowed(b4a.from(pubkeyBuf)), true, 'Buffer lookup works')
ac.destroy()
})
test('AccessControl - addDevice rolls back when save fails', async (t) => {
const storage = tmpStorage()
await mkdir(storage, { recursive: true })
const ac = new AccessControl(storage)
await ac.load()
const pubkey = randomBytes(32).toString('hex')
let addedEvents = 0
ac.on('device-added', () => { addedEvents++ })
ac.save = async () => { throw new Error('disk full') }
try {
await ac.addDevice(pubkey, 'unsaved-phone')
t.fail('should throw when allowlist save fails')
} catch (err) {
t.is(err.message, 'disk full')
}
t.is(ac.isAllowed(pubkey), false, 'failed add is not allowed in memory')
t.is(ac.listDevices().length, 0, 'failed add leaves allowlist unchanged')
t.is(addedEvents, 0, 'failed add does not emit device-added')
ac.destroy()
})
test('AccessControl - removeDevice rolls back when save fails', async (t) => {
const storage = tmpStorage()
await mkdir(storage, { recursive: true })
const ac = new AccessControl(storage)
await ac.load()
const pubkey = randomBytes(32).toString('hex')
await ac.addDevice(pubkey, 'phone')
let removedEvents = 0
ac.on('device-removed', () => { removedEvents++ })
ac.save = async () => { throw new Error('readonly filesystem') }
try {
await ac.removeDevice(pubkey)
t.fail('should throw when allowlist save fails')
} catch (err) {
t.is(err.message, 'readonly filesystem')
}
t.is(ac.isAllowed(pubkey), true, 'failed remove keeps device allowed in memory')
const devices = ac.listDevices()
t.is(devices.length, 1, 'failed remove leaves allowlist entry present')
t.is(devices[0].name, 'phone', 'failed remove restores device metadata')
t.is(removedEvents, 0, 'failed remove does not emit device-removed')
ac.destroy()
})
// ─── Pairing Tests ──────────────────────────────────────────────
test('AccessControl - pairing flow', async (t) => {
const storage = tmpStorage()
await mkdir(storage, { recursive: true })
const ac = new AccessControl(storage)
await ac.load()
t.is(ac.isPairing, false, 'not pairing initially')
const { token, expiresAt } = ac.enablePairing({ timeoutMs: 10_000 })
t.ok(token, 'token generated')
t.ok(expiresAt > Date.now(), 'expires in the future')
t.is(ac.isPairing, true, 'pairing active')
// Attempt with wrong token
const devicePub = randomBytes(32).toString('hex')
const badResult = await ac.attemptPair('wrong-token', devicePub, 'hacker')
t.is(badResult, false, 'wrong token rejected')
t.is(ac.isAllowed(devicePub), false, 'device not added on bad token')
// Attempt with correct token
const newDevice = randomBytes(32).toString('hex')
const goodResult = await ac.attemptPair(token, newDevice, 'my-phone')
t.is(goodResult, true, 'correct token accepted')
t.is(ac.isAllowed(newDevice), true, 'device added to allowlist')
t.is(ac.isPairing, false, 'pairing disabled after success (one-shot)')
ac.destroy()
})
test('AccessControl - pairing tokens use timing-safe comparison', async (t) => {
const source = await readFile('packages/core/core/relay-node/access-control.js', 'utf8')
t.ok(source.includes('timingSafeEqual'), 'pairing token compare uses timingSafeEqual')
t.ok(source.includes('safePairingTokenEqual'), 'pairing token compare is centralized')
t.absent(source.includes('token !== this._pairingState.token'), 'raw token inequality is not used')
const storage = tmpStorage()
await mkdir(storage, { recursive: true })
const ac = new AccessControl(storage)
await ac.load()
const { token } = ac.enablePairing({ timeoutMs: 10_000 })
const devicePub = randomBytes(32).toString('hex')
const oversized = token + '0'.repeat(1024)
const badResult = await ac.attemptPair(oversized, devicePub, 'oversized-token')
t.is(badResult, false, 'oversized token rejected')
t.is(ac.isAllowed(devicePub), false, 'oversized token does not pair device')
t.is(ac.isPairing, true, 'failed token comparison keeps pairing active')
const goodDevice = randomBytes(32).toString('hex')
const goodResult = await ac.attemptPair(token, goodDevice, 'phone')
t.is(goodResult, true, 'valid token still pairs')
t.is(ac.isAllowed(goodDevice), true, 'valid token adds device')
ac.destroy()
})
test('AccessControl - pairing expires', async (t) => {
const storage = tmpStorage()
await mkdir(storage, { recursive: true })
const ac = new AccessControl(storage)
await ac.load()
ac.enablePairing({ timeoutMs: 50 }) // 50ms
t.is(ac.isPairing, true)
await new Promise(resolve => setTimeout(resolve, 100))
t.is(ac.isPairing, false, 'pairing expired')
ac.destroy()
})
test('AccessControl - disable pairing manually', async (t) => {
const storage = tmpStorage()
await mkdir(storage, { recursive: true })
const ac = new AccessControl(storage)
await ac.load()
ac.enablePairing({ timeoutMs: 60_000 })
t.is(ac.isPairing, true)
ac.disablePairing()
t.is(ac.isPairing, false, 'pairing disabled')
ac.destroy()
})
test('AccessControl - pairing payload generation', async (t) => {
const storage = tmpStorage()
await mkdir(storage, { recursive: true })
const ac = new AccessControl(storage)
await ac.load()
const { token } = ac.enablePairing({ timeoutMs: 10_000 })
const payload = ac.getPairingPayload('abc123', '192.168.1.50', 49737)
t.is(payload.pubkey, 'abc123')
t.is(payload.host, '192.168.1.50')
t.is(payload.port, 49737)
t.is(payload.pairingToken, token)
t.ok(payload.expiresAt)
ac.destroy()
})
test('AccessControl - pairing save failure keeps pairing active and device unpaired', async (t) => {
const storage = tmpStorage()
await mkdir(storage, { recursive: true })
const ac = new AccessControl(storage)
await ac.load()
const { token } = ac.enablePairing({ timeoutMs: 10_000 })
const pubkey = randomBytes(32).toString('hex')
let successEvents = 0
ac.on('pairing-success', () => { successEvents++ })
ac.save = async () => { throw new Error('allowlist unavailable') }
try {
await ac.attemptPair(token, pubkey, 'phone')
t.fail('should throw when pairing cannot persist device')
} catch (err) {
t.is(err.message, 'allowlist unavailable')
}
t.is(ac.isAllowed(pubkey), false, 'failed pair does not leave device allowed')
t.is(ac.isPairing, true, 'failed pair keeps pairing session active for retry')
t.is(successEvents, 0, 'failed pair does not emit pairing-success')
ac.destroy()
})
test('AccessControl - restoreBackup rolls back when save fails', async (t) => {
const storage = tmpStorage()
await mkdir(storage, { recursive: true })
const ac = new AccessControl(storage)
await ac.load()
const secretKey = randomBytes(32)
const originalPubkey = randomBytes(32).toString('hex')
const restoredPubkey = randomBytes(32).toString('hex')
await ac.addDevice(originalPubkey, 'laptop')
const backupSource = new AccessControl(storage)
await backupSource.load()
await backupSource.addDevice(restoredPubkey, 'tablet')
const backup = await backupSource.createBackup(secretKey)
backupSource.destroy()
let restoredEvents = 0
ac.on('backup-restored', () => { restoredEvents++ })
ac.save = async () => { throw new Error('backup save failed') }
try {
await ac.restoreBackup(backup, secretKey)
t.fail('should throw when backup restore cannot persist allowlist')
} catch (err) {
t.is(err.message, 'backup save failed')
}
t.is(ac.isAllowed(originalPubkey), true, 'failed restore keeps original device')
t.is(ac.isAllowed(restoredPubkey), false, 'failed restore removes unsaved restored device')
t.is(ac.listDevices().length, 1, 'failed restore leaves original allowlist size')
t.is(restoredEvents, 0, 'failed restore does not emit backup-restored')
ac.destroy()
})
// ─── Mode Configuration Tests ───────────────────────────────────
test('RelayNode - default mode (relay-core)', async (t) => {
const node = new RelayNode({ storage: tmpStorage(), enableAPI: false })
t.is(node.mode, 'relay-core', 'default mode is relay-core')
t.is(node.accessControl, null, 'no access control by default')
t.is(node.config.discovery.dht, true, 'DHT enabled')
t.is(node.config.discovery.announce, true, 'announce enabled')
t.is(node.config.discovery.mdns, false, 'mDNS disabled')
t.is(node.config.access.open, true, 'open access')
})
test('RelayNode - private mode config', async (t) => {
const node = new RelayNode({ mode: 'private', storage: tmpStorage(), enableAPI: false })
t.is(node.mode, 'private')
t.is(node.config.discovery.dht, false, 'no DHT')
t.is(node.config.discovery.announce, false, 'no announce')
t.is(node.config.discovery.mdns, true, 'mDNS enabled')
t.is(node.config.access.open, false, 'closed access')
t.is(node.config.pairing.enabled, true, 'pairing enabled')
t.is(node.config.enableRelay, false, 'relay disabled')
t.is(node.config.enableAPI, false, 'API disabled')
})
test('RelayNode - hybrid mode config', async (t) => {
const node = new RelayNode({ mode: 'hybrid', storage: tmpStorage(), enableAPI: false })
t.is(node.mode, 'hybrid')
t.is(node.config.discovery.dht, true, 'DHT enabled (for app discovery)')
t.is(node.config.discovery.announce, false, 'no announce (stealth)')
t.is(node.config.discovery.mdns, true, 'mDNS enabled')
t.is(node.config.access.open, false, 'closed access')
t.is(node.config.pairing.enabled, true, 'pairing enabled')
})
test('RelayNode - invalid mode throws', async (t) => {
try {
const node = new RelayNode({ mode: 'banana', storage: tmpStorage() }) // eslint-disable-line no-unused-vars
t.fail('should throw')
} catch (err) {
t.ok(err.message.includes('Invalid mode'), 'throws on bad mode')
}
})
test('RelayNode - mode config override', async (t) => {
// Private mode but explicitly enable API
const node = new RelayNode({
mode: 'private',
storage: tmpStorage(),
enableAPI: true,
apiPort: 9999
})
t.is(node.mode, 'private')
t.is(node.config.enableAPI, true, 'explicit override takes precedence')
t.is(node.config.apiPort, 9999, 'api port overridden')
})
// ─── RelayNode Private Mode Start/Stop ──────────────────────────
test('RelayNode - private mode starts and stops', async (t) => {
const node = new RelayNode({
mode: 'private',
storage: tmpStorage(),
enableAPI: false,
enableServices: false,
discovery: { mdns: false } // disable mDNS for test (avoids port conflicts)
})
t.teardown(async () => { if (node.running) await node.stop() })
await node.start()
t.is(node.running, true, 'starts in private mode')
t.ok(node.accessControl, 'access control initialized')
const stats = node.getStats()
t.is(stats.mode, 'private')
t.ok(stats.accessControl, 'stats include access control')
t.is(stats.accessControl.pairedDevices, 0)
await node.stop()
t.is(node.running, false)
})
test('RelayNode - private mode with pre-configured allowlist', async (t) => {
const deviceKey = randomBytes(32).toString('hex')
const node = new RelayNode({
mode: 'private',
storage: tmpStorage(),
enableAPI: false,
enableServices: false,
access: { allowlist: [deviceKey] },
discovery: { mdns: false }
})
t.teardown(async () => { if (node.running) await node.stop() })
await node.start()
const stats = node.getStats()
t.is(stats.accessControl.pairedDevices, 1, 'pre-configured device loaded')
const devices = node.listDevices()
t.is(devices.length, 1)
t.is(devices[0].pubkey, deviceKey)
await node.stop()
})
test('RelayNode - private mode pairing methods', async (t) => {
const node = new RelayNode({
mode: 'private',
storage: tmpStorage(),
enableAPI: false,
enableServices: false,
discovery: { mdns: false }
})
t.teardown(async () => { if (node.running) await node.stop() })
await node.start()
// Enable pairing
const pairingInfo = node.enablePairing({ timeoutMs: 10_000 })
t.ok(pairingInfo.token, 'pairing token returned')
t.ok(pairingInfo.relayPubkey, 'relay pubkey in pairing info')
// Pair a device
const deviceKey = randomBytes(32).toString('hex')
const paired = await node.pairDevice(pairingInfo.token, deviceKey, 'test-phone')
t.is(paired, true, 'device paired successfully')
const devices = node.listDevices()
t.is(devices.length, 1)
t.is(devices[0].name, 'test-phone')
// Remove device
await node.removeDevice(deviceKey)
t.is(node.listDevices().length, 0)
await node.stop()
})
test('RelayNode - public mode rejects pairing calls', async (t) => {
// Don't need to start() — just test that the constructor sets up
// public mode correctly and pairing methods reject
const node = new RelayNode({
mode: 'public',
storage: tmpStorage(),
enableAPI: false
})
t.is(node.accessControl, null, 'no access control in public mode')
try {
node.enablePairing()
t.fail('should throw in public mode')
} catch (err) {
t.ok(err.message.includes('private/hybrid'), 'throws for public mode')
}
try {
await node.addDevice(randomBytes(32).toString('hex'), 'test')
t.fail('should throw in public mode')
} catch (err) {
t.ok(err.message.includes('private/hybrid'), 'addDevice throws for public mode')
}
})
// ─── Connection Rejection Tests ─────────────────────────────────
//
// These exercise RelayNode._onConnection's allowlist gate. The gate is a
// pure function of {conn.remotePublicKey, info.publicKey, this.accessControl};
// it needs no swarm, no DHT, no Corestore. Previously the tests called
// `await node.start()` which spins up real Hyperswarm (10-15s per test)
// and the resulting teardown races occasionally made `_rejectedConnections`
// read as 0 when it should have been 1 — a flake that was masked for a
// while by an errant `setTimeout(() => process.exit(0))` hack that killed
// the entire test process. Now we construct the AccessControl manually,
// wire it to a minimal RelayNode shell, and exercise the gate directly.
// No swarm, no timing sensitivity.
async function makePrivateNodeShell (allowlist = []) {
const storage = tmpStorage()
await mkdir(storage, { recursive: true })
// Construct a RelayNode but don't start it — _onConnection doesn't need
// the swarm, and starting it is what made the test slow + flaky.
const node = new RelayNode({
mode: 'private',
storage,
enableAPI: false,
enableServices: false,
access: { allowlist },
discovery: { mdns: false }
})
// AccessControl is normally set up inside start(). Wire it directly.
const ac = new AccessControl(storage)
await ac.load()
for (const pubkey of allowlist) {
await ac.addDevice(pubkey, 'bootstrap')
}
node.accessControl = ac
// The gate calls store.replicate() on the allowed path; stub it so we
// don't need a real Corestore session.
node.store = { replicate: () => {} }
node._storageIngressReady = true
return node
}
test('RelayNode - private mode emits connection-rejected for unknown peers', async (t) => {
const node = await makePrivateNodeShell([])
t.teardown(() => node.accessControl?.destroy?.())
const events = []
node.on('connection-rejected', (info) => events.push(info))
const unknownPubkey = randomBytes(32)
const fakeConn = new EventEmitter()
fakeConn.remotePublicKey = unknownPubkey
fakeConn.destroyed = false
fakeConn.destroy = () => { fakeConn.destroyed = true }
node._onConnection(fakeConn, { publicKey: unknownPubkey })
t.is(fakeConn.destroyed, true, 'connection destroyed')
t.is(events.length, 1, 'rejection event emitted')
t.is(events[0].reason, 'not in allowlist')
t.is(node._rejectedConnections, 1)
})
test('RelayNode - private mode allows paired devices through', async (t) => {
const deviceKey = randomBytes(32).toString('hex')
const node = await makePrivateNodeShell([deviceKey])
t.teardown(() => node.accessControl?.destroy?.())
const events = []
node.on('connection', (info) => events.push(info))
const devicePubBuf = b4a.from(deviceKey, 'hex')
const fakeConn = new EventEmitter()
fakeConn.remotePublicKey = devicePubBuf
fakeConn.destroyed = false
fakeConn.destroy = () => { fakeConn.destroyed = true }
node._onConnection(fakeConn, { publicKey: devicePubBuf })
t.is(fakeConn.destroyed, false, 'connection NOT destroyed for allowed device')
t.is(events.length, 1, 'connection event emitted')
t.is(node._rejectedConnections, 0, 'no rejections')
})
// ─── mDNS Discovery Tests ───────────────────────────────────────
test('MDNSDiscovery - start and stop', async (t) => {
const mdns = new MDNSDiscovery({
publicKey: randomBytes(32),
port: 49737,
mode: 'private'
})
t.teardown(async () => { await mdns.stop() })
// Just test start/stop lifecycle (actual multicast may not work in CI)
try {
await mdns.start()
t.is(mdns._running, true, 'started')
await mdns.stop()
t.is(mdns._running, false, 'stopped')
} catch (err) {
// mDNS may fail in some environments (docker, CI)
t.ok(err, 'mDNS unavailable in this environment — acceptable')
}
})
test('MDNSDiscovery - ignores own announcements', async (t) => {
const pubkey = randomBytes(32)
const mdns = new MDNSDiscovery({
publicKey: pubkey,
port: 49737,
mode: 'private'
})
t.teardown(async () => { try { await mdns.stop() } catch (_) {} })
const discovered = []
mdns.on('peer-discovered', (peer) => discovered.push(peer))
// Simulate receiving our own announcement as DNS-SD response
const ownResponse = {
answers: [
{ name: 'hiverelay._hiverelay._udp.local', type: 'SRV', data: { port: 49737, target: 'host.local' } },
{ name: 'hiverelay._hiverelay._udp.local', type: 'TXT', data: [`pk=${b4a.toString(pubkey, 'hex')}`, 'mode=private', 'v=1'] }
],
additionals: []
}
mdns._handleResponse(ownResponse, { address: '192.168.1.50' })
t.is(discovered.length, 0, 'own announcement ignored')
})
test('MDNSDiscovery - discovers other peers', async (t) => {
const ourKey = randomBytes(32)
const mdns = new MDNSDiscovery({
publicKey: ourKey,
port: 49737,
mode: 'private'
})
t.teardown(async () => { try { await mdns.stop() } catch (_) {} })
const discovered = []
mdns.on('peer-discovered', (peer) => discovered.push(peer))
// Simulate receiving another node's announcement as DNS-SD
const otherKey = randomBytes(32).toString('hex')
const otherResponse = {
answers: [
{ name: 'friend-hive._hiverelay._udp.local', type: 'SRV', data: { port: 49738, target: 'friend.local' } },
{ name: 'friend-hive._hiverelay._udp.local', type: 'TXT', data: [`pk=${otherKey}`, 'mode=private', 'v=1'] }
],
additionals: []
}
mdns._handleResponse(otherResponse, { address: '192.168.1.51' })
t.is(discovered.length, 1, 'peer discovered')
t.is(discovered[0].pubkey, otherKey)
t.is(discovered[0].host, '192.168.1.51')
t.is(discovered[0].port, 49738)
t.is(discovered[0].name, 'friend-hive')
const peers = mdns.getDiscoveredPeers()
t.is(peers.length, 1)
})
test('MDNSDiscovery - ignores non-hiverelay services', async (t) => {
const mdns = new MDNSDiscovery({
publicKey: randomBytes(32),
port: 49737
})
t.teardown(async () => { try { await mdns.stop() } catch (_) {} })
const discovered = []
mdns.on('peer-discovered', (peer) => discovered.push(peer))
// DNS-SD response for a different service type
const otherResponse = {
answers: [
{ name: 'webserver._http._tcp.local', type: 'SRV', data: { port: 80, target: 'web.local' } },
{ name: 'webserver._http._tcp.local', type: 'TXT', data: ['path=/'] }
],
additionals: []
}
mdns._handleResponse(otherResponse, { address: '192.168.1.100' })
t.is(discovered.length, 0, 'non-hiverelay service ignored')
})
test('MDNSDiscovery - handles malformed messages gracefully', async (t) => {
const mdns = new MDNSDiscovery({
publicKey: randomBytes(32),
port: 49737
})
t.teardown(async () => { try { await mdns.stop() } catch (_) {} })
// Should not throw on malformed DNS-SD responses
mdns._handleResponse({ answers: [], additionals: [] }, { address: '1.2.3.4' })
mdns._handleResponse({ answers: [{ name: 'x._hiverelay._udp.local', type: 'SRV', data: { port: 1 } }], additionals: [] }, { address: '1.2.3.4' })
mdns._handleResponse({ answers: null, additionals: null }, { address: '1.2.3.4' })
t.pass('malformed messages handled without crash')
})
// Historical note: this file previously ended with
// `setTimeout(() => process.exit(0), 500)`
// to work around dangling MDNS sockets that kept the process alive. That
// hack killed every subsequent test file in the combined suite (relay-node,
// sdk-seeding, etc.) — a silent and bad regression. The real fix is
// t.teardown() on each MDNSDiscovery test above. Don't re-add process.exit.