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