Skip to main content

vta_cli_common/commands/
contexts.rs

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    /// Explicit path label for the DID on the hosting server. `None`
22    /// leaves the host to auto-assign one; `.well-known` selects the
23    /// reserved root slot. Mirrors `pnm did-mgmt dids create --path`.
24    pub did_path: Option<String>,
25    pub portable: bool,
26    pub add_mediator_service: bool,
27    pub pre_rotation_count: u32,
28}
29
30/// Build the create request for a context **path** (`acme` or `acme/eng`).
31///
32/// The wire request carries the leaf as `id` and the rest as `parent`, so the
33/// path is split at its last separator after validating the whole of it.
34fn 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    // Fetch VTA config so the minted CredentialBundle carries VTA DID/URL.
64    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    // Mint admin did:key locally + register ACL. The VTA never sees the
72    // private half; the full bundle reaches the recipient via sealed transfer.
73    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
99/// Render a list of context records — table view by default, full
100/// `key: value` blocks when `--full-display` is set.
101///
102/// Shared by the online (`pnm contexts list`, REST) and offline
103/// (`vta contexts list`, keystore-direct) paths so both render
104/// identically.
105pub 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    // DID field carries full did:webvh / did:key values (40+ chars).
161    // Use `Min` so it expands on wide terminals rather than truncating
162    // at the former fixed 30-char width.
163    let table = Table::new(
164        rows,
165        [
166            Constraint::Min(16),    // ID
167            Constraint::Min(20),    // Name
168            Constraint::Min(40),    // DID
169            Constraint::Length(16), // Base Path
170            Constraint::Length(10), // Created
171        ],
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
185/// Render a single context record's details, used by `get` and the
186/// success path of `update`. Shared by online + offline call sites.
187pub 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/// Options for optionally creating a context-scoped admin ACL entry as
226/// part of `pnm contexts create`.
227///
228/// Omit the entire struct to create the context without touching the ACL —
229/// the historical behaviour. Supply a [`AdminAclOptions::did`] to atomically
230/// create an `admin`-role ACL entry scoped to the new context in the same
231/// CLI invocation.
232///
233/// Setting [`AdminAclOptions::expires_at`] flips the entry from **permanent**
234/// to a **setup ACL** that auto-expires (pruned by the VTA's ACL sweeper) if
235/// the admin never authenticates and rotates to a fresh did:key.
236#[derive(Debug, Default, Clone)]
237pub struct AdminAclOptions {
238    /// DID to grant admin access to. Must start with `did:`.
239    pub did: Option<String>,
240    /// Human-readable label stored on the ACL entry.
241    pub label: Option<String>,
242    /// Unix-epoch seconds at which the entry auto-expires. `None` = permanent.
243    pub expires_at: Option<u64>,
244    /// Raw `--admin-expires` input (e.g. `"1h"`). Preserved alongside the
245    /// resolved `expires_at` so conflict hints can re-emit the operator's
246    /// original duration verbatim instead of a drift-skewed seconds value.
247    pub expires_duration: Option<String>,
248    /// Also grant the `persona-holder` capability — authority over the holder's
249    /// own identity, which sits above every trust context.
250    ///
251    /// Kept separate from the context grant beside it because they are
252    /// different powers, and the whole point of the capability is that one does
253    /// not imply the other: this entry administers exactly one context, and
254    /// with this flag it also manages the identity of the person who owns the
255    /// agent. Super-admin only, refused server-side otherwise.
256    pub holder: bool,
257    /// Mark the entry as a one-time hand-off (VTI-ACL-054), so the admin DID
258    /// can roll over to a VTA-minted long-term admin. Requires `expires_at`.
259    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        // Friendly path when the operator's real intent was "grant this DID
283        // admin access to this context": the context already exists, so the
284        // scaffolding command is the wrong tool — point them at the ACL
285        // command and exit cleanly so repeated provisioning scripts don't
286        // choke on second runs.
287        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                // Prefer the raw duration the user typed — any latency between
304                // --admin-expires being parsed and the conflict firing would
305                // otherwise drift the re-rendered seconds (e.g. `1h` → `3599s`).
306                (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        // Scope the admin grant to the **full path** the server assigned
335        // (`<parent>/<id>` for a sub-context), not the leaf.
336        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            // Additive: it does not narrow what the admin role carries, so the
346            // entry keeps everything an admin of this context has and gains
347            // the holder-scoped persona tasks on top.
348            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
407/// Clear a context's DID (`vta/contexts/update-did/1.1`, `did: null`).
408///
409/// The context is left with no identity of its own; the DID is not deleted.
410/// This is the step `pnm webvh delete-did` asks for when the DID is the last
411/// one a context acts as and there is nothing to reassign it to.
412pub 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
449/// Print the human-readable resource preview shown before context
450/// deletion. Returns `true` when the preview lists any resources
451/// (i.e. the caller should prompt for confirmation unless `--yes`).
452///
453/// Shared by online + offline delete paths so both warn about exactly
454/// the same resource classes.
455/// Render what deleting `id` destroys, and say whether it destroys anything —
456/// which is what the caller uses to decide whether to prompt.
457///
458/// The subtree comes from the agent now (`subContexts`, trust-tasks 0.21.4).
459/// Both callers used to derive it from the context list, which worked but put
460/// two reimplementations of the agent's own cascade rule in front of a
461/// destructive prompt — and the CLI's answer was only as good as its copy of
462/// the path matching.
463///
464/// It **must** count toward the "anything at all" answer: a parent holding
465/// nothing over children holding everything otherwise renders as an empty
466/// preview, and the caller deletes the whole subtree without asking.
467///
468/// `did_templates` counts here too. It did not, so a context whose only
469/// contents were templates rendered nothing and skipped the prompt.
470pub 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    // First, because it reframes everything below it: those lists are the
493    // whole subtree's, not this context's.
494    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    // This is a destructive confirmation: the operator is deciding whether
509    // these principals should lose access. A list of opaque DIDs is the
510    // hardest possible form in which to make that call, so every DID here
511    // renders name-first with its identifier alongside.
512    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
550/// Read a `[y/N]` reply from stdin. Returns `true` for `y` / `yes`,
551/// `false` otherwise. Shared by the destructive shared commands so
552/// the prompt wording stays consistent.
553pub 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
562/// `abandon_host_copies` is the operator saying, before the fact, that a DID
563/// whose hosting server cannot confirm removing its log may be left behind.
564/// Without it the deletion is tried with host copies required gone, and if one
565/// is not, the operator is shown which and asked — `force` (`--yes`) skips the
566/// *deletion* prompt, never this one, because it is a different decision.
567pub 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    // Fetch a preview of what will be removed
574    let preview = client.preview_delete_context(id).await?;
575
576    // Best-effort naming for the confirmation prompt — a failure here must
577    // never block a deletion the operator is entitled to perform.
578    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                // The refusal stands, but it names no DID the operator could
603                // weigh, so there is nothing to base an abandonment on: report
604                // it and offer only the plain retry (#2404).
605                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                // A refusal shape this build does not know: keep it as is.
619                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    // The deletion succeeded and is still not finished. Said plainly, and
641    // last, so it is the thing left on screen.
642    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
655/// What a successful `vta/contexts/delete/1.1` response actually reports.
656///
657/// A success carrying `deleted: false` is the VTA declining to act, and the
658/// specification forbids reporting the deletion as done on the response
659/// status alone — so it is an error here, never "Context deleted". Otherwise
660/// the DIDs the deletion left published.
661fn 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
674/// Say, loudly and on stderr, which DIDs' logs a refused deletion could not
675/// get removed — before asking whether to leave them.
676fn 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    // 1. Create the context
707    eprintln!("Creating context '{id}'...");
708    let ctx_req = create_request_for_path(id, name, description)?;
709    client.create_context(ctx_req).await?;
710
711    // 2. Fetch VTA config for URL/DID (needed to build the admin credential).
712    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    // 3. Generate admin did:key locally (private key never crosses the wire)
720    //    and register it with the VTA via POST /acl. This replaces the
721    //    pre-5c6 `POST /auth/credentials` round-trip that returned a base64
722    //    CredentialBundle in a plaintext JSON body.
723    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    // 4. Optionally create a DID and collect its secrets
734    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            // `--did-path` maps onto the canonical path-mode selector:
742            // absent → auto-assign server-side, `.well-known` → the
743            // reserved root slot, anything else → an explicit label.
744            // `WebvhPathMode::from(String)` owns that trimming/mapping
745            // so the contexts surface can't drift from `dids create`.
746            path_mode: opts.did_path.map(WebvhPathMode::from),
747            // Context-bootstrap path: no per-DID domain override.
748            // The server's caller-default → system-default resolves.
749            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        // Collect secrets for the DID keys
768        eprintln!("Fetching DID key secrets...");
769        let mut secrets: Vec<SecretEntry> = Vec::new();
770        // Signing key
771        secrets.push(
772            client
773                .get_key_secret(&did_result.signing_key_id)
774                .await?
775                .into(),
776        );
777        // Key-agreement key
778        secrets.push(client.get_key_secret(&did_result.ka_key_id).await?.into());
779        // Pre-rotation keys
780        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    // 5. Build the provision bundle
796    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    // 6. Seal and emit via the shared helper
807    crate::sealed_producer::emit_context_provision_bundle(bundle, &recipient, None).await
808}
809
810/// Build a `CredentialBundle` from a VTA-stored key, deriving its `did:key`.
811///
812/// The REST fetch is transport-specific; the derivation is shared via
813/// [`CredentialBundle::from_ed25519_seed_multibase`] with the offline
814/// path in `vta-service::operations::export::credential_from_key_offline`
815/// so the `did:key` encoding and bundle shape can't drift.
816async 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    // 1. Fetch the existing context
835    eprintln!("Fetching context '{id}'...");
836    let ctx = client.get_context(id).await?;
837
838    // 2. Fetch VTA config for URL/DID
839    let config = client.get_config().await?;
840    let vta_did = config.vta_did().ok_or("VTA DID not configured")?;
841
842    // 3. Resolve admin credential
843    let (admin_credential, admin_did) = if let Some(ref kid) = key_id {
844        // Direct key ID specified
845        eprintln!("Using key '{kid}'...");
846        credential_from_key(client, kid, vta_did, config.public_url()).await?
847    } else {
848        // Interactive: list existing Ed25519 keys and let user choose
849        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        // Default to creating a new key if empty
880        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            // Create a new Ed25519 key in VTA scoped to this context
890            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    // 4. Ensure an ACL entry exists for this admin DID
913    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    // 5. Collect DID material (document, log, secrets) when the context has a DID
924    let provisioned_did = if let Some(ref did_id) = ctx.did {
925        eprintln!("Fetching DID material...");
926
927        // Fetch the DID log and extract the DID document from it
928        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        // Fetch all active key secrets for this context
939        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    // 6. Build the provision bundle
952    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    // 7. Seal and emit via the shared helper
963    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    /// A success carrying `deleted: false` is never reported as a deletion.
983    #[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}