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