feat: reliability and security hardening (#53)

This commit is contained in:
tlongwell-block
2026-03-13 15:33:22 -04:00
committed by GitHub
parent d9f8132263
commit 6a5fcbb0c2
24 changed files with 565 additions and 138 deletions
+14 -12
View File
@@ -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", <event>]`, the handler in `handlers/event.rs`
Steps 1012 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", <id>, 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 4600146012 (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","<pubkey>"]', '$')` — 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. |
---
Generated
+1
View File
@@ -2834,6 +2834,7 @@ dependencies = [
"redis",
"serde",
"serde_json",
"sprout-auth",
"sprout-core",
"thiserror",
"tokio",
+6 -1
View File
@@ -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(),
+9 -5
View File
@@ -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<nostr::PublicKey, AuthError> {
use sha2::{Digest, Sha256};
let seed = format!("sprout-test-key:{username}");
+41 -41
View File
@@ -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<sqlx::MySql> = 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", "<pubkey_hex>"] 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<sqlx::MySql> = 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})"
));
+77 -2
View File
@@ -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<Uuid>,
) -> 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<Uuid>,
) -> 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<Uuid>,
thread_meta: Option<event::ThreadMetadataParams<'_>>,
) -> 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.
+6 -1
View File
@@ -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())),
+13 -4
View File
@@ -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<Self, Box<dyn std::error::Error + Send + Sync>> {
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<ChannelDto> = client
.get(format!("{}/api/channels", api_base))
.header("Authorization", format!("Bearer {}", api_token))
+18 -4
View File
@@ -491,8 +491,10 @@ impl Translator {
// Build translated tag list: replace the channel `#e` with `#h`, keep everything else.
let mut new_tags: Vec<Tag> = Vec::new();
// SAFETY: ["h", <uuid_string>] 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);
+1
View File
@@ -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 }
+2
View File
@@ -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.
+117
View File
@@ -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<RateLimitResult, AuthError> {
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_hex>:<suffix>` (pubkey) or
/// `sprout:ratelimit:ip:<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<RateLimitResult, AuthError> {
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<RateLimitResult, AuthError> {
let key = sprout_auth::rate_limit::ip_rate_limit_key(ip);
run_rate_limit(&self.pool, &key, window_secs, limit).await
}
}
+4
View File
@@ -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"] }
+53 -39
View File
@@ -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<serde_json::Value>) {
/// 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<HashMap<String, serde_json::Value>, String> {
+11 -4
View File
@@ -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<Scope> = 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"),
),
))
}
+3 -1
View File
@@ -117,7 +117,9 @@ pub async fn handle_connection(socket: WebSocket, state: Arc<AppState>, 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();
+27 -2
View File
@@ -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, ""));
+71 -4
View File
@@ -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)
+1 -1
View File
@@ -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()
}
+42 -8
View File
@@ -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<Uuid, mpsc::Sender<WsMessage>>,
/// Map from connection ID to the sender half of the connection's outbound channel
/// and the cancellation token for the connection.
connections: DashMap<Uuid, (mpsc::Sender<WsMessage>, 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<WsMessage>) {
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<WsMessage>, 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<DashMap<Uuid, Instant>>,
/// 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<moka::sync::Cache<[u8; 32], ()>>,
}
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 {
+3 -1
View File
@@ -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 }
}
+4
View File
@@ -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<sprout_db::error::DbError> for WorkflowError {
+6 -8
View File
@@ -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 } => {
@@ -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;