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