From feb201faf786feea5141a7af729f46fc98a3ed17 Mon Sep 17 00:00:00 2001 From: jayfarei Date: Wed, 22 Jul 2026 10:56:52 +0100 Subject: [PATCH 1/2] fix(relay): preserve multi-channel workflow queries Avoid pushing a single channel predicate when a Nostr filter contains multiple #h values, so batched Desktop workflow listings retain every accessible channel. Co-authored-by: jayfarei Signed-off-by: jayfarei --- crates/buzz-relay/src/handlers/req.rs | 30 ++++++++++++++++----------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index ecc3ba6777..9d136744d2 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -838,19 +838,13 @@ fn filters_are_nip43_membership_only(filters: &[Filter]) -> bool { }) } -/// Extract a channel UUID from a single filter's `#h` tag. +/// Extract a channel UUID when a single filter targets exactly one channel. +/// +/// Multiple distinct `#h` values cannot be represented by the single-channel +/// SQL predicate, so they fall back to the accessible-channel query and Nostr +/// post-filtering. fn extract_channel_id_from_filter(filter: &Filter) -> Option { - 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::() { - return Some(id); - } - } - } - } - None + extract_channel_id_from_filters(std::slice::from_ref(filter)) } /// Convert a single NIP-01 filter into an [`EventQuery`] for the database. @@ -1407,6 +1401,18 @@ mod tests { assert_eq!(extract_channel_id_from_filters(&filters), Some(channel_id)); } + #[test] + fn test_extract_channel_id_from_filter_multiple_channels_is_not_pushed_down() { + let channel_a = uuid::Uuid::new_v4(); + let channel_b = uuid::Uuid::new_v4(); + let filter = Filter::new().custom_tags( + SingleLetterTag::lowercase(Alphabet::H), + [channel_a.to_string(), channel_b.to_string()], + ); + + assert_eq!(extract_channel_id_from_filter(&filter), None); + } + #[test] fn test_extract_channel_id_mixed_channels_returns_none() { let channel_a = uuid::Uuid::new_v4(); From 9d4cef124802423704ceee37843846888f961e92 Mon Sep 17 00:00:00 2001 From: jayfarei Date: Wed, 22 Jul 2026 11:05:40 +0100 Subject: [PATCH 2/2] fix(relay): bound multi-channel history queries Intersect explicit multi-channel filters with the caller's accessible channels before SQL LIMIT so unrelated channel rows cannot hide requested workflows. Co-authored-by: jayfarei Signed-off-by: jayfarei --- crates/buzz-relay/src/handlers/req.rs | 73 +++++++++++++++++++++------ 1 file changed, 58 insertions(+), 15 deletions(-) diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 9d136744d2..d3c1e83c5d 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -271,27 +271,24 @@ pub async fn handle_req( .iter() .enumerate() .map(|(idx, filter)| { + let requested_channels = extract_channel_ids_from_filter(filter); // Use per-filter #h channel scope when available, falling back to the // subscription-level channel_id. This prevents unrelated accessible-channel // rows from consuming the LIMIT when filters target specific channels but // the subscription is global (multiple distinct #h values across filters). - let per_filter_channel = { - let h = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H); - filter - .generic_tags - .get(&h) - .and_then(|vs| { - if vs.len() == 1 { - vs.iter().next()?.parse::().ok() - } else { - None - } - }) - .or(channel_id) - }; + let per_filter_channel = extract_channel_id_from_filter(filter).or(channel_id); let mut params = filter_to_query_params(filter, per_filter_channel, conn.tenant.community()); - apply_access_scope_to_query(&mut params, per_filter_channel, &accessible_channels); + if requested_channels.is_empty() { + apply_access_scope_to_query(&mut params, per_filter_channel, &accessible_channels); + } else { + apply_requested_access_scope_to_query( + &mut params, + per_filter_channel, + &accessible_channels, + &requested_channels, + ); + } (idx, per_filter_channel, params) }) .collect(); @@ -847,6 +844,18 @@ fn extract_channel_id_from_filter(filter: &Filter) -> Option { extract_channel_id_from_filters(std::slice::from_ref(filter)) } +/// Extract every valid channel UUID requested by one filter. +fn extract_channel_ids_from_filter(filter: &Filter) -> Vec { + let h = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H); + filter + .generic_tags + .get(&h) + .into_iter() + .flatten() + .filter_map(|value| value.parse::().ok()) + .collect() +} + /// Convert a single NIP-01 filter into an [`EventQuery`] for the database. /// /// Each filter is queried independently so that per-filter `limit` and time @@ -994,6 +1003,24 @@ pub(crate) fn apply_access_scope_to_query( } } +/// Push only explicitly requested channels that the caller may access. +fn apply_requested_access_scope_to_query( + query: &mut EventQuery, + channel_id: Option, + accessible_channels: &[uuid::Uuid], + requested_channels: &[uuid::Uuid], +) { + if channel_id.is_none() { + query.channel_ids = Some( + requested_channels + .iter() + .copied() + .filter(|channel| accessible_channels.contains(channel)) + .collect(), + ); + } +} + /// Extract a single channel UUID from filter generic tags, or `None` if the /// subscription is logically global. /// @@ -1255,6 +1282,22 @@ mod tests { assert_eq!(query.channel_id, Some(channel)); } + #[test] + fn multi_channel_filters_push_requested_accessible_scope_before_limit() { + let requested_accessible = uuid::Uuid::new_v4(); + let requested_inaccessible = uuid::Uuid::new_v4(); + let unrelated_accessible = uuid::Uuid::new_v4(); + let accessible = vec![requested_accessible, unrelated_accessible]; + let requested = vec![requested_accessible, requested_inaccessible]; + let mut query = EventQuery::for_community(buzz_core::tenant::CommunityId::from_uuid( + uuid::Uuid::new_v4(), + )); + + apply_requested_access_scope_to_query(&mut query, None, &accessible, &requested); + + assert_eq!(query.channel_ids, Some(vec![requested_accessible])); + } + /// S2 invariant: the bounded-concurrency pipeline (phase 2) must yield /// per-filter results in original filter order even when an earlier /// filter's DB query completes *after* a later one. `buffered` guarantees