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(®istry), 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(®istry), None);
452
453 let choice = ServiceCapabilities::from_document(®istry)
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}