Skip to main content

ironflow_api/
config.rs

1//! Server configuration with startup validation.
2//!
3//! Loads configuration from environment variables and validates that all
4//! required values are present **at startup**, not at first use.
5//!
6//! # Environment Variables
7//!
8//! | Variable | Required | Default | Description |
9//! |----------|----------|---------|-------------|
10//! | `DATABASE_URL` | **prod** | - | PostgreSQL connection string |
11//! | `JWT_SECRET` | **prod** | dev default | JWT signing secret |
12//! | `WORKER_TOKEN` | **prod** | dev default | Worker-to-API auth token |
13//! | `PORT` | no | `3000` | HTTP listen port |
14//! | `ALLOWED_ORIGINS` | no | same-origin | Comma-separated CORS origins |
15//! | `DASHBOARD_DIR` | no | embedded | Filesystem path to dashboard assets |
16//! | `WEBHOOK_URL` | no | - | Outbound webhook URL for notifications |
17//! | `IRONFLOW_ENV` | no | `development` | `production` or `development` |
18//!
19//! # Examples
20//!
21//! ```no_run
22//! use ironflow_api::config::ServerConfig;
23//!
24//! # fn example() -> Result<(), ironflow_api::config::ConfigError> {
25//! let config = ServerConfig::from_env()?;
26//! println!("Listening on port {}", config.port);
27//! # Ok(())
28//! # }
29//! ```
30
31use std::env;
32use std::fmt;
33use std::path::PathBuf;
34
35use tracing::warn;
36
37/// Server configuration loaded from environment variables.
38///
39/// Use [`ServerConfig::from_env`] to load and validate at startup.
40///
41/// # Examples
42///
43/// ```no_run
44/// use ironflow_api::config::ServerConfig;
45///
46/// # fn example() -> Result<(), ironflow_api::config::ConfigError> {
47/// let config = ServerConfig::from_env()?;
48/// assert!(config.port > 0);
49/// # Ok(())
50/// # }
51/// ```
52#[derive(Debug, Clone)]
53pub struct ServerConfig {
54    /// PostgreSQL connection string. Required in production.
55    pub database_url: Option<String>,
56    /// JWT signing secret.
57    pub jwt_secret: String,
58    /// Worker-to-API authentication token.
59    pub worker_token: String,
60    /// HTTP listen port.
61    pub port: u16,
62    /// Comma-separated list of allowed CORS origins.
63    pub allowed_origins: Option<String>,
64    /// Filesystem path to dashboard assets (overrides embedded).
65    pub dashboard_dir: Option<PathBuf>,
66    /// Outbound webhook URL for event notifications.
67    pub webhook_url: Option<String>,
68    /// Whether the server is running in production mode.
69    pub is_production: bool,
70}
71
72/// Configuration validation error.
73///
74/// Collects all missing/invalid values so the operator sees every problem
75/// in a single error message, not one at a time.
76///
77/// # Examples
78///
79/// ```
80/// use ironflow_api::config::ConfigError;
81///
82/// let err = ConfigError::new(vec!["JWT_SECRET is required in production".to_string()]);
83/// assert!(err.to_string().contains("JWT_SECRET"));
84/// ```
85#[derive(Debug, Clone)]
86pub struct ConfigError {
87    /// Individual validation failure messages.
88    pub errors: Vec<String>,
89}
90
91impl ConfigError {
92    /// Create a new `ConfigError` from a list of validation messages.
93    pub fn new(errors: Vec<String>) -> Self {
94        Self { errors }
95    }
96}
97
98impl fmt::Display for ConfigError {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        writeln!(f, "configuration errors:")?;
101        for error in &self.errors {
102            writeln!(f, "  - {error}")?;
103        }
104        Ok(())
105    }
106}
107
108impl std::error::Error for ConfigError {}
109
110const DEV_JWT_SECRET: &str = "ironflow-dev-secret";
111const DEV_WORKER_TOKEN: &str = "ironflow-dev-worker-token";
112
113impl ServerConfig {
114    /// Load configuration from environment variables and validate.
115    ///
116    /// In production mode (`IRONFLOW_ENV=production`), `JWT_SECRET` and
117    /// `WORKER_TOKEN` must be explicitly set (dev defaults are rejected).
118    /// `DATABASE_URL` is required in production.
119    ///
120    /// In development mode, insecure defaults are used with a warning.
121    ///
122    /// # Errors
123    ///
124    /// Returns [`ConfigError`] with all validation failures collected,
125    /// so the operator can fix everything in one pass.
126    ///
127    /// # Examples
128    ///
129    /// ```no_run
130    /// use ironflow_api::config::ServerConfig;
131    ///
132    /// # fn example() -> Result<(), ironflow_api::config::ConfigError> {
133    /// let config = ServerConfig::from_env()?;
134    /// # Ok(())
135    /// # }
136    /// ```
137    pub fn from_env() -> Result<Self, ConfigError> {
138        let is_production = env::var("IRONFLOW_ENV")
139            .map(|v| v.eq_ignore_ascii_case("production"))
140            .unwrap_or(false);
141
142        let mut errors = Vec::new();
143
144        let database_url = env::var("DATABASE_URL").ok();
145        if is_production && database_url.is_none() {
146            errors.push("DATABASE_URL is required in production".to_string());
147        }
148
149        let jwt_secret_env = env::var("JWT_SECRET").ok();
150        let jwt_secret = match jwt_secret_env {
151            Some(val) => val,
152            None if is_production => {
153                errors.push("JWT_SECRET is required in production".to_string());
154                String::new()
155            }
156            None => {
157                warn!("JWT_SECRET not set, using insecure dev default -- do NOT use in production");
158                DEV_JWT_SECRET.to_string()
159            }
160        };
161
162        let worker_token_env = env::var("WORKER_TOKEN").ok();
163        let worker_token = match worker_token_env {
164            Some(val) => val,
165            None if is_production => {
166                errors.push("WORKER_TOKEN is required in production".to_string());
167                String::new()
168            }
169            None => {
170                warn!(
171                    "WORKER_TOKEN not set, using insecure dev default -- do NOT use in production"
172                );
173                DEV_WORKER_TOKEN.to_string()
174            }
175        };
176
177        let port = match env::var("PORT").ok() {
178            Some(raw) => raw.parse::<u16>().unwrap_or_else(|_| {
179                errors.push(format!("PORT must be a valid u16, got: {raw}"));
180                0
181            }),
182            None => 3000,
183        };
184
185        let allowed_origins = env::var("ALLOWED_ORIGINS").ok();
186        let dashboard_dir = env::var("DASHBOARD_DIR").ok().map(PathBuf::from);
187        let webhook_url = env::var("WEBHOOK_URL").ok();
188
189        if !errors.is_empty() {
190            return Err(ConfigError::new(errors));
191        }
192
193        Ok(Self {
194            database_url,
195            jwt_secret,
196            worker_token,
197            port,
198            allowed_origins,
199            dashboard_dir,
200            webhook_url,
201            is_production,
202        })
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use std::sync::Mutex;
209
210    use super::*;
211
212    // Env var mutations are not thread-safe -- serialize all tests that touch them.
213    static ENV_LOCK: Mutex<()> = Mutex::new(());
214
215    /// # Safety
216    ///
217    /// Must be called while holding `ENV_LOCK`.
218    unsafe fn clear_env() {
219        unsafe {
220            env::remove_var("IRONFLOW_ENV");
221            env::remove_var("DATABASE_URL");
222            env::remove_var("JWT_SECRET");
223            env::remove_var("WORKER_TOKEN");
224            env::remove_var("PORT");
225            env::remove_var("ALLOWED_ORIGINS");
226            env::remove_var("DASHBOARD_DIR");
227            env::remove_var("WEBHOOK_URL");
228        }
229    }
230
231    #[test]
232    fn config_error_display_lists_all_errors() {
233        let err = ConfigError::new(vec![
234            "JWT_SECRET is required".to_string(),
235            "DATABASE_URL is required".to_string(),
236        ]);
237        let msg = err.to_string();
238        assert!(msg.contains("JWT_SECRET"));
239        assert!(msg.contains("DATABASE_URL"));
240        assert!(msg.contains("configuration errors:"));
241    }
242
243    #[test]
244    fn config_error_is_std_error() {
245        let err = ConfigError::new(vec!["test".to_string()]);
246        let _: &dyn std::error::Error = &err;
247    }
248
249    #[test]
250    fn default_dev_config_succeeds() {
251        let _guard = ENV_LOCK.lock().unwrap();
252        unsafe { clear_env() };
253
254        let config = ServerConfig::from_env().expect("dev config should succeed");
255        assert!(!config.is_production);
256        assert_eq!(config.port, 3000);
257        assert_eq!(config.jwt_secret, DEV_JWT_SECRET);
258        assert_eq!(config.worker_token, DEV_WORKER_TOKEN);
259    }
260
261    #[test]
262    fn production_without_secrets_fails() {
263        let _guard = ENV_LOCK.lock().unwrap();
264        unsafe {
265            clear_env();
266            env::set_var("IRONFLOW_ENV", "production");
267        }
268
269        let result = ServerConfig::from_env();
270        assert!(result.is_err());
271        let err = result.unwrap_err();
272        assert!(err.errors.len() >= 3);
273        assert!(err.errors.iter().any(|e| e.contains("DATABASE_URL")));
274        assert!(err.errors.iter().any(|e| e.contains("JWT_SECRET")));
275        assert!(err.errors.iter().any(|e| e.contains("WORKER_TOKEN")));
276
277        unsafe { env::remove_var("IRONFLOW_ENV") };
278    }
279
280    #[test]
281    fn invalid_port_returns_error() {
282        let _guard = ENV_LOCK.lock().unwrap();
283        unsafe {
284            clear_env();
285            env::set_var("PORT", "not-a-number");
286        }
287
288        let result = ServerConfig::from_env();
289        assert!(result.is_err());
290        let err = result.unwrap_err();
291        assert!(err.errors.iter().any(|e| e.contains("PORT")));
292
293        unsafe { env::remove_var("PORT") };
294    }
295}