OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / 6af541b

0.0.63: rooms are ROOM-CONTRACT v2 — authority by carrying core, joining by invitation
v1 authorised an op by the identity signed into its payload, so a captured op replayed from any core re-applied (test/room-replay.test.js, red on v1). v2 follows Keet: the core that CARRIED the op is the actor; signatures only on found / join / add-device. Joining is by invitation (token = the invitation's signing secret), optionally confirmed by a moderator or admin; ban/mute are enforced in apply; a v1 op is ignored (v1 rooms are re-founded, not migrated). rpc: room-invite / room-uninvite / room-join-invite / room-accept / room-reject / room-add-device / room-status / room-delete / room-flag; the v1 room-add / room-request / room-admit refuse naming their replacement; dm founds with initialMembers; `room` reports `bound` (this device's core is bound). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6af541bae7d233b08eb3f2fa5bdf2774ad6ff5a3
by plankatron <fleeky@gmail.com> · 2026-10-13T18:41:35.000Z

Changed files (7) +868 -749

lib/node.js +2 -2
……@@ -529,13 +529,13 @@ export class FavelaNode {
529529 }
530530
531531 /** Make a room. This node's identity founds it, so it is the room's first admin. */
532 async createRoom () {
532 async createRoom ({ initialMembers = [] } = {}) {
533533 if (!this.cred) throw new Error('creating a room needs this device\'s credential — run `favela link`')
534534 // ⚠ The key is not known until the log is open, so the slot cannot be named after it. A fresh
535535 // slot per creation keeps each room's writer its own.
536536 const slot = 'room:new:' + Date.now().toString(16) + Math.random().toString(16).slice(2, 8)
537537 const room = await new Room(this.corestore.namespace(slot), null).ready()
538 await room.found(this.cred)
538 await room.found(this.cred, { initialMembers })
539539 await room.update()
540540 const key = hex(room.key)
541541 this._rooms.set(key, room)
lib/room.js +346 -315
……@@ -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.
22//
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
75// at the right one — a role belongs to a person, not to a laptop.
86//
9// ── The contract is keet's; the substrate is ours ─────────────────────────────────────────────
7// ── ROOM-CONTRACT v2 (hyper-social/docs/ROOM-CONTRACT.md) ─────────────────────────────────────────
108//
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:
1413//
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.
1925//
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).
3028
3129import Autobee from 'autobee'
3230import b4a from 'b4a'
31import crypto from 'hypercore-crypto'
3332import { readAttested, signAttested } from './attestation.js'
3433import { recordName } from './name.js'
3534
3635const SEP = '/'
37const MEMBER = '__members__'
38const DEVICE = '__devices__'
3936const FOUNDED = '__founded__'
40const 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.
44const REQUEST = '__requests__'
37const MEMBER = '__members__'
38const DEVICE = '__devices__' // ⛔ keyed by WRITER key — the lookup authority-by-carrier needs
39const INVITE = '__invites__'
40const REQUEST = '__requests__' // keyed by the joiner's writer key
41const SETTINGS = '__settings__'
42const FLAG = '__flags__'
4543const MSG = 'message'
4644// ⛔ 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.
4945const LISTING = 'listing'
5046
51// keet's roles. Ordered, because every check here is "at least this much".
5247export const READ_ONLY = 'read-only'
5348export const PEER = 'peer'
5449export const MODERATOR = 'moderator'
……@@ -56,48 +51,38 @@ export const ADMIN = 'admin'
5651const RANK = { [READ_ONLY]: 0, [PEER]: 1, [MODERATOR]: 2, [ADMIN]: 3 }
5752const isRole = (r) => Object.prototype.hasOwnProperty.call(RANK, r)
5853
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.
61const 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).
56const SETTINGS_KEYS = new Set(['title', 'description', 'avatar', 'supportedLanguages', 'blindPeers',
57 'isJoiningDisabled', 'isAppendingDisabled', 'inviteHidden'])
6258
63const OP_VERSION = 1
59export const OP_VERSION = 2
6460const enc = (v) => b4a.from(JSON.stringify(v), 'utf8')
6561const dec = (b) => (b == null ? null : JSON.parse(b4a.toString(b, 'utf8')))
6662const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex'))
6763const isKeyHex = (s) => typeof s === 'string' && /^[0-9a-f]{64}$/i.test(s)
6864const isSeg = (s) => typeof s === 'string' && s.length > 0 && !s.includes(SEP) && s !== '.' && s !== '..'
65const truthy = (v) => v === true || v === 'true' || v === 1
6966
7067const memberKey = (root) => MEMBER + SEP + root
71const deviceKey = (device) => DEVICE + SEP + device
72const msgKey = (name) => MSG + SEP + name
68const deviceKey = (writer) => DEVICE + SEP + writer
69const inviteKey = (id) => INVITE + SEP + id
7370const requestKey = (writer) => REQUEST + SEP + writer
71const msgKey = (name) => MSG + SEP + name
72const flagKey = (target, by) => FLAG + SEP + target + SEP + by
7473const listingKey = (room) => LISTING + SEP + room
7574
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. */
7776export function canonicalOpBytes (op) {
78 const rest = {}
79 for (const k of Object.keys(op)) if (k !== 'proof') rest[k] = op[k]
8077 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]
8279 return b4a.from(JSON.stringify(ordered), 'utf8')
8380}
8481
85const 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 */
93function 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).
83const weightFor = (role) => ({ isIndexer: role === ADMIN })
9984
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. */
10186function batchView (bee, batch) {
10287 const pending = new Map()
10388 return {
……@@ -123,243 +108,292 @@ function batchView (bee, batch) {
123108}
124109
125110/**
126 * The role the SIGNER of an op acts with, or null.
111 * The member acting through this writer core, or null.
127112 *
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.
139115 */
140async 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
116async 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 }
147122}
148123
149const at = async (b, proof, role) => {
150 const r = await roleOfSigner(b, proof)
151 return r != null && RANK[r] >= RANK[role]
124const atLeast = (actor, role) => !!actor && RANK[actor.role] >= RANK[role]
125
126async 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 }].
137async function devicesOf (b, root) {
138 return (await b.scan(DEVICE + SEP)).filter((e) => e.value && e.value.member === root && e.value.isActive)
152139}
153140
154async 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. */
142async 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`). */
156const grantOf = (inv) => (RANK[inv.role] >= RANK[MODERATOR] && inv.used ? PEER : inv.role)
157
158async function applyOps (batch, view, host) {
155159 const write = view.write()
156160 const b = batchView(view, write)
157161
158162 for (const node of batch) {
159163 let op
160164 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
163167 if (!writer) continue
168 const writerBytes = b4a.from(writer, 'hex')
164169
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 ──────────────────────────────────────────
172171 if (op.t === 'found') {
173172 if (await b.get(FOUNDED)) continue
174 const proof = authorise(op, roomHex)
173 const proof = readAttested(op.bound, writerBytes)
175174 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 }
180187 continue
181188 }
182189
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 })
205213 continue
206214 }
207215
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 {}
216225 continue
217226 }
218227
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 })
228242 continue
229243 }
230244
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 })
246251 continue
247252 }
248253
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') {
258255 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 })
267269 continue
268270 }
269271
270 if (op.t === 'admit-device') {
272 if (op.t === 'remove-device') {
271273 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 {}
279279 continue
280280 }
281281
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 }
300299 continue
301300 }
302301
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)
311313 continue
312314 }
313315
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 }
320332 continue
321333 }
322334
323 // ── messages ───────────────────────────────────────────────────────────────────────────────
324335 if (op.t === 'message') {
325336 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.
339342 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 })
344346 continue
345347 }
346 }
347348
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 }
350358
351async 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 }
355388 }
356 return false
389
390 await write.flush()
357391}
358392
359393export class Room {
360394 constructor (store, key = null) {
361395 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),
363397 // A joiner's block has to reach apply before anybody can judge it.
364398 optimistic: true,
365399 fastForward: false
……@@ -369,125 +403,122 @@ export class Room {
369403 async ready () { await this.base.ready(); return this }
370404 get key () { return this.base.key }
371405 get discoveryKey () { return this.base.discoveryKey }
406 get writer () { return hex(this.base.local.key).toLowerCase() }
372407 async update () { await this.base.update() }
373408 replicate (s) { return this.base.replicate(s) }
374409 async close () { await this.base.close() }
375410
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) {
377415 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)
381417 }
382418
383419 /**
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.
387422 */
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 }))
390425 }
391426
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) }
399433 }
400434
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() })) }
404436
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 })
407444 }
408445
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() })) }
412448
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() })) }
424452
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() })) }
427456
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
431460 }
432461
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) })) }
437464
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) }
442468
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() })) }
447471
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 ────────────────────────────────────────────────────────────────────────────────────
451473
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
455477 }
456478
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 }))
459487 }
460488
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
470494 }
471495
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 }
473501
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 }
476505
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 }))
480510 }
481511
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 }))
485515 }
486516
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)
491522 }
492523
493524 async messages () {
lib/rpc.js +48 -25
……@@ -92,9 +92,10 @@ const MUTATIONS = new Set(['post', 'follow', 'unfollow', 'like', 'unlike', 'repl
9292 // registry needs no credential, and a node that cannot yet sign must still be able to look.
9393 'namespace-request', 'namespace-admit', 'namespace-grant', 'namespace-revoke',
9494 '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',
9899 // ⛔ Changing WHERE this identity publishes is an identity act, not a display preference.
99100 // `set-profile` is not here because a self-label asserts nothing; turning on a network puts words
100101 // 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) {
308309 case 'room': {
309310 const r = requireRoom(node, a.key)
310311 await r.update()
312 const me = node.rootHex()
313 const role = me ? await r.roleOf(me) : null
311314 return {
312315 key: a.key,
313 config: await r.config(),
316 config: await r.settings(),
314317 members: await r.members(),
315318 // ⚠ Reported, because "I am in this room" and "I am a MEMBER of it" are different facts and
316319 // 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() : [],
319327 listings: await r.listings(),
320328 messages: await r.messages()
321329 }
322330 }
331 // ── ROOM-CONTRACT v2: every op is carried by THIS node's writer core, which is the credential ──
323332 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 } }
340362 // ⛔ A DM is a room with two members. SOCIAL-MODEL §4: a two-member room IS a direct message,
341363 // and there is deliberately no separate DM type — one primitive, used.
342364 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' }] })
344368 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')
347370 await r.update()
348371 return { key, member: a.member }
349372 }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.62",
3 "version": "0.0.63",
44 "description": "The favela daemon \u2014 feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.",
55 "type": "module",
66 "scripts": {
test/room-replay.test.js +102 -0
……@@ -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
8import test from 'brittle'
9import Corestore from 'corestore'
10import fs from '#node:fs'
11import os from '#node:os'
12import { join } from '#node:path'
13import { Room, PEER, ADMIN } from '../lib/room.js'
14import { identity } from './helpers.js'
15
16const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-replay-'))
17
18async function who () {
19 const id = await identity()
20 return { root: id.rootHex, device: await id.device() }
21}
22
23const 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
29test('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.
65async 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.
81async 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}
test/room.test.js +316 -406
……@@ -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.
214
225import test from 'brittle'
236import Corestore from 'corestore'
247import b4a from 'b4a'
8import crypto from 'hypercore-crypto'
259import fs from '#node:fs'
2610import os from '#node:os'
2711import { join } from '#node:path'
28import { Room, ADMIN, MODERATOR, PEER, READ_ONLY } from '../lib/room.js'
12import { Room, ADMIN, MODERATOR, PEER, READ_ONLY, OP_VERSION, canonicalOpBytes } from '../lib/room.js'
13import { signAttested } from '../lib/attestation.js'
2914import { identity } from './helpers.js'
3015
3116const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-room-'))
……@@ -35,432 +20,357 @@ async function who () {
3520 return { root: id.rootHex, device: await id.device(), id }
3621}
3722
38async 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.
45async function founded (cred) {
46 const r = await room()
47 await r.found(cred)
48 await r.update()
49 return r
23const 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
5027}
5128
52test('★ 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.
30async 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 }
6457
65test('⛔ 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 }
6972
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}
7375
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)
76test('★ 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')
7982})
8083
81test('★ 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'])
84test('★ 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')
9693})
9794
98test('⛔ 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(), [])
95test('⛔ 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)
109101})
110102
111test('⛔ 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')
103test('⛔ 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')
123111})
124112
125test('⛔ 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')
113test('⛔ 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')
141125})
142126
143test('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')
127test('⛔ 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')
163140})
164141
165test('⛔ 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')
142test('⛔ 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')
174168})
175169
176test('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(), [])
170test('⛔ 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')
193187})
194188
195test('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()))
189test('⛔ 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')
212203})
213204
214// ── the properties that make a room a room ────────────────────────────────────────────────────
215
216test('⛔ 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')
205test('⛔ 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)
232215})
233216
234test('⛔ 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)
217test('⛔ 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)
247230})
248231
249test('★ 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)
232test('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')
262249})
263250
264test('★★ 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')
251test('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)
295264})
296265
297test('★★ 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')
266test('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')
334287})
335288
336test('⛔ 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
357test('★ 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')
289test('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)
372305})
373306
374test('⛔ 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(), [])
307test('★ 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)
393320})
394321
395test('⛔ 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')
322test('⛔ 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')
408336})
409337
410test('⛔⛔ 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')
338test('★ 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')
420357})
421358
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.
430test('⛔ 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')
359test('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')
451369})
452370
453test('★ 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')
371test('⛔ 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)
466376})
test/rpc-namespace.test.js +53 -0
……@@ -161,6 +161,59 @@ test('★ a DM is a room with two members — there is no DM type', async (t) =>
161161 t.is(r.config.title, 'DM')
162162})
163163
164const 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
170test('★ 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
208test('⛔ 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
164217test('rooms are listed with what a person needs to pick one', async (t) => {
165218 const { n } = await node()
166219 t.teardown(() => n.close())