From 8ac10c3fb6be2ad532817d95a1ec74dd8782dd6e Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Wed, 11 Mar 2026 10:42:50 -0400 Subject: [PATCH 1/6] feat: public profile endpoints, batch resolution, kind:0 sync, and NIP-05 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add REST endpoints for reading any user's profile and batch-resolving display names, MCP tools for agent profile access, kind:0 side-effect sync from Nostr metadata events, and a working NIP-05 identity endpoint. REST API: - GET /api/users/{pubkey}/profile — read any user's profile (auth required) - POST /api/users/batch — batch resolve display names (max 200, dedup, case-normalize; invalid-length inputs returned in `missing`) MCP tools: - get_user_profile — read own or any user's profile by pubkey - get_users_batch — batch resolve display names for message attribution Kind:0 side effect: - NIP-01 profile metadata events now sync to the users table - Absolute-state semantics: absent fields are cleared (set to NULL) - Fields: display_name (with name fallback), avatar_url (picture/image), about, nip05_handle - Empty strings converted to NULL via empty_to_none() to respect the UNIQUE constraint on nip05_handle NIP-05 (/.well-known/nostr.json): - Replaces empty stub with working identity verification - Exact domain match against relay_url (no LIKE wildcards) - Access-Control-Allow-Origin: * header (NIP-05 spec requirement) - No authentication required (public discovery endpoint) DB changes: - update_user_profile gains nip05_handle parameter - New get_user_by_nip05 function (exact match, not LIKE) - No schema migrations required Tests: - 12 new REST API e2e tests (profile CRUD, batch, NIP-05, auth, edges) - 3 new MCP e2e tests (self profile, other profile, batch) - Tool count assertion updated from 36 to 38 - All 55 e2e tests pass (32 REST + 10 MCP + 13 relay) --- crates/sprout-db/src/lib.rs | 14 +- crates/sprout-db/src/user.rs | 83 +++-- crates/sprout-mcp/src/server.rs | 50 +++ crates/sprout-relay/src/api/mod.rs | 4 +- crates/sprout-relay/src/api/nip05.rs | 70 +++++ crates/sprout-relay/src/api/users.rs | 118 ++++++- .../sprout-relay/src/handlers/side_effects.rs | 52 ++- crates/sprout-relay/src/router.rs | 12 +- crates/sprout-test-client/tests/e2e_mcp.rs | 125 +++++++- .../sprout-test-client/tests/e2e_rest_api.rs | 297 ++++++++++++++++++ 10 files changed, 784 insertions(+), 41 deletions(-) create mode 100644 crates/sprout-relay/src/api/nip05.rs diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs index 7a49fe83b..f576afffc 100644 --- a/crates/sprout-db/src/lib.rs +++ b/crates/sprout-db/src/lib.rs @@ -559,15 +559,25 @@ impl Db { user::get_user(&self.pool, pubkey).await } - /// Update a user's display_name, avatar_url, and/or about. + /// Update a user's display_name, avatar_url, about, and/or nip05_handle. pub async fn update_user_profile( &self, pubkey: &[u8], display_name: Option<&str>, avatar_url: Option<&str>, about: Option<&str>, + nip05_handle: Option<&str>, ) -> Result<()> { - user::update_user_profile(&self.pool, pubkey, display_name, avatar_url, about).await + user::update_user_profile(&self.pool, pubkey, display_name, avatar_url, about, nip05_handle).await + } + + /// Look up a user by their full NIP-05 handle (exact match). + pub async fn get_user_by_nip05( + &self, + local_part: &str, + domain: &str, + ) -> Result> { + user::get_user_by_nip05(&self.pool, local_part, domain).await } // ── API Tokens ─────────────────────────────────────────────────────────── diff --git a/crates/sprout-db/src/user.rs b/crates/sprout-db/src/user.rs index f35cf6b38..fe9ed522f 100644 --- a/crates/sprout-db/src/user.rs +++ b/crates/sprout-db/src/user.rs @@ -66,45 +66,86 @@ pub async fn get_user(pool: &MySqlPool, pubkey: &[u8]) -> Result, avatar_url: Option<&str>, about: Option<&str>, + nip05_handle: Option<&str>, ) -> Result<()> { - // 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 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 nip05_handle.is_some() { set_parts.push("nip05_handle = ?"); } if set_parts.is_empty() { - // Nothing to update — caller should have validated at least one field. return Ok(()); } + // Helper: convert empty string to None (NULL in DB). This ensures UNIQUE + // columns like nip05_handle don't collide on empty strings, and keeps + // semantics clean: absent profile data is NULL, not "". + fn empty_to_none(val: Option<&str>) -> Option<&str> { + val.filter(|s| !s.is_empty()) + } + 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); - } + if display_name.is_some() { query = query.bind(empty_to_none(display_name)); } + if avatar_url.is_some() { query = query.bind(empty_to_none(avatar_url)); } + if about.is_some() { query = query.bind(empty_to_none(about)); } + if nip05_handle.is_some() { query = query.bind(empty_to_none(nip05_handle)); } query = query.bind(pubkey); query.execute(pool).await?; Ok(()) } + +/// Look up a user by their full NIP-05 handle (exact match, case-insensitive). +/// Both `local_part` and `domain` must already be lowercased by the caller. +pub async fn get_user_by_nip05( + pool: &MySqlPool, + local_part: &str, + domain: &str, +) -> Result> { + let handle = format!("{}@{}", local_part, domain); + let row = sqlx::query_as::< + _, + ( + Vec, + Option, + Option, + Option, + Option, + ), + >( + r#" + SELECT pubkey, display_name, avatar_url, about, nip05_handle + FROM users + WHERE LOWER(nip05_handle) = LOWER(?) + LIMIT 1 + "#, + ) + .bind(&handle) + .fetch_optional(pool) + .await?; + + Ok(row.map( + |(pubkey, display_name, avatar_url, about, nip05_handle)| UserProfile { + pubkey, + display_name, + avatar_url, + about, + nip05_handle, + }, + )) +} diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index 3479616a0..0d103f322 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -382,6 +382,21 @@ pub struct SetProfileParams { pub about: Option, } +/// Parameters for the `get_user_profile` tool. +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct GetUserProfileParams { + /// Hex-encoded pubkey to look up. Omit to get your own profile. + #[serde(default)] + pub pubkey: Option, +} + +/// Parameters for the `get_users_batch` tool. +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct GetUsersBatchParams { + /// List of hex-encoded pubkeys to look up (max 200). + pub pubkeys: Vec, +} + /// Parameters for the `get_feed` tool. #[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] pub struct GetFeedParams { @@ -1302,6 +1317,41 @@ impl SproutMcpServer { Err(e) => format!("Error: {e}"), } } + + /// Read a user's profile by pubkey. + #[tool( + name = "get_user_profile", + description = "Get a user's profile by pubkey. Omit pubkey to get your own profile. Returns display name, avatar URL, about text, and NIP-05 handle." + )] + pub async fn get_user_profile( + &self, + Parameters(p): Parameters, + ) -> String { + let path = match p.pubkey { + None => "/api/users/me/profile".to_string(), + Some(pk) => format!("/api/users/{}/profile", pk), + }; + match self.client.get(&path).await { + Ok(body) => body, + Err(e) => format!("Error fetching profile: {e}"), + } + } + + /// Resolve display names for multiple pubkeys. + #[tool( + name = "get_users_batch", + description = "Resolve display names and NIP-05 handles for multiple pubkeys at once. Returns a map of pubkey to profile info, plus a list of unknown pubkeys. Useful for identifying message senders in bulk." + )] + pub async fn get_users_batch( + &self, + Parameters(p): Parameters, + ) -> String { + let body = serde_json::json!({ "pubkeys": p.pubkeys }); + match self.client.post("/api/users/batch", &body).await { + Ok(resp) => resp, + Err(e) => format!("Error fetching profiles: {e}"), + } + } } #[tool_handler] diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index 794d72bd1..2cd1110ac 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -30,6 +30,8 @@ pub mod feed; pub mod members; /// Message and thread endpoints. pub mod messages; +/// NIP-05 identity verification endpoint. +pub mod nip05; /// Presence status endpoints. pub mod presence; /// Reaction endpoints. @@ -60,7 +62,7 @@ pub use messages::{delete_message, get_thread, list_messages, send_message}; pub use presence::presence_handler; pub use reactions::{add_reaction_handler, list_reactions_handler, remove_reaction_handler}; pub use search::search_handler; -pub use users::{get_profile, update_profile}; +pub use users::{get_profile, get_user_profile, get_users_batch, update_profile}; pub use workflows::{ create_workflow, delete_workflow, get_workflow, list_channel_workflows, list_workflow_runs, trigger_workflow, update_workflow, workflow_webhook, diff --git a/crates/sprout-relay/src/api/nip05.rs b/crates/sprout-relay/src/api/nip05.rs new file mode 100644 index 000000000..34bf84744 --- /dev/null +++ b/crates/sprout-relay/src/api/nip05.rs @@ -0,0 +1,70 @@ +//! NIP-05 identity verification endpoint. + +use std::sync::Arc; + +use axum::{ + extract::{Query, State}, + http::HeaderValue, + response::{IntoResponse, Json, Response}, +}; +use nostr::util::hex as nostr_hex; +use serde::Deserialize; + +use crate::state::AppState; + +/// Query parameters for the NIP-05 identity verification endpoint. +#[derive(Deserialize)] +pub struct Nip05Query { + /// The local part of the NIP-05 identifier to look up (e.g. `alice` from `alice@relay.example`). + pub name: Option, +} + +/// `GET /.well-known/nostr.json` — NIP-05 identity verification. +/// No authentication required — public discovery endpoint. +pub async fn nostr_nip05( + State(state): State>, + Query(params): Query, +) -> Response { + let json = match params.name { + None => serde_json::json!({ "names": {}, "relays": {} }), + Some(n) => { + let name = n.to_lowercase(); + // Extract domain from relay_url (e.g. "ws://sprout.block.xyz" → "sprout.block.xyz") + let domain = extract_domain(&state.config.relay_url); + match state.db.get_user_by_nip05(&name, &domain).await { + Ok(Some(user)) => { + let hex_pubkey = nostr_hex::encode(&user.pubkey); + let relay_url = state.config.relay_url.clone(); + serde_json::json!({ + "names": { (name): hex_pubkey.clone() }, + "relays": { (hex_pubkey): [relay_url] } + }) + } + _ => serde_json::json!({ "names": {}, "relays": {} }), + } + } + }; + + let mut response = Json(json).into_response(); + response.headers_mut().insert( + axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_static("*"), + ); + response +} + +/// Extract the domain (host) from a URL string. +/// e.g. "ws://localhost:3000" → "localhost", "wss://sprout.block.xyz" → "sprout.block.xyz" +fn extract_domain(url: &str) -> String { + url.trim_start_matches("wss://") + .trim_start_matches("ws://") + .trim_start_matches("https://") + .trim_start_matches("http://") + .split(':') + .next() + .unwrap_or("localhost") + .split('/') + .next() + .unwrap_or("localhost") + .to_lowercase() +} diff --git a/crates/sprout-relay/src/api/users.rs b/crates/sprout-relay/src/api/users.rs index b9bff05eb..7a1a413fa 100644 --- a/crates/sprout-relay/src/api/users.rs +++ b/crates/sprout-relay/src/api/users.rs @@ -1,13 +1,15 @@ //! User profile REST API. //! //! Endpoints: -//! GET /api/users/me/profile — get own profile -//! PUT /api/users/me/profile — update own profile (display_name, avatar_url, about) +//! GET /api/users/me/profile — get own profile +//! PUT /api/users/me/profile — update own profile (display_name, avatar_url, about) +//! GET /api/users/{pubkey}/profile — get any user's profile by pubkey hex +//! POST /api/users/batch — resolve display names for multiple pubkeys use std::sync::Arc; use axum::{ - extract::{Json as ExtractJson, State}, + extract::{Json as ExtractJson, Path, State}, http::{HeaderMap, StatusCode}, response::Json, }; @@ -66,7 +68,7 @@ pub async fn update_profile( state .db - .update_user_profile(&pubkey_bytes, display_name, avatar_url, about) + .update_user_profile(&pubkey_bytes, display_name, avatar_url, about, None) .await .map_err(|e| internal_error(&format!("db error: {e}")))?; @@ -99,3 +101,111 @@ pub async fn get_profile( None => Err(api_error(StatusCode::NOT_FOUND, "user not found")), } } + +/// `GET /api/users/{pubkey}/profile` — get any user's profile by pubkey hex. +pub async fn get_user_profile( + State(state): State>, + headers: HeaderMap, + Path(pubkey_hex): Path, +) -> Result, (StatusCode, Json)> { + let _ = extract_auth_pubkey(&headers, &state).await?; + + let pubkey_bytes = nostr_hex::decode(&pubkey_hex) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid pubkey hex"))?; + if pubkey_bytes.len() != 32 { + return Err(api_error(StatusCode::BAD_REQUEST, "pubkey must be 32 bytes")); + } + + let profile = state + .db + .get_user(&pubkey_bytes) + .await + .map_err(|e| internal_error(&format!("db error: {e}")))? + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "user not found"))?; + + Ok(Json(serde_json::json!({ + "pubkey": nostr_hex::encode(&profile.pubkey), + "display_name": profile.display_name, + "avatar_url": profile.avatar_url, + "about": profile.about, + "nip05_handle": profile.nip05_handle, + }))) +} + +/// Request body for the batch profile resolution endpoint. +#[derive(Debug, Deserialize)] +pub struct BatchProfilesRequest { + /// List of pubkey hex strings to resolve (max 200). + pub pubkeys: Vec, +} + +/// `POST /api/users/batch` — resolve display names for multiple pubkeys. +pub async fn get_users_batch( + State(state): State>, + headers: HeaderMap, + ExtractJson(body): ExtractJson, +) -> Result, (StatusCode, Json)> { + let _ = extract_auth_pubkey(&headers, &state).await?; + + if body.pubkeys.len() > 200 { + return Err(api_error( + StatusCode::BAD_REQUEST, + "max 200 pubkeys per request", + )); + } + + let valid_inputs: Vec<&str> = body.pubkeys + .iter() + .filter(|p| p.len() == 64) + .map(|p| p.as_str()) + .collect(); + + let normalized: std::collections::HashSet = valid_inputs + .iter() + .map(|p| p.to_lowercase()) + .collect(); + let mut normalized: Vec = normalized.into_iter().collect(); + normalized.sort(); + + let pubkey_bytes: Vec> = normalized.iter() + .filter_map(|h| nostr_hex::decode(h).ok()) + .filter(|b| b.len() == 32) + .collect(); + + let records = state + .db + .get_users_bulk(&pubkey_bytes) + .await + .map_err(|e| internal_error(&format!("db error: {e}")))?; + + let found_pubkeys: std::collections::HashSet = records + .iter() + .map(|r| nostr_hex::encode(&r.pubkey)) + .collect(); + + let mut profiles = serde_json::Map::new(); + for r in records { + let hex = nostr_hex::encode(&r.pubkey); + profiles.insert(hex, serde_json::json!({ + "display_name": r.display_name, + "nip05_handle": r.nip05_handle, + })); + } + + let mut missing: Vec = normalized + .iter() + .filter(|p| !found_pubkeys.contains(p.as_str())) + .cloned() + .collect(); + missing.extend( + body.pubkeys + .iter() + .filter(|p| p.len() != 64) + .cloned(), + ); + + Ok(Json(serde_json::json!({ + "profiles": profiles, + "missing": missing, + }))) +} diff --git a/crates/sprout-relay/src/handlers/side_effects.rs b/crates/sprout-relay/src/handlers/side_effects.rs index afe07c057..5561b02fe 100644 --- a/crates/sprout-relay/src/handlers/side_effects.rs +++ b/crates/sprout-relay/src/handlers/side_effects.rs @@ -17,7 +17,7 @@ pub fn is_admin_kind(kind: u32) -> bool { /// Check if a kind triggers side effects after storage. pub fn is_side_effect_kind(kind: u32) -> bool { - matches!(kind, 7 | 9000..=9022 | 41001..=41003 | 40099) + matches!(kind, 0 | 7 | 9000..=9022 | 41001..=41003 | 40099) } /// Dispatch side effects for a stored event. @@ -27,6 +27,7 @@ pub async fn handle_side_effects( state: &Arc, ) -> anyhow::Result<()> { match kind { + 0 => handle_kind0_profile(event, state).await, 9000 => handle_put_user(event, state).await, 9001 => handle_remove_user(event, state).await, 9002 => handle_edit_metadata(event, state).await, @@ -249,6 +250,55 @@ pub async fn emit_system_message( Ok(()) } +// ── NIP-01 Kind:0 Handler ──────────────────────────────────────────────────── + +/// Kind:0 (NIP-01 profile metadata) side effect — sync profile fields to users table. +async fn handle_kind0_profile( + event: &Event, + state: &Arc, +) -> anyhow::Result<()> { + let content: serde_json::Value = serde_json::from_str(&event.content) + .map_err(|e| anyhow::anyhow!("kind:0 content parse error: {e}"))?; + + // Kind:0 is absolute state (NIP-01 replaceable event). Fields present in the + // event are set; fields absent are cleared. We use Some("") to clear absent + // fields, since update_user_profile only writes Some values. + let display_name = content.get("display_name") + .or_else(|| content.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let avatar_url = content.get("picture") + .or_else(|| content.get("image")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let about = content.get("about") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let nip05_handle = content.get("nip05") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let pubkey_bytes = event.pubkey.serialize().to_vec(); + + state.db.ensure_user(&pubkey_bytes).await?; + + // Pass all fields as Some — empty string clears the field in the DB. + // This ensures kind:0 is treated as absolute state, not a partial update. + state.db.update_user_profile( + &pubkey_bytes, + Some(display_name), + Some(avatar_url), + Some(about), + Some(nip05_handle), + ).await?; + + info!(pubkey = %nostr::util::hex::encode(&pubkey_bytes), "kind:0 profile synced to users table"); + Ok(()) +} + // ── NIP-29 Handlers ────────────────────────────────────────────────────────── async fn handle_put_user(event: &Event, state: &Arc) -> anyhow::Result<()> { diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index ef366d4cd..c605085a2 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -23,7 +23,7 @@ pub fn build_router(state: Arc) -> Router { Router::new() .route("/", get(nip11_or_ws_handler)) .route("/info", get(relay_info_handler)) - .route("/.well-known/nostr.json", get(nip05_handler)) + .route("/.well-known/nostr.json", get(api::nip05::nostr_nip05)) .route("/health", get(health_handler)) .route( "/api/channels", @@ -122,6 +122,8 @@ pub fn build_router(state: Arc) -> Router { "/api/users/me/profile", get(api::get_profile).put(api::update_profile), ) + .route("/api/users/{pubkey}/profile", get(api::get_user_profile)) + .route("/api/users/batch", post(api::get_users_batch)) // Feed route .route("/api/feed", get(api::feed_handler)) .layer(TraceLayer::new_for_http()) @@ -163,14 +165,6 @@ async fn nip11_or_ws_handler( } } -// NIP-05 stub: returns empty names/relays. Full NIP-05 verification is planned. -async fn nip05_handler() -> impl IntoResponse { - Json(serde_json::json!({ - "names": {}, - "relays": {} - })) -} - async fn health_handler() -> impl IntoResponse { (StatusCode::OK, "ok") } diff --git a/crates/sprout-test-client/tests/e2e_mcp.rs b/crates/sprout-test-client/tests/e2e_mcp.rs index 9f6aefc53..cd4b5806a 100644 --- a/crates/sprout-test-client/tests/e2e_mcp.rs +++ b/crates/sprout-test-client/tests/e2e_mcp.rs @@ -246,7 +246,7 @@ impl McpSession { // ── Tests ───────────────────────────────────────────────────────────────────── /// Spawn the MCP server, complete the initialize handshake, and verify that -/// all 36 expected tools are listed by `tools/list`. +/// all 38 expected tools are listed by `tools/list`. #[tokio::test] #[ignore] async fn test_mcp_initialize_and_list_tools() { @@ -295,8 +295,8 @@ async fn test_mcp_initialize_and_list_tools() { assert_eq!( tools.len(), - 36, - "expected exactly 36 tools, got {}. Tools: {:?}", + 38, + "expected exactly 38 tools, got {}. Tools: {:?}", tools.len(), tools .iter() @@ -344,6 +344,8 @@ async fn test_mcp_initialize_and_list_tools() { "remove_reaction", "get_reactions", "set_profile", + "get_user_profile", + "get_users_batch", ]; for expected in &expected_tools { @@ -937,3 +939,120 @@ async fn test_mcp_canvas_set_and_get() { session.stop(); } + +// ── Public profile MCP tests ────────────────────────────────────────────────── + +/// Call `get_user_profile` with no arguments to retrieve the authenticated user's own profile. +#[tokio::test] +#[ignore] +async fn test_mcp_get_user_profile_self() { + let keys = generate_test_keys(); + let pubkey_hex = keys.public_key().to_hex(); + + // Set profile via REST first + let client = reqwest::Client::new(); + client + .put(format!("{}/api/users/me/profile", relay_http_url())) + .header("X-Pubkey", &pubkey_hex) + .json(&json!({"display_name": "MCP Self Test", "about": "Testing MCP profile"})) + .send() + .await + .expect("PUT profile"); + + let mut session = McpSession::start(&keys).await; + session.initialize(); + + // Get own profile (no pubkey arg) + let resp = session.send_request( + "tools/call", + json!({ + "name": "get_user_profile", + "arguments": {} + }), + ); + + let content = &resp["result"]["content"]; + let text = content[0]["text"].as_str().expect("text"); + let profile: serde_json::Value = serde_json::from_str(text).expect("parse profile json"); + assert_eq!(profile["display_name"].as_str(), Some("MCP Self Test")); + assert_eq!(profile["about"].as_str(), Some("Testing MCP profile")); + + session.stop(); +} + +/// Call `get_user_profile` with a pubkey argument to retrieve another user's profile. +#[tokio::test] +#[ignore] +async fn test_mcp_get_user_profile_other() { + let keys = generate_test_keys(); + + // Create another user with a profile + let other_keys = Keys::generate(); + let other_hex = other_keys.public_key().to_hex(); + let client = reqwest::Client::new(); + client + .put(format!("{}/api/users/me/profile", relay_http_url())) + .header("X-Pubkey", &other_hex) + .json(&json!({"display_name": "Other User MCP"})) + .send() + .await + .expect("PUT other profile"); + + let mut session = McpSession::start(&keys).await; + session.initialize(); + + let resp = session.send_request( + "tools/call", + json!({ + "name": "get_user_profile", + "arguments": {"pubkey": other_hex} + }), + ); + + let content = &resp["result"]["content"]; + let text = content[0]["text"].as_str().expect("text"); + let profile: serde_json::Value = serde_json::from_str(text).expect("parse profile json"); + assert_eq!(profile["display_name"].as_str(), Some("Other User MCP")); + + session.stop(); +} + +/// Call `get_users_batch` with a mix of known and unknown pubkeys. +#[tokio::test] +#[ignore] +async fn test_mcp_get_users_batch() { + let keys = generate_test_keys(); + let pubkey_hex = keys.public_key().to_hex(); + + // Set own profile + let client = reqwest::Client::new(); + client + .put(format!("{}/api/users/me/profile", relay_http_url())) + .header("X-Pubkey", &pubkey_hex) + .json(&json!({"display_name": "Batch MCP User"})) + .send() + .await + .expect("PUT profile"); + + let unknown_hex = Keys::generate().public_key().to_hex(); + + let mut session = McpSession::start(&keys).await; + session.initialize(); + + let resp = session.send_request( + "tools/call", + json!({ + "name": "get_users_batch", + "arguments": {"pubkeys": [pubkey_hex, unknown_hex]} + }), + ); + + let content = &resp["result"]["content"]; + let text = content[0]["text"].as_str().expect("text"); + let batch: serde_json::Value = serde_json::from_str(text).expect("parse batch json"); + + assert!(batch["profiles"].as_object().is_some(), "profiles map present"); + assert!(batch["missing"].as_array().is_some(), "missing array present"); + + session.stop(); +} diff --git a/crates/sprout-test-client/tests/e2e_rest_api.rs b/crates/sprout-test-client/tests/e2e_rest_api.rs index 54c10a5d7..67c5096a6 100644 --- a/crates/sprout-test-client/tests/e2e_rest_api.rs +++ b/crates/sprout-test-client/tests/e2e_rest_api.rs @@ -926,3 +926,300 @@ async fn test_valid_pubkey_header_accepted() { assert_eq!(resp.status(), 200, "expected 200 for valid X-Pubkey header"); } + +// ── Public profile tests ────────────────────────────────────────────────────── + +/// GET /api/users/:pubkey/profile returns the profile for a known user. +#[tokio::test] +#[ignore] +async fn test_get_user_profile_returns_known_user() { + let client = http_client(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + + // Set a profile first + let put_resp = client + .put(format!("{}/api/users/me/profile", relay_http_url())) + .header("X-Pubkey", &pubkey_hex) + .json(&serde_json::json!({ + "display_name": "Profile Test User", + "about": "Testing public profile endpoint" + })) + .send() + .await + .expect("PUT profile"); + assert_eq!(put_resp.status(), 200); + + // Read it back via the new public endpoint (using a different reader) + let reader_keys = Keys::generate(); + let reader_hex = reader_keys.public_key().to_hex(); + + let resp = authed_get( + &client, + &format!("{}/api/users/{}/profile", relay_http_url(), pubkey_hex), + &reader_hex, + ) + .await; + + assert_eq!(resp.status(), 200, "expected 200 for known user profile"); + let body: serde_json::Value = resp.json().await.expect("json"); + assert_eq!(body["pubkey"].as_str(), Some(pubkey_hex.as_str())); + assert_eq!(body["display_name"].as_str(), Some("Profile Test User")); + assert_eq!(body["about"].as_str(), Some("Testing public profile endpoint")); +} + +/// GET /api/users/:pubkey/profile returns 404 for an unknown user. +#[tokio::test] +#[ignore] +async fn test_get_user_profile_returns_404_for_unknown() { + let client = http_client(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + // Use a pubkey that has never been registered + let unknown_hex = Keys::generate().public_key().to_hex(); + + let resp = authed_get( + &client, + &format!("{}/api/users/{}/profile", relay_http_url(), unknown_hex), + &pubkey_hex, + ) + .await; + + assert_eq!(resp.status(), 404, "expected 404 for unknown user"); +} + +/// GET /api/users/:pubkey/profile returns 401 without authentication. +#[tokio::test] +#[ignore] +async fn test_get_user_profile_requires_auth() { + let client = http_client(); + let some_pubkey = Keys::generate().public_key().to_hex(); + + let resp = client + .get(format!("{}/api/users/{}/profile", relay_http_url(), some_pubkey)) + .send() + .await + .expect("GET profile"); + + assert_eq!(resp.status(), 401, "expected 401 without auth"); +} + +/// GET /api/users/:pubkey/profile returns 400 for an invalid pubkey. +#[tokio::test] +#[ignore] +async fn test_get_user_profile_rejects_invalid_pubkey() { + let client = http_client(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + + let resp = authed_get( + &client, + &format!("{}/api/users/{}/profile", relay_http_url(), "not-a-valid-hex"), + &pubkey_hex, + ) + .await; + + assert_eq!(resp.status(), 400, "expected 400 for invalid pubkey hex"); +} + +/// POST /api/users/batch returns found profiles and a missing list. +#[tokio::test] +#[ignore] +async fn test_batch_profiles_known_and_unknown() { + let client = http_client(); + + // Create two users with profiles + let keys_a = Keys::generate(); + let hex_a = keys_a.public_key().to_hex(); + let keys_b = Keys::generate(); + let hex_b = keys_b.public_key().to_hex(); + let unknown_hex = Keys::generate().public_key().to_hex(); + + client + .put(format!("{}/api/users/me/profile", relay_http_url())) + .header("X-Pubkey", &hex_a) + .json(&serde_json::json!({"display_name": "Alice Batch"})) + .send().await.expect("PUT alice"); + + client + .put(format!("{}/api/users/me/profile", relay_http_url())) + .header("X-Pubkey", &hex_b) + .json(&serde_json::json!({"display_name": "Bob Batch"})) + .send().await.expect("PUT bob"); + + // Batch lookup + let resp = authed_post_json( + &client, + &format!("{}/api/users/batch", relay_http_url()), + &hex_a, + serde_json::json!({ + "pubkeys": [hex_a, hex_b, unknown_hex] + }), + ) + .await; + + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.expect("json"); + + let profiles = body["profiles"].as_object().expect("profiles map"); + assert_eq!(profiles.len(), 2, "expected 2 found profiles"); + assert_eq!( + profiles[&hex_a.to_lowercase()]["display_name"].as_str(), + Some("Alice Batch") + ); + assert_eq!( + profiles[&hex_b.to_lowercase()]["display_name"].as_str(), + Some("Bob Batch") + ); + + let missing = body["missing"].as_array().expect("missing array"); + assert!( + missing.iter().any(|v| v.as_str() == Some(&unknown_hex.to_lowercase())), + "unknown pubkey should be in missing" + ); +} + +/// POST /api/users/batch returns 400 when more than 200 pubkeys are submitted. +#[tokio::test] +#[ignore] +async fn test_batch_profiles_rejects_over_200() { + let client = http_client(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + + let pubkeys: Vec = (0..201).map(|i| format!("{:064x}", i)).collect(); + + let resp = authed_post_json( + &client, + &format!("{}/api/users/batch", relay_http_url()), + &pubkey_hex, + serde_json::json!({"pubkeys": pubkeys}), + ) + .await; + + assert_eq!(resp.status(), 400, "expected 400 for >200 pubkeys"); +} + +/// POST /api/users/batch returns 401 without authentication. +#[tokio::test] +#[ignore] +async fn test_batch_profiles_requires_auth() { + let client = http_client(); + + let resp = client + .post(format!("{}/api/users/batch", relay_http_url())) + .json(&serde_json::json!({"pubkeys": ["abc"]})) + .send() + .await + .expect("POST batch"); + + assert_eq!(resp.status(), 401, "expected 401 without auth"); +} + +/// POST /api/users/batch places invalid-length inputs in the missing list. +#[tokio::test] +#[ignore] +async fn test_batch_profiles_invalid_length_in_missing() { + let client = http_client(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + + let resp = authed_post_json( + &client, + &format!("{}/api/users/batch", relay_http_url()), + &pubkey_hex, + serde_json::json!({"pubkeys": ["tooshort", "x".repeat(100)]}), + ) + .await; + + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.expect("json"); + let missing = body["missing"].as_array().expect("missing"); + assert_eq!(missing.len(), 2, "both invalid-length inputs should be in missing"); +} + +/// POST /api/users/batch normalizes pubkey case before lookup. +#[tokio::test] +#[ignore] +async fn test_batch_profiles_case_normalized() { + let client = http_client(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + + // Set profile + client + .put(format!("{}/api/users/me/profile", relay_http_url())) + .header("X-Pubkey", &pubkey_hex) + .json(&serde_json::json!({"display_name": "Case Test"})) + .send().await.expect("PUT"); + + // Query with uppercase version + let upper_hex = pubkey_hex.to_uppercase(); + let resp = authed_post_json( + &client, + &format!("{}/api/users/batch", relay_http_url()), + &pubkey_hex, + serde_json::json!({"pubkeys": [upper_hex]}), + ) + .await; + + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.expect("json"); + let profiles = body["profiles"].as_object().expect("profiles"); + assert_eq!(profiles.len(), 1, "uppercase pubkey should match"); +} + +// ── NIP-05 tests ────────────────────────────────────────────────────────────── + +/// GET /.well-known/nostr.json?name=nonexistent returns empty names and relays. +#[tokio::test] +#[ignore] +async fn test_nip05_returns_empty_for_unknown_name() { + let client = http_client(); + + let resp = client + .get(format!("{}/.well-known/nostr.json?name=nonexistent", relay_http_url())) + .send() + .await + .expect("GET nip05"); + + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.expect("json"); + assert_eq!(body["names"].as_object().unwrap().len(), 0); + assert_eq!(body["relays"].as_object().unwrap().len(), 0); +} + +/// GET /.well-known/nostr.json with no name param returns empty names. +#[tokio::test] +#[ignore] +async fn test_nip05_no_name_returns_empty() { + let client = http_client(); + + let resp = client + .get(format!("{}/.well-known/nostr.json", relay_http_url())) + .send() + .await + .expect("GET nip05"); + + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.expect("json"); + assert_eq!(body["names"].as_object().unwrap().len(), 0); +} + +/// GET /.well-known/nostr.json includes the required CORS header. +#[tokio::test] +#[ignore] +async fn test_nip05_has_cors_header() { + let client = http_client(); + + let resp = client + .get(format!("{}/.well-known/nostr.json", relay_http_url())) + .send() + .await + .expect("GET nip05"); + + assert_eq!(resp.status(), 200); + let cors = resp.headers().get("access-control-allow-origin"); + assert!(cors.is_some(), "NIP-05 must have Access-Control-Allow-Origin header"); + assert_eq!(cors.unwrap().to_str().unwrap(), "*"); +} From 421769b8cc471247a3c559890ab9f111d290782a Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Wed, 11 Mar 2026 10:53:08 -0400 Subject: [PATCH 2/6] docs: update TESTING.md for public profiles feature - Update MCP tool count from 36 to 38 across all references - Add get_user_profile and get_users_batch to Profile tools table - Update integration test counts: 32 REST, 10 MCP, 55 total (plus 7 workflow tests noted separately) - Add B-8 exercise: Bob tests profile resolution via MCP tools - Add C-8 exercise: Charlie verifies NIP-05 identity endpoint - Add C-9 exercise: Charlie tests profile lookup edge cases - Update A-5 to set Alice's NIP-05 handle (alice@localhost) - Update Expected Results table with profile and NIP-05 rows - Update 'as of' date to 2026-03-11 --- TESTING.md | 66 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 18 deletions(-) diff --git a/TESTING.md b/TESTING.md index 8c6178294..e4481d545 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,13 +1,13 @@ # Sprout Testing Guide -This guide enables an AI agent (the **operator**) to run the full Sprout test suite: automated `cargo test` suites and a three-agent multi-agent E2E run that exercises all 36 MCP tools against a live relay. +This guide enables an AI agent (the **operator**) to run the full Sprout test suite: automated `cargo test` suites and a three-agent multi-agent E2E run that exercises all 38 MCP tools against a live relay. ## Two Test Modes | Mode | What It Does | When to Use | |------|-------------|-------------| | **Automated** (`cargo test`) | Unit tests + REST/WebSocket/MCP integration tests | Fast CI check; verify no unit regressions | -| **Multi-Agent E2E** | Three agents (Alice, Bob, Charlie) run via `sprout-acp` harness, exercising all 36 MCP tools via real Nostr identities | Before merging relay/MCP/auth changes; full regression run; exploring new features | +| **Multi-Agent E2E** | Three agents (Alice, Bob, Charlie) run via `sprout-acp` harness, exercising all 38 MCP tools via real Nostr identities | Before merging relay/MCP/auth changes; full regression run; exploring new features | Run both modes for a complete regression check. Run automated-only for a fast sanity check. @@ -27,7 +27,7 @@ Run both modes for a complete regression check. Run automated-only for a fast sa - [3.7 Expected Results](#37-expected-results) 4. [Advanced: ACP Harness Scenarios](#4-advanced-acp-harness-scenarios) 5. [Workflow YAML Reference](#5-workflow-yaml-reference) -6. [The 36 MCP Tools](#6-the-36-mcp-tools) +6. [The 38 MCP Tools](#6-the-38-mcp-tools) 7. [Cleanup](#7-cleanup) 8. [Known Issues / Troubleshooting](#8-known-issues--troubleshooting) @@ -163,7 +163,7 @@ sleep 3 && curl -s http://localhost:3000/health Then run the integration suites: ```bash -# REST API integration tests (20 tests) +# REST API integration tests (32 tests) RELAY_URL=ws://localhost:3000 \ cargo test -p sprout-test-client --test e2e_rest_api -- --ignored @@ -171,7 +171,7 @@ RELAY_URL=ws://localhost:3000 \ RELAY_URL=ws://localhost:3000 \ cargo test -p sprout-test-client --test e2e_relay -- --ignored -# MCP server integration tests (7 tests) +# MCP server integration tests (10 tests) RELAY_URL=ws://localhost:3000 \ cargo test -p sprout-test-client --test e2e_mcp -- --ignored ``` @@ -179,12 +179,12 @@ RELAY_URL=ws://localhost:3000 \ ### Expected Results ``` -test result: ok. 20 passed; 0 failed; 0 ignored ← REST API +test result: ok. 32 passed; 0 failed; 0 ignored ← REST API test result: ok. 13 passed; 0 failed; 0 ignored ← relay -test result: ok. 7 passed; 0 failed; 0 ignored ← MCP +test result: ok. 10 passed; 0 failed; 0 ignored ← MCP ``` -All 40 integration tests pass. If any fail, check that the relay is running and Docker services are healthy before proceeding to E2E. +All 55 integration tests pass (across the three suites above). An additional 7 workflow integration tests exist in `e2e_workflows.rs` — run them separately if workflow changes are involved. If any fail, check that the relay is running and Docker services are healthy before proceeding to E2E. --- @@ -202,7 +202,7 @@ Operator (you) Sprout Relay ──WS (NIP-01)──► sprout-acp (harness) ──stdio (ACP)──► goose │ sprout-mcp-server - (36 MCP tools) + (38 MCP tools) │ Sprout Relay (send_message, etc.) @@ -428,11 +428,11 @@ mention "$GENERAL" "$ALICE_PUBKEY" \ "Create a workflow named 'alice-notify' with a message_posted trigger on the 'general' channel. The workflow should have one step: send a message to the 'general' channel saying 'Workflow fired!'. Save the workflow ID and report it back." ``` -**A-5: Profile and presence** +**A-5: Profile, NIP-05 identity, and presence** ```bash mention "$GENERAL" "$ALICE_PUBKEY" \ - "Set your display name to 'Alice (Test Agent)'. Set your about/bio to 'I am Alice, the infrastructure creator for the Sprout E2E test suite.' Set your presence to online." + "Set your display name to 'Alice (Test Agent)'. Set your about/bio to 'I am Alice, the infrastructure creator for the Sprout E2E test suite.' Set your NIP-05 handle to 'alice@localhost' using set_profile. Set your presence to online." ``` **A-6: Feed, search, and membership** @@ -497,6 +497,13 @@ mention "$GENERAL" "$BOB_PUBKEY" \ "Get the presence status for Alice (pubkey: $ALICE_PUBKEY). Get your own presence status. Report both." ``` +**B-8: Profile resolution (public profiles)** + +```bash +mention "$GENERAL" "$BOB_PUBKEY" \ + "Use get_user_profile to look up Alice's profile (pubkey: $ALICE_PUBKEY). Report her display name and about text. Then use get_users_batch with all three pubkeys (yours: $BOB_PUBKEY, Alice: $ALICE_PUBKEY, Charlie: $CHARLIE_PUBKEY). Report which ones have display names set and which are in the missing list." +``` + --- #### Charlie — Edge Case Specialist @@ -554,6 +561,24 @@ mention "$GENERAL" "$CHARLIE_PUBKEY" \ --- +**C-8: NIP-05 identity verification** + +```bash +mention "$GENERAL" "$CHARLIE_PUBKEY" \ + "Verify the NIP-05 endpoint. Alice set her NIP-05 handle to 'alice@localhost' in exercise A-5. Use curl or an HTTP request to GET http://localhost:3000/.well-known/nostr.json?name=alice — it should return her pubkey in the 'names' map and a relay URL in the 'relays' map. Also try ?name=nonexistent and confirm it returns empty names/relays. Check that the response includes an Access-Control-Allow-Origin: * header. Report your findings." +``` + +--- + +**C-9: Profile edge cases** + +```bash +mention "$GENERAL" "$CHARLIE_PUBKEY" \ + "Test profile edge cases. Use get_user_profile with a pubkey that doesn't exist — report the error. Use get_users_batch with a mix of valid pubkeys, an invalid-length string like 'tooshort', and a string that is 64 chars but not valid hex. Report what ends up in the profiles map vs the missing list." +``` + +--- + ### 3.6 Monitoring & Verification #### Watch harness logs live @@ -660,6 +685,9 @@ After all exercises complete, the following should be true: | Bob in private-ops | Yes (Alice invited him in A-6) | | Workflow | alice-notify created with message_posted trigger | | Display names | Alice and Bob have display names set | +| Profile resolution | Bob can read Alice's profile via `get_user_profile`; `get_users_batch` returns Alice and Bob in profiles, Charlie in missing | +| NIP-05 verification | Charlie queries `/.well-known/nostr.json?name=alice` and gets Alice's pubkey (Alice set `alice@localhost` in A-5) | +| Profile edge cases | Charlie gets appropriate errors for invalid/unknown pubkeys | | Error handling | Charlie's C-1, C-2, C-5 exercises report correct errors | | charlie-lifecycle | Unarchived and final message sent successfully | @@ -967,9 +995,9 @@ steps: --- -## 6. The 36 MCP Tools +## 6. The 38 MCP Tools -The `sprout-mcp-server` exposes 36 tools covering the full Sprout feature surface. All are available to agents running via the `sprout-acp` harness. +The `sprout-mcp-server` exposes 38 tools covering the full Sprout feature surface. All are available to agents running via the `sprout-acp` harness. ### Channels (8) @@ -1037,11 +1065,13 @@ The `sprout-mcp-server` exposes 36 tools covering the full Sprout feature surfac |------|-------------| | `search` | Full-text search across messages and channels | -### Profile (3) +### Profile (5) | Tool | Description | |------|-------------| -| `get_profile` | Get the agent's profile | +| `get_profile` | Get the agent's own profile | +| `get_user_profile` | Get any user's profile by pubkey (omit pubkey for own profile) | +| `get_users_batch` | Resolve display names and NIP-05 handles for multiple pubkeys | | `set_display_name` | Set the agent's display name | | `set_about` | Set the agent's bio/about text | @@ -1123,11 +1153,11 @@ rm -f /tmp/alice-keys.txt /tmp/agent-b-keys.txt /tmp/agent-c-keys.txt ### Current Status -All automated tests pass as of 2026-03-10: +All automated tests pass as of 2026-03-11: -- ✅ 20/20 REST API integration tests +- ✅ 32/32 REST API integration tests - ✅ 13/13 WebSocket relay integration tests -- ✅ 7/7 MCP server integration tests +- ✅ 10/10 MCP server integration tests - ✅ Multi-agent E2E (Alice/Bob/Charlie) via sprout-acp harness --- From 3ef873b0279dad914d8dc531ecadba83ee49875e Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Wed, 11 Mar 2026 12:14:43 -0400 Subject: [PATCH 3/6] feat: add search + get_presence MCP tools, fix set_profile NIP-05 bug, update TESTING.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'search' MCP tool wrapping GET /api/search (Typesense full-text) - Add 'get_presence' MCP tool wrapping GET /api/presence (bulk lookup) - Fix set_profile to support nip05_handle field (was silently dropped) - Thread nip05_handle through UpdateProfileBody → update_user_profile - Update TESTING.md section 6 tool table: correct all tool names, add missing tools (40 total), remove nonexistent tools - Fix exercises A-5, B-6 for accuracy; add bootstrap channel timing note - Fix expected results: Charlie in profiles map (not missing list) - Update e2e test assertions: 38 → 40 tools --- TESTING.md | 81 +++++++++++++--------- crates/sprout-mcp/src/server.rs | 50 ++++++++++++- crates/sprout-relay/src/api/users.rs | 13 +++- crates/sprout-test-client/tests/e2e_mcp.rs | 8 ++- 4 files changed, 112 insertions(+), 40 deletions(-) diff --git a/TESTING.md b/TESTING.md index e4481d545..88b6c4760 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,13 +1,13 @@ # Sprout Testing Guide -This guide enables an AI agent (the **operator**) to run the full Sprout test suite: automated `cargo test` suites and a three-agent multi-agent E2E run that exercises all 38 MCP tools against a live relay. +This guide enables an AI agent (the **operator**) to run the full Sprout test suite: automated `cargo test` suites and a three-agent multi-agent E2E run that exercises all 40 MCP tools against a live relay. ## Two Test Modes | Mode | What It Does | When to Use | |------|-------------|-------------| | **Automated** (`cargo test`) | Unit tests + REST/WebSocket/MCP integration tests | Fast CI check; verify no unit regressions | -| **Multi-Agent E2E** | Three agents (Alice, Bob, Charlie) run via `sprout-acp` harness, exercising all 38 MCP tools via real Nostr identities | Before merging relay/MCP/auth changes; full regression run; exploring new features | +| **Multi-Agent E2E** | Three agents (Alice, Bob, Charlie) run via `sprout-acp` harness, exercising all 40 MCP tools via real Nostr identities | Before merging relay/MCP/auth changes; full regression run; exploring new features | Run both modes for a complete regression check. Run automated-only for a fast sanity check. @@ -27,7 +27,7 @@ Run both modes for a complete regression check. Run automated-only for a fast sa - [3.7 Expected Results](#37-expected-results) 4. [Advanced: ACP Harness Scenarios](#4-advanced-acp-harness-scenarios) 5. [Workflow YAML Reference](#5-workflow-yaml-reference) -6. [The 38 MCP Tools](#6-the-38-mcp-tools) +6. [The 40 MCP Tools](#6-the-40-mcp-tools) 7. [Cleanup](#7-cleanup) 8. [Known Issues / Troubleshooting](#8-known-issues--troubleshooting) @@ -202,7 +202,7 @@ Operator (you) Sprout Relay ──WS (NIP-01)──► sprout-acp (harness) ──stdio (ACP)──► goose │ sprout-mcp-server - (38 MCP tools) + (40 MCP tools) │ Sprout Relay (send_message, etc.) @@ -345,6 +345,14 @@ subscribed to channel If you see `discovered 0 channel(s)`, the agent is not yet a member of any channels. Alice will create channels in the first exercise — after that, all three will discover them on subsequent subscriptions (open channels are accessible to any authenticated pubkey). +> **Bootstrap channel timing:** Harnesses discover channels only at startup. +> If you create the bootstrap channel (in exercise A-1) *after* launching +> harnesses, Alice's harness won't be subscribed to it. Two options: +> 1. Create the bootstrap channel *before* launching harnesses (recommended): +> run the `curl -X POST` command from A-1 first, then start all three harnesses. +> 2. Restart Alice's harness after creating the bootstrap channel — it will +> discover and subscribe to it on reconnect. + --- ### 3.5 Test Exercises @@ -432,7 +440,7 @@ mention "$GENERAL" "$ALICE_PUBKEY" \ ```bash mention "$GENERAL" "$ALICE_PUBKEY" \ - "Set your display name to 'Alice (Test Agent)'. Set your about/bio to 'I am Alice, the infrastructure creator for the Sprout E2E test suite.' Set your NIP-05 handle to 'alice@localhost' using set_profile. Set your presence to online." + "Set your display name to 'Alice (Test Agent)'. Set your about/bio to 'I am Alice, the infrastructure creator for the Sprout E2E test suite.' Set your NIP-05 handle to 'alice@localhost' using set_profile. Then use get_presence to check your own presence status (pubkey: $ALICE_PUBKEY)." ``` **A-6: Feed, search, and membership** @@ -487,7 +495,7 @@ mention "$GENERAL" "$BOB_PUBKEY" \ ```bash mention "$GENERAL" "$BOB_PUBKEY" \ - "Try to get the message history from the 'private-ops' channel (ID: $PRIVATE_OPS). Report whether you can access it or get an error. Then try again — Alice should have invited you by now." + "Get the message history from the 'private-ops' channel (ID: $PRIVATE_OPS). Alice invited you in exercise A-6 — confirm you have access and report what you find." ``` **B-7: Get presence** @@ -685,7 +693,7 @@ After all exercises complete, the following should be true: | Bob in private-ops | Yes (Alice invited him in A-6) | | Workflow | alice-notify created with message_posted trigger | | Display names | Alice and Bob have display names set | -| Profile resolution | Bob can read Alice's profile via `get_user_profile`; `get_users_batch` returns Alice and Bob in profiles, Charlie in missing | +| Profile resolution | Bob can read Alice's profile via `get_user_profile`; `get_users_batch` returns Alice and Bob with display names, Charlie with null display name (all in profiles map) | | NIP-05 verification | Charlie queries `/.well-known/nostr.json?name=alice` and gets Alice's pubkey (Alice set `alice@localhost` in A-5) | | Profile edge cases | Charlie gets appropriate errors for invalid/unknown pubkeys | | Error handling | Charlie's C-1, C-2, C-5 exercises report correct errors | @@ -995,16 +1003,16 @@ steps: --- -## 6. The 38 MCP Tools +## 6. The 40 MCP Tools -The `sprout-mcp-server` exposes 38 tools covering the full Sprout feature surface. All are available to agents running via the `sprout-acp` harness. +The `sprout-mcp-server` exposes 40 tools covering the full Sprout feature surface. All are available to agents running via the `sprout-acp` harness. ### Channels (8) | Tool | Description | |------|-------------| | `list_channels` | List all channels accessible to the agent | -| `get_channel_info` | Get metadata for a specific channel | +| `get_channel` | Get metadata for a specific channel | | `create_channel` | Create a new channel (`channel_type`: stream\|forum, `visibility`: open\|private) | | `update_channel` | Update channel name or metadata | | `archive_channel` | Archive a channel (creator only) | @@ -1012,14 +1020,19 @@ The `sprout-mcp-server` exposes 38 tools covering the full Sprout feature surfac | `join_channel` | Join an open channel | | `leave_channel` | Leave a channel | -### Messages (4) +### Messages (2) | Tool | Description | |------|-------------| | `send_message` | Post a message to a channel | | `get_channel_history` | Get recent messages from a channel | -| `send_thread_reply` | Reply within a message thread | -| `get_thread_replies` | Get replies in a thread | + +### Threads (2) + +| Tool | Description | +|------|-------------| +| `send_reply` | Reply within a message thread | +| `get_thread` | Get replies in a thread | ### Reactions (3) @@ -1027,15 +1040,15 @@ The `sprout-mcp-server` exposes 38 tools covering the full Sprout feature surfac |------|-------------| | `add_reaction` | Add an emoji reaction to a message | | `remove_reaction` | Remove a reaction | -| `get_message_reactions` | List all reactions on a message | +| `get_reactions` | List all reactions on a message | ### Direct Messages (3) | Tool | Description | |------|-------------| -| `send_dm` | Send a direct message to a user | -| `get_dm_history` | Get DM conversation history | -| `list_dm_conversations` | List all DM conversations | +| `open_dm` | Create or retrieve a DM channel with a user (optionally send an initial message) | +| `add_dm_member` | Add a member to an existing DM conversation | +| `list_dms` | List all DM conversations | ### Canvas (2) @@ -1044,20 +1057,25 @@ The `sprout-mcp-server` exposes 38 tools covering the full Sprout feature surfac | `get_canvas` | Read the canvas document for a channel | | `set_canvas` | Write/overwrite the canvas document (last writer wins) | -### Workflows (3) +### Workflows (7) | Tool | Description | |------|-------------| -| `create_workflow` | Create a new workflow with trigger and steps | | `list_workflows` | List all workflows | +| `create_workflow` | Create a new workflow with trigger and steps | +| `update_workflow` | Update an existing workflow | +| `delete_workflow` | Delete a workflow | | `trigger_workflow` | Manually trigger a webhook workflow | +| `get_workflow_runs` | Get execution history for a workflow | +| `approve_workflow_step` | Approve a pending approval step in a workflow run | -### Feed (2) +### Feed (3) | Tool | Description | |------|-------------| | `get_feed` | Get the agent's personal activity feed | -| `get_channel_feed` | Get the activity feed for a specific channel | +| `get_feed_mentions` | Get mentions from the agent's feed | +| `get_feed_actions` | Get action items from the agent's feed | ### Search (1) @@ -1065,37 +1083,34 @@ The `sprout-mcp-server` exposes 38 tools covering the full Sprout feature surfac |------|-------------| | `search` | Full-text search across messages and channels | -### Profile (5) +### Profile (3) | Tool | Description | |------|-------------| -| `get_profile` | Get the agent's own profile | +| `set_profile` | Set display name, about/bio, avatar URL, and NIP-05 handle | | `get_user_profile` | Get any user's profile by pubkey (omit pubkey for own profile) | -| `get_users_batch` | Resolve display names and NIP-05 handles for multiple pubkeys | -| `set_display_name` | Set the agent's display name | -| `set_about` | Set the agent's bio/about text | +| `get_users_batch` | Bulk resolve display names and NIP-05 handles for multiple pubkeys | -### Presence (2) +### Presence (1) | Tool | Description | |------|-------------| -| `set_presence` | Set the agent's presence status (online/away/etc.) | -| `get_presence` | Get presence status for a user | +| `get_presence` | Bulk presence lookup by pubkey | -### Members (2) +### Members (3) | Tool | Description | |------|-------------| +| `add_channel_member` | Add a user (by pubkey) to a channel | +| `remove_channel_member` | Remove a member from a channel | | `list_channel_members` | List members of a channel | -| `get_user_channels` | Get channels a user belongs to | -### Admin (3) +### Admin (2) | Tool | Description | |------|-------------| | `set_channel_topic` | Set the topic for a channel | | `set_channel_purpose` | Set the purpose for a channel | -| `invite_to_channel` | Invite a user (by pubkey) to a channel | --- diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index 0d103f322..3af3b3877 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -380,6 +380,9 @@ pub struct SetProfileParams { /// Short bio or description. #[serde(default)] pub about: Option, + /// NIP-05 identifier (e.g. "alice@example.com"), or None to leave unchanged. + #[serde(default)] + pub nip05_handle: Option, } /// Parameters for the `get_user_profile` tool. @@ -397,6 +400,23 @@ pub struct GetUsersBatchParams { pub pubkeys: Vec, } +/// Parameters for the `search` tool. +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct SearchParams { + /// Full-text search query string. + pub q: String, + /// Maximum results to return (default 20, max 100). + #[serde(default)] + pub limit: Option, +} + +/// Parameters for the `get_presence` tool. +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct GetPresenceParams { + /// Comma-separated hex-encoded public keys to look up presence for (max 200). + pub pubkeys: String, +} + /// Parameters for the `get_feed` tool. #[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] pub struct GetFeedParams { @@ -1304,13 +1324,14 @@ impl SproutMcpServer { /// Update the agent's user profile. #[tool( name = "set_profile", - description = "Update the agent's user profile (display name and/or avatar URL)." + description = "Update the agent's user profile (display name, about, avatar URL, and/or NIP-05 handle)." )] pub async fn set_profile(&self, Parameters(p): Parameters) -> String { let body = serde_json::json!({ "display_name": p.display_name, "avatar_url": p.avatar_url, "about": p.about, + "nip05_handle": p.nip05_handle, }); match self.client.put("/api/users/me/profile", &body).await { Ok(b) => b, @@ -1352,6 +1373,33 @@ impl SproutMcpServer { Err(e) => format!("Error fetching profiles: {e}"), } } + + /// Full-text search across messages. + #[tool( + name = "search", + description = "Full-text search across messages in accessible channels. Returns matching messages with channel context. Powered by Typesense." + )] + pub async fn search(&self, Parameters(p): Parameters) -> String { + let limit = p.limit.unwrap_or(20).min(100); + let path = format!("/api/search?q={}&limit={}", percent_encode(&p.q), limit); + match self.client.get(&path).await { + Ok(body) => body, + Err(e) => format!("Error searching: {e}"), + } + } + + /// Get presence status for one or more users. + #[tool( + name = "get_presence", + description = "Get presence status (online/away/offline) for one or more users by pubkey. Pass comma-separated hex pubkeys." + )] + pub async fn get_presence(&self, Parameters(p): Parameters) -> String { + let path = format!("/api/presence?pubkeys={}", p.pubkeys); + match self.client.get(&path).await { + Ok(body) => body, + Err(e) => format!("Error fetching presence: {e}"), + } + } } #[tool_handler] diff --git a/crates/sprout-relay/src/api/users.rs b/crates/sprout-relay/src/api/users.rs index 7a1a413fa..ad607a446 100644 --- a/crates/sprout-relay/src/api/users.rs +++ b/crates/sprout-relay/src/api/users.rs @@ -30,6 +30,8 @@ pub struct UpdateProfileBody { pub avatar_url: Option, /// Short bio or description, or `None` to leave unchanged. pub about: Option, + /// NIP-05 identifier (e.g. "alice@example.com"), or `None` to leave unchanged. + pub nip05_handle: Option, } /// `PUT /api/users/me/profile` — update the authenticated user's profile. @@ -58,17 +60,22 @@ pub async fn update_profile( .as_deref() .map(str::trim) .filter(|s| !s.is_empty()); + let nip05_handle = body + .nip05_handle + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); - if display_name.is_none() && avatar_url.is_none() && about.is_none() { + if display_name.is_none() && avatar_url.is_none() && about.is_none() && nip05_handle.is_none() { return Err(api_error( StatusCode::BAD_REQUEST, - "at least one of display_name, avatar_url, or about is required", + "at least one of display_name, avatar_url, about, or nip05_handle is required", )); } state .db - .update_user_profile(&pubkey_bytes, display_name, avatar_url, about, None) + .update_user_profile(&pubkey_bytes, display_name, avatar_url, about, nip05_handle) .await .map_err(|e| internal_error(&format!("db error: {e}")))?; diff --git a/crates/sprout-test-client/tests/e2e_mcp.rs b/crates/sprout-test-client/tests/e2e_mcp.rs index cd4b5806a..e8a3ad183 100644 --- a/crates/sprout-test-client/tests/e2e_mcp.rs +++ b/crates/sprout-test-client/tests/e2e_mcp.rs @@ -246,7 +246,7 @@ impl McpSession { // ── Tests ───────────────────────────────────────────────────────────────────── /// Spawn the MCP server, complete the initialize handshake, and verify that -/// all 38 expected tools are listed by `tools/list`. +/// all 40 expected tools are listed by `tools/list`. #[tokio::test] #[ignore] async fn test_mcp_initialize_and_list_tools() { @@ -295,8 +295,8 @@ async fn test_mcp_initialize_and_list_tools() { assert_eq!( tools.len(), - 38, - "expected exactly 38 tools, got {}. Tools: {:?}", + 40, + "expected exactly 40 tools, got {}. Tools: {:?}", tools.len(), tools .iter() @@ -346,6 +346,8 @@ async fn test_mcp_initialize_and_list_tools() { "set_profile", "get_user_profile", "get_users_batch", + "search", + "get_presence", ]; for expected in &expected_tools { From 6c0a638139415c645f933ff118a5badfe75f73eb Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Wed, 11 Mar 2026 12:27:22 -0400 Subject: [PATCH 4/6] fix: NIP-05 validation, 409 on duplicate, clear support, round-trip tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crossfire R1 + R2 fixes: - Validate nip05_handle format (user@domain) and restrict domain to relay's own domain on both REST and kind:0 ingestion paths - Return 409 Conflict on duplicate nip05_handle (was generic 500) - Allow empty string to clear nip05_handle (empty → NULL via DB layer) - Kind:0 side effect now validates NIP-05 format and domain before syncing to users table (invalid handles silently cleared) - Add percent_encode to get_presence MCP tool for consistency - Add 3 NIP-05 integration tests: round-trip set+lookup, clear handle, duplicate handle conflict - Update doc comments to include nip05_handle - Make extract_domain pub(crate) for reuse across modules --- crates/sprout-mcp/src/server.rs | 2 +- crates/sprout-relay/src/api/nip05.rs | 2 +- crates/sprout-relay/src/api/users.rs | 47 +++++- .../sprout-relay/src/handlers/side_effects.rs | 11 ++ .../sprout-test-client/tests/e2e_rest_api.rs | 151 ++++++++++++++++++ 5 files changed, 203 insertions(+), 10 deletions(-) diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index 3af3b3877..6f2f78e23 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -1394,7 +1394,7 @@ impl SproutMcpServer { description = "Get presence status (online/away/offline) for one or more users by pubkey. Pass comma-separated hex pubkeys." )] pub async fn get_presence(&self, Parameters(p): Parameters) -> String { - let path = format!("/api/presence?pubkeys={}", p.pubkeys); + let path = format!("/api/presence?pubkeys={}", percent_encode(&p.pubkeys)); match self.client.get(&path).await { Ok(body) => body, Err(e) => format!("Error fetching presence: {e}"), diff --git a/crates/sprout-relay/src/api/nip05.rs b/crates/sprout-relay/src/api/nip05.rs index 34bf84744..3f941bc32 100644 --- a/crates/sprout-relay/src/api/nip05.rs +++ b/crates/sprout-relay/src/api/nip05.rs @@ -55,7 +55,7 @@ pub async fn nostr_nip05( /// Extract the domain (host) from a URL string. /// e.g. "ws://localhost:3000" → "localhost", "wss://sprout.block.xyz" → "sprout.block.xyz" -fn extract_domain(url: &str) -> String { +pub(crate) fn extract_domain(url: &str) -> String { url.trim_start_matches("wss://") .trim_start_matches("ws://") .trim_start_matches("https://") diff --git a/crates/sprout-relay/src/api/users.rs b/crates/sprout-relay/src/api/users.rs index ad607a446..cfb61ae6c 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, about) +//! PUT /api/users/me/profile — update own profile (display_name, avatar_url, about, nip05_handle) //! GET /api/users/{pubkey}/profile — get any user's profile by pubkey hex //! POST /api/users/batch — resolve display names for multiple pubkeys @@ -19,6 +19,7 @@ use serde::Deserialize; use crate::state::AppState; use super::{api_error, extract_auth_pubkey, internal_error}; +use super::nip05::extract_domain; /// Request body for updating a user's profile. /// All fields are optional — at least one must be present. @@ -36,7 +37,7 @@ pub struct UpdateProfileBody { /// `PUT /api/users/me/profile` — update the authenticated user's profile. /// -/// Body: `{ "display_name": "Alice", "avatar_url": "https://...", "about": "..." }` (all optional, at least one required) +/// Body: `{ "display_name": "Alice", "avatar_url": "https://...", "about": "...", "nip05_handle": "alice@relay.example" }` (all optional, at least one required) /// Returns: `{ "updated": true }` pub async fn update_profile( State(state): State>, @@ -60,11 +61,9 @@ pub async fn update_profile( .as_deref() .map(str::trim) .filter(|s| !s.is_empty()); - let nip05_handle = body - .nip05_handle - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()); + // nip05_handle: empty string means "clear", None means "leave unchanged" + let nip05_handle = body.nip05_handle.as_deref().map(str::trim); + // Don't filter empty — empty string means "clear to NULL" via empty_to_none in DB layer if display_name.is_none() && avatar_url.is_none() && about.is_none() && nip05_handle.is_none() { return Err(api_error( @@ -73,11 +72,43 @@ pub async fn update_profile( )); } + // Validate NIP-05 format: must be "local@domain" + if let Some(handle) = nip05_handle { + if !handle.is_empty() { // empty = clear, skip validation + let parts: Vec<&str> = handle.splitn(2, '@').collect(); + if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { + return Err(api_error( + StatusCode::BAD_REQUEST, + "nip05_handle must be in user@domain format", + )); + } + // Domain must match this relay's domain + let relay_domain = extract_domain(&state.config.relay_url); + let handle_domain = parts[1].to_lowercase(); + if handle_domain != relay_domain { + return Err(api_error( + StatusCode::BAD_REQUEST, + &format!( + "nip05_handle domain must match this relay ({})", + relay_domain + ), + )); + } + } + } + state .db .update_user_profile(&pubkey_bytes, display_name, avatar_url, about, nip05_handle) .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; + .map_err(|e| { + let msg = format!("{e}"); + if msg.contains("Duplicate entry") || msg.contains("1062") { + api_error(StatusCode::CONFLICT, "nip05_handle is already claimed by another user") + } else { + internal_error(&format!("db error: {e}")) + } + })?; Ok(Json(serde_json::json!({ "updated": true }))) } diff --git a/crates/sprout-relay/src/handlers/side_effects.rs b/crates/sprout-relay/src/handlers/side_effects.rs index 5561b02fe..83429eaae 100644 --- a/crates/sprout-relay/src/handlers/side_effects.rs +++ b/crates/sprout-relay/src/handlers/side_effects.rs @@ -277,8 +277,19 @@ async fn handle_kind0_profile( .and_then(|v| v.as_str()) .unwrap_or(""); + // Validate NIP-05 handle: must be user@domain where domain matches this relay. + // Invalid or off-domain handles are silently cleared (treated as absent) rather + // than stored, since the event is already persisted and can't be rejected. + let relay_domain = crate::api::nip05::extract_domain(&state.config.relay_url); let nip05_handle = content.get("nip05") .and_then(|v| v.as_str()) + .filter(|h| { + let parts: Vec<&str> = h.splitn(2, '@').collect(); + parts.len() == 2 + && !parts[0].is_empty() + && !parts[1].is_empty() + && parts[1].to_lowercase() == relay_domain + }) .unwrap_or(""); let pubkey_bytes = event.pubkey.serialize().to_vec(); diff --git a/crates/sprout-test-client/tests/e2e_rest_api.rs b/crates/sprout-test-client/tests/e2e_rest_api.rs index 67c5096a6..5697acb6c 100644 --- a/crates/sprout-test-client/tests/e2e_rest_api.rs +++ b/crates/sprout-test-client/tests/e2e_rest_api.rs @@ -79,6 +79,22 @@ async fn authed_post_json( .unwrap_or_else(|e| panic!("HTTP POST {url} failed: {e}")) } +/// Make an authenticated PUT request using the `X-Pubkey` dev-mode header. +async fn authed_put( + client: &Client, + url: &str, + pubkey_hex: &str, + body: serde_json::Value, +) -> reqwest::Response { + client + .put(url) + .header("X-Pubkey", pubkey_hex) + .json(&body) + .send() + .await + .unwrap_or_else(|e| panic!("HTTP PUT {url} failed: {e}")) +} + // ── Channel tests ───────────────────────────────────────────────────────────── /// GET /api/channels returns a non-empty list with the expected fields. @@ -1223,3 +1239,138 @@ async fn test_nip05_has_cors_header() { assert!(cors.is_some(), "NIP-05 must have Access-Control-Allow-Origin header"); assert_eq!(cors.unwrap().to_str().unwrap(), "*"); } + +/// Full round-trip: set nip05_handle via PUT, then verify via /.well-known/nostr.json. +#[tokio::test] +#[ignore] +async fn test_nip05_round_trip_set_and_lookup() { + let client = http_client(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + + // Set NIP-05 handle — use "testuser@localhost" since relay_url is ws://localhost:3000 + let unique_name = format!("nip05test{}", &pubkey_hex[..8]); + let handle = format!("{}@localhost", unique_name); + let resp = authed_put( + &client, + &format!("{}/api/users/me/profile", relay_http_url()), + &pubkey_hex, + serde_json::json!({"nip05_handle": handle}), + ) + .await; + assert_eq!(resp.status(), 200, "set nip05_handle should succeed"); + + // Query NIP-05 endpoint + let resp = client + .get(format!( + "{}/.well-known/nostr.json?name={}", + relay_http_url(), + unique_name + )) + .send() + .await + .expect("nip05 request"); + assert_eq!(resp.status(), 200); + + let body: serde_json::Value = resp.json().await.expect("json"); + let names = body["names"].as_object().expect("names map"); + assert!( + names.contains_key(&unique_name), + "NIP-05 should resolve the name. Got: {:?}", + names + ); + let resolved_pubkey = names[&unique_name].as_str().expect("pubkey string"); + assert_eq!( + resolved_pubkey, pubkey_hex, + "NIP-05 resolved pubkey should match" + ); +} + +/// Setting nip05_handle to empty string clears it. +#[tokio::test] +#[ignore] +async fn test_nip05_clear_handle() { + let client = http_client(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + + let unique_name = format!("cleartest{}", &pubkey_hex[..8]); + let handle = format!("{}@localhost", unique_name); + + // Set handle + let resp = authed_put( + &client, + &format!("{}/api/users/me/profile", relay_http_url()), + &pubkey_hex, + serde_json::json!({"nip05_handle": handle}), + ) + .await; + assert_eq!(resp.status(), 200); + + // Clear handle + let resp = authed_put( + &client, + &format!("{}/api/users/me/profile", relay_http_url()), + &pubkey_hex, + serde_json::json!({"nip05_handle": ""}), + ) + .await; + assert_eq!(resp.status(), 200); + + // Verify cleared — NIP-05 should no longer resolve + let resp = client + .get(format!( + "{}/.well-known/nostr.json?name={}", + relay_http_url(), + unique_name + )) + .send() + .await + .expect("nip05 request"); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.expect("json"); + let names = body["names"].as_object().expect("names map"); + assert!( + !names.contains_key(&unique_name), + "NIP-05 should NOT resolve after clearing. Got: {:?}", + names + ); +} + +/// Duplicate nip05_handle returns 409 Conflict. +#[tokio::test] +#[ignore] +async fn test_nip05_duplicate_handle_conflict() { + let client = http_client(); + let keys_a = Keys::generate(); + let pubkey_a = keys_a.public_key().to_hex(); + let keys_b = Keys::generate(); + let pubkey_b = keys_b.public_key().to_hex(); + + let unique_name = format!("duptest{}", &pubkey_a[..8]); + let handle = format!("{}@localhost", unique_name); + + // User A sets handle + let resp = authed_put( + &client, + &format!("{}/api/users/me/profile", relay_http_url()), + &pubkey_a, + serde_json::json!({"nip05_handle": handle}), + ) + .await; + assert_eq!(resp.status(), 200); + + // User B tries same handle → 409 + let resp = authed_put( + &client, + &format!("{}/api/users/me/profile", relay_http_url()), + &pubkey_b, + serde_json::json!({"nip05_handle": handle}), + ) + .await; + assert_eq!( + resp.status(), + 409, + "duplicate nip05_handle should return 409 Conflict" + ); +} From f5b534fd3580a4485009c3297d49e40cdd25364f Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Wed, 11 Mar 2026 13:00:53 -0400 Subject: [PATCH 5/6] fix: shared NIP-05 canonicalizer, batch non-hex handling, kind:0 resilience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crossfire R3 fixes: - Extract shared canonicalize_nip05() in nip05.rs — lowercases local+domain, validates format and relay domain match, used by both REST and kind:0 paths - Batch endpoint: 64-char non-hex inputs now correctly reported in missing list instead of silently dropped - Kind:0 duplicate NIP-05: retry profile sync without contested handle so display_name/about/avatar_url are still written (UNIQUE violation no longer drops all profile fields) - Add kind:0 NIP-05 regression test: valid handle syncs, off-domain cleared - Update batch test to cover non-hex 64-char inputs --- TESTING.md | 10 +- crates/sprout-db/src/lib.rs | 10 +- crates/sprout-db/src/user.rs | 32 +++- crates/sprout-mcp/src/server.rs | 5 +- crates/sprout-relay/src/api/nip05.rs | 24 +++ crates/sprout-relay/src/api/users.rs | 101 ++++++------ .../sprout-relay/src/handlers/side_effects.rs | 68 +++++--- crates/sprout-test-client/tests/e2e_mcp.rs | 10 +- crates/sprout-test-client/tests/e2e_relay.rs | 148 ++++++++++++++++++ .../sprout-test-client/tests/e2e_rest_api.rs | 67 ++++++-- 10 files changed, 368 insertions(+), 107 deletions(-) diff --git a/TESTING.md b/TESTING.md index 88b6c4760..b25cddbc0 100644 --- a/TESTING.md +++ b/TESTING.md @@ -163,11 +163,11 @@ sleep 3 && curl -s http://localhost:3000/health Then run the integration suites: ```bash -# REST API integration tests (32 tests) +# REST API integration tests (35 tests) RELAY_URL=ws://localhost:3000 \ cargo test -p sprout-test-client --test e2e_rest_api -- --ignored -# WebSocket relay integration tests (13 tests) +# WebSocket relay integration tests (14 tests) RELAY_URL=ws://localhost:3000 \ cargo test -p sprout-test-client --test e2e_relay -- --ignored @@ -179,12 +179,12 @@ RELAY_URL=ws://localhost:3000 \ ### Expected Results ``` -test result: ok. 32 passed; 0 failed; 0 ignored ← REST API -test result: ok. 13 passed; 0 failed; 0 ignored ← relay +test result: ok. 35 passed; 0 failed; 0 ignored ← REST API +test result: ok. 14 passed; 0 failed; 0 ignored ← relay test result: ok. 10 passed; 0 failed; 0 ignored ← MCP ``` -All 55 integration tests pass (across the three suites above). An additional 7 workflow integration tests exist in `e2e_workflows.rs` — run them separately if workflow changes are involved. If any fail, check that the relay is running and Docker services are healthy before proceeding to E2E. +All 59 integration tests pass (across the three suites above). An additional 7 workflow integration tests exist in `e2e_workflows.rs` — run them separately if workflow changes are involved. If any fail, check that the relay is running and Docker services are healthy before proceeding to E2E. --- diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs index f576afffc..36203ee1d 100644 --- a/crates/sprout-db/src/lib.rs +++ b/crates/sprout-db/src/lib.rs @@ -568,7 +568,15 @@ impl Db { about: Option<&str>, nip05_handle: Option<&str>, ) -> Result<()> { - user::update_user_profile(&self.pool, pubkey, display_name, avatar_url, about, nip05_handle).await + user::update_user_profile( + &self.pool, + pubkey, + display_name, + avatar_url, + about, + nip05_handle, + ) + .await } /// Look up a user by their full NIP-05 handle (exact match). diff --git a/crates/sprout-db/src/user.rs b/crates/sprout-db/src/user.rs index fe9ed522f..4233b81be 100644 --- a/crates/sprout-db/src/user.rs +++ b/crates/sprout-db/src/user.rs @@ -83,10 +83,18 @@ pub async fn update_user_profile( nip05_handle: Option<&str>, ) -> Result<()> { 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 nip05_handle.is_some() { set_parts.push("nip05_handle = ?"); } + 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 nip05_handle.is_some() { + set_parts.push("nip05_handle = ?"); + } if set_parts.is_empty() { return Ok(()); @@ -101,10 +109,18 @@ pub async fn update_user_profile( let sql = format!("UPDATE users SET {} WHERE pubkey = ?", set_parts.join(", ")); let mut query = sqlx::query(&sql); - if display_name.is_some() { query = query.bind(empty_to_none(display_name)); } - if avatar_url.is_some() { query = query.bind(empty_to_none(avatar_url)); } - if about.is_some() { query = query.bind(empty_to_none(about)); } - if nip05_handle.is_some() { query = query.bind(empty_to_none(nip05_handle)); } + if display_name.is_some() { + query = query.bind(empty_to_none(display_name)); + } + if avatar_url.is_some() { + query = query.bind(empty_to_none(avatar_url)); + } + if about.is_some() { + query = query.bind(empty_to_none(about)); + } + if nip05_handle.is_some() { + query = query.bind(empty_to_none(nip05_handle)); + } 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 6f2f78e23..3f0e4dd78 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -1363,10 +1363,7 @@ impl SproutMcpServer { name = "get_users_batch", description = "Resolve display names and NIP-05 handles for multiple pubkeys at once. Returns a map of pubkey to profile info, plus a list of unknown pubkeys. Useful for identifying message senders in bulk." )] - pub async fn get_users_batch( - &self, - Parameters(p): Parameters, - ) -> String { + pub async fn get_users_batch(&self, Parameters(p): Parameters) -> String { let body = serde_json::json!({ "pubkeys": p.pubkeys }); match self.client.post("/api/users/batch", &body).await { Ok(resp) => resp, diff --git a/crates/sprout-relay/src/api/nip05.rs b/crates/sprout-relay/src/api/nip05.rs index 3f941bc32..54e6a11aa 100644 --- a/crates/sprout-relay/src/api/nip05.rs +++ b/crates/sprout-relay/src/api/nip05.rs @@ -53,6 +53,30 @@ pub async fn nostr_nip05( response } +/// Validate and canonicalize a NIP-05 handle: must be `local@domain` where domain +/// matches the relay. Returns the lowercased canonical form, or an error message. +pub(crate) fn canonicalize_nip05(raw: &str, relay_url: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("empty".into()); + } + let (local, domain) = trimmed + .split_once('@') + .ok_or_else(|| "nip05_handle must be in user@domain format".to_string())?; + if local.is_empty() || domain.is_empty() { + return Err("nip05_handle must be in user@domain format".to_string()); + } + let relay_domain = extract_domain(relay_url); + let canonical_domain = domain.to_lowercase(); + if canonical_domain != relay_domain { + return Err(format!( + "nip05_handle domain must match this relay ({})", + relay_domain + )); + } + Ok(format!("{}@{}", local.to_lowercase(), canonical_domain)) +} + /// Extract the domain (host) from a URL string. /// e.g. "ws://localhost:3000" → "localhost", "wss://sprout.block.xyz" → "sprout.block.xyz" pub(crate) fn extract_domain(url: &str) -> String { diff --git a/crates/sprout-relay/src/api/users.rs b/crates/sprout-relay/src/api/users.rs index cfb61ae6c..8c2b42ae6 100644 --- a/crates/sprout-relay/src/api/users.rs +++ b/crates/sprout-relay/src/api/users.rs @@ -19,7 +19,6 @@ use serde::Deserialize; use crate::state::AppState; use super::{api_error, extract_auth_pubkey, internal_error}; -use super::nip05::extract_domain; /// Request body for updating a user's profile. /// All fields are optional — at least one must be present. @@ -62,9 +61,20 @@ pub async fn update_profile( .map(str::trim) .filter(|s| !s.is_empty()); // nip05_handle: empty string means "clear", None means "leave unchanged" - let nip05_handle = body.nip05_handle.as_deref().map(str::trim); + let nip05_handle_raw = body.nip05_handle.as_deref().map(str::trim); // Don't filter empty — empty string means "clear to NULL" via empty_to_none in DB layer + // Validate and canonicalize NIP-05 handle (if non-empty) + let canonical_nip05: Option = match nip05_handle_raw { + Some("") => Some(String::new()), // empty = clear to NULL + Some(h) => Some( + super::nip05::canonicalize_nip05(h, &state.config.relay_url) + .map_err(|msg| api_error(StatusCode::BAD_REQUEST, &msg))?, + ), + None => None, + }; + let nip05_handle = canonical_nip05.as_deref(); + if display_name.is_none() && avatar_url.is_none() && about.is_none() && nip05_handle.is_none() { return Err(api_error( StatusCode::BAD_REQUEST, @@ -72,31 +82,6 @@ pub async fn update_profile( )); } - // Validate NIP-05 format: must be "local@domain" - if let Some(handle) = nip05_handle { - if !handle.is_empty() { // empty = clear, skip validation - let parts: Vec<&str> = handle.splitn(2, '@').collect(); - if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { - return Err(api_error( - StatusCode::BAD_REQUEST, - "nip05_handle must be in user@domain format", - )); - } - // Domain must match this relay's domain - let relay_domain = extract_domain(&state.config.relay_url); - let handle_domain = parts[1].to_lowercase(); - if handle_domain != relay_domain { - return Err(api_error( - StatusCode::BAD_REQUEST, - &format!( - "nip05_handle domain must match this relay ({})", - relay_domain - ), - )); - } - } - } - state .db .update_user_profile(&pubkey_bytes, display_name, avatar_url, about, nip05_handle) @@ -104,7 +89,10 @@ pub async fn update_profile( .map_err(|e| { let msg = format!("{e}"); if msg.contains("Duplicate entry") || msg.contains("1062") { - api_error(StatusCode::CONFLICT, "nip05_handle is already claimed by another user") + api_error( + StatusCode::CONFLICT, + "nip05_handle is already claimed by another user", + ) } else { internal_error(&format!("db error: {e}")) } @@ -151,7 +139,10 @@ pub async fn get_user_profile( let pubkey_bytes = nostr_hex::decode(&pubkey_hex) .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid pubkey hex"))?; if pubkey_bytes.len() != 32 { - return Err(api_error(StatusCode::BAD_REQUEST, "pubkey must be 32 bytes")); + return Err(api_error( + StatusCode::BAD_REQUEST, + "pubkey must be 32 bytes", + )); } let profile = state @@ -192,20 +183,32 @@ pub async fn get_users_batch( )); } - let valid_inputs: Vec<&str> = body.pubkeys - .iter() - .filter(|p| p.len() == 64) - .map(|p| p.as_str()) - .collect(); + // Partition inputs: valid hex (64 chars, valid hex) vs invalid (wrong length or bad hex). + // Both wrong-length and 64-char-non-hex inputs go to the missing list. + let mut invalid_inputs: Vec = Vec::new(); + let mut valid_hex_set: std::collections::HashSet = std::collections::HashSet::new(); + + for p in &body.pubkeys { + if p.len() != 64 { + invalid_inputs.push(p.clone()); + } else { + let lower = p.to_lowercase(); + if nostr_hex::decode(&lower) + .map(|b| b.len() == 32) + .unwrap_or(false) + { + valid_hex_set.insert(lower); + } else { + invalid_inputs.push(p.clone()); + } + } + } - let normalized: std::collections::HashSet = valid_inputs - .iter() - .map(|p| p.to_lowercase()) - .collect(); - let mut normalized: Vec = normalized.into_iter().collect(); + let mut normalized: Vec = valid_hex_set.into_iter().collect(); normalized.sort(); - let pubkey_bytes: Vec> = normalized.iter() + let pubkey_bytes: Vec> = normalized + .iter() .filter_map(|h| nostr_hex::decode(h).ok()) .filter(|b| b.len() == 32) .collect(); @@ -224,10 +227,13 @@ pub async fn get_users_batch( let mut profiles = serde_json::Map::new(); for r in records { let hex = nostr_hex::encode(&r.pubkey); - profiles.insert(hex, serde_json::json!({ - "display_name": r.display_name, - "nip05_handle": r.nip05_handle, - })); + profiles.insert( + hex, + serde_json::json!({ + "display_name": r.display_name, + "nip05_handle": r.nip05_handle, + }), + ); } let mut missing: Vec = normalized @@ -235,12 +241,7 @@ pub async fn get_users_batch( .filter(|p| !found_pubkeys.contains(p.as_str())) .cloned() .collect(); - missing.extend( - body.pubkeys - .iter() - .filter(|p| p.len() != 64) - .cloned(), - ); + missing.extend(invalid_inputs); Ok(Json(serde_json::json!({ "profiles": profiles, diff --git a/crates/sprout-relay/src/handlers/side_effects.rs b/crates/sprout-relay/src/handlers/side_effects.rs index 83429eaae..0063039f8 100644 --- a/crates/sprout-relay/src/handlers/side_effects.rs +++ b/crates/sprout-relay/src/handlers/side_effects.rs @@ -253,44 +253,35 @@ pub async fn emit_system_message( // ── NIP-01 Kind:0 Handler ──────────────────────────────────────────────────── /// Kind:0 (NIP-01 profile metadata) side effect — sync profile fields to users table. -async fn handle_kind0_profile( - event: &Event, - state: &Arc, -) -> anyhow::Result<()> { +async fn handle_kind0_profile(event: &Event, state: &Arc) -> anyhow::Result<()> { let content: serde_json::Value = serde_json::from_str(&event.content) .map_err(|e| anyhow::anyhow!("kind:0 content parse error: {e}"))?; // Kind:0 is absolute state (NIP-01 replaceable event). Fields present in the // event are set; fields absent are cleared. We use Some("") to clear absent // fields, since update_user_profile only writes Some values. - let display_name = content.get("display_name") + let display_name = content + .get("display_name") .or_else(|| content.get("name")) .and_then(|v| v.as_str()) .unwrap_or(""); - let avatar_url = content.get("picture") + let avatar_url = content + .get("picture") .or_else(|| content.get("image")) .and_then(|v| v.as_str()) .unwrap_or(""); - let about = content.get("about") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let about = content.get("about").and_then(|v| v.as_str()).unwrap_or(""); // Validate NIP-05 handle: must be user@domain where domain matches this relay. // Invalid or off-domain handles are silently cleared (treated as absent) rather // than stored, since the event is already persisted and can't be rejected. - let relay_domain = crate::api::nip05::extract_domain(&state.config.relay_url); - let nip05_handle = content.get("nip05") + let nip05_owned = content + .get("nip05") .and_then(|v| v.as_str()) - .filter(|h| { - let parts: Vec<&str> = h.splitn(2, '@').collect(); - parts.len() == 2 - && !parts[0].is_empty() - && !parts[1].is_empty() - && parts[1].to_lowercase() == relay_domain - }) - .unwrap_or(""); + .and_then(|raw| crate::api::nip05::canonicalize_nip05(raw, &state.config.relay_url).ok()); + let nip05_handle = nip05_owned.as_deref().unwrap_or(""); let pubkey_bytes = event.pubkey.serialize().to_vec(); @@ -298,13 +289,38 @@ async fn handle_kind0_profile( // Pass all fields as Some — empty string clears the field in the DB. // This ensures kind:0 is treated as absolute state, not a partial update. - state.db.update_user_profile( - &pubkey_bytes, - Some(display_name), - Some(avatar_url), - Some(about), - Some(nip05_handle), - ).await?; + // If the NIP-05 handle collides with another user's UNIQUE constraint, retry + // without it so display_name/about/avatar_url are still written. + let result = state + .db + .update_user_profile( + &pubkey_bytes, + Some(display_name), + Some(avatar_url), + Some(about), + Some(nip05_handle), + ) + .await; + + if let Err(ref e) = result { + let msg = format!("{e}"); + if msg.contains("Duplicate entry") || msg.contains("1062") { + warn!(pubkey = %nostr::util::hex::encode(&pubkey_bytes), + "kind:0 NIP-05 handle contested, syncing profile without it"); + state + .db + .update_user_profile( + &pubkey_bytes, + Some(display_name), + Some(avatar_url), + Some(about), + None, // skip contested NIP-05 + ) + .await?; + } else { + result?; + } + } info!(pubkey = %nostr::util::hex::encode(&pubkey_bytes), "kind:0 profile synced to users table"); Ok(()) diff --git a/crates/sprout-test-client/tests/e2e_mcp.rs b/crates/sprout-test-client/tests/e2e_mcp.rs index e8a3ad183..c47ebf72b 100644 --- a/crates/sprout-test-client/tests/e2e_mcp.rs +++ b/crates/sprout-test-client/tests/e2e_mcp.rs @@ -1053,8 +1053,14 @@ async fn test_mcp_get_users_batch() { let text = content[0]["text"].as_str().expect("text"); let batch: serde_json::Value = serde_json::from_str(text).expect("parse batch json"); - assert!(batch["profiles"].as_object().is_some(), "profiles map present"); - assert!(batch["missing"].as_array().is_some(), "missing array present"); + assert!( + batch["profiles"].as_object().is_some(), + "profiles map present" + ); + assert!( + batch["missing"].as_array().is_some(), + "missing array present" + ); session.stop(); } diff --git a/crates/sprout-test-client/tests/e2e_relay.rs b/crates/sprout-test-client/tests/e2e_relay.rs index ef8c9ed11..213e71646 100644 --- a/crates/sprout-test-client/tests/e2e_relay.rs +++ b/crates/sprout-test-client/tests/e2e_relay.rs @@ -649,3 +649,151 @@ async fn test_eose_sent_for_empty_subscription() { client.disconnect().await.expect("disconnect"); } + +/// Kind:0 NIP-05 sync regression test. +/// +/// Verifies: +/// 1. A valid `nip05` in kind:0 content is synced to the profile and resolvable via NIP-05 endpoint. +/// 2. An off-domain `nip05` in kind:0 content is NOT synced (handle is cleared). +#[tokio::test] +#[ignore] +async fn test_kind0_nip05_sync() { + let url = relay_url(); + let http = relay_http_url(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + + // Extract the relay domain from the relay URL for building a valid NIP-05 handle. + // e.g. "ws://localhost:3000" → "localhost" + let relay_domain = url + .trim_start_matches("wss://") + .trim_start_matches("ws://") + .split(':') + .next() + .unwrap_or("localhost") + .split('/') + .next() + .unwrap_or("localhost") + .to_lowercase(); + + let unique_name = format!("kind0test{}", &pubkey_hex[..8]); + let valid_handle = format!("{}@{}", unique_name, relay_domain); + + // Step 1: Connect and publish kind:0 with a valid nip05 handle. + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + let kind0_content = serde_json::json!({ + "display_name": "Kind0 Test User", + "nip05": valid_handle, + }) + .to_string(); + + let event = nostr::EventBuilder::new(Kind::Custom(0), kind0_content, []) + .sign_with_keys(&keys) + .expect("sign kind:0"); + + let ok = client.send_event(event).await.expect("send kind:0"); + assert!( + ok.accepted, + "kind:0 event should be accepted: {:?}", + ok.message + ); + + // Give the relay a moment to process the side effect. + tokio::time::sleep(Duration::from_millis(300)).await; + + // Step 2: Verify the profile has the NIP-05 handle via REST GET. + let http_client = reqwest::Client::new(); + let profile_resp = http_client + .get(format!("{}/api/users/{}/profile", http, pubkey_hex)) + .header("X-Pubkey", &pubkey_hex) + .send() + .await + .expect("GET profile"); + assert_eq!( + profile_resp.status(), + 200, + "profile should exist after kind:0" + ); + let profile: serde_json::Value = profile_resp.json().await.expect("profile json"); + assert_eq!( + profile["nip05_handle"].as_str(), + Some(valid_handle.as_str()), + "nip05_handle should be synced from kind:0" + ); + + // Step 3: Verify NIP-05 resolves via /.well-known/nostr.json. + let nip05_resp = http_client + .get(format!( + "{}/.well-known/nostr.json?name={}", + http, unique_name + )) + .send() + .await + .expect("GET nostr.json"); + assert_eq!(nip05_resp.status(), 200); + let nip05_body: serde_json::Value = nip05_resp.json().await.expect("nip05 json"); + let resolved_pubkey = nip05_body["names"][&unique_name].as_str(); + assert_eq!( + resolved_pubkey, + Some(pubkey_hex.as_str()), + "NIP-05 should resolve the pubkey after kind:0 sync" + ); + + // Step 4: Publish another kind:0 with an off-domain nip05 (should be cleared). + let off_domain_content = serde_json::json!({ + "display_name": "Kind0 Test User", + "nip05": format!("{}@evil.com", unique_name), + }) + .to_string(); + + let event2 = nostr::EventBuilder::new(Kind::Custom(0), off_domain_content, []) + .sign_with_keys(&keys) + .expect("sign kind:0 off-domain"); + + let ok2 = client + .send_event(event2) + .await + .expect("send kind:0 off-domain"); + assert!( + ok2.accepted, + "off-domain kind:0 should still be accepted (stored but handle cleared)" + ); + + tokio::time::sleep(Duration::from_millis(300)).await; + + // Step 5: Verify the handle was CLEARED (not set to the off-domain value). + let profile_resp2 = http_client + .get(format!("{}/api/users/{}/profile", http, pubkey_hex)) + .header("X-Pubkey", &pubkey_hex) + .send() + .await + .expect("GET profile after off-domain kind:0"); + assert_eq!(profile_resp2.status(), 200); + let profile2: serde_json::Value = profile_resp2.json().await.expect("profile json"); + let handle_after = profile2["nip05_handle"].as_str().unwrap_or(""); + assert!( + handle_after.is_empty() || handle_after == "null", + "nip05_handle should be cleared after off-domain kind:0, got: {:?}", + profile2["nip05_handle"] + ); + + // Step 6: Confirm NIP-05 no longer resolves. + let nip05_resp2 = http_client + .get(format!( + "{}/.well-known/nostr.json?name={}", + http, unique_name + )) + .send() + .await + .expect("GET nostr.json after clear"); + let nip05_body2: serde_json::Value = nip05_resp2.json().await.expect("nip05 json"); + assert!( + nip05_body2["names"][&unique_name].is_null(), + "NIP-05 should not resolve after handle was cleared" + ); + + client.disconnect().await.expect("disconnect"); +} diff --git a/crates/sprout-test-client/tests/e2e_rest_api.rs b/crates/sprout-test-client/tests/e2e_rest_api.rs index 5697acb6c..b74ffd49e 100644 --- a/crates/sprout-test-client/tests/e2e_rest_api.rs +++ b/crates/sprout-test-client/tests/e2e_rest_api.rs @@ -981,7 +981,10 @@ async fn test_get_user_profile_returns_known_user() { let body: serde_json::Value = resp.json().await.expect("json"); assert_eq!(body["pubkey"].as_str(), Some(pubkey_hex.as_str())); assert_eq!(body["display_name"].as_str(), Some("Profile Test User")); - assert_eq!(body["about"].as_str(), Some("Testing public profile endpoint")); + assert_eq!( + body["about"].as_str(), + Some("Testing public profile endpoint") + ); } /// GET /api/users/:pubkey/profile returns 404 for an unknown user. @@ -1012,7 +1015,11 @@ async fn test_get_user_profile_requires_auth() { let some_pubkey = Keys::generate().public_key().to_hex(); let resp = client - .get(format!("{}/api/users/{}/profile", relay_http_url(), some_pubkey)) + .get(format!( + "{}/api/users/{}/profile", + relay_http_url(), + some_pubkey + )) .send() .await .expect("GET profile"); @@ -1030,7 +1037,11 @@ async fn test_get_user_profile_rejects_invalid_pubkey() { let resp = authed_get( &client, - &format!("{}/api/users/{}/profile", relay_http_url(), "not-a-valid-hex"), + &format!( + "{}/api/users/{}/profile", + relay_http_url(), + "not-a-valid-hex" + ), &pubkey_hex, ) .await; @@ -1055,13 +1066,17 @@ async fn test_batch_profiles_known_and_unknown() { .put(format!("{}/api/users/me/profile", relay_http_url())) .header("X-Pubkey", &hex_a) .json(&serde_json::json!({"display_name": "Alice Batch"})) - .send().await.expect("PUT alice"); + .send() + .await + .expect("PUT alice"); client .put(format!("{}/api/users/me/profile", relay_http_url())) .header("X-Pubkey", &hex_b) .json(&serde_json::json!({"display_name": "Bob Batch"})) - .send().await.expect("PUT bob"); + .send() + .await + .expect("PUT bob"); // Batch lookup let resp = authed_post_json( @@ -1090,7 +1105,9 @@ async fn test_batch_profiles_known_and_unknown() { let missing = body["missing"].as_array().expect("missing array"); assert!( - missing.iter().any(|v| v.as_str() == Some(&unknown_hex.to_lowercase())), + missing + .iter() + .any(|v| v.as_str() == Some(&unknown_hex.to_lowercase())), "unknown pubkey should be in missing" ); } @@ -1133,6 +1150,7 @@ async fn test_batch_profiles_requires_auth() { } /// POST /api/users/batch places invalid-length inputs in the missing list. +/// Also verifies that 64-char non-hex strings (e.g. "g" * 64) go to missing. #[tokio::test] #[ignore] async fn test_batch_profiles_invalid_length_in_missing() { @@ -1140,18 +1158,37 @@ async fn test_batch_profiles_invalid_length_in_missing() { let keys = Keys::generate(); let pubkey_hex = keys.public_key().to_hex(); + let non_hex_64 = "g".repeat(64); let resp = authed_post_json( &client, &format!("{}/api/users/batch", relay_http_url()), &pubkey_hex, - serde_json::json!({"pubkeys": ["tooshort", "x".repeat(100)]}), + serde_json::json!({"pubkeys": ["tooshort", "x".repeat(100), non_hex_64]}), ) .await; assert_eq!(resp.status(), 200); let body: serde_json::Value = resp.json().await.expect("json"); let missing = body["missing"].as_array().expect("missing"); - assert_eq!(missing.len(), 2, "both invalid-length inputs should be in missing"); + assert_eq!( + missing.len(), + 3, + "wrong-length and 64-char non-hex inputs should all be in missing" + ); + + let missing_strs: Vec<&str> = missing.iter().filter_map(|v| v.as_str()).collect(); + assert!( + missing_strs.contains(&"tooshort"), + "short input should be in missing" + ); + assert!( + missing_strs.iter().any(|s| s.len() == 100), + "too-long input should be in missing" + ); + assert!( + missing_strs.contains(&"g".repeat(64).as_str()), + "64-char non-hex should be in missing" + ); } /// POST /api/users/batch normalizes pubkey case before lookup. @@ -1167,7 +1204,9 @@ async fn test_batch_profiles_case_normalized() { .put(format!("{}/api/users/me/profile", relay_http_url())) .header("X-Pubkey", &pubkey_hex) .json(&serde_json::json!({"display_name": "Case Test"})) - .send().await.expect("PUT"); + .send() + .await + .expect("PUT"); // Query with uppercase version let upper_hex = pubkey_hex.to_uppercase(); @@ -1194,7 +1233,10 @@ async fn test_nip05_returns_empty_for_unknown_name() { let client = http_client(); let resp = client - .get(format!("{}/.well-known/nostr.json?name=nonexistent", relay_http_url())) + .get(format!( + "{}/.well-known/nostr.json?name=nonexistent", + relay_http_url() + )) .send() .await .expect("GET nip05"); @@ -1236,7 +1278,10 @@ async fn test_nip05_has_cors_header() { assert_eq!(resp.status(), 200); let cors = resp.headers().get("access-control-allow-origin"); - assert!(cors.is_some(), "NIP-05 must have Access-Control-Allow-Origin header"); + assert!( + cors.is_some(), + "NIP-05 must have Access-Control-Allow-Origin header" + ); assert_eq!(cors.unwrap().to_str().unwrap(), "*"); } From 2003dbad1b13af573be12f3c4933da1842c28937 Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Wed, 11 Mar 2026 15:26:36 -0400 Subject: [PATCH 6/6] feat: set_presence REST endpoint, MCP tool, and shared PresenceStatus enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PUT /api/presence so GUI clients and agents can set presence without a WebSocket connection. The existing GET endpoint and kind:20001 WebSocket path are unchanged. REST (sprout-relay): - PUT /api/presence accepts {"status": "online"|"away"|"offline"} - Returns {"status": "...", "ttl_seconds": N} (90 for online/away, 0 for offline) - "offline" calls clear_presence (Redis DEL); others call set_presence (Redis SET EX 90) - Auth via extract_auth_pubkey — users can only set their own presence - Custom ApiJson extractor returns standard {"error": "..."} envelope on deserialization failure, preserving Axum's original status codes (400/415/422) MCP (sprout-mcp): - New set_presence tool (#41) with typed PresenceStatus enum in JSON schema - relay_client error propagation improved: all HTTP methods now include status code, URL, and response body in error messages Shared types (sprout-core): - PresenceStatus enum (Online/Away/Offline) defined once in sprout-core - schemars::JsonSchema derived conditionally via mcp-schema feature flag - Imported by both sprout-relay and sprout-mcp — single source of truth - Includes Display, as_str(), serde roundtrip, and 4 unit tests Testing: - 5 new REST e2e tests: set online, away+offline transitions, invalid status (422 + envelope), auth required (401 + body), missing field (422) - 4 new MCP e2e tests: set online, offline, away (all with TTL assertions), invalid status - Tool count updated 40 -> 41 with JSON-parsed assertions throughout - All tests verify ttl_seconds in PUT responses - cargo fmt, cargo clippy, cargo check all clean --- Cargo.lock | 1 + TESTING.md | 36 ++-- crates/sprout-core/Cargo.toml | 2 + crates/sprout-core/src/lib.rs | 3 + crates/sprout-core/src/presence.rs | 74 ++++++++ crates/sprout-mcp/Cargo.toml | 2 +- crates/sprout-mcp/src/relay_client.rs | 20 +- crates/sprout-mcp/src/server.rs | 21 +++ crates/sprout-relay/src/api/mod.rs | 27 ++- crates/sprout-relay/src/api/presence.rs | 53 +++++- crates/sprout-relay/src/router.rs | 5 +- crates/sprout-test-client/tests/e2e_mcp.rs | 170 ++++++++++++++++- .../sprout-test-client/tests/e2e_rest_api.rs | 176 ++++++++++++++++++ 13 files changed, 560 insertions(+), 30 deletions(-) create mode 100644 crates/sprout-core/src/presence.rs diff --git a/Cargo.lock b/Cargo.lock index 804a4e4eb..4fb2ce65c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2689,6 +2689,7 @@ dependencies = [ "chrono", "hex", "nostr", + "schemars", "serde", "serde_json", "thiserror", diff --git a/TESTING.md b/TESTING.md index b25cddbc0..4bf68a8f6 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,13 +1,13 @@ # Sprout Testing Guide -This guide enables an AI agent (the **operator**) to run the full Sprout test suite: automated `cargo test` suites and a three-agent multi-agent E2E run that exercises all 40 MCP tools against a live relay. +This guide enables an AI agent (the **operator**) to run the full Sprout test suite: automated `cargo test` suites and a three-agent multi-agent E2E run that exercises all 41 MCP tools against a live relay. ## Two Test Modes | Mode | What It Does | When to Use | |------|-------------|-------------| | **Automated** (`cargo test`) | Unit tests + REST/WebSocket/MCP integration tests | Fast CI check; verify no unit regressions | -| **Multi-Agent E2E** | Three agents (Alice, Bob, Charlie) run via `sprout-acp` harness, exercising all 40 MCP tools via real Nostr identities | Before merging relay/MCP/auth changes; full regression run; exploring new features | +| **Multi-Agent E2E** | Three agents (Alice, Bob, Charlie) run via `sprout-acp` harness, exercising all 41 MCP tools via real Nostr identities | Before merging relay/MCP/auth changes; full regression run; exploring new features | Run both modes for a complete regression check. Run automated-only for a fast sanity check. @@ -27,7 +27,7 @@ Run both modes for a complete regression check. Run automated-only for a fast sa - [3.7 Expected Results](#37-expected-results) 4. [Advanced: ACP Harness Scenarios](#4-advanced-acp-harness-scenarios) 5. [Workflow YAML Reference](#5-workflow-yaml-reference) -6. [The 40 MCP Tools](#6-the-40-mcp-tools) +6. [The 41 MCP Tools](#6-the-41-mcp-tools) 7. [Cleanup](#7-cleanup) 8. [Known Issues / Troubleshooting](#8-known-issues--troubleshooting) @@ -163,7 +163,7 @@ sleep 3 && curl -s http://localhost:3000/health Then run the integration suites: ```bash -# REST API integration tests (35 tests) +# REST API integration tests (40 tests) RELAY_URL=ws://localhost:3000 \ cargo test -p sprout-test-client --test e2e_rest_api -- --ignored @@ -171,7 +171,7 @@ RELAY_URL=ws://localhost:3000 \ RELAY_URL=ws://localhost:3000 \ cargo test -p sprout-test-client --test e2e_relay -- --ignored -# MCP server integration tests (10 tests) +# MCP server integration tests (14 tests) RELAY_URL=ws://localhost:3000 \ cargo test -p sprout-test-client --test e2e_mcp -- --ignored ``` @@ -179,12 +179,12 @@ RELAY_URL=ws://localhost:3000 \ ### Expected Results ``` -test result: ok. 35 passed; 0 failed; 0 ignored ← REST API +test result: ok. 40 passed; 0 failed; 0 ignored ← REST API test result: ok. 14 passed; 0 failed; 0 ignored ← relay -test result: ok. 10 passed; 0 failed; 0 ignored ← MCP +test result: ok. 14 passed; 0 failed; 0 ignored ← MCP ``` -All 59 integration tests pass (across the three suites above). An additional 7 workflow integration tests exist in `e2e_workflows.rs` — run them separately if workflow changes are involved. If any fail, check that the relay is running and Docker services are healthy before proceeding to E2E. +All 68 integration tests pass (across the three suites above). An additional 7 workflow integration tests exist in `e2e_workflows.rs` — run them separately if workflow changes are involved. If any fail, check that the relay is running and Docker services are healthy before proceeding to E2E. --- @@ -202,7 +202,7 @@ Operator (you) Sprout Relay ──WS (NIP-01)──► sprout-acp (harness) ──stdio (ACP)──► goose │ sprout-mcp-server - (40 MCP tools) + (41 MCP tools) │ Sprout Relay (send_message, etc.) @@ -440,7 +440,7 @@ mention "$GENERAL" "$ALICE_PUBKEY" \ ```bash mention "$GENERAL" "$ALICE_PUBKEY" \ - "Set your display name to 'Alice (Test Agent)'. Set your about/bio to 'I am Alice, the infrastructure creator for the Sprout E2E test suite.' Set your NIP-05 handle to 'alice@localhost' using set_profile. Then use get_presence to check your own presence status (pubkey: $ALICE_PUBKEY)." + "Set your display name to 'Alice (Test Agent)'. Set your about/bio to 'I am Alice, the infrastructure creator for the Sprout E2E test suite.' Set your NIP-05 handle to 'alice@localhost' using set_profile. Then use set_presence to set your status to 'online'. Finally, use get_presence to check your own presence status (pubkey: $ALICE_PUBKEY) and confirm it shows 'online'." ``` **A-6: Feed, search, and membership** @@ -502,7 +502,7 @@ mention "$GENERAL" "$BOB_PUBKEY" \ ```bash mention "$GENERAL" "$BOB_PUBKEY" \ - "Get the presence status for Alice (pubkey: $ALICE_PUBKEY). Get your own presence status. Report both." + "Use set_presence to set your status to 'away'. Then get the presence status for Alice (pubkey: $ALICE_PUBKEY) and yourself. Report both statuses — Alice should be 'online' (from A-5) and you should be 'away'." ``` **B-8: Profile resolution (public profiles)** @@ -696,6 +696,7 @@ After all exercises complete, the following should be true: | Profile resolution | Bob can read Alice's profile via `get_user_profile`; `get_users_batch` returns Alice and Bob with display names, Charlie with null display name (all in profiles map) | | NIP-05 verification | Charlie queries `/.well-known/nostr.json?name=alice` and gets Alice's pubkey (Alice set `alice@localhost` in A-5) | | Profile edge cases | Charlie gets appropriate errors for invalid/unknown pubkeys | +| Presence | Alice is 'online' (A-5), Bob is 'away' (B-7); Charlie can read both via get_presence (C-7) | | Error handling | Charlie's C-1, C-2, C-5 exercises report correct errors | | charlie-lifecycle | Unarchived and final message sent successfully | @@ -1003,9 +1004,9 @@ steps: --- -## 6. The 40 MCP Tools +## 6. The 41 MCP Tools -The `sprout-mcp-server` exposes 40 tools covering the full Sprout feature surface. All are available to agents running via the `sprout-acp` harness. +The `sprout-mcp-server` exposes 41 tools covering the full Sprout feature surface. All are available to agents running via the `sprout-acp` harness. ### Channels (8) @@ -1091,11 +1092,12 @@ The `sprout-mcp-server` exposes 40 tools covering the full Sprout feature surfac | `get_user_profile` | Get any user's profile by pubkey (omit pubkey for own profile) | | `get_users_batch` | Bulk resolve display names and NIP-05 handles for multiple pubkeys | -### Presence (1) +### Presence (2) | Tool | Description | |------|-------------| | `get_presence` | Bulk presence lookup by pubkey | +| `set_presence` | Set presence status (online/away/offline) with TTL | ### Members (3) @@ -1170,9 +1172,9 @@ rm -f /tmp/alice-keys.txt /tmp/agent-b-keys.txt /tmp/agent-c-keys.txt All automated tests pass as of 2026-03-11: -- ✅ 32/32 REST API integration tests -- ✅ 13/13 WebSocket relay integration tests -- ✅ 10/10 MCP server integration tests +- ✅ 40/40 REST API integration tests +- ✅ 14/14 WebSocket relay integration tests +- ✅ 14/14 MCP server integration tests - ✅ Multi-agent E2E (Alice/Bob/Charlie) via sprout-acp harness --- diff --git a/crates/sprout-core/Cargo.toml b/crates/sprout-core/Cargo.toml index 726c3b4e6..c0267e313 100644 --- a/crates/sprout-core/Cargo.toml +++ b/crates/sprout-core/Cargo.toml @@ -9,6 +9,7 @@ description = "Core types, event verification, and filter matching for Sprout" [features] test-utils = [] +mcp-schema = ["schemars"] [dependencies] nostr = { workspace = true } @@ -18,5 +19,6 @@ thiserror = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } hex = { workspace = true } +schemars = { workspace = true, optional = true } # NO tokio, NO sqlx, NO redis, NO axum — zero I/O dependencies diff --git a/crates/sprout-core/src/lib.rs b/crates/sprout-core/src/lib.rs index 44f22fb38..2b03275f2 100644 --- a/crates/sprout-core/src/lib.rs +++ b/crates/sprout-core/src/lib.rs @@ -15,12 +15,15 @@ pub mod filter; pub mod kind; /// Network utilities — SSRF-safe IP classification. pub mod network; +/// Presence status types shared across crates. +pub mod presence; /// Schnorr signature and event ID verification. pub mod verification; pub use error::VerificationError; pub use event::StoredEvent; pub use nostr::{Event, EventId, Filter, Keys, Kind, PublicKey}; +pub use presence::PresenceStatus; pub use verification::verify_event; #[cfg(any(test, feature = "test-utils"))] diff --git a/crates/sprout-core/src/presence.rs b/crates/sprout-core/src/presence.rs new file mode 100644 index 000000000..801446856 --- /dev/null +++ b/crates/sprout-core/src/presence.rs @@ -0,0 +1,74 @@ +//! Presence status types shared across REST, MCP, and WebSocket surfaces. + +use serde::{Deserialize, Serialize}; + +/// Allowed presence statuses for the REST/MCP surface. +/// +/// The WebSocket path (kind:20001) accepts arbitrary status strings for +/// forward-compatibility; this enum is the curated set for structured APIs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(feature = "mcp-schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "lowercase")] +pub enum PresenceStatus { + /// User is actively online. + Online, + /// User is away / idle. + Away, + /// User is offline; clears the presence entry. + Offline, +} + +impl PresenceStatus { + /// Returns the lowercase string representation stored in Redis. + pub fn as_str(&self) -> &'static str { + match self { + Self::Online => "online", + Self::Away => "away", + Self::Offline => "offline", + } + } +} + +impl std::fmt::Display for PresenceStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serde_roundtrip() { + let online: PresenceStatus = serde_json::from_str(r#""online""#).unwrap(); + assert_eq!(online, PresenceStatus::Online); + assert_eq!(serde_json::to_string(&online).unwrap(), r#""online""#); + + let away: PresenceStatus = serde_json::from_str(r#""away""#).unwrap(); + assert_eq!(away, PresenceStatus::Away); + + let offline: PresenceStatus = serde_json::from_str(r#""offline""#).unwrap(); + assert_eq!(offline, PresenceStatus::Offline); + } + + #[test] + fn serde_rejects_unknown_variant() { + let result: Result = serde_json::from_str(r#""invisible""#); + assert!(result.is_err()); + } + + #[test] + fn as_str_matches_serde() { + assert_eq!(PresenceStatus::Online.as_str(), "online"); + assert_eq!(PresenceStatus::Away.as_str(), "away"); + assert_eq!(PresenceStatus::Offline.as_str(), "offline"); + } + + #[test] + fn display_matches_as_str() { + assert_eq!(format!("{}", PresenceStatus::Online), "online"); + assert_eq!(format!("{}", PresenceStatus::Away), "away"); + assert_eq!(format!("{}", PresenceStatus::Offline), "offline"); + } +} diff --git a/crates/sprout-mcp/Cargo.toml b/crates/sprout-mcp/Cargo.toml index 1f27ccd3b..21b38923d 100644 --- a/crates/sprout-mcp/Cargo.toml +++ b/crates/sprout-mcp/Cargo.toml @@ -13,7 +13,7 @@ path = "src/main.rs" [dependencies] # Sprout core types -sprout-core = { workspace = true } +sprout-core = { workspace = true, features = ["mcp-schema"] } # MCP SDK rmcp = { workspace = true } diff --git a/crates/sprout-mcp/src/relay_client.rs b/crates/sprout-mcp/src/relay_client.rs index 3ab3e4665..3db7f59a4 100644 --- a/crates/sprout-mcp/src/relay_client.rs +++ b/crates/sprout-mcp/src/relay_client.rs @@ -500,7 +500,9 @@ impl RelayClient { let url = format!("{}{}", self.relay_http_url(), path); let resp = self.apply_auth(self.http.get(&url)).send().await?; if !resp.status().is_success() { - return Err(anyhow::anyhow!("HTTP {}: {}", resp.status(), url)); + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!("{} {}: {}", status, url, body)); } Ok(resp.text().await?) } @@ -514,7 +516,9 @@ impl RelayClient { .send() .await?; if !resp.status().is_success() { - return Err(anyhow::anyhow!("HTTP {}: {}", resp.status(), url)); + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!("{} {}: {}", status, url, body)); } Ok(resp.text().await?) } @@ -528,7 +532,9 @@ impl RelayClient { .send() .await?; if !resp.status().is_success() { - return Err(anyhow::anyhow!("HTTP {}: {}", resp.status(), url)); + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!("{} {}: {}", status, url, body)); } Ok(resp.text().await?) } @@ -538,7 +544,9 @@ impl RelayClient { let url = format!("{}{}", self.relay_http_url(), path); let resp = self.apply_auth(self.http.delete(&url)).send().await?; if !resp.status().is_success() { - return Err(anyhow::anyhow!("HTTP {}: {}", resp.status(), url)); + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!("{} {}: {}", status, url, body)); } Ok(resp.text().await?) } @@ -560,7 +568,9 @@ impl RelayClient { pub async fn get_api(&self, url: &str) -> anyhow::Result { let resp = self.apply_auth(self.http.get(url)).send().await?; if !resp.status().is_success() { - return Err(anyhow::anyhow!("HTTP {}: {}", resp.status(), url)); + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!("{} {}: {}", status, url, body)); } Ok(resp.text().await?) } diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index 3f0e4dd78..375a76de7 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -6,6 +6,7 @@ use rmcp::{ use serde::{Deserialize, Serialize}; use crate::relay_client::RelayClient; +use sprout_core::PresenceStatus; /// Percent-encode a string for safe inclusion in a URL query parameter value. /// Encodes all characters except unreserved ones (A-Z a-z 0-9 - _ . ~). @@ -417,6 +418,13 @@ pub struct GetPresenceParams { pub pubkeys: String, } +/// Parameters for the `set_presence` tool. +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct SetPresenceParams { + /// Presence status to set. + pub status: PresenceStatus, +} + /// Parameters for the `get_feed` tool. #[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] pub struct GetFeedParams { @@ -1397,6 +1405,19 @@ impl SproutMcpServer { Err(e) => format!("Error fetching presence: {e}"), } } + + /// Set the agent's presence status. + #[tool( + name = "set_presence", + description = "Set the agent's presence status. Valid values: 'online', 'away', 'offline'. Presence auto-expires after 90 seconds — call periodically to stay online." + )] + pub async fn set_presence(&self, Parameters(p): Parameters) -> String { + let body = serde_json::json!({ "status": p.status }); + match self.client.put("/api/presence", &body).await { + Ok(b) => b, + Err(e) => format!("Error: {e}"), + } + } } #[tool_handler] diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index 2cd1110ac..2c75addd6 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -5,7 +5,7 @@ //! - `events` — GET /api/events/:id //! - `search` — GET /api/search //! - `agents` — GET /api/agents -//! - `presence` — GET /api/presence +//! - `presence` — GET/PUT /api/presence //! - `workflows` — workflow CRUD + trigger + webhook //! - `approvals` — approval grant/deny //! - `feed` — GET /api/feed @@ -59,7 +59,7 @@ pub use events::get_event; pub use feed::feed_handler; pub use members::{add_members, join_channel, leave_channel, list_members, remove_member}; pub use messages::{delete_message, get_thread, list_messages, send_message}; -pub use presence::presence_handler; +pub use presence::{presence_handler, set_presence_handler}; pub use reactions::{add_reaction_handler, list_reactions_handler, remove_reaction_handler}; pub use search::search_handler; pub use users::{get_profile, get_user_profile, get_users_batch, update_profile}; @@ -294,6 +294,29 @@ pub(crate) async fn check_channel_access( } } +// ── Custom JSON extractor ───────────────────────────────────────────────────── + +use axum::extract::{rejection::JsonRejection, FromRequest, Request}; + +/// A JSON extractor that returns our standard `{"error": "..."}` envelope +/// on deserialization failure instead of Axum's default plain-text 422. +pub struct ApiJson(pub T); + +impl FromRequest for ApiJson +where + axum::Json: FromRequest, + S: Send + Sync, +{ + type Rejection = (StatusCode, Json); + + async fn from_request(req: Request, state: &S) -> Result { + match axum::Json::::from_request(req, state).await { + Ok(axum::Json(value)) => Ok(ApiJson(value)), + Err(rejection) => Err(api_error(rejection.status(), &rejection.body_text())), + } + } +} + // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/crates/sprout-relay/src/api/presence.rs b/crates/sprout-relay/src/api/presence.rs index ad836f00d..3e72853a3 100644 --- a/crates/sprout-relay/src/api/presence.rs +++ b/crates/sprout-relay/src/api/presence.rs @@ -1,4 +1,4 @@ -//! GET /api/presence — bulk presence lookup by pubkey. +//! Presence API — GET /api/presence (bulk lookup) and PUT /api/presence (set status). use std::sync::Arc; @@ -8,6 +8,8 @@ use axum::{ response::Json, }; use serde::Deserialize; +use sprout_core::PresenceStatus; +use sprout_pubsub::presence::PRESENCE_TTL_SECS; use crate::state::AppState; @@ -64,3 +66,52 @@ pub async fn presence_handler( Ok(Json(serde_json::Value::Object(result))) } + +/// Request body for `PUT /api/presence`. +#[derive(Debug, Deserialize)] +pub struct SetPresenceBody { + /// Presence status to set. + pub status: PresenceStatus, +} + +/// Set the authenticated user's presence status. +/// +/// Accepts `{"status": "online" | "away" | "offline"}` (case-sensitive). +/// Serde rejects unknown variants automatically, returning a 422. +/// - `"offline"` clears the presence entry (TTL 0). +/// - `"online"` / `"away"` upsert the entry with a 90-second TTL. +/// +/// Returns `{"status": "...", "ttl_seconds": N}`. +/// +/// **Note:** The WebSocket path (kind:20001) accepts arbitrary status strings +/// for forward-compatibility, but the REST/MCP surface intentionally restricts +/// to the curated enum above. Aligning the WebSocket path is tracked separately. +pub async fn set_presence_handler( + State(state): State>, + headers: HeaderMap, + super::ApiJson(body): super::ApiJson, +) -> Result, (StatusCode, Json)> { + let (pubkey, _pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?; + + match body.status { + PresenceStatus::Online | PresenceStatus::Away => { + state + .pubsub + .set_presence(&pubkey, body.status.as_str()) + .await + .map_err(|e| super::internal_error(&format!("presence error: {e}")))?; + } + PresenceStatus::Offline => { + state + .pubsub + .clear_presence(&pubkey) + .await + .map_err(|e| super::internal_error(&format!("presence error: {e}")))?; + } + } + + Ok(Json(serde_json::json!({ + "status": body.status, + "ttl_seconds": if body.status == PresenceStatus::Offline { 0 } else { PRESENCE_TTL_SECS }, + }))) +} diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index c605085a2..eb35d5c79 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -32,7 +32,10 @@ pub fn build_router(state: Arc) -> Router { .route("/api/events/{id}", get(api::get_event)) .route("/api/search", get(api::search_handler)) .route("/api/agents", get(api::agents_handler)) - .route("/api/presence", get(api::presence_handler)) + .route( + "/api/presence", + get(api::presence_handler).put(api::set_presence_handler), + ) // Workflow routes .route( "/api/channels/{channel_id}/workflows", diff --git a/crates/sprout-test-client/tests/e2e_mcp.rs b/crates/sprout-test-client/tests/e2e_mcp.rs index c47ebf72b..48a4e22a7 100644 --- a/crates/sprout-test-client/tests/e2e_mcp.rs +++ b/crates/sprout-test-client/tests/e2e_mcp.rs @@ -246,7 +246,7 @@ impl McpSession { // ── Tests ───────────────────────────────────────────────────────────────────── /// Spawn the MCP server, complete the initialize handshake, and verify that -/// all 40 expected tools are listed by `tools/list`. +/// all 41 expected tools are listed by `tools/list`. #[tokio::test] #[ignore] async fn test_mcp_initialize_and_list_tools() { @@ -295,8 +295,8 @@ async fn test_mcp_initialize_and_list_tools() { assert_eq!( tools.len(), - 40, - "expected exactly 40 tools, got {}. Tools: {:?}", + 41, + "expected exactly 41 tools, got {}. Tools: {:?}", tools.len(), tools .iter() @@ -348,6 +348,7 @@ async fn test_mcp_initialize_and_list_tools() { "get_users_batch", "search", "get_presence", + "set_presence", ]; for expected in &expected_tools { @@ -1064,3 +1065,166 @@ async fn test_mcp_get_users_batch() { session.stop(); } + +/// Call `set_presence` via MCP and verify it succeeds. +#[tokio::test] +#[ignore] +async fn test_mcp_set_presence() { + let keys = generate_test_keys(); + let mut session = McpSession::start(&keys).await; + session.initialize(); + + // Set presence to "online". + let resp = session.call_tool("set_presence", json!({"status": "online"})); + assert!( + resp.get("error").is_none(), + "set_presence returned an error: {resp}" + ); + let text = McpSession::tool_text(&resp); + let parsed: serde_json::Value = serde_json::from_str(&text).expect("response should be JSON"); + assert_eq!( + parsed["status"].as_str(), + Some("online"), + "set_presence response should have status 'online', got: {text}" + ); + assert_eq!( + parsed["ttl_seconds"].as_u64(), + Some(90), + "online presence should have 90s TTL, got: {text}" + ); + + // Verify via get_presence. + let pubkey_hex = keys.public_key().to_hex(); + let resp = session.call_tool("get_presence", json!({"pubkeys": pubkey_hex})); + assert!( + resp.get("error").is_none(), + "get_presence returned an error: {resp}" + ); + let text = McpSession::tool_text(&resp); + let parsed: serde_json::Value = serde_json::from_str(&text).expect("response should be JSON"); + assert_eq!( + parsed[&pubkey_hex].as_str(), + Some("online"), + "get_presence should show 'online' after set_presence, got: {text}" + ); + + session.stop(); +} + +/// Call `set_presence` with "offline" via MCP and verify presence is cleared. +#[tokio::test] +#[ignore] +async fn test_mcp_set_presence_offline() { + let keys = generate_test_keys(); + let mut session = McpSession::start(&keys).await; + session.initialize(); + + // First set to "online". + let resp = session.call_tool("set_presence", json!({"status": "online"})); + assert!( + resp.get("error").is_none(), + "set_presence(online) returned an error: {resp}" + ); + + // Now set to "offline" — should clear presence. + let resp = session.call_tool("set_presence", json!({"status": "offline"})); + assert!( + resp.get("error").is_none(), + "set_presence(offline) returned an error: {resp}" + ); + let text = McpSession::tool_text(&resp); + let parsed: serde_json::Value = serde_json::from_str(&text).expect("response should be JSON"); + assert_eq!( + parsed["status"].as_str(), + Some("offline"), + "set_presence(offline) response should have status 'offline', got: {text}" + ); + assert_eq!( + parsed["ttl_seconds"].as_u64(), + Some(0), + "offline presence should have 0 TTL, got: {text}" + ); + + // Verify via get_presence — should show "offline". + let pubkey_hex = keys.public_key().to_hex(); + let resp = session.call_tool("get_presence", json!({"pubkeys": pubkey_hex})); + assert!( + resp.get("error").is_none(), + "get_presence returned an error: {resp}" + ); + let text = McpSession::tool_text(&resp); + let parsed: serde_json::Value = serde_json::from_str(&text).expect("response should be JSON"); + assert_eq!( + parsed[&pubkey_hex].as_str(), + Some("offline"), + "get_presence should show 'offline' after clearing, got: {text}" + ); + + session.stop(); +} + +/// Call `set_presence` with "away" via MCP and verify round-trip. +#[tokio::test] +#[ignore] +async fn test_mcp_set_presence_away() { + let keys = generate_test_keys(); + let mut session = McpSession::start(&keys).await; + session.initialize(); + + let resp = session.call_tool("set_presence", json!({"status": "away"})); + assert!( + resp.get("error").is_none(), + "set_presence(away) returned an error: {resp}" + ); + let text = McpSession::tool_text(&resp); + let parsed: serde_json::Value = serde_json::from_str(&text).expect("response should be JSON"); + assert_eq!( + parsed["status"].as_str(), + Some("away"), + "set_presence response should have status 'away', got: {text}" + ); + assert_eq!( + parsed["ttl_seconds"].as_u64(), + Some(90), + "away presence should have 90s TTL, got: {text}" + ); + + // Verify via get_presence. + let pubkey_hex = keys.public_key().to_hex(); + let resp = session.call_tool("get_presence", json!({"pubkeys": pubkey_hex})); + assert!( + resp.get("error").is_none(), + "get_presence returned an error: {resp}" + ); + let text = McpSession::tool_text(&resp); + let parsed: serde_json::Value = serde_json::from_str(&text).expect("response should be JSON"); + assert_eq!( + parsed[&pubkey_hex].as_str(), + Some("away"), + "get_presence should show 'away', got: {text}" + ); + + session.stop(); +} + +/// Call `set_presence` with an invalid status via MCP and verify error. +#[tokio::test] +#[ignore] +async fn test_mcp_set_presence_invalid_status() { + let keys = generate_test_keys(); + let mut session = McpSession::start(&keys).await; + session.initialize(); + + let resp = session.call_tool("set_presence", json!({"status": "invisible"})); + // MCP framework rejects invalid enum variants at the JSON-RPC level (not as a tool result), + // so the response has an "error" key rather than a "result" key. + let has_error = resp.get("error").is_some(); + let text = McpSession::tool_text(&resp); + let has_error_text = text.contains("422") || text.contains("error") || text.contains("Error"); + assert!( + has_error || has_error_text, + "invalid status should return a JSON-RPC error or error text, got: {resp}" + ); + + session.stop(); +} diff --git a/crates/sprout-test-client/tests/e2e_rest_api.rs b/crates/sprout-test-client/tests/e2e_rest_api.rs index b74ffd49e..056089467 100644 --- a/crates/sprout-test-client/tests/e2e_rest_api.rs +++ b/crates/sprout-test-client/tests/e2e_rest_api.rs @@ -631,6 +631,182 @@ async fn test_presence_empty_pubkeys() { ); } +/// PUT /api/presence sets the user's presence and can be read back. +#[tokio::test] +#[ignore] +async fn test_set_presence_online() { + let client = http_client(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + + // Set presence to "online" via REST. + let url = format!("{}/api/presence", relay_http_url()); + let resp = authed_put( + &client, + &url, + &pubkey_hex, + serde_json::json!({"status": "online"}), + ) + .await; + assert_eq!(resp.status(), 200, "PUT /api/presence should return 200"); + let body: serde_json::Value = resp.json().await.expect("JSON"); + assert_eq!(body["status"].as_str(), Some("online")); + assert_eq!( + body["ttl_seconds"].as_u64(), + Some(90), + "online presence should have 90s TTL" + ); + + // Verify via GET. + let get_url = format!("{}/api/presence?pubkeys={pubkey_hex}", relay_http_url()); + let resp = authed_get(&client, &get_url, &pubkey_hex).await; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.expect("JSON"); + assert_eq!( + body[&pubkey_hex].as_str(), + Some("online"), + "presence should be 'online' after PUT" + ); +} + +/// PUT /api/presence with "away" then "offline" updates and clears presence. +#[tokio::test] +#[ignore] +async fn test_set_presence_away_and_offline() { + let client = http_client(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + let url = format!("{}/api/presence", relay_http_url()); + + // Set to "away". + let resp = authed_put( + &client, + &url, + &pubkey_hex, + serde_json::json!({"status": "away"}), + ) + .await; + assert_eq!(resp.status(), 200); + let away_body: serde_json::Value = resp.json().await.expect("JSON"); + assert_eq!( + away_body["status"].as_str(), + Some("away"), + "PUT response should echo 'away'" + ); + assert_eq!( + away_body["ttl_seconds"].as_u64(), + Some(90), + "away should have 90s TTL" + ); + + // Verify "away". + let get_url = format!("{}/api/presence?pubkeys={pubkey_hex}", relay_http_url()); + let resp = authed_get(&client, &get_url, &pubkey_hex).await; + let body: serde_json::Value = resp.json().await.expect("JSON"); + assert_eq!(body[&pubkey_hex].as_str(), Some("away")); + + // Set to "offline" — should clear presence. + let resp = authed_put( + &client, + &url, + &pubkey_hex, + serde_json::json!({"status": "offline"}), + ) + .await; + assert_eq!(resp.status(), 200); + let offline_body: serde_json::Value = resp.json().await.expect("JSON"); + assert_eq!( + offline_body["status"].as_str(), + Some("offline"), + "PUT response should echo 'offline'" + ); + assert_eq!( + offline_body["ttl_seconds"].as_u64(), + Some(0), + "offline should have 0 TTL" + ); + + // Verify "offline" (key deleted from Redis, defaults to "offline"). + let resp = authed_get(&client, &get_url, &pubkey_hex).await; + let body: serde_json::Value = resp.json().await.expect("JSON"); + assert_eq!(body[&pubkey_hex].as_str(), Some("offline")); +} + +/// PUT /api/presence with an invalid status returns 422 with standard error envelope. +#[tokio::test] +#[ignore] +async fn test_set_presence_invalid_status() { + let client = http_client(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + + let url = format!("{}/api/presence", relay_http_url()); + let resp = authed_put( + &client, + &url, + &pubkey_hex, + serde_json::json!({"status": "invisible"}), + ) + .await; + assert_eq!( + resp.status(), + 422, + "invalid enum variant should return 422 Unprocessable Entity" + ); + let body: serde_json::Value = resp.json().await.expect("JSON"); + assert!( + body["error"].as_str().is_some(), + "422 response should contain standard error envelope, got: {body}" + ); +} + +/// PUT /api/presence without auth returns 401. +#[tokio::test] +#[ignore] +async fn test_set_presence_requires_auth() { + let client = http_client(); + let url = format!("{}/api/presence", relay_http_url()); + let resp = client + .put(&url) + .json(&serde_json::json!({"status": "online"})) + .send() + .await + .expect("request failed"); + assert_eq!( + resp.status(), + 401, + "PUT /api/presence without auth should return 401" + ); + let body: serde_json::Value = resp.json().await.expect("JSON"); + assert_eq!( + body["error"].as_str(), + Some("authentication required"), + "401 response should contain standard error envelope" + ); +} + +/// PUT /api/presence with missing status field returns a structured error. +#[tokio::test] +#[ignore] +async fn test_set_presence_missing_field() { + let client = http_client(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + + let url = format!("{}/api/presence", relay_http_url()); + let resp = authed_put(&client, &url, &pubkey_hex, serde_json::json!({})).await; + assert_eq!( + resp.status(), + 422, + "missing required field should return 422" + ); + let body: serde_json::Value = resp.json().await.expect("JSON"); + assert!( + body["error"].as_str().is_some(), + "422 response should contain standard error envelope, got: {body}" + ); +} + // ── Agents tests ────────────────────────────────────────────────────────────── /// GET /api/agents returns a JSON array with the expected fields.