use std::collections::HashMap;
use std::sync::Arc;
use serde_json::Value;
use tokio::sync::RwLock;
use tracing::info;
use vta_sdk::protocols::vta_management::get_config::{ConfigField, GetConfigResultBody};
use vta_sdk::protocols::vta_management::update_config::{RejectedKey, UpdateConfigResultBody};
use crate::auth::AuthClaims;
use crate::config::AppConfig;
use crate::error::AppError;
struct KeyDef {
key: &'static str,
mutable: bool,
requires_restart: bool,
immutable_reason: &'static str,
}
const REGISTRY: &[KeyDef] = &[
KeyDef {
key: "vta_did",
mutable: false,
requires_restart: false,
immutable_reason: "the VTA's own identity is set at setup and cannot be changed at \
runtime; re-pointing it would orphan every credential this agent \
issued and every ACL grant naming it",
},
KeyDef {
key: "vta_name",
mutable: true,
requires_restart: false,
immutable_reason: "",
},
KeyDef {
key: "public_url",
mutable: true,
requires_restart: true,
immutable_reason: "",
},
];
fn lookup(key: &str) -> Option<&'static KeyDef> {
REGISTRY.iter().find(|d| d.key == key)
}
fn value_of(config: &AppConfig, key: &str) -> Value {
let v = match key {
"vta_did" => config.vta_did.clone(),
"vta_name" => config.vta_name.clone(),
"public_url" => config.public_url.clone(),
_ => None,
};
v.map(Value::String).unwrap_or(Value::Null)
}
fn source_of(config: &AppConfig, key: &str) -> &'static str {
if value_of(config, key).is_null() {
"default"
} else if key == "vta_did" {
"setup"
} else {
"toml"
}
}
pub async fn get_config(
config: &Arc<RwLock<AppConfig>>,
auth: &AuthClaims,
keys: Option<Vec<String>>,
channel: &str,
) -> Result<GetConfigResultBody, AppError> {
let config = config.read().await;
let fields = REGISTRY
.iter()
.filter(|d| keys.as_ref().is_none_or(|ks| ks.iter().any(|k| k == d.key)))
.map(|d| ConfigField {
key: d.key.to_string(),
value: value_of(&config, d.key),
source: source_of(&config, d.key).to_string(),
requires_restart: d.requires_restart,
})
.collect();
info!(channel, caller = %auth.did, "config retrieved");
Ok(GetConfigResultBody { fields })
}
pub async fn update_config(
config: &Arc<RwLock<AppConfig>>,
auth: &AuthClaims,
overrides: HashMap<String, Value>,
channel: &str,
) -> Result<UpdateConfigResultBody, AppError> {
auth.require_super_admin()?;
let mut applied = Vec::new();
let mut pending_restart = Vec::new();
let mut rejected = Vec::new();
let mut writes: Vec<(&'static KeyDef, String)> = Vec::new();
for (key, value) in &overrides {
let Some(def) = lookup(key) else {
rejected.push(RejectedKey {
key: key.clone(),
reason: "unknown config key (not in the registry)".into(),
});
continue;
};
if !def.mutable {
rejected.push(RejectedKey {
key: key.clone(),
reason: def.immutable_reason.to_string(),
});
continue;
}
let Some(s) = value.as_str() else {
rejected.push(RejectedKey {
key: key.clone(),
reason: "expected a string value".into(),
});
continue;
};
writes.push((def, s.to_string()));
}
if writes.is_empty() {
info!(channel, caller = %auth.did, rejected = rejected.len(), "config patch applied nothing");
return Ok(UpdateConfigResultBody {
applied,
pending_restart,
rejected,
});
}
let (contents, path) = {
let mut config = config.write().await;
for (def, value) in &writes {
match def.key {
"vta_name" => config.vta_name = Some(value.clone()),
"public_url" => config.public_url = Some(value.clone()),
other => unreachable!("non-mutable key {other} reached the write path"),
}
if def.requires_restart {
pending_restart.push(def.key.to_string());
} else {
applied.push(def.key.to_string());
}
}
let contents = toml::to_string_pretty(&*config)
.map_err(|e| AppError::Config(format!("failed to serialize config: {e}")))?;
(contents, config.config_path.clone())
};
std::fs::write(&path, contents).map_err(AppError::Io)?;
info!(
channel,
caller = %auth.did,
applied = applied.len(),
pending_restart = pending_restart.len(),
rejected = rejected.len(),
"config updated"
);
Ok(UpdateConfigResultBody {
applied,
pending_restart,
rejected,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identity_is_registered_but_immutable() {
let did = lookup("vta_did").expect("vta_did is readable through config/show");
assert!(
!did.mutable,
"the VTA's own identity must never be patchable at runtime — \
re-pointing it orphans every credential this agent issued"
);
assert!(
!did.immutable_reason.is_empty(),
"an immutable key must carry a reason; it is surfaced to the operator verbatim"
);
}
#[test]
fn identity_is_readable() {
assert!(
REGISTRY.iter().any(|d| d.key == "vta_did"),
"config/show must still return the VTA DID"
);
}
#[test]
fn reasons_track_mutability() {
for d in REGISTRY {
assert_eq!(
d.mutable,
d.immutable_reason.is_empty(),
"{}: an immutable key needs a reason and a mutable one must not carry one",
d.key
);
}
}
#[test]
fn boot_stable_keys_are_marked_restart_required() {
assert!(
lookup("public_url").expect("registered").requires_restart,
"public_url is read at boot; a change cannot take effect in place"
);
assert!(!lookup("vta_name").expect("registered").requires_restart);
}
}