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`.
35pub const REST_SERVICE_TYPE: &str = "VTARest";
36
37/// A transport protocol, in workspace preference order: TSP, then DIDComm,
38/// then REST. `Ord` follows that order — `Tsp` is the smallest (most
39/// preferred) — so [`Protocol::PREFERENCE_ORDER`] is ascending.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum Protocol {
43    Tsp,
44    Didcomm,
45    Rest,
46}
47
48impl Protocol {
49    /// Every protocol in descending preference order (most preferred first).
50    pub const PREFERENCE_ORDER: [Protocol; 3] = [Protocol::Tsp, Protocol::Didcomm, Protocol::Rest];
51
52    /// Lowercase wire/display name (`"tsp"` / `"didcomm"` / `"rest"`).
53    #[must_use]
54    pub fn as_str(self) -> &'static str {
55        match self {
56            Protocol::Tsp => "tsp",
57            Protocol::Didcomm => "didcomm",
58            Protocol::Rest => "rest",
59        }
60    }
61}
62
63impl std::fmt::Display for Protocol {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.write_str(self.as_str())
66    }
67}
68
69/// The transport services a party advertises in its DID document, parsed by
70/// service `type`. Each field holds the endpoint the SDK would route to for
71/// that protocol:
72///
73/// - `tsp` / `didcomm`: the party's **mediator DID** (its VID / mediator),
74///   not a transport URL — TSP and DIDComm both use mediator indirection (the
75///   transport URL lives in the mediator's own DID document).
76/// - `rest`: the party's REST base URL.
77#[derive(Debug, Clone, Default, PartialEq, Eq)]
78pub struct ServiceCapabilities {
79    pub tsp: Option<String>,
80    pub didcomm: Option<String>,
81    pub rest: Option<String>,
82}
83
84impl ServiceCapabilities {
85    /// Parse the advertised transports from a resolved DID document.
86    ///
87    /// Walks the `service` array and selects entries by their `type` (D9 —
88    /// never by `#id`). The `type` may be a string or an array of strings
89    /// (DID-Core permits both). The first non-empty endpoint of each type
90    /// wins; later duplicates are ignored. A document with no `service`
91    /// array yields an all-`None` capability set.
92    #[must_use]
93    pub fn from_did_document(doc: &Value) -> Self {
94        let mut caps = ServiceCapabilities::default();
95        let Some(services) = doc.get("service").and_then(Value::as_array) else {
96            return caps;
97        };
98        for svc in services {
99            let Some(uri) = svc.get("serviceEndpoint").and_then(endpoint_uri) else {
100                continue;
101            };
102            if uri.is_empty() {
103                continue;
104            }
105            if service_has_type(svc, TSP_SERVICE_TYPE) {
106                caps.tsp.get_or_insert(uri);
107            } else if service_has_type(svc, DIDCOMM_SERVICE_TYPE) {
108                caps.didcomm.get_or_insert(uri);
109            } else if service_has_type(svc, REST_SERVICE_TYPE) {
110                caps.rest.get_or_insert(uri);
111            }
112        }
113        caps
114    }
115
116    /// The endpoint advertised for `protocol`, if any (mediator DID for
117    /// TSP/DIDComm, URL for REST).
118    #[must_use]
119    pub fn endpoint(&self, protocol: Protocol) -> Option<&str> {
120        match protocol {
121            Protocol::Tsp => self.tsp.as_deref(),
122            Protocol::Didcomm => self.didcomm.as_deref(),
123            Protocol::Rest => self.rest.as_deref(),
124        }
125    }
126
127    /// Every protocol this party advertises, in preference order.
128    #[must_use]
129    pub fn advertised(&self) -> Vec<Protocol> {
130        Protocol::PREFERENCE_ORDER
131            .into_iter()
132            .filter(|p| self.endpoint(*p).is_some())
133            .collect()
134    }
135}
136
137/// The chosen protocol and the counterparty endpoint to route to for it.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct ProtocolMatch {
140    /// The selected transport.
141    pub protocol: Protocol,
142    /// The counterparty endpoint for `protocol`: the peer's **mediator DID**
143    /// for TSP/DIDComm (resolve it onward for the transport URL), the peer's
144    /// **URL** for REST.
145    pub peer_endpoint: String,
146}
147
148/// Pick the protocol to use with a counterparty: the highest-preference one
149/// (TSP > DIDComm > REST) present in **both** `ours` and `theirs`.
150///
151/// Returns [`VtaError::NoMatchingProtocol`] — carrying both advertised sets —
152/// when the intersection is empty, so the CLI can show the operator what each
153/// side offers and which transport to enable. Never silently downgrades past
154/// what a peer advertises.
155pub fn select_protocol(
156    ours: &ServiceCapabilities,
157    theirs: &ServiceCapabilities,
158    counterparty_did: &str,
159) -> Result<ProtocolMatch, VtaError> {
160    for protocol in Protocol::PREFERENCE_ORDER {
161        if ours.endpoint(protocol).is_some()
162            && let Some(peer) = theirs.endpoint(protocol)
163        {
164            return Ok(ProtocolMatch {
165                protocol,
166                peer_endpoint: peer.to_string(),
167            });
168        }
169    }
170    Err(VtaError::NoMatchingProtocol {
171        counterparty_did: counterparty_did.to_string(),
172        ours: ours.advertised(),
173        theirs: theirs.advertised(),
174    })
175}
176
177/// Whether a service entry advertises `type_`. DID-Core permits `type` to be
178/// a single string or an array of strings.
179fn service_has_type(svc: &Value, type_: &str) -> bool {
180    match svc.get("type") {
181        Some(Value::String(s)) => s == type_,
182        Some(Value::Array(arr)) => arr.iter().any(|t| t.as_str() == Some(type_)),
183        _ => false,
184    }
185}
186
187/// Resolve a `serviceEndpoint` value to its URI, tolerating the three shapes
188/// a DID document may carry it in: a plain string (TSP/REST current
189/// convention), an object with a `uri` field (DIDComm v2), or a
190/// single-element array of either. Mirrors
191/// `vta_service::operations::protocol::document::extract_mediator_did`.
192fn endpoint_uri(endpoint: &Value) -> Option<String> {
193    match endpoint {
194        Value::String(s) => Some(s.clone()),
195        Value::Object(map) => map.get("uri")?.as_str().map(str::to_string),
196        Value::Array(arr) => arr.iter().find_map(endpoint_uri),
197        _ => None,
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use serde_json::json;
205
206    fn doc(services: Value) -> Value {
207        json!({ "id": "did:webvh:peer", "service": services })
208    }
209
210    #[test]
211    fn protocol_preference_order_is_tsp_didcomm_rest() {
212        assert_eq!(
213            Protocol::PREFERENCE_ORDER,
214            [Protocol::Tsp, Protocol::Didcomm, Protocol::Rest]
215        );
216        // Ord agrees: Tsp is the most preferred (smallest).
217        assert!(Protocol::Tsp < Protocol::Didcomm);
218        assert!(Protocol::Didcomm < Protocol::Rest);
219    }
220
221    #[test]
222    fn parses_each_type_and_endpoint_shape() {
223        let caps = ServiceCapabilities::from_did_document(&doc(json!([
224            // TSP: plain-string mediator DID.
225            { "id": "did:webvh:peer#tsp", "type": "TSPTransport",
226              "serviceEndpoint": "did:webvh:med-tsp" },
227            // DIDComm: array-of-object {uri} mediator DID.
228            { "id": "did:webvh:peer#vta-didcomm", "type": "DIDCommMessaging",
229              "serviceEndpoint": [{ "accept": ["didcomm/v2"], "uri": "did:webvh:med-dc" }] },
230            // REST: plain-string URL.
231            { "id": "did:webvh:peer#vta-rest", "type": "VTARest",
232              "serviceEndpoint": "https://peer.example/" },
233        ])));
234        assert_eq!(caps.tsp.as_deref(), Some("did:webvh:med-tsp"));
235        assert_eq!(caps.didcomm.as_deref(), Some("did:webvh:med-dc"));
236        assert_eq!(caps.rest.as_deref(), Some("https://peer.example/"));
237        assert_eq!(
238            caps.advertised(),
239            vec![Protocol::Tsp, Protocol::Didcomm, Protocol::Rest]
240        );
241    }
242
243    #[test]
244    fn matches_by_type_not_id() {
245        // A TSPTransport service whose id is a non-canonical label is still
246        // discovered (match is on `type`). And a service whose id *looks*
247        // like `#tsp` but has a different type is NOT treated as TSP.
248        let caps = ServiceCapabilities::from_did_document(&doc(json!([
249            { "id": "did:webvh:peer#tsp-transport", "type": "TSPTransport",
250              "serviceEndpoint": "did:webvh:med" },
251            { "id": "did:webvh:peer#tsp", "type": "SomethingElse",
252              "serviceEndpoint": "https://decoy.example/" },
253        ])));
254        assert_eq!(caps.tsp.as_deref(), Some("did:webvh:med"));
255        assert_eq!(caps.rest, None);
256        assert_eq!(caps.didcomm, None);
257    }
258
259    #[test]
260    fn type_may_be_an_array() {
261        let caps = ServiceCapabilities::from_did_document(&doc(json!([
262            { "id": "x", "type": ["DIDCommMessaging", "OtherThing"],
263              "serviceEndpoint": { "uri": "did:webvh:med" } },
264        ])));
265        assert_eq!(caps.didcomm.as_deref(), Some("did:webvh:med"));
266    }
267
268    #[test]
269    fn empty_or_missing_service_array_is_no_capabilities() {
270        assert_eq!(
271            ServiceCapabilities::from_did_document(&json!({ "id": "did:x" })),
272            ServiceCapabilities::default()
273        );
274        assert!(
275            ServiceCapabilities::from_did_document(&doc(json!([])))
276                .advertised()
277                .is_empty()
278        );
279    }
280
281    fn caps(tsp: Option<&str>, didcomm: Option<&str>, rest: Option<&str>) -> ServiceCapabilities {
282        ServiceCapabilities {
283            tsp: tsp.map(str::to_string),
284            didcomm: didcomm.map(str::to_string),
285            rest: rest.map(str::to_string),
286        }
287    }
288
289    #[test]
290    fn select_prefers_tsp_when_both_advertise_it() {
291        let ours = caps(
292            Some("did:m:ours"),
293            Some("did:dc:ours"),
294            Some("https://ours"),
295        );
296        let theirs = caps(
297            Some("did:m:theirs"),
298            Some("did:dc:theirs"),
299            Some("https://theirs"),
300        );
301        let m = select_protocol(&ours, &theirs, "did:webvh:peer").unwrap();
302        assert_eq!(m.protocol, Protocol::Tsp);
303        // Endpoint returned is the *counterparty's* TSP mediator DID.
304        assert_eq!(m.peer_endpoint, "did:m:theirs");
305    }
306
307    #[test]
308    fn select_falls_through_to_didcomm_then_rest() {
309        // We don't speak TSP; peer does — fall to the next shared protocol.
310        let ours = caps(None, Some("did:dc:ours"), Some("https://ours"));
311        let theirs = caps(Some("did:m:theirs"), Some("did:dc:theirs"), None);
312        let m = select_protocol(&ours, &theirs, "did:webvh:peer").unwrap();
313        assert_eq!(m.protocol, Protocol::Didcomm);
314        assert_eq!(m.peer_endpoint, "did:dc:theirs");
315
316        // Only REST in common.
317        let ours = caps(Some("did:m:ours"), None, Some("https://ours"));
318        let theirs = caps(None, Some("did:dc:theirs"), Some("https://theirs"));
319        let m = select_protocol(&ours, &theirs, "did:webvh:peer").unwrap();
320        assert_eq!(m.protocol, Protocol::Rest);
321        assert_eq!(m.peer_endpoint, "https://theirs");
322    }
323
324    #[test]
325    fn select_requires_both_sides_to_advertise() {
326        // We only speak TSP; peer only speaks REST — no overlap.
327        let ours = caps(Some("did:m:ours"), None, None);
328        let theirs = caps(None, None, Some("https://theirs"));
329        let err = select_protocol(&ours, &theirs, "did:webvh:peer").unwrap_err();
330        match err {
331            VtaError::NoMatchingProtocol {
332                counterparty_did,
333                ours,
334                theirs,
335            } => {
336                assert_eq!(counterparty_did, "did:webvh:peer");
337                assert_eq!(ours, vec![Protocol::Tsp]);
338                assert_eq!(theirs, vec![Protocol::Rest]);
339            }
340            other => panic!("expected NoMatchingProtocol, got {other:?}"),
341        }
342    }
343}