block / block/buzz

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

Open
#7,518 1 comment 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.