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