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