mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user