use std::env; use thiserror::Error; /// Application configuration loaded from environment variables. /// /// Usage: /// ```rust,no_run /// # use slipnote_backend::config::Config; /// let config = Config::from_env().expect("config"); /// ``` #[derive(Debug, Clone)] pub struct Config { /// `OpenAI` API key used for Whisper requests. pub openai_api_key: String, /// `OpenAI` base URL. pub openai_base_url: String, /// Default Whisper model. pub openai_whisper_model: String, /// Default Whisper response format. pub openai_whisper_response_format: String, /// Address/port to bind the HTTP server to. pub bind_addr: String, /// Postgres connection string. pub database_url: String, /// Named environment used for logging and diagnostics. pub slipnote_env: String, /// Tail-sampling rate for request logs (0.0-1.0). pub log_sample_rate: f64, /// Estimated cost per audio second in dollars. pub transcription_cost_per_second_dollars: f64, /// Axiom ingest token (required in prod, optional in dev). pub axiom_token: Option, /// Axiom dataset name (required in prod, optional in dev). pub axiom_dataset: Option, /// Optional Axiom API URL override. pub axiom_url: Option, /// OAuth client ID (typically the client metadata URL). pub oauth_client_id: String, /// OAuth redirect URI for callbacks. pub oauth_redirect_uri: String, /// OAuth client display name. pub oauth_client_name: Option, /// OAuth client URI for metadata. pub oauth_client_uri: Option, /// OAuth JWKS URI for signing keys. pub oauth_jwks_uri: Option, /// OAuth signing key (did:key private key). pub oauth_signing_key: String, /// OAuth scopes (space-delimited). pub oauth_scopes: String, /// OAuth post-auth redirect path or URL. pub oauth_post_auth_redirect: String, /// Session cookie name. pub oauth_cookie_name: String, /// Session TTL in seconds. pub oauth_session_ttl_seconds: i64, /// PLC directory hostname. pub plc_hostname: String, /// Optional OAuth base URL used to derive client metadata/redirect URIs. pub oauth_base_url: Option, } /// Errors that can occur when loading configuration. #[derive(Debug, Error)] pub enum ConfigError { #[error("missing required environment variable {0}")] MissingEnvVar(&'static str), } impl Config { /// Loads configuration from environment variables. /// /// Usage: /// ```rust,no_run /// # use slipnote_backend::config::Config; /// let config = Config::from_env().expect("config"); /// ``` pub fn from_env() -> Result { load_dotenv(); let require_axiom = !cfg!(debug_assertions); let default_env = if cfg!(debug_assertions) { "local" } else { "prod" }; let oauth_base_url = env::var("OAUTH_BASE_URL") .ok() .map(|value| value.trim_end_matches('/').to_string()); let oauth_client_id = env::var("OAUTH_CLIENT_ID") .ok() .or_else(|| { oauth_base_url .as_ref() .map(|base| format!("{base}/oauth/client-metadata.json")) }) .ok_or(ConfigError::MissingEnvVar("OAUTH_CLIENT_ID"))?; let oauth_redirect_uri = env::var("OAUTH_REDIRECT_URI") .ok() .or_else(|| oauth_base_url.as_ref().map(|base| format!("{base}/oauth/callback"))) .ok_or(ConfigError::MissingEnvVar("OAUTH_REDIRECT_URI"))?; let oauth_jwks_uri = env::var("OAUTH_JWKS_URI") .ok() .or_else(|| { oauth_base_url .as_ref() .map(|base| format!("{base}/.well-known/jwks.json")) }); let oauth_post_auth_redirect = env::var("OAUTH_POST_AUTH_REDIRECT").ok(); let oauth_post_auth_redirect_route = env::var("OAUTH_POST_AUTH_REDIRECT_ROUTE").ok(); let oauth_post_auth_redirect = oauth_post_auth_redirect.or_else(|| { if let Some(route) = oauth_post_auth_redirect_route { if let Some(base) = oauth_base_url.as_ref() { let trimmed_route = if route.starts_with('/') { route } else { format!("/{route}") }; return Some(format!("{base}{trimmed_route}")); } return Some(route); } oauth_base_url.clone() }); Ok(Self { openai_api_key: require_env("OPENAI_API_KEY")?, openai_base_url: env_or("OPENAI_BASE_URL", "https://api.openai.com/v1"), openai_whisper_model: env_or("OPENAI_WHISPER_MODEL", "whisper-1"), openai_whisper_response_format: env_or( "OPENAI_WHISPER_RESPONSE_FORMAT", "verbose_json", ), bind_addr: env_or("SLIPNOTE_BIND_ADDR", "0.0.0.0:3001"), database_url: require_env("DATABASE_URL")?, slipnote_env: env_or("SLIPNOTE_ENV", default_env), log_sample_rate: env_or_f64("SLIPNOTE_LOG_SAMPLE_RATE", 1.0), transcription_cost_per_second_dollars: env_or_f64( "SLIPNOTE_TRANSCRIPTION_COST_PER_SECOND_DOLLARS", DEFAULT_TRANSCRIPTION_COST_PER_SECOND_DOLLARS, ), axiom_token: if require_axiom { Some(require_env("AXIOM_TOKEN")?) } else { env::var("AXIOM_TOKEN").ok() }, axiom_dataset: if require_axiom { Some(require_env("AXIOM_DATASET")?) } else { env::var("AXIOM_DATASET").ok() }, axiom_url: env::var("AXIOM_URL").ok(), oauth_client_id, oauth_redirect_uri, oauth_client_name: env::var("OAUTH_CLIENT_NAME").ok(), oauth_client_uri: env::var("OAUTH_CLIENT_URI") .ok() .or_else(|| oauth_base_url.clone()), oauth_jwks_uri, oauth_signing_key: require_env("OAUTH_SIGNING_KEY")?, oauth_scopes: env_or("OAUTH_SCOPES", "atproto transition:generic"), oauth_post_auth_redirect: oauth_post_auth_redirect .unwrap_or_else(|| "/".to_string()), oauth_cookie_name: env_or("OAUTH_COOKIE_NAME", "slipnote_session"), oauth_session_ttl_seconds: env_or_i64("OAUTH_SESSION_TTL_SECONDS", 60 * 60 * 24 * 7), plc_hostname: env_or("PLC_HOSTNAME", "plc.directory"), oauth_base_url, }) } } const DEFAULT_TRANSCRIPTION_COST_PER_SECOND_DOLLARS: f64 = 0.000_094_34; fn require_env(key: &'static str) -> Result { env::var(key).map_err(|_| ConfigError::MissingEnvVar(key)) } fn env_or(key: &'static str, default_value: &'static str) -> String { env::var(key).unwrap_or_else(|_| default_value.to_string()) } fn env_or_f64(key: &'static str, default_value: f64) -> f64 { env::var(key) .ok() .and_then(|value| value.parse::().ok()) .unwrap_or(default_value) } fn env_or_i64(key: &'static str, default_value: i64) -> i64 { env::var(key) .ok() .and_then(|value| value.parse::().ok()) .unwrap_or(default_value) } fn load_dotenv() { if env::var("SLIPNOTE_DISABLE_DOTENV").is_err() { dotenvy::dotenv().ok(); } } #[cfg(test)] mod tests { use super::*; use std::sync::{Mutex, OnceLock}; static ENV_LOCK: OnceLock> = OnceLock::new(); fn with_env_lock(f: F) { let lock = ENV_LOCK.get_or_init(|| Mutex::new(())); let _guard = lock.lock().unwrap_or_else(|err| err.into_inner()); f(); } struct EnvSnapshot { entries: Vec<(&'static str, Option)>, } impl EnvSnapshot { fn capture(keys: &[&'static str]) -> Self { let entries = keys .iter() .map(|key| (*key, env::var(key).ok())) .collect(); Self { entries } } } impl Drop for EnvSnapshot { fn drop(&mut self) { for (key, value) in self.entries.iter() { match value { Some(current) => set_env_var(key, current), None => remove_env_var(key), } } } } fn set_env_var(key: &str, value: &str) { unsafe { env::set_var(key, value); } } fn remove_env_var(key: &str) { unsafe { env::remove_var(key); } } #[test] fn from_env_requires_api_key() { with_env_lock(|| { let _snapshot = EnvSnapshot::capture(&[ "OPENAI_API_KEY", "SLIPNOTE_DISABLE_DOTENV", "AXIOM_TOKEN", "AXIOM_DATASET", "AXIOM_URL", "OAUTH_CLIENT_ID", "OAUTH_REDIRECT_URI", "OAUTH_SIGNING_KEY", "OAUTH_BASE_URL", ]); set_env_var("SLIPNOTE_DISABLE_DOTENV", "1"); remove_env_var("OPENAI_API_KEY"); set_env_var("OAUTH_CLIENT_ID", "https://example.com/oauth/client-metadata.json"); set_env_var("OAUTH_REDIRECT_URI", "https://example.com/oauth/callback"); set_env_var("OAUTH_SIGNING_KEY", "did:key:z42tnbHmmnhF11nwSnp5kQJbcZQw2Vbw5WF3ABDSxPtDgU2o"); remove_env_var("OAUTH_BASE_URL"); let result = Config::from_env(); assert!(matches!(result, Err(ConfigError::MissingEnvVar("OPENAI_API_KEY")))); }); } #[test] fn from_env_uses_defaults() { with_env_lock(|| { let _snapshot = EnvSnapshot::capture(&[ "OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_WHISPER_MODEL", "OPENAI_WHISPER_RESPONSE_FORMAT", "SLIPNOTE_BIND_ADDR", "DATABASE_URL", "SLIPNOTE_ENV", "SLIPNOTE_LOG_SAMPLE_RATE", "SLIPNOTE_TRANSCRIPTION_COST_PER_SECOND_DOLLARS", "SLIPNOTE_DISABLE_DOTENV", "AXIOM_TOKEN", "AXIOM_DATASET", "AXIOM_URL", "OAUTH_CLIENT_ID", "OAUTH_REDIRECT_URI", "OAUTH_SIGNING_KEY", "OAUTH_SCOPES", "OAUTH_POST_AUTH_REDIRECT", "OAUTH_COOKIE_NAME", "OAUTH_SESSION_TTL_SECONDS", "PLC_HOSTNAME", "OAUTH_BASE_URL", ]); set_env_var("SLIPNOTE_DISABLE_DOTENV", "1"); set_env_var("OPENAI_API_KEY", "test-key"); remove_env_var("OPENAI_BASE_URL"); remove_env_var("OPENAI_WHISPER_MODEL"); remove_env_var("OPENAI_WHISPER_RESPONSE_FORMAT"); remove_env_var("SLIPNOTE_BIND_ADDR"); set_env_var("DATABASE_URL", "postgres://test:test@localhost:5432/test"); remove_env_var("SLIPNOTE_ENV"); remove_env_var("SLIPNOTE_LOG_SAMPLE_RATE"); remove_env_var("SLIPNOTE_TRANSCRIPTION_COST_PER_SECOND_DOLLARS"); set_env_var("OAUTH_CLIENT_ID", "https://example.com/oauth/client-metadata.json"); set_env_var("OAUTH_REDIRECT_URI", "https://example.com/oauth/callback"); set_env_var("OAUTH_SIGNING_KEY", "did:key:z42tnbHmmnhF11nwSnp5kQJbcZQw2Vbw5WF3ABDSxPtDgU2o"); remove_env_var("OAUTH_SCOPES"); remove_env_var("OAUTH_POST_AUTH_REDIRECT"); remove_env_var("OAUTH_COOKIE_NAME"); remove_env_var("OAUTH_SESSION_TTL_SECONDS"); remove_env_var("PLC_HOSTNAME"); remove_env_var("OAUTH_BASE_URL"); let config = Config::from_env().expect("config"); assert_eq!(config.openai_base_url, "https://api.openai.com/v1"); assert_eq!(config.openai_whisper_model, "whisper-1"); assert_eq!(config.openai_whisper_response_format, "verbose_json"); assert_eq!(config.bind_addr, "0.0.0.0:3001"); assert_eq!( config.database_url, "postgres://test:test@localhost:5432/test" ); assert_eq!(config.slipnote_env, "local"); assert_eq!(config.log_sample_rate, 1.0); assert_eq!( config.transcription_cost_per_second_dollars, DEFAULT_TRANSCRIPTION_COST_PER_SECOND_DOLLARS ); assert_eq!(config.oauth_scopes, "atproto transition:generic"); assert_eq!(config.oauth_post_auth_redirect, "/"); assert_eq!(config.oauth_cookie_name, "slipnote_session"); assert_eq!(config.oauth_session_ttl_seconds, 60 * 60 * 24 * 7); assert_eq!(config.plc_hostname, "plc.directory"); assert_eq!(config.oauth_base_url, None); }); } }