Skip to content

relay: multi-value #h filters collapse to lexicographically-first channel (Workflows overview shows empty) #2385

Description

@leesalminen

Summary

The relay's /query (HTTP bridge) and REQ paths mishandle a filter with multiple distinct #h values: instead of Nostr's OR semantics ("match any listed channel"), the query silently collapses to the lexicographically-first channel UUID. Every other listed channel's events are dropped.

User-visible impact: the Desktop Workflows overview screen is empty for essentially everyone. get_channels_workflows batches all member-channel ids into one #h filter (desktop/src-tauri/src/commands/workflows.rs, whose comment asserts "A nostr #h filter matches ANY of its listed values") — but the relay only returns workflows for whichever channel UUID sorts first, typically a DM channel with none.

Root cause

Two channel extractors disagree, and the bridge catchall composes them into a contradictory query:

  • extract_channel_id_from_filter (crates/buzz-relay/src/handlers/req.rs — singular-filter variant) returns the first parseable #h value even when several are present (for val in tag_values { ... return Some(id) }). Filter tag values live in a BTreeSet, so "first" = lexicographic minimum.
  • extract_channel_from_filter (crates/buzz-relay/src/api/bridge.rs) correctly requires vs.len() == 1 and returns None for multi-value filters.

In the bridge catchall (query_events_authed), build_event_query_from_filter uses the first extractor → query.channel_id = Some(<first-sorted id>), then apply_access_scope_to_query is passed the second extractor's None → also sets query.channel_ids = accessible_channels. Resulting SQL (captured via log_statement=all):

SELECT ... FROM events
WHERE community_id = $1 AND deleted_at IS NULL
  AND channel_id = $2                                   -- first-sorted #h value
  AND (channel_id IS NULL OR channel_id IN ($3,$4,$5))  -- access scope
  AND kind IN ($6) ...

The channel_id = $2 clause pins the whole query to one channel.

Note the plural WS-subscription variant extract_channel_id_from_filters already handles this correctly ("Multiple distinct channel IDs — fall back to global"); the singular variant predates that rule.

Repro (any relay, any community)

  1. Create workflows in channel A (id 57e6...). Confirm single-channel query returns them:
    POST /query [{"kinds":[30620],"#h":["57e6..."]}] → N events ✅
  2. Add a second channel id that sorts before A:
    [{"kinds":[30620],"#h":["4e18...","57e6..."]}][]
  3. Add a second channel id that sorts after A:
    [{"kinds":[30620],"#h":["57e6...","6254..."]}] → N events (only A's) — confirms first-sorted collapse.

buzz_core::filter::filters_match implements #h OR correctly, so the in-memory post-filter is not the problem — rows never come back from SQL.

Suggested fix

Make extract_channel_id_from_filter return None when the filter carries multiple distinct parseable #h values (mirroring extract_channel_id_from_filters), so the access-scope path takes over and filters_match enforces the per-event #h OR check. Alternatively push all #h values into SQL as an IN list. Either way, a regression test: multi-#h query must return events from every listed accessible channel.

Happy to send a PR for either shape.

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