use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[non_exhaustive]
pub struct EngineConfig {
pub server: ServerConfig,
pub backend: BackendConfig,
#[serde(default)]
pub workflow: WorkflowConfig,
#[serde(default)]
pub auth: AuthConfig,
#[serde(default)]
pub dashboard: DashboardConfig,
#[serde(default)]
pub logging: LoggingConfig,
#[serde(default = "default_engine_events_ttl_secs")]
pub engine_events_ttl_secs: u64,
#[serde(default)]
pub auto_enable_modules: Vec<String>,
}
fn default_engine_events_ttl_secs() -> u64 {
3 * 86_400
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[non_exhaustive]
pub struct ServerConfig {
#[serde(default = "default_bind_addr")]
pub bind_addr: String,
#[serde(default = "default_public_url")]
pub public_url: String,
#[serde(default)]
pub allowed_hosts: Vec<String>,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
bind_addr: default_bind_addr(),
public_url: default_public_url(),
allowed_hosts: Vec::new(),
}
}
}
fn default_bind_addr() -> String {
"0.0.0.0:3000".to_string()
}
fn default_public_url() -> String {
"http://localhost:3000".to_string()
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "lowercase")]
#[non_exhaustive]
pub enum BackendConfig {
Postgres {
url: String,
},
Sqlite {
#[serde(default = "default_data_dir")]
data_dir: String,
#[serde(default)]
path: Option<String>,
},
}
fn default_data_dir() -> String {
"./data".to_string()
}
impl BackendConfig {
pub fn sqlite_data_dir(&self) -> Option<String> {
match self {
Self::Sqlite { data_dir, path } => {
if let Some(p) = path {
let parent = std::path::Path::new(p)
.parent()
.map(|p| p.display().to_string())
.filter(|s| !s.is_empty());
Some(parent.unwrap_or_else(|| data_dir.clone()))
} else {
Some(data_dir.clone())
}
}
Self::Postgres { .. } => None,
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[non_exhaustive]
pub struct WorkflowConfig {
#[serde(default = "default_true")]
pub enabled: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[non_exhaustive]
pub struct AuthConfig {
pub public_url: Option<String>,
pub issuer: Option<String>,
#[serde(default)]
pub audience: Vec<String>,
#[serde(default)]
pub session: AuthSessionConfig,
#[serde(default)]
pub passkey: AuthPasskeyConfig,
#[serde(default)]
pub recovery: AuthRecoveryConfig,
#[serde(default)]
pub oidc_provider: AuthOidcProviderConfig,
#[serde(default)]
pub admin_api_keys: Vec<String>,
#[serde(default)]
external_issuers: Vec<ExternalIssuerConfig>,
}
impl AuthConfig {
pub fn external_issuers(&self) -> &[ExternalIssuerConfig] {
&self.external_issuers
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[non_exhaustive]
pub struct ExternalIssuerConfig {
pub issuer_url: String,
#[serde(default)]
pub audience: Vec<String>,
#[serde(default = "default_jwks_refresh_secs")]
pub jwks_refresh_secs: u64,
}
fn default_jwks_refresh_secs() -> u64 {
3600
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[non_exhaustive]
pub struct AuthSessionConfig {
pub ttl_seconds: Option<u64>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[non_exhaustive]
pub struct AuthPasskeyConfig {
pub rp_id: Option<String>,
pub rp_name: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[non_exhaustive]
pub struct AuthRecoveryConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_recovery_token_ttl_seconds")]
pub token_ttl_seconds: u64,
#[serde(default = "default_recovery_cooldown_seconds")]
pub request_cooldown_seconds: u64,
pub smtp: Option<AuthSmtpConfig>,
}
impl Default for AuthRecoveryConfig {
fn default() -> Self {
Self {
enabled: false,
token_ttl_seconds: default_recovery_token_ttl_seconds(),
request_cooldown_seconds: default_recovery_cooldown_seconds(),
smtp: None,
}
}
}
fn default_recovery_token_ttl_seconds() -> u64 {
15 * 60
}
fn default_recovery_cooldown_seconds() -> u64 {
60
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[non_exhaustive]
pub struct AuthSmtpConfig {
pub host: String,
#[serde(default = "default_smtp_port")]
pub port: u16,
pub username: String,
pub password: String,
pub from: String,
#[serde(default = "default_true")]
pub starttls: bool,
}
fn default_smtp_port() -> u16 {
587
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[non_exhaustive]
pub struct AuthOidcProviderConfig {
#[serde(default = "default_true")]
pub enabled: bool,
pub issuer_override: Option<String>,
#[serde(default = "default_true")]
pub auto_provision: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[non_exhaustive]
pub struct DashboardConfig {
#[serde(default = "default_true")]
pub enabled: bool,
pub operator_enabled: Option<bool>,
pub auth_ui_enabled: Option<bool>,
}
impl Default for DashboardConfig {
fn default() -> Self {
Self {
enabled: true,
operator_enabled: None,
auth_ui_enabled: None,
}
}
}
impl DashboardConfig {
pub fn operator_enabled(&self) -> bool {
self.operator_enabled.unwrap_or(self.enabled)
}
pub fn auth_ui_enabled(&self) -> bool {
self.auth_ui_enabled.unwrap_or(self.enabled)
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[non_exhaustive]
pub struct LoggingConfig {
#[serde(default = "default_log_level")]
pub level: String,
#[serde(default = "default_log_format")]
pub format: String,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
level: default_log_level(),
format: default_log_format(),
}
}
}
fn default_true() -> bool {
true
}
fn default_log_level() -> String {
"info".to_string()
}
fn default_log_format() -> String {
"pretty".to_string()
}
impl EngineConfig {
pub fn from_file(path: &Path) -> anyhow::Result<Self> {
let raw = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("read config {}: {e}", path.display()))?;
let expanded = expand_env_vars(&raw, |name| std::env::var(name).ok())
.map_err(|e| anyhow::anyhow!("expand env vars in {}: {e}", path.display()))?;
let cfg: Self = toml::from_str(&expanded)
.map_err(|e| anyhow::anyhow!("parse config {}: {e}", path.display()))?;
Ok(cfg)
}
}
fn expand_env_vars<F>(raw: &str, lookup: F) -> anyhow::Result<String>
where
F: Fn(&str) -> Option<String>,
{
let mut out = String::with_capacity(raw.len());
let mut rest = raw;
while let Some(idx) = rest.find("${") {
out.push_str(&rest[..idx]);
let after_open = &rest[idx + 2..];
let close_idx = after_open
.find('}')
.ok_or_else(|| anyhow::anyhow!("unclosed `${{` in config"))?;
let inner = &after_open[..close_idx];
let (var_name, default) = match inner.split_once(":-") {
Some((n, d)) => (n, Some(d)),
None => (inner, None),
};
if !is_valid_var_name(var_name) {
out.push_str("${");
out.push_str(inner);
out.push('}');
} else {
match lookup(var_name) {
Some(val) => out.push_str(&val),
None => match default {
Some(def) => out.push_str(def),
None => {
return Err(anyhow::anyhow!(
"env var `{}` is not set and has no default",
var_name
));
}
},
}
}
rest = &after_open[close_idx + 1..];
}
out.push_str(rest);
Ok(out)
}
fn is_valid_var_name(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
}
#[cfg(test)]
mod tests {
use super::*;
fn lookup_from<'a>(map: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |name: &str| {
map.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| (*v).to_string())
}
}
#[test]
fn no_substitution_passes_through() {
let s = "plain string with $literal but no expansion markers";
assert_eq!(expand_env_vars(s, lookup_from(&[])).unwrap(), s);
}
#[test]
fn substitutes_set_var() {
let out = expand_env_vars("value=${FOO}", lookup_from(&[("FOO", "hello")])).unwrap();
assert_eq!(out, "value=hello");
}
#[test]
fn errors_on_unset_var_with_no_default() {
let err = expand_env_vars("${MISSING}", lookup_from(&[])).unwrap_err();
assert!(err.to_string().contains("MISSING"));
}
#[test]
fn falls_back_to_default_when_unset() {
let out = expand_env_vars("${MISSING:-fallback}", lookup_from(&[])).unwrap();
assert_eq!(out, "fallback");
}
#[test]
fn ignores_default_when_var_set() {
let out = expand_env_vars("${FOO:-fallback}", lookup_from(&[("FOO", "actual")])).unwrap();
assert_eq!(out, "actual");
}
#[test]
fn empty_default_yields_empty_string() {
let out = expand_env_vars("[${MISSING:-}]", lookup_from(&[])).unwrap();
assert_eq!(out, "[]");
}
#[test]
fn substitutes_multiple_vars_in_one_string() {
let out = expand_env_vars(
"postgres://u:p@${HOST}:${PORT}/x",
lookup_from(&[("HOST", "db.example.com"), ("PORT", "5432")]),
)
.unwrap();
assert_eq!(out, "postgres://u:p@db.example.com:5432/x");
}
#[test]
fn dollar_without_braces_passes_through() {
let s = "$HOME and $USER stay literal";
let out = expand_env_vars(s, lookup_from(&[])).unwrap();
assert_eq!(out, s);
}
#[test]
fn invalid_identifier_passes_through_verbatim() {
let s = "${1NOT_VALID}";
assert_eq!(expand_env_vars(s, lookup_from(&[])).unwrap(), s);
}
#[test]
fn unclosed_brace_errors() {
let err = expand_env_vars("${UNCLOSED", lookup_from(&[])).unwrap_err();
assert!(err.to_string().contains("unclosed"));
}
#[test]
fn substitutes_inside_toml_string_values() {
let toml_input = r#"
[backend]
type = "postgres"
url = "${DB}"
"#;
let expanded =
expand_env_vars(toml_input, lookup_from(&[("DB", "postgres://u:p@h/d")])).unwrap();
assert!(expanded.contains(r#"url = "postgres://u:p@h/d""#));
}
#[test]
fn is_valid_var_name_accepts_typical_names() {
assert!(is_valid_var_name("DATABASE_URL"));
assert!(is_valid_var_name("_PRIVATE"));
assert!(is_valid_var_name("X"));
assert!(is_valid_var_name("X1"));
}
#[test]
fn is_valid_var_name_rejects_bad_names() {
assert!(!is_valid_var_name(""));
assert!(!is_valid_var_name("1LEADING_DIGIT"));
assert!(!is_valid_var_name("HAS SPACE"));
assert!(!is_valid_var_name("HAS-DASH"));
assert!(!is_valid_var_name("HAS.DOT"));
}
#[test]
fn from_file_loads_static_toml() {
let path = std::env::temp_dir().join("assay-engine-config-from-file-static.toml");
std::fs::write(
&path,
r#"
[server]
bind_addr = "127.0.0.1:3000"
[backend]
type = "sqlite"
data_dir = "/tmp/assay-engine-test-data-static"
"#,
)
.unwrap();
let cfg = EngineConfig::from_file(&path).unwrap();
let _ = std::fs::remove_file(&path);
match cfg.backend {
BackendConfig::Sqlite { ref data_dir, .. } => {
assert_eq!(data_dir, "/tmp/assay-engine-test-data-static");
}
_ => panic!("expected sqlite backend"),
}
}
#[test]
fn password_recovery_is_disabled_by_default() {
let cfg: EngineConfig = toml::from_str(
r#"
[server]
bind_addr = "127.0.0.1:3000"
[backend]
type = "sqlite"
data_dir = ":memory:"
"#,
)
.unwrap();
assert!(!cfg.auth.recovery.enabled);
assert_eq!(cfg.auth.recovery.token_ttl_seconds, 900);
assert_eq!(cfg.auth.recovery.request_cooldown_seconds, 60);
assert!(cfg.auth.recovery.smtp.is_none());
}
#[test]
fn password_recovery_smtp_configuration_deserializes() {
let cfg: EngineConfig = toml::from_str(
r#"
[server]
bind_addr = "127.0.0.1:3000"
[backend]
type = "sqlite"
data_dir = ":memory:"
[auth.recovery]
enabled = true
token_ttl_seconds = 1200
request_cooldown_seconds = 90
[auth.recovery.smtp]
host = "smtp.example.com"
port = 587
username = "mailer"
password = "secret"
from = "Example Auth <noreply@example.com>"
starttls = true
"#,
)
.unwrap();
assert!(cfg.auth.recovery.enabled);
assert_eq!(cfg.auth.recovery.token_ttl_seconds, 1200);
assert_eq!(cfg.auth.recovery.request_cooldown_seconds, 90);
let smtp = cfg.auth.recovery.smtp.unwrap();
assert_eq!(smtp.host, "smtp.example.com");
assert_eq!(smtp.port, 587);
assert_eq!(smtp.username, "mailer");
assert_eq!(smtp.password, "secret");
assert_eq!(smtp.from, "Example Auth <noreply@example.com>");
assert!(smtp.starttls);
}
#[test]
fn flagship_host_and_dashboard_boundaries_deserialize() {
let cfg: EngineConfig = toml::from_str(
r#"
[server]
bind_addr = "127.0.0.1:3000"
allowed_hosts = ["auth.assay.rs", "engine.assay.rs"]
[backend]
type = "sqlite"
data_dir = ":memory:"
[dashboard]
enabled = true
operator_enabled = false
auth_ui_enabled = true
"#,
)
.unwrap();
assert_eq!(
cfg.server.allowed_hosts,
["auth.assay.rs", "engine.assay.rs"]
);
assert!(!cfg.dashboard.operator_enabled());
assert!(cfg.dashboard.auth_ui_enabled());
}
#[test]
fn dashboard_surface_flags_preserve_the_legacy_enabled_default() {
let dashboard = DashboardConfig::default();
assert!(dashboard.operator_enabled());
assert!(dashboard.auth_ui_enabled());
assert!(ServerConfig::default().allowed_hosts.is_empty());
}
}