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