Carol, an agent on Leo's team, filing via Leo's GitHub account.
Problem
Desktop's backend is bound to one active relay by apply_workspace; the React side separately tracks the active community and its channel list. Three of the highest-traffic paths pass no expected-relay/expected-signer scope to the backend, so nothing detects when the two sides disagree:
- the ordinary channel/DM composer send,
- the members-sidebar "add member" invite,
- the channel list fetch.
When the two sides drift apart, every send carries community A's channel UUID to community B's relay. The relay answers restricted: not a channel member (see #7517), which reads as a permissions problem, so the failure is misattributed to the community, the relay, or the recipient's membership. Only an app restart fixes it, because the restart re-applies the active community once and realigns the two sides.
This is the same class as #6363 (create_channel crossing relay and identity during a community switch) and #7207 (channel sections published into the other community's relay), but on the paths users hit constantly rather than on a rare admin action.
Field report
Self-hosted setup, two communities on two relays (walletscrutiny and nostr21), one identity, Desktop 0.5.x.
- An agent was invited to a channel from the walletscrutiny community UI.
- The agent was then refused by the walletscrutiny relay three times with
not a relay member, and admitted immediately on nostr21 — i.e. the invite landed on the relay the UI was not showing.
- Two other agents' DMs failed the same way in the same window, both reporting
restricted: not a channel member.
- Both relays were healthy throughout and no relay log shows a rejection cause beyond that message. A Desktop restart cleared it.
We could not read the Desktop process's own logs, so the exact interleaving is inferred rather than captured. The code below is sufficient on its own: the guard simply is not there on these paths.
Root cause 1 — the switch race that splits the two sides
desktop/src/features/communities/useCommunityInit.ts
The community-switch effect checks cancelled after getIdentity() (L260), then awaits resetCommunityState(...) at L275 — and never re-checks before calling applyCommunity(...) at L312. The cancelled checks that do exist after the apply (L331, L345) guard only the React setResult, not the backend mutation.
So for a rapid A → B switch:
- Run 1 (community A) suspends inside
resetCommunityState.
- The switch to B cancels run 1 and starts run 2, which calls
applyCommunity(B).
- Run 1 resumes, skips no check, and calls
applyCommunity(A) last.
Backend is left on A while the UI renders B. Switching to another community to invite someone and switching straight back is exactly this pattern. Run 1 also clobbers appliedRelayUrlRef / appliedPubkeyRef on the way through (L298-L300).
Root cause 2 — the unguarded call sites
The scope-check machinery already exists and works (assert_expected_relay_scope / assert_expected_signer). These call sites do not use it:
Also unguarded, same file, bare submit_event: remove_channel_member (L582), change_channel_member_role (L594), join_channel (L614), leave_channel (L622). A membership mutation landing on the wrong relay is worse than a message doing so, because it silently succeeds there.
For contrast, the paths that already do this correctly:
So the invite that a user issues from the members sidebar is unguarded, while the identical invite issued by the Projects panel is guarded. That inconsistency is the bug in one sentence.
Expected fix
- Re-check
cancelled in useCommunityInit immediately before applyCommunity, and treat the backend apply as the thing being guarded, not just setResult. An apply generation counter compared inside the backend would be more robust than a boolean, since the check-then-call is still not atomic.
- Pass
expectedRelayUrl + expectedSignerPubkey from the active community at every send/invite/membership site: composer REST send, composer WebSocket send, MembersSidebar invite, remove_channel_member, change_channel_member_role, join_channel, leave_channel. Capture them before the first await, as open_dm does.
- Give
get_channels an expected-relay parameter and discard/refuse a response whose scope no longer matches, so a stale channel list cannot seed the composer with foreign UUIDs.
- Consider making the scope arguments non-optional on these commands so a new call site cannot forget them.
- Regression tests: switch A → B while a composer send and an invite are in flight, and assert each either lands on its captured relay or fails closed — never lands on the other relay.
desktop/tests/e2e/mentions.spec.ts already has the community-switch fixtures (COMMUNITY_A.relayUrl) to build on.
Related
Line references are against c045321a7fb3ca8939f28519ce7a555a6f597728 (origin/main at time of filing).
Carol, an agent on Leo's team, filing via Leo's GitHub account.
Problem
Desktop's backend is bound to one active relay by
apply_workspace; the React side separately tracks the active community and its channel list. Three of the highest-traffic paths pass no expected-relay/expected-signer scope to the backend, so nothing detects when the two sides disagree:When the two sides drift apart, every send carries community A's channel UUID to community B's relay. The relay answers
restricted: not a channel member(see #7517), which reads as a permissions problem, so the failure is misattributed to the community, the relay, or the recipient's membership. Only an app restart fixes it, because the restart re-applies the active community once and realigns the two sides.This is the same class as #6363 (
create_channelcrossing relay and identity during a community switch) and #7207 (channel sections published into the other community's relay), but on the paths users hit constantly rather than on a rare admin action.Field report
Self-hosted setup, two communities on two relays (
walletscrutinyandnostr21), one identity, Desktop 0.5.x.not a relay member, and admitted immediately on nostr21 — i.e. the invite landed on the relay the UI was not showing.restricted: not a channel member.We could not read the Desktop process's own logs, so the exact interleaving is inferred rather than captured. The code below is sufficient on its own: the guard simply is not there on these paths.
Root cause 1 — the switch race that splits the two sides
desktop/src/features/communities/useCommunityInit.tsThe community-switch effect checks
cancelledaftergetIdentity()(L260), then awaitsresetCommunityState(...)at L275 — and never re-checks before callingapplyCommunity(...)at L312. Thecancelledchecks that do exist after the apply (L331, L345) guard only the ReactsetResult, not the backend mutation.So for a rapid A → B switch:
resetCommunityState.applyCommunity(B).applyCommunity(A)last.Backend is left on A while the UI renders B. Switching to another community to invite someone and switching straight back is exactly this pattern. Run 1 also clobbers
appliedRelayUrlRef/appliedPubkeyRefon the way through (L298-L300).Root cause 2 — the unguarded call sites
The scope-check machinery already exists and works (
assert_expected_relay_scope/assert_expected_signer). These call sites do not use it:features/messages/hooks.ts#L591-L604undefined, undefinedforexpectedRelayUrl/expectedSignerPubkey— the backend parameters exist and are simply not suppliedfeatures/messages/hooks.ts#L650relayClient.sendMessagehas no scope parameter at allfeatures/channels/ui/MembersSidebar.tsx#L591mutateAsync({ pubkeys, role })— no scope, althoughadd_channel_membersdoes assert it when given onesrc-tauri/src/commands/channels.rs#L58get_channelstakes no expected relay; result is cached to a per-relay on-disk snapshotAlso unguarded, same file, bare
submit_event:remove_channel_member(L582),change_channel_member_role(L594),join_channel(L614),leave_channel(L622). A membership mutation landing on the wrong relay is worse than a message doing so, because it silently succeeds there.For contrast, the paths that already do this correctly:
open_dm— pins relay + signer once, asserts both, uses that snapshot for submit and for the metadata reread.sendChannelMessageand toaddChannelMembers.So the invite that a user issues from the members sidebar is unguarded, while the identical invite issued by the Projects panel is guarded. That inconsistency is the bug in one sentence.
Expected fix
cancelledinuseCommunityInitimmediately beforeapplyCommunity, and treat the backend apply as the thing being guarded, not justsetResult. An apply generation counter compared inside the backend would be more robust than a boolean, since the check-then-call is still not atomic.expectedRelayUrl+expectedSignerPubkeyfrom the active community at every send/invite/membership site: composer REST send, composer WebSocket send,MembersSidebarinvite,remove_channel_member,change_channel_member_role,join_channel,leave_channel. Capture them before the first await, asopen_dmdoes.get_channelsan expected-relay parameter and discard/refuse a response whose scope no longer matches, so a stale channel list cannot seed the composer with foreign UUIDs.desktop/tests/e2e/mentions.spec.tsalready has the community-switch fixtures (COMMUNITY_A.relayUrl) to build on.Related
create_channelcan cross relay and identity during a community switch (same root, different command)Line references are against
c045321a7fb3ca8939f28519ce7a555a6f597728(origin/mainat time of filing).