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