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