Skip to content

Commit 275ad4b

Browse files
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7wpfleger96
andcommitted
feat(relay): add NIP-ER push scheduler, ingest relaxation, and NIP-11 advertisement
The relay now proactively delivers due reminders via Redis pub/sub. Cross-pod dedup uses an atomic delivered_at claim (mirrors the reaper's archived_at guard). Ingest relaxed to allow kind:30300 without not_before (bookmarks/terminal states). max_not_before_delta enforced to prevent unbounded-future scheduling abuse. Schema: nullable not_before + delivered_at columns on events, partial index for scheduler queries. NIP-11: supported_extensions ["nip-er"], due_delivery_mode "push", max_not_before_delta advertised. Spec (NIP-ER.md) amended: not_before optional on kind:30300, required only for pending reminders that may become due. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
1 parent f319f2f commit 275ad4b

9 files changed

Lines changed: 408 additions & 8 deletions

File tree

crates/buzz-db/src/event.rs

Lines changed: 165 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ use nostr::Event;
99
use sqlx::{PgPool, QueryBuilder, Row};
1010
use uuid::Uuid;
1111

12-
use buzz_core::kind::{event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AUTH};
12+
use buzz_core::kind::{
13+
event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AUTH, KIND_EVENT_REMINDER,
14+
};
1315
use buzz_core::StoredEvent;
1416

1517
use crate::error::{DbError, Result};
@@ -96,6 +98,26 @@ pub fn extract_d_tag(event: &Event) -> Option<String> {
9698
Some(val)
9799
}
98100

101+
/// Extract the `not_before` timestamp for materialization in the `events` table.
102+
///
103+
/// Only applies to `kind:30300` (NIP-ER event reminders). Returns the first
104+
/// valid `not_before` tag value as an `i64` Unix timestamp, or `None` if the
105+
/// event is not a reminder or has no `not_before` tag.
106+
pub fn extract_not_before(event: &Event) -> Option<i64> {
107+
let kind_u32 = event.kind.as_u16() as u32;
108+
if kind_u32 != KIND_EVENT_REMINDER {
109+
return None;
110+
}
111+
event.tags.iter().find_map(|tag| {
112+
let parts = tag.as_slice();
113+
if parts.len() >= 2 && parts[0] == "not_before" {
114+
parts[1].parse::<i64>().ok()
115+
} else {
116+
None
117+
}
118+
})
119+
}
120+
99121
/// Insert a Nostr event. Rejects AUTH and ephemeral kinds.
100122
///
101123
/// Returns `(StoredEvent, was_inserted)` — `was_inserted` is `false` on duplicate.
@@ -125,10 +147,11 @@ pub async fn insert_event(
125147
.ok_or(DbError::InvalidTimestamp(created_at_secs))?;
126148
let received_at = Utc::now();
127149
let d_tag = extract_d_tag(event);
150+
let not_before = extract_not_before(event);
128151
let result = sqlx::query(
129152
r#"
130-
INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag)
131-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
153+
INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag, not_before)
154+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
132155
ON CONFLICT DO NOTHING
133156
"#,
134157
)
@@ -142,6 +165,7 @@ pub async fn insert_event(
142165
.bind(received_at)
143166
.bind(channel_id)
144167
.bind(d_tag.as_deref())
168+
.bind(not_before)
145169
.execute(pool)
146170
.await?;
147171

@@ -842,13 +866,14 @@ pub async fn insert_event_with_thread_metadata(
842866
.ok_or(DbError::InvalidTimestamp(created_at_secs))?;
843867
let received_at = Utc::now();
844868
let d_tag = extract_d_tag(event);
869+
let not_before = extract_not_before(event);
845870
let mut tx = pool.begin().await?;
846871

847872
// ── Insert event ──────────────────────────────────────────────────────────
848873
let result = sqlx::query(
849874
r#"
850-
INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag)
851-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
875+
INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag, not_before)
876+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
852877
ON CONFLICT DO NOTHING
853878
"#,
854879
)
@@ -862,6 +887,7 @@ pub async fn insert_event_with_thread_metadata(
862887
.bind(received_at)
863888
.bind(channel_id)
864889
.bind(d_tag.as_deref())
890+
.bind(not_before)
865891
.execute(&mut *tx)
866892
.await?;
867893

@@ -981,6 +1007,101 @@ pub async fn insert_event_with_thread_metadata(
9811007
))
9821008
}
9831009

