Summary
The relay's /query (HTTP bridge) and REQ paths mishandle a filter with multiple distinct #h values: instead of Nostr's OR semantics ("match any listed channel"), the query silently collapses to the lexicographically-first channel UUID. Every other listed channel's events are dropped.
User-visible impact: the Desktop Workflows overview screen is empty for essentially everyone. get_channels_workflows batches all member-channel ids into one #h filter (desktop/src-tauri/src/commands/workflows.rs, whose comment asserts "A nostr #h filter matches ANY of its listed values") — but the relay only returns workflows for whichever channel UUID sorts first, typically a DM channel with none.
Root cause
Two channel extractors disagree, and the bridge catchall composes them into a contradictory query:
extract_channel_id_from_filter (crates/buzz-relay/src/handlers/req.rs — singular-filter variant) returns the first parseable #h value even when several are present (for val in tag_values { ... return Some(id) }). Filter tag values live in a BTreeSet, so "first" = lexicographic minimum.
extract_channel_from_filter (crates/buzz-relay/src/api/bridge.rs) correctly requires vs.len() == 1 and returns None for multi-value filters.
In the bridge catchall (query_events_authed), build_event_query_from_filter uses the first extractor → query.channel_id = Some(<first-sorted id>), then apply_access_scope_to_query is passed the second extractor's None → also sets query.channel_ids = accessible_channels. Resulting SQL (captured via log_statement=all):
SELECT ... FROM events
WHERE community_id = $1 AND deleted_at IS NULL
AND channel_id = $2 -- first-sorted #h value
AND (channel_id IS NULL OR channel_id IN ($3,$4,$5)) -- access scope
AND kind IN ($6) ...
The channel_id = $2 clause pins the whole query to one channel.
Note the plural WS-subscription variant extract_channel_id_from_filters already handles this correctly ("Multiple distinct channel IDs — fall back to global"); the singular variant predates that rule.
Repro (any relay, any community)
- Create workflows in channel A (id
57e6...). Confirm single-channel query returns them:
POST /query [{"kinds":[30620],"#h":["57e6..."]}] → N events ✅
- Add a second channel id that sorts before A:
[{"kinds":[30620],"#h":["4e18...","57e6..."]}] → [] ❌
- Add a second channel id that sorts after A:
[{"kinds":[30620],"#h":["57e6...","6254..."]}] → N events (only A's) — confirms first-sorted collapse.
buzz_core::filter::filters_match implements #h OR correctly, so the in-memory post-filter is not the problem — rows never come back from SQL.
Suggested fix
Make extract_channel_id_from_filter return None when the filter carries multiple distinct parseable #h values (mirroring extract_channel_id_from_filters), so the access-scope path takes over and filters_match enforces the per-event #h OR check. Alternatively push all #h values into SQL as an IN list. Either way, a regression test: multi-#h query must return events from every listed accessible channel.
Happy to send a PR for either shape.
Summary
The relay's
/query(HTTP bridge) and REQ paths mishandle a filter with multiple distinct#hvalues: instead of Nostr's OR semantics ("match any listed channel"), the query silently collapses to the lexicographically-first channel UUID. Every other listed channel's events are dropped.User-visible impact: the Desktop Workflows overview screen is empty for essentially everyone.
get_channels_workflowsbatches all member-channel ids into one#hfilter (desktop/src-tauri/src/commands/workflows.rs, whose comment asserts "A nostr#hfilter matches ANY of its listed values") — but the relay only returns workflows for whichever channel UUID sorts first, typically a DM channel with none.Root cause
Two channel extractors disagree, and the bridge catchall composes them into a contradictory query:
extract_channel_id_from_filter(crates/buzz-relay/src/handlers/req.rs— singular-filter variant) returns the first parseable#hvalue even when several are present (for val in tag_values { ... return Some(id) }). Filter tag values live in aBTreeSet, so "first" = lexicographic minimum.extract_channel_from_filter(crates/buzz-relay/src/api/bridge.rs) correctly requiresvs.len() == 1and returnsNonefor multi-value filters.In the bridge catchall (
query_events_authed),build_event_query_from_filteruses the first extractor →query.channel_id = Some(<first-sorted id>), thenapply_access_scope_to_queryis passed the second extractor'sNone→ also setsquery.channel_ids = accessible_channels. Resulting SQL (captured vialog_statement=all):The
channel_id = $2clause pins the whole query to one channel.Note the plural WS-subscription variant
extract_channel_id_from_filtersalready handles this correctly ("Multiple distinct channel IDs — fall back to global"); the singular variant predates that rule.Repro (any relay, any community)
57e6...). Confirm single-channel query returns them:POST /query[{"kinds":[30620],"#h":["57e6..."]}]→ N events ✅[{"kinds":[30620],"#h":["4e18...","57e6..."]}]→[]❌[{"kinds":[30620],"#h":["57e6...","6254..."]}]→ N events (only A's) — confirms first-sorted collapse.buzz_core::filter::filters_matchimplements#hOR correctly, so the in-memory post-filter is not the problem — rows never come back from SQL.Suggested fix
Make
extract_channel_id_from_filterreturnNonewhen the filter carries multiple distinct parseable#hvalues (mirroringextract_channel_id_from_filters), so the access-scope path takes over andfilters_matchenforces the per-event#hOR check. Alternatively push all#hvalues into SQL as anINlist. Either way, a regression test: multi-#hquery must return events from every listed accessible channel.Happy to send a PR for either shape.