vta-sdk 0.41.1

SDK for Verifiable Trust Agents operating in Verifiable Trust Communities
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
//! Bidirectional transport-protocol matching from advertised DID-document
//! services.
//!
//! When two parties communicate, the protocol used is the highest-preference
//! one **both** advertise in their DID documents — **TSP > DIDComm > REST**
//! (`docs/05-design-notes/tsp-enablement.md` §3, §11). Services are matched on
//! their `type` (`TSPTransport` / `DIDCommMessaging` / `VTARest`), **never** on
//! the `#id` fragment, which is an arbitrary label (D9 — the OWF reference TSP
//! impl names its id `#tsp-transport`, Affinidi names it `#tsp`; same type). If
//! the advertised sets don't intersect, [`select_protocol`] returns
//! [`VtaError::NoMatchingProtocol`] carrying both sides' advertised sets.
//!
//! This is pure, side-effect-free logic over an already-resolved DID document
//! `serde_json::Value`. DID resolution itself is the caller's job; so is the
//! second hop for TSP/DIDComm (resolving the returned mediator DID to its
//! transport URL).

use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::error::VtaError;

/// DID-document service `type` for a TSP transport endpoint. `TSPTransport`
/// is the OpenWallet-Foundation-Labs reference-implementation convention
/// (`affinidi_tsp`'s DID-backed VID resolver matches on it); the ToIP TSP
/// spec names no DID-document service type. Kept in sync with
/// `vta_service::operations::protocol::document::TSP_SERVICE_TYPE`.
pub const TSP_SERVICE_TYPE: &str = "TSPTransport";

/// DID-document service `type` for a DIDComm v2 mediator endpoint (W3C).
pub const DIDCOMM_SERVICE_TYPE: &str = "DIDCommMessaging";

/// DID-document service `type` for the VTA REST endpoint. Kept in sync with
/// `vta_service::operations::protocol::document::REST_SERVICE_TYPE`.
///
/// Correct for a VTA, and only for a VTA — it says "a VTA's REST API is behind
/// this URL". Non-VTA services advertise their own REST type; see
/// [`TRQP_REST_SERVICE_TYPE`].
pub const REST_SERVICE_TYPE: &str = "VTARest";

/// DID-document service `type` for a Trust Registry's REST/TRQP surface.
///
/// A Trust Registry is not a VTA, so it must not advertise [`REST_SERVICE_TYPE`]
/// — that would promise a consumer a VTA's endpoints. `TRQPRest` names the
/// interface actually served (TRQP over REST), matching how the sibling types
/// name protocols rather than products. Kept in sync with
/// `trust_registry::didcomm::did_document::REST_SERVICE_TYPE` in
/// `affinidi-trust-registry-rs`.
pub const TRQP_REST_SERVICE_TYPE: &str = "TRQPRest";

/// DID-document service `type` for a Trust-Task HTTPS endpoint
/// (HTTPS binding 0.2 §6.2).
///
/// **The one that states an interface rather than a product**: "this party
/// accepts Trust Task documents over the HTTPS binding". Deliberately not
/// [`REST_SERVICE_TYPE`] — "is a VTA's REST API" and "accepts Trust Tasks" are
/// different claims that merely coincide while every Trust-Task server we run
/// happens to be a VTA. A consumer that conflates them posts Trust Tasks to an
/// endpoint that never agreed to accept them, which is not hypothetical: this
/// VTA posts them to `WebVHHosting`, a type whose endpoint advertises where DID
/// *documents* are served.
///
/// Its `serviceEndpoint` is the **Trust-Task base**, and the request URL is
/// `base + "/trust-tasks"`. That is what binding 0.2 §6 settles and why it had
/// to: before it, the path was fixed but what it was relative to was not, so two
/// conformant implementations composed `/api/trust-tasks` and `/trust-tasks`
/// and both were right.
///
/// Kept in sync with `TRUST_TASK_HTTPS_SERVICE_TYPE` in the browser plugin's
/// `vta/endpoint.ts`, which has implemented this since #125.
pub const TRUST_TASK_HTTPS_SERVICE_TYPE: &str = "TrustTaskHTTPS";