1010+
/// A due reminder row returned by [`query_due_reminders`].
1011+
#[derive(Debug)]
1012+
pub struct DueReminder {
1013+
/// The event's raw ID bytes.
1014+
pub id: Vec<u8>,
1015+
/// The event's pubkey bytes.
1016+
pub pubkey: Vec<u8>,
1017+
/// The event's `created_at` timestamp.
1018+
pub created_at: DateTime<Utc>,
1019+
/// The event's kind (always 30300).
1020+
pub kind: i32,
1021+
/// The event's JSONB tags.
1022+
pub tags: serde_json::Value,
1023+
/// The event's encrypted content.
1024+
pub content: String,
1025+
/// The event's signature bytes.
1026+
pub sig: Vec<u8>,
1027+
/// The channel ID (always None for reminders — global events).
1028+
pub channel_id: Option<Uuid>,
1029+
}
1030+
1031+
/// Query due reminders: latest-per-address `kind:30300` rows where
1032+
/// `not_before <= now`, `deleted_at IS NULL`, `delivered_at IS NULL`.
1033+
///
1034+
/// Returns the latest head per `(pubkey, d_tag)` using canonical NIP-16
1035+
/// ordering (`created_at DESC, id ASC`).
1036+
pub async fn query_due_reminders(
1037+
pool: &PgPool,
1038+
now_secs: i64,
1039+
batch_limit: i64,
1040+
) -> Result<Vec<DueReminder>> {
1041+
let kind_i32 = KIND_EVENT_REMINDER as i32;
1042+
let rows = sqlx::query(
1043+
r#"
1044+
SELECT DISTINCT ON (pubkey, d_tag)
1045+
id, pubkey, created_at, kind, tags, content, sig, channel_id
1046+
FROM events
1047+
WHERE kind = $1
1048+
AND not_before IS NOT NULL
1049+
AND not_before <= $2
1050+
AND deleted_at IS NULL
1051+
AND delivered_at IS NULL
1052+
ORDER BY pubkey, d_tag, created_at DESC, id ASC
1053+
LIMIT $3
1054+
"#,
1055+
)
1056+
.bind(kind_i32)
1057+
.bind(now_secs)
1058+
.bind(batch_limit)
1059+
.fetch_all(pool)
1060+
.await?;
1061+
1062+
let results = rows
1063+
.into_iter()
1064+
.map(|row| DueReminder {
1065+
id: row.get("id"),
1066+
pubkey: row.get("pubkey"),
1067+
created_at: row.get("created_at"),
1068+
kind: row.get("kind"),
1069+
tags: row.get("tags"),
1070+
content: row.get("content"),
1071+
sig: row.get("sig"),
1072+
channel_id: row.get("channel_id"),
1073+
})
1074+
.collect();
1075+
1076+
Ok(results)
1077+
}
1078+
1079+
/// Atomically claim a due reminder for delivery. Returns `Some(id)` if this
1080+
/// caller won the claim (set `delivered_at`), or `None` if another pod already
1081+
/// claimed it. Mirrors the reaper's `archived_at IS NULL` guard for cross-pod
1082+
/// idempotency.
1083+
pub async fn claim_due_reminder(
1084+
pool: &PgPool,
1085+
event_id: &[u8],
1086+
event_created_at: DateTime<Utc>,
1087+
) -> Result<bool> {
1088+
let now_epoch = Utc::now().timestamp();
1089+
let result = sqlx::query(
1090+
r#"
1091+
UPDATE events
1092+
SET delivered_at = $1
1093+
WHERE created_at = $2 AND id = $3 AND delivered_at IS NULL
1094+
"#,
1095+
)
1096+
.bind(now_epoch)
1097+
.bind(event_created_at)
1098+
.bind(event_id)
1099+
.execute(pool)
1100+
.await?;
1101+
1102+
Ok(result.rows_affected() > 0)
1103+
}
1104+
9841105
#[cfg(test)]
9851106
mod tests {
9861107
use super::*;
@@ -1082,4 +1203,43 @@ mod tests {
10821203
assert_eq!(result.len(), 2048);
10831204
assert_eq!(result, long_val);
10841205
}
1206+
1207+
#[test]
1208+
fn extract_not_before_from_reminder() {
1209+
let event = make_event_with_kind_and_tags(
1210+
KIND_EVENT_REMINDER as u16,
1211+
vec![Tag::parse(["not_before", "1717000000"]).unwrap()],
1212+
);
1213+
assert_eq!(extract_not_before(&event), Some(1_717_000_000));
1214+
}
1215+
1216+
#[test]
1217+
fn extract_not_before_absent_returns_none() {
1218+
// A bookmark/terminal reminder carries no `not_before` tag.
1219+
let event = make_event_with_kind_and_tags(
1220+
KIND_EVENT_REMINDER as u16,
1221+
vec![Tag::parse(["d", "abc"]).unwrap()],
1222+
);
1223+
assert_eq!(extract_not_before(&event), None);
1224+
}
1225+
1226+
#[test]
1227+
fn extract_not_before_non_reminder_returns_none() {
1228+
// Only kind:30300 materializes `not_before`; other kinds stay NULL.
1229+
let event = make_event_with_kind_and_tags(
1230+
30023,
1231+
vec![Tag::parse(["not_before", "1717000000"]).unwrap()],
1232+
);
1233+
assert_eq!(extract_not_before(&event), None);
1234+
}
1235+
1236+
#[test]
1237+
fn extract_not_before_non_numeric_returns_none() {
1238+
// Malformed values are rejected by ingest; materialization just skips them.
1239+
let event = make_event_with_kind_and_tags(
1240+
KIND_EVENT_REMINDER as u16,
1241+
vec![Tag::parse(["not_before", "not-a-number"]).unwrap()],
1242+
);
1243+
assert_eq!(extract_not_before(&event), None);
1244+
}
10851245
}

