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