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::render::{is_full_display, print_full_entry, print_full_list_title, print_widget};
15use crate::sealed_producer::{SealedRecipient, seal_for_recipient};
16
17pub struct ProvisionDidOptions {
18 pub server_id: Option<String>,
19 pub did_url: Option<String>,
20 pub did_path: Option<String>,
24 pub portable: bool,
25 pub add_mediator_service: bool,
26 pub pre_rotation_count: u32,
27}
28
29pub async fn cmd_context_bootstrap(
30 client: &VtaClient,
31 id: &str,
32 name: &str,
33 description: Option<String>,
34 admin_label: Option<String>,
35 recipient: SealedRecipient,
36) -> Result<(), Box<dyn std::error::Error>> {
37 let mut ctx_req = CreateContextRequest::new(id, name);
38 if let Some(desc) = description {
39 ctx_req = ctx_req.description(desc);
40 }
41 let ctx = client.create_context(ctx_req).await?;
42 println!("Context created:");
43 println!(" ID: {}", ctx.id);
44 println!(" Name: {}", ctx.name);
45 println!(" Base Path: {}", ctx.base_path);
46
47 let config = client.get_config().await?;
49 let vta_did = config
50 .community_vta_did
51 .clone()
52 .ok_or("VTA DID not configured — cannot mint admin credential")?;
53 let vta_url = config.public_url.clone();
54
55 let (admin_bundle, admin_did) = crate::local_keygen::generate_admin_did_key(vta_did, vta_url);
58 let mut acl_req =
59 vta_sdk::client::CreateAclRequest::new(&admin_did, "admin").contexts(vec![id.to_string()]);
60 if let Some(l) = admin_label {
61 acl_req = acl_req.label(l);
62 }
63 client.create_acl(acl_req).await?;
64
65 let sealed = seal_for_recipient(
66 &recipient,
67 &SealedPayloadV1::AdminCredential(Box::new(admin_bundle)),
68 )
69 .await?;
70 println!();
71 println!("Admin credential created:");
72 println!(" DID: {admin_did}");
73 println!(" Role: admin");
74 if let Some(ref label) = recipient.label {
75 println!(" Recipient: {label}");
76 }
77 println!();
78
79 crate::sealed_producer::emit_sealed_output(&sealed, None)?;
80 Ok(())
81}
82
83pub fn render_context_list(contexts: &[ContextResponse]) {
90 if contexts.is_empty() {
91 println!("No contexts found.");
92 return;
93 }
94
95 if is_full_display() {
96 print_full_list_title("Contexts", contexts.len());
97 for ctx in contexts {
98 let did = ctx.did.as_deref().unwrap_or("—");
99 let created = ctx
100 .created_at
101 .with_timezone(&chrono::Local)
102 .format("%Y-%m-%d %H:%M:%S %:z")
103 .to_string();
104 print_full_entry(&[
105 ("ID", &ctx.id),
106 ("Name", &ctx.name),
107 ("DID", did),
108 ("Base Path", &ctx.base_path),
109 ("Created", &created),
110 ]);
111 }
112 return;
113 }
114
115 let header_style = Style::default()
116 .fg(Color::White)
117 .add_modifier(Modifier::BOLD);
118 let header = Row::new(vec!["ID", "Name", "DID", "Base Path", "Created"])
119 .style(header_style)
120 .bottom_margin(1);
121
122 let rows: Vec<Row> = contexts
123 .iter()
124 .map(|ctx| {
125 let did = ctx.did.clone().unwrap_or_else(|| "\u{2014}".into());
126 let created = ctx
127 .created_at
128 .with_timezone(&chrono::Local)
129 .format("%Y-%m-%d")
130 .to_string();
131
132 Row::new(vec![
133 Cell::from(ctx.id.clone()),
134 Cell::from(ctx.name.clone()),
135 Cell::from(did).style(Style::default().fg(Color::DarkGray)),
136 Cell::from(ctx.base_path.clone()),
137 Cell::from(created),
138 ])
139 })
140 .collect();
141
142 let title = format!(" Contexts ({}) ", contexts.len());
143
144 let table = Table::new(
148 rows,
149 [
150 Constraint::Min(16), Constraint::Min(20), Constraint::Min(40), Constraint::Length(16), Constraint::Length(10), ],
156 )
157 .header(header)
158 .column_spacing(2)
159 .block(
160 Block::bordered()
161 .title(title)
162 .border_style(Style::default().fg(Color::DarkGray)),
163 );
164
165 let height = contexts.len() as u16 + 4;
166 print_widget(table, height);
167}
168
169pub fn render_context_record(ctx: &ContextResponse) {
172 println!("ID: {}", ctx.id);
173 println!("Name: {}", ctx.name);
174 println!("DID: {}", ctx.did.as_deref().unwrap_or("(not set)"));
175 println!(
176 "Description: {}",
177 ctx.description.as_deref().unwrap_or("(not set)")
178 );
179 println!("Base Path: {}", ctx.base_path);
180 println!(
181 "Created At: {}",
182 crate::duration::format_local_datetime(ctx.created_at)
183 );
184 println!(
185 "Updated At: {}",
186 crate::duration::format_local_datetime(ctx.updated_at)
187 );
188}
189
190pub async fn cmd_context_list(client: &VtaClient) -> Result<(), Box<dyn std::error::Error>> {
191 let resp = client.list_contexts().await?;
192 if crate::render::is_json_output() {
193 crate::render::print_json(&resp.contexts)?;
194 return Ok(());
195 }
196 render_context_list(&resp.contexts);
197 Ok(())
198}
199
200pub async fn cmd_context_get(
201 client: &VtaClient,
202 id: &str,
203) -> Result<(), Box<dyn std::error::Error>> {
204 let resp = client.get_context(id).await?;
205 render_context_record(&resp);
206 Ok(())
207}
208
209#[derive(Debug, Default, Clone)]
221pub struct AdminAclOptions {
222 pub did: Option<String>,
224 pub label: Option<String>,
226 pub expires_at: Option<u64>,
228 pub expires_duration: Option<String>,
232}
233
234impl AdminAclOptions {
235 fn is_requested(&self) -> bool {
236 self.did.is_some()
237 }
238}
239
240pub async fn cmd_context_create(
241 client: &VtaClient,
242 id: &str,
243 name: &str,
244 description: Option<String>,
245 parent: Option<String>,
246 admin: AdminAclOptions,
247) -> Result<(), Box<dyn std::error::Error>> {
248 use crate::render::{RESET, YELLOW};
249 use vta_sdk::error::VtaError;
250
251 let effective_id = parent
254 .as_ref()
255 .map_or_else(|| id.to_string(), |p| format!("{p}/{id}"));
256 let req = CreateContextRequest {
257 id: id.to_string(),
258 name: name.to_string(),
259 description,
260 parent,
261 };
262 let resp = match client.create_context(req).await {
263 Ok(r) => r,
264 Err(VtaError::Conflict(_)) if admin.is_requested() => {
270 let did = admin.did.as_deref().unwrap_or_default();
271 let bin = crate::render::bin_name();
272 eprintln!(
273 "{YELLOW}\u{26a0}{RESET} Context '{effective_id}' already exists — skipping context creation."
274 );
275 eprintln!();
276 eprintln!(" The --admin-did was NOT added. To grant admin access to an existing");
277 eprintln!(" context, use the ACL command directly:");
278 eprintln!();
279 let mut hint =
280 format!(" {bin} acl create --did {did} --role admin --contexts {effective_id}");
281 if let Some(label) = admin.label.as_deref() {
282 hint.push_str(&format!(" --label '{label}'"));
283 }
284 match (admin.expires_duration.as_deref(), admin.expires_at) {
285 (Some(raw), _) => hint.push_str(&format!(" --expires {raw}")),
289 (None, Some(expires_at)) => {
290 let remaining = expires_at.saturating_sub(crate::duration::now_unix());
291 hint.push_str(&format!(" --expires {remaining}s"));
292 }
293 (None, None) => {}
294 }
295 eprintln!("{hint}");
296 return Ok(());
297 }
298 Err(e) => return Err(e.into()),
299 };
300 println!("Context created:");
301 println!(" ID: {}", resp.id);
302 println!(" Name: {}", resp.name);
303 println!(" Base Path: {}", resp.base_path);
304
305 if admin.is_requested() {
306 let did = admin.did.as_deref().unwrap_or_default();
307 if !did.starts_with("did:") {
308 return Err(format!(
309 "--admin-did must start with `did:` (got {did:?}) — context was created but no ACL entry was added"
310 )
311 .into());
312 }
313 let mut acl_req =
316 vta_sdk::client::CreateAclRequest::new(did, "admin").contexts(vec![resp.id.clone()]);
317 if let Some(label) = admin.label.as_deref() {
318 acl_req = acl_req.label(label);
319 }
320 if let Some(expires_at) = admin.expires_at {
321 acl_req = acl_req.expires_at(expires_at);
322 }
323 let acl = client.create_acl(acl_req).await?;
324
325 println!();
326 println!("Admin ACL entry created:");
327 println!(" DID: {}", acl.did);
328 println!(" Role: {}", acl.role);
329 println!(" Contexts: {}", acl.allowed_contexts.join(", "));
330 if let Some(ref label) = acl.label {
331 println!(" Label: {label}");
332 }
333 match acl.expires_at {
334 Some(secs) => {
335 println!(
336 " Expires at: {} ({}) — setup ACL",
337 crate::duration::format_local_time(secs),
338 crate::duration::format_remaining(secs),
339 );
340 println!();
341 println!(" The admin should authenticate before expiry. On first successful");
342 println!(" connect PNM rotates to a fresh long-lived did:key and replaces this");
343 println!(" temporary entry with a permanent one.");
344 }
345 None => println!(" Expires at: (permanent)"),
346 }
347 }
348
349 Ok(())
350}
351
352pub async fn cmd_context_update(
353 client: &VtaClient,
354 id: &str,
355 name: Option<String>,
356 did: Option<String>,
357 description: Option<String>,
358) -> Result<(), Box<dyn std::error::Error>> {
359 let req = UpdateContextRequest {
360 name,
361 did,
362 description,
363 context_policy: None,
364 };
365 let resp = client.update_context(id, req).await?;
366 println!("Context updated:");
367 render_context_record(&resp);
368 Ok(())
369}
370
371pub async fn cmd_context_update_did(
372 client: &VtaClient,
373 id: &str,
374 did: &str,
375) -> Result<(), Box<dyn std::error::Error>> {
376 let resp = client.update_context_did(id, did).await?;
377 println!("Context DID updated:");
378 println!(" ID: {}", resp.id);
379 println!(
380 " DID: {}",
381 resp.did.as_deref().unwrap_or("(not set)")
382 );
383 println!(
384 " Updated At: {}",
385 crate::duration::format_local_datetime(resp.updated_at)
386 );
387 Ok(())
388}
389
390pub fn render_delete_context_preview(
397 id: &str,
398 preview: &vta_sdk::protocols::context_management::delete::DeleteContextPreviewResultBody,
399) -> bool {
400 let has_resources = !preview.keys.is_empty()
401 || !preview.webvh_dids.is_empty()
402 || !preview.acl_entries_removed.is_empty()
403 || !preview.acl_entries_updated.is_empty();
404
405 if !has_resources {
406 return false;
407 }
408
409 println!(
410 "Deleting context '{}' will remove the following resources:\n",
411 id
412 );
413
414 if !preview.keys.is_empty() {
415 println!(" Keys ({}):", preview.keys.len());
416 for key in &preview.keys {
417 println!(" - {key}");
418 }
419 }
420
421 if !preview.webvh_dids.is_empty() {
422 println!(" WebVH DIDs ({}):", preview.webvh_dids.len());
423 for did in &preview.webvh_dids {
424 println!(" - {did}");
425 }
426 }
427
428 if !preview.acl_entries_removed.is_empty() {
429 println!(
430 " ACL entries removed ({}):",
431 preview.acl_entries_removed.len()
432 );
433 for did in &preview.acl_entries_removed {
434 println!(" - {did}");
435 }
436 }
437
438 if !preview.acl_entries_updated.is_empty() {
439 println!(
440 " ACL entries updated (context removed from access list) ({}):",
441 preview.acl_entries_updated.len()
442 );
443 for did in &preview.acl_entries_updated {
444 println!(" - {did}");
445 }
446 }
447
448 println!();
449 true
450}
451
452pub fn confirm_destructive(prompt: &str) -> Result<bool, Box<dyn std::error::Error>> {
456 print!("{prompt} [y/N] ");
457 io::stdout().flush()?;
458 let mut input = String::new();
459 io::stdin().read_line(&mut input)?;
460 let input = input.trim().to_lowercase();
461 Ok(input == "y" || input == "yes")
462}
463
464pub async fn cmd_context_delete(
465 client: &VtaClient,
466 id: &str,
467 force: bool,
468) -> Result<(), Box<dyn std::error::Error>> {
469 let preview = client.preview_delete_context(id).await?;
471
472 let has_resources = render_delete_context_preview(id, &preview);
473
474 if has_resources && !force && !confirm_destructive("Proceed with deletion?")? {
475 println!("Aborted.");
476 return Ok(());
477 }
478
479 client.delete_context(id, true).await?;
480 println!("Context deleted: {id}");
481 Ok(())
482}
483
484pub async fn cmd_context_provision(
485 client: &VtaClient,
486 id: &str,
487 name: &str,
488 description: Option<String>,
489 admin_label: Option<String>,
490 did_opts: Option<ProvisionDidOptions>,
491 recipient: SealedRecipient,
492) -> Result<(), Box<dyn std::error::Error>> {
493 eprintln!("Creating context '{id}'...");
495 let mut ctx_req = CreateContextRequest::new(id, name);
496 if let Some(desc) = description {
497 ctx_req = ctx_req.description(desc);
498 }
499 client.create_context(ctx_req).await?;
500
501 let config = client.get_config().await?;
503 let vta_did = config
504 .community_vta_did
505 .clone()
506 .ok_or("VTA DID not configured — cannot mint admin credential")?;
507 let vta_url = config.public_url.clone();
508
509 eprintln!("Minting local admin credential and registering ACL...");
514 let (admin_credential, admin_did) =
515 crate::local_keygen::generate_admin_did_key(vta_did, vta_url);
516 let mut acl_req =
517 vta_sdk::client::CreateAclRequest::new(&admin_did, "admin").contexts(vec![id.to_string()]);
518 if let Some(l) = admin_label {
519 acl_req = acl_req.label(l);
520 }
521 client.create_acl(acl_req).await?;
522
523 let provisioned_did = if let Some(opts) = did_opts {
525 eprintln!("Creating WebVH DID...");
526 let req = CreateDidWebvhRequest {
527 context_id: id.to_string(),
528 server_id: opts.server_id,
529 url: opts.did_url,
530 path: None,
531 path_mode: opts.did_path.map(WebvhPathMode::from),
537 domain: None,
540 label: Some(id.to_string()),
541 portable: opts.portable,
542 add_mediator_service: opts.add_mediator_service,
543 additional_services: None,
544 pre_rotation_count: opts.pre_rotation_count,
545 did_document: None,
546 did_log: None,
547 set_primary: true,
548 signing_key_id: None,
549 ka_key_id: None,
550 template: None,
551 template_context: None,
552 template_vars: std::collections::HashMap::new(),
553 };
554 let did_result = client.create_did_webvh(req).await?;
555
556 eprintln!("Fetching DID key secrets...");
558 let mut secrets: Vec<SecretEntry> = Vec::new();
559 secrets.push(
561 client
562 .get_key_secret(&did_result.signing_key_id)
563 .await?
564 .into(),
565 );
566 secrets.push(client.get_key_secret(&did_result.ka_key_id).await?.into());
568 for i in 0..did_result.pre_rotation_key_count {
570 let pre_rot_id = format!("{}#pre-rotation-{i}", did_result.did);
571 secrets.push(client.get_key_secret(&pre_rot_id).await?.into());
572 }
573
574 Some(ProvisionedDid {
575 id: did_result.did,
576 did_document: did_result.did_document,
577 log_entry: did_result.log_entry,
578 secrets,
579 })
580 } else {
581 None
582 };
583
584 let bundle = ContextProvisionBundle {
586 context_id: id.to_string(),
587 context_name: name.to_string(),
588 vta_url: config.public_url,
589 vta_did: config.community_vta_did,
590 credential: admin_credential,
591 admin_did,
592 did: provisioned_did,
593 };
594
595 crate::sealed_producer::emit_context_provision_bundle(bundle, &recipient, None).await
597}
598
599async fn credential_from_key(
606 client: &VtaClient,
607 key_id: &str,
608 vta_did: &str,
609 vta_url: Option<&str>,
610) -> Result<(CredentialBundle, String), Box<dyn std::error::Error>> {
611 let secret = client.get_key_secret(key_id).await?;
612 CredentialBundle::from_ed25519_seed_multibase(&secret.private_key_multibase, vta_did, vta_url)
613 .map_err(|e| format!("Cannot decode key secret: {e}").into())
614}
615
616pub async fn cmd_context_reprovision(
617 client: &VtaClient,
618 id: &str,
619 key_id: Option<String>,
620 admin_label: Option<String>,
621 recipient: SealedRecipient,
622) -> Result<(), Box<dyn std::error::Error>> {
623 eprintln!("Fetching context '{id}'...");
625 let ctx = client.get_context(id).await?;
626
627 let config = client.get_config().await?;
629 let vta_did = config
630 .community_vta_did
631 .as_deref()
632 .ok_or("VTA DID not configured")?;
633
634 let (admin_credential, admin_did) = if let Some(ref kid) = key_id {
636 eprintln!("Using key '{kid}'...");
638 credential_from_key(client, kid, vta_did, config.public_url.as_deref()).await?
639 } else {
640 let keys_resp = client.list_keys(0, 10000, Some("active"), Some(id)).await?;
642 let ed25519_keys: Vec<_> = keys_resp
643 .keys
644 .iter()
645 .filter(|k| k.key_type == KeyType::Ed25519)
646 .collect();
647
648 eprintln!();
649 eprintln!("Select an admin credential key for context '{id}':");
650 eprintln!();
651 for (i, key) in ed25519_keys.iter().enumerate() {
652 let label = key
653 .label
654 .as_deref()
655 .map(|l| format!(" ({l})"))
656 .unwrap_or_default();
657 eprintln!(" [{}] {}{}", i + 1, key.key_id, label);
658 }
659 let new_option = ed25519_keys.len() + 1;
660 eprintln!(" [{}] Create a new admin key", new_option);
661 eprintln!();
662 eprint!("Choice [{}]: ", new_option);
663 io::stderr().flush()?;
664
665 let mut input = String::new();
666 io::stdin().read_line(&mut input)?;
667 let input = input.trim();
668
669 let choice: usize = if input.is_empty() {
671 new_option
672 } else {
673 input
674 .parse()
675 .map_err(|_| format!("Invalid choice: {input}"))?
676 };
677
678 if choice == new_option {
679 eprintln!("Creating new admin key...");
681 let key_resp = client
682 .create_key(CreateKeyRequest {
683 key_type: KeyType::Ed25519,
684 derivation_path: None,
685 key_id: None,
686 mnemonic: None,
687 label: admin_label.or_else(|| Some("admin".to_string())),
688 context_id: Some(id.to_string()),
689 })
690 .await?;
691 credential_from_key(
692 client,
693 &key_resp.key_id,
694 vta_did,
695 config.public_url.as_deref(),
696 )
697 .await?
698 } else if choice >= 1 && choice <= ed25519_keys.len() {
699 let selected = &ed25519_keys[choice - 1];
700 eprintln!("Using key '{}'...", selected.key_id);
701 credential_from_key(
702 client,
703 &selected.key_id,
704 vta_did,
705 config.public_url.as_deref(),
706 )
707 .await?
708 } else {
709 return Err(format!("Invalid choice: {choice}").into());
710 }
711 };
712
713 if client.get_acl(&admin_did).await.is_err() {
715 eprintln!("Creating ACL entry for {admin_did}...");
716 client
717 .create_acl(
718 vta_sdk::client::CreateAclRequest::new(&admin_did, "admin")
719 .contexts(vec![id.to_string()]),
720 )
721 .await?;
722 }
723
724 let provisioned_did = if let Some(ref did_id) = ctx.did {
726 eprintln!("Fetching DID material...");
727
728 let log_resp = client.get_did_webvh_log(did_id).await?;
730 let (did_document, log_entry) = if let Some(ref log_str) = log_resp.log {
731 let parsed: serde_json::Value = serde_json::from_str(log_str)
732 .map_err(|e| format!("failed to parse DID log: {e}"))?;
733 let doc = parsed.get("state").cloned();
734 (doc, Some(log_str.clone()))
735 } else {
736 (None, None)
737 };
738
739 let secrets_bundle = client.fetch_did_secrets_bundle(id).await?;
741
742 Some(ProvisionedDid {
743 id: did_id.clone(),
744 did_document,
745 log_entry,
746 secrets: secrets_bundle.secrets,
747 })
748 } else {
749 None
750 };
751
752 let bundle = ContextProvisionBundle {
754 context_id: id.to_string(),
755 context_name: ctx.name.clone(),
756 vta_url: config.public_url,
757 vta_did: config.community_vta_did,
758 credential: admin_credential,
759 admin_did,
760 did: provisioned_did,
761 };
762
763 crate::sealed_producer::emit_context_provision_bundle(bundle, &recipient, None).await
765}