vta_sdk/protocol/matching.rs
1//! Bidirectional transport-protocol matching from advertised DID-document
2//! services.
3//!
4//! When two parties communicate, the protocol used is the highest-preference
5//! one **both** advertise in their DID documents — **TSP > DIDComm > REST**
6//! (`docs/05-design-notes/tsp-enablement.md` §3, §11). Services are matched on
7//! their `type` (`TSPTransport` / `DIDCommMessaging` / `VTARest`), **never** on
8//! the `#id` fragment, which is an arbitrary label (D9 — the OWF reference TSP
9//! impl names its id `#tsp-transport`, Affinidi names it `#tsp`; same type). If
10//! the advertised sets don't intersect, [`select_protocol`] returns
11//! [`VtaError::NoMatchingProtocol`] carrying both sides' advertised sets.
12//!
13//! This is pure, side-effect-free logic over an already-resolved DID document
14//! `serde_json::Value`. DID resolution itself is the caller's job; so is the
15//! second hop for TSP/DIDComm (resolving the returned mediator DID to its
16//! transport URL).
17
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21use crate::error::VtaError;
22
23/// DID-document service `type` for a TSP transport endpoint. `TSPTransport`
24/// is the OpenWallet-Foundation-Labs reference-implementation convention
25/// (`affinidi_tsp`'s DID-backed VID resolver matches on it); the ToIP TSP
26/// spec names no DID-document service type. Kept in sync with
27/// `vta_service::operations::protocol::document::TSP_SERVICE_TYPE`.
28pub const TSP_SERVICE_TYPE: &str = "TSPTransport";
29
30/// DID-document service `type` for a DIDComm v2 mediator endpoint (W3C).
31pub const DIDCOMM_SERVICE_TYPE: &str = "DIDCommMessaging";
32
33/// DID-document service `type` for the VTA REST endpoint. Kept in sync with
34/// `vta_service::operations::protocol::document::REST_SERVICE_TYPE`.
35///
36/// Correct for a VTA, and only for a VTA — it says "a VTA's REST API is behind
37/// this URL". Non-VTA services advertise their own REST type; see
38/// [`TRQP_REST_SERVICE_TYPE`].
39pub const REST_SERVICE_TYPE: &str = "VTARest";
40
41/// DID-document service `type` for a Trust Registry's REST/TRQP surface.
42///
43/// A Trust Registry is not a VTA, so it must not advertise [`REST_SERVICE_TYPE`]
44/// — that would promise a consumer a VTA's endpoints. `TRQPRest` names the
45/// interface actually served (TRQP over REST), matching how the sibling types
46/// name protocols rather than products. Kept in sync with
47/// `trust_registry::didcomm::did_document::REST_SERVICE_TYPE` in
48/// `affinidi-trust-registry-rs`.
49pub const TRQP_REST_SERVICE_TYPE: &str = "TRQPRest";
50
51/// DID-document service `type` for a Trust-Task HTTPS endpoint
52/// (HTTPS binding 0.2 §6.2).
53///
54/// **The one that states an interface rather than a product**: "this party
55/// accepts Trust Task documents over the HTTPS binding". Deliberately not
56/// [`REST_SERVICE_TYPE`] — "is a VTA's REST API" and "accepts Trust Tasks" are
57/// different claims that merely coincide while every Trust-Task server we run
58/// happens to be a VTA. A consumer that conflates them posts Trust Tasks to an
59/// endpoint that never agreed to accept them, which is not hypothetical: this
60/// VTA posts them to `WebVHHosting`, a type whose endpoint advertises where DID
61/// *documents* are served.
62///
63/// Its `serviceEndpoint` is the **Trust-Task base**, and the request URL is
64/// `base + "/trust-tasks"`. That is what binding 0.2 §6 settles and why it had
65/// to: before it, the path was fixed but what it was relative to was not, so two
66/// conformant implementations composed `/api/trust-tasks` and `/trust-tasks`
67/// and both were right.
68///
69/// Kept in sync with `TRUST_TASK_HTTPS_SERVICE_TYPE` in the browser plugin's
70/// `vta/endpoint.ts`, which has implemented this since #125.
71pub const TRUST_TASK_HTTPS_SERVICE_TYPE: &str = "TrustTaskHTTPS";
72
73/// Every service `type` that denotes an endpoint accepting Trust Tasks over
74/// HTTPS, in match order.
75///
76/// [`TRUST_TASK_HTTPS_SERVICE_TYPE`] is first because it is the only one that
77/// *says so*. The two product types after it are accepted because every VTA and
78/// Trust Registry in this workspace advertises one of them today and their
79/// endpoint is, in practice, the Trust-Task base — but they are a compatibility
80/// reading of a claim that was never quite the one being made, and a party that
81/// wants to be found should advertise the binding type.
82///
83/// Adding a type here is the only change needed for a service to become
84/// discoverable.
85pub const REST_SERVICE_TYPES: [&str; 3] = [
86 TRUST_TASK_HTTPS_SERVICE_TYPE,
87 REST_SERVICE_TYPE,
88 TRQP_REST_SERVICE_TYPE,
89];
90
91/// A transport protocol, in workspace preference order: TSP, then DIDComm,
92/// then REST. `Ord` follows that order — `Tsp` is the smallest (most
93/// preferred) — so [`Protocol::PREFERENCE_ORDER`] is ascending.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
95#[serde(rename_all = "lowercase")]
96pub enum Protocol {
97 Tsp,
98 Didcomm,
99 Rest,
100}
101
102impl Protocol {
103 /// Every protocol in descending preference order (most preferred first).
104 pub const PREFERENCE_ORDER: [Protocol; 3] = [Protocol::Tsp, Protocol::Didcomm, Protocol::Rest];
105
106 /// Lowercase wire/display name (`"tsp"` / `"didcomm"` / `"rest"`).
107 #[must_use]
108 pub fn as_str(self) -> &'static str {
109 match self {
110 Protocol::Tsp => "tsp",
111 Protocol::Didcomm => "didcomm",
112 Protocol::Rest => "rest",
113 }
114 }
115}
116
117impl std::fmt::Display for Protocol {
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 f.write_str(self.as_str())
120 }
121}
122
123/// The transport services a party advertises in its DID document, parsed by
124/// service `type`. Each field holds the endpoint the SDK would route to for
125/// that protocol:
126///
127/// - `tsp` / `didcomm`: the party's **mediator DID** (its VID / mediator),
128/// not a transport URL — TSP and DIDComm both use mediator indirection (the
129/// transport URL lives in the mediator's own DID document).
130/// - `rest`: the party's REST base URL.
131#[derive(Debug, Clone, Default, PartialEq, Eq)]
132pub struct ServiceCapabilities {
133 pub tsp: Option<String>,
134 pub didcomm: Option<String>,
135 pub rest: Option<String>,
136}
137
138impl ServiceCapabilities {
139 /// Parse the advertised transports from a resolved DID document.
140 ///
141 /// Walks the `service` array and selects entries by their `type` (D9 —
142 /// never by `#id`). The `type` may be a string or an array of strings
143 /// (DID-Core permits both). The first non-empty endpoint of each type
144 /// wins; later duplicates are ignored. A document with no `service`
145 /// array yields an all-`None` capability set.
146 #[must_use]
147 pub fn from_did_document(doc: &Value) -> Self {
148 let mut caps = ServiceCapabilities::default();
149 let Some(services) = doc.get("service").and_then(Value::as_array) else {
150 return caps;
151 };
152 // The REST winner is chosen by *which type it is*, never by where it
153 // sits: `REST_SERVICE_TYPES` is in match order and the best rank wins,
154 // ties going to document order. Position-dependence would mean a party
155 // advertising both `TrustTaskHTTPS` and a product type got whichever it
156 // happened to list first — and only one of those actually claims to
157 // accept Trust Tasks. The same reasoning the DIDComm-wherever-it-sits
158 // rule already follows elsewhere.
159 let mut rest_rank = usize::MAX;
160 for svc in services {
161 let Some(uri) = svc.get("serviceEndpoint").and_then(endpoint_uri) else {
162 continue;
163 };
164 if uri.is_empty() {
165 continue;
166 }
167 if service_has_type(svc, TSP_SERVICE_TYPE) {
168 caps.tsp.get_or_insert(uri);
169 } else if service_has_type(svc, DIDCOMM_SERVICE_TYPE) {
170 caps.didcomm.get_or_insert(uri);
171 } else if let Some(rank) = REST_SERVICE_TYPES
172 .iter()
173 .position(|t| service_has_type(svc, t))
174 && rank < rest_rank
175 {
176 rest_rank = rank;
177 caps.rest = Some(uri);
178 }
179 }
180 caps
181 }
182
183 /// The endpoint advertised for `protocol`, if any (mediator DID for
184 /// TSP/DIDComm, URL for REST).
185 #[must_use]
186 pub fn endpoint(&self, protocol: Protocol) -> Option<&str> {
187 match protocol {
188 Protocol::Tsp => self.tsp.as_deref(),
189 Protocol::Didcomm => self.didcomm.as_deref(),
190 Protocol::Rest => self.rest.as_deref(),
191 }
192 }
193
194 /// Every protocol this party advertises, in preference order.
195 #[must_use]
196 pub fn advertised(&self) -> Vec<Protocol> {
197 Protocol::PREFERENCE_ORDER
198 .into_iter()
199 .filter(|p| self.endpoint(*p).is_some())
200 .collect()
201 }
202}
203
204/// The chosen protocol and the counterparty endpoint to route to for it.
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct ProtocolMatch {
207 /// The selected transport.
208 pub protocol: Protocol,
209 /// The counterparty endpoint for `protocol`: the peer's **mediator DID**
210 /// for TSP/DIDComm (resolve it onward for the transport URL), the peer's
211 /// **URL** for REST.
212 pub peer_endpoint: String,
213}
214
215/// Pick the protocol to use with a counterparty: the highest-preference one
216/// (TSP > DIDComm > REST) present in **both** `ours` and `theirs`.
217///
218/// Returns [`VtaError::NoMatchingProtocol`] — carrying both advertised sets —
219/// when the intersection is empty, so the CLI can show the operator what each
220/// side offers and which transport to enable. Never silently downgrades past
221/// what a peer advertises.
222pub fn select_protocol(
223 ours: &ServiceCapabilities,
224 theirs: &ServiceCapabilities,
225 counterparty_did: &str,
226) -> Result<ProtocolMatch, VtaError> {
227 for protocol in Protocol::PREFERENCE_ORDER {
228 if ours.endpoint(protocol).is_some()
229 && let Some(peer) = theirs.endpoint(protocol)
230 {
231 return Ok(ProtocolMatch {
232 protocol,
233 peer_endpoint: peer.to_string(),
234 });
235 }
236 }
237 Err(VtaError::NoMatchingProtocol {
238 counterparty_did: counterparty_did.to_string(),
239 ours: ours.advertised(),
240 theirs: theirs.advertised(),
241 })
242}
243
244/// Whether a service entry advertises `type_`. DID-Core permits `type` to be
245/// a single string or an array of strings.
246fn service_has_type(svc: &Value, type_: &str) -> bool {
247 match svc.get("type") {
248 Some(Value::String(s)) => s == type_,
249 Some(Value::Array(arr)) => arr.iter().any(|t| t.as_str() == Some(type_)),
250 _ => false,
251 }
252}
253
254/// Resolve a `serviceEndpoint` value to its URI, tolerating the three shapes
255/// a DID document may carry it in: a plain string (TSP/REST current
256/// convention), an object with a `uri` field (DIDComm v2), or a
257/// single-element array of either. Mirrors
258/// `vta_service::operations::protocol::document::extract_mediator_did`.
259fn endpoint_uri(endpoint: &Value) -> Option<String> {
260 match endpoint {
261 Value::String(s) => Some(s.clone()),
262 Value::Object(map) => map.get("uri")?.as_str().map(str::to_string),
263 Value::Array(arr) => arr.iter().find_map(endpoint_uri),
264 _ => None,
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use serde_json::json;
272
273 fn doc(services: Value) -> Value {
274 json!({ "id": "did:webvh:peer", "service": services })
275 }
276
277 /// A VTA advertises `VTARest`; a Trust Registry advertises `TRQPRest`.
278 /// Both are REST endpoints, and neither should have to claim the other's
279 /// service type to be discovered.
280 #[test]
281 fn both_rest_service_types_are_recognised() {
282 for ty in ["VTARest", "TRQPRest"] {
283 let caps = ServiceCapabilities::from_did_document(&doc(json!([
284 { "id": "did:webvh:peer#rest", "type": ty,
285 "serviceEndpoint": "https://peer.example" }
286 ])));
287 assert_eq!(
288 caps.rest.as_deref(),
289 Some("https://peer.example"),
290 "{ty} must be recognised as a REST endpoint"
291 );
292 assert_eq!(caps.advertised(), vec![Protocol::Rest]);
293 }
294 }
295
296 /// A registry advertising only `TRQPRest` must be selectable over REST —
297 /// this is the case that previously yielded `NoMatchingProtocol`.
298 #[test]
299 fn trqp_rest_only_peer_is_selectable() {
300 let theirs = ServiceCapabilities::from_did_document(&doc(json!([
301 { "id": "did:webvh:registry#rest", "type": "TRQPRest",
302 "serviceEndpoint": "https://registry.example" }
303 ])));
304 let ours = ServiceCapabilities {
305 rest: Some("https://us.example".into()),
306 ..Default::default()
307 };
308 let chosen = select_protocol(&ours, &theirs, "did:webvh:registry").unwrap();
309 assert_eq!(chosen.protocol, Protocol::Rest);
310 assert_eq!(chosen.peer_endpoint, "https://registry.example");
311 }
312
313 #[test]
314 fn protocol_preference_order_is_tsp_didcomm_rest() {
315 assert_eq!(
316 Protocol::PREFERENCE_ORDER,
317 [Protocol::Tsp, Protocol::Didcomm, Protocol::Rest]
318 );
319 // Ord agrees: Tsp is the most preferred (smallest).
320 assert!(Protocol::Tsp < Protocol::Didcomm);
321 assert!(Protocol::Didcomm < Protocol::Rest);
322 }
323
324 #[test]
325 fn parses_each_type_and_endpoint_shape() {
326 let caps = ServiceCapabilities::from_did_document(&doc(json!([
327 // TSP: plain-string mediator DID.
328 { "id": "did:webvh:peer#tsp", "type": "TSPTransport",
329 "serviceEndpoint": "did:webvh:med-tsp" },
330 // DIDComm: array-of-object {uri} mediator DID.
331 { "id": "did:webvh:peer#vta-didcomm", "type": "DIDCommMessaging",
332 "serviceEndpoint": [{ "accept": ["didcomm/v2"], "uri": "did:webvh:med-dc" }] },
333 // REST: plain-string URL.
334 { "id": "did:webvh:peer#vta-rest", "type": "VTARest",
335 "serviceEndpoint": "https://peer.example/" },
336 ])));
337 assert_eq!(caps.tsp.as_deref(), Some("did:webvh:med-tsp"));
338 assert_eq!(caps.didcomm.as_deref(), Some("did:webvh:med-dc"));
339 assert_eq!(caps.rest.as_deref(), Some("https://peer.example/"));
340 assert_eq!(
341 caps.advertised(),
342 vec![Protocol::Tsp, Protocol::Didcomm, Protocol::Rest]
343 );
344 }
345
346 #[test]
347 fn matches_by_type_not_id() {
348 // A TSPTransport service whose id is a non-canonical label is still
349 // discovered (match is on `type`). And a service whose id *looks*
350 // like `#tsp` but has a different type is NOT treated as TSP.
351 let caps = ServiceCapabilities::from_did_document(&doc(json!([
352 { "id": "did:webvh:peer#tsp-transport", "type": "TSPTransport",
353 "serviceEndpoint": "did:webvh:med" },
354 { "id": "did:webvh:peer#tsp", "type": "SomethingElse",
355 "serviceEndpoint": "https://decoy.example/" },
356 ])));
357 assert_eq!(caps.tsp.as_deref(), Some("did:webvh:med"));
358 assert_eq!(caps.rest, None);
359 assert_eq!(caps.didcomm, None);
360 }
361
362 #[test]
363 fn type_may_be_an_array() {
364 let caps = ServiceCapabilities::from_did_document(&doc(json!([
365 { "id": "x", "type": ["DIDCommMessaging", "OtherThing"],
366 "serviceEndpoint": { "uri": "did:webvh:med" } },
367 ])));
368 assert_eq!(caps.didcomm.as_deref(), Some("did:webvh:med"));
369 }
370
371 #[test]
372 fn empty_or_missing_service_array_is_no_capabilities() {
373 assert_eq!(
374 ServiceCapabilities::from_did_document(&json!({ "id": "did:x" })),
375 ServiceCapabilities::default()
376 );
377 assert!(
378 ServiceCapabilities::from_did_document(&doc(json!([])))
379 .advertised()
380 .is_empty()
381 );
382 }
383
384 fn caps(tsp: Option<&str>, didcomm: Option<&str>, rest: Option<&str>) -> ServiceCapabilities {
385 ServiceCapabilities {
386 tsp: tsp.map(str::to_string),
387 didcomm: didcomm.map(str::to_string),
388 rest: rest.map(str::to_string),
389 }
390 }
391
392 #[test]
393 fn select_prefers_tsp_when_both_advertise_it() {
394 let ours = caps(
395 Some("did:m:ours"),
396 Some("did:dc:ours"),
397 Some("https://ours"),
398 );
399 let theirs = caps(
400 Some("did:m:theirs"),
401 Some("did:dc:theirs"),
402 Some("https://theirs"),
403 );
404 let m = select_protocol(&ours, &theirs, "did:webvh:peer").unwrap();
405 assert_eq!(m.protocol, Protocol::Tsp);
406 // Endpoint returned is the *counterparty's* TSP mediator DID.
407 assert_eq!(m.peer_endpoint, "did:m:theirs");
408 }
409
410 #[test]
411 fn select_falls_through_to_didcomm_then_rest() {
412 // We don't speak TSP; peer does — fall to the next shared protocol.
413 let ours = caps(None, Some("did:dc:ours"), Some("https://ours"));
414 let theirs = caps(Some("did:m:theirs"), Some("did:dc:theirs"), None);
415 let m = select_protocol(&ours, &theirs, "did:webvh:peer").unwrap();
416 assert_eq!(m.protocol, Protocol::Didcomm);
417 assert_eq!(m.peer_endpoint, "did:dc:theirs");
418
419 // Only REST in common.
420 let ours = caps(Some("did:m:ours"), None, Some("https://ours"));
421 let theirs = caps(None, Some("did:dc:theirs"), Some("https://theirs"));
422 let m = select_protocol(&ours, &theirs, "did:webvh:peer").unwrap();
423 assert_eq!(m.protocol, Protocol::Rest);
424 assert_eq!(m.peer_endpoint, "https://theirs");
425 }
426
427 #[test]
428 fn select_requires_both_sides_to_advertise() {
429 // We only speak TSP; peer only speaks REST — no overlap.
430 let ours = caps(Some("did:m:ours"), None, None);
431 let theirs = caps(None, None, Some("https://theirs"));
432 let err = select_protocol(&ours, &theirs, "did:webvh:peer").unwrap_err();
433 match err {
434 VtaError::NoMatchingProtocol {
435 counterparty_did,
436 ours,
437 theirs,
438 } => {
439 assert_eq!(counterparty_did, "did:webvh:peer");
440 assert_eq!(ours, vec![Protocol::Tsp]);
441 assert_eq!(theirs, vec![Protocol::Rest]);
442 }
443 other => panic!("expected NoMatchingProtocol, got {other:?}"),
444 }
445 }
446
447 /// The binding type is the only one that claims to accept Trust Tasks, so
448 /// it wins over a product type wherever it sits in the array. Listed last
449 /// here deliberately: position must not decide this.
450 #[test]
451 fn the_binding_type_beats_a_product_type_wherever_it_sits() {
452 let doc = serde_json::json!({ "service": [
453 { "id": "#rest", "type": "VTARest", "serviceEndpoint": "https://vta.example" },
454 { "id": "#tt", "type": "TrustTaskHTTPS", "serviceEndpoint": "https://vta.example/api" },
455 ]});
456 let caps = ServiceCapabilities::from_did_document(&doc);
457 assert_eq!(
458 caps.endpoint(Protocol::Rest),
459 Some("https://vta.example/api"),
460 "the product type won because it was listed first"
461 );
462 }
463
464 /// And a party advertising only a product type is still reachable — every
465 /// VTA in this workspace advertises `VTARest` today.
466 #[test]
467 fn a_product_type_alone_is_still_discoverable() {
468 let doc = serde_json::json!({ "service": [
469 { "id": "#rest", "type": "VTARest", "serviceEndpoint": "https://vta.example" },
470 ]});
471 let caps = ServiceCapabilities::from_did_document(&doc);
472 assert_eq!(caps.endpoint(Protocol::Rest), Some("https://vta.example"));
473 }
474}