Skip to main content

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}