Background
PR #366 fixes 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 (plus the check_membership_status modal routing). That guard closes every realistic path to the symptom.
This issue tracks a defense-in-depth gap one layer deeper, in the invitation-accept GET handler (ui/src/components/app/freenet_api/response_handler/get_response.rs).
The gap
If the entry-level guard is ever skipped — e.g. ROOMS.try_read() returns Err at accept time, or some future caller populates PENDING_INVITES for a room the user is already in — the GET handler still runs the full new-member accept for an already-member room:
Why a partial backstop is NOT enough
A first attempt in PR #366 guarded only the self_sk overwrite ("don't clobber self_sk if the held key is already a member"). External (Codex) review correctly flagged this as worse, not better: it preserves the old self_sk while the rest of the handler still records the new key's credentials and PUTs the new member. That leaves self_sk and self_authorized_member/self_member_info pointing at different keys — a split/inconsistent local state that breaks identity export and rejoin/member-info flows. The original (unconditional-clobber) behavior is at least internally consistent. So the partial guard was reverted.
The correct fix
Detect "already a member under the held self_sk" early — before build_state_for_put — and short-circuit the entire accept: do not PUT a duplicate member, do not record new self-credentials, do not change self_sk. Treat it as a no-op / refresh (merge the retrieved state, repopulate secrets, clear the pending invite). This needs a synchronous ROOMS read near the top of the is_pending_invite branch, with a fall-through only when ROOMS is genuinely unreadable.
Add a regression/pin test for the short-circuit. This is the structural backstop that makes #365 impossible regardless of how the GET was triggered.
Refs: get_response.rs (~249 build_state_for_put, ~335 self_sk overwrite, ~411 record_invite_credentials). Follow-up to #365 / PR #366.
[AI-assisted - Claude]
Background
PR #366 fixes 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(plus thecheck_membership_statusmodal routing). That guard closes every realistic path to the symptom.This issue tracks a defense-in-depth gap one layer deeper, in the invitation-accept GET handler (
ui/src/components/app/freenet_api/response_handler/get_response.rs).The gap
If the entry-level guard is ever skipped — e.g.
ROOMS.try_read()returnsErrat accept time, or some future caller populatesPENDING_INVITESfor a room the user is already in — the GET handler still runs the full new-member accept for an already-member room:build_state_for_put(~line 249) synthesizes a join for the invitation's fresh key and PUTs a duplicate member to the network.ROOMSmutation overwritesRoomData.self_skwith the fresh key (~line 335), 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_credentials(~line 411) records the fresh key'sself_authorized_member/self_member_info.Why a partial backstop is NOT enough
A first attempt in PR #366 guarded only the
self_skoverwrite ("don't clobberself_skif the held key is already a member"). External (Codex) review correctly flagged this as worse, not better: it preserves the oldself_skwhile the rest of the handler still records the new key's credentials and PUTs the new member. That leavesself_skandself_authorized_member/self_member_infopointing at different keys — a split/inconsistent local state that breaks identity export and rejoin/member-info flows. The original (unconditional-clobber) behavior is at least internally consistent. So the partial guard was reverted.The correct fix
Detect "already a member under the held
self_sk" early — beforebuild_state_for_put— and short-circuit the entire accept: do not PUT a duplicate member, do not record new self-credentials, do not changeself_sk. Treat it as a no-op / refresh (merge the retrieved state, repopulate secrets, clear the pending invite). This needs a synchronousROOMSread near the top of theis_pending_invitebranch, with a fall-through only whenROOMSis genuinely unreadable.Add a regression/pin test for the short-circuit. This is the structural backstop that makes #365 impossible regardless of how the GET was triggered.
Refs:
get_response.rs(~249build_state_for_put, ~335self_skoverwrite, ~411record_invite_credentials). Follow-up to #365 / PR #366.[AI-assisted - Claude]