// The namespace's DOORS.
//
// ⛔ Every capability this project has lost was a method with no way to reach it: `browser-auth`
// written with no route (#63 §8), a name that could be claimed by the daemon and by no person
// (#64 §2), `avatar-put` converted to take a path while the CLI kept sending base64, and
// `favela pair --cancel` calling a `has()` that was never defined. Unit tests on the method cannot
// see any of that — they call the method.
//
// So these drive `dispatch`, the thing the socket actually calls.
import test from 'brittle'
import process from '#node:process'
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import b4a from 'b4a'
import { FavelaNode } from '../lib/node.js'
import { dispatch } from '../lib/rpc.js'
import { saveCredential } from '../lib/device.js'
import { identity } from './helpers.js'
const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-rpcns-'))
const stubTransport = () => ({ async join () {}, async leave () {}, async destroy () {} })
async function node () {
// ⚠ FOUNDS ITS OWN. A fresh node now defaults to the fleet namespace, so a suite about founding,
// admins and carrying must say so. Set HERE rather than at module scope: env is process-wide and
// brittle loads every file before running, so a top-level assignment is racy with other files.
process.env.FAVELA_NAMESPACE = 'own'
const storage = tmp()
const id = await identity()
const cred = await id.device()
saveCredential(storage, cred)
const n = new FavelaNode(storage, { transport: stubTransport() })
await n.ready()
// Admitted to its own feed, or every mutating command is refused at the door by design.
await n.link(cred)
return { n, cred, root: id.rootHex }
}
test('`namespace` reports whether this node CARRIES the log, not just which one it is', async (t) => {
const { n, root } = await node()
t.teardown(() => n.close())
const ns = await dispatch(n, 'namespace', {})
t.is(ns.key.length, 64)
t.ok(ns.founded, 'founded at first boot')
t.is(ns.founder, root)
t.alike(ns.admins, [root])
// ⛔ "I resolve handles here" and "I carry this log" are different facts with different fixes,
// and neither is visible from a key. A reader is not broken; it just cannot admit anyone.
t.is(ns.carrying, true, 'the founder carries its own namespace')
t.alike(ns.requests, [])
})
test('★ the whole join → request → admit ceremony is reachable over the socket', async (t) => {
const a = await node()
const b = await node()
t.teardown(async () => { await a.n.close(); await b.n.close() })
const theirs = b4a.toString(a.n.names.key, 'hex')
await dispatch(b.n, 'namespace-join', { key: theirs })
t.is(b4a.toString(b.n.names.key, 'hex'), theirs, 'B resolves handles in A\'s namespace now')
await dispatch(a.n, 'namespace-grant', { root: b.root })
await dispatch(b.n, 'namespace-request', {})
await b.n.names.update()
// ⛔ B cannot answer its own ask: the grant would ride B's own weightless block and be capped to
// zero, silently. The door says so rather than appending something inert.
await t.exception(dispatch(b.n, 'namespace-admit',
{ writer: b4a.toString(b.n.names.base.local.key, 'hex') }), /weight/)
})
test('⛔ a malformed namespace key is refused at the door, not at apply', async (t) => {
const { n } = await node()
t.teardown(() => n.close())
const before = b4a.toString(n.names.key, 'hex')
await t.exception(dispatch(n, 'namespace-join', { key: 'nope' }), /32 bytes of hex/)
t.is(b4a.toString(n.names.key, 'hex'), before)
})
test('⚠ namespace-join needs no credential; the signing ops do', async (t) => {
const storage = tmp()
process.env.FAVELA_NAMESPACE = 'own'
const unlinked = new FavelaNode(storage, { transport: stubTransport() })
await unlinked.ready()
t.teardown(() => unlinked.close())
// A node that cannot sign must still be able to LOOK — otherwise an unlinked machine cannot even
// be pointed at the registry it is about to join.
const ns = await dispatch(unlinked, 'namespace', {})
t.is(ns.founded, false, 'unlinked, so unfounded — reported rather than hidden')
await t.exception(dispatch(unlinked, 'namespace-request', {}), /not admitted|favela link/)
})
// ── peek ──────────────────────────────────────────────────────────────────────────────────────
test('★ peek is reachable, writes nothing, and reports what it did', async (t) => {
const { n } = await node()
t.teardown(() => n.close())
const stranger = 'ab'.repeat(32)
const before = await dispatch(n, 'following', {})
const r = await dispatch(n, 'peek', { feed: stranger })
t.is(r.feed, stranger)
// ⛔ It ANSWERS before it has finished. Opening a feed we have never seen is a swarm join, and
// doing that inside the request is indistinguishable from a hung daemon.
t.is(r.status, 'opening')
t.alike(await dispatch(n, 'following', {}), before, 'and my own feed is untouched')
await n.opening(stranger)
const h = await dispatch(n, 'holding', { feed: stranger })
t.alike(h, { held: true, peeked: true, following: false, opening: false, blocked: false },
'four states, because "not held" and "still arriving" need different pages')
})
test('⚠ peek needs no credential — reading is not writing', async (t) => {
const { n } = await node()
t.teardown(() => n.close())
// It is not in MUTATIONS, so an unlinked device that cannot write to its own feed can still read
// a stranger's page. Refusing that would gate reading behind being able to post.
t.ok((await dispatch(n, 'peek', { feed: 'cd'.repeat(32) })).feed)
})
// ── rooms over the socket ─────────────────────────────────────────────────────────────────────
test('★ the whole room ceremony is reachable', async (t) => {
const { n } = await node()
t.teardown(() => n.close())
const { key } = await dispatch(n, 'room-create', {})
const before = await dispatch(n, 'room', { key })
t.is(before.role, 'admin', 'the creator is its admin, by identity')
await dispatch(n, 'room-config', { key, field: 'title', value: 'Berlin P2P' })
await dispatch(n, 'room-post', { key, body: 'first message' })
const after = await dispatch(n, 'room', { key })
t.is(after.config.title, 'Berlin P2P')
t.alike(after.messages.map((m) => m.body), ['first message'])
})
test('⛔ a room this node is not in says WHICH thing is wrong', async (t) => {
const { n } = await node()
t.teardown(() => n.close())
// "not in that room" and "no such room" look identical from outside and are fixed by completely
// different things — one by a key, the other by nothing.
await t.exception(dispatch(n, 'room-post', { key: 'ab'.repeat(32), body: 'x' }), /not in room/)
})
test('★ a DM is a room with two members — there is no DM type', async (t) => {
const { n } = await node()
t.teardown(() => n.close())
const other = 'cd'.repeat(32)
const { key } = await dispatch(n, 'dm', { member: other })
const r = await dispatch(n, 'room', { key })
t.is(r.members.length, 2, 'me and them')
t.ok(r.members.some((m) => m.root === other))
// SOCIAL-MODEL §4: a two-member room IS a direct message. One primitive, used — not a second
// thing that behaves almost the same.
t.is(r.config.title, 'DM')
})
const until = async (fn, ms = 10000) => {
const end = Date.now() + ms
while (Date.now() < end) { const v = await fn(); if (v) return v; await new Promise((resolve) => setTimeout(resolve, 40)) }
return null
}
test('★ ROOM-CONTRACT v2 over the socket: invite → join on the joiner\'s own core → accept', async (t) => {
const a = await node()
const b = await node()
t.teardown(async () => { await a.n.close(); await b.n.close() })
const { key } = await dispatch(a.n, 'room-create', {})
// The transport is stubbed, so the two rooms are piped by hand — what the swarm does in the daemon.
await dispatch(b.n, 'room-join', { key })
const s1 = a.n.room(key).replicate(true); const s2 = b.n.room(key).replicate(false)
s1.pipe(s2).pipe(s1)
t.teardown(() => { s1.destroy(); s2.destroy() })
t.ok(await until(async () => (await dispatch(b.n, 'room', { key })).members.length === 1), 'the joiner reads the room')
const before = await dispatch(b.n, 'room', { key })
t.is(before.role, null, 'holding the key is not membership')
t.is(before.bound, false, 'and no core of its is bound')
const inv = await dispatch(a.n, 'room-invite', { key, requiresConfirmation: true })
t.ok(/^[0-9a-f]+$/.test(inv.token), 'the invitation comes back as a token to hand over')
t.ok(await until(async () => { await b.n.room(key).update(); return (await b.n.room(key).invites()).length === 1 }), 'the joiner sees the invitation')
await dispatch(b.n, 'room-join-invite', { key, token: inv.token })
const req = await until(async () => {
await dispatch(a.n, 'room-config', { key, field: 'title', value: 'anchor' })
const r = await dispatch(a.n, 'room', { key })
return r.requests.length === 1 && r.requests[0]
})
t.ok(req, 'a confirmation-required join waits as a request the admin sees')
t.is(req.root, b.root)
await dispatch(a.n, 'room-accept', { key, writer: req.writer })
t.ok(await until(async () => (await dispatch(b.n, 'room', { key })).role === 'peer'), 'accepted: a peer, on the joiner\'s node too')
t.is((await dispatch(b.n, 'room', { key })).bound, true, 'with its own core bound')
await dispatch(b.n, 'room-post', { key, body: 'hello from b' })
t.ok(await until(async () => (await dispatch(a.n, 'room', { key })).messages.some((m) => m.body === 'hello from b' && m.author === b.root)),
'the joiner writes, attributed to its identity')
})
test('⛔ the v1 admission verbs refuse with the way forward', async (t) => {
const { n } = await node()
t.teardown(() => n.close())
const { key } = await dispatch(n, 'room-create', {})
for (const verb of ['room-add', 'room-request', 'room-admit']) {
await t.exception(dispatch(n, verb, { key }), /room-invite/, verb + ' names its replacement')
}
})
test('rooms are listed with what a person needs to pick one', async (t) => {
const { n } = await node()
t.teardown(() => n.close())
const { key } = await dispatch(n, 'room-create', {})
await dispatch(n, 'room-config', { key, field: 'title', value: 'Berlin' })
const { rooms } = await dispatch(n, 'rooms', {})
t.is(rooms.length, 1)
t.is(rooms[0].config.title, 'Berlin')
t.is(rooms[0].members, 1, 'and how many people are in it')
})