use crate::store::keyspaces;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use chrono::{Duration as ChronoDuration, Utc};
use dialoguer::{Confirm, Input};
use serde_json::Value as JsonValue;
use tokio::sync::mpsc;
use tracing::warn;
use vta_sdk::client::VtaClient;
use vta_sdk::did_templates::{
DIDCOMM_SERVICE_VAR, TRUST_REGISTRY_SERVICE_VAR, TSP_SERVICE_VAR, didcomm_service,
referral_service, tsp_service,
};
use vta_sdk::provision_client::{
EphemeralSetupKey, OperatorMessages, ProvisionAsk, ProvisionResultV2, ResolvedVta, VtaEvent,
VtaIntent, VtaReply, resolve_vta, run_connection_test, run_provision_flight,
};
use vti_common::config::StoreConfig;
use vti_common::error::AppError;
use vti_common::setup::secrets_prompt::{
AvailableBackends, BackendResolvers, SecretsBackendChoice, SecretsPromptError,
configure_secrets,
};
use vti_common::store::Store;
use crate::config::{AppConfig, AuthConfig, LogConfig, MessagingConfig, SecretsConfig};
use crate::install::{
INSTALL_TOKEN_DEFAULT_TTL_SECS, InstallTokenSigner, InstallTokenStore, mint_install_token,
};
use crate::keys::seed_store::create_secret_store;
use crate::setup::VtcKeyBundle;
pub async fn run_setup_wizard(
config_path: Option<PathBuf>,
single_admin: bool,
) -> Result<(), AppError> {
intro_banner();
let plan = collect_interactive(config_path, single_admin).await?;
let outcome = apply(plan).await?;
print_setup_summary_interactive(&outcome)?;
Ok(())
}
async fn collect_interactive(
config_path: Option<PathBuf>,
single_admin: bool,
) -> Result<WizardPlan, AppError> {
let config_path = prompt_config_path(config_path)?;
refuse_if_already_set_up(&config_path)?;
let inputs = prompt_inputs(single_admin)?;
let resolved: Option<ResolvedVta> = match resolve_vta(&inputs.vta_did).await {
Ok(r) => Some(r),
Err(e) => {
warn!(
error = %e,
vta_did = %inputs.vta_did,
"could not resolve VTA DID — mediator suggestion + did-hosting picker unavailable"
);
None
}
};
let messaging = prompt_messaging(resolved.as_ref().and_then(|r| r.mediator_did.clone()))?;
let transports = match messaging.as_ref() {
Some(m) => prompt_transports(&m.mediator_did)?,
None => Vec::new(),
};
let setup_key = EphemeralSetupKey::generate()
.map_err(|e| AppError::Internal(format!("failed to generate ephemeral setup key: {e}")))?;
print_acl_step(&inputs, &setup_key);
if !Confirm::new()
.with_prompt("Has the ACL grant been created at the VTA?")
.default(false)
.interact()
.map_err(prompt_err)?
{
return Err(AppError::Validation("setup aborted".into()));
}
let webvh = select_webvh_target(resolved.as_ref(), &setup_key).await?;
let secrets = prompt_secrets_config()?;
Ok(WizardPlan {
config_path,
inputs: WizardInputs {
transports,
..inputs
},
webvh,
secrets,
messaging,
setup_key,
})
}
pub(crate) async fn apply(plan: WizardPlan) -> Result<SetupOutcome, AppError> {
let WizardPlan {
config_path,
inputs,
webvh,
secrets,
messaging,
setup_key,
} = plan;
let provision = run_provision_quietly(&inputs, &webvh, messaging.as_ref(), &setup_key).await?;
let integration_did = provision
.integration_did()
.ok_or_else(|| {
AppError::Internal(
"VTA returned a bundle with no integration DID — vtc-host template should mint \
one"
.into(),
)
})?
.to_string();
let integration_key = provision.integration_key().ok_or_else(|| {
AppError::Internal(
"VTA returned a bundle with no integration key material — vtc-host bundle is \
malformed"
.into(),
)
})?;
let bundle = VtcKeyBundle::from_did_key_material_v2(integration_did.clone(), integration_key);
let did_log = provision.webvh_log().ok_or_else(|| {
AppError::Internal(
"vtc-host template did not produce a did.jsonl output — the VTC must be a did:webvh"
.into(),
)
})?;
let data_dir = default_data_dir_for(&config_path);
let label = did_log_label_or_err(&integration_did)?;
write_did_log(&data_dir, &label, did_log)?;
let mut app_config = build_app_config(
config_path.clone(),
integration_did.clone(),
inputs.vta_did.clone(),
inputs.base_url.clone(),
data_dir.clone(),
secrets,
messaging,
)?;
app_config.acl.single_admin_mode = inputs.single_admin_mode;
app_config.vta_context = Some(inputs.context.clone());
let bundle_bytes = bundle.to_secret_store_bytes()?;
let inline_config_backend = app_config.secrets.secret.is_some()
|| matches!(
app_config.secrets.backend,
Some(crate::config::SecretBackend::Config)
);
if inline_config_backend {
app_config.secrets.secret = Some(hex::encode(&bundle_bytes));
write_config_toml(&config_path, &app_config)?;
} else {
write_config_toml(&config_path, &app_config)?;
let secret_store = create_secret_store(&app_config)
.map_err(|e| AppError::Config(format!("failed to construct secret store: {e}")))?;
secret_store
.set(&bundle_bytes)
.await
.map_err(|e| AppError::SecretStore(format!("failed to store VTC key bundle: {e}")))?;
}
open_keyspaces(&app_config)?;
let admin_did = provision.admin_did().to_string();
if let Some(warning) = single_admin_warning(&inputs) {
eprintln!("{warning}");
}
if inputs.co_admin_did.as_deref() == Some(admin_did.as_str()) {
return Err(AppError::Config(format!(
"the second administrator ({admin_did}) is the first one; name a different DID, \
or leave it out"
)));
}
let (install_url, claim_code) = mint_initial_install_token(
&app_config,
&bundle,
&admin_did,
inputs.co_admin_did.as_deref(),
&inputs.base_url,
)
.await?;
let admin_key_summary = provision
.admin_key()
.and_then(|k| serde_json::to_string_pretty(k).ok());
Ok(SetupOutcome {
vtc_did: integration_did,
admin_did,
config_path,
data_dir,
install_url,
claim_code,
admin_key_json: admin_key_summary,
single_admin_mode: inputs.single_admin_mode,
})
}
pub(crate) fn single_admin_warning(inputs: &WizardInputs) -> Option<String> {
(inputs.single_admin_mode && inputs.co_admin_did.is_some()).then(|| {
"warning: single-administrator mode is set, and a second administrator \
(co_admin_did) is installed too. The mode waives another administrator's consent \
whether or not other administrators exist, so neither administrator's operations will \
wait for the other. Keep it only if co_admin_did is another of your own identifiers \
(VTI-APV-022)."
.to_string()
})
}
fn print_setup_summary_interactive(outcome: &SetupOutcome) -> Result<(), AppError> {
println!();
println!("\x1b[1;32m✅ VTC setup complete.\x1b[0m");
println!();
println!("VTC DID: {}", outcome.vtc_did);
println!("Admin DID: {}", outcome.admin_did);
println!("Config: {}", outcome.config_path.display());
println!("Data dir: {}", outcome.data_dir.display());
if outcome.single_admin_mode {
println!(
"Single-administrator mode: on ([acl] single_admin_mode in config.toml; \
VTI-APV-022)"
);
}
println!();
if let Some(key_json) = outcome.admin_key_json.as_deref() {
let reveal = Confirm::new()
.with_prompt(
"Display the admin private key now? It's needed for CLI access and is shown only once",
)
.default(false)
.interact()
.map_err(prompt_err)?;
if reveal {
println!("\x1b[1;33mAdmin key (save this — needed for CLI access):\x1b[0m");
println!("{key_json}");
} else {
println!(
"\x1b[1;33mAdmin key not displayed.\x1b[0m Re-run setup if you need it; it is \
not persisted."
);
}
println!();
}
println!("\x1b[1mInstall URL (one-shot, 15 min TTL):\x1b[0m");
println!(" {}", outcome.install_url);
println!();
println!("\x1b[1mClaim code (required at claim time):\x1b[0m");
println!(" {}", outcome.claim_code);
println!();
println!("Both URL and code are needed to claim the passkey. The code is shown");
println!("only once and not persisted — copy it before continuing.");
println!();
println!("Next steps:");
println!(" 1. Run `vtc` to start the daemon.");
println!(" 2. Open the install URL in your browser.");
println!(" 3. Enter the claim code when prompted, then register your passkey.");
println!();
println!("The ACL stays empty until the install URL is claimed. Without a browser,");
println!("seed the admin with the daemon stopped:");
println!(" {}", offline_acl_add_command(outcome));
println!();
Ok(())
}
pub(crate) fn offline_acl_add_command(outcome: &SetupOutcome) -> String {
format!(
"vtc --config {} acl add --did {} --role admin --label \"first admin\"",
outcome.config_path.display(),
outcome.admin_did
)
}
pub(crate) struct WizardInputs {
pub(crate) base_url: String,
pub(crate) vta_did: String,
pub(crate) context: String,
pub(crate) registry_did: Option<String>,
pub(crate) transports: Vec<Transport>,
pub(crate) co_admin_did: Option<String>,
pub(crate) single_admin_mode: bool,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Deserialize, serde::Serialize,
)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Transport {
Tsp,
Didcomm,
}
impl Transport {
pub(crate) fn as_str(self) -> &'static str {
match self {
Transport::Tsp => "tsp",
Transport::Didcomm => "didcomm",
}
}
}
pub(crate) struct WizardPlan {
pub(crate) config_path: PathBuf,
pub(crate) inputs: WizardInputs,
pub(crate) webvh: WebvhTarget,
pub(crate) secrets: SecretsConfig,
pub(crate) messaging: Option<MessagingConfig>,
pub(crate) setup_key: EphemeralSetupKey,
}
pub(crate) struct SetupOutcome {
pub(crate) vtc_did: String,
pub(crate) admin_did: String,
pub(crate) config_path: PathBuf,
pub(crate) data_dir: PathBuf,
pub(crate) install_url: String,
pub(crate) claim_code: String,
pub(crate) admin_key_json: Option<String>,
pub(crate) single_admin_mode: bool,
}
#[derive(Default, Debug, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct WebvhTarget {
#[serde(default)]
pub(crate) server_id: Option<String>,
#[serde(default)]
pub(crate) domain: Option<String>,
#[serde(default)]
pub(crate) path: Option<String>,
}
fn prompt_config_path(initial: Option<PathBuf>) -> Result<PathBuf, AppError> {
let default = initial
.map(|p| p.to_string_lossy().into_owned())
.or_else(|| std::env::var("VTC_CONFIG_PATH").ok())
.unwrap_or_else(|| "config.toml".into());
let path: String = Input::new()
.with_prompt("Config file path")
.default(default)
.interact_text()
.map_err(prompt_err)?;
Ok(PathBuf::from(path))
}
pub(crate) fn refuse_if_already_set_up(config_path: &std::path::Path) -> Result<(), AppError> {
if !config_path.exists() {
return Ok(());
}
if let Ok(existing) = AppConfig::load(Some(config_path.to_path_buf()))
&& existing.vtc_did.is_some()
{
return Err(AppError::Config(format!(
"VTC already configured at {} (vtc_did = {:?}). \
Move the config aside or pass `--config <other-path>` to set up a fresh community.",
config_path.display(),
existing.vtc_did
)));
}
Ok(())
}
fn prompt_inputs(single_admin: bool) -> Result<WizardInputs, AppError> {
println!();
println!("Provisioning a fresh VTC requires the daemon's base URL, the VTA's DID,");
println!("and the context name. The VTA's transport endpoints are resolved from");
println!("its DID document — no separate VTA URL is needed.");
println!();
println!("The VTC daemon serves three surfaces — API, admin UX, public website —");
println!("all mounted under one base URL by default:");
println!();
println!(" Base URL https://vtc.example.com");
println!(" API https://vtc.example.com/v1/...");
println!(" Admin UX https://vtc.example.com/admin");
println!(" Website https://vtc.example.com/");
println!();
println!("If you want separate subdomains per surface (e.g. api.vtc.example.com,");
println!("admin.vtc.example.com), keep the base URL as the public-website host");
println!("here and add [routing.api].host / [routing.admin_ui].host to config.toml");
println!("after setup. See docs/03-vtc/website-and-admin.md.");
println!();
let base_url: String = Input::new()
.with_prompt("VTC base URL (no trailing slash, no /v1, e.g. https://vtc.example.com)")
.interact_text()
.map_err(prompt_err)?;
let base_url = base_url.trim_end_matches('/').to_string();
let vta_did: String = Input::new()
.with_prompt("VTA DID (e.g. did:webvh:vta.example.com:abc)")
.interact_text()
.map_err(prompt_err)?;
let context: String = Input::new()
.with_prompt("Context name at the VTA for this community")
.default("default".into())
.interact_text()
.map_err(prompt_err)?;
println!();
println!("If this community has a trust registry, naming it here publishes a");
println!("TrustRegistry referral in the VTC's DID document, so a client that");
println!("holds only this community's DID can resolve one hop to the registry");
println!("rather than being configured with both DIDs.");
println!();
println!("You can change it later: `pnm did-mgmt dids edit` at the VTA, then, if");
println!("this VTC serves its own DID log, `cnm did-log install`. Leave blank if");
println!("the community has no registry, or if you don't know it yet.");
println!();
let registry_did: String = Input::new()
.with_prompt("Trust registry DID (blank for none)")
.allow_empty(true)
.interact_text()
.map_err(prompt_err)?;
let registry_did = normalize_registry_did(®istry_did)?;
println!();
println!("Making anyone an unrestricted administrator of this community needs");
println!("the consent of another unrestricted administrator. With only one, there");
println!("is nobody to give it: a second could then only be added offline, with");
println!("the daemon stopped. Name a second administrator's DID here to install");
println!("both. They need no passkey to approve — an approval is a document their");
println!("DID signs. Leave blank to install one.");
println!();
let co_admin_did: String = Input::new()
.with_prompt("Second administrator DID (blank for none)")
.allow_empty(true)
.interact_text()
.map_err(prompt_err)?;
let co_admin_did = normalize_co_admin_did(&co_admin_did)?;
let single_admin_mode = if single_admin {
true
} else {
println!();
println!("A community run by one person has nobody to give that consent. In");
println!("single-administrator mode, your own passkey gesture bound to the");
println!("operation authorizes it instead — even if you administer from several");
println!("identifiers, one per device — audited at the highest severity, and");
println!("shown to every administrator in every session. Turn it on only if every");
println!("administrator is you. It can be changed only on this host, in");
println!("config.toml (VTI-APV-022).");
println!();
Confirm::new()
.with_prompt("Run as a single-administrator community?")
.default(false)
.interact()
.map_err(prompt_err)?
};
Ok(WizardInputs {
co_admin_did,
single_admin_mode,
base_url,
vta_did,
context,
registry_did,
transports: Vec::new(),
})
}
pub(crate) fn normalize_co_admin_did(raw: &str) -> Result<Option<String>, AppError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(None);
}
if !trimmed.starts_with("did:") {
return Err(AppError::Config(format!(
"the second administrator must be named by DID, got '{trimmed}'"
)));
}
Ok(Some(trimmed.to_string()))
}
pub(crate) fn normalize_registry_did(raw: &str) -> Result<Option<String>, AppError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(None);
}
if !trimmed.starts_with("did:") {
return Err(AppError::Config(format!(
"trust registry must be named by DID, got '{trimmed}'. The referral published in \
the VTC's DID document names the registry by DID, not by URL."
)));
}
Ok(Some(trimmed.to_string()))
}
fn prompt_messaging(vta_mediator: Option<String>) -> Result<Option<MessagingConfig>, AppError> {
use dialoguer::Select;
println!();
let mut labels: Vec<String> = Vec::new();
let mut tags: Vec<&str> = Vec::new();
if let Some(med) = vta_mediator.as_deref() {
labels.push(format!("Use the VTA's mediator ({med})"));
tags.push("vta-mediator");
} else {
println!(" Note: the VTA's DID document does not advertise a DIDComm mediator;");
println!(" you'll need to supply one yourself or skip messaging.");
println!();
}
labels.push("Specify a different mediator DID".to_string());
tags.push("custom");
labels.push("Skip messaging (DIDComm disabled)".to_string());
tags.push("skip");
let idx = Select::new()
.with_prompt("DIDComm messaging")
.items(&labels)
.default(0)
.interact()
.map_err(prompt_err)?;
let mediator_did = match tags[idx] {
"vta-mediator" => vta_mediator.expect("present when tag was inserted"),
"custom" => Input::new()
.with_prompt("Mediator DID (must start with `did:`)")
.validate_with(|input: &String| -> Result<(), String> {
if input.starts_with("did:") {
Ok(())
} else {
Err("DID must start with 'did:' (e.g. did:webvh:... or did:key:...)".into())
}
})
.interact_text()
.map_err(prompt_err)?,
"skip" => return Ok(None),
other => {
return Err(AppError::Internal(format!(
"internal: unknown messaging tag '{other}'"
)));
}
};
Ok(Some(MessagingConfig {
mediator_url: String::new(),
mediator_did,
mediator_host: None,
setup_acl: false,
drain_inbox_on_start: false,
}))
}
fn prompt_transports(mediator_did: &str) -> Result<Vec<Transport>, AppError> {
use dialoguer::MultiSelect;
println!();
println!(" Which transports should this community advertise in its DID document?");
println!();
println!(" Both entries point at your mediator:");
println!(" {mediator_did}");
println!();
println!(" ⚠ Advertising a transport your mediator does not route makes this");
println!(" community unreachable on it — clients will prefer it and fail.");
println!(" Check your mediator advertises the matching service before enabling.");
println!();
println!(" To change this later: `pnm did-mgmt dids edit` at the VTA, then, if this");
println!(" VTC serves its own DID log, `cnm did-log install`.");
println!();
let options = [
(
"TSP (TSPTransport) — preferred transport",
Transport::Tsp,
),
(
"DIDComm (DIDCommMessaging) — supported fallback",
Transport::Didcomm,
),
];
let labels: Vec<&str> = options.iter().map(|(label, _)| *label).collect();
loop {
let picked = MultiSelect::new()
.with_prompt("Transports (space to toggle, enter to confirm)")
.items(&labels)
.defaults(&[true, true])
.interact()
.map_err(prompt_err)?;
if picked.is_empty() {
println!();
println!(" No transport selected — this community would connect to the mediator");
println!(" but advertise no way to reach it. Select at least one, or restart");
println!(" setup and choose \"Skip messaging\" if that is what you want.");
println!();
continue;
}
let chosen: Vec<Transport> = picked.into_iter().map(|i| options[i].1).collect();
println!();
println!(
" Advertising: {}",
chosen
.iter()
.map(|t| t.as_str())
.collect::<Vec<_>>()
.join(", ")
);
if chosen.len() == 1 && chosen[0] == Transport::Tsp {
println!(
" Note: TSP only — peers that do not speak TSP will have no way to reach this"
);
println!(" community over a mediator.");
}
println!();
return Ok(chosen);
}
}
async fn select_webvh_target(
resolved: Option<&ResolvedVta>,
setup_key: &EphemeralSetupKey,
) -> Result<WebvhTarget, AppError> {
println!();
println!("\x1b[1mDID hosting\x1b[0m");
println!(" Your community is identified by a `did:webvh` DID of the form:");
println!();
println!(" did:webvh:<scid>:<host>:<path>");
println!();
println!(" <scid> is a self-certifying id the VTA generates for you. <host> is the");
println!(" did-hosting server's domain and <path> is an optional label under it.");
println!(" This is the DID every member, credential, and trust-registry entry will");
println!(" reference — choose its host and path deliberately.");
println!();
println!(" Serverless instead drops the <path>: the VTC self-hosts its own");
println!(" did.jsonl at <host>/.well-known/did.jsonl, served by this daemon — no");
println!(" external did-hosting server required.");
println!();
let client = match resolved {
Some(r) => match connect_setup_client(r, setup_key).await {
Ok(c) => Some(c),
Err(e) => {
warn!(error = %e, "could not connect to the VTA to list did-hosting servers");
println!(
" Could not reach the VTA to list did-hosting servers ({e}); the VTA \
will auto-select one (or self-host)."
);
None
}
},
None => {
println!(
" The VTA DID didn't resolve, so an interactive server/domain picker isn't \
available; the VTA will auto-select a server (or self-host)."
);
None
}
};
let Some(client) = client else {
return Ok(WebvhTarget::default());
};
let target = prompt_webvh_target(&client).await;
client.shutdown().await;
target
}
async fn prompt_webvh_target(client: &VtaClient) -> Result<WebvhTarget, AppError> {
let server_id = prompt_webvh_server(client).await?;
let (domain, path) = match server_id.as_deref() {
Some(sid) => {
let domain = prompt_webvh_domain(client, sid).await?;
let path = prompt_webvh_path(sid)?;
(domain, path)
}
None => (None, None),
};
Ok(WebvhTarget {
server_id,
domain,
path,
})
}
async fn connect_setup_client(
resolved: &ResolvedVta,
setup_key: &EphemeralSetupKey,
) -> Result<VtaClient, AppError> {
if let Some(rest_url) = resolved.rest_url.as_deref() {
let http = reqwest::Client::new();
let auth = vta_sdk::auth_light::challenge_response_light(
&http,
rest_url,
&setup_key.did,
setup_key.private_key_multibase(),
&resolved.vta_did,
)
.await
.map_err(|e| AppError::Internal(format!("VTA REST authentication failed: {e}")))?;
let client = VtaClient::authenticated(
rest_url,
vta_sdk::client::ClientIdentity {
client_did: setup_key.did.clone(),
private_key_multibase: setup_key.private_key_multibase().to_string(),
vta_did: resolved.vta_did.clone(),
verification_method: None,
},
auth.access_token,
)
.await;
return Ok(client);
}
if let Some(mediator_did) = resolved.mediator_did.as_deref() {
return VtaClient::connect_didcomm(
&setup_key.did,
setup_key.private_key_multibase(),
&resolved.vta_did,
mediator_did,
resolved.rest_url.clone(),
)
.await
.map_err(|e| AppError::Internal(format!("VTA DIDComm connection failed: {e}")));
}
Err(AppError::Internal(
"VTA advertises neither a REST nor a DIDComm transport".into(),
))
}
async fn prompt_webvh_server(client: &VtaClient) -> Result<Option<String>, AppError> {
use dialoguer::Select;
let servers = match client.list_webvh_servers().await {
Ok(body) => body.servers,
Err(e) => {
println!(" Could not list did-hosting servers ({e}); defaulting to serverless.");
return Ok(None);
}
};
if servers.is_empty() {
println!(" No did-hosting servers are registered with this VTA — the VTC will");
println!(" self-host its `did.jsonl` at the base URL (serverless).");
return Ok(None);
}
let mut labels: Vec<String> = servers
.iter()
.map(|s| match s.label.as_deref() {
Some(label) if !label.is_empty() => format!("{} — {label} ({})", s.id, s.did),
_ => format!("{} ({})", s.id, s.did),
})
.collect();
labels.push("Serverless — self-host did.jsonl at the VTC base URL".to_string());
let idx = Select::new()
.with_prompt("Where should the VTC DID be published?")
.items(&labels)
.default(0)
.interact()
.map_err(prompt_err)?;
if idx == servers.len() {
Ok(None)
} else {
Ok(Some(servers[idx].id.clone()))
}
}
async fn prompt_webvh_domain(
client: &VtaClient,
server_id: &str,
) -> Result<Option<String>, AppError> {
use dialoguer::Select;
let domains = match client.list_webvh_server_domains(server_id).await {
Ok(d) => d,
Err(e) => {
warn!(error = %e, server_id, "could not list hosting domains");
println!(
" Could not list hosting domains on `{server_id}` ({e}); using the \
server's default domain."
);
return Ok(None);
}
};
if domains.domains.len() < 2 {
return Ok(None);
}
let mut labels: Vec<String> = domains
.domains
.iter()
.map(|d| {
let default = if d.default_domain { " (default)" } else { "" };
let disabled = if d.status == "disabled" {
" [disabled]"
} else {
""
};
match d.label.as_deref() {
Some(l) if !l.is_empty() => format!("{}{default}{disabled} — {l}", d.name),
_ => format!("{}{default}{disabled}", d.name),
}
})
.collect();
labels.push("Use the server's default domain".to_string());
let default_idx = domains
.domains
.iter()
.position(|d| d.default_domain)
.unwrap_or(domains.domains.len());
let idx = Select::new()
.with_prompt(format!("Tenant domain on `{server_id}`"))
.items(&labels)
.default(default_idx)
.interact()
.map_err(prompt_err)?;
if idx == domains.domains.len() {
Ok(None)
} else {
Ok(Some(domains.domains[idx].name.clone()))
}
}
fn prompt_webvh_path(server_id: &str) -> Result<Option<String>, AppError> {
println!();
println!(" Optional path label under the hosting server `{server_id}`. It becomes the");
println!(" trailing `<path>` of the VTC DID — e.g. `acme` yields a DID ending");
println!(" `:acme`. Operators with a naming convention (community slug, env)");
println!(" can pin it; leave blank to let the server assign one.");
let raw: String = Input::new()
.with_prompt("WebVH path (blank → server-assigned)")
.default(String::new())
.allow_empty(true)
.interact_text()
.map_err(prompt_err)?;
let trimmed = raw.trim();
Ok(if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
})
}
fn prompt_secrets_config() -> Result<SecretsConfig, AppError> {
#[cfg_attr(
not(any(
feature = "aws-secrets",
feature = "gcp-secrets",
feature = "azure-secrets"
)),
allow(unused_mut)
)]
let mut resolvers = BackendResolvers::empty();
#[cfg(feature = "aws-secrets")]
{
resolvers.aws = Some(Box::new(aws_resolver));
}
#[cfg(feature = "gcp-secrets")]
{
resolvers.gcp = Some(Box::new(gcp_resolver));
}
#[cfg(feature = "azure-secrets")]
{
resolvers.azure = Some(Box::new(azure_resolver));
}
let choice = configure_secrets(
&AvailableBackends {
keyring: cfg!(feature = "keyring"),
aws: cfg!(feature = "aws-secrets"),
gcp: cfg!(feature = "gcp-secrets"),
azure: cfg!(feature = "azure-secrets"),
k8s: cfg!(feature = "k8s-secrets"),
inline_config: cfg!(feature = "config-secret"),
plaintext: true,
},
"vtc",
resolvers,
)
.map_err(secrets_prompt_err)?;
Ok(secrets_choice_to_config(choice))
}
#[cfg(feature = "aws-secrets")]
fn aws_resolver() -> Result<(String, Option<String>), SecretsPromptError> {
let region: String = dialoguer::Input::new()
.with_prompt("AWS region (leave empty for SDK default)")
.allow_empty(true)
.interact_text()?;
let region_opt = if region.is_empty() {
None
} else {
Some(region)
};
let listing = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(vti_secrets::discovery::list_aws_secrets(
region_opt.as_deref(),
))
});
let default_name = "vtc-master-seed";
let secret_name = match listing {
Ok(names) if !names.is_empty() => {
let mut items: Vec<String> = names;
items.push("Create new secret".into());
let pick = dialoguer::Select::new()
.with_prompt("Select an existing secret or create a new one")
.items(&items)
.default(0)
.interact()?;
if pick == items.len() - 1 {
dialoguer::Input::new()
.with_prompt("AWS Secrets Manager secret name")
.default(default_name.into())
.interact_text()?
} else {
items.swap_remove(pick)
}
}
Ok(_) => {
eprintln!(" No existing secrets found in this region.");
dialoguer::Input::new()
.with_prompt("AWS Secrets Manager secret name")
.default(default_name.into())
.interact_text()?
}
Err(e) => {
warn!(error = %e, "could not list AWS secrets");
eprintln!(" Warning: could not list secrets ({e}).");
dialoguer::Input::new()
.with_prompt("AWS Secrets Manager secret name")
.default(default_name.into())
.interact_text()?
}
};
Ok((secret_name, region_opt))
}
#[cfg(feature = "gcp-secrets")]
fn gcp_resolver() -> Result<(String, String), SecretsPromptError> {
let project: String = dialoguer::Input::new()
.with_prompt("GCP project ID")
.interact_text()?;
let listing = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current()
.block_on(vti_secrets::discovery::list_gcp_secrets(&project))
});
let default_name = "vtc-master-seed";
let secret_name = match listing {
Ok(names) if !names.is_empty() => {
let mut items: Vec<String> = names;
items.push("Create new secret".into());
let pick = dialoguer::Select::new()
.with_prompt("Select an existing secret or create a new one")
.items(&items)
.default(0)
.interact()?;
if pick == items.len() - 1 {
dialoguer::Input::new()
.with_prompt("GCP Secret Manager secret name")
.default(default_name.into())
.interact_text()?
} else {
items.swap_remove(pick)
}
}
Ok(_) => {
eprintln!(" No existing secrets found in this project.");
dialoguer::Input::new()
.with_prompt("GCP Secret Manager secret name")
.default(default_name.into())
.interact_text()?
}
Err(e) => {
warn!(error = %e, "could not list GCP secrets");
eprintln!(" Warning: could not list secrets ({e}).");
dialoguer::Input::new()
.with_prompt("GCP Secret Manager secret name")
.default(default_name.into())
.interact_text()?
}
};
Ok((project, secret_name))
}
#[cfg(feature = "azure-secrets")]
fn azure_resolver() -> Result<(String, String), SecretsPromptError> {
let vault_url: String = dialoguer::Input::new()
.with_prompt("Azure Key Vault URL (e.g. https://my-vault.vault.azure.net)")
.interact_text()?;
let listing = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current()
.block_on(vti_secrets::discovery::list_azure_secrets(&vault_url))
});
let default_name = "vtc-master-seed";
let secret_name = match listing {
Ok(names) if !names.is_empty() => {
let mut items: Vec<String> = names;
items.push("Create new secret".into());
let pick = dialoguer::Select::new()
.with_prompt("Select an existing secret or create a new one")
.items(&items)
.default(0)
.interact()?;
if pick == items.len() - 1 {
dialoguer::Input::new()
.with_prompt("Azure Key Vault secret name")
.default(default_name.into())
.interact_text()?
} else {
items.swap_remove(pick)
}
}
Ok(_) => {
eprintln!(" No existing secrets found in this vault.");
dialoguer::Input::new()
.with_prompt("Azure Key Vault secret name")
.default(default_name.into())
.interact_text()?
}
Err(e) => {
warn!(error = %e, "could not list Azure secrets");
eprintln!(" Warning: could not list secrets ({e}).");
dialoguer::Input::new()
.with_prompt("Azure Key Vault secret name")
.default(default_name.into())
.interact_text()?
}
};
Ok((vault_url, secret_name))
}
fn secrets_choice_to_config(choice: SecretsBackendChoice) -> SecretsConfig {
let mut config = SecretsConfig::default();
match choice {
SecretsBackendChoice::Keyring { service } => config.keyring_service = service,
SecretsBackendChoice::Aws {
secret_name,
region,
} => {
config.aws_secret_name = Some(secret_name);
config.aws_region = region;
}
SecretsBackendChoice::Gcp {
project,
secret_name,
} => {
config.gcp_project = Some(project);
config.gcp_secret_name = Some(secret_name);
}
SecretsBackendChoice::Azure {
vault_url,
secret_name,
} => {
config.azure_vault_url = Some(vault_url);
config.azure_secret_name = Some(secret_name);
}
SecretsBackendChoice::Kubernetes {
secret_name,
namespace,
secret_key,
} => {
config.k8s_secret_name = Some(secret_name);
config.k8s_namespace = namespace;
if !secret_key.is_empty() {
config.k8s_secret_key = secret_key;
}
}
SecretsBackendChoice::InlineConfig => {
config.secret = Some(String::new());
}
SecretsBackendChoice::Plaintext => {
config.backend = Some(crate::config::SecretBackend::Plaintext);
}
}
config
}
async fn run_provision_quietly(
inputs: &WizardInputs,
webvh: &WebvhTarget,
messaging: Option<&MessagingConfig>,
setup_key: &EphemeralSetupKey,
) -> Result<ProvisionResultV2, AppError> {
let mut vars = BTreeMap::new();
vars.insert(
"URL".to_string(),
JsonValue::String(inputs.base_url.clone()),
);
if let Some(server) = webvh.server_id.as_deref() {
vars.insert(
"WEBVH_SERVER".to_string(),
JsonValue::String(server.to_string()),
);
}
if let Some(domain) = webvh.domain.as_deref() {
vars.insert(
"WEBVH_DOMAIN".to_string(),
JsonValue::String(domain.to_string()),
);
}
if let Some(path) = webvh.path.as_deref() {
vars.insert(
"WEBVH_PATH".to_string(),
JsonValue::String(path.to_string()),
);
}
if let Some(registry_did) = inputs.registry_did.as_deref() {
vars.insert(
TRUST_REGISTRY_SERVICE_VAR.to_string(),
referral_service(registry_did)
.map_err(|e| AppError::Config(format!("trust-registry referral: {e}")))?,
);
}
if !inputs.transports.is_empty() {
let mediator_did = messaging.map(|m| m.mediator_did.as_str()).ok_or_else(|| {
AppError::Internal(
"transports selected with no mediator configured — the setup front-ends must \
not produce this"
.into(),
)
})?;
for transport in &inputs.transports {
let (var, entry) = match transport {
Transport::Tsp => (
TSP_SERVICE_VAR,
tsp_service(mediator_did)
.map_err(|e| AppError::Config(format!("TSP service entry: {e}")))?,
),
Transport::Didcomm => (
DIDCOMM_SERVICE_VAR,
didcomm_service(mediator_did)
.map_err(|e| AppError::Config(format!("DIDComm service entry: {e}")))?,
),
};
vars.insert(var.to_string(), entry);
}
}
let ask = ProvisionAsk::for_template("vtc-host", vars, inputs.context.clone())
.with_label("vtc-host integration");
let reply = drive_provision(inputs.vta_did.clone(), setup_key, ask).await?;
match reply {
VtaReply::Full(result) => Ok(*result),
VtaReply::AdminOnly(_) => Err(AppError::Internal(
"VTA returned an admin-only reply but the vtc-host template requires a \
FullSetup reply"
.into(),
)),
}
}
async fn drive_provision(
vta_did: String,
setup_key: &EphemeralSetupKey,
ask: ProvisionAsk,
) -> Result<VtaReply, AppError> {
let (tx, mut rx) = mpsc::unbounded_channel();
tokio::spawn(run_connection_test(
VtaIntent::FullSetup,
vta_did.clone(),
setup_key.did.clone(),
setup_key.private_key_multibase().to_string(),
ask.clone(),
None,
tx,
));
while let Some(ev) = rx.recv().await {
match ev {
VtaEvent::Connected { reply, .. } => return Ok(reply),
VtaEvent::Failed(msg) => return Err(provision_failed(&msg)),
VtaEvent::PreflightDone {
rest_url,
mediator_did,
..
} => {
let (ftx, mut frx) = mpsc::unbounded_channel();
tokio::spawn(run_provision_flight(
vta_did.clone(),
setup_key.did.clone(),
setup_key.private_key_multibase().to_string(),
mediator_did,
rest_url,
ask.clone(),
None,
None,
Arc::new(VtcHostMessages),
ftx,
));
while let Some(fev) = frx.recv().await {
match fev {
VtaEvent::Connected { reply, .. } => return Ok(reply),
VtaEvent::Failed(msg) => return Err(provision_failed(&msg)),
_ => {}
}
}
return Err(provision_failed(
"provisioning ended without a terminal event",
));
}
_ => {}
}
}
Err(provision_failed(
"provisioning ended without a terminal event",
))
}
fn provision_failed(msg: &str) -> AppError {
const TRANSPORT_MARKERS: &[&str] = &[
"timed out",
"request timed out",
"bad gateway",
"transport error",
"failed to send message",
];
let lower = msg.to_ascii_lowercase();
let hint = if TRANSPORT_MARKERS.iter().any(|m| lower.contains(m)) {
"The VTA accepted the request but could not complete it — this is a transport failure \
between the VTA and one of its peers (its DID-hosting server or mediator), not an \
authorization problem. Check the VTA's own logs for the operation that stalled, and \
that its DID-hosting server and mediator are reachable and version-compatible."
} else {
"Double-check the VTA URL/DID and that the ephemeral DID was authorized via \
`pnm contexts create` (or `pnm acl create` if the context already exists)."
};
AppError::Internal(format!("VTA provisioning failed: {msg}. {hint}"))
}
#[cfg(test)]
mod provision_failed_tests {
use super::provision_failed;
#[test]
fn transport_timeout_does_not_blame_the_acl() {
let err = provision_failed(
"Provisioning failed. Details: provision-integration call failed: server error \
(500): bad gateway: failed to send message: transport error: request timed out \
after 30s",
);
let rendered = format!("{err:?}");
assert!(
!rendered.contains("pnm contexts create"),
"a transport failure must not suggest an ACL grant: {rendered}"
);
assert!(
rendered.contains("transport failure"),
"a transport failure must be named as one: {rendered}"
);
}
#[test]
fn unclassified_failure_keeps_the_acl_hint() {
let err = provision_failed("provisioning ended without a terminal event");
let rendered = format!("{err:?}");
assert!(
rendered.contains("pnm contexts create"),
"the default hint must survive: {rendered}"
);
}
#[test]
fn forbidden_keeps_the_acl_hint() {
let err = provision_failed("provision-integration call failed: forbidden");
let rendered = format!("{err:?}");
assert!(rendered.contains("pnm acl create"), "{rendered}");
}
}
pub(crate) struct VtcHostMessages;
impl OperatorMessages for VtcHostMessages {
fn integration_label(&self) -> &str {
"VTC"
}
fn integration_label_lower(&self) -> &str {
"vtc"
}
fn pnm_admin_command_hint(&self, context_id: &str, setup_did: &str) -> String {
format!(
"pnm contexts create --id {context_id} --name \"VTC\" \\\n \
--admin-did {setup_did} --admin-expires 1h --admin-handoff"
)
}
}
fn write_did_log(data_dir: &std::path::Path, scid: &str, content: &str) -> Result<(), AppError> {
let did_dir = data_dir.join("did");
std::fs::create_dir_all(&did_dir).map_err(|e| {
AppError::Io(std::io::Error::new(
e.kind(),
format!("create did dir {}: {e}", did_dir.display()),
))
})?;
let path = did_dir.join(format!("{scid}.jsonl"));
std::fs::write(&path, content).map_err(|e| {
AppError::Io(std::io::Error::new(
e.kind(),
format!("write did log {}: {e}", path.display()),
))
})?;
Ok(())
}
fn build_app_config(
config_path: PathBuf,
vtc_did: String,
vta_did: String,
public_url: String,
data_dir: PathBuf,
secrets: SecretsConfig,
messaging: Option<MessagingConfig>,
) -> Result<AppConfig, AppError> {
use toml::Value;
let mut store_table = toml::map::Map::new();
store_table.insert(
"data_dir".into(),
Value::String(data_dir.to_string_lossy().into_owned()),
);
let mut root = toml::map::Map::new();
root.insert("store".into(), Value::Table(store_table));
let mut config: AppConfig = Value::Table(root)
.try_into()
.map_err(|e| AppError::Config(format!("config: {e}")))?;
config.vtc_did = Some(vtc_did);
config.vta_did = Some(vta_did);
config.public_url = Some(public_url);
config.secrets = secrets;
config.messaging = messaging;
config.auth = AuthConfig {
jwt_signing_key: Some(generate_jwt_signing_key()),
..AuthConfig::default()
};
config.log = LogConfig::default();
config.config_path = config_path;
Ok(config)
}
fn generate_jwt_signing_key() -> String {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use rand::Rng;
let mut bytes = [0u8; 32];
rand::rng().fill_bytes(&mut bytes);
B64.encode(bytes)
}
fn write_config_toml(path: &std::path::Path, config: &AppConfig) -> Result<(), AppError> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(|e| {
AppError::Io(std::io::Error::new(
e.kind(),
format!("create config dir {}: {e}", parent.display()),
))
})?;
}
let serialised = toml::to_string_pretty(config)
.map_err(|e| AppError::Config(format!("config serialise: {e}")))?;
std::fs::write(path, serialised).map_err(|e| {
AppError::Io(std::io::Error::new(
e.kind(),
format!("write config {}: {e}", path.display()),
))
})?;
crate::secure_file::restrict_file_to_owner(path).map_err(|e| {
AppError::Io(std::io::Error::new(
e.kind(),
format!("harden config perms {}: {e}", path.display()),
))
})?;
Ok(())
}
fn open_keyspaces(config: &AppConfig) -> Result<(), AppError> {
let store = Store::open(&StoreConfig {
data_dir: config.store.data_dir.clone(),
})?;
for ks in keyspaces::ALL {
let _ = store.keyspace(ks)?;
}
Ok(())
}
async fn mint_initial_install_token(
config: &AppConfig,
bundle: &VtcKeyBundle,
admin_did: &str,
co_admin_did: Option<&str>,
base_url: &str,
) -> Result<(String, String), AppError> {
let ed25519 = bundle.ed25519_private_bytes()?;
let signer = InstallTokenSigner::from_master_seed(&*ed25519)?;
let minted = mint_install_token(
&signer,
&bundle.integration_did,
admin_did,
INSTALL_TOKEN_DEFAULT_TTL_SECS,
)?;
let claim_code = crate::install::claim_secret::generate();
let claim_code_hash = crate::install::claim_secret::hash(&claim_code)?;
let store = Store::open(&StoreConfig {
data_dir: config.store.data_dir.clone(),
})?;
let install_ks = store.keyspace(keyspaces::INSTALL)?;
let install_store = InstallTokenStore::new(install_ks);
let exp = Utc::now() + ChronoDuration::seconds(INSTALL_TOKEN_DEFAULT_TTL_SECS as i64);
install_store
.record_issued(
&minted.jti,
minted.cnonce_bytes,
*minted.ephemeral_signing_key,
exp,
Some(claim_code_hash),
Some(admin_did.to_string()),
)
.await?;
if let Some(co_admin) = co_admin_did {
install_store.record_co_admin(co_admin).await?;
}
let install_url = format!(
"{}/admin/install?token={}",
base_url.trim_end_matches('/'),
minted.jwt
);
Ok((install_url, claim_code))
}
fn intro_banner() {
println!();
println!("\x1b[1;36m`vtc setup` — provision a fresh Verifiable Trust Community.\x1b[0m");
println!();
println!(
"This wizard provisions the VTC's DID + keys against a running VTA, then writes the\n\
daemon's config and the one-shot URL you'll use to claim your admin passkey."
);
println!();
}
fn print_acl_step(inputs: &WizardInputs, setup_key: &EphemeralSetupKey) {
println!();
println!("\x1b[1;33m── Operator action required ──\x1b[0m");
println!();
println!("Authorize this ephemeral DID at the VTA before continuing:");
println!();
println!(" DID: {}", setup_key.did);
println!(" Context: {}", inputs.context);
println!();
println!(
"Run on a machine with PNM admin access to the VTA ({}):",
inputs.vta_did
);
println!();
println!(
" {}",
VtcHostMessages.pnm_admin_command_hint(&inputs.context, &setup_key.did)
);
println!();
println!(" If the context already exists, grant admin access to the ephemeral DID instead:");
println!();
println!(
" pnm acl create --did {} \\\n --role admin --contexts {} --expires 1h --handoff",
setup_key.did, inputs.context,
);
println!();
}
fn default_data_dir_for(config_path: &std::path::Path) -> PathBuf {
config_path
.parent()
.map(|p| p.join("data"))
.unwrap_or_else(|| PathBuf::from("data"))
}
fn did_log_label_or_err(did: &str) -> Result<String, AppError> {
did.strip_prefix("did:webvh:")
.and_then(|suffix| suffix.split(':').next_back())
.map(str::to_string)
.ok_or_else(|| AppError::Internal(format!("VTA returned non-webvh DID: {did}")))
}
fn prompt_err(e: dialoguer::Error) -> AppError {
AppError::Internal(format!("interactive prompt failed: {e}"))
}
fn secrets_prompt_err(e: SecretsPromptError) -> AppError {
match e {
SecretsPromptError::Dialoguer(d) => prompt_err(d),
other => {
warn!(error = %other, "secrets prompt failed");
AppError::Internal(format!("secrets prompt: {other}"))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn did_log_label_is_the_final_component() {
let label = did_log_label_or_err("did:webvh:abc123:vtc.example.com").unwrap();
assert_eq!(label, "vtc.example.com");
}
#[test]
fn did_log_label_refuses_non_webvh() {
let err = did_log_label_or_err("did:key:z6Mk…").unwrap_err();
assert!(format!("{err}").contains("non-webvh"));
}
#[test]
fn default_data_dir_sits_alongside_config() {
let cfg = PathBuf::from("/etc/vtc/config.toml");
assert_eq!(default_data_dir_for(&cfg), PathBuf::from("/etc/vtc/data"));
}
#[test]
fn default_data_dir_falls_back_when_config_has_no_parent() {
let cfg = PathBuf::from("config.toml");
let dir = default_data_dir_for(&cfg);
assert!(dir.ends_with("data"));
}
#[test]
fn secrets_choice_to_config_routes_keyring_to_keyring_service() {
let choice = SecretsBackendChoice::Keyring {
service: "custom-name".into(),
};
let config = secrets_choice_to_config(choice);
assert_eq!(config.keyring_service, "custom-name");
}
#[test]
fn secrets_choice_to_config_pins_plaintext_backend() {
let config = secrets_choice_to_config(SecretsBackendChoice::Plaintext);
assert_eq!(
config.backend,
Some(crate::config::SecretBackend::Plaintext)
);
}
#[test]
fn secrets_choice_to_config_routes_aws_to_aws_fields() {
let choice = SecretsBackendChoice::Aws {
secret_name: "my-secret".into(),
region: Some("us-east-1".into()),
};
let config = secrets_choice_to_config(choice);
assert_eq!(config.aws_secret_name.as_deref(), Some("my-secret"));
assert_eq!(config.aws_region.as_deref(), Some("us-east-1"));
}
#[test]
fn vtc_host_messages_use_pnm_contexts_create_command() {
let msg = VtcHostMessages.pnm_admin_command_hint("ctx-x", "did:key:zAbc");
assert!(
msg.contains("pnm contexts create"),
"expected `pnm contexts create` form, got: {msg}"
);
assert!(msg.contains("--id ctx-x"));
assert!(msg.contains("--name \"VTC\""));
assert!(msg.contains("--admin-did did:key:zAbc"));
assert!(msg.contains("--admin-expires 1h --admin-handoff"));
}
fn sample_config(cfg_path: &std::path::Path) -> AppConfig {
build_app_config(
cfg_path.to_path_buf(),
"did:webvh:scid:vtc.example.com".into(),
"did:webvh:scid:vta.example.com".into(),
"https://vtc.example.com".into(),
cfg_path.parent().unwrap().join("data"),
secrets_choice_to_config(SecretsBackendChoice::Keyring {
service: "vtc".into(),
}),
None,
)
.unwrap()
}
#[test]
fn vti_apv_022_setup_writes_single_admin_mode_only_when_chosen() {
let tmp = tempfile::tempdir().unwrap();
let cfg_path = tmp.path().join("config.toml");
let config = sample_config(&cfg_path);
assert!(!config.acl.single_admin_mode, "off by default");
write_config_toml(&cfg_path, &config).unwrap();
let written = std::fs::read_to_string(&cfg_path).unwrap();
assert!(
!written.contains("single_admin_mode"),
"not written unless chosen:\n{written}"
);
let mut config = sample_config(&cfg_path);
config.acl.single_admin_mode = true;
write_config_toml(&cfg_path, &config).unwrap();
let written = std::fs::read_to_string(&cfg_path).unwrap();
let parsed: toml::Value = toml::from_str(&written).unwrap();
assert_eq!(
parsed["acl"]["single_admin_mode"].as_bool(),
Some(true),
"{written}"
);
let reloaded = AppConfig::load(Some(cfg_path.clone())).unwrap();
assert!(reloaded.acl.single_admin_mode);
}
#[test]
fn vti_apv_022_single_admin_beside_a_co_admin_warns() {
let inputs = |single, co: Option<&str>| WizardInputs {
base_url: "https://vtc.example.com".into(),
vta_did: "did:webvh:vta".into(),
context: "default".into(),
registry_did: None,
transports: Vec::new(),
co_admin_did: co.map(String::from),
single_admin_mode: single,
};
assert!(single_admin_warning(&inputs(true, None)).is_none());
assert!(single_admin_warning(&inputs(false, Some("did:key:z6MkCo"))).is_none());
let w = single_admin_warning(&inputs(true, Some("did:key:z6MkCo"))).unwrap();
assert!(w.contains("another of your own identifiers"), "{w}");
assert!(w.contains("VTI-APV-022"), "{w}");
}
#[cfg(unix)]
#[test]
fn write_config_toml_produces_mode_0600() {
use std::os::unix::fs::PermissionsExt;
let tmp = std::env::temp_dir().join(format!("vtc-cfg-{}", rand::random::<u32>()));
std::fs::create_dir_all(&tmp).unwrap();
let cfg_path = tmp.join("config.toml");
let config = build_app_config(
cfg_path.clone(),
"did:webvh:scid:vtc.example.com".into(),
"did:webvh:scid:vta.example.com".into(),
"https://vtc.example.com".into(),
tmp.join("data"),
secrets_choice_to_config(SecretsBackendChoice::Keyring {
service: "vtc".into(),
}),
None,
)
.unwrap();
write_config_toml(&cfg_path, &config).unwrap();
let mode = std::fs::metadata(&cfg_path).unwrap().permissions().mode();
assert_eq!(
mode & 0o777,
0o600,
"config.toml must be 0600, got {mode:o}"
);
let _ = std::fs::remove_dir_all(&tmp);
}
}