1use serde::{Deserialize, Serialize};
17use std::path::Path;
18
19#[derive(Clone, Debug, Deserialize, Serialize)]
20#[non_exhaustive]
21pub struct EngineConfig {
22 pub server: ServerConfig,
23 pub backend: BackendConfig,
24 #[serde(default)]
25 pub workflow: WorkflowConfig,
26 #[serde(default)]
27 pub auth: AuthConfig,
28 #[serde(default)]
29 pub vault: VaultConfig,
30 #[serde(default)]
31 pub dashboard: DashboardConfig,
32 #[serde(default)]
33 pub logging: LoggingConfig,
34 #[serde(default = "default_engine_events_ttl_secs")]
37 pub engine_events_ttl_secs: u64,
38 #[serde(default)]
45 pub auto_enable_modules: Vec<String>,
46}
47
48fn default_engine_events_ttl_secs() -> u64 {
49 3 * 86_400
50}
51
52#[derive(Clone, Debug, Deserialize, Serialize)]
53#[non_exhaustive]
54pub struct ServerConfig {
55 #[serde(default = "default_bind_addr")]
56 pub bind_addr: String,
57 #[serde(default = "default_public_url")]
63 pub public_url: String,
64 #[serde(default)]
68 pub allowed_hosts: Vec<String>,
69}
70
71impl Default for ServerConfig {
72 fn default() -> Self {
73 Self {
74 bind_addr: default_bind_addr(),
75 public_url: default_public_url(),
76 allowed_hosts: Vec::new(),
77 }
78 }
79}
80
81fn default_bind_addr() -> String {
82 "0.0.0.0:3000".to_string()
83}
84
85fn default_public_url() -> String {
86 "http://localhost:3000".to_string()
87}
88
89#[derive(Clone, Debug, Deserialize, Serialize)]
90#[serde(tag = "type", rename_all = "lowercase")]
91#[non_exhaustive]
92pub enum BackendConfig {
93 Postgres {
94 url: String,
97 },
98 Sqlite {
99 #[serde(default = "default_data_dir")]
105 data_dir: String,
106 #[serde(default)]
111 path: Option<String>,
112 },
113}
114
115fn default_data_dir() -> String {
116 "./data".to_string()
117}
118
119impl BackendConfig {
120 pub fn sqlite_data_dir(&self) -> Option<String> {
122 match self {
123 Self::Sqlite { data_dir, path } => {
124 if let Some(p) = path {
128 let parent = std::path::Path::new(p)
129 .parent()
130 .map(|p| p.display().to_string())
131 .filter(|s| !s.is_empty());
132 Some(parent.unwrap_or_else(|| data_dir.clone()))
133 } else {
134 Some(data_dir.clone())
135 }
136 }
137 Self::Postgres { .. } => None,
138 }
139 }
140}
141
142#[derive(Clone, Debug, Default, Deserialize, Serialize)]
143#[non_exhaustive]
144pub struct WorkflowConfig {
145 #[serde(default = "default_true")]
146 pub enabled: bool,
147}
148
149#[derive(Clone, Debug, Default, Deserialize, Serialize)]
153#[non_exhaustive]
154pub struct AuthConfig {
155 pub public_url: Option<String>,
159 pub issuer: Option<String>,
163 #[serde(default)]
166 pub audience: Vec<String>,
167 #[serde(default)]
168 pub session: AuthSessionConfig,
169 #[serde(default)]
170 pub passkey: AuthPasskeyConfig,
171 #[serde(default)]
172 pub recovery: AuthRecoveryConfig,
173 #[serde(default)]
174 pub oidc_provider: AuthOidcProviderConfig,
175 #[serde(default)]
181 pub admin_api_keys: Vec<String>,
182 #[serde(default)]
200 external_issuers: Vec<ExternalIssuerConfig>,
201}
202
203impl AuthConfig {
204 pub fn external_issuers(&self) -> &[ExternalIssuerConfig] {
206 &self.external_issuers
207 }
208}
209
210#[derive(Clone, Debug, Default, Deserialize, Serialize)]
212#[non_exhaustive]
213pub struct ExternalIssuerConfig {
214 pub issuer_url: String,
218 #[serde(default)]
222 pub audience: Vec<String>,
223 #[serde(default = "default_jwks_refresh_secs")]
227 pub jwks_refresh_secs: u64,
228}
229
230fn default_jwks_refresh_secs() -> u64 {
231 3600
232}
233
234#[derive(Clone, Debug, Default, Deserialize, Serialize)]
236#[non_exhaustive]
237pub struct AuthSessionConfig {
238 pub ttl_seconds: Option<u64>,
241}
242
243#[derive(Clone, Debug, Default, Deserialize, Serialize)]
245#[non_exhaustive]
246pub struct AuthPasskeyConfig {
247 pub rp_id: Option<String>,
251 pub rp_name: Option<String>,
253}
254
255#[derive(Clone, Debug, Deserialize, Serialize)]
257#[non_exhaustive]
258pub struct AuthRecoveryConfig {
259 #[serde(default)]
261 pub enabled: bool,
262 #[serde(default = "default_recovery_token_ttl_seconds")]
264 pub token_ttl_seconds: u64,
265 #[serde(default = "default_recovery_cooldown_seconds")]
267 pub request_cooldown_seconds: u64,
268 pub smtp: Option<AuthSmtpConfig>,
270}
271
272impl Default for AuthRecoveryConfig {
273 fn default() -> Self {
274 Self {
275 enabled: false,
276 token_ttl_seconds: default_recovery_token_ttl_seconds(),
277 request_cooldown_seconds: default_recovery_cooldown_seconds(),
278 smtp: None,
279 }
280 }
281}
282
283fn default_recovery_token_ttl_seconds() -> u64 {
284 15 * 60
285}
286
287fn default_recovery_cooldown_seconds() -> u64 {
288 60
289}
290
291#[derive(Clone, Debug, Deserialize, Serialize)]
293#[non_exhaustive]
294pub struct AuthSmtpConfig {
295 pub host: String,
296 #[serde(default = "default_smtp_port")]
297 pub port: u16,
298 pub username: String,
299 pub password: String,
300 pub from: String,
301 #[serde(default = "default_true")]
302 pub starttls: bool,
303}
304
305fn default_smtp_port() -> u16 {
306 587
307}
308
309#[derive(Clone, Debug, Default, Deserialize, Serialize)]
311#[non_exhaustive]
312pub struct AuthOidcProviderConfig {
313 #[serde(default = "default_true")]
316 pub enabled: bool,
317 pub issuer_override: Option<String>,
320 #[serde(default = "default_true")]
332 pub auto_provision: bool,
333}
334
335#[derive(Clone, Debug, Default, Deserialize, Serialize)]
336#[non_exhaustive]
337pub struct VaultConfig {
338 #[serde(default)]
339 pub hashicorp_compat: HashicorpCompatConfig,
340}
341
342#[derive(Clone, Debug, Deserialize, Serialize)]
346#[non_exhaustive]
347pub struct HashicorpCompatConfig {
348 #[serde(default)]
349 pub enabled: bool,
350 #[serde(default = "default_vault_compat_mount")]
353 pub mount: String,
354}
355
356impl Default for HashicorpCompatConfig {
357 fn default() -> Self {
358 Self {
359 enabled: false,
360 mount: default_vault_compat_mount(),
361 }
362 }
363}
364
365fn default_vault_compat_mount() -> String {
366 "secrets".to_string()
367}
368
369#[derive(Clone, Debug, Deserialize, Serialize)]
370#[non_exhaustive]
371pub struct DashboardConfig {
372 #[serde(default = "default_true")]
373 pub enabled: bool,
374 pub operator_enabled: Option<bool>,
377 pub auth_ui_enabled: Option<bool>,
380}
381
382impl Default for DashboardConfig {
383 fn default() -> Self {
384 Self {
390 enabled: true,
391 operator_enabled: None,
392 auth_ui_enabled: None,
393 }
394 }
395}
396
397impl DashboardConfig {
398 pub fn operator_enabled(&self) -> bool {
399 self.operator_enabled.unwrap_or(self.enabled)
400 }
401
402 pub fn auth_ui_enabled(&self) -> bool {
403 self.auth_ui_enabled.unwrap_or(self.enabled)
404 }
405}
406
407#[derive(Clone, Debug, Deserialize, Serialize)]
408#[non_exhaustive]
409pub struct LoggingConfig {
410 #[serde(default = "default_log_level")]
411 pub level: String,
412 #[serde(default = "default_log_format")]
413 pub format: String,
414}
415
416impl Default for LoggingConfig {
417 fn default() -> Self {
418 Self {
419 level: default_log_level(),
420 format: default_log_format(),
421 }
422 }
423}
424
425fn default_true() -> bool {
426 true
427}
428
429fn default_log_level() -> String {
430 "info".to_string()
431}
432
433fn default_log_format() -> String {
434 "pretty".to_string()
435}
436
437impl EngineConfig {
438 pub fn from_file(path: &Path) -> anyhow::Result<Self> {
444 let raw = std::fs::read_to_string(path)
445 .map_err(|e| anyhow::anyhow!("read config {}: {e}", path.display()))?;
446 let expanded = expand_env_vars(&raw, |name| std::env::var(name).ok())
447 .map_err(|e| anyhow::anyhow!("expand env vars in {}: {e}", path.display()))?;
448 let cfg: Self = toml::from_str(&expanded)
449 .map_err(|e| anyhow::anyhow!("parse config {}: {e}", path.display()))?;
450 Ok(cfg)
451 }
452}
453
454fn expand_env_vars<F>(raw: &str, lookup: F) -> anyhow::Result<String>
466where
467 F: Fn(&str) -> Option<String>,
468{
469 let mut out = String::with_capacity(raw.len());
470 let mut rest = raw;
471 while let Some(idx) = rest.find("${") {
472 out.push_str(&rest[..idx]);
473 let after_open = &rest[idx + 2..];
474 let close_idx = after_open
475 .find('}')
476 .ok_or_else(|| anyhow::anyhow!("unclosed `${{` in config"))?;
477 let inner = &after_open[..close_idx];
478 let (var_name, default) = match inner.split_once(":-") {
479 Some((n, d)) => (n, Some(d)),
480 None => (inner, None),
481 };
482 if !is_valid_var_name(var_name) {
483 out.push_str("${");
485 out.push_str(inner);
486 out.push('}');
487 } else {
488 match lookup(var_name) {
489 Some(val) => out.push_str(&val),
490 None => match default {
491 Some(def) => out.push_str(def),
492 None => {
493 return Err(anyhow::anyhow!(
494 "env var `{}` is not set and has no default",
495 var_name
496 ));
497 }
498 },
499 }
500 }
501 rest = &after_open[close_idx + 1..];
502 }
503 out.push_str(rest);
504 Ok(out)
505}
506
507fn is_valid_var_name(s: &str) -> bool {
508 let mut chars = s.chars();
509 match chars.next() {
510 Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
511 _ => return false,
512 }
513 chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519
520 fn lookup_from<'a>(map: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
521 move |name: &str| {
522 map.iter()
523 .find(|(k, _)| *k == name)
524 .map(|(_, v)| (*v).to_string())
525 }
526 }
527
528 #[test]
529 fn no_substitution_passes_through() {
530 let s = "plain string with $literal but no expansion markers";
531 assert_eq!(expand_env_vars(s, lookup_from(&[])).unwrap(), s);
532 }
533
534 #[test]
535 fn substitutes_set_var() {
536 let out = expand_env_vars("value=${FOO}", lookup_from(&[("FOO", "hello")])).unwrap();
537 assert_eq!(out, "value=hello");
538 }
539
540 #[test]
541 fn errors_on_unset_var_with_no_default() {
542 let err = expand_env_vars("${MISSING}", lookup_from(&[])).unwrap_err();
543 assert!(err.to_string().contains("MISSING"));
544 }
545
546 #[test]
547 fn falls_back_to_default_when_unset() {
548 let out = expand_env_vars("${MISSING:-fallback}", lookup_from(&[])).unwrap();
549 assert_eq!(out, "fallback");
550 }
551
552 #[test]
553 fn ignores_default_when_var_set() {
554 let out = expand_env_vars("${FOO:-fallback}", lookup_from(&[("FOO", "actual")])).unwrap();
555 assert_eq!(out, "actual");
556 }
557
558 #[test]
559 fn empty_default_yields_empty_string() {
560 let out = expand_env_vars("[${MISSING:-}]", lookup_from(&[])).unwrap();
561 assert_eq!(out, "[]");
562 }
563
564 #[test]
565 fn substitutes_multiple_vars_in_one_string() {
566 let out = expand_env_vars(
567 "postgres://u:p@${HOST}:${PORT}/x",
568 lookup_from(&[("HOST", "db.example.com"), ("PORT", "5432")]),
569 )
570 .unwrap();
571 assert_eq!(out, "postgres://u:p@db.example.com:5432/x");
572 }
573
574 #[test]
575 fn dollar_without_braces_passes_through() {
576 let s = "$HOME and $USER stay literal";
579 let out = expand_env_vars(s, lookup_from(&[])).unwrap();
580 assert_eq!(out, s);
581 }
582
583 #[test]
584 fn invalid_identifier_passes_through_verbatim() {
585 let s = "${1NOT_VALID}";
587 assert_eq!(expand_env_vars(s, lookup_from(&[])).unwrap(), s);
588 }
589
590 #[test]
591 fn unclosed_brace_errors() {
592 let err = expand_env_vars("${UNCLOSED", lookup_from(&[])).unwrap_err();
593 assert!(err.to_string().contains("unclosed"));
594 }
595
596 #[test]
597 fn substitutes_inside_toml_string_values() {
598 let toml_input = r#"
599[backend]
600type = "postgres"
601url = "${DB}"
602"#;
603 let expanded =
604 expand_env_vars(toml_input, lookup_from(&[("DB", "postgres://u:p@h/d")])).unwrap();
605 assert!(expanded.contains(r#"url = "postgres://u:p@h/d""#));
606 }
607
608 #[test]
609 fn is_valid_var_name_accepts_typical_names() {
610 assert!(is_valid_var_name("DATABASE_URL"));
611 assert!(is_valid_var_name("_PRIVATE"));
612 assert!(is_valid_var_name("X"));
613 assert!(is_valid_var_name("X1"));
614 }
615
616 #[test]
617 fn is_valid_var_name_rejects_bad_names() {
618 assert!(!is_valid_var_name(""));
619 assert!(!is_valid_var_name("1LEADING_DIGIT"));
620 assert!(!is_valid_var_name("HAS SPACE"));
621 assert!(!is_valid_var_name("HAS-DASH"));
622 assert!(!is_valid_var_name("HAS.DOT"));
623 }
624
625 #[test]
626 fn from_file_loads_static_toml() {
627 let path = std::env::temp_dir().join("assay-engine-config-from-file-static.toml");
631 std::fs::write(
632 &path,
633 r#"
634[server]
635bind_addr = "127.0.0.1:3000"
636
637[backend]
638type = "sqlite"
639data_dir = "/tmp/assay-engine-test-data-static"
640"#,
641 )
642 .unwrap();
643 let cfg = EngineConfig::from_file(&path).unwrap();
644 let _ = std::fs::remove_file(&path);
645 match cfg.backend {
646 BackendConfig::Sqlite { ref data_dir, .. } => {
647 assert_eq!(data_dir, "/tmp/assay-engine-test-data-static");
648 }
649 _ => panic!("expected sqlite backend"),
650 }
651 }
652
653 fn minimal_config_with(sections: &str) -> EngineConfig {
654 let base = r#"
655[server]
656bind_addr = "127.0.0.1:3000"
657
658[backend]
659type = "sqlite"
660data_dir = ":memory:"
661"#;
662 toml::from_str(&format!("{base}{sections}")).unwrap()
663 }
664
665 #[test]
666 fn the_vault_compat_facade_is_off_until_an_operator_asks_for_it() {
667 let cfg = minimal_config_with("");
668
669 assert!(!cfg.vault.hashicorp_compat.enabled);
670 assert_eq!(cfg.vault.hashicorp_compat.mount, "secrets");
671 }
672
673 #[test]
674 fn the_vault_compat_mount_is_operator_selectable() {
675 let cfg = minimal_config_with(
676 r#"
677[vault.hashicorp_compat]
678enabled = true
679mount = "kv"
680"#,
681 );
682
683 assert!(cfg.vault.hashicorp_compat.enabled);
684 assert_eq!(cfg.vault.hashicorp_compat.mount, "kv");
685 }
686
687 #[test]
688 fn password_recovery_is_disabled_by_default() {
689 let cfg: EngineConfig = toml::from_str(
690 r#"
691[server]
692bind_addr = "127.0.0.1:3000"
693
694[backend]
695type = "sqlite"
696data_dir = ":memory:"
697"#,
698 )
699 .unwrap();
700
701 assert!(!cfg.auth.recovery.enabled);
702 assert_eq!(cfg.auth.recovery.token_ttl_seconds, 900);
703 assert_eq!(cfg.auth.recovery.request_cooldown_seconds, 60);
704 assert!(cfg.auth.recovery.smtp.is_none());
705 }
706
707 #[test]
708 fn password_recovery_smtp_configuration_deserializes() {
709 let cfg: EngineConfig = toml::from_str(
710 r#"
711[server]
712bind_addr = "127.0.0.1:3000"
713
714[backend]
715type = "sqlite"
716data_dir = ":memory:"
717
718[auth.recovery]
719enabled = true
720token_ttl_seconds = 1200
721request_cooldown_seconds = 90
722
723[auth.recovery.smtp]
724host = "smtp.example.com"
725port = 587
726username = "mailer"
727password = "secret"
728from = "Example Auth <noreply@example.com>"
729starttls = true
730"#,
731 )
732 .unwrap();
733
734 assert!(cfg.auth.recovery.enabled);
735 assert_eq!(cfg.auth.recovery.token_ttl_seconds, 1200);
736 assert_eq!(cfg.auth.recovery.request_cooldown_seconds, 90);
737 let smtp = cfg.auth.recovery.smtp.unwrap();
738 assert_eq!(smtp.host, "smtp.example.com");
739 assert_eq!(smtp.port, 587);
740 assert_eq!(smtp.username, "mailer");
741 assert_eq!(smtp.password, "secret");
742 assert_eq!(smtp.from, "Example Auth <noreply@example.com>");
743 assert!(smtp.starttls);
744 }
745
746 #[test]
747 fn flagship_host_and_dashboard_boundaries_deserialize() {
748 let cfg: EngineConfig = toml::from_str(
749 r#"
750[server]
751bind_addr = "127.0.0.1:3000"
752allowed_hosts = ["auth.assay.rs", "engine.assay.rs"]
753
754[backend]
755type = "sqlite"
756data_dir = ":memory:"
757
758[dashboard]
759enabled = true
760operator_enabled = false
761auth_ui_enabled = true
762"#,
763 )
764 .unwrap();
765
766 assert_eq!(
767 cfg.server.allowed_hosts,
768 ["auth.assay.rs", "engine.assay.rs"]
769 );
770 assert!(!cfg.dashboard.operator_enabled());
771 assert!(cfg.dashboard.auth_ui_enabled());
772 }
773
774 #[test]
775 fn dashboard_surface_flags_preserve_the_legacy_enabled_default() {
776 let dashboard = DashboardConfig::default();
777 assert!(dashboard.operator_enabled());
778 assert!(dashboard.auth_ui_enabled());
779 assert!(ServerConfig::default().allowed_hosts.is_empty());
780 }
781}