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