/// Every service `type` that denotes an endpoint accepting Trust Tasks over
/// HTTPS, in match order.
///
/// [`TRUST_TASK_HTTPS_SERVICE_TYPE`] is first because it is the only one that
/// *says so*. The two product types after it are accepted because every VTA and
/// Trust Registry in this workspace advertises one of them today and their
/// endpoint is, in practice, the Trust-Task base — but they are a compatibility
/// reading of a claim that was never quite the one being made, and a party that
/// wants to be found should advertise the binding type.
///
/// Adding a type here is the only change needed for a service to become
/// discoverable.
pub const REST_SERVICE_TYPES: [&str; 3] = [
    TRUST_TASK_HTTPS_SERVICE_TYPE,
    REST_SERVICE_TYPE,
    TRQP_REST_SERVICE_TYPE,
];

/// A transport protocol, in workspace preference order: TSP, then DIDComm,
/// then REST. `Ord` follows that order — `Tsp` is the smallest (most
/// preferred) — so [`Protocol::PREFERENCE_ORDER`] is ascending.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Protocol {
    Tsp,
    Didcomm,
    Rest,
}

impl Protocol {
    /// Every protocol in descending preference order (most preferred first).
    pub const PREFERENCE_ORDER: [Protocol; 3] = [Protocol::Tsp, Protocol::Didcomm, Protocol::Rest];

    /// Lowercase wire/display name (`"tsp"` / `"didcomm"` / `"rest"`).
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Protocol::Tsp => "tsp",
            Protocol::Didcomm => "didcomm",
            Protocol::Rest => "rest",
        }
    }
}

impl std::fmt::Display for Protocol {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// The transport services a party advertises in its DID document, parsed by
/// service `type`. Each field holds the endpoint the SDK would route to for
/// that protocol:
///
/// - `tsp` / `didcomm`: the party's **mediator DID** (its VID / mediator),
///   not a transport URL — TSP and DIDComm both use mediator indirection (the
///   transport URL lives in the mediator's own DID document).
/// - `rest`: the party's REST base URL.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ServiceCapabilities {
    pub tsp: Option<String>,
    pub didcomm: Option<String>,
    pub rest: Option<String>,
}

impl ServiceCapabilities {
    /// Parse the advertised transports from a resolved DID document.
    ///
    /// Walks the `service` array and selects entries by their `type` (D9 —
    /// never by `#id`). The `type` may be a string or an array of strings
    /// (DID-Core permits both). The first non-empty endpoint of each type
    /// wins; later duplicates are ignored. A document with no `service`
    /// array yields an all-`None` capability set.
    #[must_use]
    pub fn from_did_document(doc: &Value) -> Self {
        let mut caps = ServiceCapabilities::default();
        let Some(services) = doc.get("service").and_then(Value::as_array) else {
            return caps;
        };
        // The REST winner is chosen by *which type it is*, never by where it
        // sits: `REST_SERVICE_TYPES` is in match order and the best rank wins,
        // ties going to document order. Position-dependence would mean a party
        // advertising both `TrustTaskHTTPS` and a product type got whichever it
        // happened to list first — and only one of those actually claims to
        // accept Trust Tasks. The same reasoning the DIDComm-wherever-it-sits
        // rule already follows elsewhere.
        let mut rest_rank = usize::MAX;
        for svc in services {
            let Some(uri) = svc.get("serviceEndpoint").and_then(endpoint_uri) else {
                continue;
            };
            if uri.is_empty() {
                continue;
            }
            if service_has_type(svc, TSP_SERVICE_TYPE) {
                caps.tsp.get_or_insert(uri);
            } else if service_has_type(svc, DIDCOMM_SERVICE_TYPE) {
                caps.didcomm.get_or_insert(uri);
            } else if let Some(rank) = REST_SERVICE_TYPES
                .iter()
                .position(|t| service_has_type(svc, t))
                && rank < rest_rank
            {
                rest_rank = rank;
                caps.rest = Some(uri);
            }
        }
        caps
    }

