mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
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:
committed by
Will Pfleger
parent
a101fd6ad3
commit
0b3bed62bf
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
927
crates/buzz-test-client/tests/e2e_event_reminder.rs
Normal file
927
crates/buzz-test-client/tests/e2e_event_reminder.rs
Normal 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");
|
||||
}
|
||||
Reference in New Issue
Block a user