Skip to main content

trql_client/
discovery.rs

1//! Transport discovery from a registry's DID document.
2//!
3//! A registry advertises the wires it speaks as `service` entries in its DID
4//! document. This module turns those entries into a [`ServiceCapabilities`]
5//! set and picks the transport to use — the highest-preference protocol
6//! **both** sides speak, in the workspace order **TSP > DIDComm > HTTPS**.
7//!
8//! Two rules the matching deliberately follows:
9//!
10//! * **Match on service `type`**, never on the `#id` fragment and never on the
11//!   endpoint's value shape. A TSP VID is a DID too, so "it looks like a DID,
12//!   therefore DIDComm" is wrong. Fragments are arbitrary labels — the OWF
13//!   reference TSP implementation names its id `#tsp-transport` while Affinidi
14//!   names it `#tsp`, for the same `TSPTransport` type.
15//! * **Never silently downgrade** past what the registry advertises. No shared
16//!   protocol is a typed [`TrqlError::NoMatchingTransport`], not a quiet
17//!   fallback to HTTPS.
18//!
19//! Resolution itself is the caller's job: this module is pure logic over an
20//! already-resolved document, so it needs no resolver dependency and works
21//! under any feature combination.
22//!
23//! ```rust,ignore
24//! let doc: serde_json::Value = resolve(registry_did).await?;
25//! let caps = ServiceCapabilities::from_document(&doc);
26//! let choice = caps.select(TransportKind::compiled())?;
27//! match choice.kind {
28//!     TransportKind::Https => /* build HttpsTransport with choice.endpoint */,
29//!     // TSP/DIDComm endpoints are the registry's *mediator* DID — resolve
30//!     // onward for the transport URL, or hand it to an ATM profile.
31//!     _ => { /* ... */ }
32//! }
33//! ```
34
35use serde_json::Value;
36
37use crate::error::TrqlError;
38use crate::transport::TransportKind;
39
40/// DID-document service `type` for a TSP transport endpoint.
41///
42/// `TSPTransport` is the OpenWallet-Foundation-Labs reference-implementation
43/// convention; the ToIP TSP spec names no DID-document service type. Kept in
44/// sync with `vta_sdk::protocol::matching::TSP_SERVICE_TYPE` and the registry's
45/// own `didcomm::did_document::TSP_SERVICE_TYPE`.
46pub const TSP_SERVICE_TYPE: &str = "TSPTransport";
47
48/// DID-document service `type` for a DIDComm v2 mediator endpoint (W3C).
49pub const DIDCOMM_SERVICE_TYPE: &str = "DIDCommMessaging";
50
51/// DID-document service `type` for a Trust Registry's REST/TRQP surface.
52///
53/// Names the interface served — TRQP over REST — matching how the sibling
54/// types name protocols rather than products.
55pub const REST_SERVICE_TYPE: &str = "TRQPRest";
56
57/// A VTA's REST API service `type`.
58///
59/// Accepted when discovering a peer because a caller may point this client at
60/// a VTA-hosted endpoint, and because `vta-sdk` and `vta-service` continue to
61/// use it — correctly, for VTAs. A Trust Registry must **not** advertise it:
62/// see `trust_registry::didcomm::did_document::REST_SERVICE_TYPE`.
63pub const VTA_REST_SERVICE_TYPE: &str = "VTARest";
64
65/// Every service `type` that denotes a REST endpoint, in match order.
66///
67/// Matching a set rather than one string is what lets a consumer discover both
68/// kinds of peer without either having to claim the other's identity.
69///
70/// [`TRUST_REGISTRY_SERVICE_TYPE`] is deliberately **absent**. It is not a
71/// transport: in a VTC's document it carries a DID, and admitting it here
72/// would land that DID in `ServiceCapabilities::https`, where [`select`] would
73/// hand an HTTPS transport a `did:webvh:` string to POST to.
74///
75/// [`select`]: ServiceCapabilities::select
76pub const REST_SERVICE_TYPES: [&str; 2] = [REST_SERVICE_TYPE, VTA_REST_SERVICE_TYPE];
77
78/// DID-document service `type` for a Trust Registry, per the
79/// [ToIP Trust Registry Service Profile][profile].
80///
81/// The type means two different things depending on whose document carries it,
82/// and the difference is which kind of URI the endpoint holds:
83///
84/// * in a **registry's own** document — an endpoint, alongside `TRQPRest`,
85///   pointing at the registry's TRQP surface;
86/// * in a **VTC's** document — a referral, whose `uri` is the *DID* of the
87///   registry authoritative for that community.
88///
89/// [`registry_referral`] draws that line. TRQP v2 recommends the referral form
90/// without naming a service type, which is why this one comes from the Service
91/// Profile spec rather than the protocol spec.
92///
93/// [profile]: https://github.com/trustoverip/tswg-trust-registry-service-profile/blob/main/spec.md
94pub const TRUST_REGISTRY_SERVICE_TYPE: &str = "TrustRegistry";
95
96/// Transports in descending preference order: TSP, then DIDComm, then HTTPS.
97///
98/// TSP is preferred where both sides speak it because it keeps intermediaries
99/// blind to routing metadata; HTTPS is the floor.
100pub const PREFERENCE_ORDER: [TransportKind; 3] = [
101    TransportKind::Tsp,
102    TransportKind::Didcomm,
103    TransportKind::Https,
104];
105
106impl TransportKind {
107    /// The DID-document service `type` that advertises this transport.
108    #[must_use]
109    pub fn service_type(self) -> &'static str {
110        match self {
111            Self::Tsp => TSP_SERVICE_TYPE,
112            Self::Didcomm => DIDCOMM_SERVICE_TYPE,
113            Self::Https => REST_SERVICE_TYPE,
114        }
115    }
116
117    /// Whether this build can actually construct this transport.
118    #[must_use]
119    pub fn is_compiled(self) -> bool {
120        match self {
121            Self::Tsp => cfg!(feature = "tsp"),
122            Self::Didcomm => cfg!(feature = "didcomm"),
123            Self::Https => cfg!(feature = "https"),
124        }
125    }
126
127    /// The transports compiled into this build, in preference order.
128    ///
129    /// Selecting against this rather than a hard-coded list means a binary
130    /// built without `--features tsp` will not choose TSP and then fail to
131    /// construct the transport.
132    #[must_use]
133    pub fn compiled() -> Vec<TransportKind> {
134        PREFERENCE_ORDER
135            .into_iter()
136            .filter(|k| k.is_compiled())
137            .collect()
138    }
139}
140
141/// The transports a registry advertises, parsed from its DID document by
142/// service `type`.
143///
144/// Each field holds the endpoint to route to for that protocol:
145///
146/// * `tsp` / `didcomm` — the registry's **mediator DID**, not a transport URL.
147///   Both use mediator indirection; the URL lives in the mediator's own DID
148///   document, so a second resolution hop is required.
149/// * `https` — the registry's REST **base URL**, used directly.
150#[derive(Debug, Clone, Default, PartialEq, Eq)]
151pub struct ServiceCapabilities {
152    /// Mediator DID advertised for TSP, if any.
153    pub tsp: Option<String>,
154    /// Mediator DID advertised for DIDComm, if any.
155    pub didcomm: Option<String>,
156    /// REST base URL advertised, if any.
157    pub https: Option<String>,
158}
159
160/// The transport chosen for a registry, and where to send.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct TransportChoice {
163    /// The selected binding.
164    pub kind: TransportKind,
165    /// Where to route: the registry's **mediator DID** for TSP/DIDComm (resolve
166    /// onward), its **base URL** for HTTPS.
167    pub endpoint: String,
168}
169
170impl ServiceCapabilities {
171    /// Parse the `service` array of a resolved DID document.
172    ///
173    /// Unknown service types are ignored, entries missing a usable endpoint are
174    /// skipped, and the first entry of each type wins — a document advertising
175    /// two DIDComm services is not an error, it just has one preferred.
176    #[must_use]
177    pub fn from_document(doc: &Value) -> Self {
178        let mut caps = Self::default();
179        let Some(services) = doc.get("service").and_then(Value::as_array) else {
180            return caps;
181        };
182        for svc in services {
183            let Some(uri) = svc.get("serviceEndpoint").and_then(endpoint_uri) else {
184                continue;
185            };
186            if uri.is_empty() {
187                continue;
188            }
189            if service_has_type(svc, TSP_SERVICE_TYPE) {
190                caps.tsp.get_or_insert(uri);
191            } else if service_has_type(svc, DIDCOMM_SERVICE_TYPE) {
192                caps.didcomm.get_or_insert(uri);
193            } else if REST_SERVICE_TYPES.iter().any(|t| service_has_type(svc, t)) {
194                caps.https.get_or_insert(uri);
195            }
196        }
197        caps
198    }
199
200    /// The endpoint advertised for `kind`, if any.
201    #[must_use]
202    pub fn endpoint(&self, kind: TransportKind) -> Option<&str> {
203        match kind {
204            TransportKind::Tsp => self.tsp.as_deref(),
205            TransportKind::Didcomm => self.didcomm.as_deref(),
206            TransportKind::Https => self.https.as_deref(),
207        }
208    }
209
210    /// Every transport advertised, in preference order.
211    #[must_use]
212    pub fn advertised(&self) -> Vec<TransportKind> {
213        PREFERENCE_ORDER
214            .into_iter()
215            .filter(|k| self.endpoint(*k).is_some())
216            .collect()
217    }
218
219    /// Choose the transport to use: the highest-preference one present in both
220    /// `ours` and this capability set.
221    ///
222    /// Returns [`TrqlError::NoMatchingTransport`] carrying both sides' sets
223    /// when the intersection is empty, so an operator can see what each side
224    /// offers rather than guessing why a query failed.
225    pub fn select(&self, ours: &[TransportKind]) -> Result<TransportChoice, TrqlError> {
226        for kind in PREFERENCE_ORDER {
227            if ours.contains(&kind)
228                && let Some(endpoint) = self.endpoint(kind)
229            {
230                return Ok(TransportChoice {
231                    kind,
232                    endpoint: endpoint.to_string(),
233                });
234            }
235        }
236        Err(TrqlError::NoMatchingTransport {
237            ours: ours.to_vec(),
238            theirs: self.advertised(),
239        })
240    }
241}
242
243/// The DID this document refers a TRQP query on to, if it refers at all.
244///
245/// A `TrustRegistry` entry is a **referral** when its `uri` is a DID other
246/// than this document's own `id` — the shape a VTC publishes to name the
247/// registry authoritative for it. Any other `TrustRegistry` entry is an
248/// **endpoint** (the registry describing its own surface) and yields `None`,
249/// so the caller parses capabilities from the document it already has.
250///
251/// The `did:` test is unambiguous because the other DID-valued endpoints in a
252/// document are mediator addresses, and those always carry
253/// `DIDCommMessaging` or `TSPTransport`, never `TrustRegistry`.
254///
255/// # Following a referral
256///
257/// Resolve the returned DID, then parse [`ServiceCapabilities`] from *that*
258/// document:
259///
260/// ```ignore
261/// let mut doc = resolve(start_did).await?;
262/// if let Some(target) = registry_referral(&doc) {
263///     doc = resolve(&target).await?;   // one hop, no loop
264/// }
265/// let choice = ServiceCapabilities::from_document(&doc).select(&TransportKind::compiled())?;
266/// ```
267///
268/// **Cap at one hop.** TRQP's wording assumes the registry's own document
269/// holds the endpoints, so a second referral is a misconfiguration rather than
270/// a chain to follow, and chasing it invites cycles. This function is
271/// deliberately pure and single-shot: it cannot resolve, so it cannot loop.
272///
273/// # This does not establish authority
274///
275/// A referral is a **self-assertion**. Anyone can publish a document naming
276/// any registry, and nothing here checks that the named registry agrees.
277/// Authority flows registry → subject, never the reverse, so a caller that
278/// follows a referral must close the loop: confirm the registry's answer
279/// carries an `authority_id` equal to the DID the referral started from.
280/// Until then the referral has established *where to ask* and nothing about
281/// the answer.
282#[must_use]
283pub fn registry_referral(doc: &Value) -> Option<String> {
284    let own_id = doc.get("id").and_then(Value::as_str);
285    doc.get("service")
286        .and_then(Value::as_array)?
287        .iter()
288        .filter(|svc| service_has_type(svc, TRUST_REGISTRY_SERVICE_TYPE))
289        .filter_map(|svc| svc.get("serviceEndpoint").and_then(endpoint_uri))
290        .find(|uri| uri.starts_with("did:") && Some(uri.as_str()) != own_id)
291}
292
293/// Does this service entry carry `type_`?
294///
295/// `type` may be a string or an array of strings per the DID Core spec.
296fn service_has_type(svc: &Value, type_: &str) -> bool {
297    match svc.get("type") {
298        Some(Value::String(s)) => s == type_,
299        Some(Value::Array(arr)) => arr.iter().any(|t| t.as_str() == Some(type_)),
300        _ => false,
301    }
302}
303
304/// Resolve a `serviceEndpoint` to its URI, tolerating the three shapes a DID
305/// document may carry it in: a plain string (the TSP/REST convention), an
306/// object with a `uri` field (DIDComm v2), or an array of either.
307fn endpoint_uri(endpoint: &Value) -> Option<String> {
308    match endpoint {
309        Value::String(s) => Some(s.clone()),
310        Value::Object(map) => map.get("uri")?.as_str().map(str::to_string),
311        Value::Array(arr) => arr.iter().find_map(endpoint_uri),
312        _ => None,
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use serde_json::json;
320
321    fn doc(services: Value) -> Value {
322        json!({ "id": "did:webvh:registry.example", "service": services })
323    }
324
325    const ALL: [TransportKind; 3] = [
326        TransportKind::Tsp,
327        TransportKind::Didcomm,
328        TransportKind::Https,
329    ];
330
331    // --- VTC → registry referral ---
332
333    /// A VTC names the registry authoritative for it: same service `type`,
334    /// but the endpoint holds a DID rather than a URL.
335    #[test]
336    fn a_vtc_pointing_at_a_registry_did_is_a_referral() {
337        let vtc = json!({
338            "id": "did:webvh:QmVtcScid:community.example",
339            "service": [
340                { "id": "#trust-registry", "type": "TrustRegistry",
341                  "serviceEndpoint": { "uri": "did:webvh:QmRegistryScid:registry.example",
342                                       "profile": "https://trustoverip.org/profiles/trqp/v2" } },
343                { "id": "#didcomm", "type": "DIDCommMessaging",
344                  "serviceEndpoint": { "uri": "did:web:mediator.example" } },
345            ]
346        });
347        assert_eq!(
348            registry_referral(&vtc).as_deref(),
349            Some("did:webvh:QmRegistryScid:registry.example")
350        );
351    }
352
353    /// The same type in the registry's own document describes its surface, so
354    /// there is nowhere to be referred to — the caller uses this document.
355    #[test]
356    fn a_registry_describing_its_own_surface_is_not_a_referral() {
357        let registry = json!({
358            "id": "did:webvh:QmRegistryScid:registry.example",
359            "service": [
360                { "id": "#rest", "type": ["TRQPRest", "TrustRegistry"],
361                  "serviceEndpoint": { "uri": "https://registry.example",
362                                       "profile": "https://trustoverip.org/profiles/trqp/v2" } },
363            ]
364        });
365        assert_eq!(registry_referral(&registry), None);
366    }
367
368    /// A document naming *itself* is a misconfiguration, not a hop: following
369    /// it would resolve the same document forever.
370    #[test]
371    fn a_self_referential_entry_is_not_a_referral() {
372        let doc = json!({
373            "id": "did:webvh:QmRegistryScid:registry.example",
374            "service": [
375                { "id": "#trust-registry", "type": "TrustRegistry",
376                  "serviceEndpoint": "did:webvh:QmRegistryScid:registry.example" },
377            ]
378        });
379        assert_eq!(registry_referral(&doc), None);
380    }
381
382    /// The trap §5 of the design note calls out: a referral DID must never be
383    /// treated as a REST base URL, or `select` hands an HTTPS transport a DID
384    /// to POST to.
385    #[test]
386    fn a_referral_did_never_becomes_an_https_endpoint() {
387        let vtc = json!({
388            "id": "did:webvh:QmVtcScid:community.example",
389            "service": [
390                { "id": "#trust-registry", "type": "TrustRegistry",
391                  "serviceEndpoint": { "uri": "did:webvh:QmRegistryScid:registry.example" } },
392            ]
393        });
394        let caps = ServiceCapabilities::from_document(&vtc);
395        assert_eq!(caps, ServiceCapabilities::default(), "{caps:?}");
396        assert!(
397            caps.select(&ALL).is_err(),
398            "a referral advertises no transport of its own"
399        );
400    }
401
402    /// Mediator entries are DID-valued too; only `TrustRegistry` ones refer.
403    #[test]
404    fn a_mediator_did_is_not_mistaken_for_a_referral() {
405        let doc = json!({
406            "id": "did:webvh:QmRegistryScid:registry.example",
407            "service": [
408                { "id": "#tsp", "type": "TSPTransport", "serviceEndpoint": "did:web:mediator" },
409                { "id": "#didcomm", "type": "DIDCommMessaging",
410                  "serviceEndpoint": { "uri": "did:web:mediator" } },
411            ]
412        });
413        assert_eq!(registry_referral(&doc), None);
414    }
415
416    /// Both halves of the walk, in the order a caller performs them: the VTC
417    /// refers, the registry's own document supplies the transports.
418    #[test]
419    fn one_hop_lands_on_the_registrys_capabilities() {
420        let vtc = json!({
421            "id": "did:webvh:QmVtcScid:community.example",
422            "service": [{ "id": "#trust-registry", "type": "TrustRegistry",
423                          "serviceEndpoint": { "uri": "did:webvh:QmRegistryScid:registry.example" } }]
424        });
425        let registry = json!({
426            "id": "did:webvh:QmRegistryScid:registry.example",
427            "service": [
428                { "id": "#rest", "type": ["TRQPRest", "TrustRegistry"],
429                  "serviceEndpoint": { "uri": "https://registry.example" } },
430                { "id": "#tsp", "type": "TSPTransport", "serviceEndpoint": "did:web:mediator" },
431            ]
432        });
433
434        let target = registry_referral(&vtc).expect("the VTC refers");
435        assert_eq!(target, registry["id"].as_str().unwrap());
436        // Second hop is not taken: the registry's document does not refer on.
437        assert_eq!(registry_referral(&registry), None);
438
439        let choice = ServiceCapabilities::from_document(&registry)
440            .select(&ALL)
441            .unwrap();
442        assert_eq!(choice.kind, TransportKind::Tsp);
443        assert_eq!(choice.endpoint, "did:web:mediator");
444    }
445
446    #[test]
447    fn a_document_with_no_services_refers_nowhere() {
448        assert_eq!(registry_referral(&json!({ "id": "did:webvh:x" })), None);
449    }
450
451    #[test]
452    fn parses_each_service_type() {
453        let caps = ServiceCapabilities::from_document(&doc(json!([
454            { "id": "#tsp", "type": "TSPTransport", "serviceEndpoint": "did:web:mediator" },
455            { "id": "#didcomm", "type": "DIDCommMessaging",
456              "serviceEndpoint": { "uri": "did:web:mediator", "accept": ["didcomm/v2"] } },
457            { "id": "#rest", "type": "TRQPRest", "serviceEndpoint": "https://registry.example" },
458        ])));
459        assert_eq!(caps.tsp.as_deref(), Some("did:web:mediator"));
460        assert_eq!(caps.didcomm.as_deref(), Some("did:web:mediator"));
461        assert_eq!(caps.https.as_deref(), Some("https://registry.example"));
462    }
463
464    /// The registry's two DID-document builders emit different endpoint
465    /// shapes for the same service, so both must parse identically.
466    #[test]
467    fn tolerates_string_object_and_array_endpoints() {
468        for endpoint in [
469            json!("did:web:mediator"),
470            json!({ "uri": "did:web:mediator", "accept": ["didcomm/v2"] }),
471            json!([{ "uri": "did:web:mediator" }]),
472        ] {
473            let caps = ServiceCapabilities::from_document(&doc(json!([
474                { "id": "#x", "type": "DIDCommMessaging", "serviceEndpoint": endpoint }
475            ])));
476            assert_eq!(caps.didcomm.as_deref(), Some("did:web:mediator"));
477        }
478    }
479
480    /// Fragments are arbitrary labels; only `type` decides.
481    #[test]
482    fn matches_on_type_not_fragment() {
483        let caps = ServiceCapabilities::from_document(&doc(json!([
484            { "id": "did:x#tsp-transport", "type": "TSPTransport", "serviceEndpoint": "did:web:m" },
485            { "id": "did:x#tsp", "type": "TRQPRest", "serviceEndpoint": "https://r.example" },
486        ])));
487        assert_eq!(caps.tsp.as_deref(), Some("did:web:m"));
488        // The `#tsp`-fragmented entry is REST by type, and must be read as such.
489        assert_eq!(caps.https.as_deref(), Some("https://r.example"));
490    }
491
492    #[test]
493    fn type_may_be_an_array() {
494        let caps = ServiceCapabilities::from_document(&doc(json!([
495            { "id": "#m", "type": ["DIDCommMessaging", "Other"], "serviceEndpoint": "did:web:m" }
496        ])));
497        assert_eq!(caps.didcomm.as_deref(), Some("did:web:m"));
498    }
499
500    #[test]
501    fn ignores_unknown_types_empty_and_missing_endpoints() {
502        let caps = ServiceCapabilities::from_document(&doc(json!([
503            { "id": "#a", "type": "SomethingElse", "serviceEndpoint": "https://x" },
504            { "id": "#b", "type": "TRQPRest", "serviceEndpoint": "" },
505            { "id": "#c", "type": "TSPTransport" },
506            { "id": "#d", "type": "DIDCommMessaging", "serviceEndpoint": 42 },
507        ])));
508        assert_eq!(caps, ServiceCapabilities::default());
509        assert!(caps.advertised().is_empty());
510    }
511
512    #[test]
513    fn document_without_services_yields_nothing() {
514        assert_eq!(
515            ServiceCapabilities::from_document(&json!({ "id": "did:x" })),
516            ServiceCapabilities::default()
517        );
518    }
519
520    #[test]
521    fn first_entry_of_a_type_wins() {
522        let caps = ServiceCapabilities::from_document(&doc(json!([
523            { "id": "#r1", "type": "TRQPRest", "serviceEndpoint": "https://first.example" },
524            { "id": "#r2", "type": "TRQPRest", "serviceEndpoint": "https://second.example" },
525        ])));
526        assert_eq!(caps.https.as_deref(), Some("https://first.example"));
527    }
528
529    #[test]
530    fn selects_the_most_preferred_shared_transport() {
531        let caps = ServiceCapabilities {
532            tsp: Some("did:web:m".into()),
533            didcomm: Some("did:web:m".into()),
534            https: Some("https://r.example".into()),
535        };
536        assert_eq!(caps.select(&ALL).unwrap().kind, TransportKind::Tsp);
537
538        // We don't speak TSP -> next best.
539        let choice = caps
540            .select(&[TransportKind::Didcomm, TransportKind::Https])
541            .unwrap();
542        assert_eq!(choice.kind, TransportKind::Didcomm);
543        assert_eq!(choice.endpoint, "did:web:m");
544
545        // HTTPS-only client falls to REST and gets the URL, not the mediator.
546        let choice = caps.select(&[TransportKind::Https]).unwrap();
547        assert_eq!(choice.kind, TransportKind::Https);
548        assert_eq!(choice.endpoint, "https://r.example");
549    }
550
551    /// A registry that advertises only DIDComm must not be reached over HTTPS
552    /// merely because we can speak it — that is a silent downgrade past what
553    /// the peer offered.
554    #[test]
555    fn no_shared_transport_is_a_typed_error_not_a_fallback() {
556        let caps = ServiceCapabilities {
557            didcomm: Some("did:web:m".into()),
558            ..Default::default()
559        };
560        let err = caps.select(&[TransportKind::Https]).unwrap_err();
561        match err {
562            TrqlError::NoMatchingTransport { ours, theirs } => {
563                assert_eq!(ours, vec![TransportKind::Https]);
564                assert_eq!(theirs, vec![TransportKind::Didcomm]);
565            }
566            other => panic!("expected NoMatchingTransport, got {other:?}"),
567        }
568    }
569
570    /// A registry advertising nothing is the same failure, and must name that
571    /// it advertised nothing rather than blaming the client.
572    #[test]
573    fn empty_capabilities_report_an_empty_peer_set() {
574        let err = ServiceCapabilities::default()
575            .select(&ALL)
576            .expect_err("no transports advertised");
577        match err {
578            TrqlError::NoMatchingTransport { theirs, .. } => assert!(theirs.is_empty()),
579            other => panic!("expected NoMatchingTransport, got {other:?}"),
580        }
581    }
582
583    #[test]
584    fn no_matching_transport_is_not_retryable() {
585        let err = ServiceCapabilities::default().select(&ALL).unwrap_err();
586        assert!(!err.is_retryable());
587    }
588
589    #[test]
590    fn service_types_match_the_workspace_constants() {
591        assert_eq!(TransportKind::Tsp.service_type(), "TSPTransport");
592        assert_eq!(TransportKind::Didcomm.service_type(), "DIDCommMessaging");
593        assert_eq!(TransportKind::Https.service_type(), "TRQPRest");
594    }
595
596    /// A registry advertises `TRQPRest`; a VTA advertises `VTARest`. Both are
597    /// REST endpoints, and neither has to claim the other's type for a
598    /// consumer to find it.
599    #[test]
600    fn both_rest_type_names_are_discovered() {
601        for ty in ["TRQPRest", "VTARest"] {
602            let caps = ServiceCapabilities::from_document(&doc(json!([
603                { "id": "#rest", "type": ty, "serviceEndpoint": "https://r.example" }
604            ])));
605            assert_eq!(
606                caps.https.as_deref(),
607                Some("https://r.example"),
608                "{ty} must be recognised as REST"
609            );
610        }
611    }
612
613    #[test]
614    fn compiled_transports_are_in_preference_order() {
615        let compiled = TransportKind::compiled();
616        let expected: Vec<_> = PREFERENCE_ORDER
617            .into_iter()
618            .filter(|k| compiled.contains(k))
619            .collect();
620        assert_eq!(compiled, expected);
621        // The default feature set always includes HTTPS.
622        #[cfg(feature = "https")]
623        assert!(compiled.contains(&TransportKind::Https));
624    }
625}