From 7995470b895d44172e1a78494e5a6dde108e78dd Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Tue, 10 Mar 2026 16:56:45 -0400 Subject: [PATCH 1/4] fix: resolve 10 bugs from multi-agent E2E testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 (critical): create_channel MCP tool now uses REST POST /api/channels instead of Nostr kind:40 event that relay ignored Bug 2 (critical): send_message routed through REST instead of WebSocket, eliminating ~5min timeout/disconnect failures Bug 3 (critical): set_canvas now includes both 'channel' and 'e' tags so desktop subscriptions receive canvas updates Bug 4 (medium): list_channels visibility filter now applied in SQL query Bug 5 (medium): thread reply counters fixed — INSERT IGNORE ensures parent/ root thread_metadata rows exist before incrementing reply_count Bug 6 (medium): workflow SendMessage action implemented via REST POST to relay instead of being a no-op stub Bug 7 (low): documented intentional NIP-29 permission asymmetry between update_channel (admin) and set_topic/set_purpose (any member) Bug 8 (low): set_profile now accepts 'about' field for agent bios Bug 9 (build): removed cfg gate on derive_pubkey_from_username so release builds compile Bug 10 (pre-existing): API token auth implemented — relay now intercepts sprout_ tokens for both WebSocket (NIP-42) and REST (Bearer) paths, hashes and verifies against api_tokens table --- crates/sprout-auth/src/lib.rs | 1 - crates/sprout-db/src/channel.rs | 29 +++-- crates/sprout-db/src/event.rs | 42 +++++++ crates/sprout-db/src/lib.rs | 6 +- crates/sprout-db/src/thread.rs | 42 +++++++ crates/sprout-mcp/src/server.rs | 84 ++++--------- crates/sprout-relay/src/api/agents.rs | 2 +- crates/sprout-relay/src/api/channels.rs | 12 +- .../sprout-relay/src/api/channels_metadata.rs | 7 +- crates/sprout-relay/src/api/mod.rs | 58 ++++++++- crates/sprout-relay/src/handlers/auth.rs | 91 ++++++++++++++ crates/sprout-workflow/src/executor.rs | 115 +++++++++++++++++- 12 files changed, 406 insertions(+), 83 deletions(-) diff --git a/crates/sprout-auth/src/lib.rs b/crates/sprout-auth/src/lib.rs index 542b2e6..fde1269 100644 --- a/crates/sprout-auth/src/lib.rs +++ b/crates/sprout-auth/src/lib.rs @@ -339,7 +339,6 @@ 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", debug_assertions))] pub fn derive_pubkey_from_username(username: &str) -> Result { use sha2::{Digest, Sha256}; let seed = format!("sprout-test-key:{username}"); diff --git a/crates/sprout-db/src/channel.rs b/crates/sprout-db/src/channel.rs index 73a343a..fe382f6 100644 --- a/crates/sprout-db/src/channel.rs +++ b/crates/sprout-db/src/channel.rs @@ -689,12 +689,15 @@ pub struct UserRecord { /// /// Uses DISTINCT + LEFT JOIN so a user who is a member of an open channel does not /// see it twice. Results are ordered stream → forum → dm, then alphabetically by name. +/// +/// If `visibility_filter` is `Some("open")` or `Some("private")`, only channels with +/// that visibility value are returned. `None` returns all accessible channels. pub async fn get_accessible_channels( pool: &MySqlPool, pubkey: &[u8], + visibility_filter: Option<&str>, ) -> Result> { - let rows = sqlx::query( - r#" + let base = r#" SELECT DISTINCT c.id, c.name, c.channel_type, c.visibility, c.description, c.canvas, c.created_by, c.created_at, c.updated_at, c.archived_at, c.deleted_at, c.nip29_group_id, c.topic_required, c.max_members, @@ -705,14 +708,22 @@ pub async fn get_accessible_channels( ON c.id = cm.channel_id AND cm.pubkey = ? AND cm.removed_at IS NULL WHERE c.deleted_at IS NULL AND (c.visibility = 'open' OR cm.channel_id IS NOT NULL) - ORDER BY FIELD(c.channel_type, 'stream', 'forum', 'dm'), c.name - LIMIT 1000 - "#, - ) - .bind(pubkey) - .fetch_all(pool) - .await?; + "#; + + let sql = if visibility_filter.is_some() { + format!("{base} AND c.visibility = ?\n ORDER BY FIELD(c.channel_type, 'stream', 'forum', 'dm'), c.name\n LIMIT 1000") + } else { + format!("{base} ORDER BY FIELD(c.channel_type, 'stream', 'forum', 'dm'), c.name\n LIMIT 1000") + }; + + let query = sqlx::query(&sql).bind(pubkey); + let query = if let Some(vis) = visibility_filter { + query.bind(vis) + } else { + query + }; + let rows = query.fetch_all(pool).await?; rows.into_iter().map(row_to_channel_record).collect() } diff --git a/crates/sprout-db/src/event.rs b/crates/sprout-db/src/event.rs index 2614030..0c56470 100644 --- a/crates/sprout-db/src/event.rs +++ b/crates/sprout-db/src/event.rs @@ -459,6 +459,48 @@ pub async fn insert_event_with_thread_metadata( // Only bump reply counts if the metadata row was actually inserted. if tm_result.rows_affected() > 0 { if let Some(pid) = meta.parent_event_id { + // Ensure the parent has a thread_metadata row so the UPDATE + // below has something to hit. Root (depth=0) messages don't + // get a row on first insert, so we create a stub here. + let parent_ts = meta.parent_event_created_at.unwrap_or(meta.event_created_at); + sqlx::query( + r#" + INSERT IGNORE INTO thread_metadata + (event_created_at, event_id, channel_id, + parent_event_id, parent_event_created_at, + root_event_id, root_event_created_at, + depth, broadcast) + VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 0, 0) + "#, + ) + .bind(parent_ts) + .bind(pid) + .bind(ch_bytes.as_slice()) + .execute(&mut *tx) + .await?; + + // Ensure the root also has a row (may differ from parent for nested replies). + if let Some(root_id) = meta.root_event_id { + if root_id != pid { + let root_ts = meta.root_event_created_at.unwrap_or(meta.event_created_at); + sqlx::query( + r#" + INSERT IGNORE INTO thread_metadata + (event_created_at, event_id, channel_id, + parent_event_id, parent_event_created_at, + root_event_id, root_event_created_at, + depth, broadcast) + VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 0, 0) + "#, + ) + .bind(root_ts) + .bind(root_id) + .bind(ch_bytes.as_slice()) + .execute(&mut *tx) + .await?; + } + } + sqlx::query( r#" UPDATE thread_metadata diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs index 6829b15..aee4146 100644 --- a/crates/sprout-db/src/lib.rs +++ b/crates/sprout-db/src/lib.rs @@ -320,11 +320,15 @@ impl Db { /// Returns full channel records for all channels accessible to `pubkey`: /// open channels plus channels where the user is an active member. + /// + /// If `visibility_filter` is `Some("open")` or `Some("private")`, only channels + /// with that visibility are returned. pub async fn get_accessible_channels( &self, pubkey: &[u8], + visibility_filter: Option<&str>, ) -> Result> { - channel::get_accessible_channels(&self.pool, pubkey).await + channel::get_accessible_channels(&self.pool, pubkey, visibility_filter).await } /// Returns all bot-role members with aggregated channel names. diff --git a/crates/sprout-db/src/thread.rs b/crates/sprout-db/src/thread.rs index 39fb25b..be69142 100644 --- a/crates/sprout-db/src/thread.rs +++ b/crates/sprout-db/src/thread.rs @@ -147,6 +147,48 @@ pub async fn insert_thread_metadata( // INSERT IGNORE on a duplicate key returns rows_affected = 0. if result.rows_affected() > 0 { if let Some(pid) = parent_event_id { + // Ensure the parent has a thread_metadata row so the UPDATE below + // has something to hit. Root (depth=0) messages don't get a row on + // first insert, so we create a stub here with INSERT IGNORE. + let parent_ts = parent_event_created_at.unwrap_or(event_created_at); + sqlx::query( + r#" + INSERT IGNORE INTO thread_metadata + (event_created_at, event_id, channel_id, + parent_event_id, parent_event_created_at, + root_event_id, root_event_created_at, + depth, broadcast) + VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 0, 0) + "#, + ) + .bind(parent_ts) + .bind(pid) + .bind(channel_id_bytes.as_slice()) + .execute(&mut *tx) + .await?; + + // Ensure the root also has a row (may differ from parent for nested replies). + if let Some(root_id) = root_event_id { + if root_id != pid { + let root_ts = root_event_created_at.unwrap_or(event_created_at); + sqlx::query( + r#" + INSERT IGNORE INTO thread_metadata + (event_created_at, event_id, channel_id, + parent_event_id, parent_event_created_at, + root_event_id, root_event_created_at, + depth, broadcast) + VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 0, 0) + "#, + ) + .bind(root_ts) + .bind(root_id) + .bind(channel_id_bytes.as_slice()) + .execute(&mut *tx) + .await?; + } + } + // Increment parent's direct reply count and last_reply_at. sqlx::query( r#" diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index 713dac2..a9a629c 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -367,6 +367,9 @@ pub struct SetProfileParams { /// URL of the agent's avatar image. #[serde(default)] pub avatar_url: Option, + /// Short bio or description. + #[serde(default)] + pub about: Option, } /// Parameters for the `get_feed` tool. @@ -444,49 +447,21 @@ impl SproutMcpServer { ); } - // If a parent_event_id is provided, route through REST (thread reply). + // Route all messages through REST — avoids WebSocket timeout (~5 min). + // The relay determines kind from channel_type; parent_event_id is optional. + let mut body = serde_json::json!({ + "content": p.content, + }); if let Some(ref parent_id) = p.parent_event_id { - let body = serde_json::json!({ - "content": p.content, - "parent_event_id": parent_id, - }); - return match self - .client - .post(&format!("/api/channels/{}/messages", p.channel_id), &body) - .await - { - Ok(b) => b, - Err(e) => format!("Error: {e}"), - }; - } - - let kind = p.kind.unwrap_or(40001); - - let channel_tag = match nostr::Tag::parse(&["channel", &p.channel_id]) { - Ok(t) => t, - Err(e) => return format!("Error building channel tag: {e}"), - }; - let event_ref_tag = match nostr::Tag::parse(&["e", &p.channel_id]) { - Ok(t) => t, - Err(e) => return format!("Error building event-ref tag: {e}"), - }; - - let keys = self.client.keys().clone(); - let event = match nostr::EventBuilder::new( - nostr::Kind::Custom(kind), - &p.content, - [channel_tag, event_ref_tag], - ) - .sign_with_keys(&keys) + body["parent_event_id"] = serde_json::Value::String(parent_id.clone()); + } + match self + .client + .post(&format!("/api/channels/{}/messages", p.channel_id), &body) + .await { - Ok(e) => e, - Err(e) => return format!("Error signing event: {e}"), - }; - - match self.client.send_event(event).await { - Ok(ok) if ok.accepted => format!("Message sent. Event ID: {}", ok.event_id), - Ok(ok) => format!("Message rejected: {}", ok.message), - Err(e) => format!("Relay error: {e}"), + Ok(b) => b, + Err(e) => format!("Error: {e}"), } } @@ -546,27 +521,15 @@ impl SproutMcpServer { /// Create a new Sprout channel. #[tool(name = "create_channel", description = "Create a new Sprout channel")] pub async fn create_channel(&self, Parameters(p): Parameters) -> String { - let keys = self.client.keys().clone(); - - let metadata = serde_json::json!({ + let body = serde_json::json!({ "name": p.name, "channel_type": p.channel_type, "visibility": p.visibility, "description": p.description, }); - - let event = - match nostr::EventBuilder::new(nostr::Kind::Custom(40), metadata.to_string(), []) - .sign_with_keys(&keys) - { - Ok(e) => e, - Err(e) => return format!("Error signing event: {e}"), - }; - - match self.client.send_event(event).await { - Ok(ok) if ok.accepted => format!("Channel created. Event ID: {}", ok.event_id), - Ok(ok) => format!("Channel creation rejected: {}", ok.message), - Err(e) => format!("Relay error: {e}"), + match self.client.post("/api/channels", &body).await { + Ok(b) => b, + Err(e) => format!("Error: {e}"), } } @@ -627,11 +590,15 @@ impl SproutMcpServer { Ok(t) => t, Err(e) => return format!("Error building tag: {e}"), }; + let event_ref_tag = match nostr::Tag::parse(&["e", &p.channel_id]) { + Ok(t) => t, + Err(e) => return format!("Error building event-ref tag: {e}"), + }; let event = match nostr::EventBuilder::new( nostr::Kind::Custom(KIND_CANVAS as u16), &p.content, - [channel_tag], + [channel_tag, event_ref_tag], ) .sign_with_keys(&keys) { @@ -1341,6 +1308,7 @@ impl SproutMcpServer { let body = serde_json::json!({ "display_name": p.display_name, "avatar_url": p.avatar_url, + "about": p.about, }); match self.client.put("/api/users/me/profile", &body).await { Ok(b) => b, diff --git a/crates/sprout-relay/src/api/agents.rs b/crates/sprout-relay/src/api/agents.rs index 04a26a5..1b01374 100644 --- a/crates/sprout-relay/src/api/agents.rs +++ b/crates/sprout-relay/src/api/agents.rs @@ -27,7 +27,7 @@ pub async fn agents_handler( // Get requester's accessible channels to filter bot channel visibility. let accessible_channels = state .db - .get_accessible_channels(&pubkey_bytes) + .get_accessible_channels(&pubkey_bytes, None) .await .map_err(|e| { tracing::error!("agents: failed to load accessible channels: {e}"); diff --git a/crates/sprout-relay/src/api/channels.rs b/crates/sprout-relay/src/api/channels.rs index df100a3..394135a 100644 --- a/crates/sprout-relay/src/api/channels.rs +++ b/crates/sprout-relay/src/api/channels.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use axum::{ extract::Json as ExtractJson, - extract::State, + extract::{Query, State}, http::{HeaderMap, StatusCode}, response::Json, }; @@ -21,18 +21,26 @@ use crate::state::AppState; use super::{api_error, extract_auth_pubkey, internal_error}; +/// Query parameters for `GET /api/channels`. +#[derive(Debug, Deserialize)] +pub struct ListChannelsParams { + /// Optional visibility filter: `"open"` or `"private"`. + pub visibility: Option, +} + /// Returns all channels accessible to the authenticated user. /// /// For DM channels, resolves participant display names and pubkeys. pub async fn channels_handler( State(state): State>, headers: HeaderMap, + Query(params): Query, ) -> Result, (StatusCode, Json)> { let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?; let channels = state .db - .get_accessible_channels(&pubkey_bytes) + .get_accessible_channels(&pubkey_bytes, params.visibility.as_deref()) .await .map_err(|e| internal_error(&format!("db error: {e}")))?; diff --git a/crates/sprout-relay/src/api/channels_metadata.rs b/crates/sprout-relay/src/api/channels_metadata.rs index 73e2f3d..3b53557 100644 --- a/crates/sprout-relay/src/api/channels_metadata.rs +++ b/crates/sprout-relay/src/api/channels_metadata.rs @@ -131,9 +131,12 @@ pub struct UpdateChannelBody { pub description: Option, } -/// PUT /api/channels/{channel_id} — Update channel name and/or description. +/// Update channel properties (name, description). /// -/// Requires owner or admin role. +/// Requires owner or admin role. Topic and purpose are settable by any member +/// via separate endpoints — see `channels_metadata.rs`. This asymmetry is +/// intentional: name and description are structural metadata, while topic and +/// purpose are collaborative content metadata (NIP-29 parity). pub async fn update_channel_handler( State(state): State>, headers: HeaderMap, diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index 127aa87..04c7fa1 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -67,6 +67,7 @@ use axum::{ http::{HeaderMap, StatusCode}, response::Json, }; +use sha2::{Digest, Sha256}; use crate::state::AppState; @@ -107,9 +108,11 @@ fn decode_jwt_payload_unverified( /// Extract an authenticated pubkey from the request headers. /// /// Auth resolution order: -/// 1. `Authorization: Bearer ` — validated via JWKS when `require_auth_token=true`, -/// or decoded unverified (username → derived key) when `require_auth_token=false`. -/// 2. `X-Pubkey: ` — accepted only when `require_auth_token=false` (dev mode). +/// 1. `Authorization: Bearer sprout_*` — API token; hashed, looked up in DB. +/// 2. `Authorization: Bearer eyJ*` — Okta JWT; validated via JWKS when +/// `require_auth_token=true`, or decoded unverified (username → derived key) when +/// `require_auth_token=false`. +/// 3. `X-Pubkey: ` — accepted only when `require_auth_token=false` (dev mode). /// /// Returns `(nostr::PublicKey, pubkey_bytes)` on success, or a 401 response on failure. pub(crate) async fn extract_auth_pubkey( @@ -118,9 +121,56 @@ pub(crate) async fn extract_auth_pubkey( ) -> Result<(nostr::PublicKey, Vec), (StatusCode, Json)> { let require_auth = state.config.require_auth_token; - // Try Authorization: Bearer + // Try Authorization: Bearer if let Some(auth_header) = headers.get("authorization").and_then(|v| v.to_str().ok()) { if let Some(token) = auth_header.strip_prefix("Bearer ") { + // ── API token path (sprout_*) ───────────────────────────────── + // Must be checked before the Okta JWT path: verify_auth_event() + // (and validate_bearer_jwt) would reject sprout_ tokens immediately. + if token.starts_with("sprout_") { + let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into(); + let record = match state.db.get_api_token_by_hash(&hash).await { + Ok(r) => r, + Err(e) => { + tracing::warn!("auth: API token lookup failed: {e}"); + return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed")); + } + }; + let owner_pubkey = match nostr::PublicKey::from_slice(&record.owner_pubkey) { + Ok(pk) => pk, + Err(e) => { + tracing::warn!("auth: API token owner pubkey invalid: {e}"); + return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed")); + } + }; + // verify_api_token_against_hash checks hash match, expiry, and + // pubkey equality. For the REST path there is no "claimed" pubkey + // from a Nostr event, so we pass the owner pubkey for both + // arguments — we are asserting identity from the DB record itself. + match state.auth.verify_api_token_against_hash( + token, + &record.token_hash, + &owner_pubkey, + &owner_pubkey, + record.expires_at, + &record.scopes, + ) { + Ok((pubkey, _scopes)) => { + let bytes = pubkey.serialize().to_vec(); + if let Err(e) = state.db.ensure_user(&bytes).await { + tracing::warn!("ensure_user failed: {e}"); + } + // Update last_used_at — non-fatal. + let _ = state.db.update_token_last_used(&hash).await; + return Ok((pubkey, bytes)); + } + Err(_) => { + tracing::warn!("auth: API token verification failed"); + return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed")); + } + } + } + if require_auth { // Production: validate JWT against JWKS match state.auth.validate_bearer_jwt(token).await { diff --git a/crates/sprout-relay/src/handlers/auth.rs b/crates/sprout-relay/src/handlers/auth.rs index c870cae..f6244ca 100644 --- a/crates/sprout-relay/src/handlers/auth.rs +++ b/crates/sprout-relay/src/handlers/auth.rs @@ -2,6 +2,7 @@ use std::sync::Arc; +use sha2::{Digest, Sha256}; use tracing::{debug, info, warn}; use crate::connection::{AuthState, ConnectionState}; @@ -40,6 +41,96 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: let auth_svc = Arc::clone(&state.auth); let event_id_hex = event.id.to_hex(); + // Extract the auth_token tag before dispatching — API tokens (sprout_*) must be + // intercepted here because verify_auth_event() has no DB access and rejects them. + let auth_token = event.tags.iter().find_map(|tag| { + let vec = tag.as_slice(); + if vec.len() >= 2 && vec[0] == "auth_token" { + Some(vec[1].to_string()) + } else { + None + } + }); + + if let Some(ref token) = auth_token { + if token.starts_with("sprout_") { + // ── API token path ────────────────────────────────────────────── + // Hash the raw token and look it up in the DB. The relay owns this + // path; sprout-auth has no DB access. + let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into(); + + let record = match state.db.get_api_token_by_hash(&hash).await { + Ok(r) => r, + Err(e) => { + warn!(conn_id = %conn_id, error = %e, "API token lookup failed"); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "auth-required: verification failed", + )); + return; + } + }; + + // Reconstruct the owner pubkey from the stored raw bytes. + let owner_pubkey = match nostr::PublicKey::from_slice(&record.owner_pubkey) { + Ok(pk) => pk, + Err(e) => { + warn!(conn_id = %conn_id, error = %e, "API token owner pubkey invalid"); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "auth-required: verification failed", + )); + return; + } + }; + + // Verify hash, expiry, and pubkey match via the auth service. + match auth_svc.verify_api_token_against_hash( + token, + &record.token_hash, + &owner_pubkey, + &event.pubkey, + record.expires_at, + &record.scopes, + ) { + Ok((pubkey, scopes)) => { + info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "API token auth successful"); + // Update last_used_at asynchronously — non-fatal if it fails. + let db = state.db.clone(); + let hash_owned = hash; + tokio::spawn(async move { + if let Err(e) = db.update_token_last_used(&hash_owned).await { + warn!("update_token_last_used failed: {e}"); + } + }); + let auth_ctx = sprout_auth::AuthContext { + pubkey, + scopes, + auth_method: sprout_auth::AuthMethod::Nip42ApiToken, + }; + *conn.auth_state.write().await = AuthState::Authenticated(auth_ctx); + conn.send(RelayMessage::ok(&event_id_hex, true, "")); + } + Err(e) => { + warn!(conn_id = %conn_id, error = %e, "API token verification failed"); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "auth-required: verification failed", + )); + } + } + return; + } + } + + // ── Okta JWT / pubkey-only path ───────────────────────────────────────── + // Non-sprout_ tokens (eyJ* JWTs) and no-token (open-relay) fall through here. match auth_svc .verify_auth_event(event, &challenge, &relay_url) .await diff --git a/crates/sprout-workflow/src/executor.rs b/crates/sprout-workflow/src/executor.rs index 7bfd106..3f4c67d 100644 --- a/crates/sprout-workflow/src/executor.rs +++ b/crates/sprout-workflow/src/executor.rs @@ -507,15 +507,51 @@ pub async fn dispatch_action( action: &ActionDef, _engine: &WorkflowEngine, run_id: Uuid, + trigger_ctx: &TriggerContext, ) -> Result { use ActionDef::*; match action { SendMessage { text, channel } => { - let target = channel.as_deref().unwrap_or(""); - info!(run_id = %run_id, step = step_id, "SendMessage → {target}: {text}"); - // TODO (WF-07): emit kind:40001 event via engine's event emitter. - Ok(StepResult::Completed(serde_json::json!({ "sent": true }))) + // Use explicit channel override if provided; otherwise fall back to + // the channel that triggered this workflow run. + let channel_id = channel + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or(&trigger_ctx.channel_id); + + info!( + run_id = %run_id, + step = step_id, + channel = %channel_id, + "SendMessage → {channel_id}: {text}" + ); + + if channel_id.is_empty() { + return Err(WorkflowError::InvalidDefinition( + "SendMessage: no channel_id available (trigger has no channel context and \ + no channel override was specified)" + .into(), + )); + } + + #[cfg(feature = "reqwest")] + { + let result = send_message_impl(channel_id, text).await?; + return Ok(StepResult::Completed(result)); + } + + #[cfg(not(feature = "reqwest"))] + { + warn!( + run_id = %run_id, + step = step_id, + "SendMessage: reqwest feature not enabled, skipping HTTP call" + ); + return Ok(StepResult::Completed( + serde_json::json!({ "sent": false, "skipped": true }), + )); + } } SendDm { to, text } => { @@ -793,6 +829,75 @@ async fn call_webhook_impl( })) } +// ── send_message implementation (feature-gated) ────────────────────────────── + +/// POST `{"content": text}` to `POST /api/channels/{channel_id}/messages`. +/// +/// Relay base URL is read from `SPROUT_RELAY_BASE_URL` (default: `http://localhost:3000`). +/// Auth header `X-Pubkey` is set from `SPROUT_RELAY_PUBKEY` when present; omitted +/// otherwise (works in dev mode when `require_auth_token=false`). +/// +/// This is an internal call — the workflow engine runs inside the relay process, +/// so `localhost:3000` is always reachable without SSRF concerns. +#[cfg(feature = "reqwest")] +async fn send_message_impl(channel_id: &str, text: &str) -> Result { + use reqwest::Client; + use std::time::Duration; + + let base_url = std::env::var("SPROUT_RELAY_BASE_URL") + .unwrap_or_else(|_| "http://localhost:3000".to_owned()); + + let url = format!("{base_url}/api/channels/{channel_id}/messages"); + + let client = Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|e| WorkflowError::WebhookError(format!("failed to build HTTP client: {e}")))?; + + let mut req = client + .post(&url) + .header("Content-Type", "application/json") + .json(&serde_json::json!({ "content": text })); + + // Attach relay pubkey for server-side auth (dev mode: X-Pubkey header). + // In production, SPROUT_RELAY_PUBKEY should be set to the relay's hex pubkey. + if let Ok(pubkey) = std::env::var("SPROUT_RELAY_PUBKEY") { + req = req.header("X-Pubkey", pubkey); + } + + let resp = req + .send() + .await + .map_err(|e| WorkflowError::WebhookError(format!("SendMessage HTTP error: {e}")))?; + + let status = resp.status(); + + if !status.is_success() { + let body = resp + .text() + .await + .unwrap_or_else(|_| "".to_owned()); + return Err(WorkflowError::WebhookError(format!( + "SendMessage: relay returned {status} for channel {channel_id}: {body}" + ))); + } + + let body_text = resp + .text() + .await + .unwrap_or_else(|_| String::new()); + + // Try to parse the response as JSON; fall back to wrapping the raw text. + let body_json: JsonValue = serde_json::from_str(&body_text) + .unwrap_or_else(|_| serde_json::json!({ "raw": body_text })); + + Ok(serde_json::json!({ + "sent": true, + "status": status.as_u16(), + "response": body_json, + })) +} + // ── Execution result ────────────────────────────────────────────────────────── /// Rich return type from `execute_run` / `execute_from_step`. @@ -997,7 +1102,7 @@ async fn execute_steps( .unwrap_or(engine.config.default_timeout_secs); let dispatch_result = tokio::time::timeout( std::time::Duration::from_secs(timeout_secs), - dispatch_action(&step.id, &resolved_action, engine, run_id), + dispatch_action(&step.id, &resolved_action, engine, run_id, trigger_ctx), ) .await; From cbf2ab1353229712570a3331ac6f8ed8078db122 Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Tue, 10 Mar 2026 17:07:31 -0400 Subject: [PATCH 2/4] test: add desktop Playwright e2e tests for channels, messaging, and integration New test files: - channels.spec.ts (8 tests): sidebar display, channel creation, switching, empty state, channel types - messaging.spec.ts (9 tests): send/receive, input clearing, Enter key, cross-channel independence, DM messaging, day dividers - integration.spec.ts (8 tests): relay-backed channel creation, multi-user message delivery, DM channels, forum display, channel independence Updated playwright.config.ts to include new test files in smoke and integration projects. All 24 smoke tests pass (8 channels + 9 messaging + 7 original). --- desktop/playwright.config.ts | 4 +- desktop/tests/e2e/channels.spec.ts | 125 +++++++++++++++++++ desktop/tests/e2e/integration.spec.ts | 164 +++++++++++++++++++++++++ desktop/tests/e2e/messaging.spec.ts | 166 ++++++++++++++++++++++++++ 4 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 desktop/tests/e2e/channels.spec.ts create mode 100644 desktop/tests/e2e/integration.spec.ts create mode 100644 desktop/tests/e2e/messaging.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 94ab872..1a93089 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -18,14 +18,14 @@ export default defineConfig({ projects: [ { name: "smoke", - testMatch: "**/smoke.spec.ts", + testMatch: ["**/smoke.spec.ts", "**/channels.spec.ts", "**/messaging.spec.ts"], use: { ...devices["Desktop Chrome"], }, }, { name: "integration", - testMatch: "**/stream.spec.ts", + testMatch: ["**/stream.spec.ts", "**/integration.spec.ts"], use: { ...devices["Desktop Chrome"], }, diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts new file mode 100644 index 0000000..bf1a33a --- /dev/null +++ b/desktop/tests/e2e/channels.spec.ts @@ -0,0 +1,125 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +test.beforeEach(async ({ page }) => { + await installMockBridge(page); +}); + +test("sidebar shows all channel types", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByTestId("app-sidebar")).toBeVisible(); + + // Streams + const streamList = page.getByTestId("stream-list"); + await expect(streamList).toContainText("general"); + await expect(streamList).toContainText("random"); + await expect(streamList).toContainText("engineering"); + await expect(streamList).toContainText("agents"); + + // Forums + const forumList = page.getByTestId("forum-list"); + await expect(forumList).toContainText("watercooler"); + await expect(forumList).toContainText("announcements"); + + // DMs + const dmList = page.getByTestId("dm-list"); + await expect(dmList).toContainText("alice-tyler"); + await expect(dmList).toContainText("bob-tyler"); +}); + +test("create stream with name and description", async ({ page }) => { + const channelName = `my-new-stream-${Date.now()}`; + + await page.goto("/"); + await page.getByRole("button", { name: "Create a stream" }).click(); + await page.getByTestId("create-stream-name").fill(channelName); + await page + .getByTestId("create-stream-description") + .fill("A stream for testing channel creation"); + await page.getByRole("button", { name: "Create" }).click(); + + await expect(page.getByTestId("stream-list")).toContainText(channelName); + await expect(page.getByTestId("chat-title")).toHaveText(channelName); +}); + +test("create stream with special characters", async ({ page }) => { + const channelName = `dev ops-${Date.now()}`; + + await page.goto("/"); + await page.getByRole("button", { name: "Create a stream" }).click(); + await page.getByTestId("create-stream-name").fill(channelName); + await page + .getByTestId("create-stream-description") + .fill("Stream with spaces and hyphens"); + await page.getByRole("button", { name: "Create" }).click(); + + await expect(page.getByTestId("stream-list")).toContainText(channelName); + await expect(page.getByTestId("chat-title")).toHaveText(channelName); +}); + +test("switch between streams", async ({ page }) => { + await page.goto("/"); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + + await page.getByTestId("channel-engineering").click(); + await expect(page.getByTestId("chat-title")).toHaveText("engineering"); +}); + +test("switch between channel types", async ({ page }) => { + await page.goto("/"); + + // Stream + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + // Forum + await page.getByTestId("channel-watercooler").click(); + await expect(page.getByTestId("chat-title")).toHaveText("watercooler"); + + // DM + await page.getByTestId("channel-alice-tyler").click(); + await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); +}); + +test("empty channel shows empty state", async ({ page }) => { + await page.goto("/"); + + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await expect(page.getByTestId("message-empty")).toBeVisible(); +}); + +test("channel with messages shows content", async ({ page }) => { + await page.goto("/"); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect(page.getByTestId("message-timeline")).toContainText( + "Welcome to #general", + ); +}); + +test("sidebar persists after channel switch", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByTestId("app-sidebar")).toBeVisible(); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect(page.getByTestId("app-sidebar")).toBeVisible(); + + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await expect(page.getByTestId("app-sidebar")).toBeVisible(); + + await page.getByTestId("channel-watercooler").click(); + await expect(page.getByTestId("chat-title")).toHaveText("watercooler"); + await expect(page.getByTestId("app-sidebar")).toBeVisible(); +}); diff --git a/desktop/tests/e2e/integration.spec.ts b/desktop/tests/e2e/integration.spec.ts new file mode 100644 index 0000000..c521000 --- /dev/null +++ b/desktop/tests/e2e/integration.spec.ts @@ -0,0 +1,164 @@ +import { expect, test, type Browser } from "@playwright/test"; + +import { installRelayBridge } from "../helpers/bridge"; +import { assertRelaySeeded } from "../helpers/seed"; + +test.beforeAll(async () => { + await assertRelaySeeded(); +}); + +test("create channel and verify in sidebar", async ({ page }) => { + const channelName = `integration-e2e-${Date.now()}`; + + await installRelayBridge(page, "tyler"); + await page.goto("/"); + await page.getByRole("button", { name: "Create a stream" }).click(); + await page.getByTestId("create-stream-name").fill(channelName); + await page.getByRole("button", { name: "Create" }).click(); + + await expect(page.getByTestId("stream-list")).toContainText(channelName); + await expect(page.getByTestId("chat-title")).toHaveText(channelName); +}); + +test("two users see the same channel", async ({ + browser, +}: { + browser: Browser; +}) => { + const channelName = `shared-channel-${Date.now()}`; + const contextOne = await browser.newContext(); + const contextTwo = await browser.newContext(); + const pageOne = await contextOne.newPage(); + const pageTwo = await contextTwo.newPage(); + + try { + await installRelayBridge(pageOne, "tyler"); + await installRelayBridge(pageTwo, "alice"); + + await pageOne.goto("/"); + await pageOne.getByRole("button", { name: "Create a stream" }).click(); + await pageOne.getByTestId("create-stream-name").fill(channelName); + await pageOne.getByRole("button", { name: "Create" }).click(); + await expect(pageOne.getByTestId("stream-list")).toContainText(channelName); + + await pageTwo.goto("/"); + await expect(pageTwo.getByTestId("stream-list")).toContainText(channelName); + } finally { + await contextOne.close(); + await contextTwo.close(); + } +}); + +test("message delivery across users", async ({ + browser, +}: { + browser: Browser; +}) => { + const message = `Cross-user message ${Date.now()}`; + const contextOne = await browser.newContext(); + const contextTwo = await browser.newContext(); + const pageOne = await contextOne.newPage(); + const pageTwo = await contextTwo.newPage(); + + try { + await installRelayBridge(pageOne, "tyler"); + await installRelayBridge(pageTwo, "alice"); + + await pageOne.goto("/"); + await pageTwo.goto("/"); + + await pageOne.getByTestId("channel-general").click(); + await pageTwo.getByTestId("channel-general").click(); + await expect(pageOne.getByTestId("chat-title")).toHaveText("general"); + await expect(pageTwo.getByTestId("chat-title")).toHaveText("general"); + + await pageOne.getByTestId("message-input").fill(message); + await pageOne.getByTestId("send-message").click(); + + await expect(pageTwo.getByTestId("message-timeline")).toContainText( + message, + ); + } finally { + await contextOne.close(); + await contextTwo.close(); + } +}); + +test("DM channel appears in sidebar", async ({ page }) => { + await installRelayBridge(page, "tyler"); + await page.goto("/"); + + await expect(page.getByTestId("dm-list")).toContainText("alice-tyler"); +}); + +test("send message to DM", async ({ page }) => { + const message = `DM message ${Date.now()}`; + + await installRelayBridge(page, "tyler"); + await page.goto("/"); + await page.getByTestId("channel-alice-tyler").click(); + await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); + + await page.getByTestId("message-input").fill(message); + await page.getByTestId("send-message").click(); + + await expect(page.getByTestId("message-timeline")).toContainText(message); +}); + +test("forum channel appears in sidebar", async ({ page }) => { + await installRelayBridge(page, "tyler"); + await page.goto("/"); + + await expect(page.getByTestId("forum-list")).toContainText("watercooler"); +}); + +test("create channel with description", async ({ page }) => { + const channelName = `desc-channel-${Date.now()}`; + const description = `Description for ${channelName}`; + + await installRelayBridge(page, "tyler"); + await page.goto("/"); + await page.getByRole("button", { name: "Create a stream" }).click(); + await page.getByTestId("create-stream-name").fill(channelName); + await page.getByTestId("create-stream-description").fill(description); + await page.getByRole("button", { name: "Create" }).click(); + + await expect(page.getByTestId("stream-list")).toContainText(channelName); + await expect(page.getByTestId("chat-title")).toHaveText(channelName); + await expect(page.getByTestId("chat-description")).toContainText(description); +}); + +test("multiple channels independent", async ({ page }) => { + const channelA = `channel-a-${Date.now()}`; + const channelB = `channel-b-${Date.now()}`; + const messageA = `Message in A ${Date.now()}`; + + await installRelayBridge(page, "tyler"); + await page.goto("/"); + + // Create channel A + await page.getByRole("button", { name: "Create a stream" }).click(); + await page.getByTestId("create-stream-name").fill(channelA); + await page.getByRole("button", { name: "Create" }).click(); + await expect(page.getByTestId("chat-title")).toHaveText(channelA); + + // Create channel B + await page.getByRole("button", { name: "Create a stream" }).click(); + await page.getByTestId("create-stream-name").fill(channelB); + await page.getByRole("button", { name: "Create" }).click(); + await expect(page.getByTestId("chat-title")).toHaveText(channelB); + + // Navigate to channel A and send a message + await page.getByTestId(`channel-${channelA}`).click(); + await expect(page.getByTestId("chat-title")).toHaveText(channelA); + await page.getByTestId("message-input").fill(messageA); + await page.getByTestId("send-message").click(); + await expect(page.getByTestId("message-timeline")).toContainText(messageA); + + // Switch to channel B — message from A should not appear + await page.getByTestId(`channel-${channelB}`).click(); + await expect(page.getByTestId("chat-title")).toHaveText(channelB); + await expect( + page.getByTestId("message-timeline"), + ).not.toContainText(messageA); +}); diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts new file mode 100644 index 0000000..bf5cc6f --- /dev/null +++ b/desktop/tests/e2e/messaging.spec.ts @@ -0,0 +1,166 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +test.beforeEach(async ({ page }) => { + await installMockBridge(page); +}); + +test("send a message and see it in timeline", async ({ page }) => { + const message = `Hello timeline ${Date.now()}`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.getByTestId("message-input").fill(message); + await page.getByTestId("send-message").click(); + + await expect(page.getByTestId("message-timeline")).toContainText(message); +}); + +test("send multiple messages in sequence", async ({ page }) => { + const ts = Date.now(); + const messages = [ + `First message ${ts}`, + `Second message ${ts}`, + `Third message ${ts}`, + ]; + const input = page.getByTestId("message-input"); + const sendButton = page.getByTestId("send-message"); + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + for (const message of messages) { + await input.fill(message); + await sendButton.click(); + await expect(page.getByTestId("message-timeline")).toContainText(message); + } + + const timeline = page.getByTestId("message-timeline"); + for (const message of messages) { + await expect(timeline).toContainText(message); + } +}); + +test("message input clears after send", async ({ page }) => { + const message = `Clear after send ${Date.now()}`; + const input = page.getByTestId("message-input"); + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await input.fill(message); + await expect(input).toHaveValue(message); + await page.getByTestId("send-message").click(); + + await expect(page.getByTestId("message-timeline")).toContainText(message); + await expect(input).toHaveValue(""); +}); + +test("empty message cannot be sent", async ({ page }) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const sendButton = page.getByTestId("send-message"); + await expect(sendButton).toBeDisabled(); +}); + +test("send message with Enter key", async ({ page }) => { + const message = `Enter key send ${Date.now()}`; + const input = page.getByTestId("message-input"); + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await input.fill(message); + await input.press("Enter"); + + await expect(page.getByTestId("message-timeline")).toContainText(message); +}); + +test("messages persist across channel switches", async ({ page }) => { + const message = `Persist across switch ${Date.now()}`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.getByTestId("message-input").fill(message); + await page.getByTestId("send-message").click(); + await expect(page.getByTestId("message-timeline")).toContainText(message); + + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect(page.getByTestId("message-timeline")).toContainText(message); +}); + +test("different channels have independent messages", async ({ page }) => { + const ts = Date.now(); + const generalMessage = `General only ${ts}`; + const randomMessage = `Random only ${ts}`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.getByTestId("message-input").fill(generalMessage); + await page.getByTestId("send-message").click(); + await expect(page.getByTestId("message-timeline")).toContainText( + generalMessage, + ); + + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await expect(page.getByTestId("message-timeline")).not.toContainText( + generalMessage, + ); + + await page.getByTestId("message-input").fill(randomMessage); + await page.getByTestId("send-message").click(); + await expect(page.getByTestId("message-timeline")).toContainText( + randomMessage, + ); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect(page.getByTestId("message-timeline")).toContainText( + generalMessage, + ); + await expect(page.getByTestId("message-timeline")).not.toContainText( + randomMessage, + ); +}); + +test("day divider appears in timeline", async ({ page }) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await expect(page.getByTestId("message-timeline")).toContainText( + "Welcome to #general", + ); + await expect( + page.getByTestId("message-timeline-day-divider"), + ).toBeVisible(); +}); + +test("send message to DM channel", async ({ page }) => { + const message = `DM message ${Date.now()}`; + + await page.goto("/"); + await page.getByTestId("channel-alice-tyler").click(); + await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); + + await page.getByTestId("message-input").fill(message); + await page.getByTestId("send-message").click(); + + await expect(page.getByTestId("message-timeline")).toContainText(message); +}); From 93be5dbbee37db4dfe9a545b01ea65dfd88162d4 Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Tue, 10 Mar 2026 17:29:01 -0400 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20address=20crossfire=20review=20?= =?UTF-8?q?=E2=80=94=20effective=20author,=20profile=20about,=20workflow?= =?UTF-8?q?=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crossfire review (codex, 3/10) identified four issues. Addressed: 1. Author identity in read paths: REST-created messages are relay-signed with the real sender in a p tag. Added effective_author_bytes() helper and applied it in list_messages and get_thread responses so pubkey reflects the actual sender, not the relay. 2. Bug 8 end-to-end: added 'about' column to users table (migration), updated DB layer with dynamic SET builder, relay handler accepts and persists the field, get_profile returns it. 3. Workflow SendMessage auth: now checks SPROUT_API_TOKEN first (Bearer), falls back to SPROUT_RELAY_PUBKEY (X-Pubkey). Documented production limitation with TODO(WF-07). 4. MCP send_message: kind parameter now passed through to REST body instead of being silently dropped after the WS-to-REST migration. --- crates/sprout-db/src/lib.rs | 5 +- crates/sprout-db/src/thread.rs | 10 ++++ crates/sprout-db/src/user.rs | 67 +++++++++++++----------- crates/sprout-mcp/src/server.rs | 3 ++ crates/sprout-relay/src/api/messages.rs | 45 ++++++++++++++-- crates/sprout-relay/src/api/users.rs | 22 +++++--- crates/sprout-workflow/src/executor.rs | 20 +++++-- migrations/20260315000001_user_about.sql | 1 + 8 files changed, 126 insertions(+), 47 deletions(-) create mode 100644 migrations/20260315000001_user_about.sql diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs index aee4146..7a49fe8 100644 --- a/crates/sprout-db/src/lib.rs +++ b/crates/sprout-db/src/lib.rs @@ -559,14 +559,15 @@ impl Db { user::get_user(&self.pool, pubkey).await } - /// Update a user's display_name and/or avatar_url. + /// Update a user's display_name, avatar_url, and/or about. pub async fn update_user_profile( &self, pubkey: &[u8], display_name: Option<&str>, avatar_url: Option<&str>, + about: Option<&str>, ) -> Result<()> { - user::update_user_profile(&self.pool, pubkey, display_name, avatar_url).await + user::update_user_profile(&self.pool, pubkey, display_name, avatar_url, about).await } // ── API Tokens ─────────────────────────────────────────────────────────── diff --git a/crates/sprout-db/src/thread.rs b/crates/sprout-db/src/thread.rs index be69142..fc7d311 100644 --- a/crates/sprout-db/src/thread.rs +++ b/crates/sprout-db/src/thread.rs @@ -26,6 +26,8 @@ pub struct ThreadReply { pub channel_id: Uuid, /// Compressed public key of the reply author. pub pubkey: Vec, + /// Nostr event tags (JSON array), used to extract effective author. + pub tags: serde_json::Value, /// Text content of the reply. pub content: String, /// Nostr event kind number. @@ -58,6 +60,8 @@ pub struct TopLevelMessage { pub event_id: Vec, /// Compressed public key of the message author. pub pubkey: Vec, + /// Nostr event tags (JSON array), used to extract effective author. + pub tags: serde_json::Value, /// Text content of the message. pub content: String, /// Nostr event kind number. @@ -345,6 +349,7 @@ pub async fn get_thread_replies( tm.root_event_id, tm.channel_id, e.pubkey, + e.tags, e.content, e.kind, tm.depth, @@ -387,6 +392,7 @@ pub async fn get_thread_replies( let root_event_id_col: Option> = row.try_get("root_event_id")?; let channel_id_bytes: Vec = row.try_get("channel_id")?; let pubkey: Vec = row.try_get("pubkey")?; + let tags: serde_json::Value = row.try_get("tags")?; let content: String = row.try_get("content")?; let kind: i32 = row.try_get("kind")?; let depth: i32 = row.try_get("depth")?; @@ -401,6 +407,7 @@ pub async fn get_thread_replies( root_event_id: root_event_id_col, channel_id, pubkey, + tags, content, kind, depth, @@ -497,6 +504,7 @@ pub async fn get_channel_messages_top_level( SELECT e.id AS event_id, e.pubkey, + e.tags, e.content, e.kind, e.created_at, @@ -534,6 +542,7 @@ pub async fn get_channel_messages_top_level( for row in rows { let event_id: Vec = row.try_get("event_id")?; let pubkey: Vec = row.try_get("pubkey")?; + let tags: serde_json::Value = row.try_get("tags")?; let content: String = row.try_get("content")?; let kind: i32 = row.try_get("kind")?; let created_at: DateTime = row.try_get("created_at")?; @@ -543,6 +552,7 @@ pub async fn get_channel_messages_top_level( messages.push(TopLevelMessage { event_id, pubkey, + tags, content, kind, created_at, diff --git a/crates/sprout-db/src/user.rs b/crates/sprout-db/src/user.rs index 6563748..77b56b8 100644 --- a/crates/sprout-db/src/user.rs +++ b/crates/sprout-db/src/user.rs @@ -12,6 +12,8 @@ pub struct UserProfile { pub display_name: Option, /// URL of the user's avatar image. pub avatar_url: Option, + /// Short bio or description provided by the user. + pub about: Option, /// NIP-05 identifier (user@domain). pub nip05_handle: Option, } @@ -33,9 +35,9 @@ pub async fn ensure_user(pool: &MySqlPool, pubkey: &[u8]) -> Result<()> { /// Get a single user record by pubkey. pub async fn get_user(pool: &MySqlPool, pubkey: &[u8]) -> Result> { - let row = sqlx::query_as::<_, (Vec, Option, Option, Option)>( + let row = sqlx::query_as::<_, (Vec, Option, Option, Option, Option)>( r#" - SELECT pubkey, display_name, avatar_url, nip05_handle + SELECT pubkey, display_name, avatar_url, about, nip05_handle FROM users WHERE pubkey = ? "#, @@ -45,16 +47,17 @@ pub async fn get_user(pool: &MySqlPool, pubkey: &[u8]) -> Result, avatar_url: Option<&str>, + about: Option<&str>, ) -> Result<()> { - match (display_name, avatar_url) { - (Some(name), Some(url)) => { - sqlx::query(r#"UPDATE users SET display_name = ?, avatar_url = ? WHERE pubkey = ?"#) - .bind(name) - .bind(url) - .bind(pubkey) - .execute(pool) - .await?; - } - (Some(name), None) => { - sqlx::query(r#"UPDATE users SET display_name = ? WHERE pubkey = ?"#) - .bind(name) - .bind(pubkey) - .execute(pool) - .await?; - } - (None, Some(url)) => { - sqlx::query(r#"UPDATE users SET avatar_url = ? WHERE pubkey = ?"#) - .bind(url) - .bind(pubkey) - .execute(pool) - .await?; - } - (None, None) => { - // Nothing to update — caller should have validated at least one field. - } + // Build SET clause dynamically to avoid 2^3 match arms. + let mut set_parts: Vec<&str> = Vec::new(); + if display_name.is_some() { + set_parts.push("display_name = ?"); } + if avatar_url.is_some() { + set_parts.push("avatar_url = ?"); + } + if about.is_some() { + set_parts.push("about = ?"); + } + + if set_parts.is_empty() { + // Nothing to update — caller should have validated at least one field. + return Ok(()); + } + + let sql = format!("UPDATE users SET {} WHERE pubkey = ?", set_parts.join(", ")); + let mut query = sqlx::query(&sql); + if let Some(name) = display_name { + query = query.bind(name); + } + if let Some(url) = avatar_url { + query = query.bind(url); + } + if let Some(bio) = about { + query = query.bind(bio); + } + query = query.bind(pubkey); + query.execute(pool).await?; Ok(()) } diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index a9a629c..398ed7d 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -455,6 +455,9 @@ impl SproutMcpServer { if let Some(ref parent_id) = p.parent_event_id { body["parent_event_id"] = serde_json::Value::String(parent_id.clone()); } + if let Some(kind) = p.kind { + body["kind"] = serde_json::json!(kind); + } match self .client .post(&format!("/api/channels/{}/messages", p.channel_id), &body) diff --git a/crates/sprout-relay/src/api/messages.rs b/crates/sprout-relay/src/api/messages.rs index e0074bd..d758dd5 100644 --- a/crates/sprout-relay/src/api/messages.rs +++ b/crates/sprout-relay/src/api/messages.rs @@ -57,6 +57,37 @@ fn effective_author(event: &nostr::Event, relay_pubkey: &nostr::PublicKey) -> Ve event.pubkey.serialize().to_vec() } +/// Resolve the effective author pubkey from stored (non-Event) data. +/// +/// REST-created messages are signed by the relay keypair and carry the real +/// sender in the first `p` tag. This helper mirrors `effective_author` but +/// works with raw bytes + stored tags JSON rather than a `nostr::Event`. +fn effective_author_bytes( + msg_pubkey: &[u8], + tags: &serde_json::Value, + relay_pubkey_bytes: &[u8], +) -> Vec { + if msg_pubkey == relay_pubkey_bytes { + // Relay-signed: real author is in the first p tag. + if let Some(tags_arr) = tags.as_array() { + for tag in tags_arr { + if let Some(arr) = tag.as_array() { + if arr.len() >= 2 && arr[0].as_str() == Some("p") { + if let Some(hex) = arr[1].as_str() { + if let Ok(bytes) = nostr_hex::decode(hex) { + if bytes.len() == 32 { + return bytes; + } + } + } + } + } + } + } + } + msg_pubkey.to_vec() +} + /// Serialize a slice of reaction summaries to JSON. fn reactions_to_json(reactions: &[sprout_db::reaction::ReactionSummary]) -> serde_json::Value { serde_json::json!(reactions @@ -434,12 +465,16 @@ pub async fn list_messages( // Determine next_cursor from the oldest message in this page. let next_cursor = messages.last().map(|m| m.created_at.timestamp()); + // Compute relay pubkey bytes once for effective-author resolution. + let relay_pk_bytes = state.relay_keypair.public_key().serialize().to_vec(); + let result: Vec = messages .iter() .map(|m| { + let author = effective_author_bytes(&m.pubkey, &m.tags, &relay_pk_bytes); let mut obj = serde_json::json!({ "event_id": nostr_hex::encode(&m.event_id), - "pubkey": nostr_hex::encode(&m.pubkey), + "pubkey": nostr_hex::encode(&author), "content": m.content, "kind": m.kind, "created_at": m.created_at.timestamp(), @@ -573,9 +608,12 @@ pub async fn get_thread( // Serialize root event. let root_created_at = root_event.event.created_at.as_u64() as i64; + let relay_pk = state.relay_keypair.public_key(); + let relay_pk_bytes = relay_pk.serialize().to_vec(); + let root_author = effective_author(&root_event.event, &relay_pk); let mut root_obj = serde_json::json!({ "event_id": root_event.event.id.to_hex(), - "pubkey": root_event.event.pubkey.to_hex(), + "pubkey": nostr_hex::encode(&root_author), "content": root_event.event.content, "kind": root_event.event.kind.as_u16(), "created_at": root_created_at, @@ -620,12 +658,13 @@ pub async fn get_thread( let reply_objs: Vec = replies .iter() .map(|r| { + let reply_author = effective_author_bytes(&r.pubkey, &r.tags, &relay_pk_bytes); let mut obj = serde_json::json!({ "event_id": nostr_hex::encode(&r.event_id), "parent_event_id": r.parent_event_id.as_ref().map(nostr_hex::encode), "root_event_id": r.root_event_id.as_ref().map(nostr_hex::encode), "channel_id": r.channel_id.to_string(), - "pubkey": nostr_hex::encode(&r.pubkey), + "pubkey": nostr_hex::encode(&reply_author), "content": r.content, "kind": r.kind, "depth": r.depth, diff --git a/crates/sprout-relay/src/api/users.rs b/crates/sprout-relay/src/api/users.rs index 2c05089..b9bff05 100644 --- a/crates/sprout-relay/src/api/users.rs +++ b/crates/sprout-relay/src/api/users.rs @@ -2,7 +2,7 @@ //! //! Endpoints: //! GET /api/users/me/profile — get own profile -//! PUT /api/users/me/profile — update own profile (display_name, avatar_url) +//! PUT /api/users/me/profile — update own profile (display_name, avatar_url, about) use std::sync::Arc; @@ -19,18 +19,20 @@ use crate::state::AppState; use super::{api_error, extract_auth_pubkey, internal_error}; /// Request body for updating a user's profile. -/// Both fields are optional — at least one must be present. +/// All fields are optional — at least one must be present. #[derive(Debug, Deserialize)] pub struct UpdateProfileBody { /// New display name for the user, or `None` to leave unchanged. pub display_name: Option, /// New avatar URL for the user, or `None` to leave unchanged. pub avatar_url: Option, + /// Short bio or description, or `None` to leave unchanged. + pub about: Option, } /// `PUT /api/users/me/profile` — update the authenticated user's profile. /// -/// Body: `{ "display_name": "Alice", "avatar_url": "https://..." }` (both optional, at least one required) +/// Body: `{ "display_name": "Alice", "avatar_url": "https://...", "about": "..." }` (all optional, at least one required) /// Returns: `{ "updated": true }` pub async fn update_profile( State(state): State>, @@ -49,17 +51,22 @@ pub async fn update_profile( .as_deref() .map(str::trim) .filter(|s| !s.is_empty()); + let about = body + .about + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); - if display_name.is_none() && avatar_url.is_none() { + if display_name.is_none() && avatar_url.is_none() && about.is_none() { return Err(api_error( StatusCode::BAD_REQUEST, - "at least one of display_name or avatar_url is required", + "at least one of display_name, avatar_url, or about is required", )); } state .db - .update_user_profile(&pubkey_bytes, display_name, avatar_url) + .update_user_profile(&pubkey_bytes, display_name, avatar_url, about) .await .map_err(|e| internal_error(&format!("db error: {e}")))?; @@ -68,7 +75,7 @@ pub async fn update_profile( /// `GET /api/users/me/profile` — get the authenticated user's profile. /// -/// Returns: `{ "pubkey": "", "display_name": "...", "avatar_url": "...", "nip05_handle": "..." }` +/// Returns: `{ "pubkey": "", "display_name": "...", "avatar_url": "...", "about": "...", "nip05_handle": "..." }` pub async fn get_profile( State(state): State>, headers: HeaderMap, @@ -86,6 +93,7 @@ pub async fn get_profile( "pubkey": nostr_hex::encode(&p.pubkey), "display_name": p.display_name, "avatar_url": p.avatar_url, + "about": p.about, "nip05_handle": p.nip05_handle, }))), None => Err(api_error(StatusCode::NOT_FOUND, "user not found")), diff --git a/crates/sprout-workflow/src/executor.rs b/crates/sprout-workflow/src/executor.rs index 3f4c67d..b75a152 100644 --- a/crates/sprout-workflow/src/executor.rs +++ b/crates/sprout-workflow/src/executor.rs @@ -834,8 +834,17 @@ async fn call_webhook_impl( /// POST `{"content": text}` to `POST /api/channels/{channel_id}/messages`. /// /// Relay base URL is read from `SPROUT_RELAY_BASE_URL` (default: `http://localhost:3000`). -/// Auth header `X-Pubkey` is set from `SPROUT_RELAY_PUBKEY` when present; omitted -/// otherwise (works in dev mode when `require_auth_token=false`). +/// +/// Auth strategy (in priority order): +/// 1. `SPROUT_API_TOKEN` env var → `Authorization: Bearer ` (production) +/// 2. `SPROUT_RELAY_PUBKEY` env var → `X-Pubkey: ` (dev mode only, +/// requires `SPROUT_REQUIRE_AUTH_TOKEN=false`) +/// +/// NOTE: This implementation uses X-Pubkey auth which only works in dev mode +/// (SPROUT_REQUIRE_AUTH_TOKEN=false). For production, the executor needs to +/// either: (a) use an API token (SPROUT_API_TOKEN env var), or (b) sign +/// events directly and submit via WebSocket. See WF-07. +/// TODO(WF-07): Support production auth for workflow-generated messages. /// /// This is an internal call — the workflow engine runs inside the relay process, /// so `localhost:3000` is always reachable without SSRF concerns. @@ -859,9 +868,10 @@ async fn send_message_impl(channel_id: &str, text: &str) -> Result Date: Tue, 10 Mar 2026 14:37:45 -0700 Subject: [PATCH 4/4] Fix lint failures on e2e branch --- crates/sprout-db/src/event.rs | 7 +++++-- crates/sprout-db/src/user.rs | 11 ++++++++++- crates/sprout-workflow/src/executor.rs | 11 ++++------- desktop/playwright.config.ts | 6 +++++- desktop/tests/e2e/integration.spec.ts | 6 +++--- desktop/tests/e2e/messaging.spec.ts | 4 +--- 6 files changed, 28 insertions(+), 17 deletions(-) diff --git a/crates/sprout-db/src/event.rs b/crates/sprout-db/src/event.rs index 0c56470..2a77685 100644 --- a/crates/sprout-db/src/event.rs +++ b/crates/sprout-db/src/event.rs @@ -462,7 +462,9 @@ pub async fn insert_event_with_thread_metadata( // Ensure the parent has a thread_metadata row so the UPDATE // below has something to hit. Root (depth=0) messages don't // get a row on first insert, so we create a stub here. - let parent_ts = meta.parent_event_created_at.unwrap_or(meta.event_created_at); + let parent_ts = meta + .parent_event_created_at + .unwrap_or(meta.event_created_at); sqlx::query( r#" INSERT IGNORE INTO thread_metadata @@ -482,7 +484,8 @@ pub async fn insert_event_with_thread_metadata( // Ensure the root also has a row (may differ from parent for nested replies). if let Some(root_id) = meta.root_event_id { if root_id != pid { - let root_ts = meta.root_event_created_at.unwrap_or(meta.event_created_at); + let root_ts = + meta.root_event_created_at.unwrap_or(meta.event_created_at); sqlx::query( r#" INSERT IGNORE INTO thread_metadata diff --git a/crates/sprout-db/src/user.rs b/crates/sprout-db/src/user.rs index 77b56b8..f35cf6b 100644 --- a/crates/sprout-db/src/user.rs +++ b/crates/sprout-db/src/user.rs @@ -35,7 +35,16 @@ pub async fn ensure_user(pool: &MySqlPool, pubkey: &[u8]) -> Result<()> { /// Get a single user record by pubkey. pub async fn get_user(pool: &MySqlPool, pubkey: &[u8]) -> Result> { - let row = sqlx::query_as::<_, (Vec, Option, Option, Option, Option)>( + let row = sqlx::query_as::< + _, + ( + Vec, + Option, + Option, + Option, + Option, + ), + >( r#" SELECT pubkey, display_name, avatar_url, about, nip05_handle FROM users diff --git a/crates/sprout-workflow/src/executor.rs b/crates/sprout-workflow/src/executor.rs index b75a152..e49c5f3 100644 --- a/crates/sprout-workflow/src/executor.rs +++ b/crates/sprout-workflow/src/executor.rs @@ -538,7 +538,7 @@ pub async fn dispatch_action( #[cfg(feature = "reqwest")] { let result = send_message_impl(channel_id, text).await?; - return Ok(StepResult::Completed(result)); + Ok(StepResult::Completed(result)) } #[cfg(not(feature = "reqwest"))] @@ -548,9 +548,9 @@ pub async fn dispatch_action( step = step_id, "SendMessage: reqwest feature not enabled, skipping HTTP call" ); - return Ok(StepResult::Completed( + Ok(StepResult::Completed( serde_json::json!({ "sent": false, "skipped": true }), - )); + )) } } @@ -892,10 +892,7 @@ async fn send_message_impl(channel_id: &str, text: &str) -> Result { // Switch to channel B — message from A should not appear await page.getByTestId(`channel-${channelB}`).click(); await expect(page.getByTestId("chat-title")).toHaveText(channelB); - await expect( - page.getByTestId("message-timeline"), - ).not.toContainText(messageA); + await expect(page.getByTestId("message-timeline")).not.toContainText( + messageA, + ); }); diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index bf5cc6f..f6e2c65 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -147,9 +147,7 @@ test("day divider appears in timeline", async ({ page }) => { await expect(page.getByTestId("message-timeline")).toContainText( "Welcome to #general", ); - await expect( - page.getByTestId("message-timeline-day-divider"), - ).toBeVisible(); + await expect(page.getByTestId("message-timeline-day-divider")).toBeVisible(); }); test("send message to DM channel", async ({ page }) => {