use std::io::{self, Write};
use ratatui::{
layout::Constraint,
style::{Color, Modifier, Style},
widgets::{Block, Cell, Row, Table},
};
use vta_sdk::client::{ContextResponse, CreateDidWebvhRequest, UpdateContextRequest};
use vta_sdk::context_provision::{ContextProvisionBundle, ProvisionedDid};
use vta_sdk::prelude::*;
use vta_sdk::sealed_transfer::SealedPayloadV1;
use crate::render::{is_full_display, print_full_entry, print_full_list_title, print_widget};
use crate::sealed_producer::{SealedRecipient, seal_for_recipient};
pub struct ProvisionDidOptions {
pub server_id: Option<String>,
pub did_url: Option<String>,
pub portable: bool,
pub add_mediator_service: bool,
pub pre_rotation_count: u32,
}
pub async fn cmd_context_bootstrap(
client: &VtaClient,
id: &str,
name: &str,
description: Option<String>,
admin_label: Option<String>,
recipient: SealedRecipient,
) -> Result<(), Box<dyn std::error::Error>> {
let mut ctx_req = CreateContextRequest::new(id, name);
if let Some(desc) = description {
ctx_req = ctx_req.description(desc);
}
let ctx = client.create_context(ctx_req).await?;
println!("Context created:");
println!(" ID: {}", ctx.id);
println!(" Name: {}", ctx.name);
println!(" Base Path: {}", ctx.base_path);
let config = client.get_config().await?;
let vta_did = config
.community_vta_did
.clone()
.ok_or("VTA DID not configured — cannot mint admin credential")?;
let vta_url = config.public_url.clone();
let (admin_bundle, admin_did) = crate::local_keygen::generate_admin_did_key(vta_did, vta_url);
let mut acl_req =
vta_sdk::client::CreateAclRequest::new(&admin_did, "admin").contexts(vec![id.to_string()]);
if let Some(l) = admin_label {
acl_req = acl_req.label(l);
}
client.create_acl(acl_req).await?;
let sealed = seal_for_recipient(
&recipient,
&SealedPayloadV1::AdminCredential(Box::new(admin_bundle)),
)
.await?;
println!();
println!("Admin credential created:");
println!(" DID: {admin_did}");
println!(" Role: admin");
if let Some(ref label) = recipient.label {
println!(" Recipient: {label}");
}
println!();
crate::sealed_producer::emit_sealed_output(&sealed, None)?;
Ok(())
}
pub fn render_context_list(contexts: &[ContextResponse]) {
if contexts.is_empty() {
println!("No contexts found.");
return;
}
if is_full_display() {
print_full_list_title("Contexts", contexts.len());
for ctx in contexts {
let did = ctx.did.as_deref().unwrap_or("—");
let created = ctx
.created_at
.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M:%S %:z")
.to_string();
print_full_entry(&[
("ID", &ctx.id),
("Name", &ctx.name),
("DID", did),
("Base Path", &ctx.base_path),
("Created", &created),
]);
}
return;
}
let header_style = Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD);
let header = Row::new(vec!["ID", "Name", "DID", "Base Path", "Created"])
.style(header_style)
.bottom_margin(1);
let rows: Vec<Row> = contexts
.iter()
.map(|ctx| {
let did = ctx.did.clone().unwrap_or_else(|| "\u{2014}".into());
let created = ctx
.created_at
.with_timezone(&chrono::Local)
.format("%Y-%m-%d")
.to_string();
Row::new(vec![
Cell::from(ctx.id.clone()),
Cell::from(ctx.name.clone()),
Cell::from(did).style(Style::default().fg(Color::DarkGray)),
Cell::from(ctx.base_path.clone()),
Cell::from(created),
])
})
.collect();
let title = format!(" Contexts ({}) ", contexts.len());
let table = Table::new(
rows,
[
Constraint::Min(16), Constraint::Min(20), Constraint::Min(40), Constraint::Length(16), Constraint::Length(10), ],
)
.header(header)
.column_spacing(2)
.block(
Block::bordered()
.title(title)
.border_style(Style::default().fg(Color::DarkGray)),
);
let height = contexts.len() as u16 + 4;
print_widget(table, height);
}
pub fn render_context_record(ctx: &ContextResponse) {
println!("ID: {}", ctx.id);
println!("Name: {}", ctx.name);
println!("DID: {}", ctx.did.as_deref().unwrap_or("(not set)"));
println!(
"Description: {}",
ctx.description.as_deref().unwrap_or("(not set)")
);
println!("Base Path: {}", ctx.base_path);
println!(
"Created At: {}",
crate::duration::format_local_datetime(ctx.created_at)
);
println!(
"Updated At: {}",
crate::duration::format_local_datetime(ctx.updated_at)
);
}
pub async fn cmd_context_list(client: &VtaClient) -> Result<(), Box<dyn std::error::Error>> {
let resp = client.list_contexts().await?;
if crate::render::is_json_output() {
crate::render::print_json(&resp.contexts)?;
return Ok(());
}
render_context_list(&resp.contexts);
Ok(())
}
pub async fn cmd_context_get(
client: &VtaClient,
id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let resp = client.get_context(id).await?;
render_context_record(&resp);
Ok(())
}
#[derive(Debug, Default, Clone)]
pub struct AdminAclOptions {
pub did: Option<String>,
pub label: Option<String>,
pub expires_at: Option<u64>,
pub expires_duration: Option<String>,
}
impl AdminAclOptions {
fn is_requested(&self) -> bool {
self.did.is_some()
}
}
pub async fn cmd_context_create(
client: &VtaClient,
id: &str,
name: &str,
description: Option<String>,
parent: Option<String>,
admin: AdminAclOptions,
) -> Result<(), Box<dyn std::error::Error>> {
use crate::render::{RESET, YELLOW};
use vta_sdk::error::VtaError;
let effective_id = parent
.as_ref()
.map_or_else(|| id.to_string(), |p| format!("{p}/{id}"));
let req = CreateContextRequest {
id: id.to_string(),
name: name.to_string(),
description,
parent,
};
let resp = match client.create_context(req).await {
Ok(r) => r,
Err(VtaError::Conflict(_)) if admin.is_requested() => {
let did = admin.did.as_deref().unwrap_or_default();
let bin = crate::render::bin_name();
eprintln!(
"{YELLOW}\u{26a0}{RESET} Context '{effective_id}' already exists — skipping context creation."
);
eprintln!();
eprintln!(" The --admin-did was NOT added. To grant admin access to an existing");
eprintln!(" context, use the ACL command directly:");
eprintln!();
let mut hint =
format!(" {bin} acl create --did {did} --role admin --contexts {effective_id}");
if let Some(label) = admin.label.as_deref() {
hint.push_str(&format!(" --label '{label}'"));
}
match (admin.expires_duration.as_deref(), admin.expires_at) {
(Some(raw), _) => hint.push_str(&format!(" --expires {raw}")),
(None, Some(expires_at)) => {
let remaining = expires_at.saturating_sub(crate::duration::now_unix());
hint.push_str(&format!(" --expires {remaining}s"));
}
(None, None) => {}
}
eprintln!("{hint}");
return Ok(());
}
Err(e) => return Err(e.into()),
};
println!("Context created:");
println!(" ID: {}", resp.id);
println!(" Name: {}", resp.name);
println!(" Base Path: {}", resp.base_path);
if admin.is_requested() {
let did = admin.did.as_deref().unwrap_or_default();
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 mut acl_req =
vta_sdk::client::CreateAclRequest::new(did, "admin").contexts(vec![resp.id.clone()]);
if let Some(label) = admin.label.as_deref() {
acl_req = acl_req.label(label);
}
if let Some(expires_at) = admin.expires_at {
acl_req = acl_req.expires_at(expires_at);
}
let acl = client.create_acl(acl_req).await?;
println!();
println!("Admin ACL entry created:");
println!(" DID: {}", acl.did);
println!(" Role: {}", acl.role);
println!(" Contexts: {}", acl.allowed_contexts.join(", "));
if let Some(ref label) = acl.label {
println!(" Label: {label}");
}
match acl.expires_at {
Some(secs) => {
println!(
" Expires at: {} ({}) — setup ACL",
crate::duration::format_local_time(secs),
crate::duration::format_remaining(secs),
);
println!();
println!(" The admin should authenticate before expiry. On first successful");
println!(" connect PNM rotates to a fresh long-lived did:key and replaces this");
println!(" temporary entry with a permanent one.");
}
None => println!(" Expires at: (permanent)"),
}
}
Ok(())
}
pub async fn cmd_context_update(
client: &VtaClient,
id: &str,
name: Option<String>,
did: Option<String>,
description: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let req = UpdateContextRequest {
name,
did,
description,
};
let resp = client.update_context(id, req).await?;
println!("Context updated:");
render_context_record(&resp);
Ok(())
}
pub async fn cmd_context_update_did(
client: &VtaClient,
id: &str,
did: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let resp = client.update_context_did(id, did).await?;
println!("Context DID updated:");
println!(" ID: {}", resp.id);
println!(
" DID: {}",
resp.did.as_deref().unwrap_or("(not set)")
);
println!(
" Updated At: {}",
crate::duration::format_local_datetime(resp.updated_at)
);
Ok(())
}
pub fn render_delete_context_preview(
id: &str,
preview: &vta_sdk::protocols::context_management::delete::DeleteContextPreviewResultBody,
) -> bool {
let has_resources = !preview.keys.is_empty()
|| !preview.webvh_dids.is_empty()
|| !preview.acl_entries_removed.is_empty()
|| !preview.acl_entries_updated.is_empty();
if !has_resources {
return false;
}
println!(
"Deleting context '{}' will remove the following resources:\n",
id
);
if !preview.keys.is_empty() {
println!(" Keys ({}):", preview.keys.len());
for key in &preview.keys {
println!(" - {key}");
}
}
if !preview.webvh_dids.is_empty() {
println!(" WebVH DIDs ({}):", preview.webvh_dids.len());
for did in &preview.webvh_dids {
println!(" - {did}");
}
}
if !preview.acl_entries_removed.is_empty() {
println!(
" ACL entries removed ({}):",
preview.acl_entries_removed.len()
);
for did in &preview.acl_entries_removed {
println!(" - {did}");
}
}
if !preview.acl_entries_updated.is_empty() {
println!(
" ACL entries updated (context removed from access list) ({}):",
preview.acl_entries_updated.len()
);
for did in &preview.acl_entries_updated {
println!(" - {did}");
}
}
println!();
true
}
pub fn confirm_destructive(prompt: &str) -> Result<bool, Box<dyn std::error::Error>> {
print!("{prompt} [y/N] ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let input = input.trim().to_lowercase();
Ok(input == "y" || input == "yes")
}
pub async fn cmd_context_delete(
client: &VtaClient,
id: &str,
force: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let preview = client.preview_delete_context(id).await?;
let has_resources = render_delete_context_preview(id, &preview);
if has_resources && !force && !confirm_destructive("Proceed with deletion?")? {
println!("Aborted.");
return Ok(());
}
client.delete_context(id, true).await?;
println!("Context deleted: {id}");
Ok(())
}
pub async fn cmd_context_provision(
client: &VtaClient,
id: &str,
name: &str,
description: Option<String>,
admin_label: Option<String>,
did_opts: Option<ProvisionDidOptions>,
recipient: SealedRecipient,
) -> Result<(), Box<dyn std::error::Error>> {
eprintln!("Creating context '{id}'...");
let mut ctx_req = CreateContextRequest::new(id, name);
if let Some(desc) = description {
ctx_req = ctx_req.description(desc);
}
client.create_context(ctx_req).await?;
let config = client.get_config().await?;
let vta_did = config
.community_vta_did
.clone()
.ok_or("VTA DID not configured — cannot mint admin credential")?;
let vta_url = config.public_url.clone();
eprintln!("Minting local admin credential and registering ACL...");
let (admin_credential, admin_did) =
crate::local_keygen::generate_admin_did_key(vta_did, vta_url);
let mut acl_req =
vta_sdk::client::CreateAclRequest::new(&admin_did, "admin").contexts(vec![id.to_string()]);
if let Some(l) = admin_label {
acl_req = acl_req.label(l);
}
client.create_acl(acl_req).await?;
let provisioned_did = if let Some(opts) = did_opts {
eprintln!("Creating WebVH DID...");
let req = CreateDidWebvhRequest {
context_id: id.to_string(),
server_id: opts.server_id,
url: opts.did_url,
path: None,
path_mode: None,
domain: None,
label: Some(id.to_string()),
portable: opts.portable,
add_mediator_service: opts.add_mediator_service,
additional_services: None,
pre_rotation_count: opts.pre_rotation_count,
did_document: None,
did_log: None,
set_primary: true,
signing_key_id: None,
ka_key_id: None,
template: None,
template_context: None,
template_vars: std::collections::HashMap::new(),
};
let did_result = client.create_did_webvh(req).await?;
eprintln!("Fetching DID key secrets...");
let mut secrets: Vec<SecretEntry> = Vec::new();
secrets.push(
client
.get_key_secret(&did_result.signing_key_id)
.await?
.into(),
);
secrets.push(client.get_key_secret(&did_result.ka_key_id).await?.into());
for i in 0..did_result.pre_rotation_key_count {
let pre_rot_id = format!("{}#pre-rotation-{i}", did_result.did);
secrets.push(client.get_key_secret(&pre_rot_id).await?.into());
}
Some(ProvisionedDid {
id: did_result.did,
did_document: did_result.did_document,
log_entry: did_result.log_entry,
secrets,
})
} else {
None
};
let bundle = ContextProvisionBundle {
context_id: id.to_string(),
context_name: name.to_string(),
vta_url: config.public_url,
vta_did: config.community_vta_did,
credential: admin_credential,
admin_did,
did: provisioned_did,
};
crate::sealed_producer::emit_context_provision_bundle(bundle, &recipient, None).await
}
async fn credential_from_key(
client: &VtaClient,
key_id: &str,
vta_did: &str,
vta_url: Option<&str>,
) -> Result<(CredentialBundle, String), Box<dyn std::error::Error>> {
let secret = client.get_key_secret(key_id).await?;
CredentialBundle::from_ed25519_seed_multibase(&secret.private_key_multibase, vta_did, vta_url)
.map_err(|e| format!("Cannot decode key secret: {e}").into())
}
pub async fn cmd_context_reprovision(
client: &VtaClient,
id: &str,
key_id: Option<String>,
admin_label: Option<String>,
recipient: SealedRecipient,
) -> Result<(), Box<dyn std::error::Error>> {
eprintln!("Fetching context '{id}'...");
let ctx = client.get_context(id).await?;
let config = client.get_config().await?;
let vta_did = config
.community_vta_did
.as_deref()
.ok_or("VTA DID not configured")?;
let (admin_credential, admin_did) = if let Some(ref kid) = key_id {
eprintln!("Using key '{kid}'...");
credential_from_key(client, kid, vta_did, config.public_url.as_deref()).await?
} else {
let keys_resp = client.list_keys(0, 10000, Some("active"), Some(id)).await?;
let ed25519_keys: Vec<_> = keys_resp
.keys
.iter()
.filter(|k| k.key_type == KeyType::Ed25519)
.collect();
eprintln!();
eprintln!("Select an admin credential key for context '{id}':");
eprintln!();
for (i, key) in ed25519_keys.iter().enumerate() {
let label = key
.label
.as_deref()
.map(|l| format!(" ({l})"))
.unwrap_or_default();
eprintln!(" [{}] {}{}", i + 1, key.key_id, label);
}
let new_option = ed25519_keys.len() + 1;
eprintln!(" [{}] Create a new admin key", new_option);
eprintln!();
eprint!("Choice [{}]: ", new_option);
io::stderr().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let input = input.trim();
let choice: usize = if input.is_empty() {
new_option
} else {
input
.parse()
.map_err(|_| format!("Invalid choice: {input}"))?
};
if choice == new_option {
eprintln!("Creating new admin key...");
let key_resp = client
.create_key(CreateKeyRequest {
key_type: KeyType::Ed25519,
derivation_path: None,
key_id: None,
mnemonic: None,
label: admin_label.or_else(|| Some("admin".to_string())),
context_id: Some(id.to_string()),
})
.await?;
credential_from_key(
client,
&key_resp.key_id,
vta_did,
config.public_url.as_deref(),
)
.await?
} else if choice >= 1 && choice <= ed25519_keys.len() {
let selected = &ed25519_keys[choice - 1];
eprintln!("Using key '{}'...", selected.key_id);
credential_from_key(
client,
&selected.key_id,
vta_did,
config.public_url.as_deref(),
)
.await?
} else {
return Err(format!("Invalid choice: {choice}").into());
}
};
if client.get_acl(&admin_did).await.is_err() {
eprintln!("Creating ACL entry for {admin_did}...");
client
.create_acl(
vta_sdk::client::CreateAclRequest::new(&admin_did, "admin")
.contexts(vec![id.to_string()]),
)
.await?;
}
let provisioned_did = if let Some(ref did_id) = ctx.did {
eprintln!("Fetching DID material...");
let log_resp = client.get_did_webvh_log(did_id).await?;
let (did_document, log_entry) = if let Some(ref log_str) = log_resp.log {
let parsed: serde_json::Value = serde_json::from_str(log_str)
.map_err(|e| format!("failed to parse DID log: {e}"))?;
let doc = parsed.get("state").cloned();
(doc, Some(log_str.clone()))
} else {
(None, None)
};
let secrets_bundle = client.fetch_did_secrets_bundle(id).await?;
Some(ProvisionedDid {
id: did_id.clone(),
did_document,
log_entry,
secrets: secrets_bundle.secrets,
})
} else {
None
};
let bundle = ContextProvisionBundle {
context_id: id.to_string(),
context_name: ctx.name.clone(),
vta_url: config.public_url,
vta_did: config.community_vta_did,
credential: admin_credential,
admin_did,
did: provisioned_did,
};
crate::sealed_producer::emit_context_provision_bundle(bundle, &recipient, None).await
}