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//! | `ARTIFACTS_DIR` | no | - | Filesystem root for artifact blobs. Unset disables artifacts |
21//! | `ARTIFACT_MAX_BYTES` | no | `104857600` | Maximum size of a single artifact |
22//! | `PURGE_MAX_AGE_DAYS` | no | `90` | Runs older than this are purged |
23//! | `PURGE_MAX_RUNS_PER_WORKFLOW` | no | `1000` | Max terminal runs kept per workflow |
24//! | `PURGE_DRY_RUN` | no | `false` | Log what would be purged without deleting |
25//! | `PURGE_INTERVAL_SECS` | no | `86400` | Seconds between purge ticks (min 60) |
26//!
27//! # Examples
28//!
29//! ```no_run
30//! use ironflow_api::config::ServerConfig;
31//!
32//! # fn example() -> Result<(), ironflow_api::config::ConfigError> {
33//! let config = ServerConfig::from_env()?;
34//! println!("Listening on port {}", config.port);
35//! # Ok(())
36//! # }
37//! ```
38
39use std::env;
40use std::fmt;
41use std::path::PathBuf;
42
43use ironflow_artifacts::local::DEFAULT_MAX_ARTIFACT_BYTES;
44use tracing::warn;
45
46/// Server configuration loaded from environment variables.
47///
48/// Use [`ServerConfig::from_env`] to load and validate at startup.
49///
50/// # Examples
51///
52/// ```no_run
53/// use ironflow_api::config::ServerConfig;
54///
55/// # fn example() -> Result<(), ironflow_api::config::ConfigError> {
56/// let config = ServerConfig::from_env()?;
57/// assert!(config.port > 0);
58/// # Ok(())
59/// # }
60/// ```
61#[derive(Debug, Clone)]
62pub struct ServerConfig {
63    /// PostgreSQL connection string. Required in production.
64    pub database_url: Option<String>,
65    /// JWT signing secret.
66    pub jwt_secret: String,
67    /// Worker-to-API authentication token.
68    pub worker_token: String,
69    /// HTTP listen port.
70    pub port: u16,
71    /// Comma-separated list of allowed CORS origins.
72    pub allowed_origins: Option<String>,
73    /// Filesystem path to dashboard assets (overrides embedded).
74    pub dashboard_dir: Option<PathBuf>,
75    /// Outbound webhook URL for event notifications.
76    pub webhook_url: Option<String>,
77    /// Filesystem root for artifact blobs.
78    ///
79    /// `None` leaves artifacts disabled: the artifact routes answer `501` and
80    /// a step that declares one fails explicitly. Every other endpoint is
81    /// unaffected, so an existing deployment upgrades without changes.
82    ///
83    /// Read from `ARTIFACTS_DIR`.
84    pub artifacts_dir: Option<PathBuf>,
85    /// Maximum size of a single artifact, in bytes.
86    ///
87    /// Read from `ARTIFACT_MAX_BYTES`, defaulting to 100 MiB.
88    pub artifact_max_bytes: u64,
89    /// Maximum age of a run before it becomes eligible for purging, in days.
90    ///
91    /// Read from `PURGE_MAX_AGE_DAYS`, defaulting to 90.
92    pub purge_max_age_days: u32,
93    /// Maximum number of terminal runs to keep per workflow.
94    ///
95    /// Read from `PURGE_MAX_RUNS_PER_WORKFLOW`, defaulting to 1000.
96    pub purge_max_runs_per_workflow: u32,
97    /// When `true`, the purger logs what would be deleted but does not delete.
98    ///
99    /// Read from `PURGE_DRY_RUN`, defaulting to `false`.
100    pub purge_dry_run: bool,
101    /// Interval between purge ticks, in seconds.
102    ///
103    /// Read from `PURGE_INTERVAL_SECS`, defaulting to 86400 (once per day).
104    pub purge_interval_secs: u64,
105    /// Whether the server is running in production mode.
106    pub is_production: bool,
107    /// Rate limit for auth credential routes (sign-in, sign-up) in requests
108    /// per minute per IP. `None` disables rate limiting on these routes.
109    pub rate_limit_auth: Option<u32>,
110    /// Rate limit for general public API routes in requests per minute per IP.
111    /// `None` disables rate limiting on these routes.
112    pub rate_limit_general: Option<u32>,
113}
114
115/// Configuration validation error.
116///
117/// Collects all missing/invalid values so the operator sees every problem
118/// in a single error message, not one at a time.
119///
120/// # Examples
121///
122/// ```
123/// use ironflow_api::config::ConfigError;
124///
125/// let err = ConfigError::new(vec!["JWT_SECRET is required in production".to_string()]);
126/// assert!(err.to_string().contains("JWT_SECRET"));
127/// ```
128#[derive(Debug, Clone)]
129pub struct ConfigError {
130    /// Individual validation failure messages.
131    pub errors: Vec<String>,
132}
133
134impl ConfigError {
135    /// Create a new `ConfigError` from a list of validation messages.
136    pub fn new(errors: Vec<String>) -> Self {
137        Self { errors }
138    }
139}
140
141impl fmt::Display for ConfigError {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        writeln!(f, "configuration errors:")?;
144        for error in &self.errors {
145            writeln!(f, "  - {error}")?;
146        }
147        Ok(())
148    }
149}
150
151impl std::error::Error for ConfigError {}
152
153const DEV_JWT_SECRET: &str = "ironflow-dev-secret";
154const DEV_WORKER_TOKEN: &str = "ironflow-dev-worker-token";
155
156/// Parse an optional u32 env var. Returns `Some(default)` if unset,
157/// `Some(value)` if set to a positive number, `None` if set to `0`
158/// (meaning disabled). Pushes to `errors` if the value is not a valid u32.
159fn parse_optional_u32(name: &str, default: u32, errors: &mut Vec<String>) -> Option<u32> {
160    match env::var(name).ok() {
161        Some(raw) => match raw.parse::<u32>() {
162            Ok(0) => None,
163            Ok(v) => Some(v),
164            Err(_) => {
165                errors.push(format!(
166                    "{name} must be a valid u32 (0 to disable), got: {raw}"
167                ));
168                Some(default)
169            }
170        },
171        None => Some(default),
172    }
173}
174
175impl ServerConfig {
176    /// Load configuration from environment variables and validate.
177    ///
178    /// In production mode (`IRONFLOW_ENV=production`), `JWT_SECRET` and
179    /// `WORKER_TOKEN` must be explicitly set (dev defaults are rejected).
180    /// `DATABASE_URL` is required in production.
181    ///
182    /// In development mode, insecure defaults are used with a warning.
183    ///
184    /// # Errors
185    ///
186    /// Returns [`ConfigError`] with all validation failures collected,
187    /// so the operator can fix everything in one pass.
188    ///
189    /// # Examples
190    ///
191    /// ```no_run
192    /// use ironflow_api::config::ServerConfig;
193    ///
194    /// # fn example() -> Result<(), ironflow_api::config::ConfigError> {
195    /// let config = ServerConfig::from_env()?;
196    /// # Ok(())
197    /// # }
198    /// ```
199    pub fn from_env() -> Result<Self, ConfigError> {
200        let is_production = env::var("IRONFLOW_ENV")
201            .map(|v| v.eq_ignore_ascii_case("production"))
202            .unwrap_or(false);
203
204        let mut errors = Vec::new();
205
206        let database_url = env::var("DATABASE_URL").ok();
207        if is_production && database_url.is_none() {
208            errors.push("DATABASE_URL is required in production".to_string());
209        }
210
211        let jwt_secret_env = env::var("JWT_SECRET").ok();
212        let jwt_secret = match jwt_secret_env {
213            Some(val) => val,
214            None if is_production => {
215                errors.push("JWT_SECRET is required in production".to_string());
216                String::new()
217            }
218            None => {
219                warn!("JWT_SECRET not set, using insecure dev default -- do NOT use in production");
220                DEV_JWT_SECRET.to_string()
221            }
222        };
223
224        let worker_token_env = env::var("WORKER_TOKEN").ok();
225        let worker_token = match worker_token_env {
226            Some(val) => val,
227            None if is_production => {
228                errors.push("WORKER_TOKEN is required in production".to_string());
229                String::new()
230            }
231            None => {
232                warn!(
233                    "WORKER_TOKEN not set, using insecure dev default -- do NOT use in production"
234                );
235                DEV_WORKER_TOKEN.to_string()
236            }
237        };
238
239        let port = match env::var("PORT").ok() {
240            Some(raw) => raw.parse::<u16>().unwrap_or_else(|_| {
241                errors.push(format!("PORT must be a valid u16, got: {raw}"));
242                0
243            }),
244            None => 3000,
245        };
246
247        let allowed_origins = env::var("ALLOWED_ORIGINS").ok();
248        let dashboard_dir = env::var("DASHBOARD_DIR").ok().map(PathBuf::from);
249        let webhook_url = env::var("WEBHOOK_URL").ok();
250
251        let rate_limit_auth = parse_optional_u32("RATE_LIMIT_AUTH", 10, &mut errors);
252        let rate_limit_general = parse_optional_u32("RATE_LIMIT_GENERAL", 60, &mut errors);
253
254        let artifacts_dir = env::var("ARTIFACTS_DIR").ok().map(PathBuf::from);
255        let artifact_max_bytes = match env::var("ARTIFACT_MAX_BYTES").ok() {
256            Some(raw) => raw.parse::<u64>().unwrap_or_else(|_| {
257                errors.push(format!(
258                    "ARTIFACT_MAX_BYTES must be a valid u64, got: {raw}"
259                ));
260                DEFAULT_MAX_ARTIFACT_BYTES
261            }),
262            None => DEFAULT_MAX_ARTIFACT_BYTES,
263        };
264
265        let purge_max_age_days = match env::var("PURGE_MAX_AGE_DAYS").ok() {
266            Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
267                errors.push(format!(
268                    "PURGE_MAX_AGE_DAYS must be a valid u32, got: {raw}"
269                ));
270                90
271            }),
272            None => 90,
273        };
274        let purge_max_runs_per_workflow = match env::var("PURGE_MAX_RUNS_PER_WORKFLOW").ok() {
275            Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
276                errors.push(format!(
277                    "PURGE_MAX_RUNS_PER_WORKFLOW must be a valid u32, got: {raw}"
278                ));
279                1000
280            }),
281            None => 1000,
282        };
283        let purge_dry_run = env::var("PURGE_DRY_RUN")
284            .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
285            .unwrap_or(false);
286        let purge_interval_secs = match env::var("PURGE_INTERVAL_SECS").ok() {
287            Some(raw) => {
288                let parsed = raw.parse::<u64>().unwrap_or_else(|_| {
289                    errors.push(format!(
290                        "PURGE_INTERVAL_SECS must be a valid u64, got: {raw}"
291                    ));
292                    86400
293                });
294                if parsed < 60 {
295                    errors.push(format!(
296                        "PURGE_INTERVAL_SECS must be at least 60, got: {parsed}"
297                    ));
298                }
299                parsed
300            }
301            None => 86400,
302        };
303
304        if !errors.is_empty() {
305            return Err(ConfigError::new(errors));
306        }
307
308        Ok(Self {
309            database_url,
310            jwt_secret,
311            worker_token,
312            port,
313            allowed_origins,
314            dashboard_dir,
315            webhook_url,
316            is_production,
317            rate_limit_auth,
318            rate_limit_general,
319            artifacts_dir,
320            artifact_max_bytes,
321            purge_max_age_days,
322            purge_max_runs_per_workflow,
323            purge_dry_run,
324            purge_interval_secs,
325        })
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use std::sync::Mutex;
332
333    use super::*;
334
335    // Env var mutations are not thread-safe -- serialize all tests that touch them.
336    static ENV_LOCK: Mutex<()> = Mutex::new(());
337
338    /// # Safety
339    ///
340    /// Must be called while holding `ENV_LOCK`.
341    unsafe fn clear_env() {
342        unsafe {
343            env::remove_var("IRONFLOW_ENV");
344            env::remove_var("DATABASE_URL");
345            env::remove_var("JWT_SECRET");
346            env::remove_var("WORKER_TOKEN");
347            env::remove_var("PORT");
348            env::remove_var("ALLOWED_ORIGINS");
349            env::remove_var("DASHBOARD_DIR");
350            env::remove_var("WEBHOOK_URL");
351            env::remove_var("RATE_LIMIT_AUTH");
352            env::remove_var("RATE_LIMIT_GENERAL");
353            env::remove_var("PURGE_MAX_AGE_DAYS");
354            env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
355            env::remove_var("PURGE_DRY_RUN");
356            env::remove_var("PURGE_INTERVAL_SECS");
357        }
358    }
359
360    #[test]
361    fn config_error_display_lists_all_errors() {
362        let err = ConfigError::new(vec![
363            "JWT_SECRET is required".to_string(),
364            "DATABASE_URL is required".to_string(),
365        ]);
366        let msg = err.to_string();
367        assert!(msg.contains("JWT_SECRET"));
368        assert!(msg.contains("DATABASE_URL"));
369        assert!(msg.contains("configuration errors:"));
370    }
371
372    #[test]
373    fn config_error_is_std_error() {
374        let err = ConfigError::new(vec!["test".to_string()]);
375        let _: &dyn std::error::Error = &err;
376    }
377
378    #[test]
379    fn default_dev_config_succeeds() {
380        let _guard = ENV_LOCK.lock().unwrap();
381        unsafe { clear_env() };
382
383        let config = ServerConfig::from_env().expect("dev config should succeed");
384        assert!(!config.is_production);
385        assert_eq!(config.port, 3000);
386        assert_eq!(config.jwt_secret, DEV_JWT_SECRET);
387        assert_eq!(config.worker_token, DEV_WORKER_TOKEN);
388    }
389
390    #[test]
391    fn production_without_secrets_fails() {
392        let _guard = ENV_LOCK.lock().unwrap();
393        unsafe {
394            clear_env();
395            env::set_var("IRONFLOW_ENV", "production");
396        }
397
398        let result = ServerConfig::from_env();
399        assert!(result.is_err());
400        let err = result.unwrap_err();
401        assert!(err.errors.len() >= 3);
402        assert!(err.errors.iter().any(|e| e.contains("DATABASE_URL")));
403        assert!(err.errors.iter().any(|e| e.contains("JWT_SECRET")));
404        assert!(err.errors.iter().any(|e| e.contains("WORKER_TOKEN")));
405
406        unsafe { env::remove_var("IRONFLOW_ENV") };
407    }
408
409    #[test]
410    fn invalid_port_returns_error() {
411        let _guard = ENV_LOCK.lock().unwrap();
412        unsafe {
413            clear_env();
414            env::set_var("PORT", "not-a-number");
415        }
416
417        let result = ServerConfig::from_env();
418        assert!(result.is_err());
419        let err = result.unwrap_err();
420        assert!(err.errors.iter().any(|e| e.contains("PORT")));
421
422        unsafe { env::remove_var("PORT") };
423    }
424
425    #[test]
426    fn default_rate_limits() {
427        let _guard = ENV_LOCK.lock().unwrap();
428        unsafe { clear_env() };
429
430        let config = ServerConfig::from_env().unwrap();
431        assert_eq!(config.rate_limit_auth, Some(10));
432        assert_eq!(config.rate_limit_general, Some(60));
433    }
434
435    #[test]
436    fn custom_rate_limits() {
437        let _guard = ENV_LOCK.lock().unwrap();
438        unsafe {
439            clear_env();
440            env::set_var("RATE_LIMIT_AUTH", "20");
441            env::set_var("RATE_LIMIT_GENERAL", "120");
442        }
443
444        let config = ServerConfig::from_env().unwrap();
445        assert_eq!(config.rate_limit_auth, Some(20));
446        assert_eq!(config.rate_limit_general, Some(120));
447
448        unsafe {
449            env::remove_var("RATE_LIMIT_AUTH");
450            env::remove_var("RATE_LIMIT_GENERAL");
451        }
452    }
453
454    #[test]
455    fn zero_rate_limit_disables() {
456        let _guard = ENV_LOCK.lock().unwrap();
457        unsafe {
458            clear_env();
459            env::set_var("RATE_LIMIT_AUTH", "0");
460            env::set_var("RATE_LIMIT_GENERAL", "0");
461        }
462
463        let config = ServerConfig::from_env().unwrap();
464        assert!(config.rate_limit_auth.is_none());
465        assert!(config.rate_limit_general.is_none());
466
467        unsafe {
468            env::remove_var("RATE_LIMIT_AUTH");
469            env::remove_var("RATE_LIMIT_GENERAL");
470        }
471    }
472
473    #[test]
474    fn invalid_rate_limit_returns_error() {
475        let _guard = ENV_LOCK.lock().unwrap();
476        unsafe {
477            clear_env();
478            env::set_var("RATE_LIMIT_AUTH", "not-a-number");
479        }
480
481        let result = ServerConfig::from_env();
482        assert!(result.is_err());
483        let err = result.unwrap_err();
484        assert!(err.errors.iter().any(|e| e.contains("RATE_LIMIT_AUTH")));
485
486        unsafe { env::remove_var("RATE_LIMIT_AUTH") };
487    }
488
489    #[test]
490    fn default_purge_config() {
491        let _guard = ENV_LOCK.lock().unwrap();
492        unsafe { clear_env() };
493
494        let config = ServerConfig::from_env().unwrap();
495        assert_eq!(config.purge_max_age_days, 90);
496        assert_eq!(config.purge_max_runs_per_workflow, 1000);
497        assert!(!config.purge_dry_run);
498        assert_eq!(config.purge_interval_secs, 86400);
499    }
500
501    #[test]
502    fn custom_purge_config() {
503        let _guard = ENV_LOCK.lock().unwrap();
504        unsafe {
505            clear_env();
506            env::set_var("PURGE_MAX_AGE_DAYS", "30");
507            env::set_var("PURGE_MAX_RUNS_PER_WORKFLOW", "500");
508            env::set_var("PURGE_DRY_RUN", "true");
509            env::set_var("PURGE_INTERVAL_SECS", "3600");
510        }
511
512        let config = ServerConfig::from_env().unwrap();
513        assert_eq!(config.purge_max_age_days, 30);
514        assert_eq!(config.purge_max_runs_per_workflow, 500);
515        assert!(config.purge_dry_run);
516        assert_eq!(config.purge_interval_secs, 3600);
517
518        unsafe {
519            env::remove_var("PURGE_MAX_AGE_DAYS");
520            env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
521            env::remove_var("PURGE_DRY_RUN");
522            env::remove_var("PURGE_INTERVAL_SECS");
523        }
524    }
525
526    #[test]
527    fn invalid_purge_max_age_days_returns_error() {
528        let _guard = ENV_LOCK.lock().unwrap();
529        unsafe {
530            clear_env();
531            env::set_var("PURGE_MAX_AGE_DAYS", "not-a-number");
532        }
533
534        let result = ServerConfig::from_env();
535        assert!(result.is_err());
536        let err = result.unwrap_err();
537        assert!(err.errors.iter().any(|e| e.contains("PURGE_MAX_AGE_DAYS")));
538
539        unsafe { env::remove_var("PURGE_MAX_AGE_DAYS") };
540    }
541}