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