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
- Join two channels, A and B, where only B contains a
kind:30620 workflow, and A's channel
UUID sorts lexicographically before B's.
- Open the Workflows view.
- The list is empty.
Directly against the bridge, with a NIP-98-signed POST /query:
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:
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:
AND channel_id = chA
AND (channel_id IS NULL OR channel_id IN (<accessible>))
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<SingleLetterTag, BTreeSet<String>>, 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:
if key == "h" {
- for val in tag_values {
- if let Ok(id) = val.parse::<uuid::Uuid>() {
- return Some(id);
- }
+ if tag_values.len() != 1 {
+ return None;
}
+ return tag_values.iter().next()?.parse::<uuid::Uuid>().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.)
Describe the bug
On the HTTP bridge (
POST /query), a filter carrying multiple#hvalues returns eventsfrom only one channel — the lexicographically smallest. Under NIP-01 the tag list is an OR
set, so every listed channel should match. WS
REQhandles the same filter correctly; onlythe 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). Theworkflows exist and run on schedule, but the view shows "No workflows yet". NIP-45
COUNTundercounts the same way, via the same query builder (
handlers/count.rs:153,:225;api/bridge.rs:1468,:1536).Steps to reproduce
kind:30620workflow, and A's channelUUID sorts lexicographically before B's.
Directly against the bridge, with a NIP-98-signed
POST /query:[{"kinds":[30620],"#h":["<chA>","<chB>"]}] // returns only chA's events [{"kinds":[30620],"#h":["<chB>"]}] // returns chB's eventsExpected behavior
The multi-value filter returns events from both channels, matching NIP-01 OR semantics and
the existing WS
REQbehavior. B's workflow appears in the Workflows view.Version and platform
ghcr.io/block/buzz, Postgres 17Logs / additional context
Two
#hextractors disagree inside the same request, atapi/bridge.rs:1227:build_event_query_from_filteruses the singularextract_channel_id_from_filter(
req.rs:851), which returns the first#hUUID →EventQuery.channel_id = Some(chA).apply_access_scope_to_queryis passedextract_channel_from_filter(bridge.rs:235),which returns
Nonefor a multi-value#h→ it setschannel_ids = accessiblebut doesnot clear
channel_id.Both predicates reach the SQL:
The
filters_matchpost-filter (bridge.rs:1299) can't recover — chB's rows were neverfetched. Which value survives is server-side and deterministic:
nostr'sGenericTagsisBTreeMap<SingleLetterTag, BTreeSet<String>>, so "first" is always the lexicographicallysmallest
#h.A minimal fix is to return
Nonefor a multi-value#h, matching the sibling extractors atbridge.rs:235andcount.rs:17. Callers then treat the filter as global, widen to theaccessible set, and let
filters_matchenforce the OR set — what WSREQalready does:On
origin/main@b7bb1512, a test asserting the multi-value case fails before this changeand passes after;
handlers::req::testsis 50/50 with three added tests, andcargo fmt -p buzz-relay -- --checkis clean. (The 9api::media/api::adminfailures inthe full lib suite are
Sqlx(PoolTimedOut)and fail identically without the patch.)