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