From a6330254fc8ce1354bd6b7423405ac4c6455ff91 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 13 Jul 2026 21:58:06 -0400 Subject: [PATCH] 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 cfa0304bf..27a848e18 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 b15a05472..c11da0ec6 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 0b346b4e6..7a85c771d 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 12bb3e711..5fff632ef 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 fbb2588a5..95af82a77 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 a4db9fa1f..db3ca3fc2 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}" + ); +}