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