From 4eab6efde6f55ef080ff15d035dab12be622643b Mon Sep 17 00:00:00 2001 From: Santhi Prakash Date: Tue, 4 Aug 2026 09:15:52 +0000 Subject: [PATCH 1/2] fix(relay): guard multi-value #h filters from collapsing to one channel extract_channel_id_from_filter previously returned the first parseable UUID from a multi-value #h tag, so a POST /query filter listing multiple channel UUIDs silently collapsed to whichever channel sorted first. NIP-01 treats a multi-value tag as OR, so the filter must be handled by the Rust-side filters_match post-filter instead of being pinned to a single channel_id SQL predicate. Match the existing guard in bridge.rs::extract_channel_from_filter and the WS REQ path: only set channel_id when the filter contains exactly one #h value and it is a parseable UUID. Add focused regression tests. Fixes #4659 Signed-off-by: Santhi Prakash --- crates/buzz-relay/src/handlers/req.rs | 84 +++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 10 deletions(-) diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 2aed12cd7f..e0f37b9bdc 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -848,18 +848,20 @@ fn filters_are_nip43_membership_only(filters: &[Filter]) -> bool { } /// Extract a channel UUID from a single filter's `#h` tag. +/// +/// Only returns `Some` when the filter contains exactly one `#h` value and it +/// is a parseable UUID. Multi-value `#h` filters use NIP-01 OR semantics and +/// must be evaluated by the Rust-side `filters_match` post-filter, not pinned +/// to a single arbitrary channel in SQL. 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); - } - } + let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H); + filter.generic_tags.get(&h_tag).and_then(|vs| { + if vs.len() == 1 { + vs.iter().next()?.parse::().ok() + } else { + None } - } - None + }) } /// Convert a single NIP-01 filter into an [`EventQuery`] for the database. @@ -1579,6 +1581,68 @@ mod tests { assert_eq!(extract_channel_id_from_filters(&filters), Some(channel_id)); } + #[test] + fn extract_channel_id_from_filter_single_h() { + let channel_id = uuid::Uuid::new_v4(); + let filter = filter_with_channel(channel_id); + assert_eq!(extract_channel_id_from_filter(&filter), Some(channel_id)); + } + + #[test] + fn extract_channel_id_from_filter_multi_h_returns_none() { + let channel_a = uuid::Uuid::new_v4(); + let channel_b = uuid::Uuid::new_v4(); + let filter = Filter::new().custom_tag( + SingleLetterTag::lowercase(Alphabet::H), + channel_a.to_string(), + ); + let filter = filter.custom_tag( + SingleLetterTag::lowercase(Alphabet::H), + channel_b.to_string(), + ); + assert_eq!( + extract_channel_id_from_filter(&filter), + None, + "multi-value #h must not be pinned to an arbitrary channel" + ); + } + + #[test] + fn extract_channel_id_from_filter_no_h_returns_none() { + assert_eq!(extract_channel_id_from_filter(&Filter::new()), None); + } + + #[test] + fn extract_channel_id_from_filter_invalid_h_returns_none() { + let filter = + Filter::new().custom_tag(SingleLetterTag::lowercase(Alphabet::H), "not-a-uuid"); + assert_eq!(extract_channel_id_from_filter(&filter), None); + } + + #[test] + fn build_event_query_from_filter_multi_h_leaves_channel_id_unset() { + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + let channel_a = uuid::Uuid::new_v4(); + let channel_b = uuid::Uuid::new_v4(); + let filter = Filter::new() + .custom_tag( + SingleLetterTag::lowercase(Alphabet::H), + channel_a.to_string(), + ) + .custom_tag( + SingleLetterTag::lowercase(Alphabet::H), + channel_b.to_string(), + ); + + let query = + filter_to_query_params(&filter, extract_channel_id_from_filter(&filter), community); + + assert_eq!( + query.channel_id, None, + "multi-value #h must not set channel_id" + ); + } + #[test] fn test_search_filter_detection() { let search_filter = Filter::new().search("hello world"); From 478af72ceaf2cb388bf5e36c0c058794e2174510 Mon Sep 17 00:00:00 2001 From: Santhi Prakash Date: Tue, 4 Aug 2026 09:46:19 +0000 Subject: [PATCH 2/2] fix(relay): include event kind in HTTP bridge success attribution log Signed-off-by: Santhi Prakash --- crates/buzz-relay/src/api/bridge.rs | 68 ++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index a118ff453f..03671dfe68 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -654,12 +654,13 @@ pub async fn submit_event( submit_event_authed(&state, &tenant, &headers, &body, pubkey, event_id_bytes).await; match &outcome { - SubmitOutcome::Ok { accepted, .. } => { + SubmitOutcome::Ok { accepted, kind, .. } => { tracing::info!( pubkey = %pubkey_hex, route = "/events", status = 200u16, accepted, + kind, "HTTP bridge request" ); } @@ -713,6 +714,7 @@ enum SubmitOutcome { /// Ingest pipeline ran and returned a result (accepted or not). Ok { accepted: bool, + kind: u32, response: Json, }, /// JSON parse failure before ingest — log category/line/column, not msg. @@ -843,6 +845,7 @@ async fn submit_event_authed( })); SubmitOutcome::Ok { accepted: result.accepted, + kind: kind_u32, response, } } @@ -3766,4 +3769,67 @@ mod tests { "attribution line must carry the pubkey;\nlog:\n{log}" ); } + + /// T3c — accepted Ok arm: a POST /events with a valid global text note + /// (kind:1) must include `kind` in the single attribution log line. + /// + /// This is a focused regression test for block/buzz#4676: the Ok arm of + /// submit_event previously logged only `accepted`, so all accepted event + /// kinds produced identical `route:"/events", status:200, accepted:true` + /// lines. + /// + /// Discriminating: if the `kind` field is removed from the `SubmitOutcome::Ok` + /// match arm or the `tracing::info!` call, this test fails. + #[test] + #[ignore = "requires Postgres"] + fn submit_event_text_note_includes_kind_in_attribution_line() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let state = rt + .block_on(bridge_handler_test_state()) + .expect("local Postgres not reachable — start Postgres on 127.0.0.1:5432 before running ignored bridge handler tests"); + + let host = { + let h = format!("bridge-attr-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&h)) + .expect("ensure community"); + h + }; + + let client_keys = Keys::generate(); + let pubkey_hex = client_keys.public_key().to_hex(); + + let text_note = EventBuilder::new(Kind::TextNote, "attribution kind test") + .sign_with_keys(&client_keys) + .expect("sign text note"); + let event_json = serde_json::to_vec(&text_note).expect("serialize event"); + + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let (status, log) = metrics::with_local_recorder(&recorder, || { + run_and_capture(&rt, state, &host, &pubkey_hex, &event_json) + }); + + assert_eq!( + status, + axum::http::StatusCode::OK, + "valid text note must be accepted with 200" + ); + + let n = count_attribution_lines(&log); + assert_eq!( + n, 1, + "expected exactly 1 attribution line for accepted event, got {n};\nlog:\n{log}" + ); + assert!( + log.contains("kind=1"), + "attribution line for accepted event must include kind;\nlog:\n{log}" + ); + assert!( + log.contains(&pubkey_hex[..16]), + "attribution line must carry the pubkey;\nlog:\n{log}" + ); + } }