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