Skip to content

Multi-value #h filters silently narrow to the lexicographically first channel, dropping all others #4579

Description

@redirwin

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.

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