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
30pub async fn cmd_context_bootstrap(
31    client: &VtaClient,
32    id: &str,
33    name: &str,
34    description: Option<String>,
35    admin_label: Option<String>,
36    recipient: SealedRecipient,
37) -> Result<(), Box<dyn std::error::Error>> {
38    let mut ctx_req = CreateContextRequest::new(id, name);
39    if let Some(desc) = description {
40        ctx_req = ctx_req.description(desc);
41    }
42    let ctx = client.create_context(ctx_req).await?;
43    println!("Context created:");
44    println!("  ID:        {}", ctx.id);
45    println!("  Name:      {}", ctx.name);
46    println!("  Base Path: {}", ctx.base_path);
47
48    // Fetch VTA config so the minted CredentialBundle carries VTA DID/URL.
49    let config = client.get_config().await?;
50    let vta_did = config
51        .vta_did()
52        .map(str::to_string)
53        .ok_or("VTA DID not configured — cannot mint admin credential")?;
54    let vta_url = config.public_url().map(str::to_string);
55
56    // Mint admin did:key locally + register ACL. The VTA never sees the
57    // private half; the full bundle reaches the recipient via sealed transfer.
58    let (admin_bundle, admin_did) = crate::local_keygen::generate_admin_did_key(vta_did, vta_url);
59    let mut acl_req =
60        vta_sdk::client::CreateAclRequest::new(&admin_did, "admin").contexts(vec![id.to_string()]);
61    if let Some(l) = admin_label {
62        acl_req = acl_req.label(l);
63    }
64    client.create_acl(acl_req).await?;
65
66    let sealed = seal_for_recipient(
67        &recipient,
68        &SealedPayloadV1::AdminCredential(Box::new(admin_bundle)),
69    )
70    .await?;
71    println!();
72    println!("Admin credential created:");
73    println!("  DID:  {admin_did}");
74    println!("  Role: admin");
75    if let Some(ref label) = recipient.label {
76        println!("  Recipient: {label}");
77    }
78    println!();
79
80    crate::sealed_producer::emit_sealed_output(&sealed, None)?;
81    Ok(())
82}
83
84/// Render a list of context records — table view by default, full
85/// `key: value` blocks when `--full-display` is set.
86///
87/// Shared by the online (`pnm contexts list`, REST) and offline
88/// (`vta contexts list`, keystore-direct) paths so both render
89/// identically.
90pub fn render_context_list(contexts: &[ContextResponse]) {
91    if contexts.is_empty() {
92        println!("No contexts found.");
93        return;
94    }
95
96    if is_full_display() {
97        print_full_list_title("Contexts", contexts.len());
98        for ctx in contexts {
99            let did = ctx.did.as_deref().unwrap_or("—");
100            let created = ctx
101                .created_at
102                .with_timezone(&chrono::Local)
103                .format("%Y-%m-%d %H:%M:%S %:z")
104                .to_string();
105            print_full_entry(&[
106                ("ID", &ctx.id),
107                ("Name", &ctx.name),
108                ("DID", did),
109                ("Base Path", &ctx.base_path),
110                ("Created", &created),
111            ]);
112        }
113        return;
114    }
115
116    let header_style = Style::default()
117        .fg(Color::White)
118        .add_modifier(Modifier::BOLD);
119    let header = Row::new(vec!["ID", "Name", "DID", "Base Path", "Created"])
120        .style(header_style)
121        .bottom_margin(1);
122
123    let rows: Vec<Row> = contexts
124        .iter()
125        .map(|ctx| {
126            let did = ctx.did.clone().unwrap_or_else(|| "\u{2014}".into());
127            let created = ctx
128                .created_at
129                .with_timezone(&chrono::Local)
130                .format("%Y-%m-%d")
131                .to_string();
132
133            Row::new(vec![
134                Cell::from(ctx.id.clone()),
135                Cell::from(ctx.name.clone()),
136                Cell::from(did).style(Style::default().fg(Color::DarkGray)),
137                Cell::from(ctx.base_path.clone()),
138                Cell::from(created),
139            ])
140        })
141        .collect();
142
143    let title = format!(" Contexts ({}) ", contexts.len());
144
145    // DID field carries full did:webvh / did:key values (40+ chars).
146    // Use `Min` so it expands on wide terminals rather than truncating
147    // at the former fixed 30-char width.
148    let table = Table::new(
149        rows,
150        [
151            Constraint::Min(16),    // ID
152            Constraint::Min(20),    // Name
153            Constraint::Min(40),    // DID
154            Constraint::Length(16), // Base Path
155            Constraint::Length(10), // Created
156        ],
157    )
158    .header(header)
159    .column_spacing(2)
160    .block(
161        Block::bordered()
162            .title(title)
163            .border_style(Style::default().fg(Color::DarkGray)),
164    );
165
166    let height = contexts.len() as u16 + 4;
167    print_widget(table, height);
168}
169
170/// Render a single context record's details, used by `get` and the
171/// success path of `update`. Shared by online + offline call sites.
172pub fn render_context_record(ctx: &ContextResponse) {
173    println!("ID:          {}", ctx.id);
174    println!("Name:        {}", ctx.name);
175    println!("DID:         {}", ctx.did.as_deref().unwrap_or("(not set)"));
176    println!(
177        "Description: {}",
178        ctx.description.as_deref().unwrap_or("(not set)")
179    );
180    println!("Base Path:   {}", ctx.base_path);
181    println!(
182        "Created At:  {}",
183        crate::duration::format_local_datetime(ctx.created_at)
184    );
185    println!(
186        "Updated At:  {}",
187        crate::duration::format_local_datetime(ctx.updated_at)
188    );
189}
190
191pub async fn cmd_context_list(client: &VtaClient) -> Result<(), Box<dyn std::error::Error>> {
192    let resp = client.list_contexts().await?;
193    if crate::render::is_json_output() {
194        crate::render::print_json(&resp.contexts)?;
195        return Ok(());
196    }
197    render_context_list(&resp.contexts);
198    Ok(())
199}
200
201pub async fn cmd_context_get(
202    client: &VtaClient,
203    id: &str,
204) -> Result<(), Box<dyn std::error::Error>> {
205    let resp = client.get_context(id).await?;
206    render_context_record(&resp);
207    Ok(())
208}
209
210/// Options for optionally creating a context-scoped admin ACL entry as
211/// part of `pnm contexts create`.
212///
213/// Omit the entire struct to create the context without touching the ACL —
214/// the historical behaviour. Supply a [`AdminAclOptions::did`] to atomically
215/// create an `admin`-role ACL entry scoped to the new context in the same
216/// CLI invocation.
217///
218/// Setting [`AdminAclOptions::expires_at`] flips the entry from **permanent**
219/// to a **setup ACL** that auto-expires (pruned by the VTA's ACL sweeper) if
220/// the admin never authenticates and rotates to a fresh did:key.
221#[derive(Debug, Default, Clone)]
222pub struct AdminAclOptions {
223    /// DID to grant admin access to. Must start with `did:`.
224    pub did: Option<String>,
225    /// Human-readable label stored on the ACL entry.
226    pub label: Option<String>,
227    /// Unix-epoch seconds at which the entry auto-expires. `None` = permanent.
228    pub expires_at: Option<u64>,
229    /// Raw `--admin-expires` input (e.g. `"1h"`). Preserved alongside the
230    /// resolved `expires_at` so conflict hints can re-emit the operator's
231    /// original duration verbatim instead of a drift-skewed seconds value.
232    pub expires_duration: Option<String>,
233    /// Also grant the `persona-holder` capability — authority over the holder's
234    /// own identity, which sits above every trust context.
235    ///
236    /// Kept separate from the context grant beside it because they are
237    /// different powers, and the whole point of the capability is that one does
238    /// not imply the other: this entry administers exactly one context, and
239    /// with this flag it also manages the identity of the person who owns the
240    /// agent. Super-admin only, refused server-side otherwise.
241    pub holder: bool,
242}
243
244impl AdminAclOptions {
245    fn is_requested(&self) -> bool {
246        self.did.is_some()
247    }
248}
249
250pub async fn cmd_context_create(
251    client: &VtaClient,
252    id: &str,
253    name: &str,
254    description: Option<String>,
255    parent: Option<String>,
256    admin: AdminAclOptions,
257) -> Result<(), Box<dyn std::error::Error>> {
258    use crate::render::{RESET, YELLOW};
259    use vta_sdk::error::VtaError;
260
261    // The full context path the server will assign (`<parent>/<id>` when nested)
262    // — used for the conflict hint before `resp` exists.
263    let effective_id = parent
264        .as_ref()
265        .map_or_else(|| id.to_string(), |p| format!("{p}/{id}"));
266    let req = CreateContextRequest {
267        id: id.to_string(),
268        name: name.to_string(),
269        description,
270        parent,
271    };
272    let resp = match client.create_context(req).await {
273        Ok(r) => r,
274        // Friendly path when the operator's real intent was "grant this DID
275        // admin access to this context": the context already exists, so the
276        // scaffolding command is the wrong tool — point them at the ACL
277        // command and exit cleanly so repeated provisioning scripts don't
278        // choke on second runs.
279        Err(VtaError::Conflict(_)) if admin.is_requested() => {
280            let did = admin.did.as_deref().unwrap_or_default();
281            let bin = crate::render::bin_name();
282            eprintln!(
283                "{YELLOW}\u{26a0}{RESET}  Context '{effective_id}' already exists — skipping context creation."
284            );
285            eprintln!();
286            eprintln!("  The --admin-did was NOT added. To grant admin access to an existing");
287            eprintln!("  context, use the ACL command directly:");
288            eprintln!();
289            let mut hint =
290                format!("    {bin} acl create --did {did} --role admin --contexts {effective_id}");
291            if let Some(label) = admin.label.as_deref() {
292                hint.push_str(&format!(" --label '{label}'"));
293            }
294            match (admin.expires_duration.as_deref(), admin.expires_at) {
295                // Prefer the raw duration the user typed — any latency between
296                // --admin-expires being parsed and the conflict firing would
297                // otherwise drift the re-rendered seconds (e.g. `1h` → `3599s`).
298                (Some(raw), _) => hint.push_str(&format!(" --expires {raw}")),
299                (None, Some(expires_at)) => {
300                    let remaining = expires_at.saturating_sub(crate::duration::now_unix());
301                    hint.push_str(&format!(" --expires {remaining}s"));
302                }
303                (None, None) => {}
304            }
305            eprintln!("{hint}");
306            return Ok(());
307        }
308        Err(e) => return Err(e.into()),
309    };
310    println!("Context created:");
311    println!("  ID:        {}", resp.id);
312    println!("  Name:      {}", resp.name);
313    println!("  Base Path: {}", resp.base_path);
314
315    if admin.is_requested() {
316        let did = admin.did.as_deref().unwrap_or_default();
317        if !did.starts_with("did:") {
318            return Err(format!(
319                "--admin-did must start with `did:` (got {did:?}) — context was created but no ACL entry was added"
320            )
321            .into());
322        }
323        // Scope the admin grant to the **full path** the server assigned
324        // (`<parent>/<id>` for a sub-context), not the leaf.
325        let mut acl_req =
326            vta_sdk::client::CreateAclRequest::new(did, "admin").contexts(vec![resp.id.clone()]);
327        if let Some(label) = admin.label.as_deref() {
328            acl_req = acl_req.label(label);
329        }
330        if let Some(expires_at) = admin.expires_at {
331            acl_req = acl_req.expires_at(expires_at);
332        }
333        if admin.holder {
334            // Additive: it does not narrow what the admin role carries, so the
335            // entry keeps everything an admin of this context has and gains
336            // the holder-scoped persona tasks on top.
337            acl_req = acl_req.capabilities(vec!["persona-holder".to_string()]);
338        }
339        let acl = client.create_acl(acl_req).await?;
340
341        println!();
342        println!("Admin ACL entry created:");
343        println!("  DID:        {}", acl.did);
344        println!("  Role:       {}", acl.role);
345        println!("  Contexts:   {}", acl.allowed_contexts.join(", "));
346        if let Some(ref label) = acl.label {
347            println!("  Label:      {label}");
348        }
349        if admin.holder {
350            println!("  Identity:   holder (persona-holder capability granted)");
351        }
352        match acl.expires_at {
353            Some(secs) => {
354                println!(
355                    "  Expires at: {} ({}) — setup ACL",
356                    crate::duration::format_local_time(secs),
357                    crate::duration::format_remaining(secs),
358                );
359                println!();
360                println!("  The admin should authenticate before expiry. On first successful");
361                println!("  connect PNM rotates to a fresh long-lived did:key and replaces this");
362                println!("  temporary entry with a permanent one.");
363            }
364            None => println!("  Expires at: (permanent)"),
365        }
366    }
367
368    Ok(())
369}
370
371pub async fn cmd_context_update(
372    client: &VtaClient,
373    id: &str,
374    name: Option<String>,
375    did: Option<String>,
376    description: Option<String>,
377) -> Result<(), Box<dyn std::error::Error>> {
378    let req = UpdateContextRequest {
379        name,
380        did,
381        description,
382        context_policy: None,
383    };
384    let resp = client.update_context(id, req).await?;
385    println!("Context updated:");
386    render_context_record(&resp);
387    Ok(())
388}
389
390pub async fn cmd_context_update_did(
391    client: &VtaClient,
392    id: &str,
393    did: &str,
394) -> Result<(), Box<dyn std::error::Error>> {
395    let resp = client.update_context_did(id, did).await?;
396    println!("Context DID updated:");
397    println!("  ID:         {}", resp.id);
398    println!(
399        "  DID:        {}",
400        resp.did.as_deref().unwrap_or("(not set)")
401    );
402    println!(
403        "  Updated At: {}",
404        crate::duration::format_local_datetime(resp.updated_at)
405    );
406    Ok(())
407}
408
409/// Print the human-readable resource preview shown before context
410/// deletion. Returns `true` when the preview lists any resources
411/// (i.e. the caller should prompt for confirmation unless `--yes`).
412///
413/// Shared by online + offline delete paths so both warn about exactly
414/// the same resource classes.
415pub fn render_delete_context_preview(
416    id: &str,
417    preview: &vta_sdk::protocols::context_management::delete::DeleteContextPreviewResultBody,
418    book: &NameBook,
419) -> bool {
420    let has_resources = !preview.keys.is_empty()
421        || !preview.webvh_dids.is_empty()
422        || !preview.acl_entries_removed.is_empty()
423        || !preview.acl_entries_updated.is_empty();
424
425    if !has_resources {
426        return false;
427    }
428
429    println!(
430        "Deleting context '{}' will remove the following resources:\n",
431        id
432    );
433
434    if !preview.keys.is_empty() {
435        println!("  Keys ({}):", preview.keys.len());
436        for key in &preview.keys {
437            println!("    - {key}");
438        }
439    }
440
441    // This is a destructive confirmation: the operator is deciding whether
442    // these principals should lose access. A list of opaque DIDs is the
443    // hardest possible form in which to make that call, so every DID here
444    // renders name-first with its identifier alongside.
445    if !preview.webvh_dids.is_empty() {
446        println!("  WebVH DIDs ({}):", preview.webvh_dids.len());
447        for did in &preview.webvh_dids {
448            println!("    - {}", inline(book, did));
449        }
450    }
451
452    if !preview.acl_entries_removed.is_empty() {
453        println!(
454            "  ACL entries removed ({}):",
455            preview.acl_entries_removed.len()
456        );
457        for did in &preview.acl_entries_removed {
458            println!("    - {}", inline(book, did));
459        }
460    }
461
462    if !preview.acl_entries_updated.is_empty() {
463        println!(
464            "  ACL entries updated (context removed from access list) ({}):",
465            preview.acl_entries_updated.len()
466        );
467        for did in &preview.acl_entries_updated {
468            println!("    - {}", inline(book, did));
469        }
470    }
471
472    println!();
473    true
474}
475
476/// Read a `[y/N]` reply from stdin. Returns `true` for `y` / `yes`,
477/// `false` otherwise. Shared by the destructive shared commands so
478/// the prompt wording stays consistent.
479pub fn confirm_destructive(prompt: &str) -> Result<bool, Box<dyn std::error::Error>> {
480    print!("{prompt} [y/N] ");
481    io::stdout().flush()?;
482    let mut input = String::new();
483    io::stdin().read_line(&mut input)?;
484    let input = input.trim().to_lowercase();
485    Ok(input == "y" || input == "yes")
486}
487
488pub async fn cmd_context_delete(
489    client: &VtaClient,
490    id: &str,
491    force: bool,
492) -> Result<(), Box<dyn std::error::Error>> {
493    // Fetch a preview of what will be removed
494    let preview = client.preview_delete_context(id).await?;
495
496    // Best-effort naming for the confirmation prompt — a failure here must
497    // never block a deletion the operator is entitled to perform.
498    let mut book = NameBook::new();
499    if let Ok(acl) = client.list_acl(None).await {
500        book_from_acl(&mut book, &acl.entries);
501    }
502
503    let has_resources = render_delete_context_preview(id, &preview, &book);
504
505    if has_resources && !force && !confirm_destructive("Proceed with deletion?")? {
506        println!("Aborted.");
507        return Ok(());
508    }
509
510    client.delete_context(id, true).await?;
511    println!("Context deleted: {id}");
512    Ok(())
513}
514
515pub async fn cmd_context_provision(
516    client: &VtaClient,
517    id: &str,
518    name: &str,
519    description: Option<String>,
520    admin_label: Option<String>,
521    did_opts: Option<ProvisionDidOptions>,
522    recipient: SealedRecipient,
523) -> Result<(), Box<dyn std::error::Error>> {
524    // 1. Create the context
525    eprintln!("Creating context '{id}'...");
526    let mut ctx_req = CreateContextRequest::new(id, name);
527    if let Some(desc) = description {
528        ctx_req = ctx_req.description(desc);
529    }
530    client.create_context(ctx_req).await?;
531
532    // 2. Fetch VTA config for URL/DID (needed to build the admin credential).
533    let config = client.get_config().await?;
534    let vta_did = config
535        .vta_did()
536        .map(str::to_string)
537        .ok_or("VTA DID not configured — cannot mint admin credential")?;
538    let vta_url = config.public_url().map(str::to_string);
539
540    // 3. Generate admin did:key locally (private key never crosses the wire)
541    //    and register it with the VTA via POST /acl. This replaces the
542    //    pre-5c6 `POST /auth/credentials` round-trip that returned a base64
543    //    CredentialBundle in a plaintext JSON body.
544    eprintln!("Minting local admin credential and registering ACL...");
545    let (admin_credential, admin_did) =
546        crate::local_keygen::generate_admin_did_key(vta_did, vta_url);
547    let mut acl_req =
548        vta_sdk::client::CreateAclRequest::new(&admin_did, "admin").contexts(vec![id.to_string()]);
549    if let Some(l) = admin_label {
550        acl_req = acl_req.label(l);
551    }
552    client.create_acl(acl_req).await?;
553
554    // 4. Optionally create a DID and collect its secrets
555    let provisioned_did = if let Some(opts) = did_opts {
556        eprintln!("Creating WebVH DID...");
557        let req = CreateDidWebvhRequest {
558            context_id: id.to_string(),
559            server_id: opts.server_id,
560            url: opts.did_url,
561            path: None,
562            // `--did-path` maps onto the canonical path-mode selector:
563            // absent → auto-assign server-side, `.well-known` → the
564            // reserved root slot, anything else → an explicit label.
565            // `WebvhPathMode::from(String)` owns that trimming/mapping
566            // so the contexts surface can't drift from `dids create`.
567            path_mode: opts.did_path.map(WebvhPathMode::from),
568            // Context-bootstrap path: no per-DID domain override.
569            // The server's caller-default → system-default resolves.
570            domain: None,
571            label: Some(id.to_string()),
572            portable: opts.portable,
573            add_mediator_service: opts.add_mediator_service,
574            add_tsp_service: false,
575            additional_services: None,
576            pre_rotation_count: opts.pre_rotation_count,
577            did_document: None,
578            did_log: None,
579            set_primary: true,
580            signing_key_id: None,
581            ka_key_id: None,
582            template: None,
583            template_context: None,
584            template_vars: std::collections::HashMap::new(),
585        };
586        let did_result = client.create_did_webvh(req).await?;
587
588        // Collect secrets for the DID keys
589        eprintln!("Fetching DID key secrets...");
590        let mut secrets: Vec<SecretEntry> = Vec::new();
591        // Signing key
592        secrets.push(
593            client
594                .get_key_secret(&did_result.signing_key_id)
595                .await?
596                .into(),
597        );
598        // Key-agreement key
599        secrets.push(client.get_key_secret(&did_result.ka_key_id).await?.into());
600        // Pre-rotation keys
601        for i in 0..did_result.pre_rotation_key_count {
602            let pre_rot_id = format!("{}#pre-rotation-{i}", did_result.did);
603            secrets.push(client.get_key_secret(&pre_rot_id).await?.into());
604        }
605
606        Some(ProvisionedDid {
607            id: did_result.did,
608            did_document: did_result.did_document,
609            log_entry: did_result.log_entry,
610            secrets,
611        })
612    } else {
613        None
614    };
615
616    // 5. Build the provision bundle
617    let bundle = ContextProvisionBundle {
618        context_id: id.to_string(),
619        context_name: name.to_string(),
620        vta_url: config.public_url().map(str::to_string),
621        vta_did: config.vta_did().map(str::to_string),
622        credential: admin_credential,
623        admin_did,
624        did: provisioned_did,
625    };
626
627    // 6. Seal and emit via the shared helper
628    crate::sealed_producer::emit_context_provision_bundle(bundle, &recipient, None).await
629}
630
631/// Build a `CredentialBundle` from a VTA-stored key, deriving its `did:key`.
632///
633/// The REST fetch is transport-specific; the derivation is shared via
634/// [`CredentialBundle::from_ed25519_seed_multibase`] with the offline
635/// path in `vta-service::operations::export::credential_from_key_offline`
636/// so the `did:key` encoding and bundle shape can't drift.
637async fn credential_from_key(
638    client: &VtaClient,
639    key_id: &str,
640    vta_did: &str,
641    vta_url: Option<&str>,
642) -> Result<(CredentialBundle, String), Box<dyn std::error::Error>> {
643    let secret = client.get_key_secret(key_id).await?;
644    CredentialBundle::from_ed25519_seed_multibase(&secret.private_key_multibase, vta_did, vta_url)
645        .map_err(|e| format!("Cannot decode key secret: {e}").into())
646}
647
648pub async fn cmd_context_reprovision(
649    client: &VtaClient,
650    id: &str,
651    key_id: Option<String>,
652    admin_label: Option<String>,
653    recipient: SealedRecipient,
654) -> Result<(), Box<dyn std::error::Error>> {
655    // 1. Fetch the existing context
656    eprintln!("Fetching context '{id}'...");
657    let ctx = client.get_context(id).await?;
658
659    // 2. Fetch VTA config for URL/DID
660    let config = client.get_config().await?;
661    let vta_did = config.vta_did().ok_or("VTA DID not configured")?;
662
663    // 3. Resolve admin credential
664    let (admin_credential, admin_did) = if let Some(ref kid) = key_id {
665        // Direct key ID specified
666        eprintln!("Using key '{kid}'...");
667        credential_from_key(client, kid, vta_did, config.public_url()).await?
668    } else {
669        // Interactive: list existing Ed25519 keys and let user choose
670        let keys_resp = client.list_keys(0, 10000, Some("active"), Some(id)).await?;
671        let ed25519_keys: Vec<_> = keys_resp
672            .keys
673            .iter()
674            .filter(|k| k.key_type == KeyType::Ed25519)
675            .collect();
676
677        eprintln!();
678        eprintln!("Select an admin credential key for context '{id}':");
679        eprintln!();
680        for (i, key) in ed25519_keys.iter().enumerate() {
681            let label = key
682                .label
683                .as_deref()
684                .map(|l| format!(" ({l})"))
685                .unwrap_or_default();
686            eprintln!("  [{}] {}{}", i + 1, key.key_id, label);
687        }
688        let new_option = ed25519_keys.len() + 1;
689        eprintln!("  [{}] Create a new admin key", new_option);
690        eprintln!();
691        eprint!("Choice [{}]: ", new_option);
692        io::stderr().flush()?;
693
694        let mut input = String::new();
695        io::stdin().read_line(&mut input)?;
696        let input = input.trim();
697
698        // Default to creating a new key if empty
699        let choice: usize = if input.is_empty() {
700            new_option
701        } else {
702            input
703                .parse()
704                .map_err(|_| format!("Invalid choice: {input}"))?
705        };
706
707        if choice == new_option {
708            // Create a new Ed25519 key in VTA scoped to this context
709            eprintln!("Creating new admin key...");
710            let key_resp = client
711                .create_key(CreateKeyRequest {
712                    internal: None,
713                    key_type: KeyType::Ed25519,
714                    derivation_path: None,
715                    key_id: None,
716                    mnemonic: None,
717                    label: admin_label.or_else(|| Some("admin".to_string())),
718                    context_id: Some(id.to_string()),
719                })
720                .await?;
721            credential_from_key(client, &key_resp.key_id, vta_did, config.public_url()).await?
722        } else if choice >= 1 && choice <= ed25519_keys.len() {
723            let selected = &ed25519_keys[choice - 1];
724            eprintln!("Using key '{}'...", selected.key_id);
725            credential_from_key(client, &selected.key_id, vta_did, config.public_url()).await?
726        } else {
727            return Err(format!("Invalid choice: {choice}").into());
728        }
729    };
730
731    // 4. Ensure an ACL entry exists for this admin DID
732    if client.get_acl(&admin_did).await.is_err() {
733        eprintln!("Creating ACL entry for {admin_did}...");
734        client
735            .create_acl(
736                vta_sdk::client::CreateAclRequest::new(&admin_did, "admin")
737                    .contexts(vec![id.to_string()]),
738            )
739            .await?;
740    }
741
742    // 5. Collect DID material (document, log, secrets) when the context has a DID
743    let provisioned_did = if let Some(ref did_id) = ctx.did {
744        eprintln!("Fetching DID material...");
745
746        // Fetch the DID log and extract the DID document from it
747        let log_resp = client.get_did_webvh_log(did_id).await?;
748        let (did_document, log_entry) = if let Some(ref log_str) = log_resp.log {
749            let parsed: serde_json::Value = serde_json::from_str(log_str)
750                .map_err(|e| format!("failed to parse DID log: {e}"))?;
751            let doc = parsed.get("state").cloned();
752            (doc, Some(log_str.clone()))
753        } else {
754            (None, None)
755        };
756
757        // Fetch all active key secrets for this context
758        let secrets_bundle = client.fetch_did_secrets_bundle(id).await?;
759
760        Some(ProvisionedDid {
761            id: did_id.clone(),
762            did_document,
763            log_entry,
764            secrets: secrets_bundle.secrets,
765        })
766    } else {
767        None
768    };
769
770    // 6. Build the provision bundle
771    let bundle = ContextProvisionBundle {
772        context_id: id.to_string(),
773        context_name: ctx.name.clone(),
774        vta_url: config.public_url().map(str::to_string),
775        vta_did: config.vta_did().map(str::to_string),
776        credential: admin_credential,
777        admin_did,
778        did: provisioned_did,
779    };
780
781    // 7. Seal and emit via the shared helper
782    crate::sealed_producer::emit_context_provision_bundle(bundle, &recipient, None).await
783}