Skip to main content

assay_engine/
config.rs

1//! Engine configuration loaded from TOML.
2//!
3//! Phase 8 wires in `AuthConfig` so the engine binary can compose an
4//! `assay_auth::AuthCtx` per-deployment (issuer, OIDC provider toggle,
5//! session/cookie shape). When `auth` isn't compiled in (Cargo feature
6//! off) the auth section is parsed but never read — keeping the TOML
7//! shape stable across feature configurations.
8//!
9//! Env-var substitution: `${VAR}` and `${VAR:-default}` references in
10//! the TOML are expanded against the process environment before parsing
11//! (added in 0.3.1). This keeps secrets out of config files when the
12//! engine runs under K8s/systemd/etc. — the typical pattern is
13//! `url = "${DATABASE_URL}"` with `DATABASE_URL` injected from a
14//! Secret/EnvironmentFile.
15
16use 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    /// TTL in seconds for the engine_events outbox. Rows older than this
33    /// are pruned hourly by the cleanup loop. Default 3 days.
34    #[serde(default = "default_engine_events_ttl_secs")]
35    pub engine_events_ttl_secs: u64,
36    /// Modules to flip from `enabled = FALSE` to `enabled = TRUE` on
37    /// first boot when they're compiled in. Empty by default — operators
38    /// of existing v0.1.2 deployments shouldn't get unexpected auth
39    /// migrations on upgrade. Local-dev convenience: set to
40    /// `["auth"]` in `engine.local.toml` to flip auth on without an
41    /// extra step.
42    #[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    /// Operator-supplied canonical URL for the engine API and dashboard.
56    /// Auth defaults to this origin too, but can use a dedicated hostname
57    /// through `auth.public_url`. Defaults to the bind address over plain
58    /// HTTP for local development; production deployments MUST override it
59    /// with the public HTTPS URL.
60    #[serde(default = "default_public_url")]
61    pub public_url: String,
62    /// Host header values accepted by the public server. Empty keeps the
63    /// embedded/local-development behavior and accepts every host. Health
64    /// checks remain reachable when this allowlist is populated.
65    #[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        /// Postgres connection URL, e.g. `postgres://user:pass@host:5432/db`.
93        /// PostgreSQL 18 is the minimum supported version.
94        url: String,
95    },
96    Sqlite {
97        /// Directory holding the per-module SQLite files
98        /// (`<data_dir>/engine.db`, `<data_dir>/workflow.db`, …). Created
99        /// on startup if missing. Defaults to `./data`. Use `:memory:`
100        /// in `path` (legacy) or set `data_dir = ":memory:"` to keep the
101        /// engine purely in-memory for tests.
102        #[serde(default = "default_data_dir")]
103        data_dir: String,
104        /// Legacy single-file SQLite path. Deprecated in v0.1.2 — when
105        /// set, the engine logs a deprecation notice and treats it as
106        /// `data_dir = parent(path)` so existing configs keep working
107        /// during the transition.
108        #[serde(default)]
109        path: Option<String>,
110    },
111}
112
113fn default_data_dir() -> String {
114    "./data".to_string()
115}
116
117impl BackendConfig {
118    /// Resolve the effective data directory for SQLite. PG returns `None`.
119    pub fn sqlite_data_dir(&self) -> Option<String> {
120        match self {
121            Self::Sqlite { data_dir, path } => {
122                // Legacy `path` wins for backwards compat — treat the
123                // parent dir as the new data_dir so existing v0.1.1
124                // configs migrate without surprise.
125                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/// Auth-module deployment shape. Read by the engine binary when the
148/// `auth` Cargo feature is compiled in AND `engine.modules.auth.enabled`
149/// is TRUE; otherwise the defaults are harmless.
150#[derive(Clone, Debug, Default, Deserialize, Serialize)]
151#[non_exhaustive]
152pub struct AuthConfig {
153    /// Canonical browser-facing origin for the auth surface. Defaults to
154    /// `server.public_url` when unset. This allows one engine deployment to
155    /// expose auth on a dedicated hostname without changing its API origin.
156    pub public_url: Option<String>,
157    /// JWT issuer + OIDC `iss` claim. Defaults to
158    /// `<auth.public_url>/auth` when unset, which matches the route
159    /// mount point.
160    pub issuer: Option<String>,
161    /// JWT audience list — also used by the OIDC provider when minting
162    /// access_tokens for resource servers. Defaults to `[issuer]`.
163    #[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    /// Admin API keys — comma-separated bearer tokens that grant access
174    /// to `/admin/*` routes. Operators rotate these via the engine
175    /// config. Per-token, no expiry; for fancier admin auth (Zanzibar
176    /// roles, session-based admin) see plan 12c § 6.7. Empty list locks
177    /// admin routes entirely (404 → 401).
178    #[serde(default)]
179    pub admin_api_keys: Vec<String>,
180    /// External OIDC issuers trusted to mint JWTs the engine accepts
181    /// pass-through (v0.3.2). Each entry's JWKS is discovered via
182    /// `<issuer_url>/.well-known/openid-configuration` at boot and
183    /// refreshed periodically thereafter. Tokens whose `iss` claim
184    /// matches a configured issuer are verified against that issuer's
185    /// keys; everything else falls through to the engine's internal
186    /// JWT path. When this list is non-empty, the engine boots without
187    /// requiring operator users / `admin_api_keys` — the upstream IdP
188    /// is the source of truth for identity.
189    ///
190    /// Mirrors the v0.12.1 `--auth-issuer` / `--auth-audience` CLI
191    /// flags in the new TOML config shape. Multiple issuers are allowed
192    /// for deployments that span more than one IdP.
193    ///
194    /// Field is private so future entries (per-issuer policy, claim
195    /// mappers, etc.) can be added without breaking downstream
196    /// construction. Read via [`AuthConfig::external_issuers`].
197    #[serde(default)]
198    external_issuers: Vec<ExternalIssuerConfig>,
199}
200
201impl AuthConfig {
202    /// Read access to the parsed `[[auth.external_issuers]]` blocks.
203    pub fn external_issuers(&self) -> &[ExternalIssuerConfig] {
204        &self.external_issuers
205    }
206}
207
208/// One trusted external OIDC issuer for pass-through JWT validation.
209#[derive(Clone, Debug, Default, Deserialize, Serialize)]
210#[non_exhaustive]
211pub struct ExternalIssuerConfig {
212    /// Issuer URL — the value the JWT's `iss` claim is matched against
213    /// and the base for `<issuer_url>/.well-known/openid-configuration`
214    /// discovery. Trailing slashes are normalized.
215    pub issuer_url: String,
216    /// Accepted `aud` claim values. A token whose `aud` isn't in this
217    /// list is rejected. Empty list = audience check disabled (NOT
218    /// recommended; set explicitly per deployment).
219    #[serde(default)]
220    pub audience: Vec<String>,
221    /// JWKS refresh interval in seconds (background task). Default 3600
222    /// (1 hour). Minimum effective value 60 seconds — anything smaller
223    /// is clamped to avoid hammering the upstream's JWKS endpoint.
224    #[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/// Session module knobs.
233#[derive(Clone, Debug, Default, Deserialize, Serialize)]
234#[non_exhaustive]
235pub struct AuthSessionConfig {
236    /// Default session lifetime in seconds. `None` ⇒ uses the
237    /// `assay_auth::session::DEFAULT_SESSION_DURATION` (30 days).
238    pub ttl_seconds: Option<u64>,
239}
240
241/// WebAuthn / passkey module knobs.
242#[derive(Clone, Debug, Default, Deserialize, Serialize)]
243#[non_exhaustive]
244pub struct AuthPasskeyConfig {
245    /// Relying-party id — the host (no scheme/port) the browser will
246    /// scope passkeys to. Defaults to the host of `auth.public_url`, or
247    /// `server.public_url` when no dedicated auth origin is configured.
248    pub rp_id: Option<String>,
249    /// Human-readable label browsers show. Defaults to `"Assay"`.
250    pub rp_name: Option<String>,
251}
252
253/// Self-service password-recovery deployment knobs.
254#[derive(Clone, Debug, Deserialize, Serialize)]
255#[non_exhaustive]
256pub struct AuthRecoveryConfig {
257    /// Mount the public recovery endpoints and send reset emails.
258    #[serde(default)]
259    pub enabled: bool,
260    /// Lifetime of a single-use recovery token. Defaults to 15 minutes.
261    #[serde(default = "default_recovery_token_ttl_seconds")]
262    pub token_ttl_seconds: u64,
263    /// Minimum time between recovery emails for one account.
264    #[serde(default = "default_recovery_cooldown_seconds")]
265    pub request_cooldown_seconds: u64,
266    /// SMTP delivery settings. Required when recovery is enabled.
267    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/// SMTP settings used only by password recovery.
290#[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/// OIDC provider knobs.
308#[derive(Clone, Debug, Default, Deserialize, Serialize)]
309#[non_exhaustive]
310pub struct AuthOidcProviderConfig {
311    /// Whether the OIDC provider routes (/authorize /token /userinfo …)
312    /// are mounted. Defaults to `true` when the Cargo feature is on.
313    #[serde(default = "default_true")]
314    pub enabled: bool,
315    /// Override the issuer URL used by the OIDC provider. Defaults to
316    /// the parent [`AuthConfig::issuer`] when unset.
317    pub issuer_override: Option<String>,
318    /// `true`  → federation callback creates an `auth.users` row on
319    ///           first sign-in for a new upstream identity (open
320    ///           signup; legacy library default — kept as the default
321    ///           here so omitted config does not silently flip
322    ///           existing deployments into invite-only).
323    /// `false` → callback looks up by email (and requires the
324    ///           upstream `email_verified` claim); missing rows
325    ///           return 403. Operators pre-populate `auth.users` via
326    ///           the admin API or the sysops `/auth/users` page.
327    ///           Recommended for shared / multi-tenant deployments —
328    ///           must be set explicitly.
329    #[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    /// Operator consoles (`/workflow`, `/engine`, `/vault`, and
339    /// `/auth/console`). Defaults to the legacy `enabled` value.
340    pub operator_enabled: Option<bool>,
341    /// Public browser authentication UI (`/auth/login`, recovery, and the
342    /// auth landing page). Defaults to the legacy `enabled` value.
343    pub auth_ui_enabled: Option<bool>,
344}
345
346impl Default for DashboardConfig {
347    fn default() -> Self {
348        // When the `[dashboard]` section is omitted entirely from
349        // engine.toml, serde calls Default::default() — and bool's
350        // derived default is `false`. We want `enabled: true` here so
351        // a fresh engine.toml without a [dashboard] section still
352        // mounts the SPAs out of the box.
353        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    /// Load `engine.toml`. String fields support `${VAR}` and
403    /// `${VAR:-default}` env-var references; references with no default
404    /// error out at load time when the variable is unset. Bracket-less
405    /// `$VAR` is left untouched, and `${...}` whose contents aren't a
406    /// valid identifier are passed through verbatim.
407    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
418/// Expand `${VAR}` and `${VAR:-default}` references in `raw` using
419/// `lookup` to resolve names. The lookup-by-closure shape keeps this
420/// pure for unit tests (the binary path uses `std::env::var`).
421///
422/// Behavior:
423/// - `${VAR}` → value if set, error if unset.
424/// - `${VAR:-default}` → value if set, else the default (which may be empty).
425/// - Bracket-less `$VAR` is untouched.
426/// - `${...}` whose contents aren't a valid identifier are passed
427///   through verbatim — keeps non-substitution `${...}` literals usable
428///   in odd field values without false positives.
429fn 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            // Not a valid identifier — pass the whole `${...}` through.
448            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        // Bracket-less `$IDENT` is intentionally left alone — only the
541        // `${...}` form is treated as an env reference.
542        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        // Digit-leading is not a valid identifier; `${1NOT_VALID}` stays literal.
550        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        // Integration sanity that the from_file path still works after the
592        // expansion step is wired in. Uses a config with no env-var
593        // references to keep the test hermetic.
594        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}