OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / test / room.test.js

377 lines · javascript ref:
// ROOM-CONTRACT v2 (hyper-social/docs/ROOM-CONTRACT.md) — every member is its own node with its own writer
// core, admitted by a real invitation, so every check here is authority-by-carrier, never a signed payload.
// §6's list, one test each, plus the v1 behaviours that still hold. The replay gate is room-replay.test.js.

import test from 'brittle'
import Corestore from 'corestore'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import { Room, ADMIN, MODERATOR, PEER, READ_ONLY, OP_VERSION, canonicalOpBytes } from '../lib/room.js'
import { signAttested } from '../lib/attestation.js'
import { identity } from './helpers.js'

const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-room-'))

async function who () {
  const id = await identity()
  return { root: id.rootHex, device: await id.device(), id }
}

const until = async (fn, ms = 8000) => {
  const end = Date.now() + ms
  while (Date.now() < end) { const v = await fn(); if (v) return v; await new Promise((resolve) => setTimeout(resolve, 30)) }
  return null
}

// A room and its founder; `add` brings in another member on its own node through a real invitation.
async function world (t) {
  const founder = await who()
  const host = new Room(new Corestore(tmp()).namespace('room'), null)
  await host.ready()
  await host.found(founder.device)
  await host.update()
  const nodes = [host]
  const streams = []
  t.teardown(async () => { for (const s of streams) s.destroy(); for (const n of nodes) await n.close() })

  const settle = async () => { for (let i = 0; i < 6; i++) { for (const n of nodes) await n.update(); await new Promise((resolve) => setTimeout(resolve, 20)) } }

  // ⛔ A MESH, as on the room's topic: every node connects to every other. A star (everyone to the
  // founder only) hides a joiner's optimistic block from nodes it is not connected to — it is not a
  // writer yet — so a moderator could never see the request it is supposed to answer.
  const node = async () => {
    const r = new Room(new Corestore(tmp()).namespace('room'), host.key)
    await r.ready()
    for (const other of nodes) {
      const s1 = other.base.replicate(true); const s2 = r.base.replicate(false)
      s1.pipe(s2).pipe(s1)
      streams.push(s1, s2)
    }
    nodes.push(r)
    await until(async () => { await r.update(); return !!(await r.founder()) })
    return r
  }

  // A member at `role`, joined by an invitation `by` mints (default: the founder's node).
  const add = async (role = PEER, { by = host, person = null } = {}) => {
    const p = person || await who()
    const r = await node()
    const { token } = await by.invite({ role })
    await settle()
    await r.join(token, p.device)
    await until(async () => { await settle(); return (await host.roleOf(p.root)) !== null })
    // ⛔ The joiner's weight is anchored by the next block of a weighted member AFTER the join is applied
    // (ROOM-CONTRACT §4) — an append that lands before the host has seen the join anchors nothing.
    await host.setSetting('title', 'room')
    await settle()
    return { ...p, room: r }
  }

  return { founder, host, node, add, settle }
}

test('★ founding: the creator is an ADMIN member, its writer core is the device, weight 2', async (t) => {
  const { founder, host } = await world(t)
  t.alike((await host.members()).map((m) => [m.root, m.role]), [[founder.root, ADMIN]])
  t.alike((await host.devices(founder.root)).map((d) => d.writer), [host.writer], 'keyed by the carrying writer core')
  const w = await host.base.system.get(host.base.local.key)
  t.is(w.maxWeight, 2, 'admin device at weight 2')
})

test('★ an invitation admits a member at its role; a peer device is granted weight 1 (§6.2)', async (t) => {
  const { host, add, settle } = await world(t)
  const p = await add(PEER)
  t.is(await host.roleOf(p.root), PEER)
  await p.room.post('hello', null)
  await settle()
  t.ok((await host.messages()).some((m) => m.body === 'hello' && m.author === p.root), 'and they can write')
  const w = await host.base.system.get(p.room.base.local.key)
  t.is(w.maxWeight, 1, 'a peer device carries weight 1, not 2')
})

