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            did_document: None,
313            did_log: None,
314            set_primary: true,
315            signing_key_id: None,
316            ka_key_id: None,
317        };
318        let did_result = client.create_did_webvh(req).await?;
319
320        // Collect secrets for the DID keys
321        eprintln!("Fetching DID key secrets...");
322        let mut secrets: Vec<SecretEntry> = Vec::new();
323        // Signing key
324        secrets.push(
325            client
326                .get_key_secret(&did_result.signing_key_id)
327                .await?
328                .into(),
329        );
330        // Key-agreement key
331        secrets.push(client.get_key_secret(&did_result.ka_key_id).await?.into());
332        // Pre-rotation keys
333        for i in 0..did_result.pre_rotation_key_count {
334            let pre_rot_id = format!("{}#pre-rotation-{i}", did_result.did);
335            secrets.push(client.get_key_secret(&pre_rot_id).await?.into());
336        }
337
338        Some(ProvisionedDid {
339            id: did_result.did,
340            did_document: did_result.did_document,
341            log_entry: did_result.log_entry,
342            secrets,
343        })
344    } else {
345        None
346    };
347
348    // 5. Build the provision bundle
349    let bundle = ContextProvisionBundle {
350        context_id: id.to_string(),
351        context_name: name.to_string(),
352        vta_url: config.public_url,
353        vta_did: config.community_vta_did,
354        credential: cred_resp.credential,
355        admin_did: cred_resp.did,
356        did: provisioned_did,
357    };
358
359    let encoded = bundle.encode().map_err(|e| format!("{e}"))?;
360
361    // 6. Output
362    eprintln!();
363    eprintln!("\x1b[1;33m╔══════════════════════════════════════════════════════════════╗");
364    eprintln!("║  Context provision bundle (contains secrets — save securely) ║");
365    eprintln!("╚══════════════════════════════════════════════════════════════╝\x1b[0m");
366    eprintln!();
367    eprintln!("  Context:   {} ({})", id, name);
368    eprintln!("  Admin DID: {}", bundle.admin_did);
369    if let Some(ref did) = bundle.did {
370        eprintln!("  DID:       {}", did.id);
371        if did.log_entry.is_some() {
372            eprintln!("             (includes log entry for self-hosting)");
373        }
374    }
375    eprintln!();
376    println!("{encoded}");
377    eprintln!();
378
379    Ok(())
380}
381
382/// Build a `CredentialBundle` from a VTA-stored key, deriving its `did:key`.
383async fn credential_from_key(
384    client: &VtaClient,
385    key_id: &str,
386    vta_did: &str,
387    vta_url: Option<&str>,
388) -> Result<(String, String), Box<dyn std::error::Error>> {
389    let secret = client.get_key_secret(key_id).await?;
390    let seed = decode_private_key_multibase(&secret.private_key_multibase)
391        .map_err(|e| format!("Cannot decode key secret: {e}"))?;
392    let public_key = ed25519_dalek::SigningKey::from_bytes(&seed)
393        .verifying_key()
394        .to_bytes();
395    let did = format!("did:key:{}", ed25519_multibase_pubkey(&public_key));
396
397    let bundle = CredentialBundle {
398        did: did.clone(),
399        private_key_multibase: secret.private_key_multibase,
400        vta_did: vta_did.to_string(),
401        vta_url: vta_url.map(String::from),
402    };
403    let encoded = bundle.encode().map_err(|e| format!("{e}"))?;
404    Ok((encoded, did))
405}
406
407pub async fn cmd_context_reprovision(
408    client: &VtaClient,
409    id: &str,
410    key_id: Option<String>,
411    admin_label: Option<String>,
412) -> Result<(), Box<dyn std::error::Error>> {
413    // 1. Fetch the existing context
414    eprintln!("Fetching context '{id}'...");
415    let ctx = client.get_context(id).await?;
416
417    // 2. Fetch VTA config for URL/DID
418    let config = client.get_config().await?;
419    let vta_did = config
420        .community_vta_did
421        .as_deref()
422        .ok_or("VTA DID not configured")?;
423
424    // 3. Resolve admin credential
425    let (admin_credential, admin_did) = if let Some(ref kid) = key_id {
426        // Direct key ID specified
427        eprintln!("Using key '{kid}'...");
428        credential_from_key(client, kid, vta_did, config.public_url.as_deref()).await?
429    } else {
430        // Interactive: list existing Ed25519 keys and let user choose
431        let keys_resp = client.list_keys(0, 10000, Some("active"), Some(id)).await?;
432        let ed25519_keys: Vec<_> = keys_resp
433            .keys
434            .iter()
435            .filter(|k| k.key_type == KeyType::Ed25519)
436            .collect();
437
438        eprintln!();
439        eprintln!("Select an admin credential key for context '{id}':");
440        eprintln!();
441        for (i, key) in ed25519_keys.iter().enumerate() {
442            let label = key
443                .label
444                .as_deref()
445                .map(|l| format!(" ({l})"))
446                .unwrap_or_default();
447            eprintln!("  [{}] {}{}", i + 1, key.key_id, label);
448        }
449        let new_option = ed25519_keys.len() + 1;
450        eprintln!("  [{}] Create a new admin key", new_option);
451        eprintln!();
452        eprint!("Choice [{}]: ", new_option);
453        io::stderr().flush()?;
454
455        let mut input = String::new();
456        io::stdin().read_line(&mut input)?;
457        let input = input.trim();
458
459        // Default to creating a new key if empty
460        let choice: usize = if input.is_empty() {
461            new_option
462        } else {
463            input
464                .parse()
465                .map_err(|_| format!("Invalid choice: {input}"))?
466        };
467
468        if choice == new_option {
469            // Create a new Ed25519 key in VTA scoped to this context
470            eprintln!("Creating new admin key...");
471            let key_resp = client
472                .create_key(CreateKeyRequest {
473                    key_type: KeyType::Ed25519,
474                    derivation_path: None,
475                    key_id: None,
476                    mnemonic: None,
477                    label: admin_label.or_else(|| Some("admin".to_string())),
478                    context_id: Some(id.to_string()),
479                })
480                .await?;
481            credential_from_key(
482                client,
483                &key_resp.key_id,
484                vta_did,
485                config.public_url.as_deref(),
486            )
487            .await?
488        } else if choice >= 1 && choice <= ed25519_keys.len() {
489            let selected = &ed25519_keys[choice - 1];
490            eprintln!("Using key '{}'...", selected.key_id);
491            credential_from_key(
492                client,
493                &selected.key_id,
494                vta_did,
495                config.public_url.as_deref(),
496            )
497            .await?
498        } else {
499            return Err(format!("Invalid choice: {choice}").into());
500        }
501    };
502
503    // 4. Ensure an ACL entry exists for this admin DID
504    if client.get_acl(&admin_did).await.is_err() {
505        eprintln!("Creating ACL entry for {admin_did}...");
506        client
507            .create_acl(
508                vta_sdk::client::CreateAclRequest::new(&admin_did, "admin")
509                    .contexts(vec![id.to_string()]),
510            )
511            .await?;
512    }
513
514    // 5. Collect DID material (document, log, secrets) when the context has a DID
515    let provisioned_did = if let Some(ref did_id) = ctx.did {
516        eprintln!("Fetching DID material...");
517
518        // Fetch the DID log and extract the DID document from it
519        let log_resp = client.get_did_webvh_log(did_id).await?;
520        let (did_document, log_entry) = if let Some(ref log_str) = log_resp.log {
521            let parsed: serde_json::Value = serde_json::from_str(log_str)
522                .map_err(|e| format!("failed to parse DID log: {e}"))?;
523            let doc = parsed.get("state").cloned();
524            (doc, Some(log_str.clone()))
525        } else {
526            (None, None)
527        };
528
529        // Fetch all active key secrets for this context
530        let secrets_bundle = client.fetch_did_secrets_bundle(id).await?;
531
532        Some(ProvisionedDid {
533            id: did_id.clone(),
534            did_document,
535            log_entry,
536            secrets: secrets_bundle.secrets,
537        })
538    } else {
539        None
540    };
541
542    // 6. Build the provision bundle
543    let bundle = ContextProvisionBundle {
544        context_id: id.to_string(),
545        context_name: ctx.name.clone(),
546        vta_url: config.public_url,
547        vta_did: config.community_vta_did,
548        credential: admin_credential,
549        admin_did,
550        did: provisioned_did,
551    };
552
553    let encoded = bundle.encode().map_err(|e| format!("{e}"))?;
554
555    // 7. Output
556    eprintln!();
557    eprintln!("\x1b[1;33m╔══════════════════════════════════════════════════════════════╗");
558    eprintln!("║  Context provision bundle (contains secrets — save securely) ║");
559    eprintln!("╚══════════════════════════════════════════════════════════════╝\x1b[0m");
560    eprintln!();
561    eprintln!("  Context:   {} ({})", id, ctx.name);
562    eprintln!("  Admin DID: {}", bundle.admin_did);
563    if let Some(ref did) = bundle.did {
564        eprintln!("  DID:       {}", did.id);
565    }
566    eprintln!();
567    println!("{encoded}");
568    eprintln!();
569
570    Ok(())
571}