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::{CreateDidWebvhRequest, UpdateContextRequest};
9use vta_sdk::context_provision::{ContextProvisionBundle, ProvisionedDid};
10use vta_sdk::prelude::*;
11
12use crate::render::print_widget;
13
14pub struct ProvisionDidOptions {
15    pub server_id: Option<String>,
16    pub did_url: Option<String>,
17    pub portable: bool,
18    pub add_mediator_service: bool,
19    pub pre_rotation_count: u32,
20}
21
22pub async fn cmd_context_bootstrap(
23    client: &VtaClient,
24    id: &str,
25    name: &str,
26    description: Option<String>,
27    admin_label: Option<String>,
28) -> Result<(), Box<dyn std::error::Error>> {
29    let mut ctx_req = CreateContextRequest::new(id, name);
30    if let Some(desc) = description {
31        ctx_req = ctx_req.description(desc);
32    }
33    let ctx = client.create_context(ctx_req).await?;
34    println!("Context created:");
35    println!("  ID:        {}", ctx.id);
36    println!("  Name:      {}", ctx.name);
37    println!("  Base Path: {}", ctx.base_path);
38
39    let mut cred_req = GenerateCredentialsRequest::new("admin").contexts(vec![id.to_string()]);
40    if let Some(l) = admin_label {
41        cred_req = cred_req.label(l);
42    }
43    let resp = client.generate_credentials(cred_req).await?;
44    println!();
45    println!("Admin credential created:");
46    println!("  DID:  {}", resp.did);
47    println!("  Role: admin");
48    println!();
49    println!("Credential (one-time secret — save this now):");
50    println!("{}", resp.credential);
51
52    Ok(())
53}
54
55pub async fn cmd_context_list(client: &VtaClient) -> Result<(), Box<dyn std::error::Error>> {
56    let resp = client.list_contexts().await?;
57
58    if resp.contexts.is_empty() {
59        println!("No contexts found.");
60        return Ok(());
61    }
62
63    let header_style = Style::default()
64        .fg(Color::White)
65        .add_modifier(Modifier::BOLD);
66    let header = Row::new(vec!["ID", "Name", "DID", "Base Path", "Created"])
67        .style(header_style)
68        .bottom_margin(1);
69
70    let rows: Vec<Row> = resp
71        .contexts
72        .iter()
73        .map(|ctx| {
74            let did = ctx.did.clone().unwrap_or_else(|| "\u{2014}".into());
75            let created = ctx.created_at.format("%Y-%m-%d").to_string();
76
77            Row::new(vec![
78                Cell::from(ctx.id.clone()),
79                Cell::from(ctx.name.clone()),
80                Cell::from(did).style(Style::default().fg(Color::DarkGray)),
81                Cell::from(ctx.base_path.clone()),
82                Cell::from(created),
83            ])
84        })
85        .collect();
86
87    let title = format!(" Contexts ({}) ", resp.contexts.len());
88
89    let table = Table::new(
90        rows,
91        [
92            Constraint::Length(16), // ID
93            Constraint::Min(20),    // Name
94            Constraint::Length(30), // DID
95            Constraint::Length(16), // Base Path
96            Constraint::Length(10), // Created
97        ],
98    )
99    .header(header)
100    .column_spacing(2)
101    .block(
102        Block::bordered()
103            .title(title)
104            .border_style(Style::default().fg(Color::DarkGray)),
105    );
106
107    let height = resp.contexts.len() as u16 + 4;
108    print_widget(table, height);
109
110    Ok(())
111}
112
113pub async fn cmd_context_get(
114    client: &VtaClient,
115    id: &str,
116) -> Result<(), Box<dyn std::error::Error>> {
117    let resp = client.get_context(id).await?;
118    println!("ID:          {}", resp.id);
119    println!("Name:        {}", resp.name);
120    println!(
121        "DID:         {}",
122        resp.did.as_deref().unwrap_or("(not set)")
123    );
124    println!(
125        "Description: {}",
126        resp.description.as_deref().unwrap_or("(not set)")
127    );
128    println!("Base Path:   {}", resp.base_path);
129    println!("Created At:  {}", resp.created_at);
130    println!("Updated At:  {}", resp.updated_at);
131    Ok(())
132}
133
134pub async fn cmd_context_create(
135    client: &VtaClient,
136    id: &str,
137    name: &str,
138    description: Option<String>,
139) -> Result<(), Box<dyn std::error::Error>> {
140    let req = CreateContextRequest {
141        id: id.to_string(),
142        name: name.to_string(),
143        description,
144    };
145    let resp = client.create_context(req).await?;
146    println!("Context created:");
147    println!("  ID:        {}", resp.id);
148    println!("  Name:      {}", resp.name);
149    println!("  Base Path: {}", resp.base_path);
150    Ok(())
151}
152
153pub async fn cmd_context_update(
154    client: &VtaClient,
155    id: &str,
156    name: Option<String>,
157    did: Option<String>,
158    description: Option<String>,
159) -> Result<(), Box<dyn std::error::Error>> {
160    let req = UpdateContextRequest {
161        name,
162        did,
163        description,
164    };
165    let resp = client.update_context(id, req).await?;
166    println!("Context updated:");
167    println!("  ID:          {}", resp.id);
168    println!("  Name:        {}", resp.name);
169    println!(
170        "  DID:         {}",
171        resp.did.as_deref().unwrap_or("(not set)")
172    );
173    println!(
174        "  Description: {}",
175        resp.description.as_deref().unwrap_or("(not set)")
176    );
177    println!("  Updated At:  {}", resp.updated_at);
178    Ok(())
179}
180
181pub async fn cmd_context_update_did(
182    client: &VtaClient,
183    id: &str,
184    did: &str,
185) -> Result<(), Box<dyn std::error::Error>> {
186    let resp = client.update_context_did(id, did).await?;
187    println!("Context DID updated:");
188    println!("  ID:         {}", resp.id);
189    println!(
190        "  DID:        {}",
191        resp.did.as_deref().unwrap_or("(not set)")
192    );
193    println!("  Updated At: {}", resp.updated_at);
194    Ok(())
195}
196
197pub async fn cmd_context_delete(
198    client: &VtaClient,
199    id: &str,
200    force: bool,
201) -> Result<(), Box<dyn std::error::Error>> {
202    // Fetch a preview of what will be removed
203    let preview = client.preview_delete_context(id).await?;
204
205    let has_resources = !preview.keys.is_empty()
206        || !preview.webvh_dids.is_empty()
207        || !preview.acl_entries_removed.is_empty()
208        || !preview.acl_entries_updated.is_empty();
209
210    if has_resources {
211        println!(
212            "Deleting context '{}' will remove the following resources:\n",
213            id
214        );
215
216        if !preview.keys.is_empty() {
217            println!("  Keys ({}):", preview.keys.len());
218            for key in &preview.keys {
219                println!("    - {key}");
220            }
221        }
222
223        if !preview.webvh_dids.is_empty() {
224            println!("  WebVH DIDs ({}):", preview.webvh_dids.len());
225            for did in &preview.webvh_dids {
226                println!("    - {did}");
227            }
228        }
229
230        if !preview.acl_entries_removed.is_empty() {
231            println!(
232                "  ACL entries removed ({}):",
233                preview.acl_entries_removed.len()
234            );
235            for did in &preview.acl_entries_removed {
236                println!("    - {did}");
237            }
238        }
239
240        if !preview.acl_entries_updated.is_empty() {
241            println!(
242                "  ACL entries updated (context removed from access list) ({}):",
243                preview.acl_entries_updated.len()
244            );
245            for did in &preview.acl_entries_updated {
246                println!("    - {did}");
247            }
248        }
249
250        println!();
251
252        if !force {
253            print!("Proceed with deletion? [y/N] ");
254            io::stdout().flush()?;
255
256            let mut input = String::new();
257            io::stdin().read_line(&mut input)?;
258            let input = input.trim().to_lowercase();
259
260            if input != "y" && input != "yes" {
261                println!("Aborted.");
262                return Ok(());
263            }
264        }
265    }
266
267    client.delete_context(id, true).await?;
268    println!("Context deleted: {id}");
269    Ok(())
270}
271
272pub async fn cmd_context_provision(
273    client: &VtaClient,
274    id: &str,
275    name: &str,
276    description: Option<String>,
277    admin_label: Option<String>,
278    did_opts: Option<ProvisionDidOptions>,
279) -> Result<(), Box<dyn std::error::Error>> {
280    // 1. Create the context
281    eprintln!("Creating context '{id}'...");
282    let mut ctx_req = CreateContextRequest::new(id, name);
283    if let Some(desc) = description {
284        ctx_req = ctx_req.description(desc);
285    }
286    client.create_context(ctx_req).await?;
287
288    // 2. Generate admin credentials scoped to this context
289    eprintln!("Generating admin credentials...");
290    let mut cred_req = GenerateCredentialsRequest::new("admin").contexts(vec![id.to_string()]);
291    if let Some(l) = admin_label {
292        cred_req = cred_req.label(l);
293    }
294    let cred_resp = client.generate_credentials(cred_req).await?;
295
296    // 3. Fetch VTA config for URL/DID
297    let config = client.get_config().await?;
298
299    // 4. Optionally create a DID and collect its secrets
300    let provisioned_did = if let Some(opts) = did_opts {
301        eprintln!("Creating WebVH DID...");
302        let req = CreateDidWebvhRequest {
303            context_id: id.to_string(),
304            server_id: opts.server_id,
305            url: opts.did_url,
306            path: None,
307            label: Some(id.to_string()),
308            portable: opts.portable,
309            add_mediator_service: opts.add_mediator_service,
310            additional_services: None,
311            pre_rotation_count: opts.pre_rotation_count,
312        };
313        let did_result = client.create_did_webvh(req).await?;
314
315        // Collect secrets for the DID keys
316        eprintln!("Fetching DID key secrets...");
317        let mut secrets: Vec<SecretEntry> = Vec::new();
318        // Signing key
319        secrets.push(
320            client
321                .get_key_secret(&did_result.signing_key_id)
322                .await?
323                .into(),
324        );
325        // Key-agreement key
326        secrets.push(client.get_key_secret(&did_result.ka_key_id).await?.into());
327        // Pre-rotation keys
328        for i in 0..did_result.pre_rotation_key_count {
329            let pre_rot_id = format!("{}#pre-rotation-{i}", did_result.did);
330            secrets.push(client.get_key_secret(&pre_rot_id).await?.into());
331        }
332
333        Some(ProvisionedDid {
334            id: did_result.did,
335            did_document: did_result.did_document,
336            log_entry: did_result.log_entry,
337            secrets,
338        })
339    } else {
340        None
341    };
342
343    // 5. Build the provision bundle
344    let bundle = ContextProvisionBundle {
345        context_id: id.to_string(),
346        context_name: name.to_string(),
347        vta_url: config.public_url,
348        vta_did: config.community_vta_did,
349        credential: cred_resp.credential,
350        admin_did: cred_resp.did,
351        did: provisioned_did,
352    };
353
354    let encoded = bundle.encode().map_err(|e| format!("{e}"))?;
355
356    // 6. Output
357    eprintln!();
358    eprintln!("\x1b[1;33m╔══════════════════════════════════════════════════════════════╗");
359    eprintln!("║  Context provision bundle (contains secrets — save securely) ║");
360    eprintln!("╚══════════════════════════════════════════════════════════════╝\x1b[0m");
361    eprintln!();
362    eprintln!("  Context:   {} ({})", id, name);
363    eprintln!("  Admin DID: {}", bundle.admin_did);
364    if let Some(ref did) = bundle.did {
365        eprintln!("  DID:       {}", did.id);
366        if did.log_entry.is_some() {
367            eprintln!("             (includes log entry for self-hosting)");
368        }
369    }
370    eprintln!();
371    println!("{encoded}");
372    eprintln!();
373
374    Ok(())
375}
376
377/// Build a `CredentialBundle` from a VTA-stored key, deriving its `did:key`.
378async fn credential_from_key(
379    client: &VtaClient,
380    key_id: &str,
381    vta_did: &str,
382    vta_url: Option<&str>,
383) -> Result<(String, String), Box<dyn std::error::Error>> {
384    let secret = client.get_key_secret(key_id).await?;
385    let seed = decode_private_key_multibase(&secret.private_key_multibase)
386        .map_err(|e| format!("Cannot decode key secret: {e}"))?;
387    let public_key = ed25519_dalek::SigningKey::from_bytes(&seed)
388        .verifying_key()
389        .to_bytes();
390    let did = format!("did:key:{}", ed25519_multibase_pubkey(&public_key));
391
392    let bundle = CredentialBundle {
393        did: did.clone(),
394        private_key_multibase: secret.private_key_multibase,
395        vta_did: vta_did.to_string(),
396        vta_url: vta_url.map(String::from),
397    };
398    let encoded = bundle.encode().map_err(|e| format!("{e}"))?;
399    Ok((encoded, did))
400}
401
402pub async fn cmd_context_reprovision(
403    client: &VtaClient,
404    id: &str,
405    key_id: Option<String>,
406    admin_label: Option<String>,
407) -> Result<(), Box<dyn std::error::Error>> {
408    // 1. Fetch the existing context
409    eprintln!("Fetching context '{id}'...");
410    let ctx = client.get_context(id).await?;
411
412    // 2. Fetch VTA config for URL/DID
413    let config = client.get_config().await?;
414    let vta_did = config
415        .community_vta_did
416        .as_deref()
417        .ok_or("VTA DID not configured")?;
418
419    // 3. Resolve admin credential
420    let (admin_credential, admin_did) = if let Some(ref kid) = key_id {
421        // Direct key ID specified
422        eprintln!("Using key '{kid}'...");
423        credential_from_key(client, kid, vta_did, config.public_url.as_deref()).await?
424    } else {
425        // Interactive: list existing Ed25519 keys and let user choose
426        let keys_resp = client.list_keys(0, 10000, Some("active"), Some(id)).await?;
427        let ed25519_keys: Vec<_> = keys_resp
428            .keys
429            .iter()
430            .filter(|k| k.key_type == KeyType::Ed25519)
431            .collect();
432
433        eprintln!();
434        eprintln!("Select an admin credential key for context '{id}':");
435        eprintln!();
436        for (i, key) in ed25519_keys.iter().enumerate() {
437            let label = key
438                .label
439                .as_deref()
440                .map(|l| format!(" ({l})"))
441                .unwrap_or_default();
442            eprintln!("  [{}] {}{}", i + 1, key.key_id, label);
443        }
444        let new_option = ed25519_keys.len() + 1;
445        eprintln!("  [{}] Create a new admin key", new_option);
446        eprintln!();
447        eprint!("Choice [{}]: ", new_option);
448        io::stderr().flush()?;
449
450        let mut input = String::new();
451        io::stdin().read_line(&mut input)?;
452        let input = input.trim();
453
454        // Default to creating a new key if empty
455        let choice: usize = if input.is_empty() {
456            new_option
457        } else {
458            input
459                .parse()
460                .map_err(|_| format!("Invalid choice: {input}"))?
461        };
462
463        if choice == new_option {
464            // Create a new Ed25519 key in VTA scoped to this context
465            eprintln!("Creating new admin key...");
466            let key_resp = client
467                .create_key(CreateKeyRequest {
468                    key_type: KeyType::Ed25519,
469                    derivation_path: None,
470                    key_id: None,
471                    mnemonic: None,
472                    label: admin_label.or_else(|| Some("admin".to_string())),
473                    context_id: Some(id.to_string()),
474                })
475                .await?;
476            credential_from_key(
477                client,
478                &key_resp.key_id,
479                vta_did,
480                config.public_url.as_deref(),
481            )
482            .await?
483        } else if choice >= 1 && choice <= ed25519_keys.len() {
484            let selected = &ed25519_keys[choice - 1];
485            eprintln!("Using key '{}'...", selected.key_id);
486            credential_from_key(
487                client,
488                &selected.key_id,
489                vta_did,
490                config.public_url.as_deref(),
491            )
492            .await?
493        } else {
494            return Err(format!("Invalid choice: {choice}").into());
495        }
496    };
497
498    // 4. Ensure an ACL entry exists for this admin DID
499    if client.get_acl(&admin_did).await.is_err() {
500        eprintln!("Creating ACL entry for {admin_did}...");
501        client
502            .create_acl(
503                vta_sdk::client::CreateAclRequest::new(&admin_did, "admin")
504                    .contexts(vec![id.to_string()]),
505            )
506            .await?;
507    }
508
509    // 5. Collect DID material (document, log, secrets) when the context has a DID
510    let provisioned_did = if let Some(ref did_id) = ctx.did {
511        eprintln!("Fetching DID material...");
512
513        // Fetch the DID log and extract the DID document from it
514        let log_resp = client.get_did_webvh_log(did_id).await?;
515        let (did_document, log_entry) = if let Some(ref log_str) = log_resp.log {
516            let parsed: serde_json::Value = serde_json::from_str(log_str)
517                .map_err(|e| format!("failed to parse DID log: {e}"))?;
518            let doc = parsed.get("state").cloned();
519            (doc, Some(log_str.clone()))
520        } else {
521            (None, None)
522        };
523
524        // Fetch all active key secrets for this context
525        let secrets_bundle = client.fetch_did_secrets_bundle(id).await?;
526
527        Some(ProvisionedDid {
528            id: did_id.clone(),
529            did_document,
530            log_entry,
531            secrets: secrets_bundle.secrets,
532        })
533    } else {
534        None
535    };
536
537    // 6. Build the provision bundle
538    let bundle = ContextProvisionBundle {
539        context_id: id.to_string(),
540        context_name: ctx.name.clone(),
541        vta_url: config.public_url,
542        vta_did: config.community_vta_did,
543        credential: admin_credential,
544        admin_did,
545        did: provisioned_did,
546    };
547
548    let encoded = bundle.encode().map_err(|e| format!("{e}"))?;
549
550    // 7. Output
551    eprintln!();
552    eprintln!("\x1b[1;33m╔══════════════════════════════════════════════════════════════╗");
553    eprintln!("║  Context provision bundle (contains secrets — save securely) ║");
554    eprintln!("╚══════════════════════════════════════════════════════════════╝\x1b[0m");
555    eprintln!();
556    eprintln!("  Context:   {} ({})", id, ctx.name);
557    eprintln!("  Admin DID: {}", bundle.admin_did);
558    if let Some(ref did) = bundle.did {
559        eprintln!("  DID:       {}", did.id);
560    }
561    eprintln!();
562    println!("{encoded}");
563    eprintln!();
564
565    Ok(())
566}