fix(search): exclude p-gated membership notices from FTS

Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr
2026-06-28 12:46:48 -04:00
parent 46ba39e45a
commit a3f407ce96
3 changed files with 52 additions and 10 deletions
+42 -5
View File
@@ -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 — \
+6 -2
View File
@@ -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,
+4 -3
View File
@@ -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,