use vta_sdk::protocol::matching::{Protocol, ServiceCapabilities};
pub const TSP_BUILD: &[Protocol] = &[Protocol::Tsp, Protocol::Didcomm];
pub const NON_TSP_BUILD: &[Protocol] = &[Protocol::Didcomm];
#[must_use]
pub fn served_transports() -> &'static [Protocol] {
if cfg!(feature = "tsp") {
TSP_BUILD
} else {
NON_TSP_BUILD
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnservableTransport {
pub protocol: Protocol,
pub endpoint: String,
}
impl UnservableTransport {
#[must_use]
pub fn remediation(&self) -> String {
match self.protocol {
Protocol::Tsp => format!(
"this VTC's DID document advertises a TSP transport (`TSPTransport` -> {}), but \
this binary was built without the `tsp` feature and cannot receive TSP frames. \
Because the workspace prefers TSP over DIDComm over REST, every conforming \
client will choose TSP and every one of its messages will be dropped \
undecodable in the messaging SDK's websocket transport, before this service \
sees it. Fix by rebuilding with `--features tsp` (it is on by default; a \
`--no-default-features` build must add it back), or by removing the \
`TSPTransport` service entry from the DID document so clients fall back to a \
transport this binary serves.",
self.endpoint
),
Protocol::Didcomm => format!(
"this VTC's DID document advertises a DIDComm mediator (`DIDCommMessaging` -> \
{}), but this binary cannot serve DIDComm. Rebuild with DIDComm support, or \
remove the `DIDCommMessaging` service entry from the DID document.",
self.endpoint
),
Protocol::Rest => format!(
"this VTC's DID document advertises a REST endpoint ({}) that this binary does \
not serve.",
self.endpoint
),
}
}
}
impl std::fmt::Display for UnservableTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.remediation())
}
}
#[must_use]
pub fn unservable_against(
served: &[Protocol],
caps: &ServiceCapabilities,
) -> Vec<UnservableTransport> {
caps.advertised()
.into_iter()
.filter(|p| *p != Protocol::Rest)
.filter(|p| !served.contains(p))
.map(|protocol| UnservableTransport {
endpoint: caps.endpoint(protocol).unwrap_or_default().to_string(),
protocol,
})
.collect()
}
#[must_use]
pub fn unservable_advertised(caps: &ServiceCapabilities) -> Vec<UnservableTransport> {
unservable_against(served_transports(), caps)
}
#[must_use]
pub fn has_servable_messaging_against(served: &[Protocol], caps: &ServiceCapabilities) -> bool {
caps.advertised()
.into_iter()
.filter(|p| *p != Protocol::Rest)
.any(|p| served.contains(&p))
}
#[must_use]
pub fn has_servable_messaging(caps: &ServiceCapabilities) -> bool {
has_servable_messaging_against(served_transports(), caps)
}
#[must_use]
pub fn advertises_messaging(caps: &ServiceCapabilities) -> bool {
caps.advertised().iter().any(|p| *p != Protocol::Rest)
}
#[must_use]
pub fn served_not_advertised(served: &[Protocol], caps: &ServiceCapabilities) -> Vec<Protocol> {
let advertised = caps.advertised();
served
.iter()
.copied()
.filter(|p| !advertised.contains(p))
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warn,
Info,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
pub severity: Severity,
pub message: String,
}
#[must_use]
pub fn findings_against(served: &[Protocol], caps: &ServiceCapabilities) -> Vec<Finding> {
let mut out = Vec::new();
for u in unservable_against(served, caps) {
out.push(Finding {
severity: Severity::Error,
message: u.remediation(),
});
}
if !advertises_messaging(caps) {
out.push(Finding {
severity: Severity::Warn,
message: "this VTC's DID document advertises no messaging transport at all (no \
`TSPTransport`, no `DIDCommMessaging`) — a client resolving it can reach \
this community over REST only, and nothing can be delivered to it over \
the mediator. If that is not deliberate, add a service entry via the \
runtime-service-management flow."
.to_string(),
});
}
if lacks_didcomm_fallback(caps) {
out.push(Finding {
severity: Severity::Warn,
message: "this VTC advertises TSP but no DIDComm mediator, so a peer that does not \
speak TSP has no messaging transport to fall back to, and a build without \
the `tsp` feature would have none at all. tsp-enablement.md §12 Phase A \
is to advertise both; add a `DIDCommMessaging` service unless dropping \
DIDComm is deliberate."
.to_string(),
});
}
for p in served_not_advertised(served, caps) {
out.push(Finding {
severity: Severity::Info,
message: format!(
"this build serves {p} but the DID document does not advertise it, so no client \
will choose it. Normal mid-rollout (ship the capable binary, then add the \
service); add the service entry to start receiving {p} traffic."
),
});
}
out
}
#[must_use]
pub fn findings_for_build(caps: &ServiceCapabilities) -> Vec<Finding> {
findings_against(served_transports(), caps)
}
pub async fn local_did_document(config: &crate::config::AppConfig) -> Option<serde_json::Value> {
let label = crate::routes::did_log::did_log_label(config.vtc_did.as_deref()?)?;
let path = config
.store
.data_dir
.join("did")
.join(format!("{label}.jsonl"));
let body = tokio::fs::read_to_string(&path).await.ok()?;
let last = body.lines().rev().find(|l| !l.trim().is_empty())?;
let entry: serde_json::Value = serde_json::from_str(last).ok()?;
entry.get("state").cloned()
}
pub async fn enforce_at_boot(
config: &crate::config::AppConfig,
) -> Result<(), crate::error::AppError> {
let Some(doc) = local_did_document(config).await else {
return Ok(());
};
let caps = ServiceCapabilities::from_did_document(&doc);
let unservable = unservable_advertised(&caps);
if unservable.is_empty() {
return Ok(());
}
let detail = unservable
.iter()
.map(UnservableTransport::remediation)
.collect::<Vec<_>>()
.join(" ");
Err(crate::error::AppError::Config(format!(
"this VTC's published DID document advertises a transport this build cannot serve, so \
conforming clients would choose a path that silently drops their messages. {detail}"
)))
}
pub async fn resolved_capabilities(
did_resolver: Option<&affinidi_did_resolver_cache_sdk::DIDCacheClient>,
vtc_did: &str,
) -> Option<ServiceCapabilities> {
let resolved = did_resolver?.resolve(vtc_did).await.ok()?;
let doc = serde_json::to_value(&resolved.doc).ok()?;
Some(ServiceCapabilities::from_did_document(&doc))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MessagingVerdict {
Ok,
Degraded(Vec<UnservableTransport>),
Unreachable(Vec<UnservableTransport>),
}
#[must_use]
pub fn classify_against(served: &[Protocol], caps: &ServiceCapabilities) -> MessagingVerdict {
let unservable = unservable_against(served, caps);
if unservable.is_empty() {
return MessagingVerdict::Ok;
}
if has_servable_messaging_against(served, caps) {
MessagingVerdict::Degraded(unservable)
} else {
MessagingVerdict::Unreachable(unservable)
}
}
#[must_use]
pub fn classify_for_messaging(caps: &ServiceCapabilities) -> MessagingVerdict {
classify_against(served_transports(), caps)
}
#[must_use]
pub fn lacks_didcomm_fallback(caps: &ServiceCapabilities) -> bool {
caps.tsp.is_some() && caps.didcomm.is_none()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn deployed_shape() -> ServiceCapabilities {
ServiceCapabilities::from_did_document(&json!({
"service": [
{ "id": "did:webvh:x:h:first-vtc#vtc-rest",
"type": "VTCRest",
"serviceEndpoint": "https://first.openvtc.net" },
{ "id": "did:webvh:x:h:first-vtc#vtc-status-list",
"type": "VTCStatusList",
"serviceEndpoint": "https://first.openvtc.net/v1/status-lists" },
{ "id": "did:webvh:x:h:first-vtc#tsp",
"type": "TSPTransport",
"serviceEndpoint": "did:webvh:y:h:mediator" },
]
}))
}
fn tsp_and_didcomm() -> ServiceCapabilities {
ServiceCapabilities::from_did_document(&json!({
"service": [
{ "id": "did:webvh:x:h:first-vtc#tsp",
"type": "TSPTransport",
"serviceEndpoint": "did:webvh:y:h:mediator" },
{ "id": "did:webvh:x:h:first-vtc#didcomm",
"type": "DIDCommMessaging",
"serviceEndpoint": "did:webvh:y:h:mediator" },
]
}))
}
fn didcomm_only() -> ServiceCapabilities {
ServiceCapabilities::from_did_document(&json!({
"service": [
{ "id": "did:webvh:x:h:first-vtc#didcomm",
"type": "DIDCommMessaging",
"serviceEndpoint": "did:webvh:y:h:mediator" },
]
}))
}
fn rest_only() -> ServiceCapabilities {
ServiceCapabilities::from_did_document(&json!({
"service": [
{ "id": "did:webvh:x:h:first-vtc#vtc-rest",
"type": "VTCRest",
"serviceEndpoint": "https://first.openvtc.net" },
]
}))
}
#[test]
fn non_tsp_build_reports_the_tsp_it_cannot_serve() {
let found = unservable_against(NON_TSP_BUILD, &deployed_shape());
assert_eq!(found.len(), 1, "expected exactly the TSP entry: {found:?}");
assert_eq!(found[0].protocol, Protocol::Tsp);
assert_eq!(found[0].endpoint, "did:webvh:y:h:mediator");
}
#[test]
fn the_error_names_transport_reason_and_fix() {
let found = unservable_against(NON_TSP_BUILD, &deployed_shape());
let text = found[0].remediation();
assert!(text.contains("TSP"), "must name the transport: {text}");
assert!(text.contains("`tsp` feature"), "must name why: {text}");
assert!(text.contains("--features tsp"), "must name the fix: {text}");
assert!(
text.contains("TSPTransport") && text.contains("DID document"),
"must name the other fix — editing the document: {text}"
);
}
#[test]
fn tsp_build_serves_the_tsp_it_advertises() {
assert_eq!(unservable_against(TSP_BUILD, &deployed_shape()), vec![]);
assert!(has_servable_messaging_against(TSP_BUILD, &deployed_shape()));
assert_eq!(
classify_against(TSP_BUILD, &deployed_shape()),
MessagingVerdict::Ok
);
}
#[test]
fn tsp_only_document_is_unreachable_to_a_non_tsp_build() {
assert!(!has_servable_messaging_against(
NON_TSP_BUILD,
&deployed_shape()
));
assert!(advertises_messaging(&deployed_shape()));
assert!(matches!(
classify_against(NON_TSP_BUILD, &deployed_shape()),
MessagingVerdict::Unreachable(u) if u.len() == 1
));
}
#[test]
fn tsp_plus_didcomm_degrades_rather_than_stopping() {
assert!(has_servable_messaging_against(
NON_TSP_BUILD,
&tsp_and_didcomm()
));
assert!(matches!(
classify_against(NON_TSP_BUILD, &tsp_and_didcomm()),
MessagingVerdict::Degraded(u) if u.len() == 1 && u[0].protocol == Protocol::Tsp
));
assert_eq!(
classify_against(TSP_BUILD, &tsp_and_didcomm()),
MessagingVerdict::Ok
);
}
#[test]
fn didcomm_is_servable_in_every_build() {
for served in [TSP_BUILD, NON_TSP_BUILD] {
assert_eq!(unservable_against(served, &didcomm_only()), vec![]);
assert!(has_servable_messaging_against(served, &didcomm_only()));
}
}
#[test]
fn rest_only_is_not_a_mismatch() {
for served in [TSP_BUILD, NON_TSP_BUILD] {
assert_eq!(unservable_against(served, &rest_only()), vec![]);
assert_eq!(classify_against(served, &rest_only()), MessagingVerdict::Ok);
assert!(!has_servable_messaging_against(served, &rest_only()));
}
assert!(!advertises_messaging(&rest_only()));
}
#[test]
fn matches_tsp_by_type_not_by_id_fragment() {
let caps = ServiceCapabilities::from_did_document(&json!({
"service": [
{ "id": "did:webvh:x:h:first-vtc#tsp-transport",
"type": "TSPTransport",
"serviceEndpoint": "did:webvh:y:h:mediator" },
]
}));
let found = unservable_against(NON_TSP_BUILD, &caps);
assert_eq!(found.len(), 1, "id fragment must not gate discovery");
assert_eq!(found[0].protocol, Protocol::Tsp);
}
#[test]
fn served_transports_tracks_the_compiled_feature() {
if cfg!(feature = "tsp") {
assert_eq!(served_transports(), TSP_BUILD);
assert!(served_transports().contains(&Protocol::Tsp));
} else {
assert_eq!(served_transports(), NON_TSP_BUILD);
assert!(!served_transports().contains(&Protocol::Tsp));
}
}
#[test]
#[allow(clippy::assertions_on_constants)]
fn the_default_build_serves_tsp() {
assert!(
cfg!(feature = "tsp"),
"`tsp` must stay a default feature of vtc-service: the shipped binary has to serve \
the TSP its DID document may advertise."
);
}
fn messages(findings: &[Finding], want: Severity) -> Vec<&str> {
findings
.iter()
.filter(|f| f.severity == want)
.map(|f| f.message.as_str())
.collect()
}
#[test]
fn unservable_advertised_transport_is_an_error_finding() {
let f = findings_against(NON_TSP_BUILD, &deployed_shape());
let errors = messages(&f, Severity::Error);
assert_eq!(errors.len(), 1, "expected one error finding: {f:?}");
assert!(errors[0].contains("TSP") && errors[0].contains("--features tsp"));
}
#[test]
fn a_document_with_no_messaging_service_is_warned_about() {
for served in [TSP_BUILD, NON_TSP_BUILD] {
let f = findings_against(served, &rest_only());
assert!(
messages(&f, Severity::Error).is_empty(),
"REST-only is legal, not an error: {f:?}"
);
assert!(
messages(&f, Severity::Warn)
.iter()
.any(|m| m.contains("no messaging transport at all")),
"expected the no-messaging warning: {f:?}"
);
}
}
#[test]
fn serving_more_than_the_document_advertises_is_informational() {
let f = findings_against(TSP_BUILD, &didcomm_only());
assert!(
messages(&f, Severity::Error).is_empty(),
"a capable binary that under-advertises strands nobody: {f:?}"
);
assert!(
messages(&f, Severity::Info)
.iter()
.any(|m| m.contains("tsp") && m.contains("does not advertise it")),
"expected the staged-rollout note for TSP: {f:?}"
);
assert_eq!(
served_not_advertised(TSP_BUILD, &didcomm_only()),
vec![Protocol::Tsp]
);
}
#[test]
fn a_document_that_matches_the_build_has_no_findings() {
assert_eq!(findings_against(TSP_BUILD, &tsp_and_didcomm()), vec![]);
}
#[test]
fn errors_sort_before_warnings_and_info() {
let f = findings_against(NON_TSP_BUILD, &deployed_shape());
assert_eq!(
f.first().map(|f| f.severity),
Some(Severity::Error),
"the unservable-transport error must lead: {f:?}"
);
}
}