feat(relay): implement NIP-ER event reminder support (kind:30300)

Adds relay-side support for NIP-ER encrypted event reminders:

Write path (ingest):
- Register KIND_EVENT_REMINDER (30300) as parameterized replaceable,
  global-only, and UsersWrite-scoped
- Validate not_before tag: exactly one, decimal digits only, no leading
  zeros, range 0..=MAX_SAFE_INTEGER
- Reject expiration <= not_before when both are present and parseable

Read path (REQ/COUNT/bridge):
- AUTHOR_ONLY_KINDS array gates visibility — only the authenticated
  event author may read kind:30300 events
- Exclusive author-only filters (kinds:[30300]) require authors=[self]
  or get CLOSED/403
- Mixed-kind filters pass the gate but per-event filtering silently
  omits other authors' reminders
- COUNT fast path disabled when author-only kinds are in play to
  prevent leaking aggregate counts

Integration tests (e2e_event_reminder.rs):
- Write validation: valid, missing, duplicate, malformed not_before;
  expiration ordering; edge cases (0, MAX_SAFE_INTEGER)
- Read filtering: author self-query, cross-user rejection (HTTP 403
  and WS CLOSED), mixed-kind omission, replacement semantics

Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Will Pfleger <wpfleger@squareup.com>
Signed-off-by: Will Pfleger <wpfleger@squareup.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-06-09 19:07:59 -04:00
committed by Will Pfleger
parent a101fd6ad3
commit 0b3bed62bf
8 changed files with 1408 additions and 30 deletions

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;
@@ -368,6 +387,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,
@@ -550,6 +570,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 ∈ 10000–19999
const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_MESH_LLM_RELAY_STATUS)); // 30621 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999

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,17 @@ 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 +619,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 +642,17 @@ 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 +707,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 +823,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;

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,16 @@ 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 +149,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 +173,16 @@ 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) => {

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::{
@@ -138,6 +138,8 @@ pub(crate) async fn dispatch_persistent_event(
.find_map(|t| t.content().map(|s| s.to_string()))
})
.flatten();
// Author-only kinds: only deliver to the event's author.
let is_author_only_kind = AUTHOR_ONLY_KINDS.contains(&kind_u32);
let mut drop_count = 0u32;
for (target_conn_id, sub_id) in &matches {
if let Some(ref owner_hex) = dm_visibility_owner {
@@ -149,6 +151,15 @@ pub(crate) async fn dispatch_persistent_event(
continue;
}
}
if is_author_only_kind {
let is_author = state
.conn_manager
.pubkey_for(*target_conn_id)
.is_some_and(|pk| pk == stored_event.event.pubkey.to_bytes());
if !is_author {
continue;
}
}
let msg = format!(r#"["EVENT","{}",{}]"#, sub_id, event_json);
if !state.conn_manager.send_to(*target_conn_id, msg) {
drop_count += 1;
@@ -162,10 +173,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())

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,81 @@ 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 MUST carry exactly one valid `not_before`, and — when an
/// optional NIP-40 `expiration` is 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;
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]),
_ => {}
}
}
let not_before = not_before.ok_or("malformed not_before")?;
// `expiration` is optional and governed by NIP-40, not this validator.
// We enforce only the spec's ordering rule (line 130), and only when the
// value parses — an unparseable `expiration` is NIP-40's concern, so we
// must not assert a false ordering reason for it.
if let Some(exp) = expiration {
if let Ok(exp) = exp.parse::<u64>() {
if exp <= not_before {
return Err("expiration before not_before");
}
}
}
Ok(())
}
// ── The pipeline ─────────────────────────────────────────────────────────────
/// Ingest a signed Nostr event through the full validation pipeline.
@@ -1368,6 +1445,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 +2349,155 @@ 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_rejects_missing_not_before() {
let ev = make_reminder(&[&["d", "abc"]]);
assert_eq!(validate_event_reminder(&ev), Err("malformed not_before"));
}
#[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));
}
}

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::*;

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,

View File

