diff --git a/crates/sprout-db/src/channel.rs b/crates/sprout-db/src/channel.rs index 9489630d5..7e1f316e1 100644 --- a/crates/sprout-db/src/channel.rs +++ b/crates/sprout-db/src/channel.rs @@ -542,6 +542,34 @@ pub async fn get_members_bulk(pool: &PgPool, channel_ids: &[Uuid]) -> Result Result> { + if channel_ids.is_empty() { + return Ok(Vec::new()); + } + + let rows = sqlx::query( + r#" + SELECT id + FROM channels + WHERE id = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(channel_ids) + .fetch_all(pool) + .await?; + + rows.into_iter() + .map(|r| { + let id: Uuid = r.try_get("id")?; + Ok(id) + }) + .collect() +} + /// Get all channel IDs accessible to a pubkey. /// /// Includes channels where the pubkey is an active member AND all open channels. @@ -1203,6 +1231,46 @@ mod tests { Keys::generate().public_key().to_bytes().to_vec() } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_filter_live_channel_ids_excludes_deleted_and_missing_channels() { + let pool = setup_pool().await; + let owner_pk = random_pubkey(); + ensure_user(&pool, &owner_pk).await.expect("ensure owner"); + + let live = create_channel( + &pool, + &format!("test-public-live-{}", Uuid::new_v4()), + ChannelType::Stream, + ChannelVisibility::Private, + None, + &owner_pk, + None, + ) + .await + .expect("create live channel"); + let deleted = create_channel( + &pool, + &format!("test-public-deleted-{}", Uuid::new_v4()), + ChannelType::Stream, + ChannelVisibility::Private, + None, + &owner_pk, + None, + ) + .await + .expect("create deleted channel"); + soft_delete_channel(&pool, deleted.id) + .await + .expect("soft delete channel"); + + let result = filter_live_channel_ids(&pool, &[live.id, deleted.id, Uuid::new_v4()]) + .await + .expect("filter live channel ids"); + + assert_eq!(result, vec![live.id]); + } + /// Agent owner (non-admin) can remove their own bot from a channel. #[tokio::test] #[ignore = "requires Postgres"] diff --git a/crates/sprout-db/src/event.rs b/crates/sprout-db/src/event.rs index e897d6035..ebb3e97d5 100644 --- a/crates/sprout-db/src/event.rs +++ b/crates/sprout-db/src/event.rs @@ -218,25 +218,23 @@ pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result Result { qb.push(format!(" AND {col_prefix}channel_id IS NULL")); } - // Multi-channel IN pushdown for COUNT: restrict to accessible channels + global. - // SECURITY: Some(empty vec) = no channel access → global events only. + // Multi-channel IN pushdown for COUNT: restrict to accessible channel-scoped + // events only. Global events require an explicit `global_only` query. + // SECURITY: Some(empty vec) = no channel access → count nothing. if let Some(ref ch_ids) = q.channel_ids { if ch_ids.is_empty() { - qb.push(format!(" AND {col_prefix}channel_id IS NULL")); + return Ok(0); } else { - qb.push(format!( - " AND ({col_prefix}channel_id IS NULL OR {col_prefix}channel_id IN (" - )); + qb.push(format!(" AND {col_prefix}channel_id IN (")); let mut sep = qb.separated(", "); for ch in ch_ids { sep.push_bind(*ch); } - qb.push("))"); + qb.push(")"); } } @@ -1064,6 +1061,35 @@ mod tests { assert_eq!(extract_d_tag(&above), None); } + #[test] + fn channel_ids_pushdown_is_channel_scoped_not_global() { + let ch = uuid::Uuid::new_v4(); + let q = EventQuery { + channel_ids: Some(vec![ch]), + ..Default::default() + }; + + assert_eq!(q.channel_ids.as_deref(), Some(&[ch][..])); + assert!( + !q.global_only, + "channel_ids pushdown is channel-scoped only; global reads require explicit global_only" + ); + } + + #[test] + fn empty_channel_ids_pushdown_matches_no_channel_events() { + let q = EventQuery { + channel_ids: Some(vec![]), + ..Default::default() + }; + + assert_eq!(q.channel_ids.as_deref(), Some(&[][..])); + assert!( + !q.global_only, + "an empty effective readable set must not silently broaden to global events" + ); + } + #[test] fn extract_d_tag_single_element_d_tag_ignored() { // A d tag with only one element (no value) should not match — parts.len() < 2 diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs index 5c2449e72..f00491863 100644 --- a/crates/sprout-db/src/lib.rs +++ b/crates/sprout-db/src/lib.rs @@ -442,6 +442,11 @@ impl Db { channel::get_accessible_channel_ids(&self.pool, pubkey).await } + /// Return the subset of channel IDs that exist and have not been soft-deleted. + pub async fn filter_live_channel_ids(&self, channel_ids: &[Uuid]) -> Result> { + channel::filter_live_channel_ids(&self.pool, channel_ids).await + } + /// Lists channels, optionally filtered by visibility. pub async fn list_channels( &self, @@ -695,8 +700,17 @@ impl Db { depth_limit: Option, limit: u32, cursor: Option<&[u8]>, + channel_id: Option, ) -> Result> { - thread::get_thread_replies(&self.pool, root_event_id, depth_limit, limit, cursor).await + thread::get_thread_replies( + &self.pool, + root_event_id, + depth_limit, + limit, + cursor, + channel_id, + ) + .await } /// Fetch aggregated thread stats. diff --git a/crates/sprout-db/src/thread.rs b/crates/sprout-db/src/thread.rs index 686530e5d..e2aec375b 100644 --- a/crates/sprout-db/src/thread.rs +++ b/crates/sprout-db/src/thread.rs @@ -316,6 +316,7 @@ pub async fn decrement_reply_count( /// - `cursor` -- if `Some(ts_bytes)`, returns replies with `event_created_at` /// strictly after the timestamp encoded in `ts_bytes`. The bytes must be an /// 8-byte big-endian i64 Unix timestamp in seconds. +/// - `channel_id` -- if `Some`, returns only replies in that channel. /// - `limit` -- maximum rows returned (caller should cap this). pub async fn get_thread_replies( pool: &PgPool, @@ -323,6 +324,7 @@ pub async fn get_thread_replies( depth_limit: Option, limit: u32, cursor: Option<&[u8]>, + channel_id: Option, ) -> Result> { // Decode cursor bytes -> DateTime for the keyset condition. let cursor_ts: Option> = match cursor { @@ -367,6 +369,10 @@ pub async fn get_thread_replies( sql.push_str(&format!(" AND tm.event_created_at > ${param_idx}")); param_idx += 1; } + if channel_id.is_some() { + sql.push_str(&format!(" AND tm.channel_id = ${param_idx}")); + param_idx += 1; + } sql.push_str(&format!( " ORDER BY tm.event_created_at ASC LIMIT ${param_idx}" @@ -380,6 +386,9 @@ pub async fn get_thread_replies( if let Some(ts) = cursor_ts { q = q.bind(ts); } + if let Some(ch_id) = channel_id { + q = q.bind(ch_id); + } q = q.bind(limit as i32); let rows = q.fetch_all(pool).await?; diff --git a/crates/sprout-relay/src/api/bridge.rs b/crates/sprout-relay/src/api/bridge.rs index ab916524b..592235075 100644 --- a/crates/sprout-relay/src/api/bridge.rs +++ b/crates/sprout-relay/src/api/bridge.rs @@ -258,9 +258,9 @@ pub async fn query_events( )); } - // Get channels this user can access — same enforcement as WS REQ handler. + // Effective authenticated read set = normal access ∪ live public-readable allowlist. let accessible_channels = state - .get_accessible_channel_ids_cached(&pubkey_bytes) + .effective_readable_channel_ids(&pubkey_bytes, None) .await .map_err(|e| internal_error(&format!("channel access lookup: {e}")))?; @@ -365,11 +365,17 @@ pub async fn query_events( _ => continue, }; - if let Some(ch_id) = extract_channel_from_filter(filter) { - if !accessible_channels.contains(&ch_id) { - handled.insert(idx); - continue; - } + let Some(ch_id) = extract_channel_from_filter(filter) else { + handled.insert(idx); + continue; + }; + // Bridge is authenticated-only today, so `accessible_channels` is already the + // effective read set. If unauthenticated/public bridge reads are added later, + // they must resolve the same live public allowlist before this access check; + // never fall back to unscoped thread expansion for `depth_limit`. + if !accessible_channels.contains(&ch_id) { + handled.insert(idx); + continue; } let limit = filter @@ -378,7 +384,7 @@ pub async fn query_events( .min(BRIDGE_THREAD_MAX_LIMIT as usize) as u32; let thread_replies = state .db - .get_thread_replies(&root_bytes, Some(depth), limit, None) + .get_thread_replies(&root_bytes, Some(depth), limit, None, Some(ch_id)) .await .map_err(|e| internal_error(&format!("thread query error: {e}")))?; @@ -415,9 +421,7 @@ pub async fn query_events( } } - let mut query = - crate::handlers::req::build_event_query_from_filter(filter, &pubkey_bytes, &state) - .await; + let mut query = crate::handlers::req::build_event_query_from_filter(filter); if let Some(bid) = extract_before_id(raw) { if query.until.is_none() { @@ -495,9 +499,9 @@ pub async fn count_events( )); } - // Get channels this user can access. + // Effective authenticated read set = normal access ∪ live public-readable allowlist. let accessible_channels = state - .get_accessible_channel_ids_cached(&pubkey_bytes) + .effective_readable_channel_ids(&pubkey_bytes, None) .await .map_err(|e| internal_error(&format!("channel access lookup: {e}")))?; @@ -509,9 +513,7 @@ pub async fn count_events( continue; // Skip filters targeting inaccessible channels. } // Channel is accessible — count with pushability check. - let query = - crate::handlers::req::build_event_query_from_filter(filter, &pubkey_bytes, &state) - .await; + let query = crate::handlers::req::build_event_query_from_filter(filter); if crate::handlers::req::filter_fully_pushable(filter) { match state.db.count_events(&query).await { Ok(n) => total += n as u64, @@ -541,9 +543,7 @@ pub async fn count_events( } else { // No channel filter — use SQL-level channel_ids pushdown to count // only events in accessible channels (+ global events). - let mut query = - crate::handlers::req::build_event_query_from_filter(filter, &pubkey_bytes, &state) - .await; + let mut query = crate::handlers::req::build_event_query_from_filter(filter); query.channel_ids = Some(accessible_channels.to_vec()); if crate::handlers::req::filter_fully_pushable(filter) { @@ -1182,6 +1182,26 @@ mod tests { assert!(extract_feed_types(&raw).is_none()); } + #[test] + fn depth_limited_thread_filter_without_channel_is_not_channel_accessible() { + let filter = nostr::Filter::new().event(nostr::EventId::from_slice(&[1u8; 32]).unwrap()); + + assert!( + extract_channel_from_filter(&filter).is_none(), + "depth_limit bridge path must fail closed unless caller supplies #h" + ); + } + + #[test] + fn depth_limited_thread_filter_with_channel_extracts_channel() { + let ch = uuid::Uuid::new_v4(); + let filter = nostr::Filter::new() + .event(nostr::EventId::from_slice(&[1u8; 32]).unwrap()) + .custom_tag(SingleLetterTag::lowercase(Alphabet::H), ch.to_string()); + + assert_eq!(extract_channel_from_filter(&filter), Some(ch)); + } + #[test] fn event_accessible_no_channel() { let keys = Keys::generate(); diff --git a/crates/sprout-relay/src/config.rs b/crates/sprout-relay/src/config.rs index 4376d89b9..eee564825 100644 --- a/crates/sprout-relay/src/config.rs +++ b/crates/sprout-relay/src/config.rs @@ -92,6 +92,15 @@ pub struct Config { /// Media storage configuration (S3/MinIO). pub media: sprout_media::MediaConfig, + /// Comma-separated channel UUIDs that are readable without channel membership. + /// + /// Public-readable channels are a read-only relay policy: they expand REQ/COUNT/query + /// access only after being validated as live, non-deleted channels by the DB. They never + /// grant write, upload, admin, or membership privileges. Invalid UUIDs fail closed by + /// being ignored. + /// Set via `SPROUT_PUBLIC_READABLE_CHANNELS`. + pub public_readable_channels: Vec, + /// Optional override for ephemeral channel TTL (in seconds). /// When set, any channel created with a TTL tag will use this value instead /// of the client-provided one. Useful for testing ephemeral expiry quickly. @@ -121,6 +130,22 @@ pub struct Config { pub web_dir: Option, } +fn parse_public_readable_channels() -> Vec { + std::env::var("SPROUT_PUBLIC_READABLE_CHANNELS") + .unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .filter_map(|s| match s.parse::() { + Ok(id) if id != uuid::Uuid::nil() => Some(id), + _ => { + warn!(channel_id = %s, "Ignoring invalid SPROUT_PUBLIC_READABLE_CHANNELS entry"); + None + } + }) + .collect() +} + impl Config { /// Loads configuration from environment variables, falling back to development defaults. pub fn from_env() -> Result { @@ -277,6 +302,8 @@ impl Config { }), }; + let public_readable_channels = parse_public_readable_channels(); + let ephemeral_ttl_override = std::env::var("SPROUT_EPHEMERAL_TTL_OVERRIDE") .ok() .and_then(|v| v.parse::().ok()) @@ -376,6 +403,7 @@ impl Config { relay_owner_pubkey, allow_nip_oa_auth, media, + public_readable_channels, ephemeral_ttl_override, git_repo_path, git_max_pack_bytes, @@ -421,6 +449,26 @@ mod tests { !config.allow_nip_oa_auth, "allow_nip_oa_auth should default to false" ); + assert!( + config.public_readable_channels.is_empty(), + "public_readable_channels should default empty" + ); + } + + #[test] + fn public_readable_channels_parses_valid_uuids_and_ignores_invalid_entries() { + let _guard = ENV_MUTEX.lock().unwrap(); + let valid_a = uuid::Uuid::new_v4(); + let valid_b = uuid::Uuid::new_v4(); + std::env::set_var( + "SPROUT_PUBLIC_READABLE_CHANNELS", + format!(" {valid_a},not-a-uuid,00000000-0000-0000-0000-000000000000,{valid_b} "), + ); + + let config = Config::from_env().expect("config"); + std::env::remove_var("SPROUT_PUBLIC_READABLE_CHANNELS"); + + assert_eq!(config.public_readable_channels, vec![valid_a, valid_b]); } #[test] diff --git a/crates/sprout-relay/src/handlers/count.rs b/crates/sprout-relay/src/handlers/count.rs index 22331c9c8..3fe39d16b 100644 --- a/crates/sprout-relay/src/handlers/count.rs +++ b/crates/sprout-relay/src/handlers/count.rs @@ -29,47 +29,71 @@ pub async fn handle_count( conn: Arc, state: Arc, ) { - // Require auth let pubkey_bytes = { let auth = conn.auth_state.read().await; match &*auth { - AuthState::Authenticated(ctx) => ctx.pubkey.to_bytes().to_vec(), - _ => { - conn.send(RelayMessage::closed( - &sub_id, - "auth-required: not authenticated", - )); - return; - } + AuthState::Authenticated(ctx) => Some(ctx.pubkey.to_bytes().to_vec()), + _ => None, } }; - // P-gated kinds (gift wraps, member notifications, observer frames) require - // the caller's own pubkey in the #p tag — same enforcement as WS REQ handler. - let authed_pubkey_hex = hex::encode(&pubkey_bytes); - if !super::req::p_gated_filters_authorized(&filters, &authed_pubkey_hex) { - conn.send(RelayMessage::closed( - &sub_id, - "restricted: p-gated kinds require #p tag matching your pubkey", - )); - return; - } - if !super::req::engram_filters_authorized(&filters, &authed_pubkey_hex) { - conn.send(RelayMessage::closed( - &sub_id, - "restricted: agent-engram reads require authors=[self] or #p=[self]", - )); - return; - } + let accessible_channels = if let Some(pubkey_bytes) = pubkey_bytes.as_deref() { + // P-gated kinds (gift wraps, member notifications, observer frames) require + // the caller's own pubkey in the #p tag — same enforcement as WS REQ handler. + let authed_pubkey_hex = hex::encode(pubkey_bytes); + if !super::req::p_gated_filters_authorized(&filters, &authed_pubkey_hex) { + conn.send(RelayMessage::closed( + &sub_id, + "restricted: p-gated kinds require #p tag matching your pubkey", + )); + return; + } + if !super::req::engram_filters_authorized(&filters, &authed_pubkey_hex) { + conn.send(RelayMessage::closed( + &sub_id, + "restricted: agent-engram reads require authors=[self] or #p=[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 { - Ok(ids) => ids, - Err(e) => { - warn!(sub_id = %sub_id, "Failed to get accessible channels: {e}"); - conn.send(RelayMessage::closed(&sub_id, "error: database error")); + match state + .effective_readable_channel_ids(pubkey_bytes, None) + .await + { + Ok(ids) => ids, + Err(e) => { + warn!(sub_id = %sub_id, "Failed to get effective readable channels: {e}"); + conn.send(RelayMessage::closed(&sub_id, "error: database error")); + return; + } + } + } else { + let public_readable_channels = match state.public_readable_channel_ids().await { + Ok(ids) => ids, + Err(e) => { + warn!(sub_id = %sub_id, "Failed to resolve public-readable channels: {e}"); + conn.send(RelayMessage::closed(&sub_id, "error: database error")); + return; + } + }; + if public_readable_channels.is_empty() { + conn.send(RelayMessage::closed( + &sub_id, + "auth-required: not authenticated", + )); + return; + } + if filters + .iter() + .any(|filter| extract_channel_from_filter(filter).is_none()) + { + conn.send(RelayMessage::closed( + &sub_id, + "restricted: public counts require #h channel scope", + )); return; } + public_readable_channels }; // For each filter, count matching events with channel access enforcement. @@ -81,8 +105,7 @@ pub async fn handle_count( continue; // Skip filters targeting inaccessible channels. } // Channel is accessible — count with pushability check. - let query = - super::req::build_event_query_from_filter(filter, &pubkey_bytes, &state).await; + let query = super::req::build_event_query_from_filter(filter); if super::req::filter_fully_pushable(filter) { match state.db.count_events(&query).await { Ok(n) => total += n as u64, @@ -118,8 +141,7 @@ pub async fn handle_count( // If the filter has generic tags beyond what SQL can push down // (#h, #p single, #d single, #e), we must fall back to // query + post-filter to avoid overcounting. - let mut query = - super::req::build_event_query_from_filter(filter, &pubkey_bytes, &state).await; + let mut query = super::req::build_event_query_from_filter(filter); query.channel_ids = Some(accessible_channels.to_vec()); if super::req::filter_fully_pushable(filter) { diff --git a/crates/sprout-relay/src/handlers/req.rs b/crates/sprout-relay/src/handlers/req.rs index 0c6ca7346..61a20ee2d 100644 --- a/crates/sprout-relay/src/handlers/req.rs +++ b/crates/sprout-relay/src/handlers/req.rs @@ -36,7 +36,8 @@ pub async fn handle_req( conn: Arc, state: Arc, ) { - let (conn_id, pubkey_bytes, token_channel_ids) = { + let conn_id = conn.conn_id; + let (pubkey_bytes, token_channel_ids) = { let auth = conn.auth_state.read().await; match &*auth { AuthState::Authenticated(ctx) => { @@ -49,46 +50,69 @@ pub async fn handle_req( return; } - let pk_bytes = ctx.pubkey.to_bytes().to_vec(); + ( + Some(ctx.pubkey.to_bytes().to_vec()), + ctx.channel_ids.clone(), + ) + } + _ => (None, None), + } + }; - let subs = conn.subscriptions.lock().await; - if !subs.contains_key(&sub_id) && subs.len() >= MAX_SUBSCRIPTIONS { - conn.send(RelayMessage::closed( - &sub_id, - "error: too many subscriptions", - )); - return; - } + let subs = conn.subscriptions.lock().await; + if !subs.contains_key(&sub_id) && subs.len() >= MAX_SUBSCRIPTIONS { + conn.send(RelayMessage::closed( + &sub_id, + "error: too many subscriptions", + )); + return; + } + drop(subs); + + let channel_id = extract_channel_id_from_filters(&filters); - (conn.conn_id, pk_bytes, ctx.channel_ids.clone()) + let (accessible_channels, include_global) = if let Some(pubkey_bytes) = pubkey_bytes.as_deref() + { + let ids = match state + .effective_readable_channel_ids(pubkey_bytes, token_channel_ids.as_deref()) + .await + { + Ok(ids) => ids, + Err(e) => { + warn!(conn_id = %conn_id, "Failed to get effective readable channels: {e}"); + conn.send(RelayMessage::closed(&sub_id, "error: database error")); + return; } - _ => { - conn.send(RelayMessage::notice( - "auth-required: authenticate before subscribing", - )); - conn.send(RelayMessage::closed( - &sub_id, - "auth-required: not authenticated", - )); + }; + (ids, token_channel_ids.is_none()) + } else { + let public_readable_channels = match state.public_readable_channel_ids().await { + Ok(ids) => ids, + Err(e) => { + warn!(conn_id = %conn_id, "Failed to resolve public-readable channels: {e}"); + conn.send(RelayMessage::closed(&sub_id, "error: database error")); return; } + }; + if public_readable_channels.is_empty() { + conn.send(RelayMessage::notice( + "auth-required: authenticate before subscribing", + )); + conn.send(RelayMessage::closed( + &sub_id, + "auth-required: not authenticated", + )); + return; } - }; - - let mut accessible_channels = match state.get_accessible_channel_ids_cached(&pubkey_bytes).await - { - Ok(ids) => ids, - Err(e) => { - warn!(conn_id = %conn_id, "Failed to get accessible channels: {e}"); - conn.send(RelayMessage::closed(&sub_id, "error: database error")); + if channel_id.is_none() { + conn.send(RelayMessage::closed( + &sub_id, + "restricted: public reads require #h channel scope", + )); return; } + (public_readable_channels, false) }; - if let Some(allowed) = token_channel_ids.as_deref() { - accessible_channels.retain(|channel_id| allowed.contains(channel_id)); - } - - let channel_id = extract_channel_id_from_filters(&filters); // ── #p / engram gating for globally-stored sensitive kinds ─────────────── // Applied BEFORE the NIP-50 search branch so that an authenticated member @@ -100,7 +124,14 @@ pub async fn handle_req( // channel-scoped subs can never receive globally-stored events because of // the fan_out() invariant in subscription.rs. if channel_id.is_none() { - let authed_pubkey_hex = hex::encode(&pubkey_bytes); + let Some(pubkey_bytes) = pubkey_bytes.as_deref() else { + conn.send(RelayMessage::closed( + &sub_id, + "restricted: public reads require #h channel scope", + )); + return; + }; + let authed_pubkey_hex = hex::encode(pubkey_bytes); if !p_gated_filters_authorized(&filters, &authed_pubkey_hex) { conn.send(RelayMessage::closed( &sub_id, @@ -135,7 +166,7 @@ pub async fn handle_req( &sub_id, &filters, &accessible_channels, - token_channel_ids.is_none(), + include_global, &conn, &state, ) @@ -454,12 +485,7 @@ async fn handle_search_req( /// Convert a single NIP-01 filter into an [`EventQuery`] for the database. /// /// Public wrapper for use by the HTTP bridge and COUNT handler. -/// Resolves accessible channels for the given pubkey and builds the query. -pub async fn build_event_query_from_filter( - filter: &Filter, - _pubkey_bytes: &[u8], - _state: &AppState, -) -> EventQuery { +pub fn build_event_query_from_filter(filter: &Filter) -> EventQuery { let channel_id = extract_channel_id_from_filter(filter); filter_to_query_params(filter, channel_id) } @@ -947,6 +973,46 @@ mod tests { ); } + #[test] + fn public_viewer_search_scope_excludes_global_results() { + let public_channel = uuid::Uuid::new_v4(); + + let scope = build_search_channel_scope_filter(&[public_channel], false) + .expect("public viewer should search configured channel"); + + assert_eq!(scope, format!("channel_id:=[{public_channel}]")); + assert!( + !scope.contains("__global__"), + "anonymous public reads must not include global search hits" + ); + } + + #[test] + fn extend_unique_unions_public_readable_channels_without_duplicates() { + let member_channel = uuid::Uuid::new_v4(); + let shared_channel = uuid::Uuid::new_v4(); + let public_only = uuid::Uuid::new_v4(); + let mut channels = vec![member_channel, shared_channel]; + + crate::state::extend_unique_channel_ids(&mut channels, &[shared_channel, public_only]); + + assert_eq!(channels, vec![member_channel, shared_channel, public_only]); + } + + #[test] + fn non_allowlisted_non_member_channel_filter_matches_nothing() { + let non_allowlisted = uuid::Uuid::new_v4(); + let other_readable = uuid::Uuid::new_v4(); + let filter = filter_with_channel(non_allowlisted); + + let per_filter_channel = extract_channel_id_from_filter(&filter); + assert_eq!(per_filter_channel, Some(non_allowlisted)); + assert!( + ![other_readable].contains(&non_allowlisted), + "handler must reject #h filters outside the effective readable set" + ); + } + // ── NIP-AE engram read gating ──────────────────────────────────────── /// Three real x-only pubkeys (valid for `PublicKey::from_hex`). Distinct, diff --git a/crates/sprout-relay/src/nip11.rs b/crates/sprout-relay/src/nip11.rs index 67e75fedb..c9191e4a6 100644 --- a/crates/sprout-relay/src/nip11.rs +++ b/crates/sprout-relay/src/nip11.rs @@ -59,21 +59,24 @@ pub struct RelayLimitation { /// Minimum proof-of-work difficulty required for events. pub min_pow_difficulty: Option, /// Whether NIP-42 authentication is required before subscribing or - /// publishing events. + /// publishing events. False only when unauthenticated reads are enabled for + /// the configured public channel allowlist; all writes still require auth. pub auth_required: bool, /// Whether payment is required to use the relay. pub payment_required: bool, - /// Whether writes are restricted to authorized pubkeys. + /// Whether writes are restricted to authorized pubkeys. Always true: public + /// readability never grants EVENT/write permission. pub restricted_writes: bool, } /// Canonical `RelayLimitation` advertised by this relay. /// -/// `auth_required` is always `true`: the REQ, EVENT, and COUNT handlers -/// unconditionally reject connections that are not in -/// `AuthState::Authenticated`. This is independent of the REST API token -/// toggle (`config.require_auth_token`). -fn relay_limitation() -> RelayLimitation { +/// Build the relay limitation block. +/// +/// `auth_required` is false when public-readable channels are configured because +/// unauthenticated REQ/COUNT are allowed for that constrained channel allowlist. +/// EVENT/write paths remain authenticated-only via `restricted_writes=true`. +fn relay_limitation(public_read_enabled: bool) -> RelayLimitation { RelayLimitation { max_message_length: Some(MAX_FRAME_BYTES as u64), max_subscriptions: Some(1024), @@ -81,7 +84,7 @@ fn relay_limitation() -> RelayLimitation { max_limit: Some(10_000), max_subid_length: Some(256), min_pow_difficulty: None, - auth_required: true, + auth_required: !public_read_enabled, payment_required: false, restricted_writes: true, } @@ -102,7 +105,11 @@ impl RelayInfo { /// gates on NIP-43 events — i.e. has a stable key AND enforces /// membership. NIP-43 events are verified against `self`, so it is a /// programmer error to advertise NIP-43 without a `relay_self`. - pub fn build(relay_self: Option<&str>, advertise_nip43: bool) -> Self { + pub fn build( + relay_self: Option<&str>, + advertise_nip43: bool, + public_read_enabled: bool, + ) -> Self { debug_assert!( !advertise_nip43 || relay_self.is_some(), "advertise_nip43=true requires relay_self=Some — NIP-43 events are verified against `self`" @@ -121,7 +128,7 @@ impl RelayInfo { supported_nips, software: "https://github.com/block/sprout".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), - limitation: Some(relay_limitation()), + limitation: Some(relay_limitation(public_read_enabled)), relay_self: relay_self.map(|s| s.to_string()), } } @@ -131,8 +138,12 @@ impl RelayInfo { pub async fn relay_info_handler( axum::extract::State(state): axum::extract::State>, ) -> axum::response::Json { - let (relay_self, advertise_nip43) = nip11_facts(&state); - axum::response::Json(RelayInfo::build(relay_self.as_deref(), advertise_nip43)) + let (relay_self, advertise_nip43, public_read_enabled) = nip11_facts(&state); + axum::response::Json(RelayInfo::build( + relay_self.as_deref(), + advertise_nip43, + public_read_enabled, + )) } /// Derives the two NIP-11 facts that depend on runtime config: @@ -147,11 +158,12 @@ pub async fn relay_info_handler( /// /// Centralised so the content-negotiated root handler and the dedicated /// `/info` endpoint can't drift apart. -pub(crate) fn nip11_facts(state: &crate::state::AppState) -> (Option, bool) { +pub(crate) fn nip11_facts(state: &crate::state::AppState) -> (Option, bool, bool) { let has_stable_key = state.config.relay_private_key.is_some(); let relay_self = has_stable_key.then(|| state.relay_keypair.public_key().to_hex()); let advertise_nip43 = has_stable_key && state.config.require_relay_membership; - (relay_self, advertise_nip43) + let public_read_enabled = !state.config.public_readable_channels.is_empty(); + (relay_self, advertise_nip43, public_read_enabled) } #[cfg(test)] @@ -185,7 +197,12 @@ mod tests { // REQ, EVENT, and COUNT all unconditionally require // `AuthState::Authenticated` (see `crates/sprout-relay/src/handlers/`), // so the NIP-11 doc must advertise it. - assert!(relay_limitation().auth_required); + assert!(relay_limitation(false).auth_required); + } + + #[test] + fn auth_required_is_advertised_false_when_public_read_enabled() { + assert!(!relay_limitation(true).auth_required); } #[test] @@ -214,7 +231,7 @@ mod tests { /// Open relay, ephemeral key — both `self` and NIP-43 are absent. #[test] fn build_open_relay_ephemeral_key_omits_self_and_nip43() { - let info = RelayInfo::build(None, false); + let info = RelayInfo::build(None, false, false); assert!(info.relay_self.is_none()); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -227,7 +244,7 @@ mod tests { #[test] fn build_open_relay_stable_key_advertises_self_but_not_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), false); + let info = RelayInfo::build(Some(pk), false, false); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -236,7 +253,7 @@ mod tests { #[test] fn build_membership_relay_advertises_self_and_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), true); + let info = RelayInfo::build(Some(pk), true, false); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -247,6 +264,6 @@ mod tests { #[test] #[should_panic(expected = "advertise_nip43=true requires relay_self=Some")] fn build_nip43_without_self_panics_in_debug() { - let _ = RelayInfo::build(None, true); + let _ = RelayInfo::build(None, true, false); } } diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index f72516bf3..ff932b8d2 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -153,10 +153,10 @@ async fn nip11_or_ws_handler( .and_then(|v| v.to_str().ok()) .unwrap_or(""); - let (relay_self, advertise_nip43) = nip11_facts(&state); + let (relay_self, advertise_nip43, public_read_enabled) = nip11_facts(&state); if accept.contains("application/nostr+json") { - let info = RelayInfo::build(relay_self.as_deref(), advertise_nip43); + let info = RelayInfo::build(relay_self.as_deref(), advertise_nip43, public_read_enabled); return Json(info).into_response(); } @@ -175,7 +175,8 @@ async fn nip11_or_ws_handler( } } // Not a WS request and not asking for nostr+json — serve NIP-11 as fallback. - let info = RelayInfo::build(relay_self.as_deref(), advertise_nip43); + let info = + RelayInfo::build(relay_self.as_deref(), advertise_nip43, public_read_enabled); Json(info).into_response() } } diff --git a/crates/sprout-relay/src/state.rs b/crates/sprout-relay/src/state.rs index 597ba6181..1d033dcef 100644 --- a/crates/sprout-relay/src/state.rs +++ b/crates/sprout-relay/src/state.rs @@ -445,6 +445,35 @@ impl AppState { self.accessible_channels_cache.invalidate_all(); } + /// Resolve configured public-readable channels to live, non-deleted channel IDs. + /// + /// DB errors fail closed by returning the error to the caller; invalid UUIDs are + /// already dropped during config parsing. + pub async fn public_readable_channel_ids(&self) -> Result, sprout_db::DbError> { + self.db + .filter_live_channel_ids(&self.config.public_readable_channels) + .await + } + + /// Get the effective channel read set for an authenticated caller. + /// + /// This is the single source of truth for authenticated reads: normal + /// membership/open-channel access, optionally narrowed by an API-token channel + /// allowlist, unioned with the relay's live public-readable channel policy. + pub async fn effective_readable_channel_ids( + &self, + pubkey: &[u8], + token_channel_ids: Option<&[Uuid]>, + ) -> Result, sprout_db::DbError> { + let mut ids = self.get_accessible_channel_ids_cached(pubkey).await?; + if let Some(allowed) = token_channel_ids { + ids.retain(|channel_id| allowed.contains(channel_id)); + } + let public_readable = self.public_readable_channel_ids().await?; + extend_unique_channel_ids(&mut ids, &public_readable); + Ok(ids) + } + /// Get accessible channel IDs with a 10-second cache. Falls back to DB on miss. pub async fn get_accessible_channel_ids_cached( &self, @@ -462,6 +491,15 @@ impl AppState { } } +/// Extend a channel-id set while preserving order and avoiding duplicates. +pub(crate) fn extend_unique_channel_ids(channels: &mut Vec, extra: &[Uuid]) { + for id in extra { + if !channels.contains(id) { + channels.push(*id); + } + } +} + /// Handle for graceful audit worker shutdown. /// /// Signals the worker to stop accepting new entries, drain its buffer, diff --git a/crates/sprout-relay/src/subscription.rs b/crates/sprout-relay/src/subscription.rs index f0cee1651..134028cd1 100644 --- a/crates/sprout-relay/src/subscription.rs +++ b/crates/sprout-relay/src/subscription.rs @@ -871,6 +871,29 @@ mod tests { assert_eq!(matches[0].0, conn_id); } + #[test] + fn authenticated_global_sub_does_not_fanout_public_channel_events() { + // Public-readable channels extend explicit channel reads only. An authenticated + // non-member's global subscription is still registered as channel_id=None, so + // fan-out must not deliver channel-scoped events from the public allowlist. + let registry = SubscriptionRegistry::new(); + let conn_id = Uuid::new_v4(); + let public_channel = Uuid::new_v4(); + + registry.register( + conn_id, + "global_public_reader".to_string(), + vec![Filter::new().kind(Kind::TextNote)], + None, + ); + + let public_channel_event = make_stored_event(Kind::TextNote, Some(public_channel)); + assert!( + registry.fan_out(&public_channel_event).is_empty(), + "global authenticated subscriptions must not receive public allowlist channel events" + ); + } + #[test] fn test_empty_kinds_array_remove_is_noop() { // Removing a kinds:[] subscription should not panic or corrupt the index.