Skip to main content

vta_sdk/protocol/
services.rs

1//! Wire types for the runtime REST service-management surface.
2//!
3//! Spec: `docs/05-design-notes/runtime-service-management.md` §4.
4//!
5//! These cover the four REST operations exposed under
6//! `pnm services rest {enable,update,disable,rollback}` plus the
7//! shared response shape (`ServiceMutationResponse`) used by every
8//! mutation across both REST and DIDComm kinds.
9//!
10//! DIDComm-side wire types live in [`super`] — `EnableDidcommRequest`,
11//! `DisableDidcommRequest`, and (renamed in T2.3) `UpdateDidcommRequest`.
12//! Keep the two surfaces in sync as they evolve.
13//!
14//! ## URL field convention
15//!
16//! `url: String` is intentional — the field stays a string here so
17//! the SDK matches the existing protocol-management types
18//! (`mediator_did: String`, etc.) and the operation layer applies a
19//! single validation pass via [`crate::protocol::validate_service_url`]
20//! (T1.2). Operators see one consistent error shape regardless of
21//! whether validation runs client-side, server-side, or both.
22
23use serde::{Deserialize, Serialize};
24
25use crate::error::VtaError;
26
27/// Validate a service-endpoint URL.
28///
29/// Spec §3.4: must be `https://`, parsable by `url::Url`, no
30/// fragment, no userinfo. Returns the parsed [`url::Url`] on
31/// success so callers don't re-parse, or [`VtaError::Validation`]
32/// with a specific message on failure.
33///
34/// Centralized so both REST handlers and DIDComm transport
35/// handlers (and any client-side pre-flight) use the same rule.
36/// The CLI surfaces the rejection through `VtaError`'s
37/// `suggested_fix` path; reasons are kept short and operator-
38/// readable rather than full stack-trace text.
39///
40/// `localhost` and IP literals are accepted — the operator may
41/// genuinely want to advertise a private deployment. TLS is still
42/// required there (operator can run a private CA / mkcert); the
43/// invariant is that clients see a TLS-protected URL in the DID
44/// document, not that the cert chains to a public root.
45pub fn validate_service_url(url: &str) -> Result<url::Url, VtaError> {
46    let trimmed = url.trim();
47    if trimmed.is_empty() {
48        return Err(VtaError::Validation("service URL is empty".into()));
49    }
50
51    let parsed = url::Url::parse(trimmed)
52        .map_err(|e| VtaError::Validation(format!("service URL is unparseable: {e}")))?;
53
54    if parsed.scheme() != "https" {
55        return Err(VtaError::Validation(format!(
56            "service URL must use https:// (got {})",
57            parsed.scheme()
58        )));
59    }
60
61    if parsed.fragment().is_some() {
62        return Err(VtaError::Validation(
63            "service URL must not contain a `#fragment`".into(),
64        ));
65    }
66
67    // userinfo = the `user:password@` part. `url::Url` exposes
68    // username() (always returns "" when absent) and password()
69    // (Option<&str>). A non-empty username OR a password being
70    // present means userinfo is set.
71    if !parsed.username().is_empty() || parsed.password().is_some() {
72        return Err(VtaError::Validation(
73            "service URL must not contain userinfo (user:password@)".into(),
74        ));
75    }
76
77    if parsed.host().is_none() {
78        return Err(VtaError::Validation("service URL must have a host".into()));
79    }
80
81    Ok(parsed)
82}
83
84/// Request body for `POST /services/rest/enable`.
85///
86/// Adds a `#vta-rest` service entry to the VTA's DID document
87/// pointing at `url`. Refused with `ServiceAlreadyEnabled` if REST
88/// is already advertised. The wire shape rendered into the DID
89/// document — `id: "{DID}#vta-rest"`, `type: "VTARest"` — is
90/// preserved by the operation layer (P1.3) for SDK-resolution
91/// compatibility (`vta-sdk/src/session.rs:1100`).
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
94#[must_use]
95pub struct EnableRestRequest {
96    pub url: String,
97}
98
99impl EnableRestRequest {
100    pub fn new(url: impl Into<String>) -> Self {
101        Self { url: url.into() }
102    }
103}
104
105/// Request body for `POST /services/rest/update`.
106///
107/// Replaces the URL on the existing `#vta-rest` entry. Refused
108/// with `ServiceNotPresent` if REST is not currently advertised.
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
110#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
111#[must_use]
112pub struct UpdateRestRequest {
113    pub url: String,
114}
115
116impl UpdateRestRequest {
117    pub fn new(url: impl Into<String>) -> Self {
118        Self { url: url.into() }
119    }
120}
121
122/// Request body for `POST /services/rest/disable`.
123///
124/// Removes the `#vta-rest` entry. Refused with
125/// `LastServiceRefused` when DIDComm is also disabled (spec §3.2)
126/// and with `ServiceNotPresent` if REST isn't currently advertised.
127///
128/// No fields today — the body exists so the wire surface stays
129/// uniform (every mutation is a `POST` with a JSON body) and so
130/// the type can grow optional fields later without a breaking
131/// change. Serializes as `{}`.
132#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
133#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
134#[must_use]
135pub struct DisableRestRequest {}
136
137/// Request body for `POST /services/rest/rollback`.
138///
139/// Fail-forwards the most recent REST mutation (spec §3.5a) by
140/// reading the snapshot store and dispatching to the equivalent
141/// forward operation. Refused with `NoPriorMutation` when no
142/// snapshot is recorded, and with `LastServiceRefused` if the
143/// rollback would brick the VTA. Like [`DisableRestRequest`],
144/// no fields today — serializes as `{}`.
145#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
146#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
147#[must_use]
148pub struct RollbackRestRequest {}
149
150// ── TSP service-management request types ──────────────────────────
151//
152// TSP advertises a **mediator DID** (the VTA's TSP VID), not a URL —
153// so where the REST types carry `url: String`, these carry
154// `mediator_did: String`. TSP reuses the shared
155// [`ServiceMutationResponse`] / [`RollbackResponse`] shapes; there
156// are no TSP-specific response types. TSP has no drain and no
157// handshake, so the disable / rollback bodies stay field-free.
158
159/// Request body for `POST /services/tsp/enable`.
160///
161/// Adds a `#tsp` service entry (`type: "TSPTransport"`) to the VTA's
162/// DID document advertising `mediator_did` — the VTA's TSP VID.
163/// Refused with `ServiceAlreadyEnabled` if TSP is already advertised.
164/// Unlike DIDComm enable, TSP enable is reachable over both REST and
165/// DIDComm (DIDComm is always running when REST is, per the §3.2
166/// at-least-one-service invariant).
167#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
168#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
169#[must_use]
170pub struct EnableTspRequest {
171    pub mediator_did: String,
172}
173
174impl EnableTspRequest {
175    pub fn new(mediator_did: impl Into<String>) -> Self {
176        Self {
177            mediator_did: mediator_did.into(),
178        }
179    }
180}
181
182/// Request body for `POST /services/tsp/update`.
183///
184/// Replaces the mediator DID on the existing `#tsp` entry. Refused
185/// with `ServiceNotPresent` if TSP is not currently advertised.
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
187#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
188#[must_use]
189pub struct UpdateTspRequest {
190    pub mediator_did: String,
191}
192
193impl UpdateTspRequest {
194    pub fn new(mediator_did: impl Into<String>) -> Self {
195        Self {
196            mediator_did: mediator_did.into(),
197        }
198    }
199}
200
201/// Request body for `POST /services/tsp/disable`.
202///
203/// Removes the `#tsp` entry. Refused with `LastServiceRefused` when
204/// TSP is the only advertised transport (spec §3.2) and with
205/// `ServiceNotPresent` if TSP isn't currently advertised.
206///
207/// No fields today — like [`DisableRestRequest`] the body exists so
208/// the wire surface stays uniform. Serializes as `{}`.
209#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
210#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
211#[must_use]
212pub struct DisableTspRequest {}
213
214/// Request body for `POST /services/tsp/rollback`.
215///
216/// Fail-forwards the most recent TSP mutation (spec §3.5a) by reading
217/// the snapshot store and dispatching to the equivalent forward
218/// operation. Refused with `NoPriorMutation` when no snapshot is
219/// recorded, and with `LastServiceRefused` if the rollback would
220/// brick the VTA. Like [`RollbackRestRequest`], no fields today —
221/// serializes as `{}`.
222#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
223#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
224#[must_use]
225pub struct RollbackTspRequest {}
226
227/// Request body for `POST /services/webauthn/enable`.
228///
229/// Adds a `#vta-webauthn` service entry to the VTA's DID document
230/// pointing at `url`. Refused with `ServiceAlreadyEnabled` if the
231/// WebAuthn-RP surface is already advertised. The wire shape
232/// rendered into the DID document — `id: "{DID}#vta-webauthn"`,
233/// `type: "WebAuthnRP"` — is preserved by the operation layer.
234///
235/// Distinct from `EnableRestRequest` because the WebAuthn-RP
236/// surface has different availability semantics: it can be
237/// toggled independently of the general REST API and is the entry
238/// point browsers discover for the auth portal.
239#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
240#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
241#[must_use]
242pub struct EnableWebauthnRequest {
243    pub url: String,
244}
245
246impl EnableWebauthnRequest {
247    pub fn new(url: impl Into<String>) -> Self {
248        Self { url: url.into() }
249    }
250}
251
252/// Request body for `POST /services/webauthn/update`.
253///
254/// Replaces the URL on the existing `#vta-webauthn` entry. Refused
255/// with `ServiceNotPresent` if the WebAuthn surface is not currently
256/// advertised.
257#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
258#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
259#[must_use]
260pub struct UpdateWebauthnRequest {
261    pub url: String,
262}
263
264impl UpdateWebauthnRequest {
265    pub fn new(url: impl Into<String>) -> Self {
266        Self { url: url.into() }
267    }
268}
269
270/// Request body for `POST /services/webauthn/disable`.
271///
272/// Removes the `#vta-webauthn` entry **and** any passkey
273/// verificationMethods published on the VTA's DID document — the
274/// operator chose hard-disable semantics so a disabled WebAuthn-RP
275/// surface doesn't leave dangling VMs claiming to authenticate
276/// against an unreachable RP.
277///
278/// Refused with `LastServiceRefused` when removing WebAuthn would
279/// leave no transport advertised at all (spec §3.2; WebAuthn counts
280/// as a transport for this invariant).
281///
282/// No fields today — serializes as `{}`.
283#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
284#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
285#[must_use]
286pub struct DisableWebauthnRequest {}
287
288/// Request body for `POST /services/webauthn/rollback`. Symmetric
289/// with [`RollbackRestRequest`].
290#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
291#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
292#[must_use]
293pub struct RollbackWebauthnRequest {}
294
295/// Request body for `POST /services/didcomm/rollback`.
296///
297/// Threads `drain_ttl_secs` through to the dispatched forward op
298/// for the disable / update arms. Server applies the spec §3.6
299/// default (24h) when omitted.
300#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
301#[must_use]
302pub struct RollbackDidcommRequest {
303    /// Drain window for the previously-active mediator (seconds).
304    /// Server default is 24h when omitted.
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub drain_ttl_secs: Option<u64>,
307}
308
309impl RollbackDidcommRequest {
310    pub fn new() -> Self {
311        Self::default()
312    }
313
314    pub fn drain_ttl_secs(mut self, secs: u64) -> Self {
315        self.drain_ttl_secs = Some(secs);
316        self
317    }
318}
319
320/// Response body for `GET /services/didcomm/drain` — the list of
321/// mediators currently in drain state. Empty list is normal.
322#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
323#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
324pub struct DrainListResponse {
325    pub entries: Vec<DrainEntry>,
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
329#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
330pub struct DrainEntry {
331    pub mediator_did: String,
332    pub endpoint: String,
333    /// Drain deadline (RFC 3339).
334    pub drains_until: String,
335}
336
337/// Response body for the rollback handlers. Wider than
338/// [`ServiceMutationResponse`] — adds `kind` and
339/// `draining_mediator` fields that downstream consumers need to
340/// distinguish the dispatched arm.
341///
342/// `log_entry_version_id` is the empty string when the rollback
343/// was a no-op (snapshot ≡ current state).
344#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
345#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
346pub struct RollbackResponse {
347    pub log_entry_version_id: String,
348    pub effective_at: String,
349    /// One of: `disabled`, `enabled`, `updated`, `no_op`.
350    pub kind: String,
351    /// `Some(rfc3339)` when the rollback scheduled a drain.
352    /// `None` for REST and DIDComm enable / no-op arms.
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub drain_until: Option<String>,
355    /// Mediator DID currently being drained by this rollback.
356    /// `None` for REST and DIDComm enable / no-op arms.
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub draining_mediator: Option<String>,
359    /// The VTA's own DID — the subject of the LogEntry this
360    /// rollback wrote. Carried so the CLI can print follow-up
361    /// commands like `pnm webvh did-log <vta_did>` for serverless
362    /// deployments without forcing the operator to look it up.
363    /// Empty string in `no_op` responses where no LogEntry was
364    /// written. Serialized as `vta_did` on the wire; elided when
365    /// empty for compactness.
366    #[serde(default, skip_serializing_if = "String::is_empty")]
367    pub vta_did: String,
368    /// True when the VTA's DID is self-hosted (`server_id =
369    /// "serverless"`). The rollback's new LogEntry is persisted
370    /// locally but NOT pushed to any webvh host — the operator
371    /// must fetch the updated `did.jsonl` and redeploy.
372    /// `#[serde(default)]` for back-compat — older servers don't
373    /// emit the field and old clients treat absent → false.
374    #[serde(default)]
375    pub serverless: bool,
376}
377
378/// Response body for `GET /services` — the operator-facing read
379/// surface for inspecting the VTA's current advertised transport
380/// services. Spec §10 (resolved): minimal shape — one entry per
381/// kind, `enabled` flag, kind-specific config when enabled.
382///
383/// Order is canonical: DIDComm before REST when both are
384/// advertised, matching the spec §3.3 ordering invariant
385/// enforced in `protocol::document::sort_services_canonical`.
386#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
387#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
388pub struct ServicesListResponse {
389    pub services: Vec<ServiceState>,
390}
391
392/// State of a single transport kind. The `kind` discriminator is
393/// `"tsp"`, `"rest"`, `"didcomm"`, or `"webauthn"` on the wire (kebab-case
394/// to align with the rest of the runtime service-management surface).
395/// When `enabled` is `false`, the kind-specific config fields are
396/// absent.
397#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
398#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
399#[serde(tag = "kind", rename_all = "lowercase")]
400pub enum ServiceState {
401    Tsp {
402        enabled: bool,
403        /// Mediator DID the `#tsp` service advertises (the VTA's TSP
404        /// VID) — TSP uses the same mediator indirection as DIDComm, so
405        /// this is typically the same value as the DIDComm `mediator_did`
406        /// (one dual-protocol mediator). `None` when TSP is disabled.
407        #[serde(default, skip_serializing_if = "Option::is_none")]
408        mediator_did: Option<String>,
409    },
410    Rest {
411        enabled: bool,
412        /// Currently-published REST URL. `None` when REST is
413        /// disabled.
414        #[serde(default, skip_serializing_if = "Option::is_none")]
415        url: Option<String>,
416    },
417    Didcomm {
418        enabled: bool,
419        /// Currently-active mediator DID. `None` when DIDComm is
420        /// disabled.
421        #[serde(default, skip_serializing_if = "Option::is_none")]
422        mediator_did: Option<String>,
423        /// Routing keys for the active mediator. Empty when
424        /// DIDComm is disabled or when the mediator entry doesn't
425        /// carry routing-keys today (the workspace's `#vta-didcomm`
426        /// service entry currently doesn't).
427        #[serde(default, skip_serializing_if = "Vec::is_empty")]
428        routing_keys: Vec<String>,
429    },
430    Webauthn {
431        enabled: bool,
432        /// Currently-published WebAuthn-RP URL — typically the
433        /// operator-facing auth portal. `None` when the WebAuthn
434        /// service is disabled.
435        #[serde(default, skip_serializing_if = "Option::is_none")]
436        url: Option<String>,
437    },
438}
439
440/// Shared response body for every successful service-mutation
441/// operation (REST + DIDComm enable/update/disable/rollback).
442///
443/// `drain_until` is `Some(rfc3339)` only for DIDComm operations
444/// that scheduled a drain (`update_didcomm`, `disable_didcomm`,
445/// or a `rollback_didcomm` whose fail-forward target was a drain
446/// transition). REST mutations always set it to `None`.
447///
448/// All timestamps are RFC 3339 strings to match the existing wire
449/// convention from `DisableDidcommResponse::drains_until`.
450#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
451#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
452pub struct ServiceMutationResponse {
453    /// Version-id of the new WebVH LogEntry produced by the
454    /// mutation. Joins telemetry events to chain history.
455    pub log_entry_version_id: String,
456    /// RFC 3339 timestamp when the mutation took effect — the
457    /// same instant stamped on the new LogEntry.
458    pub effective_at: String,
459    /// `Some(rfc3339)` when the mutation scheduled a DIDComm
460    /// drain; `None` otherwise.
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    pub drain_until: Option<String>,
463    /// The VTA's own DID — subject of the LogEntry this mutation
464    /// wrote. Carried so the CLI can print follow-up commands like
465    /// `pnm webvh did-log <vta_did>` for serverless deployments
466    /// without forcing the operator to look it up.
467    /// `#[serde(default)]` + `skip_serializing_if = "String::is_empty"`
468    /// keep the wire compact and back-compat with older servers.
469    #[serde(default, skip_serializing_if = "String::is_empty")]
470    pub vta_did: String,
471    /// True when the VTA's DID is self-hosted (`server_id =
472    /// "serverless"`). The new LogEntry is persisted locally but
473    /// NOT pushed to any webvh host — the operator must fetch the
474    /// updated `did.jsonl` and redeploy. `#[serde(default)]` for
475    /// back-compat — older servers don't emit the field; old
476    /// clients treat absent → false (no spurious hint).
477    #[serde(default)]
478    pub serverless: bool,
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    /// Round-trip every request type — both directions, across the
486    /// wire forms used by REST handlers (raw JSON body) and DIDComm
487    /// transport (JSON-tagged in the message attachment).
488    #[test]
489    fn rest_request_types_round_trip_through_json() {
490        let req = EnableRestRequest::new("https://vta.example.com");
491        let json = serde_json::to_string(&req).unwrap();
492        let restored: EnableRestRequest = serde_json::from_str(&json).unwrap();
493        assert_eq!(restored, req);
494
495        let req = UpdateRestRequest::new("https://vta-new.example.com");
496        let json = serde_json::to_string(&req).unwrap();
497        let restored: UpdateRestRequest = serde_json::from_str(&json).unwrap();
498        assert_eq!(restored, req);
499
500        let req = DisableRestRequest::default();
501        let json = serde_json::to_string(&req).unwrap();
502        let restored: DisableRestRequest = serde_json::from_str(&json).unwrap();
503        assert_eq!(restored, req);
504
505        let req = RollbackRestRequest::default();
506        let json = serde_json::to_string(&req).unwrap();
507        let restored: RollbackRestRequest = serde_json::from_str(&json).unwrap();
508        assert_eq!(restored, req);
509    }
510
511    /// TSP request types round-trip — `mediator_did` (a DID) where
512    /// REST uses `url`. Mirror of `rest_request_types_round_trip…`.
513    #[test]
514    fn tsp_request_types_round_trip_through_json() {
515        let req = EnableTspRequest::new("did:peer:2.Mediator");
516        let json = serde_json::to_string(&req).unwrap();
517        let restored: EnableTspRequest = serde_json::from_str(&json).unwrap();
518        assert_eq!(restored, req);
519
520        let req = UpdateTspRequest::new("did:peer:2.NewMediator");
521        let json = serde_json::to_string(&req).unwrap();
522        let restored: UpdateTspRequest = serde_json::from_str(&json).unwrap();
523        assert_eq!(restored, req);
524
525        let req = DisableTspRequest::default();
526        let json = serde_json::to_string(&req).unwrap();
527        let restored: DisableTspRequest = serde_json::from_str(&json).unwrap();
528        assert_eq!(restored, req);
529
530        let req = RollbackTspRequest::default();
531        let json = serde_json::to_string(&req).unwrap();
532        let restored: RollbackTspRequest = serde_json::from_str(&json).unwrap();
533        assert_eq!(restored, req);
534    }
535
536    /// TSP's enable/update field must stay literal `mediator_did` on
537    /// the wire (a DID, not a URL). Pin via JSON shape.
538    #[test]
539    fn tsp_mediator_did_field_is_literal_on_wire() {
540        let json = serde_json::to_value(EnableTspRequest::new("did:peer:2.M")).unwrap();
541        assert_eq!(json["mediator_did"], "did:peer:2.M");
542        assert!(json.get("url").is_none(), "TSP must not carry a url field");
543
544        let json = serde_json::to_value(UpdateTspRequest::new("did:peer:2.N")).unwrap();
545        assert_eq!(json["mediator_did"], "did:peer:2.N");
546    }
547
548    /// TSP's empty bodies serialize as `{}` (mirror of the REST pin).
549    #[test]
550    fn tsp_empty_request_bodies_serialize_as_empty_object() {
551        assert_eq!(
552            serde_json::to_string(&DisableTspRequest::default()).unwrap(),
553            "{}"
554        );
555        assert_eq!(
556            serde_json::to_string(&RollbackTspRequest::default()).unwrap(),
557            "{}"
558        );
559        let _: DisableTspRequest = serde_json::from_str("{}").unwrap();
560        let _: RollbackTspRequest = serde_json::from_str("{}").unwrap();
561    }
562
563    /// The empty request bodies must serialize as `{}`, not `null`,
564    /// `[]`, or a string. REST clients that send `null` would
565    /// fail strict-deserialize servers; pin the wire form.
566    #[test]
567    fn empty_request_bodies_serialize_as_empty_object() {
568        assert_eq!(
569            serde_json::to_string(&DisableRestRequest::default()).unwrap(),
570            "{}"
571        );
572        assert_eq!(
573            serde_json::to_string(&RollbackRestRequest::default()).unwrap(),
574            "{}"
575        );
576    }
577
578    /// The empty request bodies must also accept an empty object
579    /// `{}` (and `null`, via `Default`) on the deserialize side, so
580    /// callers that send no body don't trip up the server.
581    #[test]
582    fn empty_request_bodies_accept_empty_object() {
583        let _: DisableRestRequest = serde_json::from_str("{}").unwrap();
584        let _: RollbackRestRequest = serde_json::from_str("{}").unwrap();
585    }
586
587    /// `url` field name must stay literal `url` on the wire —
588    /// renaming it would break every caller. Pin via JSON shape
589    /// rather than relying solely on the `#[derive(Serialize)]`
590    /// default.
591    #[test]
592    fn rest_url_field_is_literal_url_on_wire() {
593        let json = serde_json::to_value(EnableRestRequest::new("https://x.example")).unwrap();
594        assert_eq!(json["url"], "https://x.example");
595        assert!(json.get("uri").is_none(), "must not rename url to uri");
596
597        let json = serde_json::to_value(UpdateRestRequest::new("https://y.example")).unwrap();
598        assert_eq!(json["url"], "https://y.example");
599    }
600
601    /// `ServiceMutationResponse` round-trips with and without
602    /// `drain_until`. The `None` case omits the field on the wire
603    /// (per `skip_serializing_if`); the `Some` case includes the
604    /// RFC 3339 timestamp.
605    #[test]
606    fn service_mutation_response_round_trips_both_drain_states() {
607        let rest_response = ServiceMutationResponse {
608            log_entry_version_id: "1-zQm...A".into(),
609            effective_at: "2026-05-06T13:00:00Z".into(),
610            drain_until: None,
611            vta_did: "did:webvh:scid:host:vta".into(),
612            serverless: false,
613        };
614        let json = serde_json::to_value(&rest_response).unwrap();
615        assert_eq!(json["log_entry_version_id"], "1-zQm...A");
616        assert_eq!(json["effective_at"], "2026-05-06T13:00:00Z");
617        assert!(
618            json.get("drain_until").is_none(),
619            "drain_until must be omitted when None — wire bandwidth + reader convention",
620        );
621        let restored: ServiceMutationResponse = serde_json::from_value(json).unwrap();
622        assert_eq!(restored, rest_response);
623
624        let didcomm_response = ServiceMutationResponse {
625            log_entry_version_id: "2-zQm...B".into(),
626            effective_at: "2026-05-06T13:00:00Z".into(),
627            drain_until: Some("2026-05-07T13:00:00Z".into()),
628            vta_did: "did:webvh:scid:host:vta".into(),
629            serverless: true,
630        };
631        let json = serde_json::to_value(&didcomm_response).unwrap();
632        assert_eq!(json["drain_until"], "2026-05-07T13:00:00Z");
633        assert_eq!(json["vta_did"], "did:webvh:scid:host:vta");
634        assert_eq!(json["serverless"], true);
635        let restored: ServiceMutationResponse = serde_json::from_value(json).unwrap();
636        assert_eq!(restored, didcomm_response);
637    }
638
639    /// Back-compat: older servers don't emit `vta_did` /
640    /// `serverless`. Absent on the wire → string default ("") +
641    /// bool default (false). Pins what `#[serde(default)]` buys.
642    #[test]
643    fn service_mutation_response_decodes_legacy_payload() {
644        let legacy = r#"{
645            "log_entry_version_id": "1-zQm...A",
646            "effective_at": "2026-05-06T13:00:00Z"
647        }"#;
648        let r: ServiceMutationResponse = serde_json::from_str(legacy).unwrap();
649        assert_eq!(r.vta_did, "");
650        assert!(!r.serverless);
651    }
652
653    // ── validate_service_url ──────────────────────────────────────
654
655    #[test]
656    fn validate_service_url_accepts_https() {
657        assert!(validate_service_url("https://vta.example.com").is_ok());
658        assert!(validate_service_url("https://vta.example.com/").is_ok());
659        assert!(validate_service_url("https://vta.example.com:8443").is_ok());
660        assert!(validate_service_url("https://vta.example.com/path/sub").is_ok());
661    }
662
663    #[test]
664    fn validate_service_url_accepts_localhost_and_ip_literals() {
665        // Private/dev deployments are valid — operators may run
666        // mkcert or a private CA; TLS-protected is what matters,
667        // not whether the cert chains to a public root.
668        assert!(validate_service_url("https://localhost:8443").is_ok());
669        assert!(validate_service_url("https://127.0.0.1:8443").is_ok());
670        assert!(validate_service_url("https://[::1]:8443").is_ok());
671    }
672
673    #[test]
674    fn validate_service_url_rejects_http() {
675        let err = validate_service_url("http://vta.example.com").unwrap_err();
676        match err {
677            VtaError::Validation(msg) => assert!(
678                msg.contains("https"),
679                "expected scheme-related rejection, got: {msg}",
680            ),
681            other => panic!("expected Validation, got {other:?}"),
682        }
683    }
684
685    #[test]
686    fn validate_service_url_rejects_other_schemes() {
687        for bad in [
688            "ws://vta.example.com",
689            "ftp://x.example",
690            "file:///etc/passwd",
691        ] {
692            assert!(
693                matches!(validate_service_url(bad), Err(VtaError::Validation(_))),
694                "expected rejection for {bad}",
695            );
696        }
697    }
698
699    #[test]
700    fn validate_service_url_rejects_fragment() {
701        let err = validate_service_url("https://vta.example.com/api#section").unwrap_err();
702        match err {
703            VtaError::Validation(msg) => {
704                assert!(
705                    msg.contains("fragment"),
706                    "expected fragment rejection, got: {msg}"
707                )
708            }
709            other => panic!("expected Validation, got {other:?}"),
710        }
711    }
712
713    #[test]
714    fn validate_service_url_rejects_userinfo() {
715        for bad in [
716            "https://user@vta.example.com",
717            "https://user:pass@vta.example.com",
718            "https://:pass@vta.example.com",
719        ] {
720            let err = validate_service_url(bad).unwrap_err();
721            match err {
722                VtaError::Validation(msg) => assert!(
723                    msg.contains("userinfo"),
724                    "expected userinfo rejection for {bad}, got: {msg}",
725                ),
726                other => panic!("expected Validation for {bad}, got {other:?}"),
727            }
728        }
729    }
730
731    #[test]
732    fn validate_service_url_rejects_unparseable() {
733        for bad in ["not a url at all", "://no-scheme", "https://", "🦀"] {
734            assert!(
735                matches!(validate_service_url(bad), Err(VtaError::Validation(_))),
736                "expected rejection for {bad:?}",
737            );
738        }
739    }
740
741    #[test]
742    fn validate_service_url_rejects_empty_and_whitespace() {
743        for bad in ["", "   ", "\t\n"] {
744            let err = validate_service_url(bad).unwrap_err();
745            match err {
746                VtaError::Validation(msg) => assert!(
747                    msg.contains("empty"),
748                    "expected empty-URL rejection for {bad:?}, got: {msg}",
749                ),
750                other => panic!("expected Validation for {bad:?}, got {other:?}"),
751            }
752        }
753    }
754
755    /// On success, the parsed `url::Url` is returned so callers
756    /// don't re-parse. Spot-check a couple of properties.
757    #[test]
758    fn validate_service_url_returns_parsed_url() {
759        let parsed = validate_service_url("https://vta.example.com:8443/api").unwrap();
760        assert_eq!(parsed.scheme(), "https");
761        assert_eq!(parsed.host_str(), Some("vta.example.com"));
762        assert_eq!(parsed.port(), Some(8443));
763        assert_eq!(parsed.path(), "/api");
764    }
765
766    // ── ServicesListResponse / ServiceState wire shape ──────────
767
768    #[test]
769    fn services_list_response_round_trips_both_kinds() {
770        let response = ServicesListResponse {
771            services: vec![
772                ServiceState::Didcomm {
773                    enabled: true,
774                    mediator_did: Some("did:peer:2.M".into()),
775                    routing_keys: vec!["did:peer:2.K".into()],
776                },
777                ServiceState::Rest {
778                    enabled: true,
779                    url: Some("https://vta.example.com".into()),
780                },
781            ],
782        };
783        let json = serde_json::to_string(&response).unwrap();
784        let restored: ServicesListResponse = serde_json::from_str(&json).unwrap();
785        assert_eq!(restored, response);
786    }
787
788    /// Disabled state on either kind has the kind-specific config
789    /// fields elided from the wire form (per `skip_serializing_if`).
790    #[test]
791    fn service_state_disabled_omits_config_fields() {
792        let rest_off = ServiceState::Rest {
793            enabled: false,
794            url: None,
795        };
796        let json = serde_json::to_value(&rest_off).unwrap();
797        assert_eq!(json["kind"], "rest");
798        assert_eq!(json["enabled"], false);
799        assert!(
800            json.get("url").is_none(),
801            "url must be elided when None to keep the wire form clean",
802        );
803
804        let didcomm_off = ServiceState::Didcomm {
805            enabled: false,
806            mediator_did: None,
807            routing_keys: vec![],
808        };
809        let json = serde_json::to_value(&didcomm_off).unwrap();
810        assert_eq!(json["kind"], "didcomm");
811        assert_eq!(json["enabled"], false);
812        assert!(json.get("mediator_did").is_none());
813        assert!(json.get("routing_keys").is_none());
814    }
815
816    /// Discriminator field is literal `kind` with kebab-case
817    /// (`rest` / `didcomm`) values — pin the wire contract so a
818    /// `serde(rename)` tweak fails loudly.
819    #[test]
820    fn service_state_discriminator_is_literal_kind() {
821        let json = serde_json::to_value(ServiceState::Rest {
822            enabled: true,
823            url: Some("https://x.example".into()),
824        })
825        .unwrap();
826        assert_eq!(json["kind"], "rest");
827
828        let json = serde_json::to_value(ServiceState::Didcomm {
829            enabled: true,
830            mediator_did: Some("did:peer:2.M".into()),
831            routing_keys: vec![],
832        })
833        .unwrap();
834        assert_eq!(json["kind"], "didcomm");
835    }
836
837    /// `ServiceMutationResponse` deserializes both forms — explicit
838    /// `null` for drain_until (some encoders emit it) and the
839    /// elided form (preferred).
840    #[test]
841    fn service_mutation_response_accepts_explicit_null_drain_until() {
842        let with_null = r#"{
843            "log_entry_version_id": "1-zQm...A",
844            "effective_at": "2026-05-06T13:00:00Z",
845            "drain_until": null
846        }"#;
847        let r: ServiceMutationResponse = serde_json::from_str(with_null).unwrap();
848        assert_eq!(r.drain_until, None);
849
850        let elided = r#"{
851            "log_entry_version_id": "1-zQm...A",
852            "effective_at": "2026-05-06T13:00:00Z"
853        }"#;
854        let r: ServiceMutationResponse = serde_json::from_str(elided).unwrap();
855        assert_eq!(r.drain_until, None);
856    }
857}