1use std::io::{self, Write};
2
3use ratatui::{
4 layout::Constraint,
5 style::{Color, Modifier, Style},
6 widgets::{Block, Cell, Row, Table},
7};
8use vta_sdk::client::{ContextResponse, CreateDidWebvhRequest, UpdateContextRequest};
9use vta_sdk::context_provision::{ContextProvisionBundle, ProvisionedDid};
10use vta_sdk::prelude::*;
11use vta_sdk::protocols::did_management::create::WebvhPathMode;
12use vta_sdk::sealed_transfer::SealedPayloadV1;
13
14use crate::display::{NameBook, book_from_acl, inline};
15use crate::render::{is_full_display, print_full_entry, print_full_list_title, print_widget};
16use crate::sealed_producer::{SealedRecipient, seal_for_recipient};
17
18pub struct ProvisionDidOptions {
19 pub server_id: Option<String>,
20 pub did_url: Option<String>,
21 pub did_path: Option<String>,
25 pub portable: bool,
26 pub add_mediator_service: bool,
27 pub pre_rotation_count: u32,
28}
29
30pub async fn cmd_context_bootstrap(
31 client: &VtaClient,
32 id: &str,
33 name: &str,
34 description: Option<String>,
35 admin_label: Option<String>,
36 recipient: SealedRecipient,
37) -> Result<(), Box<dyn std::error::Error>> {
38 let mut ctx_req = CreateContextRequest::new(id, name);
39 if let Some(desc) = description {
40 ctx_req = ctx_req.description(desc);
41 }
42 let ctx = client.create_context(ctx_req).await?;
43 println!("Context created:");
44 println!(" ID: {}", ctx.id);
45 println!(" Name: {}", ctx.name);
46 println!(" Base Path: {}", ctx.base_path);
47
48 let config = client.get_config().await?;
50 let vta_did = config
51 .vta_did()
52 .map(str::to_string)
53 .ok_or("VTA DID not configured — cannot mint admin credential")?;
54 let vta_url = config.public_url().map(str::to_string);
55
56 let (admin_bundle, admin_did) = crate::local_keygen::generate_admin_did_key(vta_did, vta_url);
59 let mut acl_req =
60 vta_sdk::client::CreateAclRequest::new(&admin_did, "admin").contexts(vec![id.to_string()]);
61 if let Some(l) = admin_label {
62 acl_req = acl_req.label(l);
63 }
64 client.create_acl(acl_req).await?;
65
66 let sealed = seal_for_recipient(
67 &recipient,
68 &SealedPayloadV1::AdminCredential(Box::new(admin_bundle)),
69 )
70 .await?;
71 println!();
72 println!("Admin credential created:");
73 println!(" DID: {admin_did}");
74 println!(" Role: admin");
75 if let Some(ref label) = recipient.label {
76 println!(" Recipient: {label}");
77 }
78 println!();
79
80 crate::sealed_producer::emit_sealed_output(&sealed, None)?;
81 Ok(())
82}
83
84pub fn render_context_list(contexts: &[ContextResponse]) {
91 if contexts.is_empty() {
92 println!("No contexts found.");
93 return;
94 }
95
96 if is_full_display() {
97 print_full_list_title("Contexts", contexts.len());
98 for ctx in contexts {
99 let did = ctx.did.as_deref().unwrap_or("—");
100 let created = ctx
101 .created_at
102 .with_timezone(&chrono::Local)
103 .format("%Y-%m-%d %H:%M:%S %:z")
104 .to_string();
105 print_full_entry(&[
106 ("ID", &ctx.id),
107 ("Name", &ctx.name),
108 ("DID", did),
109 ("Base Path", &ctx.base_path),
110 ("Created", &created),
111 ]);
112 }
113 return;
114 }
115
116 let header_style = Style::default()
117 .fg(Color::White)
118 .add_modifier(Modifier::BOLD);
119 let header = Row::new(vec!["ID", "Name", "DID", "Base Path", "Created"])
120 .style(header_style)
121 .bottom_margin(1);
122
123 let rows: Vec<Row> = contexts
124 .iter()
125 .map(|ctx| {
126 let did = ctx.did.clone().unwrap_or_else(|| "\u{2014}".into());
127 let created = ctx
128 .created_at
129 .with_timezone(&chrono::Local)
130 .format("%Y-%m-%d")
131 .to_string();
132
133 Row::new(vec![
134 Cell::from(ctx.id.clone()),
135 Cell::from(ctx.name.clone()),
136 Cell::from(did).style(Style::default().fg(Color::DarkGray)),
137 Cell::from(ctx.base_path.clone()),
138 Cell::from(created),
139 ])
140 })
141 .collect();
142
143 let title = format!(" Contexts ({}) ", contexts.len());
144
145 let table = Table::new(
149 rows,
150 [
151 Constraint::Min(16), Constraint::Min(20), Constraint::Min(40), Constraint::Length(16), Constraint::Length(10), ],
157 )
158 .header(header)
159 .column_spacing(2)
160 .block(
161 Block::bordered()
162 .title(title)
163 .border_style(Style::default().fg(Color::DarkGray)),
164 );
165
166 let height = contexts.len() as u16 + 4;
167 print_widget(table, height);
168}
169
170pub fn render_context_record(ctx: &ContextResponse) {
173 println!("ID: {}", ctx.id);
174 println!("Name: {}", ctx.name);
175 println!("DID: {}", ctx.did.as_deref().unwrap_or("(not set)"));
176 println!(
177 "Description: {}",
178 ctx.description.as_deref().unwrap_or("(not set)")
179 );
180 println!("Base Path: {}", ctx.base_path);
181 println!(
182 "Created At: {}",
183 crate::duration::format_local_datetime(ctx.created_at)
184 );
185 println!(
186 "Updated At: {}",
187 crate::duration::format_local_datetime(ctx.updated_at)
188 );
189}
190
191pub async fn cmd_context_list(client: &VtaClient) -> Result<(), Box<dyn std::error::Error>> {
192 let resp = client.list_contexts().await?;
193 if crate::render::is_json_output() {
194 crate::render::print_json(&resp.contexts)?;
195 return Ok(());
196 }
197 render_context_list(&resp.contexts);
198 Ok(())
199}
200
201pub async fn cmd_context_get(
202 client: &VtaClient,
203 id: &str,
204) -> Result<(), Box<dyn std::error::Error>> {
205 let resp = client.get_context(id).await?;
206 render_context_record(&resp);
207 Ok(())
208}
209
210#[derive(Debug, Default, Clone)]
222pub struct AdminAclOptions {
223 pub did: Option<String>,
225 pub label: Option<String>,
227 pub expires_at: Option<u64>,
229 pub expires_duration: Option<String>,
233 pub holder: bool,
242}
243
244impl AdminAclOptions {
245 fn is_requested(&self) -> bool {
246 self.did.is_some()
247 }
248}
249
250pub async fn cmd_context_create(
251 client: &VtaClient,
252 id: &str,
253 name: &str,
254 description: Option<String>,
255 parent: Option<String>,
256 admin: AdminAclOptions,
257) -> Result<(), Box<dyn std::error::Error>> {
258 use crate::render::{RESET, YELLOW};
259 use vta_sdk::error::VtaError;
260
261 let effective_id = parent
264 .as_ref()
265 .map_or_else(|| id.to_string(), |p| format!("{p}/{id}"));
266 let req = CreateContextRequest {
267 id: id.to_string(),
268 name: name.to_string(),
269 description,
270 parent,
271 };
272 let resp = match client.create_context(req).await {
273 Ok(r) => r,
274 Err(VtaError::Conflict(_)) if admin.is_requested() => {
280 let did = admin.did.as_deref().unwrap_or_default();
281 let bin = crate::render::bin_name();
282 eprintln!(
283 "{YELLOW}\u{26a0}{RESET} Context '{effective_id}' already exists — skipping context creation."
284 );
285 eprintln!();
286 eprintln!(" The --admin-did was NOT added. To grant admin access to an existing");
287 eprintln!(" context, use the ACL command directly:");
288 eprintln!();
289 let mut hint =
290 format!(" {bin} acl create --did {did} --role admin --contexts {effective_id}");
291 if let Some(label) = admin.label.as_deref() {
292 hint.push_str(&format!(" --label '{label}'"));
293 }
294 match (admin.expires_duration.as_deref(), admin.expires_at) {
295 (Some(raw), _) => hint.push_str(&format!(" --expires {raw}")),
299 (None, Some(expires_at)) => {
300 let remaining = expires_at.saturating_sub(crate::duration::now_unix());
301 hint.push_str(&format!(" --expires {remaining}s"));
302 }
303 (None, None) => {}
304 }
305 eprintln!("{hint}");
306 return Ok(());
307 }
308 Err(e) => return Err(e.into()),
309 };
310 println!("Context created:");
311 println!(" ID: {}", resp.id);
312 println!(" Name: {}", resp.name);
313 println!(" Base Path: {}", resp.base_path);
314
315 if admin.is_requested() {
316 let did = admin.did.as_deref().unwrap_or_default();
317 if !did.starts_with("did:") {
318 return Err(format!(
319 "--admin-did must start with `did:` (got {did:?}) — context was created but no ACL entry was added"
320 )
321 .into());
322 }
323 let mut acl_req =
326 vta_sdk::client::CreateAclRequest::new(did, "admin").contexts(vec![resp.id.clone()]);
327 if let Some(label) = admin.label.as_deref() {
328 acl_req = acl_req.label(label);
329 }
330 if let Some(expires_at) = admin.expires_at {
331 acl_req = acl_req.expires_at(expires_at);
332 }
333 if admin.holder {
334 acl_req = acl_req.capabilities(vec!["persona-holder".to_string()]);
338 }
339 let acl = client.create_acl(acl_req).await?;
340
341 println!();
342 println!("Admin ACL entry created:");
343 println!(" DID: {}", acl.did);
344 println!(" Role: {}", acl.role);
345 println!(" Contexts: {}", acl.allowed_contexts.join(", "));
346 if let Some(ref label) = acl.label {
347 println!(" Label: {label}");
348 }
349 if admin.holder {
350 println!(" Identity: holder (persona-holder capability granted)");
351 }
352 match acl.expires_at {
353 Some(secs) => {
354 println!(
355 " Expires at: {} ({}) — setup ACL",
356 crate::duration::format_local_time(secs),
357 crate::duration::format_remaining(secs),
358 );
359 println!();
360 println!(" The admin should authenticate before expiry. On first successful");
361 println!(" connect PNM rotates to a fresh long-lived did:key and replaces this");
362 println!(" temporary entry with a permanent one.");
363 }
364 None => println!(" Expires at: (permanent)"),
365 }
366 }
367
368 Ok(())
369}
370
371pub async fn cmd_context_update(
372 client: &VtaClient,
373 id: &str,
374 name: Option<String>,
375 did: Option<String>,
376 description: Option<String>,
377) -> Result<(), Box<dyn std::error::Error>> {
378 let req = UpdateContextRequest {
379 name,
380 did,
381 description,
382 context_policy: None,
383 };
384 let resp = client.update_context(id, req).await?;
385 println!("Context updated:");
386 render_context_record(&resp);
387 Ok(())
388}
389
390pub async fn cmd_context_update_did(
391 client: &VtaClient,
392 id: &str,
393 did: &str,
394) -> Result<(), Box<dyn std::error::Error>> {
395 let resp = client.update_context_did(id, did).await?;
396 println!("Context DID updated:");
397 println!(" ID: {}", resp.id);
398 println!(
399 " DID: {}",
400 resp.did.as_deref().unwrap_or("(not set)")
401 );
402 println!(
403 " Updated At: {}",
404 crate::duration::format_local_datetime(resp.updated_at)
405 );
406 Ok(())
407}
408
409pub fn render_delete_context_preview(
431 id: &str,
432 preview: &vta_sdk::protocols::context_management::delete::DeleteContextPreviewResultBody,
433 book: &NameBook,
434) -> bool {
435 let sub_contexts = &preview.sub_contexts;
436 let has_resources = !sub_contexts.is_empty()
437 || !preview.keys.is_empty()
438 || !preview.webvh_dids.is_empty()
439 || !preview.acl_entries_removed.is_empty()
440 || !preview.acl_entries_updated.is_empty()
441 || !preview.did_templates.is_empty();
442
443 if !has_resources {
444 return false;
445 }
446
447 println!(
448 "Deleting context '{}' will remove the following resources:\n",
449 id
450 );
451
452 if !sub_contexts.is_empty() {
455 println!(" Sub-contexts ({}):", sub_contexts.len());
456 for ctx in sub_contexts {
457 println!(" - {ctx}");
458 }
459 }
460
461 if !preview.keys.is_empty() {
462 println!(" Keys ({}):", preview.keys.len());
463 for key in &preview.keys {
464 println!(" - {key}");
465 }
466 }
467
468 if !preview.webvh_dids.is_empty() {
473 println!(" WebVH DIDs ({}):", preview.webvh_dids.len());
474 for did in &preview.webvh_dids {
475 println!(" - {}", inline(book, did));
476 }
477 }
478
479 if !preview.acl_entries_removed.is_empty() {
480 println!(
481 " ACL entries removed ({}):",
482 preview.acl_entries_removed.len()
483 );
484 for did in &preview.acl_entries_removed {
485 println!(" - {}", inline(book, did));
486 }
487 }
488
489 if !preview.acl_entries_updated.is_empty() {
490 println!(
491 " ACL entries updated (context removed from access list) ({}):",
492 preview.acl_entries_updated.len()
493 );
494 for did in &preview.acl_entries_updated {
495 println!(" - {}", inline(book, did));
496 }
497 }
498
499 if !preview.did_templates.is_empty() {
500 println!(" DID templates ({}):", preview.did_templates.len());
501 for name in &preview.did_templates {
502 println!(" - {name}");
503 }
504 }
505
506 println!();
507 true
508}
509
510pub fn confirm_destructive(prompt: &str) -> Result<bool, Box<dyn std::error::Error>> {
514 print!("{prompt} [y/N] ");
515 io::stdout().flush()?;
516 let mut input = String::new();
517 io::stdin().read_line(&mut input)?;
518 let input = input.trim().to_lowercase();
519 Ok(input == "y" || input == "yes")
520}
521
522pub async fn cmd_context_delete(
523 client: &VtaClient,
524 id: &str,
525 force: bool,
526) -> Result<(), Box<dyn std::error::Error>> {
527 let preview = client.preview_delete_context(id).await?;
529
530 let mut book = NameBook::new();
533 if let Ok(acl) = client.list_acl(None).await {
534 book_from_acl(&mut book, &acl.entries);
535 }
536
537 let has_resources = render_delete_context_preview(id, &preview, &book);
538
539 if has_resources && !force && !confirm_destructive("Proceed with deletion?")? {
540 println!("Aborted.");
541 return Ok(());
542 }
543
544 let result = client.delete_context_with_outcome(id, true).await?;
545 println!("Context deleted: {id}");
546 if !result.daemon_cleanup_errors.is_empty() {
549 eprintln!(
550 "\nwarning: {} DID(s) may still resolve — their hosting server did not confirm \
551 removal of the published log. Clean these up out-of-band:",
552 result.daemon_cleanup_errors.len()
553 );
554 for line in &result.daemon_cleanup_errors {
555 eprintln!(" - {line}");
556 }
557 }
558 Ok(())
559}
560
561pub async fn cmd_context_provision(
562 client: &VtaClient,
563 id: &str,
564 name: &str,
565 description: Option<String>,
566 admin_label: Option<String>,
567 did_opts: Option<ProvisionDidOptions>,
568 recipient: SealedRecipient,
569) -> Result<(), Box<dyn std::error::Error>> {
570 eprintln!("Creating context '{id}'...");
572 let mut ctx_req = CreateContextRequest::new(id, name);
573 if let Some(desc) = description {
574 ctx_req = ctx_req.description(desc);
575 }
576 client.create_context(ctx_req).await?;
577
578 let config = client.get_config().await?;
580 let vta_did = config
581 .vta_did()
582 .map(str::to_string)
583 .ok_or("VTA DID not configured — cannot mint admin credential")?;
584 let vta_url = config.public_url().map(str::to_string);
585
586 eprintln!("Minting local admin credential and registering ACL...");
591 let (admin_credential, admin_did) =
592 crate::local_keygen::generate_admin_did_key(vta_did, vta_url);
593 let mut acl_req =
594 vta_sdk::client::CreateAclRequest::new(&admin_did, "admin").contexts(vec![id.to_string()]);
595 if let Some(l) = admin_label {
596 acl_req = acl_req.label(l);
597 }
598 client.create_acl(acl_req).await?;
599
600 let provisioned_did = if let Some(opts) = did_opts {
602 eprintln!("Creating WebVH DID...");
603 let req = CreateDidWebvhRequest {
604 context_id: id.to_string(),
605 server_id: opts.server_id,
606 url: opts.did_url,
607 path: None,
608 path_mode: opts.did_path.map(WebvhPathMode::from),
614 domain: None,
617 label: Some(id.to_string()),
618 portable: opts.portable,
619 add_mediator_service: opts.add_mediator_service,
620 add_tsp_service: false,
621 additional_services: None,
622 pre_rotation_count: opts.pre_rotation_count,
623 did_document: None,
624 did_log: None,
625 set_primary: true,
626 signing_key_id: None,
627 ka_key_id: None,
628 template: None,
629 template_context: None,
630 template_vars: std::collections::HashMap::new(),
631 };
632 let did_result = client.create_did_webvh(req).await?;
633
634 eprintln!("Fetching DID key secrets...");
636 let mut secrets: Vec<SecretEntry> = Vec::new();
637 secrets.push(
639 client
640 .get_key_secret(&did_result.signing_key_id)
641 .await?
642 .into(),
643 );
644 secrets.push(client.get_key_secret(&did_result.ka_key_id).await?.into());
646 for i in 0..did_result.pre_rotation_key_count {
648 let pre_rot_id = format!("{}#pre-rotation-{i}", did_result.did);
649 secrets.push(client.get_key_secret(&pre_rot_id).await?.into());
650 }
651
652 Some(ProvisionedDid {
653 id: did_result.did,
654 did_document: did_result.did_document,
655 log_entry: did_result.log_entry,
656 secrets,
657 })
658 } else {
659 None
660 };
661
662 let bundle = ContextProvisionBundle {
664 context_id: id.to_string(),
665 context_name: name.to_string(),
666 vta_url: config.public_url().map(str::to_string),
667 vta_did: config.vta_did().map(str::to_string),
668 credential: admin_credential,
669 admin_did,
670 did: provisioned_did,
671 };
672
673 crate::sealed_producer::emit_context_provision_bundle(bundle, &recipient, None).await
675}
676
677async fn credential_from_key(
684 client: &VtaClient,
685 key_id: &str,
686 vta_did: &str,
687 vta_url: Option<&str>,
688) -> Result<(CredentialBundle, String), Box<dyn std::error::Error>> {
689 let secret = client.get_key_secret(key_id).await?;
690 CredentialBundle::from_ed25519_seed_multibase(&secret.private_key_multibase, vta_did, vta_url)
691 .map_err(|e| format!("Cannot decode key secret: {e}").into())
692}
693
694pub async fn cmd_context_reprovision(
695 client: &VtaClient,
696 id: &str,
697 key_id: Option<String>,
698 admin_label: Option<String>,
699 recipient: SealedRecipient,
700) -> Result<(), Box<dyn std::error::Error>> {
701 eprintln!("Fetching context '{id}'...");
703 let ctx = client.get_context(id).await?;
704
705 let config = client.get_config().await?;
707 let vta_did = config.vta_did().ok_or("VTA DID not configured")?;
708
709 let (admin_credential, admin_did) = if let Some(ref kid) = key_id {
711 eprintln!("Using key '{kid}'...");
713 credential_from_key(client, kid, vta_did, config.public_url()).await?
714 } else {
715 let keys_resp = client.list_keys(0, 10000, Some("active"), Some(id)).await?;
717 let ed25519_keys: Vec<_> = keys_resp
718 .keys
719 .iter()
720 .filter(|k| k.key_type == KeyType::Ed25519)
721 .collect();
722
723 eprintln!();
724 eprintln!("Select an admin credential key for context '{id}':");
725 eprintln!();
726 for (i, key) in ed25519_keys.iter().enumerate() {
727 let label = key
728 .label
729 .as_deref()
730 .map(|l| format!(" ({l})"))
731 .unwrap_or_default();
732 eprintln!(" [{}] {}{}", i + 1, key.key_id, label);
733 }
734 let new_option = ed25519_keys.len() + 1;
735 eprintln!(" [{}] Create a new admin key", new_option);
736 eprintln!();
737 eprint!("Choice [{}]: ", new_option);
738 io::stderr().flush()?;
739
740 let mut input = String::new();
741 io::stdin().read_line(&mut input)?;
742 let input = input.trim();
743
744 let choice: usize = if input.is_empty() {
746 new_option
747 } else {
748 input
749 .parse()
750 .map_err(|_| format!("Invalid choice: {input}"))?
751 };
752
753 if choice == new_option {
754 eprintln!("Creating new admin key...");
756 let key_resp = client
757 .create_key(CreateKeyRequest {
758 internal: None,
759 key_type: KeyType::Ed25519,
760 derivation_path: None,
761 key_id: None,
762 mnemonic: None,
763 label: admin_label.or_else(|| Some("admin".to_string())),
764 context_id: Some(id.to_string()),
765 })
766 .await?;
767 credential_from_key(client, &key_resp.key_id, vta_did, config.public_url()).await?
768 } else if choice >= 1 && choice <= ed25519_keys.len() {
769 let selected = &ed25519_keys[choice - 1];
770 eprintln!("Using key '{}'...", selected.key_id);
771 credential_from_key(client, &selected.key_id, vta_did, config.public_url()).await?
772 } else {
773 return Err(format!("Invalid choice: {choice}").into());
774 }
775 };
776
777 if client.get_acl(&admin_did).await.is_err() {
779 eprintln!("Creating ACL entry for {admin_did}...");
780 client
781 .create_acl(
782 vta_sdk::client::CreateAclRequest::new(&admin_did, "admin")
783 .contexts(vec![id.to_string()]),
784 )
785 .await?;
786 }
787
788 let provisioned_did = if let Some(ref did_id) = ctx.did {
790 eprintln!("Fetching DID material...");
791
792 let log_resp = client.get_did_webvh_log(did_id).await?;
794 let (did_document, log_entry) = if let Some(ref log_str) = log_resp.log {
795 let parsed: serde_json::Value = serde_json::from_str(log_str)
796 .map_err(|e| format!("failed to parse DID log: {e}"))?;
797 let doc = parsed.get("state").cloned();
798 (doc, Some(log_str.clone()))
799 } else {
800 (None, None)
801 };
802
803 let secrets_bundle = client.fetch_did_secrets_bundle(id).await?;
805
806 Some(ProvisionedDid {
807 id: did_id.clone(),
808 did_document,
809 log_entry,
810 secrets: secrets_bundle.secrets,
811 })
812 } else {
813 None
814 };
815
816 let bundle = ContextProvisionBundle {
818 context_id: id.to_string(),
819 context_name: ctx.name.clone(),
820 vta_url: config.public_url().map(str::to_string),
821 vta_did: config.vta_did().map(str::to_string),
822 credential: admin_credential,
823 admin_did,
824 did: provisioned_did,
825 };
826
827 crate::sealed_producer::emit_context_provision_bundle(bundle, &recipient, None).await
829}