use serde_json::Value;
use vta_sdk::protocol::matching::{Protocol, ServiceCapabilities};
use vti_common::error::{AppError, bad_gateway_error};
#[cfg(feature = "didcomm")]
use crate::didcomm_bridge::DIDCommBridge;
#[cfg(feature = "didcomm")]
const DIDCOMM_REPLY_TIMEOUT_SECS: u64 = 30;
const DIDCOMM_MESSAGE_TYPE: &str = trust_tasks_didcomm::ENVELOPE_TYPE;
pub const OUTBOUND_SUPPORTED: &[Protocol] = &[
#[cfg(feature = "tsp")]
Protocol::Tsp,
#[cfg(feature = "didcomm")]
Protocol::Didcomm,
Protocol::Rest,
];
#[cfg(feature = "tsp")]
const TSP_REPLY_TIMEOUT_SECS: u64 = 30;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReplyTrust {
SignedByRecipient,
TransportAuthenticated,
}
#[cfg(feature = "tsp")]
#[derive(Clone)]
pub struct TspSender {
transport: crate::messaging::tsp_transport::TspTransport,
replies: crate::trust_tasks::pending_replies::PendingReplies,
}
#[cfg(feature = "tsp")]
impl TspSender {
pub(crate) fn from_app_state(state: &crate::server::AppState) -> Option<Self> {
Some(Self {
transport: state.tsp_transport()?,
replies: state.pending_replies.clone(),
})
}
}
pub struct Outbound<'a> {
resolver: &'a affinidi_did_resolver_cache_sdk::DIDCacheClient,
#[cfg(feature = "tsp")]
tsp: Option<TspSender>,
#[cfg(feature = "didcomm")]
bridge: &'a DIDCommBridge,
}
impl<'a> Outbound<'a> {
pub fn from_parts(
resolver: &'a affinidi_did_resolver_cache_sdk::DIDCacheClient,
#[cfg(feature = "didcomm")] bridge: &'a DIDCommBridge,
#[cfg(feature = "tsp")] tsp: Option<TspSender>,
) -> Self {
Self {
resolver,
#[cfg(feature = "tsp")]
tsp,
#[cfg(feature = "didcomm")]
bridge,
}
}
pub fn from_app_state(
state: &'a crate::server::AppState,
resolver: &'a affinidi_did_resolver_cache_sdk::DIDCacheClient,
) -> Self {
Self {
resolver,
#[cfg(feature = "tsp")]
tsp: TspSender::from_app_state(state),
#[cfg(feature = "didcomm")]
bridge: state.didcomm_bridge.as_ref(),
}
}
}
pub fn pick_transport(
caps: &ServiceCapabilities,
peer: &str,
) -> Result<(Protocol, String), AppError> {
for protocol in Protocol::PREFERENCE_ORDER {
if !OUTBOUND_SUPPORTED.contains(&protocol) {
continue;
}
if let Some(endpoint) = caps.endpoint(protocol) {
return Ok((protocol, endpoint.to_string()));
}
}
let advertised: Vec<&str> = Protocol::PREFERENCE_ORDER
.iter()
.filter(|p| caps.endpoint(**p).is_some())
.map(|p| p.as_str())
.collect();
let ours: Vec<&str> = OUTBOUND_SUPPORTED.iter().map(|p| p.as_str()).collect();
Err(AppError::Validation(format!(
"no transport in common with `{peer}`: it advertises [{}] and this agent can \
initiate [{}]. This is not a peer that cannot be reached — it is one this agent cannot \
yet start a conversation with, which is a gap in the agent rather than in the peer.",
if advertised.is_empty() {
"nothing".to_string()
} else {
advertised.join(", ")
},
ours.join(", "),
)))
}
impl Outbound<'_> {
pub async fn send(
&self,
recipient: &str,
document: Value,
trust: ReplyTrust,
) -> Result<Value, AppError> {
Box::pin(self.send_inner(recipient, document, trust)).await
}
async fn send_inner(
&self,
recipient: &str,
document: Value,
trust: ReplyTrust,
) -> Result<Value, AppError> {
let resolved = self.resolver.resolve(recipient).await.map_err(|e| {
AppError::Validation(format!(
"`{recipient}` does not resolve, so there is nothing to send to: {e}"
))
})?;
let doc_value = serde_json::to_value(&resolved.doc)
.map_err(|e| AppError::Internal(format!("serialise the peer's DID document: {e}")))?;
let caps = ServiceCapabilities::from_did_document(&doc_value);
let (protocol, endpoint) = pick_transport(&caps, recipient)?;
let reply = match protocol {
Protocol::Rest => self.send_rest(recipient, &endpoint, &document).await?,
#[cfg(feature = "tsp")]
Protocol::Tsp => self.send_tsp(recipient, document).await?,
#[cfg(not(feature = "tsp"))]
Protocol::Tsp => {
return Err(AppError::Internal(
"TSP was selected in a build without the `tsp` feature".into(),
));
}
#[cfg(feature = "didcomm")]
Protocol::Didcomm => self.send_didcomm(recipient, document).await?,
#[cfg(not(feature = "didcomm"))]
Protocol::Didcomm => {
return Err(AppError::Internal(
"DIDComm was selected in a build without the `didcomm` feature".into(),
));
}
};
verify_reply(self.resolver, &reply, recipient, trust).await?;
Ok(reply)
}
async fn send_rest(
&self,
recipient: &str,
endpoint: &str,
document: &Value,
) -> Result<Value, AppError> {
let url = format!("{}/trust-tasks", endpoint.trim_end_matches('/'));
let response = vta_sdk::http::rest_client()
.post(&url)
.header("content-type", "application/json")
.json(document)
.send()
.await
.map_err(|e| {
bad_gateway_error(format!("`{recipient}` at {url} did not answer: {e}"))
})?;
let body = response.text().await.map_err(|e| {
bad_gateway_error(format!("`{recipient}` sent an unreadable body: {e}"))
})?;
serde_json::from_str(&body).map_err(|e| {
bad_gateway_error(format!(
"`{recipient}` sent a body that is not a Trust-Task document: {e}: {body}"
))
})
}
#[cfg(feature = "tsp")]
async fn send_tsp(&self, recipient: &str, document: Value) -> Result<Value, AppError> {
let tsp = self.tsp.as_ref().ok_or_else(|| {
AppError::Internal(
"TSP was selected but this node has no TSP transport; it should not have been \
offered"
.into(),
)
})?;
let thread = crate::trust_tasks::pending_replies::reply_thread_of(&document)
.ok_or_else(|| {
AppError::Internal(
"an outbound Trust Task with neither `threadId` nor `id` cannot be answered"
.into(),
)
})?
.to_string();
let body = serde_json::to_vec(&document)
.map_err(|e| AppError::Internal(format!("serialise the request: {e}")))?;
let framed = vta_sdk::tsp_binding::wrap_envelope(&body);
let waiting = tsp.replies.register(&thread);
if let Err(e) = tsp.transport.send_to(recipient, &framed).await {
tsp.replies.abandon(&thread);
return Err(bad_gateway_error(format!(
"`{recipient}` could not be reached over TSP: {e}"
)));
}
match tokio::time::timeout(
std::time::Duration::from_secs(TSP_REPLY_TIMEOUT_SECS),
waiting,
)
.await
{
Ok(Ok(reply)) => serde_json::to_value(reply)
.map_err(|e| AppError::Internal(format!("re-serialise the reply: {e}"))),
Ok(Err(_)) => {
tsp.replies.abandon(&thread);
Err(bad_gateway_error(format!(
"the wait for `{recipient}`'s reply was cancelled"
)))
}
Err(_elapsed) => {
tsp.replies.abandon(&thread);
Err(bad_gateway_error(format!(
"`{recipient}` did not answer over TSP within {TSP_REPLY_TIMEOUT_SECS}s"
)))
}
}
}
#[cfg(feature = "didcomm")]
async fn send_didcomm(&self, recipient: &str, document: Value) -> Result<Value, AppError> {
let reply = self
.bridge
.send_and_wait(
recipient,
DIDCOMM_MESSAGE_TYPE,
document,
DIDCOMM_MESSAGE_TYPE,
vta_sdk::protocols::PROBLEM_REPORT_TYPE,
DIDCOMM_REPLY_TIMEOUT_SECS,
)
.await?;
Ok(reply.body)
}
}
async fn verify_reply(
resolver: &affinidi_did_resolver_cache_sdk::DIDCacheClient,
reply: &Value,
recipient: &str,
trust: ReplyTrust,
) -> Result<(), AppError> {
if trust == ReplyTrust::TransportAuthenticated {
return Ok(());
}
let doc_type = reply
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
if doc_type.starts_with("https://trusttasks.org/spec/trust-task-error/") {
return Ok(());
}
let doc: trust_tasks_rs::TrustTask<Value> =
serde_json::from_value(reply.clone()).map_err(|e| {
bad_gateway_error(format!(
"`{recipient}` sent a reply this agent cannot read as a Trust-Task \
document: {e}"
))
})?;
let vm_resolver = vti_common::auth::TrustTaskVmResolver::from_optional(Some(resolver.clone()));
let signer = vti_common::auth::verify_trust_task_proof_with(&doc, &vm_resolver)
.await
.map_err(|e| {
AppError::Forbidden(format!(
"the reply from `{recipient}` is unsigned or its proof does not verify \
({e}), so nothing in it can be believed — an unsigned answer is bytes, not \
evidence"
))
})?;
if signer != recipient {
return Err(AppError::Forbidden(format!(
"the reply claiming to come from `{recipient}` is signed by `{signer}`. The \
proof verifies, which means somebody really signed it — just not the party this \
agent asked"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn caps_from(services: Value) -> ServiceCapabilities {
ServiceCapabilities::from_did_document(&serde_json::json!({ "service": services }))
}
async fn test_resolver() -> affinidi_did_resolver_cache_sdk::DIDCacheClient {
affinidi_did_resolver_cache_sdk::DIDCacheClient::new(
affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder::default().build(),
)
.await
.expect("a resolver for tests")
}
#[tokio::test]
async fn a_refusal_is_read_without_a_proof() {
let reply = serde_json::json!({
"type": "https://trusttasks.org/spec/trust-task-error/0.5",
"payload": { "code": "notAMember", "reason": "no" }
});
verify_reply(
&test_resolver().await,
&reply,
"did:example:host",
ReplyTrust::SignedByRecipient,
)
.await
.expect("a refusal needs no proof");
}
#[tokio::test]
async fn an_unsigned_success_reply_is_refused() {
let reply = serde_json::json!({
"id": "urn:uuid:00000000-0000-4000-8000-000000000001",
"type": "https://trusttasks.org/spec/rooms/epoch/chain/0.1#response",
"issuer": "did:example:host",
"recipient": "did:example:agent",
"issuedAt": "2026-01-01T00:00:00Z",
"payload": { "links": [] }
});
let err = verify_reply(
&test_resolver().await,
&reply,
"did:example:host",
ReplyTrust::SignedByRecipient,
)
.await
.expect_err("an unsigned success reply must not be believed");
let msg = err.to_string();
assert!(
msg.contains("bytes, not") || msg.contains("unsigned"),
"the refusal must say why an unsigned answer is worthless: {msg}"
);
}
#[test]
fn a_peer_serving_rest_is_reachable() {
let caps = caps_from(serde_json::json!([{
"id": "#rest", "type": "VTARest", "serviceEndpoint": "https://host.example"
}]));
let (protocol, endpoint) = pick_transport(&caps, "did:example:peer").expect("reachable");
assert_eq!(protocol, Protocol::Rest);
assert_eq!(endpoint, "https://host.example");
}
#[test]
fn a_didcomm_only_peer_is_reachable() {
let caps = caps_from(serde_json::json!([{
"id": "#didcomm",
"type": "DIDCommMessaging",
"serviceEndpoint": [{ "uri": "did:example:mediator", "accept": ["didcomm/v2"] }]
}]));
let (protocol, _) = pick_transport(&caps, "did:example:peer").expect("reachable");
assert_eq!(protocol, Protocol::Didcomm);
}
#[test]
fn didcomm_is_preferred_over_rest_when_a_peer_offers_both() {
let caps = caps_from(serde_json::json!([
{ "id": "#rest", "type": "VTARest", "serviceEndpoint": "https://host.example" },
{ "id": "#didcomm", "type": "DIDCommMessaging",
"serviceEndpoint": [{ "uri": "did:example:mediator", "accept": ["didcomm/v2"] }] }
]));
let (protocol, _) = pick_transport(&caps, "did:example:peer").expect("reachable");
assert_eq!(
protocol,
Protocol::Didcomm,
"REST was chosen while the peer also advertised DIDComm"
);
}
#[test]
fn preference_comes_from_preference_order_not_from_this_list() {
let rank = |p: Protocol| Protocol::PREFERENCE_ORDER.iter().position(|q| *q == p);
assert!(rank(Protocol::Didcomm) < rank(Protocol::Rest));
assert!(
OUTBOUND_SUPPORTED.contains(&Protocol::Didcomm)
&& OUTBOUND_SUPPORTED.contains(&Protocol::Rest)
);
}
fn tsp_only_peer() -> ServiceCapabilities {
caps_from(serde_json::json!([{
"id": "#tsp", "type": "TSPTransport", "serviceEndpoint": "did:example:mediator"
}]))
}
#[cfg(not(feature = "tsp"))]
#[test]
fn a_tsp_only_peer_is_refused_naming_both_sides() {
let caps = tsp_only_peer();
let msg = pick_transport(&caps, "did:example:peer")
.expect_err("no common transport")
.to_string();
assert!(
msg.contains("tsp"),
"must name what the peer advertises: {msg}"
);
assert!(
msg.contains("didcomm") && msg.contains("rest"),
"must name everything this agent can do, not just one: {msg}"
);
assert!(
msg.contains("gap in the agent"),
"must say whose limitation it is: {msg}"
);
}
#[cfg(feature = "tsp")]
#[test]
fn a_tsp_only_peer_is_reached_over_tsp_when_this_build_can_initiate_it() {
let (protocol, endpoint) = pick_transport(&tsp_only_peer(), "did:example:peer")
.expect("TSP is in the intersection when this build can initiate it");
assert_eq!(
protocol,
Protocol::Tsp,
"a TSP-only peer must be reached over TSP, not refused"
);
assert_eq!(
endpoint, "did:example:mediator",
"and over the mediator the peer's `#tsp` service names"
);
}
#[test]
fn a_peer_advertising_nothing_says_so() {
let err = pick_transport(&caps_from(serde_json::json!([])), "did:example:peer")
.expect_err("nothing advertised");
assert!(err.to_string().contains("nothing"));
}
#[tokio::test]
async fn transport_authenticated_believes_an_unsigned_reply() {
let reply = serde_json::json!({
"id": "urn:uuid:00000000-0000-4000-8000-000000000001",
"type": "https://trusttasks.org/spec/did-management/did/check-name/0.1#response",
"issuer": "did:example:peer",
"recipient": "did:example:agent",
"issuedAt": "2026-01-01T00:00:00Z",
"payload": { "available": true }
});
verify_reply(
&test_resolver().await,
&reply,
"did:example:peer",
ReplyTrust::TransportAuthenticated,
)
.await
.expect("this level asks nothing of the document");
}
#[test]
fn the_didcomm_message_carries_the_binding_envelope_type() {
assert_eq!(DIDCOMM_MESSAGE_TYPE, trust_tasks_didcomm::ENVELOPE_TYPE);
assert!(
!DIDCOMM_MESSAGE_TYPE.starts_with("https://trusttasks.org/spec/"),
"a `spec/` URI here is a task type on the wire: {DIDCOMM_MESSAGE_TYPE}"
);
}
}