`POST /query` returns events from only one channel when a filter has multiple `#h` values
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
**Describe the bug**
On the HTTP bridge (`POST /query`), a filter carrying multiple `#h` values returns events
from only one channel — the lexicographically smallest. Under NIP-01 the tag list is an OR
set, so every listed channel should match. WS `REQ` handles the same filter correctly; only
the bridge is affected.
This silently empties the desktop Workflows overview, which sends exactly this filter
(`desktop/src-tauri/src/commands/workflows.rs:87` → `query_relay` → `POST /query`). The
workflows exist and run on schedule, but the view shows "No workflows yet". NIP-45 `COUNT`
undercounts the same way, via the same query builder (`handlers/count.rs:153`, `:225`;
`api/bridge.rs:1468`, `:1536`).
**Steps to reproduce**
1. Join two channels, A and B, where only B contains a `kind:30620` workflow, and A's channel
UUID sorts lexicographically before B's.
2. Open the Workflows view.
3. The list is empty.
Directly against the bridge, with a NIP-98-signed `POST /query`:
```jsonc
[{"kinds":[30620],"#h":["",""]}] // returns only chA's events
[{"kinds":[30620],"#h":[""]}] // returns chB's events
```
**Expected behavior**
The multi-value filter returns events from both channels, matching NIP-01 OR semantics and
the existing WS `REQ` behavior. B's workflow appears in the Workflows view.
**Version and platform**
- Buzz version: 0.5.2 (desktop); relay self-hosted from `ghcr.io/block/buzz`, Postgres 17
- OS: macOS 26.6
**Logs / additional context**
Two `#h` extractors disagree inside the same request, at `api/bridge.rs:1227`:
```rust
let mut query = req::build_event_query_from_filter(filter, ...).await;
req::apply_access_scope_to_query(
&mut query,
extract_channel_from_filter(filter),
&accessible_channels,
);
```
- `build_event_query_from_filter` uses the singular `extract_channel_id_from_filter`
(`req.rs:851`), which returns the **first** `#h` UUID → `EventQuery.channel_id = Some(chA)`.
- `apply_access_scope_to_query` is passed `extract_channel_from_filter` (`bridge.rs:235`),
which returns `None` for a multi-value `#h` → it sets `channel_ids = accessible` but does
not clear `channel_id`.
Both predicates reach the SQL:
```sql
AND channel_id = chA
AND (channel_id IS NULL OR channel_id IN ())
```
The `filters_match` post-filter (`bridge.rs:1299`) can't recover — chB's rows were never
fetched. Which value survives is server-side and deterministic: `nostr`'s `GenericTags` is
`BTreeMap>`, so "first" is always the lexicographically
smallest `#h`.
A minimal fix is to return `None` for a multi-value `#h`, matching the sibling extractors at
`bridge.rs:235` and `count.rs:17`. Callers then treat the filter as global, widen to the
accessible set, and let `filters_match` enforce the OR set — what WS `REQ` already does:
```rust
if key == "h" {
- for val in tag_values {
- if let Ok(id) = val.parse::() {
- return Some(id);
- }
+ if tag_values.len() != 1 {
+ return None;
}
+ return tag_values.iter().next()?.parse::().ok();
}
```
On `origin/main` @ `b7bb1512`, a test asserting the multi-value case fails before this change
and passes after; `handlers::req::tests` is 50/50 with three added tests, and
`cargo fmt -p buzz-relay -- --check` is clean. (The 9 `api::media` / `api::admin` failures in
the full lib suite are `Sqlx(PoolTimedOut)` and fail identically without the patch.)
Contributor guide
Research direction
Start in req.rs at extract_channel_id_from_filter and trace its use from api/bridge.rs:1227 into the query builder; compare the sibling extractors in bridge.rs:235 and count.rs:17. Run the handlers::req::tests multi-value filter case and cargo fmt -p buzz-relay -- --check. Done means multi-value #h filters return events from every accessible channel without breaking single-value queries.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- postgresql, rust
- Domain
- backend-api-design, databases
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 88/100