test('⛔ a stranger\'s core writes nothing', async (t) => {
  const { host, node, settle } = await world(t)
  const stranger = await node()
  await stranger.base.append(b4a.from(JSON.stringify({ t: 'message', v: OP_VERSION, name: 'x', body: 'spam' })), { optimistic: true })
  await settle()
  t.is((await host.messages()).length, 0)
})

test('⛔ READ-ONLY cannot post, but can flag', async (t) => {
  const { host, add, settle } = await world(t)
  const r = await add(READ_ONLY)
  await r.room.post('should not land', null)
  await r.room.flag('message-x', 'spam')
  await settle()
  t.is((await host.messages()).length, 0, 'no post')
  t.is((await host.flags('message-x')).length, 1, 'the flag is recorded')
})

test('⛔ roles: a moderator manages peers but cannot touch or grant admin (§6.3)', async (t) => {
  const { founder, host, add, settle } = await world(t)
  const mod = await add(MODERATOR)
  const p = await add(PEER)
  await mod.room.setRole(p.root, READ_ONLY)
  await settle()
  t.is(await host.roleOf(p.root), READ_ONLY, 'moderator demotes a peer')
  await mod.room.setRole(p.root, ADMIN)
  await mod.room.removeMember(founder.root)
  await settle()
  t.is(await host.roleOf(p.root), READ_ONLY, 'a moderator cannot grant admin')
  t.is(await host.roleOf(founder.root), ADMIN, 'a moderator removing an admin is ignored')
})

test('⛔ the last admin can be neither demoted nor leave; another admin may leave (§6.3, §6.4)', async (t) => {
  const { founder, host, add, settle } = await world(t)
  await host.setRole(founder.root, PEER)
  await host.removeMember(founder.root)
  await settle()
  t.is(await host.roleOf(founder.root), ADMIN, 'still the admin')
  const a2 = await add(PEER)
  await host.setRole(a2.root, ADMIN)
  await settle()
  t.is(await host.roleOf(a2.root), ADMIN, 'promoted')
  await a2.room.removeMember(a2.root)
  await settle()
  t.is(await host.roleOf(a2.root), null, 'an admin leaving, with another admin present, is accepted')
})

test('⛔ removed for good; left can rejoin (§6.5)', async (t) => {
  const { host, add, node, settle } = await world(t)
  const gone = await add(PEER)
  await host.removeMember(gone.root)
  await settle()
  t.is(await host.roleOf(gone.root), null, 'removed')
  t.is((await host.devices(gone.root)).length, 0, 'and their devices with them')
  // A valid invitation does not bring a removed member back.
  const { token } = await host.invite({ role: PEER })
  await settle()
  const again = await node()
  await again.join(token, gone.device)
  await host.setSetting('title', 'x'); await settle()
  t.is(await host.roleOf(gone.root), null, 'join aborts for a removed member')
  // add-device aborts too.
  const third = await node()
  await third.addDevice(gone.device)
  await settle()
  t.is(await host.roleOf(gone.root), null, 'add-device aborts for a removed member')

  const leaver = await add(PEER)
  await leaver.room.removeMember(leaver.root)
  await settle()
  t.is(await host.roleOf(leaver.root), null, 'left')
  await add(PEER, { person: leaver })
  t.is(await host.roleOf(leaver.root), PEER, 'a member who LEFT can come back with an invitation')
})

test('⛔ muted: message, flag and invite ignored; unmuted: accepted (§6.6)', async (t) => {
  const { host, add, settle } = await world(t)
  const p = await add(PEER)
  await host.setStatus(p.root, { muted: true })
  await settle()
  await p.room.post('muted', null)
  await p.room.flag('t1', 'x')
  const { id } = await p.room.invite({ role: PEER })
  await settle()
  t.is((await host.messages()).length, 0, 'no message')
  t.is((await host.flags('t1')).length, 0, 'no flag')
  t.absent((await host.invites()).some((i) => i.id === id), 'no invitation')
  await host.setStatus(p.root, { muted: false })
  await settle()
  await p.room.post('unmuted', null)
  await settle()
  t.ok((await host.messages()).some((m) => m.body === 'unmuted'), 'unmuted: accepted')
})

