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::provision_client::{
EphemeralSetupKey, OperatorMessages, ProvisionAsk, ProvisionResult, 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>) -> Result<(), AppError> {
intro_banner();
let config_path = prompt_config_path(config_path)?;
refuse_if_already_set_up(&config_path)?;
let inputs = prompt_inputs()?;
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 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()?;
let provision = run_provision_quietly(&inputs, &webvh, &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(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 scid = extract_scid_or_err(&integration_did)?;
write_did_log(&data_dir, &scid, 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,
)?;
let bundle_bytes = bundle.to_secret_store_bytes()?;
if app_config.secrets.secret.is_some() {
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();
let (install_url, claim_code) =
mint_initial_install_token(&app_config, &bundle, &admin_did, &inputs.base_url).await?;
let admin_key_summary = provision
.admin_key()
.and_then(|k| serde_json::to_string_pretty(k).ok());
println!();
println!("\x1b[1;32m✅ VTC setup complete.\x1b[0m");
println!();
println!("VTC DID: {integration_did}");
println!("Admin DID: {admin_did}");
println!("Config: {}", config_path.display());
println!("Data dir: {}", data_dir.display());
println!();
if let Some(key_json) = admin_key_summary.as_deref() {
println!("\x1b[1;33mAdmin key (save this — needed for CLI access):\x1b[0m");
println!("{key_json}");
println!();
}
println!("\x1b[1mInstall URL (one-shot, 15 min TTL):\x1b[0m");
println!(" {install_url}");
println!();
println!("\x1b[1mClaim code (required at claim time):\x1b[0m");
println!(" {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!();
Ok(())
}
struct WizardInputs {
base_url: String,
vta_did: String,
context: String,
}
#[derive(Default)]
struct WebvhTarget {
server_id: Option<String>,
domain: Option<String>,
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))
}
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() -> 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)?;
Ok(WizardInputs {
base_url,
vta_did,
context,
})
}
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,
}))
}
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!();
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 {
let path = prompt_webvh_path(None)?;
return Ok(WebvhTarget {
path,
..WebvhTarget::default()
});
};
let server_id = prompt_webvh_server(&client).await?;
let domain = match server_id.as_deref() {
Some(sid) => prompt_webvh_domain(&client, sid).await?,
None => None,
};
let path = prompt_webvh_path(server_id.as_deref())?;
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::new(rest_url);
client.set_token_async(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: Option<&str>) -> Result<Option<String>, AppError> {
println!();
match server_id {
Some(sid) => {
println!(" Optional path label under the hosting server `{sid}`. 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.");
}
None => {
println!(" Optional path label for the VTC DID. It becomes the trailing");
println!(" `<path>` component. Leave blank to let the host assign one.");
}
}
let raw: String = Input::new()
.with_prompt("WebVH path (blank → auto-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"),
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(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 = "aws-secrets")]
async fn list_aws_secrets(
region: Option<&str>,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
const MAX_SECRETS: usize = 10_000;
let mut loader = aws_config::from_env();
if let Some(r) = region {
loader = loader.region(aws_config::Region::new(r.to_owned()));
}
let sdk_config = loader.load().await;
let client = aws_sdk_secretsmanager::Client::new(&sdk_config);
let mut names: Vec<String> = Vec::new();
let mut next_token: Option<String> = None;
loop {
let mut req = client.list_secrets();
if let Some(token) = next_token.as_ref() {
req = req.next_token(token.clone());
}
let output = req.send().await?;
names.extend(
output
.secret_list()
.iter()
.filter_map(|entry| entry.name().map(String::from)),
);
if names.len() >= MAX_SECRETS {
names.truncate(MAX_SECRETS);
break;
}
match output.next_token() {
Some(t) if !t.is_empty() => next_token = Some(t.to_string()),
_ => break,
}
}
Ok(names)
}
#[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(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 = "gcp-secrets")]
async fn list_gcp_secrets(
project: &str,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
const MAX_SECRETS: usize = 10_000;
let client = google_cloud_secretmanager_v1::client::SecretManagerService::builder()
.build()
.await?;
let prefix = format!("projects/{project}/secrets/");
let mut names: Vec<String> = Vec::new();
let mut page_token: Option<String> = None;
loop {
let mut req = client
.list_secrets()
.set_parent(format!("projects/{project}"));
if let Some(token) = page_token.as_ref() {
req = req.set_page_token(token.clone());
}
let response = req.send().await?;
names.extend(
response
.secrets
.iter()
.map(|s| s.name.strip_prefix(&prefix).unwrap_or(&s.name).to_owned()),
);
if names.len() >= MAX_SECRETS {
names.truncate(MAX_SECRETS);
break;
}
if response.next_page_token.is_empty() {
break;
}
page_token = Some(response.next_page_token);
}
Ok(names)
}
#[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(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))
}
#[cfg(feature = "azure-secrets")]
async fn list_azure_secrets(
vault_url: &str,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
use azure_security_keyvault_secrets::{ResourceExt, SecretClient};
use futures_util::TryStreamExt;
const MAX_SECRETS: usize = 10_000;
let credential = azure_identity::DeveloperToolsCredential::new(None)?;
let client = SecretClient::new(vault_url, credential, None)?;
let mut names: Vec<String> = Vec::new();
let mut pager = client.list_secret_properties(None)?;
while let Some(secret) = pager.try_next().await? {
names.push(secret.resource_id()?.name);
if names.len() >= MAX_SECRETS {
break;
}
}
Ok(names)
}
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::InlineConfig => {
config.secret = Some(String::new());
}
SecretsBackendChoice::Plaintext => {
}
}
config
}
async fn run_provision_quietly(
inputs: &WizardInputs,
webvh: &WebvhTarget,
setup_key: &EphemeralSetupKey,
) -> Result<ProvisionResult, 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()),
);
}
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 {
AppError::Internal(format!(
"VTA provisioning failed: {msg}. 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)."
))
}
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"
)
}
}
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()),
))
})?;
Ok(())
}
fn open_keyspaces(config: &AppConfig) -> Result<(), AppError> {
let store = Store::open(&StoreConfig {
data_dir: config.store.data_dir.clone(),
})?;
for ks in [
"sessions",
"acl",
"community",
"config",
"passkey",
"install",
"audit",
"audit_key",
] {
let _ = store.keyspace(ks)?;
}
Ok(())
}
async fn mint_initial_install_token(
config: &AppConfig,
bundle: &VtcKeyBundle,
admin_did: &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("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?;
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",
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 extract_scid_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 extract_scid_from_webvh() {
let scid = extract_scid_or_err("did:webvh:vtc.example.com:v1:abc123").unwrap();
assert_eq!(scid, "abc123");
}
#[test]
fn extract_scid_refuses_non_webvh() {
let err = extract_scid_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_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"));
}
}