feat: event-driven channel discovery via membership notifications (kind:44100/44101) (#65)

This commit is contained in:
tlongwell-block
2026-03-14 23:40:06 -04:00
committed by GitHub
parent 3c68325c67
commit 9ca60737eb
13 changed files with 1157 additions and 16 deletions
+90 -1
View File
@@ -29,7 +29,7 @@ pub enum ConfigError {
// ── Enums ─────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, clap::ValueEnum)]
#[derive(Debug, Clone, PartialEq, clap::ValueEnum)]
pub enum SubscribeMode {
Mentions,
All,
@@ -443,6 +443,95 @@ pub fn resolve_channel_filters(
result
}
/// Resolve the subscription filter for a single dynamically-discovered channel.
///
/// In Mentions/All mode, `channels_override` (--channels) is enforced — the agent
/// won't subscribe to channels outside the operator's allowlist. In Config mode,
/// `--channels` is ignored (per CLI contract) and rule-matching determines scope.
///
/// Returns `None` when the channel is outside the agent's configured scope:
/// - Mentions/All: channel not in `channels_override` (if set)
/// - Config: no subscription rules match the channel
pub fn resolve_dynamic_channel_filter(
config: &Config,
channel_id: Uuid,
rules: &[crate::filter::SubscriptionRule],
) -> Option<ChannelFilter> {
use sprout_core::kind::{
KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED,
};
// In Mentions/All mode, if the operator explicitly constrained channels
// with --channels, only allow dynamic subscription to channels in that
// allowlist. Config mode ignores --channels (per CLI contract) and uses
// rule-matching instead.
if config.subscribe_mode != SubscribeMode::Config {
if let Some(ref overrides) = config.channels_override {
let allowed = overrides
.iter()
.any(|s| s.parse::<Uuid>().ok() == Some(channel_id));
if !allowed {
return None;
}
}
}
match config.subscribe_mode {
SubscribeMode::Mentions => Some(ChannelFilter {
kinds: Some(config.kinds_override.clone().unwrap_or_else(|| {
vec![
KIND_STREAM_MESSAGE,
KIND_WORKFLOW_APPROVAL_REQUESTED,
KIND_STREAM_REMINDER,
]
})),
require_mention: !config.no_mention_filter,
}),
SubscribeMode::All => Some(ChannelFilter {
kinds: config.kinds_override.clone(),
require_mention: false,
}),
SubscribeMode::Config => {
// Same merge logic as resolve_channel_filters() Config branch:
// evaluate ALL rules against this specific channel (including
// channel-specific rules, not just ChannelScope::All).
let mut merged_kinds: Option<Vec<u32>> = Some(vec![]);
let mut require_mention = true;
let mut has_rule = false;
for rule in rules {
if !rule_applies_to_channel(rule, channel_id) {
continue;
}
has_rule = true;
if rule.kinds.is_empty() {
merged_kinds = None;
} else if let Some(ref mut kinds) = merged_kinds {
for k in &rule.kinds {
if !kinds.contains(k) {
kinds.push(*k);
}
}
}
if !rule.require_mention {
require_mention = false;
}
}
if !has_rule {
// No rules match — don't subscribe. Consistent with
// resolve_channel_filters() which omits unmatched channels.
return None;
}
Some(ChannelFilter {
kinds: merged_kinds,
require_mention,
})
}
}
}
fn rule_applies_to_channel(rule: &SubscriptionRule, channel_id: Uuid) -> bool {
use crate::filter::ChannelScope;
match &rule.channels {
+65 -1
View File
@@ -21,10 +21,12 @@ use pool::{AgentPool, OwnedAgent, PromptContext, PromptOutcome, PromptResult, Pr
use queue::{EventQueue, QueuedEvent};
use relay::HarnessRelay;
use sprout_core::kind::{
KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED,
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE,
KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED,
};
use tokio::sync::watch;
use tracing_subscriber::EnvFilter;
use uuid::Uuid;
#[tokio::main]
async fn main() -> Result<()> {
@@ -65,6 +67,13 @@ async fn main() -> Result<()> {
tracing::info!("connected to relay at {}", config.relay_url);
// ── Step 2b: Subscribe to membership notifications ────────────────────────
relay
.subscribe_membership_notifications()
.await
.map_err(|e| anyhow::anyhow!("membership notification subscribe error: {e}"))?;
tracing::info!("subscribed to membership notifications");
// ── Step 3: Discover channels and build subscription rules ────────────────
let channels = relay
.discover_channels()
@@ -174,6 +183,12 @@ async fn main() -> Result<()> {
});
}
// Track the newest membership notification timestamp per channel so that
// replayed events (returned in DESC order on reconnect) don't override the
// correct final state. The first event seen per channel is the newest; any
// older duplicate for the same channel is skipped.
let mut membership_newest_ts: HashMap<Uuid, u64> = HashMap::new();
// ── Step 8: Main orchestration loop ──────────────────────────────────────
//
// Branches 1 & 2 both need to borrow `pool`, but they access different
@@ -202,6 +217,55 @@ async fn main() -> Result<()> {
let _ = result_rx; // end split borrow before relay handling
match sprout_event {
Some(sprout_event) => {
let kind_u32 = sprout_event.event.kind.as_u16() as u32;
// ── Membership notification handling ──────────────
if kind_u32 == KIND_MEMBER_ADDED_NOTIFICATION
|| kind_u32 == KIND_MEMBER_REMOVED_NOTIFICATION
{
let ch = sprout_event.channel_id;
let ts = sprout_event.event.created_at.as_u64();
// Skip stale membership events: on reconnect the relay
// replays events newest-first, so the first event per
// channel is authoritative. Any later (older) event for
// the same channel is outdated and must be ignored.
let dominated = membership_newest_ts
.get(&ch)
.is_some_and(|&newest| ts < newest);
if dominated {
tracing::debug!(
channel_id = %ch,
kind = kind_u32,
ts,
"skipping stale membership notification (newer already processed)"
);
continue;
}
membership_newest_ts
.entry(ch)
.and_modify(|v| *v = (*v).max(ts))
.or_insert(ts);
if kind_u32 == KIND_MEMBER_ADDED_NOTIFICATION {
if let Some(filter) = config::resolve_dynamic_channel_filter(&config, ch, &rules) {
tracing::info!(channel_id = %ch, "membership notification: subscribing to new channel");
if let Err(e) = relay.subscribe_channel(ch, filter).await {
tracing::warn!("failed to subscribe to new channel {ch}: {e}");
}
} else {
tracing::debug!(channel_id = %ch, "membership notification: no matching rules — skipping");
}
} else {
tracing::info!(channel_id = %ch, "membership notification: unsubscribing from channel");
if let Err(e) = relay.unsubscribe_channel(ch).await {
tracing::warn!("failed to unsubscribe from channel {ch}: {e}");
}
}
continue;
}
// ── End membership notification handling ──────────
if config.ignore_self && sprout_event.event.pubkey.to_hex() == pubkey_hex {
tracing::debug!(channel_id = %sprout_event.channel_id, "dropping self-authored event");
continue;
+154 -1
View File
@@ -38,6 +38,7 @@ const AUTH_TIMEOUT: Duration = Duration::from_secs(5);
use futures_util::{SinkExt, StreamExt};
use nostr::{Event, EventBuilder, Keys, Kind, Tag, Url as NostrUrl};
use serde_json::{json, Value};
use sprout_core::kind::{KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION};
use tokio::sync::mpsc;
use tokio::time::timeout;
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
@@ -122,6 +123,9 @@ enum RelayMessage {
// ── Commands sent from HarnessRelay to the background task ───────────────────
/// Subscription ID for the global membership notification subscription.
const MEMBERSHIP_NOTIF_SUB_ID: &str = "membership-notif";
/// Commands sent from `HarnessRelay` to the background WebSocket task.
enum RelayCommand {
/// Subscribe to a channel (sends a NIP-01 REQ) with the given filter.
@@ -136,6 +140,8 @@ enum RelayCommand {
Reconnect,
/// Shut down the background task.
Shutdown,
/// Subscribe to global membership notifications.
SubscribeMembership,
}
// ── WebSocket stream type alias ───────────────────────────────────────────────
@@ -283,6 +289,15 @@ impl HarnessRelay {
Ok(())
}
/// Subscribe to membership notifications for this agent.
pub async fn subscribe_membership_notifications(&mut self) -> Result<(), RelayError> {
self.cmd_tx
.send(RelayCommand::SubscribeMembership)
.await
.map_err(|_| RelayError::ConnectionClosed)?;
Ok(())
}
/// Unsubscribe from a channel.
#[allow(dead_code)]
pub async fn unsubscribe_channel(&mut self, channel_id: Uuid) -> Result<(), RelayError> {
@@ -335,6 +350,15 @@ struct BgState {
seen_ids: HashSet<String>,
/// Per-channel filter used on subscribe (for resubscribe after reconnect).
active_filters: HashMap<Uuid, ChannelFilter>,
/// Oldest timestamp of a membership notification that was dropped due to
/// backpressure. If set, reconnect replay must start from this timestamp
/// (minus skew) to re-deliver the lost event. Reset on successful reconnect.
membership_dropped_since: Option<u64>,
/// Newest successfully-enqueued membership notification timestamp.
/// Used as the `since` for reconnect replay when no events were dropped.
membership_last_seen: Option<u64>,
/// Whether the membership notification subscription is active.
membership_sub_active: bool,
}
impl BgState {
@@ -344,6 +368,9 @@ impl BgState {
last_seen: HashMap::new(),
seen_ids: HashSet::new(),
active_filters: HashMap::new(),
membership_dropped_since: None,
membership_last_seen: None,
membership_sub_active: false,
}
}
@@ -475,6 +502,22 @@ async fn run_background_task(
debug!("unsubscribed from channel {channel_id}");
}
}
Some(RelayCommand::SubscribeMembership) => {
let _ =
send_membership_subscribe(&mut ws, &agent_pubkey_hex, None).await;
state.membership_sub_active = true;
// Seed the watermark so reconnect replays from this point
// rather than falling back to since=now (which could miss
// notifications during the reconnect gap).
if state.membership_last_seen.is_none() {
state.membership_last_seen = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
);
}
}
Some(RelayCommand::Reconnect) => {
// Reconnect command already consumed — skip the drain loop.
wait_for_reconnect(
@@ -527,7 +570,39 @@ async fn handle_ws_message(
subscription_id,
event,
} => {
if let Some(channel_id) = channel_id_from_sub_id(&subscription_id) {
if subscription_id == MEMBERSHIP_NOTIF_SUB_ID {
// Membership notification — extract channel UUID from h tag.
let channel_uuid = match extract_h_tag_uuid(&event) {
Some(uuid) => uuid,
None => {
warn!("membership notification missing h tag — dropping");
return true;
}
};
let ts = event.created_at.as_u64();
let sprout_event = SproutEvent {
channel_id: channel_uuid,
event: *event,
};
match event_tx.try_send(Some(sprout_event)) {
Ok(()) => {
state.membership_last_seen =
Some(state.membership_last_seen.unwrap_or(0).max(ts));
}
Err(mpsc::error::TrySendError::Full(_)) => {
// Track the oldest dropped timestamp so reconnect
// replay starts early enough to re-deliver it.
state.membership_dropped_since =
Some(state.membership_dropped_since.map_or(ts, |d| d.min(ts)));
warn!(
channel_id = %channel_uuid,
ts,
"membership notification dropped (backpressure) — will replay from {ts} on reconnect"
);
}
Err(mpsc::error::TrySendError::Closed(_)) => return false,
}
} else if let Some(channel_id) = channel_id_from_sub_id(&subscription_id) {
if state.record_event(channel_id, &event) {
let sprout_event = SproutEvent {
channel_id,
@@ -635,6 +710,9 @@ async fn wait_for_reconnect(
state.active_subscriptions.remove(&channel_id);
state.active_filters.remove(&channel_id);
}
Some(RelayCommand::SubscribeMembership) => {
state.membership_sub_active = true;
}
}
}
}
@@ -665,6 +743,24 @@ async fn wait_for_reconnect(
}
}
// Resubscribe to membership notifications if active.
// Use the oldest of (dropped, last_seen) so dropped events are replayed.
if state.membership_sub_active {
let replay_since =
match (state.membership_dropped_since, state.membership_last_seen) {
(Some(d), Some(l)) => Some(d.min(l)),
(Some(d), None) => Some(d),
(None, Some(l)) => Some(l),
(None, None) => None,
};
let sent = send_membership_subscribe(ws, agent_pubkey_hex, replay_since).await;
if sent {
// Only clear drop tracker if the REQ was actually sent —
// if send failed, retain it so the next reconnect retries.
state.membership_dropped_since = None;
}
}
return;
}
Err(e) => {
@@ -745,6 +841,63 @@ async fn send_subscribe(
}
}
/// Send a NIP-01 REQ for membership notifications (kind:44100+44101, global, #p=[agent_pubkey]).
/// Returns `true` if the REQ was successfully written to the WebSocket.
async fn send_membership_subscribe(
ws: &mut WsStream,
agent_pubkey_hex: &str,
since: Option<u64>,
) -> bool {
let mut req_filter = serde_json::Map::new();
req_filter.insert(
"kinds".into(),
json!([
KIND_MEMBER_ADDED_NOTIFICATION,
KIND_MEMBER_REMOVED_NOTIFICATION
]),
);
req_filter.insert("#p".into(), json!([agent_pubkey_hex]));
let since_ts = match since {
Some(ts) => ts.saturating_sub(SINCE_SKEW_SECS),
None => std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
};
req_filter.insert("since".into(), json!(since_ts));
let req = json!(["REQ", MEMBERSHIP_NOTIF_SUB_ID, Value::Object(req_filter)]);
match serde_json::to_string(&req) {
Ok(text) => match ws.send(Message::Text(text.into())).await {
Ok(()) => {
debug!("subscribed to membership notifications (since={since_ts})");
true
}
Err(e) => {
warn!("failed to send membership notification REQ: {e}");
false
}
},
Err(e) => {
warn!("failed to serialize membership notification REQ: {e}");
false
}
}
}
/// Extract a channel UUID from the h tag of a Nostr event.
fn extract_h_tag_uuid(event: &nostr::Event) -> Option<Uuid> {
event.tags.iter().find_map(|tag| {
let tag_vec = tag.as_slice();
if tag_vec.len() >= 2 && tag_vec[0] == "h" {
tag_vec[1].parse::<Uuid>().ok()
} else {
None
}
})
}
/// Build and send a NIP-42 AUTH response event.
async fn send_auth_response(
ws: &mut WsStream,
+10
View File
@@ -132,6 +132,14 @@ pub const KIND_SUBSCRIPTION_PAUSED: u32 = 44003;
/// A paused subscription was resumed.
pub const KIND_SUBSCRIPTION_RESUMED: u32 = 44004;
/// Relay-signed notification: the target pubkey was added to a channel.
/// Stored globally (channel_id = None) with p-tag = target, h-tag = channel UUID.
pub const KIND_MEMBER_ADDED_NOTIFICATION: u32 = 44100;
/// Relay-signed notification: the target pubkey was removed from a channel.
/// Stored globally (channel_id = None) with p-tag = target, h-tag = channel UUID.
pub const KIND_MEMBER_REMOVED_NOTIFICATION: u32 = 44101;
// Forum / social (4500045999)
// V1 used addressable range (3000130003) — wrong.
/// A forum post (thread root).
@@ -247,6 +255,8 @@ pub const ALL_KINDS: &[u32] = &[
KIND_SUBSCRIPTION_MATCHED,
KIND_SUBSCRIPTION_PAUSED,
KIND_SUBSCRIPTION_RESUMED,
KIND_MEMBER_ADDED_NOTIFICATION,
KIND_MEMBER_REMOVED_NOTIFICATION,
KIND_FORUM_POST,
KIND_FORUM_VOTE,
KIND_FORUM_COMMENT,
+11
View File
@@ -160,6 +160,17 @@ pub async fn create_channel(
{
tracing::warn!(channel_id = %channel.id, error = %e, "NIP-29 discovery emission failed");
}
if let Err(e) = crate::handlers::side_effects::emit_membership_notification(
&state,
channel.id,
&pubkey_bytes,
&pubkey_bytes,
sprout_core::kind::KIND_MEMBER_ADDED_NOTIFICATION,
)
.await
{
tracing::warn!("membership notification for channel creator failed: {e}");
}
Ok((
StatusCode::CREATED,
+57
View File
@@ -21,8 +21,10 @@ use serde::Deserialize;
use sprout_db::channel::MemberRole;
use uuid::Uuid;
use crate::handlers::side_effects::emit_membership_notification;
use crate::handlers::side_effects::emit_system_message;
use crate::state::AppState;
use sprout_core::kind::{KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION};
use super::{
api_error, check_channel_access, check_token_channel_access, extract_auth_context, forbidden,
@@ -143,6 +145,17 @@ pub async fn add_members(
{
tracing::warn!("Failed to emit system message: {e}");
}
if let Err(e) = emit_membership_notification(
&state,
channel_id,
&pubkey_bytes,
&actor_bytes,
KIND_MEMBER_ADDED_NOTIFICATION,
)
.await
{
tracing::warn!("membership notification failed: {e}");
}
added.push(hex_pk.clone());
}
Err(e) => {
@@ -224,6 +237,17 @@ pub async fn add_members(
{
tracing::warn!("Failed to emit system message: {e}");
}
if let Err(e) = emit_membership_notification(
&state,
channel_id,
&pubkey_bytes,
&actor_bytes,
KIND_MEMBER_ADDED_NOTIFICATION,
)
.await
{
tracing::warn!("membership notification failed: {e}");
}
added.push(hex_pk.clone());
}
Err(e) => {
@@ -329,6 +353,17 @@ pub async fn remove_member(
{
tracing::warn!("Failed to emit system message: {e}");
}
if let Err(e) = emit_membership_notification(
&state,
channel_id,
&target_bytes,
&actor_bytes,
KIND_MEMBER_REMOVED_NOTIFICATION,
)
.await
{
tracing::warn!("membership notification failed: {e}");
}
Ok(Json(serde_json::json!({ "removed": true })))
}
@@ -452,6 +487,17 @@ pub async fn join_channel(
{
tracing::warn!("Failed to emit system message: {e}");
}
if let Err(e) = emit_membership_notification(
&state,
channel_id,
&pubkey_bytes,
&pubkey_bytes,
KIND_MEMBER_ADDED_NOTIFICATION,
)
.await
{
tracing::warn!("membership notification failed: {e}");
}
Ok(Json(serde_json::json!({
"joined": true,
@@ -522,6 +568,17 @@ pub async fn leave_channel(
{
tracing::warn!("Failed to emit system message: {e}");
}
if let Err(e) = emit_membership_notification(
&state,
channel_id,
&pubkey_bytes,
&pubkey_bytes,
KIND_MEMBER_REMOVED_NOTIFICATION,
)
.await
{
tracing::warn!("membership notification failed: {e}");
}
Ok(Json(serde_json::json!({ "left": true })))
}
+1 -1
View File
@@ -86,7 +86,7 @@ impl Config {
let max_concurrent_handlers = std::env::var("SPROUT_MAX_CONCURRENT_HANDLERS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(64);
.unwrap_or(1024);
let send_buffer_size = std::env::var("SPROUT_SEND_BUFFER")
.ok()
+12 -1
View File
@@ -10,7 +10,8 @@ use sprout_audit::{AuditAction, NewAuditEntry};
use sprout_core::event::StoredEvent;
use sprout_core::kind::{
event_kind_u32, is_ephemeral, is_workflow_execution_kind, KIND_AUTH, KIND_CANVAS,
KIND_DELETION, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_PRESENCE_UPDATE,
KIND_DELETION, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE,
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_PRESENCE_UPDATE,
KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF,
KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED,
KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER,
@@ -187,6 +188,16 @@ pub async fn handle_event(event: Event, conn: Arc<ConnectionState>, state: Arc<A
return;
}
// Membership notification events are relay-signed only — reject client submissions.
if kind_u32 == KIND_MEMBER_ADDED_NOTIFICATION || kind_u32 == KIND_MEMBER_REMOVED_NOTIFICATION {
conn.send(RelayMessage::ok(
&event_id_hex,
false,
"invalid: membership notifications are relay-signed only",
));
return;
}
if is_ephemeral(kind_u32) {
handle_ephemeral_event(
event,
+41 -1
View File
@@ -5,8 +5,10 @@ use std::sync::Arc;
use tracing::{debug, warn};
use hex;
use nostr::Filter;
use sprout_core::filter::filters_match;
use sprout_core::kind::{KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION};
use sprout_db::EventQuery;
use sprout_auth::Scope;
@@ -16,7 +18,7 @@ use crate::protocol::RelayMessage;
use crate::state::AppState;
const MAX_HISTORICAL_LIMIT: i64 = 500;
const MAX_SUBSCRIPTIONS: usize = 100;
const MAX_SUBSCRIPTIONS: usize = 1024;
/// Handle a REQ message: register the subscription, deliver historical events, then send EOSE.
pub async fn handle_req(
@@ -75,6 +77,44 @@ pub async fn handle_req(
let channel_id = extract_channel_id_from_filters(&filters);
// Enforce #p filter for membership notification subscriptions.
//
// Only applies to GLOBAL subscriptions (channel_id = None). Channel-scoped
// subscriptions can never receive globally-stored membership events — the
// fan_out() invariant in subscription.rs prevents it.
//
// We use the resolved subscription scope (channel_id) rather than per-filter
// #h presence to prevent mixed-filter bypass: a client could send
// [{#h:..., kinds:[44100]}, {authors:[...]}] which resolves to global scope
// but would skip the #p check if we only looked at per-filter #h tags.
if channel_id.is_none() {
let authed_pubkey_hex = hex::encode(&pubkey_bytes);
for filter in &filters {
let can_match_membership = filter.kinds.as_ref().is_none_or(|ks| {
ks.iter().any(|k| {
let ku = k.as_u16() as u32;
ku == KIND_MEMBER_ADDED_NOTIFICATION || ku == KIND_MEMBER_REMOVED_NOTIFICATION
})
});
if can_match_membership {
let p_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P);
// ALL #p values must match the authenticated pubkey — prevents
// a client from sneaking in a victim's pubkey alongside their own
// (e.g. #p:[my_key, victim_key]) to receive the victim's notifications.
let has_matching_p = filter.generic_tags.get(&p_tag).is_some_and(|values| {
!values.is_empty() && values.iter().all(|v| *v == authed_pubkey_hex)
});
if !has_matching_p {
conn.send(RelayMessage::closed(
&sub_id,
"restricted: membership notifications require #p matching your pubkey",
));
return;
}
}
}
}
// Check channel access BEFORE registering the subscription.
// Registering first would allow non-members to receive live fan-out events
// from private channels before the access check fires.
@@ -7,8 +7,8 @@ use tracing::{info, warn};
use uuid::Uuid;
use sprout_core::kind::{
event_kind_u32, KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA,
KIND_REACTION,
event_kind_u32, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION,
KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA, KIND_REACTION,
};
use sprout_db::channel::MemberRole;
@@ -324,6 +324,83 @@ pub async fn emit_system_message(
Ok(())
}
/// Emit a relay-signed membership notification event stored globally (channel_id = None).
///
/// kind:44100 = member added, kind:44101 = member removed.
/// The p tag addresses the target pubkey; the h tag carries the channel UUID as metadata.
/// Stored with channel_id = None so global subscribers receive it via slow-path fan-out.
pub async fn emit_membership_notification(
state: &Arc<AppState>,
channel_id: Uuid,
target_pubkey: &[u8],
actor_pubkey: &[u8],
notification_kind: u32,
) -> anyhow::Result<()> {
let target_hex = hex::encode(target_pubkey);
let actor_hex = hex::encode(actor_pubkey);
let channel_id_str = channel_id.to_string();
let p_tag = Tag::parse(&["p", &target_hex])
.map_err(|e| anyhow::anyhow!("failed to build p tag: {e}"))?;
let h_tag = Tag::parse(&["h", &channel_id_str])
.map_err(|e| anyhow::anyhow!("failed to build h tag: {e}"))?;
let event_type = match notification_kind {
KIND_MEMBER_ADDED_NOTIFICATION => "member_added",
KIND_MEMBER_REMOVED_NOTIFICATION => "member_removed",
_ => {
return Err(anyhow::anyhow!(
"invalid notification kind: {notification_kind}"
))
}
};
let content = serde_json::json!({
"type": event_type,
"channel_id": channel_id_str,
"actor": actor_hex,
})
.to_string();
let event = EventBuilder::new(
Kind::Custom(notification_kind as u16),
content,
[p_tag, h_tag],
)
.sign_with_keys(&state.relay_keypair)
.map_err(|e| anyhow::anyhow!("failed to sign membership notification: {e}"))?;
// Store with channel_id = None → globally scoped, reachable by global subscribers.
let (stored, was_inserted) = state.db.insert_event(&event, None).await?;
if !was_inserted {
return Ok(());
}
// Fan-out only — skip search indexing and workflow evaluation.
let matches = state.sub_registry.fan_out(&stored);
if !matches.is_empty() {
let event_json = match serde_json::to_string(&stored.event) {
Ok(json) => json,
Err(e) => {
warn!("failed to serialize membership notification for fan-out: {e}");
return Ok(());
}
};
for (target_conn_id, sub_id) in &matches {
let msg = format!(r#"["EVENT","{}",{}]"#, sub_id, event_json);
state.conn_manager.send_to(*target_conn_id, msg);
}
}
info!(
channel = %channel_id,
target = %target_hex,
kind = notification_kind,
"membership notification emitted"
);
Ok(())
}
/// Sign, store (replacing previous), and fan-out a single addressable discovery event.
async fn emit_addressable_discovery_event(
state: &Arc<AppState>,
@@ -538,6 +615,18 @@ async fn handle_put_user(event: &Event, state: &Arc<AppState>) -> anyhow::Result
warn!(channel = %channel_id, error = %e, "NIP-29 group discovery emission failed");
}
if let Err(e) = emit_membership_notification(
state,
channel_id,
&target_pubkey,
&actor_bytes,
KIND_MEMBER_ADDED_NOTIFICATION,
)
.await
{
warn!(channel = %channel_id, error = %e, "membership notification emission failed");
}
info!(channel = %channel_id, target = %target_hex, "NIP-29 PUT_USER processed");
Ok(())
}
@@ -589,6 +678,18 @@ async fn handle_remove_user(event: &Event, state: &Arc<AppState>) -> anyhow::Res
warn!(channel = %channel_id, error = %e, "NIP-29 group discovery emission failed");
}
if let Err(e) = emit_membership_notification(
state,
channel_id,
&target_pubkey,
&actor_bytes,
KIND_MEMBER_REMOVED_NOTIFICATION,
)
.await
{
warn!(channel = %channel_id, error = %e, "membership notification emission failed");
}
Ok(())
}
@@ -783,6 +884,18 @@ async fn handle_create_group(event: &Event, state: &Arc<AppState>) -> anyhow::Re
warn!(channel = %channel.id, error = %e, "NIP-29 group discovery emission failed");
}
if let Err(e) = emit_membership_notification(
state,
channel.id,
&actor_bytes,
&actor_bytes, // creator is both actor and target
KIND_MEMBER_ADDED_NOTIFICATION,
)
.await
{
warn!(channel = %channel.id, error = %e, "membership notification emission failed");
}
info!(channel_id = %channel.id, name = %name, "NIP-29 CREATE_GROUP processed");
Ok(())
}
@@ -864,6 +977,18 @@ async fn handle_leave_request(event: &Event, state: &Arc<AppState>) -> anyhow::R
warn!(channel = %channel_id, error = %e, "NIP-29 group discovery emission failed");
}
if let Err(e) = emit_membership_notification(
state,
channel_id,
&actor_bytes,
&actor_bytes, // self-leave: actor == target
KIND_MEMBER_REMOVED_NOTIFICATION,
)
.await
{
warn!(channel = %channel_id, error = %e, "membership notification emission failed");
}
Ok(())
}
+1 -1
View File
@@ -61,7 +61,7 @@ impl RelayInfo {
version: env!("CARGO_PKG_VERSION").to_string(),
limitation: Some(RelayLimitation {
max_message_length: Some(MAX_FRAME_BYTES as u64),
max_subscriptions: Some(100),
max_subscriptions: Some(1024),
max_filters: Some(10),
max_limit: Some(500),
max_subid_length: Some(256),
+14 -7
View File
@@ -139,9 +139,16 @@ impl SubscriptionRegistry {
}
}
} else {
// Global event (channel_id = None) — only deliver to global subscriptions.
// Channel-scoped subscriptions are skipped: they target a specific channel
// and should not receive global infrastructure events (e.g. membership
// notifications) even if tag matching would succeed.
for conn_entry in self.subs.iter() {
let conn_id = *conn_entry.key();
for (sub_id, (filters, _)) in conn_entry.value().iter() {
for (sub_id, (filters, sub_channel_id)) in conn_entry.value().iter() {
if sub_channel_id.is_some() {
continue; // skip channel-scoped subscriptions
}
if filters_match(filters, event) {
results.push((conn_id, sub_id.clone()));
}
@@ -149,12 +156,12 @@ impl SubscriptionRegistry {
}
}
// NOTE: Global subscriptions (channel_id = None) intentionally do NOT
// receive channel-scoped events. Delivering channel events to global subs
// would bypass the channel membership check performed in req.rs, leaking
// private channel content to unauthorized subscribers. Clients must
// subscribe to a specific channel to receive its events — that path goes
// through the access-control check that verifies membership.
// NOTE: The scoping invariant is symmetric:
// - Global subscriptions (channel_id = None) do NOT receive channel-scoped events.
// - Channel-scoped subscriptions do NOT receive global events.
// This prevents both directions of information leakage: channel content
// leaking to global subscribers, and global infrastructure events (like
// membership notifications) leaking to channel subscribers.
results
}
@@ -1170,3 +1170,577 @@ async fn test_nip29_standard_client_flow() {
client.disconnect().await.expect("clean disconnect");
}
/// Client-submitted kind:44100 (member-added notification) must be rejected.
/// Only the relay keypair may sign these events.
#[tokio::test]
#[ignore]
async fn test_membership_notification_kind_rejected() {
let url = relay_url();
let keys = Keys::generate();
let channel_id = create_test_channel(&keys).await;
let mut client = SproutTestClient::connect(&url, &keys)
.await
.expect("connect");
let p_tag = Tag::parse(&["p", &keys.public_key().to_hex()]).expect("p tag");
let h_tag = Tag::parse(&["h", &channel_id]).expect("h tag");
let event = EventBuilder::new(Kind::Custom(44100), "", [p_tag, h_tag])
.sign_with_keys(&keys)
.expect("sign kind:44100");
let ok = client.send_event(event).await.expect("send");
assert!(
!ok.accepted,
"relay must reject client-submitted kind:44100, but accepted it"
);
let msg_lower = ok.message.to_lowercase();
assert!(
msg_lower.contains("relay-signed only")
|| msg_lower.contains("relay signed only")
|| msg_lower.contains("relay"),
"rejection message should mention relay-signed restriction, got: {}",
ok.message
);
client.disconnect().await.expect("disconnect");
}
/// When a member is added via REST, the relay must emit a kind:44100 notification
/// to any subscriber filtering on `#p` = that member's pubkey.
#[tokio::test]
#[ignore]
async fn test_membership_notification_emitted_on_add() {
let url = relay_url();
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let agent_pubkey_hex = agent_keys.public_key().to_hex();
// Connect as agent — NIP-42 auth establishes the authenticated pubkey.
let mut agent_client = SproutTestClient::connect(&url, &agent_keys)
.await
.expect("connect as agent");
// Create a channel owned by owner (not agent).
let channel_id = create_test_channel(&owner_keys).await;
// Subscribe to membership notifications for agent's own pubkey.
let sid = sub_id("membership-notif");
let filter = Filter::new()
.kinds(vec![Kind::Custom(44100), Kind::Custom(44101)])
.custom_tag(
SingleLetterTag::lowercase(Alphabet::P),
[agent_pubkey_hex.as_str()],
)
.since(nostr::Timestamp::now() - 5u64);
agent_client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe to membership notifications");
// Drain EOSE — no historical events expected.
agent_client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("EOSE for membership sub");
// Add agent to the channel via REST (owner's X-Pubkey header).
let http_client = reqwest::Client::new();
let resp = http_client
.post(format!(
"{}/api/channels/{}/members",
relay_http_url(),
channel_id
))
.header("X-Pubkey", &owner_keys.public_key().to_hex())
.json(&serde_json::json!({ "pubkeys": [agent_pubkey_hex] }))
.send()
.await
.expect("add member request");
assert!(
resp.status().is_success(),
"add member failed: {}",
resp.status()
);
// Wait for the kind:44100 notification.
let msg = agent_client
.recv_event(Duration::from_secs(5))
.await
.expect("recv kind:44100 notification");
match msg {
RelayMessage::Event { event, .. } => {
assert_eq!(
event.kind,
Kind::Custom(44100),
"expected kind:44100, got {}",
event.kind.as_u16()
);
let tags: Vec<Vec<String>> = event
.tags
.iter()
.map(|t| t.as_slice().iter().map(|s| s.to_string()).collect())
.collect();
let has_p = tags
.iter()
.any(|t| t.len() >= 2 && t[0] == "p" && t[1] == agent_pubkey_hex);
assert!(
has_p,
"kind:44100 missing p tag = agent pubkey. tags: {tags:?}"
);
let has_h = tags
.iter()
.any(|t| t.len() >= 2 && t[0] == "h" && t[1] == channel_id);
assert!(
has_h,
"kind:44100 missing h tag = channel uuid. tags: {tags:?}"
);
}
other => panic!("expected EVENT kind:44100, got {other:?}"),
}
agent_client.disconnect().await.expect("disconnect");
}
/// Subscribing to kind:44100/44101 without a `#p` filter must be rejected with CLOSED.
#[tokio::test]
#[ignore]
async fn test_membership_notification_requires_p_filter() {
let url = relay_url();
let keys = Keys::generate();
let mut client = SproutTestClient::connect(&url, &keys)
.await
.expect("connect");
let sid = sub_id("no-p-filter");
let filter = Filter::new().kinds(vec![Kind::Custom(44100), Kind::Custom(44101)]);
client
.subscribe(&sid, vec![filter])
.await
.expect("send REQ");
// Drain until we get the CLOSED for our subscription.
let msg = loop {
let m = client
.recv_event(Duration::from_secs(5))
.await
.expect("recv CLOSED");
match &m {
RelayMessage::Eose { .. } => continue,
RelayMessage::Event { .. } => continue,
_ => break m,
}
};
match msg {
RelayMessage::Closed {
subscription_id,
message,
} => {
assert_eq!(
subscription_id, sid,
"CLOSED for wrong subscription: {subscription_id}"
);
assert!(
message.to_lowercase().contains("restricted"),
"expected 'restricted' in CLOSED message, got: {message}"
);
}
other => panic!("expected CLOSED, got {other:?}"),
}
client.disconnect().await.expect("disconnect");
}
/// A subscription with NO kinds filter and NO #p filter (wildcard) must be rejected with CLOSED
/// because it can match kind:44100/44101.
#[tokio::test]
#[ignore]
async fn test_membership_notification_wildcard_filter_rejected() {
let url = relay_url();
let keys = Keys::generate();
let mut client = SproutTestClient::connect(&url, &keys)
.await
.expect("connect");
let sid = sub_id("wildcard-filter");
// Empty filter — no kinds, no #p — can match kind:44100/44101.
let filter = Filter::new();
client
.subscribe(&sid, vec![filter])
.await
.expect("send REQ");
// Drain until we get the CLOSED for our subscription.
let msg = loop {
let m = client
.recv_event(Duration::from_secs(5))
.await
.expect("recv CLOSED");
match &m {
RelayMessage::Eose { .. } => continue,
RelayMessage::Event { .. } => continue,
_ => break m,
}
};
match msg {
RelayMessage::Closed {
subscription_id,
message,
} => {
assert_eq!(
subscription_id, sid,
"CLOSED for wrong subscription: {subscription_id}"
);
assert!(
message.to_lowercase().contains("restricted"),
"expected 'restricted' in CLOSED message, got: {message}"
);
}
other => panic!("expected CLOSED, got {other:?}"),
}
client.disconnect().await.expect("disconnect");
}
/// Subscribing to kind:44100/44101 with someone else's `#p` must be rejected with CLOSED.
#[tokio::test]
#[ignore]
async fn test_membership_notification_requires_own_p_filter() {
let url = relay_url();
let keys_a = Keys::generate();
let keys_b = Keys::generate();
let keys_b_pubkey_hex = keys_b.public_key().to_hex();
// Connect as keys_a.
let mut client = SproutTestClient::connect(&url, &keys_a)
.await
.expect("connect as keys_a");
let sid = sub_id("wrong-p-filter");
// Filter uses keys_b's pubkey — not the authenticated pubkey (keys_a).
let filter = Filter::new()
.kinds(vec![Kind::Custom(44100), Kind::Custom(44101)])
.custom_tag(
SingleLetterTag::lowercase(Alphabet::P),
[keys_b_pubkey_hex.as_str()],
);
client
.subscribe(&sid, vec![filter])
.await
.expect("send REQ");
// Drain until we get the CLOSED for our subscription.
let msg = loop {
let m = client
.recv_event(Duration::from_secs(5))
.await
.expect("recv CLOSED");
match &m {
RelayMessage::Eose { .. } => continue,
RelayMessage::Event { .. } => continue,
_ => break m,
}
};
match msg {
RelayMessage::Closed {
subscription_id,
message,
} => {
assert_eq!(
subscription_id, sid,
"CLOSED for wrong subscription: {subscription_id}"
);
assert!(
message.to_lowercase().contains("restricted"),
"expected 'restricted' in CLOSED message, got: {message}"
);
}
other => panic!("expected CLOSED, got {other:?}"),
}
client.disconnect().await.expect("disconnect");
}
/// When a member is removed via REST, the relay must emit a kind:44101 notification
/// to any subscriber filtering on `#p` = that member's pubkey.
#[tokio::test]
#[ignore]
async fn test_membership_notification_emitted_on_remove() {
let url = relay_url();
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let agent_pubkey_hex = agent_keys.public_key().to_hex();
// Connect as agent — NIP-42 auth establishes the authenticated pubkey.
let mut agent_client = SproutTestClient::connect(&url, &agent_keys)
.await
.expect("connect as agent");
// Create a channel owned by owner (not agent).
let channel_id = create_test_channel(&owner_keys).await;
// Subscribe to membership notifications for agent's own pubkey.
let sid = sub_id("membership-remove-notif");
let filter = Filter::new()
.kinds(vec![Kind::Custom(44100), Kind::Custom(44101)])
.custom_tag(
SingleLetterTag::lowercase(Alphabet::P),
[agent_pubkey_hex.as_str()],
)
.since(nostr::Timestamp::now() - 5u64);
agent_client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe to membership notifications");
// Drain EOSE — no historical events expected.
agent_client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("EOSE for membership sub");
let http_client = reqwest::Client::new();
let owner_pubkey_hex = owner_keys.public_key().to_hex();
// Add agent to the channel via REST (owner's X-Pubkey header).
let resp = http_client
.post(format!(
"{}/api/channels/{}/members",
relay_http_url(),
channel_id
))
.header("X-Pubkey", &owner_pubkey_hex)
.json(&serde_json::json!({ "pubkeys": [agent_pubkey_hex] }))
.send()
.await
.expect("add member request");
assert!(
resp.status().is_success(),
"add member failed: {}",
resp.status()
);
// Consume the kind:44100 add notification before waiting for the remove.
let add_msg = agent_client
.recv_event(Duration::from_secs(5))
.await
.expect("recv kind:44100 notification");
match add_msg {
RelayMessage::Event { ref event, .. } => {
assert_eq!(
event.kind,
Kind::Custom(44100),
"expected kind:44100 add notification, got {}",
event.kind.as_u16()
);
}
other => panic!("expected EVENT kind:44100, got {other:?}"),
}
// Remove agent from the channel via REST DELETE.
let resp = http_client
.delete(format!(
"{}/api/channels/{}/members/{}",
relay_http_url(),
channel_id,
agent_pubkey_hex
))
.header("X-Pubkey", &owner_pubkey_hex)
.send()
.await
.expect("remove member request");
assert!(
resp.status().is_success(),
"remove member failed: {}",
resp.status()
);
// Wait for the kind:44101 remove notification.
let msg = agent_client
.recv_event(Duration::from_secs(5))
.await
.expect("recv kind:44101 notification");
match msg {
RelayMessage::Event { event, .. } => {
assert_eq!(
event.kind,
Kind::Custom(44101),
"expected kind:44101, got {}",
event.kind.as_u16()
);
let tags: Vec<Vec<String>> = event
.tags
.iter()
.map(|t| t.as_slice().iter().map(|s| s.to_string()).collect())
.collect();
let has_p = tags
.iter()
.any(|t| t.len() >= 2 && t[0] == "p" && t[1] == agent_pubkey_hex);
assert!(
has_p,
"kind:44101 missing p tag = agent pubkey. tags: {tags:?}"
);
let has_h = tags
.iter()
.any(|t| t.len() >= 2 && t[0] == "h" && t[1] == channel_id);
assert!(
has_h,
"kind:44101 missing h tag = channel uuid. tags: {tags:?}"
);
}
other => panic!("expected EVENT kind:44101, got {other:?}"),
}
agent_client.disconnect().await.expect("disconnect");
}
/// Subscribing to kind:44100/44101 with `#p` containing BOTH the client's own pubkey AND
/// a victim's pubkey must be rejected with CLOSED. All #p values must match the authenticated
/// pubkey — including the victim's key is not allowed.
#[tokio::test]
#[ignore]
async fn test_membership_notification_multi_p_rejected() {
let url = relay_url();
let keys_a = Keys::generate();
let keys_b = Keys::generate();
let keys_a_pubkey_hex = keys_a.public_key().to_hex();
let keys_b_pubkey_hex = keys_b.public_key().to_hex();
// Connect as keys_a.
let mut client = SproutTestClient::connect(&url, &keys_a)
.await
.expect("connect as keys_a");
let sid = sub_id("multi-p-filter");
// Filter includes keys_a's own pubkey AND keys_b's (victim) pubkey.
// The relay must reject this because not all #p values match the authenticated pubkey.
let filter = Filter::new()
.kinds(vec![Kind::Custom(44100), Kind::Custom(44101)])
.custom_tag(
SingleLetterTag::lowercase(Alphabet::P),
[keys_a_pubkey_hex.as_str(), keys_b_pubkey_hex.as_str()],
);
client
.subscribe(&sid, vec![filter])
.await
.expect("send REQ");
// Drain until we get the CLOSED for our subscription.
let msg = loop {
let m = client
.recv_event(Duration::from_secs(5))
.await
.expect("recv CLOSED");
match &m {
RelayMessage::Eose { .. } => continue,
RelayMessage::Event { .. } => continue,
_ => break m,
}
};
match msg {
RelayMessage::Closed {
subscription_id,
message,
} => {
assert_eq!(
subscription_id, sid,
"CLOSED for wrong subscription: {subscription_id}"
);
assert!(
message.to_lowercase().contains("restricted"),
"expected 'restricted' in CLOSED message, got: {message}"
);
}
other => panic!("expected CLOSED, got {other:?}"),
}
client.disconnect().await.expect("disconnect");
}
/// A mixed-filter subscription where one filter has `#h` + membership kinds and another
/// filter makes the subscription globally scoped must be rejected with CLOSED.
/// This prevents bypassing the #p requirement via mixed filters.
#[tokio::test]
#[ignore]
async fn test_membership_notification_mixed_filter_rejected() {
let url = relay_url();
let keys = Keys::generate();
let channel_id = create_test_channel(&keys).await;
let mut client = SproutTestClient::connect(&url, &keys)
.await
.expect("connect");
let sid = sub_id("mixed-filter");
// Filter 1: has #h + membership kinds (would skip per-filter #h check)
let filter1 = Filter::new().kinds(vec![Kind::Custom(44100)]).custom_tag(
SingleLetterTag::lowercase(Alphabet::H),
[channel_id.as_str()],
);
// Filter 2: global filter (no #h) — makes the subscription globally scoped.
// No kinds = wildcard, no #p = should trigger rejection.
let filter2 = Filter::new().authors(vec![keys.public_key()]);
client
.subscribe(&sid, vec![filter1, filter2])
.await
.expect("send REQ");
// Drain until we get the CLOSED for our subscription.
let msg = loop {
let m = client
.recv_event(Duration::from_secs(5))
.await
.expect("recv CLOSED");
match &m {
RelayMessage::Eose { .. } => continue,
RelayMessage::Event { .. } => continue,
_ => break m,
}
};
match msg {
RelayMessage::Closed {
subscription_id,
message,
} => {
assert_eq!(
subscription_id, sid,
"CLOSED for wrong subscription: {subscription_id}"
);
assert!(
message.to_lowercase().contains("restricted"),
"expected 'restricted' in CLOSED message, got: {message}"
);
}
other => panic!("expected CLOSED, got {other:?}"),
}
client.disconnect().await.expect("disconnect");
}