use std::path::PathBuf;
use vta_sdk::sealed_transfer::{
AssertionProof, BootstrapRequest, ProducerAssertion, SealedPayloadV1, armor, bundle_digest,
generate_ed25519_keypair, seal_payload,
};
use crate::config::AppConfig;
use crate::sealed_nonce_store::PersistentNonceStore;
use crate::store::Store;
fn default_seed_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
let dir = dirs::config_dir()
.ok_or("could not determine config directory (set --seed-dir to override)")?
.join("vta");
Ok(dir)
}
fn resolve_seed_dir(override_dir: Option<PathBuf>) -> Result<PathBuf, Box<dyn std::error::Error>> {
match override_dir {
Some(d) => Ok(d),
None => default_seed_dir(),
}
}
pub async fn run_seal(
config_path: Option<PathBuf>,
request_path: PathBuf,
payload_path: PathBuf,
out_path: PathBuf,
) -> Result<(), Box<dyn std::error::Error>> {
let request_json = std::fs::read_to_string(&request_path)
.map_err(|e| format!("read {}: {e}", request_path.display()))?;
let request: BootstrapRequest =
serde_json::from_str(&request_json).map_err(|e| format!("parse BootstrapRequest: {e}"))?;
if request.version != 1 {
return Err(format!("unsupported request version: {}", request.version).into());
}
let recipient_pk = request.decode_client_x25519_pub()?;
let bundle_id = request.decode_nonce()?;
let payload_json = std::fs::read_to_string(&payload_path)
.map_err(|e| format!("read {}: {e}", payload_path.display()))?;
let payload: SealedPayloadV1 =
serde_json::from_str(&payload_json).map_err(|e| format!("parse SealedPayloadV1: {e}"))?;
let (_producer_seed, producer_ed_pub) = generate_ed25519_keypair();
let producer_did = affinidi_crypto::did_key::ed25519_pub_to_did_key(&producer_ed_pub);
let producer = ProducerAssertion {
producer_did: producer_did.clone(),
proof: AssertionProof::PinnedOnly,
};
let config_store = AppConfig::load(config_path)?;
let persistent_store = Store::open(&config_store.store)?;
let nonce_ks = persistent_store.keyspace(crate::keyspaces::SEALED_NONCES)?;
let nonce_store = PersistentNonceStore::new(nonce_ks);
let bundle = seal_payload(&recipient_pk, bundle_id, producer, &payload, &nonce_store).await?;
persistent_store.persist().await?;
let armored = armor::encode(&bundle);
std::fs::write(&out_path, armored.as_bytes())
.map_err(|e| format!("write {}: {e}", out_path.display()))?;
let digest = bundle_digest(&bundle);
eprintln!("Sealed bundle written to {}", out_path.display());
eprintln!();
eprintln!(" Bundle-Id: {}", hex_lower(&bundle.bundle_id));
eprintln!(" Chunks: {}", bundle.chunks.len());
eprintln!(" Producer DID: {producer_did}");
eprintln!(" SHA-256 digest: {digest}");
eprintln!();
eprintln!(
"Communicate the digest to the consumer out-of-band so they can run\n \
vta bootstrap open --bundle <file> --expect-digest {digest}\n \
(or `pnm bootstrap open` if the consumer has pnm installed)"
);
Ok(())
}
pub async fn run_request(
out_path: PathBuf,
label: Option<String>,
seed_dir: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
let seed_dir = resolve_seed_dir(seed_dir)?;
let created = vta_cli_common::sealed_consumer::create_bootstrap_request(&seed_dir, label)?;
let json = serde_json::to_string_pretty(&created.request)?;
std::fs::write(&out_path, json.as_bytes())
.map_err(|e| format!("write {}: {e}", out_path.display()))?;
eprintln!("Bootstrap request written to {}", out_path.display());
eprintln!();
eprintln!(" Bundle-Id: {}", created.bundle_id_hex);
eprintln!(" Client DID: {}", created.request.client_did);
eprintln!(" Seed saved: {}", created.secret_path.display());
eprintln!();
eprintln!("Hand the request to the VTA operator. They will return an armored bundle.");
eprintln!("Verify the SHA-256 digest they print to you out-of-band, then run:");
eprintln!(" vta bootstrap open --bundle <file> --expect-digest <hex>");
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn run_provision_request(
template: String,
vars: Vec<String>,
context_hint: Option<String>,
admin_template: Option<String>,
validity_hours: f64,
label: Option<String>,
seed_dir: Option<PathBuf>,
out_path: PathBuf,
) -> Result<(), Box<dyn std::error::Error>> {
use vta_sdk::provision_integration::ProvisionRequestBuilder;
if !validity_hours.is_finite() || validity_hours <= 0.0 {
return Err(format!(
"--validity-hours must be a positive finite number, got {validity_hours}"
)
.into());
}
let validity = chrono::Duration::seconds((validity_hours * 3600.0) as i64);
let mut builder = ProvisionRequestBuilder::new(template).validity(validity);
for raw in &vars {
let (k, v) = parse_var(raw)?;
builder = builder.var(k, v);
}
if let Some(ctx) = context_hint {
builder = builder.context_hint(ctx);
}
if let Some(admin) = admin_template {
builder = builder.admin_template(admin);
}
if let Some(l) = label {
builder = builder.label(l);
}
let seed_dir = resolve_seed_dir(seed_dir)?;
let created =
vta_cli_common::sealed_consumer::create_provision_request(&seed_dir, builder).await?;
let json = serde_json::to_string_pretty(&created.request)?;
std::fs::write(&out_path, json.as_bytes())
.map_err(|e| format!("write {}: {e}", out_path.display()))?;
eprintln!(
"Provision bootstrap request written to {}",
out_path.display()
);
eprintln!();
eprintln!(" Bundle-Id: {}", created.bundle_id_hex);
eprintln!(" Client DID: {}", created.client_did);
eprintln!(" Seed saved: {}", created.secret_path.display());
eprintln!();
eprintln!("Hand the request to the VTA operator. They will run:");
eprintln!(" vta bootstrap provision-integration --request <file> --out <bundle>");
eprintln!("and return an armored sealed bundle + SHA-256 digest.");
eprintln!();
eprintln!("Verify the digest out-of-band, then:");
eprintln!(" vta bootstrap open --bundle <file> --expect-digest <hex>");
Ok(())
}
fn parse_var(raw: &str) -> Result<(String, serde_json::Value), Box<dyn std::error::Error>> {
let (key, value) = raw
.split_once('=')
.ok_or_else(|| format!("invalid --var '{raw}': expected KEY=VALUE"))?;
if key.is_empty() {
return Err(format!("invalid --var '{raw}': empty key").into());
}
let parsed = serde_json::from_str::<serde_json::Value>(value)
.unwrap_or_else(|_| serde_json::Value::String(value.to_string()));
Ok((key.to_string(), parsed))
}
pub async fn run_open(
bundle_path: PathBuf,
expect_digest: Option<String>,
no_verify_digest: bool,
expect_vta_did: Option<String>,
seed_dir: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
if no_verify_digest {
eprintln!(
"WARNING: --no-verify-digest disables out-of-band integrity verification.\n\
You are trusting the producer pubkey embedded in the bundle without\n\
any external anchor. Use only for testing."
);
}
let seed_dir = resolve_seed_dir(seed_dir)?;
let opened = vta_cli_common::sealed_consumer::open_armored_bundle(
&bundle_path,
&seed_dir,
expect_digest.as_deref(),
no_verify_digest,
)?;
print_opened(&opened, expect_vta_did.as_deref())?;
Ok(())
}
fn print_opened(
opened: &vta_cli_common::sealed_consumer::OpenedArmored,
expect_vta_did: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
println!("Sealed bundle opened.");
println!();
println!(" Bundle-Id: {}", opened.bundle_id_hex);
println!(" Digest (sha256): {}", opened.digest);
println!(" Producer DID: {}", opened.producer.producer_did);
println!(" Producer proof: {:?}", opened.producer.proof);
println!();
match &opened.payload {
SealedPayloadV1::AdminCredential(c) => {
println!("Payload: AdminCredential");
println!(" DID: {}", c.did);
println!(" VTA DID: {}", c.vta_did);
if let Some(ref u) = c.vta_url {
println!(" VTA URL: {u}");
}
}
SealedPayloadV1::ContextProvision(p) => {
println!("Payload: ContextProvision");
println!(" Context: {} ({})", p.context_id, p.context_name);
println!(" Admin DID: {}", p.admin_did);
}
SealedPayloadV1::DidSecrets(s) => {
println!("Payload: DidSecrets");
println!(" DID: {}", s.did);
println!(" Secrets: {}", s.secrets.len());
}
SealedPayloadV1::AdminKeySet(keys) => {
println!("Payload: AdminKeySet ({} keys)", keys.len());
for k in keys {
println!(" - {}", k.label);
}
}
SealedPayloadV1::RawPrivateKey(k) => {
println!("Payload: RawPrivateKey ({})", k.key_type);
}
SealedPayloadV1::TemplateBootstrap(p) => {
println!("Payload: TemplateBootstrap");
println!(" Template: {}", p.config.template_name);
println!(" Kind: {}", p.config.template_kind);
println!(" Secrets for: {} DID(s)", p.secrets.len());
println!(" Outputs: {}", p.config.outputs.len());
if let Some(ref u) = p.config.vta_url {
println!(" VTA URL: {u}");
}
match expect_vta_did {
Some(pinned) => {
verify_template_bundle(opened, p.as_ref(), pinned)?;
println!();
println!(" \x1b[1;32m✓ VC verified against pinned VTA DID\x1b[0m");
}
None => {
println!();
println!(" \x1b[1;33m⚠ VC NOT verified — digest-only trust anchor.\x1b[0m");
println!(" Re-run with --expect-vta-did <did> to verify the authorization");
println!(" VC + DidSigned producer assertion end-to-end.");
}
}
}
SealedPayloadV1::AdminRotation(p) => {
println!("Payload: AdminRotation");
println!(" Admin DID: {}", p.admin.did);
println!(" VTA DID: {}", p.vta_trust.vta_did);
if let Some(ref u) = p.vta_url {
println!(" VTA URL: {u}");
}
println!();
println!(
" \x1b[1;33m⚠ VC verification not yet wired for AdminRotation — relying on digest pinning.\x1b[0m"
);
}
SealedPayloadV1::IssuedCredential(c) => {
println!("Payload: IssuedCredential");
println!(" Issuer DID: {}", c.issuer_did);
if let Some(ref label) = c.label {
println!(" Label: {label}");
}
let kind = if c.credential.is_string() {
"SD-JWT-VC (compact)"
} else {
"W3C Data-Integrity VC"
};
println!(" Format: {kind}");
}
SealedPayloadV1::MessagingBridgeCredentials(b) => {
println!("Payload: MessagingBridgeCredentials");
println!(" Platform: {}", b.platform);
println!(" Fields: {}", b.fields.len());
}
}
Ok(())
}
fn verify_template_bundle(
opened: &vta_cli_common::sealed_consumer::OpenedArmored,
payload: &vta_sdk::sealed_transfer::TemplateBootstrapPayload,
pinned_vta_did: &str,
) -> Result<(), Box<dyn std::error::Error>> {
use vta_sdk::provision_integration::template_verify::verify_template_bootstrap;
use vta_sdk::sealed_transfer::AssertionProof;
use vta_sdk::sealed_transfer::verify::{
VerifiedAssertion, verify_producer_assertion_with_pubkey,
};
let _verified = verify_template_bootstrap(
payload.clone(),
pinned_vta_did,
chrono::Duration::minutes(5),
)?;
let producer_pubkey = if let AssertionProof::DidSigned(_) = opened.producer.proof {
match affinidi_crypto::did_key::did_key_to_ed25519_pub(&opened.producer.producer_did) {
Ok(pk) => Some(pk),
Err(e) => {
return Err(format!(
"cannot decode producer DID '{}' as did:key for assertion verification: {e}. \
did:webvh producers need resolver threading (follow-up).",
opened.producer.producer_did
)
.into());
}
}
} else {
None
};
let verdict = verify_producer_assertion_with_pubkey(
&opened.producer,
&opened.client_x25519_pub,
&opened.bundle_id,
producer_pubkey.as_ref(),
)?;
match verdict {
VerifiedAssertion::DidSignedVerified(_) => {
}
VerifiedAssertion::PinnedOnlyAcknowledged(_) => {
}
VerifiedAssertion::AttestedNeedsNitroCheck(_) => {
return Err(
"Attested producer assertion is not supported in the offline provision \
path — use the TEE Mode B bootstrap (`pnm bootstrap connect`) for \
attested-quote flows."
.into(),
);
}
}
Ok(())
}
use vta_sdk::hex::lower as hex_lower;
#[cfg(feature = "webvh")]
#[allow(clippy::too_many_arguments)]
pub async fn run_provision_integration(
config_path: Option<PathBuf>,
request_path: PathBuf,
context: Option<String>,
create_context: bool,
assertion: AssertionModeFlag,
vc_validity_hours: Option<f64>,
out_path: PathBuf,
) -> Result<(), Box<dyn std::error::Error>> {
use crate::auth::AuthClaims;
use crate::operations::provision_integration::{
AssertionMode, ProvisionIntegrationParams, provision_integration,
};
use crate::server::build_app_state;
use tokio::sync::watch;
use vta_sdk::provision_integration::BootstrapRequest;
let request_json = std::fs::read_to_string(&request_path)
.map_err(|e| format!("read {}: {e}", request_path.display()))?;
let request: BootstrapRequest = serde_json::from_str(&request_json)
.map_err(|e| format!("parse BootstrapRequest (VP): {e}"))?;
let verified = request
.verify()
.map_err(|e| format!("verify BootstrapRequest: {e}"))?;
let target_context = resolve_target_context(&verified, context)?;
let app_config = AppConfig::load(config_path)?;
let store = Store::open(&app_config.store)?;
let seed_store = crate::keys::seed_store::create_seed_store(&app_config)
.map_err(|e| format!("create seed store: {e}"))?;
let (restart_tx, _restart_rx) = watch::channel(false);
let state = build_app_state(
app_config,
&store,
seed_store.into(),
None,
None,
restart_tx,
crate::server::AppStateParts::default(),
)
.await
.map_err(|e| format!("build app state: {e}"))?;
let auth = AuthClaims::unsafe_local_cli_super_admin("provision-integration");
let context_created =
crate::operations::provision_integration::ensure_target_context_or_create(
&state.contexts_ks,
&auth,
&target_context,
create_context,
)
.await
.map_err(|e| format!("ensure context '{target_context}': {e}"))?;
if context_created {
eprintln!("Created context '{target_context}' (--create-context).");
}
let vc_validity = vc_validity_hours.map(|hrs| {
chrono::Duration::seconds((hrs * 3600.0) as i64)
});
let assertion_mode = match assertion {
AssertionModeFlag::DidSigned => AssertionMode::DidSigned,
AssertionModeFlag::PinnedOnly => AssertionMode::PinnedOnly,
};
let deps = crate::operations::provision_integration::ProvisionIntegrationDeps::from(&state);
let output = provision_integration(
&deps,
&auth,
ProvisionIntegrationParams {
request: verified,
context: target_context,
assertion_mode,
vc_validity,
},
)
.await
.map_err(|e| format!("provision-integration: {e}"))?;
store.persist().await?;
std::fs::write(&out_path, output.armored.as_bytes())
.map_err(|e| format!("write {}: {e}", out_path.display()))?;
eprintln!(
"Integration provisioned — sealed bundle written to {}",
out_path.display()
);
eprintln!();
eprintln!(" Bundle-Id: {}", output.summary.bundle_id_hex);
eprintln!(" Client DID: {}", output.summary.client_did);
if output.summary.admin_rolled_over {
eprintln!(
" Admin DID: {} (VTA-minted, rolled over from client)",
output.summary.admin_did
);
if let Some(ref admin_tpl) = output.summary.admin_template_name {
eprintln!(" Admin template: {admin_tpl}");
}
} else {
eprintln!(
" Admin DID: {} (== client)",
output.summary.admin_did
);
}
if let Some(ref integration_did) = output.summary.integration_did {
eprintln!(" Integration DID: {integration_did}");
} else {
eprintln!(" Integration DID: (none — admin-rotation only)");
}
if let (Some(name), Some(kind)) = (
output.summary.template_name.as_deref(),
output.summary.template_kind.as_deref(),
) {
eprintln!(" Template: {name} ({kind})");
}
eprintln!(" Secrets: {}", output.summary.secret_count);
eprintln!(" Outputs: {}", output.summary.output_count);
eprintln!(" SHA-256 digest: {}", output.digest);
eprintln!();
eprintln!(
"Communicate the digest to the integration's operator out-of-band so they can\n \
verify the bundle on first boot:\n \
pnm bootstrap open --bundle <file> --expect-digest {}",
output.digest
);
Ok(())
}
#[cfg(feature = "webvh")]
fn resolve_target_context(
request: &vta_sdk::provision_integration::VerifiedBootstrapRequest,
explicit: Option<String>,
) -> Result<String, Box<dyn std::error::Error>> {
use vta_sdk::provision_integration::BootstrapAsk;
let hint = match request.ask() {
BootstrapAsk::TemplateBootstrap(ask) => ask.context_hint.clone(),
BootstrapAsk::AdminRotation(ask) => ask.context_hint.clone(),
};
match (explicit, hint) {
(Some(explicit), Some(hint)) if explicit != hint => Err(format!(
"--context '{explicit}' does not match request contextHint '{hint}' — \
operator and integration must agree on the context before provisioning"
)
.into()),
(Some(explicit), _) => Ok(explicit),
(None, Some(hint)) => Ok(hint),
(None, None) => Err(
"no context specified — pass --context <id> or have the integration's \
BootstrapRequest include a contextHint"
.into(),
),
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum AssertionModeFlag {
#[default]
DidSigned,
PinnedOnly,
}
impl std::str::FromStr for AssertionModeFlag {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"did-signed" | "didsigned" | "did_signed" => Ok(Self::DidSigned),
"pinned-only" | "pinnedonly" | "pinned_only" | "pinned" => Ok(Self::PinnedOnly),
other => Err(format!(
"invalid --assertion value '{other}' — use 'did-signed' or 'pinned-only'"
)),
}
}
}
pub async fn run_keys_bundle(
config_path: Option<PathBuf>,
context: String,
recipient: Option<PathBuf>,
recipient_did: Option<String>,
recipient_nonce: Option<String>,
out: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
use crate::auth::AuthClaims;
use crate::operations::export::{ExportDeps, build_did_secrets_bundle};
use crate::server::build_app_state;
use tokio::sync::watch;
let recipient = vta_cli_common::sealed_producer::resolve_recipient(
recipient.as_deref(),
recipient_did.as_deref(),
recipient_nonce.as_deref(),
)?;
let app_config = AppConfig::load(config_path)?;
let store = Store::open(&app_config.store)?;
let seed_store = crate::keys::seed_store::create_seed_store(&app_config)
.map_err(|e| format!("create seed store: {e}"))?;
let (restart_tx, _restart_rx) = watch::channel(false);
let state = build_app_state(
app_config,
&store,
seed_store.into(),
None,
None,
restart_tx,
crate::server::AppStateParts::default(),
)
.await
.map_err(|e| format!("build app state: {e}"))?;
let auth = AuthClaims::unsafe_local_cli_super_admin("keys-bundle");
let deps = ExportDeps {
keys_ks: &state.keys_ks,
contexts_ks: &state.contexts_ks,
imported_ks: &state.imported_ks,
audit_ks: &state.audit_ks,
acl_ks: &state.acl_ks,
#[cfg(feature = "webvh")]
webvh_ks: &state.webvh_ks,
seed_store: &state.seed_store,
};
let bundle = build_did_secrets_bundle(&deps, &auth, &context, "vta-keys-bundle").await?;
vta_cli_common::sealed_producer::emit_did_secrets_bundle(
bundle,
&recipient,
&context,
out.as_deref(),
)
.await
}
fn to_context_response(
record: &vta_sdk::protocols::context_management::create::CreateContextResultBody,
) -> vta_sdk::client::ContextResponse {
vta_sdk::client::ContextResponse {
id: record.id.clone(),
name: record.name.clone(),
did: record.did.clone(),
description: record.description.clone(),
base_path: record.base_path.clone(),
created_at: record.created_at,
updated_at: record.updated_at,
}
}
#[allow(clippy::too_many_arguments)]
pub async fn run_context_create(
config_path: Option<PathBuf>,
id: String,
name: Option<String>,
description: Option<String>,
parent: Option<String>,
admin_did: Option<String>,
admin_label: Option<String>,
admin_expires: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
use crate::auth::AuthClaims;
use vta_cli_common::commands::contexts::render_context_record;
let app_config = AppConfig::load(config_path)?;
let store = Store::open(&app_config.store)?;
let contexts_ks = store.keyspace(crate::keyspaces::CONTEXTS)?;
let acl_ks = store.keyspace(crate::keyspaces::ACL)?;
let auth = AuthClaims::unsafe_local_cli_super_admin("context-create");
let display_name = name.unwrap_or_else(|| id.clone());
let record = crate::operations::contexts::create_context(
&contexts_ks,
&auth,
&id,
display_name,
description,
parent,
"vta-context-create",
)
.await?;
if let Some(did) = admin_did {
if !did.starts_with("did:") {
return Err(format!(
"--admin-did must start with `did:` (got {did:?}) — context was created \
but no ACL entry was added"
)
.into());
}
let expires_at = match admin_expires.as_deref() {
Some(raw) => Some(vta_cli_common::duration::duration_to_expires_at(raw)?),
None => None,
};
let entry = crate::acl::AclEntry::new(
did.clone(),
crate::acl::Role::Admin,
format!("vta-context-create:{}", auth.did),
)
.with_label(admin_label)
.with_contexts(vec![record.id.clone()])
.with_expires_at(expires_at);
crate::acl::store_acl_entry(&acl_ks, &entry).await?;
eprintln!(
"Admin ACL entry created for {did} (context: {}).",
record.id
);
}
store.persist().await?;
println!("Context created:");
render_context_record(&to_context_response(&record));
Ok(())
}
pub async fn run_context_list(
config_path: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
use crate::auth::AuthClaims;
use vta_cli_common::commands::contexts::render_context_list;
let app_config = AppConfig::load(config_path)?;
let store = Store::open(&app_config.store)?;
let contexts_ks = store.keyspace(crate::keyspaces::CONTEXTS)?;
let auth = AuthClaims::unsafe_local_cli_super_admin("context-list");
let resp = crate::operations::contexts::list_contexts(&contexts_ks, &auth, "vta-contexts-list")
.await?;
let contexts: Vec<_> = resp.contexts.iter().map(to_context_response).collect();
render_context_list(&contexts);
Ok(())
}
pub async fn run_context_get(
config_path: Option<PathBuf>,
id: String,
) -> Result<(), Box<dyn std::error::Error>> {
use crate::auth::AuthClaims;
use vta_cli_common::commands::contexts::render_context_record;
let app_config = AppConfig::load(config_path)?;
let store = Store::open(&app_config.store)?;
let contexts_ks = store.keyspace(crate::keyspaces::CONTEXTS)?;
let auth = AuthClaims::unsafe_local_cli_super_admin("context-get");
let record =
crate::operations::contexts::get_context_op(&contexts_ks, &auth, &id, "vta-contexts-get")
.await?;
render_context_record(&to_context_response(&record));
Ok(())
}
pub async fn run_context_update(
config_path: Option<PathBuf>,
id: String,
name: Option<String>,
did: Option<String>,
description: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
use crate::auth::AuthClaims;
use crate::operations::contexts::UpdateContextParams;
use vta_cli_common::commands::contexts::render_context_record;
let app_config = AppConfig::load(config_path)?;
let store = Store::open(&app_config.store)?;
let contexts_ks = store.keyspace(crate::keyspaces::CONTEXTS)?;
let auth = AuthClaims::unsafe_local_cli_super_admin("context-update");
let params = UpdateContextParams {
name,
did,
description,
};
let record = crate::operations::contexts::update_context(
&contexts_ks,
&auth,
&id,
params,
"vta-contexts-update",
)
.await?;
store.persist().await?;
println!("Context updated:");
render_context_record(&to_context_response(&record));
Ok(())
}
pub async fn run_context_delete(
config_path: Option<PathBuf>,
id: String,
force: bool,
) -> Result<(), Box<dyn std::error::Error>> {
use crate::auth::AuthClaims;
use crate::operations::Keyspaces;
use vta_cli_common::commands::contexts::{confirm_destructive, render_delete_context_preview};
let app_config = AppConfig::load(config_path)?;
let store = Store::open(&app_config.store)?;
let contexts_ks = store.keyspace(crate::keyspaces::CONTEXTS)?;
let keys_ks = store.keyspace(crate::keyspaces::KEYS)?;
let acl_ks = store.keyspace(crate::keyspaces::ACL)?;
let did_templates_ks = store.keyspace(crate::keyspaces::DID_TEMPLATES)?;
let audit_ks = store.keyspace(crate::keyspaces::AUDIT)?;
let imported_ks = store.keyspace(crate::keyspaces::IMPORTED_SECRETS)?;
#[cfg(feature = "webvh")]
let webvh_ks = store.keyspace(crate::keyspaces::WEBVH)?;
let auth = AuthClaims::unsafe_local_cli_super_admin("context-delete");
let preview = crate::operations::contexts::preview_delete_context(
&contexts_ks,
&keys_ks,
&acl_ks,
&did_templates_ks,
#[cfg(feature = "webvh")]
&webvh_ks,
&auth,
&id,
"vta-contexts-delete",
)
.await?;
let has_resources = render_delete_context_preview(&id, &preview);
if has_resources && !force && !confirm_destructive("Proceed with deletion?")? {
println!("Aborted.");
return Ok(());
}
let ks = Keyspaces {
contexts: &contexts_ks,
keys: &keys_ks,
acl: &acl_ks,
did_templates: &did_templates_ks,
audit: &audit_ks,
imported: &imported_ks,
#[cfg(feature = "webvh")]
webvh: &webvh_ks,
};
crate::operations::contexts::delete_context(&ks, &auth, &id, true, "vta-contexts-delete")
.await?;
store.persist().await?;
println!("Context deleted: {id}");
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn run_context_reprovision(
config_path: Option<PathBuf>,
id: String,
admin_key: Option<String>,
admin_label: Option<String>,
recipient: Option<PathBuf>,
recipient_did: Option<String>,
recipient_nonce: Option<String>,
out: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
use crate::acl::Role;
use crate::auth::AuthClaims;
use crate::keys::KeyType;
use crate::operations::export::{
ContextReprovisionInputs, ExportDeps, build_context_provision_bundle,
};
use crate::operations::keys::{CreateKeyParams, create_key};
use crate::server::build_app_state;
use tokio::sync::watch;
let recipient = vta_cli_common::sealed_producer::resolve_recipient(
recipient.as_deref(),
recipient_did.as_deref(),
recipient_nonce.as_deref(),
)?;
let app_config = AppConfig::load(config_path)?;
let store = Store::open(&app_config.store)?;
let vta_did = app_config
.vta_did
.clone()
.ok_or("VTA DID not configured — run `vta setup` or set vta_did in config")?;
let vta_url = app_config.public_url.clone();
let seed_store = crate::keys::seed_store::create_seed_store(&app_config)
.map_err(|e| format!("create seed store: {e}"))?;
let (restart_tx, _restart_rx) = watch::channel(false);
let state = build_app_state(
app_config,
&store,
seed_store.into(),
None,
None,
restart_tx,
crate::server::AppStateParts::default(),
)
.await
.map_err(|e| format!("build app state: {e}"))?;
let auth = AuthClaims::unsafe_local_cli_super_admin("context-reprovision");
let key_id = match admin_key {
Some(kid) => kid,
None => {
let label = admin_label
.clone()
.unwrap_or_else(|| "admin-reprovision".to_string());
let result = create_key(
&state.keys_ks,
&state.contexts_ks,
&state.seed_store,
&state.audit_ks,
&auth,
CreateKeyParams {
key_type: KeyType::Ed25519,
derivation_path: None,
key_id: None,
mnemonic: None,
label: Some(label),
context_id: Some(id.clone()),
},
"vta-context-reprovision",
)
.await?;
eprintln!(
"Minted fresh admin key '{}' in context '{id}'",
result.key_id
);
result.key_id
}
};
let deps = ExportDeps {
keys_ks: &state.keys_ks,
contexts_ks: &state.contexts_ks,
imported_ks: &state.imported_ks,
audit_ks: &state.audit_ks,
acl_ks: &state.acl_ks,
#[cfg(feature = "webvh")]
webvh_ks: &state.webvh_ks,
seed_store: &state.seed_store,
};
let bundle = build_context_provision_bundle(
&deps,
&auth,
ContextReprovisionInputs {
context_id: id.clone(),
key_id,
},
&vta_did,
vta_url.as_deref(),
"vta-context-reprovision",
)
.await?;
let admin_did = bundle.admin_did.clone();
let existing = crate::acl::get_acl_entry(&state.acl_ks, &admin_did).await?;
if existing.is_none() {
use crate::acl::AclEntry;
use chrono::Utc;
let entry = AclEntry::new(admin_did.clone(), Role::Admin, auth.did.clone())
.with_contexts(vec![id.clone()])
.with_created_at(Utc::now().timestamp() as u64);
crate::acl::store_acl_entry(&state.acl_ks, &entry).await?;
store.persist().await?;
eprintln!("Created ACL entry for {admin_did} in context '{id}'");
}
vta_cli_common::sealed_producer::emit_context_provision_bundle(
bundle,
&recipient,
out.as_deref(),
)
.await
}
#[cfg(test)]
mod tests {
use super::parse_var;
use serde_json::Value;
#[cfg(feature = "webvh")]
use crate::auth::AuthClaims;
#[cfg(feature = "webvh")]
use crate::contexts::get_context;
#[cfg(feature = "webvh")]
use crate::operations::provision_integration::ensure_target_context_or_create;
#[cfg(feature = "webvh")]
use crate::store::Store;
#[cfg(feature = "webvh")]
use vti_common::config::StoreConfig;
#[cfg(feature = "webvh")]
fn open_test_contexts_keyspace() -> (tempfile::TempDir, Store, crate::store::KeyspaceHandle) {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.expect("open store");
let ks = store
.keyspace(crate::keyspaces::CONTEXTS)
.expect("contexts keyspace");
(dir, store, ks)
}
#[test]
fn parse_var_plain_string() {
let (k, v) = parse_var("URL=https://mediator.example.com").unwrap();
assert_eq!(k, "URL");
assert_eq!(v, Value::String("https://mediator.example.com".into()));
}
#[test]
fn parse_var_quoted_string_is_json() {
let (k, v) = parse_var(r#"LABEL="hello world""#).unwrap();
assert_eq!(k, "LABEL");
assert_eq!(v, Value::String("hello world".into()));
}
#[test]
fn parse_var_number_is_json() {
let (k, v) = parse_var("COUNT=42").unwrap();
assert_eq!(k, "COUNT");
assert_eq!(v, Value::Number(42.into()));
}
#[test]
fn parse_var_bool_is_json() {
let (_, v) = parse_var("ENABLED=true").unwrap();
assert_eq!(v, Value::Bool(true));
}
#[test]
fn parse_var_array_is_json() {
let (_, v) = parse_var(r#"ROUTING_KEYS=["did:key:z1"]"#).unwrap();
assert!(v.is_array());
assert_eq!(v.as_array().unwrap().len(), 1);
}
#[test]
fn parse_var_value_may_contain_equals() {
let (k, v) = parse_var("URL=https://m.example.com?x=1&y=2").unwrap();
assert_eq!(k, "URL");
assert_eq!(v, Value::String("https://m.example.com?x=1&y=2".into()));
}
#[test]
fn parse_var_missing_equals_errors() {
let err = parse_var("LONELY").unwrap_err();
assert!(err.to_string().contains("KEY=VALUE"));
}
#[test]
fn parse_var_empty_key_errors() {
let err = parse_var("=value").unwrap_err();
assert!(err.to_string().contains("empty key"));
}
#[cfg(feature = "webvh")]
#[tokio::test]
async fn ensure_target_context_or_create_returns_actionable_error_when_missing() {
let (_dir, _store, contexts_ks) = open_test_contexts_keyspace();
let auth = AuthClaims::unsafe_local_cli_super_admin("test");
let err = ensure_target_context_or_create(&contexts_ks, &auth, "missing-ctx", false)
.await
.expect_err("missing context with create_context=false must error");
let msg = err.to_string();
assert!(
msg.contains("--create-context"),
"error must name --create-context flag, got: {msg}"
);
assert!(
msg.contains("missing-ctx"),
"error must name the missing context, got: {msg}"
);
assert!(
get_context(&contexts_ks, "missing-ctx")
.await
.unwrap()
.is_none(),
"context must remain absent after the negative path"
);
}
#[cfg(feature = "webvh")]
#[tokio::test]
async fn ensure_target_context_or_create_creates_context_when_flag_set() {
let (_dir, _store, contexts_ks) = open_test_contexts_keyspace();
let auth = AuthClaims::unsafe_local_cli_super_admin("test");
ensure_target_context_or_create(&contexts_ks, &auth, "fresh-ctx", true)
.await
.expect("create_context=true must succeed against a missing context");
let record = get_context(&contexts_ks, "fresh-ctx")
.await
.unwrap()
.expect("context must exist after create_context=true");
assert_eq!(record.id, "fresh-ctx");
}
#[cfg(feature = "webvh")]
#[tokio::test]
async fn ensure_target_context_or_create_is_idempotent_when_context_exists() {
let (_dir, _store, contexts_ks) = open_test_contexts_keyspace();
let auth = AuthClaims::unsafe_local_cli_super_admin("test");
crate::operations::contexts::create_context(
&contexts_ks,
&auth,
"existing-ctx",
"existing-ctx".into(),
None,
None,
"test-setup",
)
.await
.expect("seed existing context");
ensure_target_context_or_create(&contexts_ks, &auth, "existing-ctx", false)
.await
.expect("existing context with create_context=false must be a no-op");
ensure_target_context_or_create(&contexts_ks, &auth, "existing-ctx", true)
.await
.expect("existing context with create_context=true must be a no-op");
}
}