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::{
25    DisableDidcommRequest, EnableDidcommConflictBody, EnableDidcommRequest, UpdateDidcommRequest,
26};
27
28use crate::display::{NAME_HEADER, NameBook, UNNAMED, book_from_acl, inline, shorten_did};
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    println!("Services advertised on this VTA's DID document:");
37    println!();
38    for state in &response.services {
39        match state {
40            vta_sdk::protocol::services::ServiceState::Tsp {
41                enabled,
42                mediator_did,
43            } => {
44                let on = if *enabled { "on" } else { "off" };
45                println!("  TSP:      {on}");
46                if let Some(m) = mediator_did {
47                    println!("    Mediator:     {m}");
48                }
49            }
50            vta_sdk::protocol::services::ServiceState::Didcomm {
51                enabled,
52                mediator_did,
53                routing_keys,
54            } => {
55                let on = if *enabled { "on" } else { "off" };
56                println!("  DIDComm:  {on}");
57                if let Some(m) = mediator_did {
58                    println!("    Mediator:     {m}");
59                }
60                if !routing_keys.is_empty() {
61                    println!("    Routing keys: {}", routing_keys.join(", "));
62                }
63            }
64            vta_sdk::protocol::services::ServiceState::Rest { enabled, url } => {
65                let on = if *enabled { "on" } else { "off" };
66                println!("  REST:     {on}");
67                if let Some(u) = url {
68                    println!("    URL:          {u}");
69                }
70            }
71            vta_sdk::protocol::services::ServiceState::Webauthn { enabled, url } => {
72                let on = if *enabled { "on" } else { "off" };
73                println!("  WebAuthn: {on}");
74                if let Some(u) = url {
75                    println!("    URL:          {u}");
76                }
77            }
78        }
79    }
80    Ok(())
81}
82
83// ── services rest {enable, update, disable, rollback} ─────────────
84
85pub async fn cmd_services_rest_enable(
86    client: &VtaClient,
87    url: String,
88) -> Result<(), Box<dyn std::error::Error>> {
89    let req = EnableRestRequest::new(url);
90    let resp = client.enable_rest(req).await?;
91    println!("REST enabled.");
92    println!("  New version ID: {}", resp.log_entry_version_id);
93    println!("  Effective at:   {}", resp.effective_at);
94    print_serverless_hint(resp.serverless, &resp.vta_did);
95    Ok(())
96}
97
98pub async fn cmd_services_rest_update(
99    client: &VtaClient,
100    url: String,
101) -> Result<(), Box<dyn std::error::Error>> {
102    let req = UpdateRestRequest::new(url);
103    let resp = client.update_rest(req).await?;
104    println!("REST URL updated.");
105    println!("  New version ID: {}", resp.log_entry_version_id);
106    println!("  Effective at:   {}", resp.effective_at);
107    print_serverless_hint(resp.serverless, &resp.vta_did);
108    Ok(())
109}
110
111pub async fn cmd_services_rest_disable(
112    client: &VtaClient,
113) -> Result<(), Box<dyn std::error::Error>> {
114    let resp = client.disable_rest(DisableRestRequest::default()).await?;
115    println!("REST disabled.");
116    println!("  New version ID: {}", resp.log_entry_version_id);
117    println!("  Effective at:   {}", resp.effective_at);
118    print_serverless_hint(resp.serverless, &resp.vta_did);
119    Ok(())
120}
121
122pub async fn cmd_services_rest_rollback(
123    client: &VtaClient,
124) -> Result<(), Box<dyn std::error::Error>> {
125    let resp = client.rollback_rest(RollbackRestRequest::default()).await?;
126    print_rollback_result("REST", &resp);
127    Ok(())
128}
129
130// ── services tsp {enable, update, disable, rollback} ──────────────
131//
132// TSP advertises a **mediator DID** (the VTA's TSP VID), not a URL —
133// so these mirror the REST commands with `mediator_did` in place of
134// `url`. Responses reuse the same `ServiceMutationResponse` /
135// `RollbackResponse` shapes, so the rendering is identical.
136
137pub async fn cmd_services_tsp_enable(
138    client: &VtaClient,
139    mediator_did: String,
140) -> Result<(), Box<dyn std::error::Error>> {
141    let req = EnableTspRequest::new(mediator_did);
142    let resp = client.enable_tsp(req).await?;
143    println!("TSP enabled.");
144    println!("  New version ID: {}", resp.log_entry_version_id);
145    println!("  Effective at:   {}", resp.effective_at);
146    print_serverless_hint(resp.serverless, &resp.vta_did);
147    Ok(())
148}
149
150pub async fn cmd_services_tsp_update(
151    client: &VtaClient,
152    mediator_did: String,
153) -> Result<(), Box<dyn std::error::Error>> {
154    let req = UpdateTspRequest::new(mediator_did);
155    let resp = client.update_tsp(req).await?;
156    println!("TSP mediator DID updated.");
157    println!("  New version ID: {}", resp.log_entry_version_id);
158    println!("  Effective at:   {}", resp.effective_at);
159    print_serverless_hint(resp.serverless, &resp.vta_did);
160    Ok(())
161}
162
163pub async fn cmd_services_tsp_disable(
164    client: &VtaClient,
165) -> Result<(), Box<dyn std::error::Error>> {
166    let resp = client.disable_tsp(DisableTspRequest::default()).await?;
167    println!("TSP disabled.");
168    println!("  New version ID: {}", resp.log_entry_version_id);
169    println!("  Effective at:   {}", resp.effective_at);
170    print_serverless_hint(resp.serverless, &resp.vta_did);
171    Ok(())
172}
173
174pub async fn cmd_services_tsp_rollback(
175    client: &VtaClient,
176) -> Result<(), Box<dyn std::error::Error>> {
177    let resp = client.rollback_tsp(RollbackTspRequest::default()).await?;
178    print_rollback_result("TSP", &resp);
179    Ok(())
180}
181
182// ── services webauthn {enable, update, disable, rollback} ─────────
183
184pub async fn cmd_services_webauthn_enable(
185    client: &VtaClient,
186    url: String,
187) -> Result<(), Box<dyn std::error::Error>> {
188    let req = vta_sdk::protocol::services::EnableWebauthnRequest::new(url);
189    let resp = client.enable_webauthn(req).await?;
190    println!("WebAuthn enabled.");
191    println!("  New version ID: {}", resp.log_entry_version_id);
192    println!("  Effective at:   {}", resp.effective_at);
193    print_serverless_hint(resp.serverless, &resp.vta_did);
194    Ok(())
195}
196
197pub async fn cmd_services_webauthn_update(
198    client: &VtaClient,
199    url: String,
200) -> Result<(), Box<dyn std::error::Error>> {
201    let req = vta_sdk::protocol::services::UpdateWebauthnRequest::new(url);
202    let resp = client.update_webauthn(req).await?;
203    println!("WebAuthn URL updated.");
204    println!("  New version ID: {}", resp.log_entry_version_id);
205    println!("  Effective at:   {}", resp.effective_at);
206    print_serverless_hint(resp.serverless, &resp.vta_did);
207    Ok(())
208}
209
210pub async fn cmd_services_webauthn_disable(
211    client: &VtaClient,
212) -> Result<(), Box<dyn std::error::Error>> {
213    eprintln!(
214        "WARNING: disabling WebAuthn will also REMOVE passkey verificationMethods from every DID \
215         this VTA controls. Any operator currently using passkey login will need to re-enrol \
216         after the next `services webauthn enable`."
217    );
218    let resp = client
219        .disable_webauthn(vta_sdk::protocol::services::DisableWebauthnRequest::default())
220        .await?;
221    println!("WebAuthn disabled.");
222    println!("  New version ID: {}", resp.log_entry_version_id);
223    println!("  Effective at:   {}", resp.effective_at);
224    print_serverless_hint(resp.serverless, &resp.vta_did);
225    Ok(())
226}
227
228pub async fn cmd_services_webauthn_rollback(
229    client: &VtaClient,
230) -> Result<(), Box<dyn std::error::Error>> {
231    let resp = client
232        .rollback_webauthn(vta_sdk::protocol::services::RollbackWebauthnRequest::default())
233        .await?;
234    print_rollback_result("WebAuthn", &resp);
235    Ok(())
236}
237
238// ── services didcomm {enable, update, disable, rollback} ──────────
239
240pub async fn cmd_services_didcomm_enable(
241    client: &VtaClient,
242    mediator_did: String,
243    force: bool,
244    handshake_timeout_secs: Option<u64>,
245) -> Result<(), Box<dyn std::error::Error>> {
246    let mut req = EnableDidcommRequest::new(&mediator_did);
247    req.force = force;
248    req.handshake_timeout_secs = handshake_timeout_secs;
249    let resp = match client.enable_didcomm(req).await {
250        Ok(resp) => resp,
251        Err(VtaError::Conflict(body)) => {
252            if let Ok(conflict) = serde_json::from_str::<EnableDidcommConflictBody>(&body)
253                && conflict.error == "didcomm_already_enabled"
254            {
255                println!("DIDComm already enabled.");
256                if let Some(mediator_did) = conflict.mediator_did {
257                    println!("  Mediator DID:   {mediator_did}");
258                }
259                return Ok(());
260            }
261            return Err(VtaError::Conflict(body).into());
262        }
263        Err(e) => return Err(e.into()),
264    };
265    println!("DIDComm enabled.");
266    println!("  Mediator DID:   {}", resp.mediator_did);
267    if !resp.mediator_endpoint.is_empty() {
268        println!("  Mediator URL:   {}", resp.mediator_endpoint);
269    }
270    println!("  New version ID: {}", resp.new_version_id);
271    if force {
272        println!();
273        println!("  Note: --force was set; mediator handshake steps 2-5 were bypassed.");
274    }
275    print_serverless_hint(resp.serverless, &resp.vta_did);
276    Ok(())
277}
278
279pub async fn cmd_services_didcomm_update(
280    client: &VtaClient,
281    new_mediator_did: String,
282    drain_ttl_secs: u64,
283    force: bool,
284    handshake_timeout_secs: Option<u64>,
285) -> Result<(), Box<dyn std::error::Error>> {
286    let mut req = UpdateDidcommRequest::new(&new_mediator_did, drain_ttl_secs);
287    req.force = force;
288    req.handshake_timeout_secs = handshake_timeout_secs;
289    let resp = client.update_didcomm(req).await?;
290    println!("DIDComm mediator updated.");
291    println!("  Prior mediator:  {}", resp.prior_mediator_did);
292    println!("  Active mediator: {}", resp.active_mediator_did);
293    if !resp.active_mediator_endpoint.is_empty() {
294        println!("  Active endpoint: {}", resp.active_mediator_endpoint);
295    }
296    println!("  New version ID:  {}", resp.new_version_id);
297    println!(
298        "  Drain deadline:  {} (prior listener stays up until then)",
299        resp.drains_until
300    );
301    print_serverless_hint(resp.serverless, &resp.vta_did);
302    Ok(())
303}
304
305pub async fn cmd_services_didcomm_disable(
306    client: &VtaClient,
307    drain_ttl_secs: u64,
308) -> Result<(), Box<dyn std::error::Error>> {
309    let req = DisableDidcommRequest::new(drain_ttl_secs);
310    let resp = client.disable_didcomm(req).await?;
311    println!("DIDComm disabled.");
312    println!("  Prior mediator: {}", resp.prior_mediator_did);
313    println!("  New version ID: {}", resp.new_version_id);
314    match resp.drains_until {
315        Some(deadline) => {
316            println!("  Drain deadline: {deadline}");
317            println!();
318            println!("  The listener stays up until the deadline so in-flight messages can drain.");
319            println!(
320                "  Cancel early with `pnm services didcomm drain cancel --mediator-did <did>`."
321            );
322        }
323        None => println!("  Listener torn down immediately (drain TTL was 0)."),
324    }
325    print_serverless_hint(resp.serverless, &resp.vta_did);
326    Ok(())
327}
328
329pub async fn cmd_services_didcomm_rollback(
330    client: &VtaClient,
331    drain_ttl_secs: Option<u64>,
332) -> Result<(), Box<dyn std::error::Error>> {
333    let req = RollbackDidcommRequest { drain_ttl_secs };
334    let resp = client.rollback_didcomm(req).await?;
335    print_rollback_result("DIDComm", &resp);
336    Ok(())
337}
338
339/// Best-effort names for mediator / peer DIDs.
340///
341/// Neither the drain set nor the telemetry report carries a label, so the only
342/// local source is the ACL — which does cover mediators an operator has
343/// granted an entry to. Peer *senders* are not in our ACL and stay bare until
344/// agent names exist; they are the clearest case for that feature.
345///
346/// Failure is ignored on purpose. Naming is decoration, and an operator who
347/// can read the drain set but not the ACL must still get their table.
348async fn mediator_name_book(client: &VtaClient) -> NameBook {
349    let mut book = NameBook::new();
350    if let Ok(acl) = client.list_acl(None).await {
351        book_from_acl(&mut book, &acl.entries);
352    }
353    book
354}
355
356// ── services didcomm drain {list, cancel} ─────────────────────────
357
358pub async fn cmd_services_didcomm_drain_list(
359    client: &VtaClient,
360) -> Result<(), Box<dyn std::error::Error>> {
361    let resp = client.list_drain().await?;
362    if resp.entries.is_empty() {
363        println!("No mediators currently in drain.");
364        return Ok(());
365    }
366    println!("Drain set ({} mediator(s)):", resp.entries.len());
367    println!();
368
369    let book = mediator_name_book(client).await;
370    let show_names = book.names_any(resp.entries.iter().map(|e| e.mediator_did.as_str()));
371
372    let header_did = "MEDIATOR DID";
373    let header_until = "DRAIN UNTIL";
374    if show_names {
375        println!("  {:<24}  {header_did:<46}  {header_until}", NAME_HEADER);
376    } else {
377        println!("  {header_did:<46}  {header_until}");
378    }
379    for e in &resp.entries {
380        let did = shorten_did(&e.mediator_did);
381        if show_names {
382            let name = book
383                .name_of(&e.mediator_did)
384                .unwrap_or_else(|| UNNAMED.into());
385            println!("  {:<24}  {:<46}  {}", name, did, e.drains_until);
386        } else {
387            println!("  {:<46}  {}", did, e.drains_until);
388        }
389    }
390    Ok(())
391}
392
393pub async fn cmd_services_didcomm_drain_cancel(
394    client: &VtaClient,
395    mediator_did: String,
396) -> Result<(), Box<dyn std::error::Error>> {
397    let req = vta_sdk::protocol::DrainCancelRequest { mediator_did };
398    let resp = client.drain_cancel(req).await?;
399    println!("Drain cancelled for {}.", resp.mediator_did);
400    println!("  Listener was torn down immediately.");
401    Ok(())
402}
403
404// ── services report ───────────────────────────────────────────────
405
406pub async fn cmd_services_report(
407    client: &VtaClient,
408    since: Option<String>,
409    until: Option<String>,
410    format: ReportFormat,
411) -> Result<(), Box<dyn std::error::Error>> {
412    let report = client
413        .mediator_report(since.as_deref(), until.as_deref())
414        .await?;
415
416    match format {
417        ReportFormat::Json => {
418            println!("{}", serde_json::to_string_pretty(&report)?);
419        }
420        ReportFormat::Table => {
421            println!("Service-management report");
422            if let Some(ref s) = report.since {
423                println!("  Window: {s} → {}", report.until);
424            } else {
425                println!("  Window: (all time) → {}", report.until);
426            }
427            println!();
428            // Peer sender DIDs have no label anywhere in our store, so most
429            // will stay bare until agent names land — which is exactly the
430            // surface they are for.
431            let book = mediator_name_book(client).await;
432
433            if report.mediators.is_empty() {
434                println!("  No inbound DIDComm messages recorded.");
435            } else {
436                println!("  Per-mediator inbound counts (most recent first):");
437                let show_names =
438                    book.names_any(report.mediators.iter().map(|m| m.mediator_did.as_str()));
439                let header_did = "MEDIATOR DID";
440                let header_count = "INBOUND";
441                if show_names {
442                    println!(
443                        "    {:<24}  {header_did:<46}  {header_count:>10}  LAST SEEN",
444                        NAME_HEADER
445                    );
446                } else {
447                    println!("    {header_did:<46}  {header_count:>10}  LAST SEEN");
448                }
449                for m in &report.mediators {
450                    let did = shorten_did(&m.mediator_did);
451                    if show_names {
452                        let name = book
453                            .name_of(&m.mediator_did)
454                            .unwrap_or_else(|| UNNAMED.into());
455                        println!(
456                            "    {:<24}  {:<46}  {:>10}  {}",
457                            name, did, m.inbound_count, m.last_seen
458                        );
459                    } else {
460                        println!("    {:<46}  {:>10}  {}", did, m.inbound_count, m.last_seen);
461                    }
462                }
463            }
464            if !report.senders.is_empty() {
465                println!();
466                println!("  Senders by last-seen mediator:");
467                for s in &report.senders {
468                    // Prose, not a fixed-width column, so both sides carry
469                    // their DID alongside the name.
470                    println!(
471                        "    {} → {} (at {})",
472                        inline(&book, &s.sender_did),
473                        inline(&book, &s.last_seen_mediator),
474                        s.last_seen_at
475                    );
476                }
477            }
478        }
479    }
480    Ok(())
481}
482
483// ── shared helpers ────────────────────────────────────────────────
484
485fn print_rollback_result(kind: &str, resp: &vta_sdk::protocol::services::RollbackResponse) {
486    if resp.kind == "no_op" {
487        println!("{kind} rollback: no change required.");
488        println!("  Snapshot matches current state — nothing to do.");
489        return;
490    }
491    println!("{kind} rolled back.");
492    println!("  Action:         {}", resp.kind);
493    if !resp.log_entry_version_id.is_empty() {
494        println!("  New version ID: {}", resp.log_entry_version_id);
495    }
496    println!("  Effective at:   {}", resp.effective_at);
497    if let Some(ref drain_until) = resp.drain_until {
498        println!("  Drain deadline: {drain_until}");
499    }
500    if let Some(ref draining) = resp.draining_mediator {
501        println!("  Draining:       {draining}");
502    }
503    print_serverless_hint(resp.serverless, &resp.vta_did);
504}
505
506/// Print the "fetch did.jsonl + redeploy" hint when the mutation
507/// just wrote a LogEntry to a self-hosted VTA DID.
508///
509/// Silent when `serverless` is false (the VTA published to a host
510/// as part of the call — no follow-up needed) and when `vta_did`
511/// is empty (no LogEntry was written, e.g. no-op rollback).
512///
513/// Suffix is two operator-actionable lines: the command and the
514/// reason. Operators running scripted updates will see the line
515/// every time on serverless deployments — that's intentional,
516/// since the alternative is stale resolvers without an obvious
517/// cause.
518pub fn print_serverless_hint(serverless: bool, vta_did: &str) {
519    if !serverless || vta_did.is_empty() {
520        return;
521    }
522    println!();
523    println!("  This VTA's DID is self-hosted. Fetch the updated log:");
524    println!("    pnm did-mgmt dids get-log {vta_did} --out did.jsonl");
525    println!("  then redeploy did.jsonl to your host. Until you do,");
526    println!("  resolvers will keep returning the prior version.");
527}
528
529#[derive(Debug, Clone, Copy)]
530pub enum ReportFormat {
531    Json,
532    Table,
533}
534
535impl std::str::FromStr for ReportFormat {
536    type Err = String;
537    fn from_str(s: &str) -> Result<Self, Self::Err> {
538        match s {
539            "json" => Ok(Self::Json),
540            "table" => Ok(Self::Table),
541            other => Err(format!("unknown format `{other}` — use `json` or `table`")),
542        }
543    }
544}