use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; use reqwest::Method; use serde::de::DeserializeOwned; use serde::Deserialize; use sha2::{Digest, Sha256}; // nostr 0.36 alias — required for cross-version bridging with sprout-sdk. use nostr_compat; use crate::app_state::AppState; const DEFAULT_RELAY_WS_URL: &str = "ws://localhost:3000"; fn configured_env_var(name: &str) -> Option { std::env::var(name) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } pub fn relay_ws_url() -> String { configured_env_var("SPROUT_RELAY_URL") .or_else(|| option_env!("SPROUT_DESKTOP_BUILD_RELAY_URL").map(str::to_string)) .unwrap_or_else(|| DEFAULT_RELAY_WS_URL.to_string()) } /// Read the workspace relay URL override, if set. Returns `None` when no /// override is active or when the mutex is poisoned (best-effort). fn workspace_relay_override(state: &AppState) -> Option { state .relay_url_override .lock() .ok() .and_then(|guard| guard.clone()) } /// Returns the relay WebSocket URL, checking the workspace override first. /// Precedence: workspace override > env vars > build-time vars > default. pub fn relay_ws_url_with_override(state: &AppState) -> String { workspace_relay_override(state).unwrap_or_else(relay_ws_url) } /// Returns the relay HTTP API base URL, checking the workspace override first. /// Precedence: workspace override > env vars > build-time vars > default. pub fn relay_api_base_url_with_override(state: &AppState) -> String { match workspace_relay_override(state) { Some(url) => relay_http_base_url(&url), None => relay_api_base_url(), } } pub fn relay_http_base_url(relay_url: &str) -> String { let trimmed = relay_url.trim().trim_end_matches('/'); if let Some(suffix) = trimmed.strip_prefix("wss://") { return format!("https://{suffix}"); } if let Some(suffix) = trimmed.strip_prefix("ws://") { return format!("http://{suffix}"); } trimmed.to_string() } pub fn relay_api_base_url() -> String { if let Some(base) = configured_env_var("SPROUT_RELAY_HTTP") { return base.trim_end_matches('/').to_string(); } if let Some(base) = option_env!("SPROUT_DESKTOP_BUILD_RELAY_HTTP") { return base.trim().trim_end_matches('/').to_string(); } relay_http_base_url(&relay_ws_url()) } // ── NIP-98 HTTP auth ──────────────────────────────────────────────────────── pub fn build_nip98_auth_header( method: &Method, url: &str, body: &[u8], state: &AppState, ) -> Result { let keys = state.keys.lock().map_err(|error| error.to_string())?; build_nip98_auth_header_for_keys(&keys, method, url, body) } pub fn build_nip98_auth_header_for_keys( keys: &Keys, method: &Method, url: &str, body: &[u8], ) -> Result { let payload_hash = hex::encode(Sha256::digest(body)); // Nonce ensures unique event IDs even for identical requests in the same second. // Without this, rapid-fire calls (e.g. query → submit → re-query) with the same // body produce identical NIP-98 event hashes and trigger relay replay detection. let nonce_hex = uuid::Uuid::new_v4().to_string(); let tags = vec![ Tag::parse(vec!["u", url]).map_err(|error| format!("url tag failed: {error}"))?, Tag::parse(vec!["method", method.as_str()]) .map_err(|error| format!("method tag failed: {error}"))?, Tag::parse(vec!["payload", &payload_hash]) .map_err(|error| format!("payload tag failed: {error}"))?, Tag::parse(vec!["nonce", &nonce_hex]) .map_err(|error| format!("nonce tag failed: {error}"))?, ]; let event = EventBuilder::new(Kind::HttpAuth, "") .tags(tags) .sign_with_keys(keys) .map_err(|error| format!("sign failed: {error}"))?; Ok(format!( "Nostr {}", BASE64.encode(event.as_json().as_bytes()) )) } // ── Error handling ────────────────────────────────────────────────────────── pub async fn relay_error_message(response: reqwest::Response) -> String { let status = response.status(); let body = response.text().await.unwrap_or_default(); if let Ok(value) = serde_json::from_str::(&body) { if let Some(message) = value.get("message").and_then(serde_json::Value::as_str) { return format!("relay returned {status}: {message}"); } if let Some(error) = value.get("error").and_then(serde_json::Value::as_str) { return format!("relay returned {status}: {error}"); } } format!("relay returned {status}: {body}") } // ── HTTP bridge: POST /query ──────────────────────────────────────────────── /// Execute a one-shot query via the relay's HTTP bridge (`POST /query`). /// /// Filters are serialized as a JSON array. The request is authenticated with /// a NIP-98 event signed by the user's keys. Returns the deserialized array of /// events. pub async fn query_relay( state: &AppState, filters: &[serde_json::Value], ) -> Result, String> { let url = format!("{}/query", relay_api_base_url_with_override(state)); let body_bytes = serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?; let auth = build_nip98_auth_header(&Method::POST, &url, &body_bytes, state)?; let response = state .http_client .post(&url) .header("Authorization", auth) .header("Content-Type", "application/json") .body(body_bytes) .send() .await .map_err(|e| format!("request failed: {e}"))?; if !response.status().is_success() { return Err(relay_error_message(response).await); } response .json::>() .await .map_err(|e| format!("failed to parse query response: {e}")) } // ── Command response parsing ──────────────────────────────────────────────── /// Parse a command-event OK message of the form `"response:"`. /// /// Sprout's command kinds (e.g. 41010, 30620, 46020) acknowledge writes via /// relay OK messages whose payload is a `response:`-prefixed JSON document. /// This helper strips the prefix and deserializes the remainder as `T`. pub fn parse_command_response(message: &str) -> Result { // Try the spec format first: "response:{...}". if let Some(json) = message.strip_prefix("response:") { return serde_json::from_str(json).map_err(|e| format!("response parse failed: {e}")); } // Fallback: raw JSON (backward compat for relays that omit the prefix). serde_json::from_str(message) .map_err(|e| format!("expected 'response:' prefix or valid JSON, got: {message} ({e})")) } // ── Profile event builder ─────────────────────────────────────────────────── /// Build a signed kind:0 profile event, optionally injecting a verified NIP-OA auth tag. /// /// This is a pure function (no I/O) extracted from `sync_managed_agent_profile` so that /// the event-building and auth-tag-injection logic can be unit tested without HTTP calls. /// /// `sprout-sdk` uses `nostr 0.36` while the desktop crate uses `nostr 0.37`. Cross-version /// bridging is done via hex-encoded public keys and raw tag slices — both versions share the /// same wire format. fn build_profile_event( agent_keys: &nostr::Keys, display_name: &str, avatar_url: Option<&str>, auth_tag_json: Option<&str>, ) -> Result { let builder = crate::events::build_profile(Some(display_name), None, avatar_url, None, None)?; let builder = if let Some(tag_json) = auth_tag_json { // Bridge nostr 0.37 PublicKey → nostr 0.36 PublicKey via hex encoding. let agent_pubkey_hex = agent_keys.public_key().to_hex(); let compat_pubkey = nostr_compat::PublicKey::from_hex(&agent_pubkey_hex) .map_err(|e| format!("failed to convert agent pubkey for auth verification: {e}"))?; // Verify Schnorr signature before injecting into profile event. sprout_sdk::nip_oa::verify_auth_tag(tag_json, &compat_pubkey) .map_err(|e| format!("auth tag verification failed for profile event: {e}"))?; // parse_auth_tag returns a nostr 0.36 Tag; bridge to nostr 0.37 via raw slice. let compat_tag = sprout_sdk::nip_oa::parse_auth_tag(tag_json) .map_err(|e| format!("failed to parse verified auth tag: {e}"))?; let tag = nostr::Tag::parse(compat_tag.as_slice()) .map_err(|e| format!("failed to convert auth tag to nostr 0.37: {e}"))?; builder.tags([tag]) } else { builder }; builder .sign_with_keys(agent_keys) .map_err(|e| format!("failed to sign profile event: {e}")) } // ── Managed-agent profile sync ────────────────────────────────────────────── /// Sync a managed agent's kind:0 profile event to the relay using NIP-98 auth. /// /// The agent signs its own profile event and the NIP-98 HTTP-auth event, so no /// API token is required. pub async fn sync_managed_agent_profile( state: &AppState, relay_url: &str, agent_keys: &nostr::Keys, display_name: &str, avatar_url: Option<&str>, auth_tag: Option<&str>, // NIP-OA auth tag JSON ) -> Result<(), String> { // Build a signed kind:0 profile event (with optional NIP-OA auth tag). let event = build_profile_event(agent_keys, display_name, avatar_url, auth_tag)?; let event_json = event.as_json(); let body_bytes = event_json.into_bytes(); let url = format!("{}/events", relay_http_base_url(relay_url)); let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, &url, &body_bytes)?; let mut request = state .http_client .post(&url) .header("Authorization", auth) .header("Content-Type", "application/json"); if let Some(tag) = auth_tag { request = request.header("x-auth-tag", tag); } let response = request .body(body_bytes) .send() .await .map_err(|e| format!("request failed: {e}"))?; if !response.status().is_success() { let msg = relay_error_message(response).await; return Err(format!( "Created the agent, but could not sync its profile metadata: {msg}" )); } Ok(()) } // ── Signed-event submission ───────────────────────────────────────────────── /// Response from `POST /events`. #[derive(Debug, Deserialize, serde::Serialize)] pub struct SubmitEventResponse { pub event_id: String, pub accepted: bool, pub message: String, } /// Build an `EventBuilder` from the events module, sign it with the user's keys, /// and POST the signed event to `/events` with NIP-98 auth. pub async fn submit_event( builder: nostr::EventBuilder, state: &AppState, ) -> Result { // All synchronous work (signing) must complete before any .await // so the MutexGuard is dropped and the future remains Send. let url = format!("{}/events", relay_api_base_url_with_override(state)); let (auth_header, body_bytes) = { let keys = state.keys.lock().map_err(|e| e.to_string())?; let event = builder .sign_with_keys(&keys) .map_err(|e| format!("failed to sign event: {e}"))?; let body = event.as_json().into_bytes(); let auth = build_nip98_auth_header_for_keys(&keys, &Method::POST, &url, &body)?; (auth, body) }; // keys lock dropped here let response = state .http_client .post(&url) .header("Authorization", auth_header) .header("Content-Type", "application/json") .body(body_bytes) .send() .await .map_err(|e| format!("request failed: {e}"))?; if !response.status().is_success() { return Err(relay_error_message(response).await); } let result: SubmitEventResponse = response .json() .await .map_err(|e| format!("failed to parse response: {e}"))?; if !result.accepted { return Err(format!("relay rejected event: {}", result.message)); } Ok(result) } // ── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::{build_profile_event, parse_command_response}; use serde::Deserialize; // ── parse_command_response ─────────────────────────────────────────────── #[derive(Debug, Deserialize, PartialEq)] struct ChannelCreated { channel_id: String, } #[test] fn parse_command_response_decodes_typed_payload() { let msg = r#"response:{"channel_id":"abc123"}"#; let parsed: ChannelCreated = parse_command_response(msg).expect("should parse"); assert_eq!( parsed, ChannelCreated { channel_id: "abc123".to_string() } ); } #[test] fn parse_command_response_accepts_raw_json_fallback() { // Backward-compat: relays that emit raw JSON (no prefix) still work. let msg = r#"{"channel_id":"abc"}"#; let parsed: ChannelCreated = parse_command_response(msg).expect("fallback parse"); assert_eq!( parsed, ChannelCreated { channel_id: "abc".to_string() } ); } #[test] fn parse_command_response_rejects_invalid_prefixed_json() { let msg = "response:not-json"; let result: Result = parse_command_response(msg); assert!(result.is_err()); assert!(result.unwrap_err().contains("response parse failed")); } #[test] fn parse_command_response_rejects_garbage() { let msg = "totally not json or response"; let result: Result = parse_command_response(msg); assert!(result.is_err()); } // ── build_profile_event ────────────────────────────────────────────────── /// Generate a valid NIP-OA auth tag JSON string signed by a fresh owner key /// and addressed to `agent_keys`. /// /// Uses `nostr_compat` (nostr 0.36) for the owner keys because /// `sprout_sdk::nip_oa::compute_auth_tag` expects nostr 0.36 types. /// The agent pubkey is bridged via hex encoding. fn make_valid_auth_tag(agent_keys: &nostr::Keys) -> String { let owner_keys = nostr_compat::Keys::generate(); let agent_pubkey_hex = agent_keys.public_key().to_hex(); let agent_compat_pubkey = nostr_compat::PublicKey::from_hex(&agent_pubkey_hex) .expect("valid hex pubkey should parse"); sprout_sdk::nip_oa::compute_auth_tag(&owner_keys, &agent_compat_pubkey, "") .expect("compute_auth_tag should not fail with distinct keys") } #[test] fn profile_event_with_valid_auth_tag() { let agent_keys = nostr::Keys::generate(); let tag_json = make_valid_auth_tag(&agent_keys); let event = build_profile_event(&agent_keys, "TestBot", None, Some(&tag_json)) .expect("should succeed with a valid auth tag"); // Exactly one "auth" tag must be present. let auth_tags: Vec<_> = event .tags .iter() .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth")) .collect(); assert_eq!(auth_tags.len(), 1, "expected exactly 1 auth tag"); // Must be a kind:0 (Metadata) event. assert_eq!(event.kind, nostr::Kind::Metadata); } #[test] fn profile_event_without_auth_tag() { let agent_keys = nostr::Keys::generate(); let event = build_profile_event(&agent_keys, "TestBot", None, None) .expect("should succeed without an auth tag"); // No "auth" tags should be present. let auth_tags: Vec<_> = event .tags .iter() .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth")) .collect(); assert_eq!(auth_tags.len(), 0, "expected no auth tags"); assert_eq!(event.kind, nostr::Kind::Metadata); } #[test] fn profile_event_rejects_invalid_auth_tag() { let agent_keys = nostr::Keys::generate(); // Structurally valid JSON array but with a bogus signature — verification must fail. let bad_json = format!(r#"["auth","{}","","{}"]"#, "a".repeat(64), "b".repeat(128)); let result = build_profile_event(&agent_keys, "TestBot", None, Some(&bad_json)); assert!(result.is_err(), "should reject an invalid auth tag"); assert!( result.unwrap_err().contains("verification failed"), "error message should mention verification failure" ); } }