test('⛔ banned: cannot post or invite; the ban can be lifted (decided 2026-10-13)', async (t) => {
  const { host, add, settle } = await world(t)
  const p = await add(PEER)
  await host.setStatus(p.root, { banned: true })
  await settle()
  await p.room.post('banned', null)
  await settle()
  t.is((await host.messages()).length, 0)
  t.is(await host.roleOf(p.root), PEER, 'still a member — a ban is not a removal')
  await host.setStatus(p.root, { banned: false })
  await settle()
  await p.room.post('back', null)
  await settle()
  t.ok((await host.messages()).some((m) => m.body === 'back'), 'lifted')
})

test('⛔ uninvite revokes: a later join with it is ignored (§6.7)', async (t) => {
  const { host, node, settle } = await world(t)
  const p = await who()
  const { id, token } = await host.invite({ role: PEER })
  await host.uninvite(id)
  await settle()
  const r = await node()
  await r.join(token, p.device)
  await host.setSetting('title', 'x'); await settle()
  t.is(await host.roleOf(p.root), null)
})

test('⛔ a join whose attestation names another writer than its carrier is ignored (§6.8)', async (t) => {
  const { host, node, settle } = await world(t)
  const p = await who()
  const { token } = await host.invite({ role: PEER })
  await settle()
  const r = await node()
  const sk = b4a.from(token, 'hex')
  const elsewhere = b4a.alloc(32, 7) // a writer key that is NOT r's core
  const op = { t: 'join', v: OP_VERSION, invite: b4a.toString(sk.subarray(32), 'hex'), bound: signAttested(elsewhere, p.device), at: Date.now() }
  op.sig = b4a.toString(crypto.sign(canonicalOpBytes(op), sk), 'hex')
  await r.base.append(b4a.from(JSON.stringify(op)), { optimistic: true })
  await host.setSetting('title', 'x'); await settle()
  t.is(await host.roleOf(p.root), null)
})

test('delete-message: a non-author peer is ignored; the author or a moderator tombstones it (§6.9)', async (t) => {
  const { host, add, settle } = await world(t)
  const a = await add(PEER)
  const b = await add(PEER)
  const mod = await add(MODERATOR)
  const n1 = await a.room.post('one', null)
  const n2 = await a.room.post('two', null)
  await settle()
  await b.room.deleteMessage(n1)
  await settle()
  t.ok((await host.messages()).find((m) => m.name === n1).body === 'one', 'another peer cannot delete it')
  await a.room.deleteMessage(n1)
  await mod.room.deleteMessage(n2)
  await settle()
  const byName = Object.fromEntries((await host.messages()).map((m) => [m.name, m]))
  t.ok(byName[n1].deleted && byName[n1].by === a.root && byName[n1].body === undefined, 'author: tombstone, body dropped')
  t.ok(byName[n2].deleted && byName[n2].by === mod.root, 'moderator: tombstone')
})

test('an admin grant on an invitation works once; the second join gets the plain role (§6.10)', async (t) => {
  const { host, node, settle } = await world(t)
  const { token } = await host.invite({ role: ADMIN })
  await settle()
  const p1 = await who(); const p2 = await who()
  const r1 = await node(); const r2 = await node()
  await r1.join(token, p1.device)
  await host.setSetting('title', 'a'); await settle()
  await r2.join(token, p2.device)
  await host.setSetting('title', 'b'); await settle()
  await until(async () => { await settle(); return (await host.roleOf(p2.root)) !== null })
  t.is(await host.roleOf(p1.root), ADMIN)
  t.is(await host.roleOf(p2.root), PEER)
})

test('an invitation requiring confirmation waits; a peer cannot accept, a moderator can (decided 2026-10-13)', async (t) => {
  const { host, add, node, settle } = await world(t)
  const peer = await add(PEER)
  const mod = await add(MODERATOR)
  const p = await who()
  const { token } = await host.invite({ role: PEER, requiresConfirmation: true })
  await settle()
  const r = await node()
  await r.join(token, p.device)
  await host.setSetting('title', 'x'); await settle()
  t.is(await host.roleOf(p.root), null, 'nothing granted yet')
  const [req] = await host.requests()
  t.ok(req && req.root === p.root, 'a pending request')
  // A moderator answers what its OWN node has seen.
  t.ok(await until(async () => { await settle(); return (await mod.room.requests()).length === 1 }), 'the moderator\'s node sees it')
  await peer.room.acceptJoin(req.writer)
  await settle()
  t.is(await host.roleOf(p.root), null, 'a peer cannot accept')
  await mod.room.acceptJoin(req.writer)
  await settle()
  t.is(await host.roleOf(p.root), PEER, 'a moderator can')
})

