use std::path::PathBuf;
use std::sync::Arc;
use dialoguer::{Confirm, Input, Select};
use didwebvh_rs::url::WebVHURL;
use serde_json::json;
use url::Url;
use affinidi_did_resolver_cache_sdk::{DIDCacheClient, config::DIDCacheConfigBuilder};
use vta_sdk::did_secrets::{DidSecretsBundle, SecretEntry};
use vta_sdk::protocols::did_management::create::WebvhPathMode;
use crate::acl::{AclEntry, Role, store_acl_entry};
use crate::config::AppConfig;
use crate::keys::seed_store::create_seed_store;
use crate::operations;
use crate::operations::did_webvh::CreateDidWebvhParams;
use crate::setup;
use crate::store::Store;
use crate::webvh_cli::cli_super_admin;
pub struct CreateDidWebvhArgs {
pub config_path: Option<PathBuf>,
pub context: String,
pub label: Option<String>,
pub url: Option<String>,
pub export_secrets: bool,
pub admin: bool,
}
pub async fn run_create_did_webvh(
args: CreateDidWebvhArgs,
) -> Result<(), Box<dyn std::error::Error>> {
let config = AppConfig::load(args.config_path)?;
let store = Store::open(&config.store)?;
let keys_ks = store.keyspace(crate::keyspaces::KEYS)?;
let imported_ks = store.keyspace(crate::keyspaces::IMPORTED_SECRETS)?;
let contexts_ks = store.keyspace(crate::keyspaces::CONTEXTS)?;
let webvh_ks = store.keyspace(crate::keyspaces::WEBVH)?;
let audit_ks = store.keyspace(crate::keyspaces::AUDIT)?;
let did_templates_ks = store.keyspace(crate::keyspaces::DID_TEMPLATES)?;
let ctx = match crate::contexts::get_context(&contexts_ks, &args.context).await? {
Some(ctx) => ctx,
None => {
eprintln!("Context '{}' does not exist.", args.context);
let name: String = Input::new()
.with_prompt("Create it with name")
.default(args.context.clone())
.interact_text()?;
let ctx = crate::contexts::create_context(&contexts_ks, &args.context, &name).await?;
eprintln!("Created context: {} ({})", ctx.id, ctx.base_path);
ctx
}
};
let label = args.label.as_deref().unwrap_or(&args.context);
let interactive = args.url.is_none();
let webvh_url = match &args.url {
Some(raw) => {
let parsed = Url::parse(raw).map_err(|e| format!("invalid --url `{raw}`: {e}"))?;
WebVHURL::parse_url(&parsed)
.map_err(|e| format!("invalid did:webvh hosting URL `{raw}`: {e}"))?
}
None => setup::prompt_webvh_url(label)?,
};
let url_str = webvh_url
.get_http_url(None)
.map_err(|e| format!("{e}"))?
.to_string();
let seed_store = create_seed_store(&config)?;
let seed = crate::keys::seeds::load_seed_bytes(
&keys_ks,
&*seed_store,
Some(
crate::keys::seeds::get_active_seed_id(&keys_ks)
.await
.map_err(|e| format!("{e}"))?,
),
)
.await
.map_err(|e| format!("{e}"))?;
let derived = crate::keys::derive_entity_keys(
&seed,
&ctx.base_path,
&format!("{label} signing key"),
&format!("{label} key-agreement key"),
&keys_ks,
)
.await?;
let mut did_document =
operations::did_webvh::build_did_document(&derived, &config, false, &None);
if let Some(ref msg) = config.messaging {
let want_didcomm = if interactive {
let service_options = &[
"DIDComm endpoint (references mediator DID for routing)",
"No service endpoints",
];
Select::new()
.with_prompt("Service endpoints")
.items(service_options)
.default(0)
.interact()?
== 0
} else {
true
};
if want_didcomm {
did_document["service"] = json!([
{
"id": "{DID}#vta-didcomm",
"type": "DIDCommMessaging",
"serviceEndpoint": [{
"accept": ["didcomm/v2"],
"uri": msg.mediator_did
}]
}
]);
}
}
eprintln!();
eprintln!(
"\x1b[2mDID Document:\n{}\x1b[0m",
serde_json::to_string_pretty(&did_document)?
);
eprintln!();
if interactive
&& Confirm::new()
.with_prompt("Edit DID document in your editor?")
.default(false)
.interact()?
{
did_document = edit_did_document(did_document)?;
}
let portable = if interactive {
Confirm::new()
.with_prompt("Make this DID portable (can move to a different domain later)?")
.default(true)
.interact()?
} else {
true
};
let pre_rotation_count: u32 = if interactive {
Input::new()
.with_prompt("Number of pre-rotation keys (0 = none, recommended: 1-3)")
.default(1u32)
.interact_text()?
} else {
1
};
let auth = cli_super_admin();
let did_resolver = DIDCacheClient::new(DIDCacheConfigBuilder::default().build()).await?;
let no_bridge: Arc<crate::didcomm_bridge::DIDCommBridge> =
Arc::new(crate::didcomm_bridge::DIDCommBridge::placeholder());
let params = CreateDidWebvhParams {
context_id: args.context.clone(),
server_id: None,
url: Some(url_str.clone()),
path_mode: WebvhPathMode::default(),
domain: None,
label: Some(label.to_string()),
portable,
add_mediator_service: false, additional_services: None,
pre_rotation_count,
did_document: Some(did_document),
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(),
is_vta_identity: false,
};
let auth_locks = operations::did_webvh::WebvhAuthLocks::new();
let deps = operations::did_webvh::CreateDidWebvhDeps {
keys_ks: &keys_ks,
imported_ks: &imported_ks,
contexts_ks: &contexts_ks,
webvh_ks: &webvh_ks,
did_templates_ks: &did_templates_ks,
audit_ks: &audit_ks,
seed_store: &*seed_store,
config: &config,
did_resolver: &did_resolver,
didcomm_bridge: &no_bridge,
auth_locks: &auth_locks,
};
let result = operations::did_webvh::create_did_webvh(&deps, &auth, params, "cli").await?;
let final_did = &result.did;
eprintln!("\x1b[1;32mCreated DID:\x1b[0m {final_did}");
if args.admin {
let acl_ks = store.keyspace(crate::keyspaces::ACL)?;
let entry = AclEntry::new(final_did.clone(), Role::Admin, "cli:create-did-webvh")
.with_label(args.label.clone())
.with_contexts(vec![args.context.clone()]);
store_acl_entry(&acl_ks, &entry).await?;
eprintln!(
"ACL entry created: {final_did} (admin, context: {})",
args.context
);
}
store.persist().await?;
if let Some(ref log_entry) = result.log_entry {
if interactive {
let default_file = format!("{label}-did.jsonl");
let did_file: String = Input::new()
.with_prompt("Save DID log to file")
.default(default_file)
.interact_text()?;
std::fs::write(&did_file, log_entry)?;
eprintln!(" DID log saved to: {did_file}");
eprintln!(" Context '{}' updated with DID: {final_did}", args.context);
eprintln!();
eprintln!(" \x1b[2mTo self-host this DID, upload {did_file} to:");
eprintln!(" {url_str}\x1b[0m");
} else {
eprintln!(" Context '{}' updated with DID: {final_did}", args.context);
eprintln!(" \x1b[2mDID log (did.jsonl) ready; self-host at: {url_str}\x1b[0m");
}
}
let want_export = if args.export_secrets {
true
} else if interactive {
Confirm::new()
.with_prompt("Export DID secrets bundle?")
.default(false)
.interact()?
} else {
false
};
if want_export {
let signing_secret = crate::operations::keys::get_key_secret(
&keys_ks,
&imported_ks,
&Arc::from(seed_store),
&store.keyspace(crate::keyspaces::AUDIT)?,
&auth,
&result.signing_key_id,
"cli",
)
.await
.map_err(|e| format!("failed to fetch signing key secret: {e}"))?;
let mut secrets = vec![SecretEntry {
key_id: result.signing_key_id.clone(),
key_type: vta_sdk::keys::KeyType::Ed25519,
private_key_multibase: signing_secret.private_key_multibase,
}];
if !result.ka_key_id.is_empty() {
let ka_secret = crate::operations::keys::get_key_secret(
&keys_ks,
&imported_ks,
&Arc::from(create_seed_store(&config)?),
&store.keyspace(crate::keyspaces::AUDIT)?,
&auth,
&result.ka_key_id,
"cli",
)
.await
.map_err(|e| format!("failed to fetch KA key secret: {e}"))?;
secrets.push(SecretEntry {
key_id: result.ka_key_id.clone(),
key_type: vta_sdk::keys::KeyType::X25519,
private_key_multibase: ka_secret.private_key_multibase,
});
}
let bundle = DidSecretsBundle {
did: final_did.clone(),
secrets,
};
let json = serde_json::to_string_pretty(&bundle)?;
eprintln!();
eprintln!("\x1b[1;33m╔══════════════════════════════════════════════════════════╗");
eprintln!("║ WARNING: The secrets bundle contains private keys. ║");
eprintln!("║ Redirect to a file with restrictive permissions. ║");
eprintln!("╚══════════════════════════════════════════════════════════╝\x1b[0m");
eprintln!();
println!("{json}");
eprintln!();
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acl::get_acl_entry;
use crate::keys::seeds::{SeedRecord, save_seed_record, set_active_seed_id};
use vti_common::acl::Role;
#[cfg(feature = "config-seed")]
#[tokio::test]
async fn create_did_webvh_url_admin_export_is_noninteractive_and_grants_admin() {
let dir = tempfile::TempDir::new().expect("tempdir");
let data_dir = dir.path().join("data");
std::fs::create_dir_all(&data_dir).unwrap();
let config_path = dir.path().join("config.toml");
let seed_hex = hex::encode([7u8; 64]);
std::fs::write(
&config_path,
format!(
"[store]\ndata_dir = \"{}\"\n\n[secrets]\nseed = \"{seed_hex}\"\n",
data_dir.display()
),
)
.unwrap();
let config = AppConfig::load(Some(config_path.clone())).expect("load config");
let store = Store::open(&config.store).expect("open store");
let keys_ks = store.keyspace(crate::keyspaces::KEYS).unwrap();
let contexts_ks = store.keyspace(crate::keyspaces::CONTEXTS).unwrap();
save_seed_record(
&keys_ks,
&SeedRecord {
id: 0,
seed_hex: None,
seed_enc: None,
created_at: chrono::Utc::now(),
retired_at: None,
},
)
.await
.unwrap();
set_active_seed_id(&keys_ks, 0).await.unwrap();
crate::contexts::create_context(&contexts_ks, "agents", "Agents")
.await
.unwrap();
store.persist().await.unwrap();
drop(keys_ks);
drop(contexts_ks);
drop(store);
let args = CreateDidWebvhArgs {
config_path: Some(config_path.clone()),
context: "agents".to_string(),
label: Some("agent-1".to_string()),
url: Some("https://example.com/agents/agent-1".to_string()),
export_secrets: true,
admin: true,
};
run_create_did_webvh(args).await.expect("create-did-webvh");
let store = Store::open(&config.store).expect("reopen store");
let webvh_ks = store.keyspace(crate::keyspaces::WEBVH).unwrap();
let acl_ks = store.keyspace(crate::keyspaces::ACL).unwrap();
let dids = crate::webvh_store::list_dids(&webvh_ks).await.unwrap();
assert_eq!(dids.len(), 1, "one did:webvh minted");
let did = &dids[0].did;
assert!(did.starts_with("did:webvh:"), "got {did}");
let keys_ks2 = store.keyspace(crate::keyspaces::KEYS).unwrap();
let key0: Option<crate::keys::KeyRecord> = keys_ks2
.get(crate::keys::store_key(&format!("{did}#key-0")))
.await
.unwrap();
let key1: Option<crate::keys::KeyRecord> = keys_ks2
.get(crate::keys::store_key(&format!("{did}#key-1")))
.await
.unwrap();
assert!(key0.is_some(), "#key-0 (signing) record present");
assert!(key1.is_some(), "#key-1 (key-agreement) record present");
assert_eq!(key1.unwrap().key_type, crate::keys::KeyType::X25519);
let entry = get_acl_entry(&acl_ks, did)
.await
.unwrap()
.expect("ACL entry created for the did:webvh");
assert_eq!(entry.role, Role::Admin);
assert_eq!(entry.allowed_contexts, vec!["agents".to_string()]);
}
}
fn edit_did_document(
doc: serde_json::Value,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
use std::io::Write;
use std::process::Command;
let json = serde_json::to_string_pretty(&doc)?;
let mut tmp = tempfile::Builder::new().suffix(".json").tempfile()?;
tmp.write_all(json.as_bytes())?;
tmp.flush()?;
let path = tmp.path().to_path_buf();
let editor = std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| "vi".to_string());
let status = Command::new(&editor)
.arg(&path)
.status()
.map_err(|e| format!("failed to launch editor '{editor}': {e}"))?;
if !status.success() {
return Err(format!("editor exited with {status}").into());
}
let edited = std::fs::read_to_string(&path)?;
let new_doc: serde_json::Value =
serde_json::from_str(&edited).map_err(|e| format!("invalid JSON from editor: {e}"))?;
if !new_doc.is_object() || !new_doc.get("id").is_some_and(|v| v.is_string()) {
return Err("DID document must be a JSON object with an \"id\" field".into());
}
eprintln!(
"\x1b[2mUpdated DID Document:\n{}\x1b[0m",
serde_json::to_string_pretty(&new_doc)?
);
Ok(new_doc)
}