Skip to main content

vta_cli_common/commands/
webvh.rs

1use ratatui::{
2    layout::Constraint,
3    style::{Color, Modifier, Style},
4    widgets::{Block, Cell, Row, Table},
5};
6use vta_sdk::client::{AddWebvhServerRequest, CreateDidWebvhRequest, UpdateWebvhServerRequest};
7use vta_sdk::prelude::*;
8
9use crate::display::{
10    NAME_HEADER, NameBook, book_from_vta, book_from_webvh_servers, did_cell, full_display_pairs,
11    name_cell, resolve_agent_names_into,
12};
13
14use crate::render::{is_full_display, print_full_entry_owned, print_full_list_title, print_widget};
15
16pub async fn cmd_webvh_server_add(
17    client: &VtaClient,
18    id: String,
19    did: String,
20    label: Option<String>,
21) -> Result<(), Box<dyn std::error::Error>> {
22    let req = AddWebvhServerRequest { id, did, label };
23    let record = client.add_webvh_server(req).await?;
24    println!("WebVH server added:");
25    println!("  ID:  {}", record.id);
26    println!("  DID: {}", record.did);
27    if let Some(label) = &record.label {
28        println!("  Label: {label}");
29    }
30    Ok(())
31}
32
33pub async fn cmd_webvh_server_list(client: &VtaClient) -> Result<(), Box<dyn std::error::Error>> {
34    let resp = client.list_webvh_servers().await?;
35
36    if resp.servers.is_empty() {
37        println!("No WebVH servers configured.");
38        return Ok(());
39    }
40
41    // A hosting server's `label` is its name. Routing it through the book
42    // rather than printing it directly means a verified agent name will
43    // outrank it once these DIDs publish one.
44    let mut book = NameBook::new();
45    book_from_webvh_servers(&mut book, &resp.servers);
46
47    if is_full_display() {
48        print_full_list_title("WebVH Servers", resp.servers.len());
49        for s in &resp.servers {
50            let created = s
51                .created_at
52                .with_timezone(&chrono::Local)
53                .format("%Y-%m-%d %H:%M:%S %:z")
54                .to_string();
55            let mut fields = vec![("ID", s.id.clone())];
56            fields.extend(full_display_pairs(&book, &s.did));
57            fields.push(("Created", created));
58            print_full_entry_owned(&fields);
59        }
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_HEADER, "DID", "Created"])
67        .style(header_style)
68        .bottom_margin(1);
69
70    let rows: Vec<Row> = resp
71        .servers
72        .iter()
73        .map(|s| {
74            let created = s
75                .created_at
76                .with_timezone(&chrono::Local)
77                .format("%Y-%m-%d %H:%M")
78                .to_string();
79
80            Row::new(vec![
81                Cell::from(s.id.clone()),
82                name_cell(&book, &s.did),
83                did_cell(&s.did),
84                Cell::from(created).style(Style::default().fg(Color::DarkGray)),
85            ])
86        })
87        .collect();
88
89    let title = format!(" WebVH Servers ({}) ", resp.servers.len());
90
91    let table = Table::new(
92        rows,
93        [
94            Constraint::Length(16), // ID
95            Constraint::Min(40),    // DID
96            Constraint::Min(16),    // Label
97            Constraint::Length(18), // Created
98        ],
99    )
100    .header(header)
101    .column_spacing(2)
102    .block(
103        Block::bordered()
104            .title(title)
105            .border_style(Style::default().fg(Color::DarkGray)),
106    );
107
108    let height = resp.servers.len() as u16 + 4;
109    print_widget(table, height);
110
111    Ok(())
112}
113
114pub async fn cmd_webvh_server_update(
115    client: &VtaClient,
116    id: &str,
117    label: Option<String>,
118) -> Result<(), Box<dyn std::error::Error>> {
119    let req = UpdateWebvhServerRequest { label };
120    let record = client.update_webvh_server(id, req).await?;
121    println!("WebVH server updated:");
122    println!("  ID:  {}", record.id);
123    println!("  DID: {}", record.did);
124    if let Some(label) = &record.label {
125        println!("  Label: {label}");
126    }
127    Ok(())
128}
129
130pub async fn cmd_webvh_server_remove(
131    client: &VtaClient,
132    id: &str,
133) -> Result<(), Box<dyn std::error::Error>> {
134    client.remove_webvh_server(id).await?;
135    println!("WebVH server removed: {id}");
136    Ok(())
137}
138
139/// `pnm webvh register-did --did <did> --server <id> [--force]` —
140/// promote a serverless WebVH DID to a server-managed one.
141///
142/// Pushes the local `did.jsonl` to the host atomically (single
143/// batched write — no resolver gap) and flips the local record's
144/// `server_id` so future `pnm services …` mutations auto-publish
145/// there. Refused if the DID is already server-managed
146/// (re-pointing a hosted DID is out of scope).
147///
148/// `--force` is honoured only when the VTA's DID authenticates to
149/// the host as an admin replacing a slot owned by a different DID.
150/// An owner re-registering their own slot is idempotent and always
151/// succeeds without `--force`.
152pub async fn cmd_webvh_did_register_server(
153    client: &VtaClient,
154    did: &str,
155    server_id: &str,
156    force: bool,
157    domain: Option<String>,
158) -> Result<(), Box<dyn std::error::Error>> {
159    let result = client
160        .register_did_with_server(did, server_id, force, domain.as_deref())
161        .await?;
162    println!("DID registered with WebVH server.");
163    println!("  DID:        {}", result.did);
164    println!("  Server:     {}", result.server_id);
165    println!("  Log entries: {}", result.log_entry_count);
166    println!();
167    println!(
168        "Future `pnm services …` mutations will auto-publish to `{}`.",
169        result.server_id
170    );
171    Ok(())
172}
173
174/// `pnm webvh edit-did --did <did>` — interactive or
175/// non-interactive update of an existing WebVH DID document.
176///
177/// **Interactive (no flags):** fetches the latest published DID
178/// document, opens it in `$EDITOR`, asks whether to change any of
179/// the webvh parameters (pre-rotation, watchers, TTL, label),
180/// confirms, then publishes a new LogEntry.
181///
182/// **Non-interactive:** supply `--document <file>` (and optionally
183/// the per-field flags) or `--options-file <file>` to skip prompts
184/// entirely. Useful for scripted flows.
185///
186/// Refuses to publish if the operator changed the DID's top-level
187/// `id` field — the WebVH method treats the DID id as a permanent
188/// commitment from the first LogEntry.
189pub async fn cmd_webvh_did_edit(
190    client: &VtaClient,
191    did: &str,
192    flags: super::webvh_edit::EditFlags,
193    no_confirm: bool,
194) -> Result<(), Box<dyn std::error::Error>> {
195    use super::webvh_edit::{
196        build_options_from_flags, confirm_publish, diff_summary, document_id,
197        extract_current_document, extract_latest_version_id, extract_pre_rotation_status,
198        launch_editor, prompt_webvh_params,
199    };
200
201    // Fetch the DID record (for context_id + scid) — this also
202    // surfaces a clean 404 if the DID isn't registered.
203    let record = client.get_did_webvh(did).await?;
204    let context_id = record.context_id.clone();
205    let scid = record.scid.clone();
206
207    // Decide between interactive and non-interactive paths. The
208    // non-interactive heuristic: any flag set → skip the editor
209    // and prompt chain. Interactive mode (no flags) opens $EDITOR
210    // and asks the parameter questions.
211    let any_flag_set = flags.document_file.is_some()
212        || flags.options_file.is_some()
213        || flags.pre_rotation.is_some()
214        || flags.ttl.is_some()
215        || !flags.watchers.is_empty()
216        || flags.no_watchers
217        || flags.label.is_some();
218
219    let mut body = if any_flag_set {
220        let body = build_options_from_flags(&flags)?;
221        // Validate the supplied document doesn't change the DID id.
222        if let Some(edited) = &body.document {
223            let log = client.get_did_webvh_log(did).await?;
224            let log_str = log.log.ok_or_else(|| -> Box<dyn std::error::Error> {
225                "DID has no published log on the VTA — nothing to edit".into()
226            })?;
227            let prior = extract_current_document(&log_str)?;
228            super::webvh_edit::assert_did_id_unchanged(&prior, edited)?;
229        }
230        body
231    } else {
232        // Interactive path: fetch the log, extract the latest doc,
233        // open in $EDITOR, then walk the parameter prompts. Capture
234        // the latest versionId so the save call can carry an
235        // optimistic-concurrency precondition (lost-update guard).
236        let log = client.get_did_webvh_log(did).await?;
237        let log_str = log.log.ok_or_else(|| -> Box<dyn std::error::Error> {
238            "DID has no published log on the VTA — nothing to edit".into()
239        })?;
240        let prior = extract_current_document(&log_str)?;
241        let prior_id = document_id(&prior)?.to_string();
242        let fetched_version_id = extract_latest_version_id(&log_str).ok();
243        let pre_rotation_status = extract_pre_rotation_status(&log_str);
244        eprintln!("Editing DID document for {prior_id}.");
245        if let Some(ref v) = fetched_version_id {
246            eprintln!("  Current versionId: {v}");
247        }
248        eprintln!("Opening $EDITOR — save and exit to continue, or quit without saving to abort.");
249
250        let edited = match launch_editor(&prior)? {
251            Some(doc) => {
252                let summary = diff_summary(&prior, &doc);
253                eprintln!();
254                eprintln!("Document diff:");
255                for line in summary.lines() {
256                    eprintln!("  {line}");
257                }
258                eprintln!();
259                Some(doc)
260            }
261            None => {
262                eprintln!("Editor cancelled. No changes will be published.");
263                return Ok(());
264            }
265        };
266
267        let mut body = prompt_webvh_params(edited, Some(&pre_rotation_status))?;
268        // Stamp the precondition so the VTA refuses the save if the
269        // DID was updated by another operator while we were editing.
270        body.expected_version_id = fetched_version_id;
271        body
272    };
273    // Suppress the unused_mut warning when the flag-driven branch
274    // doesn't mutate `body` after construction.
275    let _ = &mut body;
276
277    confirm_publish(&body, no_confirm)?;
278
279    let result = client.update_did_webvh(&context_id, &scid, body).await?;
280    println!("WebVH DID updated.");
281    println!("  DID:             {}", result.did);
282    println!("  New version ID:  {}", result.new_version_id);
283    println!("  New SCID:        {}", result.new_scid);
284    println!("  Update keys:     {}", result.update_keys_count);
285    println!("  Pre-rotation:    {}", result.pre_rotation_key_count);
286    crate::commands::services::print_serverless_hint(result.serverless, &result.did);
287    Ok(())
288}
289
290// ── DID commands ────────────────────────────────────────────────────
291
292pub async fn cmd_webvh_did_create(
293    client: &VtaClient,
294    req: CreateDidWebvhRequest,
295) -> Result<(), Box<dyn std::error::Error>> {
296    let result = client.create_did_webvh(req).await?;
297    println!("WebVH DID created:");
298    println!("  DID:              {}", result.did);
299    println!("  Context:          {}", result.context_id);
300    if let Some(ref server_id) = result.server_id {
301        println!("  Server:           {}", server_id);
302    }
303    if let Some(ref mnemonic) = result.mnemonic {
304        println!("  Mnemonic:         {}", mnemonic);
305    }
306    println!("  SCID:             {}", result.scid);
307    println!("  Portable:         {}", result.portable);
308    println!("  Signing key:      {}", result.signing_key_id);
309    println!("  KA key:           {}", result.ka_key_id);
310    println!("  Pre-rotation keys: {}", result.pre_rotation_key_count);
311
312    if let Some(ref did_document) = result.did_document {
313        println!();
314        println!("DID Document:");
315        println!(
316            "{}",
317            serde_json::to_string_pretty(did_document)
318                .unwrap_or_else(|_| format!("{did_document}"))
319        );
320    }
321    if let Some(ref log_entry) = result.log_entry {
322        println!();
323        println!("Log Entry (did.jsonl):");
324        println!("{}", log_entry);
325        println!();
326        println!("To self-host this DID, place the log entry in a file named `did.jsonl`");
327        println!("at the URL path corresponding to your DID URL.");
328    }
329
330    Ok(())
331}
332
333/// Helper that reads optional file inputs before building a `CreateDidWebvhRequest`.
334#[allow(clippy::too_many_arguments)]
335pub async fn cmd_webvh_did_create_with_files(
336    client: &VtaClient,
337    context_id: String,
338    server_id: Option<String>,
339    url: Option<String>,
340    path: Option<String>,
341    domain: Option<String>,
342    label: Option<String>,
343    portable: bool,
344    add_mediator_service: bool,
345    services: Option<String>,
346    pre_rotation_count: u32,
347    did_document_path: Option<String>,
348    did_log_path: Option<String>,
349    no_primary: bool,
350    signing_key_id: Option<String>,
351    ka_key_id: Option<String>,
352    template: Option<String>,
353    template_context: Option<String>,
354    template_vars: Vec<(String, String)>,
355) -> Result<(), Box<dyn std::error::Error>> {
356    let did_document = match did_document_path {
357        Some(p) => {
358            let content =
359                std::fs::read_to_string(&p).map_err(|e| format!("failed to read {p}: {e}"))?;
360            Some(
361                serde_json::from_str::<serde_json::Value>(&content)
362                    .map_err(|e| format!("invalid JSON in {p}: {e}"))?,
363            )
364        }
365        None => None,
366    };
367    if template.is_some() && (did_document.is_some() || did_log_path.is_some()) {
368        return Err("--template is mutually exclusive with --did-document and --did-log".into());
369    }
370    let did_log = match did_log_path {
371        Some(p) => {
372            Some(std::fs::read_to_string(&p).map_err(|e| format!("failed to read {p}: {e}"))?)
373        }
374        None => None,
375    };
376    let additional_services = services
377        .map(|s| serde_json::from_str::<Vec<serde_json::Value>>(&s))
378        .transpose()
379        .map_err(|e| format!("invalid --services JSON: {e}"))?;
380
381    let template_vars: std::collections::HashMap<String, serde_json::Value> = template_vars
382        .into_iter()
383        .map(|(k, v)| (k, serde_json::Value::String(v)))
384        .collect();
385
386    let req = CreateDidWebvhRequest {
387        context_id,
388        server_id,
389        url,
390        path,
391        // The `--path` value travels in the legacy `path` field, which
392        // resolves to the right mode server-side (absent → auto-assign,
393        // `.well-known` → root, else explicit).
394        path_mode: None,
395        domain,
396        label,
397        portable,
398        add_mediator_service,
399        additional_services,
400        pre_rotation_count,
401        did_document,
402        did_log,
403        set_primary: !no_primary,
404        signing_key_id,
405        ka_key_id,
406        template,
407        template_context,
408        template_vars,
409    };
410    cmd_webvh_did_create(client, req).await
411}
412
413pub async fn cmd_webvh_did_list(
414    client: &VtaClient,
415    context_id: Option<&str>,
416    server_id: Option<&str>,
417) -> Result<(), Box<dyn std::error::Error>> {
418    let resp = client.list_dids_webvh(context_id, server_id).await?;
419
420    if resp.dids.is_empty() {
421        println!("No WebVH DIDs found.");
422        return Ok(());
423    }
424
425    let mut book = book_from_vta(client).await;
426    // Hosted DIDs are the only ones that *can* carry an agent name, so this
427    // is where `--resolve-agent-names` earns its round trips.
428    resolve_agent_names_into(&mut book, resp.dids.iter().map(|d| d.did.as_str())).await;
429
430    if is_full_display() {
431        print_full_list_title("WebVH DIDs", resp.dids.len());
432        for d in &resp.dids {
433            let created = d
434                .created_at
435                .with_timezone(&chrono::Local)
436                .format("%Y-%m-%d %H:%M:%S %:z")
437                .to_string();
438            let portable = if d.portable { "yes" } else { "no" };
439            // A `WebvhDidRecord` carries no name of its own, so anything we
440            // show comes from the ACL / context listings fetched above.
441            let mut fields = full_display_pairs(&book, &d.did);
442            fields.push(("Context", d.context_id.clone()));
443            fields.push(("Server", d.server_id.clone()));
444            fields.push(("SCID", d.scid.clone()));
445            fields.push(("Portable", portable.to_string()));
446            fields.push(("Created", created));
447            print_full_entry_owned(&fields);
448        }
449        return Ok(());
450    }
451
452    let header_style = Style::default()
453        .fg(Color::White)
454        .add_modifier(Modifier::BOLD);
455    let show_names = book.names_any(resp.dids.iter().map(|d| d.did.as_str()));
456    let mut header_cells = vec!["DID", "Context", "Server", "Portable", "Created"];
457    if show_names {
458        header_cells.insert(0, NAME_HEADER);
459    }
460    let header = Row::new(header_cells).style(header_style).bottom_margin(1);
461
462    let rows: Vec<Row> = resp
463        .dids
464        .iter()
465        .map(|d| {
466            let portable = if d.portable { "yes" } else { "no" };
467            let created = d
468                .created_at
469                .with_timezone(&chrono::Local)
470                .format("%Y-%m-%d %H:%M")
471                .to_string();
472
473            let mut cells = vec![
474                did_cell(&d.did),
475                Cell::from(d.context_id.clone()),
476                Cell::from(d.server_id.clone()),
477                Cell::from(portable.to_string()),
478                Cell::from(created).style(Style::default().fg(Color::DarkGray)),
479            ];
480            if show_names {
481                cells.insert(0, name_cell(&book, &d.did));
482            }
483            Row::new(cells)
484        })
485        .collect();
486
487    let title = format!(" WebVH DIDs ({}) ", resp.dids.len());
488
489    let table = Table::new(
490        rows,
491        [
492            Constraint::Min(40),    // DID
493            Constraint::Length(16), // Context
494            Constraint::Length(16), // Server
495            Constraint::Length(10), // Portable
496            Constraint::Length(18), // Created
497        ],
498    )
499    .header(header)
500    .column_spacing(2)
501    .block(
502        Block::bordered()
503            .title(title)
504            .border_style(Style::default().fg(Color::DarkGray)),
505    );
506
507    let height = resp.dids.len() as u16 + 4;
508    print_widget(table, height);
509
510    Ok(())
511}
512
513pub async fn cmd_webvh_did_get(
514    client: &VtaClient,
515    did: &str,
516) -> Result<(), Box<dyn std::error::Error>> {
517    let record = client.get_did_webvh(did).await?;
518    println!("WebVH DID:");
519    println!("  DID:             {}", record.did);
520    println!("  Context:         {}", record.context_id);
521    println!("  Server:          {}", record.server_id);
522    println!("  Mnemonic:        {}", record.mnemonic);
523    println!("  SCID:            {}", record.scid);
524    println!("  Portable:        {}", record.portable);
525    println!("  Log entries:     {}", record.log_entry_count);
526    println!(
527        "  Created:         {}",
528        crate::duration::format_local_datetime(record.created_at)
529    );
530    println!(
531        "  Updated:         {}",
532        crate::duration::format_local_datetime(record.updated_at)
533    );
534    Ok(())
535}
536
537pub async fn cmd_webvh_did_delete(
538    client: &VtaClient,
539    did: &str,
540) -> Result<(), Box<dyn std::error::Error>> {
541    client.delete_did_webvh(did).await?;
542    println!("WebVH DID deleted: {did}");
543    Ok(())
544}
545
546/// `pnm did-mgmt dids get-log <did> [--out <path>]` — fetch the raw
547/// `did.jsonl` log for a DID the VTA knows.
548///
549/// Goes through the authenticated, transport-agnostic client method so
550/// it works on both REST and DIDComm sessions. (The VTA also mirrors
551/// the log unauthenticated at `GET /did/{did}/log` for resolvers, but a
552/// DIDComm client has no HTTP base URL to build that request from.)
553pub async fn cmd_webvh_did_log(
554    client: &VtaClient,
555    did: &str,
556    out: Option<std::path::PathBuf>,
557) -> Result<(), Box<dyn std::error::Error>> {
558    let resp = client.get_did_webvh_log(did).await?;
559    let log = resp
560        .log
561        .ok_or_else(|| format!("no did.jsonl log stored for {did}"))?;
562
563    match out {
564        Some(path) => {
565            std::fs::write(&path, log.as_bytes())
566                .map_err(|e| format!("write {}: {e}", path.display()))?;
567            eprintln!(
568                "DID log written to {} ({} bytes)",
569                path.display(),
570                log.len()
571            );
572        }
573        None => {
574            // Raw to stdout — pipe to a file, to `curl --data-binary`,
575            // or to `.well-known/did.jsonl` directly.
576            print!("{log}");
577        }
578    }
579    Ok(())
580}