// A node holds rooms, the way it holds feeds.
//
// ⛔ One Corestore namespace PER ROOM. Two Autobees sharing one derive the same local writer and
// DEADLOCK rather than erroring (node.js header, measured 2026-09-06) — and a node is expected to
// hold many rooms at once, so this is the case that would hit it.
//
// ⚠ A room key is a CAPABILITY. Holding it is what lets you replicate the room at all, so the list
// of rooms this node is in is local state, not something derived from anyone else's log.
import test from 'brittle'
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import { FavelaNode } from '../lib/node.js'
import { saveCredential } from '../lib/device.js'
import { identity } from './helpers.js'
import { ADMIN, PEER } from '../lib/room.js'
const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-nr-'))
const stub = () => ({ joined: [], async join (b) { this.joined.push(b) }, async leave () {}, async destroy () {} })
async function node (storage = tmp()) {
const id = await identity()
const cred = await id.device()
saveCredential(storage, cred)
const transport = stub()
const n = new FavelaNode(storage, { transport })
await n.ready()
return { n, cred, root: id.rootHex, storage, transport }
}
test('★ creating a room founds it and the creator is its admin', async (t) => {
const { n, root } = await node()
t.teardown(() => n.close())
const { key } = await n.createRoom()
t.is(key.length, 64)
const room = n.room(key)
t.is(await room.roleOf(root), ADMIN, 'the creator, by identity')
t.alike((await n.rooms()).map((r) => r.key), [key])
})
test('rooms survive a restart, and their logs come back', async (t) => {
const { n, storage, cred } = await node()
const { key } = await n.createRoom()
await n.room(key).setConfig('title', 'Berlin', cred)
await n.room(key).update()
await n.close()
const again = new FavelaNode(storage, { transport: stub() })
await again.ready()
t.teardown(() => again.close())
t.alike((await again.rooms()).map((r) => r.key), [key], 'reopened from disk')
t.is((await again.room(key).config()).title, 'Berlin')
})
test('⛔ every room is ANNOUNCED, or it cannot replicate', async (t) => {
const { n, transport } = await node()
t.teardown(() => n.close())
const { key } = await n.createRoom()
// The same defect the namespace had: an autobee nobody announces is a private log wearing the
// name of a shared one.
t.ok(transport.joined.some((b) => b && b.key && b.key.toString('hex') === key))
})
test('⛔⛔ a room REOPENED at boot is announced too — the case the daemon actually has', async (t) => {
const { n, storage } = await node()
const { key } = await n.createRoom()
await n.close()
// ⚠ This is where the previous test lied. It injects a transport at CONSTRUCTION, and the real
// daemon cannot: `ready()` builds the overlay and the transport needs the overlay, so at boot
// `this.transport` is null and every join inside ready() is a no-op. The room came back from
// disk, was never announced, and replicated for nobody — while the test passed.
const again = new FavelaNode(storage, { transport: null })
await again.ready()
const transport = stub()
again.transport = transport
await again.announceAll()
t.teardown(() => again.close())
t.ok(transport.joined.some((b) => b && b.key && b.key.toString('hex') === key),
'announceAll covers what ready() opened and could not announce')
t.ok(transport.joined.some((b) => b === again.names), 'the namespace as well')
t.ok(transport.joined.some((b) => b === again.peer.feed), 'and the feed')
})
test('⛔ two rooms open at once do not deadlock', async (t) => {
const { n } = await node()
t.teardown(() => n.close())
const a = await n.createRoom()
const b = await n.createRoom()
t.not(a.key, b.key)
// Two Autobees on ONE Corestore namespace derive the same local writer and hang — not error,
// hang. A node holding several rooms is exactly where that would land.
t.is((await n.rooms()).length, 2)
t.ok(await n.room(a.key).founder())
t.ok(await n.room(b.key).founder())
})
test('joining by key opens a room this node did not create', async (t) => {
const host = await node()
const guest = await node()
t.teardown(async () => { await host.n.close(); await guest.n.close() })
const { key } = await host.n.createRoom()
await guest.n.joinRoom(key)
t.alike((await guest.n.rooms()).map((r) => r.key), [key])
// ⚠ Joined is not the same as admitted. Holding the key lets you replicate and read; being a
// member is a decision somebody inside makes.
t.is(await guest.n.room(key).roleOf(guest.root), null, 'in the room, not of it')
})
test('⛔ a malformed room key is refused', async (t) => {
const { n } = await node()
t.teardown(() => n.close())
await t.exception(n.joinRoom('nope'), /32 bytes of hex|room key/)
})
test('leaving closes the room and forgets it, without destroying it', async (t) => {
const { n, transport } = await node()
t.teardown(() => n.close())
const { key } = await n.createRoom()
await n.leaveRoom(key)
t.alike(await n.rooms(), [], 'gone from this node')
// ⚠ Leaving stops replicating. It does not delete a room anybody else is in, and it cannot —
// the same shape as unfollowing and delisting.
t.ok(transport.joined.length >= 1)
})