fix(ui): structurally prevent re-accept orphaning in the invitation GET handler (#367)#368
Merged
Conversation
…andler Add an early short-circuit at the top of the `is_pending_invite` branch in `handle_get_response`: when a GET for a pending invite arrives for a room the user is ALREADY a member of under their held per-room identity (`RoomData.self_sk`), treat it as a no-op refresh (merge retrieved state, repopulate secrets, clear the pending invite) instead of running the full new-member accept. Without this, a GET that reached the handler with `PENDING_INVITES` populated for an already-member room (e.g. the accept-time guard in `receive_invitation_modal::accept_invitation` falling through on an unreadable `ROOMS`, or a future caller) would PUT a duplicate member, overwrite `self_sk` with the invitation's fresh key — orphaning the original membership (the #365 mechanism) — and record the fresh key's self-credentials. A partial backstop guarding only the `self_sk` overwrite was rejected on PR #366 for leaving `self_sk` and the recorded credentials pointing at different keys; the correct fix short-circuits the entire accept before `build_state_for_put`. The short-circuit gates on a new pure `held_key_is_member` predicate that mirrors `vk_is_room_member` (the accept-time guard's predicate), so both layers agree on what "already a member" means. It is best-effort in the same sense: it only fires when `ROOMS` is readable, falling through to the normal accept otherwise. A genuine rejoin after leaving is unaffected (`leave_room` drops the room from `ROOMS`). Tests: - `held_key_is_member_matches_owner_and_members` — pure predicate unit test (owner, member, and non-member/genuine-new-join cases). - `reaccept_get_short_circuits_before_build_state_for_put` — source-grep pin that the short-circuit runs before `build_state_for_put` (the full handler reads signals/PUTs over the WebApi, so it can't run under a host unit test). - Updated `repopulate_secrets_call_sites_pinned` count 5 -> 6 for the new refresh path's `repopulate_secrets_from_state` call. Full `river-ui --bins` suite passes (366 tests); fmt + clippy clean. UI-only, no delegate/contract WASM touched, so no migration required. Closes #367 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address Codex P2 review finding on PR #368: the re-accept backstop judged membership from the local ROOMS snapshot, which can be stale. If the user was pruned for inactivity or removed/banned remotely, local ROOMS may still list their held key while the freshly-fetched canonical state no longer does — and a new invitation in that situation is a LEGITIMATE rejoin that must publish the invitation's fresh key. Now the short-circuit reads only WHICH key the user holds (`self_sk`) from the local snapshot and decides membership against the authoritative `retrieved_state.members`. A held key that is no longer a member canonically falls through to the normal accept (legitimate rejoin), while an actually-held membership still short-circuits to a no-op refresh (no duplicate join, no orphaning). Extends the `reaccept_get_short_circuits_before_build_state_for_put` source pin to assert the predicate is applied to `retrieved_state.members`, so a future refactor can't silently revert to the stale-local-state check. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address two further Codex P2 findings on PR #368: 1. Refresh-only short-circuit left the room stuck in `RoomSyncStatus::Subscribing`. `process_rooms()` marks the room `Subscribing` before sending the GET; the refresh path neither PUTs nor re-subscribes, so nothing cleared that state and the room sat in `Subscribing` until the retry/timeout machinery fired. Now set `Subscribed` on the refresh path, mirroring the existing-room refresh branch. 2. Genuine-rejoin fall-through could still split local identity. When the held key is no longer a member in the canonical state (pruned/banned remotely) the guard correctly falls through to the normal accept — but the downstream existing-room `self_sk` overwrite was gated on the STALE PRE-MERGE local members, so a stale snapshot still listing the old key left `self_sk` at the old key while the PUT published the fresh member and the recorded credentials pointed at the fresh key. Move the merge BEFORE the membership check so the `self_sk` decision is judged against the canonical merged members: a removed held key is now correctly replaced by the fresh invitation key (consistent rejoin), while an actually-held membership is still preserved (no orphan). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address Codex P2 finding on PR #368: the refresh-only short-circuit set SYNC_INFO to `Subscribed`, but this code path never establishes a subscription (the pending-invite GET is sent with `subscribe: false`, and the refresh path neither PUTs nor subscribes). For a room that is not actually subscribed yet — a stale pending invite surviving a reload/reconnect, or an imported room — claiming `Subscribed` would make `rooms_awaiting_subscription` skip it, so the room would silently stop receiving updates while appearing healthy. Reset to `Disconnected` instead. That is the status `rooms_awaiting_subscription` keys on, so `process_rooms`'s existing-room path will establish a genuine PUT+subscribe when needed; the re-PUT is idempotent and self-healing if the room was in fact already subscribed. This still gets the room out of the `Subscribing` state the pending-invite GET left it in (the original finding). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
Author
Review summary (Full tier — serial path)This PR touches invitation/membership state-authorization, a high-risk surface and the site of a prior bug (#365), so it is Full tier. It was produced by a dispatched agent that cannot spawn parallel subagents, so reviews were run serially by the author per External model pass
Codex findings — all addressed
Four-lens read (author)
Local verification
[AI-assisted - Claude] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Follow-up to #365 / PR #366. PR #366 fixed the user-visible #365 bug (accepting an invite to a room you're already in joins you twice and orphans the original membership) with an entry-level guard in
receive_invitation_modal::accept_invitation. That guard closes every realistic path to the symptom, but it is best-effort: it falls through ifROOMS.try_read()returnsErrat accept time.This PR closes the defense-in-depth gap one layer deeper, in the invitation-accept GET handler (
get_response.rs). If the entry-level guard is ever skipped — unreadableROOMS, or some future caller populatingPENDING_INVITESfor a room the user is already in — the GET handler still ran the full new-member accept for an already-member room:build_state_for_putPUTs a duplicate member for the invitation's fresh key.ROOMSmutation overwritesRoomData.self_skwith the fresh key, orphaning the original membership (the original Accepting an invite to a room you're already in joins twice and orphans the original membership #365 mechanism).record_invite_credentialsrecords the fresh key'sself_authorized_member/self_member_info.A first attempt in PR #366 guarded only the
self_skoverwrite. External (Codex) review correctly flagged that as worse: it preserved the oldself_skwhile the rest of the handler still recorded the new key's credentials and PUT the new member, leavingself_skand the recorded credentials pointing at different keys — a split, internally inconsistent local state. That partial guard was reverted.Approach
Detect "already a member under the held
self_sk" early — beforebuild_state_for_put— and short-circuit the entire accept:held_key_is_member(owner_vk, members, self_vk)mirrorsvk_is_room_member(the accept-time guard's predicate) so both layers agree on what "already a member" means. It checks the heldself_sk, never the invitation's freshinvitee_signing_key.is_pending_invitebranch, synchronously readROOMS; if the held key is already a member, do a no-op refresh: merge the retrieved state,capture_self_membership_data, repopulate secrets, mark the pending inviteSubscribed(so the modal's terminal-success path closes it), and return. No duplicate PUT, no new credentials, noself_skchange.ROOMSis readable; otherwise falls through to the normal accept (prior behavior for that rare case). A genuine rejoin after leaving is unaffected —leave_roomdrops the room fromROOMS, so the held key is no longer a member and the guard does not fire.This is the structural backstop that makes #365 impossible regardless of how the GET was triggered.
Testing
cargo test -p river-ui --bins— full suite passes (366 tests). New / updated tests:held_key_is_member_matches_owner_and_members— pure predicate unit test (owner, member, and non-member/genuine-new-join cases).reaccept_get_short_circuits_before_build_state_for_put— source-grep pin that the short-circuit is evaluated beforebuild_state_for_put(the full handler reads theROOMS/PENDING_INVITESsignals and PUTs over the WebApi, so it can't be exercised by a host unit test — same approach as the existing Identity/room import can't recover a room several contract-WASM generations behind (restores stale "old IDs") #292 / Accepting an invite to a room you're already in joins twice and orphans the original membership #365 pins in this file).repopulate_secrets_call_sites_pinnedcount updated 5 → 6 for the new refresh path'srepopulate_secrets_from_statecall (the pin's doc-comment explicitly requires this when a new state-ingestion path is added).cargo fmt+cargo clippy -p river-ui --binsclean (no new warnings). UI-only change — no delegate/contract WASM touched, so no migration required.Review
Risk tier: Full (touches invitation/membership state-authorization logic, the surface of a prior bug). Multi-model review run serially (this PR was produced by a dispatched agent that cannot spawn parallel subagents): external non-Claude model pass (
codex review/gemini) plus the four review lenses (code-first, testing, skeptical, big-picture). Findings and outcome posted as a follow-up comment.Closes #367
[AI-assisted - Claude]
🤖 Generated with Claude Code