1mod 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#[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 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 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 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 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 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 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 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 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 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 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}