diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 2b76c2e0a1..ac6718e2eb 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -218,6 +218,17 @@ fn extract_feed_types(raw: &Value) -> Option> { } } +fn extract_search_mode(raw: &Value) -> buzz_search::SearchMode { + match raw + .get("search_mode") + .or_else(|| raw.get("searchMode")) + .and_then(Value::as_str) + { + Some("prefix") => buzz_search::SearchMode::Prefix, + _ => buzz_search::SearchMode::FullText, + } +} + fn event_in_accessible_channel(se: &buzz_core::StoredEvent, accessible: &[uuid::Uuid]) -> bool { match se.channel_id { Some(ch_id) => accessible.contains(&ch_id), @@ -410,6 +421,7 @@ pub async fn query_events( } return handle_bridge_search( &state, + &raw_filters, &filters, &accessible_channels, &tenant, @@ -846,6 +858,7 @@ fn search_hit_accepted( /// from DB. Returns first page of results (no pagination for bridge MVP). async fn handle_bridge_search( state: &AppState, + raw_filters: &[Value], filters: &[nostr::Filter], accessible_channels: &[uuid::Uuid], tenant: &buzz_core::tenant::TenantContext, @@ -866,7 +879,8 @@ async fn handle_bridge_search( let mut events: Vec = Vec::new(); let mut seen_ids: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new(); - for filter in filters { + for (raw, filter) in raw_filters.iter().zip(filters) { + let search_mode = extract_search_mode(raw); let search_text = match &filter.search { Some(s) if !s.is_empty() => s.clone(), _ => continue, @@ -922,6 +936,7 @@ async fn handle_bridge_search( until, page: 1, per_page: limit, + mode: search_mode, }; let search_result = state @@ -1268,6 +1283,30 @@ mod tests { assert!(!has_mixed_search_filters(&filters)); } + #[test] + fn bridge_search_mode_extension_defaults_to_full_text() { + assert_eq!( + extract_search_mode(&serde_json::json!({ "search": "pro" })), + buzz_search::SearchMode::FullText + ); + assert_eq!( + extract_search_mode(&serde_json::json!({ "search": "pro", "search_mode": "word" })), + buzz_search::SearchMode::FullText + ); + } + + #[test] + fn bridge_search_mode_extension_accepts_prefix_snake_or_camel_case() { + assert_eq!( + extract_search_mode(&serde_json::json!({ "search": "pro", "search_mode": "prefix" })), + buzz_search::SearchMode::Prefix + ); + assert_eq!( + extract_search_mode(&serde_json::json!({ "search": "pro", "searchMode": "prefix" })), + buzz_search::SearchMode::Prefix + ); + } + /// Attack 3 proof: two stateless relay pods sharing Redis must share one /// community-scoped NIP-98 seen-set. Pod A's first claim succeeds; pod B's /// replay of the same event id in the same community is rejected. The same diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 3a3ae73cd1..6667948eb6 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -555,6 +555,7 @@ async fn handle_search_req( until, page, per_page, + mode: buzz_search::SearchMode::FullText, }; let search_result = match state.search.search(&search_query).await { diff --git a/crates/buzz-search/src/lib.rs b/crates/buzz-search/src/lib.rs index 22e40a21bb..3c206327b6 100644 --- a/crates/buzz-search/src/lib.rs +++ b/crates/buzz-search/src/lib.rs @@ -28,7 +28,7 @@ pub mod query; pub use buzz_core::CommunityId; pub use error::SearchError; -pub use query::{search, ChannelScope, SearchHit, SearchQuery, SearchResult}; +pub use query::{search, ChannelScope, SearchHit, SearchMode, SearchQuery, SearchResult}; use sqlx::PgPool; diff --git a/crates/buzz-search/src/query.rs b/crates/buzz-search/src/query.rs index 9f2837bd6b..7f33b660c4 100644 --- a/crates/buzz-search/src/query.rs +++ b/crates/buzz-search/src/query.rs @@ -52,6 +52,19 @@ pub enum ChannelScope { ChannelsOrChannelLess(Vec), } +/// Search matching semantics. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SearchMode { + /// Standard NIP-50-ish word/lexeme search using `websearch_to_tsquery`. + FullText, + /// Prefix-match the trailing normalized query token (`pro` matches `project`). + /// + /// Intended for bounded typeahead surfaces such as the desktop topbar. The + /// relay still refetches and re-authorizes every hit; this mode changes only + /// the candidate tsquery, not the access boundary. + Prefix, +} + /// A community-scoped FTS query. /// /// The community is REQUIRED at the type level — there is no construction path @@ -81,6 +94,8 @@ pub struct SearchQuery { pub page: u32, /// Page size. Clamped at 500 internally. pub per_page: u32, + /// Matching semantics for the search text. + pub mode: SearchMode, } /// A single FTS hit. The relay refetches the canonical `StoredEvent` and @@ -113,7 +128,7 @@ pub struct SearchResult { const PER_PAGE_MAX: u32 = 500; const PER_PAGE_DEFAULT: u32 = 100; -/// Hard cap on search text handed to `websearch_to_tsquery`. This keeps a +/// Hard cap on search text handed to Postgres text-search parsers. This keeps a /// single request from spending unbounded parser CPU/memory while still allowing /// far longer queries than the desktop UI normally emits. const SEARCH_TEXT_MAX_CHARS: usize = 4096; @@ -122,6 +137,45 @@ const SEARCH_TEXT_MAX_CHARS: usize = 4096; /// wire untrusted input into a multi-trillion-row OFFSET. const PAGE_MAX: u32 = 1000; +fn push_tsquery(qb: &mut QueryBuilder, mode: SearchMode, search_text: &str) { + match mode { + SearchMode::FullText => { + qb.push("websearch_to_tsquery('simple', "); + qb.push_bind(search_text); + qb.push(")"); + } + SearchMode::Prefix => { + // Prefix mode is for typeahead: completed whitespace-delimited tokens + // are exact, and only the trailing token is suffixed with `:*`. + // Each raw token still goes through Postgres' `simple` parser before + // tsquery construction so query-side normalization matches the + // `search_tsv` generated column. `quote_literal` prevents tsquery + // syntax injection from punctuation/operators in the raw topbar input. + qb.push( + "(SELECT COALESCE(\ + string_agg(\ + quote_literal(lexeme) || CASE WHEN token_ord = max_token_ord THEN ':*' ELSE '' END, \ + ' & ' ORDER BY token_ord, lex_ord\ + ), \ + ''\ + )::tsquery \ + FROM (\ + SELECT raw_token.token_ord, normalized.lexeme, normalized.lex_ord, raw_token.max_token_ord \ + FROM (\ + SELECT token, token_ord, max(token_ord) OVER () AS max_token_ord \ + FROM regexp_split_to_table(", + ); + qb.push_bind(search_text); + qb.push( + ", '\\s+') WITH ORDINALITY AS split(token, token_ord)\ + ) AS raw_token \ + CROSS JOIN LATERAL unnest(tsvector_to_array(to_tsvector('simple', raw_token.token))) \ + WITH ORDINALITY AS normalized(lexeme, lex_ord)\ + ) AS prefix_terms)", + ); + } + } +} fn normalized_search_text(q: &str) -> Option { let trimmed = q.trim(); if trimmed.is_empty() { @@ -148,7 +202,7 @@ fn normalized_search_text(q: &str) -> Option { /// SELECT id, kind, pubkey, channel_id, EXTRACT(EPOCH FROM created_at)::bigint AS created_at_s, /// ts_rank_cd(search_tsv, query) AS rank /// FROM events, -/// websearch_to_tsquery('simple', $q) AS query +/// AS query /// WHERE community_id = $ctx /// AND deleted_at IS NULL /// AND search_tsv @@ query @@ -179,13 +233,13 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result = QueryBuilder::new( "SELECT id, kind, pubkey, channel_id, \ EXTRACT(EPOCH FROM created_at)::bigint AS created_at_s, \ - ts_rank_cd(search_tsv, query) AS rank \ - FROM events, websearch_to_tsquery('simple', ", + ts_rank_cd(search_tsv, search_query.query) AS rank \ + FROM events CROSS JOIN LATERAL (SELECT ", ); - qb.push_bind(&search_text); - qb.push(") AS query WHERE community_id = "); + push_tsquery(&mut qb, query.mode, &search_text); + qb.push(" AS query) AS search_query WHERE community_id = "); qb.push_bind(*query.community.as_uuid()); - qb.push(" AND deleted_at IS NULL AND search_tsv @@ query"); + qb.push(" AND deleted_at IS NULL AND search_tsv @@ search_query.query"); // Channel scope — see `ChannelScope` doc for the four-case mapping. The // emitted SQL fragments are identical to the legacy 2x2 tuple for the diff --git a/crates/buzz-search/tests/fts_integration.rs b/crates/buzz-search/tests/fts_integration.rs index 353991a161..8f16d6c7d8 100644 --- a/crates/buzz-search/tests/fts_integration.rs +++ b/crates/buzz-search/tests/fts_integration.rs @@ -149,6 +149,7 @@ async fn search_finds_event_in_same_community() { until: None, page: 1, per_page: 10, + mode: buzz_search::SearchMode::FullText, }) .await .expect("search ok"); @@ -196,6 +197,7 @@ async fn search_does_not_return_other_community_events() { until: None, page: 1, per_page: 10, + mode: buzz_search::SearchMode::FullText, }) .await .unwrap(); @@ -212,6 +214,7 @@ async fn search_does_not_return_other_community_events() { until: None, page: 1, per_page: 10, + mode: buzz_search::SearchMode::FullText, }) .await .unwrap(); @@ -255,6 +258,7 @@ async fn kind0_search_by_display_name_works_without_flattening() { until: None, page: 1, per_page: 10, + mode: buzz_search::SearchMode::FullText, }) .await .unwrap(); @@ -264,6 +268,241 @@ async fn kind0_search_by_display_name_works_without_flattening() { teardown(pool, &schema).await; } +#[tokio::test] +#[ignore = "requires Postgres"] +async fn prefix_mode_matches_final_token_prefix_without_changing_full_text() { + let (pool, schema) = setup().await; + + let c = mk_community(&pool, "prefix.example").await; + let project_plan = rand_bytes32(); + let project_archive = rand_bytes32(); + let projectile_plan = rand_bytes32(); + insert_event( + &pool, + c, + project_plan, + rand_bytes32(), + 9, + "project planning milestone", + None, + 1_700_000_000, + ) + .await; + insert_event( + &pool, + c, + project_archive, + rand_bytes32(), + 9, + "project archive", + None, + 1_700_000_001, + ) + .await; + insert_event( + &pool, + c, + projectile_plan, + rand_bytes32(), + 9, + "projectile planning distractor", + None, + 1_700_000_002, + ) + .await; + + let svc = SearchService::new(pool.clone()); + let full_text = svc + .search(&SearchQuery { + community: c, + q: "pro".into(), + channel_scope: ChannelScope::Any, + kinds: Some(vec![9]), + authors: None, + since: None, + until: None, + page: 1, + per_page: 10, + mode: buzz_search::SearchMode::FullText, + }) + .await + .expect("full text search ok"); + assert!( + full_text.hits.is_empty(), + "plain full-text search must stay word/lexeme-based, got {:?}", + full_text + .hits + .iter() + .map(|h| h.event_id) + .collect::>() + ); + + let prefix = svc + .search(&SearchQuery { + community: c, + q: "pro".into(), + channel_scope: ChannelScope::Any, + kinds: Some(vec![9]), + authors: None, + since: None, + until: None, + page: 1, + per_page: 10, + mode: buzz_search::SearchMode::Prefix, + }) + .await + .expect("prefix search ok"); + let prefix_ids: Vec<[u8; 32]> = prefix.hits.iter().map(|h| h.event_id).collect(); + assert!(prefix_ids.contains(&project_plan)); + assert!(prefix_ids.contains(&project_archive)); + + let multi_token_prefix = svc + .search(&SearchQuery { + community: c, + q: "project pla".into(), + channel_scope: ChannelScope::Any, + kinds: Some(vec![9]), + authors: None, + since: None, + until: None, + page: 1, + per_page: 10, + mode: buzz_search::SearchMode::Prefix, + }) + .await + .expect("multi-token prefix search ok"); + assert_eq!(multi_token_prefix.hits.len(), 1); + assert_eq!(multi_token_prefix.hits[0].event_id, project_plan); + + let completed_token_is_exact = svc + .search(&SearchQuery { + community: c, + q: "project pl".into(), + channel_scope: ChannelScope::Any, + kinds: Some(vec![9]), + authors: None, + since: None, + until: None, + page: 1, + per_page: 10, + mode: buzz_search::SearchMode::Prefix, + }) + .await + .expect("completed-token exact + trailing-prefix search ok"); + let completed_ids: Vec<[u8; 32]> = completed_token_is_exact + .hits + .iter() + .map(|h| h.event_id) + .collect(); + assert!(completed_ids.contains(&project_plan)); + assert!( + !completed_ids.contains(&projectile_plan), + "completed tokens must stay exact; only the trailing token gets :*" + ); + + teardown(pool, &schema).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn prefix_mode_handles_tsquery_boundary_punctuation() { + let (pool, schema) = setup().await; + + let c = mk_community(&pool, "prefix-punctuation.example").await; + let hit_id = rand_bytes32(); + insert_event( + &pool, + c, + hit_id, + rand_bytes32(), + 9, + "operators ' : & | ( ) ! alpha beta marker", + None, + 1_700_000_000, + ) + .await; + + let svc = SearchService::new(pool.clone()); + let result = svc + .search(&SearchQuery { + community: c, + q: "operators ' : & | ( ) ! alpha be".into(), + channel_scope: ChannelScope::Any, + kinds: Some(vec![9]), + authors: None, + since: None, + until: None, + page: 1, + per_page: 10, + mode: buzz_search::SearchMode::Prefix, + }) + .await + .expect("prefix punctuation search ok"); + + assert_eq!(result.hits.len(), 1); + assert_eq!(result.hits[0].event_id, hit_id); + + teardown(pool, &schema).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn prefix_mode_preserves_storage_level_privacy_exclusions() { + let (pool, schema) = setup().await; + + let c = mk_community(&pool, "prefix-privacy.example").await; + let token = "prefixprivacy_unique_marker"; + let control_id = rand_bytes32(); + + insert_event( + &pool, + c, + control_id, + rand_bytes32(), + 9, + &format!("public control {token}"), + None, + 1_700_000_000, + ) + .await; + + for (i, &kind) in [1059_u32, 30300, 30622, 44100, 44101].iter().enumerate() { + insert_event( + &pool, + c, + rand_bytes32(), + rand_bytes32(), + kind as i32, + &format!("private kind {kind} {token}"), + None, + 1_700_000_100 + i as i64, + ) + .await; + } + + let svc = SearchService::new(pool.clone()); + let result = svc + .search(&SearchQuery { + community: c, + q: "prefixpriv".into(), + channel_scope: ChannelScope::Any, + kinds: None, + authors: None, + since: None, + until: None, + page: 1, + per_page: 10, + mode: buzz_search::SearchMode::Prefix, + }) + .await + .expect("prefix privacy search ok"); + + assert_eq!(result.hits.len(), 1); + assert_eq!(result.hits[0].event_id, control_id); + + teardown(pool, &schema).await; +} + #[tokio::test] #[ignore = "requires Postgres"] async fn channel_scope_restricts_results() { @@ -333,6 +572,7 @@ async fn channel_scope_restricts_results() { until: None, page: 1, per_page: 10, + mode: buzz_search::SearchMode::FullText, }) .await .unwrap(); @@ -351,6 +591,7 @@ async fn channel_scope_restricts_results() { until: None, page: 1, per_page: 10, + mode: buzz_search::SearchMode::FullText, }) .await .unwrap(); @@ -368,6 +609,7 @@ async fn channel_scope_restricts_results() { until: None, page: 1, per_page: 10, + mode: buzz_search::SearchMode::FullText, }) .await .unwrap(); @@ -385,6 +627,7 @@ async fn channel_scope_restricts_results() { until: None, page: 1, per_page: 10, + mode: buzz_search::SearchMode::FullText, }) .await .unwrap(); @@ -423,6 +666,7 @@ async fn deleted_events_are_excluded() { until: None, page: 1, per_page: 10, + mode: buzz_search::SearchMode::FullText, }) .await .unwrap(); @@ -453,6 +697,7 @@ async fn empty_query_returns_empty_result_no_roundtrip() { until: None, page: 1, per_page: 10, + mode: buzz_search::SearchMode::FullText, }) .await .unwrap(); @@ -519,6 +764,7 @@ async fn since_until_filters() { until: Some(1_700_015_000), page: 1, per_page: 10, + mode: buzz_search::SearchMode::FullText, }) .await .unwrap(); @@ -562,6 +808,7 @@ async fn pagination_works() { until: None, page: 1, per_page: 3, + mode: buzz_search::SearchMode::FullText, }) .await .unwrap(); @@ -578,6 +825,7 @@ async fn pagination_works() { until: None, page: 3, per_page: 3, + mode: buzz_search::SearchMode::FullText, }) .await .unwrap(); @@ -669,6 +917,7 @@ async fn channel_less_only_excludes_per_channel_events() { until: None, page: 1, per_page: 10, + mode: buzz_search::SearchMode::FullText, }) .await .unwrap(); @@ -716,6 +965,7 @@ async fn nul_bytes_in_query_are_sanitized() { until: None, page: 1, per_page: 10, + mode: buzz_search::SearchMode::FullText, }) .await .expect("NUL-containing search query should not bubble a Postgres error"); @@ -758,6 +1008,7 @@ async fn enormous_page_number_is_clamped() { until: None, page: u32::MAX, per_page: 10, + mode: buzz_search::SearchMode::FullText, }) .await .expect("huge page number should be bounded, not error"); @@ -786,6 +1037,7 @@ async fn very_long_query_is_bounded_before_pg_parse() { until: None, page: 1, per_page: 10, + mode: buzz_search::SearchMode::FullText, }) .await .expect("long search query should be capped before Postgres parses it"); @@ -912,6 +1164,7 @@ async fn excluded_kinds_are_storage_level_unsearchable() { until: None, page: 1, per_page: 10, + mode: buzz_search::SearchMode::FullText, }) .await .expect("search ok"); @@ -1006,6 +1259,7 @@ async fn author_only_kinds_are_storage_level_unsearchable() { until: None, page: 1, per_page: 100, + mode: buzz_search::SearchMode::FullText, }) .await .expect("search ok"); @@ -1112,6 +1366,7 @@ async fn p_gated_persistent_kinds_have_storage_null_tsvector() { until: None, page: 1, per_page: 100, + mode: buzz_search::SearchMode::FullText, }) .await .expect("search ok"); diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 2a04666fc4..5d4210374b 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -100,6 +100,24 @@ pub async fn get_feed( }) } +fn build_search_messages_filter(q: &str, cap: u32, channel_id: Option<&str>) -> serde_json::Value { + let mut filter = serde_json::Map::new(); + filter.insert( + "kinds".to_string(), + serde_json::json!([9, 40002, 45001, 45003]), + ); + filter.insert("search".to_string(), serde_json::json!(q.trim())); + // The desktop topbar is a typeahead surface. This bridge-only extension is + // consumed before nostr::Filter parsing on the relay, so general WS/NIP-50 + // search remains word/lexeme-based. + filter.insert("search_mode".to_string(), serde_json::json!("prefix")); + filter.insert("limit".to_string(), serde_json::json!(cap)); + if let Some(cid) = channel_id { + filter.insert("#h".to_string(), serde_json::json!([cid])); + } + serde_json::Value::Object(filter) +} + #[tauri::command] pub async fn search_messages( q: String, @@ -108,18 +126,9 @@ pub async fn search_messages( state: State<'_, AppState>, ) -> Result { let cap = limit.unwrap_or(20).min(100); - let mut filter = serde_json::Map::new(); - filter.insert( - "kinds".to_string(), - serde_json::json!([9, 40002, 45001, 45003]), - ); - filter.insert("search".to_string(), serde_json::json!(q.trim())); - filter.insert("limit".to_string(), serde_json::json!(cap)); - if let Some(cid) = channel_id { - filter.insert("#h".to_string(), serde_json::json!([cid])); - } + let filter = build_search_messages_filter(&q, cap, channel_id.as_deref()); - let events = query_relay(&state, &[serde_json::Value::Object(filter)]).await?; + let events = query_relay(&state, &[filter]).await?; Ok(nostr_convert::search_response_from_events(&events)) } @@ -568,6 +577,16 @@ mod tests { ); } + #[test] + fn search_messages_filter_requests_prefix_mode_for_topbar_typeahead() { + let filter = build_search_messages_filter(" pro ", 12, Some("channel-1")); + + assert_eq!(filter["search"], serde_json::json!("pro")); + assert_eq!(filter["search_mode"], serde_json::json!("prefix")); + assert_eq!(filter["limit"], serde_json::json!(12)); + assert_eq!(filter["#h"], serde_json::json!(["channel-1"])); + } + #[test] fn stored_managed_agent_auth_tag_trims_blank_values() { assert_eq!(