@@ -0,0 +1,927 @@
//! End-to-end integration tests for NIP-ER (Event Reminders, kind:30300).
//!
//! These tests verify:
//! - Write-path validation: `not_before` tag parsing, duplicate rejection,
//! expiration ordering
//! - Read-path filtering: author-only enforcement on REQ, COUNT, and the
//! HTTP bridge (/query, /count)
//!
//! # Running
//!
//! Start the relay, then run:
//!
//! ```text
//! RELAY_URL=ws://localhost:3001 cargo test -p buzz-test-client --test e2e_event_reminder -- --ignored
//! ```
use std::time::Duration;
use nostr::{EventBuilder, Filter, Keys, Kind, Tag};
use reqwest::Client;
use serde_json::Value;
use buzz_test_client::{BuzzTestClient, RelayMessage};
const KIND_EVENT_REMINDER: u16 = 30300;
// ── Helpers ───────────────────────────────────────────────────────────────────
fn relay_url() -> String {
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3001".to_string())
}
fn relay_http_url() -> String {
relay_url()
.replace("wss://", "https://")
.replace("ws://", "http://")
.trim_end_matches('/')
.to_string()
}
fn sub_id(name: &str) -> String {
format!("e2e-niper-{name}-{}", uuid::Uuid::new_v4())
}
fn http_client() -> Client {
Client::builder()
.timeout(Duration::from_secs(10))
.build()
.expect("failed to build HTTP client")
}
/// Build a valid kind:30300 reminder event with the given tags.
fn build_reminder(keys: &Keys, d_tag: &str, extra_tags: Vec<Tag>) -> nostr::Event {
let mut tags = vec![
Tag::parse(["d", d_tag]).unwrap(),
Tag::parse(["alt", "Encrypted reminder"]).unwrap(),
];
tags.extend(extra_tags);
EventBuilder::new(
Kind::Custom(KIND_EVENT_REMINDER),
"nip44-ciphertext-placeholder",
)
.tags(tags)
.sign_with_keys(keys)
.unwrap()
}
/// Submit an event via the HTTP bridge and return (accepted, message).
async fn submit_event_http(client: &Client, keys: &Keys, event: &nostr::Event) -> (bool, String) {
let pubkey_hex = keys.public_key().to_hex();
let resp = client
.post(format!("{}/events", relay_http_url()))
.header("X-Pubkey", &pubkey_hex)
.header("Content-Type", "application/json")
.body(serde_json::to_string(event).unwrap())
.send()
.await
.expect("submit event");
let body: Value = resp.json().await.expect("parse response");
let accepted = body["accepted"].as_bool().unwrap_or(false);
let message = body["message"].as_str().unwrap_or("").to_string();
(accepted, message)
}
/// Query events via the HTTP bridge. Returns the JSON array of events.
async fn query_events_http(client: &Client, pubkey_hex: &str, filters: Vec<Filter>) -> Vec<Value> {
let resp = client
.post(format!("{}/query", relay_http_url()))
.header("X-Pubkey", pubkey_hex)
.header("Content-Type", "application/json")
.json(&filters)
.send()
.await
.expect("query events");
assert!(
resp.status().is_success(),
"query failed: {}",
resp.status()
);
resp.json::<Vec<Value>>()
.await
.expect("parse query response")
}
/// Count events via the HTTP bridge. Returns the count or an error status.
async fn count_events_http(
client: &Client,
pubkey_hex: &str,
filters: Vec<Filter>,
) -> Result<u64, (u16, String)> {
let resp = client
.post(format!("{}/count", relay_http_url()))
.header("X-Pubkey", pubkey_hex)
.header("Content-Type", "application/json")
.json(&filters)
.send()
.await
.expect("count events");
let status = resp.status().as_u16();
let body: Value = resp.json().await.expect("parse count response");
if status == 200 {
Ok(body["count"].as_u64().unwrap_or(0))
} else {
let msg = body["error"].as_str().unwrap_or("").to_string();
Err((status, msg))
}
}
// ── Write-path validation tests ──────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_reminder_accepted_with_valid_not_before() {
let client = http_client();
let keys = Keys::generate();
let d_tag = uuid::Uuid::new_v4().to_string();
let event = build_reminder(
&keys,
&d_tag,
vec![Tag::parse(["not_before", "1717000000"]).unwrap()],
);
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
assert!(accepted, "valid reminder rejected: {msg}");
}
#[tokio::test]
#[ignore]
async fn test_reminder_rejected_missing_not_before() {
let client = http_client();
let keys = Keys::generate();
let d_tag = uuid::Uuid::new_v4().to_string();
// No not_before tag at all
let event = build_reminder(&keys, &d_tag, vec![]);
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
assert!(!accepted, "should reject missing not_before");
assert!(
msg.contains("malformed not_before"),
"unexpected message: {msg}"
);
}
#[tokio::test]
#[ignore]
async fn test_reminder_rejected_duplicate_not_before() {
let client = http_client();
let keys = Keys::generate();
let d_tag = uuid::Uuid::new_v4().to_string();
let event = build_reminder(
&keys,
&d_tag,
vec![
Tag::parse(["not_before", "1717000000"]).unwrap(),
Tag::parse(["not_before", "1717000005"]).unwrap(),
],
);
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
assert!(!accepted, "should reject duplicate not_before");
assert!(
msg.contains("malformed not_before"),
"unexpected message: {msg}"
);
}
#[tokio::test]
#[ignore]
async fn test_reminder_rejected_malformed_not_before_leading_zero() {
let client = http_client();
let keys = Keys::generate();
let d_tag = uuid::Uuid::new_v4().to_string();
let event = build_reminder(
&keys,
&d_tag,
vec![Tag::parse(["not_before", "007"]).unwrap()],
);
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
assert!(!accepted, "should reject leading-zero not_before");
assert!(
msg.contains("malformed not_before"),
"unexpected message: {msg}"
);
}
#[tokio::test]
#[ignore]
async fn test_reminder_rejected_malformed_not_before_non_digits() {
let client = http_client();
let keys = Keys::generate();
for value in ["abc", "-1", "1.0", "1e3", " 1", ""] {
let d_tag = uuid::Uuid::new_v4().to_string();
let event = build_reminder(
&keys,
&d_tag,
vec![Tag::parse(["not_before", value]).unwrap()],
);
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
assert!(
!accepted,
"should reject not_before={value:?}, got accepted"
);
assert!(
msg.contains("malformed not_before"),
"value={value:?}, unexpected message: {msg}"
);
}
}
#[tokio::test]
#[ignore]
async fn test_reminder_rejected_not_before_above_max_safe_integer() {
let client = http_client();
let keys = Keys::generate();
let d_tag = uuid::Uuid::new_v4().to_string();
let event = build_reminder(
&keys,
&d_tag,
vec![Tag::parse(["not_before", "9007199254740992"]).unwrap()],
);
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
assert!(!accepted, "should reject above MAX_SAFE_INTEGER");
assert!(
msg.contains("malformed not_before"),
"unexpected message: {msg}"
);
}
#[tokio::test]
#[ignore]
async fn test_reminder_rejected_expiration_before_not_before() {
let client = http_client();
let keys = Keys::generate();
let d_tag = uuid::Uuid::new_v4().to_string();
let event = build_reminder(
&keys,
&d_tag,
vec![
Tag::parse(["not_before", "1717000000"]).unwrap(),
Tag::parse(["expiration", "1716000000"]).unwrap(),
],
);
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
assert!(!accepted, "should reject expiration < not_before");
assert!(
msg.contains("expiration before not_before"),
"unexpected message: {msg}"
);
}
#[tokio::test]
#[ignore]
async fn test_reminder_rejected_expiration_equal_to_not_before() {
let client = http_client();
let keys = Keys::generate();
let d_tag = uuid::Uuid::new_v4().to_string();
let event = build_reminder(
&keys,
&d_tag,
vec![
Tag::parse(["not_before", "1717000000"]).unwrap(),
Tag::parse(["expiration", "1717000000"]).unwrap(),
],
);
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
assert!(!accepted, "should reject expiration == not_before");
assert!(
msg.contains("expiration before not_before"),
"unexpected message: {msg}"
);
}
#[tokio::test]
#[ignore]
async fn test_reminder_accepted_with_expiration_after_not_before() {
let client = http_client();
let keys = Keys::generate();
let d_tag = uuid::Uuid::new_v4().to_string();
let event = build_reminder(
&keys,
&d_tag,
vec![
Tag::parse(["not_before", "1717000000"]).unwrap(),
Tag::parse(["expiration", "1717000001"]).unwrap(),
],
);
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
assert!(
accepted,
"valid expiration after not_before rejected: {msg}"
);
}
#[tokio::test]
#[ignore]
async fn test_reminder_accepted_with_malformed_expiration() {
// Malformed expiration is NIP-40's concern — should not block the reminder
let client = http_client();
let keys = Keys::generate();
let d_tag = uuid::Uuid::new_v4().to_string();
let event = build_reminder(
&keys,
&d_tag,
vec![
Tag::parse(["not_before", "1717000000"]).unwrap(),
Tag::parse(["expiration", "notanumber"]).unwrap(),
],
);
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
assert!(
accepted,
"malformed expiration should not block reminder: {msg}"
);
}
// ── Read-path filtering tests (HTTP bridge) ──────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_author_can_query_own_reminders_http() {
let client = http_client();
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
let d_tag = uuid::Uuid::new_v4().to_string();
// Store a reminder
let event = build_reminder(
&keys,
&d_tag,
vec![Tag::parse(["not_before", "1717000000"]).unwrap()],
);
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
assert!(accepted, "setup failed: {msg}");
// Author queries their own reminders
let filter = Filter::new()
.kind(Kind::Custom(KIND_EVENT_REMINDER))
.author(keys.public_key());
let results = query_events_http(&client, &pubkey_hex, vec![filter]).await;
assert!(
results.iter().any(|e| {
e["tags"]
.as_array()
.unwrap_or(&vec![])
.iter()
.any(|t| t[0] == "d" && t[1] == d_tag)
}),
"author should see their own reminder"
);
}
#[tokio::test]
#[ignore]
async fn test_other_user_cannot_query_reminders_http() {
let client = http_client();
let author_keys = Keys::generate();
let other_keys = Keys::generate();
let other_pubkey_hex = other_keys.public_key().to_hex();
let d_tag = uuid::Uuid::new_v4().to_string();
// Store a reminder as author
let event = build_reminder(
&author_keys,
&d_tag,
vec![Tag::parse(["not_before", "1717000000"]).unwrap()],
);
let (accepted, msg) = submit_event_http(&client, &author_keys, &event).await;
assert!(accepted, "setup failed: {msg}");
// Other user tries to query author's reminders — should get 403
let filter = Filter::new()
.kind(Kind::Custom(KIND_EVENT_REMINDER))
.author(author_keys.public_key());
let resp = client
.post(format!("{}/query", relay_http_url()))
.header("X-Pubkey", &other_pubkey_hex)
.header("Content-Type", "application/json")
.json(&vec![filter])
.send()
.await
.expect("query events");
assert_eq!(
resp.status().as_u16(),
403,
"should get 403 for querying another author's reminders"
);
}
#[tokio::test]
#[ignore]
async fn test_author_can_count_own_reminders_http() {
let client = http_client();
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
let d_tag = uuid::Uuid::new_v4().to_string();
// Store a reminder
let event = build_reminder(
&keys,
&d_tag,
vec![Tag::parse(["not_before", "1717000000"]).unwrap()],
);
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
assert!(accepted, "setup failed: {msg}");
// Author counts their own reminders
let filter = Filter::new()
.kind(Kind::Custom(KIND_EVENT_REMINDER))
.author(keys.public_key());
let count = count_events_http(&client, &pubkey_hex, vec![filter])
.await
.expect("count should succeed for author");
assert!(count >= 1, "author should count at least 1 reminder");
}
#[tokio::test]
#[ignore]
async fn test_other_user_cannot_count_reminders_http() {
let client = http_client();
let author_keys = Keys::generate();
let other_keys = Keys::generate();
let other_pubkey_hex = other_keys.public_key().to_hex();
let d_tag = uuid::Uuid::new_v4().to_string();
// Store a reminder as author
let event = build_reminder(
&author_keys,
&d_tag,
vec![Tag::parse(["not_before", "1717000000"]).unwrap()],
);
let (accepted, msg) = submit_event_http(&client, &author_keys, &event).await;
assert!(accepted, "setup failed: {msg}");
// Other user tries to count author's reminders — should get 403
let filter = Filter::new()
.kind(Kind::Custom(KIND_EVENT_REMINDER))
.author(author_keys.public_key());
let result = count_events_http(&client, &other_pubkey_hex, vec![filter]).await;
assert!(
result.is_err(),
"should get error counting another author's reminders"
);
let (status, _) = result.unwrap_err();
assert_eq!(status, 403);
}
// ── Read-path filtering tests (WebSocket) ────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_author_can_subscribe_to_own_reminders_ws() {
let url = relay_url();
let keys = Keys::generate();
let d_tag = uuid::Uuid::new_v4().to_string();
// Store a reminder via HTTP first
let client = http_client();
let event = build_reminder(
&keys,
&d_tag,
vec![Tag::parse(["not_before", "1717000000"]).unwrap()],
);
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
assert!(accepted, "setup failed: {msg}");
// Subscribe via WebSocket as the author
let mut ws = BuzzTestClient::connect(&url, &keys)
.await
.expect("connect");
let sid = sub_id("author-read");
let filter = Filter::new()
.kind(Kind::Custom(KIND_EVENT_REMINDER))
.author(keys.public_key());
ws.subscribe(&sid, vec![filter]).await.expect("subscribe");
let events = ws
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect events");
assert!(
events.iter().any(|e| {
e.tags.iter().any(|t| {
t.as_slice().len() >= 2 && t.as_slice()[0] == "d" && t.as_slice()[1] == d_tag
})
}),
"author should receive their own reminder via WS subscription"
);
ws.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_other_user_subscription_closed_for_author_only_kind_ws() {
let url = relay_url();
let author_keys = Keys::generate();
let other_keys = Keys::generate();
let d_tag = uuid::Uuid::new_v4().to_string();
// Store a reminder as author
let client = http_client();
let event = build_reminder(
&author_keys,
&d_tag,
vec![Tag::parse(["not_before", "1717000000"]).unwrap()],
);
let (accepted, msg) = submit_event_http(&client, &author_keys, &event).await;
assert!(accepted, "setup failed: {msg}");
// Other user tries to subscribe to author's reminders
let mut ws = BuzzTestClient::connect(&url, &other_keys)
.await
.expect("connect");
let sid = sub_id("other-read");
let filter = Filter::new()
.kind(Kind::Custom(KIND_EVENT_REMINDER))
.author(author_keys.public_key());
ws.subscribe(&sid, vec![filter]).await.expect("subscribe");
// Should get CLOSED with "restricted:" message
let msg = ws
.recv_event(Duration::from_secs(5))
.await
.expect("recv response");
match msg {
RelayMessage::Closed {
subscription_id,
message,
} => {
assert_eq!(subscription_id, sid);
assert!(
message.contains("restricted:") || message.contains("author-only"),
"expected restricted message, got: {message}"
);
}
other => panic!("expected CLOSED, got: {other:?}"),
}
ws.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_mixed_kind_filter_omits_other_authors_reminders_ws() {
let url = relay_url();
let author_keys = Keys::generate();
let reader_keys = Keys::generate();
// Create a channel so the reader can send a kind:9 message
let channel = {
let client = reqwest::Client::new();
let pubkey_hex = reader_keys.public_key().to_hex();
let channel_uuid = uuid::Uuid::new_v4();
let channel_name = format!("niper-e2e-{}", channel_uuid);
let event = EventBuilder::new(Kind::Custom(9007), "")
.tags(vec![
Tag::parse(["h", &channel_uuid.to_string()]).unwrap(),
Tag::parse(["name", &channel_name]).unwrap(),
Tag::parse(["channel_type", "stream"]).unwrap(),
Tag::parse(["visibility", "open"]).unwrap(),
])
.sign_with_keys(&reader_keys)
.unwrap();
let resp = client
.post(format!("{}/events", relay_http_url()))
.header("X-Pubkey", &pubkey_hex)
.header("Content-Type", "application/json")
.body(serde_json::to_string(&event).unwrap())
.send()
.await
.expect("create channel");
assert!(resp.status().is_success());
channel_uuid.to_string()
};
// Author stores a reminder
let client = http_client();
let d_tag = uuid::Uuid::new_v4().to_string();
let reminder = build_reminder(
&author_keys,
&d_tag,
vec![Tag::parse(["not_before", "1717000000"]).unwrap()],
);
let (accepted, msg) = submit_event_http(&client, &author_keys, &reminder).await;
assert!(accepted, "reminder setup failed: {msg}");
// Reader sends a kind:9 message in the channel
let mut ws_reader = BuzzTestClient::connect(&url, &reader_keys)
.await
.expect("connect reader");
let unique_content = format!("mixed-filter-test-{}", uuid::Uuid::new_v4());
let ok = ws_reader
.send_text_message(&reader_keys, &channel, &unique_content, 9)
.await
.expect("send message");
assert!(ok.accepted, "message rejected: {}", ok.message);
// Reader subscribes with a mixed-kind filter (kind:9 + kind:30300)
// The filter doesn't exclusively target author-only kinds, so it passes
// the pre-filter gate. But per-event filtering should omit the reminder.
let sid = sub_id("mixed-kind");
let filter = Filter::new().kinds(vec![Kind::Custom(9), Kind::Custom(KIND_EVENT_REMINDER)]);
ws_reader
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
let events = ws_reader
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect events");
// Should see the kind:9 message but NOT the author's reminder
let has_message = events.iter().any(|e| e.content == unique_content);
let has_reminder = events
.iter()
.any(|e| e.kind == Kind::Custom(KIND_EVENT_REMINDER));
assert!(has_message, "reader should see their own kind:9 message");
assert!(
!has_reminder,
"reader should NOT see another author's reminders in mixed-kind results"
);
ws_reader.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_reminder_not_before_zero_accepted() {
// Edge case: not_before=0 is valid per spec
let client = http_client();
let keys = Keys::generate();
let d_tag = uuid::Uuid::new_v4().to_string();
let event = build_reminder(
&keys,
&d_tag,
vec![Tag::parse(["not_before", "0"]).unwrap()],
);
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
assert!(accepted, "not_before=0 should be valid: {msg}");
}
#[tokio::test]
#[ignore]
async fn test_reminder_not_before_max_safe_integer_accepted() {
let client = http_client();
let keys = Keys::generate();
let d_tag = uuid::Uuid::new_v4().to_string();
let event = build_reminder(
&keys,
&d_tag,
vec![Tag::parse(["not_before", "9007199254740991"]).unwrap()],
);
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
assert!(accepted, "MAX_SAFE_INTEGER should be valid: {msg}");
}
#[tokio::test]
#[ignore]
async fn test_reminder_replacement_semantics() {
// Verify parameterized replaceable behavior: same (pubkey, kind, d) replaces
let client = http_client();
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
let d_tag = uuid::Uuid::new_v4().to_string();
// First version
let event1 = build_reminder(
&keys,
&d_tag,
vec![Tag::parse(["not_before", "1717000000"]).unwrap()],
);
let (accepted, msg) = submit_event_http(&client, &keys, &event1).await;
assert!(accepted, "first version rejected: {msg}");
// Small delay to ensure created_at differs
tokio::time::sleep(Duration::from_millis(100)).await;
// Second version (snooze — later not_before)
let event2 = build_reminder(
&keys,
&d_tag,
vec![Tag::parse(["not_before", "1718000000"]).unwrap()],
);
let (accepted, msg) = submit_event_http(&client, &keys, &event2).await;
assert!(accepted, "replacement rejected: {msg}");
// Query should return only the latest version
let filter = Filter::new()
.kind(Kind::Custom(KIND_EVENT_REMINDER))
.author(keys.public_key());
let results = query_events_http(&client, &pubkey_hex, vec![filter]).await;
let matching: Vec<&Value> = results
.iter()
.filter(|e| {
e["tags"]
.as_array()
.unwrap_or(&vec![])
.iter()
.any(|t| t[0] == "d" && t[1] == d_tag)
})
.collect();
assert_eq!(
matching.len(),
1,
"should have exactly one version after replacement, got {}",
matching.len()
);
// Verify it's the newer one (has not_before=1718000000)
let reminder = matching[0];
let has_new_not_before = reminder["tags"]
.as_array()
.unwrap()
.iter()
.any(|t| t[0] == "not_before" && t[1] == "1718000000");
assert!(
has_new_not_before,
"should be the replacement version with not_before=1718000000"
);
}
// ── Fan-out isolation, WS search isolation, WS COUNT tests ───────────────────
#[tokio::test]
#[ignore]
async fn test_fanout_isolation_other_user_does_not_receive_reminder() {
let url = relay_url();
let author_keys = Keys::generate();
let other_keys = Keys::generate();
// Connect user B and open a wildcard subscription BEFORE the reminder is published.
let mut ws_other = BuzzTestClient::connect(&url, &other_keys)
.await
.expect("connect other");
let sid = sub_id("fanout-isolation");
// Subscribe to all kinds (wildcard) — no kind filter, no channel filter.
let filter = Filter::new();
ws_other
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
// Drain historical events (EOSE)
let _historical = ws_other
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("drain historical");
// User A publishes a reminder
let client = http_client();
let d_tag = uuid::Uuid::new_v4().to_string();
let reminder = build_reminder(
&author_keys,
&d_tag,
vec![Tag::parse(["not_before", "1717000000"]).unwrap()],
);
let (accepted, msg) = submit_event_http(&client, &author_keys, &reminder).await;
assert!(accepted, "reminder setup failed: {msg}");
// Wait briefly for fan-out to propagate
tokio::time::sleep(Duration::from_millis(500)).await;
// User B should NOT receive the reminder via fan-out.
// Try to receive — should timeout (no event delivered).
let result = ws_other.recv_event(Duration::from_secs(2)).await;
match result {
Err(buzz_test_client::TestClientError::Timeout) => {
// Expected: no event delivered to non-author
}
Ok(RelayMessage::Event { event, .. }) => {
assert_ne!(
event.kind,
nostr::Kind::Custom(KIND_EVENT_REMINDER),
"user B should NOT receive author-only reminder via fan-out"
);
}
Ok(_) => {
// Other message types (NOTICE, etc.) are fine
}
Err(e) => panic!("unexpected error: {e}"),
}
ws_other.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_ws_search_isolation_other_user_cannot_find_reminder() {
let url = relay_url();
let author_keys = Keys::generate();
let other_keys = Keys::generate();
// Store a reminder as author with a unique searchable content marker
let client = http_client();
let d_tag = uuid::Uuid::new_v4().to_string();
let reminder = build_reminder(
&author_keys,
&d_tag,
vec![Tag::parse(["not_before", "1717000000"]).unwrap()],
);
let (accepted, msg) = submit_event_http(&client, &author_keys, &reminder).await;
assert!(accepted, "reminder setup failed: {msg}");
// User B does a NIP-50 search with kinds including 30300
let mut ws_other = BuzzTestClient::connect(&url, &other_keys)
.await
.expect("connect other");
let sid = sub_id("search-isolation");
// Build a search filter that includes kind:30300
let filter = Filter::new()
.kinds(vec![
nostr::Kind::Custom(9),
nostr::Kind::Custom(KIND_EVENT_REMINDER),
])
.search("nip44-ciphertext-placeholder");
ws_other
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
let events = ws_other
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect events");
// User B should NOT see any kind:30300 events from other authors
let has_reminder = events
.iter()
.any(|e| e.kind == nostr::Kind::Custom(KIND_EVENT_REMINDER));
assert!(
!has_reminder,
"user B should NOT see another author's reminders in NIP-50 search results"
);
ws_other.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_ws_count_returns_zero_for_other_users_reminders() {
let url = relay_url();
let author_keys = Keys::generate();
let other_keys = Keys::generate();
// Store a reminder as author
let client = http_client();
let d_tag = uuid::Uuid::new_v4().to_string();
let reminder = build_reminder(
&author_keys,
&d_tag,
vec![Tag::parse(["not_before", "1717000000"]).unwrap()],
);
let (accepted, msg) = submit_event_http(&client, &author_keys, &reminder).await;
assert!(accepted, "reminder setup failed: {msg}");
// User B sends a COUNT for kind:30300 targeting author A — should get CLOSED
// with "restricted:" because the filter exclusively targets author-only kinds
// with another user's pubkey.
let mut ws_other = BuzzTestClient::connect(&url, &other_keys)
.await
.expect("connect other");
let sid = sub_id("ws-count");
let filter = Filter::new()
.kind(nostr::Kind::Custom(KIND_EVENT_REMINDER))
.author(author_keys.public_key());
// Send raw COUNT message
let count_msg = serde_json::json!(["COUNT", sid, filter]);
ws_other.send_raw(&count_msg).await.expect("send COUNT");
// Should get CLOSED with "restricted:" message
let msg = ws_other
.recv_event(Duration::from_secs(5))
.await
.expect("recv response");
match msg {
RelayMessage::Closed {
subscription_id,
message,
} => {
assert_eq!(subscription_id, sid);
assert!(
message.contains("restricted:"),
"expected restricted message for COUNT on another author's reminders, got: {message}"
);
}
other => panic!("expected CLOSED for COUNT on another author's reminders, got: {other:?}"),
}
ws_other.disconnect().await.expect("disconnect");
}