relay: multi-value #h filters collapse to lexicographically-first channel (Workflows overview shows empty)
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
## 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()`, 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`):
```sql
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)
1. Create workflows in channel A (id `57e6...`). Confirm single-channel query returns them:
`POST /query` `[{"kinds":[30620],"#h":["57e6..."]}]` → N events ✅
2. Add a second channel id that sorts **before** A:
`[{"kinds":[30620],"#h":["4e18...","57e6..."]}]` → `[]` ❌
3. 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.
Contributor guide
Research direction
Start with extract_channel_id_from_filter in crates/buzz-relay/src/handlers/req.rs, compare it with extract_channel_from_filter in crates/buzz-relay/src/api/bridge.rs and the plural extractor, then trace query_events_authed and the workflow query in desktop/src-tauri/src/commands/workflows.rs. Reproduce /query with two #h values and add a regression test. Done means multi-value filters return events from every listed accessible channel without breaking single-channel queries.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust, sql
- Domain
- api, backend, databases
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100