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//! | `RATE_LIMIT_AUTH` | no | `10` | Auth rate limit (req/min/IP). `0` = disabled |
19//! | `RATE_LIMIT_GENERAL` | no | `60` | General rate limit (req/min/IP). `0` = disabled |
20//!
21//! # Examples
22//!
23//! ```no_run
24//! use ironflow_api::config::ServerConfig;
25//!
26//! # fn example() -> Result<(), ironflow_api::config::ConfigError> {
27//! let config = ServerConfig::from_env()?;
28//! println!("Listening on port {}", config.port);
29//! # Ok(())
30//! # }
31//! ```
32
33use std::env;
34use std::fmt;
35use std::path::PathBuf;
36
37use tracing::warn;
38
39/// Server configuration loaded from environment variables.
40///
41/// Use [`ServerConfig::from_env`] to load and validate at startup.
42///
43/// # Examples
44///
45/// ```no_run
46/// use ironflow_api::config::ServerConfig;
47///
48/// # fn example() -> Result<(), ironflow_api::config::ConfigError> {
49/// let config = ServerConfig::from_env()?;
50/// assert!(config.port > 0);
51/// # Ok(())
52/// # }
53/// ```
54#[derive(Debug, Clone)]
55pub struct ServerConfig {
56    /// PostgreSQL connection string. Required in production.
57    pub database_url: Option<String>,
58    /// JWT signing secret.
59    pub jwt_secret: String,
60    /// Worker-to-API authentication token.
61    pub worker_token: String,
62    /// HTTP listen port.
63    pub port: u16,
64    /// Comma-separated list of allowed CORS origins.
65    pub allowed_origins: Option<String>,
66    /// Filesystem path to dashboard assets (overrides embedded).
67    pub dashboard_dir: Option<PathBuf>,
68    /// Outbound webhook URL for event notifications.
69    pub webhook_url: Option<String>,
70    /// Whether the server is running in production mode.
71    pub is_production: bool,
72    /// Rate limit for auth credential routes (sign-in, sign-up) in requests
73    /// per minute per IP. `None` disables rate limiting on these routes.
74    pub rate_limit_auth: Option<u32>,
75    /// Rate limit for general public API routes in requests per minute per IP.
76    /// `None` disables rate limiting on these routes.
77    pub rate_limit_general: Option<u32>,
78}
79
80/// Configuration validation error.
81///
82/// Collects all missing/invalid values so the operator sees every problem
83/// in a single error message, not one at a time.
84///
85/// # Examples
86///
87/// ```
88/// use ironflow_api::config::ConfigError;
89///
90/// let err = ConfigError::new(vec!["JWT_SECRET is required in production".to_string()]);
91/// assert!(err.to_string().contains("JWT_SECRET"));
92/// ```
93#[derive(Debug, Clone)]
94pub struct ConfigError {
95    /// Individual validation failure messages.
96    pub errors: Vec<String>,
97}
98
99impl ConfigError {
100    /// Create a new `ConfigError` from a list of validation messages.
101    pub fn new(errors: Vec<String>) -> Self {
102        Self { errors }
103    }
104}
105
106impl fmt::Display for ConfigError {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        writeln!(f, "configuration errors:")?;
109        for error in &self.errors {
110            writeln!(f, "  - {error}")?;
111        }
112        Ok(())
113    }
114}
115
116impl std::error::Error for ConfigError {}
117
118const DEV_JWT_SECRET: &str = "ironflow-dev-secret";
119const DEV_WORKER_TOKEN: &str = "ironflow-dev-worker-token";
120
121/// Parse an optional u32 env var. Returns `Some(default)` if unset,
122/// `Some(value)` if set to a positive number, `None` if set to `0`
123/// (meaning disabled). Pushes to `errors` if the value is not a valid u32.
124fn parse_optional_u32(name: &str, default: u32, errors: &mut Vec<String>) -> Option<u32> {
125    match env::var(name).ok() {
126        Some(raw) => match raw.parse::<u32>() {
127            Ok(0) => None,
128            Ok(v) => Some(v),
129            Err(_) => {
130                errors.push(format!(
131                    "{name} must be a valid u32 (0 to disable), got: {raw}"
132                ));
133                Some(default)
134            }
135        },
136        None => Some(default),
137    }
138}
139
140impl ServerConfig {
141    /// Load configuration from environment variables and validate.
142    ///
143    /// In production mode (`IRONFLOW_ENV=production`), `JWT_SECRET` and
144    /// `WORKER_TOKEN` must be explicitly set (dev defaults are rejected).
145    /// `DATABASE_URL` is required in production.
146    ///
147    /// In development mode, insecure defaults are used with a warning.
148    ///
149    /// # Errors
150    ///
151    /// Returns [`ConfigError`] with all validation failures collected,
152    /// so the operator can fix everything in one pass.
153    ///
154    /// # Examples
155    ///
156    /// ```no_run
157    /// use ironflow_api::config::ServerConfig;
158    ///
159    /// # fn example() -> Result<(), ironflow_api::config::ConfigError> {
160    /// let config = ServerConfig::from_env()?;
161    /// # Ok(())
162    /// # }
163    /// ```
164    pub fn from_env() -> Result<Self, ConfigError> {
165        let is_production = env::var("IRONFLOW_ENV")
166            .map(|v| v.eq_ignore_ascii_case("production"))
167            .unwrap_or(false);
168
169        let mut errors = Vec::new();
170
171        let database_url = env::var("DATABASE_URL").ok();
172        if is_production && database_url.is_none() {
173            errors.push("DATABASE_URL is required in production".to_string());
174        }
175
176        let jwt_secret_env = env::var("JWT_SECRET").ok();
177        let jwt_secret = match jwt_secret_env {
178            Some(val) => val,
179            None if is_production => {
180                errors.push("JWT_SECRET is required in production".to_string());
181                String::new()
182            }
183            None => {
184                warn!("JWT_SECRET not set, using insecure dev default -- do NOT use in production");
185                DEV_JWT_SECRET.to_string()
186            }
187        };
188
189        let worker_token_env = env::var("WORKER_TOKEN").ok();
190        let worker_token = match worker_token_env {
191            Some(val) => val,
192            None if is_production => {
193                errors.push("WORKER_TOKEN is required in production".to_string());
194                String::new()
195            }
196            None => {
197                warn!(
198                    "WORKER_TOKEN not set, using insecure dev default -- do NOT use in production"
199                );
200                DEV_WORKER_TOKEN.to_string()
201            }
202        };
203
204        let port = match env::var("PORT").ok() {
205            Some(raw) => raw.parse::<u16>().unwrap_or_else(|_| {
206                errors.push(format!("PORT must be a valid u16, got: {raw}"));
207                0
208            }),
209            None => 3000,
210        };
211
212        let allowed_origins = env::var("ALLOWED_ORIGINS").ok();
213        let dashboard_dir = env::var("DASHBOARD_DIR").ok().map(PathBuf::from);
214        let webhook_url = env::var("WEBHOOK_URL").ok();
215
216        let rate_limit_auth = parse_optional_u32("RATE_LIMIT_AUTH", 10, &mut errors);
217        let rate_limit_general = parse_optional_u32("RATE_LIMIT_GENERAL", 60, &mut errors);
218
219        if !errors.is_empty() {
220            return Err(ConfigError::new(errors));
221        }
222
223        Ok(Self {
224            database_url,
225            jwt_secret,
226            worker_token,
227            port,
228            allowed_origins,
229            dashboard_dir,
230            webhook_url,
231            is_production,
232            rate_limit_auth,
233            rate_limit_general,
234        })
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use std::sync::Mutex;
241
242    use super::*;
243
244    // Env var mutations are not thread-safe -- serialize all tests that touch them.
245    static ENV_LOCK: Mutex<()> = Mutex::new(());
246
247    /// # Safety
248    ///
249    /// Must be called while holding `ENV_LOCK`.
250    unsafe fn clear_env() {
251        unsafe {
252            env::remove_var("IRONFLOW_ENV");
253            env::remove_var("DATABASE_URL");
254            env::remove_var("JWT_SECRET");
255            env::remove_var("WORKER_TOKEN");
256            env::remove_var("PORT");
257            env::remove_var("ALLOWED_ORIGINS");
258            env::remove_var("DASHBOARD_DIR");
259            env::remove_var("WEBHOOK_URL");
260            env::remove_var("RATE_LIMIT_AUTH");
261            env::remove_var("RATE_LIMIT_GENERAL");
262        }
263    }
264
265    #[test]
266    fn config_error_display_lists_all_errors() {
267        let err = ConfigError::new(vec![
268            "JWT_SECRET is required".to_string(),
269            "DATABASE_URL is required".to_string(),
270        ]);
271        let msg = err.to_string();
272        assert!(msg.contains("JWT_SECRET"));
273        assert!(msg.contains("DATABASE_URL"));
274        assert!(msg.contains("configuration errors:"));
275    }
276
277    #[test]
278    fn config_error_is_std_error() {
279        let err = ConfigError::new(vec!["test".to_string()]);
280        let _: &dyn std::error::Error = &err;
281    }
282
283    #[test]
284    fn default_dev_config_succeeds() {
285        let _guard = ENV_LOCK.lock().unwrap();
286        unsafe { clear_env() };
287
288        let config = ServerConfig::from_env().expect("dev config should succeed");
289        assert!(!config.is_production);
290        assert_eq!(config.port, 3000);
291        assert_eq!(config.jwt_secret, DEV_JWT_SECRET);
292        assert_eq!(config.worker_token, DEV_WORKER_TOKEN);
293    }
294
295    #[test]
296    fn production_without_secrets_fails() {
297        let _guard = ENV_LOCK.lock().unwrap();
298        unsafe {
299            clear_env();
300            env::set_var("IRONFLOW_ENV", "production");
301        }
302
303        let result = ServerConfig::from_env();
304        assert!(result.is_err());
305        let err = result.unwrap_err();
306        assert!(err.errors.len() >= 3);
307        assert!(err.errors.iter().any(|e| e.contains("DATABASE_URL")));
308        assert!(err.errors.iter().any(|e| e.contains("JWT_SECRET")));
309        assert!(err.errors.iter().any(|e| e.contains("WORKER_TOKEN")));
310
311        unsafe { env::remove_var("IRONFLOW_ENV") };
312    }
313
314    #[test]
315    fn invalid_port_returns_error() {
316        let _guard = ENV_LOCK.lock().unwrap();
317        unsafe {
318            clear_env();
319            env::set_var("PORT", "not-a-number");
320        }
321
322        let result = ServerConfig::from_env();
323        assert!(result.is_err());
324        let err = result.unwrap_err();
325        assert!(err.errors.iter().any(|e| e.contains("PORT")));
326
327        unsafe { env::remove_var("PORT") };
328    }
329
330    #[test]
331    fn default_rate_limits() {
332        let _guard = ENV_LOCK.lock().unwrap();
333        unsafe { clear_env() };
334
335        let config = ServerConfig::from_env().unwrap();
336        assert_eq!(config.rate_limit_auth, Some(10));
337        assert_eq!(config.rate_limit_general, Some(60));
338    }
339
340    #[test]
341    fn custom_rate_limits() {
342        let _guard = ENV_LOCK.lock().unwrap();
343        unsafe {
344            clear_env();
345            env::set_var("RATE_LIMIT_AUTH", "20");
346            env::set_var("RATE_LIMIT_GENERAL", "120");
347        }
348
349        let config = ServerConfig::from_env().unwrap();
350        assert_eq!(config.rate_limit_auth, Some(20));
351        assert_eq!(config.rate_limit_general, Some(120));
352
353        unsafe {
354            env::remove_var("RATE_LIMIT_AUTH");
355            env::remove_var("RATE_LIMIT_GENERAL");
356        }
357    }
358
359    #[test]
360    fn zero_rate_limit_disables() {
361        let _guard = ENV_LOCK.lock().unwrap();
362        unsafe {
363            clear_env();
364            env::set_var("RATE_LIMIT_AUTH", "0");
365            env::set_var("RATE_LIMIT_GENERAL", "0");
366        }
367
368        let config = ServerConfig::from_env().unwrap();
369        assert!(config.rate_limit_auth.is_none());
370        assert!(config.rate_limit_general.is_none());
371
372        unsafe {
373            env::remove_var("RATE_LIMIT_AUTH");
374            env::remove_var("RATE_LIMIT_GENERAL");
375        }
376    }
377
378    #[test]
379    fn invalid_rate_limit_returns_error() {
380        let _guard = ENV_LOCK.lock().unwrap();
381        unsafe {
382            clear_env();
383            env::set_var("RATE_LIMIT_AUTH", "not-a-number");
384        }
385
386        let result = ServerConfig::from_env();
387        assert!(result.is_err());
388        let err = result.unwrap_err();
389        assert!(err.errors.iter().any(|e| e.contains("RATE_LIMIT_AUTH")));
390
391        unsafe { env::remove_var("RATE_LIMIT_AUTH") };
392    }
393}