diff --git a/crates/buzz-search/tests/fts_integration.rs b/crates/buzz-search/tests/fts_integration.rs index 945064fbb..f5ab58ad4 100644 --- a/crates/buzz-search/tests/fts_integration.rs +++ b/crates/buzz-search/tests/fts_integration.rs @@ -6,7 +6,10 @@ //! migration into it, exercises a scenario, and drops it. Tests are //! parallel-safe. -use buzz_core::{kind::AUTHOR_ONLY_KINDS, CommunityId}; +use buzz_core::{ + kind::{AUTHOR_ONLY_KINDS, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION}, + CommunityId, +}; use buzz_search::{ChannelScope, SearchQuery, SearchService}; use sqlx::{postgres::PgPoolOptions, Executor, PgPool}; use uuid::Uuid; @@ -798,13 +801,15 @@ async fn very_long_query_is_bounded_before_pg_parse() { /// - 1059 = `KIND_GIFT_WRAP` (NIP-17 ciphertext) /// - 30300 = `KIND_EVENT_REMINDER` (in `AUTHOR_ONLY_KINDS`) /// - 30622 = `KIND_DM_VISIBILITY` (per-viewer private hide state) +/// - 44100 = `KIND_MEMBER_ADDED_NOTIFICATION` (p-gated membership notice) +/// - 44101 = `KIND_MEMBER_REMOVED_NOTIFICATION` (p-gated membership notice) /// -/// All four events are inserted with the same unique token in their content +/// All six events are inserted with the same unique token in their content /// so a single search query exercises every kind in one round-trip. Only -/// the kind:9 control must surface — the three excluded kinds must not. +/// the kind:9 control must surface — the excluded kinds must not. /// /// Mutate-bite: drop the `CASE WHEN kind IN (…)` from the generated column -/// (revert to `to_tsvector('simple', content)`) → all four events surface → +/// (revert to `to_tsvector('simple', content)`) → excluded events surface → /// restore. #[tokio::test] #[ignore = "requires Postgres"] @@ -866,6 +871,32 @@ async fn excluded_kinds_are_storage_level_unsearchable() { ) .await; + // kind:44100 member-added notification — p-gated and MUST NOT be searchable. + insert_event( + &pool, + c, + rand_bytes32(), + rand_bytes32(), + KIND_MEMBER_ADDED_NOTIFICATION as i32, + &format!("member added — {token}"), + None, + 1_700_000_004, + ) + .await; + + // kind:44101 member-removed notification — p-gated and MUST NOT be searchable. + insert_event( + &pool, + c, + rand_bytes32(), + rand_bytes32(), + KIND_MEMBER_REMOVED_NOTIFICATION as i32, + &format!("member removed — {token}"), + None, + 1_700_000_005, + ) + .await; + let svc = SearchService::new(pool.clone()); let result = svc .search(&SearchQuery { @@ -891,7 +922,13 @@ async fn excluded_kinds_are_storage_level_unsearchable() { ); // Negative (load-bearing): each excluded kind MUST NOT surface. - for forbidden in [1059, 30300, 30622] { + for forbidden in [ + 1059, + 30300, + 30622, + KIND_MEMBER_ADDED_NOTIFICATION as i32, + KIND_MEMBER_REMOVED_NOTIFICATION as i32, + ] { assert!( !kinds.contains(&forbidden), "kind:{forbidden} MUST NOT be searchable — \ diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index 1dd090d9c..2d4035f8a 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -209,14 +209,18 @@ CREATE TABLE events ( -- 1059 = KIND_GIFT_WRAP (NIP-17 ciphertext) -- 30300 = KIND_EVENT_REMINDER (AUTHOR_ONLY_KINDS — defense in depth) -- 30622 = KIND_DM_VISIBILITY (per-viewer private hide state) + -- 44100 = KIND_MEMBER_ADDED_NOTIFICATION (p-gated membership notice) + -- 44101 = KIND_MEMBER_REMOVED_NOTIFICATION (p-gated membership notice) -- NULL tsvector never matches `@@`, so excluded rows are storage-level -- unsearchable. Constants kept in `buzz_core::kind` (KIND_GIFT_WRAP, - -- KIND_EVENT_REMINDER, KIND_DM_VISIBILITY); inlined here because a sqlx + -- KIND_EVENT_REMINDER, KIND_DM_VISIBILITY, + -- KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION); inlined + -- here because a sqlx -- migration is frozen SQL and cannot import the Rust constant. If a new -- privacy-sensitive kind is added there, update this list and add a -- regression test in `buzz-search/tests/fts_integration.rs`. search_tsv TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (1059, 30300, 30622) THEN NULL::tsvector + CASE WHEN kind IN (1059, 30300, 30622, 44100, 44101) THEN NULL::tsvector ELSE to_tsvector('simple', content) END ) STORED, diff --git a/schema/schema.sql b/schema/schema.sql index 3040a79d1..1c6fcfb98 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -203,11 +203,12 @@ CREATE TABLE events ( -- community-leading btree filters BitmapAnd-ed with the GIN probe, so the -- GIN index itself stays the minimal `GIN (search_tsv)` (Max's caveat: -- avoid btree_gin unless EXPLAIN proves it buys something). - -- Privacy: encrypted/private routing wrappers must never be discoverable - -- through NIP-50 full-text search. NULL tsvector never matches `@@`. + -- Privacy: encrypted/private routing wrappers and p-gated membership notices + -- must never be discoverable through NIP-50 full-text search. NULL tsvector + -- never matches `@@`. -- Keep in sync with migrations/0001_initial_schema.sql. search_tsv TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (1059, 30300, 30622) THEN NULL::tsvector + CASE WHEN kind IN (1059, 30300, 30622, 44100, 44101) THEN NULL::tsvector ELSE to_tsvector('simple', content) END ) STORED,