    /// The endpoint advertised for `protocol`, if any (mediator DID for
    /// TSP/DIDComm, URL for REST).
    #[must_use]
    pub fn endpoint(&self, protocol: Protocol) -> Option<&str> {
        match protocol {
            Protocol::Tsp => self.tsp.as_deref(),
            Protocol::Didcomm => self.didcomm.as_deref(),
            Protocol::Rest => self.rest.as_deref(),
        }
    }

    /// Every protocol this party advertises, in preference order.
    #[must_use]
    pub fn advertised(&self) -> Vec<Protocol> {
        Protocol::PREFERENCE_ORDER
            .into_iter()
            .filter(|p| self.endpoint(*p).is_some())
            .collect()
    }
}

/// The chosen protocol and the counterparty endpoint to route to for it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProtocolMatch {
    /// The selected transport.
    pub protocol: Protocol,
    /// The counterparty endpoint for `protocol`: the peer's **mediator DID**
    /// for TSP/DIDComm (resolve it onward for the transport URL), the peer's
    /// **URL** for REST.
    pub peer_endpoint: String,
}

/// Pick the protocol to use with a counterparty: the highest-preference one
/// (TSP > DIDComm > REST) present in **both** `ours` and `theirs`.
///
/// Returns [`VtaError::NoMatchingProtocol`] — carrying both advertised sets —
/// when the intersection is empty, so the CLI can show the operator what each
/// side offers and which transport to enable. Never silently downgrades past
/// what a peer advertises.
pub fn select_protocol(
    ours: &ServiceCapabilities,
    theirs: &ServiceCapabilities,
    counterparty_did: &str,
) -> Result<ProtocolMatch, VtaError> {
    for protocol in Protocol::PREFERENCE_ORDER {
        if ours.endpoint(protocol).is_some()
            && let Some(peer) = theirs.endpoint(protocol)
        {
            return Ok(ProtocolMatch {
                protocol,
                peer_endpoint: peer.to_string(),
            });
        }
    }
    Err(VtaError::NoMatchingProtocol {
        counterparty_did: counterparty_did.to_string(),
        ours: ours.advertised(),
        theirs: theirs.advertised(),
    })
}

/// Whether a service entry advertises `type_`. DID-Core permits `type` to be
/// a single string or an array of strings.
fn service_has_type(svc: &Value, type_: &str) -> bool {
    match svc.get("type") {
        Some(Value::String(s)) => s == type_,
        Some(Value::Array(arr)) => arr.iter().any(|t| t.as_str() == Some(type_)),
        _ => false,
    }
}

