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")]
pub(crate) 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")]
const TSP_IDLE_REESTABLISH_MS: u64 = 60 * 60 * 1000;
#[cfg(feature = "tsp")]
#[derive(Clone, Default)]
pub struct IdleReinvites(std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>);
#[cfg(feature = "tsp")]
impl IdleReinvites {
fn claim(&self, peer: &str) -> Option<IdleReinviteClaim> {
let mut peers = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
peers.insert(peer.to_string()).then(|| IdleReinviteClaim {
peers: self.clone(),
peer: peer.to_string(),
})
}
}
#[cfg(feature = "tsp")]
struct IdleReinviteClaim {
peers: IdleReinvites,
peer: String,
}
#[cfg(feature = "tsp")]
impl Drop for IdleReinviteClaim {
fn drop(&mut self) {
self.peers
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&self.peer);
}
}
#[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>,
relationships: std::sync::Arc<crate::messaging::tsp_relationship_store::VtaRelationshipStore>,
idle_reinvites: IdleReinvites,
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(),
relationships: state.tsp_relationships.clone(),
idle_reinvites: state.tsp_idle_reinvites.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, recipient);
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) => {
self.mark_active(recipient).await;
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 mark_active(&self, recipient: &str) {
let Some(our) = self.transport.our_vid() else {
return;
};
if let Err(e) = self.relationships.touch(&our, recipient, now_ms()).await {
tracing::debug!(peer = recipient, error = %e, "could not stamp TSP relationship activity");
}
}
pub(crate) async fn idle_reestablish_due(&self, recipient: &str) -> bool {
use affinidi_messaging_sdk::protocols::tsp::RelationshipStore as _;
let Some(our) = self.transport.our_vid() else {
return false;
};
match self.relationships.get(&our, recipient).await {
Ok(affinidi_messaging_sdk::protocols::tsp::RelationshipState::Bidirectional) => {}
_ => return false,
}
match self.relationships.last_active(&our, recipient).await {
Ok(Some(at_ms)) => now_ms().saturating_sub(at_ms) > TSP_IDLE_REESTABLISH_MS,
Ok(None) => true,
Err(_) => false,
}
}
pub(crate) async fn first_attempt(
&self,
recipient: &str,
peer_mediator: Option<&str>,
thread: &str,
framed: &[u8],
) -> TspAttempt {
if self.idle_reestablish_due(recipient).await {
self.send_after_idle_reestablish(recipient, peer_mediator, thread, framed)
.await
} else {
self.send_and_await(recipient, peer_mediator, thread, framed, false)
.await
}
}
async fn send_after_idle_reestablish(
&self,
recipient: &str,
peer_mediator: Option<&str>,
thread: &str,
framed: &[u8],
) -> TspAttempt {
let Some(_claim) = self.idle_reinvites.claim(recipient) else {
return self
.send_and_await(recipient, peer_mediator, thread, framed, false)
.await;
};
if let Err(e) = self.transport.reset_relationship(recipient).await {
tracing::debug!(peer = recipient, error = %e, "could not reset an idle TSP relationship");
return self
.send_and_await(recipient, peer_mediator, thread, framed, false)
.await;
}
tracing::info!(
peer = recipient,
"re-inviting an idle TSP relationship alongside the request"
);
self.send_and_await(recipient, None, thread, framed, true)
.await
}
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,
https_base: Option<String>,
}
impl<'a> Outbound<'a> {
#[must_use]
pub fn with_https_base(mut self, base: Option<String>) -> Self {
self.https_base = base;
self
}
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,
https_base: None,
}
}
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(),
https_base: None,
}
}
}
pub fn pick_transport(
caps: &ServiceCapabilities,
initiable: &[Protocol],
peer: &str,
) -> Result<(Protocol, String), AppError> {
let mut rest_refused_plaintext = false;
for protocol in Protocol::PREFERENCE_ORDER {
if !initiable.contains(&protocol) {
continue;
}
let Some(endpoint) = caps.endpoint(protocol) else {
continue;
};
if protocol == Protocol::Rest
&& !vta_sdk::protocol::matching::is_https_or_loopback(endpoint)
{
tracing::warn!(
peer,
endpoint,
"ignoring a peer's plaintext http:// REST endpoint; only a loopback host may use \
http://"
);
rest_refused_plaintext = true;
continue;
}
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(", "),
if rest_refused_plaintext {
" Its REST endpoint is plaintext http:// to a non-loopback host, so it was not \
usable."
} else {
""
},
)))
}
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 mut caps = ServiceCapabilities::from_did_document(&doc_value);
if caps.rest.is_none() {
caps.rest.clone_from(&self.https_base);
}
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
.first_attempt(recipient, Some(peer_mediator), &thread, &framed)
.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| match e {
vti_common::auth::DiProofError::ResolverFailed(_) => AppError::Forbidden(format!(
"could not retrieve `{recipient}`'s verification key, so its reply was not \
checked and is not believed ({e}). This is a key-retrieval failure, not a bad \
proof: the request may have taken effect at `{recipient}`, so check its state \
before sending it again"
)),
other => AppError::Forbidden(format!(
"the reply from `{recipient}` is unsigned or its proof does not verify \
({other}), 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 a_resolver_failure_reports_retrieval_not_invalid_proof() {
let reply = serde_json::json!({
"id": "urn:uuid:00000000-0000-4000-8000-000000000002",
"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": [] },
"proof": {
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"proofPurpose": "assertionMethod",
"verificationMethod": "did:example:host#key-0",
"created": "2026-01-01T00:00:00Z",
"proofValue": "z2aBcD"
}
});
let err = verify_reply(
&test_resolver().await,
&reply,
"did:example:host",
ReplyTrust::SignedByRecipient,
)
.await
.expect_err("did:example is not a method this resolver can resolve");
let msg = err.to_string();
assert!(
msg.contains("could not retrieve"),
"expected a retrieval-failure message, got: {msg}"
);
assert!(msg.contains("before sending it again"), "got: {msg}");
assert!(
matches!(err, AppError::Forbidden(_)),
"a retrieval failure must stay a refusal the SDK does not retry: {err:?}"
);
assert!(
!msg.contains("does not verify"),
"a retrieval failure must not read as an invalid proof: {msg}"
);
}
#[tokio::test]
async fn an_actual_bad_signature_still_says_does_not_verify() {
let (recipient, _vm) = crate::test_support::did_for_seed(13);
let mut doc: trust_tasks_rs::TrustTask<Value> = serde_json::from_value(serde_json::json!({
"id": "urn:uuid:00000000-0000-4000-8000-000000000003",
"type": "https://trusttasks.org/spec/rooms/epoch/chain/0.1#response",
"issuer": recipient,
"recipient": "did:key:zAgent",
"issuedAt": "2026-01-01T00:00:00Z",
"payload": { "links": [] }
}))
.expect("well-formed reply");
crate::test_support::sign_as(13, &mut doc);
let proof = doc.proof.as_mut().expect("document is signed");
let last = proof.proof_value.pop().expect("non-empty proofValue");
proof.proof_value.push(if last == '1' { '2' } else { '1' });
let reply = serde_json::to_value(&doc).expect("doc serialises");
let err = verify_reply(
&test_resolver().await,
&reply,
&recipient,
ReplyTrust::SignedByRecipient,
)
.await
.expect_err("a corrupted signature must not verify");
let msg = err.to_string();
assert!(msg.contains("does not verify"), "got: {msg}");
assert!(
!msg.contains("could not retrieve"),
"a bad signature must not read as a retrieval failure: {msg}"
);
}
#[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_peer_serving_plaintext_http_rest_is_refused() {
let caps = caps_from(serde_json::json!([{
"id": "#rest", "type": "TrustTaskHTTPS", "serviceEndpoint": "http://host.example"
}]));
let msg = pick_transport(&caps, OUTBOUND_SUPPORTED, "did:example:peer")
.expect_err("plaintext REST must not be selected")
.to_string();
assert!(
msg.contains("plaintext http://"),
"the refusal must say why: {msg}"
);
}
#[test]
fn a_peer_serving_a_lookalike_loopback_host_over_http_is_refused() {
for endpoint in ["http://127.0.0.1.evil.com", "http://localhost.evil"] {
let caps = caps_from(serde_json::json!([{
"id": "#rest", "type": "TrustTaskHTTPS", "serviceEndpoint": endpoint
}]));
assert!(
pick_transport(&caps, OUTBOUND_SUPPORTED, "did:example:peer").is_err(),
"{endpoint} must be refused"
);
}
}
#[test]
fn a_peer_serving_http_to_loopback_is_still_reachable() {
let caps = caps_from(serde_json::json!([{
"id": "#rest", "type": "TrustTaskHTTPS", "serviceEndpoint": "http://127.0.0.1:8100"
}]));
let (protocol, endpoint) =
pick_transport(&caps, OUTBOUND_SUPPORTED, "did:example:peer").expect("reachable");
assert_eq!(protocol, Protocol::Rest);
assert_eq!(endpoint, "http://127.0.0.1:8100");
}
#[test]
fn a_plaintext_rest_endpoint_does_not_block_a_shared_didcomm_transport() {
let caps = caps_from(serde_json::json!([
{ "id": "#rest", "type": "TrustTaskHTTPS", "serviceEndpoint": "http://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);
}
#[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}"
);
}
}
#[cfg(all(test, feature = "tsp"))]
mod idle_reinvite_tests {
use super::IdleReinvites;
#[test]
fn one_idle_reinvite_per_peer_at_a_time_and_released_on_drop() {
let reinvites = IdleReinvites::default();
let first = reinvites.claim("did:example:hosting").expect("free peer");
assert!(
reinvites.claim("did:example:hosting").is_none(),
"a second concurrent send to the same peer goes out the ordinary way"
);
assert!(
reinvites.claim("did:example:other").is_some(),
"other peers are independent"
);
drop(first);
assert!(
reinvites.claim("did:example:hosting").is_some(),
"the claim is released when the send finishes, whichever way"
);
}
}