fix(relay): suppress expired drafts at read time with 30-day server TTL

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.
This commit is contained in:
Will Pfleger
2026-07-14 00:21:25 -04:00
parent 6eb6ab1a1e
commit a6330254fc
6 changed files with 298 additions and 1 deletions
+148
View File
@@ -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::<u64>().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<u64>) -> 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"
);
}
}
+8
View File
@@ -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,
+2
View File
@@ -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 {
+2
View File
@@ -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 {
+21 -1
View File
@@ -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
@@ -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<String> = 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}"
);
}