Skip to content

Commit 79fcfd8

Browse files
wpfleger96npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
andauthored
feat(relay): implement NIP-ER event reminder support (kind:30300) (#934)
Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Will Pfleger <wpfleger@squareup.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
1 parent 538f333 commit 79fcfd8

9 files changed

Lines changed: 1602 additions & 30 deletions

File tree

crates/buzz-core/src/kind.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,25 @@ pub const KIND_AGENT_PROFILE: u32 = 10100;
9191
/// `docs/nips/NIP-AE.md` and [`crate::engram`].
9292
pub const KIND_AGENT_ENGRAM: u32 = 30174;
9393

94+
/// NIP-ER: Event Reminder (parameterized replaceable, author-only).
95+
///
96+
/// Encrypted, author-only reminder addressed by `(pubkey, kind, d_tag)`. The
97+
/// public `not_before` tag tells supporting relays when the reminder is due;
98+
/// the target, note, and state are NIP-44 encrypted to the author. Reads are
99+
/// author-only (see [`AUTHOR_ONLY_KINDS`]). See `docs/nips/NIP-ER.md`.
100+
pub const KIND_EVENT_REMINDER: u32 = 30300;
101+
102+
/// Kinds whose stored events are readable only by their author.
103+
///
104+
/// The relay must never reveal the existence, count, tags, content, schedule,
105+
/// or search matches of these events to anyone but the authenticated author.
106+
/// Shared across the ingest write path (NIP-ER `not_before` validation) and the
107+
/// read path (REQ/COUNT/subscription author-only filtering).
108+
///
109+
/// Currently O(1) with a single entry. If this grows past ~4 kinds, convert to
110+
/// a compile-time bitset or sorted array with binary search for hot-path use.
111+
pub const AUTHOR_ONLY_KINDS: &[u32] = &[KIND_EVENT_REMINDER];
112+
94113
// NIP-29 group admin events
95114
/// NIP-29: Add a user to a group.
96115
pub const KIND_NIP29_PUT_USER: u32 = 9000;
@@ -371,6 +390,7 @@ pub const ALL_KINDS: &[u32] = &[
371390
KIND_FILE_METADATA,
372391
KIND_AGENT_PROFILE,
373392
KIND_AGENT_ENGRAM,
393+
KIND_EVENT_REMINDER,
374394
KIND_NIP29_PUT_USER,
375395
KIND_NIP29_REMOVE_USER,
376396
KIND_NIP29_EDIT_METADATA,
@@ -554,6 +574,7 @@ pub fn event_kind_i32(event: &nostr::Event) -> i32 {
554574
// Compile-time: new kinds are in the expected ranges.
555575
const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000–19999
556576
const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999
577+
const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999
557578
const _: () = assert!(is_parameterized_replaceable(KIND_MESH_LLM_RELAY_STATUS)); // 30621 ∈ 30000–39999
558579
const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999
559580

crates/buzz-pubsub/src/lib.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,20 @@ impl PubSubManager {
100100
}
101101

102102
/// Publish an event to the Redis channel. Returns subscriber count.
103+
///
104+
/// Routing note (NIP-ER author-private reminders): events are keyed by
105+
/// `channel_id` (`buzz:channel:{id}`), and every relay node's subscriber
106+
/// `PSUBSCRIBE buzz:channel:*` — so the channel key is a routing label, not
107+
/// an isolation boundary; every node already receives every published event.
108+
/// Author-private reminders (kind:30300, stored under the nil channel
109+
/// sentinel) are therefore NOT protected by per-author Redis routing, and
110+
/// adding it would be pointless: the reminder's author may be connected to
111+
/// any node, so every node must still receive it. The actual author-only
112+
/// delivery boundary is `filter_fanout_by_access` in the relay, which runs
113+
/// on BOTH the in-process and the Redis cross-node (`subscribe_local`)
114+
/// fan-out paths and drops every recipient that is not the event author.
115+
/// Redis only ever carries events between nodes inside the relay trust
116+
/// domain; the ciphertext is NIP-44-encrypted to the author regardless.
103117
pub async fn publish_event(
104118
&self,
105119
channel_id: Uuid,

crates/buzz-relay/src/api/bridge.rs

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,12 @@ pub async fn query_events(
282282
"restricted: agent-engram reads require authors=[self] or #p=[self]",
283283
));
284284
}
285+
if !crate::handlers::req::author_only_filters_authorized(&filters, &authed_pubkey_hex) {
286+
return Err(api_error(
287+
StatusCode::FORBIDDEN,
288+
"restricted: author-only kinds require authors=[self]",
289+
));
290+
}
285291

286292
// Get channels this user can access — same enforcement as WS REQ handler.
287293
let accessible_channels = state
@@ -291,8 +297,14 @@ pub async fn query_events(
291297

292298
// ── NIP-50 search: route to Typesense if any filter has a `search` field ──
293299
if filters.iter().any(|f| f.search.is_some()) {
294-
return handle_bridge_search(&state, &filters, &accessible_channels, &authed_pubkey_hex)
295-
.await;
300+
return handle_bridge_search(
301+
&state,
302+
&filters,
303+
&accessible_channels,
304+
&authed_pubkey_hex,
305+
&pubkey_bytes,
306+
)
307+
.await;
296308
}
297309

298310
// ── Presence: synthesize kind:20001 from Redis (ephemeral, never in DB) ──
@@ -472,6 +484,9 @@ pub async fn query_events(
472484
) {
473485
continue;
474486
}
487+
if crate::handlers::req::is_author_only_event(&se.event, &pubkey_bytes) {
488+
continue;
489+
}
475490
if let Ok(v) = serde_json::to_value(&se.event) {
476491
events.push(v);
477492
}
@@ -528,6 +543,12 @@ pub async fn count_events(
528543
"restricted: agent-engram reads require authors=[self] or #p=[self]",
529544
));
530545
}
546+
if !crate::handlers::req::author_only_filters_authorized(&filters, &authed_pubkey_hex) {
547+
return Err(api_error(
548+
StatusCode::FORBIDDEN,
549+
"restricted: author-only kinds require authors=[self]",
550+
));
551+
}
531552

532553
// Get channels this user can access.
533554
let accessible_channels = state
@@ -537,6 +558,9 @@ pub async fn count_events(
537558

538559
let mut total: u64 = 0;
539560
for filter in &filters {
561+
let needs_author_only_filtering =
562+
crate::handlers::req::filter_can_match_author_only_kinds(filter);
563+
540564
// If filter targets a specific channel, verify access.
541565
if let Some(ch_id) = extract_channel_from_filter(filter) {
542566
if !accessible_channels.contains(&ch_id) {
@@ -546,7 +570,15 @@ pub async fn count_events(
546570
let query =
547571
crate::handlers::req::build_event_query_from_filter(filter, &pubkey_bytes, &state)
548572
.await;
549-
if crate::handlers::req::filter_fully_pushable(filter) {
573+
let author_is_self = filter.authors.as_ref().is_some_and(|authors| {
574+
!authors.is_empty()
575+
&& authors
576+
.iter()
577+
.all(|a| a.to_hex().eq_ignore_ascii_case(&authed_pubkey_hex))
578+
});
579+
if crate::handlers::req::filter_fully_pushable(filter)
580+
&& (!needs_author_only_filtering || author_is_self)
581+
{
550582
match state.db.count_events(&query).await {
551583
Ok(n) => total += n as u64,
552584
Err(e) => {
@@ -561,9 +593,15 @@ pub async fn count_events(
561593
match state.db.query_events(&q).await {
562594
Ok(stored_events) => {
563595
for se in stored_events {
564-
if buzz_core::filter::filters_match(std::slice::from_ref(filter), &se) {
565-
total += 1;
596+
if !buzz_core::filter::filters_match(std::slice::from_ref(filter), &se)
597+
{
598+
continue;
566599
}
600+
if crate::handlers::req::is_author_only_event(&se.event, &pubkey_bytes)
601+
{
602+
continue;
603+
}
604+
total += 1;
567605
}
568606
}
569607
Err(e) => {
@@ -579,7 +617,15 @@ pub async fn count_events(
579617
.await;
580618
query.channel_ids = Some(accessible_channels.to_vec());
581619

582-
if crate::handlers::req::filter_fully_pushable(filter) {
620+
let author_is_self = filter.authors.as_ref().is_some_and(|authors| {
621+
!authors.is_empty()
622+
&& authors
623+
.iter()
624+
.all(|a| a.to_hex().eq_ignore_ascii_case(&authed_pubkey_hex))
625+
});
626+
if crate::handlers::req::filter_fully_pushable(filter)
627+
&& (!needs_author_only_filtering || author_is_self)
628+
{
583629
query.limit = None;
584630
match state.db.count_events(&query).await {
585631
Ok(n) => total += n as u64,
@@ -594,9 +640,15 @@ pub async fn count_events(
594640
match state.db.query_events(&query).await {
595641
Ok(stored_events) => {
596642
for se in stored_events {
597-
if buzz_core::filter::filters_match(std::slice::from_ref(filter), &se) {
598-
total += 1;
643+
if !buzz_core::filter::filters_match(std::slice::from_ref(filter), &se)
644+
{
645+
continue;
646+
}
647+
if crate::handlers::req::is_author_only_event(&se.event, &pubkey_bytes)
648+
{
649+
continue;
599650
}
651+
total += 1;
600652
}
601653
}
602654
Err(e) => {
@@ -651,6 +703,7 @@ async fn handle_bridge_search(
651703
filters: &[nostr::Filter],
652704
accessible_channels: &[uuid::Uuid],
653705
reader_pubkey_hex: &str,
706+
pubkey_bytes: &[u8],
654707
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
655708
// Bridge always includes global (non-channel) events — same as WS with full scopes.
656709
let channel_scope = match crate::handlers::req::build_search_channel_scope_filter(
@@ -766,6 +819,9 @@ async fn handle_bridge_search(
766819
if !search_hit_accepted(filter, stored, accessible_channels, reader_pubkey_hex) {
767820
continue;
768821
}
822+
if crate::handlers::req::is_author_only_event(&stored.event, pubkey_bytes) {
823+
continue;
824+
}
769825
// Dedup across filters.
770826
if !seen_ids.insert(id_array) {
771827
continue;

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

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use nostr::Filter;
66
use tracing::warn;
77

88
use crate::connection::{AuthState, ConnectionState};
9+
use crate::handlers::req::is_author_only_event;
910
use crate::protocol::RelayMessage;
1011
use crate::state::AppState;
1112

@@ -61,6 +62,13 @@ pub async fn handle_count(
6162
));
6263
return;
6364
}
65+
if !super::req::author_only_filters_authorized(&filters, &authed_pubkey_hex) {
66+
conn.send(RelayMessage::closed(
67+
&sub_id,
68+
"restricted: author-only kinds require authors=[self]",
69+
));
70+
return;
71+
}
6472

6573
// Get channels this user can access — same enforcement as WS REQ handler.
6674
let accessible_channels = match state.get_accessible_channel_ids_cached(&pubkey_bytes).await {
@@ -75,6 +83,11 @@ pub async fn handle_count(
7583
// For each filter, count matching events with channel access enforcement.
7684
let mut total: u64 = 0;
7785
for filter in &filters {
86+
// Determine if this filter can match author-only kinds — if so, the
87+
// fast-path count_events() cannot be used because it doesn't do
88+
// per-event author filtering.
89+
let needs_author_only_filtering = super::req::filter_can_match_author_only_kinds(filter);
90+
7891
if let Some(ch_id) = extract_channel_from_filter(filter) {
7992
// Filter targets a specific channel — verify access.
8093
if !accessible_channels.contains(&ch_id) {
@@ -83,7 +96,15 @@ pub async fn handle_count(
8396
// Channel is accessible — count with pushability check.
8497
let query =
8598
super::req::build_event_query_from_filter(filter, &pubkey_bytes, &state).await;
86-
if super::req::filter_fully_pushable(filter) {
99+
let author_is_self = filter.authors.as_ref().is_some_and(|authors| {
100+
!authors.is_empty()
101+
&& authors
102+
.iter()
103+
.all(|a| a.to_hex().eq_ignore_ascii_case(&authed_pubkey_hex))
104+
});
105+
if super::req::filter_fully_pushable(filter)
106+
&& (!needs_author_only_filtering || author_is_self)
107+
{
87108
match state.db.count_events(&query).await {
88109
Ok(n) => total += n as u64,
89110
Err(e) => {
@@ -99,9 +120,14 @@ pub async fn handle_count(
99120
match state.db.query_events(&q).await {
100121
Ok(stored_events) => {
101122
for se in stored_events {
102-
if buzz_core::filter::filters_match(std::slice::from_ref(filter), &se) {
103-
total += 1;
123+
if !buzz_core::filter::filters_match(std::slice::from_ref(filter), &se)
124+
{
125+
continue;
104126
}
127+
if is_author_only_event(&se.event, &pubkey_bytes) {
128+
continue;
129+
}
130+
total += 1;
105131
}
106132
}
107133
Err(e) => {
@@ -121,7 +147,15 @@ pub async fn handle_count(
121147
super::req::build_event_query_from_filter(filter, &pubkey_bytes, &state).await;
122148
query.channel_ids = Some(accessible_channels.to_vec());
123149

124-
if super::req::filter_fully_pushable(filter) {
150+
let author_is_self = filter.authors.as_ref().is_some_and(|authors| {
151+
!authors.is_empty()
152+
&& authors
153+
.iter()
154+
.all(|a| a.to_hex().eq_ignore_ascii_case(&authed_pubkey_hex))
155+
});
156+
if super::req::filter_fully_pushable(filter)
157+
&& (!needs_author_only_filtering || author_is_self)
158+
{
125159
query.limit = None; // COUNT doesn't need a row limit
126160
match state.db.count_events(&query).await {
127161
Ok(n) => total += n as u64,
@@ -137,9 +171,14 @@ pub async fn handle_count(
137171
match state.db.query_events(&query).await {
138172
Ok(stored_events) => {
139173
for se in stored_events {
140-
if buzz_core::filter::filters_match(std::slice::from_ref(filter), &se) {
141-
total += 1;
174+
if !buzz_core::filter::filters_match(std::slice::from_ref(filter), &se)
175+
{
176+
continue;
177+
}
178+
if is_author_only_event(&se.event, &pubkey_bytes) {
179+
continue;
142180
}
181+
total += 1;
143182
}
144183
}
145184
Err(e) => {

0 commit comments

Comments
 (0)