crates/buzz-db/src/lib.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,26 @@ impl Db {
532532
channel::reap_expired_ephemeral_channels(&self.pool).await
533533
}
534534

535+
// ── Reminder scheduler ───────────────────────────────────────────────────
536+
537+
/// Query due reminders ready for delivery.
538+
pub async fn query_due_reminders(
539+
&self,
540+
now_secs: i64,
541+
batch_limit: i64,
542+
) -> Result<Vec<event::DueReminder>> {
543+
event::query_due_reminders(&self.pool, now_secs, batch_limit).await
544+
}
545+
546+
/// Atomically claim a due reminder for delivery (cross-pod dedup).
547+
pub async fn claim_due_reminder(
548+
&self,
549+
event_id: &[u8],
550+
event_created_at: chrono::DateTime<chrono::Utc>,
551+
) -> Result<bool> {
552+
event::claim_due_reminder(&self.pool, event_id, event_created_at).await
553+
}
554+
535555
// ── Users ────────────────────────────────────────────────────────────────
536556

537557
/// Ensure a user record exists (upsert).

crates/buzz-relay/src/handlers/event.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,23 @@ pub async fn filter_fanout_by_access(
6161
stored_event: &StoredEvent,
6262
matches: Vec<(crate::subscription::ConnId, crate::subscription::SubId)>,
6363
) -> Vec<(crate::subscription::ConnId, crate::subscription::SubId)> {
64+
// Author-only kinds: only the event's author may receive fan-out.
65+
// Checked before channel gating so it applies to all delivery paths
66+
// (local dispatch, cross-pod subscribe_local, scheduler-published events).
67+
let kind_u32 = event_kind_u32(&stored_event.event);
68+
if AUTHOR_ONLY_KINDS.contains(&kind_u32) {
69+
let author_bytes = stored_event.event.pubkey.to_bytes();
70+
return matches
71+
.into_iter()
72+
.filter(|(conn_id, _)| {
73+
state
74+
.conn_manager
75+
.pubkey_for_conn(*conn_id)
76+
.is_some_and(|pk| pk.as_slice() == author_bytes.as_slice())
77+
})
78+
.collect();
79+
}
80+
6481
let Some(channel_id) = stored_event.channel_id else {
6582
return matches;
6683
};
@@ -1129,5 +1146,31 @@ mod tests {
11291146
filter_fanout_by_access(&state, &channel_event(Some(channel_id)), matches).await;
11301147
assert_eq!(out, vec![(member, "m".to_string())]);
11311148
}
1149+
1150+
#[tokio::test]
1151+
async fn author_only_kind_delivers_only_to_author() {
1152+
let state = test_state().await;
1153+
let author_keys = Keys::generate();
1154+
let author_pk = author_keys.public_key().to_bytes().to_vec();
1155+
let other_pk = vec![99u8; 32];
1156+
1157+
let author_conn = register_conn(&state, Some(author_pk.clone()));
1158+
let other_conn = register_conn(&state, Some(other_pk));
1159+
let unauthed_conn = register_conn(&state, None);
1160+
1161+
// Build a kind:30300 (event reminder) — an author-only kind.
1162+
let event = EventBuilder::new(Kind::Custom(30300), "encrypted-content")
1163+
.sign_with_keys(&author_keys)
1164+
.expect("sign event");
1165+
let stored = StoredEvent::new(event, None);
1166+
1167+
let matches = vec![
1168+
(author_conn, "a".to_string()),
1169+
(other_conn, "o".to_string()),
1170+
(unauthed_conn, "u".to_string()),
1171+
];
1172+
let out = filter_fanout_by_access(&state, &stored, matches).await;
1173+
assert_eq!(out, vec![(author_conn, "a".to_string())]);
1174+
}
11321175
}
11331176
}

