Skip to content

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

Description

@aaronmiller-info

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions