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