Desktop: composer send, member invite and channel list carry no expected-relay scope — a community-switch race publishes them to the wrong relay
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
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`](https://github.com/block/buzz/blob/c045321a7fb3ca8939f28519ce7a555a6f597728/desktop/src/features/communities/useCommunityInit.ts#L275-L320)
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`](https://github.com/block/buzz/blob/c045321a7fb3ca8939f28519ce7a555a6f597728/desktop/src/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`](https://github.com/block/buzz/blob/c045321a7fb3ca8939f28519ce7a555a6f597728/desktop/src/features/messages/hooks.ts#L650) | `relayClient.sendMessage` has no scope parameter at all |
| Member invite | [`features/channels/ui/MembersSidebar.tsx#L591`](https://github.com/block/buzz/blob/c045321a7fb3ca8939f28519ce7a555a6f597728/desktop/src/features/channels/ui/MembersSidebar.tsx#L591) | `mutateAsync({ pubkeys, role })` — no scope, although `add_channel_members` [does assert it](https://github.com/block/buzz/blob/c045321a7fb3ca8939f28519ce7a555a6f597728/desktop/src-tauri/src/commands/channels.rs#L537-L553) when given one |
| Channel list | [`src-tauri/src/commands/channels.rs#L58`](https://github.com/block/buzz/blob/c045321a7fb3ca8939f28519ce7a555a6f597728/desktop/src-tauri/src/commands/channels.rs#L58-L62) | `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:
- [`open_dm`](https://github.com/block/buzz/blob/c045321a7fb3ca8939f28519ce7a555a6f597728/desktop/src-tauri/src/commands/dms.rs#L34-L60) — pins relay + signer once, asserts both, uses that snapshot for submit and for the metadata reread.
- Projects agent chat — [passes both scopes through to `sendChannelMessage`](https://github.com/block/buzz/blob/c045321a7fb3ca8939f28519ce7a555a6f597728/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx#L194-L207) and to `addChannelMembers`.
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
- #6363 — `create_channel` can cross relay and identity during a community switch (same root, different command)
- #7207 — channel sections of one community published into another's relay after a workspace switch
- #5762 — mobile counterpart of #7207
- #6370 — no per-community data isolation on Desktop
Line references are against `c045321a7fb3ca8939f28519ce7a555a6f597728` (`origin/main` at time of filing).
Contributor guide
Research direction
Start with desktop/src/features/communities/useCommunityInit.ts and trace the applyCommunity race, then inspect the composer and membership call sites in features/messages/hooks.ts, features/channels/ui/MembersSidebar.tsx, and src-tauri/src/commands/channels.rs. Use desktop/tests/e2e/mentions.spec.ts and its COMMUNITY_A fixtures to exercise a community switch during sends and invites. Done means stale operations either use their captured relay scope or fail closed, and channel-list data cannot cross relay boundaries.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, rust, typescript
- Domain
- backend, desktop, frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100