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.
69pub const REST_SERVICE_TYPES: [&str; 2] = [REST_SERVICE_TYPE, VTA_REST_SERVICE_TYPE];
70
71/// Transports in descending preference order: TSP, then DIDComm, then HTTPS.
72///
73/// TSP is preferred where both sides speak it because it keeps intermediaries
74/// blind to routing metadata; HTTPS is the floor.
75pub const PREFERENCE_ORDER: [TransportKind; 3] = [
76    TransportKind::Tsp,
77    TransportKind::Didcomm,
78    TransportKind::Https,
79];
80
81impl TransportKind {
82    /// The DID-document service `type` that advertises this transport.
83    #[must_use]
84    pub fn service_type(self) -> &'static str {
85        match self {
86            Self::Tsp => TSP_SERVICE_TYPE,
87            Self::Didcomm => DIDCOMM_SERVICE_TYPE,
88            Self::Https => REST_SERVICE_TYPE,
89        }
90    }
91
92    /// Whether this build can actually construct this transport.
93    #[must_use]
94    pub fn is_compiled(self) -> bool {
95        match self {
96            Self::Tsp => cfg!(feature = "tsp"),
97            Self::Didcomm => cfg!(feature = "didcomm"),
98            Self::Https => cfg!(feature = "https"),
99        }
100    }
101
102    /// The transports compiled into this build, in preference order.
103    ///
104    /// Selecting against this rather than a hard-coded list means a binary
105    /// built without `--features tsp` will not choose TSP and then fail to
106    /// construct the transport.
107    #[must_use]
108    pub fn compiled() -> Vec<TransportKind> {
109        PREFERENCE_ORDER
110            .into_iter()
111            .filter(|k| k.is_compiled())
112            .collect()
113    }
114}
115
116/// The transports a registry advertises, parsed from its DID document by
117/// service `type`.
118///
119/// Each field holds the endpoint to route to for that protocol:
120///
121/// * `tsp` / `didcomm` — the registry's **mediator DID**, not a transport URL.
122///   Both use mediator indirection; the URL lives in the mediator's own DID
123///   document, so a second resolution hop is required.
124/// * `https` — the registry's REST **base URL**, used directly.
125#[derive(Debug, Clone, Default, PartialEq, Eq)]
126pub struct ServiceCapabilities {
127    /// Mediator DID advertised for TSP, if any.
128    pub tsp: Option<String>,
129    /// Mediator DID advertised for DIDComm, if any.
130    pub didcomm: Option<String>,
131    /// REST base URL advertised, if any.
132    pub https: Option<String>,
133}
134
135/// The transport chosen for a registry, and where to send.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct TransportChoice {
138    /// The selected binding.
139    pub kind: TransportKind,
140    /// Where to route: the registry's **mediator DID** for TSP/DIDComm (resolve
141    /// onward), its **base URL** for HTTPS.
142    pub endpoint: String,
143}
144
145impl ServiceCapabilities {
146    /// Parse the `service` array of a resolved DID document.
147    ///
148    /// Unknown service types are ignored, entries missing a usable endpoint are
149    /// skipped, and the first entry of each type wins — a document advertising
150    /// two DIDComm services is not an error, it just has one preferred.
151    #[must_use]
152    pub fn from_document(doc: &Value) -> Self {
153        let mut caps = Self::default();
154        let Some(services) = doc.get("service").and_then(Value::as_array) else {
155            return caps;
156        };
157        for svc in services {
158            let Some(uri) = svc.get("serviceEndpoint").and_then(endpoint_uri) else {
159                continue;
160            };
161            if uri.is_empty() {
162                continue;
163            }
164            if service_has_type(svc, TSP_SERVICE_TYPE) {
165                caps.tsp.get_or_insert(uri);
166            } else if service_has_type(svc, DIDCOMM_SERVICE_TYPE) {
167                caps.didcomm.get_or_insert(uri);
168            } else if REST_SERVICE_TYPES.iter().any(|t| service_has_type(svc, t)) {
169                caps.https.get_or_insert(uri);
170            }
171        }
172        caps
173    }
174
175    /// The endpoint advertised for `kind`, if any.
176    #[must_use]
177    pub fn endpoint(&self, kind: TransportKind) -> Option<&str> {
178        match kind {
179            TransportKind::Tsp => self.tsp.as_deref(),
180            TransportKind::Didcomm => self.didcomm.as_deref(),
181            TransportKind::Https => self.https.as_deref(),
182        }
183    }
184
185    /// Every transport advertised, in preference order.
186    #[must_use]
187    pub fn advertised(&self) -> Vec<TransportKind> {
188        PREFERENCE_ORDER
189            .into_iter()
190            .filter(|k| self.endpoint(*k).is_some())
191            .collect()
192    }
193
194    /// Choose the transport to use: the highest-preference one present in both
195    /// `ours` and this capability set.
196    ///
197    /// Returns [`TrqlError::NoMatchingTransport`] carrying both sides' sets
198    /// when the intersection is empty, so an operator can see what each side
199    /// offers rather than guessing why a query failed.
200    pub fn select(&self, ours: &[TransportKind]) -> Result<TransportChoice, TrqlError> {
201        for kind in PREFERENCE_ORDER {
202            if ours.contains(&kind)
203                && let Some(endpoint) = self.endpoint(kind)
204            {
205                return Ok(TransportChoice {
206                    kind,
207                    endpoint: endpoint.to_string(),
208                });
209            }
210        }
211        Err(TrqlError::NoMatchingTransport {
212            ours: ours.to_vec(),
213            theirs: self.advertised(),
214        })
215    }
216}
217
218/// Does this service entry carry `type_`?
219///
220/// `type` may be a string or an array of strings per the DID Core spec.
221fn service_has_type(svc: &Value, type_: &str) -> bool {
222    match svc.get("type") {
223        Some(Value::String(s)) => s == type_,
224        Some(Value::Array(arr)) => arr.iter().any(|t| t.as_str() == Some(type_)),
225        _ => false,
226    }
227}
228
229/// Resolve a `serviceEndpoint` to its URI, tolerating the three shapes a DID
230/// document may carry it in: a plain string (the TSP/REST convention), an
231/// object with a `uri` field (DIDComm v2), or an array of either.
232fn endpoint_uri(endpoint: &Value) -> Option<String> {
233    match endpoint {
234        Value::String(s) => Some(s.clone()),
235        Value::Object(map) => map.get("uri")?.as_str().map(str::to_string),
236        Value::Array(arr) => arr.iter().find_map(endpoint_uri),
237        _ => None,
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use serde_json::json;
245
246    fn doc(services: Value) -> Value {
247        json!({ "id": "did:webvh:registry.example", "service": services })
248    }
249
250    const ALL: [TransportKind; 3] = [
251        TransportKind::Tsp,
252        TransportKind::Didcomm,
253        TransportKind::Https,
254    ];
255
256    #[test]
257    fn parses_each_service_type() {
258        let caps = ServiceCapabilities::from_document(&doc(json!([
259            { "id": "#tsp", "type": "TSPTransport", "serviceEndpoint": "did:web:mediator" },
260            { "id": "#didcomm", "type": "DIDCommMessaging",
261              "serviceEndpoint": { "uri": "did:web:mediator", "accept": ["didcomm/v2"] } },
262            { "id": "#rest", "type": "TRQPRest", "serviceEndpoint": "https://registry.example" },
263        ])));
264        assert_eq!(caps.tsp.as_deref(), Some("did:web:mediator"));
265        assert_eq!(caps.didcomm.as_deref(), Some("did:web:mediator"));
266        assert_eq!(caps.https.as_deref(), Some("https://registry.example"));
267    }
268
269    /// The registry's two DID-document builders emit different endpoint
270    /// shapes for the same service, so both must parse identically.
271    #[test]
272    fn tolerates_string_object_and_array_endpoints() {
273        for endpoint in [
274            json!("did:web:mediator"),
275            json!({ "uri": "did:web:mediator", "accept": ["didcomm/v2"] }),
276            json!([{ "uri": "did:web:mediator" }]),
277        ] {
278            let caps = ServiceCapabilities::from_document(&doc(json!([
279                { "id": "#x", "type": "DIDCommMessaging", "serviceEndpoint": endpoint }
280            ])));
281            assert_eq!(caps.didcomm.as_deref(), Some("did:web:mediator"));
282        }
283    }
284
285    /// Fragments are arbitrary labels; only `type` decides.
286    #[test]
287    fn matches_on_type_not_fragment() {
288        let caps = ServiceCapabilities::from_document(&doc(json!([
289            { "id": "did:x#tsp-transport", "type": "TSPTransport", "serviceEndpoint": "did:web:m" },
290            { "id": "did:x#tsp", "type": "TRQPRest", "serviceEndpoint": "https://r.example" },
291        ])));
292        assert_eq!(caps.tsp.as_deref(), Some("did:web:m"));
293        // The `#tsp`-fragmented entry is REST by type, and must be read as such.
294        assert_eq!(caps.https.as_deref(), Some("https://r.example"));
295    }
296
297    #[test]
298    fn type_may_be_an_array() {
299        let caps = ServiceCapabilities::from_document(&doc(json!([
300            { "id": "#m", "type": ["DIDCommMessaging", "Other"], "serviceEndpoint": "did:web:m" }
301        ])));
302        assert_eq!(caps.didcomm.as_deref(), Some("did:web:m"));
303    }
304
305    #[test]
306    fn ignores_unknown_types_empty_and_missing_endpoints() {
307        let caps = ServiceCapabilities::from_document(&doc(json!([
308            { "id": "#a", "type": "SomethingElse", "serviceEndpoint": "https://x" },
309            { "id": "#b", "type": "TRQPRest", "serviceEndpoint": "" },
310            { "id": "#c", "type": "TSPTransport" },
311            { "id": "#d", "type": "DIDCommMessaging", "serviceEndpoint": 42 },
312        ])));
313        assert_eq!(caps, ServiceCapabilities::default());
314        assert!(caps.advertised().is_empty());
315    }
316
317    #[test]
318    fn document_without_services_yields_nothing() {
319        assert_eq!(
320            ServiceCapabilities::from_document(&json!({ "id": "did:x" })),
321            ServiceCapabilities::default()
322        );
323    }
324
325    #[test]
326    fn first_entry_of_a_type_wins() {
327        let caps = ServiceCapabilities::from_document(&doc(json!([
328            { "id": "#r1", "type": "TRQPRest", "serviceEndpoint": "https://first.example" },
329            { "id": "#r2", "type": "TRQPRest", "serviceEndpoint": "https://second.example" },
330        ])));
331        assert_eq!(caps.https.as_deref(), Some("https://first.example"));
332    }
333
334    #[test]
335    fn selects_the_most_preferred_shared_transport() {
336        let caps = ServiceCapabilities {
337            tsp: Some("did:web:m".into()),
338            didcomm: Some("did:web:m".into()),
339            https: Some("https://r.example".into()),
340        };
341        assert_eq!(caps.select(&ALL).unwrap().kind, TransportKind::Tsp);
342
343        // We don't speak TSP -> next best.
344        let choice = caps
345            .select(&[TransportKind::Didcomm, TransportKind::Https])
346            .unwrap();
347        assert_eq!(choice.kind, TransportKind::Didcomm);
348        assert_eq!(choice.endpoint, "did:web:m");
349
350        // HTTPS-only client falls to REST and gets the URL, not the mediator.
351        let choice = caps.select(&[TransportKind::Https]).unwrap();
352        assert_eq!(choice.kind, TransportKind::Https);
353        assert_eq!(choice.endpoint, "https://r.example");
354    }
355
356    /// A registry that advertises only DIDComm must not be reached over HTTPS
357    /// merely because we can speak it — that is a silent downgrade past what
358    /// the peer offered.
359    #[test]
360    fn no_shared_transport_is_a_typed_error_not_a_fallback() {
361        let caps = ServiceCapabilities {
362            didcomm: Some("did:web:m".into()),
363            ..Default::default()
364        };
365        let err = caps.select(&[TransportKind::Https]).unwrap_err();
366        match err {
367            TrqlError::NoMatchingTransport { ours, theirs } => {
368                assert_eq!(ours, vec![TransportKind::Https]);
369                assert_eq!(theirs, vec![TransportKind::Didcomm]);
370            }
371            other => panic!("expected NoMatchingTransport, got {other:?}"),
372        }
373    }
374
375    /// A registry advertising nothing is the same failure, and must name that
376    /// it advertised nothing rather than blaming the client.
377    #[test]
378    fn empty_capabilities_report_an_empty_peer_set() {
379        let err = ServiceCapabilities::default()
380            .select(&ALL)
381            .expect_err("no transports advertised");
382        match err {
383            TrqlError::NoMatchingTransport { theirs, .. } => assert!(theirs.is_empty()),
384            other => panic!("expected NoMatchingTransport, got {other:?}"),
385        }
386    }
387
388    #[test]
389    fn no_matching_transport_is_not_retryable() {
390        let err = ServiceCapabilities::default().select(&ALL).unwrap_err();
391        assert!(!err.is_retryable());
392    }
393
394    #[test]
395    fn service_types_match_the_workspace_constants() {
396        assert_eq!(TransportKind::Tsp.service_type(), "TSPTransport");
397        assert_eq!(TransportKind::Didcomm.service_type(), "DIDCommMessaging");
398        assert_eq!(TransportKind::Https.service_type(), "TRQPRest");
399    }
400
401    /// A registry advertises `TRQPRest`; a VTA advertises `VTARest`. Both are
402    /// REST endpoints, and neither has to claim the other's type for a
403    /// consumer to find it.
404    #[test]
405    fn both_rest_type_names_are_discovered() {
406        for ty in ["TRQPRest", "VTARest"] {
407            let caps = ServiceCapabilities::from_document(&doc(json!([
408                { "id": "#rest", "type": ty, "serviceEndpoint": "https://r.example" }
409            ])));
410            assert_eq!(
411                caps.https.as_deref(),
412                Some("https://r.example"),
413                "{ty} must be recognised as REST"
414            );
415        }
416    }
417
418    #[test]
419    fn compiled_transports_are_in_preference_order() {
420        let compiled = TransportKind::compiled();
421        let expected: Vec<_> = PREFERENCE_ORDER
422            .into_iter()
423            .filter(|k| compiled.contains(k))
424            .collect();
425        assert_eq!(compiled, expected);
426        // The default feature set always includes HTTPS.
427        #[cfg(feature = "https")]
428        assert!(compiled.contains(&TransportKind::Https));
429    }
430}