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    let orphans = match client
591        .delete_context_settling_hosts(id, true, abandon_host_copies)
592        .await
593    {
594        Ok(result) => result.daemon_cleanup_errors,
595        Err(e) => {
596            let Some(failures) =
597                vta_sdk::protocols::context_management::delete::host_cleanup_failures(&e)
598            else {
599                return Err(e.into());
600            };
601            render_host_cleanup_failures(id, &failures);
602            let asked = io::IsTerminal::is_terminal(&io::stdin())
603                && confirm_destructive(
604                    "Delete the context anyway, leaving these logs published for you to remove?",
605                )?;
606            if !asked {
607                let bin = crate::render::bin_name();
608                println!(
609                    "Nothing was deleted. Retry once the host is back:\n  \
610                     {bin} contexts delete {id}\nor finish now, leaving those logs behind:\n  \
611                     {bin} contexts delete {id} --yes --abandon-host-copies"
612                );
613                return Err(e.into());
614            }
615            client
616                .delete_context_settling_hosts(id, true, true)
617                .await?
618                .daemon_cleanup_errors
619        }
620    };
621    println!("Context deleted: {id}");
622    // The deletion succeeded and is still not finished. Said plainly, and
623    // last, so it is the thing left on screen.
624    if !orphans.is_empty() {
625        eprintln!(
626            "\nwarning: {} DID(s) may still resolve — their hosting server did not confirm \
627             removal of the published log. Clean these up out-of-band:",
628            orphans.len()
629        );
630        for line in &orphans {
631            eprintln!("  - {line}");
632        }
633    }
634    Ok(())
635}
636
637/// Say, loudly and on stderr, which DIDs' logs a refused deletion could not
638/// get removed — before asking whether to leave them.
639fn render_host_cleanup_failures(
640    id: &str,
641    failures: &[vta_sdk::protocols::context_management::delete::HostCleanupFailure],
642) {
643    eprintln!(
644        "\nwarning: context `{id}` was NOT deleted — {} published DID(s) could not be removed \
645         from their hosting server:",
646        failures.len()
647    );
648    for f in failures {
649        match &f.server {
650            Some(server) => eprintln!("  - {} (server `{server}`): {}", f.did, f.reason),
651            None => eprintln!("  - {}: {}", f.did, f.reason),
652        }
653    }
654    eprintln!(
655        "\nIf the host may come back, wait and retry. If you go ahead now, these DIDs may keep \
656         resolving, and removing them from their host is up to you.\n"
657    );
658}
659
660pub async fn cmd_context_provision(
661    client: &VtaClient,
662    id: &str,
663    name: &str,
664    description: Option<String>,
665    admin_label: Option<String>,
666    did_opts: Option<ProvisionDidOptions>,
667    recipient: SealedRecipient,
668) -> Result<(), Box<dyn std::error::Error>> {
669    // 1. Create the context
670    eprintln!("Creating context '{id}'...");
671    let ctx_req = create_request_for_path(id, name, description)?;
672    client.create_context(ctx_req).await?;
673
674    // 2. Fetch VTA config for URL/DID (needed to build the admin credential).
675    let config = client.get_config().await?;
676    let vta_did = config
677        .vta_did()
678        .map(str::to_string)
679        .ok_or("VTA DID not configured — cannot mint admin credential")?;
680    let vta_url = config.public_url().map(str::to_string);
681
682    // 3. Generate admin did:key locally (private key never crosses the wire)
683    //    and register it with the VTA via POST /acl. This replaces the
684    //    pre-5c6 `POST /auth/credentials` round-trip that returned a base64
685    //    CredentialBundle in a plaintext JSON body.
686    eprintln!("Minting local admin credential and registering ACL...");
687    let (admin_credential, admin_did) =
688        crate::local_keygen::generate_admin_did_key(vta_did, vta_url);
689    let mut acl_req =
690        vta_sdk::client::CreateAclRequest::new(&admin_did, "admin").contexts(vec![id.to_string()]);
691    if let Some(l) = admin_label {
692        acl_req = acl_req.label(l);
693    }
694    client.create_acl(acl_req).await?;
695
696    // 4. Optionally create a DID and collect its secrets
697    let provisioned_did = if let Some(opts) = did_opts {
698        eprintln!("Creating WebVH DID...");
699        let req = CreateDidWebvhRequest {
700            context_id: id.to_string(),
701            server_id: opts.server_id,
702            url: opts.did_url,
703            path: None,
704            // `--did-path` maps onto the canonical path-mode selector:
705            // absent → auto-assign server-side, `.well-known` → the
706            // reserved root slot, anything else → an explicit label.
707            // `WebvhPathMode::from(String)` owns that trimming/mapping
708            // so the contexts surface can't drift from `dids create`.
709            path_mode: opts.did_path.map(WebvhPathMode::from),
710            // Context-bootstrap path: no per-DID domain override.
711            // The server's caller-default → system-default resolves.
712            domain: None,
713            label: Some(id.to_string()),
714            portable: opts.portable,
715            add_mediator_service: opts.add_mediator_service,
716            add_tsp_service: false,
717            additional_services: None,
718            pre_rotation_count: opts.pre_rotation_count,
719            did_document: None,
720            did_log: None,
721            set_primary: true,
722            signing_key_id: None,
723            ka_key_id: None,
724            template: None,
725            template_context: None,
726            template_vars: std::collections::HashMap::new(),
727        };
728        let did_result = client.create_did_webvh(req).await?;
729
730        // Collect secrets for the DID keys
731        eprintln!("Fetching DID key secrets...");
732        let mut secrets: Vec<SecretEntry> = Vec::new();
733        // Signing key
734        secrets.push(
735            client
736                .get_key_secret(&did_result.signing_key_id)
737                .await?
738                .into(),
739        );
740        // Key-agreement key
741        secrets.push(client.get_key_secret(&did_result.ka_key_id).await?.into());
742        // Pre-rotation keys
743        for i in 0..did_result.pre_rotation_key_count {
744            let pre_rot_id = format!("{}#pre-rotation-{i}", did_result.did);
745            secrets.push(client.get_key_secret(&pre_rot_id).await?.into());
746        }
747
748        Some(ProvisionedDid {
749            id: did_result.did,
750            did_document: did_result.did_document,
751            log_entry: did_result.log_entry,
752            secrets,
753        })
754    } else {
755        None
756    };
757
758    // 5. Build the provision bundle
759    let bundle = ContextProvisionBundle {
760        context_id: id.to_string(),
761        context_name: name.to_string(),
762        vta_url: config.public_url().map(str::to_string),
763        vta_did: config.vta_did().map(str::to_string),
764        credential: admin_credential,
765        admin_did,
766        did: provisioned_did,
767    };
768
769    // 6. Seal and emit via the shared helper
770    crate::sealed_producer::emit_context_provision_bundle(bundle, &recipient, None).await
771}
772
773/// Build a `CredentialBundle` from a VTA-stored key, deriving its `did:key`.
774///
775/// The REST fetch is transport-specific; the derivation is shared via
776/// [`CredentialBundle::from_ed25519_seed_multibase`] with the offline
777/// path in `vta-service::operations::export::credential_from_key_offline`
778/// so the `did:key` encoding and bundle shape can't drift.
779async fn credential_from_key(
780    client: &VtaClient,
781    key_id: &str,
782    vta_did: &str,
783    vta_url: Option<&str>,
784) -> Result<(CredentialBundle, String), Box<dyn std::error::Error>> {
785    let secret = client.get_key_secret(key_id).await?;
786    CredentialBundle::from_ed25519_seed_multibase(&secret.private_key_multibase, vta_did, vta_url)
787        .map_err(|e| format!("Cannot decode key secret: {e}").into())
788}
789
790pub async fn cmd_context_reprovision(
791    client: &VtaClient,
792    id: &str,
793    key_id: Option<String>,
794    admin_label: Option<String>,
795    recipient: SealedRecipient,
796) -> Result<(), Box<dyn std::error::Error>> {
797    // 1. Fetch the existing context
798    eprintln!("Fetching context '{id}'...");
799    let ctx = client.get_context(id).await?;
800
801    // 2. Fetch VTA config for URL/DID
802    let config = client.get_config().await?;
803    let vta_did = config.vta_did().ok_or("VTA DID not configured")?;
804
805    // 3. Resolve admin credential
806    let (admin_credential, admin_did) = if let Some(ref kid) = key_id {
807        // Direct key ID specified
808        eprintln!("Using key '{kid}'...");
809        credential_from_key(client, kid, vta_did, config.public_url()).await?
810    } else {
811        // Interactive: list existing Ed25519 keys and let user choose
812        let keys_resp = client
813            .list_keys(None, 10000, Some("active"), Some(id))
814            .await?;
815        let ed25519_keys: Vec<_> = keys_resp
816            .keys
817            .iter()
818            .filter(|k| k.key_type == KeyType::Ed25519)
819            .collect();
820
821        eprintln!();
822        eprintln!("Select an admin credential key for context '{id}':");
823        eprintln!();
824        for (i, key) in ed25519_keys.iter().enumerate() {
825            let label = key
826                .label
827                .as_deref()
828                .map(|l| format!(" ({l})"))
829                .unwrap_or_default();
830            eprintln!("  [{}] {}{}", i + 1, key.key_id, label);
831        }
832        let new_option = ed25519_keys.len() + 1;
833        eprintln!("  [{}] Create a new admin key", new_option);
834        eprintln!();
835        eprint!("Choice [{}]: ", new_option);
836        io::stderr().flush()?;
837
838        let mut input = String::new();
839        io::stdin().read_line(&mut input)?;
840        let input = input.trim();
841
842        // Default to creating a new key if empty
843        let choice: usize = if input.is_empty() {
844            new_option
845        } else {
846            input
847                .parse()
848                .map_err(|_| format!("Invalid choice: {input}"))?
849        };
850
851        if choice == new_option {
852            // Create a new Ed25519 key in VTA scoped to this context
853            eprintln!("Creating new admin key...");
854            let key_resp = client
855                .create_key(CreateKeyRequest {
856                    internal: None,
857                    key_type: KeyType::Ed25519,
858                    derivation_path: None,
859                    key_id: None,
860                    mnemonic: None,
861                    label: admin_label.or_else(|| Some("admin".to_string())),
862                    context_id: Some(id.to_string()),
863                })
864                .await?;
865            credential_from_key(client, &key_resp.key_id, vta_did, config.public_url()).await?
866        } else if choice >= 1 && choice <= ed25519_keys.len() {
867            let selected = &ed25519_keys[choice - 1];
868            eprintln!("Using key '{}'...", selected.key_id);
869            credential_from_key(client, &selected.key_id, vta_did, config.public_url()).await?
870        } else {
871            return Err(format!("Invalid choice: {choice}").into());
872        }
873    };
874
875    // 4. Ensure an ACL entry exists for this admin DID
876    if client.get_acl(&admin_did).await.is_err() {
877        eprintln!("Creating ACL entry for {admin_did}...");
878        client
879            .create_acl(
880                vta_sdk::client::CreateAclRequest::new(&admin_did, "admin")
881                    .contexts(vec![id.to_string()]),
882            )
883            .await?;
884    }
885
886    // 5. Collect DID material (document, log, secrets) when the context has a DID
887    let provisioned_did = if let Some(ref did_id) = ctx.did {
888        eprintln!("Fetching DID material...");
889
890        // Fetch the DID log and extract the DID document from it
891        let log_resp = client.get_did_webvh_log(did_id).await?;
892        let (did_document, log_entry) = if let Some(ref log_str) = log_resp.log {
893            let parsed: serde_json::Value = serde_json::from_str(log_str)
894                .map_err(|e| format!("failed to parse DID log: {e}"))?;
895            let doc = parsed.get("state").cloned();
896            (doc, Some(log_str.clone()))
897        } else {
898            (None, None)
899        };
900
901        // Fetch all active key secrets for this context
902        let secrets_bundle = client.fetch_did_secrets_bundle(id).await?;
903
904        Some(ProvisionedDid {
905            id: did_id.clone(),
906            did_document,
907            log_entry,
908            secrets: secrets_bundle.secrets,
909        })
910    } else {
911        None
912    };
913
914    // 6. Build the provision bundle
915    let bundle = ContextProvisionBundle {
916        context_id: id.to_string(),
917        context_name: ctx.name.clone(),
918        vta_url: config.public_url().map(str::to_string),
919        vta_did: config.vta_did().map(str::to_string),
920        credential: admin_credential,
921        admin_did,
922        did: provisioned_did,
923    };
924
925    // 7. Seal and emit via the shared helper
926    crate::sealed_producer::emit_context_provision_bundle(bundle, &recipient, None).await
927}
928
929#[cfg(test)]
930mod tests {
931    use super::create_request_for_path;
932
933    #[test]
934    fn a_path_splits_into_parent_and_leaf() {
935        let r = create_request_for_path("vtc/trust-registry", "n", None).unwrap();
936        assert_eq!(
937            (r.id.as_str(), r.parent.as_deref()),
938            ("trust-registry", Some("vtc"))
939        );
940        let r = create_request_for_path("a/b/c", "n", None).unwrap();
941        assert_eq!((r.id.as_str(), r.parent.as_deref()), ("c", Some("a/b")));
942    }
943
944    #[test]
945    fn a_bare_slug_is_top_level() {
946        let r = create_request_for_path("acme", "n", None).unwrap();
947        assert_eq!((r.id.as_str(), r.parent), ("acme", None));
948    }
949
950    #[test]
951    fn a_malformed_path_is_refused() {
952        for bad in ["", "/a", "a/", "a//b"] {
953            assert!(create_request_for_path(bad, "n", None).is_err(), "{bad:?}");
954        }
955    }
956}