/// Resolve a `serviceEndpoint` value to its URI, tolerating the three shapes
/// a DID document may carry it in: a plain string (TSP/REST current
/// convention), an object with a `uri` field (DIDComm v2), or a
/// single-element array of either. Mirrors
/// `vta_service::operations::protocol::document::extract_mediator_did`.
fn endpoint_uri(endpoint: &Value) -> Option<String> {
    match endpoint {
        Value::String(s) => Some(s.clone()),
        Value::Object(map) => map.get("uri")?.as_str().map(str::to_string),
        Value::Array(arr) => arr.iter().find_map(endpoint_uri),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn doc(services: Value) -> Value {
        json!({ "id": "did:webvh:peer", "service": services })
    }

    /// A VTA advertises `VTARest`; a Trust Registry advertises `TRQPRest`.
    /// Both are REST endpoints, and neither should have to claim the other's
    /// service type to be discovered.
    #[test]
    fn both_rest_service_types_are_recognised() {
        for ty in ["VTARest", "TRQPRest"] {
            let caps = ServiceCapabilities::from_did_document(&doc(json!([
                { "id": "did:webvh:peer#rest", "type": ty,
                  "serviceEndpoint": "https://peer.example" }
            ])));
            assert_eq!(
                caps.rest.as_deref(),
                Some("https://peer.example"),
                "{ty} must be recognised as a REST endpoint"
            );
            assert_eq!(caps.advertised(), vec![Protocol::Rest]);
        }
    }

    /// A registry advertising only `TRQPRest` must be selectable over REST —
    /// this is the case that previously yielded `NoMatchingProtocol`.
    #[test]
    fn trqp_rest_only_peer_is_selectable() {
        let theirs = ServiceCapabilities::from_did_document(&doc(json!([
            { "id": "did:webvh:registry#rest", "type": "TRQPRest",
              "serviceEndpoint": "https://registry.example" }
        ])));
        let ours = ServiceCapabilities {
            rest: Some("https://us.example".into()),
            ..Default::default()
        };
        let chosen = select_protocol(&ours, &theirs, "did:webvh:registry").unwrap();
        assert_eq!(chosen.protocol, Protocol::Rest);
        assert_eq!(chosen.peer_endpoint, "https://registry.example");
    }

    #[test]
    fn protocol_preference_order_is_tsp_didcomm_rest() {
        assert_eq!(
            Protocol::PREFERENCE_ORDER,
            [Protocol::Tsp, Protocol::Didcomm, Protocol::Rest]
        );
        // Ord agrees: Tsp is the most preferred (smallest).
        assert!(Protocol::Tsp < Protocol::Didcomm);
        assert!(Protocol::Didcomm < Protocol::Rest);
    }

    #[test]
    fn parses_each_type_and_endpoint_shape() {
        let caps = ServiceCapabilities::from_did_document(&doc(json!([
            // TSP: plain-string mediator DID.
            { "id": "did:webvh:peer#tsp", "type": "TSPTransport",
              "serviceEndpoint": "did:webvh:med-tsp" },
            // DIDComm: array-of-object {uri} mediator DID.
            { "id": "did:webvh:peer#vta-didcomm", "type": "DIDCommMessaging",
              "serviceEndpoint": [{ "accept": ["didcomm/v2"], "uri": "did:webvh:med-dc" }] },
            // REST: plain-string URL.
            { "id": "did:webvh:peer#vta-rest", "type": "VTARest",
              "serviceEndpoint": "https://peer.example/" },
        ])));
        assert_eq!(caps.tsp.as_deref(), Some("did:webvh:med-tsp"));
        assert_eq!(caps.didcomm.as_deref(), Some("did:webvh:med-dc"));
        assert_eq!(caps.rest.as_deref(), Some("https://peer.example/"));
        assert_eq!(
            caps.advertised(),
            vec![Protocol::Tsp, Protocol::Didcomm, Protocol::Rest]
        );
    }

    #[test]
    fn matches_by_type_not_id() {
        // A TSPTransport service whose id is a non-canonical label is still
        // discovered (match is on `type`). And a service whose id *looks*
        // like `#tsp` but has a different type is NOT treated as TSP.
        let caps = ServiceCapabilities::from_did_document(&doc(json!([
            { "id": "did:webvh:peer#tsp-transport", "type": "TSPTransport",
              "serviceEndpoint": "did:webvh:med" },
            { "id": "did:webvh:peer#tsp", "type": "SomethingElse",
              "serviceEndpoint": "https://decoy.example/" },
        ])));
        assert_eq!(caps.tsp.as_deref(), Some("did:webvh:med"));
        assert_eq!(caps.rest, None);
        assert_eq!(caps.didcomm, None);
    }

    #[test]
    fn type_may_be_an_array() {
        let caps = ServiceCapabilities::from_did_document(&doc(json!([
            { "id": "x", "type": ["DIDCommMessaging", "OtherThing"],
              "serviceEndpoint": { "uri": "did:webvh:med" } },
        ])));
        assert_eq!(caps.didcomm.as_deref(), Some("did:webvh:med"));
    }

    #[test]
    fn empty_or_missing_service_array_is_no_capabilities() {
        assert_eq!(
            ServiceCapabilities::from_did_document(&json!({ "id": "did:x" })),
            ServiceCapabilities::default()
        );
        assert!(
            ServiceCapabilities::from_did_document(&doc(json!([])))
                .advertised()
                .is_empty()
        );
    }

    fn caps(tsp: Option<&str>, didcomm: Option<&str>, rest: Option<&str>) -> ServiceCapabilities {
        ServiceCapabilities {
            tsp: tsp.map(str::to_string),
            didcomm: didcomm.map(str::to_string),
            rest: rest.map(str::to_string),
        }
    }

    #[test]
    fn select_prefers_tsp_when_both_advertise_it() {
        let ours = caps(
            Some("did:m:ours"),
            Some("did:dc:ours"),
            Some("https://ours"),
        );
        let theirs = caps(
            Some("did:m:theirs"),
            Some("did:dc:theirs"),
            Some("https://theirs"),
        );
        let m = select_protocol(&ours, &theirs, "did:webvh:peer").unwrap();
        assert_eq!(m.protocol, Protocol::Tsp);
        // Endpoint returned is the *counterparty's* TSP mediator DID.
        assert_eq!(m.peer_endpoint, "did:m:theirs");
    }

    #[test]
    fn select_falls_through_to_didcomm_then_rest() {
        // We don't speak TSP; peer does — fall to the next shared protocol.
        let ours = caps(None, Some("did:dc:ours"), Some("https://ours"));
        let theirs = caps(Some("did:m:theirs"), Some("did:dc:theirs"), None);
        let m = select_protocol(&ours, &theirs, "did:webvh:peer").unwrap();
        assert_eq!(m.protocol, Protocol::Didcomm);
        assert_eq!(m.peer_endpoint, "did:dc:theirs");

        // Only REST in common.
        let ours = caps(Some("did:m:ours"), None, Some("https://ours"));
        let theirs = caps(None, Some("did:dc:theirs"), Some("https://theirs"));
        let m = select_protocol(&ours, &theirs, "did:webvh:peer").unwrap();
        assert_eq!(m.protocol, Protocol::Rest);
        assert_eq!(m.peer_endpoint, "https://theirs");
    }

    #[test]
    fn select_requires_both_sides_to_advertise() {
        // We only speak TSP; peer only speaks REST — no overlap.
        let ours = caps(Some("did:m:ours"), None, None);
        let theirs = caps(None, None, Some("https://theirs"));
        let err = select_protocol(&ours, &theirs, "did:webvh:peer").unwrap_err();
        match err {
            VtaError::NoMatchingProtocol {
                counterparty_did,
                ours,
                theirs,
            } => {
                assert_eq!(counterparty_did, "did:webvh:peer");
                assert_eq!(ours, vec![Protocol::Tsp]);
                assert_eq!(theirs, vec![Protocol::Rest]);
            }
            other => panic!("expected NoMatchingProtocol, got {other:?}"),
        }
    }

    /// The binding type is the only one that claims to accept Trust Tasks, so
    /// it wins over a product type wherever it sits in the array. Listed last
    /// here deliberately: position must not decide this.
    #[test]
    fn the_binding_type_beats_a_product_type_wherever_it_sits() {
        let doc = serde_json::json!({ "service": [
            { "id": "#rest", "type": "VTARest", "serviceEndpoint": "https://vta.example" },
            { "id": "#tt", "type": "TrustTaskHTTPS", "serviceEndpoint": "https://vta.example/api" },
        ]});
        let caps = ServiceCapabilities::from_did_document(&doc);
        assert_eq!(
            caps.endpoint(Protocol::Rest),
            Some("https://vta.example/api"),
            "the product type won because it was listed first"
        );
    }

    /// And a party advertising only a product type is still reachable — every
    /// VTA in this workspace advertises `VTARest` today.
    #[test]
    fn a_product_type_alone_is_still_discoverable() {
        let doc = serde_json::json!({ "service": [
            { "id": "#rest", "type": "VTARest", "serviceEndpoint": "https://vta.example" },
        ]});
        let caps = ServiceCapabilities::from_did_document(&doc);
        assert_eq!(caps.endpoint(Protocol::Rest), Some("https://vta.example"));
    }
}