Summary
A /query (or REQ) filter with multiple #h channel values — e.g.
{"kinds":[30620], "#h":[<id1>,<id2>,…]} — is silently answered as if it
named only ONE channel: the lexicographically smallest id. Events in every
other listed channel are never returned. No error or warning is surfaced.
Root cause
extract_channel_id_from_filter in crates/buzz-relay/src/handlers/req.rs
returns the first parseable UUID from the filter's #h values:
fn extract_channel_id_from_filter(filter: &Filter) -> Option<uuid::Uuid> {
for (tag_key, tag_values) in filter.generic_tags.iter() {
let key = tag_key.to_string();
if key == "h" {
for val in tag_values {
if let Ok(id) = val.parse::<uuid::Uuid>() {
return Some(id);
}
}
}
}
None
}
nostr::Filter stores generic_tags values in a sorted set, so "first" is
deterministically the smallest id. build_event_query_from_filter bakes that
single id into the SQL scope (filter_to_query_params(filter, channel_id, …)),
and the Rust post-filter afterwards can only REMOVE rows — it can never
restore the channels the SQL scope dropped.
The sibling helper extract_channel_id_from_filters (plural, same file)
shows the intended semantics: on multiple distinct channel ids it returns
None and falls back to global scope. The singular version lacks that guard.
The HTTP bridge's own extract_channel_from_filter
(crates/buzz-relay/src/api/bridge.rs) also gets it right (None unless
exactly one value) — but it feeds only the access-scope check, not the query
builder, so the narrowing still happens underneath it.
User-visible impact
Buzz Desktop's global Workflows screen sends exactly this shape — one batched
kind:30620 query with every member channel in #h
(desktop/src/features/workflows/ui/WorkflowsView.tsx →
get_channels_workflows). For any user whose alphabetically-first channel is
a DM (typical), the screen renders "No workflows yet" even when workflows
exist and run. Refresh does not help. Any other client using multi-#h
filters is affected the same way.
Reproduction (empirical, self-hosted relay at 788b3c0)
NIP-98-signed POST /query, requester is a member of all channels named:
#h values |
Result |
| [apiary] |
2 workflow events |
| [dm-sorting-before-apiary, apiary] |
0 events |
| [dm-sorting-after-apiary, apiary] |
2 events |
Every combination follows the sort rule. Repro script available.
Expected
Multi-#h should scope to the UNION of the listed channels — e.g. push the
whole set down as a channel_ids IN-list (the mechanism the access-scope
path already uses) instead of first-value narrowing, or fall back to
global scope + post-filter as extract_channel_id_from_filters does.
Notes
filter_fully_pushable already declares multi-#h "not pushable", so the
fast-COUNT path correctly avoids it — the defect is only in the query-scope
builder. A fix should add a regression test: multi-#h filter returns events
from ALL listed channels regardless of sort order.
A PR with a proposed fix (IN-list pushdown + regression tests) follows
shortly and will be linked here.
Summary
A
/query(or REQ) filter with multiple#hchannel values — e.g.{"kinds":[30620], "#h":[<id1>,<id2>,…]}— is silently answered as if itnamed only ONE channel: the lexicographically smallest id. Events in every
other listed channel are never returned. No error or warning is surfaced.
Root cause
extract_channel_id_from_filterincrates/buzz-relay/src/handlers/req.rsreturns the first parseable UUID from the filter's
#hvalues:nostr::Filterstoresgeneric_tagsvalues in a sorted set, so "first" isdeterministically the smallest id.
build_event_query_from_filterbakes thatsingle id into the SQL scope (
filter_to_query_params(filter, channel_id, …)),and the Rust post-filter afterwards can only REMOVE rows — it can never
restore the channels the SQL scope dropped.
The sibling helper
extract_channel_id_from_filters(plural, same file)shows the intended semantics: on multiple distinct channel ids it returns
Noneand falls back to global scope. The singular version lacks that guard.The HTTP bridge's own
extract_channel_from_filter(
crates/buzz-relay/src/api/bridge.rs) also gets it right (Noneunlessexactly one value) — but it feeds only the access-scope check, not the query
builder, so the narrowing still happens underneath it.
User-visible impact
Buzz Desktop's global Workflows screen sends exactly this shape — one batched
kind:30620 query with every member channel in
#h(
desktop/src/features/workflows/ui/WorkflowsView.tsx→get_channels_workflows). For any user whose alphabetically-first channel isa DM (typical), the screen renders "No workflows yet" even when workflows
exist and run. Refresh does not help. Any other client using multi-
#hfilters is affected the same way.
Reproduction (empirical, self-hosted relay at 788b3c0)
NIP-98-signed
POST /query, requester is a member of all channels named:#hvaluesEvery combination follows the sort rule. Repro script available.
Expected
Multi-
#hshould scope to the UNION of the listed channels — e.g. push thewhole set down as a
channel_idsIN-list (the mechanism the access-scopepath already uses) instead of first-value narrowing, or fall back to
global scope + post-filter as
extract_channel_id_from_filtersdoes.Notes
filter_fully_pushablealready declares multi-#h"not pushable", so thefast-COUNT path correctly avoids it — the defect is only in the query-scope
builder. A fix should add a regression test: multi-
#hfilter returns eventsfrom ALL listed channels regardless of sort order.
A PR with a proposed fix (IN-list pushdown + regression tests) follows
shortly and will be linked here.