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/// let referral = registry_referral(&doc);
263/// if let Some(target) = &referral {
264///     doc = resolve(target).await?;   // one hop, no loop
265/// }
266/// let choice = ServiceCapabilities::from_document(&doc).select(&TransportKind::compiled())?;
267///
268/// // Carry the starting DID through, so the answer has to confirm the hop.
269/// let mut client = TrqlClient::new(transport_for(&choice)?, registry_did);
270/// if referral.is_some() {
271///     client = client.referred_by(start_did);
272/// }
273/// ```
274///
275/// **Cap at one hop.** TRQP's wording assumes the registry's own document
276/// holds the endpoints, so a second referral is a misconfiguration rather than
277/// a chain to follow, and chasing it invites cycles. This function is
278/// deliberately pure and single-shot: it cannot resolve, so it cannot loop.
279///
280/// # This does not establish authority
281///
282/// A referral is a **self-assertion**. Anyone can publish a document naming
283/// any registry, and nothing here checks that the named registry agrees.
284/// Authority flows registry → subject, never the reverse, so following a
285/// referral must be paired with closing the loop: confirming the registry's
286/// answer carries an `authority_id` equal to the DID the referral started
287/// from. Until then the referral has established *where to ask* and nothing
288/// about the answer.
289///
290/// Hand the starting DID to [`TrqlClient::referred_by`] and the client
291/// enforces that for you, rejecting an answer that leaves the referral
292/// unconfirmed. Discovery cannot do it here: this function sees a document,
293/// never a query result.
294///
295/// [`TrqlClient::referred_by`]: crate::TrqlClient::referred_by
296#[must_use]
297pub fn registry_referral(doc: &Value) -> Option<String> {
298    let own_id = doc.get("id").and_then(Value::as_str);
299    doc.get("service")
300        .and_then(Value::as_array)?
301        .iter()
302        .filter(|svc| service_has_type(svc, TRUST_REGISTRY_SERVICE_TYPE))
303        .filter_map(|svc| svc.get("serviceEndpoint").and_then(endpoint_uri))
304        .find(|uri| uri.starts_with("did:") && Some(uri.as_str()) != own_id)
305}
306
307/// Does this service entry carry `type_`?
308///
309/// `type` may be a string or an array of strings per the DID Core spec.
310fn service_has_type(svc: &Value, type_: &str) -> bool {
311    match svc.get("type") {
312        Some(Value::String(s)) => s == type_,
313        Some(Value::Array(arr)) => arr.iter().any(|t| t.as_str() == Some(type_)),
314        _ => false,
315    }
316}
317
318/// Resolve a `serviceEndpoint` to its URI, tolerating the three shapes a DID
319/// document may carry it in: a plain string (the TSP/REST convention), an
320/// object with a `uri` field (DIDComm v2), or an array of either.
321fn endpoint_uri(endpoint: &Value) -> Option<String> {
322    match endpoint {
323        Value::String(s) => Some(s.clone()),
324        Value::Object(map) => map.get("uri")?.as_str().map(str::to_string),
325        Value::Array(arr) => arr.iter().find_map(endpoint_uri),
326        _ => None,
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use serde_json::json;
334
335    fn doc(services: Value) -> Value {
336        json!({ "id": "did:webvh:registry.example", "service": services })
337    }
338
339    const ALL: [TransportKind; 3] = [
340        TransportKind::Tsp,
341        TransportKind::Didcomm,
342        TransportKind::Https,
343    ];
344
345    // --- VTC → registry referral ---
346
347    /// A VTC names the registry authoritative for it: same service `type`,
348    /// but the endpoint holds a DID rather than a URL.
349    #[test]
350    fn a_vtc_pointing_at_a_registry_did_is_a_referral() {
351        let vtc = json!({
352            "id": "did:webvh:QmVtcScid:community.example",
353            "service": [
354                { "id": "#trust-registry", "type": "TrustRegistry",
355                  "serviceEndpoint": { "uri": "did:webvh:QmRegistryScid:registry.example",
356                                       "profile": "https://trustoverip.org/profiles/trqp/v2" } },
357                { "id": "#didcomm", "type": "DIDCommMessaging",
358                  "serviceEndpoint": { "uri": "did:web:mediator.example" } },
359            ]
360        });
361        assert_eq!(
362            registry_referral(&vtc).as_deref(),
363            Some("did:webvh:QmRegistryScid:registry.example")
364        );
365    }
366
367    /// The same type in the registry's own document describes its surface, so
368    /// there is nowhere to be referred to — the caller uses this document.
369    #[test]
370    fn a_registry_describing_its_own_surface_is_not_a_referral() {
371        let registry = json!({
372            "id": "did:webvh:QmRegistryScid:registry.example",
373            "service": [
374                { "id": "#rest", "type": ["TRQPRest", "TrustRegistry"],
375                  "serviceEndpoint": { "uri": "https://registry.example",
376                                       "profile": "https://trustoverip.org/profiles/trqp/v2" } },
377            ]
378        });
379        assert_eq!(registry_referral(&registry), None);
380    }
381
382    /// A document naming *itself* is a misconfiguration, not a hop: following
383    /// it would resolve the same document forever.
384    #[test]
385    fn a_self_referential_entry_is_not_a_referral() {
386        let doc = json!({
387            "id": "did:webvh:QmRegistryScid:registry.example",
388            "service": [
389                { "id": "#trust-registry", "type": "TrustRegistry",
390                  "serviceEndpoint": "did:webvh:QmRegistryScid:registry.example" },
391            ]
392        });
393        assert_eq!(registry_referral(&doc), None);
394    }
395
396    /// The trap §5 of the design note calls out: a referral DID must never be
397    /// treated as a REST base URL, or `select` hands an HTTPS transport a DID
398    /// to POST to.
399    #[test]
400    fn a_referral_did_never_becomes_an_https_endpoint() {
401        let vtc = json!({
402            "id": "did:webvh:QmVtcScid:community.example",
403            "service": [
404                { "id": "#trust-registry", "type": "TrustRegistry",
405                  "serviceEndpoint": { "uri": "did:webvh:QmRegistryScid:registry.example" } },
406            ]
407        });
408        let caps = ServiceCapabilities::from_document(&vtc);
409        assert_eq!(caps, ServiceCapabilities::default(), "{caps:?}");
410        assert!(
411            caps.select(&ALL).is_err(),
412            "a referral advertises no transport of its own"
413        );
414    }
415
416    /// Mediator entries are DID-valued too; only `TrustRegistry` ones refer.
417    #[test]
418    fn a_mediator_did_is_not_mistaken_for_a_referral() {
419        let doc = json!({
420            "id": "did:webvh:QmRegistryScid:registry.example",
421            "service": [
422                { "id": "#tsp", "type": "TSPTransport", "serviceEndpoint": "did:web:mediator" },
423                { "id": "#didcomm", "type": "DIDCommMessaging",
424                  "serviceEndpoint": { "uri": "did:web:mediator" } },
425            ]
426        });
427        assert_eq!(registry_referral(&doc), None);
428    }
429
430    /// Both halves of the walk, in the order a caller performs them: the VTC
431    /// refers, the registry's own document supplies the transports.
432    #[test]
433    fn one_hop_lands_on_the_registrys_capabilities() {
434        let vtc = json!({
435            "id": "did:webvh:QmVtcScid:community.example",
436            "service": [{ "id": "#trust-registry", "type": "TrustRegistry",
437                          "serviceEndpoint": { "uri": "did:webvh:QmRegistryScid:registry.example" } }]
438        });
439        let registry = json!({
440            "id": "did:webvh:QmRegistryScid:registry.example",
441            "service": [
442                { "id": "#rest", "type": ["TRQPRest", "TrustRegistry"],
443                  "serviceEndpoint": { "uri": "https://registry.example" } },
444                { "id": "#tsp", "type": "TSPTransport", "serviceEndpoint": "did:web:mediator" },
445            ]
446        });
447
448        let target = registry_referral(&vtc).expect("the VTC refers");
449        assert_eq!(target, registry["id"].as_str().unwrap());
450        // Second hop is not taken: the registry's document does not refer on.
451        assert_eq!(registry_referral(&registry), None);
452
453        let choice = ServiceCapabilities::from_document(&registry)
454            .select(&ALL)
455            .unwrap();
456        assert_eq!(choice.kind, TransportKind::Tsp);
457        assert_eq!(choice.endpoint, "did:web:mediator");
458    }
459
460    #[test]
461    fn a_document_with_no_services_refers_nowhere() {
462        assert_eq!(registry_referral(&json!({ "id": "did:webvh:x" })), None);
463    }
464
465    #[test]
466    fn parses_each_service_type() {
467        let caps = ServiceCapabilities::from_document(&doc(json!([
468            { "id": "#tsp", "type": "TSPTransport", "serviceEndpoint": "did:web:mediator" },
469            { "id": "#didcomm", "type": "DIDCommMessaging",
470              "serviceEndpoint": { "uri": "did:web:mediator", "accept": ["didcomm/v2"] } },
471            { "id": "#rest", "type": "TRQPRest", "serviceEndpoint": "https://registry.example" },
472        ])));
473        assert_eq!(caps.tsp.as_deref(), Some("did:web:mediator"));
474        assert_eq!(caps.didcomm.as_deref(), Some("did:web:mediator"));
475        assert_eq!(caps.https.as_deref(), Some("https://registry.example"));
476    }
477
478    /// The registry's two DID-document builders emit different endpoint
479    /// shapes for the same service, so both must parse identically.
480    #[test]
481    fn tolerates_string_object_and_array_endpoints() {
482        for endpoint in [
483            json!("did:web:mediator"),
484            json!({ "uri": "did:web:mediator", "accept": ["didcomm/v2"] }),
485            json!([{ "uri": "did:web:mediator" }]),
486        ] {
487            let caps = ServiceCapabilities::from_document(&doc(json!([
488                { "id": "#x", "type": "DIDCommMessaging", "serviceEndpoint": endpoint }
489            ])));
490            assert_eq!(caps.didcomm.as_deref(), Some("did:web:mediator"));
491        }
492    }
493
494    /// Fragments are arbitrary labels; only `type` decides.
495    #[test]
496    fn matches_on_type_not_fragment() {
497        let caps = ServiceCapabilities::from_document(&doc(json!([
498            { "id": "did:x#tsp-transport", "type": "TSPTransport", "serviceEndpoint": "did:web:m" },
499            { "id": "did:x#tsp", "type": "TRQPRest", "serviceEndpoint": "https://r.example" },
500        ])));
501        assert_eq!(caps.tsp.as_deref(), Some("did:web:m"));
502        // The `#tsp`-fragmented entry is REST by type, and must be read as such.
503        assert_eq!(caps.https.as_deref(), Some("https://r.example"));
504    }
505
506    #[test]
507    fn type_may_be_an_array() {
508        let caps = ServiceCapabilities::from_document(&doc(json!([
509            { "id": "#m", "type": ["DIDCommMessaging", "Other"], "serviceEndpoint": "did:web:m" }
510        ])));
511        assert_eq!(caps.didcomm.as_deref(), Some("did:web:m"));
512    }
513
514    #[test]
515    fn ignores_unknown_types_empty_and_missing_endpoints() {
516        let caps = ServiceCapabilities::from_document(&doc(json!([
517            { "id": "#a", "type": "SomethingElse", "serviceEndpoint": "https://x" },
518            { "id": "#b", "type": "TRQPRest", "serviceEndpoint": "" },
519            { "id": "#c", "type": "TSPTransport" },
520            { "id": "#d", "type": "DIDCommMessaging", "serviceEndpoint": 42 },
521        ])));
522        assert_eq!(caps, ServiceCapabilities::default());
523        assert!(caps.advertised().is_empty());
524    }
525
526    #[test]
527    fn document_without_services_yields_nothing() {
528        assert_eq!(
529            ServiceCapabilities::from_document(&json!({ "id": "did:x" })),
530            ServiceCapabilities::default()
531        );
532    }
533
534    #[test]
535    fn first_entry_of_a_type_wins() {
536        let caps = ServiceCapabilities::from_document(&doc(json!([
537            { "id": "#r1", "type": "TRQPRest", "serviceEndpoint": "https://first.example" },
538            { "id": "#r2", "type": "TRQPRest", "serviceEndpoint": "https://second.example" },
539        ])));
540        assert_eq!(caps.https.as_deref(), Some("https://first.example"));
541    }
542
543    #[test]
544    fn selects_the_most_preferred_shared_transport() {
545        let caps = ServiceCapabilities {
546            tsp: Some("did:web:m".into()),
547            didcomm: Some("did:web:m".into()),
548            https: Some("https://r.example".into()),
549        };
550        assert_eq!(caps.select(&ALL).unwrap().kind, TransportKind::Tsp);
551
552        // We don't speak TSP -> next best.
553        let choice = caps
554            .select(&[TransportKind::Didcomm, TransportKind::Https])
555            .unwrap();
556        assert_eq!(choice.kind, TransportKind::Didcomm);
557        assert_eq!(choice.endpoint, "did:web:m");
558
559        // HTTPS-only client falls to REST and gets the URL, not the mediator.
560        let choice = caps.select(&[TransportKind::Https]).unwrap();
561        assert_eq!(choice.kind, TransportKind::Https);
562        assert_eq!(choice.endpoint, "https://r.example");
563    }
564
565    /// A registry that advertises only DIDComm must not be reached over HTTPS
566    /// merely because we can speak it — that is a silent downgrade past what
567    /// the peer offered.
568    #[test]
569    fn no_shared_transport_is_a_typed_error_not_a_fallback() {
570        let caps = ServiceCapabilities {
571            didcomm: Some("did:web:m".into()),
572            ..Default::default()
573        };
574        let err = caps.select(&[TransportKind::Https]).unwrap_err();
575        match err {
576            TrqlError::NoMatchingTransport { ours, theirs } => {
577                assert_eq!(ours, vec![TransportKind::Https]);
578                assert_eq!(theirs, vec![TransportKind::Didcomm]);
579            }
580            other => panic!("expected NoMatchingTransport, got {other:?}"),
581        }
582    }
583
584    /// A registry advertising nothing is the same failure, and must name that
585    /// it advertised nothing rather than blaming the client.
586    #[test]
587    fn empty_capabilities_report_an_empty_peer_set() {
588        let err = ServiceCapabilities::default()
589            .select(&ALL)
590            .expect_err("no transports advertised");
591        match err {
592            TrqlError::NoMatchingTransport { theirs, .. } => assert!(theirs.is_empty()),
593            other => panic!("expected NoMatchingTransport, got {other:?}"),
594        }
595    }
596
597    #[test]
598    fn no_matching_transport_is_not_retryable() {
599        let err = ServiceCapabilities::default().select(&ALL).unwrap_err();
600        assert!(!err.is_retryable());
601    }
602
603    #[test]
604    fn service_types_match_the_workspace_constants() {
605        assert_eq!(TransportKind::Tsp.service_type(), "TSPTransport");
606        assert_eq!(TransportKind::Didcomm.service_type(), "DIDCommMessaging");
607        assert_eq!(TransportKind::Https.service_type(), "TRQPRest");
608    }
609
610    /// A registry advertises `TRQPRest`; a VTA advertises `VTARest`. Both are
611    /// REST endpoints, and neither has to claim the other's type for a
612    /// consumer to find it.
613    #[test]
614    fn both_rest_type_names_are_discovered() {
615        for ty in ["TRQPRest", "VTARest"] {
616            let caps = ServiceCapabilities::from_document(&doc(json!([
617                { "id": "#rest", "type": ty, "serviceEndpoint": "https://r.example" }
618            ])));
619            assert_eq!(
620                caps.https.as_deref(),
621                Some("https://r.example"),
622                "{ty} must be recognised as REST"
623            );
624        }
625    }
626
627    #[test]
628    fn compiled_transports_are_in_preference_order() {
629        let compiled = TransportKind::compiled();
630        let expected: Vec<_> = PREFERENCE_ORDER
631            .into_iter()
632            .filter(|k| compiled.contains(k))
633            .collect();
634        assert_eq!(compiled, expected);
635        // The default feature set always includes HTTPS.
636        #[cfg(feature = "https")]
637        assert!(compiled.contains(&TransportKind::Https));
638    }
639}