`POST /query` returns events from only one channel when a filter has multiple `#h` values

Open Beginner friendly
#4,381 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
88/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Quiet
Tech stack
postgresql, rust

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.

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:87query_relayPOST /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:

[{"kinds":[30620],"#h":["<chA>","<chB>"]}]   // returns only chA's events
[{"kinds":[30620],"#h":["<chB>"]}]           // 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:

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.)

Dominant language
Rust
Stars
33.7k
Forks
4.4k
Avg merge
1d 21h
Merged PRs (30d)
239

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from block/buzz

All issues in block/buzz

Similar issues

More Rust issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.