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 dashboard: DashboardConfig,
30 #[serde(default)]
31 pub logging: LoggingConfig,
32 #[serde(default = "default_engine_events_ttl_secs")]
35 pub engine_events_ttl_secs: u64,
36 #[serde(default)]
43 pub auto_enable_modules: Vec<String>,
44}
45
46fn default_engine_events_ttl_secs() -> u64 {
47 3 * 86_400
48}
49
50#[derive(Clone, Debug, Deserialize, Serialize)]
51#[non_exhaustive]
52pub struct ServerConfig {
53 #[serde(default = "default_bind_addr")]
54 pub bind_addr: String,
55 #[serde(default = "default_public_url")]
61 pub public_url: String,
62 #[serde(default)]
66 pub allowed_hosts: Vec<String>,
67}
68
69impl Default for ServerConfig {
70 fn default() -> Self {
71 Self {
72 bind_addr: default_bind_addr(),
73 public_url: default_public_url(),
74 allowed_hosts: Vec::new(),
75 }
76 }
77}
78
79fn default_bind_addr() -> String {
80 "0.0.0.0:3000".to_string()
81}
82
83fn default_public_url() -> String {
84 "http://localhost:3000".to_string()
85}
86
87#[derive(Clone, Debug, Deserialize, Serialize)]
88#[serde(tag = "type", rename_all = "lowercase")]
89#[non_exhaustive]
90pub enum BackendConfig {
91 Postgres {
92 url: String,
95 },
96 Sqlite {
97 #[serde(default = "default_data_dir")]
103 data_dir: String,
104 #[serde(default)]
109 path: Option<String>,
110 },
111}
112
113fn default_data_dir() -> String {
114 "./data".to_string()
115}
116
117impl BackendConfig {
118 pub fn sqlite_data_dir(&self) -> Option<String> {
120 match self {
121 Self::Sqlite { data_dir, path } => {
122 if let Some(p) = path {
126 let parent = std::path::Path::new(p)
127 .parent()
128 .map(|p| p.display().to_string())
129 .filter(|s| !s.is_empty());
130 Some(parent.unwrap_or_else(|| data_dir.clone()))
131 } else {
132 Some(data_dir.clone())
133 }
134 }
135 Self::Postgres { .. } => None,
136 }
137 }
138}
139
140#[derive(Clone, Debug, Default, Deserialize, Serialize)]
141#[non_exhaustive]
142pub struct WorkflowConfig {
143 #[serde(default = "default_true")]
144 pub enabled: bool,
145}
146
147#[derive(Clone, Debug, Default, Deserialize, Serialize)]
151#[non_exhaustive]
152pub struct AuthConfig {
153 pub public_url: Option<String>,
157 pub issuer: Option<String>,
161 #[serde(default)]
164 pub audience: Vec<String>,
165 #[serde(default)]
166 pub session: AuthSessionConfig,
167 #[serde(default)]
168 pub passkey: AuthPasskeyConfig,
169 #[serde(default)]
170 pub recovery: AuthRecoveryConfig,
171 #[serde(default)]
172 pub oidc_provider: AuthOidcProviderConfig,
173 #[serde(default)]
179 pub admin_api_keys: Vec<String>,
180 #[serde(default)]
198 external_issuers: Vec<ExternalIssuerConfig>,
199}
200
201impl AuthConfig {
202 pub fn external_issuers(&self) -> &[ExternalIssuerConfig] {
204 &self.external_issuers
205 }
206}
207
208#[derive(Clone, Debug, Default, Deserialize, Serialize)]
210#[non_exhaustive]
211pub struct ExternalIssuerConfig {
212 pub issuer_url: String,
216 #[serde(default)]
220 pub audience: Vec<String>,
221 #[serde(default = "default_jwks_refresh_secs")]
225 pub jwks_refresh_secs: u64,
226}
227
228fn default_jwks_refresh_secs() -> u64 {
229 3600
230}
231
232#[derive(Clone, Debug, Default, Deserialize, Serialize)]
234#[non_exhaustive]
235pub struct AuthSessionConfig {
236 pub ttl_seconds: Option<u64>,
239}
240
241#[derive(Clone, Debug, Default, Deserialize, Serialize)]
243#[non_exhaustive]
244pub struct AuthPasskeyConfig {
245 pub rp_id: Option<String>,
249 pub rp_name: Option<String>,
251}
252
253#[derive(Clone, Debug, Deserialize, Serialize)]
255#[non_exhaustive]
256pub struct AuthRecoveryConfig {
257 #[serde(default)]
259 pub enabled: bool,
260 #[serde(default = "default_recovery_token_ttl_seconds")]
262 pub token_ttl_seconds: u64,
263 #[serde(default = "default_recovery_cooldown_seconds")]
265 pub request_cooldown_seconds: u64,
266 pub smtp: Option<AuthSmtpConfig>,
268}
269
270impl Default for AuthRecoveryConfig {
271 fn default() -> Self {
272 Self {
273 enabled: false,
274 token_ttl_seconds: default_recovery_token_ttl_seconds(),
275 request_cooldown_seconds: default_recovery_cooldown_seconds(),
276 smtp: None,
277 }
278 }
279}
280
281fn default_recovery_token_ttl_seconds() -> u64 {
282 15 * 60
283}
284
285fn default_recovery_cooldown_seconds() -> u64 {
286 60
287}
288
289#[derive(Clone, Debug, Deserialize, Serialize)]
291#[non_exhaustive]
292pub struct AuthSmtpConfig {
293 pub host: String,
294 #[serde(default = "default_smtp_port")]
295 pub port: u16,
296 pub username: String,
297 pub password: String,
298 pub from: String,
299 #[serde(default = "default_true")]
300 pub starttls: bool,
301}
302
303fn default_smtp_port() -> u16 {
304 587
305}
306
307#[derive(Clone, Debug, Default, Deserialize, Serialize)]
309#[non_exhaustive]
310pub struct AuthOidcProviderConfig {
311 #[serde(default = "default_true")]
314 pub enabled: bool,
315 pub issuer_override: Option<String>,
318 #[serde(default = "default_true")]
330 pub auto_provision: bool,
331}
332
333#[derive(Clone, Debug, Deserialize, Serialize)]
334#[non_exhaustive]
335pub struct DashboardConfig {
336 #[serde(default = "default_true")]
337 pub enabled: bool,
338 pub operator_enabled: Option<bool>,
341 pub auth_ui_enabled: Option<bool>,
344}
345
346impl Default for DashboardConfig {
347 fn default() -> Self {
348 Self {
354 enabled: true,
355 operator_enabled: None,
356 auth_ui_enabled: None,
357 }
358 }
359}
360
361impl DashboardConfig {
362 pub fn operator_enabled(&self) -> bool {
363 self.operator_enabled.unwrap_or(self.enabled)
364 }
365
366 pub fn auth_ui_enabled(&self) -> bool {
367 self.auth_ui_enabled.unwrap_or(self.enabled)
368 }
369}
370
371#[derive(Clone, Debug, Deserialize, Serialize)]
372#[non_exhaustive]
373pub struct LoggingConfig {
374 #[serde(default = "default_log_level")]
375 pub level: String,
376 #[serde(default = "default_log_format")]
377 pub format: String,
378}
379
380impl Default for LoggingConfig {
381 fn default() -> Self {
382 Self {
383 level: default_log_level(),
384 format: default_log_format(),
385 }
386 }
387}
388
389fn default_true() -> bool {
390 true
391}
392
393fn default_log_level() -> String {
394 "info".to_string()
395}
396
397fn default_log_format() -> String {
398 "pretty".to_string()
399}
400
401impl EngineConfig {
402 pub fn from_file(path: &Path) -> anyhow::Result<Self> {
408 let raw = std::fs::read_to_string(path)
409 .map_err(|e| anyhow::anyhow!("read config {}: {e}", path.display()))?;
410 let expanded = expand_env_vars(&raw, |name| std::env::var(name).ok())
411 .map_err(|e| anyhow::anyhow!("expand env vars in {}: {e}", path.display()))?;
412 let cfg: Self = toml::from_str(&expanded)
413 .map_err(|e| anyhow::anyhow!("parse config {}: {e}", path.display()))?;
414 Ok(cfg)
415 }
416}
417
418fn expand_env_vars<F>(raw: &str, lookup: F) -> anyhow::Result<String>
430where
431 F: Fn(&str) -> Option<String>,
432{
433 let mut out = String::with_capacity(raw.len());
434 let mut rest = raw;
435 while let Some(idx) = rest.find("${") {
436 out.push_str(&rest[..idx]);
437 let after_open = &rest[idx + 2..];
438 let close_idx = after_open
439 .find('}')
440 .ok_or_else(|| anyhow::anyhow!("unclosed `${{` in config"))?;
441 let inner = &after_open[..close_idx];
442 let (var_name, default) = match inner.split_once(":-") {
443 Some((n, d)) => (n, Some(d)),
444 None => (inner, None),
445 };
446 if !is_valid_var_name(var_name) {
447 out.push_str("${");
449 out.push_str(inner);
450 out.push('}');
451 } else {
452 match lookup(var_name) {
453 Some(val) => out.push_str(&val),
454 None => match default {
455 Some(def) => out.push_str(def),
456 None => {
457 return Err(anyhow::anyhow!(
458 "env var `{}` is not set and has no default",
459 var_name
460 ));
461 }
462 },
463 }
464 }
465 rest = &after_open[close_idx + 1..];
466 }
467 out.push_str(rest);
468 Ok(out)
469}
470
471fn is_valid_var_name(s: &str) -> bool {
472 let mut chars = s.chars();
473 match chars.next() {
474 Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
475 _ => return false,
476 }
477 chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483
484 fn lookup_from<'a>(map: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
485 move |name: &str| {
486 map.iter()
487 .find(|(k, _)| *k == name)
488 .map(|(_, v)| (*v).to_string())
489 }
490 }
491
492 #[test]
493 fn no_substitution_passes_through() {
494 let s = "plain string with $literal but no expansion markers";
495 assert_eq!(expand_env_vars(s, lookup_from(&[])).unwrap(), s);
496 }
497
498 #[test]
499 fn substitutes_set_var() {
500 let out = expand_env_vars("value=${FOO}", lookup_from(&[("FOO", "hello")])).unwrap();
501 assert_eq!(out, "value=hello");
502 }
503
504 #[test]
505 fn errors_on_unset_var_with_no_default() {
506 let err = expand_env_vars("${MISSING}", lookup_from(&[])).unwrap_err();
507 assert!(err.to_string().contains("MISSING"));
508 }
509
510 #[test]
511 fn falls_back_to_default_when_unset() {
512 let out = expand_env_vars("${MISSING:-fallback}", lookup_from(&[])).unwrap();
513 assert_eq!(out, "fallback");
514 }
515
516 #[test]
517 fn ignores_default_when_var_set() {
518 let out = expand_env_vars("${FOO:-fallback}", lookup_from(&[("FOO", "actual")])).unwrap();
519 assert_eq!(out, "actual");
520 }
521
522 #[test]
523 fn empty_default_yields_empty_string() {
524 let out = expand_env_vars("[${MISSING:-}]", lookup_from(&[])).unwrap();
525 assert_eq!(out, "[]");
526 }
527
528 #[test]
529 fn substitutes_multiple_vars_in_one_string() {
530 let out = expand_env_vars(
531 "postgres://u:p@${HOST}:${PORT}/x",
532 lookup_from(&[("HOST", "db.example.com"), ("PORT", "5432")]),
533 )
534 .unwrap();
535 assert_eq!(out, "postgres://u:p@db.example.com:5432/x");
536 }
537
538 #[test]
539 fn dollar_without_braces_passes_through() {
540 let s = "$HOME and $USER stay literal";
543 let out = expand_env_vars(s, lookup_from(&[])).unwrap();
544 assert_eq!(out, s);
545 }
546
547 #[test]
548 fn invalid_identifier_passes_through_verbatim() {
549 let s = "${1NOT_VALID}";
551 assert_eq!(expand_env_vars(s, lookup_from(&[])).unwrap(), s);
552 }
553
554 #[test]
555 fn unclosed_brace_errors() {
556 let err = expand_env_vars("${UNCLOSED", lookup_from(&[])).unwrap_err();
557 assert!(err.to_string().contains("unclosed"));
558 }
559
560 #[test]
561 fn substitutes_inside_toml_string_values() {
562 let toml_input = r#"
563[backend]
564type = "postgres"
565url = "${DB}"
566"#;
567 let expanded =
568 expand_env_vars(toml_input, lookup_from(&[("DB", "postgres://u:p@h/d")])).unwrap();
569 assert!(expanded.contains(r#"url = "postgres://u:p@h/d""#));
570 }
571
572 #[test]
573 fn is_valid_var_name_accepts_typical_names() {
574 assert!(is_valid_var_name("DATABASE_URL"));
575 assert!(is_valid_var_name("_PRIVATE"));
576 assert!(is_valid_var_name("X"));
577 assert!(is_valid_var_name("X1"));
578 }
579
580 #[test]
581 fn is_valid_var_name_rejects_bad_names() {
582 assert!(!is_valid_var_name(""));
583 assert!(!is_valid_var_name("1LEADING_DIGIT"));
584 assert!(!is_valid_var_name("HAS SPACE"));
585 assert!(!is_valid_var_name("HAS-DASH"));
586 assert!(!is_valid_var_name("HAS.DOT"));
587 }
588
589 #[test]
590 fn from_file_loads_static_toml() {
591 let path = std::env::temp_dir().join("assay-engine-config-from-file-static.toml");
595 std::fs::write(
596 &path,
597 r#"
598[server]
599bind_addr = "127.0.0.1:3000"
600
601[backend]
602type = "sqlite"
603data_dir = "/tmp/assay-engine-test-data-static"
604"#,
605 )
606 .unwrap();
607 let cfg = EngineConfig::from_file(&path).unwrap();
608 let _ = std::fs::remove_file(&path);
609 match cfg.backend {
610 BackendConfig::Sqlite { ref data_dir, .. } => {
611 assert_eq!(data_dir, "/tmp/assay-engine-test-data-static");
612 }
613 _ => panic!("expected sqlite backend"),
614 }
615 }
616
617 #[test]
618 fn password_recovery_is_disabled_by_default() {
619 let cfg: EngineConfig = toml::from_str(
620 r#"
621[server]
622bind_addr = "127.0.0.1:3000"
623
624[backend]
625type = "sqlite"
626data_dir = ":memory:"
627"#,
628 )
629 .unwrap();
630
631 assert!(!cfg.auth.recovery.enabled);
632 assert_eq!(cfg.auth.recovery.token_ttl_seconds, 900);
633 assert_eq!(cfg.auth.recovery.request_cooldown_seconds, 60);
634 assert!(cfg.auth.recovery.smtp.is_none());
635 }
636
637 #[test]
638 fn password_recovery_smtp_configuration_deserializes() {
639 let cfg: EngineConfig = toml::from_str(
640 r#"
641[server]
642bind_addr = "127.0.0.1:3000"
643
644[backend]
645type = "sqlite"
646data_dir = ":memory:"
647
648[auth.recovery]
649enabled = true
650token_ttl_seconds = 1200
651request_cooldown_seconds = 90
652
653[auth.recovery.smtp]
654host = "smtp.example.com"
655port = 587
656username = "mailer"
657password = "secret"
658from = "Example Auth <noreply@example.com>"
659starttls = true
660"#,
661 )
662 .unwrap();
663
664 assert!(cfg.auth.recovery.enabled);
665 assert_eq!(cfg.auth.recovery.token_ttl_seconds, 1200);
666 assert_eq!(cfg.auth.recovery.request_cooldown_seconds, 90);
667 let smtp = cfg.auth.recovery.smtp.unwrap();
668 assert_eq!(smtp.host, "smtp.example.com");
669 assert_eq!(smtp.port, 587);
670 assert_eq!(smtp.username, "mailer");
671 assert_eq!(smtp.password, "secret");
672 assert_eq!(smtp.from, "Example Auth <noreply@example.com>");
673 assert!(smtp.starttls);
674 }
675
676 #[test]
677 fn flagship_host_and_dashboard_boundaries_deserialize() {
678 let cfg: EngineConfig = toml::from_str(
679 r#"
680[server]
681bind_addr = "127.0.0.1:3000"
682allowed_hosts = ["auth.assay.rs", "engine.assay.rs"]
683
684[backend]
685type = "sqlite"
686data_dir = ":memory:"
687
688[dashboard]
689enabled = true
690operator_enabled = false
691auth_ui_enabled = true
692"#,
693 )
694 .unwrap();
695
696 assert_eq!(
697 cfg.server.allowed_hosts,
698 ["auth.assay.rs", "engine.assay.rs"]
699 );
700 assert!(!cfg.dashboard.operator_enabled());
701 assert!(cfg.dashboard.auth_ui_enabled());
702 }
703
704 #[test]
705 fn dashboard_surface_flags_preserve_the_legacy_enabled_default() {
706 let dashboard = DashboardConfig::default();
707 assert!(dashboard.operator_enabled());
708 assert!(dashboard.auth_ui_enabled());
709 assert!(ServerConfig::default().allowed_hosts.is_empty());
710 }
711}