From 79a9a89a4de77371cedbc992fd49f83e01cd204c Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Sat, 11 Jul 2026 12:27:11 -0400 Subject: [PATCH 01/20] feat(relay): add NIP-37 draft wrap support (kind:31234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add kind:31234 as an author-only, channel-less, parameterized-replaceable event kind for encrypted draft wraps per NIP-37. Privacy enforcement spans every relay read path: - WS REQ: AUTHOR_ONLY_KINDS gate closes the subscription with restricted: for any requester who isn't the author - WS COUNT: same gate applied before the count query executes - HTTP bridge /query + /count: post-filter and guard use AUTHOR_ONLY_KINDS - Live fan-out: AUTHOR_ONLY_KINDS check in dispatch_persistent_event_inner prevents draft events from being pushed to non-author subscribers - FTS (NIP-50): migration 0007 sets search_tsv = NULL for kind:31234, making drafts storage-level unsearchable Ingest validation (validate_draft_wrap_envelope): - Exactly one non-empty d tag (any bounded value; relay is grammar-agnostic) - Exactly one k tag with canonical u16 decimal (no leading zeros, fits u16) - No h or p outer tags (compose context belongs in encrypted payload only) - Content: empty string (tombstone) or NIP-44 v2 ciphertext shape check - Optional expiration: at most one, decimal, strictly future, ≤ safe integer NIP-11 now advertises NIP-37. NIP-40 is intentionally not advertised because Buzz does not yet suppress expired rows on read. Schema migration 0007 extends the search_tsv generated column exclusion list with kind 31234. New tests: - 23 unit tests for validate_draft_wrap_envelope in ingest.rs covering every acceptance and rejection path - Comprehensive E2E test suite in e2e_nip37_draft.rs covering write validation, NIP-01 replacement ordering, tombstone persistence, author-only REQ/COUNT/HTTP, kindless/mixed filter privacy, known-d privacy tripwires, live fan-out isolation, and NIP-11 advertisement Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .github/workflows/ci.yml | 9 + crates/buzz-core/src/kind.rs | 25 +- crates/buzz-db/src/migration.rs | 20 +- crates/buzz-relay/src/handlers/event.rs | 12 +- crates/buzz-relay/src/handlers/ingest.rs | 434 +++++- crates/buzz-relay/src/nip11.rs | 21 +- crates/buzz-search/tests/fts_integration.rs | 8 +- .../buzz-test-client/tests/e2e_nip37_draft.rs | 1213 +++++++++++++++++ migrations/0012_draft_wrap_fts.sql | 37 + schema/schema.sql | 4 +- 10 files changed, 1757 insertions(+), 26 deletions(-) create mode 100644 crates/buzz-test-client/tests/e2e_nip37_draft.rs create mode 100644 migrations/0012_draft_wrap_fts.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d8b5d378d..d54c3cfd3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -610,6 +610,15 @@ 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): write-path + # validation, NIP-01 replacement/tombstone ordering, author-only privacy + # across all read paths (WS REQ, WS COUNT, HTTP /query, /count, live + # fan-out), known-d privacy tripwires, FTS/NIP-50 exclusion, 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: Upload relay log if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 495ced0b6f..01763b5b07 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -101,16 +101,31 @@ 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 +/// no channel scope (`channel_id = NULL`); compose context (channel, 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; + /// 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/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-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 9e3bb05d86..8182d429b0 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(), @@ -506,6 +507,11 @@ async fn dispatch_persistent_event_inner( && !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. Excluded here explicitly + // because today's channel-less engine no-ops on them, but this guard is the + // invariant that must hold for any future workflow expansion. + && !AUTHOR_ONLY_KINDS.contains(&kind_u32) { let workflow_engine = Arc::clone(&state.workflow_engine); let workflow_event = stored_event.clone(); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 8ddcd56e4f..18ea4b2ead 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -15,18 +15,18 @@ use buzz_core::kind::{ 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, + 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 global 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. @@ -402,6 +404,11 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { // NIP-AM: agent turn metrics are owner-scoped global events. // Channel identity is encrypted inside the payload — no `h` tag. | KIND_AGENT_TURN_METRIC + // NIP-37: draft wraps are author-private global events. + // Compose context (channel, DM, reply target) is encrypted inside + // the payload — no outer `h` tag. A stray `h` tag is rejected at + // validation time (see `validate_draft_wrap_envelope`). + | KIND_DRAFT ) } @@ -1192,6 +1199,147 @@ 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. No outer `h` tag — compose context is encrypted inside the payload. +/// 4. No outer `p` tag — prevents this event from entering the mention/feed index. +/// 5. 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. +/// 6. 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 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" => { + return Err( + "draft-wrap event must not have an `h` tag (compose context belongs inside the encrypted payload)".to_string(), + ); + } + "p" => { + return Err( + "draft-wrap event must not have a `p` tag (prevents mention/feed indexing)" + .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 `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. /// @@ -1900,6 +2048,11 @@ async fn ingest_event_inner( .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } + if kind_u32 == KIND_DRAFT { + validate_draft_wrap_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + if kind_u32 == KIND_PERSONA { validate_persona_envelope(&event) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; @@ -3386,4 +3539,261 @@ 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 ev = make_draft(&[&["d", &d], &["k", "9"]], &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 ev = make_draft(&[&["d", &d], &["k", "9"]], ""); + 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 ev = make_draft(&[&["d", &d], &["k", k]], ""); + 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 ev = make_draft(&[&["k", "9"]], &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 ev = make_draft(&[&["d", ""], &["k", "9"]], &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 ev = make_draft(&[&["d", &d], &["d", &d], &["k", "9"]], &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 ev = make_draft(&[&["d", &d]], &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 ev = make_draft(&[&["d", &d], &["k", "9"], &["k", "9"]], &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 ev = make_draft(&[&["d", &d], &["k", "0x9"]], &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 ev = make_draft(&[&["d", &d], &["k", "09"]], &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(); + // 65536 = u16::MAX + 1 + let ev = make_draft(&[&["d", &d], &["k", "65536"]], &fake_nip44_v2()); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("range"), "got: {err}"); + } + + // ── h / p outer-tag exclusion ───────────────────────────────────────────── + + #[test] + fn draft_wrap_rejects_h_tag() { + let d = uuid::Uuid::new_v4().to_string(); + let ev = make_draft( + &[ + &["d", &d], + &["k", "9"], + &["h", &uuid::Uuid::new_v4().to_string()], + ], + &fake_nip44_v2(), + ); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("`h` tag"), "got: {err}"); + } + + #[test] + fn draft_wrap_rejects_p_tag() { + let d = uuid::Uuid::new_v4().to_string(); + let keys = nostr::Keys::generate(); + let ev = make_draft( + &[&["d", &d], &["k", "9"], &["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 ev = make_draft(&[&["d", &d], &["k", "9"]], "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(); + // 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"]], &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 ev = make_draft(&[&["d", &d], &["k", "9"]], "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(); + // A timestamp far in the future (year 2100). + let ev = make_draft( + &[&["d", &d], &["k", "9"], &["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 ev = make_draft( + &[ + &["d", &d], + &["k", "9"], + &["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 ev = make_draft( + &[&["d", &d], &["k", "9"], &["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 ev = make_draft( + &[&["d", &d], &["k", "9"], &["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_is_global_only() { + // Draft wraps must never be channel-scoped; compose context lives in + // the encrypted payload only. + assert!( + is_global_only_kind(KIND_DRAFT), + "KIND_DRAFT must be global-only (no h-tag channel scope)" + ); + } + + #[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" + ); + } + + #[test] + fn draft_wrap_does_not_require_h_tag() { + assert!( + !requires_h_channel_scope(KIND_DRAFT), + "KIND_DRAFT must not require an h 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..0e472b99ac 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,7 @@ 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_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()); @@ -77,6 +78,9 @@ async fn setup() -> (PgPool, String) { pool.execute(MIGRATION_0008_SQL) .await .expect("apply 0008 migration"); + pool.execute(MIGRATION_0012_SQL) + .await + .expect("apply 0012 migration"); (pool, schema) } 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..61fbfccdad --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_nip37_draft.rs @@ -0,0 +1,1213 @@ +//! End-to-end integration tests for NIP-37 draft wraps (kind:31234). +//! +//! These tests verify: +//! - Write-path validation: d/k tag rules, h/p rejection, expiration, +//! ciphertext validation, blank tombstone acceptance, oversized d +//! - Replacement ordering: NIP-01 last-write-wins, same-second tie-break +//! (lower lexicographic event ID wins), stale write cannot supersede +//! current head, tombstone replaces live draft as addressable head +//! - Author-only reads: REQ, WS COUNT, WS subscription, HTTP /query, /count +//! all confine drafts to their author — exclusive, mixed/kindless, ids, +//! known-#d filters, search/FTS, fan-out +//! - known-#d privacy tripwires: attacker knowing the `d` value does NOT +//! retrieve or count the draft via exclusive or kindless #d filters +//! - FTS / NIP-50: draft content is never surfaced in search results +//! - NIP-11: relay advertises NIP-37, does not advertise NIP-40 +//! +//! # Running +//! +//! Start the relay, then run: +//! +//! ```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; + +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 +} + +/// Build a valid kind:31234 draft wrap event with given timestamps. +fn build_draft_at( + keys: &Keys, + d_tag: &str, + k_val: &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(), + ]) + .custom_created_at(ts) + .sign_with_keys(keys) + .unwrap() +} + +/// Build a valid kind:31234 draft wrap event at current time. +fn build_draft(keys: &Keys, d_tag: &str, k_val: &str, content: &str) -> nostr::Event { + build_draft_at(keys, d_tag, k_val, content, Timestamp::now()) +} + +/// Build a blank-content tombstone (NIP-37 deletion) for a draft address. +fn build_tombstone(keys: &Keys, d_tag: &str, k_val: &str, ts: Timestamp) -> nostr::Event { + build_draft_at(keys, d_tag, k_val, "", ts) +} + +/// 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") +} + +// ─── Ingest validation ──────────────────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_accepted_with_ciphertext_content() { + let client = http_client(); + let keys = Keys::generate(); + let d_tag = uuid::Uuid::new_v4().to_string(); + let event = build_draft(&keys, &d_tag, "9", &fake_nip44_v2()); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(accepted, "valid draft rejected: {msg}"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_accepted_blank_tombstone() { + let client = http_client(); + let keys = Keys::generate(); + let d_tag = uuid::Uuid::new_v4().to_string(); + let event = build_tombstone(&keys, &d_tag, "9", Timestamp::now()); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(accepted, "blank tombstone rejected: {msg}"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_accepted_future_expiration() { + let client = http_client(); + let keys = Keys::generate(); + let d_tag = uuid::Uuid::new_v4().to_string(); + 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(["expiration", "4102444800"]).unwrap(), // year 2100 + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &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 keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) + .tags([Tag::parse(["k", "9"]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &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 keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) + .tags([ + Tag::parse(["d", ""]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &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 keys = Keys::generate(); + // D_TAG_MAX_LEN is 255 bytes in buzz-db. Use 256 'a' chars. + let d_tag = "a".repeat(256); + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) + .tags([ + Tag::parse(["d", &d_tag]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &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 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(["d", &d]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &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 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()]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &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 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(["k", "9"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &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 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", "0x9"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &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 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", "09"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &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 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", "65536"]).unwrap(), // u16::MAX + 1 + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &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_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", &uuid::Uuid::new_v4().to_string()]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(!accepted, "h tag on draft should be rejected"); + assert!(msg.contains("h` tag"), "unexpected message: {msg}"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_p_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(["p", &keys.public_key().to_hex()]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &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_malformed_ciphertext() { + let client = http_client(); + let keys = Keys::generate(); + 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(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &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 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(["expiration", "1000000000"]).unwrap(), // long past + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &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 keys = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + + // Use timestamps offset from now so they pass ±15-min ingest gate. + let now = Timestamp::now().as_secs(); + let t0 = Timestamp::from(now - 2); + let t1 = Timestamp::from(now - 1); + + let v1 = build_draft_at(&keys, &d, "9", &fake_nip44_v2(), t0); + let v2 = build_draft_at(&keys, &d, "9", &fake_nip44_v2(), t1); + let v2_id = v2.id; + + let (ok1, msg1) = submit_event_http(&client, &keys, &v1).await; + assert!(ok1, "v1 must be accepted: {msg1}"); + let (ok2, msg2) = submit_event_http(&client, &keys, &v2).await; + assert!(ok2, "v2 must be accepted: {msg2}"); + + // Author queries by #d — only the latest should be returned. + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(keys.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + let results = query_events_http(&client, &keys.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 keys = Keys::generate(); + 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(&keys, &d, "9", &fake_nip44_v2(), t_new); + let v_old = build_draft_at(&keys, &d, "9", &fake_nip44_v2(), t_old); + + // Submit new first, then try to replace with stale. + let (ok_n, msg_n) = submit_event_http(&client, &keys, &v_new).await; + assert!(ok_n, "newer draft must be accepted: {msg_n}"); + let (ok_o, msg_o) = submit_event_http(&client, &keys, &v_old).await; + // Relay may accept (duplicate) or reject the old event — either is correct; + // what matters is that the returned head is still the newer one. + let _ = (ok_o, msg_o); + + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(keys.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + let results = query_events_http(&client, &keys.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 keys = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + + let now = Timestamp::now(); + // Generate candidates until we have two with different IDs at the same ts. + // Sign 10 candidates and pick the lexically lowest and highest pair. + let mut candidates = Vec::new(); + for _ in 0..10 { + let e = build_draft_at(&keys, &d, "9", &fake_nip44_v2(), now); + candidates.push(e); + } + candidates.sort_by(|a, b| a.id.to_hex().cmp(&b.id.to_hex())); + let lowest = candidates.first().unwrap().clone(); + let highest = candidates.last().unwrap().clone(); + + if lowest.id == highest.id { + // Extremely unlikely — skip rather than fail. + return; + } + + // Submit highest first, then lowest. + let (ok_h, msg_h) = submit_event_http(&client, &keys, &highest).await; + assert!(ok_h, "highest-id draft must be accepted: {msg_h}"); + let (ok_l, msg_l) = submit_event_http(&client, &keys, &lowest).await; + assert!(ok_l, "lowest-id draft must be accepted: {msg_l}"); + + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(keys.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + let results = query_events_http(&client, &keys.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 keys = Keys::generate(); + 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(); // strictly newer + + let draft = build_draft_at(&keys, &d, "9", &fake_nip44_v2(), t_draft); + let tombstone = build_tombstone(&keys, &d, "9", t_tomb); + let tomb_id = tombstone.id; + + let (ok_d, msg_d) = submit_event_http(&client, &keys, &draft).await; + assert!(ok_d, "draft must be accepted: {msg_d}"); + let (ok_t, msg_t) = submit_event_http(&client, &keys, &tombstone).await; + assert!(ok_t, "tombstone must be accepted: {msg_t}"); + + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(keys.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + let results = query_events_http(&client, &keys.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 keys = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&keys, &d, "9", &fake_nip44_v2()); + let draft_id = draft.id; + let (ok, msg) = submit_event_http(&client, &keys, &draft).await; + assert!(ok, "draft must be accepted: {msg}"); + + let mut c = BuzzTestClient::connect(&url, &keys) + .await + .expect("connect author"); + let sid = sub_id("author-req"); + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(keys.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() { + // Victim stores a draft; attacker queries {kinds:[31234], authors:[victim]}. + // The relay must CLOSE the subscription with "restricted:". + let url = relay_url(); + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &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 msg = ac + .recv_event(Duration::from_secs(5)) + .await + .expect("recv response"); + match 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_kindless_filter_ws() { + // Victim stores a draft; attacker issues a kindless filter. + // Draft must be silently omitted. A public kind:0 event provides a + // positive control — the attacker MUST receive that but NOT the draft. + let url = relay_url(); + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + + // Victim publishes a kind:0 profile event (public) and a draft (private). + 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}"); + + let draft = build_draft(&victim, &d, "9", &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}"); + + let mut ac = BuzzTestClient::connect(&url, &attacker) + .await + .expect("connect attacker"); + let sid = sub_id("attacker-kindless"); + // Kindless filter targeting victim's pubkey. + let filter = Filter::new().author(victim.public_key()).limit(50); + ac.subscribe(&sid, vec![filter]).await.expect("subscribe"); + let results = ac + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + // Positive control: profile must be present. + assert!( + results.iter().any(|e| e.id == profile_id), + "attacker must receive victim's public profile event" + ); + // Privacy gate: draft must be absent. + assert!( + !results.iter().any(|e| e.id == draft_id), + "kindless 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() { + // Knowing the exact event ID of a draft must not grant access. + let url = relay_url(); + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &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() { + // Attacker queries {kinds:[31234], authors:[victim], #d:[known_d]}. + // The relay must CLOSE the subscription with "restricted:". + let url = relay_url(); + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &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_by_known_d_tag_kindless_ws() { + // Attacker queries {#d:[known_d]} — kindless, no authors filter. + // Draft must be silently omitted; a public kind:9 message on the same + // d-value (different kind, different event) provides a positive control + // that the attacker can receive from a public channel. + let url = relay_url(); + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &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("d-kindless"); + // Kindless #d filter — this is the dictionary-attack vector for draft addresses. + let filter = Filter::new().custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + 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), + "kindless #d filter must not expose victim's draft to attacker" + ); + ac.disconnect().await.expect("disconnect"); +} + +// ─── COUNT privacy gates ────────────────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_attacker_cannot_count_exclusive_ws() { + // WS COUNT: {kinds:[31234], authors:[victim]} must be CLOSED with restricted:. + let url = relay_url(); + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &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() { + // WS COUNT: {kinds:[31234], authors:[victim], #d:[known]} must be CLOSED. + let url = relay_url(); + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &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() { + // HTTP /count: {kinds:[31234], authors:[victim]} must return 403. + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &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() { + // Author's own HTTP /count must succeed and return ≥1. + let client = http_client(); + let keys = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&keys, &d, "9", &fake_nip44_v2()); + let (ok, msg) = submit_event_http(&client, &keys, &draft).await; + assert!(ok, "draft must be accepted: {msg}"); + + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(keys.public_key()); + let resp = client + .post(format!("{}/count", relay_http_url())) + .header("X-Pubkey", &keys.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"); +} + +// ─── HTTP /query exclusive-author privacy ──────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_attacker_cannot_query_exclusive_http() { + // HTTP /query: exclusive other-author draft query must return 403. + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &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() { + // Attacker subscribes to a mixed filter BEFORE the draft is published. + // They must NOT receive the draft in live fan-out. They MUST receive a + // public control event (kind:0 profile) from the same author — this + // proves fan-out is working and the draft was specifically excluded. + let url = relay_url(); + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + + // Attacker subscribes to victim's events using a MIXED filter + // (not exclusively kind:31234, so it won't be immediately CLOSED). + 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); // live only, no stored events + ac.subscribe(&sid_fanout, vec![filter]) + .await + .expect("subscribe to mixed filter"); + // Drain EOSE. + let _ = ac + .collect_until_eose(&sid_fanout, Duration::from_secs(3)) + .await; + + // Victim publishes a draft — must NOT reach attacker via fan-out. + let draft = build_draft(&victim, &d, "9", &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}"); + + // Victim also publishes a public profile event — MUST reach attacker. + 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}"); + + // Drain messages briefly and check what arrived. + 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"); +} + +// ─── FTS / NIP-50 exclusion ─────────────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_not_indexed_in_fts_search() { + // The relay stores search_tsv = NULL for kind:31234. Even if we could + // search by the author, the draft must never surface in NIP-50 results. + // We use the FTS HTTP query path as an attacker to verify. + let client = http_client(); + let victim = Keys::generate(); + let attacker = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + + // Publish a kind:1 text note with a unique marker as a positive control. + let marker = format!("nip37fts_probe_{}", uuid::Uuid::new_v4().simple()); + let note = EventBuilder::new(Kind::TextNote, &marker) + .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}"); + + // Publish a draft from the same author. + let draft = build_draft(&victim, &d, "9", &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}"); + + // NIP-50 search as the victim — the kind:1 note must appear; the draft must not. + let search_filter = Filter::new().search(&marker).limit(50); + let results = + query_events_http(&client, &victim.public_key().to_hex(), vec![search_filter]).await; + + // Positive control: the text note must be found. + assert!( + results + .iter() + .any(|e| e["id"].as_str() == Some(¬e_id.to_hex())), + "FTS must index the control kind:1 note (positive control)" + ); + + // Privacy gate: draft must never appear in search results. + 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" + ); + + // Searching as the attacker must also not expose the draft. + let search_filter2 = Filter::new().search(&marker).limit(50); + let attacker_results = query_events_http( + &client, + &attacker.public_key().to_hex(), + vec![search_filter2], + ) + .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:?}" + ); +} diff --git a/migrations/0012_draft_wrap_fts.sql b/migrations/0012_draft_wrap_fts.sql new file mode 100644 index 0000000000..cb31cff62b --- /dev/null +++ b/migrations/0012_draft_wrap_fts.sql @@ -0,0 +1,37 @@ +-- 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. +-- +-- Additive migration: previously applied files must not change checksum. +-- We must DROP the generated column and re-ADD it with the extended exclusion +-- list; ALTER COLUMN cannot change a GENERATED expression in Postgres. +-- +-- Final kind exclusion list after this migration: +-- 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. + +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); diff --git a/schema/schema.sql b/schema/schema.sql index e31347d726..880809d5b3 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_agent_turn_metric_fts + 0006_moderation + 0007_draft_wrap_fts). 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, From 49fce899522185d04c026490c583d9ebcf24d844 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Sat, 11 Jul 2026 13:07:14 -0400 Subject: [PATCH 02/20] feat(relay): rework NIP-37 drafts to channel-bound contract Draft wraps (kind:31234) now require exactly one `h` UUID tag binding them to a specific Buzz channel or DM. The relay enforces: - Exactly one valid UUID `h` tag on every kind:31234 event - Channel existence: the `h` UUID must resolve to a live channel - Membership: author must be a member of that channel at write time - Immutable binding: once a (author, d_tag) draft is written to channel A, replacement events must carry the same h=A; rebinding to a different channel is rejected at the ingest layer The previous channel-less/global-state design is removed. Draft fan-out already applied the author-only gate (AUTHOR_ONLY_KINDS); with channel_id now non-NULL for kind:31234, the existing channel visibility/membership filter in fan-out applies naturally with no additional changes. E2E test suite rewritten for the channel-bound contract: - h-tag validation: missing, duplicate, non-UUID, nonexistent channel - Non-member author rejection + removed-member regression - Immutable binding: rebind rejected, same-channel replacement accepted - Author-only reads: WS REQ/COUNT, HTTP /query, /count, live fan-out - known-#d privacy tripwires (exclusive and kindless) - Tombstone head queryable by author, tombstone replaces live draft - NIP-01 same-second tie-break (distinct candidates enforced) - Stale write cannot supersede current head - Workflow / channel kindless query exclusion - Tenant confinement (alien channel rejected) - FTS exclusion (NULL search_tsv confirmed) - NIP-11 advertises NIP-37, not NIP-40 Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .github/workflows/ci.yml | 11 +- crates/buzz-db/src/event.rs | 32 + crates/buzz-db/src/lib.rs | 14 + crates/buzz-relay/src/handlers/ingest.rs | 245 ++++- .../buzz-test-client/tests/e2e_nip37_draft.rs | 874 +++++++++++++----- 5 files changed, 903 insertions(+), 273 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d54c3cfd3e..c4e5f9dbc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -611,11 +611,12 @@ jobs: env: RELAY_URL: ws://localhost:3000 - name: NIP-37 draft wrap e2e - # Feature e2e for NIP-37 draft wraps (kind:31234): write-path - # validation, NIP-01 replacement/tombstone ordering, author-only privacy - # across all read paths (WS REQ, WS COUNT, HTTP /query, /count, live - # fan-out), known-d privacy tripwires, FTS/NIP-50 exclusion, and NIP-11 - # advertisement. + # 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 diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 999d4e1a4f..8f5f44c16e 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -915,6 +915,38 @@ pub async fn get_latest_global_replaceable( } } +/// Fetch the `channel_id` of the current NIP-33 head for a kind:31234 draft address. +/// +/// Used by the relay ingest handler to enforce immutable channel binding: if a +/// head already exists for `(community, author, 31234, d_tag)`, its `channel_id` +/// must match the incoming event's `channel_id`. A draft cannot be re-bound to a +/// different channel. +/// +/// Returns `Ok(Some(Some(uuid)))` if a live head exists with a channel_id, +/// `Ok(Some(None))` if a head exists but has NULL channel_id (should not happen +/// in practice but handled defensively), `Ok(None)` if no live head exists, +/// `Err` on database failure. +pub async fn get_draft_head_channel_id( + pool: &PgPool, + community_id: CommunityId, + pubkey_bytes: &[u8], + d_tag: &str, +) -> Result>> { + let row: Option<(Option,)> = sqlx::query_as( + "SELECT channel_id FROM events \ + WHERE community_id = $1 AND kind = 31234 AND pubkey = $2 AND d_tag = $3 \ + AND deleted_at IS NULL \ + ORDER BY created_at DESC, id ASC LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(pubkey_bytes) + .bind(d_tag) + .fetch_optional(pool) + .await?; + + Ok(row.map(|(ch,)| ch)) +} + /// Fetches a single event by its raw 32-byte ID, **including soft-deleted rows**. /// /// Most callers should use [`get_event_by_id`] instead. This variant is needed diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 9dd7b501a8..4ec1dbb508 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -721,6 +721,20 @@ impl Db { event::get_latest_global_replaceable(&self.pool, community_id, kind, pubkey_bytes).await } + /// Fetch the `channel_id` of the current NIP-33 head for a kind:31234 draft address. + /// + /// Returns `Ok(Some(Some(uuid)))` if a live head exists with a channel_id, + /// `Ok(Some(None))` if a live head exists without a channel_id (defensive), + /// `Ok(None)` if no live head exists, `Err` on database failure. + pub async fn get_draft_head_channel_id( + &self, + community_id: CommunityId, + pubkey_bytes: &[u8], + d_tag: &str, + ) -> Result>> { + event::get_draft_head_channel_id(&self.pool, community_id, pubkey_bytes, d_tag).await + } + /// Fetches a single non-deleted event by its raw ID bytes. /// /// Returns `None` if the event does not exist or has been soft-deleted. diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 18ea4b2ead..48165e51ac 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -404,11 +404,6 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { // NIP-AM: agent turn metrics are owner-scoped global events. // Channel identity is encrypted inside the payload — no `h` tag. | KIND_AGENT_TURN_METRIC - // NIP-37: draft wraps are author-private global events. - // Compose context (channel, DM, reply target) is encrypted inside - // the payload — no outer `h` tag. A stray `h` tag is rejected at - // validation time (see `validate_draft_wrap_envelope`). - | KIND_DRAFT ) } @@ -441,6 +436,12 @@ 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 ) } @@ -1209,8 +1210,11 @@ fn validate_not_before(tag_value: &str) -> Result { /// (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. No outer `h` tag — compose context is encrypted inside the payload. +/// 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. 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. @@ -1225,6 +1229,8 @@ fn validate_draft_wrap_envelope(event: &Event) -> Result<(), String> { 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; @@ -1243,9 +1249,8 @@ fn validate_draft_wrap_envelope(event: &Event) -> Result<(), String> { k_value = Some(&parts[1]); } "h" => { - return Err( - "draft-wrap event must not have an `h` tag (compose context belongs inside the encrypted payload)".to_string(), - ); + h_count += 1; + h_value = Some(&parts[1]); } "p" => { return Err( @@ -1296,6 +1301,20 @@ fn validate_draft_wrap_envelope(event: &Event) -> Result<(), String> { "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(); + if uuid::Uuid::parse_str(h).is_err() { + return Err(format!( + "draft-wrap `h` tag must be a canonical UUID (channel or DM id), got: {h:?}" + )); + } + // Validate `expiration` tag (optional, at most one). if expiration_count > 1 { return Err(format!( @@ -2362,6 +2381,46 @@ async fn ingest_event_inner( buzz_db::event::D_TAG_MAX_LEN, ))); } + + // Immutable channel binding for kind:31234 (NIP-37 draft wraps). + // + // The NIP-33 address (community, author, 31234, d_tag) is unique, but + // channel_id is NOT part of the NIP-33 key. This means a replacement + // with a different h-tag would silently re-bind the draft to a new + // channel while still winning the NIP-01 replacement. To prevent that, + // we reject any incoming kind:31234 update whose channel_id differs + // from the stored head's channel_id. + // + // A tombstone (empty content) carries the same h-tag as the draft it + // closes — this check enforces that invariant too. + if kind_u32 == KIND_DRAFT { + let pubkey_bytes = auth.pubkey().to_bytes().to_vec(); + match state + .db + .get_draft_head_channel_id(tenant.community(), &pubkey_bytes, &d_tag) + .await + { + Ok(Some(head_ch)) => { + // A live head exists. Its channel_id must match the incoming event's. + if head_ch != channel_id { + return Err(IngestError::Rejected( + "invalid: draft-wrap channel binding is immutable — \ + `h` tag must match the existing head's channel" + .into(), + )); + } + } + Ok(None) => { + // No live head — this is the first write for this address. + } + Err(e) => { + return Err(IngestError::Internal(format!( + "error: checking draft channel binding: {e}" + ))); + } + } + } + state .db .replace_parameterized_event(tenant.community(), &event, &d_tag, channel_id) @@ -3553,7 +3612,8 @@ mod tests { #[test] fn draft_wrap_accepts_ciphertext_content() { let d = uuid::Uuid::new_v4().to_string(); - let ev = make_draft(&[&["d", &d], &["k", "9"]], &fake_nip44_v2()); + 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" @@ -3563,7 +3623,8 @@ mod tests { #[test] fn draft_wrap_accepts_blank_tombstone() { let d = uuid::Uuid::new_v4().to_string(); - let ev = make_draft(&[&["d", &d], &["k", "9"]], ""); + 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" @@ -3574,7 +3635,8 @@ mod tests { 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 ev = make_draft(&[&["d", &d], &["k", k]], ""); + 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" @@ -3586,14 +3648,16 @@ mod tests { #[test] fn draft_wrap_rejects_missing_d_tag() { - let ev = make_draft(&[&["k", "9"]], &fake_nip44_v2()); + 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 ev = make_draft(&[&["d", ""], &["k", "9"]], &fake_nip44_v2()); + 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}"); } @@ -3601,7 +3665,11 @@ mod tests { #[test] fn draft_wrap_rejects_duplicate_d_tag() { let d = uuid::Uuid::new_v4().to_string(); - let ev = make_draft(&[&["d", &d], &["d", &d], &["k", "9"]], &fake_nip44_v2()); + 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}"); } @@ -3611,7 +3679,8 @@ mod tests { #[test] fn draft_wrap_rejects_missing_k_tag() { let d = uuid::Uuid::new_v4().to_string(); - let ev = make_draft(&[&["d", &d]], &fake_nip44_v2()); + 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}"); } @@ -3619,7 +3688,11 @@ mod tests { #[test] fn draft_wrap_rejects_duplicate_k_tag() { let d = uuid::Uuid::new_v4().to_string(); - let ev = make_draft(&[&["d", &d], &["k", "9"], &["k", "9"]], &fake_nip44_v2()); + 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}"); } @@ -3627,7 +3700,8 @@ mod tests { #[test] fn draft_wrap_rejects_k_tag_non_decimal() { let d = uuid::Uuid::new_v4().to_string(); - let ev = make_draft(&[&["d", &d], &["k", "0x9"]], &fake_nip44_v2()); + 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}"); } @@ -3635,7 +3709,8 @@ mod tests { #[test] fn draft_wrap_rejects_k_tag_leading_zero() { let d = uuid::Uuid::new_v4().to_string(); - let ev = make_draft(&[&["d", &d], &["k", "09"]], &fake_nip44_v2()); + 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}"); } @@ -3643,38 +3718,34 @@ mod tests { #[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"]], &fake_nip44_v2()); + 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}"); } - // ── h / p outer-tag exclusion ───────────────────────────────────────────── + // ── p outer-tag exclusion ───────────────────────────────────────────────── + // Note: `h` is now *required* (not forbidden); see h-tag validation section. #[test] - fn draft_wrap_rejects_h_tag() { + 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", &uuid::Uuid::new_v4().to_string()], + &["h", &ch], + &["p", &keys.public_key().to_hex()], ], &fake_nip44_v2(), ); let err = validate_draft_wrap_envelope(&ev).unwrap_err(); - assert!(err.contains("`h` tag"), "got: {err}"); - } - - #[test] - fn draft_wrap_rejects_p_tag() { - let d = uuid::Uuid::new_v4().to_string(); - let keys = nostr::Keys::generate(); - let ev = make_draft( - &[&["d", &d], &["k", "9"], &["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}"); } @@ -3683,7 +3754,8 @@ mod tests { #[test] fn draft_wrap_rejects_non_base64_content() { let d = uuid::Uuid::new_v4().to_string(); - let ev = make_draft(&[&["d", &d], &["k", "9"]], "not-a-ciphertext"); + 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"), @@ -3694,9 +3766,10 @@ mod tests { #[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"]], &bad); + 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"), @@ -3708,7 +3781,8 @@ mod tests { 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 ev = make_draft(&[&["d", &d], &["k", "9"]], "Ag=="); + 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}"); } @@ -3718,9 +3792,15 @@ mod tests { #[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"], &["expiration", "4102444800"]], + &[ + &["d", &d], + &["k", "9"], + &["h", &ch], + &["expiration", "4102444800"], + ], "", ); assert!( @@ -3732,10 +3812,12 @@ mod tests { #[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"], ], @@ -3748,8 +3830,14 @@ mod tests { #[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"], &["expiration", "1000000000"]], + &[ + &["d", &d], + &["k", "9"], + &["h", &ch], + &["expiration", "1000000000"], + ], "", ); let err = validate_draft_wrap_envelope(&ev).unwrap_err(); @@ -3759,8 +3847,14 @@ mod tests { #[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"], &["expiration", "not-a-number"]], + &[ + &["d", &d], + &["k", "9"], + &["h", &ch], + &["expiration", "not-a-number"], + ], "", ); let err = validate_draft_wrap_envelope(&ev).unwrap_err(); @@ -3770,12 +3864,20 @@ mod tests { // ── routing invariants ──────────────────────────────────────────────────── #[test] - fn draft_wrap_is_global_only() { - // Draft wraps must never be channel-scoped; compose context lives in - // the encrypted payload only. + 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_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 be global-only (no h-tag channel scope)" + !is_global_only_kind(KIND_DRAFT), + "KIND_DRAFT must not be global-only (it requires an h tag)" ); } @@ -3789,11 +3891,56 @@ mod tests { ); } + // ── 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_does_not_require_h_tag() { + 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!( - !requires_h_channel_scope(KIND_DRAFT), - "KIND_DRAFT must not require an h tag" + err.contains("`h` tag") || err.contains("UUID"), + "got: {err}" ); } } diff --git a/crates/buzz-test-client/tests/e2e_nip37_draft.rs b/crates/buzz-test-client/tests/e2e_nip37_draft.rs index 61fbfccdad..5755684f49 100644 --- a/crates/buzz-test-client/tests/e2e_nip37_draft.rs +++ b/crates/buzz-test-client/tests/e2e_nip37_draft.rs @@ -1,23 +1,20 @@ -//! End-to-end integration tests for NIP-37 draft wraps (kind:31234). +//! End-to-end integration tests for NIP-37 draft wraps (kind:31234), +//! channel-bound contract. //! -//! These tests verify: -//! - Write-path validation: d/k tag rules, h/p rejection, expiration, -//! ciphertext validation, blank tombstone acceptance, oversized d -//! - Replacement ordering: NIP-01 last-write-wins, same-second tie-break -//! (lower lexicographic event ID wins), stale write cannot supersede -//! current head, tombstone replaces live draft as addressable head +//! 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 -//! all confine drafts to their author — exclusive, mixed/kindless, ids, -//! known-#d filters, search/FTS, fan-out -//! - known-#d privacy tripwires: attacker knowing the `d` value does NOT -//! retrieve or count the draft via exclusive or kindless #d filters -//! - FTS / NIP-50: draft content is never surfaced in search results -//! - NIP-11: relay advertises NIP-37, does not advertise NIP-40 +//! - 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 //! -//! Start the relay, then run: -//! //! ```text //! RELAY_URL=ws://localhost:3000 cargo test -p buzz-test-client --test e2e_nip37_draft -- --ignored //! ``` @@ -30,6 +27,9 @@ 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()) @@ -62,32 +62,110 @@ fn fake_nip44_v2() -> String { s } -/// Build a valid kind:31234 draft wrap event with given timestamps. -fn build_draft_at( - keys: &Keys, - d_tag: &str, - k_val: &str, - content: &str, - ts: Timestamp, -) -> nostr::Event { - EventBuilder::new(Kind::Custom(KIND_DRAFT), content) +/// 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(["d", d_tag]).unwrap(), - Tag::parse(["k", k_val]).unwrap(), + 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(), ]) - .custom_created_at(ts) - .sign_with_keys(keys) - .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 } -/// Build a valid kind:31234 draft wrap event at current time. -fn build_draft(keys: &Keys, d_tag: &str, k_val: &str, content: &str) -> nostr::Event { - build_draft_at(keys, d_tag, k_val, content, Timestamp::now()) +/// 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 } -/// Build a blank-content tombstone (NIP-37 deletion) for a draft address. -fn build_tombstone(keys: &Keys, d_tag: &str, k_val: &str, ts: Timestamp) -> nostr::Event { - build_draft_at(keys, d_tag, k_val, "", ts) +/// 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). @@ -137,45 +215,331 @@ async fn query_events_http( .expect("parse query response") } -// ─── Ingest validation ──────────────────────────────────────────────────────── +/// 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 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_accepted_with_ciphertext_content() { +async fn test_draft_rejected_missing_h_tag() { let client = http_client(); let keys = Keys::generate(); - let d_tag = uuid::Uuid::new_v4().to_string(); - let event = build_draft(&keys, &d_tag, "9", &fake_nip44_v2()); + 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, "valid draft rejected: {msg}"); + 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_accepted_blank_tombstone() { +async fn test_draft_rejected_duplicate_h_tag() { let client = http_client(); let keys = Keys::generate(); - let d_tag = uuid::Uuid::new_v4().to_string(); - let event = build_tombstone(&keys, &d_tag, "9", Timestamp::now()); + 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, "blank tombstone rejected: {msg}"); + 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_accepted_future_expiration() { +async fn test_draft_rejected_non_uuid_h_tag() { let client = http_client(); let keys = Keys::generate(); - let d_tag = uuid::Uuid::new_v4().to_string(); + let d = uuid::Uuid::new_v4().to_string(); let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) .tags([ - Tag::parse(["d", &d_tag]).unwrap(), + Tag::parse(["d", &d]).unwrap(), Tag::parse(["k", "9"]).unwrap(), - Tag::parse(["expiration", "4102444800"]).unwrap(), // year 2100 + 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}"); } @@ -183,12 +547,16 @@ async fn test_draft_accepted_future_expiration() { #[ignore] async fn test_draft_rejected_missing_d_tag() { let client = http_client(); - let keys = Keys::generate(); + 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()]) - .sign_with_keys(&keys) + .tags([ + Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + ]) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + 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}"); } @@ -197,15 +565,17 @@ async fn test_draft_rejected_missing_d_tag() { #[ignore] async fn test_draft_rejected_empty_d_tag() { let client = http_client(); - let keys = Keys::generate(); + 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(&keys) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + 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}"); } @@ -214,17 +584,19 @@ async fn test_draft_rejected_empty_d_tag() { #[ignore] async fn test_draft_rejected_oversized_d_tag() { let client = http_client(); - let keys = Keys::generate(); - // D_TAG_MAX_LEN is 255 bytes in buzz-db. Use 256 'a' chars. - let d_tag = "a".repeat(256); + 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(&keys) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + 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"), @@ -236,17 +608,19 @@ async fn test_draft_rejected_oversized_d_tag() { #[ignore] async fn test_draft_rejected_duplicate_d_tag() { let client = http_client(); - let keys = Keys::generate(); + 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(&keys) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + 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}"); } @@ -255,13 +629,17 @@ async fn test_draft_rejected_duplicate_d_tag() { #[ignore] async fn test_draft_rejected_missing_k_tag() { let client = http_client(); - let keys = Keys::generate(); + 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()]) - .sign_with_keys(&keys) + .tags([ + Tag::parse(["d", &d]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + ]) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + 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}"); } @@ -270,17 +648,19 @@ async fn test_draft_rejected_missing_k_tag() { #[ignore] async fn test_draft_rejected_duplicate_k_tag() { let client = http_client(); - let keys = Keys::generate(); + 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(&keys) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + 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}"); } @@ -289,16 +669,18 @@ async fn test_draft_rejected_duplicate_k_tag() { #[ignore] async fn test_draft_rejected_malformed_k_tag_non_decimal() { let client = http_client(); - let keys = Keys::generate(); + 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(&keys) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; assert!(!accepted, "non-decimal k tag should be rejected"); assert!( msg.contains("canonical decimal"), @@ -310,16 +692,18 @@ async fn test_draft_rejected_malformed_k_tag_non_decimal() { #[ignore] async fn test_draft_rejected_k_tag_leading_zero() { let client = http_client(); - let keys = Keys::generate(); + 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(&keys) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + 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}"); } @@ -328,54 +712,39 @@ async fn test_draft_rejected_k_tag_leading_zero() { #[ignore] async fn test_draft_rejected_k_tag_out_of_range() { let client = http_client(); - let keys = Keys::generate(); + 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(&keys) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + 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_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", &uuid::Uuid::new_v4().to_string()]).unwrap(), - ]) - .sign_with_keys(&keys) - .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; - assert!(!accepted, "h tag on draft should be rejected"); - assert!(msg.contains("h` tag"), "unexpected message: {msg}"); -} - #[tokio::test] #[ignore] async fn test_draft_rejected_p_tag() { let client = http_client(); - let keys = Keys::generate(); + 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(["p", &keys.public_key().to_hex()]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + Tag::parse(["p", &owner.public_key().to_hex()]).unwrap(), ]) - .sign_with_keys(&keys) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + 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}"); } @@ -384,16 +753,18 @@ async fn test_draft_rejected_p_tag() { #[ignore] async fn test_draft_rejected_malformed_ciphertext() { let client = http_client(); - let keys = Keys::generate(); + 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(&keys) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + 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"), @@ -405,17 +776,19 @@ async fn test_draft_rejected_malformed_ciphertext() { #[ignore] async fn test_draft_rejected_expiration_in_past() { let client = http_client(); - let keys = Keys::generate(); + 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(&keys) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; assert!(!accepted, "past expiration should be rejected"); assert!(msg.contains("expiration"), "unexpected message: {msg}"); } @@ -426,32 +799,31 @@ async fn test_draft_rejected_expiration_in_past() { #[ignore] async fn test_draft_replaced_by_newer_event() { let client = http_client(); - let keys = Keys::generate(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; let d = uuid::Uuid::new_v4().to_string(); - // Use timestamps offset from now so they pass ±15-min ingest gate. let now = Timestamp::now().as_secs(); let t0 = Timestamp::from(now - 2); let t1 = Timestamp::from(now - 1); - let v1 = build_draft_at(&keys, &d, "9", &fake_nip44_v2(), t0); - let v2 = build_draft_at(&keys, &d, "9", &fake_nip44_v2(), t1); + 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, &keys, &v1).await; + let (ok1, msg1) = submit_event_http(&client, &owner, &v1).await; assert!(ok1, "v1 must be accepted: {msg1}"); - let (ok2, msg2) = submit_event_http(&client, &keys, &v2).await; + let (ok2, msg2) = submit_event_http(&client, &owner, &v2).await; assert!(ok2, "v2 must be accepted: {msg2}"); - // Author queries by #d — only the latest should be returned. let filter = Filter::new() .kind(nostr::Kind::Custom(KIND_DRAFT)) - .author(keys.public_key()) + .author(owner.public_key()) .custom_tag( nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), d.as_str(), ); - let results = query_events_http(&client, &keys.public_key().to_hex(), vec![filter]).await; + 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(), @@ -464,32 +836,31 @@ async fn test_draft_replaced_by_newer_event() { #[ignore] async fn test_draft_stale_write_cannot_supersede_current_head() { let client = http_client(); - let keys = Keys::generate(); + 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(&keys, &d, "9", &fake_nip44_v2(), t_new); - let v_old = build_draft_at(&keys, &d, "9", &fake_nip44_v2(), t_old); + 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); - // Submit new first, then try to replace with stale. - let (ok_n, msg_n) = submit_event_http(&client, &keys, &v_new).await; + let (ok_n, msg_n) = submit_event_http(&client, &owner, &v_new).await; assert!(ok_n, "newer draft must be accepted: {msg_n}"); - let (ok_o, msg_o) = submit_event_http(&client, &keys, &v_old).await; - // Relay may accept (duplicate) or reject the old event — either is correct; - // what matters is that the returned head is still the newer one. - let _ = (ok_o, msg_o); + // Relay may accept (no-op duplicate) or reject the stale event — either is + // correct; what matters is that the head is still the newer one. + let _ = submit_event_http(&client, &owner, &v_old).await; let filter = Filter::new() .kind(nostr::Kind::Custom(KIND_DRAFT)) - .author(keys.public_key()) + .author(owner.public_key()) .custom_tag( nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), d.as_str(), ); - let results = query_events_http(&client, &keys.public_key().to_hex(), vec![filter]).await; + 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(), @@ -504,40 +875,45 @@ 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 keys = Keys::generate(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; let d = uuid::Uuid::new_v4().to_string(); - let now = Timestamp::now(); - // Generate candidates until we have two with different IDs at the same ts. - // Sign 10 candidates and pick the lexically lowest and highest pair. - let mut candidates = Vec::new(); - for _ in 0..10 { - let e = build_draft_at(&keys, &d, "9", &fake_nip44_v2(), now); + let ts = Timestamp::now(); + + // Sign candidates until we have at least two with distinct IDs. Because + // kind:31234 is parameterised-replaceable, the same (author, d, ts) may + // produce distinct IDs via different signing randomness. Generate up to 20 + // candidates; if every pair has the same ID (near-impossible), skip. + let mut candidates: Vec = Vec::new(); + for _ in 0..20 { + let e = build_draft_at(&owner, &d, "9", &ch_id, &fake_nip44_v2(), ts); candidates.push(e); } - candidates.sort_by(|a, b| a.id.to_hex().cmp(&b.id.to_hex())); - let lowest = candidates.first().unwrap().clone(); - let highest = candidates.last().unwrap().clone(); - - if lowest.id == highest.id { + // Deduplicate by ID. + candidates.dedup_by_key(|e| e.id.to_hex()); + if candidates.len() < 2 { // Extremely unlikely — skip rather than fail. return; } + candidates.sort_by(|a, b| a.id.to_hex().cmp(&b.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, &keys, &highest).await; + 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, &keys, &lowest).await; + 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(keys.public_key()) + .author(owner.public_key()) .custom_tag( nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), d.as_str(), ); - let results = query_events_http(&client, &keys.public_key().to_hex(), vec![filter]).await; + 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(), @@ -550,30 +926,31 @@ async fn test_draft_same_second_tie_break_lower_id_wins() { #[ignore] async fn test_draft_tombstone_head_queryable_by_author() { let client = http_client(); - let keys = Keys::generate(); + 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(); // strictly newer + let t_tomb = Timestamp::now(); - let draft = build_draft_at(&keys, &d, "9", &fake_nip44_v2(), t_draft); - let tombstone = build_tombstone(&keys, &d, "9", t_tomb); + 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, &keys, &draft).await; + 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, &keys, &tombstone).await; + 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(keys.public_key()) + .author(owner.public_key()) .custom_tag( nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), d.as_str(), ); - let results = query_events_http(&client, &keys.public_key().to_hex(), vec![filter]).await; + 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(), @@ -594,21 +971,22 @@ async fn test_draft_tombstone_head_queryable_by_author() { async fn test_draft_author_can_req_own_drafts_ws() { let url = relay_url(); let client = http_client(); - let keys = Keys::generate(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; let d = uuid::Uuid::new_v4().to_string(); - let draft = build_draft(&keys, &d, "9", &fake_nip44_v2()); + let draft = build_draft(&owner, &d, "9", &ch_id, &fake_nip44_v2()); let draft_id = draft.id; - let (ok, msg) = submit_event_http(&client, &keys, &draft).await; + let (ok, msg) = submit_event_http(&client, &owner, &draft).await; assert!(ok, "draft must be accepted: {msg}"); - let mut c = BuzzTestClient::connect(&url, &keys) + 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(keys.public_key()); + .author(owner.public_key()); c.subscribe(&sid, vec![filter]).await.expect("subscribe"); let results = c .collect_until_eose(&sid, Duration::from_secs(5)) @@ -624,15 +1002,14 @@ async fn test_draft_author_can_req_own_drafts_ws() { #[tokio::test] #[ignore] async fn test_draft_attacker_cannot_req_victims_drafts_exclusive_ws() { - // Victim stores a draft; attacker queries {kinds:[31234], authors:[victim]}. - // The relay must CLOSE the subscription with "restricted:". 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", &fake_nip44_v2()); + 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}"); @@ -645,11 +1022,11 @@ async fn test_draft_attacker_cannot_req_victims_drafts_exclusive_ws() { .author(victim.public_key()); ac.subscribe(&sid, vec![filter]).await.expect("subscribe"); - let msg = ac + let relay_msg = ac .recv_event(Duration::from_secs(5)) .await .expect("recv response"); - match msg { + match relay_msg { RelayMessage::Closed { subscription_id, message, @@ -674,16 +1051,13 @@ async fn test_draft_attacker_cannot_req_victims_drafts_exclusive_ws() { #[tokio::test] #[ignore] async fn test_draft_attacker_cannot_see_draft_in_kindless_filter_ws() { - // Victim stores a draft; attacker issues a kindless filter. - // Draft must be silently omitted. A public kind:0 event provides a - // positive control — the attacker MUST receive that but NOT the draft. 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 kind:0 profile event (public) and a draft (private). let profile = EventBuilder::new(Kind::Metadata, "{}") .sign_with_keys(&victim) .unwrap(); @@ -691,7 +1065,7 @@ async fn test_draft_attacker_cannot_see_draft_in_kindless_filter_ws() { let (ok_p, msg_p) = submit_event_http(&client, &victim, &profile).await; assert!(ok_p, "victim profile must be accepted: {msg_p}"); - let draft = build_draft(&victim, &d, "9", &fake_nip44_v2()); + 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}"); @@ -700,7 +1074,6 @@ async fn test_draft_attacker_cannot_see_draft_in_kindless_filter_ws() { .await .expect("connect attacker"); let sid = sub_id("attacker-kindless"); - // Kindless filter targeting victim's pubkey. let filter = Filter::new().author(victim.public_key()).limit(50); ac.subscribe(&sid, vec![filter]).await.expect("subscribe"); let results = ac @@ -708,12 +1081,10 @@ async fn test_draft_attacker_cannot_see_draft_in_kindless_filter_ws() { .await .expect("collect"); - // Positive control: profile must be present. assert!( results.iter().any(|e| e.id == profile_id), "attacker must receive victim's public profile event" ); - // Privacy gate: draft must be absent. assert!( !results.iter().any(|e| e.id == draft_id), "kindless filter must not expose victim's draft to attacker" @@ -724,14 +1095,14 @@ async fn test_draft_attacker_cannot_see_draft_in_kindless_filter_ws() { #[tokio::test] #[ignore] async fn test_draft_attacker_cannot_retrieve_by_known_event_id_ws() { - // Knowing the exact event ID of a draft must not grant access. 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", &fake_nip44_v2()); + 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}"); @@ -758,15 +1129,14 @@ async fn test_draft_attacker_cannot_retrieve_by_known_event_id_ws() { #[tokio::test] #[ignore] async fn test_draft_attacker_cannot_retrieve_by_known_d_tag_exclusive_ws() { - // Attacker queries {kinds:[31234], authors:[victim], #d:[known_d]}. - // The relay must CLOSE the subscription with "restricted:". 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", &fake_nip44_v2()); + 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}"); @@ -812,17 +1182,14 @@ async fn test_draft_attacker_cannot_retrieve_by_known_d_tag_exclusive_ws() { #[tokio::test] #[ignore] async fn test_draft_attacker_cannot_retrieve_by_known_d_tag_kindless_ws() { - // Attacker queries {#d:[known_d]} — kindless, no authors filter. - // Draft must be silently omitted; a public kind:9 message on the same - // d-value (different kind, different event) provides a positive control - // that the attacker can receive from a public channel. 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", &fake_nip44_v2()); + 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}"); @@ -831,7 +1198,6 @@ async fn test_draft_attacker_cannot_retrieve_by_known_d_tag_kindless_ws() { .await .expect("connect attacker"); let sid = sub_id("d-kindless"); - // Kindless #d filter — this is the dictionary-attack vector for draft addresses. let filter = Filter::new().custom_tag( nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), d.as_str(), @@ -853,14 +1219,14 @@ async fn test_draft_attacker_cannot_retrieve_by_known_d_tag_kindless_ws() { #[tokio::test] #[ignore] async fn test_draft_attacker_cannot_count_exclusive_ws() { - // WS COUNT: {kinds:[31234], authors:[victim]} must be CLOSED with restricted:. 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", &fake_nip44_v2()); + 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}"); @@ -890,7 +1256,9 @@ async fn test_draft_attacker_cannot_count_exclusive_ws() { "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:?}"), + other => { + panic!("expected CLOSED for WS COUNT on another author's drafts, got: {other:?}") + } } ac.disconnect().await.expect("disconnect"); } @@ -898,14 +1266,14 @@ async fn test_draft_attacker_cannot_count_exclusive_ws() { #[tokio::test] #[ignore] async fn test_draft_attacker_cannot_count_via_known_d_ws() { - // WS COUNT: {kinds:[31234], authors:[victim], #d:[known]} must be CLOSED. 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", &fake_nip44_v2()); + 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}"); @@ -943,13 +1311,13 @@ async fn test_draft_attacker_cannot_count_via_known_d_ws() { #[tokio::test] #[ignore] async fn test_draft_attacker_cannot_count_exclusive_http() { - // HTTP /count: {kinds:[31234], authors:[victim]} must return 403. 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", &fake_nip44_v2()); + 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}"); @@ -974,21 +1342,21 @@ async fn test_draft_attacker_cannot_count_exclusive_http() { #[tokio::test] #[ignore] async fn test_draft_author_can_count_own_drafts_http() { - // Author's own HTTP /count must succeed and return ≥1. let client = http_client(); - let keys = Keys::generate(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; let d = uuid::Uuid::new_v4().to_string(); - let draft = build_draft(&keys, &d, "9", &fake_nip44_v2()); - let (ok, msg) = submit_event_http(&client, &keys, &draft).await; + 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(keys.public_key()); + .author(owner.public_key()); let resp = client .post(format!("{}/count", relay_http_url())) - .header("X-Pubkey", &keys.public_key().to_hex()) + .header("X-Pubkey", &owner.public_key().to_hex()) .header("Content-Type", "application/json") .json(&vec![filter]) .send() @@ -1009,13 +1377,13 @@ async fn test_draft_author_can_count_own_drafts_http() { #[tokio::test] #[ignore] async fn test_draft_attacker_cannot_query_exclusive_http() { - // HTTP /query: exclusive other-author draft query must return 403. 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", &fake_nip44_v2()); + 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}"); @@ -1042,18 +1410,13 @@ async fn test_draft_attacker_cannot_query_exclusive_http() { #[tokio::test] #[ignore] async fn test_draft_live_fanout_only_reaches_author() { - // Attacker subscribes to a mixed filter BEFORE the draft is published. - // They must NOT receive the draft in live fan-out. They MUST receive a - // public control event (kind:0 profile) from the same author — this - // proves fan-out is working and the draft was specifically excluded. 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(); - // Attacker subscribes to victim's events using a MIXED filter - // (not exclusively kind:31234, so it won't be immediately CLOSED). let mut ac = BuzzTestClient::connect(&url, &attacker) .await .expect("connect attacker"); @@ -1061,22 +1424,19 @@ async fn test_draft_live_fanout_only_reaches_author() { let filter = Filter::new() .kinds(vec![Kind::Metadata, Kind::Custom(KIND_DRAFT)]) .author(victim.public_key()) - .limit(0); // live only, no stored events + .limit(0); ac.subscribe(&sid_fanout, vec![filter]) .await .expect("subscribe to mixed filter"); - // Drain EOSE. let _ = ac .collect_until_eose(&sid_fanout, Duration::from_secs(3)) .await; - // Victim publishes a draft — must NOT reach attacker via fan-out. - let draft = build_draft(&victim, &d, "9", &fake_nip44_v2()); + 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}"); - // Victim also publishes a public profile event — MUST reach attacker. let profile = EventBuilder::new(Kind::Metadata, "{}") .sign_with_keys(&victim) .unwrap(); @@ -1084,7 +1444,6 @@ async fn test_draft_live_fanout_only_reaches_author() { let (ok_p, msg_p) = submit_event_http(&client, &victim, &profile).await; assert!(ok_p, "profile must be accepted: {msg_p}"); - // Drain messages briefly and check what arrived. let mut received_draft = false; let mut received_profile = false; let deadline = tokio::time::Instant::now() + Duration::from_secs(3); @@ -1119,20 +1478,103 @@ async fn test_draft_live_fanout_only_reaches_author() { ac.disconnect().await.expect("disconnect"); } +// ─── Tenant confinement ─────────────────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_tenant_confinement_channel_from_different_community() { + // Channel UUID that exists in one community must not be valid in another. + // This test requires a second community/tenant to be reachable — if the + // relay runs as a single tenant the test is a no-op (channel simply won't + // exist from the adversarial requester's perspective). + // + // We simulate by using a randomly generated UUID that is almost certain + // not to exist in any community: submitting a draft to that UUID must + // be rejected by the nonexistent-channel check. + let client = http_client(); + let adversary = Keys::generate(); + let alien_channel_id = uuid::Uuid::new_v4().to_string(); + + let d = uuid::Uuid::new_v4().to_string(); + let event = build_draft(&adversary, &d, "9", &alien_channel_id, &fake_nip44_v2()); + let (accepted, msg) = submit_event_http(&client, &adversary, &event).await; + assert!( + !accepted, + "draft to alien/nonexistent channel must be rejected" + ); + assert!( + msg.contains("channel") || msg.contains("member") || msg.contains("not found"), + "unexpected message: {msg}" + ); +} + +// ─── Workflow exclusion ─────────────────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_not_returned_in_kindless_channel_query() { + // A kindless channel filter must not return draft events even when the + // requester is the author. Draft content is private — never leaked via + // channel-scoped queries. + 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}"); + + // 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}"); + + // Query by channel h-tag, no kind filter. + let mut c = BuzzTestClient::connect(&url, &owner) + .await + .expect("connect"); + let sid = sub_id("ch-kindless"); + 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. + assert!( + results.iter().any(|e| e.id == msg_id), + "channel message must appear in h-tag query (positive control)" + ); + // Draft must be absent — drafts are author-private, not channel-public. + assert!( + !results.iter().any(|e| e.id == draft_id), + "draft must not be returned by a kindless channel h-tag filter" + ); + c.disconnect().await.expect("disconnect"); +} + // ─── FTS / NIP-50 exclusion ─────────────────────────────────────────────────── #[tokio::test] #[ignore] async fn test_draft_not_indexed_in_fts_search() { - // The relay stores search_tsv = NULL for kind:31234. Even if we could - // search by the author, the draft must never surface in NIP-50 results. - // We use the FTS HTTP query path as an attacker to verify. 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(); - // Publish a kind:1 text note with a unique marker as a positive control. let marker = format!("nip37fts_probe_{}", uuid::Uuid::new_v4().simple()); let note = EventBuilder::new(Kind::TextNote, &marker) .sign_with_keys(&victim) @@ -1141,26 +1583,21 @@ async fn test_draft_not_indexed_in_fts_search() { let (ok_note, msg_note) = submit_event_http(&client, &victim, ¬e).await; assert!(ok_note, "control note must be accepted: {msg_note}"); - // Publish a draft from the same author. - let draft = build_draft(&victim, &d, "9", &fake_nip44_v2()); + 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}"); - // NIP-50 search as the victim — the kind:1 note must appear; the draft must not. let search_filter = Filter::new().search(&marker).limit(50); let results = query_events_http(&client, &victim.public_key().to_hex(), vec![search_filter]).await; - // Positive control: the text note must be found. assert!( results .iter() .any(|e| e["id"].as_str() == Some(¬e_id.to_hex())), "FTS must index the control kind:1 note (positive control)" ); - - // Privacy gate: draft must never appear in search results. assert!( !results .iter() @@ -1168,7 +1605,6 @@ async fn test_draft_not_indexed_in_fts_search() { "kind:31234 must have NULL search_tsv — draft must not appear in NIP-50 search" ); - // Searching as the attacker must also not expose the draft. let search_filter2 = Filter::new().search(&marker).limit(50); let attacker_results = query_events_http( &client, From 268ea028fa4581633fc7929552c3176170f3463d Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Sat, 11 Jul 2026 13:51:37 -0400 Subject: [PATCH 03/20] fix(relay): harden NIP-37 draft-wrap contract - Move immutable-channel binding check inside replace_parameterized_event under the advisory lock (atomically safe, race-proof). Remove the preflight get_draft_head_channel_id call. Add DraftChannelMismatch error variant. All other replace_parameterized_event callers pass None. - Move validate_draft_wrap_envelope before channel extraction so that structural failures (missing/duplicate/non-UUID h-tag, p-tag) report via the right gate rather than the channel-scope gate. - Require canonical lowercase-hyphenated UUID in h-tag validator (parsed.to_string() == h); reject uppercase and simple-hex forms. - Fix test_draft_same_second_tie_break: add per-candidate _tiebreak tag to force distinct event hashes and non-empty candidate set. - Replace two timing-prone kindless WS privacy tests with explicit kinds=[0,31234] and kinds=[30023,31234] mixed-kinds tests. - FTS test: use explicit kinds=[1,31234] search filter as author. - excluded_kinds_are_storage_level_unsearchable: add kind:31234 row, update event count and forbidden list. - Add Postgres DB integration tests: draft_is_confined_to_its_community (two-community tenant confinement) and concurrent_different_channel_drafts_one_wins_one_loses (race guard). - Add workflow-dispatch tripwire unit test in event.rs confirming AUTHOR_ONLY_KINDS.contains(&KIND_DRAFT) at the guard seam. - Add DM channel path test (kind:41010 draft acceptance/replacement/ tombstone) and removed-member read-denial test (historical REQ/COUNT + live fan-out denial after removal). - Fix stale-write test to assert accepted:true result before head check. - Update stale 'channel-less/global' docs in kind.rs, ingest.rs, event.rs to reflect channel-bound reality. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-core/src/kind.rs | 4 +- crates/buzz-db/src/error.rs | 9 + crates/buzz-db/src/lib.rs | 193 +++++++- crates/buzz-relay/src/handlers/event.rs | 39 +- crates/buzz-relay/src/handlers/ingest.rs | 97 ++-- .../buzz-relay/src/handlers/side_effects.rs | 2 +- .../buzz-relay/src/mesh_status_publisher.rs | 2 +- crates/buzz-search/tests/fts_integration.rs | 23 +- .../buzz-test-client/tests/e2e_nip37_draft.rs | 440 ++++++++++++++++-- 9 files changed, 695 insertions(+), 114 deletions(-) diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 01763b5b07..b15a05472e 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -106,8 +106,8 @@ pub const KIND_EVENT_REMINDER: u32 = 30300; /// 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 -/// no channel scope (`channel_id = NULL`); compose context (channel, reply target, -/// etc.) lives only inside the encrypted payload. +/// 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 diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index f8b8a2eb56..4989abfcdc 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -48,6 +48,15 @@ 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, } /// Convenience alias for `Result`. diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 4ec1dbb508..0c34bb3901 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -2806,6 +2806,13 @@ 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 (`expected_channel_id`):** When `Some(expected)`, + /// the function checks — **inside the advisory lock, before the stale-ordering step** — + /// that the current head's `channel_id` equals `expected`. If it differs the + /// transaction is rolled back and an `Err(DbError::DraftChannelMismatch)` is returned. + /// Pass `None` to skip the check (all parameterized-replaceable kinds except + /// kind:31234 draft wraps). + /// /// 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 @@ -2816,6 +2823,7 @@ impl Db { event: &nostr::Event, d_tag: &str, channel_id: Option, + expected_channel_id: Option>, ) -> Result<(StoredEvent, bool)> { let kind_i32 = buzz_core::kind::event_kind_i32(event); let pubkey_bytes = event.pubkey.to_bytes(); @@ -2884,17 +2892,33 @@ 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. + // + // 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 let Some(expected) = expected_channel_id { + if let Some((_, _, head_channel_id)) = &existing { + if *head_channel_id != expected { + 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 \ @@ -2913,14 +2937,16 @@ 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 dominated = - existing - .iter() - .chain(watermark.iter()) - .any(|(accepted_ts, accepted_id)| { - created_at < *accepted_ts - || (created_at == *accepted_ts && incoming_id >= accepted_id.as_slice()) - }); + let existing_ordering = existing + .as_ref() + .map(|(ts, id, _)| (ts.clone(), id.clone())); + let dominated = existing_ordering + .iter() + .chain(watermark.iter()) + .any(|(accepted_ts, accepted_id)| { + created_at < *accepted_ts + || (created_at == *accepted_ts && incoming_id >= accepted_id.as_slice()) + }); if dominated { tx.rollback().await?; let received_at = chrono::Utc::now(); @@ -2953,7 +2979,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( @@ -4183,4 +4209,131 @@ 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() + } + + /// 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. + #[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 d_tag = Uuid::new_v4().to_string(); + + // Insert a draft into community A. + let draft = build_test_draft(&keys, &d_tag, &ch_a); + let (_, inserted) = db + .replace_parameterized_event(community_a, &draft, &d_tag, Some(ch_a), Some(Some(ch_a))) + .await + .expect("insert draft into A"); + assert!(inserted, "draft must be inserted into A"); + + // Reading the same (pubkey, d_tag) from community B must see no existing head + // → expected_channel_id check must pass (no head → no conflict). + let ch_b = Uuid::new_v4(); + let draft_b = build_test_draft(&keys, &d_tag, &ch_b); + let result = db + .replace_parameterized_event( + community_b, + &draft_b, + &d_tag, + Some(ch_b), + Some(Some(ch_b)), + ) + .await; + assert!( + result.is_ok(), + "same (pubkey, d_tag) in community B must be an independent address; got: {result:?}" + ); + let (_, b_inserted) = result.unwrap(); + assert!( + b_inserted, + "draft must be stored as a new independent head in B" + ); + } + + /// 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. + #[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), Some(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), Some(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:?}" + ); + } } diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 8182d429b0..d4151d7c2a 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -131,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 @@ -507,10 +507,9 @@ async fn dispatch_persistent_event_inner( && !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. Excluded here explicitly - // because today's channel-less engine no-ops on them, but this guard is the - // invariant that must hold for any future workflow expansion. + // 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) { let workflow_engine = Arc::clone(&state.workflow_engine); @@ -2427,5 +2426,25 @@ mod tests { must not receive a community-B event. Got: {out:?}" ); } + + /// Tripwire: kind:31234 (NIP-37 draft wrap) MUST appear in + /// `AUTHOR_ONLY_KINDS` so the workflow-dispatch guard at + /// `event.rs:514` (`!AUTHOR_ONLY_KINDS.contains(&kind_u32)`) + /// permanently suppresses workflow triggers for draft events. + /// + /// A draft must never arrive in the workflow engine, regardless + /// of future refactoring in the dispatch path. + #[test] + fn draft_kind_is_excluded_from_workflow_dispatch_by_author_only_guard() { + // This is the exact predicate that gates workflow dispatch in + // `dispatch_persistent_event`. Changing AUTHOR_ONLY_KINDS + // without updating this test would turn it red immediately. + assert!( + buzz_core::kind::AUTHOR_ONLY_KINDS.contains(&buzz_core::kind::KIND_DRAFT), + "KIND_DRAFT must be in AUTHOR_ONLY_KINDS — the workflow-dispatch guard \ + at event.rs:514 (`!AUTHOR_ONLY_KINDS.contains(&kind_u32)`) relies on \ + this to suppress draft events from reaching the workflow engine" + ); + } } } diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 48165e51ac..e5b4af6895 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -157,7 +157,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result { Ok(Scope::UsersWrite) } - // NIP-37: draft wraps are author-private global state (UsersWrite scope). + // 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), @@ -1309,10 +1309,18 @@ fn validate_draft_wrap_envelope(event: &Event) -> Result<(), String> { )); } let h = h_value.unwrap(); - if uuid::Uuid::parse_str(h).is_err() { - return Err(format!( - "draft-wrap `h` tag must be a canonical UUID (channel or DM id), got: {h:?}" - )); + 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). @@ -1688,6 +1696,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), @@ -2067,11 +2085,6 @@ async fn ingest_event_inner( .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } - if kind_u32 == KIND_DRAFT { - validate_draft_wrap_envelope(&event) - .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; - } - if kind_u32 == KIND_PERSONA { validate_persona_envelope(&event) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; @@ -2382,50 +2395,34 @@ async fn ingest_event_inner( ))); } - // Immutable channel binding for kind:31234 (NIP-37 draft wraps). - // - // The NIP-33 address (community, author, 31234, d_tag) is unique, but - // channel_id is NOT part of the NIP-33 key. This means a replacement - // with a different h-tag would silently re-bind the draft to a new - // channel while still winning the NIP-01 replacement. To prevent that, - // we reject any incoming kind:31234 update whose channel_id differs - // from the stored head's channel_id. - // - // A tombstone (empty content) carries the same h-tag as the draft it - // closes — this check enforces that invariant too. - if kind_u32 == KIND_DRAFT { - let pubkey_bytes = auth.pubkey().to_bytes().to_vec(); - match state - .db - .get_draft_head_channel_id(tenant.community(), &pubkey_bytes, &d_tag) - .await - { - Ok(Some(head_ch)) => { - // A live head exists. Its channel_id must match the incoming event's. - if head_ch != channel_id { - return Err(IngestError::Rejected( - "invalid: draft-wrap channel binding is immutable — \ - `h` tag must match the existing head's channel" - .into(), - )); - } - } - Ok(None) => { - // No live head — this is the first write for this address. - } - Err(e) => { - return Err(IngestError::Internal(format!( - "error: checking draft channel binding: {e}" - ))); - } - } - } + // For kind:31234 draft wraps, pass the expected channel_id so that + // replace_parameterized_event enforces the immutable-binding invariant + // atomically inside the advisory lock. All other parameterized- + // replaceable kinds pass None (no channel binding constraint). + let expected_channel_id = if kind_u32 == KIND_DRAFT { + Some(channel_id) + } else { + None + }; state .db - .replace_parameterized_event(tenant.community(), &event, &d_tag, channel_id) + .replace_parameterized_event( + tenant.community(), + &event, + &d_tag, + channel_id, + expected_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 diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 33a95ee64e..932f9d7321 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -3037,7 +3037,7 @@ pub async fn publish_dm_visibility_snapshot( let (stored, was_inserted) = state .db - .replace_parameterized_event(tenant.community(), &event, &viewer_hex, None) + .replace_parameterized_event(tenant.community(), &event, &viewer_hex, None, None) .await?; if was_inserted { dispatch_persistent_event( diff --git a/crates/buzz-relay/src/mesh_status_publisher.rs b/crates/buzz-relay/src/mesh_status_publisher.rs index 4285e72211..f725689f2a 100644 --- a/crates/buzz-relay/src/mesh_status_publisher.rs +++ b/crates/buzz-relay/src/mesh_status_publisher.rs @@ -225,7 +225,7 @@ pub async fn publish_mesh_status( let (stored, was_inserted) = state .db - .replace_parameterized_event(tenant.community(), &event, &d_tag, None) + .replace_parameterized_event(tenant.community(), &event, &d_tag, None, None) .await?; if was_inserted { let relay_pubkey_hex = state.relay_keypair.public_key().to_hex(); diff --git a/crates/buzz-search/tests/fts_integration.rs b/crates/buzz-search/tests/fts_integration.rs index 0e472b99ac..03052c2a54 100644 --- a/crates/buzz-search/tests/fts_integration.rs +++ b/crates/buzz-search/tests/fts_integration.rs @@ -1091,11 +1091,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. /// @@ -1162,6 +1163,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, @@ -1171,7 +1185,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; @@ -1184,7 +1198,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; @@ -1197,7 +1211,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; @@ -1231,6 +1245,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, diff --git a/crates/buzz-test-client/tests/e2e_nip37_draft.rs b/crates/buzz-test-client/tests/e2e_nip37_draft.rs index 5755684f49..23840d7863 100644 --- a/crates/buzz-test-client/tests/e2e_nip37_draft.rs +++ b/crates/buzz-test-client/tests/e2e_nip37_draft.rs @@ -849,9 +849,14 @@ async fn test_draft_stale_write_cannot_supersede_current_head() { let (ok_n, msg_n) = submit_event_http(&client, &owner, &v_new).await; assert!(ok_n, "newer draft must be accepted: {msg_n}"); - // Relay may accept (no-op duplicate) or reject the stale event — either is - // correct; what matters is that the head is still the newer one. - let _ = submit_event_http(&client, &owner, &v_old).await; + // 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)) @@ -881,16 +886,26 @@ async fn test_draft_same_second_tie_break_lower_id_wins() { let ts = Timestamp::now(); - // Sign candidates until we have at least two with distinct IDs. Because - // kind:31234 is parameterised-replaceable, the same (author, d, ts) may - // produce distinct IDs via different signing randomness. Generate up to 20 - // candidates; if every pair has the same ID (near-impossible), skip. + // 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 _ in 0..20 { - let e = build_draft_at(&owner, &d, "9", &ch_id, &fake_nip44_v2(), ts); + 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. + // Deduplicate by ID (should never trigger, but kept for safety). candidates.dedup_by_key(|e| e.id.to_hex()); if candidates.len() < 2 { // Extremely unlikely — skip rather than fail. @@ -1050,7 +1065,9 @@ async fn test_draft_attacker_cannot_req_victims_drafts_exclusive_ws() { #[tokio::test] #[ignore] -async fn test_draft_attacker_cannot_see_draft_in_kindless_filter_ws() { +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(); @@ -1058,6 +1075,7 @@ async fn test_draft_attacker_cannot_see_draft_in_kindless_filter_ws() { 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(); @@ -1065,16 +1083,20 @@ async fn test_draft_attacker_cannot_see_draft_in_kindless_filter_ws() { 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("attacker-kindless"); - let filter = Filter::new().author(victim.public_key()).limit(50); + 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)) @@ -1083,11 +1105,64 @@ async fn test_draft_attacker_cannot_see_draft_in_kindless_filter_ws() { assert!( results.iter().any(|e| e.id == profile_id), - "attacker must receive victim's public profile event" + "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), - "kindless filter must not expose victim's draft to attacker" + "kinds=[30023,31234] filter must not expose victim's draft to attacker" ); ac.disconnect().await.expect("disconnect"); } @@ -1181,7 +1256,12 @@ async fn test_draft_attacker_cannot_retrieve_by_known_d_tag_exclusive_ws() { #[tokio::test] #[ignore] -async fn test_draft_attacker_cannot_retrieve_by_known_d_tag_kindless_ws() { +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. + // This also covers the kindless #d path: explicit kind:31234 is strictly worse + // for the attacker than kindless, so if the kind-specific filter is blocked the + // kindless variant is also blocked by the author-only gate. let url = relay_url(); let client = http_client(); let victim = Keys::generate(); @@ -1194,23 +1274,75 @@ async fn test_draft_attacker_cannot_retrieve_by_known_d_tag_kindless_ws() { let (ok, msg) = submit_event_http(&client, &victim, &draft).await; assert!(ok, "victim draft must be accepted: {msg}"); + // Also publish a public kind:0 so we can verify the filter would return + // other results if drafts were not gated. + 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}"); + let mut ac = BuzzTestClient::connect(&url, &attacker) .await .expect("connect attacker"); - let sid = sub_id("d-kindless"); - let filter = Filter::new().custom_tag( + 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:?}"), + } + + // Positive control: a kindless #d filter must NOT return drafts but also + // must not CLOSED (it's a valid filter for other kinds). + let sid2 = sub_id("d-kindless-check"); + let filter2 = Filter::new().custom_tag( nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), d.as_str(), ); - ac.subscribe(&sid, vec![filter]).await.expect("subscribe"); - let results = ac - .collect_until_eose(&sid, Duration::from_secs(5)) + ac.subscribe(&sid2, vec![filter2]) .await - .expect("collect"); + .expect("subscribe kindless"); + let kindless_results = ac + .collect_until_eose(&sid2, Duration::from_secs(5)) + .await + .expect("collect kindless"); assert!( - !results.iter().any(|e| e.id == draft_id), + !kindless_results.iter().any(|e| e.id == draft_id), "kindless #d filter must not expose victim's draft to attacker" ); + // The profile (kind:0) has no d-tag so it won't appear here either; that's fine. + // The important thing is the draft is not in the results. + let _ = profile_id; // referenced for completeness + ac.disconnect().await.expect("disconnect"); } @@ -1569,13 +1701,21 @@ async fn test_draft_not_returned_in_kindless_channel_query() { #[tokio::test] #[ignore] async fn test_draft_not_indexed_in_fts_search() { + // A kind:31234 has NULL search_tsv at the storage layer, so NIP-50 search + // must never surface it — even when the draft's content would otherwise + // contain the search token, and even when the requester is the author. 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(); + // Use a unique marker as the search token and include it in the kind:1 note + // content (positive control) and as the "label" of the draft's fake + // NIP-44 payload (which is base64 — not searchable at the storage level). let marker = format!("nip37fts_probe_{}", uuid::Uuid::new_v4().simple()); + + // Kind:1 control note — MUST appear in FTS results. let note = EventBuilder::new(Kind::TextNote, &marker) .sign_with_keys(&victim) .unwrap(); @@ -1583,12 +1723,20 @@ async fn test_draft_not_indexed_in_fts_search() { 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 — MUST NOT appear in FTS results. + // Content is valid NIP-44 v2 ciphertext regardless of the marker (storage + // layer enforces NULL tsvector for kind:31234 before content is considered). 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 search_filter = Filter::new().search(&marker).limit(50); + // Search as the author with an explicit kinds=[1,31234] filter to ensure we + // probe the storage-layer null-tsvector exclusion, not just the read gate. + let search_filter = Filter::new() + .kinds(vec![Kind::TextNote, Kind::Custom(KIND_DRAFT)]) + .search(&marker) + .limit(50); let results = query_events_http(&client, &victim.public_key().to_hex(), vec![search_filter]).await; @@ -1605,11 +1753,15 @@ async fn test_draft_not_indexed_in_fts_search() { "kind:31234 must have NULL search_tsv — draft must not appear in NIP-50 search" ); - let search_filter2 = Filter::new().search(&marker).limit(50); + // Attacker-side check: search with kinds=[1,31234] as attacker. + let attacker_filter = Filter::new() + .kinds(vec![Kind::TextNote, Kind::Custom(KIND_DRAFT)]) + .search(&marker) + .limit(50); let attacker_results = query_events_http( &client, &attacker.public_key().to_hex(), - vec![search_filter2], + vec![attacker_filter], ) .await; assert!( @@ -1647,3 +1799,239 @@ async fn test_nip11_advertises_nip37_not_nip40() { "NIP-11 must NOT advertise NIP-40 (expiry suppression not implemented); got {nip_numbers:?}" ); } + +// ─── 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 { + // Fallback: skip test if DM channel UUID is not surfaced here. + return; + }; + + // Alice submits a draft bound to the DM channel UUID. + let d = uuid::Uuid::new_v4().to_string(); + let now = nostr::Timestamp::now().as_secs(); + let v1 = build_draft_at( + &alice, + &d, + "9", + &dm_channel_id, + &fake_nip44_v2(), + nostr::Timestamp::from(now - 1), + ); + let (ok1, msg1) = submit_event_http(&client, &alice, &v1).await; + assert!(ok1, "draft v1 to DM channel must be accepted: {msg1}"); + + // Replace with a newer version. + let v2 = build_draft(&alice, &d, "9", &dm_channel_id, &fake_nip44_v2()); + 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. + let tomb = build_tombstone(&alice, &d, "9", &dm_channel_id, nostr::Timestamp::now()); + 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" + ); +} + +// ─── 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 same channel; removed member + // must not receive it via a pre-existing WS subscription. + let mut removed_client = BuzzTestClient::connect(&url, &member) + .await + .expect("connect removed member"); + let sid = sub_id("removed-fanout"); + let live_filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(member.public_key()) + .limit(0); // skip historical; only live + // This subscription itself may be CLOSED (author-only with no membership); + // either outcome is correct. + 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 for the removed member's address. + // (Won't be accepted since owner != member, but any live fanout to removed + // member would indicate a leak.) + // Verify no draft events arrive for the removed member. + 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 succeeds; check it doesn't reach removed member. + if ok_od { + let _ = tokio::time::timeout(Duration::from_secs(2), async { + loop { + match removed_client.recv_event(Duration::from_secs(1)).await { + Ok(RelayMessage::Event { event, .. }) => { + if event.id == owner_draft_id { + panic!( + "removed member received a draft via live fan-out after removal" + ); + } + } + _ => break, + } + } + }) + .await; + } + removed_client.disconnect().await.expect("disconnect"); +} From e3a0e257bcd26c2520ad0324d5be6e5b451381cb Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Sat, 11 Jul 2026 13:56:42 -0400 Subject: [PATCH 04/20] test(relay): add non-canonical UUID h-tag rejection tests; drop dead get_draft_head_channel_id validate_draft_wrap_envelope already had the parsed.to_string() != h guard (hardening commit 2f27d7c). Add two explicit unit tests exercising: - uppercase UUID form (parse-valid, non-canonical) - simple 32-hex form without hyphens (parse-valid, non-canonical) Both tests confirm the relay rejects these forms with a diagnostic message mentioning 'lowercase', 'canonical', or 'UUID'. Also removes the now-dead get_draft_head_channel_id function from buzz-db (event.rs + lib.rs wrapper). The preflight call was replaced by the atomic binding check inside replace_parameterized_event; no callers remain outside buzz-db itself. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-db/src/event.rs | 32 ----------------- crates/buzz-db/src/lib.rs | 14 -------- crates/buzz-relay/src/handlers/ingest.rs | 45 ++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 46 deletions(-) diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 8f5f44c16e..999d4e1a4f 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -915,38 +915,6 @@ pub async fn get_latest_global_replaceable( } } -/// Fetch the `channel_id` of the current NIP-33 head for a kind:31234 draft address. -/// -/// Used by the relay ingest handler to enforce immutable channel binding: if a -/// head already exists for `(community, author, 31234, d_tag)`, its `channel_id` -/// must match the incoming event's `channel_id`. A draft cannot be re-bound to a -/// different channel. -/// -/// Returns `Ok(Some(Some(uuid)))` if a live head exists with a channel_id, -/// `Ok(Some(None))` if a head exists but has NULL channel_id (should not happen -/// in practice but handled defensively), `Ok(None)` if no live head exists, -/// `Err` on database failure. -pub async fn get_draft_head_channel_id( - pool: &PgPool, - community_id: CommunityId, - pubkey_bytes: &[u8], - d_tag: &str, -) -> Result>> { - let row: Option<(Option,)> = sqlx::query_as( - "SELECT channel_id FROM events \ - WHERE community_id = $1 AND kind = 31234 AND pubkey = $2 AND d_tag = $3 \ - AND deleted_at IS NULL \ - ORDER BY created_at DESC, id ASC LIMIT 1", - ) - .bind(community_id.as_uuid()) - .bind(pubkey_bytes) - .bind(d_tag) - .fetch_optional(pool) - .await?; - - Ok(row.map(|(ch,)| ch)) -} - /// Fetches a single event by its raw 32-byte ID, **including soft-deleted rows**. /// /// Most callers should use [`get_event_by_id`] instead. This variant is needed diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 0c34bb3901..c6a0887668 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -721,20 +721,6 @@ impl Db { event::get_latest_global_replaceable(&self.pool, community_id, kind, pubkey_bytes).await } - /// Fetch the `channel_id` of the current NIP-33 head for a kind:31234 draft address. - /// - /// Returns `Ok(Some(Some(uuid)))` if a live head exists with a channel_id, - /// `Ok(Some(None))` if a live head exists without a channel_id (defensive), - /// `Ok(None)` if no live head exists, `Err` on database failure. - pub async fn get_draft_head_channel_id( - &self, - community_id: CommunityId, - pubkey_bytes: &[u8], - d_tag: &str, - ) -> Result>> { - event::get_draft_head_channel_id(&self.pool, community_id, pubkey_bytes, d_tag).await - } - /// Fetches a single non-deleted event by its raw ID bytes. /// /// Returns `None` if the event does not exist or has been soft-deleted. diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index e5b4af6895..4eefe96d0c 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -3940,4 +3940,49 @@ mod tests { "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}" + ); + } } From 6fcb8a723df2ad0aa8f72df44132ee58ecdce688 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Sat, 11 Jul 2026 14:31:31 -0400 Subject: [PATCH 05/20] =?UTF-8?q?test(relay):=20harden=20NIP-37=20coverage?= =?UTF-8?q?=20matrix=20=E2=80=94=20non-vacuous=20tests,=20Clippy=20clean?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix all remaining quality gaps identified in the pre-Thufir review: Clippy (FIX-11): - Remove 17 needless borrows in e2e_nip37_draft.rs (auto-fixed) - sort_by → sort_by_key in tie-break test (auto-fixed) - while_let_loop → while let loop in removed-member fan-out test - splitn(3, ':').next() → split(':').next() in ingest.rs NIP-09 guard DB tests (CRITICAL-A-test, FIX-4, FIX-5): - Add build_test_draft_at helper (explicit timestamp control) - Add query_draft_head helper (reusable across tests) - Expand draft_is_confined_to_its_community: full A/B lifecycle (insert → query → replace → tombstone) with scoped head assertions after each step; uses same d_tag in both communities to prove community_id is the real isolation boundary - Add draft_channel_binding_is_immutable_across_sequential_calls: sequential rebind attempt on an already-bound address → DraftChannelMismatch; stored head still v1 after failed rebind - Add post-race head query to concurrent_different_channel_drafts_one_wins_one_loses: assert exactly one live head bound to the winning channel after the race E2E tests (CRITICAL-B-test, FIX-6, FIX-7, FIX-8, FIX-9, FIX-10): - Add test_nip09_a_tag_deletion_of_draft_is_rejected: kind:5 a-tag targeting 31234:: must be rejected; draft must still be live head after - FIX-7: Expand workflow tripwire to evaluate the actual dispatch predicate (is_workflow_execution_kind && is_command_kind && AUTHOR_ONLY_KINDS) for kind:31234 (→ false) and kind:9 (→ true, positive control) - FIX-8: DM test — replace silent return with panic! on missing channel_id; use strictly increasing timestamps (base-2, base-1, base) to guarantee deterministic ordering across v1/v2/tombstone - FIX-6: test_draft_not_returned_in_kindless_channel_query — rewrite to use attacker (not owner) as requester; the author-only gate strips drafts from non-author queries, not from the author's own channel queries - FIX-9: Removed-member live fan-out — use author(owner) subscription so the probe event (owner draft) actually matches the filter and exercises the gate - FIX-10: Rename test_draft_tenant_confinement_channel_from_different_community → inline note + pointer to the existing test_draft_rejected_nonexistent_channel_h_tag (the old name was misleading; true cross-tenant confinement is the DB test) CI (FIX-CI): - Wire buzz-db NIP-37 draft Postgres tests to backend-integration job Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .github/workflows/ci.yml | 9 + crates/buzz-db/src/lib.rs | 234 +++++++++++-- crates/buzz-relay/src/handlers/event.rs | 40 ++- crates/buzz-relay/src/handlers/ingest.rs | 48 ++- .../buzz-relay/src/handlers/side_effects.rs | 14 +- .../buzz-relay/src/mesh_status_publisher.rs | 2 +- .../buzz-test-client/tests/e2e_nip37_draft.rs | 325 +++++++++++------- 7 files changed, 486 insertions(+), 186 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4e5f9dbc7..a91e4a4d2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -620,6 +620,15 @@ jobs: 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: Upload relay log if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index c6a0887668..de2efaa9fb 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -2792,12 +2792,16 @@ 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 (`expected_channel_id`):** When `Some(expected)`, - /// the function checks — **inside the advisory lock, before the stale-ordering step** — - /// that the current head's `channel_id` equals `expected`. If it differs the - /// transaction is rolled back and an `Err(DbError::DraftChannelMismatch)` is returned. - /// Pass `None` to skip the check (all parameterized-replaceable kinds except - /// kind:31234 draft wraps). + /// **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. + /// + /// 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 @@ -2809,7 +2813,6 @@ impl Db { event: &nostr::Event, d_tag: &str, channel_id: Option, - expected_channel_id: Option>, ) -> Result<(StoredEvent, bool)> { let kind_i32 = buzz_core::kind::event_kind_i32(event); let pubkey_bytes = event.pubkey.to_bytes(); @@ -2893,12 +2896,13 @@ impl Db { // Immutable channel-binding check for kind:31234 draft wraps. // - // 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 let Some(expected) = expected_channel_id { + // 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 { if let Some((_, _, head_channel_id)) = &existing { - if *head_channel_id != expected { + if *head_channel_id != channel_id { tx.rollback().await?; return Err(DbError::DraftChannelMismatch); } @@ -4210,12 +4214,49 @@ mod tests { .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. + /// 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() { @@ -4225,37 +4266,135 @@ mod tests { 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(); - // Insert a draft into community A. - let draft = build_test_draft(&keys, &d_tag, &ch_a); - let (_, inserted) = db - .replace_parameterized_event(community_a, &draft, &d_tag, Some(ch_a), Some(Some(ch_a))) + 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("insert draft into A"); - assert!(inserted, "draft must be inserted into A"); + .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); - // Reading the same (pubkey, d_tag) from community B must see no existing head - // → expected_channel_id check must pass (no head → no conflict). + let keys = nostr::Keys::generate(); + let ch_a = Uuid::new_v4(); let ch_b = Uuid::new_v4(); - let draft_b = build_test_draft(&keys, &d_tag, &ch_b); + 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_b, - &draft_b, - &d_tag, - Some(ch_b), - Some(Some(ch_b)), - ) + .replace_parameterized_event(community, &draft_v2, &d_tag, Some(ch_b)) .await; assert!( - result.is_ok(), - "same (pubkey, d_tag) in community B must be an independent address; got: {result:?}" + matches!(result, Err(DbError::DraftChannelMismatch)), + "sequential rebind to a different channel must return DraftChannelMismatch; got: {result:?}" ); - let (_, b_inserted) = result.unwrap(); - assert!( - b_inserted, - "draft must be stored as a new independent head in B" + + // 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" ); } @@ -4264,6 +4403,8 @@ mod tests { /// /// 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() { @@ -4284,7 +4425,7 @@ mod tests { 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), Some(Some(ch_a))) + db_a.replace_parameterized_event(community, &ev_a, &d_a, Some(ch_a)) .await }; @@ -4292,7 +4433,7 @@ mod tests { 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), Some(Some(ch_b))) + db_b.replace_parameterized_event(community, &ev_b, &d_b, Some(ch_b)) .await }; @@ -4321,5 +4462,24 @@ mod tests { "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" + ); } } diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index d4151d7c2a..90b93cd261 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -2434,17 +2434,51 @@ mod tests { /// /// A draft must never arrive in the workflow engine, regardless /// of future refactoring in the dispatch path. + /// + /// The test exercises the **actual dispatch predicate** used in + /// `dispatch_persistent_event_inner` (the `if` condition that + /// gates the workflow-spawn block) — not just membership in the + /// constant. It compares a kind:31234 draft against a kind:9 + /// channel message to prove the gate is live: the predicate must + /// evaluate to `false` for the draft and `true` for the message. #[test] fn draft_kind_is_excluded_from_workflow_dispatch_by_author_only_guard() { - // This is the exact predicate that gates workflow dispatch in - // `dispatch_persistent_event`. Changing AUTHOR_ONLY_KINDS - // without updating this test would turn it red immediately. + // Step 1 — membership check: AUTHOR_ONLY_KINDS must contain KIND_DRAFT. + // Changing the constant without updating this test turns it red. assert!( buzz_core::kind::AUTHOR_ONLY_KINDS.contains(&buzz_core::kind::KIND_DRAFT), "KIND_DRAFT must be in AUTHOR_ONLY_KINDS — the workflow-dispatch guard \ at event.rs:514 (`!AUTHOR_ONLY_KINDS.contains(&kind_u32)`) relies on \ this to suppress draft events from reaching the workflow engine" ); + + // Step 2 — dispatch-predicate evaluation for a normal user event + // (is_relay_workflow_msg = false for any user-submitted event, so + // that branch does not affect the predicate). This mirrors the + // exact `if` condition that gates the workflow spawn in + // `dispatch_persistent_event_inner`. + let should_trigger_workflow = |kind_u32: u32| -> bool { + // is_relay_workflow_msg = false for user-submitted events. + !buzz_core::kind::is_workflow_execution_kind(kind_u32) + && !buzz_core::kind::is_command_kind(kind_u32) + && kind_u32 != buzz_core::kind::KIND_GIFT_WRAP + && !buzz_core::kind::AUTHOR_ONLY_KINDS.contains(&kind_u32) + }; + + assert!( + !should_trigger_workflow(buzz_core::kind::KIND_DRAFT), + "workflow dispatch predicate must return false for kind:31234 — \ + a draft wrap must NEVER reach the workflow engine" + ); + + // Positive control: a plain channel message (kind:9) must pass the + // same predicate so we know the gate is not just trivially always-false. + let kind_channel_message: u32 = 9; + assert!( + should_trigger_workflow(kind_channel_message), + "workflow dispatch predicate must return true for kind:9 channel messages \ + (positive control) — the gate must be live, not trivially always-false" + ); } } } diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 4eefe96d0c..4f3ae99cfe 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2020,6 +2020,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 { @@ -2395,25 +2422,14 @@ async fn ingest_event_inner( ))); } - // For kind:31234 draft wraps, pass the expected channel_id so that // replace_parameterized_event enforces the immutable-binding invariant - // atomically inside the advisory lock. All other parameterized- - // replaceable kinds pass None (no channel binding constraint). - let expected_channel_id = if kind_u32 == KIND_DRAFT { - Some(channel_id) - } else { - None - }; - + // 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, - expected_channel_id, - ) + .replace_parameterized_event(tenant.community(), &event, &d_tag, channel_id) .await .map_err(|e| match e { buzz_db::DbError::DraftChannelMismatch => IngestError::Rejected( diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 932f9d7321..b7cdc51dce 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1984,6 +1984,18 @@ async fn handle_a_tag_deletion( let actor_bytes = effective_message_author(event, &state.relay_keypair.public_key()); match kind_num { + // NIP-37 draft wraps use an empty-content tombstone (kind:31234 with "" + // content) for deletion — they do NOT use NIP-09 a-tag soft-deletion. + // Accepting a NIP-09 a-tag soft-delete would erase the head row, after + // which a different-channel write would see no existing head and bypass + // the immutable-binding invariant. Reject with an error so the + // caller can surface it as a validation failure. + 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) { @@ -3037,7 +3049,7 @@ pub async fn publish_dm_visibility_snapshot( let (stored, was_inserted) = state .db - .replace_parameterized_event(tenant.community(), &event, &viewer_hex, None, None) + .replace_parameterized_event(tenant.community(), &event, &viewer_hex, None) .await?; if was_inserted { dispatch_persistent_event( diff --git a/crates/buzz-relay/src/mesh_status_publisher.rs b/crates/buzz-relay/src/mesh_status_publisher.rs index f725689f2a..4285e72211 100644 --- a/crates/buzz-relay/src/mesh_status_publisher.rs +++ b/crates/buzz-relay/src/mesh_status_publisher.rs @@ -225,7 +225,7 @@ pub async fn publish_mesh_status( let (stored, was_inserted) = state .db - .replace_parameterized_event(tenant.community(), &event, &d_tag, None, None) + .replace_parameterized_event(tenant.community(), &event, &d_tag, None) .await?; if was_inserted { let relay_pubkey_hex = state.relay_keypair.public_key().to_hex(); diff --git a/crates/buzz-test-client/tests/e2e_nip37_draft.rs b/crates/buzz-test-client/tests/e2e_nip37_draft.rs index 23840d7863..19f1e2e3a2 100644 --- a/crates/buzz-test-client/tests/e2e_nip37_draft.rs +++ b/crates/buzz-test-client/tests/e2e_nip37_draft.rs @@ -265,7 +265,7 @@ 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()) + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) .tags([ Tag::parse(["d", &d]).unwrap(), Tag::parse(["k", "9"]).unwrap(), @@ -288,7 +288,7 @@ async fn test_draft_rejected_duplicate_h_tag() { 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()) + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) .tags([ Tag::parse(["d", &d]).unwrap(), Tag::parse(["k", "9"]).unwrap(), @@ -311,7 +311,7 @@ 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()) + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) .tags([ Tag::parse(["d", &d]).unwrap(), Tag::parse(["k", "9"]).unwrap(), @@ -530,7 +530,7 @@ async fn test_draft_accepted_future_expiration() { 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()) + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) .tags([ Tag::parse(["d", &d]).unwrap(), Tag::parse(["k", "9"]).unwrap(), @@ -549,7 +549,7 @@ 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()) + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) .tags([ Tag::parse(["k", "9"]).unwrap(), Tag::parse(["h", &ch_id]).unwrap(), @@ -567,7 +567,7 @@ 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()) + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) .tags([ Tag::parse(["d", ""]).unwrap(), Tag::parse(["k", "9"]).unwrap(), @@ -588,7 +588,7 @@ async fn test_draft_rejected_oversized_d_tag() { 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()) + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) .tags([ Tag::parse(["d", &d_tag]).unwrap(), Tag::parse(["k", "9"]).unwrap(), @@ -611,7 +611,7 @@ async fn test_draft_rejected_duplicate_d_tag() { 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()) + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) .tags([ Tag::parse(["d", &d]).unwrap(), Tag::parse(["d", &d]).unwrap(), @@ -632,7 +632,7 @@ async fn test_draft_rejected_missing_k_tag() { 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()) + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) .tags([ Tag::parse(["d", &d]).unwrap(), Tag::parse(["h", &ch_id]).unwrap(), @@ -651,7 +651,7 @@ async fn test_draft_rejected_duplicate_k_tag() { 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()) + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) .tags([ Tag::parse(["d", &d]).unwrap(), Tag::parse(["k", "9"]).unwrap(), @@ -672,7 +672,7 @@ async fn test_draft_rejected_malformed_k_tag_non_decimal() { 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()) + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) .tags([ Tag::parse(["d", &d]).unwrap(), Tag::parse(["k", "0x9"]).unwrap(), @@ -695,7 +695,7 @@ async fn test_draft_rejected_k_tag_leading_zero() { 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()) + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) .tags([ Tag::parse(["d", &d]).unwrap(), Tag::parse(["k", "09"]).unwrap(), @@ -715,7 +715,7 @@ async fn test_draft_rejected_k_tag_out_of_range() { 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()) + 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 @@ -735,7 +735,7 @@ async fn test_draft_rejected_p_tag() { 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()) + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) .tags([ Tag::parse(["d", &d]).unwrap(), Tag::parse(["k", "9"]).unwrap(), @@ -779,7 +779,7 @@ async fn test_draft_rejected_expiration_in_past() { 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()) + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) .tags([ Tag::parse(["d", &d]).unwrap(), Tag::parse(["k", "9"]).unwrap(), @@ -892,7 +892,7 @@ async fn test_draft_same_second_tie_break_lower_id_wins() { // 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()) + let e = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) .tags([ Tag::parse(["d", &d]).unwrap(), Tag::parse(["k", "9"]).unwrap(), @@ -907,11 +907,15 @@ async fn test_draft_same_second_tie_break_lower_id_wins() { } // Deduplicate by ID (should never trigger, but kept for safety). candidates.dedup_by_key(|e| e.id.to_hex()); - if candidates.len() < 2 { - // Extremely unlikely — skip rather than fail. - return; - } - candidates.sort_by(|a, b| a.id.to_hex().cmp(&b.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(); @@ -1259,14 +1263,16 @@ async fn test_draft_attacker_cannot_retrieve_by_known_d_tag_exclusive_ws() { 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. - // This also covers the kindless #d path: explicit kind:31234 is strictly worse - // for the attacker than kindless, so if the kind-specific filter is blocked the - // kindless variant is also blocked by the author-only gate. + // + // 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()); @@ -1274,14 +1280,15 @@ async fn test_draft_attacker_cannot_retrieve_draft_by_d_tag_in_mixed_kinds_ws() let (ok, msg) = submit_event_http(&client, &victim, &draft).await; assert!(ok, "victim draft must be accepted: {msg}"); - // Also publish a public kind:0 so we can verify the filter would return - // other results if drafts were not gated. - let profile = EventBuilder::new(Kind::Metadata, "{}") + // 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 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}"); + 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 @@ -1321,27 +1328,32 @@ async fn test_draft_attacker_cannot_retrieve_draft_by_d_tag_in_mixed_kinds_ws() other => panic!("expected CLOSED for #d+kind:31234 filter, got: {other:?}"), } - // Positive control: a kindless #d filter must NOT return drafts but also - // must not CLOSED (it's a valid filter for other kinds). - let sid2 = sub_id("d-kindless-check"); - let filter2 = Filter::new().custom_tag( - nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), - d.as_str(), - ); + // 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 kindless"); - let kindless_results = ac + .expect("subscribe mixed kinds"); + let mixed_results = ac .collect_until_eose(&sid2, Duration::from_secs(5)) .await - .expect("collect kindless"); + .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!( - !kindless_results.iter().any(|e| e.id == draft_id), - "kindless #d filter must not expose victim's draft to attacker" + !mixed_results.iter().any(|e| e.id == draft_id), + "kind:31234 draft must not appear in [30023,31234]+#d filter for attacker" ); - // The profile (kind:0) has no d-tag so it won't appear here either; that's fine. - // The important thing is the draft is not in the results. - let _ = profile_id; // referenced for completeness ac.disconnect().await.expect("disconnect"); } @@ -1610,47 +1622,27 @@ async fn test_draft_live_fanout_only_reaches_author() { ac.disconnect().await.expect("disconnect"); } -// ─── Tenant confinement ─────────────────────────────────────────────────────── - -#[tokio::test] -#[ignore] -async fn test_draft_tenant_confinement_channel_from_different_community() { - // Channel UUID that exists in one community must not be valid in another. - // This test requires a second community/tenant to be reachable — if the - // relay runs as a single tenant the test is a no-op (channel simply won't - // exist from the adversarial requester's perspective). - // - // We simulate by using a randomly generated UUID that is almost certain - // not to exist in any community: submitting a draft to that UUID must - // be rejected by the nonexistent-channel check. - let client = http_client(); - let adversary = Keys::generate(); - let alien_channel_id = uuid::Uuid::new_v4().to_string(); +// ─── Nonexistent / alien channel rejection ──────────────────────────────────── - let d = uuid::Uuid::new_v4().to_string(); - let event = build_draft(&adversary, &d, "9", &alien_channel_id, &fake_nip44_v2()); - let (accepted, msg) = submit_event_http(&client, &adversary, &event).await; - assert!( - !accepted, - "draft to alien/nonexistent channel must be rejected" - ); - assert!( - msg.contains("channel") || msg.contains("member") || msg.contains("not found"), - "unexpected message: {msg}" - ); -} +// 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). -// ─── Workflow exclusion ─────────────────────────────────────────────────────── +// ─── Kindless channel query — draft privacy ─────────────────────────────────── #[tokio::test] #[ignore] -async fn test_draft_not_returned_in_kindless_channel_query() { - // A kindless channel filter must not return draft events even when the - // requester is the author. Draft content is private — never leaked via - // channel-scoped queries. +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(); @@ -1668,11 +1660,11 @@ async fn test_draft_not_returned_in_kindless_channel_query() { let (ok_m, msg_m) = submit_event_http(&client, &owner, &msg_event).await; assert!(ok_m, "channel message must be accepted: {msg_m}"); - // Query by channel h-tag, no kind filter. - let mut c = BuzzTestClient::connect(&url, &owner) + // Attacker queries by channel h-tag, no kind filter. + let mut c = BuzzTestClient::connect(&url, &attacker) .await - .expect("connect"); - let sid = sub_id("ch-kindless"); + .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(), @@ -1683,15 +1675,15 @@ async fn test_draft_not_returned_in_kindless_channel_query() { .await .expect("collect"); - // Channel message must appear. + // 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 h-tag query (positive control)" + "channel message must appear in attacker's h-tag query (positive control)" ); - // Draft must be absent — drafts are author-private, not channel-public. + // 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" + "draft must not be returned by a kindless channel h-tag filter to a non-author" ); c.disconnect().await.expect("disconnect"); } @@ -1702,40 +1694,43 @@ async fn test_draft_not_returned_in_kindless_channel_query() { #[ignore] async fn test_draft_not_indexed_in_fts_search() { // A kind:31234 has NULL search_tsv at the storage layer, so NIP-50 search - // must never surface it — even when the draft's content would otherwise - // contain the search token, and even when the requester is the author. + // must never surface it — even when the requester is the author. + // + // The test explicitly uses kinds=[1,31234] in the search filter so the query + // cannot be satisfied by the read-gate alone: if kind:31234 had a non-NULL + // tsvector matching the token, the relay would return it to the authorized + // author. NULL tsvector is the only thing that hides it. 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(); - // Use a unique marker as the search token and include it in the kind:1 note - // content (positive control) and as the "label" of the draft's fake - // NIP-44 payload (which is base64 — not searchable at the storage level). - let marker = format!("nip37fts_probe_{}", uuid::Uuid::new_v4().simple()); + // 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, &marker) + 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 — MUST NOT appear in FTS results. - // Content is valid NIP-44 v2 ciphertext regardless of the marker (storage - // layer enforces NULL tsvector for kind:31234 before content is considered). + // Kind:31234 draft — NIP-44 v2 content (relay validates). Storage migration + // sets search_tsv = NULL for all kind:31234 rows, so even a theoretically + // searchable payload must not surface in FTS results. 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 an explicit kinds=[1,31234] filter to ensure we - // probe the storage-layer null-tsvector exclusion, not just the read gate. + // Search as the author with explicit kinds=[1,31234]. The kind:31234 draft + // is excluded by NULL search_tsv; the kind:1 note IS found. let search_filter = Filter::new() .kinds(vec![Kind::TextNote, Kind::Custom(KIND_DRAFT)]) - .search(&marker) + .search(&token) .limit(50); let results = query_events_http(&client, &victim.public_key().to_hex(), vec![search_filter]).await; @@ -1756,7 +1751,7 @@ async fn test_draft_not_indexed_in_fts_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(&marker) + .search(&token) .limit(50); let attacker_results = query_events_http( &client, @@ -1800,6 +1795,69 @@ async fn test_nip11_advertises_nip37_not_nip40() { ); } +// ─── NIP-09 a-tag deletion guard ───────────────────────────────────────────── + +#[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" + ); +} + // ─── DM channel path ───────────────────────────────────────────────────────── #[tokio::test] @@ -1839,26 +1897,36 @@ async fn test_draft_accepted_and_replaced_in_dm_channel() { .expect("channel_id in DM response") .to_string() } else { - // Fallback: skip test if DM channel UUID is not surfaced here. - return; + 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 now = nostr::Timestamp::now().as_secs(); + let base = nostr::Timestamp::now().as_secs(); let v1 = build_draft_at( &alice, &d, "9", &dm_channel_id, &fake_nip44_v2(), - nostr::Timestamp::from(now - 1), + 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 newer version. - let v2 = build_draft(&alice, &d, "9", &dm_channel_id, &fake_nip44_v2()); + // 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!( @@ -1885,8 +1953,14 @@ async fn test_draft_accepted_and_replaced_in_dm_channel() { "v2 must be the head after replacement" ); - // Tombstone the draft. - let tomb = build_tombstone(&alice, &d, "9", &dm_channel_id, nostr::Timestamp::now()); + // 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}"); @@ -1985,27 +2059,27 @@ async fn test_removed_member_cannot_read_drafts_after_removal() { ); } - // Live fan-out: owner posts a new draft to the same channel; removed member - // must not receive it via a pre-existing WS subscription. + // 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(member.public_key()) + .author(owner.public_key()) .limit(0); // skip historical; only live - // This subscription itself may be CLOSED (author-only with no membership); - // either outcome is correct. 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 for the removed member's address. - // (Won't be accepted since owner != member, but any live fanout to removed - // member would indicate a leak.) - // Verify no draft events arrive for the removed member. + // Owner submits a new draft — this is the live probe event. let owner_draft = build_draft( &owner, &uuid::Uuid::new_v4().to_string(), @@ -2015,19 +2089,14 @@ async fn test_removed_member_cannot_read_drafts_after_removal() { ); let owner_draft_id = owner_draft.id; let (ok_od, _) = submit_event_http(&client, &owner, &owner_draft).await; - // Owner's own draft succeeds; check it doesn't reach removed member. + // 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 { - loop { - match removed_client.recv_event(Duration::from_secs(1)).await { - Ok(RelayMessage::Event { event, .. }) => { - if event.id == owner_draft_id { - panic!( - "removed member received a draft via live fan-out after removal" - ); - } - } - _ => break, + 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"); } } }) From edd16089db6554f00a1f6d1e5b6d53e8f600c9c2 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Sat, 11 Jul 2026 17:52:45 -0400 Subject: [PATCH 06/20] =?UTF-8?q?test(relay):=20fix=20test=5Fdraft=5Frejec?= =?UTF-8?q?ted=5Fp=5Ftag=20=E2=80=94=20use=20third-party=20pubkey=20in=20p?= =?UTF-8?q?=20tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EventBuilder silently drops p tags whose value equals the signer's own pubkey (NIP self-tagging suppression, enabled by default). The test was using owner.public_key().to_hex() as the p tag value, so the tag was stripped before signing and the event arrived at the relay with no p tag at all — the validator always saw a clean envelope and the rejection was never exercised. Fix: generate a separate Keys pair for the p tag value so it survives EventBuilder's self-tag filter and reaches the relay's validator. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-test-client/tests/e2e_nip37_draft.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/buzz-test-client/tests/e2e_nip37_draft.rs b/crates/buzz-test-client/tests/e2e_nip37_draft.rs index 19f1e2e3a2..f10afe2ca6 100644 --- a/crates/buzz-test-client/tests/e2e_nip37_draft.rs +++ b/crates/buzz-test-client/tests/e2e_nip37_draft.rs @@ -733,6 +733,11 @@ async fn test_draft_rejected_k_tag_out_of_range() { 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()) @@ -740,7 +745,7 @@ async fn test_draft_rejected_p_tag() { Tag::parse(["d", &d]).unwrap(), Tag::parse(["k", "9"]).unwrap(), Tag::parse(["h", &ch_id]).unwrap(), - Tag::parse(["p", &owner.public_key().to_hex()]).unwrap(), + Tag::parse(["p", &other.public_key().to_hex()]).unwrap(), ]) .sign_with_keys(&owner) .unwrap(); From 1e82a573d7efcc1778bbae0b298ccb84b8d01f75 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Sat, 11 Jul 2026 18:33:13 -0400 Subject: [PATCH 07/20] fix(relay): block all e-tag deletion routes targeting kind:31234 drafts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A kind:5 e-tag deletion that resolved to a kind:31234 event was accepted by validate_standard_deletion_event (which only checked authorship, not target kind). After soft-delete the live head row was gone, allowing a second write with a different channel h-tag to bypass the immutable-binding invariant. That path was fixed in the previous commit on this branch. A second bypass existed via kind:9005 (channel admin delete): the 9005 branch in validate_admin_event resolved the e-tag target and authorized the actor (including channel admins who do not own the draft), then returned Ok(()). Post-storage side effects would soft-delete the head row, re-opening the cross-channel rebind window. Fix: in the kind:9005 branch of validate_admin_event (side_effects.rs), immediately after target resolution, reject pre-storage if the target kind is KIND_DRAFT. Authorship and agent-owner actors receive the tombstone-guidance error (mirror of the kind:5 wording). All other actors — including channel admins — receive the generic "target event not found" response, byte-identical to the missing-target branch, so the validator cannot act as a draft-existence oracle. Add two new E2E regressions: - test_nip09_kind9005_deletion_of_draft_is_rejected_and_binding_holds: full bypass sequence (publish draft h=A -> kind:9005 e=draft rejected -> head still live -> h=B rebind rejected), deterministic base/base+1 timestamps. - test_nip09_kind9005_admin_deletion_of_draft_is_masked_as_not_found: channel admin submits kind:9005 targeting a draft; verifies response is exactly "target event not found" (oracle-masking tripwire). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../buzz-relay/src/handlers/side_effects.rs | 54 +++- .../buzz-test-client/tests/e2e_nip37_draft.rs | 275 +++++++++++++++++- 2 files changed, 324 insertions(+), 5 deletions(-) diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index b7cdc51dce..282b200a5c 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}; @@ -226,6 +226,20 @@ pub async fn validate_standard_deletion_event( { return Err(anyhow::anyhow!("must be event author")); } + + // NIP-37: kind:5 e-tag 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). + // Check is placed after authz so the relay does not reveal draft + // existence to callers who do not own the target event. + if event_kind_u32(&target_event.event) == KIND_DRAFT { + return Err(anyhow::anyhow!( + "NIP-09 e-tag deletion of kind:31234 draft wraps is not supported; \ + use an empty-content tombstone (kind:31234 with empty content) instead" + )); + } } Ok(()) @@ -558,6 +572,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!( diff --git a/crates/buzz-test-client/tests/e2e_nip37_draft.rs b/crates/buzz-test-client/tests/e2e_nip37_draft.rs index f10afe2ca6..c62db7bfc5 100644 --- a/crates/buzz-test-client/tests/e2e_nip37_draft.rs +++ b/crates/buzz-test-client/tests/e2e_nip37_draft.rs @@ -1800,7 +1800,7 @@ async fn test_nip11_advertises_nip37_not_nip40() { ); } -// ─── NIP-09 a-tag deletion guard ───────────────────────────────────────────── +// ─── NIP-09 deletion guard (a-tag and e-tag) ────────────────────────────────── #[tokio::test] #[ignore] @@ -1863,6 +1863,279 @@ async fn test_nip09_a_tag_deletion_of_draft_is_rejected() { ); } +#[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] From ed274bdc3c566dc01405d649387418f0405877b3 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Sun, 12 Jul 2026 13:09:46 -0400 Subject: [PATCH 08/20] fix(relay): exclude author-only kinds from channel-window path The bridge top_level channel-window filter (handle_channel_window_filter) passed all window rows to the response without an author-only guard. Since next_cursor and has_more are computed at the DB layer before any in-memory filtering, a bridge-level skip would still expose draft ids via the 39006 bounds overlay and leave has_more counts inflated by draft rows. Fix: add an author_pubkey param to get_channel_window. When Some, the query appends AND (e.kind NOT IN (30300, 31234) OR e.pubkey = $N), excluding author-only kinds (KIND_DRAFT=31234, KIND_EVENT_REMINDER=30300) for rows whose pubkey does not match the requester. This keeps the cursor, has_more, and all overlay values computed against the already-restricted row set. Pass Some(&pubkey_bytes) from handle_channel_window_filter via the new pubkey_bytes parameter; internal / test callers pass None. Tests: two new e2e tests in e2e_nip37_draft.rs: - test_channel_window_draft_excluded_for_non_author: mixed kinds:[9,31234] query by a channel member who is not the author must return zero draft rows and zero draft ids anywhere in the response (rows, aux, overlays). kind:9 positive control row must still be present. - test_channel_window_draft_visible_to_author: the author sees their own kind:31234 draft in the window, consistent with all other author-only read paths. Closes the last unguarded author-only read surface identified in code review of PR #1757. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-db/src/lib.rs | 2 + crates/buzz-db/src/thread.rs | 35 +++- crates/buzz-relay/src/api/bridge.rs | 3 + .../buzz-test-client/tests/e2e_nip37_draft.rs | 197 ++++++++++++++++++ 4 files changed, 232 insertions(+), 5 deletions(-) diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index de2efaa9fb..be26efb02b 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 } diff --git a/crates/buzz-db/src/thread.rs b/crates/buzz-db/src/thread.rs index 3f92212dd5..aae37987b5 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 (kind:31234 drafts, +/// kind:30300 reminders) are excluded for rows whose `pubkey` does not match. +/// 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,21 @@ 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; + // KIND_AUTHOR_ONLY list kept in sync with buzz_core::kind::AUTHOR_ONLY_KINDS + // (30300 = KIND_EVENT_REMINDER, 31234 = KIND_DRAFT). + sql.push_str(&format!( + " AND (e.kind NOT IN (30300, 31234) 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 +656,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 +1556,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 +1621,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 +1671,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 +1720,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..f20e51a00e 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -374,6 +374,7 @@ async fn handle_channel_window_filter( filter: &nostr::Filter, accessible_channels: &[uuid::Uuid], events: &mut Vec, + pubkey_bytes: &[u8], ) -> Result<(), (StatusCode, Json)> { use buzz_core::kind::{KIND_THREAD_SUMMARY, KIND_WINDOW_BOUNDS}; @@ -436,6 +437,7 @@ 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}")))?; @@ -768,6 +770,7 @@ pub async fn query_events( filter, &accessible_channels, &mut events, + &pubkey_bytes, ) .await?; handled.insert(idx); diff --git a/crates/buzz-test-client/tests/e2e_nip37_draft.rs b/crates/buzz-test-client/tests/e2e_nip37_draft.rs index c62db7bfc5..6cfa45b343 100644 --- a/crates/buzz-test-client/tests/e2e_nip37_draft.rs +++ b/crates/buzz-test-client/tests/e2e_nip37_draft.rs @@ -2382,3 +2382,200 @@ async fn test_removed_member_cannot_read_drafts_after_removal() { } 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:?}" + ); +} From 5c3ac628603bd0c71c11adf906c0557a66d9c57f Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Sun, 12 Jul 2026 14:08:28 -0400 Subject: [PATCH 09/20] fix(relay): close write-path id-oracle and complete Q2-Q16 review fixes Add actor_can_reference_target helper to mask author-only event ids on all five write-path target-resolution sites (reaction channel derivation, NIP-10 thread parent, stream-edit, forum-vote, kind:5 e-tag deletion), preventing non-authors from distinguishing a real draft/reminder id from a random id via differential error responses. Q2: DbError::DraftChannelRequired + channel_id=None DB-layer rejection Q3: participates_in_thread_metadata split; outer e-tag rejection on drafts Q4: reader_can_receive_event canonical read gate in buzz-core/filter.rs; wire into req.rs / count.rs / bridge.rs / search path Q5: CI step for buzz-search FTS integration tests (ignored suite now runs) Q6: HTTP catchall kindless privacy e2e test_draft_not_returned_in_kindless_channel_http_query Q7: draft_kind_is_excluded_from_workflow_dispatch_by_author_only_guard Q8: a-tag defensive-guard comment rewrite in side_effects.rs Q9: e2e smoke for outer-e-tag rejection (test_draft_rejected_outer_e_tag) Q10: fan-out author-side positive control (test_draft_live_fanout_reaches_author_own_subscription) Q11: mixed-kinds /count test (test_draft_attacker_mixed_kinds_count_excludes_drafts) Q12: derive exclusion list from AUTHOR_ONLY_KINDS in thread.rs Q13: DM privacy recipient-side test (test_dm_draft_not_readable_by_dm_recipient) Q14: migration 0007 deploy-window note Q15: actor_can_reference_target post-lookup helper applied at reaction channel derivation (all actors rejected), NIP-10 thread parent (all actors rejected), stream-edit / forum-vote (non-author masked, author falls through), kind:5 e-tag deletion (non-author masked before authz, false comment corrected); generalizes over AUTHOR_ONLY_KINDS so kind:30300 reminders are also protected; unit tests confirm membership Q16: channel-window cursor-boundary e2e (test_channel_window_cursor_boundary_excludes_draft) Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .github/workflows/ci.yml | 8 + crates/buzz-core/src/filter.rs | 30 ++ crates/buzz-db/src/error.rs | 10 + crates/buzz-db/src/lib.rs | 61 ++- crates/buzz-db/src/thread.rs | 22 +- crates/buzz-relay/src/api/bridge.rs | 84 +-- crates/buzz-relay/src/handlers/count.rs | 14 +- crates/buzz-relay/src/handlers/ingest.rs | 261 ++++++++- crates/buzz-relay/src/handlers/req.rs | 36 +- .../buzz-relay/src/handlers/side_effects.rs | 65 ++- .../buzz-test-client/tests/e2e_nip37_draft.rs | 496 +++++++++++++++++- migrations/0012_draft_wrap_fts.sql | 6 + 12 files changed, 964 insertions(+), 129 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a91e4a4d2d..8950bf157a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -629,6 +629,14 @@ jobs: 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..cfa0304bf2 100644 --- a/crates/buzz-core/src/filter.rs +++ b/crates/buzz-core/src/filter.rs @@ -32,6 +32,36 @@ 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 +} + +/// Canonical per-event read-authorization gate: combines `reader_authorized_for_event` +/// (p-gated/result-gated kinds) and `is_author_only_event` (author-private kinds) +/// 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 two 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) +} + fn filter_match_one(f: &Filter, ev: &StoredEvent) -> bool { if let Some(kinds) = &f.kinds { if !kinds.contains(&ev.event.kind) { diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index 4989abfcdc..0a3af0a0f6 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -57,6 +57,16 @@ pub enum DbError { "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 be26efb02b..00aa14e91d 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -2798,7 +2798,10 @@ impl Db { /// 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. + /// 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 @@ -2903,6 +2906,13 @@ impl Db { // 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?; @@ -4484,4 +4494,53 @@ mod tests { "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/thread.rs b/crates/buzz-db/src/thread.rs index aae37987b5..e0658aff48 100644 --- a/crates/buzz-db/src/thread.rs +++ b/crates/buzz-db/src/thread.rs @@ -563,11 +563,11 @@ pub async fn get_thread_summary( /// 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 (kind:31234 drafts, -/// kind:30300 reminders) are excluded for rows whose `pubkey` does not match. -/// 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). +/// `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, @@ -638,10 +638,16 @@ pub async fn get_channel_window( // leave those values pointing at or counting draft rows for non-authors. if author_pubkey.is_some() { let pk_idx = param_idx; - // KIND_AUTHOR_ONLY list kept in sync with buzz_core::kind::AUTHOR_ONLY_KINDS - // (30300 = KIND_EVENT_REMINDER, 31234 = KIND_DRAFT). + // 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 (30300, 31234) OR e.pubkey = ${pk_idx})" + " AND (e.kind NOT IN ({ao_list}) OR e.pubkey = ${pk_idx})" )); param_idx += 1; } diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index f20e51a00e..0b346b4e66 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -846,10 +846,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) { @@ -912,10 +917,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) { @@ -1006,17 +1016,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); } @@ -1168,13 +1176,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; } @@ -1232,13 +1237,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; } @@ -1277,6 +1279,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; @@ -1286,7 +1289,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 @@ -1410,10 +1417,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. @@ -2512,11 +2522,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" ); } @@ -2539,9 +2549,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" ); } @@ -2563,11 +2573,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" ); } @@ -2869,11 +2879,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..12bb3e7113 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; @@ -187,12 +187,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; } @@ -257,12 +255,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/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 4f3ae99cfe..708fece4fb 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -12,14 +12,14 @@ 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_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, + 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, @@ -283,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, @@ -311,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()), } @@ -445,6 +461,20 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { ) } +/// 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 @@ -571,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()); @@ -718,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( @@ -752,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) { @@ -765,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 { @@ -790,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 { @@ -833,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()); @@ -1215,10 +1320,14 @@ fn validate_not_before(tag_value: &str) -> Result { /// 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. Non-empty content must be a syntactically plausible NIP-44 v2 ciphertext +/// 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. -/// 6. At most one `expiration` tag, whose value must be canonical ASCII decimal, +/// 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> { @@ -1258,6 +1367,11 @@ fn validate_draft_wrap_envelope(event: &Event) -> Result<(), String> { .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]); @@ -2256,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 @@ -3885,6 +3999,23 @@ mod tests { ); } + #[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. @@ -4001,4 +4132,96 @@ mod tests { "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 that `actor_can_reference_target` masks kind:31234 + // (draft wraps) and kind:30300 (reminders) — all `AUTHOR_ONLY_KINDS` — without + // requiring a live Postgres. The helper is the single seam that closes the + // write-path id-oracle on reaction, thread-parent, stream-edit, forum-vote, + // and kind:5 e-tag deletion paths. + + #[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..fbb2588a5d 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -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) { @@ -1132,7 +1128,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 +1140,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 +1426,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 282b200a5c..35c7551ea8 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -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 @@ -226,20 +256,6 @@ pub async fn validate_standard_deletion_event( { return Err(anyhow::anyhow!("must be event author")); } - - // NIP-37: kind:5 e-tag 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). - // Check is placed after authz so the relay does not reveal draft - // existence to callers who do not own the target event. - if event_kind_u32(&target_event.event) == KIND_DRAFT { - return Err(anyhow::anyhow!( - "NIP-09 e-tag deletion of kind:31234 draft wraps is not supported; \ - use an empty-content tombstone (kind:31234 with empty content) instead" - )); - } } Ok(()) @@ -2030,12 +2046,21 @@ async fn handle_a_tag_deletion( let actor_bytes = effective_message_author(event, &state.relay_keypair.public_key()); match kind_num { - // NIP-37 draft wraps use an empty-content tombstone (kind:31234 with "" - // content) for deletion — they do NOT use NIP-09 a-tag soft-deletion. - // Accepting a NIP-09 a-tag soft-delete would erase the head row, after - // which a different-channel write would see no existing head and bypass - // the immutable-binding invariant. Reject with an error so the - // caller can surface it as a validation failure. + // 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; \ diff --git a/crates/buzz-test-client/tests/e2e_nip37_draft.rs b/crates/buzz-test-client/tests/e2e_nip37_draft.rs index 6cfa45b343..1593b13c7c 100644 --- a/crates/buzz-test-client/tests/e2e_nip37_draft.rs +++ b/crates/buzz-test-client/tests/e2e_nip37_draft.rs @@ -754,6 +754,35 @@ async fn test_draft_rejected_p_tag() { 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() { @@ -1521,6 +1550,66 @@ async fn test_draft_author_can_count_own_drafts_http() { 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] @@ -1627,6 +1716,53 @@ async fn test_draft_live_fanout_only_reaches_author() { 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). + 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()) + .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) @@ -1695,16 +1831,78 @@ async fn test_draft_not_returned_in_kindless_channel_query_by_attacker() { // ─── FTS / NIP-50 exclusion ─────────────────────────────────────────────────── +#[tokio::test] +#[ignore] +async fn test_draft_not_returned_in_kindless_channel_http_query() { + // HTTP bridge catchall /query mirror of the WS kindless h-tag test above. + // Exercises bridge.rs catchall path (distinct from req.rs), proving the + // reader_can_receive_event gate is live there. + // + // Non-author queries the channel with a kindless h-tag filter via HTTP + // /query. Kind:9 message must appear (positive control); kind:31234 draft + // must be absent. + 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}"); + + // Kindless h-tag filter over HTTP /query as non-author. + let kindless_filter = Filter::new().custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::H), + ch_id.as_str(), + ); + let results = query_events_http( + &client, + &attacker.public_key().to_hex(), + vec![kindless_filter], + ) + .await; + + // Kind:9 message must appear — proves the subscription is live. + assert!( + results + .iter() + .any(|e| e["id"].as_str() == Some(&msg_id.to_hex())), + "kind:9 channel message must appear in HTTP kindless query (positive control)" + ); + // Draft must be absent — bridge.rs catchall author-only gate must suppress it. + assert!( + !results + .iter() + .any(|e| e["id"].as_str() == Some(&draft_id.to_hex())), + "kind:31234 draft must not appear in non-author HTTP kindless channel query" + ); +} + #[tokio::test] #[ignore] async fn test_draft_not_indexed_in_fts_search() { - // A kind:31234 has NULL search_tsv at the storage layer, so NIP-50 search - // must never surface it — even when the requester is the author. + // 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. // - // The test explicitly uses kinds=[1,31234] in the search filter so the query - // cannot be satisfied by the read-gate alone: if kind:31234 had a non-NULL - // tsvector matching the token, the relay would return it to the authorized - // author. NULL tsvector is the only thing that hides it. + // 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(); @@ -1723,16 +1921,17 @@ async fn test_draft_not_indexed_in_fts_search() { 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). Storage migration - // sets search_tsv = NULL for all kind:31234 rows, so even a theoretically - // searchable payload must not surface in FTS results. + // 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:31234 draft - // is excluded by NULL search_tsv; the kind:1 note IS found. + // 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) @@ -2259,6 +2458,101 @@ async fn test_draft_accepted_and_replaced_in_dm_channel() { ); } +#[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] @@ -2579,3 +2873,183 @@ async fn test_channel_window_draft_visible_to_author() { "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. + // Then post a draft between message 1 and 2 (same second as msg-1 so it + // sits in the middle of the window) to make it straddle the cursor + // boundary when limit=2. + 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}"); + } + + // Post a draft with a timestamp between msg-0 and msg-1 so it lands in + // the middle of the window (created_at = base_ts + 1). + let d = uuid::Uuid::new_v4().to_string(); + let draft_ts = nostr::Timestamp::from(base_ts + 1); + 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)) + }); + + // If the relay returned has_more=true and a cursor, paginate to page 2 + // and verify the second page also contains no draft id. + if let Some((cursor_ts, cursor_id)) = cursor { + // 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:?}" + ); + } + // If has_more=false, all 3 messages fit in one page — cursor test skipped, + // but the no-draft assertion above already passed. +} diff --git a/migrations/0012_draft_wrap_fts.sql b/migrations/0012_draft_wrap_fts.sql index cb31cff62b..7d45b1207d 100644 --- a/migrations/0012_draft_wrap_fts.sql +++ b/migrations/0012_draft_wrap_fts.sql @@ -10,6 +10,12 @@ -- We must DROP the generated column and re-ADD it with the extended exclusion -- list; ALTER COLUMN cannot change a GENERATED expression in Postgres. -- +-- DEPLOY NOTE: the DROP + re-ADD below 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. +-- -- Final kind exclusion list after this migration: -- 1059 = KIND_GIFT_WRAP (NIP-17 ciphertext) -- 30300 = KIND_EVENT_REMINDER (AUTHOR_ONLY_KINDS — defense in depth) From 4a7ea477edb7d9cac0ee8ca2b7544770690b1c8f Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Sun, 12 Jul 2026 14:30:20 -0400 Subject: [PATCH 10/20] test(relay): add Q15 e2e oracle-closure tests for write-path draft id guards The previous Q15 unit tests only asserted AUTHOR_ONLY_KINDS membership; deleting the guard from derive_reaction_channel or resolve_nip10_thread_meta left every one green. Replace the vacuous coverage with four `#[ignore]` e2e tests that exercise the live relay: - test_draft_target_reaction_oracle_closed: attacker submits kind:7 reaction to (A) real draft id, (B) random nonexistent id; asserts byte-identical error string and zero kind:7 rows stored. - test_draft_target_thread_parent_oracle_closed: attacker submits kind:9 reply with draft id as NIP-10 parent vs random parent; asserts byte-identical error, zero stored replies, and no 39005 fan-out. - test_draft_target_public_reference_author_also_rejected: draft author reacts to and replies-to their own draft; both must be rejected with the masking not-found errors (public-reference paths reject everyone). - test_draft_target_kind5_oracle_closed: attacker submits kind:5 e-tag deletion targeting (A) real draft id, (B) random id; asserts byte-identical masking error and draft still readable by author. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../buzz-test-client/tests/e2e_nip37_draft.rs | 397 ++++++++++++++++++ 1 file changed, 397 insertions(+) diff --git a/crates/buzz-test-client/tests/e2e_nip37_draft.rs b/crates/buzz-test-client/tests/e2e_nip37_draft.rs index 1593b13c7c..a61afddaa6 100644 --- a/crates/buzz-test-client/tests/e2e_nip37_draft.rs +++ b/crates/buzz-test-client/tests/e2e_nip37_draft.rs @@ -3053,3 +3053,400 @@ async fn test_channel_window_cursor_boundary_excludes_draft() { // If has_more=false, all 3 messages fit in one page — cursor test skipped, // but the no-draft assertion above already passed. } + +// ─── 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" + ); +} From 985c20f88588f0653335569121167c88adfe9583 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Sun, 12 Jul 2026 14:46:59 -0400 Subject: [PATCH 11/20] fix(relay): address pass-1 review findings R1-R3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1 (IMPORTANT): Extract should_dispatch_workflow helper from inline if condition in dispatch_persistent_event_inner. The Q7 test previously duplicated the production predicate as a local closure; deleting the AUTHOR_ONLY_KINDS guard from the real if block would leave it green. Now dispatch_persistent_event_inner calls should_dispatch_workflow, and the test calls the same function — no copy-paste divergence is possible. Test gains kind:30300 + is_relay_workflow_msg=true controls. R2 (IMPORTANT): Add test_reminder_target_reaction_oracle_closed e2e to prove the author-only mask in derive_reaction_channel generalizes to kind:30300 reminders (not only kind:31234 drafts). Corrects the overclaiming comment at ingest.rs:4149-4154 which asserted the unit tests exercised actor_can_reference_target directly; they only assert constant membership, and the comment now says so explicitly with a pointer to the e2e tests that provide behavioral coverage. R3 (MINOR): Move draft timestamp to base+3 in test_channel_window_cursor_boundary_excludes_draft so the draft is inside the raw first page (raw DESC: msg2@+4, draft@+3, msg1@+2, msg0@+0 — a filter-after-pagination regression would cursor off draft). Replace the optional if-let cursor branch with cursor.expect(), forcing the test to fail if has_more=false or no cursor is returned. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-relay/src/handlers/event.rs | 106 ++++---- crates/buzz-relay/src/handlers/ingest.rs | 15 +- .../buzz-test-client/tests/e2e_nip37_draft.rs | 227 +++++++++++++----- 3 files changed, 235 insertions(+), 113 deletions(-) diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 90b93cd261..cf93b57299 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -370,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, @@ -503,15 +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 - // 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) - { + 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(); @@ -2427,58 +2444,51 @@ mod tests { ); } - /// Tripwire: kind:31234 (NIP-37 draft wrap) MUST appear in - /// `AUTHOR_ONLY_KINDS` so the workflow-dispatch guard at - /// `event.rs:514` (`!AUTHOR_ONLY_KINDS.contains(&kind_u32)`) - /// permanently suppresses workflow triggers for draft events. - /// - /// A draft must never arrive in the workflow engine, regardless - /// of future refactoring in the dispatch path. + /// 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 exercises the **actual dispatch predicate** used in - /// `dispatch_persistent_event_inner` (the `if` condition that - /// gates the workflow-spawn block) — not just membership in the - /// constant. It compares a kind:31234 draft against a kind:9 - /// channel message to prove the gate is live: the predicate must - /// evaluate to `false` for the draft and `true` for the message. + /// 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() { - // Step 1 — membership check: AUTHOR_ONLY_KINDS must contain KIND_DRAFT. - // Changing the constant without updating this test turns it red. + // kind:31234 must be excluded from workflow dispatch. + // is_relay_workflow_msg=false for any user-submitted event. assert!( - buzz_core::kind::AUTHOR_ONLY_KINDS.contains(&buzz_core::kind::KIND_DRAFT), - "KIND_DRAFT must be in AUTHOR_ONLY_KINDS — the workflow-dispatch guard \ - at event.rs:514 (`!AUTHOR_ONLY_KINDS.contains(&kind_u32)`) relies on \ - this to suppress draft events from reaching the workflow engine" + !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" ); - // Step 2 — dispatch-predicate evaluation for a normal user event - // (is_relay_workflow_msg = false for any user-submitted event, so - // that branch does not affect the predicate). This mirrors the - // exact `if` condition that gates the workflow spawn in - // `dispatch_persistent_event_inner`. - let should_trigger_workflow = |kind_u32: u32| -> bool { - // is_relay_workflow_msg = false for user-submitted events. - !buzz_core::kind::is_workflow_execution_kind(kind_u32) - && !buzz_core::kind::is_command_kind(kind_u32) - && kind_u32 != buzz_core::kind::KIND_GIFT_WRAP - && !buzz_core::kind::AUTHOR_ONLY_KINDS.contains(&kind_u32) - }; - + // kind:30300 (NIP-ER reminder) is also AUTHOR_ONLY and must be excluded. assert!( - !should_trigger_workflow(buzz_core::kind::KIND_DRAFT), - "workflow dispatch predicate must return false for kind:31234 — \ - a draft wrap must NEVER reach the workflow engine" + !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 pass the - // same predicate so we know the gate is not just trivially always-false. + // 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!( - should_trigger_workflow(kind_channel_message), - "workflow dispatch predicate must return true for kind:9 channel messages \ + 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 708fece4fb..f8c3bb06e0 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -4153,11 +4153,16 @@ mod tests { } // ── actor_can_reference_target: author-only mask ─────────────────────────── - // These unit tests verify that `actor_can_reference_target` masks kind:31234 - // (draft wraps) and kind:30300 (reminders) — all `AUTHOR_ONLY_KINDS` — without - // requiring a live Postgres. The helper is the single seam that closes the - // write-path id-oracle on reaction, thread-parent, stream-edit, forum-vote, - // and kind:5 e-tag deletion paths. + // 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() { diff --git a/crates/buzz-test-client/tests/e2e_nip37_draft.rs b/crates/buzz-test-client/tests/e2e_nip37_draft.rs index a61afddaa6..dc10888724 100644 --- a/crates/buzz-test-client/tests/e2e_nip37_draft.rs +++ b/crates/buzz-test-client/tests/e2e_nip37_draft.rs @@ -2895,9 +2895,11 @@ async fn test_channel_window_cursor_boundary_excludes_draft() { add_member_http(&client, &author, &ch_id, &attacker).await; // Post 3 public kind:9 messages with strictly increasing timestamps. - // Then post a draft between message 1 and 2 (same second as msg-1 so it - // sits in the middle of the window) to make it straddle the cursor - // boundary when limit=2. + // 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 { @@ -2912,10 +2914,11 @@ async fn test_channel_window_cursor_boundary_excludes_draft() { assert!(ok, "kind:9 message {i} must be accepted: {err}"); } - // Post a draft with a timestamp between msg-0 and msg-1 so it lands in - // the middle of the window (created_at = base_ts + 1). + // 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 + 1); + 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(), @@ -2996,62 +2999,65 @@ async fn test_channel_window_cursor_boundary_excludes_draft() { Some((cursor_ts, cursor_id)) }); - // If the relay returned has_more=true and a cursor, paginate to page 2 - // and verify the second page also contains no draft id. - if let Some((cursor_ts, cursor_id)) = cursor { - // 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" - ); + // 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", + ); - 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:?}" - ); + // 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" + ); - // 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:?}" - ); - } - // If has_more=false, all 3 messages fit in one page — cursor test skipped, - // but the no-draft assertion above already passed. + 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 ──────────────────── @@ -3450,3 +3456,104 @@ async fn test_draft_target_kind5_oracle_closed() { "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:?}" + ); +} From 6dda6fd7c3c181e5bcad8d50ae85be332c0d76fe Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Sun, 12 Jul 2026 15:29:27 -0400 Subject: [PATCH 12/20] test(relay): fix D2/D3 CI-failing e2e tests for draft HTTP query and fanout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D2: rewrite test_draft_not_returned_in_kindless_channel_http_query. The HTTP bridge runs sensitive-kind gates unconditionally, so a kindless h-tag filter triggers p_gated_filters_authorized and returns 403 for any non-owner caller — this is fail-closed, pre-existing main behavior. The test now asserts 403 for the kindless case (i), then uses a mixed kinds:[9,31234] h-tag filter as the attacker (passes all three HTTP gates) and asserts kind:9 appears while the draft is absent (ii), proving reader_can_receive_event is live on the bridge per-event path. D3: add #h ch_id tag to subscription filter in test_draft_live_fanout_reaches_author_own_subscription. Drafts are channel-bound events; fan_out_scoped's symmetric scoping invariant means a global (no #h) sub never sees channel-scoped events by design. Channel-scoping the filter routes the sub through channel_kind_index so fan-out can match and filter_fanout_by_access passes the author through. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../buzz-test-client/tests/e2e_nip37_draft.rs | 68 ++++++++++++++----- 1 file changed, 51 insertions(+), 17 deletions(-) diff --git a/crates/buzz-test-client/tests/e2e_nip37_draft.rs b/crates/buzz-test-client/tests/e2e_nip37_draft.rs index dc10888724..a4db9fa1f9 100644 --- a/crates/buzz-test-client/tests/e2e_nip37_draft.rs +++ b/crates/buzz-test-client/tests/e2e_nip37_draft.rs @@ -1731,6 +1731,10 @@ async fn test_draft_live_fanout_reaches_author_own_subscription() { 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"); @@ -1738,6 +1742,10 @@ async fn test_draft_live_fanout_reaches_author_own_subscription() { 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 @@ -1834,13 +1842,20 @@ async fn test_draft_not_returned_in_kindless_channel_query_by_attacker() { #[tokio::test] #[ignore] async fn test_draft_not_returned_in_kindless_channel_http_query() { - // HTTP bridge catchall /query mirror of the WS kindless h-tag test above. - // Exercises bridge.rs catchall path (distinct from req.rs), proving the - // reader_can_receive_event gate is live there. + // 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. // - // Non-author queries the channel with a kindless h-tag filter via HTTP - // /query. Kind:9 message must appear (positive control); kind:31234 draft - // must be absent. + // 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(); @@ -1861,31 +1876,50 @@ async fn test_draft_not_returned_in_kindless_channel_http_query() { let (ok_m, msg_m) = submit_event_http(&client, &owner, &msg_event).await; assert!(ok_m, "channel message must be accepted: {msg_m}"); - // Kindless h-tag filter over HTTP /query as non-author. + // (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 results = query_events_http( - &client, - &attacker.public_key().to_hex(), - vec![kindless_filter], - ) - .await; + 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 subscription is live. + // 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 HTTP kindless query (positive control)" + "kind:9 channel message must appear in mixed-kinds HTTP query (positive control)" ); - // Draft must be absent — bridge.rs catchall author-only gate must suppress it. + // 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 non-author HTTP kindless channel query" + "kind:31234 draft must not appear in mixed-kinds HTTP query for non-author" ); } From 6eb6ab1a1edd9fd809cff1ede374b6b17c791c78 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Sun, 12 Jul 2026 15:58:01 -0400 Subject: [PATCH 13/20] test(search): serialize pgcrypto install to fix parallel-setup race fts_integration.rs setup() applies migration 0001, which contains CREATE EXTENSION IF NOT EXISTS pgcrypto. pg_extension is database- global with a unique index on extname; IF NOT EXISTS is a check-then- insert, not an atomic upsert. When 18 tests run in parallel, the first wave all see the extension as absent, all attempt the insert, and all but one hit SQLSTATE 23505 (duplicate key on pg_extension_name_index). Fix: take a session-level pg_advisory_lock on the admin connection before the extension create, release it after. This serializes the install across all parallel test workers so exactly one does the real CREATE; the rest treat it as a no-op on lock acquisition. The advisory lock is released automatically when the session ends. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-search/tests/fts_integration.rs | 25 +++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/buzz-search/tests/fts_integration.rs b/crates/buzz-search/tests/fts_integration.rs index 03052c2a54..55d698abf3 100644 --- a/crates/buzz-search/tests/fts_integration.rs +++ b/crates/buzz-search/tests/fts_integration.rs @@ -38,6 +38,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) From a6330254fc8ce1354bd6b7423405ac4c6455ff91 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 13 Jul 2026 21:58:06 -0400 Subject: [PATCH 14/20] fix(relay): suppress expired drafts at read time with 30-day server TTL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NIP-37 expiration tags were validated at ingest then discarded — expired drafts were served and counted indefinitely. This addresses R3 from Tyler's team's retention review. Adds draft_expired(event, now) to reader_can_receive_event: a KIND_DRAFT event is suppressed when now >= min(expiration_tag, created_at + 30d). Tombstones follow the same rule. Non-draft kinds are unaffected. The COUNT fast-path (author_is_self SQL count_events()) cannot evaluate per-event expiry, so filter_can_match_draft forces draft-matching COUNT filters to the per-event fallback on all four fast-path sites (count.rs ×2, bridge.rs ×2). KIND_EVENT_REMINDER retains its fast-path — reminders carry no expiry semantics. All changes are rebase-safe against #1771: nothing in replace_parameterized_event or schema is touched. NIP-40 advertisement stays absent until the physical delete reaper (#1771 coupling) lands. --- crates/buzz-core/src/filter.rs | 148 ++++++++++++++++++ crates/buzz-core/src/kind.rs | 8 + crates/buzz-relay/src/api/bridge.rs | 2 + crates/buzz-relay/src/handlers/count.rs | 2 + crates/buzz-relay/src/handlers/req.rs | 22 ++- .../buzz-test-client/tests/e2e_nip37_draft.rs | 117 ++++++++++++++ 6 files changed, 298 insertions(+), 1 deletion(-) diff --git a/crates/buzz-core/src/filter.rs b/crates/buzz-core/src/filter.rs index cfa0304bf2..27a848e188 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 { @@ -52,6 +53,41 @@ pub fn is_author_only_event(event: &nostr::Event, requester_pubkey_bytes: &[u8]) /// site prevents future read surfaces from accidentally omitting half the privacy /// model. /// +/// 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 +} + /// Returns `true` if `reader` MAY receive the event. pub fn reader_can_receive_event( event: &nostr::Event, @@ -60,6 +96,7 @@ pub fn reader_can_receive_event( ) -> 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 { @@ -327,4 +364,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 b15a05472e..c11da0ec6e 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -114,6 +114,14 @@ pub const KIND_EVENT_REMINDER: u32 = 30300; /// (`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, diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 0b346b4e66..7a85c771dd 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1151,6 +1151,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, @@ -1212,6 +1213,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 { diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 12bb3e7113..5fff632ef8 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -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, @@ -228,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 { diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index fbb2588a5d..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; @@ -1101,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 diff --git a/crates/buzz-test-client/tests/e2e_nip37_draft.rs b/crates/buzz-test-client/tests/e2e_nip37_draft.rs index a4db9fa1f9..db3ca3fc22 100644 --- a/crates/buzz-test-client/tests/e2e_nip37_draft.rs +++ b/crates/buzz-test-client/tests/e2e_nip37_draft.rs @@ -30,6 +30,8 @@ const KIND_DRAFT: u16 = 31234; const KIND_CREATE_CHANNEL: u16 = 9007; const KIND_PUT_USER: u16 = 9000; const KIND_REMOVE_USER: u16 = 9001; +/// Must match `buzz_core::kind::DRAFT_MAX_TTL_SECS`. +const DRAFT_MAX_TTL_SECS: u64 = 30 * 24 * 3600; fn relay_url() -> String { std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) @@ -3591,3 +3593,118 @@ async fn test_reminder_target_reaction_oracle_closed() { found: {kind7_as_author:?}" ); } + +// ─── Read-time expiry suppression (30-day server TTL) ───────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_expired_by_server_ttl_suppressed_on_http_query() { + // An expired draft (created_at > 30d ago, no expiration tag) must be absent + // from a self-authored HTTP /query while a fresh draft on the same filter is + // present. This verifies `draft_expired` is live on every read surface that + // routes through `reader_can_receive_event`. + // + // Bite check: if draft_expired were removed from reader_can_receive_event, the + // expired draft would appear 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_expired = uuid::Uuid::new_v4().to_string(); + let d_fresh = uuid::Uuid::new_v4().to_string(); + + // The relay accepts events with any created_at (no ingest freshness check). + // draft_expired then suppresses at read because created_at + 30d is in the past. + let expired = build_draft_at( + &owner, + &d_expired, + "9", + &ch_id, + &fake_nip44_v2(), + Timestamp::from(Timestamp::now().as_secs() - DRAFT_MAX_TTL_SECS - 86400), + ); + let (ok_e, msg_e) = submit_event_http(&client, &owner, &expired).await; + assert!(ok_e, "expired 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}"); + + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(owner.public_key()); + let events = query_events_http(&client, &owner.public_key().to_hex(), vec![filter]).await; + + let ids: Vec = events + .iter() + .filter_map(|e| e["id"].as_str().map(String::from)) + .collect(); + assert!( + ids.contains(&fresh_id.to_hex()), + "fresh draft must appear in self-authored query; got: {ids:?}" + ); + assert!( + !ids.contains(&expired.id.to_hex()), + "expired draft (created_at > 30d ago) must be suppressed; got: {ids:?}" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_expired_not_counted_on_count_surface() { + // COUNT of self-authored drafts must exclude expired drafts. + // + // This test 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 assertion `count == 1` would fail, proving + // the test bites against the un-patched fast-path. + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d_expired = uuid::Uuid::new_v4().to_string(); + let d_fresh = uuid::Uuid::new_v4().to_string(); + + let expired = build_draft_at( + &owner, + &d_expired, + "9", + &ch_id, + &fake_nip44_v2(), + Timestamp::from(Timestamp::now().as_secs() - DRAFT_MAX_TTL_SECS - 86400), + ); + let (ok_e, msg_e) = submit_event_http(&client, &owner, &expired).await; + assert!(ok_e, "expired 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()); + 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 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); + // 2 drafts in storage; 1 expired → COUNT must return 1. + // If filter_can_match_draft fast-path bypass is missing, count_events() returns + // 2 and this assertion fails — proving the test bites against the old fast-path. + assert_eq!( + count, 1, + "COUNT must return 1 (expired draft excluded); got: {count}" + ); +} From cf593a4f5a0cd844822c421de29257b6d9f12258 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 13 Jul 2026 22:45:06 -0400 Subject: [PATCH 15/20] fix(relay): close channel-window bypass and fix e2e draft-expiry tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route channel-window rows through reader_can_receive_event so expired drafts are suppressed on the top_level bridge path — previously this surface serialized rows directly, bypassing the canonical read gate. Rewrite e2e fixtures to use a short future expiration tag (now + 2s) instead of a 31-day-old created_at that ingest rejects (±15 min drift limit). Add a third e2e test covering the channel-window surface. All three tests poll rather than fixed-sleep. Move the canonical-gate rustdoc block back onto reader_can_receive_event (it was incorrectly placed on draft_expired). Accepted tradeoff: self-authored draft COUNTs now route through the 5000-candidate fallback rather than raw COUNT(*). A user with >5000 drafts matching one filter hits the "restricted: narrower constraints" rejection — not a real scenario. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-core/src/filter.rs | 21 +- crates/buzz-relay/src/api/bridge.rs | 14 +- .../buzz-test-client/tests/e2e_nip37_draft.rs | 227 ++++++++++++------ 3 files changed, 174 insertions(+), 88 deletions(-) diff --git a/crates/buzz-core/src/filter.rs b/crates/buzz-core/src/filter.rs index 27a848e188..57e74b951d 100644 --- a/crates/buzz-core/src/filter.rs +++ b/crates/buzz-core/src/filter.rs @@ -42,17 +42,6 @@ pub fn is_author_only_event(event: &nostr::Event, requester_pubkey_bytes: &[u8]) && event.pubkey.to_bytes() != requester_pubkey_bytes } -/// Canonical per-event read-authorization gate: combines `reader_authorized_for_event` -/// (p-gated/result-gated kinds) and `is_author_only_event` (author-private kinds) -/// 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 two predicates at each call -/// site prevents future read surfaces from accidentally omitting half the privacy -/// model. -/// /// 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`. @@ -88,6 +77,16 @@ pub fn draft_expired(event: &nostr::Event, now: nostr::Timestamp) -> bool { 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, diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 7a85c771dd..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, @@ -375,6 +376,7 @@ async fn handle_channel_window_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}; @@ -442,9 +444,18 @@ async fn handle_channel_window_filter( .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}")))?; @@ -771,6 +782,7 @@ pub async fn query_events( &accessible_channels, &mut events, &pubkey_bytes, + &authed_pubkey_hex, ) .await?; handled.insert(idx); diff --git a/crates/buzz-test-client/tests/e2e_nip37_draft.rs b/crates/buzz-test-client/tests/e2e_nip37_draft.rs index db3ca3fc22..0ff55237b2 100644 --- a/crates/buzz-test-client/tests/e2e_nip37_draft.rs +++ b/crates/buzz-test-client/tests/e2e_nip37_draft.rs @@ -30,8 +30,6 @@ const KIND_DRAFT: u16 = 31234; const KIND_CREATE_CHANNEL: u16 = 9007; const KIND_PUT_USER: u16 = 9000; const KIND_REMOVE_USER: u16 = 9001; -/// Must match `buzz_core::kind::DRAFT_MAX_TTL_SECS`. -const DRAFT_MAX_TTL_SECS: u64 = 30 * 24 * 3600; fn relay_url() -> String { std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) @@ -3594,60 +3592,71 @@ async fn test_reminder_target_reaction_oracle_closed() { ); } -// ─── Read-time expiry suppression (30-day server TTL) ───────────────────────── +// ─── Read-time expiry suppression (short-lived expiration tag) ──────────────── #[tokio::test] #[ignore] async fn test_draft_expired_by_server_ttl_suppressed_on_http_query() { - // An expired draft (created_at > 30d ago, no expiration tag) must be absent - // from a self-authored HTTP /query while a fresh draft on the same filter is - // present. This verifies `draft_expired` is live on every read surface that - // routes through `reader_can_receive_event`. + // A draft with a short future `expiration` tag (now + 2s) 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 appear alongside the fresh one — the final `ids` assertion - // would fail. + // 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_expired = uuid::Uuid::new_v4().to_string(); + let d_expiring = uuid::Uuid::new_v4().to_string(); let d_fresh = uuid::Uuid::new_v4().to_string(); - // The relay accepts events with any created_at (no ingest freshness check). - // draft_expired then suppresses at read because created_at + 30d is in the past. - let expired = build_draft_at( - &owner, - &d_expired, - "9", - &ch_id, - &fake_nip44_v2(), - Timestamp::from(Timestamp::now().as_secs() - DRAFT_MAX_TTL_SECS - 86400), - ); - let (ok_e, msg_e) = submit_event_http(&client, &owner, &expired).await; - assert!(ok_e, "expired draft must be accepted at ingest: {msg_e}"); + // Short-lived draft: expiration = now + 2s. Passes ingest's strictly-future + // check, then `draft_expired` suppresses once 2s elapse. + let exp_ts = Timestamp::now().as_secs() + 2; + let expiring = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) + .tags([ + Tag::parse(["d", &d_expiring]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + Tag::parse(["expiration", &exp_ts.to_string()]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + 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 10s). let filter = Filter::new() .kind(nostr::Kind::Custom(KIND_DRAFT)) .author(owner.public_key()); - let events = query_events_http(&client, &owner.public_key().to_hex(), vec![filter]).await; - - let ids: Vec = events - .iter() - .filter_map(|e| e["id"].as_str().map(String::from)) - .collect(); - assert!( - ids.contains(&fresh_id.to_hex()), - "fresh draft must appear in self-authored query; got: {ids:?}" - ); - assert!( - !ids.contains(&expired.id.to_hex()), - "expired draft (created_at > 30d ago) must be suppressed; got: {ids:?}" - ); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + 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] @@ -3655,28 +3664,29 @@ async fn test_draft_expired_by_server_ttl_suppressed_on_http_query() { async fn test_draft_expired_not_counted_on_count_surface() { // COUNT of self-authored drafts must exclude expired drafts. // - // This test 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 assertion `count == 1` would fail, proving - // the test bites against the un-patched fast-path. + // 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_expired = uuid::Uuid::new_v4().to_string(); + let d_expiring = uuid::Uuid::new_v4().to_string(); let d_fresh = uuid::Uuid::new_v4().to_string(); - let expired = build_draft_at( - &owner, - &d_expired, - "9", - &ch_id, - &fake_nip44_v2(), - Timestamp::from(Timestamp::now().as_secs() - DRAFT_MAX_TTL_SECS - 86400), - ); - let (ok_e, msg_e) = submit_event_http(&client, &owner, &expired).await; - assert!(ok_e, "expired draft must be accepted at ingest: {msg_e}"); + let exp_ts = Timestamp::now().as_secs() + 2; + let expiring = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) + .tags([ + Tag::parse(["d", &d_expiring]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + Tag::parse(["expiration", &exp_ts.to_string()]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + 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; @@ -3685,26 +3695,91 @@ async fn test_draft_expired_not_counted_on_count_surface() { 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 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); - // 2 drafts in storage; 1 expired → COUNT must return 1. - // If filter_can_match_draft fast-path bypass is missing, count_events() returns - // 2 and this assertion fails — proving the test bites against the old fast-path. - assert_eq!( - count, 1, - "COUNT must return 1 (expired draft excluded); got: {count}" - ); + + // Poll until COUNT drops to 1 (timeout 10s). + let deadline = std::time::Instant::now() + Duration::from_secs(10); + 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 exp_ts = Timestamp::now().as_secs() + 2; + let expiring = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) + .tags([ + Tag::parse(["d", &d_expiring]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + Tag::parse(["expiration", &exp_ts.to_string()]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + 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 10s). + let deadline = std::time::Instant::now() + Duration::from_secs(10); + 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; + } } From 35bbf6fefe104457ff77fd9a3aab064ad33ad6dd Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 13 Jul 2026 23:02:50 -0400 Subject: [PATCH 16/20] fix(tests): widen e2e draft expiry margin and factor shared helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The now+2s expiration fixtures had a wall-clock race at ingest — truncation plus CI scheduler pauses could trip the strictly-future check before the test reached its read assertions. Widen to now+10s with a 20s poll timeout. Factor the fixture into a shared build_expiring_draft helper so the margin lives in one place. Rename the misnamed server-TTL test to reflect its actual client-tag fixture. --- .../buzz-test-client/tests/e2e_nip37_draft.rs | 78 +++++++++---------- 1 file changed, 36 insertions(+), 42 deletions(-) diff --git a/crates/buzz-test-client/tests/e2e_nip37_draft.rs b/crates/buzz-test-client/tests/e2e_nip37_draft.rs index 0ff55237b2..78adbbd1c0 100644 --- a/crates/buzz-test-client/tests/e2e_nip37_draft.rs +++ b/crates/buzz-test-client/tests/e2e_nip37_draft.rs @@ -246,6 +246,29 @@ fn build_draft_at( .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, @@ -3596,10 +3619,10 @@ async fn test_reminder_target_reaction_oracle_closed() { #[tokio::test] #[ignore] -async fn test_draft_expired_by_server_ttl_suppressed_on_http_query() { - // A draft with a short future `expiration` tag (now + 2s) must be served - // immediately after ingest, then suppressed on /query once the tag lapses. - // A fresh draft (no expiration) must remain visible throughout. +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` @@ -3610,18 +3633,7 @@ async fn test_draft_expired_by_server_ttl_suppressed_on_http_query() { let d_expiring = uuid::Uuid::new_v4().to_string(); let d_fresh = uuid::Uuid::new_v4().to_string(); - // Short-lived draft: expiration = now + 2s. Passes ingest's strictly-future - // check, then `draft_expired` suppresses once 2s elapse. - let exp_ts = Timestamp::now().as_secs() + 2; - let expiring = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) - .tags([ - Tag::parse(["d", &d_expiring]).unwrap(), - Tag::parse(["k", "9"]).unwrap(), - Tag::parse(["h", &ch_id]).unwrap(), - Tag::parse(["expiration", &exp_ts.to_string()]).unwrap(), - ]) - .sign_with_keys(&owner) - .unwrap(); + 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}"); @@ -3631,11 +3643,11 @@ async fn test_draft_expired_by_server_ttl_suppressed_on_http_query() { 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 10s). + // 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(10); + 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; @@ -3675,16 +3687,7 @@ async fn test_draft_expired_not_counted_on_count_surface() { let d_expiring = uuid::Uuid::new_v4().to_string(); let d_fresh = uuid::Uuid::new_v4().to_string(); - let exp_ts = Timestamp::now().as_secs() + 2; - let expiring = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) - .tags([ - Tag::parse(["d", &d_expiring]).unwrap(), - Tag::parse(["k", "9"]).unwrap(), - Tag::parse(["h", &ch_id]).unwrap(), - Tag::parse(["expiration", &exp_ts.to_string()]).unwrap(), - ]) - .sign_with_keys(&owner) - .unwrap(); + 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}"); @@ -3696,8 +3699,8 @@ async fn test_draft_expired_not_counted_on_count_surface() { .kind(nostr::Kind::Custom(KIND_DRAFT)) .author(owner.public_key()); - // Poll until COUNT drops to 1 (timeout 10s). - let deadline = std::time::Instant::now() + Duration::from_secs(10); + // 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())) @@ -3741,16 +3744,7 @@ async fn test_draft_expired_suppressed_on_channel_window() { let d_expiring = uuid::Uuid::new_v4().to_string(); let d_fresh = uuid::Uuid::new_v4().to_string(); - let exp_ts = Timestamp::now().as_secs() + 2; - let expiring = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2()) - .tags([ - Tag::parse(["d", &d_expiring]).unwrap(), - Tag::parse(["k", "9"]).unwrap(), - Tag::parse(["h", &ch_id]).unwrap(), - Tag::parse(["expiration", &exp_ts.to_string()]).unwrap(), - ]) - .sign_with_keys(&owner) - .unwrap(); + 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}"); @@ -3760,8 +3754,8 @@ async fn test_draft_expired_suppressed_on_channel_window() { 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 10s). - let deadline = std::time::Instant::now() + Duration::from_secs(10); + // 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 From ea6c2fc16c443dd815c123de0ac1880f390ad53a Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 14 Jul 2026 00:24:40 -0400 Subject: [PATCH 17/20] fix(db): make 0012 draft FTS migration conditional on live expression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh installs from 0008 already have the positive FTS allowlist, which excludes kind 31234 by omission. The unconditional DROP+re-ADD would revert the allowlist to the legacy negative blocklist. Inspect the live search_tsv expression via pg_get_expr: allowlist form (contains '45001') → no-op; legacy blocklist form → extend with 31234. --- migrations/0012_draft_wrap_fts.sql | 53 ++++++++++++++++++++++-------- schema/schema.sql | 2 +- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/migrations/0012_draft_wrap_fts.sql b/migrations/0012_draft_wrap_fts.sql index 7d45b1207d..b1d2785088 100644 --- a/migrations/0012_draft_wrap_fts.sql +++ b/migrations/0012_draft_wrap_fts.sql @@ -6,17 +6,22 @@ -- that makes draft wraps author-private. The relay also must not index -- plaintext compose context through any search surface. -- --- Additive migration: previously applied files must not change checksum. --- We must DROP the generated column and re-ADD it with the extended exclusion --- list; ALTER COLUMN cannot change a GENERATED expression in Postgres. +-- 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 (contains '45001') → no-op, 31234 already unsearchable. +-- - Legacy blocklist form → DROP + re-ADD with 31234 added to exclusion. -- --- DEPLOY NOTE: the DROP + re-ADD below acquires ACCESS EXCLUSIVE on `events` +-- 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) acquires no exclusive lock. -- --- Final kind exclusion list after this migration: +-- 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) @@ -32,12 +37,34 @@ -- NULL tsvector never matches `@@`, so excluded rows are storage-level -- unsearchable. -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; +LOCK TABLE events IN SHARE ROW EXCLUSIVE MODE; --- Recreate the GIN index dropped with the column. -CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); +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) contains '45001' in its kind list. + -- If present, 31234 is already excluded by omission — nothing to do. + IF expr IS NOT NULL AND expr LIKE '%45001%' 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 880809d5b3..6734557b30 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -206,7 +206,7 @@ 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 + 0006_moderation + 0007_draft_wrap_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, 31234, 44100, 44101, 44200) THEN NULL::tsvector ELSE to_tsvector('simple', content) From f31b405fd5e125c1c6d7965bb84d10449910e96e Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 14 Jul 2026 00:25:23 -0400 Subject: [PATCH 18/20] fix(db): use deref instead of clone on Copy type DateTime Clippy clone_on_copy: DateTime is Copy, dereference instead. --- crates/buzz-db/src/lib.rs | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 00aa14e91d..cba5d107cc 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -2939,16 +2939,15 @@ 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.clone(), id.clone())); - let dominated = existing_ordering - .iter() - .chain(watermark.iter()) - .any(|(accepted_ts, accepted_id)| { - created_at < *accepted_ts - || (created_at == *accepted_ts && incoming_id >= accepted_id.as_slice()) - }); + let existing_ordering = existing.as_ref().map(|(ts, id, _)| (*ts, id.clone())); + let dominated = + existing_ordering + .iter() + .chain(watermark.iter()) + .any(|(accepted_ts, accepted_id)| { + created_at < *accepted_ts + || (created_at == *accepted_ts && incoming_id >= accepted_id.as_slice()) + }); if dominated { tx.rollback().await?; let received_at = chrono::Utc::now(); From 0530ab4619914dc6e0e54f919d21c0c228cd5aa9 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 14 Jul 2026 09:12:09 -0400 Subject: [PATCH 19/20] fix(db): use shape-based allowlist detection in migration 0012 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The discriminator was `LIKE '%45001%'` — keying on an incidental member of the allowlist kind set. A future negative blocklist containing 45001 would misclassify as allowlist and leave 31234 indexed. Switch to detecting the allowlist's structural shape: the positive allowlist (0008) falls through to `ELSE NULL::tsvector` for unlisted kinds, while the legacy blocklist uses `THEN NULL::tsvector` for excluded kinds. Checking for `ELSE NULL` in the `pg_get_expr` output is robust against future kind list changes in either form. Also fixes the no-op path comment: `LOCK TABLE ... IN SHARE ROW EXCLUSIVE MODE` runs before the branch on every path, so the no-op path does not "acquire no exclusive lock" — it does not escalate to ACCESS EXCLUSIVE. --- migrations/0012_draft_wrap_fts.sql | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/migrations/0012_draft_wrap_fts.sql b/migrations/0012_draft_wrap_fts.sql index b1d2785088..f4e7812a10 100644 --- a/migrations/0012_draft_wrap_fts.sql +++ b/migrations/0012_draft_wrap_fts.sql @@ -11,15 +11,15 @@ -- 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 (contains '45001') → no-op, 31234 already unsearchable. --- - Legacy blocklist form → DROP + re-ADD with 31234 added to exclusion. +-- - 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) acquires no exclusive lock. +-- 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) @@ -50,9 +50,12 @@ BEGIN WHERE attrelid = 'events'::regclass AND attname = 'search_tsv'); - -- The positive allowlist form (0008) contains '45001' in its kind list. - -- If present, 31234 is already excluded by omission — nothing to do. - IF expr IS NOT NULL AND expr LIKE '%45001%' THEN + -- 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; From 7edc3cf7f6e1c0c8624c0df0c8d2579878144ca7 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 14 Jul 2026 09:12:34 -0400 Subject: [PATCH 20/20] test(search): add behavioral coverage for 0012 legacy-blocklist rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The brownfield migration path — where 0012 DROP + re-ADDs search_tsv on populated production databases still on the pre-#1771 negative blocklist — was exercised by zero tests. The fresh-install (allowlist) path always no-oped via setup(), and migration.rs only string-matched the SQL text. Add two integration tests: - migration_0012_rewrites_legacy_blocklist_to_exclude_drafts: constructs the legacy state (0001–0007 → seed row → 0008 skips → 0009–0011), seeds kind:9 control + kind:31234 draft, applies 0012, and asserts the rewrite took effect: expression contains 31234, GIN index rebuilt, control row searchable, draft row's search_tsv NULL, discriminator correctly classifies the post-rewrite expression as blocklist-shaped. - migration_0012_noops_on_fresh_install_allowlist: verifies the fresh-install expression has the ELSE NULL shape that 0012's discriminator detects. Also applies migrations 0009–0011 in setup() so the test chain matches production exactly (previously omitted). --- crates/buzz-search/tests/fts_integration.rs | 338 ++++++++++++++++++++ 1 file changed, 338 insertions(+) diff --git a/crates/buzz-search/tests/fts_integration.rs b/crates/buzz-search/tests/fts_integration.rs index 55d698abf3..b21a81417a 100644 --- a/crates/buzz-search/tests/fts_integration.rs +++ b/crates/buzz-search/tests/fts_integration.rs @@ -27,6 +27,12 @@ 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) { @@ -103,6 +109,15 @@ 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"); @@ -1486,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; +}