mgmt: self-serve link of an invite-only room is unsatisfiable — the invite gate rejects the invite the link reply instructs #111

Closed
opened 2026-07-12 18:59:50 +00:00 by robocub · 2 comments
Collaborator

Found while shaping fed_link_v12 (#109). The link probe-join failure reply says:

If the room is invite-only or restricted, invite @…_bot to it first, then run link again.

But for a self-serve user that instruction is unsatisfiable: their invite is auto-rejected by the invite gate (mgmt.rs:1263 — "rejecting non-direct invite from self-serve user (DMs only)"), leaving a leave membership that then blocks the probe join outright (live-confirmed on the fixture: v12 invite-only room, bot membership leave, probe join 403 "cannot join a room that is not public"). Operators are unaffected (their invites are accepted).

So self-serve link currently only works for publicly-joinable (or restricted-with-allow) rooms. Options, not mutually exclusive: (a) reword the failure reply for self-serve users (honest: "make the room public/joinable, or ask the operator"); (b) a narrow invite-gate exemption — accept a non-direct invite from a user with a PENDING link naming that exact room (time-boxed, reap on abandon, #83/#87 semantics); (c) leave as policy, document. The e2e scenario sidesteps it with a public v12 room, which still covers the whole #103 surface.

**Found while shaping `fed_link_v12` (#109).** The `link` probe-join failure reply says: > If the room is invite-only or restricted, invite @…_bot to it first, then run `link` again. But for a **self-serve** user that instruction is unsatisfiable: their invite is auto-rejected by the invite gate (mgmt.rs:1263 — "rejecting non-direct invite from self-serve user (DMs only)"), leaving a `leave` membership that then blocks the probe join outright (live-confirmed on the fixture: v12 invite-only room, bot membership `leave`, probe join 403 "cannot join a room that is not public"). Operators are unaffected (their invites are accepted). So self-serve `link` currently only works for publicly-joinable (or restricted-with-allow) rooms. Options, not mutually exclusive: (a) reword the failure reply for self-serve users (honest: "make the room public/joinable, or ask the operator"); (b) a narrow invite-gate exemption — accept a non-direct invite from a user with a PENDING link naming that exact room (time-boxed, reap on abandon, #83/#87 semantics); (c) leave as policy, document. The e2e scenario sidesteps it with a public v12 room, which still covers the whole #103 surface.
Author
Collaborator

The beta.3 reword made the self-serve join-failure reply honest ("make the room publicly joinable, or ask the operator"). This proposal makes it unnecessary for invite-only/restricted rooms: let a self-serve user invite the bot when — and only when — the invite matches a link attempt they just made.

Flow (user's view)

  1. Self-serve user runs link <name> <room> <guild> <channel> on their invite-only room.
  2. The probe join 403s. Instead of a dead end, the bot records an awaiting-invite intent and replies:
    "I can't join that room on my own. Invite me (@bot) to it within 10 minutes and I'll continue this link automatically."
  3. The user invites the bot from the room. The invite gate finds the matching intent → accepts, resumes the link exactly where it left off (probe → prereq checks → pending approval code in the DM), and deletes the intent.
  4. Everything downstream (Discord /bridge approve, #87 reap, etc.) is unchanged.

Mechanics (coordinates in crates/matrix-rtc/src/mgmt.rs)

  • New state: awaiting_invite: StdMutex<HashMap<(String /*requester*/, String /*room_id*/), AwaitingInvite>> on BotCtx, sibling of pending (mgmt.rs:192). AwaitingInvite carries the parsed NewTenant, origin_room, and created: Instant. Not merged into PendingLink — different lifecycle: no approval code exists yet, no room membership exists yet, so expiry is a pure map-drop (nothing to reap), vs. #87's leave-on-expiry.
  • Creation: in handle_link, only on the join-failure branch (probe_room_prereqs's first match arm), only for self-serve requesters, and only after every existing gate has already passed (ban, per-owner/per-guild caps, max_link_attempts, global budget — they all run before the probe today, so the intent inherits their protection). Re-running link for the same room replaces the intent (idempotent). The tier-aware join_failure_reply (beta.3) gains the "invite me within N minutes" text for the self-serve+intent-created case.
  • Consumption: in handle_invite_gated (mgmt.rs:1256), a new arm in the self-serve branch, checked after ban / global-budget, replacing the flat !i.is_direct reject (mgmt.rs:1286) with: non-direct invite + matching intent where intent key == (i.sender, room_id) → accept, remove intent, resume the link. Non-direct invite with no matching intent → rejected exactly as today.
  • Strict matching: inviter MXID must equal the link requester AND the room id must equal the intent's room. A room admin other than the requester inviting the bot does NOT match (documented; the requester needs invite power in their room — reasonable for someone bridging it).
  • Resumption: the resumed probe must treat the membership as probe-created. The invite-accept path passes probe_joined = true explicitly — otherwise probe_room_prereqs's already_member read (mgmt.rs:2038) would see the invite-created join and the #83 fail-safe would refuse to reap it on a failed probe, letting a hostile user pin the bot in a room via a link that then fails the authority check.

Two traps this design explicitly avoids

  1. The #89a cooldown trap (the #110 lesson). link is a state-changing command and stamps the per-sender cooldown; the follow-up invite would arrive inside that window and be declined by the cooldown gate (mgmt.rs:1332) — the exemption would be stillborn, flakily, only when mgmt_cooldown_secs is armed (which is exactly the fixture config, not default prod — the kind of gap #110 taught us about). Fix: a matched-intent invite bypasses the #89a cooldown check and does not stamp it. Safe because it is not a churn vector: it consumes a single-use intent whose creation is already velocity-gated by max_link_attempts + the global budget.
  2. Bot-pinning via failed resumption — handled by the explicit probe_joined = true above; a resumed link that fails its probe leaves the room like any probe-created join (#83).

Bounds & expiry

  • TTL: 10 min (const, no new config knob — prod config stays untouched). Expiry = drop from the map + best-effort notice to origin_room ("invite window expired; run link again"). Pruned lazily on insert/lookup + by the existing 300 s maintenance sweep.
  • Caps: 1 awaiting intent per requester (an honest user needs at most one; replacing on re-link keeps it 1), global cap sharing the spirit of PENDING_LINK_CAP (16).
  • Audit: intent creation, match-accept, no-match reject, and expiry all audit()-logged like the existing invite decisions (#78).

Security summary (open-registration surface)

The invite path gains no authority: everything a matched invite unlocks was already reachable through a public room. Ban list and global budget still run first; the intent can only exist for a requester who passed every link gate seconds earlier; matching is exact (sender, room); the intent is single-use with a 10-min TTL; failed resumption reaps the join (#83); unapproved success reaps on the normal #87 path.

Test plan

  • Unit: intent match/mismatch (wrong sender / wrong room), replacement, TTL expiry, per-requester cap, cooldown-bypass decision.
  • Live e2e (staging): new scenario mgmt_link_invite_only — self-serve user mints an invite-only room, link → awaiting-invite reply, invites the bot → pending approval code arrives; plus assert the negative (non-direct invite with no intent still rejected — guards today's behaviour). Regression: mgmt_self_serve_link, fed_link_v12, mgmt_invite_offline.
  • Federated twin (fed_link_invite_only on the nether.autos fixture) as a follow-up once the same-server scenario is green.

Scope

~150–200 lines in mgmt.rs + one e2e scenario. No config changes, no migration, no effect on operator-tier flows. Closes #111 when shipped.

Status: proposal only — awaiting operator review before implementation ships.

## Design proposal: pending-link invite-gate exemption (the #111 remainder) The beta.3 reword made the self-serve join-failure reply *honest* ("make the room publicly joinable, or ask the operator"). This proposal makes it *unnecessary* for invite-only/restricted rooms: let a self-serve user invite the bot **when — and only when — the invite matches a link attempt they just made**. ### Flow (user's view) 1. Self-serve user runs `link <name> <room> <guild> <channel>` on their invite-only room. 2. The probe join 403s. Instead of a dead end, the bot records an **awaiting-invite intent** and replies: *"I can't join that room on my own. **Invite me (@bot) to it within 10 minutes** and I'll continue this link automatically."* 3. The user invites the bot from the room. The invite gate finds the matching intent → accepts, resumes the link exactly where it left off (probe → prereq checks → pending approval code in the DM), and deletes the intent. 4. Everything downstream (Discord `/bridge approve`, #87 reap, etc.) is unchanged. ### Mechanics (coordinates in `crates/matrix-rtc/src/mgmt.rs`) - **New state:** `awaiting_invite: StdMutex<HashMap<(String /*requester*/, String /*room_id*/), AwaitingInvite>>` on `BotCtx`, sibling of `pending` (mgmt.rs:192). `AwaitingInvite` carries the parsed `NewTenant`, `origin_room`, and `created: Instant`. **Not** merged into `PendingLink` — different lifecycle: no approval code exists yet, no room membership exists yet, so expiry is a pure map-drop (nothing to reap), vs. #87's leave-on-expiry. - **Creation:** in `handle_link`, only on the join-failure branch (`probe_room_prereqs`'s first match arm), only for self-serve requesters, and only **after** every existing gate has already passed (ban, per-owner/per-guild caps, `max_link_attempts`, global budget — they all run before the probe today, so the intent inherits their protection). Re-running `link` for the same room replaces the intent (idempotent). The tier-aware `join_failure_reply` (beta.3) gains the "invite me within N minutes" text for the self-serve+intent-created case. - **Consumption:** in `handle_invite_gated` (mgmt.rs:1256), a new arm in the self-serve branch, checked **after** ban / global-budget, replacing the flat `!i.is_direct` reject (mgmt.rs:1286) with: non-direct invite + matching intent where `intent key == (i.sender, room_id)` → accept, remove intent, resume the link. Non-direct invite with **no** matching intent → rejected exactly as today. - **Strict matching:** inviter MXID must equal the link requester AND the room id must equal the intent's room. A room admin other than the requester inviting the bot does NOT match (documented; the requester needs invite power in their room — reasonable for someone bridging it). - **Resumption:** the resumed probe must treat the membership as probe-created. The invite-accept path passes `probe_joined = true` explicitly — otherwise `probe_room_prereqs`'s `already_member` read (mgmt.rs:2038) would see the invite-created join and the #83 fail-safe would refuse to reap it on a failed probe, letting a hostile user pin the bot in a room via a link that then fails the authority check. ### Two traps this design explicitly avoids 1. **The #89a cooldown trap (the #110 lesson).** `link` is a state-changing command and stamps the per-sender cooldown; the follow-up invite would arrive inside that window and be declined by the cooldown gate (mgmt.rs:1332) — the exemption would be stillborn, flakily, only when `mgmt_cooldown_secs` is armed (which is exactly the fixture config, not default prod — the kind of gap #110 taught us about). Fix: a matched-intent invite **bypasses the #89a cooldown check and does not stamp it**. Safe because it is not a churn vector: it consumes a single-use intent whose creation is already velocity-gated by `max_link_attempts` + the global budget. 2. **Bot-pinning via failed resumption** — handled by the explicit `probe_joined = true` above; a resumed link that fails its probe leaves the room like any probe-created join (#83). ### Bounds & expiry - **TTL:** 10 min (const, no new config knob — prod config stays untouched). Expiry = drop from the map + best-effort notice to `origin_room` ("invite window expired; run `link` again"). Pruned lazily on insert/lookup + by the existing 300 s maintenance sweep. - **Caps:** 1 awaiting intent per requester (an honest user needs at most one; replacing on re-`link` keeps it 1), global cap sharing the spirit of `PENDING_LINK_CAP` (16). - **Audit:** intent creation, match-accept, no-match reject, and expiry all `audit()`-logged like the existing invite decisions (#78). ### Security summary (open-registration surface) The invite path gains no authority: everything a matched invite unlocks was already reachable through a public room. Ban list and global budget still run first; the intent can only exist for a requester who passed every `link` gate seconds earlier; matching is exact (sender, room); the intent is single-use with a 10-min TTL; failed resumption reaps the join (#83); unapproved success reaps on the normal #87 path. ### Test plan - **Unit:** intent match/mismatch (wrong sender / wrong room), replacement, TTL expiry, per-requester cap, cooldown-bypass decision. - **Live e2e (staging):** new scenario `mgmt_link_invite_only` — self-serve user mints an invite-only room, `link` → awaiting-invite reply, invites the bot → pending approval code arrives; plus assert the negative (non-direct invite with no intent still rejected — guards today's behaviour). Regression: `mgmt_self_serve_link`, `fed_link_v12`, `mgmt_invite_offline`. - **Federated twin** (`fed_link_invite_only` on the nether.autos fixture) as a follow-up once the same-server scenario is green. ### Scope ~150–200 lines in `mgmt.rs` + one e2e scenario. No config changes, no migration, no effect on operator-tier flows. Closes #111 when shipped. **Status:** proposal only — awaiting operator review before implementation ships.
Author
Collaborator

Shipped in two stages, both merged to master; closing.

  1. Honest reword (v0.3.0-beta.3, PR #115): the join-failure reply became tier-aware, ending the "invite the bot first" dead-end instruction.
  2. The real fix — awaiting-invite link resume (PR #119, operator-approved design in comment 1226): a self-serve link of an un-joinable room arms a single-use (requester, room_id) intent (TTL [limits] invite_intent_ttl_secs, default 30 min); the requester's own invite for that exact room is accepted through the gate (ban check first; #89a cooldown + global budget bypassed for the matched, already-velocity-gated intent) and the link resumes automatically to the pending Discord-approval code. Failed resumes reap the join (#83); unapproved pendings reap on the #87 path. list now surfaces pending approvals + intents. Unified across tiers.

Evidence: live scenario mgmt_link_invite_only (suite → 27) PASS first try, proving the full chain including the cooldown bypass with mgmt_cooldown_secs armed; regressions mgmt_self_serve_link / mgmt_invite_offline / mgmt_dm_e2ee / fed_link_v12 green; 124 unit tests + clippy -D warnings. Docs updated to the invite-after-link flow (self-serve walkthrough + hosted-instance).

Follow-up noted in PR #119 (not this issue): greet_when_joined's #85 anti-spoof leave appears to break the manual operator invite-then-relink flow for >2-member rooms — the intent path sidesteps it, but the manual path deserves its own issue.

**Shipped in two stages, both merged to master; closing.** 1. **Honest reword** (v0.3.0-beta.3, PR #115): the join-failure reply became tier-aware, ending the "invite the bot first" dead-end instruction. 2. **The real fix — awaiting-invite link resume** (PR #119, operator-approved design in comment 1226): a self-serve `link` of an un-joinable room arms a single-use `(requester, room_id)` intent (TTL `[limits] invite_intent_ttl_secs`, default 30 min); the requester's own invite for that exact room is accepted through the gate (ban check first; #89a cooldown + global budget bypassed for the matched, already-velocity-gated intent) and the link resumes automatically to the pending Discord-approval code. Failed resumes reap the join (#83); unapproved pendings reap on the #87 path. `list` now surfaces pending approvals + intents. Unified across tiers. **Evidence:** live scenario `mgmt_link_invite_only` (suite → 27) PASS first try, proving the full chain including the cooldown bypass with `mgmt_cooldown_secs` armed; regressions `mgmt_self_serve_link` / `mgmt_invite_offline` / `mgmt_dm_e2ee` / `fed_link_v12` green; 124 unit tests + clippy `-D warnings`. Docs updated to the invite-after-link flow (self-serve walkthrough + hosted-instance). Follow-up noted in PR #119 (not this issue): `greet_when_joined`'s #85 anti-spoof leave appears to break the *manual* operator invite-then-relink flow for >2-member rooms — the intent path sidesteps it, but the manual path deserves its own issue.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
dark/nether-voicebridge#111
No description provided.