Skip to main content

vta_cli_common/commands/
services.rs

1//! `pnm services …` command implementations — unified CLI surface.
2//!
3//! Spec: `docs/05-design-notes/runtime-service-management.md` §5.1.
4//!
5//! Twelve commands across two transport kinds plus a top-level
6//! list/report. Each function calls the matching `vta-sdk` client
7//! method; the typed `VtaError` variants are surfaced via the
8//! existing CLI error renderer (`render::print_cli_error`) which
9//! attaches operator-actionable suggested-fix strings per
10//! CLAUDE.md.
11//!
12//! The retired `pnm mediator …` subcommand surface is replaced by
13//! `pnm services didcomm {update,rollback,drain {list,cancel}}` —
14//! see the migration cue in pnm-cli/cnm-cli for the
15//! retired-command UX.
16
17use vta_sdk::client::VtaClient;
18use vta_sdk::error::VtaError;
19use vta_sdk::protocol::services::{
20    DisableRestRequest, DisableTspRequest, EnableRestRequest, EnableTspRequest,
21    RollbackDidcommRequest, RollbackRestRequest, RollbackTspRequest, UpdateRestRequest,
22    UpdateTspRequest,
23};
24use vta_sdk::protocol::{DisableDidcommRequest, EnableDidcommRequest, UpdateDidcommRequest};
25
26use crate::display::{NAME_HEADER, NameBook, UNNAMED, book_from_acl, inline, shorten_did};
27
28const DIDCOMM_RESTART_WAIT_SECS: u64 = 300;
29
30// ── services list ──────────────────────────────────────────────────
31
32/// `pnm services list` — show current REST + DIDComm advertisements.
33pub async fn cmd_services_list(client: &VtaClient) -> Result<(), Box<dyn std::error::Error>> {
34    let response = client.list_services().await?;
35
36    if crate::render::is_json_output() {
37        crate::render::print_json(&response)?;
38        return Ok(());
39    }
40
41    println!("Services advertised on this VTA's DID document:");
42    println!();
43    for state in &response.services {
44        match state {
45            vta_sdk::protocol::services::ServiceState::Tsp {
46                enabled,
47                mediator_did,
48            } => {
49                let on = if *enabled { "on" } else { "off" };
50                println!("  TSP:      {on}");
51                if let Some(m) = mediator_did {
52                    println!("    Mediator:     {m}");
53                }
54            }
55            vta_sdk::protocol::services::ServiceState::Didcomm {
56                enabled,
57                mediator_did,
58                routing_keys,
59            } => {
60                let on = if *enabled { "on" } else { "off" };
61                println!("  DIDComm:  {on}");
62                if let Some(m) = mediator_did {
63                    println!("    Mediator:     {m}");
64                }
65                if !routing_keys.is_empty() {
66                    println!("    Routing keys: {}", routing_keys.join(", "));
67                }
68            }
69            vta_sdk::protocol::services::ServiceState::Rest { enabled, url } => {
70                let on = if *enabled { "on" } else { "off" };
71                println!("  REST:     {on}");
72                if let Some(u) = url {
73                    println!("    URL:          {u}");
74                }
75            }
76            vta_sdk::protocol::services::ServiceState::Webauthn { enabled, url } => {
77                let on = if *enabled { "on" } else { "off" };
78                println!("  WebAuthn: {on}");
79                if let Some(u) = url {
80                    println!("    URL:          {u}");
81                }
82            }
83        }
84    }
85    Ok(())
86}
87
88// ── services rest {enable, update, disable, rollback} ─────────────
89
90pub async fn cmd_services_rest_enable(
91    client: &VtaClient,
92    url: String,
93) -> Result<(), Box<dyn std::error::Error>> {
94    let req = EnableRestRequest::new(url);
95    let resp = client.enable_rest(req).await?;
96    println!("REST enabled.");
97    println!("  New version ID: {}", resp.log_entry_version_id);
98    println!("  Effective at:   {}", resp.effective_at);
99    print_serverless_hint(resp.serverless, &resp.vta_did);
100    Ok(())
101}
102
103pub async fn cmd_services_rest_update(
104    client: &VtaClient,
105    url: String,
106) -> Result<(), Box<dyn std::error::Error>> {
107    let req = UpdateRestRequest::new(url);
108    let resp = client.update_rest(req).await?;
109    println!("REST URL updated.");
110    println!("  New version ID: {}", resp.log_entry_version_id);
111    println!("  Effective at:   {}", resp.effective_at);
112    print_serverless_hint(resp.serverless, &resp.vta_did);
113    Ok(())
114}
115
116pub async fn cmd_services_rest_disable(
117    client: &VtaClient,
118) -> Result<(), Box<dyn std::error::Error>> {
119    let resp = client.disable_rest(DisableRestRequest::default()).await?;
120    println!("REST disabled.");
121    println!("  New version ID: {}", resp.log_entry_version_id);
122    println!("  Effective at:   {}", resp.effective_at);
123    print_serverless_hint(resp.serverless, &resp.vta_did);
124    Ok(())
125}
126
127pub async fn cmd_services_rest_rollback(
128    client: &VtaClient,
129) -> Result<(), Box<dyn std::error::Error>> {
130    let resp = client.rollback_rest(RollbackRestRequest::default()).await?;
131    print_rollback_result("REST", &resp);
132    Ok(())
133}
134
135// ── services tsp {enable, update, disable, rollback} ──────────────
136//
137// TSP advertises a **mediator DID** (the VTA's TSP VID), not a URL —
138// so these mirror the REST commands with `mediator_did` in place of
139// `url`. Responses reuse the same `ServiceMutationResponse` /
140// `RollbackResponse` shapes, so the rendering is identical.
141
142pub async fn cmd_services_tsp_enable(
143    client: &VtaClient,
144    mediator_did: String,
145) -> Result<(), Box<dyn std::error::Error>> {
146    let req = EnableTspRequest::new(mediator_did);
147    let resp = client.enable_tsp(req).await?;
148    println!("TSP enabled.");
149    println!("  New version ID: {}", resp.log_entry_version_id);
150    println!("  Effective at:   {}", resp.effective_at);
151    print_serverless_hint(resp.serverless, &resp.vta_did);
152    Ok(())
153}
154
155pub async fn cmd_services_tsp_update(
156    client: &VtaClient,
157    mediator_did: String,
158) -> Result<(), Box<dyn std::error::Error>> {
159    let req = UpdateTspRequest::new(mediator_did);
160    let resp = client.update_tsp(req).await?;
161    println!("TSP mediator DID updated.");
162    println!("  New version ID: {}", resp.log_entry_version_id);
163    println!("  Effective at:   {}", resp.effective_at);
164    print_serverless_hint(resp.serverless, &resp.vta_did);
165    Ok(())
166}
167
168pub async fn cmd_services_tsp_disable(
169    client: &VtaClient,
170) -> Result<(), Box<dyn std::error::Error>> {
171    let resp = client.disable_tsp(DisableTspRequest::default()).await?;
172    println!("TSP disabled.");
173    println!("  New version ID: {}", resp.log_entry_version_id);
174    println!("  Effective at:   {}", resp.effective_at);
175    print_serverless_hint(resp.serverless, &resp.vta_did);
176    Ok(())
177}
178
179pub async fn cmd_services_tsp_rollback(
180    client: &VtaClient,
181) -> Result<(), Box<dyn std::error::Error>> {
182    let resp = client.rollback_tsp(RollbackTspRequest::default()).await?;
183    print_rollback_result("TSP", &resp);
184    Ok(())
185}
186
187// ── services webauthn {enable, update, disable, rollback} ─────────
188
189pub async fn cmd_services_webauthn_enable(
190    client: &VtaClient,
191    url: String,
192) -> Result<(), Box<dyn std::error::Error>> {
193    let req = vta_sdk::protocol::services::EnableWebauthnRequest::new(url);
194    let resp = client.enable_webauthn(req).await?;
195    println!("WebAuthn enabled.");
196    println!("  New version ID: {}", resp.log_entry_version_id);
197    println!("  Effective at:   {}", resp.effective_at);
198    print_serverless_hint(resp.serverless, &resp.vta_did);
199    Ok(())
200}
201
202pub async fn cmd_services_webauthn_update(
203    client: &VtaClient,
204    url: String,
205) -> Result<(), Box<dyn std::error::Error>> {
206    let req = vta_sdk::protocol::services::UpdateWebauthnRequest::new(url);
207    let resp = client.update_webauthn(req).await?;
208    println!("WebAuthn URL updated.");
209    println!("  New version ID: {}", resp.log_entry_version_id);
210    println!("  Effective at:   {}", resp.effective_at);
211    print_serverless_hint(resp.serverless, &resp.vta_did);
212    Ok(())
213}
214
215pub async fn cmd_services_webauthn_disable(
216    client: &VtaClient,
217) -> Result<(), Box<dyn std::error::Error>> {
218    eprintln!(
219        "WARNING: disabling WebAuthn will also REMOVE passkey verificationMethods from every DID \
220         this VTA controls. Any operator currently using passkey login will need to re-enrol \
221         after the next `services webauthn enable`."
222    );
223    let resp = client
224        .disable_webauthn(vta_sdk::protocol::services::DisableWebauthnRequest::default())
225        .await?;
226    println!("WebAuthn disabled.");
227    println!("  New version ID: {}", resp.log_entry_version_id);
228    println!("  Effective at:   {}", resp.effective_at);
229    print_serverless_hint(resp.serverless, &resp.vta_did);
230    Ok(())
231}
232
233pub async fn cmd_services_webauthn_rollback(
234    client: &VtaClient,
235) -> Result<(), Box<dyn std::error::Error>> {
236    let resp = client
237        .rollback_webauthn(vta_sdk::protocol::services::RollbackWebauthnRequest::default())
238        .await?;
239    print_rollback_result("WebAuthn", &resp);
240    Ok(())
241}
242
243// ── services didcomm {enable, update, disable, rollback} ──────────
244
245pub async fn cmd_services_didcomm_enable(
246    client: &VtaClient,
247    mediator_did: String,
248    setup_acl: bool,
249    force: bool,
250    handshake_timeout_secs: Option<u64>,
251    wait_secs: Option<u64>,
252    no_wait: bool,
253) -> Result<(), Box<dyn std::error::Error>> {
254    let mut req = EnableDidcommRequest::new(&mediator_did);
255    req.setup_acl = setup_acl;
256    req.force = force;
257    req.handshake_timeout_secs = handshake_timeout_secs;
258    let resp = match client.enable_didcomm(req).await {
259        Ok(resp) => resp,
260        // The task answers an already-enabled DIDComm as a conflict whose
261        // message names the command to run instead; enabling twice is not a
262        // failure worth an error, so say so and stop.
263        Err(VtaError::Conflict(body)) if body.contains("DIDComm is already enabled") => {
264            println!("DIDComm already enabled.");
265            println!("  To change the mediator: pnm services didcomm update --mediator-did <did>");
266            return Ok(());
267        }
268        Err(e) => return Err(e.into()),
269    };
270    println!("DIDComm enabled.");
271    println!("  Mediator DID:   {}", resp.mediator_did);
272    if !resp.mediator_endpoint.is_empty() {
273        println!("  Mediator URL:   {}", resp.mediator_endpoint);
274    }
275    println!("  New version ID: {}", resp.new_version_id);
276    if force {
277        println!();
278        println!("  Note: --force was set; mediator handshake steps 2-5 were bypassed.");
279    }
280    print_serverless_hint(resp.serverless, &resp.vta_did);
281    if resp.restarting {
282        if no_wait {
283            println!("  The VTA is restarting; readiness polling was disabled.");
284        } else {
285            wait_for_didcomm_restart(
286                client,
287                &mediator_did,
288                wait_secs.unwrap_or(DIDCOMM_RESTART_WAIT_SECS),
289            )
290            .await?;
291        }
292    }
293    Ok(())
294}
295
296async fn wait_for_didcomm_restart(
297    client: &VtaClient,
298    mediator_did: &str,
299    wait_secs: u64,
300) -> Result<(), Box<dyn std::error::Error>> {
301    use std::time::Duration;
302
303    // One session for the whole wait, as `pnm health` does. It opens this
304    // client's own mediator account once — `--setup-acl` opens the VTA's, and
305    // the pong comes back forwarded — and keeps the ~4s TDK/ATM/websocket setup
306    // out of every probe window. Opened inside a probe rather than before the
307    // poll: the enable has already succeeded, so a session that will not open
308    // (the mediator busy while the VTA reconnects) is one more thing to wait
309    // out, not a reason to report the enable as failed.
310    let session = std::sync::Arc::new(tokio::sync::Mutex::new(None));
311    let probe_session = std::sync::Arc::clone(&session);
312
313    let result = wait_for_didcomm_restart_with_probe(
314        move |probe_window| {
315            let session = std::sync::Arc::clone(&probe_session);
316            async move {
317                let probe = async {
318                    let mut session = session.lock().await;
319                    if session.is_none() {
320                        *session = Some(client.open_vta_ping_session(mediator_did, "pnm").await?);
321                    }
322                    let (session, vta_did) = session.as_ref().expect("opened above");
323                    session.ping(Some(vta_did)).await
324                };
325                tokio::time::timeout(probe_window, probe)
326                    .await
327                    .unwrap_or_else(|_| Err("trust-ping timed out".into()))
328            }
329        },
330        Duration::from_secs(wait_secs),
331        Duration::from_secs(10),
332        Duration::from_secs(2),
333    )
334    .await;
335
336    if let Some((session, _)) = session.lock().await.take() {
337        session.shutdown().await;
338    }
339    result
340}
341
342async fn wait_for_didcomm_restart_with_probe<Probe, ProbeFuture>(
343    mut probe: Probe,
344    timeout: std::time::Duration,
345    probe_window: std::time::Duration,
346    retry_delay: std::time::Duration,
347) -> Result<(), Box<dyn std::error::Error>>
348where
349    Probe: FnMut(std::time::Duration) -> ProbeFuture,
350    ProbeFuture: std::future::Future<Output = Result<u128, Box<dyn std::error::Error>>>,
351{
352    use std::time::Instant;
353
354    let deadline = Instant::now() + timeout;
355    println!(
356        "  Waiting for the restarted DIDComm listener (up to {}s)...",
357        timeout.as_secs()
358    );
359
360    loop {
361        let remaining = deadline.saturating_duration_since(Instant::now());
362        if remaining.is_zero() {
363            break;
364        }
365        if let Ok(latency) = probe(probe_window.min(remaining)).await {
366            println!("  Trust-ping:     pong ({latency}ms)");
367            return Ok(());
368        }
369
370        let remaining = deadline.saturating_duration_since(Instant::now());
371        if remaining.is_zero() {
372            break;
373        }
374        tokio::time::sleep(retry_delay.min(remaining)).await;
375    }
376
377    Err(format!(
378        "DIDComm is enabled, but its listener did not answer within {}s; the VTA's mediator self-DID readiness gate may still be waiting for the VTA DID to resolve, or the mediator may be dropping the pong because this client's own account refuses forwarded delivery",
379        timeout.as_secs()
380    )
381    .into())
382}
383
384pub async fn cmd_services_didcomm_update(
385    client: &VtaClient,
386    new_mediator_did: String,
387    drain_ttl_secs: u64,
388    setup_acl: bool,
389    force: bool,
390    handshake_timeout_secs: Option<u64>,
391) -> Result<(), Box<dyn std::error::Error>> {
392    let mut req = UpdateDidcommRequest::new(&new_mediator_did, drain_ttl_secs);
393    req.setup_acl = setup_acl;
394    req.force = force;
395    req.handshake_timeout_secs = handshake_timeout_secs;
396    let resp = client.update_didcomm(req).await?;
397    println!("DIDComm mediator updated.");
398    println!("  Prior mediator:  {}", resp.prior_mediator_did);
399    println!("  Active mediator: {}", resp.active_mediator_did);
400    if !resp.active_mediator_endpoint.is_empty() {
401        println!("  Active endpoint: {}", resp.active_mediator_endpoint);
402    }
403    println!("  New version ID:  {}", resp.new_version_id);
404    println!(
405        "  Drain deadline:  {} (prior listener stays up until then)",
406        resp.drains_until
407    );
408    print_serverless_hint(resp.serverless, &resp.vta_did);
409    Ok(())
410}
411
412pub async fn cmd_services_didcomm_disable(
413    client: &VtaClient,
414    drain_ttl_secs: u64,
415) -> Result<(), Box<dyn std::error::Error>> {
416    let req = DisableDidcommRequest::new(drain_ttl_secs);
417    let resp = client.disable_didcomm(req).await?;
418    println!("DIDComm disabled.");
419    println!("  Prior mediator: {}", resp.prior_mediator_did);
420    println!("  New version ID: {}", resp.new_version_id);
421    match resp.drains_until {
422        Some(deadline) => {
423            println!("  Drain deadline: {deadline}");
424            println!();
425            println!("  The listener stays up until the deadline so in-flight messages can drain.");
426            println!(
427                "  Cancel early with `pnm services didcomm drain cancel --mediator-did <did>`."
428            );
429        }
430        None => println!("  Listener torn down immediately (drain TTL was 0)."),
431    }
432    print_serverless_hint(resp.serverless, &resp.vta_did);
433    Ok(())
434}
435
436pub async fn cmd_services_didcomm_rollback(
437    client: &VtaClient,
438    drain_ttl_secs: Option<u64>,
439) -> Result<(), Box<dyn std::error::Error>> {
440    let req = RollbackDidcommRequest { drain_ttl_secs };
441    let resp = client.rollback_didcomm(req).await?;
442    print_rollback_result("DIDComm", &resp);
443    Ok(())
444}
445
446/// Best-effort names for mediator / peer DIDs.
447///
448/// Neither the drain set nor the telemetry report carries a label, so the only
449/// local source is the ACL — which does cover mediators an operator has
450/// granted an entry to. Peer *senders* are not in our ACL and stay bare until
451/// agent names exist; they are the clearest case for that feature.
452///
453/// Failure is ignored on purpose. Naming is decoration, and an operator who
454/// can read the drain set but not the ACL must still get their table.
455async fn mediator_name_book(client: &VtaClient) -> NameBook {
456    let mut book = NameBook::new();
457    if let Ok(acl) = client.list_acl(None).await {
458        book_from_acl(&mut book, &acl.entries);
459    }
460    book
461}
462
463// ── services didcomm drain {list, cancel} ─────────────────────────
464
465pub async fn cmd_services_didcomm_drain_list(
466    client: &VtaClient,
467) -> Result<(), Box<dyn std::error::Error>> {
468    let resp = client.list_drain().await?;
469    if resp.entries.is_empty() {
470        println!("No mediators currently in drain.");
471        return Ok(());
472    }
473    println!("Drain set ({} mediator(s)):", resp.entries.len());
474    println!();
475
476    let book = mediator_name_book(client).await;
477    let show_names = book.names_any(resp.entries.iter().map(|e| e.mediator_did.as_str()));
478
479    let header_did = "MEDIATOR DID";
480    let header_until = "DRAIN UNTIL";
481    if show_names {
482        println!("  {:<24}  {header_did:<46}  {header_until}", NAME_HEADER);
483    } else {
484        println!("  {header_did:<46}  {header_until}");
485    }
486    for e in &resp.entries {
487        let did = shorten_did(&e.mediator_did);
488        if show_names {
489            let name = book
490                .name_of(&e.mediator_did)
491                .unwrap_or_else(|| UNNAMED.into());
492            println!("  {:<24}  {:<46}  {}", name, did, e.drains_until);
493        } else {
494            println!("  {:<46}  {}", did, e.drains_until);
495        }
496    }
497    Ok(())
498}
499
500pub async fn cmd_services_didcomm_drain_cancel(
501    client: &VtaClient,
502    mediator_did: String,
503) -> Result<(), Box<dyn std::error::Error>> {
504    let req = vta_sdk::protocol::DrainCancelRequest { mediator_did };
505    let resp = client.drain_cancel(req).await?;
506    println!("Drain cancelled for {}.", resp.mediator_did);
507    println!("  Listener was torn down immediately.");
508    Ok(())
509}
510
511// ── services report ───────────────────────────────────────────────
512
513pub async fn cmd_services_report(
514    client: &VtaClient,
515    since: Option<String>,
516    until: Option<String>,
517    format: ReportFormat,
518) -> Result<(), Box<dyn std::error::Error>> {
519    let report = client
520        .mediator_report(since.as_deref(), until.as_deref())
521        .await?;
522
523    match format {
524        ReportFormat::Json => {
525            println!("{}", serde_json::to_string_pretty(&report)?);
526        }
527        ReportFormat::Table => {
528            println!("Service-management report");
529            if let Some(ref s) = report.since {
530                println!("  Window: {s} → {}", report.until);
531            } else {
532                println!("  Window: (all time) → {}", report.until);
533            }
534            println!();
535            // Peer sender DIDs have no label anywhere in our store, so most
536            // will stay bare until agent names land — which is exactly the
537            // surface they are for.
538            let book = mediator_name_book(client).await;
539
540            if report.mediators.is_empty() {
541                println!("  No inbound DIDComm messages recorded.");
542            } else {
543                println!("  Per-mediator inbound counts (most recent first):");
544                let show_names =
545                    book.names_any(report.mediators.iter().map(|m| m.mediator_did.as_str()));
546                let header_did = "MEDIATOR DID";
547                let header_count = "INBOUND";
548                if show_names {
549                    println!(
550                        "    {:<24}  {header_did:<46}  {header_count:>10}  LAST SEEN",
551                        NAME_HEADER
552                    );
553                } else {
554                    println!("    {header_did:<46}  {header_count:>10}  LAST SEEN");
555                }
556                for m in &report.mediators {
557                    let did = shorten_did(&m.mediator_did);
558                    if show_names {
559                        let name = book
560                            .name_of(&m.mediator_did)
561                            .unwrap_or_else(|| UNNAMED.into());
562                        println!(
563                            "    {:<24}  {:<46}  {:>10}  {}",
564                            name, did, m.inbound_count, m.last_seen
565                        );
566                    } else {
567                        println!("    {:<46}  {:>10}  {}", did, m.inbound_count, m.last_seen);
568                    }
569                }
570            }
571            if !report.senders.is_empty() {
572                println!();
573                println!("  Senders by last-seen mediator:");
574                for s in &report.senders {
575                    // Prose, not a fixed-width column, so both sides carry
576                    // their DID alongside the name.
577                    println!(
578                        "    {} → {} (at {})",
579                        inline(&book, &s.sender_did),
580                        inline(&book, &s.last_seen_mediator),
581                        s.last_seen_at
582                    );
583                }
584            }
585        }
586    }
587    Ok(())
588}
589
590// ── shared helpers ────────────────────────────────────────────────
591
592fn print_rollback_result(kind: &str, resp: &vta_sdk::protocol::services::RollbackResponse) {
593    if resp.kind == "no_op" {
594        println!("{kind} rollback: no change required.");
595        println!("  Snapshot matches current state — nothing to do.");
596        return;
597    }
598    println!("{kind} rolled back.");
599    println!("  Action:         {}", resp.kind);
600    if !resp.log_entry_version_id.is_empty() {
601        println!("  New version ID: {}", resp.log_entry_version_id);
602    }
603    println!("  Effective at:   {}", resp.effective_at);
604    if let Some(ref drain_until) = resp.drain_until {
605        println!("  Drain deadline: {drain_until}");
606    }
607    if let Some(ref draining) = resp.draining_mediator {
608        println!("  Draining:       {draining}");
609    }
610    print_serverless_hint(resp.serverless, &resp.vta_did);
611}
612
613/// Tell the operator where the new log entry went, after a mutation of the
614/// VTA's **own** DID on a running VTA.
615///
616/// Silent when `serverless` is false (the VTA published to a did-hosting
617/// server as part of the call) and when `vta_did` is empty (no LogEntry was
618/// written, e.g. a no-op rollback).
619///
620/// A self-hosted ("serverless") VTA serves its own `did.jsonl` from its store
621/// at the DID's canonical path, read per request — so the entry just written is
622/// already being served, and there is nothing to redeploy. Advising a redeploy
623/// here sent operators to copy a log to the host that was already serving it
624/// (Keyring VTI-36). The one case that still needs a copy is a log the operator
625/// *also* publishes somewhere else, which is said last.
626pub fn print_serverless_hint(serverless: bool, vta_did: &str) {
627    if !serverless || vta_did.is_empty() {
628        return;
629    }
630    print_self_hosted_notice(vta_did, "now serves");
631}
632
633/// [`print_serverless_hint`] for the offline `vta services …` surface, which
634/// runs with the daemon stopped: the entry is served once the VTA starts.
635pub fn print_serverless_hint_offline(serverless: bool, vta_did: &str) {
636    if !serverless || vta_did.is_empty() {
637        return;
638    }
639    print_self_hosted_notice(vta_did, "will serve, once it is running again,");
640}
641
642fn print_self_hosted_notice(vta_did: &str, serves: &str) {
643    println!();
644    match webvh_log_url(vta_did) {
645        Some(url) => {
646            println!("  This VTA hosts its own DID, and {serves} the updated log at");
647            println!("    {url}");
648        }
649        None => println!("  This VTA hosts its own DID, and {serves} the updated log itself."),
650    }
651    println!("  Nothing to redeploy. Resolvers pick up the new version as their cache");
652    println!("  expires (60 s for this VTA's own responses, up to 5 min in a caching resolver).");
653    println!("  Only a copy you also publish elsewhere needs replacing:");
654    println!("    pnm did-mgmt dids get-log {vta_did} --out did.jsonl");
655}
656
657/// After a mutation of a DID this VTA does **not** serve itself — a
658/// self-hosted DID it manages for someone else, such as a community's. The
659/// new entry exists only in the VTA's store until it is delivered to wherever
660/// that DID's log is served.
661pub fn print_redeploy_hint(serverless: bool, did: &str) {
662    if !serverless || did.is_empty() {
663        return;
664    }
665    println!();
666    println!("  This DID is self-hosted, and not by this VTA. Fetch the updated log:");
667    println!("    pnm did-mgmt dids get-log {did} --out did.jsonl");
668    match webvh_log_url(did) {
669        Some(url) => println!("  then install it where {url} is served from."),
670        None => println!("  then install it where the DID's log is served from."),
671    }
672    println!("  For a community that self-hosts its DID, that is:");
673    println!("    cnm did-log install --file did.jsonl");
674    println!("  Until you do, resolvers will keep returning the prior version.");
675}
676
677/// The HTTPS URL a `did:webvh` DID's log is resolved from (did:webvh v1.0,
678/// DID-to-HTTPS transformation): `did:webvh:<scid>:<host>[:<path>…]` becomes
679/// `https://<host>/<path…>/did.jsonl`, or `/.well-known/did.jsonl` with no
680/// path. `%3A` in the host is a port separator. A `did:scid:vh:1` DID URL is
681/// the log its `src` names (VTI-KEY-160). `None` for anything else.
682fn webvh_log_url(did: &str) -> Option<String> {
683    let log_did = vta_sdk::did_scid::webvh_did(did);
684    let did = log_did.as_deref().unwrap_or(did);
685    let rest = did.strip_prefix("did:webvh:")?;
686    let mut parts = rest.split(':');
687    let _scid = parts.next().filter(|s| !s.is_empty())?;
688    let host = parts.next().filter(|s| !s.is_empty())?;
689    let host = host.replace("%3A", ":").replace("%3a", ":");
690    let path: Vec<&str> = parts.collect();
691    if path.iter().any(|p| p.is_empty()) {
692        return None;
693    }
694    Some(if path.is_empty() {
695        format!("https://{host}/.well-known/did.jsonl")
696    } else {
697        format!("https://{host}/{}/did.jsonl", path.join("/"))
698    })
699}
700
701#[derive(Debug, Clone, Copy)]
702pub enum ReportFormat {
703    Json,
704    Table,
705}
706
707impl std::str::FromStr for ReportFormat {
708    type Err = String;
709    fn from_str(s: &str) -> Result<Self, Self::Err> {
710        match s {
711            "json" => Ok(Self::Json),
712            "table" => Ok(Self::Table),
713            other => Err(format!("unknown format `{other}` — use `json` or `table`")),
714        }
715    }
716}
717
718#[cfg(test)]
719mod tests {
720    /// Keyring VTI-36: the hint names the URL the log is resolved from.
721    #[test]
722    fn webvh_log_url_follows_the_did_to_https_transform() {
723        use super::webvh_log_url;
724        assert_eq!(
725            webvh_log_url("did:webvh:QmScid:vta.example.com").as_deref(),
726            Some("https://vta.example.com/.well-known/did.jsonl")
727        );
728        assert_eq!(
729            webvh_log_url("did:webvh:QmScid:example.com%3A8100:dids:vta").as_deref(),
730            Some("https://example.com:8100/dids/vta/did.jsonl")
731        );
732        assert_eq!(webvh_log_url("did:key:z6Mk"), None);
733        assert_eq!(webvh_log_url("did:webvh:QmScid"), None);
734    }
735
736    #[tokio::test]
737    async fn readiness_poll_retries_until_a_pong_arrives() {
738        use std::sync::{Arc, Mutex};
739        use std::time::Duration;
740
741        let attempts = Arc::new(Mutex::new(0u32));
742        let attempts_for_probe = Arc::clone(&attempts);
743        let result = super::wait_for_didcomm_restart_with_probe(
744            move |_| {
745                let attempt = {
746                    let mut attempts = attempts_for_probe.lock().unwrap();
747                    *attempts += 1;
748                    *attempts
749                };
750                async move {
751                    if attempt < 3 {
752                        Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "not ready").into())
753                    } else {
754                        Ok(12)
755                    }
756                }
757            },
758            Duration::from_millis(100),
759            Duration::from_millis(1),
760            Duration::from_millis(1),
761        )
762        .await;
763
764        assert!(result.is_ok());
765        assert_eq!(*attempts.lock().unwrap(), 3);
766    }
767
768    #[tokio::test]
769    async fn readiness_poll_names_the_gate_when_deadline_expires() {
770        use std::time::Duration;
771
772        let result = super::wait_for_didcomm_restart_with_probe(
773            |_| async {
774                Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "not ready").into())
775            },
776            Duration::from_millis(5),
777            Duration::from_millis(1),
778            Duration::from_millis(1),
779        )
780        .await;
781
782        let error = result.expect_err("poll should time out").to_string();
783        assert!(error.contains("self-DID readiness gate"), "{error}");
784        // The other cause, and the one an operator cannot guess from the symptom.
785        assert!(error.contains("forwarded delivery"), "{error}");
786    }
787
788    /// A pong ends the wait. The retry delay is long enough that sleeping
789    /// before returning would stall this test rather than let it pass slowly.
790    #[tokio::test]
791    async fn readiness_poll_stops_after_the_first_pong() {
792        use std::sync::{Arc, Mutex};
793        use std::time::{Duration, Instant};
794
795        let attempts = Arc::new(Mutex::new(0u32));
796        let counter = Arc::clone(&attempts);
797        let started = Instant::now();
798        let result = super::wait_for_didcomm_restart_with_probe(
799            move |_| {
800                *counter.lock().unwrap() += 1;
801                async { Ok::<u128, Box<dyn std::error::Error>>(7) }
802            },
803            Duration::from_secs(300),
804            Duration::from_secs(10),
805            Duration::from_secs(10),
806        )
807        .await;
808
809        assert!(result.is_ok());
810        assert_eq!(*attempts.lock().unwrap(), 1);
811        assert!(
812            started.elapsed() < Duration::from_secs(5),
813            "a pong must return immediately, not after the retry delay: {:?}",
814            started.elapsed()
815        );
816    }
817
818    /// `--wait-secs` is the operator's deadline, so no single probe may be
819    /// handed more time than is left of it. Unclamped, the 10s window below
820    /// would overrun a 50ms budget by two orders of magnitude.
821    #[tokio::test]
822    async fn readiness_poll_never_hands_a_probe_more_time_than_remains() {
823        use std::sync::{Arc, Mutex};
824        use std::time::Duration;
825
826        let windows = Arc::new(Mutex::new(Vec::new()));
827        let seen = Arc::clone(&windows);
828        let budget = Duration::from_millis(50);
829
830        let result = super::wait_for_didcomm_restart_with_probe(
831            move |window| {
832                seen.lock().unwrap().push(window);
833                async { Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "not ready").into()) }
834            },
835            budget,
836            Duration::from_secs(10),
837            Duration::from_millis(1),
838        )
839        .await;
840
841        assert!(result.is_err());
842        let windows = windows.lock().unwrap();
843        assert!(!windows.is_empty(), "the poll must probe at least once");
844        assert!(
845            windows.iter().all(|w| *w <= budget),
846            "a probe was given more than the {budget:?} budget: {windows:?}"
847        );
848    }
849
850    /// The gap between attempts is clamped the same way — an unclamped 10s
851    /// retry would leave the CLI sitting there long after the deadline passed.
852    #[tokio::test]
853    async fn readiness_poll_clamps_its_retry_delay_to_the_deadline() {
854        use std::time::{Duration, Instant};
855
856        let started = Instant::now();
857        let result = super::wait_for_didcomm_restart_with_probe(
858            |_| async {
859                Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "not ready").into())
860            },
861            Duration::from_millis(50),
862            Duration::from_millis(1),
863            Duration::from_secs(10),
864        )
865        .await;
866
867        assert!(result.is_err());
868        assert!(
869            started.elapsed() < Duration::from_secs(5),
870            "the poll slept past its own deadline: {:?}",
871            started.elapsed()
872        );
873    }
874}