crates/buzz-relay/src/handlers/ingest.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,6 +1037,20 @@ fn validate_event_reminder(event: &Event) -> Result<(), &'static str> {
10371037
// `not_before` is optional — terminal states (done/cancelled) and bookmarks
10381038
// omit it. The ordering check only applies when both are present.
10391039
if let Some(nb) = not_before {
1040+
// Reject reminders scheduled beyond the configured horizon. The same
1041+
// SPROUT_MAX_NOT_BEFORE_DELTA env var is advertised in NIP-11.
1042+
let max_delta: u64 = std::env::var("SPROUT_MAX_NOT_BEFORE_DELTA")
1043+
.ok()
1044+
.and_then(|v| v.parse().ok())
1045+
.unwrap_or(31_536_000); // 1 year default
1046+
let now = std::time::SystemTime::now()
1047+
.duration_since(std::time::UNIX_EPOCH)
1048+
.unwrap_or_default()
1049+
.as_secs();
1050+
if nb > now + max_delta {
1051+
return Err("not_before too far in future");
1052+
}
1053+
10401054
if let Some(exp) = expiration {
10411055
if let Ok(exp) = exp.parse::<u64>() {
10421056
if exp <= nb {
@@ -2458,6 +2472,17 @@ mod tests {
24582472
assert!(validate_event_reminder(&ev).is_ok());
24592473
}
24602474

2475+
#[test]
2476+
fn reminder_rejects_not_before_too_far_in_future() {
2477+
// `not_before` beyond the max horizon (default 1 year) is rejected.
2478+
let far_future = (chrono::Utc::now().timestamp() as u64) + 63_072_000; // ~2 years
2479+
let ev = make_reminder(&[&["d", "abc"], &["not_before", &far_future.to_string()]]);
2480+
assert_eq!(
2481+
validate_event_reminder(&ev),
2482+
Err("not_before too far in future")
2483+
);
2484+
}
2485+
24612486
#[test]
24622487
fn reminder_rejects_duplicate_not_before() {
24632488
let ev = make_reminder(&[

0 commit comments

Comments
 (0)