diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d8b5d378d..8950bf157a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -610,6 +610,33 @@ jobs: run: cargo test --profile ci -p buzz-test-client --test e2e_event_reminder -- --ignored env: RELAY_URL: ws://localhost:3000 + - name: NIP-37 draft wrap e2e + # Feature e2e for NIP-37 draft wraps (kind:31234), channel-bound + # contract: h-tag validation, channel existence + membership gates, + # immutable channel binding, author-only privacy (WS REQ, WS COUNT, + # HTTP /query, /count, live fan-out), known-d privacy tripwires, + # FTS/NIP-50 exclusion, workflow exclusion, tenant confinement, and + # NIP-11 advertisement. + run: cargo test --profile ci -p buzz-test-client --test e2e_nip37_draft -- --ignored + env: + RELAY_URL: ws://localhost:3000 + - name: NIP-37 draft wrap DB tests + # DB-layer tests for NIP-37 draft wraps: tenant confinement (A/B + # independent heads across communities), immutable channel-binding + # (sequential rebind → DraftChannelMismatch), and race-guard + # (concurrent writes → exactly one winner + one DraftChannelMismatch). + # These run directly against Postgres without a relay process. + run: cargo test --profile ci -p buzz-db -- draft --ignored + env: + DATABASE_URL: postgres://buzz:buzz_dev@localhost:5432/buzz + - name: buzz-search FTS integration tests + # Storage-layer FTS guarantee tests, including the NIP-37 draft exclusion: + # kind:31234 rows must have NULL search_tsv so they never surface in + # NIP-50 results regardless of content. These tests are #[ignore] + # because they require a live Postgres; the CI job already provisions one. + run: cargo test --profile ci -p buzz-search --tests -- --include-ignored + env: + BUZZ_TEST_DATABASE_URL: postgres://buzz:buzz_dev@localhost:5432/buzz - name: Upload relay log if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/crates/buzz-core/src/filter.rs b/crates/buzz-core/src/filter.rs index 1671f76224..57e74b951d 100644 --- a/crates/buzz-core/src/filter.rs +++ b/crates/buzz-core/src/filter.rs @@ -5,6 +5,7 @@ use nostr::Filter; use crate::event::StoredEvent; +use crate::kind::{event_kind_u32, DRAFT_MAX_TTL_SECS, KIND_DRAFT}; /// Returns `true` if the event matches any of the provided NIP-01 filters. pub fn filters_match(filters: &[Filter], event: &StoredEvent) -> bool { @@ -32,6 +33,71 @@ pub fn reader_authorized_for_event(event: &nostr::Event, reader_pubkey_hex: &str .any(|t| t.content() == Some(reader_pubkey_hex)) } +/// Returns `true` if the event is an author-only kind and the requester is NOT +/// the author. Used as a per-event filter during historical delivery and fan-out +/// to silently omit unauthorized events from mixed-kind result sets. +pub fn is_author_only_event(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool { + let kind_u32 = crate::kind::event_kind_u32(event); + crate::kind::AUTHOR_ONLY_KINDS.contains(&kind_u32) + && event.pubkey.to_bytes() != requester_pubkey_bytes +} + +/// Returns `true` if a draft event should be suppressed at read time due to expiry. +/// +/// Only applies to `KIND_DRAFT` events; all other kinds return `false`. +/// +/// Effective expiry is `min(client_expiration_tag, created_at + DRAFT_MAX_TTL_SECS)`. +/// When no `expiration` tag is present the server ceiling (`created_at + 30d`) governs. +/// A client tag shorter than the 30-day ceiling is honoured; one longer is clamped to it. +/// Tombstones (empty-content drafts) follow the same rule — expiry is a property of the +/// event envelope, not its payload. +/// +/// The `now` parameter is supplied by the caller so unit tests can inject an arbitrary +/// clock. Production call sites pass `nostr::Timestamp::now()`. +pub fn draft_expired(event: &nostr::Event, now: nostr::Timestamp) -> bool { + if event_kind_u32(event) != KIND_DRAFT { + return false; + } + let server_ceil = event + .created_at + .as_secs() + .saturating_add(DRAFT_MAX_TTL_SECS); + let effective_expiry = event + .tags + .iter() + .find_map(|t| { + if t.kind().to_string() == "expiration" { + t.content().and_then(|v| v.parse::().ok()) + } else { + None + } + }) + .map(|client_exp| client_exp.min(server_ceil)) + .unwrap_or(server_ceil); + now.as_secs() >= effective_expiry +} + +/// Canonical per-event read-authorization gate: combines `reader_authorized_for_event` +/// (p-gated/result-gated kinds), `is_author_only_event` (author-private kinds), and +/// `draft_expired` (time-based draft suppression) into a single predicate. +/// +/// Every delivery surface — WS historical pull, WS fan-out, HTTP bridge (all +/// branches: feed, thread, search, catchall, channel-window), and COUNT fallback +/// — must pass each event through this function before serializing it to the wire. +/// Using one canonical gate instead of composing the predicates at each call site +/// prevents future read surfaces from accidentally omitting half the privacy model. +/// +/// Returns `true` if `reader` MAY receive the event. +pub fn reader_can_receive_event( + event: &nostr::Event, + reader_pubkey_hex: &str, + reader_pubkey_bytes: &[u8], +) -> bool { + reader_authorized_for_event(event, reader_pubkey_hex) + && !is_author_only_event(event, reader_pubkey_bytes) + && !draft_expired(event, nostr::Timestamp::now()) +} + fn filter_match_one(f: &Filter, ev: &StoredEvent) -> bool { if let Some(kinds) = &f.kinds { if !kinds.contains(&ev.event.kind) { @@ -297,4 +363,115 @@ mod tests { "the authoring agent must NOT be authorized to read its own metric event (owner-only)" ); } + + // --- draft_expired unit tests --- + // + // All tests inject an explicit `now` so they don't depend on the wall clock. + // Draft events use KIND_DRAFT (31234). Non-draft events are TextNote. + + fn make_draft(keys: &Keys, created_at_secs: u64, expiration_tag: Option) -> nostr::Event { + use crate::kind::KIND_DRAFT; + let mut builder = EventBuilder::new(Kind::Custom(KIND_DRAFT as u16), "ciphertext") + .custom_created_at(Timestamp::from(created_at_secs)); + if let Some(exp) = expiration_tag { + builder = builder.tags([Tag::parse(["expiration", &exp.to_string()]).unwrap()]); + } + builder.sign_with_keys(keys).expect("sign") + } + + #[test] + fn draft_expired_tag_in_past_returns_true() { + let keys = Keys::generate(); + let now = nostr::Timestamp::now(); + // created 1 day ago, expiration 1 hour ago + let created = now.as_secs() - 86400; + let exp = now.as_secs() - 3600; + let draft = make_draft(&keys, created, Some(exp)); + assert!(draft_expired(&draft, now), "expired tag must be suppressed"); + } + + #[test] + fn draft_expired_tag_in_future_returns_false() { + let keys = Keys::generate(); + let now = nostr::Timestamp::now(); + let created = now.as_secs() - 3600; + let exp = now.as_secs() + 86400; // expires tomorrow + let draft = make_draft(&keys, created, Some(exp)); + assert!(!draft_expired(&draft, now), "future tag must be served"); + } + + #[test] + fn draft_expired_no_tag_over_30d_returns_true() { + use crate::kind::DRAFT_MAX_TTL_SECS; + let keys = Keys::generate(); + let now = nostr::Timestamp::now(); + // created 31 days ago, no expiration tag + let created = now.as_secs() - DRAFT_MAX_TTL_SECS - 86400; + let draft = make_draft(&keys, created, None); + assert!( + draft_expired(&draft, now), + "draft older than 30d with no tag must be suppressed" + ); + } + + #[test] + fn draft_expired_no_tag_under_30d_returns_false() { + let keys = Keys::generate(); + let now = nostr::Timestamp::now(); + // created 1 day ago, no expiration tag + let created = now.as_secs() - 86400; + let draft = make_draft(&keys, created, None); + assert!( + !draft_expired(&draft, now), + "draft under 30d with no tag must be served" + ); + } + + #[test] + fn draft_expired_tag_longer_than_30d_capped_at_ceiling() { + use crate::kind::DRAFT_MAX_TTL_SECS; + let keys = Keys::generate(); + let now = nostr::Timestamp::now(); + // created 31 days ago; client tag says 40 days — should be clamped to 30d ceiling + let created = now.as_secs() - DRAFT_MAX_TTL_SECS - 86400; + let long_exp = created + DRAFT_MAX_TTL_SECS + (10 * 86400); // created + 40d + let draft = make_draft(&keys, created, Some(long_exp)); + assert!( + draft_expired(&draft, now), + "tag longer than 30d must be clamped to server ceiling — draft must be suppressed" + ); + } + + #[test] + fn draft_expired_non_draft_kind_never_suppressed() { + let keys = Keys::generate(); + let now = nostr::Timestamp::now(); + // TextNote with an expiration tag that is in the past — must NOT suppress + let past_exp = now.as_secs() - 3600; + let event = EventBuilder::new(Kind::TextNote, "hello") + .tags([Tag::parse(["expiration", &past_exp.to_string()]).unwrap()]) + .sign_with_keys(&keys) + .expect("sign"); + assert!( + !draft_expired(&event, now), + "non-draft kinds must never be suppressed by draft_expired" + ); + } + + #[test] + fn draft_expired_tombstone_follows_same_expiry_rule() { + use crate::kind::DRAFT_MAX_TTL_SECS; + let keys = Keys::generate(); + let now = nostr::Timestamp::now(); + // Tombstone (empty content) created 31 days ago — same rule as regular draft + let created = now.as_secs() - DRAFT_MAX_TTL_SECS - 86400; + let tombstone = EventBuilder::new(Kind::Custom(crate::kind::KIND_DRAFT as u16), "") + .custom_created_at(Timestamp::from(created)) + .sign_with_keys(&keys) + .expect("sign"); + assert!( + draft_expired(&tombstone, now), + "tombstone draft must be suppressed by the same expiry rule" + ); + } } diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 495ced0b6f..c11da0ec6e 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -101,16 +101,39 @@ pub const KIND_AGENT_ENGRAM: u32 = 30174; /// author-only (see [`AUTHOR_ONLY_KINDS`]). See `docs/nips/NIP-ER.md`. pub const KIND_EVENT_REMINDER: u32 = 30300; +/// NIP-37: Draft wrap (parameterized replaceable, author-only). +/// +/// Encrypted draft of an unsent message, addressed by `(pubkey, kind=31234, d_tag)`. +/// Content is NIP-44 v2 ciphertext (to self) containing an unsigned inner event, +/// or the empty string as a NIP-37 deletion tombstone. The outer envelope carries +/// one `h` UUID binding the draft to a Buzz channel or DM; compose context +/// (reply target, etc.) lives only inside the encrypted payload. +/// +/// Reads are strictly author-only — see [`AUTHOR_ONLY_KINDS`] and the author-only +/// gate in the REQ/COUNT/fan-out handlers. Draft wraps are excluded from FTS +/// (`search_tsv = NULL`) and must not trigger workflow dispatch. +pub const KIND_DRAFT: u32 = 31234; + +/// Server-owned maximum lifetime for draft events (30 days). +/// +/// A draft without a client-supplied `expiration` tag is served for at most +/// this many seconds from `created_at`. A client tag shorter than the ceiling +/// takes precedence; one longer than it is clamped to it. See +/// `buzz_core::filter::draft_expired`. +pub const DRAFT_MAX_TTL_SECS: u64 = 30 * 24 * 3600; + /// Kinds whose stored events are readable only by their author. /// /// The relay must never reveal the existence, count, tags, content, schedule, /// or search matches of these events to anyone but the authenticated author. -/// Shared across the ingest write path (NIP-ER `not_before` validation) and the -/// read path (REQ/COUNT/subscription author-only filtering). +/// Shared across the ingest write path and the read path (REQ/COUNT/subscription +/// author-only filtering). Also drives the FTS null-tsvector tripwire in +/// `buzz-search/tests/fts_integration.rs` — every kind added here MUST also +/// appear in the `search_tsv` generated column exclusion list in +/// `schema/schema.sql` and the additive migration that introduced it. /// -/// Currently O(1) with a single entry. If this grows past ~4 kinds, convert to -/// a compile-time bitset or sorted array with binary search for hot-path use. -pub const AUTHOR_ONLY_KINDS: &[u32] = &[KIND_EVENT_REMINDER]; +/// Sorted for binary-search readiness if the list grows. +pub const AUTHOR_ONLY_KINDS: &[u32] = &[KIND_EVENT_REMINDER, KIND_DRAFT]; /// Kinds that require a result-level read gate beyond the filter-layer /// `#p` check: even a reader who knows an event id MUST match the event's diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index f8b8a2eb56..0a3af0a0f6 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -48,6 +48,25 @@ pub enum DbError { /// A stored timestamp value could not be interpreted. #[error("invalid timestamp: {0}")] InvalidTimestamp(i64), + + /// A kind:31234 draft-wrap update tried to rebind to a different channel. + /// + /// The `(community, author, 31234, d_tag)` address is already bound to a + /// channel UUID. Incoming updates must use the same `h` tag. + #[error( + "draft-wrap channel binding is immutable — `h` tag must match the existing head's channel" + )] + DraftChannelMismatch, + + /// A kind:31234 draft-wrap was submitted without a channel binding. + /// + /// Every draft wrap requires a `channel_id` (resolved from the `h` tag + /// by ingest). A `None` channel_id at the DB layer means the caller + /// bypassed the ingest validator and must be rejected. + #[error( + "draft-wrap channel_id is required — draft wraps must be bound to a channel via `h` tag" + )] + DraftChannelRequired, } /// Convenience alias for `Result`. diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 9dd7b501a8..cba5d107cc 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -1453,6 +1453,7 @@ impl Db { limit: u32, cursor: Option<(DateTime, Vec)>, kind_filter: Option<&[u32]>, + author_pubkey: Option<&[u8]>, ) -> Result { thread::get_channel_window( &self.pool, @@ -1461,6 +1462,7 @@ impl Db { limit, cursor, kind_filter, + author_pubkey, ) .await } @@ -2792,6 +2794,20 @@ impl Db { /// by its d-tag, regardless of which channel it was submitted to. The `channel_id` /// parameter is stored on the new row for query scoping but does not affect replacement. /// + /// **Immutable-channel enforcement (kind:31234):** When the event is a + /// kind:31234 draft wrap, the function checks — **inside the advisory lock, + /// before the stale-ordering step** — that the current head's `channel_id` + /// equals the incoming `channel_id`. If it differs the transaction is rolled + /// back and an `Err(DbError::DraftChannelMismatch)` is returned. An incoming + /// `channel_id` of `None` is also rejected with `Err(DbError::DraftChannelRequired)` + /// — every draft wrap must be bound to a channel via `h` tag, and a `None` + /// here means the caller bypassed the ingest validator. + /// + /// The check is inferred from `event.kind` so no caller can supply a separate + /// "expected" value that differs from the "stored" value — the API is + /// structurally non-bypassable. All other parameterized-replaceable kinds + /// skip the check. + /// /// Note: `replace_addressable_event()` keys on `channel_id` because it serves /// relay-signed NIP-29 group metadata (kind 39000–39002) where the relay is the /// author and channel_id distinguishes groups. User-submitted NIP-33 events use @@ -2870,17 +2886,41 @@ impl Db { // Check the live head and, for NIP-RS, the compact historical ordering // watermark. The watermark remains after a NIP-09 coordinate deletion, // preventing a previously accepted signed blob from being resurrected. - let existing: Option<(chrono::DateTime, Vec)> = sqlx::query_as( - "SELECT created_at, id FROM events \ - WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL \ - ORDER BY created_at DESC, id ASC LIMIT 1", - ) - .bind(community_id.as_uuid()) - .bind(kind_i32) - .bind(pubkey_bytes.as_slice()) - .bind(d_tag) - .fetch_optional(&mut *tx) - .await?; + let existing: Option<(chrono::DateTime, Vec, Option)> = + sqlx::query_as( + "SELECT created_at, id, channel_id FROM events \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL \ + ORDER BY created_at DESC, id ASC LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(kind_i32) + .bind(pubkey_bytes.as_slice()) + .bind(d_tag) + .fetch_optional(&mut *tx) + .await?; + + // Immutable channel-binding check for kind:31234 draft wraps. + // + // Inferred from event.kind — no separate "expected" parameter means no caller + // can pass expected=A while storing channel_id=B. Runs **inside** the advisory + // lock so the read-then-reject is atomic: no concurrent writer can slip a + // different-channel head between the SELECT and our rollback. + if buzz_core::kind::event_kind_u32(event) == buzz_core::kind::KIND_DRAFT { + // channel_id is required for all draft wraps. A None here means the + // caller bypassed the ingest validator (which enforces the `h` tag). + // Reject at the DB layer to keep the structural invariant self-contained. + if channel_id.is_none() { + tx.rollback().await?; + return Err(DbError::DraftChannelRequired); + } + if let Some((_, _, head_channel_id)) = &existing { + if *head_channel_id != channel_id { + tx.rollback().await?; + return Err(DbError::DraftChannelMismatch); + } + } + } + let watermark: Option<(chrono::DateTime, Vec)> = if is_nip_rs { sqlx::query_as( "SELECT created_at, event_id FROM parameterized_event_watermarks \ @@ -2899,8 +2939,9 @@ impl Db { // Stale-write protection: reject if either durable ordering source // dominates the incoming tuple. Equal timestamps use lowest event id. let incoming_id = event.id.as_bytes().as_slice(); + let existing_ordering = existing.as_ref().map(|(ts, id, _)| (*ts, id.clone())); let dominated = - existing + existing_ordering .iter() .chain(watermark.iter()) .any(|(accepted_ts, accepted_id)| { @@ -2939,7 +2980,7 @@ impl Db { .await?; if is_nip_rs { - if let Some((_, existing_id)) = &existing { + if let Some((_, existing_id, _)) = &existing { // Event first, mentions second: migration 0009's live-event // fence uses this global lock order to avoid deadlocks. sqlx::query( @@ -4169,4 +4210,336 @@ mod tests { "A's reaction must be gone after A removes it" ); } + + // ─── NIP-37 draft-wrap DB tests ────────────────────────────────────────── + + /// Build a minimal signed kind:31234 event for DB-layer tests. + fn build_test_draft(keys: &nostr::Keys, d_tag: &str, channel_id: &Uuid) -> nostr::Event { + nostr::EventBuilder::new(nostr::Kind::Custom(31234), "") + .tags([ + nostr::Tag::parse(["d", d_tag]).unwrap(), + nostr::Tag::parse(["k", "9"]).unwrap(), + nostr::Tag::parse(["h", &channel_id.to_string()]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap() + } + + /// Build a kind:31234 event at a specific Unix timestamp (seconds). + fn build_test_draft_at( + keys: &nostr::Keys, + d_tag: &str, + channel_id: &Uuid, + ts_secs: u64, + ) -> nostr::Event { + nostr::EventBuilder::new(nostr::Kind::Custom(31234), "") + .tags([ + nostr::Tag::parse(["d", d_tag]).unwrap(), + nostr::Tag::parse(["k", "9"]).unwrap(), + nostr::Tag::parse(["h", &channel_id.to_string()]).unwrap(), + ]) + .custom_created_at(nostr::Timestamp::from(ts_secs)) + .sign_with_keys(keys) + .unwrap() + } + + /// Query the live head for a (community, kind:31234, author, d_tag) address. + async fn query_draft_head( + db: &Db, + community_id: CommunityId, + author: &nostr::Keys, + d_tag: &str, + ) -> Vec { + db.query_events(&EventQuery { + kinds: Some(vec![31234_i32]), + authors: Some(vec![author.public_key().to_bytes().to_vec()]), + d_tag: Some(d_tag.to_string()), + ..EventQuery::for_community(community_id) + }) + .await + .expect("query draft head") + } + + /// Tenant confinement: a kind:31234 written to community A must NOT be + /// visible via community B's `replace_parameterized_event` — the query is + /// scoped by `community_id`. + /// + /// This tests the DB layer directly (two real communities) and does not + /// require a second relay instance. The full lifecycle is exercised: + /// insert → query → replace → tombstone, with each community's head + /// checked independently at every step. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn draft_is_confined_to_its_community() { + let db = setup_db().await; + let community_a = CommunityId::from_uuid(make_community(&db.pool).await); + let community_b = CommunityId::from_uuid(make_community(&db.pool).await); + + let keys = nostr::Keys::generate(); + let ch_a = Uuid::new_v4(); + let ch_b = Uuid::new_v4(); + // Same d_tag for both communities — proves community_id is the real + // isolation boundary, not an accidental d_tag split. + let d_tag = Uuid::new_v4().to_string(); + + let now = nostr::Timestamp::now().as_secs(); + + // Step 1: insert draft v1 into community A. + let draft_a_v1 = build_test_draft_at(&keys, &d_tag, &ch_a, now - 3); + let (_, inserted_a) = db + .replace_parameterized_event(community_a, &draft_a_v1, &d_tag, Some(ch_a)) + .await + .expect("insert draft v1 into A"); + assert!(inserted_a, "draft v1 must be inserted into A"); + + // Step 2: same (pubkey, d_tag) in community B is an independent address. + let draft_b_v1 = build_test_draft_at(&keys, &d_tag, &ch_b, now - 3); + let (_, inserted_b) = db + .replace_parameterized_event(community_b, &draft_b_v1, &d_tag, Some(ch_b)) + .await + .expect("same (pubkey, d_tag) in community B must succeed as independent address"); + assert!( + inserted_b, + "draft must be stored as a new independent head in B" + ); + + // Verify each community sees only its own head. + let heads_a = query_draft_head(&db, community_a, &keys, &d_tag).await; + let heads_b = query_draft_head(&db, community_b, &keys, &d_tag).await; + assert_eq!( + heads_a.len(), + 1, + "A must have exactly one head after v1 insert" + ); + assert_eq!( + heads_b.len(), + 1, + "B must have exactly one head after v1 insert" + ); + assert_eq!( + heads_a[0].event.id, draft_a_v1.id, + "A head must be A's draft v1" + ); + assert_eq!( + heads_b[0].event.id, draft_b_v1.id, + "B head must be B's draft v1" + ); + + // Step 3: replace A's draft with v2 — B's head must not change. + let draft_a_v2 = build_test_draft_at(&keys, &d_tag, &ch_a, now - 1); + let (_, replaced_a) = db + .replace_parameterized_event(community_a, &draft_a_v2, &d_tag, Some(ch_a)) + .await + .expect("replace A draft with v2"); + assert!(replaced_a, "A draft v2 must supersede v1"); + + let heads_a_after = query_draft_head(&db, community_a, &keys, &d_tag).await; + let heads_b_after = query_draft_head(&db, community_b, &keys, &d_tag).await; + assert_eq!( + heads_a_after[0].event.id, draft_a_v2.id, + "A head must be v2 after replace" + ); + assert_eq!( + heads_b_after[0].event.id, draft_b_v1.id, + "B head must still be B's v1 — A's replace must not touch B" + ); + + // Step 4: tombstone A's draft — B's head must still be unchanged. + let tombstone_a = build_test_draft_at(&keys, &d_tag, &ch_a, now + 1); + let (_, tomb_inserted) = db + .replace_parameterized_event(community_a, &tombstone_a, &d_tag, Some(ch_a)) + .await + .expect("tombstone A draft"); + assert!(tomb_inserted, "tombstone must supersede v2"); + + let heads_a_tomb = query_draft_head(&db, community_a, &keys, &d_tag).await; + let heads_b_tomb = query_draft_head(&db, community_b, &keys, &d_tag).await; + assert_eq!( + heads_a_tomb[0].event.id, tombstone_a.id, + "A head must be the tombstone" + ); + assert_eq!( + heads_b_tomb[0].event.id, draft_b_v1.id, + "B head must still be B's v1 — A's tombstone must not touch B" + ); + } + + /// Immutable channel binding — sequential rebind attempt: once a draft + /// address (community, author, d_tag) is bound to channel A, a subsequent + /// call with the same address but channel B must fail with + /// `DraftChannelMismatch`. The advisory-lock read-then-reject must fire + /// on a plain sequential call — no concurrent race required. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn draft_channel_binding_is_immutable_across_sequential_calls() { + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + + let keys = nostr::Keys::generate(); + let ch_a = Uuid::new_v4(); + let ch_b = Uuid::new_v4(); + let d_tag = Uuid::new_v4().to_string(); + + let now = nostr::Timestamp::now().as_secs(); + + // Bind the address to ch_a. + let draft_v1 = build_test_draft_at(&keys, &d_tag, &ch_a, now - 1); + let (_, inserted) = db + .replace_parameterized_event(community, &draft_v1, &d_tag, Some(ch_a)) + .await + .expect("initial insert for ch_a must succeed"); + assert!(inserted, "draft must be inserted on first call"); + + // Attempt to rebind the same address to ch_b — must be rejected. + let draft_v2 = build_test_draft_at(&keys, &d_tag, &ch_b, now + 1); + let result = db + .replace_parameterized_event(community, &draft_v2, &d_tag, Some(ch_b)) + .await; + assert!( + matches!(result, Err(DbError::DraftChannelMismatch)), + "sequential rebind to a different channel must return DraftChannelMismatch; got: {result:?}" + ); + + // The stored head must still be the original v1 (bound to ch_a). + let heads = query_draft_head(&db, community, &keys, &d_tag).await; + assert_eq!(heads.len(), 1, "exactly one live head after failed rebind"); + assert_eq!( + heads[0].event.id, draft_v1.id, + "live head must still be v1 bound to ch_a — rebind must not overwrite it" + ); + } + + /// Race guard: two concurrent writers trying to bind the same + /// (community, author, d_tag) address to *different* channels. + /// + /// One must win (the first committer) and the second must get + /// `DraftChannelMismatch` — never a corrupt split-brain state. + /// Post-race: the live head for the winning address must be exactly one + /// event bound to the winning channel. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn concurrent_different_channel_drafts_one_wins_one_loses() { + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + + let keys = nostr::Keys::generate(); + let ch_a = Uuid::new_v4(); + let ch_b = Uuid::new_v4(); + let d_tag = Uuid::new_v4().to_string(); + + // Build distinct events for each channel (different h-tag → different id). + let draft_a = build_test_draft(&keys, &d_tag, &ch_a); + let draft_b = build_test_draft(&keys, &d_tag, &ch_b); + + // Run both writes concurrently. + let db_a = db.clone(); + let d_a = d_tag.clone(); + let ev_a = draft_a.clone(); + let fut_a = async move { + db_a.replace_parameterized_event(community, &ev_a, &d_a, Some(ch_a)) + .await + }; + + let db_b = db.clone(); + let d_b = d_tag.clone(); + let ev_b = draft_b.clone(); + let fut_b = async move { + db_b.replace_parameterized_event(community, &ev_b, &d_b, Some(ch_b)) + .await + }; + + let (res_a, res_b) = tokio::join!(fut_a, fut_b); + + // Exactly one must succeed (insert) and the other must fail with + // DraftChannelMismatch (advisory lock ensures serialization). + let outcomes = [&res_a, &res_b]; + let successes: Vec<_> = outcomes + .iter() + .filter_map(|r| r.as_ref().ok()) + .filter(|(_, inserted)| *inserted) + .collect(); + let mismatches: usize = outcomes + .iter() + .filter(|r| matches!(r, Err(DbError::DraftChannelMismatch))) + .count(); + + assert_eq!( + successes.len(), + 1, + "exactly one concurrent write must succeed; res_a={res_a:?}, res_b={res_b:?}" + ); + assert_eq!( + mismatches, 1, + "exactly one concurrent write must fail with DraftChannelMismatch; \ + res_a={res_a:?}, res_b={res_b:?}" + ); + + // Post-race invariant: the address must have exactly one live head, + // bound to the winning channel. + let winning_ch = if matches!(&res_a, Ok((_, true))) { + ch_a + } else { + ch_b + }; + let heads = query_draft_head(&db, community, &keys, &d_tag).await; + assert_eq!( + heads.len(), + 1, + "address must have exactly one live head after the race" + ); + assert_eq!( + heads[0].channel_id, + Some(winning_ch), + "live head must be bound to the winning channel" + ); + } + + /// Direct `replace_parameterized_event(channel_id = None)` for a kind:31234 + /// draft must be rejected with `DraftChannelRequired`, regardless of whether + /// a head already exists. The DB-layer invariant must hold even when the + /// ingest pre-storage gate is bypassed. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn draft_without_channel_id_is_rejected_at_db_layer() { + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + let keys = nostr::Keys::generate(); + let d_tag = Uuid::new_v4().to_string(); + let ch = Uuid::new_v4(); + let now = nostr::Timestamp::now().as_secs(); + + // First call: no existing head, channel_id = None. + let draft = build_test_draft_at(&keys, &d_tag, &ch, now); + let result = db + .replace_parameterized_event(community, &draft, &d_tag, None) + .await; + assert!( + matches!(result, Err(DbError::DraftChannelRequired)), + "first write with channel_id=None must return DraftChannelRequired; got: {result:?}" + ); + + // Verify no head was persisted. + let heads = query_draft_head(&db, community, &keys, &d_tag).await; + assert!( + heads.is_empty(), + "no head must be stored after DraftChannelRequired rejection" + ); + + // Second call: establish a real head first, then try channel_id = None + // update to confirm the None-reject fires before the mismatch check. + let (_, inserted) = db + .replace_parameterized_event(community, &draft, &d_tag, Some(ch)) + .await + .expect("first write with channel_id = Some must succeed"); + assert!(inserted, "first write must be a new insertion"); + + let update = build_test_draft_at(&keys, &d_tag, &ch, now + 1); + let result2 = db + .replace_parameterized_event(community, &update, &d_tag, None) + .await; + assert!( + matches!(result2, Err(DbError::DraftChannelRequired)), + "update with channel_id=None must return DraftChannelRequired even when head exists; got: {result2:?}" + ); + } } diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index d62f7e2069..75c851c81e 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -542,7 +542,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 11); + assert_eq!(migrations.len(), 12); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -710,6 +710,24 @@ mod tests { .contains("CREATE OR REPLACE FUNCTION purge_soft_deleted_nip_rs")); assert!(migrations[10].sql.as_str().contains("tag->>0 = 'd'")); assert!(migrations[10].sql.as_str().contains(") = 1")); + + // NIP-37 (kind 31234) FTS exclusion: additive conditional migration. + // On legacy-blocklist DBs: drops and re-adds search_tsv with 31234. + // On fresh-install allowlist DBs: no-op (31234 already unsearchable). + // 0001 must NOT carry 31234; migration 12 must carry it separately. + assert_eq!(migrations[11].version, 12); + assert!( + migrations[11].sql.as_str().contains("search_tsv"), + "migration 0012 must reference the search_tsv generated column" + ); + assert!( + migrations[11].sql.as_str().contains("31234"), + "migration 0012 must add kind 31234 to the FTS exclusion list" + ); + assert!( + !migrations[0].sql.as_str().contains("31234"), + "kind 31234 must not be folded into the initial migration" + ); } #[test] diff --git a/crates/buzz-db/src/thread.rs b/crates/buzz-db/src/thread.rs index 3f92212dd5..e0658aff48 100644 --- a/crates/buzz-db/src/thread.rs +++ b/crates/buzz-db/src/thread.rs @@ -562,6 +562,12 @@ pub async fn get_thread_summary( /// predicates (deletion, top-level, kinds). The sentinel row is dropped here /// and never reaches the wire; callers must not re-derive exhaustion from row /// counts (`rows < limit` proves nothing on an exact-multiple final page). +/// +/// `author_pubkey`: when `Some`, author-only kinds (as listed in +/// `buzz_core::kind::AUTHOR_ONLY_KINDS`) are excluded for rows whose `pubkey` +/// does not match the requester. Pass the authenticated requester's pubkey bytes +/// on all bridge read paths. `None` skips the filter (internal/test callers +/// that don't need author-only visibility control). pub async fn get_channel_window( pool: &PgPool, community_id: CommunityId, @@ -569,6 +575,7 @@ pub async fn get_channel_window( limit: u32, cursor: Option<(DateTime, Vec)>, kind_filter: Option<&[u32]>, + author_pubkey: Option<&[u8]>, ) -> Result { let mut param_idx = 3u32; // $1 is community_id, $2 is channel_id let mut sql = String::from( @@ -624,6 +631,27 @@ pub async fn get_channel_window( } } + // Author-only kinds (kind:31234 drafts, kind:30300 reminders) must never + // be returned for a requester who is not their author. The filter is + // applied at the SQL layer so that `has_more` and `next_cursor` are also + // computed over the already-restricted row set — a bridge-level skip would + // leave those values pointing at or counting draft rows for non-authors. + if author_pubkey.is_some() { + let pk_idx = param_idx; + // Derive the exclusion list from AUTHOR_ONLY_KINDS (buzz_core) so that + // adding a new author-only kind in one place automatically extends this + // filter without a separate hardcoded list here. + let ao_list = buzz_core::kind::AUTHOR_ONLY_KINDS + .iter() + .map(|k| k.to_string()) + .collect::>() + .join(","); + sql.push_str(&format!( + " AND (e.kind NOT IN ({ao_list}) OR e.pubkey = ${pk_idx})" + )); + param_idx += 1; + } + sql.push_str(&format!( " ORDER BY e.created_at DESC, e.id ASC LIMIT ${param_idx}" )); @@ -634,6 +662,9 @@ pub async fn get_channel_window( if let Some((ts, id)) = &cursor { q = q.bind(*ts).bind(id.clone()); } + if let Some(pk) = author_pubkey { + q = q.bind(pk); + } // The +1 probe row is the server-internal has_more evidence. q = q.bind(limit as i64 + 1); @@ -1531,7 +1562,7 @@ mod tests { let quiet_reply = make_stream_event(&author, "quiet reply"); insert_reply(&pool, community, channel.id, &root, &quiet_reply, false).await; - let window = get_channel_window(&pool, community, channel.id, 50, None, None) + let window = get_channel_window(&pool, community, channel.id, 50, None, None, None) .await .expect("fetch window"); @@ -1596,7 +1627,7 @@ mod tests { let mut collected: Vec> = Vec::new(); let mut cursor: Option<(DateTime, Vec)> = None; loop { - let window = get_channel_window(&pool, community, channel.id, 2, cursor, None) + let window = get_channel_window(&pool, community, channel.id, 2, cursor, None, None) .await .expect("fetch window page"); for row in &window.rows { @@ -1646,14 +1677,14 @@ mod tests { insert_root(&pool, community, channel.id, &event).await; } - let page1 = get_channel_window(&pool, community, channel.id, 2, None, None) + let page1 = get_channel_window(&pool, community, channel.id, 2, None, None, None) .await .expect("fetch page 1"); assert_eq!(page1.rows.len(), 2); assert!(page1.has_more, "two more rows exist past page 1"); let cursor = page1.next_cursor.expect("has_more implies next_cursor"); - let page2 = get_channel_window(&pool, community, channel.id, 2, Some(cursor), None) + let page2 = get_channel_window(&pool, community, channel.id, 2, Some(cursor), None, None) .await .expect("fetch page 2"); assert_eq!(page2.rows.len(), 2, "final page is exactly full"); @@ -1695,7 +1726,7 @@ mod tests { insert_reply(&pool, community, channel.id, &discussed, &reply, false).await; } - let window = get_channel_window(&pool, community, channel.id, 50, None, None) + let window = get_channel_window(&pool, community, channel.id, 50, None, None, None) .await .expect("fetch window"); diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 58cb2d09e5..29d05f16da 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -367,6 +367,7 @@ const WINDOW_AUX_DELETE_KINDS: [u32; 2] = [ /// Validation errors (missing `#h`, half a cursor) are deterministic client /// mistakes and return `400`; an inaccessible channel is an access-scope skip /// that still emits nothing, matching every other read path here. +#[allow(clippy::too_many_arguments)] async fn handle_channel_window_filter( state: &AppState, tenant: &buzz_core::TenantContext, @@ -374,6 +375,8 @@ async fn handle_channel_window_filter( filter: &nostr::Filter, accessible_channels: &[uuid::Uuid], events: &mut Vec, + pubkey_bytes: &[u8], + reader_pubkey_hex: &str, ) -> Result<(), (StatusCode, Json)> { use buzz_core::kind::{KIND_THREAD_SUMMARY, KIND_WINDOW_BOUNDS}; @@ -436,13 +439,23 @@ async fn handle_channel_window_filter( limit, cursor.clone(), kind_filter.as_deref(), + Some(pubkey_bytes), ) .await .map_err(|e| internal_error(&format!("channel window error: {e}")))?; - // 1. Rows, in keyset order. + // 1. Rows, in keyset order. Gate each row through the canonical read- + // authorization predicate so draft-expiry (and any future per-event + // predicates) apply consistently with every other read surface. let mut row_ids_hex = Vec::with_capacity(window.rows.len()); for row in &window.rows { + if !buzz_core::filter::reader_can_receive_event( + &row.stored_event.event, + reader_pubkey_hex, + pubkey_bytes, + ) { + continue; + } row_ids_hex.push(row.stored_event.event.id.to_hex()); let v = serde_json::to_value(&row.stored_event.event) .map_err(|e| internal_error(&format!("window row serialize: {e}")))?; @@ -768,6 +781,8 @@ pub async fn query_events( filter, &accessible_channels, &mut events, + &pubkey_bytes, + &authed_pubkey_hex, ) .await?; handled.insert(idx); @@ -843,10 +858,15 @@ pub async fn query_events( if !event_in_accessible_channel(&se, &accessible_channels) { continue; } - // Defense-in-depth: never deliver a result-gated event (e.g. kind:44200 - // or kind:30622) to a non-owner via the feed path, even though feed SQL - // kind allowlists already exclude these kinds. - if !buzz_core::filter::reader_authorized_for_event(&se.event, &authed_pubkey_hex) { + // Canonical per-event read gate: covers result-gated (p-owned) kinds + // and author-only kinds (e.g. kind:31234). Feed SQL kind allowlists + // exclude these kinds in practice, but this gate closes any future + // latent hole if the allowlist drifts. + if !buzz_core::filter::reader_can_receive_event( + &se.event, + &authed_pubkey_hex, + &pubkey_bytes, + ) { continue; } if let Ok(v) = serde_json::to_value(&se.event) { @@ -909,10 +929,15 @@ pub async fn query_events( if !event_in_accessible_channel(&se, &accessible_channels) { continue; } - // Defense-in-depth: never deliver a result-gated event (e.g. kind:44200 - // or kind:30622) to a non-owner via the thread path, even though - // requires_h_channel_scope already excludes these kinds from thread metadata. - if !buzz_core::filter::reader_authorized_for_event(&se.event, &authed_pubkey_hex) { + // Canonical per-event read gate: covers result-gated (p-owned) kinds + // and author-only kinds (e.g. kind:31234). Both guards are needed here + // even though requires_h_channel_scope already excludes these from thread + // metadata, because thread replies can reach this path through other routes. + if !buzz_core::filter::reader_can_receive_event( + &se.event, + &authed_pubkey_hex, + &pubkey_bytes, + ) { continue; } if let Ok(v) = serde_json::to_value(&se.event) { @@ -1003,17 +1028,15 @@ pub async fn query_events( if !buzz_core::filter::filters_match(std::slice::from_ref(filter), &se) { continue; } - // Result-level read auth: never hand a viewer-private snapshot - // (kind:30622) to anyone but its owner, even via kindless `ids`. - if !buzz_core::filter::reader_authorized_for_event( + // Canonical per-event read gate: covers both result-gated (p-owned) + // and author-only kinds in a single call. + if !buzz_core::filter::reader_can_receive_event( &se.event, &authed_pubkey_hex, + &pubkey_bytes, ) { continue; } - if crate::handlers::req::is_author_only_event(&se.event, &pubkey_bytes) { - continue; - } if let Ok(v) = serde_json::to_value(&se.event) { events.push(v); } @@ -1140,6 +1163,7 @@ pub async fn count_events( if crate::handlers::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering + && !crate::handlers::req::filter_can_match_draft(filter) { match state.db.count_events(&query).await { Ok(n) => total += n as u64, @@ -1165,13 +1189,10 @@ pub async fn count_events( { continue; } - if crate::handlers::req::is_author_only_event(&se.event, &pubkey_bytes) - { - continue; - } - if !buzz_core::filter::reader_authorized_for_event( + if !buzz_core::filter::reader_can_receive_event( &se.event, &authed_pubkey_hex, + &pubkey_bytes, ) { continue; } @@ -1204,6 +1225,7 @@ pub async fn count_events( if crate::handlers::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering + && !crate::handlers::req::filter_can_match_draft(filter) { query.limit = None; match state.db.count_events(&query).await { @@ -1229,13 +1251,10 @@ pub async fn count_events( { continue; } - if crate::handlers::req::is_author_only_event(&se.event, &pubkey_bytes) - { - continue; - } - if !buzz_core::filter::reader_authorized_for_event( + if !buzz_core::filter::reader_can_receive_event( &se.event, &authed_pubkey_hex, + &pubkey_bytes, ) { continue; } @@ -1274,6 +1293,7 @@ fn search_hit_accepted( stored: &buzz_core::StoredEvent, accessible_channels: &[uuid::Uuid], reader_pubkey_hex: &str, + reader_pubkey_bytes: &[u8], ) -> bool { if !buzz_core::filter::filters_match(std::slice::from_ref(filter), stored) { return false; @@ -1283,7 +1303,11 @@ fn search_hit_accepted( return false; } } - if !buzz_core::filter::reader_authorized_for_event(&stored.event, reader_pubkey_hex) { + if !buzz_core::filter::reader_can_receive_event( + &stored.event, + reader_pubkey_hex, + reader_pubkey_bytes, + ) { return false; } true @@ -1407,10 +1431,13 @@ async fn handle_bridge_search( Some(ev) => ev, None => continue, }; - if !search_hit_accepted(filter, stored, accessible_channels, reader_pubkey_hex) { - continue; - } - if crate::handlers::req::is_author_only_event(&stored.event, pubkey_bytes) { + if !search_hit_accepted( + filter, + stored, + accessible_channels, + reader_pubkey_hex, + pubkey_bytes, + ) { continue; } // Dedup across filters. @@ -2509,11 +2536,11 @@ mod tests { // 30174 is not owner-gated, so any reader hex is fine here. let reader = Keys::generate().public_key().to_hex(); assert!( - search_hit_accepted(&filter, &env_for_a, &[], &reader), + search_hit_accepted(&filter, &env_for_a, &[], &reader, &[]), "envelope addressed to owner_a must be returned" ); assert!( - !search_hit_accepted(&filter, &env_for_b, &[], &reader), + !search_hit_accepted(&filter, &env_for_b, &[], &reader, &[]), "envelope addressed to owner_b must NOT be returned for a #p=[owner_a] search" ); } @@ -2536,9 +2563,9 @@ mod tests { .author(agent_a.public_key()); let reader = Keys::generate().public_key().to_hex(); - assert!(search_hit_accepted(&filter, &env_a, &[], &reader)); + assert!(search_hit_accepted(&filter, &env_a, &[], &reader, &[])); assert!( - !search_hit_accepted(&filter, &env_b, &[], &reader), + !search_hit_accepted(&filter, &env_b, &[], &reader, &[]), "authors=[agent_a] search must not return events authored by agent_b" ); } @@ -2560,11 +2587,11 @@ mod tests { let reader = Keys::generate().public_key().to_hex(); assert!( - !search_hit_accepted(&filter, &stored, &[], &reader), + !search_hit_accepted(&filter, &stored, &[], &reader, &[]), "channel-scoped hit must be rejected when caller has no channel access" ); assert!( - search_hit_accepted(&filter, &stored, &[scoped_channel], &reader), + search_hit_accepted(&filter, &stored, &[scoped_channel], &reader, &[]), "channel-scoped hit must be accepted when caller has access to that channel" ); } @@ -2866,11 +2893,11 @@ mod tests { let filter = nostr::Filter::new().id(ev.id); assert!( - !search_hit_accepted(&filter, &stored, &[], &third_party), + !search_hit_accepted(&filter, &stored, &[], &third_party, &[]), "third party must not receive a DM-visibility snapshot via kindless ids search" ); assert!( - search_hit_accepted(&filter, &stored, &[], &viewer), + search_hit_accepted(&filter, &stored, &[], &viewer, &[]), "owner must still receive their own snapshot" ); } diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 4689826f23..5fff632ef8 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -7,7 +7,7 @@ use tracing::warn; use crate::connection::{AuthState, ConnectionState}; use crate::handlers::req::{ - filter_can_match_result_gated_kinds, is_author_only_event, result_gated_count_safe_for_pushdown, + filter_can_match_result_gated_kinds, result_gated_count_safe_for_pushdown, }; use crate::protocol::RelayMessage; use crate::state::AppState; @@ -160,6 +160,7 @@ pub async fn handle_count( if super::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering + && !super::req::filter_can_match_draft(filter) { match state.db.count_events(&query).await { Ok(n) => total += n as u64, @@ -187,12 +188,10 @@ pub async fn handle_count( { continue; } - if is_author_only_event(&se.event, &pubkey_bytes) { - continue; - } - if !buzz_core::filter::reader_authorized_for_event( + if !buzz_core::filter::reader_can_receive_event( &se.event, &authed_pubkey_hex, + &pubkey_bytes, ) { continue; } @@ -230,6 +229,7 @@ pub async fn handle_count( if super::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering + && !super::req::filter_can_match_draft(filter) { query.limit = None; // COUNT doesn't need a row limit match state.db.count_events(&query).await { @@ -257,12 +257,10 @@ pub async fn handle_count( { continue; } - if is_author_only_event(&se.event, &pubkey_bytes) { - continue; - } - if !buzz_core::filter::reader_authorized_for_event( + if !buzz_core::filter::reader_can_receive_event( &se.event, &authed_pubkey_hex, + &pubkey_bytes, ) { continue; } diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 9e3bb05d86..cf93b57299 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -38,12 +38,13 @@ fn bounded_kind_label(kind: u32) -> String { 8000..=8003 | 9000..=9022 | 9030..=9036 => kind.to_string(), 13534..=13535 => kind.to_string(), 20000..=29999 => kind.to_string(), - 30023 | 30315 | 39000..=39003 => kind.to_string(), + 30023 | 30078 | 30174 | 30175..=30177 | 30300 | 30315 | 31234 | 39000..=39003 => { + kind.to_string() + } 40002..=40100 => kind.to_string(), 41001 | 41010..=41012 => kind.to_string(), 43001..=43006 => kind.to_string(), - 44100..=44101 => kind.to_string(), - 44200 => kind.to_string(), + 44100..=44101 | 44200 => kind.to_string(), 45001..=45003 => kind.to_string(), 46001..=46012 | 46020 | 46030..=46031 => kind.to_string(), 48001 | 48100..=48103 | 48106 => kind.to_string(), @@ -130,12 +131,12 @@ pub async fn filter_fanout_by_access( }) .collect(); - // Author-only kinds (NIP-ER reminders) may only ever be delivered to the - // event's own author. This gate lives here — the chokepoint shared by the - // ingest fan-out path and the Redis cross-node `subscribe_local` path, the - // only paths that route author-only kinds — so no such delivery can bypass - // it. It runs before (and independent of) the channel-membership filter - // below because author-only kinds are stored globally (channel_id = None). + // Author-only kinds (NIP-ER reminders, NIP-37 draft wraps) may only ever + // be delivered to the event's own author. This gate lives here — the + // chokepoint shared by the ingest fan-out path and the Redis cross-node + // `subscribe_local` path, the only paths that route author-only kinds — + // so no such delivery can bypass it. It runs before (and independent of) + // the channel-membership filter below. let matches = if AUTHOR_ONLY_KINDS.contains(&event_kind_u32(&stored_event.event)) { let author = stored_event.event.pubkey.to_bytes(); matches @@ -369,6 +370,31 @@ pub(crate) async fn dispatch_persistent_event( 0 } +/// Returns `true` when an event of `kind_u32` should trigger workspace-level +/// workflow dispatch. +/// +/// This is the single canonical gate that `dispatch_persistent_event_inner` +/// uses. Extracting it as a named function lets tests call the real predicate +/// rather than maintaining a parallel closure that can silently diverge. +/// +/// Excluded from dispatch: +/// - Workflow-execution kinds (avoid re-triggering the engine on its own output) +/// - Command kinds (internal relay commands) +/// - Relay-signed workflow messages (tagged `buzz:workflow`) +/// - `KIND_GIFT_WRAP` (encrypted payloads — content is opaque) +/// - `AUTHOR_ONLY_KINDS` (NIP-37 draft wraps, NIP-ER reminders — private +/// per-user state that must never reach workspace-level automations) +pub(crate) fn should_dispatch_workflow(kind_u32: u32, is_relay_workflow_msg: bool) -> bool { + !buzz_core::kind::is_workflow_execution_kind(kind_u32) + && !buzz_core::kind::is_command_kind(kind_u32) + && !is_relay_workflow_msg + && kind_u32 != KIND_GIFT_WRAP + // Author-only kinds (NIP-ER reminders, NIP-37 draft wraps) are private + // per-user state that must not trigger workspace-level workflows. + // AUTHOR_ONLY_KINDS.contains is the permanent guard at this seam. + && !AUTHOR_ONLY_KINDS.contains(&kind_u32) +} + /// Run post-commit delivery/side effects for a stored event. async fn dispatch_persistent_event_inner( tenant: &TenantContext, @@ -502,11 +528,7 @@ async fn dispatch_persistent_event_inner( .iter() .any(|t| t.as_slice().first().map(|s| s.as_str()) == Some("buzz:workflow")); - if !buzz_core::kind::is_workflow_execution_kind(kind_u32) - && !buzz_core::kind::is_command_kind(kind_u32) - && !is_relay_workflow_msg - && kind_u32 != KIND_GIFT_WRAP - { + if should_dispatch_workflow(kind_u32, is_relay_workflow_msg) { let workflow_engine = Arc::clone(&state.workflow_engine); let workflow_event = stored_event.clone(); let trigger_kind = kind_u32.to_string(); @@ -2421,5 +2443,52 @@ mod tests { must not receive a community-B event. Got: {out:?}" ); } + + /// Dispatch-predicate tripwire: kind:31234 (NIP-37 draft wrap) and + /// kind:30300 (NIP-ER reminder) are in `AUTHOR_ONLY_KINDS` so + /// `should_dispatch_workflow` returns `false` for them, permanently + /// suppressing workflow triggers for author-only events. + /// + /// The test calls `should_dispatch_workflow` — the same function + /// that `dispatch_persistent_event_inner` calls — so deleting or + /// changing the guard in the production path reds this test. + /// A kind:9 positive control proves the predicate is not trivially + /// always-false. + #[test] + fn draft_kind_is_excluded_from_workflow_dispatch_by_author_only_guard() { + // kind:31234 must be excluded from workflow dispatch. + // is_relay_workflow_msg=false for any user-submitted event. + assert!( + !super::super::should_dispatch_workflow(buzz_core::kind::KIND_DRAFT, false), + "should_dispatch_workflow must return false for kind:31234 — \ + a draft wrap must NEVER reach the workflow engine" + ); + + // kind:30300 (NIP-ER reminder) is also AUTHOR_ONLY and must be excluded. + assert!( + !super::super::should_dispatch_workflow( + buzz_core::kind::KIND_EVENT_REMINDER, + false + ), + "should_dispatch_workflow must return false for kind:30300 — \ + reminders are author-only and must not trigger workspace workflows" + ); + + // Positive control: a plain channel message (kind:9) must return true + // so we know the predicate is not trivially always-false. + let kind_channel_message: u32 = 9; + assert!( + super::super::should_dispatch_workflow(kind_channel_message, false), + "should_dispatch_workflow must return true for kind:9 channel messages \ + (positive control) — the gate must be live, not trivially always-false" + ); + + // is_relay_workflow_msg=true must suppress dispatch for any kind. + assert!( + !super::super::should_dispatch_workflow(kind_channel_message, true), + "should_dispatch_workflow must return false when is_relay_workflow_msg=true \ + (relay-signed workflow messages must not re-trigger the engine)" + ); + } } } diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 8ddcd56e4f..f8c3bb06e0 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -12,21 +12,21 @@ use uuid::Uuid; use buzz_auth::Scope; use buzz_core::kind::{ event_kind_u32, is_identity_archive_request_kind, is_parameterized_replaceable, - is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_AGENT_TURN_METRIC, - KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, - KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, - KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, KIND_FOLLOW_SET, KIND_FORUM_COMMENT, - KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH, - KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, - KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, - KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, - KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST, - KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_MEMBER_ADDED_NOTIFICATION, - KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MESH_LLM_RELAY_STATUS, KIND_MODERATION_BAN, - KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, - KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, - KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, - KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, + is_relay_admin_kind, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, + KIND_AGENT_TURN_METRIC, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, + KIND_BOOKMARK_SET, KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, + KIND_DM_HIDE, KIND_DM_OPEN, KIND_DRAFT, KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, + KIND_FOLLOW_SET, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, + KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, + KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, + KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, + KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, + KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, + KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MESH_LLM_RELAY_STATUS, + KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, + KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, + KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, + KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, @@ -157,6 +157,8 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result { Ok(Scope::UsersWrite) } + // NIP-37: draft wraps are author-private channel-bound state (UsersWrite scope). + KIND_DRAFT => Ok(Scope::UsersWrite), // NIP-AM: agent turn metrics are agent-authored global events (encrypted to owner). KIND_AGENT_TURN_METRIC => Ok(Scope::MessagesWrite), // NIP-56 reports are ordinary member writes into the mod-only queue. @@ -281,6 +283,13 @@ pub(crate) enum ReactionChannelResult { } /// Derive channel_id from the target event for NIP-25 reactions. +/// +/// Author-only targets (kind:31234, kind:30300 — see `AUTHOR_ONLY_KINDS`) are +/// rejected for ALL actors, including the author themselves: a reaction writes +/// the draft id into a public kind:7 row, creating durable public state that +/// leaks the draft's existence. The caller receives `NotFound` regardless of +/// whether the actor authored the target, making the response byte-identical +/// to the missing-target branch — no oracle. pub(crate) async fn derive_reaction_channel( community_id: CommunityId, db: &buzz_db::Db, @@ -309,10 +318,19 @@ pub(crate) async fn derive_reaction_channel( }; match db.get_event_by_id(community_id, &id_bytes).await { - Ok(Some(target)) => match target.channel_id { - Some(ch_id) => ReactionChannelResult::Channel(ch_id), - None => ReactionChannelResult::NoChannel, - }, + Ok(Some(target)) => { + // Author-only targets (drafts, reminders) are rejected for everyone + // — including the author — because a successful reaction writes the + // draft id into a public kind:7 row (durable public state, id oracle). + // Return NotFound so the response is byte-identical to a random id. + if AUTHOR_ONLY_KINDS.contains(&event_kind_u32(&target.event)) { + return ReactionChannelResult::NotFound; + } + match target.channel_id { + Some(ch_id) => ReactionChannelResult::Channel(ch_id), + None => ReactionChannelResult::NoChannel, + } + } Ok(None) => ReactionChannelResult::NotFound, Err(e) => ReactionChannelResult::DbError(e.to_string()), } @@ -434,9 +452,29 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { | KIND_HUDDLE_PARTICIPANT_LEFT | KIND_HUDDLE_ENDED | KIND_HUDDLE_GUIDELINES + // NIP-37: draft wraps are channel-bound author-private events. + // Each draft is scoped to a specific channel or DM; the `h` tag + // carries the channel UUID. The relay resolves and validates the + // channel, enforces membership, and persists the draft with + // channel_id set. + | KIND_DRAFT ) } +/// Kinds that participate in NIP-10 thread metadata (reply chains, depth, +/// thread summaries, live-summary fan-out). +/// +/// This is a strict subset of `requires_h_channel_scope`: all threaded kinds +/// must be channel-scoped, but NOT all channel-scoped kinds are threaded. +/// Kind:31234 draft wraps are channel-bound but are author-private and must +/// NOT participate in thread metadata — an outer thread reference would +/// expose the fact that a draft is a reply to a specific event, and the +/// live thread-summary side effect (`emit_live_thread_summary`) would +/// broadcast that information to channel subscribers. +pub(crate) fn participates_in_thread_metadata(kind: u32) -> bool { + requires_h_channel_scope(kind) && kind != KIND_DRAFT +} + /// Check channel membership: member OR open-visibility channel. /// /// `channel` is the request's already-fetched channel row, when the caller has @@ -563,6 +601,15 @@ pub(crate) async fn resolve_nip10_thread_meta( .map_err(|e| format!("db error looking up parent: {e}"))? .ok_or_else(|| "reply parent not found".to_string())?; + // Author-only targets (drafts, reminders) are rejected as thread parents + // for EVERYONE, including the author themselves: the NIP-10 thread metadata + // row (and the live thread-summary fan-out) would write the draft id into + // public state. Return the same not-found string so the response is + // byte-identical to the missing-parent branch — no oracle. + if AUTHOR_ONLY_KINDS.contains(&event_kind_u32(&parent_event.event)) { + return Err("reply parent not found".to_string()); + } + match parent_event.channel_id { Some(parent_ch) if parent_ch != channel_id => { return Err("parent event belongs to a different channel".to_string()); @@ -710,6 +757,56 @@ pub(crate) fn effective_message_author(event: &Event, relay_pubkey: &nostr::Publ event.pubkey.to_bytes().to_vec() } +/// Post-lookup visibility predicate for e-tag target resolution on write paths. +/// +/// Returns `Ok(true)` if `actor_bytes` may use `target_event` as an e-tag +/// reference target. Returns `Ok(false)` if the reference must be rejected +/// (caller uses its own site-specific not-found string for the error, so the +/// response is byte-identical to the missing-target branch). +/// +/// **Public-reference paths** (reaction target, NIP-10 thread parent): caller +/// must reject for EVERYONE — pass `allow_author_reference: false`. An author- +/// only event written into a public row (kind:7, thread metadata) leaks the +/// draft id as durable public state; the author has no legitimate use case for +/// threading or reacting to their own draft either. +/// +/// **Owner-mutation paths** (kind:5 e-tag deletion, stream-edit, forum-vote): +/// pass `allow_author_reference: true`. Non-authors/non-agent-owners receive +/// the byte-identical not-found mask; the author falls through to the site's +/// natural validation errors (tombstone-guidance for kind:5, etc.). +/// +/// Generalizes over all `AUTHOR_ONLY_KINDS` (currently kind:31234 draft wraps +/// and kind:30300 reminders) so adding a new author-only kind in one place +/// automatically extends the mask here. +pub(crate) async fn actor_can_reference_target( + target_event: &buzz_core::StoredEvent, + actor_bytes: &[u8], + allow_author_reference: bool, + community_id: CommunityId, + state: &AppState, +) -> Result { + let target_kind = event_kind_u32(&target_event.event); + if !AUTHOR_ONLY_KINDS.contains(&target_kind) { + return Ok(true); + } + if allow_author_reference { + let target_author = + effective_message_author(&target_event.event, &state.relay_keypair.public_key()); + if target_author == actor_bytes { + return Ok(true); + } + if state + .db + .is_agent_owner(community_id, &target_author, actor_bytes) + .await + .map_err(|e| format!("db error checking agent ownership: {e}"))? + { + return Ok(true); + } + } + Ok(false) +} + /// Validate kind:40003 edit ownership — event.pubkey must match target's effective author, /// or the actor must be the owning human of the agent that authored the target message. async fn validate_edit_ownership( @@ -744,6 +841,14 @@ async fn validate_edit_ownership( .map_err(|e| format!("db error: {e}"))? .ok_or_else(|| "edit target event not found".to_string())?; + // Author-only targets (drafts, reminders): non-author/non-agent-owner receives + // "edit target event not found" (byte-identical to the missing-target branch — + // no oracle). The author falls through to the natural validation errors below. + let actor_bytes = event.pubkey.to_bytes().to_vec(); + if !actor_can_reference_target(&target_event, &actor_bytes, true, community_id, state).await? { + return Err("edit target event not found".to_string()); + } + // Verify target belongs to the same channel as the edit event. let edit_channel_id = extract_channel_id(event); match (edit_channel_id, target_event.channel_id) { @@ -757,13 +862,12 @@ async fn validate_edit_ownership( } let author = effective_message_author(&target_event.event, &state.relay_keypair.public_key()); - let actor = event.pubkey.to_bytes().to_vec(); - if author == actor { + if author == actor_bytes { // Author editing their own message: re-gate on membership/open visibility so that // a removed private-channel member cannot mutate old messages after access is revoked. if let Some(ch_id) = target_event.channel_id { let is_member = state - .is_member_cached(community_id, ch_id, &actor) + .is_member_cached(community_id, ch_id, &actor_bytes) .await .map_err(|e| format!("db error checking membership: {e}"))?; if !is_member { @@ -782,7 +886,7 @@ async fn validate_edit_ownership( // Allow the owning human to edit messages authored by their agent. let is_owner = state .db - .is_agent_owner(community_id, &author, &actor) + .is_agent_owner(community_id, &author, &actor_bytes) .await .map_err(|e| format!("db error checking agent ownership: {e}"))?; if !is_owner { @@ -825,6 +929,15 @@ async fn validate_forum_vote_target( .map_err(|e| format!("db error: {e}"))? .ok_or_else(|| "vote target event not found".to_string())?; + // Author-only targets (drafts, reminders): non-author/non-agent-owner receives + // "vote target event not found" (byte-identical to missing-target — no oracle). + // The author falls through; their draft fails the forum-post/comment kind check + // below with a natural validation error. + let actor_bytes = event.pubkey.to_bytes().to_vec(); + if !actor_can_reference_target(&target_event, &actor_bytes, true, community_id, state).await? { + return Err("vote target event not found".to_string()); + } + let target_kind = event_kind_u32(&target_event.event); if target_kind != KIND_FORUM_POST && target_kind != KIND_FORUM_COMMENT { return Err("vote target must be a forum post or comment".to_string()); @@ -1192,6 +1305,182 @@ fn validate_not_before(tag_value: &str) -> Result { Ok(value) } +/// Validate the public envelope of a NIP-37 `kind:31234` draft wrap before it +/// reaches NIP-33 parameterized replacement. +/// +/// The relay cannot decrypt or verify the payload; it enforces the mandatory outer +/// tag shape so a malformed event cannot win replacement against a valid head: +/// +/// 1. Exactly one non-empty `d` tag within `D_TAG_MAX_LEN`. Grammar is unrestricted +/// (NIP-37 does not prescribe it); the relay accepts any non-empty opaque identifier. +/// 2. Exactly one `k` tag with a canonical ASCII-decimal inner kind value in the +/// unsigned 16-bit range (0..=65535) with no leading zeros (except bare "0"). +/// 3. Exactly one `h` tag with a canonical UUID value — the channel or DM this +/// draft is bound to. The relay resolves and validates the channel separately; +/// this check only validates the tag's syntactic shape. +/// 4. No outer `p` tag — prevents this event from entering the mention/feed index. +/// Recipient/reply/edit details remain encrypted inside the payload. +/// 5. No outer `e` tag — thread-reference context (reply-to, root) belongs inside +/// the NIP-44 encrypted payload, not as plain outer metadata. An outer `e` tag +/// would be visible to anyone with relay access, leaking the fact that this draft +/// is a reply or edit of a specific event. +/// 6. Non-empty content must be a syntactically plausible NIP-44 v2 ciphertext +/// (reuses `validate_engram_nip44_content`). Empty content is the NIP-37 +/// deletion tombstone and is explicitly valid. +/// 7. At most one `expiration` tag, whose value must be canonical ASCII decimal, +/// strictly greater than both `event.created_at` and the relay's current time. +/// If present, it must be in the safe-integer range for JSON interoperability. +fn validate_draft_wrap_envelope(event: &Event) -> Result<(), String> { + let now_secs = chrono::Utc::now().timestamp() as u64; + let event_created_at = event.created_at.as_secs(); + + let mut d_count = 0usize; + let mut d_value: Option<&str> = None; + let mut k_count = 0usize; + let mut k_value: Option<&str> = None; + let mut h_count = 0usize; + let mut h_value: Option<&str> = None; + let mut expiration_count = 0usize; + let mut expiration_value: Option<&str> = None; + + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.len() < 2 { + continue; + } + match parts[0].as_str() { + "d" => { + d_count += 1; + d_value = Some(&parts[1]); + } + "k" => { + k_count += 1; + k_value = Some(&parts[1]); + } + "h" => { + h_count += 1; + h_value = Some(&parts[1]); + } + "p" => { + return Err( + "draft-wrap event must not have a `p` tag (prevents mention/feed indexing)" + .to_string(), + ); + } + "e" => { + return Err("draft-wrap event must not have an outer `e` tag \ + (thread-reference context belongs inside the encrypted payload)" + .to_string()); + } + "expiration" => { + expiration_count += 1; + expiration_value = Some(&parts[1]); + } + _ => {} + } + } + + // Validate `d` tag. + if d_count != 1 { + return Err(format!( + "draft-wrap event must have exactly one `d` tag (got {d_count})" + )); + } + let d = d_value.unwrap(); + if d.is_empty() { + return Err("draft-wrap `d` tag must not be empty".to_string()); + } + if d.len() > buzz_db::event::D_TAG_MAX_LEN { + return Err(format!( + "draft-wrap `d` tag too long ({} bytes, max {})", + d.len(), + buzz_db::event::D_TAG_MAX_LEN, + )); + } + + // Validate `k` tag — canonical decimal, fits u16, no leading zeros. + if k_count != 1 { + return Err(format!( + "draft-wrap event must have exactly one `k` tag (got {k_count})" + )); + } + let k = k_value.unwrap(); + if k.is_empty() || !k.bytes().all(|b| b.is_ascii_digit()) { + return Err("draft-wrap `k` tag must be a canonical decimal integer".to_string()); + } + if k.len() > 1 && k.starts_with('0') { + return Err("draft-wrap `k` tag must not have leading zeros".to_string()); + } + let _inner_kind: u16 = k.parse().map_err(|_| { + "draft-wrap `k` tag value out of range (must fit unsigned 16-bit kind)".to_string() + })?; + + // Validate `h` tag — required, exactly one, must be a well-formed UUID. + if h_count != 1 { + return Err(format!( + "draft-wrap event must have exactly one `h` tag (got {h_count}); \ + draft wraps are channel-bound" + )); + } + let h = h_value.unwrap(); + match uuid::Uuid::parse_str(h) { + Err(_) => { + return Err(format!( + "draft-wrap `h` tag must be a canonical UUID (channel or DM id), got: {h:?}" + )); + } + Ok(parsed) if parsed.to_string() != h => { + return Err(format!( + "draft-wrap `h` tag must be lowercase hyphenated UUID, got: {h:?}" + )); + } + Ok(_) => {} + } + + // Validate `expiration` tag (optional, at most one). + if expiration_count > 1 { + return Err(format!( + "draft-wrap event must have at most one `expiration` tag (got {expiration_count})" + )); + } + if let Some(exp_str) = expiration_value { + if exp_str.is_empty() || !exp_str.bytes().all(|b| b.is_ascii_digit()) { + return Err( + "draft-wrap `expiration` tag must be a canonical decimal Unix timestamp" + .to_string(), + ); + } + if exp_str.len() > 1 && exp_str.starts_with('0') { + return Err("draft-wrap `expiration` tag must not have leading zeros".to_string()); + } + const MAX_SAFE_INT: u64 = 9_007_199_254_740_991; + let exp: u64 = exp_str.parse().map_err(|_| { + "draft-wrap `expiration` tag value out of safe-integer range".to_string() + })?; + if exp > MAX_SAFE_INT { + return Err( + "draft-wrap `expiration` tag value exceeds JSON safe-integer limit".to_string(), + ); + } + if exp <= event_created_at { + return Err( + "draft-wrap `expiration` must be strictly after event `created_at`".to_string(), + ); + } + if exp <= now_secs { + return Err("draft-wrap `expiration` must be in the future".to_string()); + } + } + + // Validate content: either empty (tombstone) or NIP-44 v2 ciphertext. + if !event.content.is_empty() { + validate_engram_nip44_content(&event.content) + .map_err(|e| e.replace("agent-engram", "draft-wrap"))?; + } + + Ok(()) +} + /// Validate the public tag envelope of a NIP-ER `kind:30300` event before it /// reaches NIP-33 parameterized replacement. /// @@ -1521,6 +1810,16 @@ async fn ingest_event_inner( } } + // NIP-37 draft wraps: validate the envelope (d/k/h/p/expiration/content) + // BEFORE channel extraction so that missing/malformed/duplicate h-tag errors + // are reported as structural validation failures rather than channel-scope + // failures. This also ensures the `p`-tag guard fires before the channel + // membership check. + if kind_u32 == KIND_DRAFT { + validate_draft_wrap_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + let mut channel_id = if kind_u32 == KIND_REACTION { match derive_reaction_channel(tenant.community(), &state.db, &event).await { ReactionChannelResult::Channel(ch_id) => Some(ch_id), @@ -1835,6 +2134,33 @@ async fn ingest_event_inner( "invalid: deletion events must reference exactly one target via e or a tag (got e={e_count}, a={a_count})" ))); } + + // NIP-37 draft wraps (kind:31234) do NOT support NIP-09 a-tag deletion. + // Accepting it would erase the head row, after which a write with a + // different channel could bypass the immutable-binding invariant. + // The correct NIP-37 deletion mechanism is an empty-content tombstone + // (kind:31234 with "" content) that keeps the address visible and + // preserves the channel binding. + if a_count == 1 { + let targets_draft = event.tags.iter().any(|t| { + if t.kind().to_string() == "a" { + t.content() + .and_then(|v| v.split(':').next()) + .and_then(|k| k.parse::().ok()) + .map(|k| k == KIND_DRAFT) + .unwrap_or(false) + } else { + false + } + }); + if targets_draft { + return Err(IngestError::Rejected( + "invalid: NIP-09 a-tag deletion of kind:31234 draft wraps is not supported; \ + use an empty-content tombstone (kind:31234 with empty content) instead" + .into(), + )); + } + } } if kind_u32 == KIND_STREAM_MESSAGE_EDIT { @@ -2044,7 +2370,7 @@ async fn ingest_event_inner( .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } - let thread_meta = if requires_h_channel_scope(kind_u32) { + let thread_meta = if participates_in_thread_metadata(kind_u32) { if let Some(ch_id) = channel_id { resolve_nip10_thread_meta(tenant.community(), &event, ch_id, state) .await @@ -2209,11 +2535,24 @@ async fn ingest_event_inner( buzz_db::event::D_TAG_MAX_LEN, ))); } + + // replace_parameterized_event enforces the immutable-binding invariant + // for kind:31234 draft wraps atomically inside the advisory lock by + // inferring the check from event.kind — no separate expected_channel_id + // parameter needed. All other parameterized-replaceable kinds have no + // channel binding constraint. state .db .replace_parameterized_event(tenant.community(), &event, &d_tag, channel_id) .await - .map_err(|e| IngestError::Internal(format!("error: {e}")))? + .map_err(|e| match e { + buzz_db::DbError::DraftChannelMismatch => IngestError::Rejected( + "invalid: draft-wrap channel binding is immutable — \ + `h` tag must match the existing head's channel" + .into(), + ), + other => IngestError::Internal(format!("error: {other}")), + })? } else { let thread_params = thread_meta.as_ref().map(|m| m.as_params()); match state @@ -3386,4 +3725,508 @@ mod tests { // error comes from validate_engram_nip44_content with label replaced assert!(err.contains("agent-turn-metric"), "got: {err}"); } + + // ────────────────────────────────────────────────────────────────────────── + // validate_draft_wrap_envelope tests (kind:31234 / NIP-37) + // ────────────────────────────────────────────────────────────────────────── + + fn make_draft(tags: &[&[&str]], content: &str) -> Event { + make_event_with_tags(KIND_DRAFT, content, tags) + } + + // ── acceptance ──────────────────────────────────────────────────────────── + + #[test] + fn draft_wrap_accepts_ciphertext_content() { + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["d", &d], &["k", "9"], &["h", &ch]], &fake_nip44_v2()); + assert!( + validate_draft_wrap_envelope(&ev).is_ok(), + "canonical draft with ciphertext content must be accepted" + ); + } + + #[test] + fn draft_wrap_accepts_blank_tombstone() { + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["d", &d], &["k", "9"], &["h", &ch]], ""); + assert!( + validate_draft_wrap_envelope(&ev).is_ok(), + "tombstone (empty content) must be accepted" + ); + } + + #[test] + fn draft_wrap_accepts_various_valid_k_values() { + for k in ["0", "1", "9", "1000", "30023", "65535"] { + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["d", &d], &["k", k], &["h", &ch]], ""); + assert!( + validate_draft_wrap_envelope(&ev).is_ok(), + "k={k} must be accepted" + ); + } + } + + // ── d-tag validation ────────────────────────────────────────────────────── + + #[test] + fn draft_wrap_rejects_missing_d_tag() { + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["k", "9"], &["h", &ch]], &fake_nip44_v2()); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("`d` tag"), "got: {err}"); + } + + #[test] + fn draft_wrap_rejects_empty_d_tag() { + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["d", ""], &["k", "9"], &["h", &ch]], &fake_nip44_v2()); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("`d` tag"), "got: {err}"); + } + + #[test] + fn draft_wrap_rejects_duplicate_d_tag() { + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft( + &[&["d", &d], &["d", &d], &["k", "9"], &["h", &ch]], + &fake_nip44_v2(), + ); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("`d` tag"), "got: {err}"); + } + + // ── k-tag validation ────────────────────────────────────────────────────── + + #[test] + fn draft_wrap_rejects_missing_k_tag() { + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["d", &d], &["h", &ch]], &fake_nip44_v2()); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("`k` tag"), "got: {err}"); + } + + #[test] + fn draft_wrap_rejects_duplicate_k_tag() { + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft( + &[&["d", &d], &["k", "9"], &["k", "9"], &["h", &ch]], + &fake_nip44_v2(), + ); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("`k` tag"), "got: {err}"); + } + + #[test] + fn draft_wrap_rejects_k_tag_non_decimal() { + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["d", &d], &["k", "0x9"], &["h", &ch]], &fake_nip44_v2()); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("canonical decimal"), "got: {err}"); + } + + #[test] + fn draft_wrap_rejects_k_tag_leading_zero() { + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["d", &d], &["k", "09"], &["h", &ch]], &fake_nip44_v2()); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("leading zero"), "got: {err}"); + } + + #[test] + fn draft_wrap_rejects_k_tag_out_of_u16_range() { + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + // 65536 = u16::MAX + 1 + let ev = make_draft( + &[&["d", &d], &["k", "65536"], &["h", &ch]], + &fake_nip44_v2(), + ); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("range"), "got: {err}"); + } + + // ── p outer-tag exclusion ───────────────────────────────────────────────── + // Note: `h` is now *required* (not forbidden); see h-tag validation section. + + #[test] + fn draft_wrap_rejects_p_tag() { + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + let keys = nostr::Keys::generate(); + let ev = make_draft( + &[ + &["d", &d], + &["k", "9"], + &["h", &ch], + &["p", &keys.public_key().to_hex()], + ], + &fake_nip44_v2(), + ); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("`p` tag"), "got: {err}"); + } + + // ── content validation ──────────────────────────────────────────────────── + + #[test] + fn draft_wrap_rejects_non_base64_content() { + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["d", &d], &["k", "9"], &["h", &ch]], "not-a-ciphertext"); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!( + err.contains("base64") || err.contains("NIP-44"), + "got: {err}" + ); + } + + #[test] + fn draft_wrap_rejects_wrong_nip44_version_byte() { + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + // 132 chars of valid base64 but version byte decodes to 0x00, not 0x02. + let bad = "A".repeat(132); + let ev = make_draft(&[&["d", &d], &["k", "9"], &["h", &ch]], &bad); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!( + err.contains("NIP-44 v2") || err.contains("0x02"), + "got: {err}" + ); + } + + #[test] + fn draft_wrap_rejects_short_ciphertext() { + // 1-byte decoded (version prefix only, no payload) — too short. + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["d", &d], &["k", "9"], &["h", &ch]], "Ag=="); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("too short"), "got: {err}"); + } + + // ── expiration tag validation ───────────────────────────────────────────── + + #[test] + fn draft_wrap_accepts_valid_future_expiration() { + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + // A timestamp far in the future (year 2100). + let ev = make_draft( + &[ + &["d", &d], + &["k", "9"], + &["h", &ch], + &["expiration", "4102444800"], + ], + "", + ); + assert!( + validate_draft_wrap_envelope(&ev).is_ok(), + "valid future expiration must be accepted" + ); + } + + #[test] + fn draft_wrap_rejects_duplicate_expiration_tag() { + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft( + &[ + &["d", &d], + &["k", "9"], + &["h", &ch], + &["expiration", "4102444800"], + &["expiration", "4102444800"], + ], + "", + ); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("expiration"), "got: {err}"); + } + + #[test] + fn draft_wrap_rejects_expiration_in_past() { + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft( + &[ + &["d", &d], + &["k", "9"], + &["h", &ch], + &["expiration", "1000000000"], + ], + "", + ); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("expiration"), "got: {err}"); + } + + #[test] + fn draft_wrap_rejects_non_decimal_expiration() { + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft( + &[ + &["d", &d], + &["k", "9"], + &["h", &ch], + &["expiration", "not-a-number"], + ], + "", + ); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("expiration"), "got: {err}"); + } + + // ── routing invariants ──────────────────────────────────────────────────── + + #[test] + fn draft_wrap_requires_h_channel_scope() { + // Draft wraps are channel-bound; compose context exposed via `h` tag. + assert!( + requires_h_channel_scope(KIND_DRAFT), + "KIND_DRAFT must require an `h` tag (channel-bound)" + ); + } + + #[test] + fn draft_wrap_does_not_participate_in_thread_metadata() { + // Draft wraps must NOT participate in NIP-10 thread metadata. + // An outer reply/root `e` tag on a draft would expose that the draft is + // a reply to a specific event; the live-summary fan-out would broadcast + // that information to channel subscribers, breaking author-privacy. + assert!( + !participates_in_thread_metadata(KIND_DRAFT), + "KIND_DRAFT must be excluded from thread-metadata processing" + ); + // Sanity: a plain channel message DOES participate. + assert!( + participates_in_thread_metadata(KIND_STREAM_MESSAGE), + "KIND_STREAM_MESSAGE must participate in thread metadata (positive control)" + ); + } + + #[test] + fn draft_wrap_is_not_global_only() { + // Draft wraps have a required `h` tag — they are channel-scoped, not global. + assert!( + !is_global_only_kind(KIND_DRAFT), + "KIND_DRAFT must not be global-only (it requires an h tag)" + ); + } + + #[test] + fn draft_wrap_requires_users_write_scope() { + let dummy = make_dummy_event(); + assert_eq!( + required_scope_for_kind(KIND_DRAFT, &dummy).unwrap(), + Scope::UsersWrite, + "KIND_DRAFT must require UsersWrite scope" + ); + } + + // ── h-tag validation ────────────────────────────────────────────────────── + + #[test] + fn draft_wrap_accepts_valid_h_tag_uuid() { + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["d", &d], &["k", "9"], &["h", &ch]], &fake_nip44_v2()); + assert!( + validate_draft_wrap_envelope(&ev).is_ok(), + "draft with valid UUID h tag must be accepted" + ); + } + + #[test] + fn draft_wrap_rejects_missing_h_tag() { + let d = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["d", &d], &["k", "9"]], &fake_nip44_v2()); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!( + err.contains("`h` tag") || err.contains("channel-bound"), + "got: {err}" + ); + } + + #[test] + fn draft_wrap_rejects_duplicate_h_tag() { + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft( + &[&["d", &d], &["k", "9"], &["h", &ch], &["h", &ch]], + &fake_nip44_v2(), + ); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!( + err.contains("`h` tag") || err.contains("channel-bound"), + "got: {err}" + ); + } + + #[test] + fn draft_wrap_rejects_non_uuid_h_tag() { + let d = uuid::Uuid::new_v4().to_string(); + let ev = make_draft( + &[&["d", &d], &["k", "9"], &["h", "not-a-uuid"]], + &fake_nip44_v2(), + ); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!( + err.contains("`h` tag") || err.contains("UUID"), + "got: {err}" + ); + } + + /// `Uuid::parse_str` accepts uppercase hex — the relay must reject it so the + /// only accepted form is the canonical lowercase-hyphenated representation. + #[test] + fn draft_wrap_rejects_uppercase_uuid_h_tag() { + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + // Convert the canonical form to UPPERCASE — still parse-valid, non-canonical. + let ch_upper = ch.to_uppercase(); + assert_ne!(ch, ch_upper, "test setup: uppercase must differ"); + let ev = make_draft( + &[&["d", &d], &["k", "9"], &["h", &ch_upper]], + &fake_nip44_v2(), + ); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!( + err.contains("lowercase") || err.contains("canonical") || err.contains("UUID"), + "expected canonical/lowercase/UUID rejection, got: {err}" + ); + } + + /// `Uuid::parse_str` accepts the 32-hex-char form without hyphens — the relay + /// must reject it so that `h` is always the canonical `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` + /// form and NIP-33 address derivation is unambiguous. + #[test] + fn draft_wrap_rejects_simple_hex_uuid_h_tag() { + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + // Strip hyphens to get the 32-char simple hex form. + let ch_simple = ch.replace('-', ""); + assert_eq!( + ch_simple.len(), + 32, + "test setup: simple form is 32 hex chars" + ); + let ev = make_draft( + &[&["d", &d], &["k", "9"], &["h", &ch_simple]], + &fake_nip44_v2(), + ); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!( + err.contains("lowercase") || err.contains("canonical") || err.contains("UUID"), + "expected canonical/lowercase/UUID rejection, got: {err}" + ); + } + + /// Outer `e` tags on a draft wrap must be rejected: thread-reference context + /// (reply-to, root-event) belongs inside the NIP-44 encrypted payload, not as + /// plain outer metadata visible to any relay reader. + #[test] + fn draft_wrap_rejects_outer_e_tag() { + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + let target_id = hex::encode([0u8; 32]); + let ev = make_draft( + &[&["d", &d], &["k", "9"], &["h", &ch], &["e", &target_id]], + &fake_nip44_v2(), + ); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!( + err.contains("`e` tag") || err.contains("e tag"), + "expected outer-e-tag rejection, got: {err}" + ); + } + + // ── actor_can_reference_target: author-only mask ─────────────────────────── + // These unit tests verify AUTHOR_ONLY_KINDS membership for kind:31234 and + // kind:30300 — the constant that `actor_can_reference_target` (and the + // inline guards in `derive_reaction_channel` / `resolve_nip10_thread_meta`) + // derive their mask from. They do NOT exercise the async helper or any + // call site directly (no live Postgres is available here). + // + // Behavioral coverage — that the guard actually fires in the live relay for + // both draft and reminder targets — is provided by the e2e tests: + // - e2e_nip37_draft.rs: test_draft_target_reaction_oracle_closed (kind:31234) + // - e2e_nip37_draft.rs: test_reminder_target_reaction_oracle_closed (kind:30300) + + #[test] + fn author_only_kinds_covers_draft_and_reminder() { + // AUTHOR_ONLY_KINDS must contain both KIND_DRAFT and KIND_EVENT_REMINDER. + // actor_can_reference_target derives its mask from this constant, so any + // author-only kind not in the list escapes the guard. + assert!( + buzz_core::kind::AUTHOR_ONLY_KINDS.contains(&buzz_core::kind::KIND_DRAFT), + "AUTHOR_ONLY_KINDS must contain KIND_DRAFT (31234)" + ); + assert!( + buzz_core::kind::AUTHOR_ONLY_KINDS.contains(&buzz_core::kind::KIND_EVENT_REMINDER), + "AUTHOR_ONLY_KINDS must contain KIND_EVENT_REMINDER (30300)" + ); + // Positive control: a non-author-only kind must NOT be in the list. + let kind_channel_msg: u32 = 9; + assert!( + !buzz_core::kind::AUTHOR_ONLY_KINDS.contains(&kind_channel_msg), + "kind:9 must NOT be in AUTHOR_ONLY_KINDS (positive control)" + ); + } + + #[test] + fn derive_reaction_channel_masks_draft_target() { + // derive_reaction_channel returns NotFound for a stored draft target — + // it must never return Channel/NoChannel and allow the reaction to proceed. + // + // We test via participates_in_thread_metadata / AUTHOR_ONLY_KINDS membership + // since derive_reaction_channel is async and uses the DB. The logic is: + // `if AUTHOR_ONLY_KINDS.contains(&event_kind_u32(&target.event)) => NotFound` + // so this test verifies the constant + kind function used by the guard. + let kind_val = buzz_core::kind::KIND_DRAFT; + assert!( + buzz_core::kind::AUTHOR_ONLY_KINDS.contains(&kind_val), + "KIND_DRAFT must be in AUTHOR_ONLY_KINDS — derive_reaction_channel \ + relies on this to return NotFound for draft targets" + ); + let reminder_kind = buzz_core::kind::KIND_EVENT_REMINDER; + assert!( + buzz_core::kind::AUTHOR_ONLY_KINDS.contains(&reminder_kind), + "KIND_EVENT_REMINDER must be in AUTHOR_ONLY_KINDS — derive_reaction_channel \ + also masks reminders (Q15 generalizes over all AUTHOR_ONLY_KINDS)" + ); + } + + #[test] + fn resolve_nip10_thread_meta_masks_draft_parent() { + // participates_in_thread_metadata(KIND_DRAFT) must be false — draft kinds + // are excluded from NIP-10 thread metadata processing. The Q15 guard + // in resolve_nip10_thread_meta uses AUTHOR_ONLY_KINDS; this test verifies + // the membership that drives it. + assert!( + !participates_in_thread_metadata(KIND_DRAFT), + "KIND_DRAFT must not participate in thread metadata" + ); + // The AUTHOR_ONLY_KINDS membership check gates the explicit not-found + // masking added in Q15. Verify both draft and reminder are covered. + assert!( + buzz_core::kind::AUTHOR_ONLY_KINDS.contains(&KIND_DRAFT), + "KIND_DRAFT must be in AUTHOR_ONLY_KINDS for the Q15 not-found mask" + ); + assert!( + buzz_core::kind::AUTHOR_ONLY_KINDS.contains(&buzz_core::kind::KIND_EVENT_REMINDER), + "KIND_EVENT_REMINDER must be in AUTHOR_ONLY_KINDS for the Q15 not-found mask" + ); + } } diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 12ac43bc28..95af82a774 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -7,7 +7,7 @@ use tracing::{debug, warn}; use buzz_core::filter::filters_match; use buzz_core::kind::{ - AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, KIND_DM_VISIBILITY, + AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, KIND_DM_VISIBILITY, KIND_DRAFT, P_GATED_KINDS, RESULT_GATED_KINDS, }; use buzz_core::tenant::TenantContext; @@ -368,16 +368,14 @@ pub async fn handle_req( } } - // Result-level read auth: a viewer-private snapshot (kind:30622) is - // delivered only to its owner, even if reached via a kindless - // `ids:[…]` subscription that skips the filter-level `#p` gate. - if !buzz_core::filter::reader_authorized_for_event(&stored.event, &viewer_hex) { - continue; - } - - // Author-only kinds: only the event author may see these events. - // Mixed-kind filters still serve other kinds normally. - if is_author_only_event(&stored.event, &pubkey_bytes) { + // Canonical per-event read gate: covers result-gated (p-owned) kinds + // and author-only kinds in a single call so no delivery surface can + // accidentally omit half the privacy model. + if !buzz_core::filter::reader_can_receive_event( + &stored.event, + &viewer_hex, + &pubkey_bytes, + ) { continue; } @@ -693,15 +691,13 @@ async fn handle_search_req( continue; } } - if !buzz_core::filter::reader_authorized_for_event( + if !buzz_core::filter::reader_can_receive_event( &stored.event, reader_pubkey_hex, + reader_pubkey_bytes, ) { continue; } - if is_author_only_event(&stored.event, reader_pubkey_bytes) { - continue; - } // Dedup AFTER acceptance — an event that fails filter A's constraints // must remain eligible for filter B (NIP-01 OR semantics). if !seen_ids.insert(stored.event.id) { @@ -1105,6 +1101,26 @@ pub(crate) fn filter_can_match_author_only_kinds(filter: &Filter) -> bool { }) } +/// Returns `true` if the filter CAN match `KIND_DRAFT` events — meaning it +/// either has no `kinds` constraint (wildcard) or explicitly includes `KIND_DRAFT`. +/// +/// Used by COUNT handlers to bypass the `author_is_self` fast-path when the +/// filter could match draft events. Draft events carry a per-event expiry +/// property (`expiration` tag + server-side 30-day ceiling) that the SQL +/// `count_events()` path cannot see, so draft-matching filters must always +/// route through the per-event `reader_can_receive_event` gate to avoid +/// reporting expired drafts as still-existing. +/// +/// Note: `KIND_EVENT_REMINDER` is NOT included here because reminders carry no +/// expiry semantics — they retain their existing fast-path, so this predicate +/// introduces zero behaviour change for reminder COUNTs. +pub(crate) fn filter_can_match_draft(filter: &Filter) -> bool { + filter + .kinds + .as_ref() + .is_none_or(|ks| ks.iter().any(|k| k.as_u16() as u32 == KIND_DRAFT)) +} + /// Returns `true` if the filter CAN match result-gated kinds — meaning it /// either has no `kinds` constraint (wildcard) or includes at least one kind /// that carries a per-event result-level read gate (currently @@ -1132,7 +1148,7 @@ pub(crate) fn filter_can_match_result_gated_kinds(filter: &Filter) -> bool { /// `{kinds:[44200], #p:[self]}`. /// /// When this returns `false`, the COUNT handler MUST use the per-event fallback -/// and apply `reader_authorized_for_event` on each row. +/// and apply `reader_can_receive_event` on each row. pub(crate) fn result_gated_count_safe_for_pushdown( filter: &Filter, authed_pubkey_hex: &str, @@ -1144,14 +1160,6 @@ pub(crate) fn result_gated_count_safe_for_pushdown( .is_some_and(|values| !values.is_empty() && values.iter().all(|v| v == authed_pubkey_hex)) } -/// Returns `true` if the event is an author-only kind and the requester is NOT -/// the author. Used as a per-event filter during historical delivery and fan-out -/// to silently omit unauthorized events from mixed-kind result sets. -pub(crate) fn is_author_only_event(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool { - let kind_u32 = event.kind.as_u16() as u32; - AUTHOR_ONLY_KINDS.contains(&kind_u32) && event.pubkey.to_bytes() != requester_pubkey_bytes -} - /// Pre-filter authorization for filters that exclusively target author-only kinds. /// /// If a filter targets ONLY author-only kinds (e.g. `{kinds:[30300]}`), the @@ -1438,7 +1446,7 @@ mod tests { // Case 2: kindless {ids:[...]} — the existing ids exemption applies // at this filter-authorization gate (consistent with other p-gated kinds). // The kindless path is closed at the result level by - // `reader_authorized_for_event` (buzz-core/src/filter.rs), which gates + // `reader_can_receive_event` (buzz-core/src/filter.rs), which gates // kind:44200 delivery to the #p owner across all pull paths (WS historical, // HTTP bridge) and live fan-out. Pass-through here is correct; the // result-level gate is the enforcement point for this path. diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 33a95ee64e..35c7551ea8 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -8,10 +8,10 @@ use uuid::Uuid; use buzz_core::kind::{ event_kind_u32, is_parameterized_replaceable, KIND_AGENT_PROFILE, KIND_DM_VISIBILITY, - KIND_GIT_REPO_ANNOUNCEMENT, KIND_IA_ARCHIVED, KIND_IA_ARCHIVED_LIST, KIND_IA_UNARCHIVED, - KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS, - KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION, - KIND_THREAD_SUMMARY, + KIND_DRAFT, KIND_GIT_REPO_ANNOUNCEMENT, KIND_IA_ARCHIVED, KIND_IA_ARCHIVED_LIST, + KIND_IA_UNARCHIVED, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, + KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA, + KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION, KIND_THREAD_SUMMARY, }; use buzz_core::StoredEvent; use buzz_db::channel::{MemberRecord, MemberRole}; @@ -216,6 +216,36 @@ pub async fn validate_standard_deletion_event( .await? .ok_or_else(|| anyhow::anyhow!("target event not found"))?; + // Author-only kinds (drafts, reminders): mask existence for non-authors. + // The authz check below produces "must be event author" for a real event + // vs "target event not found" for a random id — that differential IS an + // oracle. Placing the author-only check before authz ensures that a + // non-author/non-agent-owner receives "target event not found" regardless, + // collapsing the oracle. The author falls through to the informative + // tombstone-guidance error below. + if buzz_core::kind::AUTHOR_ONLY_KINDS + .contains(&buzz_core::kind::event_kind_u32(&target_event.event)) + { + let draft_author = + effective_message_author(&target_event.event, &state.relay_keypair.public_key()); + let is_owner = state + .db + .is_agent_owner(tenant.community(), &draft_author, &actor_bytes) + .await + .unwrap_or(false); + if draft_author != actor_bytes && !is_owner { + // Non-author: respond byte-identical to the missing-target branch. + return Err(anyhow::anyhow!("target event not found")); + } + // Author or agent-owner: informative tombstone-guidance error. + return Err(anyhow::anyhow!( + "NIP-09 e-tag deletion of kind:{} events is not supported; \ + use an empty-content tombstone (kind:{} with empty content) instead", + buzz_core::kind::event_kind_u32(&target_event.event), + buzz_core::kind::event_kind_u32(&target_event.event), + )); + } + let target_author = effective_message_author(&target_event.event, &state.relay_keypair.public_key()); if target_author != actor_bytes @@ -558,6 +588,38 @@ pub async fn validate_admin_event( .map_err(|e| anyhow::anyhow!("db error looking up target: {e}"))? .ok_or_else(|| anyhow::anyhow!("target event not found"))?; + // NIP-37: kind:9005 deletion of kind:31234 draft wraps is not + // supported. Accepting it would erase the live head row, enabling a + // subsequent write to rebind the immutable (community, author, kind, + // d) address to a different channel. The correct deletion mechanism + // is an empty-content tombstone (kind:31234 with "" content). + // + // Placement: before the channel-match and actor-authz checks so that + // channel admins cannot probe `e=` to distinguish a draft from a + // nonexistent event. Author and agent-owner get the informative + // tombstone-guidance error; everyone else gets the generic + // "target event not found" response (byte-identical to the + // missing-target branch above). + if event_kind_u32(&target_event.event) == KIND_DRAFT { + let draft_author = effective_message_author( + &target_event.event, + &state.relay_keypair.public_key(), + ); + if draft_author == actor_bytes + || state + .db + .is_agent_owner(tenant.community(), &draft_author, &actor_bytes) + .await? + { + return Err(anyhow::anyhow!( + "kind:9005 deletion of kind:31234 draft wraps is not supported; \ + use an empty-content tombstone (kind:31234 with empty content) instead" + )); + } else { + return Err(anyhow::anyhow!("target event not found")); + } + } + match target_event.channel_id { Some(target_ch) if target_ch != channel_id => { return Err(anyhow::anyhow!( @@ -1984,6 +2046,27 @@ async fn handle_a_tag_deletion( let actor_bytes = effective_message_author(event, &state.relay_keypair.public_key()); match kind_num { + // Defensive guard: draft wraps (kind:31234) do NOT use NIP-09 a-tag + // soft-deletion — they use an empty-content tombstone (a replacement + // write with empty content) instead. Accepting an a-tag soft-delete + // would erase the head row and allow a different-channel write to + // rebind the (author, d_tag) address, breaking the immutable-binding + // invariant. + // + // This branch is unreachable via normal ingest: the pre-storage gate at + // `ingest.rs` rejects any NIP-09 kind:5 event whose a-tag targets + // kind:31234 before it is ever stored. This guard is a second-line + // defensive check in case side_effects is called from a future path + // that bypasses the pre-storage gate. It does NOT surface as a + // client-facing validation error — errors returned by side-effect + // handlers after storage are logged only (callers do not propagate + // them to the client). + buzz_core::kind::KIND_DRAFT => { + return Err(anyhow::anyhow!( + "NIP-09 a-tag deletion of kind:31234 draft wraps is not supported; \ + use an empty-content tombstone (kind:31234 with empty content) instead" + )); + } buzz_core::kind::KIND_WORKFLOW_DEF => { // Try UUID first (workflow_id); fall back to name-based lookup. if let Ok(wf_id) = uuid::Uuid::parse_str(d_tag) { diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 6241bfb2b4..2b3db9bc23 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -12,7 +12,8 @@ use crate::config::DEFAULT_MAX_FRAME_BYTES; /// /// NIP-43 (relay membership) is advertised separately by [`RelayInfo::build`] /// only when membership enforcement is actually enabled — see that function. -pub(crate) const SUPPORTED_NIPS: &[u32] = &[1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 38, 42, 50, 56]; +pub(crate) const SUPPORTED_NIPS: &[u32] = + &[1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 37, 38, 42, 50, 56]; /// NIP-43 (relay membership). Advertised only when the relay actually /// enforces membership (`BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`) AND has a @@ -297,6 +298,24 @@ mod tests { ); } + #[test] + fn supported_nips_includes_nip37() { + assert!( + SUPPORTED_NIPS.contains(&37), + "NIP-37 (draft wraps) must be advertised — kind:31234 ingest and author-only reads are live" + ); + } + + #[test] + fn supported_nips_does_not_include_nip40() { + // NIP-40 requires expired stored rows to be suppressed on read; Buzz does not + // do this generically yet. Desktop must not rely on relay-side expiry suppression. + assert!( + !SUPPORTED_NIPS.contains(&40), + "NIP-40 must NOT be advertised until expired stored rows are generically suppressed on read" + ); + } + #[test] fn build_advertises_buzz_repository_url() { let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); diff --git a/crates/buzz-search/tests/fts_integration.rs b/crates/buzz-search/tests/fts_integration.rs index 65c405260a..b21a81417a 100644 --- a/crates/buzz-search/tests/fts_integration.rs +++ b/crates/buzz-search/tests/fts_integration.rs @@ -3,12 +3,12 @@ //! Run with a local PG: `BUZZ_TEST_DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz cargo test -p buzz-search --tests -- --include-ignored` //! //! Each test creates a uniquely-named schema, applies the full migration chain -//! (0001 through 0008) into it, exercises a scenario, and drops it. Tests are +//! (0001 through 0012) into it, exercises a scenario, and drops it. Tests are //! parallel-safe. use buzz_core::{ kind::{ - AUTHOR_ONLY_KINDS, KIND_AGENT_TURN_METRIC, KIND_MEMBER_ADDED_NOTIFICATION, + AUTHOR_ONLY_KINDS, KIND_AGENT_TURN_METRIC, KIND_DRAFT, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, P_GATED_KINDS, }, CommunityId, @@ -27,6 +27,13 @@ const MIGRATION_0006_SQL: &str = include_str!("../../../migrations/0006_moderati const MIGRATION_0007_SQL: &str = include_str!("../../../migrations/0007_nip_rs_retention.sql"); const MIGRATION_0008_SQL: &str = include_str!("../../../migrations/0008_fresh_install_search_allowlist.sql"); +const MIGRATION_0009_SQL: &str = + include_str!("../../../migrations/0009_nip_rs_database_guards.sql"); +const MIGRATION_0010_SQL: &str = + include_str!("../../../migrations/0010_nip_rs_exact_replay_guard.sql"); +const MIGRATION_0011_SQL: &str = + include_str!("../../../migrations/0011_nip_rs_exact_tag_cardinality.sql"); +const MIGRATION_0012_SQL: &str = include_str!("../../../migrations/0012_draft_wrap_fts.sql"); async fn setup() -> (PgPool, String) { let url = std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string()); @@ -37,6 +44,31 @@ async fn setup() -> (PgPool, String) { .connect(&url) .await .expect("connect"); + + // Serialize the pgcrypto extension install across all parallel test workers. + // + // `CREATE EXTENSION IF NOT EXISTS pgcrypto` targets `pg_extension`, which is + // database-global with a unique index on `extname`. PostgreSQL's IF NOT EXISTS + // is a check-then-insert, not an atomic upsert: when many test workers run + // concurrently they all see the extension as absent, all attempt the insert, + // and all but one hit SQLSTATE 23505 (unique violation). A session-level + // advisory lock (arbitrary but stable key) serializes the install so that + // exactly one worker does the real CREATE; the rest treat it as a no-op when + // they enter the lock. The lock is released automatically when the session + // ends, so there is no risk of wedging subsequent runs. + sqlx::query("SELECT pg_advisory_lock(3723742987654321)") + .execute(&admin_pool) + .await + .expect("acquire pgcrypto advisory lock"); + sqlx::query("CREATE EXTENSION IF NOT EXISTS pgcrypto") + .execute(&admin_pool) + .await + .expect("install pgcrypto extension"); + sqlx::query("SELECT pg_advisory_unlock(3723742987654321)") + .execute(&admin_pool) + .await + .expect("release pgcrypto advisory lock"); + let create_sql = format!("CREATE SCHEMA \"{schema}\""); sqlx::query(sqlx::AssertSqlSafe(create_sql)) .execute(&admin_pool) @@ -77,6 +109,18 @@ async fn setup() -> (PgPool, String) { pool.execute(MIGRATION_0008_SQL) .await .expect("apply 0008 migration"); + pool.execute(MIGRATION_0009_SQL) + .await + .expect("apply 0009 migration"); + pool.execute(MIGRATION_0010_SQL) + .await + .expect("apply 0010 migration"); + pool.execute(MIGRATION_0011_SQL) + .await + .expect("apply 0011 migration"); + pool.execute(MIGRATION_0012_SQL) + .await + .expect("apply 0012 migration"); (pool, schema) } @@ -1087,11 +1131,12 @@ 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) +/// - 31234 = `KIND_DRAFT` (NIP-37: channel-bound author-private draft) /// - 44100 = `KIND_MEMBER_ADDED_NOTIFICATION` (p-gated membership notice) /// - 44101 = `KIND_MEMBER_REMOVED_NOTIFICATION` (p-gated membership notice) /// - 44200 = `KIND_AGENT_TURN_METRIC` (NIP-AM: p-gated encrypted turn metrics) /// -/// All seven events are inserted with the same unique token in their content +/// All eight 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 excluded kinds must not. /// @@ -1158,6 +1203,19 @@ async fn excluded_kinds_are_storage_level_unsearchable() { ) .await; + // kind:31234 draft wrap (NIP-37, AUTHOR_ONLY_KINDS) — MUST NOT be searchable. + insert_event( + &pool, + c, + rand_bytes32(), + rand_bytes32(), + KIND_DRAFT as i32, + &format!("draft wrap — {token}"), + None, + 1_700_000_004, + ) + .await; + // kind:44100 member-added notification — p-gated and MUST NOT be searchable. insert_event( &pool, @@ -1167,7 +1225,7 @@ async fn excluded_kinds_are_storage_level_unsearchable() { KIND_MEMBER_ADDED_NOTIFICATION as i32, &format!("member added — {token}"), None, - 1_700_000_004, + 1_700_000_005, ) .await; @@ -1180,7 +1238,7 @@ async fn excluded_kinds_are_storage_level_unsearchable() { KIND_MEMBER_REMOVED_NOTIFICATION as i32, &format!("member removed — {token}"), None, - 1_700_000_005, + 1_700_000_006, ) .await; @@ -1193,7 +1251,7 @@ async fn excluded_kinds_are_storage_level_unsearchable() { KIND_AGENT_TURN_METRIC as i32, &format!("agent turn metric — {token}"), None, - 1_700_000_006, + 1_700_000_007, ) .await; @@ -1227,6 +1285,7 @@ async fn excluded_kinds_are_storage_level_unsearchable() { 1059, 30300, 30622, + KIND_DRAFT as i32, KIND_MEMBER_ADDED_NOTIFICATION as i32, KIND_MEMBER_REMOVED_NOTIFICATION as i32, KIND_AGENT_TURN_METRIC as i32, @@ -1442,3 +1501,326 @@ async fn p_gated_persistent_kinds_have_storage_null_tsvector() { teardown(pool, &schema).await; } + +/// Set up a schema with the LEGACY blocklist expression (pre-#1771 state). +/// +/// Applies 0001–0007 (establishing the legacy negative-blocklist `search_tsv`), +/// inserts a dummy row so the `events` table is non-empty, then applies 0008 +/// (which skips on non-empty tables) and 0009–0011 (NIP-RS guards, no FTS +/// changes). The resulting schema has the legacy blocklist expression where +/// kind 31234 is NOT excluded — exactly the brownfield state that 0012's +/// rewrite branch must handle. +/// +/// Does NOT apply 0012 — the caller controls that step. +async fn setup_legacy_blocklist() -> (PgPool, String) { + let url = std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string()); + let schema = format!("fts_legacy_{}", Uuid::new_v4().simple()); + let admin_pool = PgPoolOptions::new() + .max_connections(1) + .connect(&url) + .await + .expect("connect"); + + sqlx::query("SELECT pg_advisory_lock(3723742987654321)") + .execute(&admin_pool) + .await + .expect("acquire pgcrypto advisory lock"); + sqlx::query("CREATE EXTENSION IF NOT EXISTS pgcrypto") + .execute(&admin_pool) + .await + .expect("install pgcrypto extension"); + sqlx::query("SELECT pg_advisory_unlock(3723742987654321)") + .execute(&admin_pool) + .await + .expect("release pgcrypto advisory lock"); + + let create_sql = format!("CREATE SCHEMA \"{schema}\""); + sqlx::query(sqlx::AssertSqlSafe(create_sql)) + .execute(&admin_pool) + .await + .expect("create schema"); + admin_pool.close().await; + + let url_with_search_path = format!("{url}?options=-c%20search_path%3D{schema}"); + let pool = PgPoolOptions::new() + .max_connections(2) + .connect(&url_with_search_path) + .await + .expect("connect with search_path"); + + // Apply 0001–0007: establishes the legacy negative-blocklist search_tsv. + pool.execute(MIGRATION_0001_SQL) + .await + .expect("apply 0001 migration"); + pool.execute(MIGRATION_0002_SQL) + .await + .expect("apply 0002 migration"); + pool.execute(MIGRATION_0003_SQL) + .await + .expect("apply 0003 migration"); + pool.execute(MIGRATION_0004_SQL) + .await + .expect("apply 0004 migration"); + pool.execute(MIGRATION_0005_SQL) + .await + .expect("apply 0005 migration"); + pool.execute(MIGRATION_0006_SQL) + .await + .expect("apply 0006 migration"); + pool.execute(MIGRATION_0007_SQL) + .await + .expect("apply 0007 migration"); + + // Insert a dummy community + event so the table is non-empty. + // 0008 checks `IF NOT EXISTS (SELECT 1 FROM events LIMIT 1)` and SKIPS + // on non-empty tables, preserving the legacy blocklist expression. + let dummy_community_id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host, signing_key) VALUES ($1, $2, $3)") + .bind(dummy_community_id) + .bind("legacy-seed.example") + .bind(b"signingkey".as_slice()) + .execute(&pool) + .await + .expect("insert seed community"); + sqlx::query( + "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig) \ + VALUES ($1, $2, $3, to_timestamp($4), $5, '[]'::jsonb, $6, $7)", + ) + .bind(dummy_community_id) + .bind(&rand_bytes32()[..]) + .bind(&rand_bytes32()[..]) + .bind(1_600_000_000_i64) + .bind(9_i32) + .bind("seed event for non-empty table") + .bind(b"signature".as_slice()) + .execute(&pool) + .await + .expect("insert seed event"); + + // Apply 0008: skips because table is non-empty → legacy blocklist preserved. + pool.execute(MIGRATION_0008_SQL) + .await + .expect("apply 0008 migration"); + // Apply 0009–0011: NIP-RS guards, no FTS changes. + pool.execute(MIGRATION_0009_SQL) + .await + .expect("apply 0009 migration"); + pool.execute(MIGRATION_0010_SQL) + .await + .expect("apply 0010 migration"); + pool.execute(MIGRATION_0011_SQL) + .await + .expect("apply 0011 migration"); + + (pool, schema) +} + +/// Behavioral test for migration 0012's legacy-blocklist rewrite branch. +/// +/// This is the operationally risky path: populated production databases still +/// on the pre-#1771 negative blocklist need 0012 to DROP + re-ADD `search_tsv` +/// with kind 31234 added to the exclusion list. The fresh-install (allowlist) +/// path is covered by the existing tests via `setup()`. +/// +/// The test constructs the legacy state, seeds control and draft rows, applies +/// 0012, and asserts: +/// 1. 0012 took the REWRITE branch (the live expression now contains 31234). +/// 2. The GIN index was rebuilt. +/// 3. The kind:9 control row remains searchable via `search_tsv`. +/// 4. The kind:31234 draft row's `search_tsv` IS NULL (storage-level unsearchable). +/// 5. The discriminator correctly classifies both forms: the post-rewrite +/// expression remains blocklist-shaped (not ELSE NULL). +/// +/// Bite-proof: temporarily commenting out the DROP+re-ADD in 0012's legacy +/// branch causes assertions 1 and 4 to fail (31234 not in exclusion list, +/// draft row's `search_tsv` is non-NULL). +#[tokio::test] +#[ignore = "requires Postgres"] +async fn migration_0012_rewrites_legacy_blocklist_to_exclude_drafts() { + let (pool, schema) = setup_legacy_blocklist().await; + + // Verify we have the legacy blocklist expression BEFORE applying 0012. + let pre_expr: String = sqlx::query_scalar( + "SELECT pg_get_expr(adbin, adrelid) FROM pg_attrdef \ + WHERE adrelid = 'events'::regclass \ + AND adnum = (SELECT attnum FROM pg_attribute \ + WHERE attrelid = 'events'::regclass AND attname = 'search_tsv')", + ) + .fetch_one(&pool) + .await + .expect("read pre-0012 search_tsv expression"); + + // Legacy blocklist: THEN NULL (not ELSE NULL) — 0012 must NOT see this as allowlist. + assert!( + !pre_expr.contains("ELSE NULL"), + "pre-0012 expression should be legacy blocklist (THEN NULL, not ELSE NULL), got: {pre_expr}" + ); + assert!( + !pre_expr.contains("31234"), + "pre-0012 expression should not already exclude 31234, got: {pre_expr}" + ); + + // Seed test rows: a kind:9 control and a kind:31234 draft. + let community_id: Uuid = + sqlx::query_scalar("SELECT id FROM communities WHERE host = 'legacy-seed.example'") + .fetch_one(&pool) + .await + .expect("fetch seed community"); + let community = CommunityId::from_uuid(community_id); + + let control_id = rand_bytes32(); + let draft_id = rand_bytes32(); + let token = "legacy_rewrite_marker_unique"; + + insert_event( + &pool, + community, + control_id, + rand_bytes32(), + 9, + &format!("public chat — {token}"), + None, + 1_700_000_000, + ) + .await; + insert_event( + &pool, + community, + draft_id, + rand_bytes32(), + KIND_DRAFT as i32, + &format!("draft wrap — {token}"), + None, + 1_700_000_001, + ) + .await; + + // Before 0012: the draft row IS searchable under the legacy blocklist + // (31234 is not in the exclusion list). + let pre_tsv: Option = + sqlx::query_scalar("SELECT search_tsv::text FROM events WHERE id = $1") + .bind(&draft_id[..]) + .fetch_one(&pool) + .await + .expect("read pre-0012 draft search_tsv"); + assert!( + pre_tsv.is_some(), + "before 0012, kind:31234 should have non-NULL search_tsv under legacy blocklist" + ); + + // Apply 0012: should take the rewrite branch. + pool.execute(MIGRATION_0012_SQL) + .await + .expect("apply 0012 migration"); + + // 1. Verify 0012 took the REWRITE branch: expression now contains 31234. + let post_expr: String = sqlx::query_scalar( + "SELECT pg_get_expr(adbin, adrelid) FROM pg_attrdef \ + WHERE adrelid = 'events'::regclass \ + AND adnum = (SELECT attnum FROM pg_attribute \ + WHERE attrelid = 'events'::regclass AND attname = 'search_tsv')", + ) + .fetch_one(&pool) + .await + .expect("read post-0012 search_tsv expression"); + assert!( + post_expr.contains("31234"), + "after 0012, legacy-rewrite expression must include 31234, got: {post_expr}" + ); + + // 2. Verify GIN index was rebuilt. + let index_exists: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'events' \ + AND indexname = 'idx_events_search_tsv')", + ) + .fetch_one(&pool) + .await + .expect("check GIN index existence"); + assert!( + index_exists, + "GIN index idx_events_search_tsv must exist after rewrite" + ); + + // 3. Control row (kind:9) remains searchable. + let svc = SearchService::new(pool.clone()); + let result = svc + .search(&SearchQuery { + community, + q: token.into(), + channel_scope: ChannelScope::Any, + kinds: None, + authors: None, + since: None, + until: None, + page: 1, + per_page: 10, + mode: buzz_search::SearchMode::FullText, + }) + .await + .expect("search ok"); + let kinds: Vec = result.hits.iter().map(|h| h.kind).collect(); + assert!( + kinds.contains(&9), + "kind:9 control row MUST remain searchable after legacy rewrite, got kinds={kinds:?}" + ); + + // 4. Draft row's search_tsv is now NULL (storage-level unsearchable). + let post_tsv: Option = + sqlx::query_scalar("SELECT search_tsv::text FROM events WHERE id = $1") + .bind(&draft_id[..]) + .fetch_one(&pool) + .await + .expect("read post-0012 draft search_tsv"); + assert!( + post_tsv.is_none(), + "after 0012, kind:31234 draft row's search_tsv MUST be NULL, got: {post_tsv:?}" + ); + assert!( + !kinds.contains(&(KIND_DRAFT as i32)), + "kind:31234 MUST NOT appear in search results after legacy rewrite, got kinds={kinds:?}" + ); + + // 5. Verify discriminator correctness: the post-rewrite expression is still + // blocklist-shaped (THEN NULL, not ELSE NULL) — 0012 extended it, didn't + // flip to allowlist. And the allowlist form (from a fresh setup) has ELSE NULL. + assert!( + !post_expr.contains("ELSE NULL"), + "post-rewrite expression should remain blocklist-shaped (not allowlist ELSE NULL), \ + got: {post_expr}" + ); + + teardown(pool, &schema).await; +} + +/// Companion to `migration_0012_rewrites_legacy_blocklist_to_exclude_drafts`: +/// verifies that 0012 correctly no-ops on the fresh-install allowlist form +/// and that the discriminator classifies it as allowlist (ELSE NULL shape). +#[tokio::test] +#[ignore = "requires Postgres"] +async fn migration_0012_noops_on_fresh_install_allowlist() { + let (pool, schema) = setup().await; + + let expr: String = sqlx::query_scalar( + "SELECT pg_get_expr(adbin, adrelid) FROM pg_attrdef \ + WHERE adrelid = 'events'::regclass \ + AND adnum = (SELECT attnum FROM pg_attribute \ + WHERE attrelid = 'events'::regclass AND attname = 'search_tsv')", + ) + .fetch_one(&pool) + .await + .expect("read search_tsv expression"); + + // The allowlist expression (from 0008) must have the ELSE NULL shape + // that 0012's discriminator detects. + assert!( + expr.contains("ELSE NULL"), + "fresh-install expression must contain ELSE NULL (allowlist shape), got: {expr}" + ); + // 31234 should NOT appear in the expression — it's excluded by omission. + assert!( + !expr.contains("31234"), + "fresh-install allowlist should not mention 31234, got: {expr}" + ); + + teardown(pool, &schema).await; +} diff --git a/crates/buzz-test-client/tests/e2e_nip37_draft.rs b/crates/buzz-test-client/tests/e2e_nip37_draft.rs new file mode 100644 index 0000000000..78adbbd1c0 --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_nip37_draft.rs @@ -0,0 +1,3779 @@ +//! End-to-end integration tests for NIP-37 draft wraps (kind:31234), +//! channel-bound contract. +//! +//! Every kind:31234 must carry exactly one `h` UUID binding it to a Buzz +//! channel (or DM). The relay enforces: +//! +//! - Structural: valid `d`/`k`/`h` tags, `p` forbidden +//! - Channel existence: `h` UUID must resolve to a live channel +//! - Membership: author must be a member of that channel +//! - Immutable binding: once written, the `h` tag is frozen per (author, d_tag) +//! - Author-only reads: REQ, WS COUNT, WS subscription, HTTP /query, /count +//! - FTS exclusion: search_tsv = NULL, never surfaces in NIP-50 results +//! - Workflow exclusion: draft events must not appear in workflow triggers +//! - NIP-11 advertisement: relay claims NIP-37 +//! +//! # Running +//! +//! ```text +//! RELAY_URL=ws://localhost:3000 cargo test -p buzz-test-client --test e2e_nip37_draft -- --ignored +//! ``` + +use std::time::Duration; + +use buzz_test_client::{BuzzTestClient, RelayMessage}; +use nostr::{EventBuilder, Filter, Keys, Kind, Tag, Timestamp}; +use reqwest::Client; +use serde_json::{json, Value}; + +const KIND_DRAFT: u16 = 31234; +const KIND_CREATE_CHANNEL: u16 = 9007; +const KIND_PUT_USER: u16 = 9000; +const KIND_REMOVE_USER: u16 = 9001; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn relay_http_url() -> String { + relay_url() + .replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() +} + +fn sub_id(name: &str) -> String { + format!("e2e-nip37-{name}-{}", uuid::Uuid::new_v4()) +} + +fn http_client() -> Client { + Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .expect("failed to build HTTP client") +} + +/// Minimal syntactically-plausible NIP-44 v2 payload. +/// base64(b"\x02" + b"\x00" * 98) — 132 chars, decoded 99 bytes, first byte 0x02. +fn fake_nip44_v2() -> String { + let mut s = String::from("Ag"); + s.push_str(&"A".repeat(130)); + s +} + +/// Create an open channel as `owner`; returns the channel UUID string. +async fn create_open_channel(owner: &Keys) -> String { + let client = http_client(); + let ch_id = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_CREATE_CHANNEL), "") + .tags([ + Tag::parse(["h", &ch_id]).unwrap(), + Tag::parse(["name", &format!("nip37-test-{ch_id}")]).unwrap(), + Tag::parse(["channel_type", "stream"]).unwrap(), + Tag::parse(["visibility", "open"]).unwrap(), + ]) + .sign_with_keys(owner) + .unwrap(); + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", &owner.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&event).unwrap()) + .send() + .await + .expect("create channel"); + let body: Value = resp.json().await.expect("parse channel response"); + assert!( + body["accepted"].as_bool().unwrap_or(false), + "channel creation not accepted: {body}" + ); + ch_id +} + +/// Create a private channel as `owner`; returns the channel UUID string. +async fn create_private_channel(owner: &Keys) -> String { + let client = http_client(); + let ch_id = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_CREATE_CHANNEL), "") + .tags([ + Tag::parse(["h", &ch_id]).unwrap(), + Tag::parse(["name", &format!("nip37-priv-{ch_id}")]).unwrap(), + Tag::parse(["channel_type", "stream"]).unwrap(), + Tag::parse(["visibility", "private"]).unwrap(), + ]) + .sign_with_keys(owner) + .unwrap(); + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", &owner.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&event).unwrap()) + .send() + .await + .expect("create private channel"); + let body: Value = resp.json().await.expect("parse channel response"); + assert!( + body["accepted"].as_bool().unwrap_or(false), + "private channel creation not accepted: {body}" + ); + ch_id +} + +/// Add `member` to a channel via kind:9000 submitted by `owner` over HTTP. +async fn add_member_http(client: &Client, owner: &Keys, channel_id: &str, member: &Keys) { + let event = EventBuilder::new(Kind::Custom(KIND_PUT_USER), "") + .tags([ + Tag::parse(["h", channel_id]).unwrap(), + Tag::parse(["p", &member.public_key().to_hex()]).unwrap(), + ]) + .sign_with_keys(owner) + .unwrap(); + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", &owner.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&event).unwrap()) + .send() + .await + .expect("add member"); + let body: Value = resp.json().await.expect("parse add-member response"); + assert!( + body["accepted"].as_bool().unwrap_or(false), + "add member not accepted: {body}" + ); +} + +/// Remove `member` from a channel via kind:9001 submitted by `owner` over HTTP. +async fn remove_member_http(client: &Client, owner: &Keys, channel_id: &str, member: &Keys) { + let event = EventBuilder::new(Kind::Custom(KIND_REMOVE_USER), "") + .tags([ + Tag::parse(["h", channel_id]).unwrap(), + Tag::parse(["p", &member.public_key().to_hex()]).unwrap(), + ]) + .sign_with_keys(owner) + .unwrap(); + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", &owner.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&event).unwrap()) + .send() + .await + .expect("remove member"); + let body: Value = resp.json().await.expect("parse remove-member response"); + assert!( + body["accepted"].as_bool().unwrap_or(false), + "remove member not accepted: {body}" + ); +} + +/// Submit an event via the HTTP bridge and return (accepted, message). +async fn submit_event_http(client: &Client, keys: &Keys, event: &nostr::Event) -> (bool, String) { + let pubkey_hex = keys.public_key().to_hex(); + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", &pubkey_hex) + .header("Content-Type", "application/json") + .body(serde_json::to_string(event).unwrap()) + .send() + .await + .expect("submit event"); + let status = resp.status().as_u16(); + let body: Value = resp.json().await.expect("parse response"); + if status == 200 { + let accepted = body["accepted"].as_bool().unwrap_or(false); + let message = body["message"].as_str().unwrap_or("").to_string(); + (accepted, message) + } else { + let message = body["error"].as_str().unwrap_or("").to_string(); + (false, message) + } +} + +/// Query events via HTTP bridge as `as_pubkey_hex`. Returns events array. +async fn query_events_http( + client: &Client, + as_pubkey_hex: &str, + filters: Vec, +) -> Vec { + let resp = client + .post(format!("{}/query", relay_http_url())) + .header("X-Pubkey", as_pubkey_hex) + .header("Content-Type", "application/json") + .json(&filters) + .send() + .await + .expect("query events"); + assert!( + resp.status().is_success(), + "query failed: {}", + resp.status() + ); + resp.json::>() + .await + .expect("parse query response") +} + +/// Build a valid kind:31234 draft wrap event bound to `channel_id`. +fn build_draft( + keys: &Keys, + d_tag: &str, + k_val: &str, + channel_id: &str, + content: &str, +) -> nostr::Event { + build_draft_at(keys, d_tag, k_val, channel_id, content, Timestamp::now()) +} + +/// Build a valid kind:31234 draft wrap event bound to `channel_id` at `ts`. +fn build_draft_at( + keys: &Keys, + d_tag: &str, + k_val: &str, + channel_id: &str, + content: &str, + ts: Timestamp, +) -> nostr::Event { + EventBuilder::new(Kind::Custom(KIND_DRAFT), content) + .tags([ + Tag::parse(["d", d_tag]).unwrap(), + Tag::parse(["k", k_val]).unwrap(), + Tag::parse(["h", channel_id]).unwrap(), + ]) + .custom_created_at(ts) + .sign_with_keys(keys) + .unwrap() +} + +/// Build a kind:31234 draft with a short-lived `expiration` tag. +/// +/// `secs_from_now` controls how far in the future the expiration is set. +/// Ingest requires `expiration` strictly in the future, so callers should use +/// a margin wide enough to survive CI scheduler pauses (~10s recommended). +fn build_expiring_draft( + keys: &Keys, + d_tag: &str, + channel_id: &str, + secs_from_now: u64, +) -> nostr::Event { + let exp_ts = Timestamp::now().as_secs() + secs_from_now; + EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) + .tags([ + Tag::parse(["d", d_tag]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", channel_id]).unwrap(), + Tag::parse(["expiration", &exp_ts.to_string()]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap() +} + +/// Build a blank-content tombstone (NIP-37 deletion) bound to `channel_id`. +fn build_tombstone( + keys: &Keys, + d_tag: &str, + k_val: &str, + channel_id: &str, + ts: Timestamp, +) -> nostr::Event { + build_draft_at(keys, d_tag, k_val, channel_id, "", ts) +} + +// ─── h-tag validation ───────────────────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_missing_h_tag() { + let client = http_client(); + let keys = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) + .tags([ + Tag::parse(["d", &d]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + // no h tag + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(!accepted, "missing h tag should be rejected"); + assert!( + msg.contains("h` tag") || msg.contains("channel-bound"), + "unexpected message: {msg}" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_duplicate_h_tag() { + let client = http_client(); + let keys = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) + .tags([ + Tag::parse(["d", &d]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch]).unwrap(), + Tag::parse(["h", &ch]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(!accepted, "duplicate h tag should be rejected"); + assert!( + msg.contains("h` tag") || msg.contains("channel-bound"), + "unexpected message: {msg}" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_non_uuid_h_tag() { + let client = http_client(); + let keys = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) + .tags([ + Tag::parse(["d", &d]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", "not-a-uuid"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(!accepted, "non-UUID h tag should be rejected"); + assert!( + msg.contains("UUID") || msg.contains("h` tag"), + "unexpected message: {msg}" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_nonexistent_channel_h_tag() { + // h tag is a syntactically valid UUID, but no channel exists for it. + let client = http_client(); + let keys = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + let nonexistent_ch = uuid::Uuid::new_v4().to_string(); + let event = build_draft(&keys, &d, "9", &nonexistent_ch, &fake_nip44_v2()); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(!accepted, "draft to nonexistent channel should be rejected"); + assert!( + msg.contains("channel") || msg.contains("not found") || msg.contains("member"), + "unexpected message: {msg}" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_non_member_author() { + // Channel exists but author is not a member. + let client = http_client(); + let owner = Keys::generate(); + let non_member = Keys::generate(); + + let ch_id = create_private_channel(&owner).await; + + let d = uuid::Uuid::new_v4().to_string(); + let event = build_draft(&non_member, &d, "9", &ch_id, &fake_nip44_v2()); + let (accepted, msg) = submit_event_http(&client, &non_member, &event).await; + assert!( + !accepted, + "non-member should be unable to post draft: {msg}" + ); + assert!( + msg.contains("member") || msg.contains("restricted"), + "unexpected message: {msg}" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_accepted_by_channel_member() { + // Channel owner is always a member — their draft must be accepted. + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + + let d = uuid::Uuid::new_v4().to_string(); + let event = build_draft(&owner, &d, "9", &ch_id, &fake_nip44_v2()); + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; + assert!(accepted, "owner draft must be accepted: {msg}"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_after_member_removed() { + // Member writes a draft; gets removed; attempts a replacement — must be rejected. + let client = http_client(); + let owner = Keys::generate(); + let member = Keys::generate(); + let ch_id = create_private_channel(&owner).await; + add_member_http(&client, &owner, &ch_id, &member).await; + + let d = uuid::Uuid::new_v4().to_string(); + let now = Timestamp::now().as_secs(); + let v1 = build_draft_at( + &member, + &d, + "9", + &ch_id, + &fake_nip44_v2(), + Timestamp::from(now - 1), + ); + let (ok1, msg1) = submit_event_http(&client, &member, &v1).await; + assert!(ok1, "member draft v1 must be accepted: {msg1}"); + + remove_member_http(&client, &owner, &ch_id, &member).await; + + let v2 = build_draft(&member, &d, "9", &ch_id, &fake_nip44_v2()); + let (accepted, msg) = submit_event_http(&client, &member, &v2).await; + assert!( + !accepted, + "removed member should not be able to update draft: {msg}" + ); + assert!( + msg.contains("member") || msg.contains("restricted"), + "unexpected message: {msg}" + ); +} + +// ─── Immutable channel binding ──────────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_channel_binding_is_immutable() { + // Once a draft is bound to channel A, updating it with h=B must be rejected. + let client = http_client(); + let owner = Keys::generate(); + let ch_a = create_open_channel(&owner).await; + let ch_b = create_open_channel(&owner).await; + + let d = uuid::Uuid::new_v4().to_string(); + let now = Timestamp::now().as_secs(); + let v1 = build_draft_at( + &owner, + &d, + "9", + &ch_a, + &fake_nip44_v2(), + Timestamp::from(now - 1), + ); + let (ok1, msg1) = submit_event_http(&client, &owner, &v1).await; + assert!(ok1, "initial draft to ch_a must be accepted: {msg1}"); + + // Attempt to update the same d to a different channel. + let v2 = build_draft(&owner, &d, "9", &ch_b, &fake_nip44_v2()); + let (accepted, msg) = submit_event_http(&client, &owner, &v2).await; + assert!( + !accepted, + "rebinding draft to a different channel must be rejected" + ); + assert!( + msg.contains("immutable") || msg.contains("channel"), + "unexpected message: {msg}" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_same_channel_replacement_accepted() { + // Updating a draft on the same channel must succeed (normal NIP-33 replacement). + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + + let d = uuid::Uuid::new_v4().to_string(); + let now = Timestamp::now().as_secs(); + let v1 = build_draft_at( + &owner, + &d, + "9", + &ch_id, + &fake_nip44_v2(), + Timestamp::from(now - 1), + ); + let (ok1, msg1) = submit_event_http(&client, &owner, &v1).await; + assert!(ok1, "v1 must be accepted: {msg1}"); + + let v2 = build_draft(&owner, &d, "9", &ch_id, &fake_nip44_v2()); + let v2_id = v2.id; + let (ok2, msg2) = submit_event_http(&client, &owner, &v2).await; + assert!(ok2, "v2 same-channel replacement must be accepted: {msg2}"); + + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(owner.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + let results = query_events_http(&client, &owner.public_key().to_hex(), vec![filter]).await; + assert_eq!(results.len(), 1, "replacement must leave exactly one head"); + assert_eq!( + results[0]["id"].as_str().unwrap(), + v2_id.to_hex(), + "v2 must be the current head" + ); +} + +// ─── Ingest validation (structural) ────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_accepted_with_ciphertext_content() { + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + let event = build_draft(&owner, &d, "9", &ch_id, &fake_nip44_v2()); + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; + assert!(accepted, "valid draft rejected: {msg}"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_accepted_blank_tombstone() { + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + let event = build_tombstone(&owner, &d, "9", &ch_id, Timestamp::now()); + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; + assert!(accepted, "blank tombstone rejected: {msg}"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_accepted_future_expiration() { + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) + .tags([ + Tag::parse(["d", &d]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + Tag::parse(["expiration", "4102444800"]).unwrap(), // year 2100 + ]) + .sign_with_keys(&owner) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; + assert!(accepted, "future expiration draft rejected: {msg}"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_missing_d_tag() { + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) + .tags([ + Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; + assert!(!accepted, "missing d tag should be rejected"); + assert!(msg.contains("d` tag"), "unexpected message: {msg}"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_empty_d_tag() { + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) + .tags([ + Tag::parse(["d", ""]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; + assert!(!accepted, "empty d tag should be rejected"); + assert!(msg.contains("d` tag"), "unexpected message: {msg}"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_oversized_d_tag() { + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + // D_TAG_MAX_LEN is 1024 bytes in buzz-db. Use 1025 'a' chars. + let d_tag = "a".repeat(1025); + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) + .tags([ + Tag::parse(["d", &d_tag]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; + assert!(!accepted, "oversized d tag should be rejected"); + assert!( + msg.contains("d` tag") || msg.contains("too long"), + "unexpected message: {msg}" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_duplicate_d_tag() { + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) + .tags([ + Tag::parse(["d", &d]).unwrap(), + Tag::parse(["d", &d]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; + assert!(!accepted, "duplicate d tag should be rejected"); + assert!(msg.contains("d` tag"), "unexpected message: {msg}"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_missing_k_tag() { + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) + .tags([ + Tag::parse(["d", &d]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; + assert!(!accepted, "missing k tag should be rejected"); + assert!(msg.contains("k` tag"), "unexpected message: {msg}"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_duplicate_k_tag() { + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) + .tags([ + Tag::parse(["d", &d]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; + assert!(!accepted, "duplicate k tag should be rejected"); + assert!(msg.contains("k` tag"), "unexpected message: {msg}"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_malformed_k_tag_non_decimal() { + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) + .tags([ + Tag::parse(["d", &d]).unwrap(), + Tag::parse(["k", "0x9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; + assert!(!accepted, "non-decimal k tag should be rejected"); + assert!( + msg.contains("canonical decimal"), + "unexpected message: {msg}" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_k_tag_leading_zero() { + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) + .tags([ + Tag::parse(["d", &d]).unwrap(), + Tag::parse(["k", "09"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; + assert!(!accepted, "k tag with leading zero should be rejected"); + assert!(msg.contains("leading zero"), "unexpected message: {msg}"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_k_tag_out_of_range() { + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) + .tags([ + Tag::parse(["d", &d]).unwrap(), + Tag::parse(["k", "65536"]).unwrap(), // u16::MAX + 1 + Tag::parse(["h", &ch_id]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; + assert!(!accepted, "k=65536 should be rejected (out of u16 range)"); + assert!(msg.contains("range"), "unexpected message: {msg}"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_p_tag() { + let client = http_client(); + let owner = Keys::generate(); + // Use a different pubkey for the `p` tag — EventBuilder silently strips + // `p` tags that match the signer's own key (NIP self-tagging rule), so + // testing with owner.public_key() would produce an event with NO `p` tag + // and the rejection would never be exercised. + let other = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) + .tags([ + Tag::parse(["d", &d]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + Tag::parse(["p", &other.public_key().to_hex()]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; + assert!(!accepted, "p tag on draft should be rejected"); + assert!(msg.contains("p` tag"), "unexpected message: {msg}"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_outer_e_tag() { + // Smoke test: outer `e` tag on a kind:31234 event must be rejected. + // Thread-reference context (reply-to, root) belongs inside the NIP-44 + // encrypted payload — plain outer `e` tags would expose the fact that + // this draft is a reply/edit of a specific event to any relay reader. + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + let target_id = "0".repeat(64); // 64-char hex string representing a nonexistent event id + let event = nostr::EventBuilder::new(nostr::Kind::Custom(KIND_DRAFT), fake_nip44_v2()) + .tags([ + nostr::Tag::parse(["d", &d]).unwrap(), + nostr::Tag::parse(["k", "9"]).unwrap(), + nostr::Tag::parse(["h", &ch_id]).unwrap(), + nostr::Tag::parse(["e", &target_id]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; + assert!(!accepted, "outer `e` tag on draft should be rejected"); + assert!( + msg.contains("e` tag") || msg.contains("e tag"), + "unexpected rejection message: {msg}" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_malformed_ciphertext() { + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), "not-a-ciphertext") + .tags([ + Tag::parse(["d", &d]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; + assert!(!accepted, "malformed ciphertext should be rejected"); + assert!( + msg.contains("base64") || msg.contains("NIP-44"), + "unexpected message: {msg}" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_expiration_in_past() { + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) + .tags([ + Tag::parse(["d", &d]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + Tag::parse(["expiration", "1000000000"]).unwrap(), // long past + ]) + .sign_with_keys(&owner) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; + assert!(!accepted, "past expiration should be rejected"); + assert!(msg.contains("expiration"), "unexpected message: {msg}"); +} + +// ─── NIP-01 replacement / tombstone ordering ───────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_replaced_by_newer_event() { + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + + let now = Timestamp::now().as_secs(); + let t0 = Timestamp::from(now - 2); + let t1 = Timestamp::from(now - 1); + + let v1 = build_draft_at(&owner, &d, "9", &ch_id, &fake_nip44_v2(), t0); + let v2 = build_draft_at(&owner, &d, "9", &ch_id, &fake_nip44_v2(), t1); + let v2_id = v2.id; + + let (ok1, msg1) = submit_event_http(&client, &owner, &v1).await; + assert!(ok1, "v1 must be accepted: {msg1}"); + let (ok2, msg2) = submit_event_http(&client, &owner, &v2).await; + assert!(ok2, "v2 must be accepted: {msg2}"); + + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(owner.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + let results = query_events_http(&client, &owner.public_key().to_hex(), vec![filter]).await; + assert_eq!(results.len(), 1, "should return exactly the latest draft"); + assert_eq!( + results[0]["id"].as_str().unwrap(), + v2_id.to_hex(), + "latest event must be the returned head" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_stale_write_cannot_supersede_current_head() { + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + + let now = Timestamp::now().as_secs(); + let t_old = Timestamp::from(now - 2); + let t_new = Timestamp::from(now - 1); + + let v_new = build_draft_at(&owner, &d, "9", &ch_id, &fake_nip44_v2(), t_new); + let v_old = build_draft_at(&owner, &d, "9", &ch_id, &fake_nip44_v2(), t_old); + + let (ok_n, msg_n) = submit_event_http(&client, &owner, &v_new).await; + assert!(ok_n, "newer draft must be accepted: {msg_n}"); + // The stale write must be accepted (relay returns `accepted: true` with + // `duplicate:` or silently deduplicated) but MUST NOT become the new head. + // The relay's stale-ordering protection keeps the newer event as head. + let (stale_accepted, _stale_msg) = submit_event_http(&client, &owner, &v_old).await; + assert!( + stale_accepted, + "stale write must be accepted (no-op), not hard-rejected; got: {_stale_msg}" + ); + + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(owner.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + let results = query_events_http(&client, &owner.public_key().to_hex(), vec![filter]).await; + assert_eq!(results.len(), 1, "should have exactly one head"); + assert_eq!( + results[0]["id"].as_str().unwrap(), + v_new.id.to_hex(), + "stale write must not replace current head" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_same_second_tie_break_lower_id_wins() { + // Two events at identical timestamps: NIP-01 tie-break retains the one + // with the lexically lower event ID, regardless of submission order. + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + + let ts = Timestamp::now(); + + // Sign candidates until we have at least two with distinct IDs. Add a + // per-candidate unknown tag so each signing call produces a unique event + // (different tag payload → different event hash → distinct IDs even if the + // Schnorr nonce were deterministic). + let mut candidates: Vec = Vec::new(); + for i in 0u32..20 { + let e = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) + .tags([ + Tag::parse(["d", &d]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + // Unique-per-candidate sentinel tag — forces distinct event hashes. + Tag::parse(["_tiebreak", &i.to_string()]).unwrap(), + ]) + .custom_created_at(ts) + .sign_with_keys(&owner) + .unwrap(); + candidates.push(e); + } + // Deduplicate by ID (should never trigger, but kept for safety). + candidates.dedup_by_key(|e| e.id.to_hex()); + // The unique _tiebreak tag guarantees distinct event hashes — this must + // always produce at least 2 distinct IDs. A silent return here would + // allow the test to pass without ever exercising the tie-break logic. + assert!( + candidates.len() >= 2, + "expected at least 2 distinct candidate IDs with unique _tiebreak tags; got {}", + candidates.len() + ); + candidates.sort_by_key(|a| a.id.to_hex()); + let lowest = candidates.first().unwrap().clone(); + let highest = candidates.last().unwrap().clone(); + + // Submit highest first, then lowest. + let (ok_h, msg_h) = submit_event_http(&client, &owner, &highest).await; + assert!(ok_h, "highest-id draft must be accepted: {msg_h}"); + let (ok_l, msg_l) = submit_event_http(&client, &owner, &lowest).await; + assert!(ok_l, "lowest-id draft must be accepted: {msg_l}"); + + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(owner.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + let results = query_events_http(&client, &owner.public_key().to_hex(), vec![filter]).await; + assert_eq!(results.len(), 1, "tie-break must leave exactly one head"); + assert_eq!( + results[0]["id"].as_str().unwrap(), + lowest.id.to_hex(), + "lower event ID must win same-second tie" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_tombstone_head_queryable_by_author() { + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + + let now = Timestamp::now().as_secs(); + let t_draft = Timestamp::from(now - 1); + let t_tomb = Timestamp::now(); + + let draft = build_draft_at(&owner, &d, "9", &ch_id, &fake_nip44_v2(), t_draft); + let tombstone = build_tombstone(&owner, &d, "9", &ch_id, t_tomb); + let tomb_id = tombstone.id; + + let (ok_d, msg_d) = submit_event_http(&client, &owner, &draft).await; + assert!(ok_d, "draft must be accepted: {msg_d}"); + let (ok_t, msg_t) = submit_event_http(&client, &owner, &tombstone).await; + assert!(ok_t, "tombstone must be accepted: {msg_t}"); + + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(owner.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + let results = query_events_http(&client, &owner.public_key().to_hex(), vec![filter]).await; + assert_eq!(results.len(), 1, "tombstone must be the queryable head"); + assert_eq!( + results[0]["id"].as_str().unwrap(), + tomb_id.to_hex(), + "tombstone is the current head" + ); + assert_eq!( + results[0]["content"].as_str().unwrap(), + "", + "tombstone content must be empty" + ); +} + +// ─── Author-only read gates ─────────────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_author_can_req_own_drafts_ws() { + let url = relay_url(); + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&owner, &d, "9", &ch_id, &fake_nip44_v2()); + let draft_id = draft.id; + let (ok, msg) = submit_event_http(&client, &owner, &draft).await; + assert!(ok, "draft must be accepted: {msg}"); + + let mut c = BuzzTestClient::connect(&url, &owner) + .await + .expect("connect author"); + let sid = sub_id("author-req"); + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(owner.public_key()); + c.subscribe(&sid, vec![filter]).await.expect("subscribe"); + let results = c + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + assert!( + results.iter().any(|e| e.id == draft_id), + "author must receive own draft" + ); + c.disconnect().await.expect("disconnect"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_attacker_cannot_req_victims_drafts_exclusive_ws() { + let url = relay_url(); + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2()); + let (ok, msg) = submit_event_http(&client, &victim, &draft).await; + assert!(ok, "victim draft must be accepted: {msg}"); + + let mut ac = BuzzTestClient::connect(&url, &attacker) + .await + .expect("connect attacker"); + let sid = sub_id("attacker-excl"); + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(victim.public_key()); + ac.subscribe(&sid, vec![filter]).await.expect("subscribe"); + + let relay_msg = ac + .recv_event(Duration::from_secs(5)) + .await + .expect("recv response"); + match relay_msg { + RelayMessage::Closed { + subscription_id, + message, + } => { + assert_eq!(subscription_id, sid); + assert!( + message.contains("restricted:") || message.contains("author-only"), + "expected restricted message, got: {message}" + ); + } + RelayMessage::Event { event, .. } => { + panic!( + "attacker received victim's draft via exclusive filter: event {}", + event.id + ); + } + other => panic!("expected CLOSED for exclusive draft filter, got: {other:?}"), + } + ac.disconnect().await.expect("disconnect"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_attacker_cannot_see_draft_in_mixed_kinds_filter_ws() { + // A filter with kinds=[0,31234] must return the victim's public profile (kind:0) + // but MUST NOT return their draft (kind:31234). + let url = relay_url(); + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; + let d = uuid::Uuid::new_v4().to_string(); + + // Victim publishes a public profile (kind:0). + let profile = EventBuilder::new(Kind::Metadata, "{}") + .sign_with_keys(&victim) + .unwrap(); + let profile_id = profile.id; + let (ok_p, msg_p) = submit_event_http(&client, &victim, &profile).await; + assert!(ok_p, "victim profile must be accepted: {msg_p}"); + + // Victim publishes a draft. + let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2()); + let draft_id = draft.id; + let (ok_d, msg_d) = submit_event_http(&client, &victim, &draft).await; + assert!(ok_d, "victim draft must be accepted: {msg_d}"); + + // Attacker subscribes with an explicit kinds=[0,31234] filter. + let mut ac = BuzzTestClient::connect(&url, &attacker) + .await + .expect("connect attacker"); + let sid = sub_id("mixed-kinds-0-31234"); + let filter = Filter::new() + .kinds(vec![Kind::Metadata, Kind::Custom(KIND_DRAFT)]) + .author(victim.public_key()); + ac.subscribe(&sid, vec![filter]).await.expect("subscribe"); + let results = ac + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert!( + results.iter().any(|e| e.id == profile_id), + "attacker must receive victim's public profile (positive control)" + ); + assert!( + !results.iter().any(|e| e.id == draft_id), + "kinds=[0,31234] filter must not expose victim's draft to attacker" + ); + ac.disconnect().await.expect("disconnect"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_attacker_cannot_see_draft_in_mixed_longform_kinds_filter_ws() { + // A filter with kinds=[30023,31234] must return the victim's long-form note + // but MUST NOT return their draft (kind:31234). + let url = relay_url(); + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; + let d_draft = uuid::Uuid::new_v4().to_string(); + + // Victim publishes a long-form note (kind:30023 — global replaceable, public). + let d_article = uuid::Uuid::new_v4().to_string(); + let article = EventBuilder::new(Kind::Custom(30023), "article content") + .tags([Tag::parse(["d", &d_article]).unwrap()]) + .sign_with_keys(&victim) + .unwrap(); + let article_id = article.id; + let (ok_a, msg_a) = submit_event_http(&client, &victim, &article).await; + assert!(ok_a, "victim article must be accepted: {msg_a}"); + + // Victim publishes a draft. + let draft = build_draft(&victim, &d_draft, "9", &ch_id, &fake_nip44_v2()); + let draft_id = draft.id; + let (ok_d, msg_d) = submit_event_http(&client, &victim, &draft).await; + assert!(ok_d, "victim draft must be accepted: {msg_d}"); + + // Attacker subscribes with kinds=[30023,31234]. + let mut ac = BuzzTestClient::connect(&url, &attacker) + .await + .expect("connect attacker"); + let sid = sub_id("mixed-kinds-30023-31234"); + let filter = Filter::new() + .kinds(vec![Kind::Custom(30023), Kind::Custom(KIND_DRAFT)]) + .author(victim.public_key()); + ac.subscribe(&sid, vec![filter]).await.expect("subscribe"); + let results = ac + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert!( + results.iter().any(|e| e.id == article_id), + "attacker must receive victim's public article (positive control)" + ); + assert!( + !results.iter().any(|e| e.id == draft_id), + "kinds=[30023,31234] filter must not expose victim's draft to attacker" + ); + ac.disconnect().await.expect("disconnect"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_attacker_cannot_retrieve_by_known_event_id_ws() { + let url = relay_url(); + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2()); + let draft_id = draft.id; + let (ok, msg) = submit_event_http(&client, &victim, &draft).await; + assert!(ok, "victim draft must be accepted: {msg}"); + + let mut ac = BuzzTestClient::connect(&url, &attacker) + .await + .expect("connect attacker"); + let sid = sub_id("attacker-ids"); + let filter = Filter::new().id(draft_id); + ac.subscribe(&sid, vec![filter]).await.expect("subscribe"); + let results = ac + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + assert!( + !results.iter().any(|e| e.id == draft_id), + "knowing a draft's event id must not expose it to another user" + ); + ac.disconnect().await.expect("disconnect"); +} + +// ─── known-#d privacy tripwires ─────────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_attacker_cannot_retrieve_by_known_d_tag_exclusive_ws() { + let url = relay_url(); + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2()); + let (ok, msg) = submit_event_http(&client, &victim, &draft).await; + assert!(ok, "victim draft must be accepted: {msg}"); + + let mut ac = BuzzTestClient::connect(&url, &attacker) + .await + .expect("connect attacker"); + let sid = sub_id("d-excl"); + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(victim.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + ac.subscribe(&sid, vec![filter]).await.expect("subscribe"); + + let relay_msg = ac + .recv_event(Duration::from_secs(5)) + .await + .expect("recv response"); + match relay_msg { + RelayMessage::Closed { + subscription_id, + message, + } => { + assert_eq!(subscription_id, sid); + assert!( + message.contains("restricted:") || message.contains("author-only"), + "expected restricted message for #d exclusive filter, got: {message}" + ); + } + RelayMessage::Event { event, .. } => { + panic!( + "attacker retrieved victim's draft via exclusive #d filter: event {}", + event.id + ); + } + other => panic!("expected CLOSED for #d exclusive filter, got: {other:?}"), + } + ac.disconnect().await.expect("disconnect"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_attacker_cannot_retrieve_draft_by_d_tag_in_mixed_kinds_ws() { + // An attacker who knows the victim's d-tag value submits + // kinds=[31234] + #d=[d_value] + author=[victim]. Must get CLOSED, not the event. + // + // Positive control: a public kind:30023 (long-form article) published under + // the SAME `d` must be returned by kinds=[30023,31234]+author+#d — proving + // the filter itself is not broken, only the draft is gated. + let url = relay_url(); + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; + // Use the same `d` value for both the draft AND the kind:30023 control. + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2()); + let draft_id = draft.id; + let (ok, msg) = submit_event_http(&client, &victim, &draft).await; + assert!(ok, "victim draft must be accepted: {msg}"); + + // Publish a public kind:30023 with the same d-tag — this is the positive + // control that proves kinds=[30023,31234]+#d is a live filter, not a no-op. + let article = EventBuilder::new(Kind::Custom(30023), "long-form article content") + .tags([Tag::parse(["d", &d]).unwrap()]) + .sign_with_keys(&victim) + .unwrap(); + let article_id = article.id; + let (ok_a, msg_a) = submit_event_http(&client, &victim, &article).await; + assert!(ok_a, "victim article must be accepted: {msg_a}"); + + let mut ac = BuzzTestClient::connect(&url, &attacker) + .await + .expect("connect attacker"); + let sid = sub_id("d-mixed-31234"); + // kinds=[31234] + #d=[d] is the sharpest possible known-address query. + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(victim.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + ac.subscribe(&sid, vec![filter]).await.expect("subscribe"); + + let relay_msg = ac + .recv_event(Duration::from_secs(5)) + .await + .expect("recv response"); + match relay_msg { + RelayMessage::Closed { + subscription_id, + message, + } => { + assert_eq!(subscription_id, sid); + assert!( + message.contains("restricted:") || message.contains("author-only"), + "expected restricted message for #d+kind:31234 filter, got: {message}" + ); + } + RelayMessage::Event { event, .. } => { + panic!( + "attacker retrieved victim's draft via #d+kind:31234 filter: event {}", + event.id + ); + } + other => panic!("expected CLOSED for #d+kind:31234 filter, got: {other:?}"), + } + + // Mixed-kinds positive control: kinds=[30023,31234] + author + #d=[same d]. + // The kind:30023 article must appear; the kind:31234 draft must NOT. + // Using explicit kinds avoids the p-gated wildcard guard. + let sid2 = sub_id("d-mixed-control"); + let filter2 = Filter::new() + .kinds(vec![Kind::Custom(30023), Kind::Custom(KIND_DRAFT)]) + .author(victim.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + ac.subscribe(&sid2, vec![filter2]) + .await + .expect("subscribe mixed kinds"); + let mixed_results = ac + .collect_until_eose(&sid2, Duration::from_secs(5)) + .await + .expect("collect mixed kinds"); + assert!( + mixed_results.iter().any(|e| e.id == article_id), + "kind:30023 article under same d must appear in [30023,31234]+#d filter (positive control)" + ); + assert!( + !mixed_results.iter().any(|e| e.id == draft_id), + "kind:31234 draft must not appear in [30023,31234]+#d filter for attacker" + ); + + ac.disconnect().await.expect("disconnect"); +} + +// ─── COUNT privacy gates ────────────────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_attacker_cannot_count_exclusive_ws() { + let url = relay_url(); + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2()); + let (ok, msg) = submit_event_http(&client, &victim, &draft).await; + assert!(ok, "victim draft must be accepted: {msg}"); + + let mut ac = BuzzTestClient::connect(&url, &attacker) + .await + .expect("connect attacker"); + let sid = sub_id("count-ws"); + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(victim.public_key()); + ac.send_raw(&json!(["COUNT", sid, filter])) + .await + .expect("send COUNT"); + + let relay_msg = ac + .recv_event(Duration::from_secs(5)) + .await + .expect("recv response"); + match relay_msg { + RelayMessage::Closed { + subscription_id, + message, + } => { + assert_eq!(subscription_id, sid); + assert!( + message.contains("restricted:") || message.contains("author-only"), + "expected restricted message for COUNT on another author's drafts, got: {message}" + ); + } + other => { + panic!("expected CLOSED for WS COUNT on another author's drafts, got: {other:?}") + } + } + ac.disconnect().await.expect("disconnect"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_attacker_cannot_count_via_known_d_ws() { + let url = relay_url(); + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2()); + let (ok, msg) = submit_event_http(&client, &victim, &draft).await; + assert!(ok, "victim draft must be accepted: {msg}"); + + let mut ac = BuzzTestClient::connect(&url, &attacker) + .await + .expect("connect attacker"); + let sid = sub_id("count-ws-d"); + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(victim.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + ac.send_raw(&json!(["COUNT", sid, filter])) + .await + .expect("send COUNT"); + + let relay_msg = ac + .recv_event(Duration::from_secs(5)) + .await + .expect("recv response"); + match relay_msg { + RelayMessage::Closed { message, .. } => { + assert!( + message.contains("restricted:") || message.contains("author-only"), + "expected restricted for #d COUNT, got: {message}" + ); + } + other => panic!("expected CLOSED for #d COUNT, got: {other:?}"), + } + ac.disconnect().await.expect("disconnect"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_attacker_cannot_count_exclusive_http() { + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2()); + let (ok, msg) = submit_event_http(&client, &victim, &draft).await; + assert!(ok, "victim draft must be accepted: {msg}"); + + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(victim.public_key()); + let resp = client + .post(format!("{}/count", relay_http_url())) + .header("X-Pubkey", &attacker.public_key().to_hex()) + .header("Content-Type", "application/json") + .json(&vec![filter]) + .send() + .await + .expect("count request"); + assert_eq!( + resp.status().as_u16(), + 403, + "HTTP exclusive COUNT for another author's drafts must return 403" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_author_can_count_own_drafts_http() { + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&owner, &d, "9", &ch_id, &fake_nip44_v2()); + let (ok, msg) = submit_event_http(&client, &owner, &draft).await; + assert!(ok, "draft must be accepted: {msg}"); + + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(owner.public_key()); + let resp = client + .post(format!("{}/count", relay_http_url())) + .header("X-Pubkey", &owner.public_key().to_hex()) + .header("Content-Type", "application/json") + .json(&vec![filter]) + .send() + .await + .expect("count request"); + assert!( + resp.status().is_success(), + "author's own count must succeed, got: {}", + resp.status() + ); + let body: Value = resp.json().await.expect("parse count response"); + let count = body["count"].as_u64().unwrap_or(0); + assert!(count >= 1, "author must count at least 1 own draft"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_attacker_mixed_kinds_count_excludes_drafts() { + // A mixed-kinds COUNT filter (e.g. kinds=[9,31234]) from a non-author must + // count only the public events — the kind:31234 draft must not be included. + // This exercises the per-event fallback path in handle_count where the + // reader_can_receive_event gate runs for each row in a mixed-kinds result. + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; + let d = uuid::Uuid::new_v4().to_string(); + + // Victim submits one kind:9 channel message and one kind:31234 draft. + let msg = EventBuilder::new(Kind::Custom(9), "hello") + .tags([Tag::parse(["h", &ch_id]).unwrap()]) + .sign_with_keys(&victim) + .unwrap(); + let (ok_m, err_m) = submit_event_http(&client, &victim, &msg).await; + assert!(ok_m, "kind:9 message must be accepted: {err_m}"); + + let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2()); + let (ok_d, err_d) = submit_event_http(&client, &victim, &draft).await; + assert!(ok_d, "draft must be accepted: {err_d}"); + + // Attacker COUNTs with a mixed kinds=[9,31234] filter via HTTP /count. + // Should be allowed (kindless-mixed filter is not exclusively author-only) + // but the kind:31234 must be excluded from the count. + let url = relay_url(); + let filter = serde_json::json!({ + "kinds": [9, KIND_DRAFT], + "authors": [victim.public_key().to_hex()], + }); + let resp = client + .post(format!( + "{}/count", + url.replace("ws://", "http://") + .replace("wss://", "https://") + )) + .header("X-Pubkey", &attacker.public_key().to_hex()) + .header("Content-Type", "application/json") + .json(&vec![filter]) + .send() + .await + .expect("count request"); + assert!( + resp.status().is_success(), + "mixed-kinds /count must succeed for attacker, got: {}", + resp.status() + ); + let body: Value = resp.json().await.expect("parse count response"); + let count = body["count"].as_u64().unwrap_or(0); + // The attacker sees at most 1 (the kind:9 message). The kind:31234 draft + // must NOT be included. + assert!( + count <= 1, + "mixed-kinds count for non-author must not include kind:31234 drafts; got count={count}" + ); +} + +// ─── HTTP /query exclusive-author privacy ──────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_attacker_cannot_query_exclusive_http() { + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2()); + let (ok, msg) = submit_event_http(&client, &victim, &draft).await; + assert!(ok, "victim draft must be accepted: {msg}"); + + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(victim.public_key()); + let resp = client + .post(format!("{}/query", relay_http_url())) + .header("X-Pubkey", &attacker.public_key().to_hex()) + .header("Content-Type", "application/json") + .json(&vec![filter]) + .send() + .await + .expect("query request"); + assert_eq!( + resp.status().as_u16(), + 403, + "exclusive other-author HTTP /query for kind:31234 must return 403" + ); +} + +// ─── Live fan-out privacy ───────────────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_live_fanout_only_reaches_author() { + let url = relay_url(); + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; + let d = uuid::Uuid::new_v4().to_string(); + + let mut ac = BuzzTestClient::connect(&url, &attacker) + .await + .expect("connect attacker"); + let sid_fanout = sub_id("fanout-attacker"); + let filter = Filter::new() + .kinds(vec![Kind::Metadata, Kind::Custom(KIND_DRAFT)]) + .author(victim.public_key()) + .limit(0); + ac.subscribe(&sid_fanout, vec![filter]) + .await + .expect("subscribe to mixed filter"); + let _ = ac + .collect_until_eose(&sid_fanout, Duration::from_secs(3)) + .await; + + let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2()); + let draft_id = draft.id; + let (ok_d, msg_d) = submit_event_http(&client, &victim, &draft).await; + assert!(ok_d, "draft must be accepted: {msg_d}"); + + let profile = EventBuilder::new(Kind::Metadata, "{}") + .sign_with_keys(&victim) + .unwrap(); + let profile_id = profile.id; + let (ok_p, msg_p) = submit_event_http(&client, &victim, &profile).await; + assert!(ok_p, "profile must be accepted: {msg_p}"); + + let mut received_draft = false; + let mut received_profile = false; + let deadline = tokio::time::Instant::now() + Duration::from_secs(3); + loop { + let remaining = deadline + .checked_duration_since(tokio::time::Instant::now()) + .unwrap_or(Duration::ZERO); + if remaining.is_zero() { + break; + } + match ac.recv_event(remaining).await { + Ok(RelayMessage::Event { event, .. }) => { + if event.id == draft_id { + received_draft = true; + } + if event.id == profile_id { + received_profile = true; + } + } + _ => break, + } + } + + assert!( + !received_draft, + "attacker must NOT receive victim's draft via live fan-out" + ); + assert!( + received_profile, + "attacker MUST receive victim's public profile (positive control)" + ); + ac.disconnect().await.expect("disconnect"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_live_fanout_reaches_author_own_subscription() { + // Positive-control companion to test_draft_live_fanout_only_reaches_author: + // the AUTHOR themselves MUST receive their own draft via live fan-out when + // they have an active subscription for it. This proves the author-only + // gate is a filter (not a kill-switch) and doesn't silently break delivery + // to the author. + let url = relay_url(); + let client = http_client(); + let author = Keys::generate(); + let ch_id = create_open_channel(&author).await; + let d = uuid::Uuid::new_v4().to_string(); + + // Author opens a subscription with limit:0 (skip historical, live only). + // The subscription MUST be channel-scoped (#h ch_id): drafts are channel-bound + // events and fan_out_scoped's symmetric scoping invariant means a global + // (no #h) sub never sees channel-scoped events, regardless of the author-only + // gate. + let mut ac = BuzzTestClient::connect(&url, &author) + .await + .expect("connect author"); + let sid = sub_id("fanout-author-self"); + let filter = Filter::new() + .kind(Kind::Custom(KIND_DRAFT)) + .author(author.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::H), + ch_id.as_str(), + ) + .limit(0); + ac.subscribe(&sid, vec![filter]) + .await + .expect("subscribe author draft filter"); + let _ = ac.collect_until_eose(&sid, Duration::from_secs(3)).await; + + // Author submits their own draft. + let draft = build_draft(&author, &d, "9", &ch_id, &fake_nip44_v2()); + let draft_id = draft.id; + let (ok, msg) = submit_event_http(&client, &author, &draft).await; + assert!(ok, "author's draft must be accepted: {msg}"); + + // Author's own subscription must deliver the draft via fan-out. + let received = ac + .recv_event(Duration::from_secs(5)) + .await + .expect("author must receive their own draft via fan-out"); + let got_draft = matches!(&received, RelayMessage::Event { event, .. } if event.id == draft_id); + assert!( + got_draft, + "author's own subscription must receive their draft via live fan-out; got: {received:?}" + ); + ac.disconnect().await.expect("disconnect"); +} + +// ─── Nonexistent / alien channel rejection ──────────────────────────────────── + +// NOTE: test_draft_rejected_nonexistent_channel_h_tag (at the top of this file) +// already covers this: a draft with a valid-UUID h-tag pointing to no live +// channel is rejected. That test is the single authoritative nonexistent-channel +// guard. True cross-community tenant confinement is covered at the DB layer by +// `draft_is_confined_to_its_community` (requires Postgres, wired to CI). + +// ─── Kindless channel query — draft privacy ─────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_not_returned_in_kindless_channel_query_by_attacker() { + // A kindless channel h-tag filter submitted by a non-author must never + // return the author's draft. The attacker has channel membership (the + // channel is open) and subscribes to all events in the channel — they + // must receive the owner's public messages but not their drafts. + let url = relay_url(); + let client = http_client(); + let owner = Keys::generate(); + let attacker = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&owner, &d, "9", &ch_id, &fake_nip44_v2()); + let draft_id = draft.id; + let (ok, msg) = submit_event_http(&client, &owner, &draft).await; + assert!(ok, "draft must be accepted: {msg}"); + + // Also publish a public channel message as a positive control. + let msg_event = EventBuilder::new(Kind::Custom(9), "hello channel") + .tags([Tag::parse(["h", &ch_id]).unwrap()]) + .sign_with_keys(&owner) + .unwrap(); + let msg_id = msg_event.id; + let (ok_m, msg_m) = submit_event_http(&client, &owner, &msg_event).await; + assert!(ok_m, "channel message must be accepted: {msg_m}"); + + // Attacker queries by channel h-tag, no kind filter. + let mut c = BuzzTestClient::connect(&url, &attacker) + .await + .expect("connect attacker"); + let sid = sub_id("ch-kindless-attacker"); + let filter = Filter::new().custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::H), + ch_id.as_str(), + ); + c.subscribe(&sid, vec![filter]).await.expect("subscribe"); + let results = c + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + // Channel message must appear (positive control — attacker can see public messages). + assert!( + results.iter().any(|e| e.id == msg_id), + "channel message must appear in attacker's h-tag query (positive control)" + ); + // Draft must be absent — author-only gate must strip it before delivery. + assert!( + !results.iter().any(|e| e.id == draft_id), + "draft must not be returned by a kindless channel h-tag filter to a non-author" + ); + c.disconnect().await.expect("disconnect"); +} + +// ─── FTS / NIP-50 exclusion ─────────────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_not_returned_in_kindless_channel_http_query() { + // HTTP bridge /query tests for draft privacy. The bridge runs sensitive-kind + // gates unconditionally, so a kindless h-tag filter (wildcard) triggers + // p_gated_filters_authorized and returns 403 for any caller that is not the + // p-tagged owner. This is fail-closed, pre-existing main behavior — NOT a + // production defect. + // + // Two sub-cases: + // (i) Kindless h-tag /query as non-author → 403. The fail-closed + // outcome IS the oracle; the route simply does not exist over HTTP. + // (ii) Mixed kinds:[9, KIND_DRAFT] h-tag /query as attacker (passes all + // three HTTP gates — neither kind is p-gated, not exclusively + // author-only). Kind:9 message must appear; kind:31234 draft must + // be absent, proving reader_can_receive_event is live on the bridge + // per-event path. + let client = http_client(); + let owner = Keys::generate(); + let attacker = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&owner, &d, "9", &ch_id, &fake_nip44_v2()); + let draft_id = draft.id; + let (ok, msg) = submit_event_http(&client, &owner, &draft).await; + assert!(ok, "draft must be accepted: {msg}"); + + // Publish a public channel message for use as a positive control. + let msg_event = EventBuilder::new(Kind::Custom(9), "hello http channel") + .tags([Tag::parse(["h", &ch_id]).unwrap()]) + .sign_with_keys(&owner) + .unwrap(); + let msg_id = msg_event.id; + let (ok_m, msg_m) = submit_event_http(&client, &owner, &msg_event).await; + assert!(ok_m, "channel message must be accepted: {msg_m}"); + + // (i) Kindless h-tag /query as non-author must return 403 (fail-closed). + let kindless_filter = Filter::new().custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::H), + ch_id.as_str(), + ); + let kindless_resp = client + .post(format!("{}/query", relay_http_url())) + .header("X-Pubkey", &attacker.public_key().to_hex()) + .header("Content-Type", "application/json") + .json(&vec![kindless_filter]) + .send() + .await + .expect("kindless query request"); + assert_eq!( + kindless_resp.status().as_u16(), + 403, + "kindless h-tag /query as non-author must be rejected with 403 (bridge fail-closed)" + ); + + // (ii) Mixed kinds:[9, KIND_DRAFT] h-tag /query as attacker. + // Both kinds pass the p-gated and exclusively-author-only HTTP gates, + // so the query reaches the per-event reader_can_receive_event guard. + let mixed_filter = Filter::new() + .kinds([Kind::Custom(9), Kind::Custom(KIND_DRAFT)]) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::H), + ch_id.as_str(), + ); + let results = + query_events_http(&client, &attacker.public_key().to_hex(), vec![mixed_filter]).await; + + // Kind:9 message must appear — proves the filter reached the data path. + assert!( + results + .iter() + .any(|e| e["id"].as_str() == Some(&msg_id.to_hex())), + "kind:9 channel message must appear in mixed-kinds HTTP query (positive control)" + ); + // Draft must be absent — reader_can_receive_event must strip it for non-author. + assert!( + !results + .iter() + .any(|e| e["id"].as_str() == Some(&draft_id.to_hex())), + "kind:31234 draft must not appear in mixed-kinds HTTP query for non-author" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_not_indexed_in_fts_search() { + // Proves the search surface returns no drafts: the HTTP /query search path + // is exercised with a mixed kinds=[1,31234] filter, and the kind:31234 is + // absent from results. + // + // This test CANNOT prove the NULL-tsvector storage guarantee by itself: + // draft content is a NIP-44 ciphertext, so the search token could never + // appear in stored content regardless of the tsvector column. The + // storage-layer guarantee (search_tsv = NULL for all kind:31234 rows) is + // owned by `crates/buzz-search/tests/fts_integration.rs`. This e2e test + // only proves the search read-path returns no draft rows — a necessary + // complement that exercises the HTTP bridge search branch end-to-end. + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; + let d = uuid::Uuid::new_v4().to_string(); + + // Unique word token — used as kind:1 content (FTS-indexed) and as the + // search query for both kinds. + let token = format!("nip37probe{}", uuid::Uuid::new_v4().simple()); + + // Kind:1 control note — MUST appear in FTS results. + let note = EventBuilder::new(Kind::TextNote, &token) + .sign_with_keys(&victim) + .unwrap(); + let note_id = note.id; + let (ok_note, msg_note) = submit_event_http(&client, &victim, ¬e).await; + assert!(ok_note, "control note must be accepted: {msg_note}"); + + // Kind:31234 draft — NIP-44 v2 content (relay validates). The draft + // content is a ciphertext and cannot contain the plaintext token; the + // search_tsv is also NULL at the storage layer (fts_integration.rs owns + // that guarantee). Either property would prevent this draft from surfacing. + let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2()); + let draft_id = draft.id; + let (ok_d, msg_d) = submit_event_http(&client, &victim, &draft).await; + assert!(ok_d, "draft must be accepted: {msg_d}"); + + // Search as the author with explicit kinds=[1,31234]. The kind:1 note IS + // found (positive control); the kind:31234 draft is absent. + let search_filter = Filter::new() + .kinds(vec![Kind::TextNote, Kind::Custom(KIND_DRAFT)]) + .search(&token) + .limit(50); + let results = + query_events_http(&client, &victim.public_key().to_hex(), vec![search_filter]).await; + + assert!( + results + .iter() + .any(|e| e["id"].as_str() == Some(¬e_id.to_hex())), + "FTS must index the control kind:1 note (positive control)" + ); + assert!( + !results + .iter() + .any(|e| e["id"].as_str() == Some(&draft_id.to_hex())), + "kind:31234 must have NULL search_tsv — draft must not appear in NIP-50 search" + ); + + // Attacker-side check: search with kinds=[1,31234] as attacker. + let attacker_filter = Filter::new() + .kinds(vec![Kind::TextNote, Kind::Custom(KIND_DRAFT)]) + .search(&token) + .limit(50); + let attacker_results = query_events_http( + &client, + &attacker.public_key().to_hex(), + vec![attacker_filter], + ) + .await; + assert!( + !attacker_results + .iter() + .any(|e| e["id"].as_str() == Some(&draft_id.to_hex())), + "draft must not appear in attacker's NIP-50 search either" + ); +} + +// ─── NIP-11 advertisement ───────────────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_nip11_advertises_nip37_not_nip40() { + let client = http_client(); + let resp = client + .get(relay_http_url()) + .header("Accept", "application/nostr+json") + .send() + .await + .expect("NIP-11 request"); + assert!(resp.status().is_success()); + let info: Value = resp.json().await.expect("parse NIP-11 response"); + let nips = info["supported_nips"] + .as_array() + .expect("supported_nips must be an array"); + let nip_numbers: Vec = nips.iter().filter_map(|v| v.as_u64()).collect(); + assert!( + nip_numbers.contains(&37), + "NIP-11 must advertise NIP-37 (draft wraps); got {nip_numbers:?}" + ); + assert!( + !nip_numbers.contains(&40), + "NIP-11 must NOT advertise NIP-40 (expiry suppression not implemented); got {nip_numbers:?}" + ); +} + +// ─── NIP-09 deletion guard (a-tag and e-tag) ────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_nip09_a_tag_deletion_of_draft_is_rejected() { + // kind:5 with a single `a` tag targeting `31234::` must be + // rejected at ingest. The relay must never let a kind:5 event act as an + // escape hatch to clear a draft's immutable channel binding. + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + + // First publish a draft so there is something to attempt to delete. + let draft = build_draft(&owner, &d, "9", &ch_id, &fake_nip44_v2()); + let (ok_d, msg_d) = submit_event_http(&client, &owner, &draft).await; + assert!( + ok_d, + "draft must be accepted before the deletion attempt: {msg_d}" + ); + + // Build kind:5 with a single a-tag targeting the draft's NIP-33 address. + let a_coord = format!("31234:{}:{}", owner.public_key().to_hex(), d); + let deletion = EventBuilder::new(Kind::EventDeletion, "") + .tags([Tag::parse(["a", &a_coord]).unwrap()]) + .sign_with_keys(&owner) + .unwrap(); + + let (accepted, msg) = submit_event_http(&client, &owner, &deletion).await; + assert!( + !accepted, + "kind:5 a-tag deletion targeting kind:31234 must be rejected; relay said: {msg}" + ); + assert!( + msg.contains("31234") + || msg.contains("draft") + || msg.contains("not supported") + || msg.contains("invalid"), + "rejection message must explain why; got: {msg}" + ); + + // The draft must still exist as a live head — the rejected kind:5 must not + // have modified anything. + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(owner.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + let results = query_events_http(&client, &owner.public_key().to_hex(), vec![filter]).await; + assert_eq!( + results.len(), + 1, + "draft must still have one live head after rejected kind:5 deletion" + ); + assert_eq!( + results[0]["id"].as_str().unwrap(), + draft.id.to_hex(), + "live head must still be the original draft — kind:5 must not have altered it" + ); +} + +#[tokio::test] +#[ignore] +async fn test_nip09_e_tag_deletion_of_draft_is_rejected_and_binding_holds() { + // Full bypass sequence proving the e-tag deletion path cannot circumvent + // the immutable channel binding. + // + // 1. Publish draft at address `d` on channel h=A (timestamp = base). + // 2. Submit kind:5 with a single `e` tag pointing at the draft event id. + // The relay must reject this pre-storage. + // 3. Query as the author — the original draft head must still be live. + // 4. Attempt to rebind the same `d` address to h=B (timestamp = base+1, + // guaranteed newer) — must be rejected because the ch_a binding is intact. + let client = http_client(); + let owner = Keys::generate(); + + // Two distinct channels — owner is a member of both. + let ch_a = create_open_channel(&owner).await; + let ch_b = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + let base = Timestamp::now().as_secs(); + + // Step 1: publish draft bound to ch_a. + let draft = build_draft_at( + &owner, + &d, + "9", + &ch_a, + &fake_nip44_v2(), + Timestamp::from(base), + ); + let (ok_d, msg_d) = submit_event_http(&client, &owner, &draft).await; + assert!( + ok_d, + "draft must be accepted before the deletion attempt: {msg_d}" + ); + + // Step 2: submit kind:5 with e-tag pointing at the draft event id. + let deletion = EventBuilder::new(Kind::EventDeletion, "") + .tags([Tag::parse(["e", &draft.id.to_hex()]).unwrap()]) + .sign_with_keys(&owner) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &owner, &deletion).await; + assert!( + !accepted, + "kind:5 e-tag deletion targeting kind:31234 must be rejected; relay said: {msg}" + ); + assert!( + msg.contains("31234") + || msg.contains("draft") + || msg.contains("not supported") + || msg.contains("invalid"), + "rejection message must explain why; got: {msg}" + ); + + // Step 3: author queries by address — draft head must still be live. + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(owner.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + let results = query_events_http(&client, &owner.public_key().to_hex(), vec![filter]).await; + assert_eq!( + results.len(), + 1, + "draft must still have one live head after rejected kind:5 e-tag deletion" + ); + assert_eq!( + results[0]["id"].as_str().unwrap(), + draft.id.to_hex(), + "live head must be the original draft — e-tag kind:5 must not have altered it" + ); + + // Step 4: attempt rebind to ch_b with a strictly newer timestamp — must be + // rejected because the ch_a binding is intact (the e-tag delete did not + // erase the head row). + let rebind = build_draft_at( + &owner, + &d, + "9", + &ch_b, + &fake_nip44_v2(), + Timestamp::from(base + 1), + ); + let (rebind_accepted, rebind_msg) = submit_event_http(&client, &owner, &rebind).await; + assert!( + !rebind_accepted, + "rebind to ch_b must be rejected; relay said: {rebind_msg}" + ); + assert!( + rebind_msg.contains("channel") + || rebind_msg.contains("mismatch") + || rebind_msg.contains("immutable") + || rebind_msg.contains("invalid"), + "rebind rejection must name the binding invariant; got: {rebind_msg}" + ); +} + +#[tokio::test] +#[ignore] +/// kind:9005 deletion of a kind:31234 draft must be rejected pre-storage and +/// the immutable channel binding must remain intact. +/// +/// 1. Publish draft at address `d` on channel h=A (timestamp = base). +/// 2. Draft author submits kind:9005 with e= and h=A — must +/// be rejected pre-storage (tombstone-guidance error). +/// 3. Query as the author — the original draft head must still be live. +/// 4. Attempt to rebind the same `d` address to h=B (timestamp = base+1) — +/// must be rejected because the ch_a binding is intact. +async fn test_nip09_kind9005_deletion_of_draft_is_rejected_and_binding_holds() { + let client = http_client(); + let owner = Keys::generate(); + + // Two distinct channels — owner is a member of both. + let ch_a = create_open_channel(&owner).await; + let ch_b = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + let base = Timestamp::now().as_secs(); + + // Step 1: publish draft bound to ch_a. + let draft = build_draft_at( + &owner, + &d, + "9", + &ch_a, + &fake_nip44_v2(), + Timestamp::from(base), + ); + let (ok_d, msg_d) = submit_event_http(&client, &owner, &draft).await; + assert!( + ok_d, + "draft must be accepted before the deletion attempt: {msg_d}" + ); + + // Step 2: author submits kind:9005 targeting the draft event id. + let del_9005 = EventBuilder::new(Kind::Custom(9005), "") + .tags([ + Tag::parse(["e", &draft.id.to_hex()]).unwrap(), + Tag::parse(["h", &ch_a]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &owner, &del_9005).await; + assert!( + !accepted, + "kind:9005 deletion targeting kind:31234 must be rejected; relay said: {msg}" + ); + assert!( + msg.contains("31234") + || msg.contains("draft") + || msg.contains("not supported") + || msg.contains("invalid"), + "rejection message must explain why; got: {msg}" + ); + + // Step 3: author queries by address — draft head must still be live. + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(owner.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + let results = query_events_http(&client, &owner.public_key().to_hex(), vec![filter]).await; + assert_eq!( + results.len(), + 1, + "draft must still have one live head after rejected kind:9005 deletion" + ); + assert_eq!( + results[0]["id"].as_str().unwrap(), + draft.id.to_hex(), + "live head must be the original draft — kind:9005 must not have altered it" + ); + + // Step 4: attempt rebind to ch_b with a strictly newer timestamp — must be + // rejected because the ch_a binding is intact. + let rebind = build_draft_at( + &owner, + &d, + "9", + &ch_b, + &fake_nip44_v2(), + Timestamp::from(base + 1), + ); + let (rebind_accepted, rebind_msg) = submit_event_http(&client, &owner, &rebind).await; + assert!( + !rebind_accepted, + "rebind to ch_b must be rejected; relay said: {rebind_msg}" + ); + assert!( + rebind_msg.contains("channel") + || rebind_msg.contains("mismatch") + || rebind_msg.contains("immutable") + || rebind_msg.contains("invalid"), + "rebind rejection must name the binding invariant; got: {rebind_msg}" + ); +} + +#[tokio::test] +#[ignore] +/// A channel admin who does not own the draft must receive "target event not +/// found" when attempting kind:9005 deletion — indistinguishable from a +/// missing-target response (oracle-masking tripwire). +async fn test_nip09_kind9005_admin_deletion_of_draft_is_masked_as_not_found() { + let client = http_client(); + let owner = Keys::generate(); + let admin = Keys::generate(); + + // Create an open channel, add admin as a channel admin. + let ch_id = create_open_channel(&owner).await; + let add_admin_event = EventBuilder::new(Kind::Custom(KIND_PUT_USER), "") + .tags([ + Tag::parse(["h", &ch_id]).unwrap(), + Tag::parse(["p", &admin.public_key().to_hex()]).unwrap(), + Tag::parse(["role", "admin"]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + let (ok_add, msg_add) = submit_event_http(&client, &owner, &add_admin_event).await; + assert!(ok_add, "add admin must succeed: {msg_add}"); + + // Publish a draft as the channel owner. + let d = uuid::Uuid::new_v4().to_string(); + let draft = build_draft_at(&owner, &d, "9", &ch_id, &fake_nip44_v2(), Timestamp::now()); + let (ok_draft, msg_draft) = submit_event_http(&client, &owner, &draft).await; + assert!(ok_draft, "draft must be accepted: {msg_draft}"); + + // Admin (non-owner of draft) submits kind:9005 targeting the draft event id. + // Must be rejected with "target event not found" — not a draft-specific error. + let del_9005 = EventBuilder::new(Kind::Custom(9005), "") + .tags([ + Tag::parse(["e", &draft.id.to_hex()]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + ]) + .sign_with_keys(&admin) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &admin, &del_9005).await; + assert!( + !accepted, + "admin kind:9005 on draft must be rejected; relay said: {msg}" + ); + + // Control probe: same admin submits kind:9005 with a random nonexistent + // event id. This produces the genuine "target event not found" path. + // The oracle-masking invariant requires that `msg` (draft target) is + // byte-identical to `control_msg` (nonexistent target) and contains no + // draft-specific terms. + let fake_id = "de".repeat(32); + let control_9005 = EventBuilder::new(Kind::Custom(9005), "") + .tags([ + Tag::parse(["e", &fake_id]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + ]) + .sign_with_keys(&admin) + .unwrap(); + let (control_accepted, control_msg) = submit_event_http(&client, &admin, &control_9005).await; + assert!( + !control_accepted, + "control probe (nonexistent target) must also be rejected; relay said: {control_msg}" + ); + assert_eq!( + msg, control_msg, + "draft-target and nonexistent-target must produce byte-identical rejection messages \ + (oracle mask); draft msg: {msg:?}, control msg: {control_msg:?}" + ); + assert!( + !msg.contains("31234") && !msg.contains("draft") && !msg.contains("tombstone"), + "rejection must not leak draft-specific terms; got: {msg:?}" + ); +} + +// ─── DM channel path ───────────────────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_accepted_and_replaced_in_dm_channel() { + // Draft wraps are channel-bound. A DM channel UUID (returned from + // kind:41010) is a valid `h` target — the relay treats it identically + // to a stream/broadcast channel for draft storage purposes. + let client = http_client(); + let alice = Keys::generate(); + let bob = Keys::generate(); + + // Alice opens a DM with Bob — relay creates and returns the channel UUID. + let dm_event = EventBuilder::new(Kind::Custom(41010), "") + .tags([Tag::parse(["p", &bob.public_key().to_hex()]).unwrap()]) + .sign_with_keys(&alice) + .unwrap(); + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", &alice.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&dm_event).unwrap()) + .send() + .await + .expect("open DM"); + let body: Value = resp.json().await.expect("parse DM response"); + assert!( + body["accepted"].as_bool().unwrap_or(false), + "DM open must be accepted: {body}" + ); + // The relay embeds the DM channel UUID in the message payload. + let msg = body["message"].as_str().unwrap_or(""); + let dm_channel_id = if let Some(stripped) = msg.strip_prefix("response:") { + let parsed: Value = serde_json::from_str(stripped).expect("response JSON"); + parsed["channel_id"] + .as_str() + .expect("channel_id in DM response") + .to_string() + } else { + panic!( + "DM open response must contain a `response:{{...}}` payload with channel_id; \ + got message: {msg:?} (full body: {body})" + ); + }; + + // Alice submits a draft bound to the DM channel UUID. + // Timestamps are strictly increasing to guarantee deterministic ordering. + let d = uuid::Uuid::new_v4().to_string(); + let base = nostr::Timestamp::now().as_secs(); + let v1 = build_draft_at( + &alice, + &d, + "9", + &dm_channel_id, + &fake_nip44_v2(), + nostr::Timestamp::from(base - 2), + ); + let (ok1, msg1) = submit_event_http(&client, &alice, &v1).await; + assert!(ok1, "draft v1 to DM channel must be accepted: {msg1}"); + + // Replace with a strictly newer version (base - 1 > base - 2). + let v2 = build_draft_at( + &alice, + &d, + "9", + &dm_channel_id, + &fake_nip44_v2(), + nostr::Timestamp::from(base - 1), + ); + let v2_id = v2.id; + let (ok2, msg2) = submit_event_http(&client, &alice, &v2).await; + assert!( + ok2, + "draft v2 replacement in DM channel must be accepted: {msg2}" + ); + + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(alice.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + let results = query_events_http(&client, &alice.public_key().to_hex(), vec![filter]).await; + assert_eq!( + results.len(), + 1, + "DM-channel draft must have exactly one head" + ); + assert_eq!( + results[0]["id"].as_str().unwrap(), + v2_id.to_hex(), + "v2 must be the head after replacement" + ); + + // Tombstone the draft (base > base - 1, so this supersedes v2). + let tomb = build_tombstone( + &alice, + &d, + "9", + &dm_channel_id, + nostr::Timestamp::from(base), + ); + let tomb_id = tomb.id; + let (ok_t, msg_t) = submit_event_http(&client, &alice, &tomb).await; + assert!(ok_t, "tombstone in DM channel must be accepted: {msg_t}"); + + let filter2 = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(alice.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + let results2 = query_events_http(&client, &alice.public_key().to_hex(), vec![filter2]).await; + assert_eq!(results2.len(), 1, "tombstone must be the only head"); + assert_eq!( + results2[0]["id"].as_str().unwrap(), + tomb_id.to_hex(), + "tombstone must be the current head after DM-channel draft is closed" + ); +} + +#[tokio::test] +#[ignore] +async fn test_dm_draft_not_readable_by_dm_recipient() { + // Regression guard (Q13): Alice opens a DM with Bob and writes a draft to + // that DM channel. Bob (the DM recipient and channel member) must NOT be + // able to read Alice's draft — drafts are author-only regardless of + // channel membership. The test queries from Bob's perspective to prove + // the author-only guard fires on the recipient-side, not just on arbitrary + // third parties. + let client = http_client(); + let alice = Keys::generate(); + let bob = Keys::generate(); + + // Alice opens a DM with Bob — relay creates the DM channel UUID. + let dm_event = EventBuilder::new(Kind::Custom(41010), "") + .tags([Tag::parse(["p", &bob.public_key().to_hex()]).unwrap()]) + .sign_with_keys(&alice) + .unwrap(); + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", &alice.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&dm_event).unwrap()) + .send() + .await + .expect("open DM"); + let body: Value = resp.json().await.expect("parse DM response"); + assert!( + body["accepted"].as_bool().unwrap_or(false), + "DM open must be accepted: {body}" + ); + let msg = body["message"].as_str().unwrap_or(""); + let dm_channel_id = if let Some(stripped) = msg.strip_prefix("response:") { + let parsed: Value = serde_json::from_str(stripped).expect("response JSON"); + parsed["channel_id"] + .as_str() + .expect("channel_id in DM response") + .to_string() + } else { + panic!("DM open response must contain channel_id; got: {msg:?}"); + }; + + // Alice writes a draft to the DM channel. + let d = uuid::Uuid::new_v4().to_string(); + let draft = build_draft(&alice, &d, "9", &dm_channel_id, &fake_nip44_v2()); + let draft_id = draft.id; + let (ok, err) = submit_event_http(&client, &alice, &draft).await; + assert!(ok, "Alice's draft must be accepted: {err}"); + + // Bob queries the DM channel as a member (recipient-side query). + // He should NOT see Alice's draft — drafts are author-only. + let filter = Filter::new() + .kind(Kind::Custom(KIND_DRAFT)) + .author(alice.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + let resp2 = client + .post(format!("{}/query", relay_http_url())) + .header("X-Pubkey", &bob.public_key().to_hex()) + .header("Content-Type", "application/json") + .json(&vec![filter]) + .send() + .await + .expect("Bob query"); + assert_eq!( + resp2.status().as_u16(), + 403, + "Bob (DM recipient) must get 403 querying Alice's draft; got: {}", + resp2.status() + ); + + // Verify Alice herself can still read it (positive control). + let filter2 = Filter::new() + .kind(Kind::Custom(KIND_DRAFT)) + .author(alice.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + let alice_results = + query_events_http(&client, &alice.public_key().to_hex(), vec![filter2]).await; + assert_eq!( + alice_results.len(), + 1, + "Alice must be able to read her own DM draft (positive control)" + ); + assert_eq!( + alice_results[0]["id"].as_str().unwrap(), + draft_id.to_hex(), + "Alice's query must return her draft" + ); +} + +// ─── Removed-member read denial ────────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_removed_member_cannot_read_drafts_after_removal() { + // A member who wrote a draft is later removed from the channel. + // After removal they must not be able to REQ or COUNT their own draft + // (channel membership is required for draft reads, not just writes). + // + // Note: draft reads are author-only, so this also tests that the + // author-only gate stacks correctly with the channel-membership gate. + let url = relay_url(); + let client = http_client(); + let owner = Keys::generate(); + let member = Keys::generate(); + let ch_id = create_private_channel(&owner).await; + add_member_http(&client, &owner, &ch_id, &member).await; + + let d = uuid::Uuid::new_v4().to_string(); + let v1 = build_draft_at( + &member, + &d, + "9", + &ch_id, + &fake_nip44_v2(), + nostr::Timestamp::from(nostr::Timestamp::now().as_secs() - 2), + ); + let (ok1, msg1) = submit_event_http(&client, &member, &v1).await; + assert!(ok1, "member draft must be accepted: {msg1}"); + let v1_id = v1.id; + + // Remove member. + remove_member_http(&client, &owner, &ch_id, &member).await; + + // Historical REQ: removed member must not retrieve their old draft. + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(member.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + let results = + query_events_http(&client, &member.public_key().to_hex(), vec![filter.clone()]).await; + // The relay may return CLOSED or 0 results; the draft must not appear. + assert!( + !results + .iter() + .any(|e| e["id"].as_str() == Some(&v1_id.to_hex())), + "removed member must not retrieve their draft via HTTP /query" + ); + + // HTTP COUNT: removed member must get 0 count (not 403 here, since it's + // their own draft address, but the channel-membership check should exclude it). + let count_resp = client + .post(format!("{}/count", relay_http_url())) + .header("X-Pubkey", &member.public_key().to_hex()) + .header("Content-Type", "application/json") + .json(&vec![filter]) + .send() + .await + .expect("count request"); + let count_status = count_resp.status().as_u16(); + if count_status == 200 { + let body: Value = count_resp.json().await.expect("parse count"); + let count = body["count"].as_u64().unwrap_or(0); + assert_eq!( + count, 0, + "removed member's draft count must be 0 after removal" + ); + } else { + // 403 is also acceptable — relay may gate based on membership entirely. + assert!( + count_status == 403, + "expected 200 with count=0 or 403, got {count_status}" + ); + } + + // Live fan-out: owner posts a new draft to the channel. The removed + // member must not receive it via a pre-existing WS subscription, even + // if they filter on author(owner) — only current channel members may + // receive owner's drafts. + let mut removed_client = BuzzTestClient::connect(&url, &member) + .await + .expect("connect removed member"); + let sid = sub_id("removed-fanout"); + // Subscribe to the owner's drafts — if channel membership is properly + // enforced, the relay must CLOSE or simply not deliver owner's new draft + // to the removed member. + let live_filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(owner.public_key()) + .limit(0); // skip historical; only live + let _ = removed_client.subscribe(&sid, vec![live_filter]).await; + let _ = removed_client + .collect_until_eose(&sid, Duration::from_secs(2)) + .await; + + // Owner submits a new draft — this is the live probe event. + let owner_draft = build_draft( + &owner, + &uuid::Uuid::new_v4().to_string(), + "9", + &ch_id, + &fake_nip44_v2(), + ); + let owner_draft_id = owner_draft.id; + let (ok_od, _) = submit_event_http(&client, &owner, &owner_draft).await; + // Owner's own draft must be accepted; verify it doesn't reach removed member. + if ok_od { + let _ = tokio::time::timeout(Duration::from_secs(2), async { + while let Ok(RelayMessage::Event { event, .. }) = + removed_client.recv_event(Duration::from_secs(1)).await + { + if event.id == owner_draft_id { + panic!("removed member received owner's draft via live fan-out after removal"); + } + } + }) + .await; + } + removed_client.disconnect().await.expect("disconnect"); +} + +// ─── Channel-window (top_level) draft privacy ──────────────────────────────── + +/// POST /query with `top_level: true`, mixed `kinds:[9,31234]`, as `as_keys`. +/// Returns the raw event array (rows + overlays + aux). +async fn query_channel_window_mixed( + as_keys: &Keys, + channel_id: &str, + include_aux: bool, + include_summaries: bool, +) -> Vec { + let client = http_client(); + let filter = serde_json::json!({ + "kinds": [9, KIND_DRAFT], + "#h": [channel_id], + "top_level": true, + "include_aux": include_aux, + "include_summaries": include_summaries, + }); + let resp = client + .post(format!("{}/query", relay_http_url())) + .header("X-Pubkey", &as_keys.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&serde_json::json!([filter])).unwrap()) + .send() + .await + .expect("channel window mixed query"); + assert!( + resp.status().is_success(), + "window query failed: {}", + resp.status() + ); + resp.json::>() + .await + .expect("parse window response") +} + +/// `top_level: true` channel-window path with mixed `kinds:[9,31234]`: +/// a non-author channel member must receive zero kind:31234 draft rows and +/// zero draft event-ids anywhere in the response (rows, aux, overlays). +/// +/// Contract: the channel-window path applies the same author-only visibility +/// rule as every other read path — kind:31234 drafts are excluded for any +/// requester who is not their author. The SQL-level guard ensures that +/// `next_cursor` and `has_more` are also computed from the draft-excluded row +/// set, so no draft id can leak via the 39006 bounds overlay or aux closure. +#[tokio::test] +#[ignore] +async fn test_channel_window_draft_excluded_for_non_author() { + let client = http_client(); + let author = Keys::generate(); + let attacker = Keys::generate(); + let ch_id = create_open_channel(&author).await; + + // Attacker joins the channel so they can issue a valid top_level query. + add_member_http(&client, &author, &ch_id, &attacker).await; + + // Author posts a visible kind:9 message — provides a positive control row. + let msg = EventBuilder::new(nostr::Kind::Custom(9), "hello channel") + .tags([nostr::Tag::parse(["h", &ch_id]).unwrap()]) + .sign_with_keys(&author) + .unwrap(); + let msg_id = msg.id.to_hex(); + let (ok_m, reason_m) = submit_event_http(&client, &author, &msg).await; + assert!(ok_m, "kind:9 message must be accepted: {reason_m}"); + + // Author posts a kind:31234 draft in the same channel. + let d = uuid::Uuid::new_v4().to_string(); + let draft = build_draft(&author, &d, "9", &ch_id, &fake_nip44_v2()); + let draft_id = draft.id.to_hex(); + let (ok_d, reason_d) = submit_event_http(&client, &author, &draft).await; + assert!(ok_d, "draft must be accepted: {reason_d}"); + + // Attacker issues a top_level window query with kinds:[9,31234] — must + // not receive the draft in rows, aux, or overlays. + let events = query_channel_window_mixed(&attacker, &ch_id, true, true).await; + + // Collect all event-ids that appear anywhere in the response (own id + + // any id referenced in tags — covers bounds `d` tag, summary `e`/`d` tags). + let all_ids: Vec = events + .iter() + .flat_map(|e| { + let own_id = e["id"].as_str().map(|s| s.to_string()); + let tag_values: Vec = e["tags"] + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .flat_map(|t| { + t.as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + }) + .collect(); + own_id.into_iter().chain(tag_values) + }) + .collect(); + + assert!( + !all_ids.iter().any(|id| id == &draft_id), + "draft id must not appear anywhere in the channel-window response for a non-author: \ + draft_id={draft_id}, response={events:?}" + ); + + // Positive control: the kind:9 message MUST be present as a row. + let row_kinds: Vec = events.iter().filter_map(|e| e["kind"].as_u64()).collect(); + assert!( + row_kinds.contains(&9), + "kind:9 row must appear in window response for attacker: {events:?}" + ); + // msg_id must specifically be in the rows. + let ids_in_response: Vec = events + .iter() + .filter_map(|e| e["id"].as_str().map(|s| s.to_string())) + .collect(); + assert!( + ids_in_response.contains(&msg_id), + "kind:9 message id must appear in window rows: msg_id={msg_id}, response={events:?}" + ); + + assert!( + !row_kinds.contains(&(KIND_DRAFT as u64)), + "no kind:31234 event may appear in window response for non-author: {events:?}" + ); + + // Exactly one 39006 bounds overlay must be present (window invariant). + let bounds_count = events + .iter() + .filter(|e| e["kind"].as_u64() == Some(39006)) + .count(); + assert_eq!( + bounds_count, 1, + "exactly one 39006 bounds overlay required: {events:?}" + ); +} + +/// `top_level: true` channel-window with kinds:[9,31234]: the author themselves +/// CAN see their own draft in the window — consistent with all other read paths +/// which allow authors to retrieve their own author-only events. +#[tokio::test] +#[ignore] +async fn test_channel_window_draft_visible_to_author() { + let client = http_client(); + let author = Keys::generate(); + let ch_id = create_open_channel(&author).await; + + // Post a kind:9 message and a draft in the same channel. + let msg = EventBuilder::new(nostr::Kind::Custom(9), "public message") + .tags([nostr::Tag::parse(["h", &ch_id]).unwrap()]) + .sign_with_keys(&author) + .unwrap(); + let (ok_m, reason_m) = submit_event_http(&client, &author, &msg).await; + assert!(ok_m, "kind:9 message must be accepted: {reason_m}"); + + let d = uuid::Uuid::new_v4().to_string(); + let draft = build_draft(&author, &d, "9", &ch_id, &fake_nip44_v2()); + let draft_id = draft.id.to_hex(); + let (ok_d, reason_d) = submit_event_http(&client, &author, &draft).await; + assert!(ok_d, "draft must be accepted: {reason_d}"); + + // Author queries the window — their own draft must appear. + let events = query_channel_window_mixed(&author, &ch_id, false, false).await; + + let row_kinds: Vec = events.iter().filter_map(|e| e["kind"].as_u64()).collect(); + + // Both kind:9 and kind:31234 (own draft) must be present. + assert!( + row_kinds.contains(&9), + "kind:9 row must appear for author: {events:?}" + ); + assert!( + row_kinds.contains(&(KIND_DRAFT as u64)), + "author must see their own kind:31234 draft in the window: {events:?}" + ); + + // Verify the specific draft id is present. + let ids_in_response: Vec = events + .iter() + .filter_map(|e| e["id"].as_str().map(|s| s.to_string())) + .collect(); + assert!( + ids_in_response.contains(&draft_id), + "author's draft id must appear in window rows: draft_id={draft_id}, response={events:?}" + ); + + // 39006 bounds overlay invariant. + let bounds_count = events + .iter() + .filter(|e| e["kind"].as_u64() == Some(39006)) + .count(); + assert_eq!( + bounds_count, 1, + "exactly one 39006 bounds overlay required: {events:?}" + ); +} + +/// Q16: channel-window cursor-boundary variant. +/// +/// Posts more messages than the `limit` so the window response includes a +/// `next_cursor`, then verifies: +/// 1. `next_cursor` ids never contain a draft id (draft-excluded row set). +/// 2. Paginating with the cursor returns only public messages and remains gapless. +/// 3. Drafts are excluded from both pages and from every id in the overlay. +/// +/// This is the exact mutate-bite scenario the whole review run hunted: +/// a future regression that moves the author-only filter after pagination +/// would either leak a draft id in `next_cursor` or create a gap on page 2. +#[tokio::test] +#[ignore] +async fn test_channel_window_cursor_boundary_excludes_draft() { + let client = http_client(); + let author = Keys::generate(); + let attacker = Keys::generate(); + let ch_id = create_open_channel(&author).await; + add_member_http(&client, &author, &ch_id, &attacker).await; + + // Post 3 public kind:9 messages with strictly increasing timestamps. + // The draft is placed at base+3 — inside the raw first page of limit=2 + // (raw DESC order: msg2@+4, draft@+3, msg1@+2, msg0@+0). A regression + // that filters drafts AFTER computing the cursor would pick draft as the + // cursor candidate and leak it. Correct SQL-layer exclusion computes + // the cursor from the filtered set, giving msg1@+2 as the page-1 cursor. + let base_ts = nostr::Timestamp::now().as_secs() - 10; + let mut msg_ids = Vec::new(); + for i in 0..3u64 { + let ts = nostr::Timestamp::from(base_ts + i * 2); // spread 0, 2, 4 seconds + let msg = nostr::EventBuilder::new(nostr::Kind::Custom(9), format!("msg-{i}")) + .tags([nostr::Tag::parse(["h", &ch_id]).unwrap()]) + .custom_created_at(ts) + .sign_with_keys(&author) + .unwrap(); + msg_ids.push(msg.id.to_hex()); + let (ok, err) = submit_event_http(&client, &author, &msg).await; + assert!(ok, "kind:9 message {i} must be accepted: {err}"); + } + + // Draft at base+3: sits between msg2@+4 and msg1@+2, inside the raw + // first page. A filter-after-pagination regression would use it as + // the cursor (leaking the draft id via 39006 next_cursor). + let d = uuid::Uuid::new_v4().to_string(); + let draft_ts = nostr::Timestamp::from(base_ts + 3); + let draft = nostr::EventBuilder::new(nostr::Kind::Custom(KIND_DRAFT), fake_nip44_v2()) + .tags([ + nostr::Tag::parse(["d", &d]).unwrap(), + nostr::Tag::parse(["k", "9"]).unwrap(), + nostr::Tag::parse(["h", &ch_id]).unwrap(), + ]) + .custom_created_at(draft_ts) + .sign_with_keys(&author) + .unwrap(); + let draft_id = draft.id.to_hex(); + let (ok_d, err_d) = submit_event_http(&client, &author, &draft).await; + assert!(ok_d, "draft must be accepted: {err_d}"); + + // Page 1: limit=2 as the attacker. Response must have has_more=true and a + // next_cursor. No draft id must appear anywhere (rows, overlay, aux). + let page1_filter = serde_json::json!({ + "kinds": [9, KIND_DRAFT], + "#h": [ch_id], + "top_level": true, + "limit": 2, + "include_aux": true, + "include_summaries": false, + }); + let resp1 = client + .post(format!("{}/query", relay_http_url())) + .header("X-Pubkey", &attacker.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&serde_json::json!([page1_filter])).unwrap()) + .send() + .await + .expect("page 1 window query"); + assert!(resp1.status().is_success(), "page 1 must succeed"); + let page1_events: Vec = resp1.json().await.expect("page 1 parse"); + + // Collect all ids present in page 1 (rows + overlays + aux). + let page1_ids: Vec = page1_events + .iter() + .flat_map(|e| { + let mut ids = vec![]; + if let Some(id) = e["id"].as_str() { + ids.push(id.to_string()); + } + // 39006 overlay content may embed next_cursor.id — check content too. + if e["kind"].as_u64() == Some(39006) { + if let Some(content_str) = e["content"].as_str() { + if let Ok(content) = serde_json::from_str::(content_str) { + if let Some(cursor_id) = content + .get("next_cursor") + .and_then(|c| c.get("id")) + .and_then(|v| v.as_str()) + { + ids.push(cursor_id.to_string()); + } + } + } + } + ids + }) + .collect(); + + assert!( + !page1_ids.iter().any(|id| id == &draft_id), + "draft id must not appear anywhere in page 1: draft_id={draft_id}, page1={page1_events:?}" + ); + + // Extract next_cursor from the 39006 overlay. + let overlay1 = page1_events + .iter() + .find(|e| e["kind"].as_u64() == Some(39006)); + let cursor = overlay1.and_then(|o| { + let content: Value = serde_json::from_str(o["content"].as_str()?).ok()?; + let has_more = content["has_more"].as_bool().unwrap_or(false); + if !has_more { + return None; + } + let cursor_ts = content["next_cursor"]["created_at"].as_i64()?; + let cursor_id = content["next_cursor"]["id"].as_str()?.to_string(); + Some((cursor_ts, cursor_id)) + }); + + // With 3 public messages and limit=2, has_more MUST be true and a cursor + // MUST be present — the test is structured to force pagination. If the + // relay returned has_more=false, the SQL-layer exclusion likely mis-counted. + let (cursor_ts, cursor_id) = cursor.expect( + "page 1 must have has_more=true and a next_cursor — 3 public messages + limit=2 \ + guarantees pagination; if this fails the draft may have been counted or the \ + cursor may have been computed from the unfiltered row set", + ); + + // The cursor must not be the draft id. + assert_ne!( + cursor_id, draft_id, + "next_cursor.id must not be the draft id — cursor must be computed on the \ + draft-excluded row set" + ); + + let page2_filter = serde_json::json!({ + "kinds": [9, KIND_DRAFT], + "#h": [ch_id], + "top_level": true, + "limit": 2, + "until": cursor_ts, + "before_id": cursor_id, + "include_aux": true, + "include_summaries": false, + }); + let resp2 = client + .post(format!("{}/query", relay_http_url())) + .header("X-Pubkey", &attacker.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&serde_json::json!([page2_filter])).unwrap()) + .send() + .await + .expect("page 2 window query"); + assert!(resp2.status().is_success(), "page 2 must succeed"); + let page2_events: Vec = resp2.json().await.expect("page 2 parse"); + + let page2_ids: Vec = page2_events + .iter() + .filter_map(|e| e["id"].as_str().map(|s| s.to_string())) + .collect(); + assert!( + !page2_ids.iter().any(|id| id == &draft_id), + "draft id must not appear in page 2: draft_id={draft_id}, page2={page2_events:?}" + ); + + // Gapless invariant: the two pages together cover all 3 public messages. + let all_msg_ids: std::collections::HashSet = page1_ids + .iter() + .chain(page2_ids.iter()) + .filter(|id| msg_ids.contains(id)) + .cloned() + .collect(); + assert_eq!( + all_msg_ids.len(), + 3, + "all 3 public messages must appear across both pages with no gaps; \ + got: page1={page1_ids:?}, page2={page2_ids:?}" + ); +} + +// ─── Q15 oracle-closure e2e — write-path id-oracle guards ──────────────────── +// +// These four tests prove the author-only target masking actually fires in the +// live relay, not just that the constant AUTHOR_ONLY_KINDS contains KIND_DRAFT. +// For each write path, we submit against a REAL draft id and against a RANDOM +// (guaranteed-nonexistent) 64-hex id, and assert: +// - the error strings are BYTE-IDENTICAL between the two submissions (no oracle) +// - after each rejected attempt, no public rows referencing the draft id exist +// +// The tests are intentionally verbose so a reviewer can follow every assertion. + +#[tokio::test] +#[ignore] +async fn test_draft_target_reaction_oracle_closed() { + // Attacker reacts (kind:7) to: + // (A) a real draft event id authored by `author` + // (B) a random 64-hex id that definitely does not exist + // + // Both must return the SAME byte-identical error string so an attacker + // cannot distinguish "this id is a real draft" from "this id doesn't exist". + // After each rejected submission, the attacker queries for kind:7 events + // that e-tag the target id and asserts zero are stored. + let client = http_client(); + let author = Keys::generate(); + let attacker = Keys::generate(); + let ch_id = create_open_channel(&author).await; + let d = uuid::Uuid::new_v4().to_string(); + + // Author publishes a draft — this is the real id we'll probe with. + let draft = build_draft(&author, &d, "9", &ch_id, &fake_nip44_v2()); + let draft_id_hex = draft.id.to_hex(); + let (ok_d, err_d) = submit_event_http(&client, &author, &draft).await; + assert!(ok_d, "draft must be accepted: {err_d}"); + + // A guaranteed-nonexistent id: all zeros is an invalid SHA-256 preimage + // for any real event on any relay. + let random_id_hex = "0".repeat(64); + + // Helper: build a kind:7 reaction with e-tag pointing to `target_id`. + // Reactions targeting non-channel events don't carry an h tag — the relay + // derives the channel from the target. We don't include one here to stay + // minimal and match the ingest path under test. + let build_reaction = |target_hex: &str| { + EventBuilder::new(nostr::Kind::Custom(7), "+") + .tags([nostr::Tag::parse(["e", target_hex]).unwrap()]) + .sign_with_keys(&attacker) + .unwrap() + }; + + // (A) Attacker reacts to the real draft id. + let reaction_a = build_reaction(&draft_id_hex); + let (accepted_a, msg_a) = submit_event_http(&client, &attacker, &reaction_a).await; + assert!( + !accepted_a, + "reaction to a real draft id must be rejected; relay said: {msg_a}" + ); + + // (B) Attacker reacts to the random (nonexistent) id. + let reaction_b = build_reaction(&random_id_hex); + let (accepted_b, msg_b) = submit_event_http(&client, &attacker, &reaction_b).await; + assert!( + !accepted_b, + "reaction to a nonexistent id must be rejected; relay said: {msg_b}" + ); + + // Oracle-closure: error strings must be byte-identical. + assert_eq!( + msg_a, msg_b, + "reaction rejection for real-draft-id vs random-id must be BYTE-IDENTICAL \ + to prevent an id-oracle attack; \ + real_draft='{msg_a}', random='{msg_b}'" + ); + assert_eq!( + msg_a, "invalid: reaction target event not found", + "expected byte-exact masking error; got: '{msg_a}'" + ); + + // Post-rejection storage check: no kind:7 events e-tagging the draft id + // must be stored — neither the attacker's attempt nor any fan-out. + let kind7_filter = nostr::Filter::new() + .kind(nostr::Kind::Custom(7)) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::E), + draft_id_hex.as_str(), + ); + // Query as attacker (their own events) and as author (would see any fan-out) + let kind7_as_attacker = query_events_http( + &client, + &attacker.public_key().to_hex(), + vec![kind7_filter.clone()], + ) + .await; + assert!( + kind7_as_attacker.is_empty(), + "zero kind:7 events referencing the draft id must be stored after attacker's \ + rejected reaction attempt; found: {kind7_as_attacker:?}" + ); + let kind7_as_author = + query_events_http(&client, &author.public_key().to_hex(), vec![kind7_filter]).await; + assert!( + kind7_as_author.is_empty(), + "author-side query must also return zero kind:7 events referencing the draft id; \ + found: {kind7_as_author:?}" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_target_thread_parent_oracle_closed() { + // Attacker posts a kind:9 reply (NIP-10 e-tag) whose parent is: + // (A) a real draft event id authored by `author` + // (B) a random 64-hex id that definitely does not exist + // + // Both must return the SAME byte-identical error string. After each + // rejected submission, we verify no kind:9 thread-children of the draft + // id exist, and that no 39005 thread-summary event was fan-out fired. + let url = relay_url(); + let client = http_client(); + let author = Keys::generate(); + let attacker = Keys::generate(); + let ch_id = create_open_channel(&author).await; + + // Attacker must be a channel member to get past membership check and reach + // the thread-parent guard. Add them to the open channel. + add_member_http(&client, &author, &ch_id, &attacker).await; + + let d = uuid::Uuid::new_v4().to_string(); + let draft = build_draft(&author, &d, "9", &ch_id, &fake_nip44_v2()); + let draft_id_hex = draft.id.to_hex(); + let (ok_d, err_d) = submit_event_http(&client, &author, &draft).await; + assert!(ok_d, "draft must be accepted: {err_d}"); + + let random_id_hex = "1".repeat(64); + + // Subscribe as a channel watcher to detect any 39005 fan-out. + let mut watcher = BuzzTestClient::connect(&url, &author) + .await + .expect("connect watcher"); + let sid_39005 = sub_id("thread-39005-watch"); + let filter_39005 = nostr::Filter::new() + .kind(nostr::Kind::Custom(39005)) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::H), + ch_id.as_str(), + ) + .limit(0); + watcher + .subscribe(&sid_39005, vec![filter_39005]) + .await + .expect("subscribe 39005"); + let _ = watcher + .collect_until_eose(&sid_39005, Duration::from_secs(3)) + .await; + + // Helper: build a kind:9 reply whose parent (and root) is `parent_hex`. + // Using a single e-tag with marker "reply" makes it both root and parent + // per the NIP-10 two-marker fallback in resolve_nip10_thread_meta. + let build_reply = |parent_hex: &str| { + EventBuilder::new(nostr::Kind::Custom(9), "reply text") + .tags([ + nostr::Tag::parse(["h", &ch_id]).unwrap(), + nostr::Tag::parse(["e", parent_hex, "", "reply"]).unwrap(), + ]) + .sign_with_keys(&attacker) + .unwrap() + }; + + // (A) Attacker replies with real draft id as parent. + let reply_a = build_reply(&draft_id_hex); + let (accepted_a, msg_a) = submit_event_http(&client, &attacker, &reply_a).await; + assert!( + !accepted_a, + "reply with real draft id as parent must be rejected; relay said: {msg_a}" + ); + + // (B) Attacker replies with random (nonexistent) id as parent. + let reply_b = build_reply(&random_id_hex); + let (accepted_b, msg_b) = submit_event_http(&client, &attacker, &reply_b).await; + assert!( + !accepted_b, + "reply with nonexistent id as parent must be rejected; relay said: {msg_b}" + ); + + // Oracle-closure: error strings must be byte-identical. + assert_eq!( + msg_a, msg_b, + "thread-parent rejection for real-draft-id vs random-id must be BYTE-IDENTICAL; \ + real_draft='{msg_a}', random='{msg_b}'" + ); + assert_eq!( + msg_a, "invalid: reply parent not found", + "expected byte-exact masking error; got: '{msg_a}'" + ); + + // Post-rejection storage check: no kind:9 events e-tagging the draft id + // as a parent must be stored. + let reply_filter = nostr::Filter::new() + .kind(nostr::Kind::Custom(9)) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::E), + draft_id_hex.as_str(), + ); + let replies_stored = + query_events_http(&client, &author.public_key().to_hex(), vec![reply_filter]).await; + assert!( + replies_stored.is_empty(), + "zero kind:9 thread-children referencing the draft id must be stored; \ + found: {replies_stored:?}" + ); + + // No 39005 fan-out must have fired for this channel during the above + // rejected attempts. Give the relay a short window to deliver anything. + let deadline = tokio::time::Instant::now() + Duration::from_millis(500); + let mut received_39005 = false; + loop { + let remaining = deadline + .checked_duration_since(tokio::time::Instant::now()) + .unwrap_or(Duration::ZERO); + if remaining.is_zero() { + break; + } + match watcher.recv_event(remaining).await { + Ok(RelayMessage::Event { event, .. }) => { + if event.kind == nostr::Kind::Custom(39005) { + received_39005 = true; + break; + } + } + _ => break, + } + } + assert!( + !received_39005, + "no 39005 thread-summary fan-out must fire when a reply is rejected due to \ + author-only parent masking" + ); + watcher.disconnect().await.expect("disconnect watcher"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_target_public_reference_author_also_rejected() { + // The public-reference paths (reaction, thread-reply) reject drafts as + // targets for EVERYONE — including the draft's own author. This is because + // a successful reaction/reply would write the draft id into a public row + // (kind:7 or thread metadata), making the draft's existence public state. + // + // This test uses the author themselves as the submitter to prove the guard + // is unconditional on public-reference paths. + let client = http_client(); + let author = Keys::generate(); + let ch_id = create_open_channel(&author).await; + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&author, &d, "9", &ch_id, &fake_nip44_v2()); + let draft_id_hex = draft.id.to_hex(); + let (ok_d, err_d) = submit_event_http(&client, &author, &draft).await; + assert!(ok_d, "draft must be accepted: {err_d}"); + + // Author reacts to their own draft. + let self_reaction = EventBuilder::new(nostr::Kind::Custom(7), "+") + .tags([nostr::Tag::parse(["e", &draft_id_hex]).unwrap()]) + .sign_with_keys(&author) + .unwrap(); + let (accepted_rxn, msg_rxn) = submit_event_http(&client, &author, &self_reaction).await; + assert!( + !accepted_rxn, + "author reacting to their own draft must be rejected (public-reference path \ + writes draft id into public kind:7 row); relay said: {msg_rxn}" + ); + assert_eq!( + msg_rxn, "invalid: reaction target event not found", + "author's self-reaction rejection must use the masking not-found error; got: '{msg_rxn}'" + ); + + // Author replies with their own draft as parent. + let self_reply = EventBuilder::new(nostr::Kind::Custom(9), "author reply") + .tags([ + nostr::Tag::parse(["h", &ch_id]).unwrap(), + nostr::Tag::parse(["e", &draft_id_hex, "", "reply"]).unwrap(), + ]) + .sign_with_keys(&author) + .unwrap(); + let (accepted_reply, msg_reply) = submit_event_http(&client, &author, &self_reply).await; + assert!( + !accepted_reply, + "author replying to their own draft as parent must be rejected (public-reference \ + path writes draft id into thread metadata); relay said: {msg_reply}" + ); + assert_eq!( + msg_reply, "invalid: reply parent not found", + "author's self-reply rejection must use the masking not-found error; got: '{msg_reply}'" + ); + + // Verify the draft is still readable by the author (the rejections must + // not have altered it). + let draft_filter = nostr::Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(author.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + let drafts = + query_events_http(&client, &author.public_key().to_hex(), vec![draft_filter]).await; + assert_eq!( + drafts.len(), + 1, + "draft must still be readable by author after rejected self-reaction and self-reply" + ); + assert_eq!( + drafts[0]["id"].as_str().unwrap(), + draft_id_hex, + "draft head must be unchanged" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_target_kind5_oracle_closed() { + // Non-author submits a kind:5 (NIP-09 e-tag deletion) targeting: + // (A) a real draft event id authored by `author` + // (B) a random 64-hex id that definitely does not exist + // + // Both must return the SAME byte-identical error string so the attacker + // cannot distinguish "this id is a real draft" from "this id doesn't exist". + // After each rejected submission, the draft must still be readable by the + // author (it was not deleted). + let client = http_client(); + let author = Keys::generate(); + let attacker = Keys::generate(); + let ch_id = create_open_channel(&author).await; + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&author, &d, "9", &ch_id, &fake_nip44_v2()); + let draft_id_hex = draft.id.to_hex(); + let (ok_d, err_d) = submit_event_http(&client, &author, &draft).await; + assert!(ok_d, "draft must be accepted: {err_d}"); + + let random_id_hex = "2".repeat(64); + + let build_deletion = |target_hex: &str, signer: &Keys| { + EventBuilder::new(nostr::Kind::EventDeletion, "") + .tags([nostr::Tag::parse(["e", target_hex]).unwrap()]) + .sign_with_keys(signer) + .unwrap() + }; + + // (A) Attacker submits kind:5 e-tagging the real draft id. + let del_a = build_deletion(&draft_id_hex, &attacker); + let (accepted_a, msg_a) = submit_event_http(&client, &attacker, &del_a).await; + assert!( + !accepted_a, + "kind:5 targeting a real draft id must be rejected; relay said: {msg_a}" + ); + + // (B) Attacker submits kind:5 e-tagging the random (nonexistent) id. + let del_b = build_deletion(&random_id_hex, &attacker); + let (accepted_b, msg_b) = submit_event_http(&client, &attacker, &del_b).await; + assert!( + !accepted_b, + "kind:5 targeting a nonexistent id must be rejected; relay said: {msg_b}" + ); + + // Oracle-closure: error strings must be byte-identical. + assert_eq!( + msg_a, msg_b, + "kind:5 rejection for real-draft-id vs random-id must be BYTE-IDENTICAL; \ + real_draft='{msg_a}', random='{msg_b}'" + ); + assert_eq!( + msg_a, "invalid: target event not found", + "expected byte-exact masking error; got: '{msg_a}'" + ); + + // Draft must still be readable by the author — not deleted. + let draft_filter = nostr::Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(author.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + let drafts = + query_events_http(&client, &author.public_key().to_hex(), vec![draft_filter]).await; + assert_eq!( + drafts.len(), + 1, + "draft must still be readable by author after attacker's rejected kind:5 deletion" + ); + assert_eq!( + drafts[0]["id"].as_str().unwrap(), + draft_id_hex, + "draft head must be the original draft — attacker's kind:5 must not have altered it" + ); +} + +#[tokio::test] +#[ignore] +async fn test_reminder_target_reaction_oracle_closed() { + // Behavioral companion to test_draft_target_reaction_oracle_closed for + // kind:30300 (NIP-ER reminders). This proves the author-only mask in + // derive_reaction_channel generalizes over ALL AUTHOR_ONLY_KINDS, not + // just kind:31234 drafts. + // + // Attacker reacts (kind:7) to: + // (A) a real kind:30300 reminder event id authored by `author` + // (B) a random 64-hex id that definitely does not exist + // + // Both must return the SAME byte-identical error string. After each + // rejected submission, the attacker queries for kind:7 events that + // e-tag the target id and asserts zero are stored. + // + // Reminders are global (no channel association), so the test does not + // need a channel and the relay must fire the author-only guard before + // any channel-derivation logic. + let client = http_client(); + let author = Keys::generate(); + let attacker = Keys::generate(); + + // Author publishes a kind:30300 reminder. Minimal valid shape: d tag + alt tag. + let d = uuid::Uuid::new_v4().to_string(); + let reminder = + nostr::EventBuilder::new(nostr::Kind::Custom(30300), "nip44-ciphertext-placeholder") + .tags([ + nostr::Tag::parse(["d", &d]).unwrap(), + nostr::Tag::parse(["alt", "Encrypted reminder"]).unwrap(), + ]) + .sign_with_keys(&author) + .unwrap(); + let reminder_id_hex = reminder.id.to_hex(); + let (ok_r, err_r) = submit_event_http(&client, &author, &reminder).await; + assert!(ok_r, "reminder must be accepted: {err_r}"); + + let random_id_hex = "3".repeat(64); + + let build_reaction = |target_hex: &str| { + nostr::EventBuilder::new(nostr::Kind::Custom(7), "+") + .tags([nostr::Tag::parse(["e", target_hex]).unwrap()]) + .sign_with_keys(&attacker) + .unwrap() + }; + + // (A) Attacker reacts to the real reminder id. + let reaction_a = build_reaction(&reminder_id_hex); + let (accepted_a, msg_a) = submit_event_http(&client, &attacker, &reaction_a).await; + assert!( + !accepted_a, + "reaction to a real reminder id must be rejected; relay said: {msg_a}" + ); + + // (B) Attacker reacts to the random (nonexistent) id. + let reaction_b = build_reaction(&random_id_hex); + let (accepted_b, msg_b) = submit_event_http(&client, &attacker, &reaction_b).await; + assert!( + !accepted_b, + "reaction to a nonexistent id must be rejected; relay said: {msg_b}" + ); + + // Oracle-closure: error strings must be byte-identical. + assert_eq!( + msg_a, msg_b, + "reaction rejection for real-reminder-id vs random-id must be BYTE-IDENTICAL; \ + real_reminder='{msg_a}', random='{msg_b}'" + ); + assert_eq!( + msg_a, "invalid: reaction target event not found", + "expected byte-exact masking error for reminder target; got: '{msg_a}'" + ); + + // Post-rejection storage check: no kind:7 events e-tagging the reminder id + // must be stored. + let kind7_filter = nostr::Filter::new() + .kind(nostr::Kind::Custom(7)) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::E), + reminder_id_hex.as_str(), + ); + let kind7_as_attacker = query_events_http( + &client, + &attacker.public_key().to_hex(), + vec![kind7_filter.clone()], + ) + .await; + assert!( + kind7_as_attacker.is_empty(), + "zero kind:7 events referencing the reminder id must be stored after attacker's \ + rejected reaction attempt; found: {kind7_as_attacker:?}" + ); + let kind7_as_author = + query_events_http(&client, &author.public_key().to_hex(), vec![kind7_filter]).await; + assert!( + kind7_as_author.is_empty(), + "author-side query must also return zero kind:7 events referencing the reminder id; \ + found: {kind7_as_author:?}" + ); +} + +// ─── Read-time expiry suppression (short-lived expiration tag) ──────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_expired_by_client_tag_suppressed_on_http_query() { + // A draft with a short future `expiration` tag must be served immediately + // after ingest, then suppressed on /query once the tag lapses. A fresh + // draft (no expiration) must remain visible throughout. + // + // Bite check: if `draft_expired` were removed from `reader_can_receive_event`, + // the expired draft would persist alongside the fresh one — the final `ids` + // assertion would fail. + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d_expiring = uuid::Uuid::new_v4().to_string(); + let d_fresh = uuid::Uuid::new_v4().to_string(); + + let expiring = build_expiring_draft(&owner, &d_expiring, &ch_id, 10); + let expiring_id = expiring.id; + let (ok_e, msg_e) = submit_event_http(&client, &owner, &expiring).await; + assert!(ok_e, "expiring draft must be accepted at ingest: {msg_e}"); + + let fresh = build_draft(&owner, &d_fresh, "9", &ch_id, &fake_nip44_v2()); + let fresh_id = fresh.id; + let (ok_f, msg_f) = submit_event_http(&client, &owner, &fresh).await; + assert!(ok_f, "fresh draft must be accepted: {msg_f}"); + + // Poll until the expiring draft drops from /query (timeout 20s). + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(owner.public_key()); + let deadline = std::time::Instant::now() + Duration::from_secs(20); + loop { + let events = + query_events_http(&client, &owner.public_key().to_hex(), vec![filter.clone()]).await; + let ids: Vec = events + .iter() + .filter_map(|e| e["id"].as_str().map(String::from)) + .collect(); + if !ids.contains(&expiring_id.to_hex()) { + // Suppressed — verify the fresh draft is still present. + assert!( + ids.contains(&fresh_id.to_hex()), + "fresh draft must appear in self-authored /query; got: {ids:?}" + ); + break; + } + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for expiring draft to be suppressed on /query" + ); + tokio::time::sleep(Duration::from_millis(500)).await; + } +} + +#[tokio::test] +#[ignore] +async fn test_draft_expired_not_counted_on_count_surface() { + // COUNT of self-authored drafts must exclude expired drafts. + // + // This validates the COUNT fast-path bypass: `filter_can_match_draft` forces + // the per-event fallback path (which runs `reader_can_receive_event` including + // `draft_expired`) instead of the raw SQL `count_events()` that cannot see + // per-event expiry. Without the bypass, `count_events()` would return 2 (both + // drafts in storage) — the `count == 1` assertion proves the test bites. + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d_expiring = uuid::Uuid::new_v4().to_string(); + let d_fresh = uuid::Uuid::new_v4().to_string(); + + let expiring = build_expiring_draft(&owner, &d_expiring, &ch_id, 10); + let (ok_e, msg_e) = submit_event_http(&client, &owner, &expiring).await; + assert!(ok_e, "expiring draft must be accepted at ingest: {msg_e}"); + + let fresh = build_draft(&owner, &d_fresh, "9", &ch_id, &fake_nip44_v2()); + let (ok_f, msg_f) = submit_event_http(&client, &owner, &fresh).await; + assert!(ok_f, "fresh draft must be accepted: {msg_f}"); + + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(owner.public_key()); + + // Poll until COUNT drops to 1 (timeout 20s). + let deadline = std::time::Instant::now() + Duration::from_secs(20); + loop { + let resp = client + .post(format!("{}/count", relay_http_url())) + .header("X-Pubkey", &owner.public_key().to_hex()) + .header("Content-Type", "application/json") + .json(&vec![filter.clone()]) + .send() + .await + .expect("count request"); + assert!( + resp.status().is_success(), + "author COUNT must succeed, got: {}", + resp.status() + ); + let body: Value = resp.json().await.expect("parse count response"); + let count = body["count"].as_u64().unwrap_or(u64::MAX); + if count == 1 { + break; + } + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for COUNT to exclude expired draft; last count: {count}" + ); + tokio::time::sleep(Duration::from_millis(500)).await; + } +} + +#[tokio::test] +#[ignore] +async fn test_draft_expired_suppressed_on_channel_window() { + // An expired draft must also be absent from the channel-window (`top_level:true`) + // bridge path. This surface previously bypassed `reader_can_receive_event` — + // this test proves the fix bites. + // + // Bite check: if the `reader_can_receive_event` gate in `handle_channel_window_filter` + // were removed, the expired draft would appear in the window response and this + // test would fail. + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d_expiring = uuid::Uuid::new_v4().to_string(); + let d_fresh = uuid::Uuid::new_v4().to_string(); + + let expiring = build_expiring_draft(&owner, &d_expiring, &ch_id, 10); + let expiring_id = expiring.id; + let (ok_e, msg_e) = submit_event_http(&client, &owner, &expiring).await; + assert!(ok_e, "expiring draft must be accepted at ingest: {msg_e}"); + + let fresh = build_draft(&owner, &d_fresh, "9", &ch_id, &fake_nip44_v2()); + let fresh_id = fresh.id; + let (ok_f, msg_f) = submit_event_http(&client, &owner, &fresh).await; + assert!(ok_f, "fresh draft must be accepted: {msg_f}"); + + // Poll channel-window until the expiring draft drops (timeout 20s). + let deadline = std::time::Instant::now() + Duration::from_secs(20); + loop { + let events = query_channel_window_mixed(&owner, &ch_id, false, false).await; + let ids: Vec = events + .iter() + .filter_map(|e| e["id"].as_str().map(String::from)) + .collect(); + if !ids.contains(&expiring_id.to_hex()) { + // Suppressed — verify the fresh draft is still present. + assert!( + ids.contains(&fresh_id.to_hex()), + "fresh draft must appear in channel-window; got: {ids:?}" + ); + break; + } + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for expiring draft to be suppressed on channel-window" + ); + tokio::time::sleep(Duration::from_millis(500)).await; + } +} diff --git a/migrations/0012_draft_wrap_fts.sql b/migrations/0012_draft_wrap_fts.sql new file mode 100644 index 0000000000..f4e7812a10 --- /dev/null +++ b/migrations/0012_draft_wrap_fts.sql @@ -0,0 +1,73 @@ +-- Exclude kind 31234 (NIP-37 draft wraps) from full-text search. +-- +-- NIP-37 draft wraps carry NIP-44-v2 ciphertext in `content` (or empty string +-- for deletion tombstones). Indexing ciphertext would waste storage and violate +-- the "channel/DM context lives only inside the encrypted payload" invariant +-- that makes draft wraps author-private. The relay also must not index +-- plaintext compose context through any search surface. +-- +-- Conditional migration: fresh installs already have the positive FTS allowlist +-- from 0008 (kind IN (0, 9, 40002, 45001, 45003)), which excludes 31234 by +-- omission. Populated databases still on the legacy negative blocklist need +-- the column rewritten to add 31234 to the exclusion list. This migration +-- inspects the live column expression and acts accordingly: +-- - Allowlist form (ELSE NULL shape) → no-op, 31234 already unsearchable. +-- - Legacy blocklist form (ELSE to_tsvector shape) → DROP + re-ADD with 31234. +-- +-- DEPLOY NOTE: the DROP + re-ADD path acquires ACCESS EXCLUSIVE on `events` +-- for the duration of the migration, blocking all reads and writes to the +-- table. The GIN index rebuild that follows is a full table scan. On large +-- deployments this migration should run during a scheduled maintenance window. +-- This is the same shape as migration 0005 and was accepted as precedent. +-- The no-op path (fresh installs) does not escalate to ACCESS EXCLUSIVE. +-- +-- Final kind exclusion list after this migration (legacy path): +-- 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) +-- 31234 = KIND_DRAFT (NIP-37: AUTHOR_ONLY_KINDS — ciphertext or tombstone) +-- 44100 = KIND_MEMBER_ADDED_NOTIFICATION (p-gated membership notice) +-- 44101 = KIND_MEMBER_REMOVED_NOTIFICATION (p-gated membership notice) +-- 44200 = KIND_AGENT_TURN_METRIC (NIP-AM: p-gated encrypted turn metrics) +-- Constants kept in `buzz_core::kind`; inlined here because a sqlx migration +-- is frozen SQL and cannot import the Rust constant. If a new privacy-sensitive +-- kind is added there, add a new additive migration following this pattern and +-- add a regression test in `buzz-search/tests/fts_integration.rs`. +-- +-- NULL tsvector never matches `@@`, so excluded rows are storage-level +-- unsearchable. + +LOCK TABLE events IN SHARE ROW EXCLUSIVE MODE; + +DO $$ +DECLARE + expr text; +BEGIN + SELECT pg_get_expr(adbin, adrelid) INTO expr + FROM pg_attrdef + WHERE adrelid = 'events'::regclass + AND adnum = (SELECT attnum FROM pg_attribute + WHERE attrelid = 'events'::regclass + AND attname = 'search_tsv'); + + -- The positive allowlist form (0008) falls through to ELSE NULL::tsvector + -- for unlisted kinds, while the legacy blocklist uses THEN NULL::tsvector + -- for excluded kinds and ELSE to_tsvector(...) for everything else. + -- Detecting the allowlist shape (ELSE NULL) is robust against future kind + -- list changes in either form. + IF expr IS NOT NULL AND expr LIKE '%ELSE NULL%' THEN + RAISE NOTICE '0012_draft_wrap_fts: allowlist expression detected, 31234 already excluded — no-op'; + RETURN; + END IF; + + -- Legacy blocklist form: rewrite to add 31234 to the exclusion list. + ALTER TABLE events DROP COLUMN search_tsv; + ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS ( + CASE WHEN kind IN (1059, 30300, 30622, 31234, 44100, 44101, 44200) THEN NULL::tsvector + ELSE to_tsvector('simple', content) + END + ) STORED; + + -- Recreate the GIN index dropped with the column. + CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); +END $$; diff --git a/schema/schema.sql b/schema/schema.sql index e31347d726..6734557b30 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -206,9 +206,9 @@ CREATE TABLE events ( -- 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 (final state: 0001 + 0005_agent_turn_metric_fts). + -- Keep in sync with migrations (final state: 0001 + 0005 + 0006 + 0008 allowlist or 0012 legacy-blocklist-extended). search_tsv TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (1059, 30300, 30622, 44100, 44101, 44200) THEN NULL::tsvector + CASE WHEN kind IN (1059, 30300, 30622, 31234, 44100, 44101, 44200) THEN NULL::tsvector ELSE to_tsvector('simple', content) END ) STORED,