| … | … | @@ -0,0 +1,326 @@ |
| 1 | # The room contract — authority by carrier, Keet's membership rules |
| 2 | |
| 3 | **Date:** 2026-09-30 · **Status:** spec, nothing built · **Follows:** #62 §3, #74 §6–§8, `FAVELA-WEB-RETIREMENT.md` |
| 4 | **Replaces:** the authorisation model of `favela-peer/lib/room.js` (0.0.58) · **Reference:** keet-core **4.22.28** |
| 5 | (`hyper-fleeky/keet-audit-4.22/pretty/@holepunchto/keet-core`), autobee 2.11.8 |
| 6 | |
| 7 | #62 §3 said "stop designing the room and adopt keet's contract". `room.js` adopted Keet's **words** |
| 8 | (admin / moderator / peer / read-only, a closed config set) and not its **enforcement**. This document |
| 9 | specifies the enforcement. Every Keet claim below cites the file it was read from on 2026-09-30; |
| 10 | every `room.js` claim cites the line it was read from the same day. |
| 11 | |
| 12 | --- |
| 13 | |
| 14 | ## 1. Why — three defects that share one cause |
| 15 | |
| 16 | **1.1 Every op can be replayed.** `authorise()` (`room.js:93-98`) checks the op version, the room key |
| 17 | and the signature over the op bytes. There is no nonce, no seen-set, and nothing ties the op to the |
| 18 | writer core that carried it. So a correctly signed op is valid every time it appears, from anyone: |
| 19 | |
| 20 | | Replayed op | Effect | |
| 21 | |---|---| |
| 22 | | `add-member` | re-admits a removed member (`:214` accepts `existing.removed`) | |
| 23 | | `admit-device` | re-admits a removed device (`:270-279` never reads the device's `removed`) | |
| 24 | | `set-role` | re-promotes a demoted member | |
| 25 | | `config` | reverts a config change | |
| 26 | | `message` | reverts an author's edit (same author passes `:340`) | |
| 27 | |
| 28 | **1.2 Authority and weight are separate facts.** A role lives in the view; weight lives in autobee. |
| 29 | `room.js` makes **every** admitted device an indexer whatever its member's role (`:179`, `:266`, |
| 30 | `:278`), so a `peer` member's laptop carries the same weight as the founder's. |
| 31 | |
| 32 | **1.3 A person can be added without agreeing.** `add-member` (`:209-217`) appoints an identity root |
| 33 | directly. `:254` already argues this is wrong for a device ("admitting somebody TO a room rather than |
| 34 | WITH them"); it is equally wrong for a person. |
| 35 | |
| 36 | **The common cause.** `room.js` authorises by the identity root signed **into the payload**, not by |
| 37 | the core that **carried** it. Its own reason (`:128-136`) is relaying: one node appending another's |
| 38 | op. That case came from the first draft of the tests (`:133-135`). In Keet nobody relays: each device |
| 39 | appends only to its own core, and that core *is* the authority. |
| 40 | |
| 41 | --- |
| 42 | |
| 43 | ## 2. What Keet does (4.22.28, read in source) |
| 44 | |
| 45 | Paths are under `lib/core/` unless noted. |
| 46 | |
| 47 | ### 2.1 Authority is the carrier |
| 48 | |
| 49 | - A device is attested to its member **once**, when it is added: `verifyAttestation(proof, deviceKey, |
| 50 | {expectedIdentity: memberKey})` (`invitations/index.js:121-130`). |
| 51 | - After that every op is authorised by **which writer core carried it**: |
| 52 | `getMemberForDeviceKey(writer.key)` → `isMemberActive` → role flags (`contract/query.js`). Ops carry |
| 53 | no signature. `chat.js:14-24` is the pattern every handler follows. |
| 54 | - Replay is impossible by construction: only that device can append to its core. |
| 55 | |
| 56 | ### 2.2 Admin is weight |
| 57 | |
| 58 | - `roles.js`: `ADMIN {weight: 2}`, `WRITER {weight: 1}`, `READER {weight: 0}`. |
| 59 | - A device added with `isIndexing` makes its member an admin (`members/index.js:45`) and is added at |
| 60 | `ADMIN` weight (`:53`). Everyone else's devices are `WRITER`. |
| 61 | - Demoting an admin re-weights each of their active devices to `WRITER` (`:394`). |
| 62 | - Keet runs rooms on **autobee** (`members/index.js:1`; `autobee 2.11.8` in the audit's `dep-versions.json`). |
| 63 | |
| 64 | ### 2.3 Removal and status — two different mechanisms |
| 65 | |
| 66 | - **`@keet/remove-member`** (`members/index.js:404-445`): |
| 67 | - by a moderator → `isRemoved: true` (`:425`), and **nothing anywhere clears it**. Rejoining through |
| 68 | an invitation aborts (`:149`); adding a device by member proof needs `isActive` (`:323`, ABI ≥ 12). |
| 69 | **Permanent.** |
| 70 | - by the member themself → inactive and `isPending`, **not** `isRemoved` (`:424-425`). **Reversible.** |
| 71 | - an admin can only be removed by themself: `SELF_ONLY` (`:419`). |
| 72 | - all the member's devices are de-weighted (`removeWriter`) and marked inactive (`:426-433`). |
| 73 | - **`@keet/change-member-status`** (`:366-374`): moderator-only toggles for `isMuted`, `isBanned`, |
| 74 | `isShadowBanned`, in both directions. |
| 75 | - **Mute is enforced in apply:** `chat.js:24` (`WRITER_IS_MUTED`), reactions (`reactions.js:12,27`), |
| 76 | labels (`labels.js:23,53`); muted members cannot create invitations (`members/index.js:207`). |
| 77 | - **Ban and shadow-ban are stored and never read** anywhere in keet-core. Whatever they do happens |
| 78 | in the client. |
| 79 | |
| 80 | ### 2.4 Admins |
| 81 | |
| 82 | - `@keet/change-member-role` (`:375-403`): the actor must be an active moderator; touching an admin, or |
| 83 | granting admin, requires the actor to be an admin (`:388`). |
| 84 | - **An admin can demote another admin** — unless the target is the **last** admin (`:392-393`), or a |
| 85 | device cannot be de-weighted (`removeable` false → no change, `:394`). |
| 86 | - So "only an admin can remove themself" holds for `remove-member` only. In two steps (demote, then |
| 87 | remove) an admin can push out another admin. |
| 88 | |
| 89 | ### 2.5 Joining |
| 90 | |
| 91 | - **Invitations are created in apply by any active member** (`:200-222`), unless muted (silent no-op), |
| 92 | or the room sets `isRoomInviteHidden` (moderators only, `:211-213`). An invitation's |
| 93 | `canModerate`/`canAdmin` are capped at the inviter's own (`:215-216`). DM rooms take none (`:209`). |
| 94 | - An invitation is a random seed; its **id is the seed's public key** (`invitations/index.js:92-106`). |
| 95 | - The joiner's request is signed with the invitation secret, encrypted to the room mailbox |
| 96 | (`members/index.js:279-280`), and **names the joiner's writer key, which must be the writer carrying |
| 97 | it** (`:297`). |
| 98 | - **Admission happens in apply, on the joiner's own block** (`:307-310`): |
| 99 | - no confirmation required → admitted at once with the invitation's roles (`→ :146-153`); |
| 100 | - `requiresConfirmation` → stored as a join request; **any active member** may accept or reject |
| 101 | (`:181-199`). |
| 102 | - Admin/moderator rights on an invitation are **single-use**: cleared after the first join |
| 103 | (`:169-171`). A plain membership invitation stays reusable. |
| 104 | - An existing member adds another device **without an invitation**, by member proof (`:313-328`). |
| 105 | - **Not checked in apply:** an invitation's `isActive` (cleared by `remove-invitation`, `:226`, never |
| 106 | read by the join path, `:303`) and its `expiration`. Possibly enforced at the pairing layer; unverified. |
| 107 | |
| 108 | ### 2.6 Settings |
| 109 | |
| 110 | One closed set, moderator-gated in apply (`internal.js:82-91`): `roomType`, `isExperimental`, |
| 111 | `isJoiningDisabled`, `isAppendingDisabled`, `isCallingDisabled`, `isMembershipEventsDisabled`, |
| 112 | `isAttachmentsDisabled`, `isLinksDisabled`, `isRoomInviteHidden` (`:14-24`). Extended only by an ABI |
| 113 | bump. Authority is never read from message content. |
| 114 | |
| 115 | --- |
| 116 | |
| 117 | ## 3. The contract |
| 118 | |
| 119 | Normative. "Apply" means `applyOps` in `room.js`; everything here is enforced there, nowhere else. |
| 120 | |
| 121 | ### 3.1 Authority by carrier |
| 122 | |
| 123 | 1. **An op's actor is the member owning the writer core that carried it**: |
| 124 | `device = devices[node.key]`, `member = members[device.member]`. Unknown writer → op ignored. |
| 125 | 2. **Room ops carry no signature and no `root`.** The `room` anti-lift field and `canonicalOpBytes` |
| 126 | go: an op cannot be lifted into another room because a writer core belongs to one room only. |
| 127 | 3. **Signatures remain in exactly two places**, both about binding a *new* writer core to a member: |
| 128 | the founding op (§3.4 `found`) and admitting a device (§3.6). They are verified once, at admission; |
| 129 | afterwards the core is the credential. |
| 130 | 4. **A device is inactive once removed, for good.** A removed device's core is de-weighted |
| 131 | (`removeWriter`) and its later blocks are ignored by rule 1. Coming back means a new device. |
| 132 | 5. `OP_VERSION` becomes **2**. A v1 op is ignored. |
| 133 | |
| 134 | ### 3.2 State |
| 135 | |
| 136 | ``` |
| 137 | __founded__ { member } |
| 138 | __members__/<member> { role, isActive, isRemoved, isMuted, isBanned } |
| 139 | __devices__/<writerHex> { member, device, isActive } ← keyed by WRITER key, the lookup rule 3.1.1 needs |
| 140 | __invites__/<inviteId> { by, role, requiresConfirmation, isActive, used } |
| 141 | __requests__/<writerHex> { invite, member, device } ← only when the invite requires confirmation |
| 142 | __settings__ { …closed set, §3.9 } |
| 143 | message/<name> { author, body, at } | { author, deleted: true, by } |
| 144 | __flags__/<target>/<member> { reason, at } |
| 145 | listing/<roomKey> { note, by } | { removed: true } |
| 146 | ``` |
| 147 | |
| 148 | `member` is the identity root (hex), as today. |
| 149 | |
| 150 | ### 3.3 Roles and weight |
| 151 | |
| 152 | | Role | Device weight | May | |
| 153 | |---|---|---| |
| 154 | | **admin** | **2** (indexer) | everything below, plus role changes involving admin, settings, admitting devices | |
| 155 | | **moderator** | 1 | remove members, change status (mute/ban), delete others' messages, list/delist, revoke invites | |
| 156 | | **peer** | 1 | post, flag, invite (unless `inviteHidden`) | |
| 157 | | **read-only** | 1 | flag, leave; **cannot post** (enforced in apply) | |
| 158 | |
| 159 | - **Admin ⇔ weight 2.** Granting admin re-weights every active device of that member to 2; removing it |
| 160 | re-weights them to 1. The role and the weight change in the **same apply**, never one without the other. |
| 161 | - `read-only` is ours, not Keet's (Keet expresses it per room with `isAppendingDisabled`). Kept because |
| 162 | a community room wants some members who read and flag but don't post. |
| 163 | |
| 164 | ### 3.4 Ops |
| 165 | |
| 166 | | Op | Actor | Effect | Keet | |
| 167 | |---|---|---|---| |
| 168 | | `found` | genesis block | founder = admin, founder's device at weight 2; optional `initialMembers` | `internal.js:25-68` | |
| 169 | | `invite` | active, not muted; moderator if `inviteHidden` | records an invitation; role capped at the actor's | `members/index.js:200-222` | |
| 170 | | `uninvite` | moderator, or the inviter | `isActive: false` | `:223-227` | |
| 171 | | `join` | the joiner's own block | §3.6 | `:273-311` | |
| 172 | | `accept-join` / `reject-join` | any active member | admits / drops a pending request | `:181-199` | |
| 173 | | `add-device` | the new device's own block, with a member proof | §3.6 | `:313-328` | |
| 174 | | `remove-device` | the member owning it | device inactive, de-weighted | `:345-365` | |
| 175 | | `set-role` | moderator; **admin** if the target is or becomes admin | §3.5 | `:375-403` | |
| 176 | | `set-status` | moderator | toggles `isMuted` / `isBanned` | `:366-374` | |
| 177 | | `remove-member` | moderator, or the member themself | §3.5 | `:404-445` | |
| 178 | | `message` | active, not muted, not read-only, not banned | post or revise **own** message | `chat.js:14-24` | |
| 179 | | `delete-message` | the author, or a moderator | tombstone `{deleted, by}`; body dropped from the view | — | |
| 180 | | `flag` | active, not muted | records a flag; grants nothing | `labels.js` | |
| 181 | | `settings` | moderator | §3.9 | `internal.js:82-91` | |
| 182 | | `list-room` / `delist-room` | moderator | unchanged from `room.js:292-312` | — | |
| 183 | |
| 184 | ### 3.5 Removal, status and admins |
| 185 | |
| 186 | **Removal.** |
| 187 | - By a moderator: `isRemoved: true`, **permanent**. Nothing clears it. `join` and `add-device` abort |
| 188 | for a removed member. |
| 189 | - By the member themself (leaving): inactive, **not** removed. They may come back through an invitation. |
| 190 | - Either way, every device of the member is de-weighted and marked inactive in the same apply. |
| 191 | |
| 192 | **Status — the reversible tools.** |
| 193 | - `isMuted`: enforced — `message`, `flag` and `invite` from a muted member are ignored. |
| 194 | - `isBanned`: **enforced in apply as "cannot post or invite", reversible.** This is the one place we go |
| 195 | past Keet, which stores ban and never reads it. ⚠ Decide before building (§8.1). |
| 196 | - So a community has a ban it can lift (`set-status`) and a removal it cannot (`remove-member`). |
| 197 | |
| 198 | **Admins.** |
| 199 | - `remove-member` on an admin is valid **only from that admin** (Keet `SELF_ONLY`). |
| 200 | - `set-role` may demote an admin, actor must be an admin, and **never the last admin**. |
| 201 | - Demotion re-weights the target's devices to 1 (§3.3). |
| 202 | - ⚠ `room.js` today lets a **moderator** remove an admin (`:234-237`). That goes. |
| 203 | |
| 204 | ### 3.6 Joining and devices |
| 205 | |
| 206 | **`join`** — appended by the joiner's own, new writer core: |
| 207 | ``` |
| 208 | { t: 'join', v: 2, invite: <inviteId>, member: <root>, device: <deviceKey>, |
| 209 | attestation: <device→member proof>, sig: <signature by the invitation secret over the rest> } |
| 210 | ``` |
| 211 | Apply checks, in order: the invitation exists and `isActive`; `sig` verifies against `inviteId`; |
| 212 | the attestation binds `device` to `member`; the carrying writer is the one the attestation names; |
| 213 | `member` is not removed. Then: |
| 214 | - invitation without confirmation → member created (or reactivated if they had left) at the |
| 215 | invitation's role, device added at the role's weight; |
| 216 | - with confirmation → a `__requests__` row; nothing granted until `accept-join`. |
| 217 | |
| 218 | Unlike Keet we **do** check `isActive`, so `uninvite` actually revokes. No expiry: apply has no clock |
| 219 | (`namespace.js:79-80` reached the same decision). An admin/moderator grant on an invitation is |
| 220 | **single-use**; plain membership invitations are reusable until revoked. |
| 221 | |
| 222 | **`add-device`** — a member's new device, appended by that device's own core, carrying the member |
| 223 | proof. Valid only if the member is active and not removed. |
| 224 | |
| 225 | **DMs.** The `dm` verb (`rpc.js:342-349`) uses `add-member`. It becomes a `found` with both members |
| 226 | in `initialMembers`, which is how Keet makes a DM (`internal.js:57-64`). |
| 227 | |
| 228 | ### 3.7 Moderation scope |
| 229 | |
| 230 | Binding moderation happens **inside the room**, through the ops above. A cross-room labeler is its own |
| 231 | room whose flags are advisory: another room acts on them only through its own moderators' ops (#74 §6, |
| 232 | "advisory across, binding within"). No separate moderation room for in-room decisions. |
| 233 | |
| 234 | ### 3.8 What is deleted from `room.js` |
| 235 | |
| 236 | - `authorise`, `canonicalOpBytes`, per-op `proof` / `root` / `room` / `at`, `roleOfSigner` |
| 237 | - `add-member` (replaced by invitations and `initialMembers`) |
| 238 | - `request` / `admit-request` (replaced by `join` + confirmation) and `admit-device` (replaced by `add-device`) |
| 239 | - `{ isIndexer: true }` on every admitted device |
| 240 | |
| 241 | ### 3.9 Settings |
| 242 | |
| 243 | One `settings` op, moderator-gated, over a **closed** set. It replaces `CONFIG_KEYS` (`room.js:61`) |
| 244 | and the `_hs:room-meta` message convention (`ROOMS-ARE-THE-COMPUTER.md:51`), which put |
| 245 | authority-bearing state where apply cannot gate it. |
| 246 | - Carried over: `title`, `description`, `avatar`, `supportedLanguages`, `blindPeers`. |
| 247 | - From Keet: `isJoiningDisabled`, `isAppendingDisabled`, `inviteHidden`. |
| 248 | - favela's own, from #74 §6: `public`, `bridgedTo`, `curation`. ⚠ Shape not settled (§8.2). |
| 249 | |
| 250 | Adding a key bumps `OP_VERSION`. |
| 251 | |
| 252 | --- |
| 253 | |
| 254 | ## 4. ⛔ Prerequisite — can a joiner's own block admit it? |
| 255 | |
| 256 | The whole of §3.6 assumes a `join` on the joiner's own block can give that block's writer weight. |
| 257 | **On autobee 2.1.1 it cannot**, measured 2026-09-07 (`favela-peer/test/namespace-admin.test.js:30-44`, |
| 258 | `probe-weight.mjs`): `system.addWriter` caps a grant at the weight of the carrying block, and a |
| 259 | joiner's block has none. |
| 260 | |
| 261 | **The cap is identical in 2.11.8**, read 2026-09-30 in |
| 262 | `keet-audit-4.22/extracted/node_modules/autobee/lib/system.js`: |
| 263 | `effective = coord && carrier < weight ? max(carrier, 0) : weight`. |
| 264 | Yet Keet admits from the joiner's own block (`members/index.js:307-310`, `addWriter` at `:53`). |
| 265 | Both versions also record the requested weight as `pending` and have a `promotions` module |
| 266 | (`pendingPromotion`, `grantHint`). **That promotion path is the likely answer and has not been read.** |
| 267 | |
| 268 | Before any of §3.6 is built: |
| 269 | 1. Read `autobee/lib/promotions.js` (2.1.1 and 2.11.8). |
| 270 | 2. Probe on **fresh test storage**: a weightless joiner calls `addWriter(self, weight 1)` in apply; |
| 271 | record `maxWeight` and `weight` on both peers before and after an admin's next block. |
| 272 | 3. If the grant is promoted when a weighted writer next appends: §3.6 stands, admission completes when |
| 273 | any weighted member is online. |
| 274 | 4. If not: invitations still work, but the admit is carried by any online member's node, unattended — |
| 275 | the pattern `namespace.js:73-77` already ships. §3.6's checks are unchanged; only the carrier differs. |
| 276 | |
| 277 | Sequence this with the autobee 2.1.1 → 2.12.x upgrade (#74 §8 step 2), not before it. |
| 278 | |
| 279 | --- |
| 280 | |
| 281 | ## 5. Migration |
| 282 | |
| 283 | - v1 ops are ignored by a v2 apply, so a v2 node opening a v1 room sees an empty room. |
| 284 | - **Unknown, check first:** whether any `room.js` room on tabertron, stortron or ashatron holds state |
| 285 | anyone needs. loser-club is **not** one — it is on the old favela-community stack (#74 §7). |
| 286 | - If none: no migration; v2 rooms are new rooms. |
| 287 | - If some: a one-shot re-founding (the founder founds a v2 room, re-invites members) — no in-place upgrade. |
| 288 | |
| 289 | --- |
| 290 | |
| 291 | ## 6. Tests — written first, red for the right reason |
| 292 | |
| 293 | Each against today's `room.js`, then against v2: |
| 294 | 1. **Replay**, one per row of §1.1: re-append a captured op from another writer → ignored. |
| 295 | 2. A peer's device has weight 1; an admin's has weight 2; promotion and demotion move weight in the same apply. |
| 296 | 3. A moderator removing an admin is ignored; the admin leaving is accepted. |
| 297 | 4. Demoting the last admin is ignored. |
| 298 | 5. A removed member: `join` with a valid invitation aborts; `add-device` aborts. A member who left can rejoin. |
| 299 | 6. Muted: `message`, `flag`, `invite` ignored; unmuted: accepted. |
| 300 | 7. `uninvite` → a later `join` with that invitation is ignored. |
| 301 | 8. A `join` whose attestation names a different writer than the carrier is ignored. |
| 302 | 9. `delete-message` by a non-author peer is ignored; by the author or a moderator, tombstoned. |
| 303 | 10. An admin grant on an invitation works once; the second join gets the plain role. |
| 304 | 11. **The §4 probe**, as a test, so the substrate assumption is checked on every autobee bump. |
| 305 | |
| 306 | --- |
| 307 | |
| 308 | ## 7. Deliberately not copied from Keet |
| 309 | |
| 310 | - **Unenforced ban.** Keet stores `isBanned` and never reads it; we enforce it (§3.5), pending §8.1. |
| 311 | - **Revoked invitations that still admit.** Keet's join path does not read `isActive`; ours does. |
| 312 | - **Shadow-ban.** Needs every reader to agree to hide someone; it is a client display rule, not apply state. |
| 313 | - **Any member may accept a join request.** Copied as is, but worth confirming for communities (§8.3). |
| 314 | - **The engine.** Keet is on autobee 2.11.8; we are on 2.1.1. The upgrade is #74 §8 step 2. |
| 315 | |
| 316 | --- |
| 317 | |
| 318 | ## 8. Open |
| 319 | |
| 320 | 1. **Ban in apply, or display-only like Keet?** Enforcing it gives communities a liftable ban that |
| 321 | actually stops posting; leaving it to the client matches Keet and keeps apply smaller. |
| 322 | 2. **Shape of `public` / `bridgedTo` / `curation`** in the closed settings set (#74 §6 named them; not designed). |
| 323 | 3. **Who may accept a join request** — any active member (Keet) or moderators only? |
| 324 | 4. **`namespace.js` shares `room.js`'s per-op signature model** (`authorise`-style proofs). Not audited |
| 325 | here; it needs the same replay check. |
| 326 | 5. Whether `read-only` should exist as a member role at all, or only as the room setting `isAppendingDisabled`. |