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), Constraint::Min(20), Constraint::Length(30), Constraint::Length(16), Constraint::Length(10), ],
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 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 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 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 let config = client.get_config().await?;
286
287 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 eprintln!("Fetching DID key secrets...");
305 let mut secrets = Vec::new();
306 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 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 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 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 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
375async 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 eprintln!("Fetching context '{id}'...");
408 let ctx = client.get_context(id).await?;
409
410 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 let (admin_credential, admin_did) = if let Some(ref kid) = key_id {
419 eprintln!("Using key '{kid}'...");
421 credential_from_key(client, kid, vta_did, config.public_url.as_deref()).await?
422 } else {
423 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 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 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 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 let provisioned_did = if let Some(ref did_id) = ctx.did {
513 eprintln!("Fetching DID material...");
514
515 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 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 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 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}