Skip to main content

vta_sdk/protocol/
mod.rs

1//! Client surface for DIDComm protocol management.
2//!
3//! Spec: `docs/05-design-notes/didcomm-protocol-management.md`.
4//!
5//! Phase 3 lands `enable_didcomm` (REST-only by nature — DIDComm is
6//! not yet running at first-enable time). The disable / migrate /
7//! drain-cancel / report calls — and their DIDComm transport
8//! handlers — arrive in Phase 4 verticals.
9//!
10//! Runtime REST service-management wire types (the symmetric
11//! REST-side of the spec §4 surface — `EnableRestRequest`,
12//! `UpdateRestRequest`, `DisableRestRequest`, `RollbackRestRequest`,
13//! `ServiceMutationResponse`) live in [`services`].
14
15pub mod matching;
16pub mod services;
17
18use serde::{Deserialize, Serialize};
19
20#[cfg(feature = "client")]
21use crate::client::VtaClient;
22#[cfg(feature = "client")]
23use crate::error::VtaError;
24#[cfg(feature = "client")]
25use crate::protocols::protocol_management;
26
27/// Request body for `POST /services/didcomm/enable`.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[must_use]
30pub struct EnableDidcommRequest {
31    pub mediator_did: String,
32    /// Skip handshake steps 2-5 (DID resolution always runs).
33    /// Emits a `MediatorHandshakeBypassed` telemetry event when set.
34    #[serde(default)]
35    pub force: bool,
36    /// Trust-ping round-trip timeout (default: 10 seconds).
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub handshake_timeout_secs: Option<u64>,
39}
40
41impl EnableDidcommRequest {
42    pub fn new(mediator_did: impl Into<String>) -> Self {
43        Self {
44            mediator_did: mediator_did.into(),
45            force: false,
46            handshake_timeout_secs: None,
47        }
48    }
49
50    pub fn force(mut self, force: bool) -> Self {
51        self.force = force;
52        self
53    }
54
55    pub fn handshake_timeout_secs(mut self, secs: u64) -> Self {
56        self.handshake_timeout_secs = Some(secs);
57        self
58    }
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct EnableDidcommResponse {
63    pub new_version_id: String,
64    pub mediator_did: String,
65    pub mediator_endpoint: String,
66    /// The VTA's own DID — subject of the LogEntry this enable
67    /// wrote. Carried so the CLI can print follow-up commands like
68    /// `pnm webvh did-log <vta_did>` for serverless deployments.
69    /// `#[serde(default)]` + elide-when-empty keeps the wire form
70    /// back-compat with older servers.
71    #[serde(default, skip_serializing_if = "String::is_empty")]
72    pub vta_did: String,
73    /// True when the VTA's DID is self-hosted (`server_id =
74    /// "serverless"`). The new LogEntry is local only — operators
75    /// must fetch the updated `did.jsonl` and redeploy.
76    /// `#[serde(default)]` for back-compat.
77    #[serde(default)]
78    pub serverless: bool,
79}
80
81/// Response body for `GET /services/didcomm`.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
84pub struct DidcommStatusResponse {
85    pub enabled: bool,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub mediator_did: Option<String>,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub websocket_status: Option<String>,
90}
91
92/// Body returned by the server on `409 Conflict` from
93/// `POST /services/didcomm/enable` when DIDComm is already active.
94#[derive(Debug, Clone, Deserialize)]
95pub struct EnableDidcommConflictBody {
96    pub error: String,
97    #[serde(default)]
98    pub mediator_did: Option<String>,
99}
100
101/// Request body for `POST /services/didcomm/disable`.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct DisableDidcommRequest {
104    /// Drain TTL in seconds. 0 = immediate teardown (REST only;
105    /// over DIDComm transport, minimum 1h is enforced server-side).
106    pub drain_ttl_secs: u64,
107}
108
109impl DisableDidcommRequest {
110    pub fn new(drain_ttl_secs: u64) -> Self {
111        Self { drain_ttl_secs }
112    }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct DisableDidcommResponse {
117    pub new_version_id: String,
118    pub prior_mediator_did: String,
119    /// `Some(rfc3339)` when the listener entered drain state;
120    /// `None` when it was torn down immediately.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub drains_until: Option<String>,
123    /// The VTA's own DID. See [`EnableDidcommResponse::vta_did`].
124    #[serde(default, skip_serializing_if = "String::is_empty")]
125    pub vta_did: String,
126    /// True when the VTA's DID is self-hosted. See
127    /// [`EnableDidcommResponse::serverless`].
128    #[serde(default)]
129    pub serverless: bool,
130}
131
132#[cfg(feature = "client")]
133impl VtaClient {
134    /// Enable DIDComm on a REST-only VTA. Spec: success criterion #1.
135    ///
136    /// The VTA must be configured with a vta_did, must currently
137    /// have `services.didcomm = false`, and the caller must have
138    /// super-admin role. On success, the VTA publishes a new WebVH
139    /// LogEntry advertising the mediator and registers it as
140    /// active.
141    ///
142    /// **First-enable handshake:** the route runs a transient
143    /// `DIDCommService` round-trip against the candidate mediator
144    /// before publishing — see
145    /// `vta_service::messaging::transient_handshake`. The operation
146    /// itself uses [`AlwaysOkProver`] because the steady-state
147    /// `DIDCommService` doesn't exist yet at first-enable. The
148    /// live-prover path through `update_didcomm` covers the
149    /// steady-state case.
150    pub async fn enable_didcomm(
151        &self,
152        req: EnableDidcommRequest,
153    ) -> Result<EnableDidcommResponse, VtaError> {
154        match &self.transport {
155            crate::client::Transport::Rest {
156                client,
157                base_url,
158                auth,
159            } => {
160                Self::ensure_token_valid(client, base_url, auth).await?;
161                let token = auth.lock().await.token.clone();
162                let req = client
163                    .post(format!("{base_url}/services/didcomm/enable"))
164                    .json(&req);
165                let resp = Self::with_auth_token(req, &token).send().await?;
166                Self::handle_response(resp).await
167            }
168            #[cfg(feature = "session")]
169            crate::client::Transport::DIDComm { .. } => Err(VtaError::UnsupportedTransport(
170                "enable_didcomm is REST-only".into(),
171            )),
172            #[cfg(feature = "tsp")]
173            crate::client::Transport::Tsp { .. } => Err(VtaError::UnsupportedTransport(
174                "enable_didcomm is REST-only".into(),
175            )),
176        }
177    }
178
179    /// Read the current DIDComm runtime status. Auth: super-admin (parity with
180    /// `GET /services` / `list_services`, which exposes the same `mediator_did`).
181    pub async fn didcomm_status(&self) -> Result<DidcommStatusResponse, VtaError> {
182        match &self.transport {
183            crate::client::Transport::Rest {
184                client,
185                base_url,
186                auth,
187            } => {
188                crate::client::VtaClient::ensure_token_valid(client, base_url, auth).await?;
189                let token = auth.lock().await.token.clone();
190                let req = client.get(format!("{base_url}/services/didcomm"));
191                let resp = crate::client::VtaClient::with_auth_token(req, &token)
192                    .send()
193                    .await?;
194                crate::client::VtaClient::handle_response(resp).await
195            }
196            #[cfg(feature = "session")]
197            crate::client::Transport::DIDComm { .. } => Err(VtaError::UnsupportedTransport(
198                "didcomm status is REST-only in the SDK".into(),
199            )),
200            #[cfg(feature = "tsp")]
201            crate::client::Transport::Tsp { .. } => Err(VtaError::UnsupportedTransport(
202                "didcomm status is REST-only in the SDK".into(),
203            )),
204        }
205    }
206
207    /// Disable DIDComm. Refuses if REST is also disabled
208    /// (`NoProtocolRemaining`). Drain TTL semantics:
209    /// - `0` = immediate teardown (REST transport only).
210    /// - `>= 3600` = drain window over DIDComm transport (server
211    ///   enforces 1h minimum).
212    pub async fn disable_didcomm(
213        &self,
214        req: DisableDidcommRequest,
215    ) -> Result<DisableDidcommResponse, VtaError> {
216        self.rpc(
217            protocol_management::DISABLE_DIDCOMM,
218            serde_json::to_value(&req)?,
219            protocol_management::DISABLE_DIDCOMM_RESULT,
220            30,
221            |c, url| c.post(format!("{url}/services/didcomm/disable")).json(&req),
222        )
223        .await
224    }
225
226    /// Update which DIDComm mediator the VTA's `#vta-didcomm`
227    /// service entry advertises. Runs the pre-promotion handshake
228    /// against the new mediator and places the prior mediator in
229    /// drain state for the requested TTL.
230    ///
231    /// (T2.3 rename — was `migrate_mediator`. Operation is the
232    /// same; the naming aligns with the unified `services
233    /// {kind} {verb}` surface.)
234    pub async fn update_didcomm(
235        &self,
236        req: UpdateDidcommRequest,
237    ) -> Result<UpdateDidcommResponse, VtaError> {
238        self.rpc(
239            protocol_management::UPDATE_DIDCOMM,
240            serde_json::to_value(&req)?,
241            protocol_management::UPDATE_DIDCOMM_RESULT,
242            120,
243            |c, url| c.post(format!("{url}/services/didcomm/update")).json(&req),
244        )
245        .await
246    }
247
248    // ── REST service-management client methods (P1 wire types,
249    //    P5 client surface) ──────────────────────────────────────────
250
251    /// Enable REST advertisement on the VTA's DID document by
252    /// publishing a `#vta-rest` service entry. Spec §3.4.
253    pub async fn enable_rest(
254        &self,
255        req: services::EnableRestRequest,
256    ) -> Result<services::ServiceMutationResponse, VtaError> {
257        self.rpc(
258            protocol_management::ENABLE_REST,
259            serde_json::to_value(&req)?,
260            protocol_management::ENABLE_REST_RESULT,
261            30,
262            |c, url| c.post(format!("{url}/services/rest/enable")).json(&req),
263        )
264        .await
265    }
266
267    /// Update the URL on the existing `#vta-rest` service entry.
268    pub async fn update_rest(
269        &self,
270        req: services::UpdateRestRequest,
271    ) -> Result<services::ServiceMutationResponse, VtaError> {
272        self.rpc(
273            protocol_management::UPDATE_REST,
274            serde_json::to_value(&req)?,
275            protocol_management::UPDATE_REST_RESULT,
276            30,
277            |c, url| c.post(format!("{url}/services/rest/update")).json(&req),
278        )
279        .await
280    }
281
282    /// Remove the `#vta-rest` entry from the VTA's DID document.
283    /// Refused with `LastServiceRefused` when DIDComm is also off.
284    pub async fn disable_rest(
285        &self,
286        req: services::DisableRestRequest,
287    ) -> Result<services::ServiceMutationResponse, VtaError> {
288        self.rpc(
289            protocol_management::DISABLE_REST,
290            serde_json::to_value(&req)?,
291            protocol_management::DISABLE_REST_RESULT,
292            30,
293            |c, url| c.post(format!("{url}/services/rest/disable")).json(&req),
294        )
295        .await
296    }
297
298    // ── Fail-forward rollback client methods (P3 server, P5
299    //    client surface) ──────────────────────────────────────────
300
301    /// Fail-forward the most recent REST mutation by re-applying
302    /// the snapshotted prior state. Spec §3.5a.
303    pub async fn rollback_rest(
304        &self,
305        req: services::RollbackRestRequest,
306    ) -> Result<services::RollbackResponse, VtaError> {
307        self.rpc(
308            protocol_management::ROLLBACK_REST,
309            serde_json::to_value(&req)?,
310            protocol_management::ROLLBACK_REST_RESULT,
311            60,
312            |c, url| c.post(format!("{url}/services/rest/rollback")).json(&req),
313        )
314        .await
315    }
316
317    // ── TSP service-management client methods ──────────────────────
318    //
319    // TSP advertises a **mediator DID** (the VTA's TSP VID), not a
320    // URL — so these mirror the REST methods exactly except for the
321    // request types carrying `mediator_did` and the `/services/tsp/…`
322    // paths. All four go through `self.rpc(…)` (reachable over both
323    // REST and DIDComm); `enable_tsp` is NOT REST-only the way
324    // `enable_didcomm` is.
325
326    /// Enable TSP advertisement on the VTA's DID document by
327    /// publishing a `#tsp` service entry pointing at the mediator
328    /// DID. Spec §3.4.
329    pub async fn enable_tsp(
330        &self,
331        req: services::EnableTspRequest,
332    ) -> Result<services::ServiceMutationResponse, VtaError> {
333        self.rpc(
334            protocol_management::ENABLE_TSP,
335            serde_json::to_value(&req)?,
336            protocol_management::ENABLE_TSP_RESULT,
337            30,
338            |c, url| c.post(format!("{url}/services/tsp/enable")).json(&req),
339        )
340        .await
341    }
342
343    /// Update the mediator DID on the existing `#tsp` service entry.
344    pub async fn update_tsp(
345        &self,
346        req: services::UpdateTspRequest,
347    ) -> Result<services::ServiceMutationResponse, VtaError> {
348        self.rpc(
349            protocol_management::UPDATE_TSP,
350            serde_json::to_value(&req)?,
351            protocol_management::UPDATE_TSP_RESULT,
352            30,
353            |c, url| c.post(format!("{url}/services/tsp/update")).json(&req),
354        )
355        .await
356    }
357
358    /// Remove the `#tsp` entry from the VTA's DID document.
359    /// Refused with `LastServiceRefused` when TSP is the only
360    /// advertised transport.
361    pub async fn disable_tsp(
362        &self,
363        req: services::DisableTspRequest,
364    ) -> Result<services::ServiceMutationResponse, VtaError> {
365        self.rpc(
366            protocol_management::DISABLE_TSP,
367            serde_json::to_value(&req)?,
368            protocol_management::DISABLE_TSP_RESULT,
369            30,
370            |c, url| c.post(format!("{url}/services/tsp/disable")).json(&req),
371        )
372        .await
373    }
374
375    /// Fail-forward the most recent TSP mutation by re-applying the
376    /// snapshotted prior state. Spec §3.5a.
377    pub async fn rollback_tsp(
378        &self,
379        req: services::RollbackTspRequest,
380    ) -> Result<services::RollbackResponse, VtaError> {
381        self.rpc(
382            protocol_management::ROLLBACK_TSP,
383            serde_json::to_value(&req)?,
384            protocol_management::ROLLBACK_TSP_RESULT,
385            60,
386            |c, url| c.post(format!("{url}/services/tsp/rollback")).json(&req),
387        )
388        .await
389    }
390
391    // ── WebAuthn service-management client methods ─────────────────
392
393    /// Enable WebAuthn-RP advertisement on the VTA's DID document by
394    /// publishing a `#vta-webauthn` service entry.
395    pub async fn enable_webauthn(
396        &self,
397        req: services::EnableWebauthnRequest,
398    ) -> Result<services::ServiceMutationResponse, VtaError> {
399        self.rpc(
400            protocol_management::ENABLE_WEBAUTHN,
401            serde_json::to_value(&req)?,
402            protocol_management::ENABLE_WEBAUTHN_RESULT,
403            30,
404            |c, url| c.post(format!("{url}/services/webauthn/enable")).json(&req),
405        )
406        .await
407    }
408
409    /// Update the URL on the existing `#vta-webauthn` entry.
410    pub async fn update_webauthn(
411        &self,
412        req: services::UpdateWebauthnRequest,
413    ) -> Result<services::ServiceMutationResponse, VtaError> {
414        self.rpc(
415            protocol_management::UPDATE_WEBAUTHN,
416            serde_json::to_value(&req)?,
417            protocol_management::UPDATE_WEBAUTHN_RESULT,
418            30,
419            |c, url| c.post(format!("{url}/services/webauthn/update")).json(&req),
420        )
421        .await
422    }
423
424    /// Remove the `#vta-webauthn` entry AND strip passkey VMs from
425    /// every DID this VTA controls (hard-disable semantics).
426    /// Refused with `LastServiceRefused` when removing WebAuthn
427    /// would leave no transport advertised.
428    pub async fn disable_webauthn(
429        &self,
430        req: services::DisableWebauthnRequest,
431    ) -> Result<services::ServiceMutationResponse, VtaError> {
432        // Longer timeout than REST/DIDComm disable because the
433        // passkey-VM cleanup iterates every DID this VTA controls
434        // and publishes a WebVH update per affected DID.
435        self.rpc(
436            protocol_management::DISABLE_WEBAUTHN,
437            serde_json::to_value(&req)?,
438            protocol_management::DISABLE_WEBAUTHN_RESULT,
439            300,
440            |c, url| {
441                c.post(format!("{url}/services/webauthn/disable"))
442                    .json(&req)
443            },
444        )
445        .await
446    }
447
448    /// Fail-forward the most recent WebAuthn mutation by re-applying
449    /// the snapshotted prior state.
450    pub async fn rollback_webauthn(
451        &self,
452        req: services::RollbackWebauthnRequest,
453    ) -> Result<services::RollbackResponse, VtaError> {
454        self.rpc(
455            protocol_management::ROLLBACK_WEBAUTHN,
456            serde_json::to_value(&req)?,
457            protocol_management::ROLLBACK_WEBAUTHN_RESULT,
458            300,
459            |c, url| {
460                c.post(format!("{url}/services/webauthn/rollback"))
461                    .json(&req)
462            },
463        )
464        .await
465    }
466
467    /// Fail-forward the most recent DIDComm mutation. Threads
468    /// `drain_ttl_secs` through to the dispatched forward op for
469    /// the disable / update arms.
470    pub async fn rollback_didcomm(
471        &self,
472        req: services::RollbackDidcommRequest,
473    ) -> Result<services::RollbackResponse, VtaError> {
474        self.rpc(
475            protocol_management::ROLLBACK_DIDCOMM,
476            serde_json::to_value(&req)?,
477            protocol_management::ROLLBACK_DIDCOMM_RESULT,
478            120,
479            |c, url| {
480                c.post(format!("{url}/services/didcomm/rollback"))
481                    .json(&req)
482            },
483        )
484        .await
485    }
486
487    // ── Read-only inspection (P4 server, P5 client surface) ──────
488
489    /// Inspect the VTA's currently-advertised transport services.
490    /// Returns one entry per kind in canonical DIDComm-then-REST
491    /// order.
492    pub async fn list_services(&self) -> Result<services::ServicesListResponse, VtaError> {
493        self.rpc(
494            protocol_management::LIST_SERVICES,
495            serde_json::Value::Null,
496            protocol_management::LIST_SERVICES_RESULT,
497            30,
498            |c, url| c.get(format!("{url}/services")),
499        )
500        .await
501    }
502
503    /// List currently-draining mediators. Empty list is normal.
504    pub async fn list_drain(&self) -> Result<services::DrainListResponse, VtaError> {
505        self.rpc(
506            protocol_management::LIST_DRAIN,
507            serde_json::Value::Null,
508            protocol_management::LIST_DRAIN_RESULT,
509            30,
510            |c, url| c.get(format!("{url}/services/didcomm/drain")),
511        )
512        .await
513    }
514}
515
516/// Request body for `POST /services/didcomm/update`.
517#[derive(Debug, Clone, Serialize, Deserialize)]
518#[must_use]
519pub struct UpdateDidcommRequest {
520    pub new_mediator_did: String,
521    pub drain_ttl_secs: u64,
522    #[serde(default)]
523    pub force: bool,
524    #[serde(default, skip_serializing_if = "Option::is_none")]
525    pub handshake_timeout_secs: Option<u64>,
526    /// Tag the operation as a rollback in telemetry.
527    #[serde(default)]
528    pub rollback: bool,
529}
530
531impl UpdateDidcommRequest {
532    pub fn new(new_mediator_did: impl Into<String>, drain_ttl_secs: u64) -> Self {
533        Self {
534            new_mediator_did: new_mediator_did.into(),
535            drain_ttl_secs,
536            force: false,
537            handshake_timeout_secs: None,
538            rollback: false,
539        }
540    }
541
542    pub fn force(mut self, force: bool) -> Self {
543        self.force = force;
544        self
545    }
546
547    pub fn rollback(mut self, rollback: bool) -> Self {
548        self.rollback = rollback;
549        self
550    }
551
552    pub fn handshake_timeout_secs(mut self, secs: u64) -> Self {
553        self.handshake_timeout_secs = Some(secs);
554        self
555    }
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize)]
559pub struct UpdateDidcommResponse {
560    pub new_version_id: String,
561    pub prior_mediator_did: String,
562    pub active_mediator_did: String,
563    pub active_mediator_endpoint: String,
564    pub drains_until: String,
565    /// The VTA's own DID. See [`EnableDidcommResponse::vta_did`].
566    #[serde(default, skip_serializing_if = "String::is_empty")]
567    pub vta_did: String,
568    /// True when the VTA's DID is self-hosted. See
569    /// [`EnableDidcommResponse::serverless`].
570    #[serde(default)]
571    pub serverless: bool,
572}
573
574/// Request body for `POST /mediators/drain/cancel`.
575#[derive(Debug, Clone, Serialize, Deserialize)]
576pub struct DrainCancelRequest {
577    pub mediator_did: String,
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize)]
581pub struct DrainCancelResponse {
582    pub mediator_did: String,
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize)]
586pub struct MediatorStats {
587    pub mediator_did: String,
588    pub inbound_count: u64,
589    pub first_seen: String,
590    pub last_seen: String,
591}
592
593#[derive(Debug, Clone, Serialize, Deserialize)]
594pub struct SenderLastSeen {
595    pub sender_did: String,
596    pub last_seen_mediator: String,
597    pub last_seen_at: String,
598}
599
600#[derive(Debug, Clone, Serialize, Deserialize)]
601pub struct MediatorReport {
602    #[serde(default)]
603    pub since: Option<String>,
604    pub until: String,
605    pub mediators: Vec<MediatorStats>,
606    pub senders: Vec<SenderLastSeen>,
607}
608
609#[cfg(feature = "client")]
610impl VtaClient {
611    /// Cancel a drain entry early, dropping the listener for that
612    /// mediator immediately. Refuses if the named DID is the
613    /// active mediator (use `services disable didcomm` instead) or
614    /// not registered at all.
615    pub async fn drain_cancel(
616        &self,
617        req: DrainCancelRequest,
618    ) -> Result<DrainCancelResponse, VtaError> {
619        self.rpc(
620            protocol_management::DRAIN_CANCEL,
621            serde_json::to_value(&req)?,
622            protocol_management::DRAIN_CANCEL_RESULT,
623            30,
624            |c, url| c.post(format!("{url}/mediators/drain/cancel")).json(&req),
625        )
626        .await
627    }
628
629    /// Query the mediator-attribution report. `since`/`until` are
630    /// optional RFC 3339 timestamps. Returns per-mediator inbound
631    /// counts and per-sender last-seen mediator (so operators can
632    /// spot senders still using the prior mediator after a
633    /// migrate).
634    pub async fn mediator_report(
635        &self,
636        since: Option<&str>,
637        until: Option<&str>,
638    ) -> Result<MediatorReport, VtaError> {
639        let since_owned = since.map(str::to_string);
640        let until_owned = until.map(str::to_string);
641        let qs = build_report_query(since_owned.as_deref(), until_owned.as_deref());
642        self.rpc(
643            protocol_management::MEDIATOR_REPORT,
644            serde_json::json!({
645                "since": since_owned,
646                "until": until_owned,
647            }),
648            protocol_management::MEDIATOR_REPORT_RESULT,
649            30,
650            move |c, url| {
651                let url = if qs.is_empty() {
652                    format!("{url}/mediators/report")
653                } else {
654                    format!("{url}/mediators/report?{qs}")
655                };
656                c.get(url)
657            },
658        )
659        .await
660    }
661}
662
663#[cfg(feature = "client")]
664fn build_report_query(since: Option<&str>, until: Option<&str>) -> String {
665    let mut parts: Vec<String> = Vec::new();
666    if let Some(s) = since {
667        parts.push(format!("since={}", url_encode(s)));
668    }
669    if let Some(u) = until {
670        parts.push(format!("until={}", url_encode(u)));
671    }
672    parts.join("&")
673}
674
675#[cfg(feature = "client")]
676fn url_encode(s: &str) -> String {
677    // RFC 3339 timestamps contain `:` and `+`; the latter is the
678    // form-urlencoded representation of a space and would mangle
679    // the timestamp on the server side. Encode the unsafe chars
680    // explicitly.
681    s.chars()
682        .flat_map(|c| match c {
683            ':' => "%3A".chars().collect::<Vec<_>>(),
684            '+' => "%2B".chars().collect::<Vec<_>>(),
685            _ => vec![c],
686        })
687        .collect()
688}