test('settings: a closed set, moderators only; isJoiningDisabled stops joins', async (t) => {
  const { host, add, node, settle } = await world(t)
  const p = await add(PEER)
  await p.room.setSetting('title', 'peer-set')
  await host.setSetting('not-a-setting', 'x')
  await host.setSetting('title', 'admin-set')
  await settle()
  t.alike(await host.settings(), { title: 'admin-set' })
  await host.setSetting('isJoiningDisabled', 'true')
  const { token } = await host.invite({ role: PEER })
  await settle()
  const q = await who()
  const r = await node()
  await r.join(token, q.device)
  await host.setSetting('title', 'y'); await settle()
  t.is(await host.roleOf(q.root), null)
})

test('★ listings are a moderator act; a peer cannot list', async (t) => {
  const { host, add, settle } = await world(t)
  const p = await add(PEER)
  const other = b4a.toString(crypto.randomBytes(32), 'hex')
  await p.room.listRoom(other, 'nope')
  await settle()
  t.is((await host.listings()).length, 0)
  await host.listRoom(other, 'a bar')
  await settle()
  t.alike((await host.listings()).map((l) => l.room), [other])
  await host.delistRoom(other)
  await settle()
  t.is((await host.listings()).length, 0)
})

test('⛔ one member cannot overwrite — or reattribute — another member\'s message; their own, they can', async (t) => {
  const { host, add, settle } = await world(t)
  const a = await add(PEER)
  const b = await add(PEER)
  await a.room.post('mine', null, 'the-name')
  await settle()
  await b.room.post('hijack', null, 'the-name')
  await settle()
  let m = (await host.messages()).find((x) => x.name === 'the-name')
  t.is(m.author, a.root); t.is(m.body, 'mine')
  await a.room.post('mine, revised', null, 'the-name')
  await settle()
  m = (await host.messages()).find((x) => x.name === 'the-name')
  t.is(m.body, 'mine, revised')
})

test('★ a DM: founded with the other person as an initial member; their node binds its core with add-device', async (t) => {
  const founder = await who()
  const other = await who()
  const host = new Room(new Corestore(tmp()).namespace('room'), null)
  await host.ready()
  await host.found(founder.device, { initialMembers: [{ root: other.root, role: PEER }] })
  await host.update()
  const r = new Room(new Corestore(tmp()).namespace('room'), host.key)
  await r.ready()
  const s1 = host.base.replicate(true); const s2 = r.base.replicate(false)
  s1.pipe(s2).pipe(s1)
  t.teardown(async () => { s1.destroy(); s2.destroy(); await host.close(); await r.close() })
  t.is(await host.roleOf(other.root), PEER, 'a member before any device')
  await until(async () => { await r.update(); return !!(await r.founder()) })
  await r.addDevice(other.device)
  await host.setSetting('title', 'DM')
  await until(async () => { await host.update(); await r.update(); return (await host.devices(other.root)).length === 1 })
  await r.post('hi', null)
  t.ok(await until(async () => { await host.update(); await r.update(); return (await host.messages()).some((m) => m.body === 'hi') }), 'and they can write')
})

test('remove-device: only the member owning the device', async (t) => {
  const { host, add, settle } = await world(t)
  const a = await add(PEER)
  const b = await add(PEER)
  await b.room.removeDevice(a.room.writer)
  await settle()
  t.is((await host.devices(a.root)).length, 1, 'another member cannot remove it')
  await a.room.removeDevice(a.room.writer)
  await settle()
  t.is((await host.devices(a.root)).length, 0, 'the owner can')
})

test('⛔ a v1 op is ignored (OP_VERSION 2)', async (t) => {
  const { host, settle } = await world(t)
  await host.base.append(b4a.from(JSON.stringify({ t: 'message', v: 1, name: 'old', body: 'v1' })))
  await settle()
  t.is((await host.messages()).length, 0)
})