//! Channel-relay service configuration. use secrecy::SecretString; /// Configuration for connecting to a channel-relay service. #[derive(Clone)] pub struct RelayConfig { /// Base URL of the channel-relay service (e.g., `http://localhost:3001`). pub url: String, /// Bearer token for authenticated channel-relay endpoints (`sk-agent-*`). pub api_key: SecretString, /// Override for the OAuth callback URL (e.g., a tunnel URL). pub callback_url: Option, /// Override for the instance identifier. pub instance_id: Option, /// HTTP request timeout in seconds (default: 30). pub request_timeout_secs: u64, /// Path for the webhook callback endpoint (default: `/relay/events`). pub webhook_path: String, } impl std::fmt::Debug for RelayConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("RelayConfig") .field("url", &self.url) .field("api_key", &"[REDACTED]") .field("callback_url", &self.callback_url) .field("instance_id", &self.instance_id) .field("request_timeout_secs", &self.request_timeout_secs) .field("webhook_path", &self.webhook_path) .finish() } } impl RelayConfig { /// Load relay config from environment variables. /// /// Returns `None` if either of the required env vars (`CHANNEL_RELAY_URL`, /// `CHANNEL_RELAY_API_KEY`) is not set, making the relay integration opt-in. /// The signing secret is fetched from channel-relay at activation time via /// the authenticated `/relay/signing-secret` endpoint — no env var required. pub fn from_env() -> Option { Self::from_env_reader(|key| std::env::var(key).ok()) } /// Build a config for tests without touching the process environment. pub fn from_values(url: impl Into, api_key: impl Into) -> Self { Self { url: url.into(), api_key: SecretString::from(api_key.into()), callback_url: None, instance_id: None, request_timeout_secs: 30, webhook_path: "/relay/events".into(), } } /// Internal constructor that reads values through a closure, enabling safe testing. fn from_env_reader(env: impl Fn(&str) -> Option) -> Option { let url = env("CHANNEL_RELAY_URL")?; let api_key = SecretString::from(env("CHANNEL_RELAY_API_KEY")?); Some(Self { url, api_key, callback_url: env("IRONCLAW_OAUTH_CALLBACK_URL"), instance_id: env("IRONCLAW_INSTANCE_ID"), request_timeout_secs: env("RELAY_REQUEST_TIMEOUT_SECS") .and_then(|v| v.parse().ok()) .unwrap_or(30), webhook_path: env("RELAY_WEBHOOK_PATH").unwrap_or_else(|| "/relay/events".into()), }) } } #[cfg(test)] mod tests { use super::*; #[test] fn from_env_reader_returns_none_when_unset() { let config = RelayConfig::from_env_reader(|_| None); assert!(config.is_none()); } #[test] fn from_env_reader_requires_only_url_and_api_key() { // Signing secret is fetched at activation time — only URL + API key needed. let config = RelayConfig::from_env_reader(|key| match key { "CHANNEL_RELAY_URL" => Some("http://localhost:3001".into()), "CHANNEL_RELAY_API_KEY" => Some("test-key".into()), _ => None, }); assert!( config.is_some(), "relay config should load with just URL + API key" ); } #[test] fn from_env_reader_loads_all_required() { let config = RelayConfig::from_env_reader(|key| match key { "CHANNEL_RELAY_URL" => Some("http://localhost:3001".into()), "CHANNEL_RELAY_API_KEY" => Some("test-key".into()), _ => None, }) .expect("config should be Some"); assert_eq!(config.url, "http://localhost:3001"); assert_eq!(config.request_timeout_secs, 30); assert_eq!(config.webhook_path, "/relay/events"); assert!(config.callback_url.is_none()); assert!(config.instance_id.is_none()); } #[test] fn from_env_reader_loads_overrides() { let config = RelayConfig::from_env_reader(|key| match key { "CHANNEL_RELAY_URL" => Some("http://relay:3001".into()), "CHANNEL_RELAY_API_KEY" => Some("secret".into()), "IRONCLAW_OAUTH_CALLBACK_URL" => Some("https://tunnel.example.com".into()), "IRONCLAW_INSTANCE_ID" => Some("my-instance".into()), "RELAY_REQUEST_TIMEOUT_SECS" => Some("60".into()), "RELAY_WEBHOOK_PATH" => Some("/custom/events".into()), _ => None, }) .expect("config should be Some"); assert_eq!( config.callback_url.as_deref(), Some("https://tunnel.example.com") ); assert_eq!(config.instance_id.as_deref(), Some("my-instance")); assert_eq!(config.request_timeout_secs, 60); assert_eq!(config.webhook_path, "/custom/events"); } #[test] fn from_values_builds_with_defaults() { let config = RelayConfig::from_values("http://localhost:3001", "key"); assert_eq!(config.url, "http://localhost:3001"); assert_eq!(config.request_timeout_secs, 30); } #[test] fn debug_redacts_secrets() { let config = RelayConfig::from_values("http://localhost:3001", "super-secret"); let debug = format!("{:?}", config); assert!(debug.contains("[REDACTED]")); assert!(!debug.contains("super-secret")); } }