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")]
use vta_sdk::budget::TSP_REPLY_TIMEOUT_SECS;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReplyTrust {
SignedByRecipient,
TransportAuthenticated,
}
#[cfg(feature = "tsp")]
use affinidi_messaging_sdk::RecoveryAction;
#[cfg(feature = "tsp")]
enum TspAttempt {
Reply(Value),
Timeout,
Cancelled,
SendFailed(String),
}
#[cfg(feature = "tsp")]
fn resend_after_reform(type_uri: &str) -> bool {
vta_sdk::retry_safety::retry_safety(type_uri).is_some_and(|c| c.is_blind_retry_safe())
}
#[cfg(feature = "tsp")]
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(feature = "tsp")]
#[derive(Clone)]
pub struct TspSender {
transport: crate::messaging::tsp_transport::TspTransport,
replies: crate::trust_tasks::pending_replies::PendingReplies,
recovery: std::sync::Arc<affinidi_messaging_sdk::RecoveryCoordinator>,
reply_timeout: std::time::Duration,
}
#[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(),
recovery: state.tsp_recovery.clone(),
reply_timeout: std::time::Duration::from_secs(TSP_REPLY_TIMEOUT_SECS),
})
}
#[cfg(all(test, feature = "transport-harness"))]
pub(crate) fn with_reply_timeout(mut self, timeout: std::time::Duration) -> Self {
self.reply_timeout = timeout;
self
}
#[cfg(all(test, feature = "transport-harness"))]
pub(crate) fn recovery(&self) -> &affinidi_messaging_sdk::RecoveryCoordinator {
&self.recovery
}
#[cfg(all(test, feature = "transport-harness"))]
pub(crate) async fn recover_for_test(
&self,
recipient: &str,
thread: &str,
framed: &[u8],
type_uri: &str,
) -> Result<Value, AppError> {
self.recover_send_tsp(recipient, thread, framed, type_uri)
.await
}
async fn send_and_await(
&self,
recipient: &str,
peer_mediator: Option<&str>,
thread: &str,
framed: &[u8],
reestablish: bool,
) -> TspAttempt {
let waiting = self.replies.register(thread);
let sent = if reestablish {
self.transport.send_reestablishing(recipient, framed).await
} else {
self.transport
.send_metadata_private(recipient, peer_mediator, framed)
.await
};
if let Err(e) = sent {
self.replies.abandon(thread);
return TspAttempt::SendFailed(e.to_string());
}
match tokio::time::timeout(self.reply_timeout, waiting).await {
Ok(Ok(reply)) => match serde_json::to_value(reply) {
Ok(v) => TspAttempt::Reply(v),
Err(e) => TspAttempt::SendFailed(format!("re-serialise the reply: {e}")),
},
Ok(Err(_)) => {
self.replies.abandon(thread);
TspAttempt::Cancelled
}
Err(_elapsed) => {
self.replies.abandon(thread);
TspAttempt::Timeout
}
}
}
async fn recover_send_tsp(
&self,
recipient: &str,
thread: &str,
framed: &[u8],
type_uri: &str,
) -> Result<Value, AppError> {
let timed_out = || {
bad_gateway_error(format!(
"`{recipient}` did not answer over TSP within {TSP_REPLY_TIMEOUT_SECS}s"
))
};
let Some(our) = self.transport.our_vid() else {
return Err(timed_out());
};
let now = now_ms();
let backoff = || {
self.recovery
.retry_delay(0, 1.0)
.unwrap_or(std::time::Duration::from_secs(1))
};
match self.recovery.begin(&our, recipient, now).await {
RecoveryAction::Start => {
if let Err(e) = self.transport.reset_relationship(recipient).await {
self.recovery
.settle_failure(&our, recipient, now, backoff())
.await;
return Err(bad_gateway_error(format!(
"could not re-establish the TSP relationship with `{recipient}`: {e}"
)));
}
if resend_after_reform(type_uri) {
match self
.send_and_await(recipient, None, thread, framed, true)
.await
{
TspAttempt::Reply(v) => {
self.recovery.settle_success(&our, recipient).await;
Ok(v)
}
other => {
self.recovery
.settle_failure(&our, recipient, now, backoff())
.await;
Err(bad_gateway_error(match other {
TspAttempt::SendFailed(reason) => format!(
"could not resend to `{recipient}` over TSP after \
re-establishing the relationship: {reason}"
),
TspAttempt::Cancelled => format!(
"the wait for `{recipient}`'s reply was cancelled after \
re-establishing the relationship"
),
_ => format!(
"`{recipient}` did not answer over TSP after re-establishing \
the relationship"
),
}))
}
}
} else {
if let Err(e) = self.transport.relate(recipient).await {
self.recovery
.settle_failure(&our, recipient, now, backoff())
.await;
return Err(bad_gateway_error(format!(
"could not re-establish the TSP relationship with `{recipient}`: {e}"
)));
}
self.recovery.settle_success(&our, recipient).await;
Err(bad_gateway_error(format!(
"`{recipient}` did not answer over TSP; the relationship was re-established \
— retry the operation"
)))
}
}
RecoveryAction::InFlight => Err(bad_gateway_error(format!(
"re-establishing the TSP relationship with `{recipient}` is already in flight — retry"
))),
RecoveryAction::Backoff(_) => Err(bad_gateway_error(format!(
"backing off before re-establishing the TSP relationship with `{recipient}` — retry \
later"
))),
RecoveryAction::GiveUp => Err(bad_gateway_error(format!(
"gave up re-establishing the TSP relationship with `{recipient}` after repeated \
failures"
))),
}
}
}
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,
initiable: &[Protocol],
peer: &str,
) -> Result<(Protocol, String), AppError> {
for protocol in Protocol::PREFERENCE_ORDER {
if !initiable.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> = initiable.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(", "),
)))
}
fn initiable_from(has_tsp: bool) -> Vec<Protocol> {
OUTBOUND_SUPPORTED
.iter()
.copied()
.filter(|p| *p != Protocol::Tsp || has_tsp)
.collect()
}
impl Outbound<'_> {
fn initiable_protocols(&self) -> Vec<Protocol> {
#[cfg(feature = "tsp")]
let has_tsp = self.tsp.is_some();
#[cfg(not(feature = "tsp"))]
let has_tsp = false;
initiable_from(has_tsp)
}
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, &self.initiable_protocols(), recipient)?;
let reply = match protocol {
Protocol::Rest => self.send_rest(recipient, &endpoint, &document).await?,
#[cfg(feature = "tsp")]
Protocol::Tsp => self.send_tsp(recipient, &endpoint, 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,
peer_mediator: &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 type_uri = document
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.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);
match tsp
.send_and_await(recipient, Some(peer_mediator), &thread, &framed, false)
.await
{
TspAttempt::Reply(v) => Ok(v),
TspAttempt::SendFailed(e) => Err(bad_gateway_error(format!(
"`{recipient}` could not be reached over TSP: {e}"
))),
TspAttempt::Cancelled => Err(bad_gateway_error(format!(
"the wait for `{recipient}`'s reply was cancelled"
))),
TspAttempt::Timeout => {
tsp.recover_send_tsp(recipient, &thread, &framed, &type_uri)
.await
}
}
}
#[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, OUTBOUND_SUPPORTED, "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, OUTBOUND_SUPPORTED, "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, OUTBOUND_SUPPORTED, "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, OUTBOUND_SUPPORTED, "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(), OUTBOUND_SUPPORTED, "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"
);
}
#[cfg(feature = "tsp")]
#[test]
fn initiable_drops_tsp_without_a_live_sender_and_keeps_it_with_one() {
assert!(
!initiable_from(false).contains(&Protocol::Tsp),
"TSP must not be initiable without a live sender"
);
assert!(
initiable_from(false).contains(&Protocol::Didcomm)
&& initiable_from(false).contains(&Protocol::Rest),
"dropping TSP must not disturb the transports that remain"
);
assert!(
initiable_from(true).contains(&Protocol::Tsp),
"TSP is initiable once a sender is wired"
);
}
#[cfg(feature = "tsp")]
#[test]
fn a_tsp_and_didcomm_peer_is_reached_over_didcomm_when_this_sender_has_no_tsp() {
let caps = caps_from(serde_json::json!([
{ "id": "#tsp", "type": "TSPTransport", "serviceEndpoint": "did:example:mediator" },
{ "id": "#didcomm", "type": "DIDCommMessaging",
"serviceEndpoint": [{ "uri": "did:example:mediator", "accept": ["didcomm/v2"] }] }
]));
let (protocol, _) = pick_transport(&caps, &initiable_from(false), "did:example:peer")
.expect("DIDComm is shared, so the peer is reachable");
assert_eq!(
protocol,
Protocol::Didcomm,
"a sender with no TSP must fall to the shared DIDComm, not select TSP"
);
}
#[cfg(feature = "tsp")]
#[test]
fn a_tsp_only_peer_is_refused_not_errored_when_this_sender_has_no_tsp() {
let err = pick_transport(&tsp_only_peer(), &initiable_from(false), "did:example:peer")
.expect_err("TSP is the only shared transport and this sender cannot start it");
let msg = err.to_string();
assert!(
matches!(err, AppError::Validation(_)),
"must be a caller-facing validation refusal, not an internal error: {msg}"
);
assert!(
msg.contains("tsp") && msg.contains("didcomm") && msg.contains("rest"),
"must name what the peer offers and what this sender can start: {msg}"
);
}
#[test]
fn a_peer_advertising_nothing_says_so() {
let err = pick_transport(
&caps_from(serde_json::json!([])),
OUTBOUND_SUPPORTED,
"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}"
);
}
}