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//! | `PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS` | no | `30` | Days of Provider Account usage history kept (min 1) |
27//! | `SIGNAL_RETENTION_DAYS` | no | `7` | Days received signals are kept (min 1) |
28//! | `ARTIFACT_BACKEND` | no | `local` | Blob storage backend: `local` or `s3` |
29//! | `ARTIFACT_S3_BUCKET` | **if s3** | - | S3 bucket name |
30//! | `ARTIFACT_S3_REGION` | no | `eu-west-1` | S3 region |
31//! | `ARTIFACT_S3_ENDPOINT` | no | - | Custom S3 endpoint (MinIO, R2) |
32//! | `ARTIFACT_S3_PREFIX` | no | - | Key prefix within the bucket |
33//! | `ARTIFACT_GC_INTERVAL_SECS` | no | `86400` | Seconds between GC ticks (min 60) |
34//! | `ARTIFACT_GC_GRACE_DAYS` | no | `7` | Days before an orphan blob is deleted |
35//! | `ARTIFACT_GC_DRY_RUN` | no | `false` | Log what would be GC'd without deleting |
36//!
37//! # Examples
38//!
39//! ```no_run
40//! use ironflow_api::config::ServerConfig;
41//!
42//! # fn example() -> Result<(), ironflow_api::config::ConfigError> {
43//! let config = ServerConfig::from_env()?;
44//! println!("Listening on port {}", config.port);
45//! # Ok(())
46//! # }
47//! ```
48
49use std::env;
50use std::fmt;
51use std::path::PathBuf;
52
53use ironflow_artifacts::local::DEFAULT_MAX_ARTIFACT_BYTES;
54use tracing::warn;
55
56/// Server configuration loaded from environment variables.
57///
58/// Use [`ServerConfig::from_env`] to load and validate at startup.
59///
60/// # Examples
61///
62/// ```no_run
63/// use ironflow_api::config::ServerConfig;
64///
65/// # fn example() -> Result<(), ironflow_api::config::ConfigError> {
66/// let config = ServerConfig::from_env()?;
67/// assert!(config.port > 0);
68/// # Ok(())
69/// # }
70/// ```
71#[derive(Debug, Clone)]
72pub struct ServerConfig {
73    /// PostgreSQL connection string. Required in production.
74    pub database_url: Option<String>,
75    /// JWT signing secret.
76    pub jwt_secret: String,
77    /// Worker-to-API authentication token.
78    pub worker_token: String,
79    /// HTTP listen port.
80    pub port: u16,
81    /// Comma-separated list of allowed CORS origins.
82    pub allowed_origins: Option<String>,
83    /// Filesystem path to dashboard assets (overrides embedded).
84    pub dashboard_dir: Option<PathBuf>,
85    /// Outbound webhook URL for event notifications.
86    pub webhook_url: Option<String>,
87    /// Filesystem root for artifact blobs.
88    ///
89    /// `None` leaves artifacts disabled: the artifact routes answer `501` and
90    /// a step that declares one fails explicitly. Every other endpoint is
91    /// unaffected, so an existing deployment upgrades without changes.
92    ///
93    /// Read from `ARTIFACTS_DIR`.
94    pub artifacts_dir: Option<PathBuf>,
95    /// Maximum size of a single artifact, in bytes.
96    ///
97    /// Read from `ARTIFACT_MAX_BYTES`, defaulting to 100 MiB.
98    pub artifact_max_bytes: u64,
99    /// Maximum age of a run before it becomes eligible for purging, in days.
100    ///
101    /// Read from `PURGE_MAX_AGE_DAYS`, defaulting to 90.
102    pub purge_max_age_days: u32,
103    /// Maximum number of terminal runs to keep per workflow.
104    ///
105    /// Read from `PURGE_MAX_RUNS_PER_WORKFLOW`, defaulting to 1000.
106    pub purge_max_runs_per_workflow: u32,
107    /// When `true`, the purger logs what would be deleted but does not delete.
108    ///
109    /// Read from `PURGE_DRY_RUN`, defaulting to `false`.
110    pub purge_dry_run: bool,
111    /// Interval between purge ticks, in seconds.
112    ///
113    /// Read from `PURGE_INTERVAL_SECS`, defaulting to 86400 (once per day).
114    pub purge_interval_secs: u64,
115    /// Days of Provider Account usage history kept by the purger.
116    ///
117    /// Read from `PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS`, defaulting to 30.
118    pub provider_account_usage_retention_days: u32,
119    /// Days received signals are kept by the purger.
120    ///
121    /// Read from `SIGNAL_RETENTION_DAYS`, defaulting to 7.
122    pub signal_retention_days: u32,
123    /// Whether the server is running in production mode.
124    pub is_production: bool,
125    /// Rate limit for auth credential routes (sign-in, sign-up) in requests
126    /// per minute per IP. `None` disables rate limiting on these routes.
127    pub rate_limit_auth: Option<u32>,
128    /// Rate limit for general public API routes in requests per minute per IP.
129    /// `None` disables rate limiting on these routes.
130    pub rate_limit_general: Option<u32>,
131    /// Blob storage backend: `local` or `s3`.
132    pub artifact_backend: String,
133    /// S3 bucket name (required when `artifact_backend` is `s3`).
134    pub artifact_s3_bucket: Option<String>,
135    /// S3 region, defaults to `eu-west-1`.
136    pub artifact_s3_region: String,
137    /// Custom S3 endpoint for MinIO, R2, or GCS S3-compat.
138    pub artifact_s3_endpoint: Option<String>,
139    /// Key prefix within the S3 bucket.
140    pub artifact_s3_prefix: Option<String>,
141    /// Seconds between GC ticks, defaults to 86400 (once per day).
142    pub artifact_gc_interval_secs: u64,
143    /// Days before an orphan blob is eligible for deletion, defaults to 7.
144    pub artifact_gc_grace_days: u32,
145    /// When `true`, the GC logs what would be deleted without deleting.
146    pub artifact_gc_dry_run: bool,
147}
148
149/// Configuration validation error.
150///
151/// Collects all missing/invalid values so the operator sees every problem
152/// in a single error message, not one at a time.
153///
154/// # Examples
155///
156/// ```
157/// use ironflow_api::config::ConfigError;
158///
159/// let err = ConfigError::new(vec!["JWT_SECRET is required in production".to_string()]);
160/// assert!(err.to_string().contains("JWT_SECRET"));
161/// ```
162#[derive(Debug, Clone)]
163pub struct ConfigError {
164    /// Individual validation failure messages.
165    pub errors: Vec<String>,
166}
167
168impl ConfigError {
169    /// Create a new `ConfigError` from a list of validation messages.
170    pub fn new(errors: Vec<String>) -> Self {
171        Self { errors }
172    }
173}
174
175impl fmt::Display for ConfigError {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        writeln!(f, "configuration errors:")?;
178        for error in &self.errors {
179            writeln!(f, "  - {error}")?;
180        }
181        Ok(())
182    }
183}
184
185impl std::error::Error for ConfigError {}
186
187const DEV_JWT_SECRET: &str = "ironflow-dev-secret";
188const DEV_WORKER_TOKEN: &str = "ironflow-dev-worker-token";
189
190/// Prefix shared by every known development secret default. In production a
191/// secret carrying this prefix is refused outright, so a fresh deploy that
192/// copied the setup template verbatim cannot boot with a value published in
193/// the repository.
194const DEV_SECRET_PREFIX: &str = "ironflow-dev-";
195
196/// Minimum accepted length, in bytes, for a production secret. A value shorter
197/// than this is trivially brute-forced against an HS256 signature.
198const MIN_PROD_SECRET_BYTES: usize = 32;
199
200/// Reject a production secret that is a known development default or too short.
201///
202/// Pushes at most one message to `errors` (a known default is reported as such,
203/// not also as "too short"), so the collect-all pattern lists each faulty
204/// secret exactly once. Called only in production and only for a secret that
205/// was explicitly set; an unset secret is handled by the "required" check.
206fn reject_insecure_secret(name: &str, value: &str, errors: &mut Vec<String>) {
207    if value.starts_with(DEV_SECRET_PREFIX) {
208        errors.push(format!(
209            "{name} must not use a known development default in production"
210        ));
211    } else if value.len() < MIN_PROD_SECRET_BYTES {
212        errors.push(format!(
213            "{name} must be at least {MIN_PROD_SECRET_BYTES} bytes in production, got {}",
214            value.len()
215        ));
216    }
217}
218
219/// Parse an optional u32 env var. Returns `Some(default)` if unset,
220/// `Some(value)` if set to a positive number, `None` if set to `0`
221/// (meaning disabled). Pushes to `errors` if the value is not a valid u32.
222fn parse_optional_u32(name: &str, default: u32, errors: &mut Vec<String>) -> Option<u32> {
223    match env::var(name).ok() {
224        Some(raw) => match raw.parse::<u32>() {
225            Ok(0) => None,
226            Ok(v) => Some(v),
227            Err(_) => {
228                errors.push(format!(
229                    "{name} must be a valid u32 (0 to disable), got: {raw}"
230                ));
231                Some(default)
232            }
233        },
234        None => Some(default),
235    }
236}
237
238impl ServerConfig {
239    /// Load configuration from environment variables and validate.
240    ///
241    /// In production mode (`IRONFLOW_ENV=production`), `JWT_SECRET` and
242    /// `WORKER_TOKEN` must be explicitly set, must not carry the known
243    /// development prefix `ironflow-dev-`, and must be at least 32 bytes long.
244    /// `DATABASE_URL` is required in production.
245    ///
246    /// In development mode, insecure defaults are used with a warning.
247    ///
248    /// # Errors
249    ///
250    /// Returns [`ConfigError`] with all validation failures collected,
251    /// so the operator can fix everything in one pass.
252    ///
253    /// # Examples
254    ///
255    /// ```no_run
256    /// use ironflow_api::config::ServerConfig;
257    ///
258    /// # fn example() -> Result<(), ironflow_api::config::ConfigError> {
259    /// let config = ServerConfig::from_env()?;
260    /// # Ok(())
261    /// # }
262    /// ```
263    pub fn from_env() -> Result<Self, ConfigError> {
264        let is_production = env::var("IRONFLOW_ENV")
265            .map(|v| v.eq_ignore_ascii_case("production"))
266            .unwrap_or(false);
267
268        let mut errors = Vec::new();
269
270        let database_url = env::var("DATABASE_URL").ok();
271        if is_production && database_url.is_none() {
272            errors.push("DATABASE_URL is required in production".to_string());
273        }
274
275        let jwt_secret_env = env::var("JWT_SECRET").ok();
276        let jwt_secret = match jwt_secret_env {
277            Some(val) => {
278                if is_production {
279                    reject_insecure_secret("JWT_SECRET", &val, &mut errors);
280                }
281                val
282            }
283            None if is_production => {
284                errors.push("JWT_SECRET is required in production".to_string());
285                String::new()
286            }
287            None => {
288                warn!("JWT_SECRET not set, using insecure dev default -- do NOT use in production");
289                DEV_JWT_SECRET.to_string()
290            }
291        };
292
293        let worker_token_env = env::var("WORKER_TOKEN").ok();
294        let worker_token = match worker_token_env {
295            Some(val) => {
296                if is_production {
297                    reject_insecure_secret("WORKER_TOKEN", &val, &mut errors);
298                }
299                val
300            }
301            None if is_production => {
302                errors.push("WORKER_TOKEN is required in production".to_string());
303                String::new()
304            }
305            None => {
306                warn!(
307                    "WORKER_TOKEN not set, using insecure dev default -- do NOT use in production"
308                );
309                DEV_WORKER_TOKEN.to_string()
310            }
311        };
312
313        let port = match env::var("PORT").ok() {
314            Some(raw) => raw.parse::<u16>().unwrap_or_else(|_| {
315                errors.push(format!("PORT must be a valid u16, got: {raw}"));
316                0
317            }),
318            None => 3000,
319        };
320
321        let allowed_origins = env::var("ALLOWED_ORIGINS").ok();
322        let dashboard_dir = env::var("DASHBOARD_DIR").ok().map(PathBuf::from);
323        let webhook_url = env::var("WEBHOOK_URL").ok();
324
325        let rate_limit_auth = parse_optional_u32("RATE_LIMIT_AUTH", 10, &mut errors);
326        let rate_limit_general = parse_optional_u32("RATE_LIMIT_GENERAL", 60, &mut errors);
327
328        let artifacts_dir = env::var("ARTIFACTS_DIR").ok().map(PathBuf::from);
329        let artifact_max_bytes = match env::var("ARTIFACT_MAX_BYTES").ok() {
330            Some(raw) => raw.parse::<u64>().unwrap_or_else(|_| {
331                errors.push(format!(
332                    "ARTIFACT_MAX_BYTES must be a valid u64, got: {raw}"
333                ));
334                DEFAULT_MAX_ARTIFACT_BYTES
335            }),
336            None => DEFAULT_MAX_ARTIFACT_BYTES,
337        };
338
339        let purge_max_age_days = match env::var("PURGE_MAX_AGE_DAYS").ok() {
340            Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
341                errors.push(format!(
342                    "PURGE_MAX_AGE_DAYS must be a valid u32, got: {raw}"
343                ));
344                90
345            }),
346            None => 90,
347        };
348        let purge_max_runs_per_workflow = match env::var("PURGE_MAX_RUNS_PER_WORKFLOW").ok() {
349            Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
350                errors.push(format!(
351                    "PURGE_MAX_RUNS_PER_WORKFLOW must be a valid u32, got: {raw}"
352                ));
353                1000
354            }),
355            None => 1000,
356        };
357        let purge_dry_run = env::var("PURGE_DRY_RUN")
358            .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
359            .unwrap_or(false);
360        let provider_account_usage_retention_days =
361            match env::var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS").ok() {
362                Some(raw) => match raw.parse::<u32>() {
363                    Ok(days) if days >= 1 => days,
364                    _ => {
365                        errors.push(format!(
366                        "PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS must be an integer >= 1, got: {raw}"
367                    ));
368                        30
369                    }
370                },
371                None => 30,
372            };
373        let signal_retention_days = match env::var("SIGNAL_RETENTION_DAYS").ok() {
374            Some(raw) => match raw.parse::<u32>() {
375                Ok(days) if days >= 1 => days,
376                _ => {
377                    errors.push(format!(
378                        "SIGNAL_RETENTION_DAYS must be an integer >= 1, got: {raw}"
379                    ));
380                    7
381                }
382            },
383            None => 7,
384        };
385        let purge_interval_secs = match env::var("PURGE_INTERVAL_SECS").ok() {
386            Some(raw) => {
387                let parsed = raw.parse::<u64>().unwrap_or_else(|_| {
388                    errors.push(format!(
389                        "PURGE_INTERVAL_SECS must be a valid u64, got: {raw}"
390                    ));
391                    86400
392                });
393                if parsed < 60 {
394                    errors.push(format!(
395                        "PURGE_INTERVAL_SECS must be at least 60, got: {parsed}"
396                    ));
397                }
398                parsed
399            }
400            None => 86400,
401        };
402
403        let artifact_backend = env::var("ARTIFACT_BACKEND")
404            .unwrap_or_else(|_| "local".to_string())
405            .to_lowercase();
406        let artifact_s3_bucket = env::var("ARTIFACT_S3_BUCKET").ok();
407        let artifact_s3_region =
408            env::var("ARTIFACT_S3_REGION").unwrap_or_else(|_| "eu-west-1".to_string());
409        let artifact_s3_endpoint = env::var("ARTIFACT_S3_ENDPOINT").ok();
410        let artifact_s3_prefix = env::var("ARTIFACT_S3_PREFIX").ok();
411
412        if artifact_backend == "s3" && artifact_s3_bucket.is_none() {
413            errors.push("ARTIFACT_S3_BUCKET is required when ARTIFACT_BACKEND=s3".to_string());
414        }
415        if artifact_backend != "local" && artifact_backend != "s3" {
416            errors.push(format!(
417                "ARTIFACT_BACKEND must be 'local' or 's3', got: {artifact_backend}"
418            ));
419        }
420
421        let artifact_gc_interval_secs = match env::var("ARTIFACT_GC_INTERVAL_SECS").ok() {
422            Some(raw) => {
423                let parsed = raw.parse::<u64>().unwrap_or_else(|_| {
424                    errors.push(format!(
425                        "ARTIFACT_GC_INTERVAL_SECS must be a valid u64, got: {raw}"
426                    ));
427                    86400
428                });
429                if parsed < 60 {
430                    errors.push(format!(
431                        "ARTIFACT_GC_INTERVAL_SECS must be at least 60, got: {parsed}"
432                    ));
433                }
434                parsed
435            }
436            None => 86400,
437        };
438        let artifact_gc_grace_days = match env::var("ARTIFACT_GC_GRACE_DAYS").ok() {
439            Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
440                errors.push(format!(
441                    "ARTIFACT_GC_GRACE_DAYS must be a valid u32, got: {raw}"
442                ));
443                7
444            }),
445            None => 7,
446        };
447        let artifact_gc_dry_run = env::var("ARTIFACT_GC_DRY_RUN")
448            .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
449            .unwrap_or(false);
450
451        if !errors.is_empty() {
452            return Err(ConfigError::new(errors));
453        }
454
455        Ok(Self {
456            database_url,
457            jwt_secret,
458            worker_token,
459            port,
460            allowed_origins,
461            dashboard_dir,
462            webhook_url,
463            is_production,
464            rate_limit_auth,
465            rate_limit_general,
466            artifacts_dir,
467            artifact_max_bytes,
468            purge_max_age_days,
469            purge_max_runs_per_workflow,
470            purge_dry_run,
471            purge_interval_secs,
472            provider_account_usage_retention_days,
473            signal_retention_days,
474            artifact_backend,
475            artifact_s3_bucket,
476            artifact_s3_region,
477            artifact_s3_endpoint,
478            artifact_s3_prefix,
479            artifact_gc_interval_secs,
480            artifact_gc_grace_days,
481            artifact_gc_dry_run,
482        })
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use std::sync::Mutex;
489
490    use super::*;
491
492    // Env var mutations are not thread-safe -- serialize all tests that touch them.
493    static ENV_LOCK: Mutex<()> = Mutex::new(());
494
495    /// # Safety
496    ///
497    /// Must be called while holding `ENV_LOCK`.
498    unsafe fn clear_env() {
499        unsafe {
500            env::remove_var("IRONFLOW_ENV");
501            env::remove_var("DATABASE_URL");
502            env::remove_var("JWT_SECRET");
503            env::remove_var("WORKER_TOKEN");
504            env::remove_var("PORT");
505            env::remove_var("ALLOWED_ORIGINS");
506            env::remove_var("DASHBOARD_DIR");
507            env::remove_var("WEBHOOK_URL");
508            env::remove_var("RATE_LIMIT_AUTH");
509            env::remove_var("RATE_LIMIT_GENERAL");
510            env::remove_var("PURGE_MAX_AGE_DAYS");
511            env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
512            env::remove_var("PURGE_DRY_RUN");
513            env::remove_var("PURGE_INTERVAL_SECS");
514            env::remove_var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS");
515            env::remove_var("SIGNAL_RETENTION_DAYS");
516            env::remove_var("ARTIFACT_BACKEND");
517            env::remove_var("ARTIFACT_S3_BUCKET");
518            env::remove_var("ARTIFACT_S3_REGION");
519            env::remove_var("ARTIFACT_S3_ENDPOINT");
520            env::remove_var("ARTIFACT_S3_PREFIX");
521            env::remove_var("ARTIFACT_GC_INTERVAL_SECS");
522            env::remove_var("ARTIFACT_GC_GRACE_DAYS");
523            env::remove_var("ARTIFACT_GC_DRY_RUN");
524        }
525    }
526
527    #[test]
528    fn config_error_display_lists_all_errors() {
529        let err = ConfigError::new(vec![
530            "JWT_SECRET is required".to_string(),
531            "DATABASE_URL is required".to_string(),
532        ]);
533        let msg = err.to_string();
534        assert!(msg.contains("JWT_SECRET"));
535        assert!(msg.contains("DATABASE_URL"));
536        assert!(msg.contains("configuration errors:"));
537    }
538
539    #[test]
540    fn config_error_is_std_error() {
541        let err = ConfigError::new(vec!["test".to_string()]);
542        let _: &dyn std::error::Error = &err;
543    }
544
545    #[test]
546    fn default_dev_config_succeeds() {
547        let _guard = ENV_LOCK.lock().unwrap();
548        unsafe { clear_env() };
549
550        let config = ServerConfig::from_env().expect("dev config should succeed");
551        assert!(!config.is_production);
552        assert_eq!(config.port, 3000);
553        assert_eq!(config.jwt_secret, DEV_JWT_SECRET);
554        assert_eq!(config.worker_token, DEV_WORKER_TOKEN);
555    }
556
557    #[test]
558    fn production_without_secrets_fails() {
559        let _guard = ENV_LOCK.lock().unwrap();
560        unsafe {
561            clear_env();
562            env::set_var("IRONFLOW_ENV", "production");
563        }
564
565        let result = ServerConfig::from_env();
566        assert!(result.is_err());
567        let err = result.unwrap_err();
568        assert!(err.errors.len() >= 3);
569        assert!(err.errors.iter().any(|e| e.contains("DATABASE_URL")));
570        assert!(err.errors.iter().any(|e| e.contains("JWT_SECRET")));
571        assert!(err.errors.iter().any(|e| e.contains("WORKER_TOKEN")));
572
573        unsafe { env::remove_var("IRONFLOW_ENV") };
574    }
575
576    #[test]
577    fn invalid_port_returns_error() {
578        let _guard = ENV_LOCK.lock().unwrap();
579        unsafe {
580            clear_env();
581            env::set_var("PORT", "not-a-number");
582        }
583
584        let result = ServerConfig::from_env();
585        assert!(result.is_err());
586        let err = result.unwrap_err();
587        assert!(err.errors.iter().any(|e| e.contains("PORT")));
588
589        unsafe { env::remove_var("PORT") };
590    }
591
592    #[test]
593    fn default_rate_limits() {
594        let _guard = ENV_LOCK.lock().unwrap();
595        unsafe { clear_env() };
596
597        let config = ServerConfig::from_env().unwrap();
598        assert_eq!(config.rate_limit_auth, Some(10));
599        assert_eq!(config.rate_limit_general, Some(60));
600    }
601
602    #[test]
603    fn custom_rate_limits() {
604        let _guard = ENV_LOCK.lock().unwrap();
605        unsafe {
606            clear_env();
607            env::set_var("RATE_LIMIT_AUTH", "20");
608            env::set_var("RATE_LIMIT_GENERAL", "120");
609        }
610
611        let config = ServerConfig::from_env().unwrap();
612        assert_eq!(config.rate_limit_auth, Some(20));
613        assert_eq!(config.rate_limit_general, Some(120));
614
615        unsafe {
616            env::remove_var("RATE_LIMIT_AUTH");
617            env::remove_var("RATE_LIMIT_GENERAL");
618        }
619    }
620
621    #[test]
622    fn zero_rate_limit_disables() {
623        let _guard = ENV_LOCK.lock().unwrap();
624        unsafe {
625            clear_env();
626            env::set_var("RATE_LIMIT_AUTH", "0");
627            env::set_var("RATE_LIMIT_GENERAL", "0");
628        }
629
630        let config = ServerConfig::from_env().unwrap();
631        assert!(config.rate_limit_auth.is_none());
632        assert!(config.rate_limit_general.is_none());
633
634        unsafe {
635            env::remove_var("RATE_LIMIT_AUTH");
636            env::remove_var("RATE_LIMIT_GENERAL");
637        }
638    }
639
640    #[test]
641    fn invalid_rate_limit_returns_error() {
642        let _guard = ENV_LOCK.lock().unwrap();
643        unsafe {
644            clear_env();
645            env::set_var("RATE_LIMIT_AUTH", "not-a-number");
646        }
647
648        let result = ServerConfig::from_env();
649        assert!(result.is_err());
650        let err = result.unwrap_err();
651        assert!(err.errors.iter().any(|e| e.contains("RATE_LIMIT_AUTH")));
652
653        unsafe { env::remove_var("RATE_LIMIT_AUTH") };
654    }
655
656    #[test]
657    fn default_purge_config() {
658        let _guard = ENV_LOCK.lock().unwrap();
659        unsafe { clear_env() };
660
661        let config = ServerConfig::from_env().unwrap();
662        assert_eq!(config.purge_max_age_days, 90);
663        assert_eq!(config.purge_max_runs_per_workflow, 1000);
664        assert!(!config.purge_dry_run);
665        assert_eq!(config.purge_interval_secs, 86400);
666        assert_eq!(config.provider_account_usage_retention_days, 30);
667        assert_eq!(config.signal_retention_days, 7);
668    }
669
670    #[test]
671    fn provider_account_usage_retention_days_from_env() {
672        let _guard = ENV_LOCK.lock().unwrap();
673        // SAFETY: env writes serialized by ENV_LOCK, held above.
674        unsafe { clear_env() };
675        unsafe { env::set_var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS", "7") };
676        let config = ServerConfig::from_env().unwrap();
677        assert_eq!(config.provider_account_usage_retention_days, 7);
678
679        unsafe { env::set_var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS", "0") };
680        assert!(ServerConfig::from_env().is_err());
681        unsafe { clear_env() };
682    }
683
684    #[test]
685    fn signal_retention_days_from_env() {
686        let _guard = ENV_LOCK.lock().unwrap();
687        // SAFETY: env writes serialized by ENV_LOCK, held above.
688        unsafe { clear_env() };
689        unsafe { env::set_var("SIGNAL_RETENTION_DAYS", "3") };
690        let config = ServerConfig::from_env().unwrap();
691        assert_eq!(config.signal_retention_days, 3);
692
693        unsafe { env::set_var("SIGNAL_RETENTION_DAYS", "0") };
694        assert!(ServerConfig::from_env().is_err());
695        unsafe { env::set_var("SIGNAL_RETENTION_DAYS", "soon") };
696        assert!(ServerConfig::from_env().is_err());
697        unsafe { clear_env() };
698    }
699
700    #[test]
701    fn custom_purge_config() {
702        let _guard = ENV_LOCK.lock().unwrap();
703        unsafe {
704            clear_env();
705            env::set_var("PURGE_MAX_AGE_DAYS", "30");
706            env::set_var("PURGE_MAX_RUNS_PER_WORKFLOW", "500");
707            env::set_var("PURGE_DRY_RUN", "true");
708            env::set_var("PURGE_INTERVAL_SECS", "3600");
709        }
710
711        let config = ServerConfig::from_env().unwrap();
712        assert_eq!(config.purge_max_age_days, 30);
713        assert_eq!(config.purge_max_runs_per_workflow, 500);
714        assert!(config.purge_dry_run);
715        assert_eq!(config.purge_interval_secs, 3600);
716
717        unsafe {
718            env::remove_var("PURGE_MAX_AGE_DAYS");
719            env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
720            env::remove_var("PURGE_DRY_RUN");
721            env::remove_var("PURGE_INTERVAL_SECS");
722        }
723    }
724
725    #[test]
726    fn invalid_purge_max_age_days_returns_error() {
727        let _guard = ENV_LOCK.lock().unwrap();
728        unsafe {
729            clear_env();
730            env::set_var("PURGE_MAX_AGE_DAYS", "not-a-number");
731        }
732
733        let result = ServerConfig::from_env();
734        assert!(result.is_err());
735        let err = result.unwrap_err();
736        assert!(err.errors.iter().any(|e| e.contains("PURGE_MAX_AGE_DAYS")));
737
738        unsafe { env::remove_var("PURGE_MAX_AGE_DAYS") };
739    }
740
741    // Strong secrets: >= 32 bytes and not carrying the dev prefix.
742    const STRONG_JWT: &str = "prod-jwt-secret-0123456789abcdef0123456789";
743    const STRONG_WORKER: &str = "prod-worker-token-0123456789abcdef01234567";
744
745    /// Wipe the environment, then set production mode with the given secrets.
746    ///
747    /// # Safety
748    ///
749    /// Mutates process-global environment variables; must be called while
750    /// holding `ENV_LOCK` so no other test observes a torn environment.
751    unsafe fn setup_prod(jwt: &str, worker: &str) {
752        unsafe {
753            clear_env();
754            env::set_var("IRONFLOW_ENV", "production");
755            env::set_var("DATABASE_URL", "postgres://x");
756            env::set_var("JWT_SECRET", jwt);
757            env::set_var("WORKER_TOKEN", worker);
758        }
759    }
760
761    #[test]
762    fn from_env_production_rejects_known_dev_jwt_secret() {
763        let _guard = ENV_LOCK.lock().unwrap();
764        // "ironflow-dev-jwt-secret" is the exact value the setup template shipped.
765        // SAFETY: env writes serialized by ENV_LOCK, held above.
766        unsafe { setup_prod("ironflow-dev-jwt-secret", STRONG_WORKER) };
767
768        let err = ServerConfig::from_env().unwrap_err();
769        assert!(
770            err.errors
771                .iter()
772                .any(|e| e.contains("JWT_SECRET") && e.contains("known development default")),
773            "expected a known-default rejection for JWT_SECRET, got {:?}",
774            err.errors
775        );
776        assert!(
777            !err.errors.iter().any(|e| e.contains("WORKER_TOKEN")),
778            "a strong WORKER_TOKEN must not be flagged, got {:?}",
779            err.errors
780        );
781
782        unsafe { clear_env() };
783    }
784
785    #[test]
786    fn from_env_production_rejects_known_dev_worker_token() {
787        let _guard = ENV_LOCK.lock().unwrap();
788        // SAFETY: env writes serialized by ENV_LOCK, held above.
789        unsafe { setup_prod(STRONG_JWT, DEV_WORKER_TOKEN) };
790
791        let err = ServerConfig::from_env().unwrap_err();
792        assert!(
793            err.errors
794                .iter()
795                .any(|e| e.contains("WORKER_TOKEN") && e.contains("known development default")),
796            "expected a known-default rejection for WORKER_TOKEN, got {:?}",
797            err.errors
798        );
799        assert!(!err.errors.iter().any(|e| e.contains("JWT_SECRET")));
800
801        unsafe { clear_env() };
802    }
803
804    #[test]
805    fn from_env_production_rejects_short_jwt_secret() {
806        let _guard = ENV_LOCK.lock().unwrap();
807        // "shortsecret" is not a dev default, but is under 32 bytes.
808        // SAFETY: env writes serialized by ENV_LOCK, held above.
809        unsafe { setup_prod("shortsecret", STRONG_WORKER) };
810
811        let err = ServerConfig::from_env().unwrap_err();
812        assert!(
813            err.errors
814                .iter()
815                .any(|e| e.contains("JWT_SECRET") && e.contains("32 bytes")),
816            "expected a length rejection for JWT_SECRET, got {:?}",
817            err.errors
818        );
819
820        unsafe { clear_env() };
821    }
822
823    #[test]
824    fn from_env_production_rejects_short_worker_token() {
825        let _guard = ENV_LOCK.lock().unwrap();
826        // SAFETY: env writes serialized by ENV_LOCK, held above.
827        unsafe { setup_prod(STRONG_JWT, "tinytoken") };
828
829        let err = ServerConfig::from_env().unwrap_err();
830        assert!(
831            err.errors
832                .iter()
833                .any(|e| e.contains("WORKER_TOKEN") && e.contains("32 bytes")),
834            "expected a length rejection for WORKER_TOKEN, got {:?}",
835            err.errors
836        );
837
838        unsafe { clear_env() };
839    }
840
841    #[test]
842    fn from_env_production_accepts_strong_secrets() {
843        let _guard = ENV_LOCK.lock().unwrap();
844        // SAFETY: env writes serialized by ENV_LOCK, held above.
845        unsafe { setup_prod(STRONG_JWT, STRONG_WORKER) };
846
847        let config = ServerConfig::from_env().expect("strong secrets should be accepted");
848        assert!(config.is_production);
849        assert_eq!(config.jwt_secret, STRONG_JWT);
850        assert_eq!(config.worker_token, STRONG_WORKER);
851
852        unsafe { clear_env() };
853    }
854
855    #[test]
856    fn from_env_production_secret_length_boundary() {
857        // Exactly 32 bytes is accepted; 31 is rejected. Guards the `<` in the
858        // length check against an off-by-one drift to `<=`.
859        let exactly_32: &str = "0123456789abcdef0123456789abcdef";
860        let just_under: &str = "0123456789abcdef0123456789abcde";
861        assert_eq!(exactly_32.len(), 32);
862        assert_eq!(just_under.len(), 31);
863
864        let _guard = ENV_LOCK.lock().unwrap();
865
866        // SAFETY: env writes serialized by ENV_LOCK, held above.
867        unsafe { setup_prod(exactly_32, STRONG_WORKER) };
868        let config = ServerConfig::from_env().expect("a 32-byte secret is accepted");
869        assert_eq!(config.jwt_secret, exactly_32);
870
871        // SAFETY: ENV_LOCK still held.
872        unsafe { setup_prod(just_under, STRONG_WORKER) };
873        let err = ServerConfig::from_env().unwrap_err();
874        assert!(
875            err.errors
876                .iter()
877                .any(|e| e.contains("JWT_SECRET") && e.contains("32 bytes")),
878            "31 bytes must be rejected, got {:?}",
879            err.errors
880        );
881
882        unsafe { clear_env() };
883    }
884
885    #[test]
886    fn from_env_development_accepts_weak_secret() {
887        // The value/length checks run in production only: a short, dev-prefixed
888        // secret set outside production is accepted and used unchanged.
889        let _guard = ENV_LOCK.lock().unwrap();
890        // SAFETY: env writes serialized by ENV_LOCK, held above.
891        unsafe {
892            clear_env();
893            env::set_var("IRONFLOW_ENV", "development");
894            env::set_var("JWT_SECRET", "ironflow-dev-jwt-secret");
895            env::set_var("WORKER_TOKEN", "tinytoken");
896        }
897
898        let config = ServerConfig::from_env().expect("weak secrets are allowed outside production");
899        assert!(!config.is_production);
900        assert_eq!(config.jwt_secret, "ironflow-dev-jwt-secret");
901        assert_eq!(config.worker_token, "tinytoken");
902
903        unsafe { clear_env() };
904    }
905
906    #[test]
907    fn from_env_production_lists_all_insecure_secrets() {
908        // Non-regression: the exact state a fresh deploy reached by copying the
909        // setup template `.env.example` verbatim and flipping IRONFLOW_ENV to
910        // production. It used to boot silently; it must now be refused, with
911        // both secrets named.
912        let _guard = ENV_LOCK.lock().unwrap();
913        // SAFETY: env writes serialized by ENV_LOCK, held above.
914        unsafe { setup_prod("ironflow-dev-jwt-secret", "ironflow-dev-worker-token") };
915
916        let err = ServerConfig::from_env().unwrap_err();
917        assert!(
918            err.errors.iter().any(|e| e.contains("JWT_SECRET")),
919            "JWT_SECRET must be listed, got {:?}",
920            err.errors
921        );
922        assert!(
923            err.errors.iter().any(|e| e.contains("WORKER_TOKEN")),
924            "WORKER_TOKEN must be listed, got {:?}",
925            err.errors
926        );
927
928        unsafe { clear_env() };
929    }
930}