use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use vta_sdk::provision_client::EphemeralSetupKey;
use vti_common::error::AppError;
use crate::config::{MessagingConfig, SecretsConfig};
use super::wizard::{
SetupOutcome, Transport, WebvhTarget, WizardInputs, WizardPlan, apply, normalize_registry_did,
refuse_if_already_set_up,
};
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct MessagingSetup {
#[serde(flatten)]
pub config: MessagingConfig,
pub transports: Vec<Transport>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct VtcWizardInputs {
pub config_path: PathBuf,
pub base_url: String,
pub vta_did: String,
#[serde(default = "default_context")]
pub context: String,
#[serde(default)]
pub registry_did: Option<String>,
#[serde(default)]
pub co_admin_did: Option<String>,
#[serde(default)]
pub single_admin_mode: bool,
#[serde(default)]
pub webvh: WebvhTarget,
#[serde(default)]
pub messaging: Option<MessagingSetup>,
pub secrets: SecretsConfig,
pub setup_key_file: PathBuf,
}
fn default_context() -> String {
"default".to_string()
}
pub(crate) fn parse_from_toml(
file_path: &Path,
single_admin: bool,
) -> Result<WizardPlan, AppError> {
let raw = std::fs::read_to_string(file_path)
.map_err(|e| AppError::Config(format!("read setup file {}: {e}", file_path.display())))?;
let inputs: VtcWizardInputs = toml::from_str(&raw)
.map_err(|e| AppError::Config(format!("parse setup file {}: {e}", file_path.display())))?;
validate(&inputs)?;
refuse_if_already_set_up(&inputs.config_path)?;
let setup_key = EphemeralSetupKey::load_from(&inputs.setup_key_file).map_err(|e| {
AppError::Config(format!(
"load setup key {}: {e}. Generate + persist an ephemeral setup key and grant its \
did:key an admin ACL at the VTA before running `vtc setup --from` (see the \
non-interactive setup docs).",
inputs.setup_key_file.display()
))
})?;
let registry_did = normalize_registry_did(inputs.registry_did.as_deref().unwrap_or(""))?;
let (messaging, transports) = match inputs.messaging {
Some(m) => (Some(m.config), m.transports),
None => (None, Vec::new()),
};
Ok(WizardPlan {
config_path: inputs.config_path,
inputs: WizardInputs {
base_url: inputs.base_url.trim_end_matches('/').to_string(),
vta_did: inputs.vta_did,
context: inputs.context,
registry_did,
transports,
co_admin_did: super::wizard::normalize_co_admin_did(
inputs.co_admin_did.as_deref().unwrap_or(""),
)?,
single_admin_mode: inputs.single_admin_mode || single_admin,
},
webvh: inputs.webvh,
secrets: inputs.secrets,
messaging,
setup_key,
})
}
fn validate(inputs: &VtcWizardInputs) -> Result<(), AppError> {
let mut errors: Vec<String> = Vec::new();
let base = inputs.base_url.trim_end_matches('/');
if base.is_empty() {
errors.push("base_url must not be empty".into());
} else {
if !(base.starts_with("http://") || base.starts_with("https://")) {
errors.push(format!(
"base_url must start with http:// or https:// (got {:?})",
inputs.base_url
));
}
if base.ends_with("/v1") {
errors.push(
"base_url must be the host base, without the /v1 API prefix (the template \
appends API paths itself)"
.into(),
);
}
}
if !inputs.vta_did.starts_with("did:") {
errors.push(format!(
"vta_did must be a DID starting with `did:` (got {:?})",
inputs.vta_did
));
}
if inputs.context.trim().is_empty() {
errors.push("context must not be empty".into());
}
if let Some(co_admin) = inputs.co_admin_did.as_deref() {
let trimmed = co_admin.trim();
if !trimmed.is_empty() && !trimmed.starts_with("did:") {
errors.push(format!(
"co_admin_did must be a DID starting with `did:` (got {co_admin:?}). Omit it to \
install a single admin."
));
}
}
if let Some(registry_did) = inputs.registry_did.as_deref() {
let trimmed = registry_did.trim();
if !trimmed.is_empty() && !trimmed.starts_with("did:") {
errors.push(format!(
"registry_did must be a DID starting with `did:` (got {registry_did:?}) — the \
referral names the registry by DID, not by URL. Omit it for a community with \
no trust registry."
));
}
}
if let Some(messaging) = inputs.messaging.as_ref() {
if messaging.config.mediator_did.trim().is_empty() {
errors.push(
"messaging.mediator_did must not be empty when [messaging] is present".into(),
);
}
if messaging.transports.is_empty() {
errors.push(
"messaging.transports must name at least one transport when [messaging] is \
present — e.g. transports = [\"tsp\", \"didcomm\"]. An empty list connects this \
VTC to the mediator while advertising no way to reach it, and correcting the \
DID document afterwards takes a VTA-side edit. Omit the whole [messaging] table \
for a REST-only community."
.into(),
);
}
let mut seen = messaging.transports.clone();
seen.sort_unstable();
seen.dedup();
if seen.len() != messaging.transports.len() {
errors.push("messaging.transports must not repeat a transport".into());
}
}
if errors.is_empty() {
Ok(())
} else {
Err(AppError::Validation(format!(
"setup file has {} validation error(s):\n - {}",
errors.len(),
errors.join("\n - ")
)))
}
}
pub async fn run_setup_from_file(file_path: PathBuf, single_admin: bool) -> Result<(), AppError> {
eprintln!(
"Running non-interactive VTC setup from {} ...",
file_path.display()
);
let plan = parse_from_toml(&file_path, single_admin)?;
let outcome = apply(plan).await?;
print_setup_summary_terse(&outcome);
Ok(())
}
fn print_setup_summary_terse(outcome: &SetupOutcome) {
println!("VTC setup complete.");
println!("vtc_did={}", outcome.vtc_did);
println!("admin_did={}", outcome.admin_did);
println!("config_path={}", outcome.config_path.display());
println!("data_dir={}", outcome.data_dir.display());
println!("install_url={}", outcome.install_url);
println!("claim_code={}", outcome.claim_code);
println!("single_admin_mode={}", outcome.single_admin_mode);
println!(
"offline_admin_grant={}",
super::wizard::offline_acl_add_command(outcome)
);
if outcome.admin_key_json.is_some() {
println!(
"admin_key=<not printed in non-interactive mode; re-run interactively if you need it>"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_toml(config_path: &str, setup_key_file: &str) -> String {
format!(
r#"
config_path = "{config_path}"
base_url = "https://vtc.example.com"
vta_did = "did:webvh:vta.example.com:abc"
setup_key_file = "{setup_key_file}"
[secrets]
keyring_service = "vtc-test"
"#
)
}
fn persist_setup_key(dir: &Path) -> (PathBuf, String) {
let key = EphemeralSetupKey::generate().expect("generate setup key");
let did = key.did.clone();
let path = dir.join("setup-key.json");
key.persist_to(&path).expect("persist setup key");
(path, did)
}
#[test]
fn minimal_inputs_parse_and_default() {
let toml = minimal_toml("/srv/vtc/config.toml", "/tmp/key.json");
let inputs: VtcWizardInputs = toml::from_str(&toml).expect("parse");
assert_eq!(inputs.context, "default", "context defaults to `default`");
assert!(inputs.messaging.is_none());
assert!(inputs.webvh.server_id.is_none());
assert_eq!(inputs.secrets.keyring_service, "vtc-test");
}
#[test]
fn unknown_field_rejected() {
let toml = r#"
config_path = "/srv/vtc/config.toml"
base_url = "https://vtc.example.com"
vta_did = "did:web:vta.example.com"
setup_key_file = "/tmp/key.json"
bogus_field = true
[secrets]
"#;
let err = toml::from_str::<VtcWizardInputs>(toml).unwrap_err();
assert!(
err.to_string().contains("bogus_field"),
"deny_unknown_fields should reject bogus_field, got: {err}"
);
}
#[test]
fn full_inputs_parse() {
let toml = r#"
config_path = "/srv/vtc/config.toml"
base_url = "https://vtc.example.com/"
vta_did = "did:webvh:vta.example.com:abc"
context = "acme"
setup_key_file = "/secrets/vtc-setup-key.json"
[webvh]
server_id = "host-1"
domain = "tenant.example.com"
path = "communities/acme"
[messaging]
mediator_did = "did:web:mediator.example.com"
transports = ["tsp", "didcomm"]
[secrets]
keyring_service = "vtc-acme"
"#;
let inputs: VtcWizardInputs = toml::from_str(toml).expect("parse");
assert_eq!(inputs.context, "acme");
assert_eq!(inputs.webvh.server_id.as_deref(), Some("host-1"));
assert_eq!(inputs.webvh.domain.as_deref(), Some("tenant.example.com"));
assert_eq!(inputs.webvh.path.as_deref(), Some("communities/acme"));
let messaging = inputs.messaging.as_ref().unwrap();
assert_eq!(
messaging.config.mediator_did,
"did:web:mediator.example.com"
);
assert_eq!(
messaging.transports,
vec![Transport::Tsp, Transport::Didcomm]
);
}
#[test]
fn messaging_without_transports_is_refused() {
let err = toml::from_str::<VtcWizardInputs>(
r#"
config_path = "/srv/vtc/config.toml"
base_url = "https://vtc.example.com"
vta_did = "did:web:vta.example.com"
setup_key_file = "/tmp/key.json"
[messaging]
mediator_did = "did:web:mediator.example.com"
[secrets]
"#,
)
.unwrap_err()
.to_string();
assert!(
err.contains("transports"),
"must name the missing key: {err}"
);
}
#[test]
fn validate_rejects_an_empty_transport_list() {
let inputs: VtcWizardInputs = toml::from_str(
r#"
config_path = "/srv/vtc/config.toml"
base_url = "https://vtc.example.com"
vta_did = "did:web:vta.example.com"
setup_key_file = "/tmp/key.json"
[messaging]
mediator_did = "did:web:mediator.example.com"
transports = []
[secrets]
"#,
)
.expect("an empty list is valid TOML");
let err = validate(&inputs).unwrap_err().to_string();
assert!(err.contains("at least one transport"), "{err}");
assert!(
err.contains("Omit the whole [messaging] table"),
"must name the REST-only alternative: {err}"
);
}
#[test]
fn validate_rejects_duplicate_transports() {
let inputs: VtcWizardInputs = toml::from_str(
r#"
config_path = "/srv/vtc/config.toml"
base_url = "https://vtc.example.com"
vta_did = "did:web:vta.example.com"
setup_key_file = "/tmp/key.json"
[messaging]
mediator_did = "did:web:mediator.example.com"
transports = ["tsp", "tsp"]
[secrets]
"#,
)
.expect("parse");
let err = validate(&inputs).unwrap_err().to_string();
assert!(err.contains("must not repeat"), "{err}");
}
#[test]
fn no_messaging_table_needs_no_transports() {
let inputs: VtcWizardInputs = toml::from_str(
r#"
config_path = "/srv/vtc/config.toml"
base_url = "https://vtc.example.com"
vta_did = "did:web:vta.example.com"
setup_key_file = "/tmp/key.json"
[secrets]
"#,
)
.expect("parse");
assert!(inputs.messaging.is_none());
validate(&inputs).expect("REST-only is valid");
}
#[test]
fn a_single_transport_is_accepted() {
let inputs: VtcWizardInputs = toml::from_str(
r#"
config_path = "/srv/vtc/config.toml"
base_url = "https://vtc.example.com"
vta_did = "did:web:vta.example.com"
setup_key_file = "/tmp/key.json"
[messaging]
mediator_did = "did:web:mediator.example.com"
transports = ["didcomm"]
[secrets]
"#,
)
.expect("parse");
validate(&inputs).expect("didcomm-only is valid");
assert_eq!(
inputs.messaging.unwrap().transports,
vec![Transport::Didcomm]
);
}
#[test]
fn validate_rejects_bad_base_url_and_vta_did() {
let inputs: VtcWizardInputs = toml::from_str(
r#"
config_path = "/srv/vtc/config.toml"
base_url = "vtc.example.com/v1"
vta_did = "not-a-did"
setup_key_file = "/tmp/key.json"
[secrets]
"#,
)
.expect("parse");
let err = validate(&inputs).unwrap_err().to_string();
assert!(err.contains("base_url must start with http"), "{err}");
assert!(err.contains("/v1"), "{err}");
assert!(err.contains("vta_did must be a DID"), "{err}");
}
#[test]
fn validate_rejects_empty_mediator_did() {
let inputs: VtcWizardInputs = toml::from_str(
r#"
config_path = "/srv/vtc/config.toml"
base_url = "https://vtc.example.com"
vta_did = "did:web:vta.example.com"
setup_key_file = "/tmp/key.json"
[messaging]
mediator_did = ""
transports = ["didcomm"]
[secrets]
"#,
)
.expect("parse");
let err = validate(&inputs).unwrap_err().to_string();
assert!(err.contains("messaging.mediator_did"), "{err}");
}
#[test]
fn parse_from_toml_builds_plan_with_loaded_key() {
let dir = tempfile::tempdir().expect("tempdir");
let (key_path, key_did) = persist_setup_key(dir.path());
let toml = format!(
r#"
config_path = "{cfg}"
base_url = "https://vtc.example.com/"
vta_did = "did:webvh:vta.example.com:abc"
setup_key_file = "{key}"
[secrets]
keyring_service = "vtc-test"
"#,
cfg = dir.path().join("config.toml").display(),
key = key_path.display(),
);
let toml_path = dir.path().join("setup.toml");
std::fs::write(&toml_path, toml).expect("write toml");
let plan = parse_from_toml(&toml_path, false).expect("parse_from_toml");
assert_eq!(
plan.setup_key.did, key_did,
"the plan must carry the pre-authorised key loaded from disk"
);
assert_eq!(
plan.inputs.base_url, "https://vtc.example.com",
"trailing slash trimmed"
);
assert_eq!(plan.inputs.context, "default");
}
#[test]
fn parse_from_toml_reports_missing_setup_key() {
let dir = tempfile::tempdir().expect("tempdir");
let toml = minimal_toml(
dir.path().join("config.toml").to_str().unwrap(),
dir.path().join("does-not-exist.json").to_str().unwrap(),
);
let toml_path = dir.path().join("setup.toml");
std::fs::write(&toml_path, toml).expect("write toml");
let err = match parse_from_toml(&toml_path, false) {
Ok(_) => panic!("expected a missing-setup-key error"),
Err(e) => e.to_string(),
};
assert!(err.contains("load setup key"), "{err}");
assert!(err.contains("admin ACL"), "actionable hint present: {err}");
}
#[test]
fn vti_apv_022_single_admin_mode_from_the_file_or_the_flag() {
let dir = tempfile::tempdir().expect("tempdir");
let (key_path, _) = persist_setup_key(dir.path());
let write = |extra: &str| {
let toml = format!(
"{extra}\n{}",
minimal_toml(
dir.path().join("config.toml").to_str().unwrap(),
key_path.to_str().unwrap(),
)
);
let path = dir.path().join("setup.toml");
std::fs::write(&path, toml).expect("write toml");
path
};
let plain = write("");
let off = parse_from_toml(&plain, false)
.map_err(|e| e.to_string())
.unwrap();
assert!(!off.inputs.single_admin_mode, "off unless chosen");
let flagged = parse_from_toml(&plain, true)
.map_err(|e| e.to_string())
.unwrap();
assert!(flagged.inputs.single_admin_mode, "--single-admin sets it");
let in_file = write("single_admin_mode = true");
let on = parse_from_toml(&in_file, false)
.map_err(|e| e.to_string())
.unwrap();
assert!(on.inputs.single_admin_mode, "the file sets it");
}
#[test]
fn co_admin_did_is_optional_and_must_be_a_did() {
let none: VtcWizardInputs = toml::from_str(&minimal_toml_with("")).expect("parse");
assert!(none.co_admin_did.is_none());
validate(&none).expect("one admin is a valid install");
let some: VtcWizardInputs = toml::from_str(&minimal_toml_with(
r#"co_admin_did = "did:key:z6MkCoAdmin""#,
))
.expect("parse");
validate(&some).expect("a DID-valued co-admin is valid");
assert_eq!(
super::super::wizard::normalize_co_admin_did(" did:key:z6MkCoAdmin ").unwrap(),
Some("did:key:z6MkCoAdmin".to_string())
);
assert!(
super::super::wizard::normalize_co_admin_did(" ")
.unwrap()
.is_none()
);
let bad: VtcWizardInputs =
toml::from_str(&minimal_toml_with(r#"co_admin_did = "alice@example.com""#))
.expect("parse");
let err = validate(&bad).unwrap_err().to_string();
assert!(err.contains("co_admin_did"), "{err}");
}
fn minimal_toml_with(extra: &str) -> String {
format!(
r#"
config_path = "/srv/vtc/config.toml"
base_url = "https://vtc.example.com"
vta_did = "did:webvh:vta.example.com:abc"
setup_key_file = "/secrets/vtc-setup-key.json"
{extra}
[secrets]
keyring_service = "vtc"
"#
)
}
#[test]
fn registry_did_parses_and_validates() {
let inputs: VtcWizardInputs = toml::from_str(&minimal_toml_with(
r#"registry_did = "did:webvh:xyz:registry""#,
))
.expect("parse");
assert_eq!(
inputs.registry_did.as_deref(),
Some("did:webvh:xyz:registry")
);
validate(&inputs).expect("a DID-valued registry_did is valid");
}
#[test]
fn registry_did_is_optional() {
let inputs: VtcWizardInputs = toml::from_str(&minimal_toml_with("")).expect("parse");
assert!(inputs.registry_did.is_none());
validate(&inputs).expect("no registry is a valid community");
assert!(normalize_registry_did("").unwrap().is_none());
}
#[test]
fn registry_did_rejects_a_url() {
let inputs: VtcWizardInputs = toml::from_str(&minimal_toml_with(
r#"registry_did = "https://registry.example.com""#,
))
.expect("parse");
let err = validate(&inputs).expect_err("a URL is not a DID");
assert!(err.to_string().contains("registry_did"), "got: {err}");
}
#[test]
fn blank_registry_did_reads_as_no_registry() {
let inputs: VtcWizardInputs =
toml::from_str(&minimal_toml_with(r#"registry_did = " ""#)).expect("parse");
validate(&inputs).expect("blank is not an invalid DID, it is no registry");
assert!(normalize_registry_did(" ").unwrap().is_none());
}
#[test]
fn shipped_example_parses() {
let example = include_str!("../../../docs/03-vtc/examples/vtc-setup.example.toml");
let inputs: VtcWizardInputs = toml::from_str(example).expect("shipped example must parse");
assert!(inputs.base_url.starts_with("http"));
assert!(inputs.vta_did.starts_with("did:"));
validate(&inputs).expect("shipped example must validate");
}
}