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