Skip to content

Desktop: composer send, member invite and channel list carry no expected-relay scope — a community-switch race publishes them to the wrong relay #7518

Description

@Giszmo

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.

  1. An agent was invited to a channel from the walletscrutiny community UI.
  2. 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.
  3. Two other agents' DMs failed the same way in the same window, both reporting restricted: not a channel member.
  4. 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:

  1. Run 1 (community A) suspends inside resetCommunityState.
  2. The switch to B cancels run 1 and starts run 2, which calls applyCommunity(B).
  3. 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:

Path Site State
Composer send (REST) features/messages/hooks.ts#L591-L604 passes undefined, undefined for expectedRelayUrl / expectedSignerPubkey — the backend parameters exist and are simply not supplied
Composer send (WebSocket) features/messages/hooks.ts#L650 relayClient.sendMessage has no scope parameter at all
Member invite features/channels/ui/MembersSidebar.tsx#L591 mutateAsync({ pubkeys, role }) — no scope, although add_channel_members does assert it when given one
Channel list src-tauri/src/commands/channels.rs#L58 get_channels takes no expected relay; result is cached to a per-relay on-disk snapshot

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

  1. 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.
  2. 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.
  3. 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.
  4. Consider making the scope arguments non-optional on these commands so a new call site cannot forget them.
  5. 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).

Activity

  1. Giszmo commented on Sep 9, 2026

    @Giszmo
    Author

    Non-agent bug report: I switch between communities and asked agent Bob on Community B to create Carol on community C on their VM. Bob gave me Carol's npub. I "invited Carol on community C". Bob was surprised but knew to check on B and made Carol introduce herself on B. My DM to Bob on B failed until I restarted Buzz desktop.

  2. EugeneBot01 commented on Oct 10, 2026

    @EugeneBot01

    We are seeing what looks like this on a hosted community (*.communities.buzz.xyz), desktop 0.5.27 on macOS.

    • A member who is in the channel (both people show in the member list) could not post a thread reply that mentions another member. The toast was Message failed to send: restricted: not a channel member.
    • The affected people use more than one community on the same app, and have separately reported that switching communities is slow and that the sidebar sometimes still shows the previous community's channels after a switch.
    • I have not yet confirmed that restarting the app clears it in our case; I will follow up when I know.

    Disclosure: we run a build with our own client-side patches on top of the tagged release (sidebar, calendar, notifications). None of them touch the composer send path or community switching, and sendChannelMessage is called without expectedRelayUrl in the unpatched source as described above (features/messages/hooks.ts, features/home/ui/HomeView.tsx).

    Two things would have saved us time: the distinct error in #7517, and the composer failing closed with a message that says the community switch has not finished.

    Written with Claude Code on behalf of the account owner.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions