mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: reliability and security hardening (#53)
This commit is contained in:
+14
-12
@@ -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 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", <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 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","<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
@@ -2834,6 +2834,7 @@ dependencies = [
|
||||
"redis",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sprout-auth",
|
||||
"sprout-core",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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})"
|
||||
));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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, ""));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user