| … | … | @@ -529,13 +529,13 @@ export class FavelaNode { |
| 529 | 529 | } |
| 530 | 530 | |
| 531 | 531 | /** Make a room. This node's identity founds it, so it is the room's first admin. */ |
| 532 | async createRoom () { | |
| 532 | async createRoom ({ initialMembers = [] } = {}) { | |
| 533 | 533 | if (!this.cred) throw new Error('creating a room needs this device\'s credential — run `favela link`') |
| 534 | 534 | // ⚠ The key is not known until the log is open, so the slot cannot be named after it. A fresh |
| 535 | 535 | // slot per creation keeps each room's writer its own. |
| 536 | 536 | const slot = 'room:new:' + Date.now().toString(16) + Math.random().toString(16).slice(2, 8) |
| 537 | 537 | const room = await new Room(this.corestore.namespace(slot), null).ready() |
| 538 | await room.found(this.cred) | |
| 538 | await room.found(this.cred, { initialMembers }) | |
| 539 | 539 | await room.update() |
| 540 | 540 | const key = hex(room.key) |
| 541 | 541 | this._rooms.set(key, room) |
| … | … | @@ -1,54 +1,49 @@ |
| 1 | // A room — many members, roles, and membership as the audience. | |
| 1 | // A room — many members, roles, and membership as the audience. ROOM-CONTRACT v2. | |
| 2 | 2 | // |
| 3 | // ⛔ THE DISTINCTION FROM A FEED, which is the whole reason this file exists. `feed.js`'s roster is | |
| 4 | // DEVICE-FLAT: one identity, writers are its devices, and the authorisation rule is "is this writer | |
| 5 | // on the roster". #62 §3 named that as right for a feed and wrong for a room. A room has many | |
| 6 | // MEMBERS and each member has devices, so there are two levels and every question has to be asked | |
| 3 | // ⛔ THE DISTINCTION FROM A FEED. `feed.js`'s roster is DEVICE-FLAT: one identity, writers are its devices. | |
| 4 | // A room has many MEMBERS and each member has devices, so there are two levels and every question is asked | |
| 7 | 5 | // at the right one — a role belongs to a person, not to a laptop. |
| 8 | 6 | // |
| 9 | // ── The contract is keet's; the substrate is ours ───────────────────────────────────────────── | |
| 7 | // ── ROOM-CONTRACT v2 (hyper-social/docs/ROOM-CONTRACT.md) ───────────────────────────────────────── | |
| 10 | 8 | // |
| 11 | // #62 §3: "stop designing it and adopt keet's contract. One thing we design, one we reuse." The | |
| 12 | // vocabulary is keet's — admin / moderator / peer / read-only, members, invitations, config with | |
| 13 | // five built-in keys (COMMUNITY-MODERATION.md, SOCIAL-MODEL §6.7, ROOMS-ARE-THE-COMPUTER). | |
| 9 | // v1 adopted Keet's WORDS (admin / moderator / peer / read-only) and not its ENFORCEMENT: it authorised an | |
| 10 | // op by the identity root signed into the payload, so a correctly signed op was valid every time it | |
| 11 | // appeared, from anyone — a captured `add-member` re-admitted a member a moderator had removed | |
| 12 | // (test/room-replay.test.js, red on 0.0.62). v2 is Keet's model: | |
| 14 | 13 | // |
| 15 | // The ENGINE is autobee rather than autobase, which is where we part company with keet. autobase's | |
| 16 | // quorum is floor(N/2)+1 indexers: at N=2 one member going offline freezes `indexedLength` while | |
| 17 | // the view keeps growing, and it cannot self-heal by dropping to one. A person's two devices make | |
| 18 | // N=2 the ordinary posture rather than an edge case. Weights retire that class. | |
| 14 | // ⛔ AUTHORITY IS THE CARRIER. An op's actor is the member owning the WRITER CORE that carried it: | |
| 15 | // `__devices__/<writer>` → member → role. Only that device can append to its core, so a replay from any | |
| 16 | // other core is somebody else's op, judged as theirs. Room ops carry no signature and no `root`. | |
| 17 | // ⛔ Signatures remain only where a NEW writer core is bound to a member: `found`, `join`, `add-device`. | |
| 18 | // Verified once, at admission; afterwards the core is the credential. | |
| 19 | // ⛔ AUTHORITY IS THE ROLE, NEVER THE WEIGHT. autobee 2.12.1 cannot lower a writer's weight (measured, | |
| 20 | // ROOM-CONTRACT §3.3), so apply never reads weight to decide a permission. Admin devices are granted | |
| 21 | // weight 2 (ordering priority); a demoted admin loses its powers in the same apply and keeps only the | |
| 22 | // tie-break priority. | |
| 23 | // ⛔ JOINING IS BY INVITATION, on the joiner's OWN block (ROOM-CONTRACT §4, measured on 2.12.1): the | |
| 24 | // joiner's `addWriter(self)` is a pending promotion that the next block of any weighted member anchors. | |
| 19 | 25 | // |
| 20 | // ⛔ And weights bring their own rule, MEASURED while building namespace admins: `system.addWriter` | |
| 21 | // caps a grant at the weight of the block CARRYING it, so nobody can admit themselves and the cap | |
| 22 | // is SILENT. Admission is therefore always an act by somebody already inside — which is what a room | |
| 23 | // wanted anyway. | |
| 24 | // | |
| 25 | // ── Visibility is a PLACE ───────────────────────────────────────────────────────────────────── | |
| 26 | // | |
| 27 | // SOCIAL-MODEL §4: you do not mark a post followers-only, you write it in a room whose members are | |
| 28 | // your followers. That only holds if membership actually gates writing, which is what `applyOps` | |
| 29 | // enforces — otherwise "visibility is a place" is a promise instead of a property. | |
| 26 | // Decided 2026-10-13: a ban is enforced in apply (liftable); join requests are accepted by moderators and | |
| 27 | // admins; `read-only` stays a role; v1 rooms are not migrated (a v1 op is ignored). | |
| 30 | 28 | |
| 31 | 29 | import Autobee from 'autobee' |
| 32 | 30 | import b4a from 'b4a' |
| 31 | import crypto from 'hypercore-crypto' | |
| 33 | 32 | import { readAttested, signAttested } from './attestation.js' |
| 34 | 33 | import { recordName } from './name.js' |
| 35 | 34 | |
| 36 | 35 | const SEP = '/' |
| 37 | const MEMBER = '__members__' | |
| 38 | const DEVICE = '__devices__' | |
| 39 | 36 | const FOUNDED = '__founded__' |
| 40 | const CONFIG = '__config__' | |
| 41 | // A node asking to be let in. ⛔ The one op a not-yet-member can usefully send: an admin cannot | |
| 42 | // admit a writer key it has no way to learn, and the joiner cannot grant itself weight, so without | |
| 43 | // this the ceremony has a hole a person cannot cross. | |
| 44 | const REQUEST = '__requests__' | |
| 37 | const MEMBER = '__members__' | |
| 38 | const DEVICE = '__devices__' // ⛔ keyed by WRITER key — the lookup authority-by-carrier needs | |
| 39 | const INVITE = '__invites__' | |
| 40 | const REQUEST = '__requests__' // keyed by the joiner's writer key | |
| 41 | const SETTINGS = '__settings__' | |
| 42 | const FLAG = '__flags__' | |
| 45 | 43 | const MSG = 'message' |
| 46 | 44 | // ⛔ A community is a ROOM WHOSE LISTINGS ARE ROOMS — convention, not a type. |
| 47 | // ROOMS-ARE-THE-COMPUTER: "the registry was a room with extra steps". So a community inherits | |
| 48 | // members, roles, invites and chat instead of needing its own version of each. | |
| 49 | 45 | const LISTING = 'listing' |
| 50 | 46 | |
| 51 | // keet's roles. Ordered, because every check here is "at least this much". | |
| 52 | 47 | export const READ_ONLY = 'read-only' |
| 53 | 48 | export const PEER = 'peer' |
| 54 | 49 | export const MODERATOR = 'moderator' |
| … | … | @@ -56,48 +51,38 @@ export const ADMIN = 'admin' |
| 56 | 51 | const RANK = { [READ_ONLY]: 0, [PEER]: 1, [MODERATOR]: 2, [ADMIN]: 3 } |
| 57 | 52 | const isRole = (r) => Object.prototype.hasOwnProperty.call(RANK, r) |
| 58 | 53 | |
| 59 | // keet's five built-in config keys. ⛔ A closed set: anything else is a message by convention | |
| 60 | // (ROOMS-ARE-THE-COMPUTER, "_hs:room-meta"), so config cannot quietly become a key-value store. | |
| 61 | const CONFIG_KEYS = new Set(['title', 'description', 'avatar', 'supportedLanguages', 'blindPeers']) | |
| 54 | // ⛔ A CLOSED set (ROOM-CONTRACT §3.9). Authority-bearing state goes where apply can gate it; adding a key | |
| 55 | // bumps OP_VERSION. `public` / `bridgedTo` / `curation` are deliberately deferred (§8, with the gateway). | |
| 56 | const SETTINGS_KEYS = new Set(['title', 'description', 'avatar', 'supportedLanguages', 'blindPeers', | |
| 57 | 'isJoiningDisabled', 'isAppendingDisabled', 'inviteHidden']) | |
| 62 | 58 | |
| 63 | const OP_VERSION = 1 | |
| 59 | export const OP_VERSION = 2 | |
| 64 | 60 | const enc = (v) => b4a.from(JSON.stringify(v), 'utf8') |
| 65 | 61 | const dec = (b) => (b == null ? null : JSON.parse(b4a.toString(b, 'utf8'))) |
| 66 | 62 | const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex')) |
| 67 | 63 | const isKeyHex = (s) => typeof s === 'string' && /^[0-9a-f]{64}$/i.test(s) |
| 68 | 64 | const isSeg = (s) => typeof s === 'string' && s.length > 0 && !s.includes(SEP) && s !== '.' && s !== '..' |
| 65 | const truthy = (v) => v === true || v === 'true' || v === 1 | |
| 69 | 66 | |
| 70 | 67 | const memberKey = (root) => MEMBER + SEP + root |
| 71 | const deviceKey = (device) => DEVICE + SEP + device | |
| 72 | const msgKey = (name) => MSG + SEP + name | |
| 68 | const deviceKey = (writer) => DEVICE + SEP + writer | |
| 69 | const inviteKey = (id) => INVITE + SEP + id | |
| 73 | 70 | const requestKey = (writer) => REQUEST + SEP + writer |
| 71 | const msgKey = (name) => MSG + SEP + name | |
| 72 | const flagKey = (target, by) => FLAG + SEP + target + SEP + by | |
| 74 | 73 | const listingKey = (room) => LISTING + SEP + room |
| 75 | 74 | |
| 76 | /** The bytes an op's proof covers: every field except the proof, keys sorted. Same as namespace.js. */ | |
| 75 | /** The bytes a `join` signature covers: every field except `sig`, keys sorted. */ | |
| 77 | 76 | export function canonicalOpBytes (op) { |
| 78 | const rest = {} | |
| 79 | for (const k of Object.keys(op)) if (k !== 'proof') rest[k] = op[k] | |
| 80 | 77 | const ordered = {} |
| 81 | for (const k of Object.keys(rest).sort()) ordered[k] = rest[k] | |
| 78 | for (const k of Object.keys(op).sort()) if (k !== 'sig') ordered[k] = op[k] | |
| 82 | 79 | return b4a.from(JSON.stringify(ordered), 'utf8') |
| 83 | 80 | } |
| 84 | 81 | |
| 85 | const sign = (op, cred) => ({ ...op, proof: signAttested(canonicalOpBytes(op), cred) }) | |
| 86 | ||
| 87 | /** | |
| 88 | * Who signed this op, if it verifies and names this room. | |
| 89 | * | |
| 90 | * ⛔ `roomHex` is inside the signed bytes for the reason namespace.js learned: without it a signed | |
| 91 | * op lifts verbatim into another room, and an admin here becomes an admin there. | |
| 92 | */ | |
| 93 | function authorise (op, roomHex) { | |
| 94 | if (op.v !== OP_VERSION) return null | |
| 95 | if (!isKeyHex(op.root)) return null | |
| 96 | if (op.room !== roomHex) return null | |
| 97 | return readAttested(op.proof, canonicalOpBytes(op), op.root) | |
| 98 | } | |
| 82 | // Weight follows the role at the moment it is granted. ⚠ It can rise, never fall (see the header). | |
| 83 | const weightFor = (role) => ({ isIndexer: role === ADMIN }) | |
| 99 | 84 | |
| 100 | /** The { get, set, scan } surface apply wants, over one hyperbee2 batch. Same shape as elsewhere. */ | |
| 85 | /** The { get, set, scan } surface apply wants, over one hyperbee2 batch. */ | |
| 101 | 86 | function batchView (bee, batch) { |
| 102 | 87 | const pending = new Map() |
| 103 | 88 | return { |
| … | … | @@ -123,243 +108,292 @@ function batchView (bee, batch) { |
| 123 | 108 | } |
| 124 | 109 | |
| 125 | 110 | /** |
| 126 | * The role the SIGNER of an op acts with, or null. | |
| 111 | * The member acting through this writer core, or null. | |
| 127 | 112 | * |
| 128 | * ⛔ By the SIGNED ROOT, never by the writer core that carried the block. Two reasons, and the | |
| 129 | * second is the one that bites: | |
| 130 | * | |
| 131 | * 1. A role belongs to a PERSON. The proof says which identity signed these exact bytes, so the | |
| 132 | * answer does not change with which of their machines happened to append it. | |
| 133 | * 2. Authorising by writer core cannot tell two signers apart when one core carries both — which | |
| 134 | * is every case where a node relays, and was every case in the first draft of the tests. A | |
| 135 | * stranger's op looked like the room founder's because the founder's node appended it. | |
| 136 | * | |
| 137 | * ⚠ The DEVICE still matters, for revocation: a retired device's signature must stop counting even | |
| 138 | * though its member is still in the room. That is checked here, at the level the chain expresses. | |
| 113 | * ⛔ THE ONE AUTHORISATION RULE (ROOM-CONTRACT §3.1.1): unknown writer, inactive device, or inactive or | |
| 114 | * removed member → the op is ignored. | |
| 139 | 115 | */ |
| 140 | async function roleOfSigner (b, proof) { | |
| 141 | if (!proof) return null | |
| 142 | const d = await b.get(deviceKey(proof.device)) | |
| 143 | if (d && d.removed) return null | |
| 144 | const m = await b.get(memberKey(proof.root)) | |
| 145 | if (!m || m.removed) return null | |
| 146 | return m.role | |
| 116 | async function actorOf (b, writer) { | |
| 117 | const d = await b.get(deviceKey(writer)) | |
| 118 | if (!d || !d.isActive) return null | |
| 119 | const m = await b.get(memberKey(d.member)) | |
| 120 | if (!m || !m.isActive || m.isRemoved) return null | |
| 121 | return { root: d.member, role: m.role, muted: !!m.isMuted, banned: !!m.isBanned } | |
| 147 | 122 | } |
| 148 | 123 | |
| 149 | const at = async (b, proof, role) => { | |
| 150 | const r = await roleOfSigner(b, proof) | |
| 151 | return r != null && RANK[r] >= RANK[role] | |
| 124 | const atLeast = (actor, role) => !!actor && RANK[actor.role] >= RANK[role] | |
| 125 | ||
| 126 | async function adminCount (b, except = null) { | |
| 127 | let n = 0 | |
| 128 | for (const e of await b.scan(MEMBER + SEP)) { | |
| 129 | const root = e.key.slice(MEMBER.length + 1) | |
| 130 | if (root === except) continue | |
| 131 | if (e.value && e.value.isActive && !e.value.isRemoved && e.value.role === ADMIN) n++ | |
| 132 | } | |
| 133 | return n | |
| 134 | } | |
| 135 | ||
| 136 | // Every ACTIVE device of a member, as [{ key, value }]. | |
| 137 | async function devicesOf (b, root) { | |
| 138 | return (await b.scan(DEVICE + SEP)).filter((e) => e.value && e.value.member === root && e.value.isActive) | |
| 152 | 139 | } |
| 153 | 140 | |
| 154 | async function applyOps (batch, view, host, roomHex) { | |
| 141 | /** Make `root` an active member at `role` and bind this writer core to it. Used by join and accept-join. */ | |
| 142 | async function admit (b, host, { root, device, writer, role }) { | |
| 143 | const prior = await b.get(memberKey(root)) | |
| 144 | await b.set(memberKey(root), { | |
| 145 | role, | |
| 146 | isActive: true, | |
| 147 | isRemoved: false, | |
| 148 | isMuted: prior ? !!prior.isMuted : false, | |
| 149 | isBanned: prior ? !!prior.isBanned : false | |
| 150 | }) | |
| 151 | await b.set(deviceKey(writer), { member: root, device, isActive: true }) | |
| 152 | try { host.addWriter(b4a.from(writer, 'hex'), weightFor(role)) } catch {} | |
| 153 | } | |
| 154 | ||
| 155 | /** The role an invitation grants now. ⛔ An admin/moderator grant is single-use (Keet `:169-171`). */ | |
| 156 | const grantOf = (inv) => (RANK[inv.role] >= RANK[MODERATOR] && inv.used ? PEER : inv.role) | |
| 157 | ||
| 158 | async function applyOps (batch, view, host) { | |
| 155 | 159 | const write = view.write() |
| 156 | 160 | const b = batchView(view, write) |
| 157 | 161 | |
| 158 | 162 | for (const node of batch) { |
| 159 | 163 | let op |
| 160 | 164 | try { op = dec(node.value) } catch { continue } |
| 161 | if (!op || typeof op !== 'object') continue | |
| 162 | const writer = node.key ? hex(node.key) : null | |
| 165 | if (!op || typeof op !== 'object' || op.v !== OP_VERSION) continue | |
| 166 | const writer = node.key ? hex(node.key).toLowerCase() : null | |
| 163 | 167 | if (!writer) continue |
| 168 | const writerBytes = b4a.from(writer, 'hex') | |
| 164 | 169 | |
| 165 | // A room takes writes from a member whose device this is; an unknown writer's block still has | |
| 166 | // to stick long enough to be judged, which is what the ack is for. It grants nothing. | |
| 167 | try { host.ackWriter(node.key) } catch {} | |
| 168 | ||
| 169 | // ── genesis ──────────────────────────────────────────────────────────────────────────────── | |
| 170 | // ⛔ genesis is checked BEFORE the shared authorise below, because it is the one op whose | |
| 171 | // signer is not yet a member of anything. | |
| 170 | // ── genesis: the founder binds its own writer core ────────────────────────────────────────── | |
| 172 | 171 | if (op.t === 'found') { |
| 173 | 172 | if (await b.get(FOUNDED)) continue |
| 174 | const proof = authorise(op, roomHex) | |
| 173 | const proof = readAttested(op.bound, writerBytes) | |
| 175 | 174 | if (!proof) continue |
| 176 | await b.set(FOUNDED, { root: proof.root }) | |
| 177 | await b.set(memberKey(proof.root), { root: proof.root, role: ADMIN }) | |
| 178 | await b.set(deviceKey(proof.device), { root: proof.root, device: proof.device, writer }) | |
| 179 | try { host.addWriter(node.key, { isIndexer: true }) } catch {} | |
| 175 | await b.set(FOUNDED, { member: proof.root }) | |
| 176 | await b.set(memberKey(proof.root), { role: ADMIN, isActive: true, isRemoved: false, isMuted: false, isBanned: false }) | |
| 177 | await b.set(deviceKey(writer), { member: proof.root, device: proof.device, isActive: true }) | |
| 178 | try { host.addWriter(node.key, weightFor(ADMIN)) } catch {} | |
| 179 | // A DM is a room founded with both members (Keet `internal.js:57-64`); the other person's node then | |
| 180 | // binds its own core with `add-device`. | |
| 181 | for (const m of Array.isArray(op.initialMembers) ? op.initialMembers : []) { | |
| 182 | const root = m && String(m.root || '').toLowerCase() | |
| 183 | if (!isKeyHex(root) || root === proof.root) continue | |
| 184 | const role = isRole(m.role) && m.role !== ADMIN ? m.role : PEER | |
| 185 | await b.set(memberKey(root), { role, isActive: true, isRemoved: false, isMuted: false, isBanned: false }) | |
| 186 | } | |
| 180 | 187 | continue |
| 181 | 188 | } |
| 182 | 189 | |
| 183 | // ── members ──────────────────────────────────────────────────────────────────────────────── | |
| 184 | /** | |
| 185 | * ⛔ ONE authorise, above every handler that needs it. The first version declared this in the | |
| 186 | * middle of the switch and a handler above it read `signer` in the temporal dead zone — a | |
| 187 | * ReferenceError thrown INSIDE apply, which wedges the base rather than rejecting an op. apply | |
| 188 | * must be total; a `const` used before its declaration is a way to make it not. | |
| 189 | * | |
| 190 | * `found` is deliberately above this: it is the one op whose signer is not yet a member of | |
| 191 | * anything, so it does its own check. | |
| 192 | */ | |
| 193 | const signer = authorise(op, roomHex) | |
| 194 | if (!signer) continue | |
| 195 | ||
| 196 | // ── asking to be let in ──────────────────────────────────────────────────────────────────── | |
| 197 | // | |
| 198 | // Recorded, and it grants nothing: not membership, not weight, not the ability to write. It | |
| 199 | // says a writer core exists and whose it is, which is exactly what an admin needs to answer. | |
| 200 | if (op.t === 'request') { | |
| 201 | if (!isKeyHex(op.writer)) continue | |
| 202 | const bound = readAttested(op.bound, b4a.from(op.writer, 'hex')) | |
| 203 | if (!bound || bound.root !== signer.root) continue | |
| 204 | await b.set(requestKey(op.writer.toLowerCase()), { root: signer.root, device: signer.device }) | |
| 190 | // ── joining, on the joiner's OWN block (§3.6) ─────────────────────────────────────────────── | |
| 191 | if (op.t === 'join') { | |
| 192 | if (!isKeyHex(op.invite) || typeof op.sig !== 'string') continue | |
| 193 | const settings = (await b.get(SETTINGS)) || {} | |
| 194 | if (truthy(settings.isJoiningDisabled)) continue | |
| 195 | const id = op.invite.toLowerCase() | |
| 196 | const inv = await b.get(inviteKey(id)) | |
| 197 | if (!inv || !inv.isActive) continue // ⛔ unlike Keet, a revoked invitation does not admit | |
| 198 | let ok = false | |
| 199 | try { ok = crypto.verify(canonicalOpBytes(op), b4a.from(op.sig, 'hex'), b4a.from(id, 'hex')) } catch {} | |
| 200 | if (!ok) continue | |
| 201 | // ⛔ The attestation must name THE CARRIER: a join lifted onto another core binds nothing. | |
| 202 | const proof = readAttested(op.bound, writerBytes) | |
| 203 | if (!proof) continue | |
| 204 | const m = await b.get(memberKey(proof.root)) | |
| 205 | if (m && m.isRemoved) continue | |
| 206 | if (await b.get(deviceKey(writer))) continue | |
| 207 | if (inv.requiresConfirmation) { | |
| 208 | await b.set(requestKey(writer), { invite: id, member: proof.root, device: proof.device }) | |
| 209 | continue | |
| 210 | } | |
| 211 | await admit(b, host, { root: proof.root, device: proof.device, writer, role: grantOf(inv) }) | |
| 212 | if (RANK[inv.role] >= RANK[MODERATOR] && !inv.used) await b.set(inviteKey(id), { ...inv, used: true }) | |
| 205 | 213 | continue |
| 206 | 214 | } |
| 207 | 215 | |
| 208 | ||
| 209 | if (op.t === 'add-member') { | |
| 210 | if (!isKeyHex(op.member) || !isRole(op.role)) continue | |
| 211 | // ⛔ Appointing is an ADMIN act. A moderator removes; only an admin decides who is in. | |
| 212 | if (!(await at(b, signer, ADMIN))) continue | |
| 213 | const existing = await b.get(memberKey(op.member)) | |
| 214 | if (existing && !existing.removed) continue | |
| 215 | await b.set(memberKey(op.member), { root: op.member, role: op.role }) | |
| 216 | // ── a member's new device, on that device's own block ─────────────────────────────────────── | |
| 217 | if (op.t === 'add-device') { | |
| 218 | const proof = readAttested(op.bound, writerBytes) | |
| 219 | if (!proof) continue | |
| 220 | const m = await b.get(memberKey(proof.root)) | |
| 221 | if (!m || !m.isActive || m.isRemoved) continue | |
| 222 | if (await b.get(deviceKey(writer))) continue | |
| 223 | await b.set(deviceKey(writer), { member: proof.root, device: proof.device, isActive: true }) | |
| 224 | try { host.addWriter(node.key, weightFor(m.role)) } catch {} | |
| 216 | 225 | continue |
| 217 | 226 | } |
| 218 | 227 | |
| 219 | if (op.t === 'set-role') { | |
| 220 | if (!isKeyHex(op.member) || !isRole(op.role)) continue | |
| 221 | if (!(await at(b, signer, ADMIN))) continue | |
| 222 | const m = await b.get(memberKey(op.member)) | |
| 223 | if (!m || m.removed) continue | |
| 224 | // ⛔ Never the last admin. A room with no admin can admit nobody ever again — nothing left | |
| 225 | // carries the weight — and that is unrecoverable rather than merely awkward. | |
| 226 | if (m.role === ADMIN && op.role !== ADMIN && !(await hasOtherAdmin(b, op.member))) continue | |
| 227 | await b.set(memberKey(op.member), { ...m, role: op.role }) | |
| 228 | // ── everything else: the actor is whoever owns the carrying core ───────────────────────────── | |
| 229 | const actor = await actorOf(b, writer) | |
| 230 | if (!actor) continue | |
| 231 | ||
| 232 | if (op.t === 'invite') { | |
| 233 | if (!isKeyHex(op.id) || !isRole(op.role)) continue | |
| 234 | if (actor.muted || actor.banned) continue | |
| 235 | const settings = (await b.get(SETTINGS)) || {} | |
| 236 | if (truthy(settings.inviteHidden) && !atLeast(actor, MODERATOR)) continue | |
| 237 | const id = op.id.toLowerCase() | |
| 238 | if (await b.get(inviteKey(id))) continue | |
| 239 | // Capped at the inviter's own role (Keet `:215-216`). | |
| 240 | const role = RANK[op.role] > RANK[actor.role] ? actor.role : op.role | |
| 241 | await b.set(inviteKey(id), { by: actor.root, role, requiresConfirmation: !!op.requiresConfirmation, isActive: true, used: false }) | |
| 228 | 242 | continue |
| 229 | 243 | } |
| 230 | 244 | |
| 231 | if (op.t === 'remove-member') { | |
| 232 | if (!isKeyHex(op.member)) continue | |
| 233 | // A moderator may remove. SOCIAL-MODEL §6.7: moderation needs no new permission system. | |
| 234 | if (!(await at(b, signer, MODERATOR))) continue | |
| 235 | const m = await b.get(memberKey(op.member)) | |
| 236 | if (!m || m.removed) continue | |
| 237 | if (m.role === ADMIN && !(await hasOtherAdmin(b, op.member))) continue | |
| 238 | await b.set(memberKey(op.member), { ...m, removed: true }) | |
| 239 | // ⚠ Their devices go with them. A member-level decision repeated per device is a rule | |
| 240 | // somebody eventually applies incompletely. | |
| 241 | for (const e of await b.scan(DEVICE + SEP)) { | |
| 242 | if (!e.value || e.value.root !== op.member || e.value.removed) continue | |
| 243 | await b.set(e.key, { ...e.value, removed: true }) | |
| 244 | if (e.value.writer) { try { host.removeWriter(b4a.from(e.value.writer, 'hex')) } catch {} } | |
| 245 | } | |
| 245 | if (op.t === 'uninvite') { | |
| 246 | if (!isKeyHex(op.id)) continue | |
| 247 | const inv = await b.get(inviteKey(op.id.toLowerCase())) | |
| 248 | if (!inv) continue | |
| 249 | if (inv.by !== actor.root && !atLeast(actor, MODERATOR)) continue | |
| 250 | await b.set(inviteKey(op.id.toLowerCase()), { ...inv, isActive: false }) | |
| 246 | 251 | continue |
| 247 | 252 | } |
| 248 | 253 | |
| 249 | // ── devices ──────────────────────────────────────────────────────────────────────────────── | |
| 250 | // | |
| 251 | // ⛔ Carried by somebody already inside, because a grant is capped at the carrying block's | |
| 252 | // weight and a joiner's block has none. The device proves WHOSE it is; the admin supplies the | |
| 253 | // weight. Both halves are needed and neither is sufficient. | |
| 254 | // Answer an ask: admit the writer that asked. ⛔ It must have ASKED — an admin conscripting a | |
| 255 | // machine that never offered would be admitting somebody TO a room rather than WITH them, and | |
| 256 | // the request is also the only place the writer key legitimately comes from. | |
| 257 | if (op.t === 'admit-request') { | |
| 254 | if (op.t === 'accept-join' || op.t === 'reject-join') { | |
| 258 | 255 | if (!isKeyHex(op.writer)) continue |
| 259 | if (!(await at(b, signer, ADMIN))) continue | |
| 260 | const asked = await b.get(requestKey(op.writer.toLowerCase())) | |
| 261 | if (!asked || asked.removed) continue | |
| 262 | const m = await b.get(memberKey(asked.root)) | |
| 263 | if (!m || m.removed) continue | |
| 264 | await b.set(deviceKey(asked.device), { root: asked.root, device: asked.device, writer: op.writer.toLowerCase() }) | |
| 265 | await b.set(requestKey(op.writer.toLowerCase()), { ...asked, removed: true }) | |
| 266 | try { host.addWriter(b4a.from(op.writer, 'hex'), { isIndexer: true }) } catch {} | |
| 256 | if (!atLeast(actor, MODERATOR)) continue // decided 2026-10-13: moderators and admins, not any member | |
| 257 | const w = op.writer.toLowerCase() | |
| 258 | const req = await b.get(requestKey(w)) | |
| 259 | if (!req || req.done) continue | |
| 260 | await b.set(requestKey(w), { ...req, done: op.t === 'accept-join' ? 'accepted' : 'rejected' }) | |
| 261 | if (op.t === 'reject-join') continue | |
| 262 | const inv = await b.get(inviteKey(req.invite)) | |
| 263 | if (!inv || !inv.isActive) continue | |
| 264 | const m = await b.get(memberKey(req.member)) | |
| 265 | if (m && m.isRemoved) continue | |
| 266 | if (await b.get(deviceKey(w))) continue | |
| 267 | await admit(b, host, { root: req.member, device: req.device, writer: w, role: grantOf(inv) }) | |
| 268 | if (RANK[inv.role] >= RANK[MODERATOR] && !inv.used) await b.set(inviteKey(req.invite), { ...inv, used: true }) | |
| 267 | 269 | continue |
| 268 | 270 | } |
| 269 | 271 | |
| 270 | if (op.t === 'admit-device') { | |
| 272 | if (op.t === 'remove-device') { | |
| 271 | 273 | if (!isKeyHex(op.writer)) continue |
| 272 | if (!(await at(b, signer, ADMIN))) continue | |
| 273 | const proof = readAttested(op.bound, b4a.from(op.writer, 'hex')) | |
| 274 | if (!proof) continue | |
| 275 | const m = await b.get(memberKey(proof.root)) | |
| 276 | if (!m || m.removed) continue | |
| 277 | await b.set(deviceKey(proof.device), { root: proof.root, device: proof.device, writer: op.writer.toLowerCase() }) | |
| 278 | try { host.addWriter(b4a.from(op.writer, 'hex'), { isIndexer: true }) } catch {} | |
| 274 | const w = op.writer.toLowerCase() | |
| 275 | const d = await b.get(deviceKey(w)) | |
| 276 | if (!d || !d.isActive || d.member !== actor.root) continue // ⛔ only the member owning it | |
| 277 | await b.set(deviceKey(w), { ...d, isActive: false }) | |
| 278 | try { host.removeWriter(b4a.from(w, 'hex')) } catch {} | |
| 279 | 279 | continue |
| 280 | 280 | } |
| 281 | 281 | |
| 282 | // ── listings ─────────────────────────────────────────────────────────────────────────────── | |
| 283 | // | |
| 284 | // ⛔ A MODERATOR act. COMMUNITY-MODERATION: admins and moderators curate LISTINGS — and they | |
| 285 | // cannot moderate inside a listed room. Two rooms, two scopes, and the gap between them is | |
| 286 | // deliberate: "the community admin is a neighbourhood association; they decide which bars are | |
| 287 | // listed in the directory, they don't enter the bars." | |
| 288 | // ⛔ `listed`, NOT `room`. The envelope already uses `room` for THIS room's key, which is what | |
| 289 | // stops an op signed here from being replayed into another room — and a payload field of the | |
| 290 | // same name overwrote it, so every listing failed the anti-lift check before reaching this | |
| 291 | // handler and vanished with no error. The envelope owns that name. | |
| 292 | if (op.t === 'list-room') { | |
| 293 | if (!isKeyHex(op.listed)) continue | |
| 294 | if (!(await at(b, signer, MODERATOR))) continue | |
| 295 | await b.set(listingKey(op.listed.toLowerCase()), { | |
| 296 | room: op.listed.toLowerCase(), | |
| 297 | note: String(op.note || '').slice(0, 280), | |
| 298 | by: signer.root | |
| 299 | }) | |
| 282 | if (op.t === 'set-role') { | |
| 283 | const target = String(op.member || '').toLowerCase() | |
| 284 | if (!isKeyHex(target) || !isRole(op.role)) continue | |
| 285 | if (!atLeast(actor, MODERATOR)) continue | |
| 286 | const m = await b.get(memberKey(target)) | |
| 287 | if (!m || !m.isActive || m.isRemoved) continue | |
| 288 | // ⛔ Touching an admin, or granting admin, takes an admin (Keet `:388`). | |
| 289 | if ((m.role === ADMIN || op.role === ADMIN) && !atLeast(actor, ADMIN)) continue | |
| 290 | // ⛔ Never the last admin: a room with no admin can never grant admin again. | |
| 291 | if (m.role === ADMIN && op.role !== ADMIN && (await adminCount(b, target)) === 0) continue | |
| 292 | await b.set(memberKey(target), { ...m, role: op.role }) | |
| 293 | // Granting admin raises each active device to weight 2. ⚠ Demotion cannot lower it (header). | |
| 294 | if (op.role === ADMIN && m.role !== ADMIN) { | |
| 295 | for (const e of await devicesOf(b, target)) { | |
| 296 | try { host.addWriter(b4a.from(e.key.slice(DEVICE.length + 1), 'hex'), weightFor(ADMIN)) } catch {} | |
| 297 | } | |
| 298 | } | |
| 300 | 299 | continue |
| 301 | 300 | } |
| 302 | 301 | |
| 303 | if (op.t === 'delist-room') { | |
| 304 | if (!isKeyHex(op.listed)) continue | |
| 305 | if (!(await at(b, signer, MODERATOR))) continue | |
| 306 | const l = await b.get(listingKey(op.listed.toLowerCase())) | |
| 307 | if (!l) continue | |
| 308 | // ⚠ A tombstone that stops PROPAGATION, not existence. The listed room is untouched and | |
| 309 | // anybody already holding its key still has it — a registry is a phone book, not a gate. | |
| 310 | await b.set(listingKey(op.listed.toLowerCase()), { ...l, removed: true }) | |
| 302 | if (op.t === 'set-status') { | |
| 303 | const target = String(op.member || '').toLowerCase() | |
| 304 | if (!isKeyHex(target)) continue | |
| 305 | if (!atLeast(actor, MODERATOR)) continue | |
| 306 | const m = await b.get(memberKey(target)) | |
| 307 | if (!m || m.isRemoved) continue | |
| 308 | if (m.role === ADMIN && !atLeast(actor, ADMIN)) continue | |
| 309 | const next = { ...m } | |
| 310 | if (op.muted !== undefined) next.isMuted = !!op.muted | |
| 311 | if (op.banned !== undefined) next.isBanned = !!op.banned | |
| 312 | await b.set(memberKey(target), next) | |
| 311 | 313 | continue |
| 312 | 314 | } |
| 313 | 315 | |
| 314 | // ── config ───────────────────────────────────────────────────────────────────────────────── | |
| 315 | if (op.t === 'config') { | |
| 316 | if (!CONFIG_KEYS.has(op.key)) continue | |
| 317 | if (!(await at(b, signer, MODERATOR))) continue | |
| 318 | const cur = (await b.get(CONFIG)) || {} | |
| 319 | await b.set(CONFIG, { ...cur, [op.key]: String(op.value == null ? '' : op.value).slice(0, 512) }) | |
| 316 | if (op.t === 'remove-member') { | |
| 317 | const target = String(op.member || '').toLowerCase() | |
| 318 | if (!isKeyHex(target)) continue | |
| 319 | const m = await b.get(memberKey(target)) | |
| 320 | if (!m || m.isRemoved || !m.isActive) continue | |
| 321 | const self = target === actor.root | |
| 322 | if (!self && !atLeast(actor, MODERATOR)) continue | |
| 323 | // ⛔ An admin is removed only by themself (Keet SELF_ONLY), and never the last one. | |
| 324 | if (m.role === ADMIN && !self) continue | |
| 325 | if (m.role === ADMIN && (await adminCount(b, target)) === 0) continue | |
| 326 | // By a moderator: removed for good. By themself: left — reversible through an invitation. | |
| 327 | await b.set(memberKey(target), { ...m, isActive: false, isRemoved: !self }) | |
| 328 | for (const e of await devicesOf(b, target)) { | |
| 329 | await b.set(e.key, { ...e.value, isActive: false }) | |
| 330 | try { host.removeWriter(b4a.from(e.key.slice(DEVICE.length + 1), 'hex')) } catch {} | |
| 331 | } | |
| 320 | 332 | continue |
| 321 | 333 | } |
| 322 | 334 | |
| 323 | // ── messages ─────────────────────────────────────────────────────────────────────────────── | |
| 324 | 335 | if (op.t === 'message') { |
| 325 | 336 | if (!isSeg(op.name)) continue |
| 326 | // ⛔ PEER and up. A read-only member is a member who cannot write, and if anyone could write | |
| 327 | // then membership would describe nothing and "visibility is a place" would be a promise | |
| 328 | // rather than a property. | |
| 329 | if (!(await at(b, signer, PEER))) continue | |
| 330 | // ⛔ A NAME ALREADY HELD BY SOMEBODY ELSE IS REFUSED. This was an unconditional `set`, and the | |
| 331 | // row carries `author`, so posting under a name another member had used replaced their words | |
| 332 | // AND took their byline — silently, with nothing in the view to show it. Not a race: an op is | |
| 333 | // `{t:'message', name, body}`, so any writer on this autobee can pick the name directly no | |
| 334 | // matter what the RPC surface allows. lib/name.js makes an accidental collision impractical; | |
| 335 | // only apply can make a deliberate one ineffective, because apply must never trust the op. | |
| 336 | // | |
| 337 | // Same author is still allowed through: that is a revision of your own record, and it is the | |
| 338 | // only edit path a room has today. | |
| 337 | // ⛔ PEER and up, not muted, not banned. A read-only member reads and flags. | |
| 338 | if (!atLeast(actor, PEER) || actor.muted || actor.banned) continue | |
| 339 | const settings = (await b.get(SETTINGS)) || {} | |
| 340 | if (truthy(settings.isAppendingDisabled) && !atLeast(actor, MODERATOR)) continue | |
| 341 | // ⛔ A name held by somebody else is refused; the same author revises their own. | |
| 339 | 342 | const prior = await b.get(msgKey(op.name)) |
| 340 | if (prior && prior.author && prior.author !== signer.root) continue | |
| 341 | await b.set(msgKey(op.name), { | |
| 342 | author: signer.root, body: String(op.body || ''), at: typeof op.at === 'number' ? op.at : 0 | |
| 343 | }) | |
| 343 | if (prior && prior.author !== actor.root) continue | |
| 344 | if (prior && prior.deleted) continue | |
| 345 | await b.set(msgKey(op.name), { author: actor.root, body: String(op.body || ''), at: typeof op.at === 'number' ? op.at : 0 }) | |
| 344 | 346 | continue |
| 345 | 347 | } |
| 346 | } | |
| 347 | 348 | |
| 348 | await write.flush() | |
| 349 | } | |
| 349 | if (op.t === 'delete-message') { | |
| 350 | if (!isSeg(op.name)) continue | |
| 351 | const prior = await b.get(msgKey(op.name)) | |
| 352 | if (!prior || prior.deleted) continue | |
| 353 | if (prior.author !== actor.root && !atLeast(actor, MODERATOR)) continue | |
| 354 | // The body leaves the view; the tombstone says who removed it. | |
| 355 | await b.set(msgKey(op.name), { author: prior.author, deleted: true, by: actor.root, at: prior.at }) | |
| 356 | continue | |
| 357 | } | |
| 350 | 358 | |
| 351 | async function hasOtherAdmin (b, except) { | |
| 352 | for (const e of await b.scan(MEMBER + SEP)) { | |
| 353 | if (!e.value || e.value.removed || e.value.role !== ADMIN) continue | |
| 354 | if (e.value.root !== except) return true | |
| 359 | if (op.t === 'flag') { | |
| 360 | if (typeof op.target !== 'string' || op.target.length === 0 || op.target.length > 256) continue | |
| 361 | if (actor.muted) continue | |
| 362 | await b.set(flagKey(op.target, actor.root), { reason: String(op.reason || '').slice(0, 280), at: typeof op.at === 'number' ? op.at : 0 }) | |
| 363 | continue | |
| 364 | } | |
| 365 | ||
| 366 | if (op.t === 'settings') { | |
| 367 | if (!SETTINGS_KEYS.has(op.key)) continue | |
| 368 | if (!atLeast(actor, MODERATOR)) continue | |
| 369 | const cur = (await b.get(SETTINGS)) || {} | |
| 370 | await b.set(SETTINGS, { ...cur, [op.key]: String(op.value == null ? '' : op.value).slice(0, 512) }) | |
| 371 | continue | |
| 372 | } | |
| 373 | ||
| 374 | // ⛔ A MODERATOR act (COMMUNITY-MODERATION): curate listings, never moderate inside a listed room. | |
| 375 | if (op.t === 'list-room') { | |
| 376 | if (!isKeyHex(op.listed) || !atLeast(actor, MODERATOR)) continue | |
| 377 | await b.set(listingKey(op.listed.toLowerCase()), { room: op.listed.toLowerCase(), note: String(op.note || '').slice(0, 280), by: actor.root }) | |
| 378 | continue | |
| 379 | } | |
| 380 | ||
| 381 | if (op.t === 'delist-room') { | |
| 382 | if (!isKeyHex(op.listed) || !atLeast(actor, MODERATOR)) continue | |
| 383 | const l = await b.get(listingKey(op.listed.toLowerCase())) | |
| 384 | if (!l) continue | |
| 385 | await b.set(listingKey(op.listed.toLowerCase()), { ...l, removed: true }) | |
| 386 | continue | |
| 387 | } | |
| 355 | 388 | } |
| 356 | return false | |
| 389 | ||
| 390 | await write.flush() | |
| 357 | 391 | } |
| 358 | 392 | |
| 359 | 393 | export class Room { |
| 360 | 394 | constructor (store, key = null) { |
| 361 | 395 | this.base = new Autobee(store, key || null, { |
| 362 | apply: (batch, view, host) => applyOps(batch, view, host, hex(this.base.key)), | |
| 396 | apply: (batch, view, host) => applyOps(batch, view, host), | |
| 363 | 397 | // A joiner's block has to reach apply before anybody can judge it. |
| 364 | 398 | optimistic: true, |
| 365 | 399 | fastForward: false |
| … | … | @@ -369,125 +403,122 @@ export class Room { |
| 369 | 403 | async ready () { await this.base.ready(); return this } |
| 370 | 404 | get key () { return this.base.key } |
| 371 | 405 | get discoveryKey () { return this.base.discoveryKey } |
| 406 | get writer () { return hex(this.base.local.key).toLowerCase() } | |
| 372 | 407 | async update () { await this.base.update() } |
| 373 | 408 | replicate (s) { return this.base.replicate(s) } |
| 374 | 409 | async close () { await this.base.close() } |
| 375 | 410 | |
| 376 | _root (cred) { | |
| 411 | // Ops carry no signature: the carrying core is the credential. `cred` parameters on ops other than | |
| 412 | // found/join/addDevice are accepted and ignored, so v1 callers keep working while they move. | |
| 413 | _op (t, extra) { return enc({ ...extra, t, v: OP_VERSION, at: Date.now() }) } | |
| 414 | _bind (cred) { | |
| 377 | 415 | if (!cred) throw new Error('this needs a device credential — run `favela link`') |
| 378 | const read = readAttested(cred.proof, undefined) | |
| 379 | if (!read) throw new Error('this device\'s credential does not verify') | |
| 380 | return read.root | |
| 416 | return signAttested(b4a.from(this.writer, 'hex'), cred) | |
| 381 | 417 | } |
| 382 | 418 | |
| 383 | 419 | /** |
| 384 | * ⛔ The envelope is written LAST, so a payload can never overwrite it. `room` and `root` are what | |
| 385 | * `authorise` checks — a payload field of the same name silently disarmed the anti-lift check and | |
| 386 | * every op of that kind vanished with no error anywhere. | |
| 420 | * Found the room; this device's identity becomes its first admin. ⛔ Non-optimistic: an optimistic writer | |
| 421 | * cannot be genesis. `initialMembers` [{root, role}] — a DM is a room founded with both people. | |
| 387 | 422 | */ |
| 388 | _op (t, extra, cred) { | |
| 389 | return sign({ ...extra, t, v: OP_VERSION, room: hex(this.base.key), root: this._root(cred), at: Date.now() }, cred) | |
| 423 | async found (cred, { initialMembers = [] } = {}) { | |
| 424 | await this.base.append(this._op('found', { bound: this._bind(cred), initialMembers })) | |
| 390 | 425 | } |
| 391 | 426 | |
| 392 | /** | |
| 393 | * Found the room. ⛔ Non-optimistic: an optimistic writer cannot be genesis | |
| 394 | * (autobee/lib/writers.js), so an optimistic founding op is never processed and the room comes up | |
| 395 | * with no admin and no way to add one. | |
| 396 | */ | |
| 397 | async found (cred) { | |
| 398 | await this.base.append(enc(this._op('found', {}, cred))) | |
| 427 | /** Mint an invitation. Returns the TOKEN to hand to the joiner (it carries the signing secret). */ | |
| 428 | async invite ({ role = PEER, requiresConfirmation = false } = {}) { | |
| 429 | const kp = crypto.keyPair() | |
| 430 | const id = hex(kp.publicKey) | |
| 431 | await this.base.append(this._op('invite', { id, role, requiresConfirmation: !!requiresConfirmation })) | |
| 432 | return { id, token: hex(kp.secretKey) } | |
| 399 | 433 | } |
| 400 | 434 | |
| 401 | async addMember (root, role, cred) { | |
| 402 | await this.base.append(enc(this._op('add-member', { member: String(root).toLowerCase(), role }, cred)), { optimistic: true }) | |
| 403 | } | |
| 435 | async uninvite (id) { await this.base.append(this._op('uninvite', { id: String(id).toLowerCase() })) } | |
| 404 | 436 | |
| 405 | async setRole (root, role, cred) { | |
| 406 | await this.base.append(enc(this._op('set-role', { member: String(root).toLowerCase(), role }, cred)), { optimistic: true }) | |
| 437 | /** Join with an invitation token — appended by THIS node's own core, optimistically. */ | |
| 438 | async join (token, cred) { | |
| 439 | const sk = b4a.from(String(token), 'hex') | |
| 440 | if (sk.length !== 64) throw new Error('an invitation token is 64 bytes of hex') | |
| 441 | const op = { t: 'join', v: OP_VERSION, invite: hex(sk.subarray(32)), bound: this._bind(cred), at: Date.now() } | |
| 442 | op.sig = hex(crypto.sign(canonicalOpBytes(op), sk)) | |
| 443 | await this.base.append(enc(op), { optimistic: true }) | |
| 407 | 444 | } |
| 408 | 445 | |
| 409 | async removeMember (root, cred) { | |
| 410 | await this.base.append(enc(this._op('remove-member', { member: String(root).toLowerCase() }, cred)), { optimistic: true }) | |
| 411 | } | |
| 446 | async acceptJoin (writer) { await this.base.append(this._op('accept-join', { writer: String(writer).toLowerCase() })) } | |
| 447 | async rejectJoin (writer) { await this.base.append(this._op('reject-join', { writer: String(writer).toLowerCase() })) } | |
| 412 | 448 | |
| 413 | /** | |
| 414 | * Admit a device — `by` is a member already inside, and `cred` is the device being admitted. | |
| 415 | * | |
| 416 | * ⚠ Two credentials, because the two halves are different facts: the device proves WHOSE it is, | |
| 417 | * and the admin supplies the weight that makes the grant stick. | |
| 418 | */ | |
| 419 | async admitDevice (cred, by, writerHex = null) { | |
| 420 | const w = writerHex || hex(this.base.local.key) | |
| 421 | const bound = signAttested(b4a.from(w, 'hex'), cred) | |
| 422 | await this.base.append(enc(this._op('admit-device', { writer: w.toLowerCase(), bound }, by)), { optimistic: true }) | |
| 423 | } | |
| 449 | /** Attach this node's core as another device of a member already in the room (no invitation). */ | |
| 450 | async addDevice (cred) { await this.base.append(this._op('add-device', { bound: this._bind(cred) }), { optimistic: true }) } | |
| 451 | async removeDevice (writer) { await this.base.append(this._op('remove-device', { writer: String(writer).toLowerCase() })) } | |
| 424 | 452 | |
| 425 | /** Ask to be let in. Appended optimistically — a joiner is not a writer yet. */ | |
| 426 | async request (cred) { return this.requestWriter(hex(this.base.local.key), cred) } | |
| 453 | async setRole (root, role) { await this.base.append(this._op('set-role', { member: String(root).toLowerCase(), role })) } | |
| 454 | async setStatus (root, { muted, banned } = {}) { await this.base.append(this._op('set-status', { member: String(root).toLowerCase(), muted, banned })) } | |
| 455 | async removeMember (root) { await this.base.append(this._op('remove-member', { member: String(root).toLowerCase() })) } | |
| 427 | 456 | |
| 428 | async requestWriter (writerHex, cred) { | |
| 429 | const bound = signAttested(b4a.from(writerHex, 'hex'), cred) | |
| 430 | await this.base.append(enc(this._op('request', { writer: String(writerHex).toLowerCase(), bound }, cred)), { optimistic: true }) | |
| 457 | async post (body, cred, name = recordName()) { | |
| 458 | await this.base.append(this._op('message', { name, body: String(body || '') })) | |
| 459 | return name | |
| 431 | 460 | } |
| 432 | 461 | |
| 433 | /** Answer an ask. The caller must already be an admin — the weight comes from their block. */ | |
| 434 | async admitRequest (writerHex, cred) { | |
| 435 | await this.base.append(enc(this._op('admit-request', { writer: String(writerHex).toLowerCase() }, cred)), { optimistic: true }) | |
| 436 | } | |
| 462 | async deleteMessage (name) { await this.base.append(this._op('delete-message', { name })) } | |
| 463 | async flag (target, reason = '') { await this.base.append(this._op('flag', { target: String(target), reason: String(reason) })) } | |
| 437 | 464 | |
| 438 | async requests () { | |
| 439 | return (await this._prefix(REQUEST)).filter((e) => !e.value.removed) | |
| 440 | .map((e) => ({ writer: e.key, root: e.value.root, device: e.value.device })) | |
| 441 | } | |
| 465 | async setSetting (key, value) { await this.base.append(this._op('settings', { key, value })) } | |
| 466 | /** v1 name, kept for callers. */ | |
| 467 | async setConfig (key, value) { return this.setSetting(key, value) } | |
| 442 | 468 | |
| 443 | /** List a room in this community. A moderator act. */ | |
| 444 | async listRoom (roomKey, note, cred) { | |
| 445 | await this.base.append(enc(this._op('list-room', { listed: String(roomKey).toLowerCase(), note: String(note || '') }, cred)), { optimistic: true }) | |
| 446 | } | |
| 469 | async listRoom (roomKey, note) { await this.base.append(this._op('list-room', { listed: String(roomKey).toLowerCase(), note: String(note || '') })) } | |
| 470 | async delistRoom (roomKey) { await this.base.append(this._op('delist-room', { listed: String(roomKey).toLowerCase() })) } | |
| 447 | 471 | |
| 448 | async delistRoom (roomKey, cred) { | |
| 449 | await this.base.append(enc(this._op('delist-room', { listed: String(roomKey).toLowerCase() }, cred)), { optimistic: true }) | |
| 450 | } | |
| 472 | // ── reads ──────────────────────────────────────────────────────────────────────────────────── | |
| 451 | 473 | |
| 452 | async listings () { | |
| 453 | return (await this._prefix(LISTING)).filter((e) => !e.value.removed) | |
| 454 | .map((e) => ({ room: e.key, note: e.value.note, by: e.value.by })) | |
| 474 | async founder () { | |
| 475 | const f = await this._get(FOUNDED) | |
| 476 | return f ? { root: f.member } : null | |
| 455 | 477 | } |
| 456 | 478 | |
| 457 | async setConfig (key, value, cred) { | |
| 458 | await this.base.append(enc(this._op('config', { key, value }, cred)), { optimistic: true }) | |
| 479 | async settings () { return (await this._get(SETTINGS)) || {} } | |
| 480 | /** v1 name, kept for callers. */ | |
| 481 | async config () { return this.settings() } | |
| 482 | ||
| 483 | /** Active members (left and removed are not members). */ | |
| 484 | async members () { | |
| 485 | return (await this._prefix(MEMBER)).filter((e) => e.value.isActive && !e.value.isRemoved) | |
| 486 | .map((e) => ({ root: e.key, role: e.value.role, muted: !!e.value.isMuted, banned: !!e.value.isBanned })) | |
| 459 | 487 | } |
| 460 | 488 | |
| 461 | // ⛔ The default was `String(Date.now()) + 4 hex chars` — a THIRD naming scheme, different again | |
| 462 | // from the two in rpc.js, and one whose digit-subsequence collapsed 83% of names when | |
| 463 | // favela-bridge derived a bsky rkey from it. One generator now, in lib/name.js. | |
| 464 | // | |
| 465 | // `name` stays a parameter only so a test can force a collision and prove apply refuses it. No | |
| 466 | // caller in the daemon passes it. | |
| 467 | async post (body, cred, name = recordName()) { | |
| 468 | await this.base.append(enc(this._op('message', { name, body: String(body || '') }, cred)), { optimistic: true }) | |
| 469 | return name | |
| 489 | async member (root) { return this._get(memberKey(String(root).toLowerCase())) } | |
| 490 | ||
| 491 | async roleOf (root) { | |
| 492 | const m = await this.member(root) | |
| 493 | return m && m.isActive && !m.isRemoved ? m.role : null | |
| 470 | 494 | } |
| 471 | 495 | |
| 472 | // ── reads ──────────────────────────────────────────────────────────────────────────────────── | |
| 496 | async devices (root) { | |
| 497 | return (await this._prefix(DEVICE)) | |
| 498 | .filter((e) => e.value.isActive && (!root || e.value.member === String(root).toLowerCase())) | |
| 499 | .map((e) => ({ writer: e.key, root: e.value.member, device: e.value.device })) | |
| 500 | } | |
| 473 | 501 | |
| 474 | async founder () { return this._get(FOUNDED) } | |
| 475 | async config () { return (await this._get(CONFIG)) || {} } | |
| 502 | async invites () { | |
| 503 | return (await this._prefix(INVITE)).map((e) => ({ id: e.key, ...e.value })) | |
| 504 | } | |
| 476 | 505 | |
| 477 | async members () { | |
| 478 | return (await this._prefix(MEMBER)).filter((e) => !e.value.removed) | |
| 479 | .map((e) => ({ root: e.key, role: e.value.role })) | |
| 506 | /** Pending join requests (invitations that require confirmation). */ | |
| 507 | async requests () { | |
| 508 | return (await this._prefix(REQUEST)).filter((e) => !e.value.done) | |
| 509 | .map((e) => ({ writer: e.key, root: e.value.member, device: e.value.device, invite: e.value.invite })) | |
| 480 | 510 | } |
| 481 | 511 | |
| 482 | async roleOf (root) { | |
| 483 | const m = await this._get(memberKey(String(root).toLowerCase())) | |
| 484 | return m && !m.removed ? m.role : null | |
| 512 | async listings () { | |
| 513 | return (await this._prefix(LISTING)).filter((e) => !e.value.removed) | |
| 514 | .map((e) => ({ room: e.key, note: e.value.note, by: e.value.by })) | |
| 485 | 515 | } |
| 486 | 516 | |
| 487 | async devices (root) { | |
| 488 | return (await this._prefix(DEVICE)) | |
| 489 | .filter((e) => !e.value.removed && (!root || e.value.root === String(root).toLowerCase())) | |
| 490 | .map((e) => ({ device: e.key, root: e.value.root, writer: e.value.writer || null })) | |
| 517 | async flags (target = null) { | |
| 518 | return (await this._prefix(FLAG)).map((e) => { | |
| 519 | const i = e.key.lastIndexOf(SEP) | |
| 520 | return { target: e.key.slice(0, i), by: e.key.slice(i + 1), ...e.value } | |
| 521 | }).filter((f) => !target || f.target === target) | |
| 491 | 522 | } |
| 492 | 523 | |
| 493 | 524 | async messages () { |
| … | … | @@ -92,9 +92,10 @@ const MUTATIONS = new Set(['post', 'follow', 'unfollow', 'like', 'unlike', 'repl |
| 92 | 92 | // registry needs no credential, and a node that cannot yet sign must still be able to look. |
| 93 | 93 | 'namespace-request', 'namespace-admit', 'namespace-grant', 'namespace-revoke', |
| 94 | 94 | 'namespace-invite', 'namespace-uninvite', |
| 95 | // Room ops all sign with this device's credential. | |
| 96 | 'room-create', 'room-post', 'room-config', 'room-add', 'room-role', 'room-remove', 'room-admit', | |
| 97 | 'room-request', 'room-list', 'room-delist', 'dm', | |
| 95 | // Room ops act through this device's writer core (ROOM-CONTRACT v2): an unlinked device has none. | |
| 96 | 'room-create', 'room-post', 'room-config', 'room-role', 'room-remove', 'room-status', 'room-invite', | |
| 97 | 'room-uninvite', 'room-join-invite', 'room-accept', 'room-reject', 'room-add-device', 'room-delete', | |
| 98 | 'room-flag', 'room-list', 'room-delist', 'dm', | |
| 98 | 99 | // ⛔ Changing WHERE this identity publishes is an identity act, not a display preference. |
| 99 | 100 | // `set-profile` is not here because a self-label asserts nothing; turning on a network puts words |
| 100 | 101 | // under this identity somewhere that does not forget, so it requires an ATTESTED device — the |
| … | … | @@ -308,42 +309,64 @@ export async function dispatch (node, cmd, a) { |
| 308 | 309 | case 'room': { |
| 309 | 310 | const r = requireRoom(node, a.key) |
| 310 | 311 | await r.update() |
| 312 | const me = node.rootHex() | |
| 313 | const role = me ? await r.roleOf(me) : null | |
| 311 | 314 | return { |
| 312 | 315 | key: a.key, |
| 313 | config: await r.config(), | |
| 316 | config: await r.settings(), | |
| 314 | 317 | members: await r.members(), |
| 315 | 318 | // ⚠ Reported, because "I am in this room" and "I am a MEMBER of it" are different facts and |
| 316 | 319 | // only the second lets you write. Holding the key gets you the first. |
| 317 | role: await r.roleOf(node.rootHex()), | |
| 318 | requests: await r.requests(), | |
| 320 | role, | |
| 321 | // ⛔ The identity being a member is not THIS device being able to write: a member's second | |
| 322 | // device has the role and no bound core until it runs room-add-device. | |
| 323 | bound: (await r.devices()).some((d) => d.writer === r.writer), | |
| 324 | // Join requests and invitations are what moderators act on; others see none. | |
| 325 | requests: role === 'moderator' || role === 'admin' ? await r.requests() : [], | |
| 326 | invites: role === 'moderator' || role === 'admin' ? await r.invites() : [], | |
| 319 | 327 | listings: await r.listings(), |
| 320 | 328 | messages: await r.messages() |
| 321 | 329 | } |
| 322 | 330 | } |
| 331 | // ── ROOM-CONTRACT v2: every op is carried by THIS node's writer core, which is the credential ── | |
| 323 | 332 | case 'room-post': { const r = requireRoom(node, a.key); const name = await r.post(a.body, node.cred); await r.update(); return { name } } |
| 324 | case 'room-config': { const r = requireRoom(node, a.key); await r.setConfig(a.field, a.value, node.cred); await r.update(); return { ok: true } } | |
| 325 | case 'room-add': { const r = requireRoom(node, a.key); await r.addMember(a.member, a.role || 'peer', node.cred); await r.update(); return { ok: true } } | |
| 326 | case 'room-role': { const r = requireRoom(node, a.key); await r.setRole(a.member, a.role, node.cred); await r.update(); return { ok: true } } | |
| 327 | case 'room-remove': { const r = requireRoom(node, a.key); await r.removeMember(a.member, node.cred); await r.update(); return { ok: true } } | |
| 328 | // Admit a device into the room — carried by THIS node, which must already be an admin. The | |
| 329 | // weight cannot come from the joiner; see room.js. | |
| 330 | // ⛔ Ask to be let in. The joiner cannot grant itself weight and an admin cannot admit a | |
| 331 | // writer key it has no way to learn, so this is the only crossing a person can make alone. | |
| 332 | case 'room-request': { const r = requireRoom(node, a.key); await r.request(node.cred); await r.update(); return { asked: r.base.local.key.toString('hex') } } | |
| 333 | case 'room-requests': { const r = requireRoom(node, a.key); await r.update(); return { requests: await r.requests() } } | |
| 334 | // Answer one. Requires being an admin HERE, because the weight comes from this node's block. | |
| 335 | case 'room-admit': { const r = requireRoom(node, a.key); await r.admitRequest(a.writer, node.cred); await r.update(); return { admitted: a.writer } } | |
| 336 | // ⛔ A community is a ROOM WHOSE LISTINGS ARE ROOMS — not a type. "The registry was a room with | |
| 337 | // extra steps": it inherits members, roles, invites and chat instead of having its own. | |
| 338 | case 'room-list': { const r = requireRoom(node, a.key); await r.listRoom(a.room, a.note, node.cred); await r.update(); return { listed: a.room } } | |
| 339 | case 'room-delist': { const r = requireRoom(node, a.key); await r.delistRoom(a.room, node.cred); await r.update(); return { delisted: a.room } } | |
| 333 | case 'room-config': { const r = requireRoom(node, a.key); await r.setSetting(a.field, a.value); await r.update(); return { ok: true } } | |
| 334 | case 'room-role': { const r = requireRoom(node, a.key); await r.setRole(a.member, a.role); await r.update(); return { ok: true } } | |
| 335 | // Removing yourself is leaving (reversible by invitation); a moderator removing you is permanent. | |
| 336 | case 'room-remove': { const r = requireRoom(node, a.key); await r.removeMember(a.member || node.rootHex()); await r.update(); return { ok: true } } | |
| 337 | case 'room-status': { const r = requireRoom(node, a.key); await r.setStatus(a.member, { muted: a.muted, banned: a.banned }); await r.update(); return { ok: true } } | |
| 338 | // ⛔ Joining is by INVITATION (v2). The token carries the invitation's signing secret: hand it over | |
| 339 | // a private channel; anyone holding it can join until it is revoked. | |
| 340 | case 'room-invite': { const r = requireRoom(node, a.key); const inv = await r.invite({ role: a.role || 'peer', requiresConfirmation: !!a.requiresConfirmation }); await r.update(); return inv } | |
| 341 | case 'room-uninvite': { const r = requireRoom(node, a.key); await r.uninvite(a.id); await r.update(); return { ok: true } } | |
| 342 | case 'room-join-invite': { | |
| 343 | // Opens the room if this node does not hold it yet, then joins on THIS node's own core. | |
| 344 | await node.joinRoom(a.key).catch(() => {}) | |
| 345 | const r = requireRoom(node, a.key) | |
| 346 | await r.join(a.token, node.cred) | |
| 347 | await r.update() | |
| 348 | return { key: a.key, writer: r.writer } | |
| 349 | } | |
| 350 | case 'room-accept': { const r = requireRoom(node, a.key); await r.acceptJoin(a.writer); await r.update(); return { accepted: a.writer } } | |
| 351 | case 'room-reject': { const r = requireRoom(node, a.key); await r.rejectJoin(a.writer); await r.update(); return { rejected: a.writer } } | |
| 352 | // A member's other device: binds THIS node's core to the identity already in the room. | |
| 353 | case 'room-add-device': { const r = requireRoom(node, a.key); await r.addDevice(node.cred); await r.update(); return { writer: r.writer } } | |
| 354 | case 'room-delete': { const r = requireRoom(node, a.key); await r.deleteMessage(a.name); await r.update(); return { ok: true } } | |
| 355 | case 'room-flag': { const r = requireRoom(node, a.key); await r.flag(a.target, a.reason); await r.update(); return { ok: true } } | |
| 356 | // ⛔ v1 verbs, gone with authority-by-signature. Said, not silently absent. | |
| 357 | case 'room-add': case 'room-request': case 'room-admit': | |
| 358 | throw new Error(cmd + ' is gone: rooms admit by invitation now (ROOM-CONTRACT v2) — use room-invite, then room-join-invite on the joining node') | |
| 359 | // ⛔ A community is a ROOM WHOSE LISTINGS ARE ROOMS — not a type. | |
| 360 | case 'room-list': { const r = requireRoom(node, a.key); await r.listRoom(a.room, a.note); await r.update(); return { listed: a.room } } | |
| 361 | case 'room-delist': { const r = requireRoom(node, a.key); await r.delistRoom(a.room); await r.update(); return { delisted: a.room } } | |
| 340 | 362 | // ⛔ A DM is a room with two members. SOCIAL-MODEL §4: a two-member room IS a direct message, |
| 341 | 363 | // and there is deliberately no separate DM type — one primitive, used. |
| 342 | 364 | case 'dm': { |
| 343 | const { key } = await node.createRoom() | |
| 365 | // ⛔ Founded with both people (Keet `internal.js:57-64`); the other person's node binds its own core | |
| 366 | // with room-add-device. No invitation needed: they are a member from the start. | |
| 367 | const { key } = await node.createRoom({ initialMembers: [{ root: String(a.member).toLowerCase(), role: 'peer' }] }) | |
| 344 | 368 | const r = node.room(key) |
| 345 | await r.addMember(a.member, 'peer', node.cred) | |
| 346 | await r.setConfig('title', 'DM', node.cred) | |
| 369 | await r.setSetting('title', 'DM') | |
| 347 | 370 | await r.update() |
| 348 | 371 | return { key, member: a.member } |
| 349 | 372 | } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-peer", |
| 3 | "version": "0.0.62", | |
| 3 | "version": "0.0.63", | |
| 4 | 4 | "description": "The favela daemon \u2014 feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "scripts": { |
| … | … | @@ -0,0 +1,102 @@ |
| 1 | // ROOM-CONTRACT §1.1 / §6 test 1 — a captured room op replayed by ANOTHER writer must do nothing. | |
| 2 | // | |
| 3 | // ⛔ v1 (room.js 0.0.62) authorises an op by the identity root signed INTO its payload, so a correctly | |
| 4 | // signed op is valid every time it appears, from anyone: re-appending a captured `add-member` re-admits a | |
| 5 | // member a moderator removed. v2 authorises by the writer core that CARRIED the op (Keet's model), so a | |
| 6 | // replay from any other core is ignored by construction. | |
| 7 | ||
| 8 | import test from 'brittle' | |
| 9 | import Corestore from 'corestore' | |
| 10 | import fs from '#node:fs' | |
| 11 | import os from '#node:os' | |
| 12 | import { join } from '#node:path' | |
| 13 | import { Room, PEER, ADMIN } from '../lib/room.js' | |
| 14 | import { identity } from './helpers.js' | |
| 15 | ||
| 16 | const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-replay-')) | |
| 17 | ||
| 18 | async function who () { | |
| 19 | const id = await identity() | |
| 20 | return { root: id.rootHex, device: await id.device() } | |
| 21 | } | |
| 22 | ||
| 23 | const until = async (fn, ms = 10000) => { | |
| 24 | const end = Date.now() + ms | |
| 25 | while (Date.now() < end) { const v = await fn(); if (v) return v; await new Promise((resolve) => setTimeout(resolve, 40)) } | |
| 26 | return null | |
| 27 | } | |
| 28 | ||
| 29 | test('a captured op re-appended by another writer core is ignored (§1.1 replay)', async (t) => { | |
| 30 | const admin = await who() | |
| 31 | const peer = await who() | |
| 32 | const one = new Room(new Corestore(tmp()).namespace('room'), null) | |
| 33 | await one.ready() | |
| 34 | await one.found(admin.device) | |
| 35 | await one.update() | |
| 36 | ||
| 37 | // A second node in the room as a plain member, carrying its own core. | |
| 38 | const two = new Room(new Corestore(tmp()).namespace('room'), one.key) | |
| 39 | await two.ready() | |
| 40 | const s1 = one.base.replicate(true); const s2 = two.base.replicate(false) | |
| 41 | s1.pipe(s2).pipe(s1) | |
| 42 | t.teardown(async () => { s1.destroy(); s2.destroy(); await one.close(); await two.close() }) | |
| 43 | await admitAsPeer(t, one, two, admin, peer) | |
| 44 | ||
| 45 | // A third identity the admin lets in, then removes — the state a replay would try to undo. | |
| 46 | const victim = await who() | |
| 47 | const captured = await letInThenRemove(one, admin, victim) | |
| 48 | t.is(await one.roleOf(victim.root), null, 'precondition: the member is removed') | |
| 49 | ||
| 50 | // The PEER's node re-appends the admin's captured op verbatim. | |
| 51 | await two.base.append(captured, { optimistic: true }) | |
| 52 | await until(async () => { await one.update(); await two.update(); return false }, 1500) | |
| 53 | t.is(await one.roleOf(victim.root), null, 'the replayed op did not bring the removed member back') | |
| 54 | t.ok(await one.roleOf(admin.root) === ADMIN, 'and the admin is still the admin') | |
| 55 | if (typeof one.invites === 'function') { | |
| 56 | // v2: the captured op was the admin's ADMIN invitation. Replayed from the peer's core it is the peer's | |
| 57 | // op — and its id is already taken, so it creates nothing; no invitation is ever attributed to the peer. | |
| 58 | const invites = await one.invites() | |
| 59 | t.absent(invites.some((i) => i.by === peer.root), 'the replay created no invitation at all') | |
| 60 | t.ok(invites.every((i) => i.by === admin.root), 'every invitation is the admin\'s own') | |
| 61 | } | |
| 62 | }) | |
| 63 | ||
| 64 | // v1 and v2 admit a member differently; each branch is the documented path for its version. | |
| 65 | async function admitAsPeer (t, one, two, admin, peer) { | |
| 66 | if (typeof one.invite === 'function') { | |
| 67 | const { token } = await one.invite({ role: PEER }, admin.device) | |
| 68 | await one.update() | |
| 69 | await until(async () => { await two.update(); return (await two.members()).length >= 1 }) | |
| 70 | await two.join(token, peer.device) | |
| 71 | } else { | |
| 72 | await one.addMember(peer.root, PEER, admin.device) | |
| 73 | await one.admitDevice(peer.device, admin.device, two.base.local.key.toString('hex')) | |
| 74 | } | |
| 75 | t.ok(await until(async () => { await one.update(); await two.update(); return (await one.roleOf(peer.root)) === PEER }), 'the peer is in') | |
| 76 | } | |
| 77 | ||
| 78 | // Returns the raw bytes of the op that ADMITTED the victim, as a replayer would have captured them. | |
| 79 | // ⛔ The OP VALUE handed to append — not `base.local.get(n)`, which is an encoded oplog node (links, | |
| 80 | // clock) and re-appending it wraps it again, so apply cannot even parse it and the "replay" proves nothing. | |
| 81 | async function letInThenRemove (one, admin, victim) { | |
| 82 | const seen = [] | |
| 83 | const append = one.base.append.bind(one.base) | |
| 84 | one.base.append = (value, opts) => { seen.push(value); return append(value, opts) } | |
| 85 | try { | |
| 86 | if (typeof one.invite === 'function') { | |
| 87 | // v2 has no admin-signed admission to replay: a member is admitted by its OWN join, from its OWN | |
| 88 | // core. The captured op is the admin's invite — replaying an admin op from a peer's core must not | |
| 89 | // act as the admin (it would at most be the peer's own invite, capped at the peer's role). | |
| 90 | await one.invite({ role: ADMIN }, admin.device) | |
| 91 | await one.update() | |
| 92 | await one.removeMember(victim.root, admin.device) | |
| 93 | await one.update() | |
| 94 | return seen[0] | |
| 95 | } | |
| 96 | await one.addMember(victim.root, PEER, admin.device) | |
| 97 | await one.update() | |
| 98 | await one.removeMember(victim.root, admin.device) | |
| 99 | await one.update() | |
| 100 | return seen[0] | |
| 101 | } finally { one.base.append = append } | |
| 102 | } |
| … | … | @@ -1,31 +1,16 @@ |
| 1 | // A room — many members, roles, and membership as the audience. | |
| 2 | // | |
| 3 | // ⛔ THE DISTINCTION THAT MATTERS. `feed.js`'s roster is DEVICE-FLAT: one identity, writers are its | |
| 4 | // devices, and the authorisation rule is "is this writer on the roster". #62 §3 named that as fine | |
| 5 | // for a feed and wrong for a room, because a room has many MEMBERS and each member has devices. | |
| 6 | // So a room has two levels — member (an identity root, with a role) and device (bound to a member) | |
| 7 | // — and every question has to be asked at the right one. A role belongs to a person, not a laptop. | |
| 8 | // | |
| 9 | // ── The contract is keet's, the substrate is ours ───────────────────────────────────────────── | |
| 10 | // | |
| 11 | // #62 §3: "stop designing it and adopt keet's contract. One thing we design, one we reuse." The | |
| 12 | // vocabulary here is keet's — admin / moderator / peer / read-only, members, invitations, config | |
| 13 | // (COMMUNITY-MODERATION.md, SOCIAL-MODEL §6.7, ROOMS-ARE-THE-COMPUTER). The engine is autobee, | |
| 14 | // because keet runs autobase and autobase's quorum is floor(N/2)+1: at N=2 one member going | |
| 15 | // offline freezes the view and you cannot self-heal by dropping to one. A person's two devices | |
| 16 | // make N=2 the normal posture. Weights retire that class. | |
| 17 | // | |
| 18 | // ⛔ And weights bring their own rule, MEASURED today building namespace admins: a grant is capped | |
| 19 | // at the weight of the block CARRYING it, so nobody can admit themselves. Admission is always an | |
| 20 | // act by somebody already inside. | |
| 1 | // ROOM-CONTRACT v2 (hyper-social/docs/ROOM-CONTRACT.md) — every member is its own node with its own writer | |
| 2 | // core, admitted by a real invitation, so every check here is authority-by-carrier, never a signed payload. | |
| 3 | // §6's list, one test each, plus the v1 behaviours that still hold. The replay gate is room-replay.test.js. | |
| 21 | 4 | |
| 22 | 5 | import test from 'brittle' |
| 23 | 6 | import Corestore from 'corestore' |
| 24 | 7 | import b4a from 'b4a' |
| 8 | import crypto from 'hypercore-crypto' | |
| 25 | 9 | import fs from '#node:fs' |
| 26 | 10 | import os from '#node:os' |
| 27 | 11 | import { join } from '#node:path' |
| 28 | import { Room, ADMIN, MODERATOR, PEER, READ_ONLY } from '../lib/room.js' | |
| 12 | import { Room, ADMIN, MODERATOR, PEER, READ_ONLY, OP_VERSION, canonicalOpBytes } from '../lib/room.js' | |
| 13 | import { signAttested } from '../lib/attestation.js' | |
| 29 | 14 | import { identity } from './helpers.js' |
| 30 | 15 | |
| 31 | 16 | const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-room-')) |
| … | … | @@ -35,432 +20,357 @@ async function who () { |
| 35 | 20 | return { root: id.rootHex, device: await id.device(), id } |
| 36 | 21 | } |
| 37 | 22 | |
| 38 | async function room (key = null, store = null) { | |
| 39 | const r = new Room((store || new Corestore(tmp())).namespace('room'), key) | |
| 40 | await r.ready() | |
| 41 | return r | |
| 42 | } | |
| 43 | ||
| 44 | // A founded room, which is the only kind that exists — see the genesis test. | |
| 45 | async function founded (cred) { | |
| 46 | const r = await room() | |
| 47 | await r.found(cred) | |
| 48 | await r.update() | |
| 49 | return r | |
| 23 | const until = async (fn, ms = 8000) => { | |
| 24 | const end = Date.now() + ms | |
| 25 | while (Date.now() < end) { const v = await fn(); if (v) return v; await new Promise((resolve) => setTimeout(resolve, 30)) } | |
| 26 | return null | |
| 50 | 27 | } |
| 51 | 28 | |
| 52 | test('★ founding a room makes its creator an ADMIN member', async (t) => { | |
| 53 | const a = await who() | |
| 54 | const r = await founded(a.device) | |
| 55 | t.teardown(() => r.close()) | |
| 56 | ||
| 57 | const members = await r.members() | |
| 58 | t.alike(members.map((m) => m.root), [a.root], 'the identity, not the device and not the writer core') | |
| 59 | t.is(members[0].role, ADMIN) | |
| 60 | // ⛔ There is no unfounded room, the same shape `feed.js` uses for genesis: a second | |
| 61 | // authorisation rule is a mode somebody is in by accident. | |
| 62 | t.ok(await r.founder()) | |
| 63 | }) | |
| 29 | // A room and its founder; `add` brings in another member on its own node through a real invitation. | |
| 30 | async function world (t) { | |
| 31 | const founder = await who() | |
| 32 | const host = new Room(new Corestore(tmp()).namespace('room'), null) | |
| 33 | await host.ready() | |
| 34 | await host.found(founder.device) | |
| 35 | await host.update() | |
| 36 | const nodes = [host] | |
| 37 | const streams = [] | |
| 38 | t.teardown(async () => { for (const s of streams) s.destroy(); for (const n of nodes) await n.close() }) | |
| 39 | ||
| 40 | 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)) } } | |
| 41 | ||
| 42 | // ⛔ A MESH, as on the room's topic: every node connects to every other. A star (everyone to the | |
| 43 | // founder only) hides a joiner's optimistic block from nodes it is not connected to — it is not a | |
| 44 | // writer yet — so a moderator could never see the request it is supposed to answer. | |
| 45 | const node = async () => { | |
| 46 | const r = new Room(new Corestore(tmp()).namespace('room'), host.key) | |
| 47 | await r.ready() | |
| 48 | for (const other of nodes) { | |
| 49 | const s1 = other.base.replicate(true); const s2 = r.base.replicate(false) | |
| 50 | s1.pipe(s2).pipe(s1) | |
| 51 | streams.push(s1, s2) | |
| 52 | } | |
| 53 | nodes.push(r) | |
| 54 | await until(async () => { await r.update(); return !!(await r.founder()) }) | |
| 55 | return r | |
| 56 | } | |
| 64 | 57 | |
| 65 | test('⛔ a room member is an IDENTITY; devices hang beneath', async (t) => { | |
| 66 | const a = await who() | |
| 67 | const r = await founded(a.device) | |
| 68 | t.teardown(() => r.close()) | |
| 58 | // A member at `role`, joined by an invitation `by` mints (default: the founder's node). | |
| 59 | const add = async (role = PEER, { by = host, person = null } = {}) => { | |
| 60 | const p = person || await who() | |
| 61 | const r = await node() | |
| 62 | const { token } = await by.invite({ role }) | |
| 63 | await settle() | |
| 64 | await r.join(token, p.device) | |
| 65 | await until(async () => { await settle(); return (await host.roleOf(p.root)) !== null }) | |
| 66 | // ⛔ The joiner's weight is anchored by the next block of a weighted member AFTER the join is applied | |
| 67 | // (ROOM-CONTRACT §4) — an append that lands before the host has seen the join anchors nothing. | |
| 68 | await host.setSetting('title', 'room') | |
| 69 | await settle() | |
| 70 | return { ...p, room: r } | |
| 71 | } | |
| 69 | 72 | |
| 70 | const phone = await a.device.child() | |
| 71 | await r.admitDevice(phone, a.device) // a second device of the SAME member | |
| 72 | await r.update() | |
| 73 | return { founder, host, node, add, settle } | |
| 74 | } | |
| 73 | 75 | |
| 74 | t.is((await r.members()).length, 1, 'still one member — a person did not become two') | |
| 75 | t.is((await r.devices(a.root)).length, 2, 'with two devices under them') | |
| 76 | // A role belongs to the person. Asking a device for its role is asking the wrong level, and a | |
| 77 | // device-flat roster cannot tell these apart at all. | |
| 78 | t.is(await r.roleOf(a.root), ADMIN) | |
| 76 | test('★ founding: the creator is an ADMIN member, its writer core is the device, weight 2', async (t) => { | |
| 77 | const { founder, host } = await world(t) | |
| 78 | t.alike((await host.members()).map((m) => [m.root, m.role]), [[founder.root, ADMIN]]) | |
| 79 | t.alike((await host.devices(founder.root)).map((d) => d.writer), [host.writer], 'keyed by the carrying writer core') | |
| 80 | const w = await host.base.system.get(host.base.local.key) | |
| 81 | t.is(w.maxWeight, 2, 'admin device at weight 2') | |
| 79 | 82 | }) |
| 80 | 83 | |
| 81 | test('★ an admin adds a member, who can then write', async (t) => { | |
| 82 | const a = await who() | |
| 83 | const b = await who() | |
| 84 | const r = await founded(a.device) | |
| 85 | t.teardown(() => r.close()) | |
| 86 | ||
| 87 | await r.addMember(b.root, PEER, a.device) | |
| 88 | await r.update() | |
| 89 | t.is(await r.roleOf(b.root), PEER) | |
| 90 | ||
| 91 | await r.admitDevice(b.device, a.device) | |
| 92 | await r.update() | |
| 93 | await r.post('hello from b', b.device) | |
| 94 | await r.update() | |
| 95 | t.alike((await r.messages()).map((m) => m.body), ['hello from b']) | |
| 84 | test('★ an invitation admits a member at its role; a peer device is granted weight 1 (§6.2)', async (t) => { | |
| 85 | const { host, add, settle } = await world(t) | |
| 86 | const p = await add(PEER) | |
| 87 | t.is(await host.roleOf(p.root), PEER) | |
| 88 | await p.room.post('hello', null) | |
| 89 | await settle() | |
| 90 | t.ok((await host.messages()).some((m) => m.body === 'hello' && m.author === p.root), 'and they can write') | |
| 91 | const w = await host.base.system.get(p.room.base.local.key) | |
| 92 | t.is(w.maxWeight, 1, 'a peer device carries weight 1, not 2') | |
| 96 | 93 | }) |
| 97 | 94 | |
| 98 | test('⛔ a stranger cannot write, and their message is not stored', async (t) => { | |
| 99 | const a = await who() | |
| 100 | const stranger = await who() | |
| 101 | const r = await founded(a.device) | |
| 102 | t.teardown(() => r.close()) | |
| 103 | ||
| 104 | await r.post('I do not belong here', stranger.device) | |
| 105 | await r.update() | |
| 106 | // The room IS the audience (SOCIAL-MODEL §4). If anyone could write, membership would describe | |
| 107 | // nothing and "visibility is a place" would be a promise rather than a property. | |
| 108 | t.alike(await r.messages(), []) | |
| 95 | test('⛔ a stranger\'s core writes nothing', async (t) => { | |
| 96 | const { host, node, settle } = await world(t) | |
| 97 | const stranger = await node() | |
| 98 | await stranger.base.append(b4a.from(JSON.stringify({ t: 'message', v: OP_VERSION, name: 'x', body: 'spam' })), { optimistic: true }) | |
| 99 | await settle() | |
| 100 | t.is((await host.messages()).length, 0) | |
| 109 | 101 | }) |
| 110 | 102 | |
| 111 | test('⛔ READ-ONLY is a member who cannot write', async (t) => { | |
| 112 | const a = await who() | |
| 113 | const b = await who() | |
| 114 | const r = await founded(a.device) | |
| 115 | t.teardown(() => r.close()) | |
| 116 | ||
| 117 | await r.addMember(b.root, READ_ONLY, a.device) | |
| 118 | await r.admitDevice(b.device, a.device) | |
| 119 | await r.update() | |
| 120 | await r.post('may I', b.device) | |
| 121 | await r.update() | |
| 122 | t.alike(await r.messages(), [], 'membership is not the same question as write access') | |
| 103 | test('⛔ READ-ONLY cannot post, but can flag', async (t) => { | |
| 104 | const { host, add, settle } = await world(t) | |
| 105 | const r = await add(READ_ONLY) | |
| 106 | await r.room.post('should not land', null) | |
| 107 | await r.room.flag('message-x', 'spam') | |
| 108 | await settle() | |
| 109 | t.is((await host.messages()).length, 0, 'no post') | |
| 110 | t.is((await host.flags('message-x')).length, 1, 'the flag is recorded') | |
| 123 | 111 | }) |
| 124 | 112 | |
| 125 | test('⛔ only an ADMIN may change roles or add members', async (t) => { | |
| 126 | const a = await who() | |
| 127 | const b = await who() | |
| 128 | const c = await who() | |
| 129 | const r = await founded(a.device) | |
| 130 | t.teardown(() => r.close()) | |
| 131 | ||
| 132 | await r.addMember(b.root, PEER, a.device) | |
| 133 | await r.admitDevice(b.device, a.device) | |
| 134 | await r.update() | |
| 135 | ||
| 136 | await r.addMember(c.root, PEER, b.device) // a peer trying to admit | |
| 137 | await r.setRole(a.root, READ_ONLY, b.device) // and trying to demote the admin | |
| 138 | await r.update() | |
| 139 | t.is(await r.roleOf(c.root), null, 'a peer cannot add members') | |
| 140 | t.is(await r.roleOf(a.root), ADMIN, 'nor demote an admin') | |
| 113 | test('⛔ roles: a moderator manages peers but cannot touch or grant admin (§6.3)', async (t) => { | |
| 114 | const { founder, host, add, settle } = await world(t) | |
| 115 | const mod = await add(MODERATOR) | |
| 116 | const p = await add(PEER) | |
| 117 | await mod.room.setRole(p.root, READ_ONLY) | |
| 118 | await settle() | |
| 119 | t.is(await host.roleOf(p.root), READ_ONLY, 'moderator demotes a peer') | |
| 120 | await mod.room.setRole(p.root, ADMIN) | |
| 121 | await mod.room.removeMember(founder.root) | |
| 122 | await settle() | |
| 123 | t.is(await host.roleOf(p.root), READ_ONLY, 'a moderator cannot grant admin') | |
| 124 | t.is(await host.roleOf(founder.root), ADMIN, 'a moderator removing an admin is ignored') | |
| 141 | 125 | }) |
| 142 | 126 | |
| 143 | test('a MODERATOR may remove a member but not appoint one', async (t) => { | |
| 144 | const a = await who() | |
| 145 | const mod = await who() | |
| 146 | const peer = await who() | |
| 147 | const r = await founded(a.device) | |
| 148 | t.teardown(() => r.close()) | |
| 149 | ||
| 150 | await r.addMember(mod.root, MODERATOR, a.device) | |
| 151 | await r.addMember(peer.root, PEER, a.device) | |
| 152 | await r.admitDevice(mod.device, a.device) | |
| 153 | await r.update() | |
| 154 | ||
| 155 | await r.removeMember(peer.root, mod.device) | |
| 156 | await r.update() | |
| 157 | t.is(await r.roleOf(peer.root), null, 'removed') | |
| 158 | ||
| 159 | const other = await who() | |
| 160 | await r.addMember(other.root, PEER, mod.device) | |
| 161 | await r.update() | |
| 162 | t.is(await r.roleOf(other.root), null, 'but appointing is an admin act') | |
| 127 | test('⛔ the last admin can be neither demoted nor leave; another admin may leave (§6.3, §6.4)', async (t) => { | |
| 128 | const { founder, host, add, settle } = await world(t) | |
| 129 | await host.setRole(founder.root, PEER) | |
| 130 | await host.removeMember(founder.root) | |
| 131 | await settle() | |
| 132 | t.is(await host.roleOf(founder.root), ADMIN, 'still the admin') | |
| 133 | const a2 = await add(PEER) | |
| 134 | await host.setRole(a2.root, ADMIN) | |
| 135 | await settle() | |
| 136 | t.is(await host.roleOf(a2.root), ADMIN, 'promoted') | |
| 137 | await a2.room.removeMember(a2.root) | |
| 138 | await settle() | |
| 139 | t.is(await host.roleOf(a2.root), null, 'an admin leaving, with another admin present, is accepted') | |
| 163 | 140 | }) |
| 164 | 141 | |
| 165 | test('⛔ the last admin cannot be removed', async (t) => { | |
| 166 | const a = await who() | |
| 167 | const r = await founded(a.device) | |
| 168 | t.teardown(() => r.close()) | |
| 169 | await r.removeMember(a.root, a.device) | |
| 170 | await r.update() | |
| 171 | // A room with no admin can never admit anyone again — nobody left carries the weight, and it is | |
| 172 | // unrecoverable rather than merely awkward. | |
| 173 | t.is(await r.roleOf(a.root), ADMIN, 'refused') | |
| 142 | test('⛔ removed for good; left can rejoin (§6.5)', async (t) => { | |
| 143 | const { host, add, node, settle } = await world(t) | |
| 144 | const gone = await add(PEER) | |
| 145 | await host.removeMember(gone.root) | |
| 146 | await settle() | |
| 147 | t.is(await host.roleOf(gone.root), null, 'removed') | |
| 148 | t.is((await host.devices(gone.root)).length, 0, 'and their devices with them') | |
| 149 | // A valid invitation does not bring a removed member back. | |
| 150 | const { token } = await host.invite({ role: PEER }) | |
| 151 | await settle() | |
| 152 | const again = await node() | |
| 153 | await again.join(token, gone.device) | |
| 154 | await host.setSetting('title', 'x'); await settle() | |
| 155 | t.is(await host.roleOf(gone.root), null, 'join aborts for a removed member') | |
| 156 | // add-device aborts too. | |
| 157 | const third = await node() | |
| 158 | await third.addDevice(gone.device) | |
| 159 | await settle() | |
| 160 | t.is(await host.roleOf(gone.root), null, 'add-device aborts for a removed member') | |
| 161 | ||
| 162 | const leaver = await add(PEER) | |
| 163 | await leaver.room.removeMember(leaver.root) | |
| 164 | await settle() | |
| 165 | t.is(await host.roleOf(leaver.root), null, 'left') | |
| 166 | await add(PEER, { person: leaver }) | |
| 167 | t.is(await host.roleOf(leaver.root), PEER, 'a member who LEFT can come back with an invitation') | |
| 174 | 168 | }) |
| 175 | 169 | |
| 176 | test('removing a member removes their devices with them', async (t) => { | |
| 177 | const a = await who() | |
| 178 | const b = await who() | |
| 179 | const r = await founded(a.device) | |
| 180 | t.teardown(() => r.close()) | |
| 181 | await r.addMember(b.root, PEER, a.device) | |
| 182 | await r.admitDevice(b.device, a.device) | |
| 183 | await r.update() | |
| 184 | t.is((await r.devices(b.root)).length, 1) | |
| 185 | ||
| 186 | await r.removeMember(b.root, a.device) | |
| 187 | await r.update() | |
| 188 | await r.post('still here?', b.device) | |
| 189 | await r.update() | |
| 190 | // ⚠ One removal, every device. A member-level decision that had to be repeated per device would | |
| 191 | // be a rule somebody eventually applies incompletely. | |
| 192 | t.alike(await r.messages(), []) | |
| 170 | test('⛔ muted: message, flag and invite ignored; unmuted: accepted (§6.6)', async (t) => { | |
| 171 | const { host, add, settle } = await world(t) | |
| 172 | const p = await add(PEER) | |
| 173 | await host.setStatus(p.root, { muted: true }) | |
| 174 | await settle() | |
| 175 | await p.room.post('muted', null) | |
| 176 | await p.room.flag('t1', 'x') | |
| 177 | const { id } = await p.room.invite({ role: PEER }) | |
| 178 | await settle() | |
| 179 | t.is((await host.messages()).length, 0, 'no message') | |
| 180 | t.is((await host.flags('t1')).length, 0, 'no flag') | |
| 181 | t.absent((await host.invites()).some((i) => i.id === id), 'no invitation') | |
| 182 | await host.setStatus(p.root, { muted: false }) | |
| 183 | await settle() | |
| 184 | await p.room.post('unmuted', null) | |
| 185 | await settle() | |
| 186 | t.ok((await host.messages()).some((m) => m.body === 'unmuted'), 'unmuted: accepted') | |
| 193 | 187 | }) |
| 194 | 188 | |
| 195 | test('config is room metadata, and only a moderator may set it', async (t) => { | |
| 196 | const a = await who() | |
| 197 | const peer = await who() | |
| 198 | const r = await founded(a.device) | |
| 199 | t.teardown(() => r.close()) | |
| 200 | await r.addMember(peer.root, PEER, a.device) | |
| 201 | await r.admitDevice(peer.device, a.device) | |
| 202 | await r.update() | |
| 203 | ||
| 204 | await r.setConfig('title', 'Berlin P2P', a.device) | |
| 205 | await r.setConfig('title', 'hijacked', peer.device) | |
| 206 | await r.update() | |
| 207 | t.is((await r.config()).title, 'Berlin P2P') | |
| 208 | // keet's five built-in keys; anything else is a message by convention, not a config field. | |
| 209 | await r.setConfig('nonsense', 'x', a.device) | |
| 210 | await r.update() | |
| 211 | t.absent('nonsense' in (await r.config())) | |
| 189 | test('⛔ banned: cannot post or invite; the ban can be lifted (decided 2026-10-13)', async (t) => { | |
| 190 | const { host, add, settle } = await world(t) | |
| 191 | const p = await add(PEER) | |
| 192 | await host.setStatus(p.root, { banned: true }) | |
| 193 | await settle() | |
| 194 | await p.room.post('banned', null) | |
| 195 | await settle() | |
| 196 | t.is((await host.messages()).length, 0) | |
| 197 | t.is(await host.roleOf(p.root), PEER, 'still a member — a ban is not a removal') | |
| 198 | await host.setStatus(p.root, { banned: false }) | |
| 199 | await settle() | |
| 200 | await p.room.post('back', null) | |
| 201 | await settle() | |
| 202 | t.ok((await host.messages()).some((m) => m.body === 'back'), 'lifted') | |
| 212 | 203 | }) |
| 213 | 204 | |
| 214 | // ── the properties that make a room a room ──────────────────────────────────────────────────── | |
| 215 | ||
| 216 | test('⛔ a signed op does not LIFT into another room', async (t) => { | |
| 217 | const a = await who() | |
| 218 | const one = await founded(a.device) | |
| 219 | const two = await founded(a.device) | |
| 220 | t.teardown(() => Promise.all([one.close(), two.close()])) | |
| 221 | ||
| 222 | const b = await who() | |
| 223 | // The exact op, replayed verbatim. Without the room key inside the signed bytes, being an admin | |
| 224 | // in one room would make you an admin in every room you could reach. | |
| 225 | const op = one._op('add-member', { member: b.root, role: PEER }, a.device) | |
| 226 | await one.base.append(b4a.from(JSON.stringify(op)), { optimistic: true }) | |
| 227 | await two.base.append(b4a.from(JSON.stringify(op)), { optimistic: true }) | |
| 228 | await one.update(); await two.update() | |
| 229 | ||
| 230 | t.is(await one.roleOf(b.root), PEER, 'valid where it was signed') | |
| 231 | t.is(await two.roleOf(b.root), null, 'and refused everywhere else') | |
| 205 | test('⛔ uninvite revokes: a later join with it is ignored (§6.7)', async (t) => { | |
| 206 | const { host, node, settle } = await world(t) | |
| 207 | const p = await who() | |
| 208 | const { id, token } = await host.invite({ role: PEER }) | |
| 209 | await host.uninvite(id) | |
| 210 | await settle() | |
| 211 | const r = await node() | |
| 212 | await r.join(token, p.device) | |
| 213 | await host.setSetting('title', 'x'); await settle() | |
| 214 | t.is(await host.roleOf(p.root), null) | |
| 232 | 215 | }) |
| 233 | 216 | |
| 234 | test('⛔ a tampered op is refused — the signature covers every field', async (t) => { | |
| 235 | const a = await who() | |
| 236 | const b = await who() | |
| 237 | const r = await founded(a.device) | |
| 238 | t.teardown(() => r.close()) | |
| 239 | ||
| 240 | const op = r._op('add-member', { member: b.root, role: PEER }, a.device) | |
| 241 | // What an attacker actually holds: a valid signature and a wish. Promoting themselves to admin | |
| 242 | // changes the bytes the proof covers. | |
| 243 | const tampered = { ...op, role: ADMIN } | |
| 244 | await r.base.append(b4a.from(JSON.stringify(tampered)), { optimistic: true }) | |
| 245 | await r.update() | |
| 246 | t.is(await r.roleOf(b.root), null) | |
| 217 | test('⛔ a join whose attestation names another writer than its carrier is ignored (§6.8)', async (t) => { | |
| 218 | const { host, node, settle } = await world(t) | |
| 219 | const p = await who() | |
| 220 | const { token } = await host.invite({ role: PEER }) | |
| 221 | await settle() | |
| 222 | const r = await node() | |
| 223 | const sk = b4a.from(token, 'hex') | |
| 224 | const elsewhere = b4a.alloc(32, 7) // a writer key that is NOT r's core | |
| 225 | const op = { t: 'join', v: OP_VERSION, invite: b4a.toString(sk.subarray(32), 'hex'), bound: signAttested(elsewhere, p.device), at: Date.now() } | |
| 226 | op.sig = b4a.toString(crypto.sign(canonicalOpBytes(op), sk), 'hex') | |
| 227 | await r.base.append(b4a.from(JSON.stringify(op)), { optimistic: true }) | |
| 228 | await host.setSetting('title', 'x'); await settle() | |
| 229 | t.is(await host.roleOf(p.root), null) | |
| 247 | 230 | }) |
| 248 | 231 | |
| 249 | test('★ a role belongs to the PERSON — any of their devices acts with it', async (t) => { | |
| 250 | const a = await who() | |
| 251 | const r = await founded(a.device) | |
| 252 | t.teardown(() => r.close()) | |
| 253 | ||
| 254 | const phone = await a.device.child() | |
| 255 | const b = await who() | |
| 256 | // ⛔ The phone was never admitted as a writer and never named in the room. It is still the same | |
| 257 | // identity, so it still acts as an admin — which is what "a role belongs to a person, not a | |
| 258 | // laptop" has to mean in practice. | |
| 259 | await r.addMember(b.root, PEER, phone) | |
| 260 | await r.update() | |
| 261 | t.is(await r.roleOf(b.root), PEER) | |
| 232 | test('delete-message: a non-author peer is ignored; the author or a moderator tombstones it (§6.9)', async (t) => { | |
| 233 | const { host, add, settle } = await world(t) | |
| 234 | const a = await add(PEER) | |
| 235 | const b = await add(PEER) | |
| 236 | const mod = await add(MODERATOR) | |
| 237 | const n1 = await a.room.post('one', null) | |
| 238 | const n2 = await a.room.post('two', null) | |
| 239 | await settle() | |
| 240 | await b.room.deleteMessage(n1) | |
| 241 | await settle() | |
| 242 | t.ok((await host.messages()).find((m) => m.name === n1).body === 'one', 'another peer cannot delete it') | |
| 243 | await a.room.deleteMessage(n1) | |
| 244 | await mod.room.deleteMessage(n2) | |
| 245 | await settle() | |
| 246 | const byName = Object.fromEntries((await host.messages()).map((m) => [m.name, m])) | |
| 247 | t.ok(byName[n1].deleted && byName[n1].by === a.root && byName[n1].body === undefined, 'author: tombstone, body dropped') | |
| 248 | t.ok(byName[n2].deleted && byName[n2].by === mod.root, 'moderator: tombstone') | |
| 262 | 249 | }) |
| 263 | 250 | |
| 264 | test('★★ two members over a real stream — the room converges', async (t) => { | |
| 265 | const a = await who() | |
| 266 | const b = await who() | |
| 267 | const one = await founded(a.device) | |
| 268 | const two = new Room(new Corestore(tmp()).namespace('room'), one.key) | |
| 269 | await two.ready() | |
| 270 | const s1 = one.base.replicate(true); const s2 = two.base.replicate(false) | |
| 271 | s1.pipe(s2).pipe(s1) | |
| 272 | t.teardown(async () => { s1.destroy(); s2.destroy(); await one.close(); await two.close() }) | |
| 273 | ||
| 274 | const until = async (fn, ms = 10000) => { | |
| 275 | const end = Date.now() + ms | |
| 276 | while (Date.now() < end) { const v = await fn(); if (v) return v; await new Promise((r) => setTimeout(r, 40)) } | |
| 277 | return null | |
| 278 | } | |
| 279 | ||
| 280 | t.ok(await until(async () => { await two.update(); return (await two.members()).length === 1 }), | |
| 281 | 'the joiner sees the room before being in it') | |
| 282 | ||
| 283 | await one.addMember(b.root, PEER, a.device) | |
| 284 | await one.admitDevice(b.device, a.device, b4a.toString(two.base.local.key, 'hex')) | |
| 285 | await one.update() | |
| 286 | ||
| 287 | t.ok(await until(async () => { await two.update(); return two.base.writable }), | |
| 288 | 'admitted, and carrying the log — the grant was carried by an admin, which is the only way it sticks') | |
| 289 | ||
| 290 | await two.post('hello from the second member', b.device) | |
| 291 | t.ok(await until(async () => { | |
| 292 | await one.update() | |
| 293 | return (await one.messages()).some((m) => m.body.includes('second member')) | |
| 294 | }), 'and their message reaches the founder') | |
| 251 | test('an admin grant on an invitation works once; the second join gets the plain role (§6.10)', async (t) => { | |
| 252 | const { host, node, settle } = await world(t) | |
| 253 | const { token } = await host.invite({ role: ADMIN }) | |
| 254 | await settle() | |
| 255 | const p1 = await who(); const p2 = await who() | |
| 256 | const r1 = await node(); const r2 = await node() | |
| 257 | await r1.join(token, p1.device) | |
| 258 | await host.setSetting('title', 'a'); await settle() | |
| 259 | await r2.join(token, p2.device) | |
| 260 | await host.setSetting('title', 'b'); await settle() | |
| 261 | await until(async () => { await settle(); return (await host.roleOf(p2.root)) !== null }) | |
| 262 | t.is(await host.roleOf(p1.root), ADMIN) | |
| 263 | t.is(await host.roleOf(p2.root), PEER) | |
| 295 | 264 | }) |
| 296 | 265 | |
| 297 | test('★★ join → request → admit: the ceremony a joiner can actually complete', async (t) => { | |
| 298 | const a = await who() | |
| 299 | const b = await who() | |
| 300 | const one = await founded(a.device) | |
| 301 | const two = new Room(new Corestore(tmp()).namespace('room'), one.key) | |
| 302 | await two.ready() | |
| 303 | const s1 = one.base.replicate(true); const s2 = two.base.replicate(false) | |
| 304 | s1.pipe(s2).pipe(s1) | |
| 305 | t.teardown(async () => { s1.destroy(); s2.destroy(); await one.close(); await two.close() }) | |
| 306 | const until = async (fn, ms = 10000) => { | |
| 307 | const end = Date.now() + ms | |
| 308 | while (Date.now() < end) { const v = await fn(); if (v) return v; await new Promise((r) => setTimeout(r, 40)) } | |
| 309 | return null | |
| 310 | } | |
| 311 | await until(async () => { await two.update(); return await two.founder() }) | |
| 312 | ||
| 313 | // ⛔ The joiner ASKS. An admin cannot admit a writer key it has no way to learn, and the joiner | |
| 314 | // cannot grant itself weight — so without this the ceremony has a hole a person cannot cross. | |
| 315 | await two.request(b.device) | |
| 316 | const asked = await until(async () => { | |
| 317 | await one.update() | |
| 318 | const rs = await one.requests() | |
| 319 | return rs.length ? rs : null | |
| 320 | }) | |
| 321 | t.is(asked.length, 1, 'the ask reached the admin') | |
| 322 | t.is(asked[0].root, b.root, 'saying which identity is asking') | |
| 323 | ||
| 324 | await one.addMember(b.root, PEER, a.device) | |
| 325 | await one.admitRequest(asked[0].writer, a.device) | |
| 326 | await one.update() | |
| 327 | ||
| 328 | t.ok(await until(async () => { await two.update(); return two.base.writable }), 'admitted') | |
| 329 | await two.post('hello, I asked and was let in', b.device) | |
| 330 | t.ok(await until(async () => { | |
| 331 | await one.update() | |
| 332 | return (await one.messages()).some((m) => m.body.includes('asked and was let in')) | |
| 333 | }), 'and can write') | |
| 266 | test('an invitation requiring confirmation waits; a peer cannot accept, a moderator can (decided 2026-10-13)', async (t) => { | |
| 267 | const { host, add, node, settle } = await world(t) | |
| 268 | const peer = await add(PEER) | |
| 269 | const mod = await add(MODERATOR) | |
| 270 | const p = await who() | |
| 271 | const { token } = await host.invite({ role: PEER, requiresConfirmation: true }) | |
| 272 | await settle() | |
| 273 | const r = await node() | |
| 274 | await r.join(token, p.device) | |
| 275 | await host.setSetting('title', 'x'); await settle() | |
| 276 | t.is(await host.roleOf(p.root), null, 'nothing granted yet') | |
| 277 | const [req] = await host.requests() | |
| 278 | t.ok(req && req.root === p.root, 'a pending request') | |
| 279 | // A moderator answers what its OWN node has seen. | |
| 280 | t.ok(await until(async () => { await settle(); return (await mod.room.requests()).length === 1 }), 'the moderator\'s node sees it') | |
| 281 | await peer.room.acceptJoin(req.writer) | |
| 282 | await settle() | |
| 283 | t.is(await host.roleOf(p.root), null, 'a peer cannot accept') | |
| 284 | await mod.room.acceptJoin(req.writer) | |
| 285 | await settle() | |
| 286 | t.is(await host.roleOf(p.root), PEER, 'a moderator can') | |
| 334 | 287 | }) |
| 335 | 288 | |
| 336 | test('⛔ an ask grants nothing on its own', async (t) => { | |
| 337 | const a = await who() | |
| 338 | const b = await who() | |
| 339 | const r = await founded(a.device) | |
| 340 | t.teardown(() => r.close()) | |
| 341 | await r.requestWriter('ab'.repeat(32), b.device) | |
| 342 | await r.update() | |
| 343 | t.is((await r.requests()).length, 1, 'recorded') | |
| 344 | t.is(await r.roleOf(b.root), null, 'and they are still not a member') | |
| 345 | await r.post('let me in', b.device) | |
| 346 | await r.update() | |
| 347 | t.alike(await r.messages(), [], 'nor can they write') | |
| 348 | }) | |
| 349 | ||
| 350 | // ── communities: a room whose listings are rooms ─────────────────────────────────────────────── | |
| 351 | // | |
| 352 | // ⛔ NOT a new type. ROOMS-ARE-THE-COMPUTER: "the registry was a room with extra steps" — a | |
| 353 | // community is a room used a certain way, so it inherits members, roles, invites and chat rather | |
| 354 | // than needing its own versions of each. COMMUNITY-MODERATION: admins and moderators curate | |
| 355 | // LISTINGS; they cannot moderate inside a listed room. Two rooms, two scopes. | |
| 356 | ||
| 357 | test('★ a moderator lists a room; the listings ARE the community', async (t) => { | |
| 358 | const a = await who() | |
| 359 | const mod = await who() | |
| 360 | const c = await founded(a.device) | |
| 361 | t.teardown(() => c.close()) | |
| 362 | await c.addMember(mod.root, MODERATOR, a.device) | |
| 363 | await c.update() | |
| 364 | ||
| 365 | const listed = 'ab'.repeat(32) | |
| 366 | await c.listRoom(listed, 'weekly hypercore deep-dives', mod.device) | |
| 367 | await c.update() | |
| 368 | const l = await c.listings() | |
| 369 | t.is(l.length, 1) | |
| 370 | t.is(l[0].room, listed) | |
| 371 | t.is(l[0].note, 'weekly hypercore deep-dives') | |
| 289 | test('settings: a closed set, moderators only; isJoiningDisabled stops joins', async (t) => { | |
| 290 | const { host, add, node, settle } = await world(t) | |
| 291 | const p = await add(PEER) | |
| 292 | await p.room.setSetting('title', 'peer-set') | |
| 293 | await host.setSetting('not-a-setting', 'x') | |
| 294 | await host.setSetting('title', 'admin-set') | |
| 295 | await settle() | |
| 296 | t.alike(await host.settings(), { title: 'admin-set' }) | |
| 297 | await host.setSetting('isJoiningDisabled', 'true') | |
| 298 | const { token } = await host.invite({ role: PEER }) | |
| 299 | await settle() | |
| 300 | const q = await who() | |
| 301 | const r = await node() | |
| 302 | await r.join(token, q.device) | |
| 303 | await host.setSetting('title', 'y'); await settle() | |
| 304 | t.is(await host.roleOf(q.root), null) | |
| 372 | 305 | }) |
| 373 | 306 | |
| 374 | test('⛔ a peer cannot list; a moderator can delist', async (t) => { | |
| 375 | const a = await who() | |
| 376 | const peer = await who() | |
| 377 | const c = await founded(a.device) | |
| 378 | t.teardown(() => c.close()) | |
| 379 | await c.addMember(peer.root, PEER, a.device) | |
| 380 | await c.update() | |
| 381 | ||
| 382 | await c.listRoom('cd'.repeat(32), 'mine', peer.device) | |
| 383 | await c.update() | |
| 384 | t.alike(await c.listings(), [], 'curating a directory is a moderator act') | |
| 385 | ||
| 386 | await c.listRoom('cd'.repeat(32), 'ours', a.device) | |
| 387 | await c.update() | |
| 388 | await c.delistRoom('cd'.repeat(32), a.device) | |
| 389 | await c.update() | |
| 390 | // ⚠ A tombstone that stops PROPAGATION, not existence. The listed room is untouched and anybody | |
| 391 | // already holding its key still has it — a registry is a phone book, not a gate. | |
| 392 | t.alike(await c.listings(), []) | |
| 307 | test('★ listings are a moderator act; a peer cannot list', async (t) => { | |
| 308 | const { host, add, settle } = await world(t) | |
| 309 | const p = await add(PEER) | |
| 310 | const other = b4a.toString(crypto.randomBytes(32), 'hex') | |
| 311 | await p.room.listRoom(other, 'nope') | |
| 312 | await settle() | |
| 313 | t.is((await host.listings()).length, 0) | |
| 314 | await host.listRoom(other, 'a bar') | |
| 315 | await settle() | |
| 316 | t.alike((await host.listings()).map((l) => l.room), [other]) | |
| 317 | await host.delistRoom(other) | |
| 318 | await settle() | |
| 319 | t.is((await host.listings()).length, 0) | |
| 393 | 320 | }) |
| 394 | 321 | |
| 395 | test('⛔ listing a room grants no power INSIDE it', async (t) => { | |
| 396 | const a = await who() | |
| 397 | const c = await founded(a.device) | |
| 398 | const other = await founded(a.device) | |
| 399 | t.teardown(() => Promise.all([c.close(), other.close()])) | |
| 400 | ||
| 401 | await c.listRoom(other.key.toString('hex'), 'a room we like', a.device) | |
| 402 | await c.update() | |
| 403 | // COMMUNITY-MODERATION states the accountability gap as a property: "the community admin is a | |
| 404 | // neighbourhood association — they decide which bars are listed in the directory, they don't | |
| 405 | // enter the bars." Being listed changes nothing about who may write in the listed room. | |
| 406 | t.is(await other.roleOf(a.root), ADMIN, 'unchanged') | |
| 407 | t.alike(await other.listings(), [], 'and the listed room knows nothing about it') | |
| 322 | test('⛔ one member cannot overwrite — or reattribute — another member\'s message; their own, they can', async (t) => { | |
| 323 | const { host, add, settle } = await world(t) | |
| 324 | const a = await add(PEER) | |
| 325 | const b = await add(PEER) | |
| 326 | await a.room.post('mine', null, 'the-name') | |
| 327 | await settle() | |
| 328 | await b.room.post('hijack', null, 'the-name') | |
| 329 | await settle() | |
| 330 | let m = (await host.messages()).find((x) => x.name === 'the-name') | |
| 331 | t.is(m.author, a.root); t.is(m.body, 'mine') | |
| 332 | await a.room.post('mine, revised', null, 'the-name') | |
| 333 | await settle() | |
| 334 | m = (await host.messages()).find((x) => x.name === 'the-name') | |
| 335 | t.is(m.body, 'mine, revised') | |
| 408 | 336 | }) |
| 409 | 337 | |
| 410 | test('⛔⛔ a payload can never overwrite the envelope', async (t) => { | |
| 411 | const a = await who() | |
| 412 | const r = await founded(a.device) | |
| 413 | t.teardown(() => r.close()) | |
| 414 | // The envelope's `room` is what stops an op signed here being replayed into another room, and | |
| 415 | // `root` is who signed it. A payload field of either name used to overwrite them — silently, and | |
| 416 | // the op then failed a check it was never meant to be subject to. The envelope is written last. | |
| 417 | const op = r._op('list-room', { listed: 'ab'.repeat(32), room: 'ff'.repeat(32), root: 'ee'.repeat(32) }, a.device) | |
| 418 | t.is(op.room, r.key.toString('hex'), 'this room, whatever the payload said') | |
| 419 | t.is(op.root, a.root, 'and the real signer') | |
| 338 | test('★ a DM: founded with the other person as an initial member; their node binds its core with add-device', async (t) => { | |
| 339 | const founder = await who() | |
| 340 | const other = await who() | |
| 341 | const host = new Room(new Corestore(tmp()).namespace('room'), null) | |
| 342 | await host.ready() | |
| 343 | await host.found(founder.device, { initialMembers: [{ root: other.root, role: PEER }] }) | |
| 344 | await host.update() | |
| 345 | const r = new Room(new Corestore(tmp()).namespace('room'), host.key) | |
| 346 | await r.ready() | |
| 347 | const s1 = host.base.replicate(true); const s2 = r.base.replicate(false) | |
| 348 | s1.pipe(s2).pipe(s1) | |
| 349 | t.teardown(async () => { s1.destroy(); s2.destroy(); await host.close(); await r.close() }) | |
| 350 | t.is(await host.roleOf(other.root), PEER, 'a member before any device') | |
| 351 | await until(async () => { await r.update(); return !!(await r.founder()) }) | |
| 352 | await r.addDevice(other.device) | |
| 353 | await host.setSetting('title', 'DM') | |
| 354 | await until(async () => { await host.update(); await r.update(); return (await host.devices(other.root)).length === 1 }) | |
| 355 | await r.post('hi', null) | |
| 356 | t.ok(await until(async () => { await host.update(); await r.update(); return (await host.messages()).some((m) => m.body === 'hi') }), 'and they can write') | |
| 420 | 357 | }) |
| 421 | 358 | |
| 422 | // ⛔ THE OVERWRITE. `apply` wrote a message with an unconditional `b.set(msgKey(op.name), …)` and | |
| 423 | // the row carries `author: signer.root` — so a member who posted under a name another member had | |
| 424 | // already used replaced their words AND took the byline, with no error and nothing in the log to | |
| 425 | // show it happened. Not a race: an op is `{t:'message', name, body}`, so any writer on the room's | |
| 426 | // autobee can choose the name directly, whatever the RPC surface does. | |
| 427 | // | |
| 428 | // The name generator (lib/name.js) makes an accidental collision impractical. It cannot make a | |
| 429 | // deliberate one impossible, because apply must never trust the op — so the refusal belongs here. | |
| 430 | test('⛔ one member cannot overwrite — or reattribute — another member\'s message', async (t) => { | |
| 431 | const a = await who() | |
| 432 | const b = await who() | |
| 433 | const r = await founded(a.device) | |
| 434 | t.teardown(() => r.close()) | |
| 435 | ||
| 436 | await r.addMember(b.root, PEER, a.device) | |
| 437 | await r.update() | |
| 438 | await r.admitDevice(b.device, a.device) | |
| 439 | await r.update() | |
| 440 | ||
| 441 | const name = await r.post('the original, by A', a.device) | |
| 442 | await r.update() | |
| 443 | ||
| 444 | await r.post('replaced, by B', b.device, name) | |
| 445 | await r.update() | |
| 446 | ||
| 447 | const msgs = await r.messages() | |
| 448 | t.is(msgs.length, 1, 'still one message') | |
| 449 | t.is(msgs[0].body, 'the original, by A', "A's words stand") | |
| 450 | t.is(msgs[0].author, a.root, 'and the byline is still A') | |
| 359 | test('remove-device: only the member owning the device', async (t) => { | |
| 360 | const { host, add, settle } = await world(t) | |
| 361 | const a = await add(PEER) | |
| 362 | const b = await add(PEER) | |
| 363 | await b.room.removeDevice(a.room.writer) | |
| 364 | await settle() | |
| 365 | t.is((await host.devices(a.root)).length, 1, 'another member cannot remove it') | |
| 366 | await a.room.removeDevice(a.room.writer) | |
| 367 | await settle() | |
| 368 | t.is((await host.devices(a.root)).length, 0, 'the owner can') | |
| 451 | 369 | }) |
| 452 | 370 | |
| 453 | test('★ a member may still overwrite their OWN message', async (t) => { | |
| 454 | const a = await who() | |
| 455 | const r = await founded(a.device) | |
| 456 | t.teardown(() => r.close()) | |
| 457 | ||
| 458 | const name = await r.post('first', a.device) | |
| 459 | await r.update() | |
| 460 | await r.post('second', a.device, name) | |
| 461 | await r.update() | |
| 462 | ||
| 463 | const msgs = await r.messages() | |
| 464 | t.is(msgs.length, 1) | |
| 465 | t.is(msgs[0].body, 'second', 'the author can revise; only a stranger is refused') | |
| 371 | test('⛔ a v1 op is ignored (OP_VERSION 2)', async (t) => { | |
| 372 | const { host, settle } = await world(t) | |
| 373 | await host.base.append(b4a.from(JSON.stringify({ t: 'message', v: 1, name: 'old', body: 'v1' }))) | |
| 374 | await settle() | |
| 375 | t.is((await host.messages()).length, 0) | |
| 466 | 376 | }) |
| … | … | @@ -161,6 +161,59 @@ test('★ a DM is a room with two members — there is no DM type', async (t) => |
| 161 | 161 | t.is(r.config.title, 'DM') |
| 162 | 162 | }) |
| 163 | 163 | |
| 164 | const until = async (fn, ms = 10000) => { | |
| 165 | const end = Date.now() + ms | |
| 166 | while (Date.now() < end) { const v = await fn(); if (v) return v; await new Promise((resolve) => setTimeout(resolve, 40)) } | |
| 167 | return null | |
| 168 | } | |
| 169 | ||
| 170 | test('★ ROOM-CONTRACT v2 over the socket: invite → join on the joiner\'s own core → accept', async (t) => { | |
| 171 | const a = await node() | |
| 172 | const b = await node() | |
| 173 | t.teardown(async () => { await a.n.close(); await b.n.close() }) | |
| 174 | const { key } = await dispatch(a.n, 'room-create', {}) | |
| 175 | // The transport is stubbed, so the two rooms are piped by hand — what the swarm does in the daemon. | |
| 176 | await dispatch(b.n, 'room-join', { key }) | |
| 177 | const s1 = a.n.room(key).replicate(true); const s2 = b.n.room(key).replicate(false) | |
| 178 | s1.pipe(s2).pipe(s1) | |
| 179 | t.teardown(() => { s1.destroy(); s2.destroy() }) | |
| 180 | t.ok(await until(async () => (await dispatch(b.n, 'room', { key })).members.length === 1), 'the joiner reads the room') | |
| 181 | ||
| 182 | const before = await dispatch(b.n, 'room', { key }) | |
| 183 | t.is(before.role, null, 'holding the key is not membership') | |
| 184 | t.is(before.bound, false, 'and no core of its is bound') | |
| 185 | ||
| 186 | const inv = await dispatch(a.n, 'room-invite', { key, requiresConfirmation: true }) | |
| 187 | t.ok(/^[0-9a-f]+$/.test(inv.token), 'the invitation comes back as a token to hand over') | |
| 188 | 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') | |
| 189 | ||
| 190 | await dispatch(b.n, 'room-join-invite', { key, token: inv.token }) | |
| 191 | const req = await until(async () => { | |
| 192 | await dispatch(a.n, 'room-config', { key, field: 'title', value: 'anchor' }) | |
| 193 | const r = await dispatch(a.n, 'room', { key }) | |
| 194 | return r.requests.length === 1 && r.requests[0] | |
| 195 | }) | |
| 196 | t.ok(req, 'a confirmation-required join waits as a request the admin sees') | |
| 197 | t.is(req.root, b.root) | |
| 198 | ||
| 199 | await dispatch(a.n, 'room-accept', { key, writer: req.writer }) | |
| 200 | t.ok(await until(async () => (await dispatch(b.n, 'room', { key })).role === 'peer'), 'accepted: a peer, on the joiner\'s node too') | |
| 201 | t.is((await dispatch(b.n, 'room', { key })).bound, true, 'with its own core bound') | |
| 202 | ||
| 203 | await dispatch(b.n, 'room-post', { key, body: 'hello from b' }) | |
| 204 | t.ok(await until(async () => (await dispatch(a.n, 'room', { key })).messages.some((m) => m.body === 'hello from b' && m.author === b.root)), | |
| 205 | 'the joiner writes, attributed to its identity') | |
| 206 | }) | |
| 207 | ||
| 208 | test('⛔ the v1 admission verbs refuse with the way forward', async (t) => { | |
| 209 | const { n } = await node() | |
| 210 | t.teardown(() => n.close()) | |
| 211 | const { key } = await dispatch(n, 'room-create', {}) | |
| 212 | for (const verb of ['room-add', 'room-request', 'room-admit']) { | |
| 213 | await t.exception(dispatch(n, verb, { key }), /room-invite/, verb + ' names its replacement') | |
| 214 | } | |
| 215 | }) | |
| 216 | ||
| 164 | 217 | test('rooms are listed with what a person needs to pick one', async (t) => { |
| 165 | 218 | const { n } = await node() |
| 166 | 219 | t.teardown(() => n.close()) |