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>
This commit is contained in:
Will Pfleger
2026-06-17 10:24:02 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 538f33341f
commit 79fcfd82bc
9 changed files with 1602 additions and 30 deletions
+21
View File
@@ -91,6 +91,25 @@ pub const KIND_AGENT_PROFILE: u32 = 10100;
/// `docs/nips/NIP-AE.md` and [`crate::engram`].
pub const KIND_AGENT_ENGRAM: u32 = 30174;
/// NIP-ER: Event Reminder (parameterized replaceable, author-only).
///
/// Encrypted, author-only reminder addressed by `(pubkey, kind, d_tag)`. The
/// public `not_before` tag tells supporting relays when the reminder is due;
/// the target, note, and state are NIP-44 encrypted to the author. Reads are
/// author-only (see [`AUTHOR_ONLY_KINDS`]). See `docs/nips/NIP-ER.md`.
pub const KIND_EVENT_REMINDER: u32 = 30300;
/// Kinds whose stored events are readable only by their author.
///
/// The relay must never reveal the existence, count, tags, content, schedule,
/// or search matches of these events to anyone but the authenticated author.
/// Shared across the ingest write path (NIP-ER `not_before` validation) and the
/// read path (REQ/COUNT/subscription author-only filtering).
///
/// Currently O(1) with a single entry. If this grows past ~4 kinds, convert to
/// a compile-time bitset or sorted array with binary search for hot-path use.
pub const AUTHOR_ONLY_KINDS: &[u32] = &[KIND_EVENT_REMINDER];
// NIP-29 group admin events
/// NIP-29: Add a user to a group.
pub const KIND_NIP29_PUT_USER: u32 = 9000;
@@ -371,6 +390,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_FILE_METADATA,
KIND_AGENT_PROFILE,
KIND_AGENT_ENGRAM,
KIND_EVENT_REMINDER,
KIND_NIP29_PUT_USER,
KIND_NIP29_REMOVE_USER,
KIND_NIP29_EDIT_METADATA,
@@ -554,6 +574,7 @@ pub fn event_kind_i32(event: &nostr::Event) -> i32 {
// Compile-time: new kinds are in the expected ranges.
const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 1000019999
const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 3000039999
const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 3000039999
const _: () = assert!(is_parameterized_replaceable(KIND_MESH_LLM_RELAY_STATUS)); // 30621 ∈ 3000039999
const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 3000039999
+14
View File
@@ -100,6 +100,20 @@ impl PubSubManager {
}
/// Publish an event to the Redis channel. Returns subscriber count.
///
/// Routing note (NIP-ER author-private reminders): events are keyed by
/// `channel_id` (`buzz:channel:{id}`), and every relay node's subscriber
/// `PSUBSCRIBE buzz:channel:*` — so the channel key is a routing label, not
/// an isolation boundary; every node already receives every published event.
/// Author-private reminders (kind:30300, stored under the nil channel
/// sentinel) are therefore NOT protected by per-author Redis routing, and
/// adding it would be pointless: the reminder's author may be connected to
/// any node, so every node must still receive it. The actual author-only
/// delivery boundary is `filter_fanout_by_access` in the relay, which runs
/// on BOTH the in-process and the Redis cross-node (`subscribe_local`)
/// fan-out paths and drops every recipient that is not the event author.
/// Redis only ever carries events between nodes inside the relay trust
/// domain; the ciphertext is NIP-44-encrypted to the author regardless.
pub async fn publish_event(
&self,
channel_id: Uuid,
+64 -8
View File
@@ -282,6 +282,12 @@ pub async fn query_events(
"restricted: agent-engram reads require authors=[self] or #p=[self]",
));
}
if !crate::handlers::req::author_only_filters_authorized(&filters, &authed_pubkey_hex) {
return Err(api_error(
StatusCode::FORBIDDEN,
"restricted: author-only kinds require authors=[self]",
));
}
// Get channels this user can access — same enforcement as WS REQ handler.
let accessible_channels = state
@@ -291,8 +297,14 @@ pub async fn query_events(
// ── NIP-50 search: route to Typesense if any filter has a `search` field ──
if filters.iter().any(|f| f.search.is_some()) {
return handle_bridge_search(&state, &filters, &accessible_channels, &authed_pubkey_hex)
.await;
return handle_bridge_search(
&state,
&filters,
&accessible_channels,
&authed_pubkey_hex,
&pubkey_bytes,
)
.await;
}
// ── Presence: synthesize kind:20001 from Redis (ephemeral, never in DB) ──
@@ -472,6 +484,9 @@ pub async fn query_events(
) {
continue;
}
if crate::handlers::req::is_author_only_event(&se.event, &pubkey_bytes) {
continue;
}
if let Ok(v) = serde_json::to_value(&se.event) {
events.push(v);
}
@@ -528,6 +543,12 @@ pub async fn count_events(
"restricted: agent-engram reads require authors=[self] or #p=[self]",
));
}
if !crate::handlers::req::author_only_filters_authorized(&filters, &authed_pubkey_hex) {
return Err(api_error(
StatusCode::FORBIDDEN,
"restricted: author-only kinds require authors=[self]",
));
}
// Get channels this user can access.
let accessible_channels = state
@@ -537,6 +558,9 @@ pub async fn count_events(
let mut total: u64 = 0;
for filter in &filters {
let needs_author_only_filtering =
crate::handlers::req::filter_can_match_author_only_kinds(filter);
// If filter targets a specific channel, verify access.
if let Some(ch_id) = extract_channel_from_filter(filter) {
if !accessible_channels.contains(&ch_id) {
@@ -546,7 +570,15 @@ pub async fn count_events(
let query =
crate::handlers::req::build_event_query_from_filter(filter, &pubkey_bytes, &state)
.await;
if crate::handlers::req::filter_fully_pushable(filter) {
let author_is_self = filter.authors.as_ref().is_some_and(|authors| {
!authors.is_empty()
&& authors
.iter()
.all(|a| a.to_hex().eq_ignore_ascii_case(&authed_pubkey_hex))
});
if crate::handlers::req::filter_fully_pushable(filter)
&& (!needs_author_only_filtering || author_is_self)
{
match state.db.count_events(&query).await {
Ok(n) => total += n as u64,
Err(e) => {
@@ -561,9 +593,15 @@ pub async fn count_events(
match state.db.query_events(&q).await {
Ok(stored_events) => {
for se in stored_events {
if buzz_core::filter::filters_match(std::slice::from_ref(filter), &se) {
total += 1;
if !buzz_core::filter::filters_match(std::slice::from_ref(filter), &se)
{
continue;
}
if crate::handlers::req::is_author_only_event(&se.event, &pubkey_bytes)
{
continue;
}
total += 1;
}
}
Err(e) => {
@@ -579,7 +617,15 @@ pub async fn count_events(
.await;
query.channel_ids = Some(accessible_channels.to_vec());
if crate::handlers::req::filter_fully_pushable(filter) {
let author_is_self = filter.authors.as_ref().is_some_and(|authors| {
!authors.is_empty()
&& authors
.iter()
.all(|a| a.to_hex().eq_ignore_ascii_case(&authed_pubkey_hex))
});
if crate::handlers::req::filter_fully_pushable(filter)
&& (!needs_author_only_filtering || author_is_self)
{
query.limit = None;
match state.db.count_events(&query).await {
Ok(n) => total += n as u64,
@@ -594,9 +640,15 @@ pub async fn count_events(
match state.db.query_events(&query).await {
Ok(stored_events) => {
for se in stored_events {
if buzz_core::filter::filters_match(std::slice::from_ref(filter), &se) {
total += 1;
if !buzz_core::filter::filters_match(std::slice::from_ref(filter), &se)
{
continue;
}
if crate::handlers::req::is_author_only_event(&se.event, &pubkey_bytes)
{
continue;
}
total += 1;
}
}
Err(e) => {
@@ -651,6 +703,7 @@ async fn handle_bridge_search(
filters: &[nostr::Filter],
accessible_channels: &[uuid::Uuid],
reader_pubkey_hex: &str,
pubkey_bytes: &[u8],
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
// Bridge always includes global (non-channel) events — same as WS with full scopes.
let channel_scope = match crate::handlers::req::build_search_channel_scope_filter(
@@ -766,6 +819,9 @@ async fn handle_bridge_search(
if !search_hit_accepted(filter, stored, accessible_channels, reader_pubkey_hex) {
continue;
}
if crate::handlers::req::is_author_only_event(&stored.event, pubkey_bytes) {
continue;
}
// Dedup across filters.
if !seen_ids.insert(id_array) {
continue;
+45 -6
View File
@@ -6,6 +6,7 @@ use nostr::Filter;
use tracing::warn;
use crate::connection::{AuthState, ConnectionState};
use crate::handlers::req::is_author_only_event;
use crate::protocol::RelayMessage;
use crate::state::AppState;
@@ -61,6 +62,13 @@ pub async fn handle_count(
));
return;
}
if !super::req::author_only_filters_authorized(&filters, &authed_pubkey_hex) {
conn.send(RelayMessage::closed(
&sub_id,
"restricted: author-only kinds require authors=[self]",
));
return;
}
// Get channels this user can access — same enforcement as WS REQ handler.
let accessible_channels = match state.get_accessible_channel_ids_cached(&pubkey_bytes).await {
@@ -75,6 +83,11 @@ pub async fn handle_count(
// For each filter, count matching events with channel access enforcement.
let mut total: u64 = 0;
for filter in &filters {
// Determine if this filter can match author-only kinds — if so, the
// fast-path count_events() cannot be used because it doesn't do
// per-event author filtering.
let needs_author_only_filtering = super::req::filter_can_match_author_only_kinds(filter);
if let Some(ch_id) = extract_channel_from_filter(filter) {
// Filter targets a specific channel — verify access.
if !accessible_channels.contains(&ch_id) {
@@ -83,7 +96,15 @@ pub async fn handle_count(
// Channel is accessible — count with pushability check.
let query =
super::req::build_event_query_from_filter(filter, &pubkey_bytes, &state).await;
if super::req::filter_fully_pushable(filter) {
let author_is_self = filter.authors.as_ref().is_some_and(|authors| {
!authors.is_empty()
&& authors
.iter()
.all(|a| a.to_hex().eq_ignore_ascii_case(&authed_pubkey_hex))
});
if super::req::filter_fully_pushable(filter)
&& (!needs_author_only_filtering || author_is_self)
{
match state.db.count_events(&query).await {
Ok(n) => total += n as u64,
Err(e) => {
@@ -99,9 +120,14 @@ pub async fn handle_count(
match state.db.query_events(&q).await {
Ok(stored_events) => {
for se in stored_events {
if buzz_core::filter::filters_match(std::slice::from_ref(filter), &se) {
total += 1;
if !buzz_core::filter::filters_match(std::slice::from_ref(filter), &se)
{
continue;
}
if is_author_only_event(&se.event, &pubkey_bytes) {
continue;
}
total += 1;
}
}
Err(e) => {
@@ -121,7 +147,15 @@ pub async fn handle_count(
super::req::build_event_query_from_filter(filter, &pubkey_bytes, &state).await;
query.channel_ids = Some(accessible_channels.to_vec());
if super::req::filter_fully_pushable(filter) {
let author_is_self = filter.authors.as_ref().is_some_and(|authors| {
!authors.is_empty()
&& authors
.iter()
.all(|a| a.to_hex().eq_ignore_ascii_case(&authed_pubkey_hex))
});
if super::req::filter_fully_pushable(filter)
&& (!needs_author_only_filtering || author_is_self)
{
query.limit = None; // COUNT doesn't need a row limit
match state.db.count_events(&query).await {
Ok(n) => total += n as u64,
@@ -137,9 +171,14 @@ pub async fn handle_count(
match state.db.query_events(&query).await {
Ok(stored_events) => {
for se in stored_events {
if buzz_core::filter::filters_match(std::slice::from_ref(filter), &se) {
total += 1;
if !buzz_core::filter::filters_match(std::slice::from_ref(filter), &se)
{
continue;
}
if is_author_only_event(&se.event, &pubkey_bytes) {
continue;
}
total += 1;
}
}
Err(e) => {
+63 -3
View File
@@ -6,7 +6,7 @@ use tracing::{debug, error, info, warn};
use buzz_core::event::StoredEvent;
use buzz_core::kind::{
event_kind_u32, is_ephemeral, KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP,
event_kind_u32, is_ephemeral, AUTHOR_ONLY_KINDS, KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP,
KIND_MESH_CONNECT_REQUEST, KIND_MESH_STATUS_REPORT, KIND_PRESENCE_UPDATE,
};
use buzz_core::observer::{
@@ -61,6 +61,27 @@ pub async fn filter_fanout_by_access(
stored_event: &StoredEvent,
matches: Vec<(crate::subscription::ConnId, crate::subscription::SubId)>,
) -> Vec<(crate::subscription::ConnId, crate::subscription::SubId)> {
// Author-only kinds (NIP-ER reminders) may only ever be delivered to the
// event's own author. This gate lives here — the chokepoint shared by the
// ingest fan-out path and the Redis cross-node `subscribe_local` path, the
// only paths that route author-only kinds — so no such delivery can bypass
// it. It runs before (and independent of) the channel-membership filter
// below because author-only kinds are stored globally (channel_id = None).
let matches = if AUTHOR_ONLY_KINDS.contains(&event_kind_u32(&stored_event.event)) {
let author = stored_event.event.pubkey.to_bytes();
matches
.into_iter()
.filter(|(conn_id, _)| {
state
.conn_manager
.pubkey_for_conn(*conn_id)
.is_some_and(|pk| pk == author)
})
.collect()
} else {
matches
};
let Some(channel_id) = stored_event.channel_id else {
return matches;
};
@@ -138,6 +159,8 @@ pub(crate) async fn dispatch_persistent_event(
.find_map(|t| t.content().map(|s| s.to_string()))
})
.flatten();
// Author-only delivery gating (NIP-ER reminders) is enforced centrally in
// filter_fanout_by_access, applied to `matches` above before this loop.
let mut drop_count = 0u32;
for (target_conn_id, sub_id) in &matches {
if let Some(ref owner_hex) = dm_visibility_owner {
@@ -162,10 +185,12 @@ pub(crate) async fn dispatch_persistent_event(
);
}
// Skip search indexing for NIP-17 gift wraps (ciphertext) and NIP-DV
// visibility snapshots (per-viewer private hide state, owner-gated reads).
// Skip search indexing for NIP-17 gift wraps (ciphertext), NIP-DV
// visibility snapshots (per-viewer private hide state, owner-gated reads),
// and author-only kinds (ciphertext not useful in search, defense in depth).
if kind_u32 != KIND_GIFT_WRAP
&& kind_u32 != buzz_core::kind::KIND_DM_VISIBILITY
&& !AUTHOR_ONLY_KINDS.contains(&kind_u32)
&& state
.search_index_tx
.try_send(stored_event.clone())
@@ -1116,5 +1141,40 @@ mod tests {
filter_fanout_by_access(&state, &channel_event(Some(channel_id)), matches).await;
assert_eq!(out, vec![(member, "m".to_string())]);
}
#[tokio::test]
async fn author_only_reminder_delivers_to_author_only() {
let state = test_state().await;
let author_keys = Keys::generate();
let author_pk = author_keys.public_key().to_bytes().to_vec();
let other_pk = vec![9u8; 32];
// KIND_EVENT_REMINDER (30300) is in AUTHOR_ONLY_KINDS and is stored
// globally (channel_id = None), so the gate must apply independent
// of any channel-membership check.
let reminder = EventBuilder::new(
Kind::Custom(buzz_core::kind::KIND_EVENT_REMINDER as u16),
"{}",
)
.sign_with_keys(&author_keys)
.expect("sign reminder");
let stored = StoredEvent::new(reminder, None);
let author_conn = register_conn(&state, Some(author_pk));
let other_conn = register_conn(&state, Some(other_pk));
let unauthed_conn = register_conn(&state, None);
let matches = vec![
(author_conn, "a".to_string()),
(other_conn, "o".to_string()),
(unauthed_conn, "u".to_string()),
];
let out = filter_fanout_by_access(&state, &stored, matches).await;
// Only the author's subscription survives; the non-author and the
// unauthenticated connection are both dropped.
assert_eq!(out, vec![(author_conn, "a".to_string())]);
}
}
}
+302 -11
View File
@@ -15,14 +15,15 @@ use buzz_core::kind::{
is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_APPROVAL_DENY,
KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, KIND_CANVAS,
KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN,
KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_FOLLOW_SET, KIND_FORUM_COMMENT, KIND_FORUM_POST,
KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE,
KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED,
KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED,
KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT,
KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM,
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MESH_LLM_RELAY_STATUS,
KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP,
KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, KIND_FOLLOW_SET, KIND_FORUM_COMMENT,
KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH,
KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE,
KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN,
KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED,
KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST,
KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MEMBER_ADDED_NOTIFICATION,
KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MESH_LLM_RELAY_STATUS, KIND_MUTE_LIST,
KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP,
KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST,
KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST,
KIND_NIP65_RELAY_LIST_METADATA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, KIND_PROFILE,
@@ -152,9 +153,8 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
match kind {
KIND_PROFILE => Ok(Scope::UsersWrite),
KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite),
KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM => {
Ok(Scope::UsersWrite)
}
KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM
| KIND_EVENT_REMINDER => Ok(Scope::UsersWrite),
// NIP-51 standard lists and NIP-65 relay list — user-owned global state,
// same ownership shape as kind:3 (contacts) and kind:0 (profile).
KIND_MUTE_LIST
@@ -340,6 +340,8 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
| KIND_EMOJI_LIST
// NIP-AE agent engrams are addressed by (pubkey_a, kind, d_tag); never channel-scoped.
| KIND_AGENT_ENGRAM
// NIP-ER event reminders are addressed by (pubkey, kind, d_tag); never channel-scoped.
| KIND_EVENT_REMINDER
// Agent profile (10100): user-owned replaceable, keyed by pubkey.
| KIND_AGENT_PROFILE
// NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope).
@@ -954,6 +956,99 @@ fn validate_engram_nip44_content(content: &str) -> Result<(), String> {
Ok(())
}
/// Parse a NIP-ER `not_before` tag value into a Unix timestamp.
///
/// The value MUST be a decimal integer string containing only ASCII digits, with
/// no sign, whitespace, decimal point, or leading zero (except the literal `"0"`),
/// and MUST be in the range 0..=9007199254740991 (`Number.MAX_SAFE_INTEGER`, the
/// interoperable JSON integer bound the spec mandates). Parsing is exact integer
/// parsing — never lossy floating-point — so values that overflow are malformed.
fn validate_not_before(tag_value: &str) -> Result<u64, &'static str> {
const MAX_NOT_BEFORE: u64 = 9_007_199_254_740_991;
if tag_value.is_empty() || !tag_value.bytes().all(|b| b.is_ascii_digit()) {
return Err("malformed not_before");
}
// Reject leading zeros (e.g. "007") so each timestamp has one canonical form.
// "0" itself is the only value allowed to begin with '0'.
if tag_value.len() > 1 && tag_value.starts_with('0') {
return Err("malformed not_before");
}
// Exact integer parse — `u64::from_str` rejects overflow rather than rounding,
// so values that would lose precision as f64 are caught before the range check.
let value: u64 = tag_value.parse().map_err(|_| "malformed not_before")?;
if value > MAX_NOT_BEFORE {
return Err("malformed not_before");
}
Ok(value)
}
/// Validate the public tag envelope of a NIP-ER `kind:30300` event before it
/// reaches NIP-33 parameterized replacement.
///
/// The relay never decrypts the reminder; it only enforces the public schedule
/// tags. A reminder carries at most one `not_before` (omitted on terminal
/// states), and — when both `not_before` and an optional NIP-40 `expiration`
/// are present — `expiration` MUST be strictly after `not_before` (an
/// `expiration <= not_before` window would expire the reminder before it ever
/// became due).
fn validate_event_reminder(event: &Event) -> Result<(), &'static str> {
let mut not_before: Option<u64> = None;
let mut expiration: Option<&str> = None;
let mut d_count = 0u8;
let mut d_empty = false;
for tag in event.tags.iter() {
let parts = tag.as_slice();
if parts.len() < 2 {
continue;
}
match parts[0].as_str() {
"not_before" => {
// Spec (NIP-ER line 60) collapses invalid and duplicate
// `not_before` into one wire string clients may match on.
if not_before.is_some() {
return Err("malformed not_before");
}
not_before = Some(validate_not_before(&parts[1])?);
}
"expiration" => expiration = Some(&parts[1]),
"d" => {
d_count = d_count.saturating_add(1);
if parts[1].is_empty() {
d_empty = true;
}
}
_ => {}
}
}
// d-tag: must have exactly one, non-empty
if d_count == 0 {
return Err("missing d tag");
}
if d_count > 1 {
return Err("duplicate d tag");
}
if d_empty {
return Err("empty d tag");
}
// `not_before` is optional — terminal states (done/cancelled) and bookmarks
// omit it. The ordering check only applies when both are present.
if let Some(nb) = not_before {
if let Some(exp) = expiration {
if let Ok(exp) = exp.parse::<u64>() {
if exp <= nb {
return Err("expiration before not_before");
}
}
}
}
Ok(())
}
// ── The pipeline ─────────────────────────────────────────────────────────────
/// Ingest a signed Nostr event through the full validation pipeline.
@@ -1368,6 +1463,12 @@ pub async fn ingest_event(
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
}
// ── 15b. Event reminder schedule tags (kind:30300) ───────────────────
if kind_u32 == KIND_EVENT_REMINDER {
validate_event_reminder(&event)
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
}
// Track pre-created channel UUID for compensation on insert failure.
let mut pre_created_channel: Option<Uuid> = None;
@@ -2266,4 +2367,194 @@ mod tests {
let err = validate_engram_envelope(&ev).unwrap_err();
assert!(err.contains("base64"), "got: {err}");
}
// ── NIP-ER not_before validation ─────────────────────────────────────
#[test]
fn not_before_accepts_zero() {
assert_eq!(validate_not_before("0"), Ok(0));
}
#[test]
fn not_before_accepts_typical_timestamp() {
assert_eq!(validate_not_before("1717000000"), Ok(1_717_000_000));
}
#[test]
fn not_before_accepts_max_safe_integer() {
assert_eq!(
validate_not_before("9007199254740991"),
Ok(9_007_199_254_740_991)
);
}
#[test]
fn not_before_rejects_above_max_safe_integer() {
assert_eq!(
validate_not_before("9007199254740992"),
Err("malformed not_before")
);
}
#[test]
fn not_before_rejects_leading_zero() {
assert_eq!(validate_not_before("007"), Err("malformed not_before"));
}
#[test]
fn not_before_rejects_empty() {
assert_eq!(validate_not_before(""), Err("malformed not_before"));
}
#[test]
fn not_before_rejects_non_digits() {
// Sign, whitespace, decimal point, and non-decimal forms are all
// rejected — only ASCII decimal digits are valid.
for value in ["-1", "+1", " 1", "1 ", "1.0", "1e3", "0x10", "abc"] {
assert_eq!(
validate_not_before(value),
Err("malformed not_before"),
"value {value:?} should be malformed"
);
}
}
#[test]
fn not_before_rejects_u64_overflow() {
// Exceeds u64::MAX — `from_str` errors rather than wrapping, so the
// value is malformed (not a lossy round-trip).
assert_eq!(
validate_not_before("99999999999999999999999999"),
Err("malformed not_before")
);
}
// ── NIP-ER reminder envelope validation ──────────────────────────────
fn make_reminder(tags: &[&[&str]]) -> Event {
make_event_with_tags(KIND_EVENT_REMINDER, "ciphertext", tags)
}
#[test]
fn reminder_accepts_single_valid_not_before() {
let ev = make_reminder(&[&["d", "abc"], &["not_before", "1717000000"]]);
assert!(validate_event_reminder(&ev).is_ok());
}
#[test]
fn reminder_accepts_expiration_after_not_before() {
let ev = make_reminder(&[
&["d", "abc"],
&["not_before", "1717000000"],
&["expiration", "1717000001"],
]);
assert!(validate_event_reminder(&ev).is_ok());
}
#[test]
fn reminder_accepts_missing_not_before() {
// Terminal states (done/cancelled) and bookmarks omit not_before
let ev = make_reminder(&[&["d", "abc"]]);
assert!(validate_event_reminder(&ev).is_ok());
}
#[test]
fn reminder_rejects_duplicate_not_before() {
let ev = make_reminder(&[
&["d", "abc"],
&["not_before", "1717000000"],
&["not_before", "1717000005"],
]);
assert_eq!(validate_event_reminder(&ev), Err("malformed not_before"));
}
#[test]
fn reminder_rejects_malformed_not_before() {
let ev = make_reminder(&[&["d", "abc"], &["not_before", "007"]]);
assert_eq!(validate_event_reminder(&ev), Err("malformed not_before"));
}
#[test]
fn reminder_rejects_expiration_equal_to_not_before() {
let ev = make_reminder(&[
&["d", "abc"],
&["not_before", "1717000000"],
&["expiration", "1717000000"],
]);
assert_eq!(
validate_event_reminder(&ev),
Err("expiration before not_before")
);
}
#[test]
fn reminder_rejects_expiration_before_not_before() {
let ev = make_reminder(&[
&["d", "abc"],
&["not_before", "1717000000"],
&["expiration", "1716000000"],
]);
assert_eq!(
validate_event_reminder(&ev),
Err("expiration before not_before")
);
}
#[test]
fn reminder_ignores_malformed_expiration() {
// A malformed `expiration` is NIP-40's concern, not this validator's:
// the ordering check runs only when `expiration` parses, so a valid
// `not_before` with an unparseable expiration is accepted here.
let ev = make_reminder(&[
&["d", "abc"],
&["not_before", "1717000000"],
&["expiration", "notanumber"],
]);
assert!(validate_event_reminder(&ev).is_ok());
}
#[test]
fn event_reminder_is_global_only_and_param_replaceable() {
assert!(is_global_only_kind(KIND_EVENT_REMINDER));
assert!(!requires_h_channel_scope(KIND_EVENT_REMINDER));
assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER));
}
#[test]
fn reminder_accepts_expiration_without_not_before() {
// A terminal/bookmark with expiration but no not_before is valid —
// no ordering check applies when not_before is absent.
let ev = make_reminder(&[&["d", "abc"], &["expiration", "1777542730"]]);
assert!(validate_event_reminder(&ev).is_ok());
}
#[test]
fn reminder_rejects_missing_d_tag() {
let ev = make_event_with_tags(
KIND_EVENT_REMINDER,
"ciphertext",
&[&["not_before", "1717000000"]],
);
assert_eq!(validate_event_reminder(&ev), Err("missing d tag"));
}
#[test]
fn reminder_rejects_empty_d_tag() {
let ev = make_event_with_tags(
KIND_EVENT_REMINDER,
"ciphertext",
&[&["d", ""], &["not_before", "1717000000"]],
);
assert_eq!(validate_event_reminder(&ev), Err("empty d tag"));
}
#[test]
fn reminder_rejects_duplicate_d_tag() {
let ev = make_event_with_tags(
KIND_EVENT_REMINDER,
"ciphertext",
&[&["d", "abc"], &["d", "def"], &["not_before", "1717000000"]],
);
assert_eq!(validate_event_reminder(&ev), Err("duplicate d tag"));
}
}
+76 -2
View File
@@ -7,8 +7,8 @@ use tracing::{debug, warn};
use buzz_core::filter::filters_match;
use buzz_core::kind::{
KIND_AGENT_ENGRAM, KIND_AGENT_OBSERVER_FRAME, KIND_DM_VISIBILITY, KIND_GIFT_WRAP,
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION,
AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_OBSERVER_FRAME, KIND_DM_VISIBILITY,
KIND_GIFT_WRAP, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION,
};
use buzz_db::EventQuery;
use hex;
@@ -116,6 +116,13 @@ pub async fn handle_req(
));
return;
}
if !author_only_filters_authorized(&filters, &authed_pubkey_hex) {
conn.send(RelayMessage::closed(
&sub_id,
"restricted: author-only kinds require authors=[self]",
));
return;
}
}
// ── NIP-50 search: one-shot, no persistent subscription ──────────────────
@@ -138,6 +145,7 @@ pub async fn handle_req(
&accessible_channels,
token_channel_ids.is_none(),
&hex::encode(&pubkey_bytes),
&pubkey_bytes,
&conn,
&state,
)
@@ -228,6 +236,12 @@ pub async fn handle_req(
continue;
}
// Author-only kinds: only the event author may see these events.
// Mixed-kind filters still serve other kinds normally.
if is_author_only_event(&stored.event, &pubkey_bytes) {
continue;
}
// Dedup AFTER acceptance — an event that fails filter A's constraints
// must remain eligible for filter B (NIP-01 OR semantics).
if !seen_ids.insert(stored.event.id) {
@@ -283,12 +297,14 @@ pub(crate) fn build_search_channel_scope_filter(
})
}
#[allow(clippy::too_many_arguments)]
async fn handle_search_req(
sub_id: &str,
filters: &[Filter],
accessible_channels: &[uuid::Uuid],
include_global: bool,
reader_pubkey_hex: &str,
reader_pubkey_bytes: &[u8],
conn: &ConnectionState,
state: &AppState,
) {
@@ -447,6 +463,9 @@ async fn handle_search_req(
) {
continue;
}
if is_author_only_event(&stored.event, reader_pubkey_bytes) {
continue;
}
// Dedup AFTER acceptance — an event that fails filter A's constraints
// must remain eligible for filter B (NIP-01 OR semantics).
if !seen_ids.insert(stored.event.id) {
@@ -807,6 +826,61 @@ pub(crate) fn engram_filters_authorized(filters: &[Filter], authed_pubkey_hex: &
})
}
/// Returns `true` if the filter CAN match author-only kinds — meaning it either
/// has no `kinds` constraint (wildcard) or includes at least one author-only kind.
///
/// Used by the COUNT handler to force the fallback path (per-event filtering)
/// instead of the fast `count_events()` which cannot exclude other authors'
/// author-only events from the aggregate count.
pub(crate) fn filter_can_match_author_only_kinds(filter: &Filter) -> bool {
filter.kinds.as_ref().is_none_or(|ks| {
ks.iter()
.any(|k| AUTHOR_ONLY_KINDS.contains(&(k.as_u16() as u32)))
})
}
/// Returns `true` if the event is an author-only kind and the requester is NOT
/// the author. Used as a per-event filter during historical delivery and fan-out
/// to silently omit unauthorized events from mixed-kind result sets.
pub(crate) fn is_author_only_event(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool {
let kind_u32 = event.kind.as_u16() as u32;
AUTHOR_ONLY_KINDS.contains(&kind_u32) && event.pubkey.to_bytes() != requester_pubkey_bytes
}
/// Pre-filter authorization for filters that exclusively target author-only kinds.
///
/// If a filter targets ONLY author-only kinds (e.g. `{kinds:[30300]}`), the
/// `authors` field MUST contain only the requester's pubkey. Otherwise the relay
/// would execute a DB query guaranteed to return zero results after per-event
/// filtering — wasting resources and potentially leaking timing information.
///
/// For unauthenticated single-kind 30300 requests, the WS handler closes with
/// `auth-required:`. For authenticated requests targeting another author's
/// reminders, the WS handler closes with `restricted:`.
///
/// Mixed-kind filters (e.g. `{kinds:[30300, 9]}`) pass this gate — the per-event
/// filter in the delivery loop handles the author-only omission.
pub(crate) fn author_only_filters_authorized(filters: &[Filter], authed_pubkey_hex: &str) -> bool {
filters.iter().all(|filter| {
let targets_only_author_only = filter.kinds.as_ref().is_some_and(|ks| {
!ks.is_empty()
&& ks
.iter()
.all(|k| AUTHOR_ONLY_KINDS.contains(&(k.as_u16() as u32)))
});
if !targets_only_author_only {
return true;
}
// Filter exclusively targets author-only kinds — require authors=[self].
filter.authors.as_ref().is_some_and(|authors| {
!authors.is_empty()
&& authors
.iter()
.all(|a| a.to_hex().eq_ignore_ascii_case(authed_pubkey_hex))
})
})
}
#[cfg(test)]
mod tests {
use super::*;
+6
View File
@@ -149,6 +149,12 @@ impl BuzzTestClient {
Ok(())
}
/// Sends a raw JSON value as a WebSocket text frame.
pub async fn send_raw(&mut self, value: &serde_json::Value) -> Result<(), TestClientError> {
self.inner.send_raw(value).await?;
Ok(())
}
/// Receives the next relay message, waiting up to `timeout_dur`.
pub async fn recv_event(
&mut self,
File diff suppressed because it is too large Load Diff