use crate::error::AppError;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub use vti_common::config::{AuthConfig, LogConfig, LogFormat, MessagingConfig, StoreConfig};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AppConfig {
pub vtc_did: Option<String>,
pub vta_did: Option<String>,
#[serde(alias = "community_name")]
pub vtc_name: Option<String>,
#[serde(alias = "community_description")]
pub vtc_description: Option<String>,
pub public_url: Option<String>,
#[serde(default = "default_server_config")]
pub server: ServerConfig,
#[serde(default)]
pub log: LogConfig,
#[serde(default = "default_store_config")]
pub store: StoreConfig,
pub messaging: Option<MessagingConfig>,
#[serde(default)]
pub auth: AuthConfig,
#[serde(default)]
pub secrets: SecretsConfig,
#[serde(default)]
pub routing: RoutingConfig,
#[serde(default)]
pub cors: CorsConfig,
#[serde(default)]
pub registry: RegistryConfig,
#[serde(default)]
pub renewal: RenewalConfig,
#[serde(default)]
pub website: WebsiteConfig,
#[serde(default)]
pub admin_ui: AdminUiConfig,
#[serde(skip)]
pub config_path: PathBuf,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct AdminUiConfig {
#[serde(default = "default_admin_ui_mode")]
pub mode: String,
#[serde(default)]
pub external_origin: Option<String>,
#[serde(default)]
pub rp_id: Option<String>,
#[serde(default)]
pub plugin_dir: Option<std::path::PathBuf>,
}
impl Default for AdminUiConfig {
fn default() -> Self {
Self {
mode: default_admin_ui_mode(),
external_origin: None,
rp_id: None,
plugin_dir: None,
}
}
}
fn default_admin_ui_mode() -> String {
"embedded".into()
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct WebsiteConfig {
#[serde(default)]
pub root_dir: Option<PathBuf>,
#[serde(default = "default_deploy_mode")]
pub deploy_mode: String,
#[serde(default = "default_live_cache_ttl_seconds")]
pub live_cache_ttl_seconds: u64,
#[serde(default = "default_managed_generations_keep")]
pub managed_generations_keep: u32,
#[serde(default = "default_cache_control")]
pub cache_control: String,
#[serde(default = "default_executable_blocklist")]
pub executable_blocklist: Vec<String>,
#[serde(default = "default_max_bundle_size_mb")]
pub max_bundle_size_mb: u64,
#[serde(default = "default_max_file_size_mb")]
pub max_file_size_mb: u64,
#[serde(default = "default_csp_override_file")]
pub csp_override_file: String,
}
impl Default for WebsiteConfig {
fn default() -> Self {
Self {
root_dir: None,
deploy_mode: default_deploy_mode(),
live_cache_ttl_seconds: default_live_cache_ttl_seconds(),
managed_generations_keep: default_managed_generations_keep(),
cache_control: default_cache_control(),
executable_blocklist: default_executable_blocklist(),
max_bundle_size_mb: default_max_bundle_size_mb(),
max_file_size_mb: default_max_file_size_mb(),
csp_override_file: default_csp_override_file(),
}
}
}
fn default_deploy_mode() -> String {
"live".into()
}
fn default_live_cache_ttl_seconds() -> u64 {
5
}
fn default_managed_generations_keep() -> u32 {
5
}
fn default_cache_control() -> String {
"public, max-age=300".into()
}
fn default_executable_blocklist() -> Vec<String> {
vec![".cgi".into(), ".php".into(), ".exe".into()]
}
fn default_max_bundle_size_mb() -> u64 {
50
}
fn default_max_file_size_mb() -> u64 {
10
}
fn default_csp_override_file() -> String {
".vtc-website.toml".into()
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub struct RenewalConfig {
#[serde(default)]
pub on_personhood_fail: PersonhoodFailMode,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum PersonhoodFailMode {
#[default]
Downgrade,
Refuse,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct RegistryConfig {
#[serde(default)]
pub url: Option<String>,
#[serde(default = "default_health_probe_interval")]
pub health_probe_interval_seconds: u64,
#[serde(default = "default_registry_http_timeout")]
pub http_timeout_seconds: u64,
#[serde(default = "default_rtbf_batch_window_hours")]
pub rtbf_batch_window_hours: u64,
}
fn default_health_probe_interval() -> u64 {
60
}
fn default_registry_http_timeout() -> u64 {
5
}
fn default_rtbf_batch_window_hours() -> u64 {
24
}
impl Default for RegistryConfig {
fn default() -> Self {
Self {
url: None,
health_probe_interval_seconds: default_health_probe_interval(),
http_timeout_seconds: default_registry_http_timeout(),
rtbf_batch_window_hours: default_rtbf_batch_window_hours(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct RoutingConfig {
#[serde(default = "default_api_mount")]
pub api: MountConfig,
#[serde(default = "default_admin_ui_mount")]
pub admin_ui: MountConfig,
#[serde(default = "default_website_mount")]
pub website: MountConfig,
#[serde(default = "default_subdomain_mode_strict")]
pub subdomain_mode_strict: bool,
}
impl Default for RoutingConfig {
fn default() -> Self {
Self {
api: default_api_mount(),
admin_ui: default_admin_ui_mount(),
website: default_website_mount(),
subdomain_mode_strict: default_subdomain_mode_strict(),
}
}
}
fn default_subdomain_mode_strict() -> bool {
true
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct MountConfig {
pub mount: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
}
fn default_api_mount() -> MountConfig {
MountConfig {
mount: "/v1".into(),
host: None,
}
}
fn default_admin_ui_mount() -> MountConfig {
MountConfig {
mount: "/admin".into(),
host: None,
}
}
fn default_website_mount() -> MountConfig {
MountConfig {
mount: "/".into(),
host: None,
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct CorsConfig {
#[serde(default)]
pub allowed_origins: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ServerConfig {
#[serde(default = "default_host")]
pub host: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default)]
pub trust_xff: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SecretsConfig {
pub secret: Option<String>,
pub aws_secret_name: Option<String>,
pub aws_region: Option<String>,
pub gcp_project: Option<String>,
pub gcp_secret_name: Option<String>,
pub azure_vault_url: Option<String>,
pub azure_secret_name: Option<String>,
#[serde(default = "default_keyring_service")]
pub keyring_service: String,
}
fn default_keyring_service() -> String {
"vtc".to_string()
}
impl Default for SecretsConfig {
fn default() -> Self {
Self {
secret: None,
aws_secret_name: None,
aws_region: None,
gcp_project: None,
gcp_secret_name: None,
azure_vault_url: None,
azure_secret_name: None,
keyring_service: default_keyring_service(),
}
}
}
fn default_host() -> String {
default_host_value()
}
fn default_port() -> u16 {
default_port_value()
}
pub(crate) fn default_host_value() -> String {
"0.0.0.0".to_string()
}
pub(crate) fn default_port_value() -> u16 {
8200
}
fn default_server_config() -> ServerConfig {
ServerConfig::default()
}
fn validate_routing(routing: &RoutingConfig) -> Result<(), AppError> {
for (name, m) in [
("api", &routing.api),
("admin_ui", &routing.admin_ui),
("website", &routing.website),
] {
if !m.mount.starts_with('/') {
return Err(AppError::Config(format!(
"routing.{name}.mount must start with '/': got '{}'",
m.mount
)));
}
}
if routing.admin_ui.host.is_none() && routing.admin_ui.mount == "/" {
return Err(AppError::Config(
"routing.admin_ui.mount = '/' would collapse the admin cookie scope; \
pick a non-root prefix (default: '/admin') or enable subdomain mode via \
routing.admin_ui.host"
.into(),
));
}
let path_mounts: Vec<(&str, &str)> = [
(
"api",
routing.api.host.as_deref(),
routing.api.mount.as_str(),
),
(
"admin_ui",
routing.admin_ui.host.as_deref(),
routing.admin_ui.mount.as_str(),
),
(
"website",
routing.website.host.as_deref(),
routing.website.mount.as_str(),
),
]
.into_iter()
.filter_map(|(name, host, mount)| host.is_none().then_some((name, mount)))
.collect();
for i in 0..path_mounts.len() {
for j in (i + 1)..path_mounts.len() {
let (a, am) = path_mounts[i];
let (b, bm) = path_mounts[j];
if am == bm {
return Err(AppError::Config(format!(
"routing.{a}.mount and routing.{b}.mount both = '{am}'; \
path-mode mounts must be unique",
)));
}
}
}
Ok(())
}
fn validate_cors(cors: &CorsConfig) -> Result<(), AppError> {
for origin in &cors.allowed_origins {
let trimmed = origin.trim();
if trimmed.is_empty() {
return Err(AppError::Config(
"cors.allowed_origins contains an empty / whitespace entry".into(),
));
}
if trimmed == "*" || trimmed.contains('*') {
return Err(AppError::Config(format!(
"cors.allowed_origins entry '{trimmed}' uses a wildcard; \
spec §9.3 demands an exact-match allowlist"
)));
}
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
return Err(AppError::Config(format!(
"cors.allowed_origins entry '{trimmed}' must be a full origin \
(e.g., 'https://admin.example.com')"
)));
}
}
Ok(())
}
fn default_store_config() -> StoreConfig {
StoreConfig {
data_dir: PathBuf::from("data/vtc"),
}
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: default_host(),
port: default_port(),
trust_xff: false,
}
}
}
impl AppConfig {
pub fn load(config_path: Option<PathBuf>) -> Result<Self, AppError> {
let path = config_path
.or_else(|| std::env::var("VTC_CONFIG_PATH").ok().map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("config.toml"));
if !path.exists() {
return Err(AppError::Config(format!(
"configuration file not found: {}",
path.display()
)));
}
let contents = std::fs::read_to_string(&path).map_err(AppError::Io)?;
let mut config = toml::from_str::<AppConfig>(&contents)
.map_err(|e| AppError::Config(format!("failed to parse {}: {e}", path.display())))?;
config.config_path = path.clone();
if let Ok(vtc_did) = std::env::var("VTC_DID") {
config.vtc_did = Some(vtc_did);
}
if let Ok(vta_did) = std::env::var("VTC_VTA_DID") {
config.vta_did = Some(vta_did);
}
if let Ok(host) = std::env::var("VTC_SERVER_HOST") {
config.server.host = host;
}
if let Ok(port) = std::env::var("VTC_SERVER_PORT") {
config.server.port = port
.parse()
.map_err(|e| AppError::Config(format!("invalid VTC_SERVER_PORT: {e}")))?;
}
if let Ok(level) = std::env::var("VTC_LOG_LEVEL") {
config.log.level = level;
}
if let Ok(format) = std::env::var("VTC_LOG_FORMAT") {
config.log.format = match format.to_lowercase().as_str() {
"json" => LogFormat::Json,
"text" => LogFormat::Text,
other => {
return Err(AppError::Config(format!(
"invalid VTC_LOG_FORMAT '{other}', expected 'text' or 'json'"
)));
}
};
}
if let Ok(public_url) = std::env::var("VTC_PUBLIC_URL") {
config.public_url = Some(public_url);
}
if let Ok(data_dir) = std::env::var("VTC_STORE_DATA_DIR") {
config.store.data_dir = PathBuf::from(data_dir);
}
match (
std::env::var("VTC_MESSAGING_MEDIATOR_URL"),
std::env::var("VTC_MESSAGING_MEDIATOR_DID"),
) {
(Ok(url), Ok(did)) => {
config.messaging = Some(MessagingConfig {
mediator_url: url,
mediator_did: did,
mediator_host: None,
});
}
(Ok(url), Err(_)) => {
let messaging = config.messaging.get_or_insert(MessagingConfig {
mediator_url: String::new(),
mediator_did: String::new(),
mediator_host: None,
});
messaging.mediator_url = url;
}
(Err(_), Ok(did)) => {
let messaging = config.messaging.get_or_insert(MessagingConfig {
mediator_url: String::new(),
mediator_did: String::new(),
mediator_host: None,
});
messaging.mediator_did = did;
}
(Err(_), Err(_)) => {}
}
if let Ok(secret) = std::env::var("VTC_SECRETS_SECRET") {
config.secrets.secret = Some(secret);
}
if let Ok(name) = std::env::var("VTC_SECRETS_AWS_SECRET_NAME") {
config.secrets.aws_secret_name = Some(name);
}
if let Ok(region) = std::env::var("VTC_SECRETS_AWS_REGION") {
config.secrets.aws_region = Some(region);
}
if let Ok(project) = std::env::var("VTC_SECRETS_GCP_PROJECT") {
config.secrets.gcp_project = Some(project);
}
if let Ok(name) = std::env::var("VTC_SECRETS_GCP_SECRET_NAME") {
config.secrets.gcp_secret_name = Some(name);
}
if let Ok(url) = std::env::var("VTC_SECRETS_AZURE_VAULT_URL") {
config.secrets.azure_vault_url = Some(url);
}
if let Ok(name) = std::env::var("VTC_SECRETS_AZURE_SECRET_NAME") {
config.secrets.azure_secret_name = Some(name);
}
if let Ok(service) = std::env::var("VTC_SECRETS_KEYRING_SERVICE") {
config.secrets.keyring_service = service;
}
if let Ok(expiry) = std::env::var("VTC_AUTH_ACCESS_EXPIRY") {
config.auth.access_token_expiry = expiry
.parse()
.map_err(|e| AppError::Config(format!("invalid VTC_AUTH_ACCESS_EXPIRY: {e}")))?;
}
if let Ok(expiry) = std::env::var("VTC_AUTH_REFRESH_EXPIRY") {
config.auth.refresh_token_expiry = expiry
.parse()
.map_err(|e| AppError::Config(format!("invalid VTC_AUTH_REFRESH_EXPIRY: {e}")))?;
}
if let Ok(ttl) = std::env::var("VTC_AUTH_CHALLENGE_TTL") {
config.auth.challenge_ttl = ttl
.parse()
.map_err(|e| AppError::Config(format!("invalid VTC_AUTH_CHALLENGE_TTL: {e}")))?;
}
if let Ok(interval) = std::env::var("VTC_AUTH_SESSION_CLEANUP_INTERVAL") {
config.auth.session_cleanup_interval = interval.parse().map_err(|e| {
AppError::Config(format!("invalid VTC_AUTH_SESSION_CLEANUP_INTERVAL: {e}"))
})?;
}
if let Ok(key) = std::env::var("VTC_AUTH_JWT_SIGNING_KEY") {
config.auth.jwt_signing_key = Some(key);
}
config.validate_routing_and_cors()?;
Ok(config)
}
pub fn validate_routing_and_cors(&self) -> Result<(), AppError> {
validate_routing(&self.routing)?;
validate_cors(&self.cors)?;
Ok(())
}
pub fn save(&self) -> Result<(), AppError> {
self.validate_routing_and_cors()?;
let contents = toml::to_string_pretty(self)
.map_err(|e| AppError::Config(format!("failed to serialize config: {e}")))?;
std::fs::write(&self.config_path, contents).map_err(AppError::Io)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_toml_parses_with_all_defaults() {
let config: AppConfig = toml::from_str("").expect("empty TOML must parse");
assert_eq!(config.server.host, "0.0.0.0");
assert_eq!(config.server.port, 8200, "VTC default port is 8200");
assert!(config.vtc_did.is_none());
assert!(config.vta_did.is_none());
assert!(config.messaging.is_none());
}
#[test]
fn minimal_toml_parses() {
let toml_src = r#"
vtc_did = "did:key:zVTC"
vta_did = "did:key:zVTA"
"#;
let config: AppConfig = toml::from_str(toml_src).expect("minimal TOML must parse");
assert_eq!(config.vtc_did.as_deref(), Some("did:key:zVTC"));
assert_eq!(config.vta_did.as_deref(), Some("did:key:zVTA"));
}
#[test]
fn community_name_alias_is_accepted() {
let toml_src = r#"
community_name = "Alpha Community"
community_description = "First VTC"
"#;
let config: AppConfig = toml::from_str(toml_src).expect("alias must parse");
assert_eq!(config.vtc_name.as_deref(), Some("Alpha Community"));
assert_eq!(config.vtc_description.as_deref(), Some("First VTC"));
}
#[test]
fn vtc_name_canonical_field_is_accepted() {
let toml_src = r#"
vtc_name = "Alpha"
vtc_description = "Canonical"
"#;
let config: AppConfig = toml::from_str(toml_src).expect("canonical name parses");
assert_eq!(config.vtc_name.as_deref(), Some("Alpha"));
}
#[test]
fn invalid_toml_produces_config_error() {
let err = toml::from_str::<AppConfig>("server.port = \"not-a-number\"")
.expect_err("invalid port type must fail parse");
let msg = format!("{err}");
assert!(msg.contains("port"), "error must name the field: {msg}");
}
#[test]
fn server_port_bounds() {
let toml_src = r#"
[server]
port = 70000
"#;
let err =
toml::from_str::<AppConfig>(toml_src).expect_err("out-of-range port must not parse");
assert!(format!("{err}").contains("port"), "got {err}");
}
#[test]
fn secrets_config_keyring_service_defaults_to_vtc() {
let empty: AppConfig = toml::from_str("").unwrap();
assert_eq!(empty.secrets.keyring_service, "vtc");
}
#[test]
fn config_round_trip_preserves_fields() {
let original: AppConfig = toml::from_str(
r#"
vtc_did = "did:key:zVTC"
vta_did = "did:key:zVTA"
vtc_name = "Round Trip"
public_url = "https://vtc.example.com"
"#,
)
.unwrap();
let serialized = toml::to_string_pretty(&original).expect("serialize ok");
let parsed: AppConfig = toml::from_str(&serialized).expect("re-parse ok");
assert_eq!(parsed.vtc_did, original.vtc_did);
assert_eq!(parsed.vta_did, original.vta_did);
assert_eq!(parsed.vtc_name, original.vtc_name);
assert_eq!(parsed.public_url, original.public_url);
}
}