Skip to content

HTTP bridge /query silently scopes multi-value #h filters to a single channel (breaks desktop Workflows list) #4659

Description

@gldnlab

Describe the bug

The HTTP bridge POST /query returns wrong (empty) results for any filter with a multi-value #h tag. Instead of matching events in ANY of the listed channels (NIP-01 OR semantics), the query gets silently scoped to whichever channel UUID happens to iterate first out of the tag-value set — all other channels are ignored.

Root cause: query_events in api/bridge.rs builds its catch-all DB query via handlers::req::build_event_query_from_filter, which calls extract_channel_id_from_filter (handlers/req.rs). That helper returns the first parseable UUID from the #h values with no arity check:

// handlers/req.rs
fn extract_channel_id_from_filter(filter: &Filter) -> Option<uuid::Uuid> {
    for (tag_key, tag_values) in filter.generic_tags.iter() {
        ...
        if key == "h" {
            for val in tag_values {
                if let Ok(id) = val.parse::<uuid::Uuid>() {
                    return Some(id);   // <-- multi-#h collapses to one channel
                }
            }
        }
    }
    ...
}

bridge.rs has its own extract_channel_from_filter with the correct vs.len() == 1 guard, and the WS REQ path guards arity too (per_filter_channel in handle_req, and filter_fully_pushable explicitly documents multi-#h as not pushable) — but the bridge's query construction goes through the unguarded helper, so the SQL WHERE channel_id = $x predicate pins the query to one channel before the (correct) filters_match post-filter ever sees the rows.

Since generic_tags values are an ordered set, "first" is effectively the lexicographically smallest UUID, so which channel wins is data-dependent and surprising.

User-visible impact: the desktop Workflows screen fetches workflows for all member channels in one batched query (get_channels_workflows, single filter with "#h": [all member channel ids]). For any user in more than one channel whose lexicographically-first channel has no workflows, the screen shows "No workflows yet" even though workflows exist and run. Workflows created from the dialog appear to vanish (create succeeds, list never shows them, and they can't be edited/deleted from the UI).

Steps to reproduce

  1. On a relay with channels A and B (UUIDs such that A sorts before B), create a workflow in channel B (via desktop dialog or buzz workflows create).
  2. POST /query (NIP-98 auth) with body [{"kinds":[30620],"#h":["<A>","<B>"]}][].
  3. Same query with [{"kinds":[30620],"#h":["<B>"]}] → returns the workflow.
  4. Same query with [{"kinds":[30620]}] (no #h) → also returns the workflow (access-scope fallback works; only the multi-value scoping is broken).
  5. In the desktop app, be a member of ≥2 channels and open the Workflows screen → "No workflows yet".

Verified against a self-hosted relay running ghcr.io/block/buzz:main (2026-08-02); the unguarded helper is unchanged on current main.

Expected behavior

A multi-value #h filter matches events in any of the listed channels, like the WS REQ path: either push the full value set into SQL (e.g. channel_id = ANY($ids), intersected with the caller's accessible channels), or leave channel_id unset when len > 1 and let the existing access scope + filters_match post-filter handle it. Reusing the arity-guarded extract_channel_from_filter already in bridge.rs for query construction would fix the wrong-scoping half in one line.

Version and platform

  • Buzz version: desktop 0.5.4; relay image ghcr.io/block/buzz:main pulled 2026-08-02
  • OS: macOS (desktop), Linux/Railway (relay)

Logs / additional context

  • Reproduced with raw NIP-98 POST /query requests (steps 2–4 above) — happy to share the exact request/response pairs.
  • Related but separate quirks noticed while debugging (can file separately if useful): workflows delete removes the workflow from the scheduler but the kind:30620 event remains visible in list/get; and the create dialog surfaces no feedback when the list can't show the result, so users click Create repeatedly and accumulate duplicates.

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