diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9de2be20b..8624b19f0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -46,12 +46,14 @@ Sprout is a Rust monorepo (~22.7K LOC across 13 crates), licensed Apache 2.0 und │ audit) │ │ PUBLISH) │ └────────────┘ └───────────┘ - Fan-out is IN-PROCESS: sub_registry.fan_out() - → conn_manager.send_to() (direct to WS connections) + Fan-out: sub_registry.fan_out() → conn_manager.send_to() + (in-process for local events; Redis round-trip for + events from other relay instances) - Redis PUBLISH occurs for channel-scoped events but - the subscriber loop's broadcast is not yet consumed - by the relay for cross-process fan-out. + Redis PUBLISH occurs for channel-scoped events. + PSUBSCRIBE subscriber loop runs and a consumer task + fans out received events to local WS connections + (multi-node fan-out wired; local-echo dedup is TODO). ┌──────────────┐ │ Typesense │ ← sprout-search (async, spawned per event) @@ -130,7 +132,7 @@ The `kind` integer is the only dispatch switch. The relay routes, stores, and fa `sprout-core` defines all 74 kinds as `pub const KIND_*: u32` and exports `ALL_KINDS: &[u32]`. Kinds are `u32` (NIP-01 specifies unsigned integer; `u32` covers the full range). Sprout uses both standard Nostr kinds (e.g., kind 7 for reactions) and custom ranges (40000+). -Note: some protocol-relevant kinds are defined ad hoc in other crates rather than in `sprout-core`. For example, `KIND_AUTH` (22242) is hardcoded as a `u16` constant in `sprout-relay/src/handlers/event.rs`, and canvas kind 40100 is used as a literal in `sprout-mcp/src/server.rs`. Neither has a corresponding `pub const` in `sprout-core`. +Note: `KIND_AUTH` (22242) is `pub const KIND_AUTH: u32` in `sprout-core/src/kind.rs` and imported by `sprout-relay/src/handlers/event.rs`. `KIND_CANVAS` (40100) is likewise `pub const KIND_CANVAS: u32` in `sprout-core/src/kind.rs`; `sprout-mcp/src/server.rs` uses the constant via import. ### Wire Protocol (NIP-01 messages) @@ -220,7 +222,7 @@ When the relay receives `["EVENT", ]`, the handler in `handlers/event.rs` Steps 10–12 are fire-and-forget: they are spawned as independent async tasks. A failure in search indexing or audit logging does not fail the event submission. The client receives `["OK", , true, ""]` at the end of the pipeline (after all spawns), not immediately after DB insert. -Step 9 (fan-out) also checks global subscriptions (no `channel_id` constraint) — broad subscriptions receive channel-scoped events if their filters match. +Step 9 (fan-out) explicitly **excludes** global subscriptions (no `channel_id` constraint) from channel-scoped events — global subscriptions do NOT receive events from private channels, regardless of filter match. This is a deliberate security boundary: only subscriptions scoped to an accessible `channel_id` receive those events. Workflow loop prevention: kinds 46001–46012 (workflow execution events) are excluded from triggering workflows. Exception: stream message kind 40001 (`KIND_STREAM_MESSAGE`) always triggers regardless of other exclusion rules. Kind 40002 (`KIND_STREAM_MESSAGE_V2`) does not trigger workflows. @@ -280,7 +282,7 @@ When an event arrives, `fan_out` consults three indexes in order: | 2 | `channel_wildcard_index` | `channel_id` | Subs with channel but no `kinds` constraint | | 3 | `subs` (linear scan) | — | Global subs (no channel_id) — fallback scan | -Global subs also receive channel-scoped events if their filters match — tier 3 is always checked. +Global subs (tier 3) are checked for non-channel-scoped events only. Channel-scoped events are delivered exclusively to subscriptions that carry a matching `channel_id` — global subscriptions are explicitly excluded from channel fan-out as a security boundary. ### NIP-01 Edge Cases @@ -369,7 +371,7 @@ pub trait RateLimiter: Send + Sync { ... } - Token format: `sprout_<64-hex-chars>` (71 chars). `hash_token()` → SHA-256 → stored hash. - Scopeless JWT defaults to `[MessagesRead]` only (not read+write). - NIP-42 timestamp tolerance: ±60 seconds. -- Dev-only key derivation: `SHA-256("sprout-test-key:{username}")` — gated behind `#[cfg(any(test, feature = "dev", debug_assertions))]`. +- Dev-only key derivation: `SHA-256("sprout-test-key:{username}")` — gated behind `#[cfg(any(test, feature = "dev"))]`. The `dev` feature must not be enabled in production relay deployments. **Does NOT:** implement `RateLimiter` beyond a test stub (`AlwaysAllowRateLimiter`, gated behind `#[cfg(any(test, feature = "test-utils"))]`). No Redis-backed rate limiter exists anywhere in the codebase — rate limiting is not currently enforced. `RateLimitConfig` defines 4 tiers (human, agent-standard, agent-elevated, agent-platform) as a design target. @@ -423,7 +425,7 @@ Subscriber → dedicated PubSub → PSUBSCRIBE sprout:channel:* The subscriber uses a **dedicated** `redis::aio::PubSub` connection — not from the pool. This is intentional: pool connections cannot hold `PSUBSCRIBE` state. -**Current state:** The subscriber loop runs and populates the broadcast channel, but `sprout-relay` does not currently consume the broadcast for WebSocket fan-out. Real-time delivery is handled entirely in-process via `sub_registry.fan_out()`. The Redis pub/sub infrastructure is in place for future multi-node fan-out. +**Current state:** The subscriber loop is spawned in `sprout-relay/src/main.rs` and populates the broadcast channel. A consumer task subscribes via `pubsub.subscribe_local()`, calls `sub_registry.fan_out()` on each received event, and delivers matches to local WebSocket connections via `conn_manager.send_to()`. Multi-node fan-out is now wired end-to-end. Note: local-echo deduplication is not yet implemented — events published by the local relay instance are re-delivered to local subscribers via the Redis round-trip; NIP-01 client-side dedup handles this in practice (TODO: server-side dedup in a follow-up). **Reconnection:** exponential backoff 1s → 30s (`backoff_secs * 2`). Backoff resets to 1s only after a clean stream end, not on each reconnect attempt. @@ -826,9 +828,9 @@ These are verified gaps in the current implementation — not design aspirations | 1 | **No sqlx offline query cache** | Uses `sqlx::query()` (runtime) not `sqlx::query!()` (compile-time). No `.sqlx/` directory. Queries are not validated at compile time. | | 2 | **Feed mentions: full table scan** | `query_mentions` uses `JSON_CONTAINS(tags, '["p",""]', '$')` — no index on JSON column. Phase 2 mitigation plan documented in `sprout-db/src/feed.rs`: normalized `mentions` table with composite index on `(pubkey_hex, created_at)`. | | 3 | **No rate limiting implementation** | `RateLimiter` trait exists in `sprout-auth`. Only implementation is `AlwaysAllowRateLimiter` (test stub, gated behind `#[cfg(any(test, feature = "test-utils"))]`). `RateLimitConfig` defines 4 tiers (human, agent-standard, agent-elevated, agent-platform) but none are enforced. | -| 4 | **Single-process fan-out** | `SubscriptionRegistry` is in-process DashMap. Redis `PUBLISH` occurs for channel-scoped events, and a `PSUBSCRIBE` subscriber loop runs, but the relay does not consume the broadcast stream for WebSocket delivery. Fan-out is entirely in-process. Running multiple relay instances would result in split fan-out. | +| 4 | **Local-echo deduplication** | Multi-node fan-out is wired: the Redis `PSUBSCRIBE` subscriber loop runs, and a consumer task fans out received events to local WebSocket connections. However, events published by the local relay instance are re-delivered to local subscribers via the Redis round-trip (no server-side dedup). NIP-01 client-side dedup handles this in practice. Server-side dedup is a TODO. | | 5 | **Cron scheduler is a stub** | `WorkflowEngine::run()` loops every 60 seconds but the loop body logs "not yet implemented" (TODO WF-07). Schedule-triggered workflows do not fire. | -| 6 | **Typing indicators not delivered** | Typing events (kind 20002) are published to Redis via the ephemeral pipeline but never reach WebSocket subscribers — the relay does not consume the Redis broadcast stream, and non-presence ephemeral events have no local fan-out path. Typing state is queryable via the REST `/api/presence` endpoint but not pushed in real-time. | +| 6 | **Typing indicators: cross-node only** | Typing events (kind 20002) are published to Redis via the ephemeral pipeline. The multi-node consumer task fans them out to local WS subscribers when received from Redis (cross-node path). However, there is no direct local fan-out for typing events on the originating node — they travel Redis → broadcast → WS rather than being fanned out in-process before the Redis round-trip. Typing state is also queryable via the REST `/api/presence` endpoint. | | 7 | **sprout-huddle is scaffolding** | `sprout-huddle` defines types, token generation, and webhook parsing, but relay-side lifecycle event emission is not implemented. Huddle state events are not wired into the relay's event pipeline. `sprout-proxy` is now functional — see its section above. | --- diff --git a/Cargo.lock b/Cargo.lock index ae2ec150a..df2de6541 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2834,6 +2834,7 @@ dependencies = [ "redis", "serde", "serde_json", + "sprout-auth", "sprout-core", "thiserror", "tokio", diff --git a/crates/sprout-acp/src/relay.rs b/crates/sprout-acp/src/relay.rs index 2a981fb25..e5a6a6ba4 100644 --- a/crates/sprout-acp/src/relay.rs +++ b/crates/sprout-acp/src/relay.rs @@ -207,7 +207,12 @@ impl HarnessRelay { Ok(Self { event_rx, cmd_tx, - http: reqwest::Client::new(), + // SAFETY: default builder with only timeout config cannot fail + http: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .connect_timeout(std::time::Duration::from_secs(5)) + .build() + .expect("SAFETY: default builder with only timeout config cannot fail"), relay_url: relay_url.to_string(), api_token: api_token.map(|t| t.to_string()), keys: keys.clone(), diff --git a/crates/sprout-auth/src/lib.rs b/crates/sprout-auth/src/lib.rs index 39584e34a..58675662f 100644 --- a/crates/sprout-auth/src/lib.rs +++ b/crates/sprout-auth/src/lib.rs @@ -113,7 +113,11 @@ impl AuthService { Self { config, jwks_cache: JwksCache::new(), - http_client: reqwest::Client::new(), + http_client: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .connect_timeout(std::time::Duration::from_secs(5)) + .build() + .expect("SAFETY: default builder with timeout config cannot fail"), } } @@ -319,7 +323,7 @@ impl AuthService { /// /// # ⚠️ SECURITY — Dev/test only /// -/// This function is gated behind `#[cfg(any(test, feature = "dev", debug_assertions))]` +/// This function is gated behind `#[cfg(any(test, feature = "dev"))]` /// and **must never be compiled into a production release build**. /// /// - The derived keys are deterministic and predictable from the username alone. @@ -331,9 +335,8 @@ impl AuthService { /// | Build command | Included? | Reason | /// |---|---|---| /// | `cargo test` | ✅ Yes | `test` cfg | -/// | `cargo build` (debug) | ✅ Yes | `debug_assertions` | -/// | `cargo run` (debug) | ✅ Yes | `debug_assertions` | -/// | `cargo build --release` | ❌ No | Neither `test` nor `debug_assertions` nor `dev` feature | +/// | `cargo build` (debug) | ❌ No | Not included without `dev` feature | +/// | `cargo build --release` | ❌ No | Neither `test` nor `dev` feature | /// | `cargo build --release --features dev` | ✅ Yes | `dev` feature — use only for integration harnesses | /// /// ## The `dev` feature @@ -342,6 +345,7 @@ impl AuthService { /// helpers) in release-mode integration test harnesses. It must **not** be /// enabled in production relay deployments. Check `sprout-relay/Cargo.toml` to /// ensure `sprout-auth` is not listed with `features = ["dev"]` in production. +#[cfg(any(test, feature = "dev"))] pub fn derive_pubkey_from_username(username: &str) -> Result { use sha2::{Digest, Sha256}; let seed = format!("sprout-test-key:{username}"); diff --git a/crates/sprout-db/src/feed.rs b/crates/sprout-db/src/feed.rs index 2b6a13ffe..8eb606aab 100644 --- a/crates/sprout-db/src/feed.rs +++ b/crates/sprout-db/src/feed.rs @@ -7,18 +7,19 @@ //! //! ## Performance characteristics //! -//! `query_mentions` and `query_needs_action` use `JSON_CONTAINS` on the `tags` column. -//! `JSON_CONTAINS` performs a **full table scan** — it cannot use a B-tree index on the -//! JSON column. For small deployments this is acceptable, but at scale (>100k events) -//! it will become the dominant query cost. +//! `query_mentions` and `query_needs_action` join against the `event_mentions` table, +//! which carries composite indexes on `(pubkey_hex, event_created_at DESC)` and +//! `(pubkey_hex, event_kind, event_created_at DESC)`. This replaces the Phase 1 +//! `JSON_CONTAINS` full-table scan with an indexed lookup, keeping feed queries +//! sub-millisecond at scale (>100k events). //! -//! **Phase 2 mitigation**: replace the `JSON_CONTAINS` scan with a normalised `mentions` -//! table (event_id, pubkey_hex) populated by a trigger or application-level write path. -//! That table can carry a composite index on `(pubkey_hex, created_at)` and reduce the -//! fan-out to a simple indexed lookup. +//! **Phase 2 implemented**: the `event_mentions` table is populated by +//! [`crate::insert_mentions`] on every event insert. `query_mentions` and +//! `query_needs_action` now use `INNER JOIN event_mentions` instead of +//! `JSON_CONTAINS`. //! -//! Until Phase 2 lands, all feed queries enforce a hard `LIMIT` cap of `FEED_MAX_LIMIT` -//! rows to bound the result-set size and prevent runaway memory usage. +//! All feed queries enforce a hard `LIMIT` cap of `FEED_MAX_LIMIT` rows to bound +//! the result-set size and prevent runaway memory usage. /// Hard upper bound on rows returned by any feed query. /// @@ -42,10 +43,8 @@ use crate::event::row_to_stored_event; /// Find events that @mention the given pubkey (have `["p", pubkey_hex]` in tags). /// -/// Uses `JSON_CONTAINS` on the `tags` column — Phase 1 implementation. -/// **Performance**: `JSON_CONTAINS` is a full table scan (no index). See module-level -/// docs for the Phase 2 migration plan. -/// Phase 2: replace with indexed `mentions` table lookup. +/// Joins against the `event_mentions` table — Phase 2 implementation. +/// **Performance**: indexed lookup on `(pubkey_hex, event_created_at DESC)`. /// /// Only returns events from `accessible_channel_ids` for access control. /// `limit` is capped at [`FEED_MAX_LIMIT`] regardless of the value passed by the caller. @@ -60,24 +59,21 @@ pub async fn query_mentions( let pubkey_hex = hex::encode(pubkey_bytes); let mut qb: QueryBuilder = QueryBuilder::new( - "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ - FROM events WHERE 1=1", + "SELECT e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, e.sig, \ + e.received_at, e.channel_id \ + FROM events e \ + INNER JOIN event_mentions m ON e.id = m.event_id \ + WHERE m.pubkey_hex = ", ); - - // Tag filter: JSON array contains the sub-array ["p", ""] as an element. - // We wrap in an outer array so MySQL checks for exact sub-array membership, not - // element-wise containment. Without the outer array, JSON_CONTAINS(tags, '["p","x"]') - // returns TRUE whenever "p" AND "x" both appear *anywhere* in tags — wrong semantics. - qb.push(" AND JSON_CONTAINS(tags, ") - .push_bind(serde_json::json!([["p", pubkey_hex]]).to_string()) - .push(", '$')"); + qb.push_bind(&pubkey_hex); + qb.push(" AND e.deleted_at IS NULL"); qb.push(format!( - " AND kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, {KIND_FORUM_POST}, {KIND_FORUM_COMMENT})" + " AND e.kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, {KIND_FORUM_POST}, {KIND_FORUM_COMMENT})" )); if !accessible_channel_ids.is_empty() { - qb.push(" AND channel_id IN ("); + qb.push(" AND e.channel_id IN ("); let mut sep = qb.separated(", "); for id in accessible_channel_ids { sep.push_bind(id.as_bytes().to_vec()); @@ -86,10 +82,11 @@ pub async fn query_mentions( } if let Some(s) = since { - qb.push(" AND created_at >= ").push_bind(s); + qb.push(" AND m.event_created_at >= ").push_bind(s); } - qb.push(" ORDER BY created_at DESC LIMIT ").push_bind(limit); + qb.push(" ORDER BY m.event_created_at DESC LIMIT ") + .push_bind(limit); let rows = qb.build().fetch_all(pool).await?; let mut out = Vec::with_capacity(rows.len()); @@ -107,7 +104,8 @@ pub async fn query_mentions( /// /// Only returns events from channels the user has access to (`accessible_channel_ids`). /// This prevents surfacing approval requests from channels the user was removed from. -/// **Performance**: uses `JSON_CONTAINS` — full table scan. See module-level docs. +/// **Performance**: indexed lookup via `event_mentions` join on +/// `(pubkey_hex, event_kind, event_created_at DESC)`. /// `limit` is capped at [`FEED_MAX_LIMIT`] regardless of the value passed by the caller. pub async fn query_needs_action( pool: &MySqlPool, @@ -120,22 +118,21 @@ pub async fn query_needs_action( let pubkey_hex = hex::encode(pubkey_bytes); let mut qb: QueryBuilder = QueryBuilder::new( - "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ - FROM events WHERE 1=1", + "SELECT e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, e.sig, \ + e.received_at, e.channel_id \ + FROM events e \ + INNER JOIN event_mentions m ON e.id = m.event_id \ + WHERE m.pubkey_hex = ", ); + qb.push_bind(&pubkey_hex); + qb.push(" AND e.deleted_at IS NULL"); qb.push(format!( - " AND kind IN ({KIND_WORKFLOW_APPROVAL_REQUESTED}, {KIND_STREAM_REMINDER})" + " AND e.kind IN ({KIND_WORKFLOW_APPROVAL_REQUESTED}, {KIND_STREAM_REMINDER})" )); - // Wrap in outer array so MySQL checks for exact sub-array membership — see - // query_mentions for a full explanation of the JSON_CONTAINS semantics. - qb.push(" AND JSON_CONTAINS(tags, ") - .push_bind(serde_json::json!([["p", pubkey_hex]]).to_string()) - .push(", '$')"); - if !accessible_channel_ids.is_empty() { - qb.push(" AND channel_id IN ("); + qb.push(" AND e.channel_id IN ("); let mut sep = qb.separated(", "); for id in accessible_channel_ids { sep.push_bind(id.as_bytes().to_vec()); @@ -144,10 +141,11 @@ pub async fn query_needs_action( } if let Some(s) = since { - qb.push(" AND created_at >= ").push_bind(s); + qb.push(" AND m.event_created_at >= ").push_bind(s); } - qb.push(" ORDER BY created_at DESC LIMIT ").push_bind(limit); + qb.push(" ORDER BY m.event_created_at DESC LIMIT ") + .push_bind(limit); let rows = qb.build().fetch_all(pool).await?; let mut out = Vec::with_capacity(rows.len()); @@ -177,6 +175,8 @@ pub async fn query_activity( FROM events WHERE 1=1", ); + qb.push(" AND deleted_at IS NULL"); + qb.push(format!( " AND kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, {KIND_FORUM_POST}, {KIND_JOB_REQUEST}, {KIND_JOB_PROGRESS}, {KIND_JOB_RESULT})" )); diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs index 872bee559..f431101e1 100644 --- a/crates/sprout-db/src/lib.rs +++ b/crates/sprout-db/src/lib.rs @@ -45,6 +45,67 @@ use sprout_core::StoredEvent; use crate::event::uuid_from_bytes; +/// Extract p-tag mentions from an event and insert into the `event_mentions` table. +/// +/// Called after event insertion. Failures are logged but do not block event storage. +/// Uses `INSERT IGNORE` so duplicate inserts (e.g. on retry) are silently skipped. +pub async fn insert_mentions( + pool: &MySqlPool, + event: &nostr::Event, + channel_id: Option, +) -> Result<()> { + let p_tags: Vec<&str> = event + .tags + .iter() + .filter_map(|tag| { + let tag_vec = tag.as_slice(); + if tag_vec.len() >= 2 && tag_vec[0] == "p" { + Some(tag_vec[1].as_str()) + } else { + None + } + }) + .collect(); + + if p_tags.is_empty() { + return Ok(()); + } + + let event_id_bytes = event.id.as_bytes(); + let created_at_secs = event.created_at.as_u64() as i64; + let created_at = DateTime::from_timestamp(created_at_secs, 0) + .ok_or(crate::error::DbError::InvalidTimestamp(created_at_secs))?; + let channel_id_bytes = channel_id.map(|id| id.as_bytes().to_vec()); + let kind = event.kind.as_u16() as u32; + + for pubkey_hex in p_tags { + // Validate: must be exactly 64 hex characters (32-byte pubkey) + if pubkey_hex.len() != 64 || !pubkey_hex.chars().all(|c| c.is_ascii_hexdigit()) { + tracing::debug!( + event_id = %event.id, + invalid_ptag = pubkey_hex, + "skipping malformed p-tag in mentions insert" + ); + continue; + } + // Normalize to lowercase — queries use hex::encode which produces lowercase. + let pubkey_lower = pubkey_hex.to_ascii_lowercase(); + sqlx::query( + "INSERT IGNORE INTO event_mentions \ + (pubkey_hex, event_id, event_created_at, channel_id, event_kind) \ + VALUES (?, ?, ?, ?, ?)", + ) + .bind(&pubkey_lower) + .bind(event_id_bytes.as_slice()) + .bind(created_at) + .bind(channel_id_bytes.as_deref()) + .bind(kind) + .execute(pool) + .await?; + } + Ok(()) +} + /// Database handle. Clone is cheap (Arc-backed pool). #[derive(Clone, Debug)] pub struct Db { @@ -131,7 +192,13 @@ impl Db { event: &nostr::Event, channel_id: Option, ) -> Result<(StoredEvent, bool)> { - event::insert_event(&self.pool, event, channel_id).await + let result = event::insert_event(&self.pool, event, channel_id).await?; + if result.1 { + if let Err(e) = insert_mentions(&self.pool, event, channel_id).await { + tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); + } + } + Ok(result) } /// Queries events matching the given filter parameters. @@ -166,7 +233,15 @@ impl Db { channel_id: Option, thread_meta: Option>, ) -> Result<(StoredEvent, bool)> { - event::insert_event_with_thread_metadata(&self.pool, ev, channel_id, thread_meta).await + let result = + event::insert_event_with_thread_metadata(&self.pool, ev, channel_id, thread_meta) + .await?; + if result.1 { + if let Err(e) = insert_mentions(&self.pool, ev, channel_id).await { + tracing::warn!(event_id = %ev.id, "Failed to insert mentions: {e}"); + } + } + Ok(result) } /// Soft-delete an event. Returns `true` if the event was deleted. diff --git a/crates/sprout-mcp/src/relay_client.rs b/crates/sprout-mcp/src/relay_client.rs index 3db7f59a4..ed1fed5df 100644 --- a/crates/sprout-mcp/src/relay_client.rs +++ b/crates/sprout-mcp/src/relay_client.rs @@ -420,7 +420,12 @@ impl RelayClient { Ok(Self { keys: keys.clone(), relay_url: relay_url.to_string(), - http: reqwest::Client::new(), + // SAFETY: default builder with only timeout config cannot fail + http: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .connect_timeout(std::time::Duration::from_secs(5)) + .build() + .expect("SAFETY: default builder with only timeout config cannot fail"), inner: Arc::new(Mutex::new(inner)), api_token: api_token.map(|t| t.to_string()), active_subscriptions: Arc::new(Mutex::new(HashMap::new())), diff --git a/crates/sprout-proxy/src/channel_map.rs b/crates/sprout-proxy/src/channel_map.rs index 4d3e219c0..fcb0d15cc 100644 --- a/crates/sprout-proxy/src/channel_map.rs +++ b/crates/sprout-proxy/src/channel_map.rs @@ -85,10 +85,11 @@ impl ChannelMap { .to_string(); // nostr 0.36: EventBuilder::new(kind, content, tags) + // SAFETY: signing with a pre-validated Keys instance cannot fail EventBuilder::new(Kind::ChannelCreation, content, []) .custom_created_at(Timestamp::from(created_at_unix)) .sign_with_keys(&self.server_keys) - .expect("signing with valid keys cannot fail") + .expect("SAFETY: signing with valid keys cannot fail") } /// Synthesize a NIP-28 kind:41 channel metadata event that references the @@ -101,14 +102,17 @@ impl ChannelMap { }) .to_string(); + // SAFETY: kind40_event_id is always a valid hex string — it was produced by event.id.to_hex() in register() let e_tag = Tag::event( - EventId::from_hex(&info.kind40_event_id).expect("kind40_event_id is valid hex"), + EventId::from_hex(&info.kind40_event_id) + .expect("SAFETY: kind40_event_id is always valid hex from event.id.to_hex()"), ); + // SAFETY: signing with a pre-validated Keys instance cannot fail EventBuilder::new(Kind::ChannelMetadata, content, [e_tag]) .custom_created_at(Timestamp::from(info.created_at_unix)) .sign_with_keys(&self.server_keys) - .expect("signing with valid keys cannot fail") + .expect("SAFETY: signing with valid keys cannot fail") } } @@ -135,7 +139,12 @@ impl ChannelMap { ) -> Result> { let map = Self::new(server_keys); - let client = reqwest::Client::new(); + // SAFETY: default builder with only timeout config cannot fail + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .connect_timeout(std::time::Duration::from_secs(5)) + .build() + .expect("SAFETY: default builder with only timeout config cannot fail"); let channels: Vec = client .get(format!("{}/api/channels", api_base)) .header("Authorization", format!("Bearer {}", api_token)) diff --git a/crates/sprout-proxy/src/translate.rs b/crates/sprout-proxy/src/translate.rs index a215e087e..b303af25d 100644 --- a/crates/sprout-proxy/src/translate.rs +++ b/crates/sprout-proxy/src/translate.rs @@ -491,8 +491,10 @@ impl Translator { // Build translated tag list: replace the channel `#e` with `#h`, keep everything else. let mut new_tags: Vec = Vec::new(); + // SAFETY: ["h", ] is always a valid 2-element tag structure new_tags.push( - Tag::parse(&["h", &channel_info.uuid.to_string()]).expect("h tag is always valid"), + Tag::parse(&["h", &channel_info.uuid.to_string()]) + .expect("SAFETY: [\"h\", uuid_string] is always a valid tag structure"), ); for tag in event.tags.iter() { let s = tag.as_slice(); @@ -517,8 +519,12 @@ impl Translator { // Re-sign with the shadow key for the external user. let shadow_keys = self.shadow_keys.get_or_create(external_pubkey)?; + // SAFETY: sprout_kind is derived from KindTranslator which maps to known Sprout kinds (40001, 40002, 40003) that fit in u16 let translated = EventBuilder::new( - Kind::Custom(u16::try_from(sprout_kind).expect("sprout kind must fit in u16")), + Kind::Custom( + u16::try_from(sprout_kind) + .expect("SAFETY: sprout kind values (40001, 40002, 40003) always fit in u16"), + ), &event.content, new_tags, ) @@ -552,7 +558,11 @@ impl Translator { .map(|k| { let k_u32 = k.as_u16() as u32; let sprout_k = self.kind_translator.to_sprout(k_u32); - Kind::Custom(u16::try_from(sprout_k).expect("sprout kind must fit in u16")) + // SAFETY: sprout kind values (40001, 40002, 40003) always fit in u16 + Kind::Custom( + u16::try_from(sprout_k) + .expect("SAFETY: sprout kind values always fit in u16"), + ) }) .collect(); // Rebuild via the builder to stay consistent with nostr's internal state. @@ -624,7 +634,11 @@ impl Translator { .map(|k| { let k_u32 = k.as_u16() as u32; let standard_k = self.kind_translator.to_standard(k_u32); - Kind::Custom(u16::try_from(standard_k).expect("standard kind must fit in u16")) + // SAFETY: standard kind values (42, 41) always fit in u16 + Kind::Custom( + u16::try_from(standard_k) + .expect("SAFETY: standard kind values always fit in u16"), + ) }) .collect(); f = f.remove_kinds(kinds.iter().cloned()).kinds(new_kinds); diff --git a/crates/sprout-pubsub/Cargo.toml b/crates/sprout-pubsub/Cargo.toml index 01b2c5f3b..56329b20b 100644 --- a/crates/sprout-pubsub/Cargo.toml +++ b/crates/sprout-pubsub/Cargo.toml @@ -9,6 +9,7 @@ description = "Redis pub/sub fan-out, presence, and typing indicators for Sprout [dependencies] sprout-core = { workspace = true } +sprout-auth = { workspace = true } redis = { workspace = true } deadpool-redis = { workspace = true } tokio = { workspace = true } diff --git a/crates/sprout-pubsub/src/lib.rs b/crates/sprout-pubsub/src/lib.rs index 5b5d6fda2..a538327d4 100644 --- a/crates/sprout-pubsub/src/lib.rs +++ b/crates/sprout-pubsub/src/lib.rs @@ -27,6 +27,8 @@ pub mod error; pub mod presence; /// Redis PUBLISH for channel event fan-out. pub mod publisher; +/// Redis-backed rate limiter (fixed-window INCR + EXPIRE). +pub mod rate_limiter; /// Redis SUBSCRIBE for channel event delivery. pub mod subscriber; /// Typing indicator tracking in Redis. diff --git a/crates/sprout-pubsub/src/rate_limiter.rs b/crates/sprout-pubsub/src/rate_limiter.rs new file mode 100644 index 000000000..9b8148781 --- /dev/null +++ b/crates/sprout-pubsub/src/rate_limiter.rs @@ -0,0 +1,117 @@ +//! Redis-backed rate limiter using atomic Lua script (INCR + EXPIRE). +//! +//! Implements the [`RateLimiter`] trait from `sprout-auth`. +//! Uses a single Lua script to atomically INCR and conditionally EXPIRE, +//! eliminating the crash window where a key could exist without a TTL. +//! +//! ⚠️ Fixed windows allow up to 2× burst at boundaries. Upgrade to sliding +//! window or token bucket for strict limiting. + +use std::net::IpAddr; + +use nostr::PublicKey; +use redis::Script; +use sprout_auth::{ + error::AuthError, + rate_limit::{LimitType, RateLimitResult, RateLimiter}, +}; + +/// Atomically INCR the key, set EXPIRE on first call, and return (count, ttl). +/// +/// Using a Lua script ensures INCR and EXPIRE are executed atomically — +/// a crash between them can no longer leave a key without a TTL. +const RATE_LIMIT_SCRIPT: &str = r#" +local count = redis.call('INCR', KEYS[1]) +if count == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) +end +local ttl = redis.call('TTL', KEYS[1]) +return {count, ttl} +"#; + +/// Run the atomic rate-limit Lua script against `key` and return a +/// [`RateLimitResult`]. +/// +/// If the TTL comes back negative (key exists without expiry — broken state +/// from a prior crash), the key is repaired with a fresh EXPIRE and a warning +/// is logged. +async fn run_rate_limit( + pool: &deadpool_redis::Pool, + key: &str, + window_secs: u64, + limit: u64, +) -> Result { + let mut conn = pool + .get() + .await + .map_err(|e| AuthError::Internal(format!("Redis pool: {e}")))?; + + let script = Script::new(RATE_LIMIT_SCRIPT); + let (count, ttl): (u64, i64) = script + .key(key) + .arg(window_secs as i64) + .invoke_async(&mut *conn) + .await + .map_err(|e| AuthError::Internal(format!("Redis rate limit script: {e}")))?; + + // ttl == -1 means the key exists but has no expiry — broken state from a + // prior crash between INCR and EXPIRE. Repair it now. + let reset_in_secs = if ttl < 0 { + tracing::warn!(key = %key, "rate limit key has no TTL — repairing"); + let _: () = redis::cmd("EXPIRE") + .arg(key) + .arg(window_secs as i64) + .query_async(&mut *conn) + .await + .map_err(|e| AuthError::Internal(format!("Redis EXPIRE repair: {e}")))?; + // After repair, the window resets to the full duration. + window_secs + } else { + ttl.max(0) as u64 + }; + + if count <= limit { + Ok(RateLimitResult::allowed(count, limit, reset_in_secs)) + } else { + Ok(RateLimitResult::denied(count, limit, reset_in_secs)) + } +} + +/// Redis-backed rate limiter using fixed-window counters. +/// +/// Each key is `sprout:ratelimit::` (pubkey) or +/// `sprout:ratelimit:ip::conn` (IP). The counter and its TTL are managed +/// atomically via a Lua script to prevent keys from persisting without expiry. +pub struct RedisRateLimiter { + pool: deadpool_redis::Pool, +} + +impl RedisRateLimiter { + /// Create a new `RedisRateLimiter` backed by the given connection pool. + pub fn new(pool: deadpool_redis::Pool) -> Self { + Self { pool } + } +} + +impl RateLimiter for RedisRateLimiter { + async fn check_and_increment( + &self, + pubkey: &PublicKey, + limit_type: LimitType, + window_secs: u64, + limit: u64, + ) -> Result { + let key = sprout_auth::rate_limit::rate_limit_key(pubkey, &limit_type); + run_rate_limit(&self.pool, &key, window_secs, limit).await + } + + async fn check_ip_connection( + &self, + ip: &IpAddr, + window_secs: u64, + limit: u64, + ) -> Result { + let key = sprout_auth::rate_limit::ip_rate_limit_key(ip); + run_rate_limit(&self.pool, &key, window_secs, limit).await + } +} diff --git a/crates/sprout-relay/Cargo.toml b/crates/sprout-relay/Cargo.toml index d772bb77c..1a40a1960 100644 --- a/crates/sprout-relay/Cargo.toml +++ b/crates/sprout-relay/Cargo.toml @@ -45,5 +45,9 @@ hex = { workspace = true } url = { workspace = true } moka = { workspace = true } +[features] +dev = ["sprout-auth/dev"] + [dev-dependencies] sprout-core = { workspace = true, features = ["test-utils"] } +sprout-auth = { workspace = true, features = ["dev"] } diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index b15b88cca..80b5d0106 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -74,6 +74,7 @@ pub use workflows::{ // ── Shared helpers ──────────────────────────────────────────────────────────── +#[cfg(any(test, feature = "dev"))] use std::collections::HashMap; use std::time::{Duration, Instant}; @@ -301,51 +302,63 @@ pub(crate) async fn extract_auth_context( } } else { // Dev mode: decode JWT payload without JWKS validation. - match decode_jwt_payload_unverified(token) { - Ok(claims) => { - if let Some(username) = - claims.get("preferred_username").and_then(|v| v.as_str()) - { - match sprout_auth::derive_pubkey_from_username(username) { - Ok(pubkey) => { - let pubkey_bytes = pubkey.serialize().to_vec(); - if let Err(e) = state.db.ensure_user(&pubkey_bytes).await { - tracing::warn!("ensure_user failed: {e}"); + // Only compiled when the `dev` feature is enabled — disabled in release builds. + #[cfg(any(test, feature = "dev"))] + { + match decode_jwt_payload_unverified(token) { + Ok(claims) => { + if let Some(username) = + claims.get("preferred_username").and_then(|v| v.as_str()) + { + match sprout_auth::derive_pubkey_from_username(username) { + Ok(pubkey) => { + let pubkey_bytes = pubkey.serialize().to_vec(); + if let Err(e) = state.db.ensure_user(&pubkey_bytes).await { + tracing::warn!("ensure_user failed: {e}"); + } + return Ok(RestAuthContext { + pubkey, + pubkey_bytes, + scopes: vec![Scope::MessagesRead, Scope::MessagesWrite], + auth_method: RestAuthMethod::OktaJwt, + token_id: None, + channel_ids: None, + }); + } + Err(_) => { + tracing::warn!("auth: key derivation failed for username"); + return Err(( + StatusCode::UNAUTHORIZED, + Json( + serde_json::json!({ "error": "authentication failed" }), + ), + )); } - return Ok(RestAuthContext { - pubkey, - pubkey_bytes, - scopes: vec![Scope::MessagesRead, Scope::MessagesWrite], - auth_method: RestAuthMethod::OktaJwt, - token_id: None, - channel_ids: None, - }); - } - Err(_) => { - tracing::warn!("auth: key derivation failed for username"); - return Err(( - StatusCode::UNAUTHORIZED, - Json( - serde_json::json!({ "error": "authentication failed" }), - ), - )); } } + tracing::warn!("auth: JWT missing preferred_username claim"); + return Err(( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ "error": "authentication failed" })), + )); + } + Err(_) => { + tracing::warn!("auth: malformed JWT"); + return Err(( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ "error": "authentication failed" })), + )); } - tracing::warn!("auth: JWT missing preferred_username claim"); - return Err(( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ "error": "authentication failed" })), - )); - } - Err(_) => { - tracing::warn!("auth: malformed JWT"); - return Err(( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ "error": "authentication failed" })), - )); } } + #[cfg(not(any(test, feature = "dev")))] + { + tracing::warn!("auth: dev-mode JWT auth disabled in release builds"); + return Err(( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ "error": "authentication failed" })), + )); + } } } } @@ -455,6 +468,7 @@ pub(crate) fn forbidden(msg: &str) -> (StatusCode, Json) { /// Decode a JWT payload segment without signature verification. /// Used in dev mode (`require_auth_token=false`) to extract `preferred_username`. +#[cfg(any(test, feature = "dev"))] fn decode_jwt_payload_unverified( token: &str, ) -> Result, String> { diff --git a/crates/sprout-relay/src/api/tokens.rs b/crates/sprout-relay/src/api/tokens.rs index 687b10746..af52bd288 100644 --- a/crates/sprout-relay/src/api/tokens.rs +++ b/crates/sprout-relay/src/api/tokens.rs @@ -80,7 +80,7 @@ impl MintRateLimiter { .cache .entry(*pubkey_bytes) .or_insert_with(|| Arc::new(std::sync::Mutex::new(VecDeque::new()))); - let mut timestamps = entry.value().lock().unwrap(); + let mut timestamps = entry.value().lock().unwrap_or_else(|e| e.into_inner()); // Evict timestamps that have fallen outside the rolling window. while timestamps @@ -92,6 +92,7 @@ impl MintRateLimiter { } if timestamps.len() >= self.limit { + // SAFETY: vec is guaranteed non-empty by prior check (len >= limit > 0) let retry_after = MINT_WINDOW - now.duration_since(*timestamps.front().unwrap()); return Err(retry_after); } @@ -244,7 +245,8 @@ pub async fn post_tokens( // POST /api/tokens requires the payload tag — body must be // cryptographically bound to the signed event. let event: nostr::Event = - nostr::Event::from_json(&event_json).expect("already verified"); + // SAFETY: event_json was already parsed and verified by verify_nip98_event above + nostr::Event::from_json(&event_json).expect("SAFETY: already verified by verify_nip98_event"); let has_payload = event.tags.find(nostr::TagKind::Payload).is_some(); if !has_payload { tracing::warn!("post_tokens: NIP-98 event missing required payload tag"); @@ -305,7 +307,8 @@ pub async fn post_tokens( let mut parsed_scopes: Vec = Vec::with_capacity(req.scopes.len()); for s in &req.scopes { - let scope: Scope = s.parse().expect("infallible"); + // SAFETY: Scope::from_str is infallible — unknown values map to Scope::Unknown(_) + let scope: Scope = s.parse().expect("SAFETY: Scope::from_str is infallible"); match &scope { Scope::Unknown(_) => { return Err(( @@ -556,9 +559,13 @@ pub async fn post_tokens( expires_at: expires_at.map(|t| t.to_rfc3339()), }; + // SAFETY: MintTokenResponse contains only String/Uuid/Vec fields — serialization is infallible Ok(( StatusCode::CREATED, - Json(serde_json::to_value(resp).unwrap()), + Json( + serde_json::to_value(resp) + .expect("SAFETY: MintTokenResponse serialization is infallible"), + ), )) } diff --git a/crates/sprout-relay/src/connection.rs b/crates/sprout-relay/src/connection.rs index f712d1418..7fdf66b6f 100644 --- a/crates/sprout-relay/src/connection.rs +++ b/crates/sprout-relay/src/connection.rs @@ -117,7 +117,9 @@ pub async fn handle_connection(socket: WebSocket, state: Arc, addr: So } // Register after challenge succeeds — avoids leaked entries on early disconnect. - state.conn_manager.register(conn_id, tx.clone()); + state + .conn_manager + .register(conn_id, tx.clone(), cancel.clone()); let (ws_send, ws_recv) = socket.split(); diff --git a/crates/sprout-relay/src/handlers/event.rs b/crates/sprout-relay/src/handlers/event.rs index d14340140..1b57eba70 100644 --- a/crates/sprout-relay/src/handlers/event.rs +++ b/crates/sprout-relay/src/handlers/event.rs @@ -33,7 +33,12 @@ pub(crate) async fn dispatch_persistent_event( let event_id_hex = stored_event.event.id.to_hex(); if let Some(ch_id) = stored_event.channel_id { + state.mark_local_event(&stored_event.event.id); if let Err(e) = state.pubsub.publish_event(ch_id, &stored_event.event).await { + // Publish failed — remove from dedup cache so the ID doesn't leak. + state + .local_event_ids + .invalidate(&stored_event.event.id.to_bytes()); warn!(event_id = %event_id_hex, "Redis publish failed: {e}"); } } @@ -48,9 +53,19 @@ pub(crate) async fn dispatch_persistent_event( let event_json = serde_json::to_string(&stored_event.event) .expect("nostr::Event serialization is infallible for well-formed events"); + let mut drop_count = 0u32; 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); + if !state.conn_manager.send_to(*target_conn_id, msg) { + drop_count += 1; + } + } + if drop_count > 0 { + tracing::warn!( + event_id = %event_id_hex, + drop_count, + "fan-out: {drop_count} connection(s) cancelled due to full/closed buffers" + ); } let search = Arc::clone(&state.search); @@ -429,9 +444,19 @@ async fn handle_ephemeral_event( let matches = state.sub_registry.fan_out(&stored_event); let event_json = serde_json::to_string(&event) .expect("nostr::Event serialization is infallible for well-formed events"); + let mut drop_count = 0u32; 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); + if !state.conn_manager.send_to(*target_conn_id, msg) { + drop_count += 1; + } + } + if drop_count > 0 { + tracing::warn!( + event_id = %event_id_hex, + drop_count, + "fan-out: {drop_count} connection(s) cancelled due to full/closed buffers" + ); } conn.send(RelayMessage::ok(event_id_hex, true, "")); diff --git a/crates/sprout-relay/src/main.rs b/crates/sprout-relay/src/main.rs index d821b7d2f..b598331c5 100644 --- a/crates/sprout-relay/src/main.rs +++ b/crates/sprout-relay/src/main.rs @@ -68,9 +68,11 @@ async fn main() -> anyhow::Result<()> { ); info!("Redis pub/sub connected"); - // TODO: spawn pubsub.run_subscriber() for multi-node fan-out. - // Currently no consumer calls subscribe_local(), so the subscriber - // would process Redis messages into a broadcast channel with zero receivers. + // Spawn Redis pub/sub subscriber for multi-node fan-out. + // Events published by other relay instances are received here and + // fanned out to local WebSocket subscribers. + let pubsub_for_sub = Arc::clone(&pubsub); + tokio::spawn(async move { pubsub_for_sub.run_subscriber().await }); let auth = AuthService::new(config.auth.clone()); @@ -91,7 +93,8 @@ async fn main() -> anyhow::Result<()> { tokio::spawn(async move { wf_cron.run().await }); let relay_keypair = if let Some(hex) = &config.relay_private_key { - nostr::Keys::parse(hex).expect("invalid SPROUT_RELAY_PRIVATE_KEY") + nostr::Keys::parse(hex) + .map_err(|e| anyhow::anyhow!("invalid SPROUT_RELAY_PRIVATE_KEY: {e}"))? } else { let keys = nostr::Keys::generate(); tracing::info!("Generated relay keypair: {}", keys.public_key().to_hex()); @@ -108,6 +111,70 @@ async fn main() -> anyhow::Result<()> { workflow_engine, relay_keypair, )); + // Multi-node fan-out consumer: receive events from Redis pub/sub + // (published by other relay instances) and fan out to local WS subscribers. + { + let state_for_sub = Arc::clone(&state); + let mut rx = state_for_sub.pubsub.subscribe_local(); + tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(channel_event) => { + let stored = sprout_core::StoredEvent::new( + channel_event.event, + Some(channel_event.channel_id), + ); + + // Skip events that were already fanned out in-process (local echo). + // The cache has TTL-based eviction (60s) so entries are bounded + // regardless of subscriber health. + let event_id_bytes = stored.event.id.to_bytes(); + if state_for_sub.local_event_ids.get(&event_id_bytes).is_some() { + state_for_sub.local_event_ids.invalidate(&event_id_bytes); + continue; + } + + let matches = state_for_sub.sub_registry.fan_out(&stored); + if matches.is_empty() { + continue; + } + + let event_json = match serde_json::to_string(&stored.event) { + Ok(json) => json, + Err(e) => { + tracing::error!( + "Failed to serialize event for multi-node fan-out: {e}" + ); + continue; + } + }; + let mut drop_count = 0u32; + for (conn_id, sub_id) in &matches { + let msg = format!(r#"["EVENT","{}",{}]"#, sub_id, event_json); + if !state_for_sub.conn_manager.send_to(*conn_id, msg) { + drop_count += 1; + } + } + if drop_count > 0 { + tracing::warn!( + event_id = %stored.event.id.to_hex(), + drop_count, + "multi-node fan-out: {drop_count} connection(s) dropped" + ); + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!("Multi-node fan-out lagged by {n} messages"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + tracing::error!("Multi-node fan-out broadcast channel closed"); + break; + } + } + } + }); + } + let router = build_router(Arc::clone(&state)); let listener = tokio::net::TcpListener::bind(&config.bind_addr) diff --git a/crates/sprout-relay/src/protocol.rs b/crates/sprout-relay/src/protocol.rs index 17478f628..22aa4ac16 100644 --- a/crates/sprout-relay/src/protocol.rs +++ b/crates/sprout-relay/src/protocol.rs @@ -141,7 +141,7 @@ impl RelayMessage { /// Format an EVENT message delivering an event to a subscriber. pub fn event(sub_id: &str, event: &Event) -> String { let event_json = serde_json::to_value(event) - .expect("nostr::Event serialization is infallible for well-formed events"); + .expect("SAFETY: nostr::Event serialization is infallible for well-formed events"); serde_json::json!(["EVENT", sub_id, event_json]).to_string() } diff --git a/crates/sprout-relay/src/state.rs b/crates/sprout-relay/src/state.rs index 1a1b4d6a4..2551e7f24 100644 --- a/crates/sprout-relay/src/state.rs +++ b/crates/sprout-relay/src/state.rs @@ -6,6 +6,7 @@ use std::time::Instant; use axum::extract::ws::Message as WsMessage; use dashmap::DashMap; use tokio::sync::{mpsc, Semaphore}; +use tokio_util::sync::CancellationToken; use uuid::Uuid; use sprout_audit::AuditService; @@ -21,8 +22,9 @@ use crate::subscription::SubscriptionRegistry; /// Tracks active WebSocket connections and provides message routing by connection ID. pub struct ConnectionManager { - /// Map from connection ID to the sender half of the connection's outbound channel. - connections: DashMap>, + /// Map from connection ID to the sender half of the connection's outbound channel + /// and the cancellation token for the connection. + connections: DashMap, CancellationToken)>, } impl ConnectionManager { @@ -33,9 +35,9 @@ impl ConnectionManager { } } - /// Registers a connection with its outbound sender. - pub fn register(&self, conn_id: Uuid, tx: mpsc::Sender) { - self.connections.insert(conn_id, tx); + /// Registers a connection with its outbound sender and cancellation token. + pub fn register(&self, conn_id: Uuid, tx: mpsc::Sender, cancel: CancellationToken) { + self.connections.insert(conn_id, (tx, cancel)); } /// Removes a connection from the registry. @@ -43,10 +45,25 @@ impl ConnectionManager { self.connections.remove(&conn_id); } - /// Sends a text message to the given connection. Returns `false` if the connection is gone or the buffer is full. + /// Sends a text message to the given connection. + /// + /// Returns `false` if the connection is gone or the buffer is full. + /// On a full buffer, cancels the slow client's connection to prevent silent drops. pub fn send_to(&self, conn_id: Uuid, msg: String) -> bool { - if let Some(tx) = self.connections.get(&conn_id) { - tx.try_send(WsMessage::Text(msg.into())).is_ok() + if let Some(entry) = self.connections.get(&conn_id) { + let (tx, cancel) = entry.value(); + match tx.try_send(WsMessage::Text(msg.into())) { + Ok(_) => true, + Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { + tracing::warn!(conn_id = %conn_id, "fan-out: send buffer full — cancelling slow client"); + cancel.cancel(); + false + } + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => { + tracing::debug!(conn_id = %conn_id, "fan-out: send channel closed"); + false + } + } } else { false } @@ -92,6 +109,11 @@ pub struct AppState { /// Entries map token UUID → last time we wrote `last_used_at` to the DB. /// Resets on restart (acceptable — `last_used_at` is informational, not security-critical). pub last_used_cache: Arc>, + /// Recently-published event IDs for local-echo deduplication. + /// Events fanned out in-process are added here; the Redis subscriber + /// consumer skips them to avoid double delivery. Entries expire after + /// 60 seconds via moka's TTL eviction — bounded regardless of subscriber health. + pub local_event_ids: Arc>, } impl AppState { @@ -124,8 +146,20 @@ impl AppState { relay_keypair, mint_rate_limiter: Arc::new(MintRateLimiter::new()), last_used_cache: Arc::new(DashMap::new()), + local_event_ids: Arc::new( + moka::sync::Cache::builder() + .max_capacity(10_000) + .time_to_live(std::time::Duration::from_secs(60)) + .build(), + ), } } + + /// Record an event ID as locally-published for dedup. + /// Called before Redis publish so the multi-node consumer can skip the echo. + pub fn mark_local_event(&self, event_id: &nostr::EventId) { + self.local_event_ids.insert(event_id.to_bytes(), ()); + } } impl std::fmt::Debug for AppState { diff --git a/crates/sprout-search/src/lib.rs b/crates/sprout-search/src/lib.rs index c18249c17..6d75dc378 100644 --- a/crates/sprout-search/src/lib.rs +++ b/crates/sprout-search/src/lib.rs @@ -60,10 +60,12 @@ pub struct SearchService { impl SearchService { /// Creates a new `SearchService` with a default HTTP client. pub fn new(config: SearchConfig) -> Self { + // SAFETY: default builder with only timeout/connect_timeout config cannot fail let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) + .connect_timeout(std::time::Duration::from_secs(5)) .build() - .expect("failed to build reqwest client"); + .expect("SAFETY: default builder with only timeout config cannot fail"); Self { client, config } } diff --git a/crates/sprout-workflow/src/error.rs b/crates/sprout-workflow/src/error.rs index 5d671fa95..04cfa770b 100644 --- a/crates/sprout-workflow/src/error.rs +++ b/crates/sprout-workflow/src/error.rs @@ -53,6 +53,10 @@ pub enum WorkflowError { /// A database operation failed. #[error("database error: {0}")] Database(String), + + /// The action is defined but not yet implemented. + #[error("action not implemented: {0}")] + NotImplemented(String), } impl From for WorkflowError { diff --git a/crates/sprout-workflow/src/executor.rs b/crates/sprout-workflow/src/executor.rs index e249b50b6..829881399 100644 --- a/crates/sprout-workflow/src/executor.rs +++ b/crates/sprout-workflow/src/executor.rs @@ -554,18 +554,16 @@ pub async fn dispatch_action( } } - SendDm { to, text } => { - info!(run_id = %run_id, step = step_id, "SendDm → {to}: {text}"); + SendDm { to, text: _ } => { + warn!(run_id = %run_id, step = step_id, "SendDm not yet implemented (to={to})"); // TODO (WF-07): emit DM event. - Ok(StepResult::Completed(serde_json::json!({ "sent": true }))) + Err(WorkflowError::NotImplemented("SendDm".into())) } - SetChannelTopic { topic } => { - info!(run_id = %run_id, step = step_id, "SetChannelTopic → {topic}"); + SetChannelTopic { topic: _ } => { + warn!(run_id = %run_id, step = step_id, "SetChannelTopic not yet implemented"); // TODO (WF-07): update channel topic via DB. - Ok(StepResult::Completed( - serde_json::json!({ "updated": true }), - )) + Err(WorkflowError::NotImplemented("SetChannelTopic".into())) } AddReaction { emoji } => { diff --git a/migrations/20260313120000_add_event_mentions.sql b/migrations/20260313120000_add_event_mentions.sql new file mode 100644 index 000000000..579caa365 --- /dev/null +++ b/migrations/20260313120000_add_event_mentions.sql @@ -0,0 +1,35 @@ +-- Denormalized mentions table for indexed p-tag lookups. +-- Replaces JSON_CONTAINS full-table scans in feed queries. +CREATE TABLE IF NOT EXISTS event_mentions ( + pubkey_hex VARCHAR(64) NOT NULL, + event_id VARBINARY(32) NOT NULL, + event_created_at DATETIME(6) NOT NULL, + channel_id BINARY(16) NULL, + event_kind INT UNSIGNED NOT NULL, + PRIMARY KEY (pubkey_hex, event_id), + INDEX idx_mentions_pubkey_time (pubkey_hex, event_created_at DESC), + INDEX idx_mentions_pubkey_kind_time (pubkey_hex, event_kind, event_created_at DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Backfill existing events that have p-tags. +-- JSON_TABLE extracts each tag sub-array; we filter for p-tags. +INSERT IGNORE INTO event_mentions (pubkey_hex, event_id, event_created_at, channel_id, event_kind) +SELECT + LOWER(jt.pubkey_hex), + e.id, + e.created_at, + e.channel_id, + e.kind +FROM events e, +JSON_TABLE( + e.tags, + '$[*]' COLUMNS ( + tag_name VARCHAR(10) PATH '$[0]', + pubkey_hex VARCHAR(64) PATH '$[1]' + ) +) AS jt +WHERE jt.tag_name = 'p' + AND jt.pubkey_hex IS NOT NULL + AND LENGTH(jt.pubkey_hex) = 64 + AND jt.pubkey_hex REGEXP '^[0-9a-fA-F]{64}$' + AND e.deleted_at IS NULL;