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::{Arc, Mutex};
489    use std::time::Duration;
490
491    use ironflow_store::memory::InMemoryStore;
492
493    use crate::purger::{DEFAULT_SIGNAL_RETENTION_DAYS, RunPurger};
494
495    use super::*;
496
497    // Env var mutations are not thread-safe -- serialize all tests that touch them.
498    static ENV_LOCK: Mutex<()> = Mutex::new(());
499
500    /// # Safety
501    ///
502    /// Must be called while holding `ENV_LOCK`.
503    unsafe fn clear_env() {
504        unsafe {
505            env::remove_var("IRONFLOW_ENV");
506            env::remove_var("DATABASE_URL");
507            env::remove_var("JWT_SECRET");
508            env::remove_var("WORKER_TOKEN");
509            env::remove_var("PORT");
510            env::remove_var("ALLOWED_ORIGINS");
511            env::remove_var("DASHBOARD_DIR");
512            env::remove_var("WEBHOOK_URL");
513            env::remove_var("RATE_LIMIT_AUTH");
514            env::remove_var("RATE_LIMIT_GENERAL");
515            env::remove_var("PURGE_MAX_AGE_DAYS");
516            env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
517            env::remove_var("PURGE_DRY_RUN");
518            env::remove_var("PURGE_INTERVAL_SECS");
519            env::remove_var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS");
520            env::remove_var("SIGNAL_RETENTION_DAYS");
521            env::remove_var("ARTIFACT_BACKEND");
522            env::remove_var("ARTIFACT_S3_BUCKET");
523            env::remove_var("ARTIFACT_S3_REGION");
524            env::remove_var("ARTIFACT_S3_ENDPOINT");
525            env::remove_var("ARTIFACT_S3_PREFIX");
526            env::remove_var("ARTIFACT_GC_INTERVAL_SECS");
527            env::remove_var("ARTIFACT_GC_GRACE_DAYS");
528            env::remove_var("ARTIFACT_GC_DRY_RUN");
529        }
530    }
531
532    #[test]
533    fn config_error_display_lists_all_errors() {
534        let err = ConfigError::new(vec![
535            "JWT_SECRET is required".to_string(),
536            "DATABASE_URL is required".to_string(),
537        ]);
538        let msg = err.to_string();
539        assert!(msg.contains("JWT_SECRET"));
540        assert!(msg.contains("DATABASE_URL"));
541        assert!(msg.contains("configuration errors:"));
542    }
543
544    #[test]
545    fn config_error_is_std_error() {
546        let err = ConfigError::new(vec!["test".to_string()]);
547        let _: &dyn std::error::Error = &err;
548    }
549
550    #[test]
551    fn default_dev_config_succeeds() {
552        let _guard = ENV_LOCK.lock().unwrap();
553        unsafe { clear_env() };
554
555        let config = ServerConfig::from_env().expect("dev config should succeed");
556        assert!(!config.is_production);
557        assert_eq!(config.port, 3000);
558        assert_eq!(config.jwt_secret, DEV_JWT_SECRET);
559        assert_eq!(config.worker_token, DEV_WORKER_TOKEN);
560    }
561
562    #[test]
563    fn production_without_secrets_fails() {
564        let _guard = ENV_LOCK.lock().unwrap();
565        unsafe {
566            clear_env();
567            env::set_var("IRONFLOW_ENV", "production");
568        }
569
570        let result = ServerConfig::from_env();
571        assert!(result.is_err());
572        let err = result.unwrap_err();
573        assert!(err.errors.len() >= 3);
574        assert!(err.errors.iter().any(|e| e.contains("DATABASE_URL")));
575        assert!(err.errors.iter().any(|e| e.contains("JWT_SECRET")));
576        assert!(err.errors.iter().any(|e| e.contains("WORKER_TOKEN")));
577
578        unsafe { env::remove_var("IRONFLOW_ENV") };
579    }
580
581    #[test]
582    fn invalid_port_returns_error() {
583        let _guard = ENV_LOCK.lock().unwrap();
584        unsafe {
585            clear_env();
586            env::set_var("PORT", "not-a-number");
587        }
588
589        let result = ServerConfig::from_env();
590        assert!(result.is_err());
591        let err = result.unwrap_err();
592        assert!(err.errors.iter().any(|e| e.contains("PORT")));
593
594        unsafe { env::remove_var("PORT") };
595    }
596
597    #[test]
598    fn default_rate_limits() {
599        let _guard = ENV_LOCK.lock().unwrap();
600        unsafe { clear_env() };
601
602        let config = ServerConfig::from_env().unwrap();
603        assert_eq!(config.rate_limit_auth, Some(10));
604        assert_eq!(config.rate_limit_general, Some(60));
605    }
606
607    #[test]
608    fn custom_rate_limits() {
609        let _guard = ENV_LOCK.lock().unwrap();
610        unsafe {
611            clear_env();
612            env::set_var("RATE_LIMIT_AUTH", "20");
613            env::set_var("RATE_LIMIT_GENERAL", "120");
614        }
615
616        let config = ServerConfig::from_env().unwrap();
617        assert_eq!(config.rate_limit_auth, Some(20));
618        assert_eq!(config.rate_limit_general, Some(120));
619
620        unsafe {
621            env::remove_var("RATE_LIMIT_AUTH");
622            env::remove_var("RATE_LIMIT_GENERAL");
623        }
624    }
625
626    #[test]
627    fn zero_rate_limit_disables() {
628        let _guard = ENV_LOCK.lock().unwrap();
629        unsafe {
630            clear_env();
631            env::set_var("RATE_LIMIT_AUTH", "0");
632            env::set_var("RATE_LIMIT_GENERAL", "0");
633        }
634
635        let config = ServerConfig::from_env().unwrap();
636        assert!(config.rate_limit_auth.is_none());
637        assert!(config.rate_limit_general.is_none());
638
639        unsafe {
640            env::remove_var("RATE_LIMIT_AUTH");
641            env::remove_var("RATE_LIMIT_GENERAL");
642        }
643    }
644
645    #[test]
646    fn invalid_rate_limit_returns_error() {
647        let _guard = ENV_LOCK.lock().unwrap();
648        unsafe {
649            clear_env();
650            env::set_var("RATE_LIMIT_AUTH", "not-a-number");
651        }
652
653        let result = ServerConfig::from_env();
654        assert!(result.is_err());
655        let err = result.unwrap_err();
656        assert!(err.errors.iter().any(|e| e.contains("RATE_LIMIT_AUTH")));
657
658        unsafe { env::remove_var("RATE_LIMIT_AUTH") };
659    }
660
661    #[test]
662    fn default_purge_config() {
663        let _guard = ENV_LOCK.lock().unwrap();
664        unsafe { clear_env() };
665
666        let config = ServerConfig::from_env().unwrap();
667        assert_eq!(config.purge_max_age_days, 90);
668        assert_eq!(config.purge_max_runs_per_workflow, 1000);
669        assert!(!config.purge_dry_run);
670        assert_eq!(config.purge_interval_secs, 86400);
671        assert_eq!(config.provider_account_usage_retention_days, 30);
672        assert_eq!(config.signal_retention_days, 7);
673    }
674
675    #[test]
676    fn provider_account_usage_retention_days_from_env() {
677        let _guard = ENV_LOCK.lock().unwrap();
678        // SAFETY: env writes serialized by ENV_LOCK, held above.
679        unsafe { clear_env() };
680        unsafe { env::set_var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS", "7") };
681        let config = ServerConfig::from_env().unwrap();
682        assert_eq!(config.provider_account_usage_retention_days, 7);
683
684        unsafe { env::set_var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS", "0") };
685        assert!(ServerConfig::from_env().is_err());
686        unsafe { clear_env() };
687    }
688
689    #[test]
690    fn signal_retention_days_from_env() {
691        let _guard = ENV_LOCK.lock().unwrap();
692        // SAFETY: env writes serialized by ENV_LOCK, held above.
693        unsafe { clear_env() };
694        unsafe { env::set_var("SIGNAL_RETENTION_DAYS", "3") };
695        let config = ServerConfig::from_env().unwrap();
696        assert_eq!(config.signal_retention_days, 3);
697
698        unsafe { env::set_var("SIGNAL_RETENTION_DAYS", "0") };
699        assert!(ServerConfig::from_env().is_err());
700        unsafe { env::set_var("SIGNAL_RETENTION_DAYS", "soon") };
701        assert!(ServerConfig::from_env().is_err());
702        unsafe { clear_env() };
703    }
704
705    #[test]
706    fn purger_from_config_uses_env_values() {
707        let _guard = ENV_LOCK.lock().unwrap();
708        unsafe {
709            clear_env();
710            env::set_var("SIGNAL_RETENTION_DAYS", "1");
711            env::set_var("PURGE_MAX_AGE_DAYS", "30");
712            env::set_var("PURGE_MAX_RUNS_PER_WORKFLOW", "50");
713            env::set_var("PURGE_DRY_RUN", "true");
714            env::set_var("PURGE_INTERVAL_SECS", "3600");
715            env::set_var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS", "5");
716        }
717        let config = ServerConfig::from_env().unwrap();
718        unsafe { clear_env() };
719
720        let purger = RunPurger::from_config(Arc::new(InMemoryStore::new()), &config);
721        assert_eq!(purger.signal_retention_days, 1);
722        assert_eq!(purger.usage_retention_days, 5);
723        assert_eq!(purger.policy.max_age_days, 30);
724        assert_eq!(purger.policy.max_runs_per_workflow, 50);
725        assert!(purger.policy.dry_run);
726        assert_eq!(purger.interval, Duration::from_secs(3600));
727    }
728
729    #[test]
730    fn purger_from_config_defaults() {
731        let _guard = ENV_LOCK.lock().unwrap();
732        unsafe { clear_env() };
733        let config = ServerConfig::from_env().unwrap();
734
735        let purger = RunPurger::from_config(Arc::new(InMemoryStore::new()), &config);
736        assert_eq!(purger.signal_retention_days, DEFAULT_SIGNAL_RETENTION_DAYS);
737        assert_eq!(purger.signal_retention_days, 7);
738    }
739
740    #[test]
741    fn custom_purge_config() {
742        let _guard = ENV_LOCK.lock().unwrap();
743        unsafe {
744            clear_env();
745            env::set_var("PURGE_MAX_AGE_DAYS", "30");
746            env::set_var("PURGE_MAX_RUNS_PER_WORKFLOW", "500");
747            env::set_var("PURGE_DRY_RUN", "true");
748            env::set_var("PURGE_INTERVAL_SECS", "3600");
749        }
750
751        let config = ServerConfig::from_env().unwrap();
752        assert_eq!(config.purge_max_age_days, 30);
753        assert_eq!(config.purge_max_runs_per_workflow, 500);
754        assert!(config.purge_dry_run);
755        assert_eq!(config.purge_interval_secs, 3600);
756
757        unsafe {
758            env::remove_var("PURGE_MAX_AGE_DAYS");
759            env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
760            env::remove_var("PURGE_DRY_RUN");
761            env::remove_var("PURGE_INTERVAL_SECS");
762        }
763    }
764
765    #[test]
766    fn invalid_purge_max_age_days_returns_error() {
767        let _guard = ENV_LOCK.lock().unwrap();
768        unsafe {
769            clear_env();
770            env::set_var("PURGE_MAX_AGE_DAYS", "not-a-number");
771        }
772
773        let result = ServerConfig::from_env();
774        assert!(result.is_err());
775        let err = result.unwrap_err();
776        assert!(err.errors.iter().any(|e| e.contains("PURGE_MAX_AGE_DAYS")));
777
778        unsafe { env::remove_var("PURGE_MAX_AGE_DAYS") };
779    }
780
781    // Strong secrets: >= 32 bytes and not carrying the dev prefix.
782    const STRONG_JWT: &str = "prod-jwt-secret-0123456789abcdef0123456789";
783    const STRONG_WORKER: &str = "prod-worker-token-0123456789abcdef01234567";
784
785    /// Wipe the environment, then set production mode with the given secrets.
786    ///
787    /// # Safety
788    ///
789    /// Mutates process-global environment variables; must be called while
790    /// holding `ENV_LOCK` so no other test observes a torn environment.
791    unsafe fn setup_prod(jwt: &str, worker: &str) {
792        unsafe {
793            clear_env();
794            env::set_var("IRONFLOW_ENV", "production");
795            env::set_var("DATABASE_URL", "postgres://x");
796            env::set_var("JWT_SECRET", jwt);
797            env::set_var("WORKER_TOKEN", worker);
798        }
799    }
800
801    #[test]
802    fn from_env_production_rejects_known_dev_jwt_secret() {
803        let _guard = ENV_LOCK.lock().unwrap();
804        // "ironflow-dev-jwt-secret" is the exact value the setup template shipped.
805        // SAFETY: env writes serialized by ENV_LOCK, held above.
806        unsafe { setup_prod("ironflow-dev-jwt-secret", STRONG_WORKER) };
807
808        let err = ServerConfig::from_env().unwrap_err();
809        assert!(
810            err.errors
811                .iter()
812                .any(|e| e.contains("JWT_SECRET") && e.contains("known development default")),
813            "expected a known-default rejection for JWT_SECRET, got {:?}",
814            err.errors
815        );
816        assert!(
817            !err.errors.iter().any(|e| e.contains("WORKER_TOKEN")),
818            "a strong WORKER_TOKEN must not be flagged, got {:?}",
819            err.errors
820        );
821
822        unsafe { clear_env() };
823    }
824
825    #[test]
826    fn from_env_production_rejects_known_dev_worker_token() {
827        let _guard = ENV_LOCK.lock().unwrap();
828        // SAFETY: env writes serialized by ENV_LOCK, held above.
829        unsafe { setup_prod(STRONG_JWT, DEV_WORKER_TOKEN) };
830
831        let err = ServerConfig::from_env().unwrap_err();
832        assert!(
833            err.errors
834                .iter()
835                .any(|e| e.contains("WORKER_TOKEN") && e.contains("known development default")),
836            "expected a known-default rejection for WORKER_TOKEN, got {:?}",
837            err.errors
838        );
839        assert!(!err.errors.iter().any(|e| e.contains("JWT_SECRET")));
840
841        unsafe { clear_env() };
842    }
843
844    #[test]
845    fn from_env_production_rejects_short_jwt_secret() {
846        let _guard = ENV_LOCK.lock().unwrap();
847        // "shortsecret" is not a dev default, but is under 32 bytes.
848        // SAFETY: env writes serialized by ENV_LOCK, held above.
849        unsafe { setup_prod("shortsecret", STRONG_WORKER) };
850
851        let err = ServerConfig::from_env().unwrap_err();
852        assert!(
853            err.errors
854                .iter()
855                .any(|e| e.contains("JWT_SECRET") && e.contains("32 bytes")),
856            "expected a length rejection for JWT_SECRET, got {:?}",
857            err.errors
858        );
859
860        unsafe { clear_env() };
861    }
862
863    #[test]
864    fn from_env_production_rejects_short_worker_token() {
865        let _guard = ENV_LOCK.lock().unwrap();
866        // SAFETY: env writes serialized by ENV_LOCK, held above.
867        unsafe { setup_prod(STRONG_JWT, "tinytoken") };
868
869        let err = ServerConfig::from_env().unwrap_err();
870        assert!(
871            err.errors
872                .iter()
873                .any(|e| e.contains("WORKER_TOKEN") && e.contains("32 bytes")),
874            "expected a length rejection for WORKER_TOKEN, got {:?}",
875            err.errors
876        );
877
878        unsafe { clear_env() };
879    }
880
881    #[test]
882    fn from_env_production_accepts_strong_secrets() {
883        let _guard = ENV_LOCK.lock().unwrap();
884        // SAFETY: env writes serialized by ENV_LOCK, held above.
885        unsafe { setup_prod(STRONG_JWT, STRONG_WORKER) };
886
887        let config = ServerConfig::from_env().expect("strong secrets should be accepted");
888        assert!(config.is_production);
889        assert_eq!(config.jwt_secret, STRONG_JWT);
890        assert_eq!(config.worker_token, STRONG_WORKER);
891
892        unsafe { clear_env() };
893    }
894
895    #[test]
896    fn from_env_production_secret_length_boundary() {
897        // Exactly 32 bytes is accepted; 31 is rejected. Guards the `<` in the
898        // length check against an off-by-one drift to `<=`.
899        let exactly_32: &str = "0123456789abcdef0123456789abcdef";
900        let just_under: &str = "0123456789abcdef0123456789abcde";
901        assert_eq!(exactly_32.len(), 32);
902        assert_eq!(just_under.len(), 31);
903
904        let _guard = ENV_LOCK.lock().unwrap();
905
906        // SAFETY: env writes serialized by ENV_LOCK, held above.
907        unsafe { setup_prod(exactly_32, STRONG_WORKER) };
908        let config = ServerConfig::from_env().expect("a 32-byte secret is accepted");
909        assert_eq!(config.jwt_secret, exactly_32);
910
911        // SAFETY: ENV_LOCK still held.
912        unsafe { setup_prod(just_under, STRONG_WORKER) };
913        let err = ServerConfig::from_env().unwrap_err();
914        assert!(
915            err.errors
916                .iter()
917                .any(|e| e.contains("JWT_SECRET") && e.contains("32 bytes")),
918            "31 bytes must be rejected, got {:?}",
919            err.errors
920        );
921
922        unsafe { clear_env() };
923    }
924
925    #[test]
926    fn from_env_development_accepts_weak_secret() {
927        // The value/length checks run in production only: a short, dev-prefixed
928        // secret set outside production is accepted and used unchanged.
929        let _guard = ENV_LOCK.lock().unwrap();
930        // SAFETY: env writes serialized by ENV_LOCK, held above.
931        unsafe {
932            clear_env();
933            env::set_var("IRONFLOW_ENV", "development");
934            env::set_var("JWT_SECRET", "ironflow-dev-jwt-secret");
935            env::set_var("WORKER_TOKEN", "tinytoken");
936        }
937
938        let config = ServerConfig::from_env().expect("weak secrets are allowed outside production");
939        assert!(!config.is_production);
940        assert_eq!(config.jwt_secret, "ironflow-dev-jwt-secret");
941        assert_eq!(config.worker_token, "tinytoken");
942
943        unsafe { clear_env() };
944    }
945
946    #[test]
947    fn from_env_production_lists_all_insecure_secrets() {
948        // Non-regression: the exact state a fresh deploy reached by copying the
949        // setup template `.env.example` verbatim and flipping IRONFLOW_ENV to
950        // production. It used to boot silently; it must now be refused, with
951        // both secrets named.
952        let _guard = ENV_LOCK.lock().unwrap();
953        // SAFETY: env writes serialized by ENV_LOCK, held above.
954        unsafe { setup_prod("ironflow-dev-jwt-secret", "ironflow-dev-worker-token") };
955
956        let err = ServerConfig::from_env().unwrap_err();
957        assert!(
958            err.errors.iter().any(|e| e.contains("JWT_SECRET")),
959            "JWT_SECRET must be listed, got {:?}",
960            err.errors
961        );
962        assert!(
963            err.errors.iter().any(|e| e.contains("WORKER_TOKEN")),
964            "WORKER_TOKEN must be listed, got {:?}",
965            err.errors
966        );
967
968        unsafe { clear_env() };
969    }
970}