Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
72 changes: 64 additions & 8 deletions crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) ──
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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) => {
Expand All @@ -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) => {
Expand All @@ -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,
Expand All @@ -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) => {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand Down
51 changes: 45 additions & 6 deletions crates/buzz-relay/src/handlers/count.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand All @@ -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) => {
Expand All @@ -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) => {
Expand All @@ -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,
Expand All @@ -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) => {
Expand Down
19 changes: 16 additions & 3 deletions crates/buzz-relay/src/handlers/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -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())
Expand Down
Loading