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
30fn create_request_for_path(
35 path: &str,
36 name: &str,
37 description: Option<String>,
38) -> Result<CreateContextRequest, Box<dyn std::error::Error>> {
39 vta_sdk::context_path::validate_context_path(path)?;
40 let mut req = match path.rsplit_once(vta_sdk::context_path::SEPARATOR) {
41 Some((parent, leaf)) => CreateContextRequest::new(leaf, name).parent(parent),
42 None => CreateContextRequest::new(path, name),
43 };
44 req.description = description;
45 Ok(req)
46}
47
48pub async fn cmd_context_bootstrap(
49 client: &VtaClient,
50 id: &str,
51 name: &str,
52 description: Option<String>,
53 admin_label: Option<String>,
54 recipient: SealedRecipient,
55) -> Result<(), Box<dyn std::error::Error>> {
56 let ctx_req = create_request_for_path(id, name, description)?;
57 let ctx = client.create_context(ctx_req).await?;
58 println!("Context created:");
59 println!(" ID: {}", ctx.id);
60 println!(" Name: {}", ctx.name);
61 println!(" Base Path: {}", ctx.base_path);
62
63 let config = client.get_config().await?;
65 let vta_did = config
66 .vta_did()
67 .map(str::to_string)
68 .ok_or("VTA DID not configured — cannot mint admin credential")?;
69 let vta_url = config.public_url().map(str::to_string);
70
71 let (admin_bundle, admin_did) = crate::local_keygen::generate_admin_did_key(vta_did, vta_url);
74 let mut acl_req =
75 vta_sdk::client::CreateAclRequest::new(&admin_did, "admin").contexts(vec![id.to_string()]);
76 if let Some(l) = admin_label {
77 acl_req = acl_req.label(l);
78 }
79 client.create_acl(acl_req).await?;
80
81 let sealed = seal_for_recipient(
82 &recipient,
83 &SealedPayloadV1::AdminCredential(Box::new(admin_bundle)),
84 )
85 .await?;
86 println!();
87 println!("Admin credential created:");
88 println!(" DID: {admin_did}");
89 println!(" Role: admin");
90 if let Some(ref label) = recipient.label {
91 println!(" Recipient: {label}");
92 }
93 println!();
94
95 crate::sealed_producer::emit_sealed_output(&sealed, None)?;
96 Ok(())
97}
98
99pub fn render_context_list(contexts: &[ContextResponse]) {
106 if contexts.is_empty() {
107 println!("No contexts found.");
108 return;
109 }
110
111 if is_full_display() {
112 print_full_list_title("Contexts", contexts.len());
113 for ctx in contexts {
114 let did = ctx.did.as_deref().unwrap_or("—");
115 let created = ctx
116 .created_at
117 .with_timezone(&chrono::Local)
118 .format("%Y-%m-%d %H:%M:%S %:z")
119 .to_string();
120 print_full_entry(&[
121 ("ID", &ctx.id),
122 ("Name", &ctx.name),
123 ("DID", did),
124 ("Base Path", &ctx.base_path),
125 ("Created", &created),
126 ]);
127 }
128 return;
129 }
130
131 let header_style = Style::default()
132 .fg(Color::White)
133 .add_modifier(Modifier::BOLD);
134 let header = Row::new(vec!["ID", "Name", "DID", "Base Path", "Created"])
135 .style(header_style)
136 .bottom_margin(1);
137
138 let rows: Vec<Row> = contexts
139 .iter()
140 .map(|ctx| {
141 let did = ctx.did.clone().unwrap_or_else(|| "\u{2014}".into());
142 let created = ctx
143 .created_at
144 .with_timezone(&chrono::Local)
145 .format("%Y-%m-%d")
146 .to_string();
147
148 Row::new(vec![
149 Cell::from(ctx.id.clone()),
150 Cell::from(ctx.name.clone()),
151 Cell::from(did).style(Style::default().fg(Color::DarkGray)),
152 Cell::from(ctx.base_path.clone()),
153 Cell::from(created),
154 ])
155 })
156 .collect();
157
158 let title = format!(" Contexts ({}) ", contexts.len());
159
160 let table = Table::new(
164 rows,
165 [
166 Constraint::Min(16), Constraint::Min(20), Constraint::Min(40), Constraint::Length(16), Constraint::Length(10), ],
172 )
173 .header(header)
174 .column_spacing(2)
175 .block(
176 Block::bordered()
177 .title(title)
178 .border_style(Style::default().fg(Color::DarkGray)),
179 );
180
181 let height = contexts.len() as u16 + 4;
182 print_widget(table, height);
183}
184
185pub fn render_context_record(ctx: &ContextResponse) {
188 println!("ID: {}", ctx.id);
189 println!("Name: {}", ctx.name);
190 println!("DID: {}", ctx.did.as_deref().unwrap_or("(not set)"));
191 println!(
192 "Description: {}",
193 ctx.description.as_deref().unwrap_or("(not set)")
194 );
195 println!("Base Path: {}", ctx.base_path);
196 println!(
197 "Created At: {}",
198 crate::duration::format_local_datetime(ctx.created_at)
199 );
200 println!(
201 "Updated At: {}",
202 crate::duration::format_local_datetime(ctx.updated_at)
203 );
204}
205
206pub async fn cmd_context_list(client: &VtaClient) -> Result<(), Box<dyn std::error::Error>> {
207 let resp = client.list_contexts().await?;
208 if crate::render::is_json_output() {
209 crate::render::print_json(&resp.contexts)?;
210 return Ok(());
211 }
212 render_context_list(&resp.contexts);
213 Ok(())
214}
215
216pub async fn cmd_context_get(
217 client: &VtaClient,
218 id: &str,
219) -> Result<(), Box<dyn std::error::Error>> {
220 let resp = client.get_context(id).await?;
221 render_context_record(&resp);
222 Ok(())
223}
224
225#[derive(Debug, Default, Clone)]
237pub struct AdminAclOptions {
238 pub did: Option<String>,
240 pub label: Option<String>,
242 pub expires_at: Option<u64>,
244 pub expires_duration: Option<String>,
248 pub holder: bool,
257 pub handoff: bool,
260}
261
262impl AdminAclOptions {
263 fn is_requested(&self) -> bool {
264 self.did.is_some()
265 }
266}
267
268pub async fn cmd_context_create(
269 client: &VtaClient,
270 id: &str,
271 name: &str,
272 description: Option<String>,
273 admin: AdminAclOptions,
274) -> Result<(), Box<dyn std::error::Error>> {
275 use crate::render::{RESET, YELLOW};
276 use vta_sdk::error::VtaError;
277
278 let effective_id = id;
279 let req = create_request_for_path(id, name, description)?;
280 let resp = match client.create_context(req).await {
281 Ok(r) => r,
282 Err(VtaError::Conflict(_)) if admin.is_requested() => {
288 let did = admin.did.as_deref().unwrap_or_default();
289 let bin = crate::render::bin_name();
290 eprintln!(
291 "{YELLOW}\u{26a0}{RESET} Context '{effective_id}' already exists — skipping context creation."
292 );
293 eprintln!();
294 eprintln!(" The --admin-did was NOT added. To grant admin access to an existing");
295 eprintln!(" context, use the ACL command directly:");
296 eprintln!();
297 let mut hint =
298 format!(" {bin} acl create --did {did} --role admin --contexts {effective_id}");
299 if let Some(label) = admin.label.as_deref() {
300 hint.push_str(&format!(" --label '{label}'"));
301 }
302 match (admin.expires_duration.as_deref(), admin.expires_at) {
303 (Some(raw), _) => hint.push_str(&format!(" --expires {raw}")),
307 (None, Some(expires_at)) => {
308 let remaining = expires_at.saturating_sub(crate::duration::now_unix());
309 hint.push_str(&format!(" --expires {remaining}s"));
310 }
311 (None, None) => {}
312 }
313 if admin.handoff {
314 hint.push_str(" --handoff");
315 }
316 eprintln!("{hint}");
317 return Ok(());
318 }
319 Err(e) => return Err(e.into()),
320 };
321 println!("Context created:");
322 println!(" ID: {}", resp.id);
323 println!(" Name: {}", resp.name);
324 println!(" Base Path: {}", resp.base_path);
325
326 if admin.is_requested() {
327 let did = admin.did.as_deref().unwrap_or_default();
328 if !did.starts_with("did:") {
329 return Err(format!(
330 "--admin-did must start with `did:` (got {did:?}) — context was created but no ACL entry was added"
331 )
332 .into());
333 }
334 let mut acl_req =
337 vta_sdk::client::CreateAclRequest::new(did, "admin").contexts(vec![resp.id.clone()]);
338 if let Some(label) = admin.label.as_deref() {
339 acl_req = acl_req.label(label);
340 }
341 if let Some(expires_at) = admin.expires_at {
342 acl_req = acl_req.expires_at(expires_at);
343 }
344 if admin.holder {
345 acl_req = acl_req.capabilities(vec!["persona-holder".to_string()]);
349 }
350 if admin.handoff {
351 acl_req = acl_req.handoff();
352 }
353 let acl = client.create_acl(acl_req).await?;
354
355 println!();
356 println!("Admin ACL entry created:");
357 println!(" DID: {}", acl.did);
358 println!(" Role: {}", acl.role);
359 println!(" Contexts: {}", acl.allowed_contexts.join(", "));
360 if let Some(ref label) = acl.label {
361 println!(" Label: {label}");
362 }
363 if admin.holder {
364 println!(" Identity: holder (persona-holder capability granted)");
365 }
366 if acl.handoff() {
367 println!(" Hand-off: one-time (may roll over once to a VTA-minted admin)");
368 }
369 match acl.expires_at {
370 Some(secs) => {
371 println!(
372 " Expires at: {} ({}) — setup ACL",
373 crate::duration::format_local_time(secs),
374 crate::duration::format_remaining(secs),
375 );
376 println!();
377 println!(" The admin should authenticate before expiry. On first successful");
378 println!(" connect PNM rotates to a fresh long-lived did:key and replaces this");
379 println!(" temporary entry with a permanent one.");
380 }
381 None => println!(" Expires at: (permanent)"),
382 }
383 }
384
385 Ok(())
386}
387
388pub async fn cmd_context_update(
389 client: &VtaClient,
390 id: &str,
391 name: Option<String>,
392 did: Option<String>,
393 description: Option<String>,
394) -> Result<(), Box<dyn std::error::Error>> {
395 let req = UpdateContextRequest {
396 name,
397 did,
398 description,
399 context_policy: None,
400 };
401 let resp = client.update_context(id, req).await?;
402 println!("Context updated:");
403 render_context_record(&resp);
404 Ok(())
405}
406
407pub async fn cmd_context_clear_did(
413 client: &VtaClient,
414 id: &str,
415) -> Result<(), Box<dyn std::error::Error>> {
416 let resp = client.clear_context_did(id).await?;
417 println!("Context DID cleared:");
418 println!(" ID: {}", resp.id);
419 println!(
420 " DID: {}",
421 resp.did.as_deref().unwrap_or("(not set)")
422 );
423 println!(
424 " Updated At: {}",
425 crate::duration::format_local_datetime(resp.updated_at)
426 );
427 Ok(())
428}
429
430pub async fn cmd_context_update_did(
431 client: &VtaClient,
432 id: &str,
433 did: &str,
434) -> Result<(), Box<dyn std::error::Error>> {
435 let resp = client.update_context_did(id, did).await?;
436 println!("Context DID updated:");
437 println!(" ID: {}", resp.id);
438 println!(
439 " DID: {}",
440 resp.did.as_deref().unwrap_or("(not set)")
441 );
442 println!(
443 " Updated At: {}",
444 crate::duration::format_local_datetime(resp.updated_at)
445 );
446 Ok(())
447}
448
449pub fn render_delete_context_preview(
471 id: &str,
472 preview: &vta_sdk::protocols::context_management::delete::DeleteContextPreviewResultBody,
473 book: &NameBook,
474) -> bool {
475 let sub_contexts = &preview.sub_contexts;
476 let has_resources = !sub_contexts.is_empty()
477 || !preview.keys.is_empty()
478 || !preview.webvh_dids.is_empty()
479 || !preview.acl_entries_removed.is_empty()
480 || !preview.acl_entries_updated.is_empty()
481 || !preview.did_templates.is_empty();
482
483 if !has_resources {
484 return false;
485 }
486
487 println!(
488 "Deleting context '{}' will remove the following resources:\n",
489 id
490 );
491
492 if !sub_contexts.is_empty() {
495 println!(" Sub-contexts ({}):", sub_contexts.len());
496 for ctx in sub_contexts {
497 println!(" - {ctx}");
498 }
499 }
500
501 if !preview.keys.is_empty() {
502 println!(" Keys ({}):", preview.keys.len());
503 for key in &preview.keys {
504 println!(" - {key}");
505 }
506 }
507
508 if !preview.webvh_dids.is_empty() {
513 println!(" WebVH DIDs ({}):", preview.webvh_dids.len());
514 for did in &preview.webvh_dids {
515 println!(" - {}", inline(book, did));
516 }
517 }
518
519 if !preview.acl_entries_removed.is_empty() {
520 println!(
521 " ACL entries removed ({}):",
522 preview.acl_entries_removed.len()
523 );
524 for did in &preview.acl_entries_removed {
525 println!(" - {}", inline(book, did));
526 }
527 }
528
529 if !preview.acl_entries_updated.is_empty() {
530 println!(
531 " ACL entries updated (context removed from access list) ({}):",
532 preview.acl_entries_updated.len()
533 );
534 for did in &preview.acl_entries_updated {
535 println!(" - {}", inline(book, did));
536 }
537 }
538
539 if !preview.did_templates.is_empty() {
540 println!(" DID templates ({}):", preview.did_templates.len());
541 for name in &preview.did_templates {
542 println!(" - {name}");
543 }
544 }
545
546 println!();
547 true
548}
549
550pub fn confirm_destructive(prompt: &str) -> Result<bool, Box<dyn std::error::Error>> {
554 print!("{prompt} [y/N] ");
555 io::stdout().flush()?;
556 let mut input = String::new();
557 io::stdin().read_line(&mut input)?;
558 let input = input.trim().to_lowercase();
559 Ok(input == "y" || input == "yes")
560}
561
562pub async fn cmd_context_delete(
568 client: &VtaClient,
569 id: &str,
570 force: bool,
571 abandon_host_copies: bool,
572) -> Result<(), Box<dyn std::error::Error>> {
573 let preview = client.preview_delete_context(id).await?;
575
576 let mut book = NameBook::new();
579 if let Ok(acl) = client.list_acl(None).await {
580 book_from_acl(&mut book, &acl.entries);
581 }
582
583 let has_resources = render_delete_context_preview(id, &preview, &book);
584
585 if has_resources && !force && !confirm_destructive("Proceed with deletion?")? {
586 println!("Aborted.");
587 return Ok(());
588 }
589
590 use vta_sdk::protocols::context_management::delete::{
591 HostCleanupRefusal, host_cleanup_refusal,
592 };
593 let result = match client
594 .delete_context_settling_hosts(id, true, abandon_host_copies)
595 .await
596 {
597 Ok(result) => result,
598 Err(e) => {
599 let failures = match host_cleanup_refusal(&e) {
600 None => return Err(e.into()),
601 Some(HostCleanupRefusal::Evidenced(failures)) => failures,
602 Some(HostCleanupRefusal::Unreadable { why }) => {
606 let bin = crate::render::bin_name();
607 eprintln!(
608 "\nwarning: context `{id}` was NOT deleted — the VTA could not confirm \
609 removing published DID logs from their hosting server, but did not \
610 say which ({why})."
611 );
612 println!(
613 "Nothing was deleted. Retry once the host is back:\n \
614 {bin} contexts delete {id}"
615 );
616 return Err(e.into());
617 }
618 Some(_) => return Err(e.into()),
620 };
621 render_host_cleanup_failures(id, &failures);
622 let asked = io::IsTerminal::is_terminal(&io::stdin())
623 && confirm_destructive(
624 "Delete the context anyway, leaving these logs published for you to remove?",
625 )?;
626 if !asked {
627 let bin = crate::render::bin_name();
628 println!(
629 "Nothing was deleted. Retry once the host is back:\n \
630 {bin} contexts delete {id}\nor finish now, leaving those logs behind:\n \
631 {bin} contexts delete {id} --yes --abandon-host-copies"
632 );
633 return Err(e.into());
634 }
635 client.delete_context_settling_hosts(id, true, true).await?
636 }
637 };
638 let orphans = deletion_outcome(id, result)?;
639 println!("Context deleted: {id}");
640 if !orphans.is_empty() {
643 eprintln!(
644 "\nwarning: {} DID(s) may still resolve — their hosting server did not confirm \
645 removal of the published log. Clean these up out-of-band:",
646 orphans.len()
647 );
648 for line in &orphans {
649 eprintln!(" - {line}");
650 }
651 }
652 Ok(())
653}
654
655fn deletion_outcome(
662 id: &str,
663 result: vta_sdk::specs::vta::contexts::delete::v1_1::Response,
664) -> Result<Vec<String>, Box<dyn std::error::Error>> {
665 if !result.deleted {
666 return Err(format!(
667 "the VTA answered without deleting context `{id}` (deleted: false); nothing was removed"
668 )
669 .into());
670 }
671 Ok(result.daemon_cleanup_errors)
672}
673
674fn render_host_cleanup_failures(
677 id: &str,
678 failures: &[vta_sdk::protocols::context_management::delete::HostCleanupFailure],
679) {
680 eprintln!(
681 "\nwarning: context `{id}` was NOT deleted — {} published DID(s) could not be removed \
682 from their hosting server:",
683 failures.len()
684 );
685 for f in failures {
686 match &f.server {
687 Some(server) => eprintln!(" - {} (server `{server}`): {}", f.did, f.reason),
688 None => eprintln!(" - {}: {}", f.did, f.reason),
689 }
690 }
691 eprintln!(
692 "\nIf the host may come back, wait and retry. If you go ahead now, these DIDs may keep \
693 resolving, and removing them from their host is up to you.\n"
694 );
695}
696
697pub async fn cmd_context_provision(
698 client: &VtaClient,
699 id: &str,
700 name: &str,
701 description: Option<String>,
702 admin_label: Option<String>,
703 did_opts: Option<ProvisionDidOptions>,
704 recipient: SealedRecipient,
705) -> Result<(), Box<dyn std::error::Error>> {
706 eprintln!("Creating context '{id}'...");
708 let ctx_req = create_request_for_path(id, name, description)?;
709 client.create_context(ctx_req).await?;
710
711 let config = client.get_config().await?;
713 let vta_did = config
714 .vta_did()
715 .map(str::to_string)
716 .ok_or("VTA DID not configured — cannot mint admin credential")?;
717 let vta_url = config.public_url().map(str::to_string);
718
719 eprintln!("Minting local admin credential and registering ACL...");
724 let (admin_credential, admin_did) =
725 crate::local_keygen::generate_admin_did_key(vta_did, vta_url);
726 let mut acl_req =
727 vta_sdk::client::CreateAclRequest::new(&admin_did, "admin").contexts(vec![id.to_string()]);
728 if let Some(l) = admin_label {
729 acl_req = acl_req.label(l);
730 }
731 client.create_acl(acl_req).await?;
732
733 let provisioned_did = if let Some(opts) = did_opts {
735 eprintln!("Creating WebVH DID...");
736 let req = CreateDidWebvhRequest {
737 context_id: id.to_string(),
738 server_id: opts.server_id,
739 url: opts.did_url,
740 path: None,
741 path_mode: opts.did_path.map(WebvhPathMode::from),
747 domain: None,
750 label: Some(id.to_string()),
751 portable: opts.portable,
752 add_mediator_service: opts.add_mediator_service,
753 add_tsp_service: false,
754 additional_services: None,
755 pre_rotation_count: opts.pre_rotation_count,
756 did_document: None,
757 did_log: None,
758 set_primary: true,
759 signing_key_id: None,
760 ka_key_id: None,
761 template: None,
762 template_context: None,
763 template_vars: std::collections::HashMap::new(),
764 };
765 let did_result = client.create_did_webvh(req).await?;
766
767 eprintln!("Fetching DID key secrets...");
769 let mut secrets: Vec<SecretEntry> = Vec::new();
770 secrets.push(
772 client
773 .get_key_secret(&did_result.signing_key_id)
774 .await?
775 .into(),
776 );
777 secrets.push(client.get_key_secret(&did_result.ka_key_id).await?.into());
779 for i in 0..did_result.pre_rotation_key_count {
781 let pre_rot_id = format!("{}#pre-rotation-{i}", did_result.did);
782 secrets.push(client.get_key_secret(&pre_rot_id).await?.into());
783 }
784
785 Some(ProvisionedDid {
786 id: did_result.did,
787 did_document: did_result.did_document,
788 log_entry: did_result.log_entry,
789 secrets,
790 })
791 } else {
792 None
793 };
794
795 let bundle = ContextProvisionBundle {
797 context_id: id.to_string(),
798 context_name: name.to_string(),
799 vta_url: config.public_url().map(str::to_string),
800 vta_did: config.vta_did().map(str::to_string),
801 credential: admin_credential,
802 admin_did,
803 did: provisioned_did,
804 };
805
806 crate::sealed_producer::emit_context_provision_bundle(bundle, &recipient, None).await
808}
809
810async fn credential_from_key(
817 client: &VtaClient,
818 key_id: &str,
819 vta_did: &str,
820 vta_url: Option<&str>,
821) -> Result<(CredentialBundle, String), Box<dyn std::error::Error>> {
822 let secret = client.get_key_secret(key_id).await?;
823 CredentialBundle::from_ed25519_seed_multibase(&secret.private_key_multibase, vta_did, vta_url)
824 .map_err(|e| format!("Cannot decode key secret: {e}").into())
825}
826
827pub async fn cmd_context_reprovision(
828 client: &VtaClient,
829 id: &str,
830 key_id: Option<String>,
831 admin_label: Option<String>,
832 recipient: SealedRecipient,
833) -> Result<(), Box<dyn std::error::Error>> {
834 eprintln!("Fetching context '{id}'...");
836 let ctx = client.get_context(id).await?;
837
838 let config = client.get_config().await?;
840 let vta_did = config.vta_did().ok_or("VTA DID not configured")?;
841
842 let (admin_credential, admin_did) = if let Some(ref kid) = key_id {
844 eprintln!("Using key '{kid}'...");
846 credential_from_key(client, kid, vta_did, config.public_url()).await?
847 } else {
848 let keys_resp = client
850 .list_keys(None, 10000, Some("active"), Some(id))
851 .await?;
852 let ed25519_keys: Vec<_> = keys_resp
853 .keys
854 .iter()
855 .filter(|k| k.key_type == KeyType::Ed25519)
856 .collect();
857
858 eprintln!();
859 eprintln!("Select an admin credential key for context '{id}':");
860 eprintln!();
861 for (i, key) in ed25519_keys.iter().enumerate() {
862 let label = key
863 .label
864 .as_deref()
865 .map(|l| format!(" ({l})"))
866 .unwrap_or_default();
867 eprintln!(" [{}] {}{}", i + 1, key.key_id, label);
868 }
869 let new_option = ed25519_keys.len() + 1;
870 eprintln!(" [{}] Create a new admin key", new_option);
871 eprintln!();
872 eprint!("Choice [{}]: ", new_option);
873 io::stderr().flush()?;
874
875 let mut input = String::new();
876 io::stdin().read_line(&mut input)?;
877 let input = input.trim();
878
879 let choice: usize = if input.is_empty() {
881 new_option
882 } else {
883 input
884 .parse()
885 .map_err(|_| format!("Invalid choice: {input}"))?
886 };
887
888 if choice == new_option {
889 eprintln!("Creating new admin key...");
891 let key_resp = client
892 .create_key(CreateKeyRequest {
893 internal: None,
894 key_type: KeyType::Ed25519,
895 derivation_path: None,
896 key_id: None,
897 mnemonic: None,
898 label: admin_label.or_else(|| Some("admin".to_string())),
899 context_id: Some(id.to_string()),
900 })
901 .await?;
902 credential_from_key(client, &key_resp.key_id, vta_did, config.public_url()).await?
903 } else if choice >= 1 && choice <= ed25519_keys.len() {
904 let selected = &ed25519_keys[choice - 1];
905 eprintln!("Using key '{}'...", selected.key_id);
906 credential_from_key(client, &selected.key_id, vta_did, config.public_url()).await?
907 } else {
908 return Err(format!("Invalid choice: {choice}").into());
909 }
910 };
911
912 if client.get_acl(&admin_did).await.is_err() {
914 eprintln!("Creating ACL entry for {admin_did}...");
915 client
916 .create_acl(
917 vta_sdk::client::CreateAclRequest::new(&admin_did, "admin")
918 .contexts(vec![id.to_string()]),
919 )
920 .await?;
921 }
922
923 let provisioned_did = if let Some(ref did_id) = ctx.did {
925 eprintln!("Fetching DID material...");
926
927 let log_resp = client.get_did_webvh_log(did_id).await?;
929 let (did_document, log_entry) = if let Some(ref log_str) = log_resp.log {
930 let parsed: serde_json::Value = serde_json::from_str(log_str)
931 .map_err(|e| format!("failed to parse DID log: {e}"))?;
932 let doc = parsed.get("state").cloned();
933 (doc, Some(log_str.clone()))
934 } else {
935 (None, None)
936 };
937
938 let secrets_bundle = client.fetch_did_secrets_bundle(id).await?;
940
941 Some(ProvisionedDid {
942 id: did_id.clone(),
943 did_document,
944 log_entry,
945 secrets: secrets_bundle.secrets,
946 })
947 } else {
948 None
949 };
950
951 let bundle = ContextProvisionBundle {
953 context_id: id.to_string(),
954 context_name: ctx.name.clone(),
955 vta_url: config.public_url().map(str::to_string),
956 vta_did: config.vta_did().map(str::to_string),
957 credential: admin_credential,
958 admin_did,
959 did: provisioned_did,
960 };
961
962 crate::sealed_producer::emit_context_provision_bundle(bundle, &recipient, None).await
964}
965
966#[cfg(test)]
967mod tests {
968 use super::{create_request_for_path, deletion_outcome};
969
970 fn response(
971 deleted: bool,
972 orphans: &[&str],
973 ) -> vta_sdk::specs::vta::contexts::delete::v1_1::Response {
974 serde_json::from_value(serde_json::json!({
975 "id": "ctx",
976 "deleted": deleted,
977 "daemonCleanupErrors": orphans,
978 }))
979 .unwrap()
980 }
981
982 #[test]
984 fn a_declined_deletion_is_not_reported_as_done() {
985 let err = deletion_outcome("ctx", response(false, &[])).unwrap_err();
986 assert!(err.to_string().contains("deleted: false"), "{err}");
987 }
988
989 #[test]
990 fn a_deletion_reports_what_it_left_published() {
991 assert_eq!(
992 deletion_outcome("ctx", response(true, &["did:webvh:a: unreachable"])).unwrap(),
993 vec!["did:webvh:a: unreachable".to_string()]
994 );
995 assert!(
996 deletion_outcome("ctx", response(true, &[]))
997 .unwrap()
998 .is_empty()
999 );
1000 }
1001
1002 #[test]
1003 fn a_path_splits_into_parent_and_leaf() {
1004 let r = create_request_for_path("vtc/trust-registry", "n", None).unwrap();
1005 assert_eq!(
1006 (r.id.as_str(), r.parent.as_deref()),
1007 ("trust-registry", Some("vtc"))
1008 );
1009 let r = create_request_for_path("a/b/c", "n", None).unwrap();
1010 assert_eq!((r.id.as_str(), r.parent.as_deref()), ("c", Some("a/b")));
1011 }
1012
1013 #[test]
1014 fn a_bare_slug_is_top_level() {
1015 let r = create_request_for_path("acme", "n", None).unwrap();
1016 assert_eq!((r.id.as_str(), r.parent), ("acme", None));
1017 }
1018
1019 #[test]
1020 fn a_malformed_path_is_refused() {
1021 for bad in ["", "/a", "a/", "a//b"] {
1022 assert!(create_request_for_path(bad, "n", None).is_err(), "{bad:?}");
1023 }
1024 }
1025}