Skip to main content

forge_core/config/
mod.rs

1//! Configuration types for the Forge framework.
2//!
3//! Each config section lives in its own sub-module for locality. The root
4//! [`ForgeConfig`] struct ties them together and owns parsing, env-var
5//! substitution, and cross-field validation.
6
7mod auth;
8pub mod cluster;
9mod cron_config;
10mod daemon_config;
11mod database;
12mod function;
13mod gateway;
14pub(crate) mod loader;
15mod mcp_config;
16mod node;
17mod observability;
18mod project;
19mod rate_limit;
20mod realtime_config;
21mod security;
22pub mod signals;
23pub mod types;
24mod worker;
25mod workflow_config;
26
27pub use auth::{AuthConfig, JwtAlgorithm, LegacySecret};
28pub use cluster::ClusterConfig;
29pub use cron_config::CronConfig;
30pub use daemon_config::DaemonConfig;
31pub use database::DatabaseConfig;
32pub use function::FunctionConfig;
33pub use gateway::{GatewayConfig, TlsConfig};
34pub use mcp_config::McpConfig;
35pub use node::{NodeConfig, NodeRole};
36pub use observability::ObservabilityConfig;
37pub use project::ProjectConfig;
38pub use rate_limit::{RateLimitMode, RateLimitSettings};
39pub use realtime_config::RealtimeConfig;
40pub use security::SecurityConfig;
41pub use signals::SignalsConfig;
42pub use types::{DurationStr, SizeStr};
43pub use worker::{CRON_QUEUE, DEFAULT_QUEUE, QueueWorkerConfig, WORKFLOWS_QUEUE, WorkerConfig};
44pub use workflow_config::{SignatureCheckMode, WorkflowConfig};
45
46pub use loader::substitute_env_vars;
47
48use serde::{Deserialize, Serialize};
49use std::path::Path;
50
51use crate::error::{ForgeError, Result};
52
53/// Root configuration. Loaded from `forge.toml` with `${ENV_VAR}` / `${VAR-default}`
54/// substitution. Only `database.url` is required.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56#[non_exhaustive]
57pub struct ForgeConfig {
58    #[serde(default)]
59    pub project: ProjectConfig,
60
61    pub database: DatabaseConfig,
62
63    #[serde(default)]
64    pub node: NodeConfig,
65
66    #[serde(default)]
67    pub gateway: GatewayConfig,
68
69    #[serde(default)]
70    pub function: FunctionConfig,
71
72    #[serde(default)]
73    pub worker: WorkerConfig,
74
75    #[serde(default)]
76    pub workflow: WorkflowConfig,
77
78    #[serde(default)]
79    pub cron: CronConfig,
80
81    #[serde(default)]
82    pub daemon: DaemonConfig,
83
84    #[serde(default)]
85    pub cluster: ClusterConfig,
86
87    #[serde(default)]
88    pub security: SecurityConfig,
89
90    #[serde(default)]
91    pub auth: AuthConfig,
92
93    #[serde(default)]
94    pub observability: ObservabilityConfig,
95
96    #[serde(default)]
97    pub mcp: McpConfig,
98
99    #[serde(default)]
100    pub signals: SignalsConfig,
101
102    #[serde(default)]
103    pub rate_limit: RateLimitSettings,
104
105    #[serde(default)]
106    pub realtime: RealtimeConfig,
107
108    #[serde(default)]
109    pub email: crate::email::EmailConfig,
110}
111
112impl ForgeConfig {
113    /// Load from a TOML file.
114    pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
115        let content = std::fs::read_to_string(path.as_ref())
116            .map_err(|e| ForgeError::config_with("Failed to read config file", e))?;
117
118        Self::parse_toml(&content)
119    }
120
121    /// Parse from a TOML string.
122    pub fn parse_toml(content: &str) -> Result<Self> {
123        let content = loader::substitute_env_vars(content);
124
125        let config: Self = toml::from_str(&content)
126            .map_err(|e| ForgeError::config_with("Failed to parse config", e))?;
127
128        config.validate()?;
129        Ok(config)
130    }
131
132    /// Validate cross-field constraints.
133    pub fn validate(&self) -> Result<()> {
134        self.database.validate()?;
135        self.auth.validate()?;
136        self.mcp.validate()?;
137        let body_limit = self.gateway.max_body_size.as_bytes();
138        let file_limit = self.gateway.max_file_size.as_bytes();
139        if file_limit > body_limit {
140            return Err(ForgeError::config(format!(
141                "gateway.max_file_size ({}) cannot exceed gateway.max_body_size ({})",
142                self.gateway.max_file_size, self.gateway.max_body_size
143            )));
144        }
145        self.gateway.tls.validate()?;
146
147        // Cross-field: OAuth requires jwt_secret for signing tokens
148        if self.mcp.oauth && self.auth.jwt_secret.is_none() {
149            return Err(ForgeError::config(
150                "mcp.oauth = true requires auth.jwt_secret to be set. \
151                 OAuth-issued tokens are signed with this secret, even when using \
152                 an external provider (JWKS) for identity verification.",
153            ));
154        }
155        if self.mcp.oauth && !self.mcp.enabled {
156            return Err(ForgeError::config(
157                "mcp.oauth = true requires mcp.enabled = true",
158            ));
159        }
160
161        if !self.gateway.cors_enabled && !self.gateway.cors_origins.is_empty() {
162            return Err(ForgeError::config(
163                "gateway.cors_origins is set but gateway.cors_enabled = false. \
164                 Set cors_enabled = true to activate CORS, or remove cors_origins.",
165            ));
166        }
167
168        if self.gateway.cors_enabled {
169            if self.gateway.cors_origins.is_empty() {
170                return Err(ForgeError::config(
171                    "gateway.cors_enabled = true requires at least one origin. \
172                     Use cors_origins = [\"*\"] to allow any origin.",
173                ));
174            }
175            // Wildcard mixed with concrete origins is ignored by browsers on
176            // credentialed requests and signals a misconfiguration.
177            let has_wildcard = self.gateway.cors_origins.iter().any(|o| o == "*");
178            let has_concrete = self.gateway.cors_origins.iter().any(|o| o != "*");
179            if has_wildcard && has_concrete {
180                return Err(ForgeError::config(
181                    "gateway.cors_origins cannot mix \"*\" with concrete origins. \
182                     Browsers ignore wildcards on credentialed requests.",
183                ));
184            }
185
186            for origin in &self.gateway.cors_origins {
187                if origin == "*" {
188                    continue;
189                }
190                if origin.bytes().any(|b| b < 32 || b == 127) {
191                    return Err(ForgeError::config(format!(
192                        "gateway.cors_origins contains invalid origin \"{origin}\". \
193                         Origins must be valid HTTP header values."
194                    )));
195                }
196                if !origin.starts_with("http://") && !origin.starts_with("https://") {
197                    return Err(ForgeError::config(format!(
198                        "gateway.cors_origins contains \"{origin}\" which is not a valid origin. \
199                         Origins must start with http:// or https://."
200                    )));
201                }
202            }
203        }
204
205        if self.gateway.max_multipart_fields < 1 {
206            return Err(ForgeError::config(
207                "gateway.max_multipart_fields must be at least 1",
208            ));
209        }
210
211        let quiet_ms = self.realtime.debounce_quiet_window.as_millis();
212        let max_ms = self.realtime.debounce_max_wait.as_millis();
213        if quiet_ms > max_ms {
214            return Err(ForgeError::config(format!(
215                "realtime.debounce_quiet_window ({}) cannot exceed \
216                 realtime.debounce_max_wait ({})",
217                self.realtime.debounce_quiet_window, self.realtime.debounce_max_wait
218            )));
219        }
220
221        for entry in &self.gateway.trusted_proxies {
222            if entry.parse::<std::net::IpAddr>().is_err() && entry.parse::<ipnet::IpNet>().is_err()
223            {
224                return Err(ForgeError::config(format!(
225                    "gateway.trusted_proxies contains invalid entry \"{entry}\". \
226                     Expected an IP address (e.g. \"10.0.0.1\") or CIDR range (e.g. \"10.0.0.0/8\")."
227                )));
228            }
229        }
230
231        Ok(())
232    }
233
234    /// Construct with defaults and the given database URL.
235    pub fn default_with_database_url(url: &str) -> Self {
236        Self {
237            project: ProjectConfig::default(),
238            database: DatabaseConfig::new(url),
239            node: NodeConfig::default(),
240            gateway: GatewayConfig::default(),
241            function: FunctionConfig::default(),
242            worker: WorkerConfig::default(),
243            workflow: WorkflowConfig::default(),
244            cron: CronConfig::default(),
245            daemon: DaemonConfig::default(),
246            cluster: ClusterConfig::default(),
247            security: SecurityConfig::default(),
248            auth: AuthConfig::default(),
249            observability: ObservabilityConfig::default(),
250            mcp: McpConfig::default(),
251            signals: SignalsConfig::default(),
252            rate_limit: RateLimitSettings::default(),
253            realtime: RealtimeConfig::default(),
254            email: crate::email::EmailConfig::default(),
255        }
256    }
257}
258
259pub(crate) fn default_true() -> bool {
260    true
261}
262
263#[cfg(test)]
264#[allow(clippy::unwrap_used, clippy::indexing_slicing, unsafe_code)]
265mod tests {
266    use std::time::Duration;
267
268    use super::*;
269
270    #[test]
271    fn test_default_config() {
272        let config = ForgeConfig::default_with_database_url("postgres://localhost/test");
273        assert_eq!(config.gateway.port, 9081);
274        assert_eq!(config.node.roles.len(), 4);
275        assert_eq!(config.mcp.path, "/mcp");
276        assert!(!config.mcp.enabled);
277    }
278
279    #[test]
280    fn test_parse_minimal_config() {
281        let toml = r#"
282            [database]
283            url = "postgres://localhost/myapp"
284        "#;
285
286        let config = ForgeConfig::parse_toml(toml).unwrap();
287        assert_eq!(config.database.url(), "postgres://localhost/myapp");
288        assert_eq!(config.gateway.port, 9081);
289    }
290
291    #[test]
292    fn test_parse_full_config() {
293        let toml = r#"
294            [project]
295            name = "my-app"
296            version = "1.0.0"
297
298            [database]
299            url = "postgres://localhost/myapp"
300            pool_size = 100
301
302            [node]
303            roles = ["gateway", "worker"]
304            worker_capabilities = ["media", "general"]
305
306            [gateway]
307            port = 3000
308            grpc_port = 9001
309        "#;
310
311        let config = ForgeConfig::parse_toml(toml).unwrap();
312        assert_eq!(config.project.name, "my-app");
313        assert_eq!(config.database.pool_size, 100);
314        assert_eq!(config.node.roles.len(), 2);
315        assert_eq!(config.gateway.port, 3000);
316    }
317
318    #[test]
319    fn test_env_var_substitution() {
320        unsafe {
321            std::env::set_var("TEST_DB_URL", "postgres://test:test@localhost/test");
322        }
323
324        let toml = r#"
325            [database]
326            url = "${TEST_DB_URL}"
327        "#;
328
329        let config = ForgeConfig::parse_toml(toml).unwrap();
330        assert_eq!(config.database.url(), "postgres://test:test@localhost/test");
331
332        unsafe {
333            std::env::remove_var("TEST_DB_URL");
334        }
335    }
336
337    #[test]
338    fn test_auth_validation_no_config() {
339        let auth = AuthConfig::default();
340        assert!(auth.validate().is_ok());
341    }
342
343    #[test]
344    fn test_auth_validation_hmac_with_secret() {
345        let auth = AuthConfig {
346            jwt_secret: Some("a-secret-long-enough-to-pass-the-32-byte-minimum".into()),
347            jwt_algorithm: JwtAlgorithm::HS256,
348            jwt_audience: Some("https://api.example.com".into()),
349            ..Default::default()
350        };
351        assert!(auth.validate().is_ok());
352    }
353
354    #[test]
355    fn test_auth_validation_hmac_missing_secret() {
356        let auth = AuthConfig {
357            jwt_issuer: Some("my-issuer".into()),
358            jwt_algorithm: JwtAlgorithm::HS256,
359            ..Default::default()
360        };
361        let result = auth.validate();
362        assert!(result.is_err());
363        let err_msg = result.unwrap_err().to_string();
364        assert!(err_msg.contains("jwt_secret is required"));
365    }
366
367    #[test]
368    fn test_auth_validation_rsa_with_jwks() {
369        let auth = AuthConfig {
370            jwks_url: Some("https://example.com/.well-known/jwks.json".into()),
371            jwt_algorithm: JwtAlgorithm::RS256,
372            jwt_audience: Some("https://api.example.com".into()),
373            ..Default::default()
374        };
375        assert!(auth.validate().is_ok());
376    }
377
378    #[test]
379    fn test_auth_validation_rsa_missing_jwks() {
380        let auth = AuthConfig {
381            jwt_issuer: Some("my-issuer".into()),
382            jwt_algorithm: JwtAlgorithm::RS256,
383            ..Default::default()
384        };
385        let result = auth.validate();
386        assert!(result.is_err());
387        let err_msg = result.unwrap_err().to_string();
388        assert!(err_msg.contains("jwks_url is required"));
389    }
390
391    #[test]
392    fn test_forge_config_validation_fails_on_empty_url() {
393        let toml = r#"
394            [database]
395
396            url = ""
397        "#;
398
399        let result = ForgeConfig::parse_toml(toml);
400        assert!(result.is_err());
401        let err_msg = result.unwrap_err().to_string();
402        assert!(err_msg.contains("database.url is required"));
403    }
404
405    #[test]
406    fn test_forge_config_validation_fails_on_invalid_auth() {
407        let toml = r#"
408            [database]
409
410            url = "postgres://localhost/test"
411
412            [auth]
413            jwt_issuer = "my-issuer"
414            jwt_algorithm = "RS256"
415        "#;
416
417        let result = ForgeConfig::parse_toml(toml);
418        assert!(result.is_err());
419        let err_msg = result.unwrap_err().to_string();
420        assert!(err_msg.contains("jwks_url is required"));
421    }
422
423    #[test]
424    fn test_observability_config_default_disabled() {
425        let toml = r#"
426            [database]
427            url = "postgres://localhost/test"
428        "#;
429
430        let config = ForgeConfig::parse_toml(toml).unwrap();
431        assert!(!config.observability.enabled);
432        assert!(!config.observability.otlp_active());
433    }
434
435    #[test]
436    fn test_observability_config_with_env_default() {
437        // Simulates what the template produces when no env vars are set
438        unsafe {
439            std::env::remove_var("TEST_OTEL_ENABLED");
440        }
441
442        let toml = r#"
443            [database]
444            url = "postgres://localhost/test"
445
446            [observability]
447            enabled = ${TEST_OTEL_ENABLED-false}
448        "#;
449
450        let config = ForgeConfig::parse_toml(toml).unwrap();
451        assert!(!config.observability.enabled);
452    }
453
454    #[test]
455    fn test_mcp_config_validation_rejects_invalid_path() {
456        let toml = r#"
457            [database]
458
459            url = "postgres://localhost/test"
460
461            [mcp]
462            enabled = true
463            path = "mcp"
464        "#;
465
466        let result = ForgeConfig::parse_toml(toml);
467        assert!(result.is_err());
468        let err_msg = result.unwrap_err().to_string();
469        assert!(err_msg.contains("mcp.path must start with '/'"));
470    }
471
472    #[test]
473    fn test_access_token_ttl_defaults() {
474        let auth = AuthConfig::default();
475        assert_eq!(auth.access_token_ttl_secs(), 3600);
476        assert_eq!(auth.refresh_token_ttl_days(), 30);
477    }
478
479    #[test]
480    fn test_access_token_ttl_custom() {
481        let auth = AuthConfig {
482            access_token_ttl: Some(DurationStr::new(Duration::from_secs(900))),
483            refresh_token_ttl: Some(DurationStr::new(Duration::from_secs(7 * 86400))),
484            ..Default::default()
485        };
486        assert_eq!(auth.access_token_ttl_secs(), 900);
487        assert_eq!(auth.refresh_token_ttl_days(), 7);
488    }
489
490    #[test]
491    fn test_access_token_ttl_minimum_enforced() {
492        let auth = AuthConfig {
493            access_token_ttl: Some(DurationStr::new(Duration::from_secs(0))),
494            ..Default::default()
495        };
496        // Should floor at 1, not 0
497        assert_eq!(auth.access_token_ttl_secs(), 1);
498    }
499
500    #[test]
501    fn test_refresh_token_ttl_minimum_enforced() {
502        let auth = AuthConfig {
503            refresh_token_ttl: Some(DurationStr::new(Duration::from_secs(3600))),
504            ..Default::default()
505        };
506        // 1 hour < 1 day, so should floor at 1 day
507        assert_eq!(auth.refresh_token_ttl_days(), 1);
508    }
509
510    #[test]
511    fn test_max_body_size_defaults() {
512        let gw = GatewayConfig::default();
513        assert_eq!(gw.max_body_size.as_bytes(), 20 * 1024 * 1024);
514    }
515
516    #[test]
517    fn test_max_body_size_custom() {
518        let gw = GatewayConfig {
519            max_body_size: SizeStr::new(100 * 1024 * 1024),
520            ..Default::default()
521        };
522        assert_eq!(gw.max_body_size.as_bytes(), 100 * 1024 * 1024);
523    }
524
525    #[test]
526    fn test_max_body_size_from_toml() {
527        let toml = r#"
528            [database]
529            url = "postgres://localhost/test"
530            [gateway]
531            max_body_size = "100mb"
532        "#;
533        let config = ForgeConfig::parse_toml(toml).unwrap();
534        assert_eq!(config.gateway.max_body_size.as_bytes(), 100 * 1024 * 1024);
535    }
536
537    #[test]
538    fn test_max_file_size_defaults() {
539        let gw = GatewayConfig::default();
540        assert_eq!(gw.max_file_size.as_bytes(), 10 * 1024 * 1024);
541    }
542
543    #[test]
544    fn test_max_file_size_from_toml() {
545        let toml = r#"
546            [database]
547            url = "postgres://localhost/test"
548            [gateway]
549            max_body_size = "500mb"
550            max_file_size = "200mb"
551        "#;
552        let config = ForgeConfig::parse_toml(toml).unwrap();
553        assert_eq!(config.gateway.max_file_size.as_bytes(), 200 * 1024 * 1024);
554    }
555
556    #[test]
557    fn test_validate_rejects_file_larger_than_body() {
558        let toml = r#"
559            [database]
560            url = "postgres://localhost/test"
561
562            [gateway]
563            max_body_size = "10mb"
564            max_file_size = "20mb"
565        "#;
566        let err = ForgeConfig::parse_toml(toml).unwrap_err().to_string();
567        assert!(
568            err.contains("max_file_size"),
569            "Expected max_file_size error, got: {err}"
570        );
571    }
572
573    #[test]
574    fn test_mcp_config_rejects_reserved_paths() {
575        for reserved in McpConfig::RESERVED_PATHS {
576            let toml = format!(
577                r#"
578                [database]
579                url = "postgres://localhost/test"
580
581                [mcp]
582                enabled = true
583                path = "{reserved}"
584                "#
585            );
586
587            let result = ForgeConfig::parse_toml(&toml);
588            assert!(result.is_err(), "Expected {reserved} to be rejected");
589            let err_msg = result.unwrap_err().to_string();
590            assert!(
591                err_msg.contains("conflicts with a reserved gateway route"),
592                "Wrong error for {reserved}: {err_msg}"
593            );
594        }
595    }
596
597    #[test]
598    fn test_tls_disabled_default() {
599        let config = ForgeConfig::default_with_database_url("postgres://localhost/test");
600        assert!(!config.gateway.tls.is_enabled());
601        assert!(config.gateway.tls.cert_path.is_none());
602        assert!(config.gateway.tls.key_path.is_none());
603        assert!(config.validate().is_ok());
604    }
605
606    #[test]
607    fn test_tls_file_based_valid() {
608        let toml = r#"
609            [database]
610            url = "postgres://localhost/test"
611
612            [gateway.tls]
613            cert_path = "/etc/forge/cert.pem"
614            key_path = "/etc/forge/key.pem"
615        "#;
616
617        let config = ForgeConfig::parse_toml(toml).unwrap();
618        assert!(config.gateway.tls.is_enabled());
619        assert_eq!(
620            config.gateway.tls.cert_path.as_deref(),
621            Some("/etc/forge/cert.pem")
622        );
623        assert_eq!(
624            config.gateway.tls.key_path.as_deref(),
625            Some("/etc/forge/key.pem")
626        );
627    }
628
629    #[test]
630    fn test_tls_only_cert_path_fails() {
631        let toml = r#"
632            [database]
633            url = "postgres://localhost/test"
634
635            [gateway.tls]
636            cert_path = "/etc/forge/cert.pem"
637        "#;
638
639        let result = ForgeConfig::parse_toml(toml);
640        assert!(result.is_err());
641        let err_msg = result.unwrap_err().to_string();
642        assert!(
643            err_msg.contains("key_path is missing"),
644            "Unexpected error: {err_msg}"
645        );
646    }
647
648    #[test]
649    fn test_tls_only_key_path_fails() {
650        let toml = r#"
651            [database]
652            url = "postgres://localhost/test"
653
654            [gateway.tls]
655            key_path = "/etc/forge/key.pem"
656        "#;
657
658        let result = ForgeConfig::parse_toml(toml);
659        assert!(result.is_err());
660        let err_msg = result.unwrap_err().to_string();
661        assert!(
662            err_msg.contains("cert_path is missing"),
663            "Unexpected error: {err_msg}"
664        );
665    }
666
667    #[test]
668    fn test_tls_empty_strings_normalize_to_off() {
669        // Env-var-driven deploys rely on `cert_path = "${FOO-}"` resolving
670        // to `cert_path = ""` when the variable is unset. That must be
671        // treated as "TLS off", not a validation error.
672        let toml = r#"
673            [database]
674            url = "postgres://localhost/test"
675
676            [gateway.tls]
677            cert_path = ""
678            key_path = ""
679        "#;
680
681        let config = ForgeConfig::parse_toml(toml).expect("empty strings should normalize");
682        assert!(!config.gateway.tls.is_enabled());
683        assert!(config.gateway.tls.cert_path.is_none());
684        assert!(config.gateway.tls.key_path.is_none());
685    }
686
687    #[test]
688    fn test_tls_empty_cert_with_set_key_fails_as_half_set() {
689        let toml = r#"
690            [database]
691            url = "postgres://localhost/test"
692
693            [gateway.tls]
694            cert_path = ""
695            key_path = "/etc/forge/key.pem"
696        "#;
697
698        let result = ForgeConfig::parse_toml(toml);
699        assert!(result.is_err());
700        let err_msg = result.unwrap_err().to_string();
701        assert!(
702            err_msg.contains("cert_path is missing"),
703            "Unexpected error: {err_msg}"
704        );
705    }
706
707    #[test]
708    fn jwt_secret_shorter_than_32_bytes_rejected() {
709        let auth = AuthConfig {
710            jwt_secret: Some("short".into()),
711            jwt_algorithm: JwtAlgorithm::HS256,
712            ..Default::default()
713        };
714        let err = auth.validate().unwrap_err().to_string();
715        assert!(err.contains("32 bytes"), "{err}");
716    }
717
718    #[test]
719    fn jwt_secret_32_bytes_accepted() {
720        let auth = AuthConfig {
721            jwt_secret: Some("a".repeat(32)),
722            jwt_algorithm: JwtAlgorithm::HS256,
723            jwt_audience: Some("https://api.example.com".into()),
724            ..Default::default()
725        };
726        assert!(auth.validate().is_ok());
727    }
728
729    #[test]
730    fn audience_required_fails_validate_when_missing() {
731        let auth = AuthConfig {
732            jwt_secret: Some("a-valid-32-byte-secret-for-tests!".into()),
733            jwt_audience: None,
734            audience_required: true,
735            ..Default::default()
736        };
737        let err = auth.validate().unwrap_err().to_string();
738        assert!(
739            err.contains("jwt_audience"),
740            "error should mention jwt_audience, got: {err}"
741        );
742    }
743
744    #[test]
745    fn audience_required_opt_out_passes_validate() {
746        let auth = AuthConfig {
747            jwt_secret: Some("a-valid-32-byte-secret-for-tests!".into()),
748            jwt_audience: None,
749            audience_required: false,
750            ..Default::default()
751        };
752        assert!(auth.validate().is_ok());
753    }
754
755    #[test]
756    fn cors_enabled_with_empty_origins_rejected() {
757        let toml = r#"
758            [database]
759            url = "postgres://localhost/test"
760            [gateway]
761            cors_enabled = true
762        "#;
763        let err = ForgeConfig::parse_toml(toml).unwrap_err().to_string();
764        assert!(err.contains("cors_enabled"), "{err}");
765    }
766
767    #[test]
768    fn cors_wildcard_only_accepted() {
769        let toml = r#"
770            [database]
771            url = "postgres://localhost/test"
772            [gateway]
773            cors_enabled = true
774            cors_origins = ["*"]
775        "#;
776        assert!(ForgeConfig::parse_toml(toml).is_ok());
777    }
778
779    #[test]
780    fn cors_mixed_wildcard_and_concrete_rejected() {
781        let toml = r#"
782            [database]
783            url = "postgres://localhost/test"
784            [gateway]
785            cors_enabled = true
786            cors_origins = ["*", "https://example.com"]
787        "#;
788        let err = ForgeConfig::parse_toml(toml).unwrap_err().to_string();
789        assert!(err.contains("cors_origins"), "{err}");
790    }
791
792    #[test]
793    fn cors_disabled_does_not_require_origins() {
794        let toml = r#"
795            [database]
796            url = "postgres://localhost/test"
797            [gateway]
798            cors_enabled = false
799        "#;
800        assert!(ForgeConfig::parse_toml(toml).is_ok());
801    }
802
803    #[test]
804    fn legacy_secrets_parse_with_valid_until_from_toml() {
805        let toml = r#"
806            [database]
807            url = "postgres://localhost/test"
808
809            [auth]
810            jwt_secret = "active-secret-key-32-bytes-pad!!"
811            jwt_audience = "https://api.example.com"
812
813            [[auth.legacy_secrets]]
814            secret = "retired-secret-key-32-bytes-pad!!"
815            valid_until = "2099-01-01T00:00:00Z"
816        "#;
817        let config = ForgeConfig::parse_toml(toml).unwrap();
818        assert_eq!(config.auth.legacy_secrets.len(), 1);
819        let entry = &config.auth.legacy_secrets[0];
820        assert_eq!(entry.secret, "retired-secret-key-32-bytes-pad!!");
821        assert_eq!(entry.valid_until.to_rfc3339(), "2099-01-01T00:00:00+00:00");
822    }
823
824    #[test]
825    fn realtime_quota_fields_parse_and_enforce() {
826        let toml = r#"
827            [database]
828            url = "postgres://localhost/test"
829            [realtime]
830            max_sessions_per_user = 4
831            max_sessions_per_ip = 16
832            max_subscriptions_per_user = 200
833            max_cached_result_bytes = 1048576
834        "#;
835        let config = ForgeConfig::parse_toml(toml).unwrap();
836        assert_eq!(config.realtime.max_sessions_per_user, 4);
837        assert_eq!(config.realtime.max_sessions_per_ip, 16);
838        assert_eq!(config.realtime.max_subscriptions_per_user, 200);
839        assert_eq!(config.realtime.max_cached_result_bytes, 1024 * 1024);
840    }
841}