use std::sync::Arc;
use std::time::Duration;
use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use affinidi_messaging_core::{Inbound, InboundKind, MessageTransport, Protocol};
use affinidi_messaging_delivery::{Delivery, MessagingService, OutboxStore};
use affinidi_messaging_didcomm::Message;
use affinidi_tdk::common::TDKSharedState;
use affinidi_tdk::common::config::TDKConfig;
use affinidi_tdk::messaging::config::ATMConfig;
use affinidi_tdk::messaging::profiles::ATMProfile;
use affinidi_tdk::messaging::{ATM, DidCommTransport};
use affinidi_tdk::secrets_resolver::secrets::Secret;
use affinidi_tdk::secrets_resolver::{SecretsResolver, ThreadedSecretsResolver};
use futures_util::StreamExt;
use tokio::sync::watch;
use tracing::{debug, error, info, warn};
use serde_json::json;
use vti_common::outbox_store::VtiOutboxStore;
use vti_common::sender_order::{FrameOrder, SenderOrder};
use vta_sdk::protocols::{PROBLEM_REPORT_TYPE, problem_report_codes as codes};
use crate::config::AppConfig;
use crate::join::JoinTransport;
use crate::members::Disposition;
use crate::server::AppState;
use crate::store::KeyspaceHandle;
use crate::trust_tasks::{JoinAuthCtx, TrustTaskOutcome, dispatch_trust_task_core};
const TRUST_PING_TYPE: &str = "https://didcomm.org/trust-ping/2.0/ping";
const TRUST_PONG_TYPE: &str = "https://didcomm.org/trust-ping/2.0/ping-response";
const MESSAGE_PICKUP_STATUS_TYPE: &str = "https://didcomm.org/messagepickup/3.0/status";
const UNPROCESSABLE_CHANNEL_CAPACITY: usize = 64;
pub struct VtcMessaging {
pub service: Arc<MessagingService>,
pub atm: Arc<ATM>,
pub vtc_did: String,
pub profile: Arc<ATMProfile>,
pub mediator_did: String,
pub inbox: vti_common::inbox::InboxWatch,
pub uncollected: Arc<vti_common::inbox::UncollectedPeers>,
}
async fn vtc_key_ids(
did_resolver: Option<&DIDCacheClient>,
vtc_did: &str,
) -> (String, Option<String>) {
let conventional = || (format!("{vtc_did}#key-0"), Some(format!("{vtc_did}#key-1")));
let Some(resolver) = did_resolver else {
return conventional();
};
let resolved = match resolver.resolve(vtc_did).await {
Ok(r) => r,
Err(e) => {
warn!(
%vtc_did,
error = %e,
"could not resolve the VTC DID for its key ids — falling back to #key-0/#key-1",
);
return conventional();
}
};
let absolutize = |id: &str| -> String {
match id.strip_prefix('#') {
Some(fragment) => format!("{vtc_did}#{fragment}"),
None => id.to_string(),
}
};
let doc = &resolved.doc;
let signing = doc
.authentication
.first()
.map(|vr| vr.get_id())
.or_else(|| doc.verification_method.first().map(|vm| vm.id.as_str()))
.map(absolutize);
let ka = doc.key_agreement.first().map(|vr| absolutize(vr.get_id()));
match signing {
Some(signing) => (signing, ka),
None => conventional(),
}
}
async fn build_messaging(
secrets: Vec<Secret>,
vtc_did: &str,
mediator_did: &str,
outbox_ks: KeyspaceHandle,
tsp_relationships_ks: KeyspaceHandle,
did_resolver: Option<DIDCacheClient>,
did_cache: &vti_common::config::DidCacheConfig,
) -> Result<(Arc<MessagingService>, Arc<ATM>, Arc<ATMProfile>), String> {
let tdk_config = match did_resolver {
Some(resolver) => TDKConfig::builder().with_did_resolver(resolver),
None => TDKConfig::builder().with_did_resolver_config(
vta_sdk::resolver::build_verifier_did_cache_config(
None,
did_cache.ttl_secs,
did_cache.capacity,
),
),
}
.build()
.map_err(|e| format!("build TDK config: {e}"))?;
let tdk = TDKSharedState::new(tdk_config)
.await
.map_err(|e| format!("create TDK shared state: {e}"))?;
for secret in secrets {
tdk.secrets_resolver().insert(secret).await;
}
let atm_config_builder = ATMConfig::builder()
.with_delete_unprocessable(true)
.with_unprocessable_message_channel(UNPROCESSABLE_CHANNEL_CAPACITY);
#[cfg(feature = "tsp")]
let atm_config_builder = atm_config_builder.with_relationship_store(
vti_common::relationship_store::build_relationship_store(tsp_relationships_ks.clone()),
);
#[cfg(not(feature = "tsp"))]
let _ = &tsp_relationships_ks;
let atm = ATM::new(
atm_config_builder
.build()
.map_err(|e| format!("build ATM config: {e}"))?,
Arc::new(tdk),
)
.await
.map_err(|e| format!("create ATM: {e}"))?;
let profile = ATMProfile::new(
&atm,
None,
vtc_did.to_string(),
Some(mediator_did.to_string()),
)
.await
.map_err(|e| format!("create ATM profile: {e}"))?;
let profile = atm
.profile_add(&profile, false)
.await
.map_err(|e| format!("register ATM profile: {e}"))?;
let atm = Arc::new(atm);
let transport = match connect_transport(&atm, &profile).await {
Ok(transport) => transport,
Err(e) => {
atm.graceful_shutdown().await;
return Err(e);
}
};
let outbox: Arc<dyn OutboxStore> = Arc::new(VtiOutboxStore::new(outbox_ks));
let service = Arc::new(MessagingService::new(transport.clone(), outbox.clone()));
tokio::spawn(affinidi_messaging_delivery::drain_loop(
outbox.clone(),
transport.clone(),
std::time::Duration::from_secs(2),
));
tokio::spawn(affinidi_messaging_delivery::outbox_drain_loop(
transport.clone(),
outbox.clone(),
std::time::Duration::from_secs(10),
));
tokio::spawn(affinidi_messaging_delivery::confirmation_loop(
outbox.clone(),
std::time::Duration::from_secs(30),
));
Ok((service, atm, profile))
}
async fn connect_transport(
atm: &Arc<ATM>,
profile: &Arc<ATMProfile>,
) -> Result<Arc<dyn MessageTransport>, String> {
match tokio::time::timeout(
Duration::from_secs(30),
atm.profile_enable_websocket(profile),
)
.await
{
Ok(res) => res.map_err(|e| format!("enable websocket: {e}"))?,
Err(_) => {
return Err(
"timeout enabling websocket to mediator after 30s — mediator may be unreachable"
.to_string(),
);
}
}
Ok(Arc::new(
DidCommTransport::new((**atm).clone(), profile.clone())
.await
.map_err(|e| format!("bind DidComm transport: {e}"))?,
))
}
const CONNECT_RETRY_BASE: Duration = Duration::from_secs(2);
const CONNECT_RETRY_CAP: Duration = Duration::from_secs(60);
fn connect_backoff_ceiling(attempt: u32) -> Duration {
let factor = 1u32.checked_shl(attempt).unwrap_or(u32::MAX);
CONNECT_RETRY_BASE
.saturating_mul(factor)
.min(CONNECT_RETRY_CAP)
}
fn connect_backoff(attempt: u32) -> Duration {
use rand::RngExt as _;
let max_ms = connect_backoff_ceiling(attempt).as_millis() as u64;
Duration::from_millis(rand::rng().random_range(0..=max_ms))
}
pub async fn run_didcomm_service(
config: &AppConfig,
secrets_resolver: &Arc<ThreadedSecretsResolver>,
vtc_did: &str,
state: AppState,
shutdown_rx: &mut watch::Receiver<bool>,
) {
let mediator_did = match &config.messaging {
Some(m) => m.mediator_did.clone(),
None => {
warn!("messaging not configured — inbound message handling disabled");
let _ = shutdown_rx.changed().await;
return;
}
};
match crate::transport_capability::resolved_capabilities(state.did_resolver.as_ref(), vtc_did)
.await
{
Some(caps) => {
use crate::transport_capability::{MessagingVerdict, Severity};
for finding in crate::transport_capability::findings_for_build(&caps) {
let code = format!("{:?}", finding.code);
match finding.severity {
Severity::Error => error!(finding = %code, "{}", finding.message),
Severity::Warn => warn!(finding = %code, "{}", finding.message),
Severity::Info => info!(finding = %code, "{}", finding.message),
}
}
match crate::transport_capability::classify_for_messaging(&caps) {
MessagingVerdict::Ok => {}
MessagingVerdict::Degraded(_) => {
warn!(
"starting messaging anyway — at least one advertised transport is \
servable, but clients preferring the transports above will be silently \
dropped"
);
}
MessagingVerdict::Unreachable(_) => {
error!(
"no advertised messaging transport is servable by this build — messaging \
disabled. The VTC will keep serving REST; fix the build or the DID \
document above to restore messaging."
);
let _ = shutdown_rx.changed().await;
return;
}
}
}
None => {
debug!(
%vtc_did,
"could not resolve the VTC DID to check advertised transports against this \
build — continuing"
);
}
}
let (signing_id, ka_id) = vtc_key_ids(state.did_resolver.as_ref(), vtc_did).await;
let mut secrets = Vec::new();
if let Some(s) = secrets_resolver.get_secret(&signing_id).await {
secrets.push(s);
} else {
warn!(%signing_id, "VTC signing secret not found — messaging disabled");
let _ = shutdown_rx.changed().await;
return;
}
if let Some(ka_id) = ka_id.as_deref()
&& let Some(s) = secrets_resolver.get_secret(ka_id).await
{
secrets.push(s);
}
info!(
vtc_did = %vtc_did,
mediator = %mediator_did,
"starting VTC messaging listener"
);
let mut attempt: u32 = 0;
let (service, atm, profile) = loop {
match build_messaging(
secrets.clone(),
vtc_did,
&mediator_did,
state.outbox_ks.clone(),
state.tsp_relationships_ks.clone(),
state.did_resolver.clone(),
&config.did_cache,
)
.await
{
Ok(v) => break v,
Err(e) => {
let wait = connect_backoff(attempt);
warn!(
attempt = attempt + 1,
retry_in_secs = wait.as_secs_f64(),
"failed to start VTC messaging: {e}; retrying"
);
tokio::select! {
_ = shutdown_rx.changed() => {
info!("VTC messaging stopping before it connected (shutdown signalled)");
return;
}
_ = tokio::time::sleep(wait) => {}
}
attempt = attempt.saturating_add(1);
}
}
};
#[cfg(feature = "tsp")]
tokio::spawn(vti_common::relationship_store::maintenance_loop(
vti_common::relationship_store::build_relationship_store(
state.tsp_relationships_ks.clone(),
),
));
let inbox = vti_common::inbox::InboxWatch::new();
let uncollected = Arc::new(vti_common::inbox::UncollectedPeers::new());
let messaging = Arc::new(VtcMessaging {
service: service.clone(),
atm: atm.clone(),
vtc_did: vtc_did.to_string(),
profile: profile.clone(),
mediator_did: mediator_did.clone(),
inbox: inbox.clone(),
uncollected: uncollected.clone(),
});
#[cfg(feature = "tsp")]
let tsp_messaging = messaging.clone();
if state.didcomm.set(messaging).is_err() {
warn!("VTC messaging handle was already published — outbound sends use the existing one");
}
{
use affinidi_messaging_delivery::OutboxStore;
let outbox: Arc<dyn OutboxStore> = Arc::new(vti_common::outbox_store::VtiOutboxStore::new(
state.outbox_ks.clone(),
));
#[cfg(feature = "tsp")]
if let Some(primary) = service.primary_transport() {
let tsp: Arc<dyn MessageTransport> = Arc::new(crate::member_push::TspPushTransport {
atm: atm.clone(),
profile: profile.clone(),
mediator_did: mediator_did.clone(),
pushes: state.member_pushes_ks.clone(),
conn: primary.connection_state(),
});
service.add_transport(crate::member_push::TSP_TRANSPORT_ID.into(), tsp.clone());
tokio::spawn(affinidi_messaging_delivery::drain_loop_via(
outbox.clone(),
crate::member_push::TSP_TRANSPORT_ID.into(),
tsp,
Duration::from_secs(2),
));
}
let rest: Arc<dyn MessageTransport> = Arc::new(crate::member_push::RestPushTransport::new(
state.member_pushes_ks.clone(),
crate::recognition::verify::foreign_fetch_client(),
));
service.add_transport(crate::member_push::REST_TRANSPORT_ID.into(), rest.clone());
tokio::spawn(affinidi_messaging_delivery::drain_loop_via(
outbox,
crate::member_push::REST_TRANSPORT_ID.into(),
rest,
Duration::from_secs(2),
));
let sweep_state = state.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(30));
loop {
tick.tick().await;
if let Err(e) = crate::member_push::sweep(&sweep_state).await {
warn!(error = %e, "member-push sweep failed; retrying next tick");
}
}
});
}
let catch_up = tokio::spawn(vti_common::inbox::run_inbox_watch(
atm.clone(),
profile.clone(),
service.primary_transport().map(|t| t.connection_state()),
inbox.clone(),
Arc::new(vti_common::inbox::ReconnectGovernor::new(
vti_common::inbox::Escalation::Never,
)),
));
let unprocessable = atm.get_unprocessable_message_channel().map(|rx| {
tokio::spawn(vti_common::inbox::run_unprocessable_report(
rx,
inbox.clone(),
))
});
info!("VTC messaging connected to mediator — inbound messages will be processed");
let vtc_did_owned = vtc_did.to_string();
let stream = service.subscribe();
run_inbound_loop(stream, shutdown_rx, move |inbound| {
let state = state.clone();
let atm = atm.clone();
let service = service.clone();
let vtc_did = vtc_did_owned.clone();
#[cfg(feature = "tsp")]
let tsp_messaging = tsp_messaging.clone();
#[cfg(feature = "tsp")]
let mediator_did = mediator_did.clone();
async move {
#[cfg(feature = "tsp")]
if inbound.message.protocol == Protocol::TSP {
handle_tsp(inbound, &tsp_messaging, &state, &mediator_did).await;
return;
}
handle_inbound_frame(inbound, &state, &atm, &service, &vtc_did).await;
}
})
.await;
catch_up.abort();
if let Some(unprocessable) = unprocessable {
unprocessable.abort();
}
info!("VTC messaging stopped");
}
const MAX_INFLIGHT_INBOUND: usize = 32;
async fn run_inbound_loop<S, H, Fut>(
mut stream: S,
shutdown_rx: &mut watch::Receiver<bool>,
handle: H,
) where
S: futures_util::Stream<Item = Inbound> + Unpin,
H: Fn(Inbound) -> Fut,
Fut: std::future::Future<Output = ()> + Send + 'static,
{
let inflight = Arc::new(tokio::sync::Semaphore::new(MAX_INFLIGHT_INBOUND));
let sender_order = SenderOrder::new();
loop {
tokio::select! {
maybe = stream.next() => {
let Some(inbound) = maybe else {
warn!("VTC inbound stream ended — messaging dispatcher stopping");
break;
};
let permit = match Arc::clone(&inflight).acquire_owned().await {
Ok(p) => p,
Err(_) => {
warn!("inbound concurrency semaphore closed — stopping");
break;
}
};
let ticket = sender_order
.admit(inbound.message.sender.as_deref(), frame_order(&inbound));
let work = handle(inbound);
tokio::spawn(async move {
let _permit = permit;
ticket.ready().await;
let _ticket = ticket;
work.await;
});
}
_ = shutdown_rx.changed() => {
info!("VTC messaging stopping (shutdown signalled)");
break;
}
}
}
}
fn frame_order(inbound: &Inbound) -> FrameOrder {
match inbound.message.protocol {
Protocol::TSP => match inbound.kind {
InboundKind::RelationshipControl { .. } => FrameOrder::Barrier,
_ => FrameOrder::Follower,
},
_ => FrameOrder::Unordered,
}
}
async fn handle_inbound_frame(
inbound: Inbound,
state: &AppState,
atm: &Arc<ATM>,
service: &Arc<MessagingService>,
vtc_did: &str,
) {
match inbound.message.protocol {
Protocol::DIDComm => {}
#[cfg(not(feature = "tsp"))]
Protocol::TSP => {
warn!("received an inbound TSP frame but the `tsp` feature is disabled — dropping");
return;
}
Protocol::DIDCommV1 => {
warn!("received an inbound DIDComm v1 frame; this VTC speaks v2.1 only — dropping");
return;
}
other => {
warn!(
protocol = ?other,
"received an inbound frame in a protocol this VTC does not implement — dropping"
);
return;
}
}
let reply_to = inbound.message.sender.clone().or_else(|| {
serde_json::from_slice::<Message>(&inbound.message.payload)
.ok()
.and_then(|m| m.from)
});
let Some(reply) = dispatch(inbound, state).await else {
return;
};
let Some(to) = reply_to else {
warn!(
reply_type = %reply.type_,
"computed a DIDComm reply but the inbound message had no sender/from to reply to — \
dropping"
);
return;
};
let reply_id = uuid::Uuid::new_v4().to_string();
let reply_msg = Message::build(reply_id, reply.type_, reply.body)
.from(vtc_did.to_string())
.to(to.clone())
.thid(reply.thid)
.finalize();
match atm
.pack_encrypted(&reply_msg, &to, Some(vtc_did), Some(vtc_did))
.await
{
Ok((packed, _)) => {
match service
.send(&to, packed.into_bytes(), Delivery::BestEffort)
.await
{
Ok(_) => {
if let Some(m) = state.didcomm.get() {
m.uncollected.observe_delivered(&to);
}
}
Err(e) => {
let not_collecting =
vti_common::inbox::messaging_refused_recipient_not_collecting(&e);
let reported = state.didcomm.get().is_some_and(|m| {
m.uncollected
.observe_send(&to, "DIDComm reply", not_collecting)
});
if !reported {
warn!(recipient = %to, error = %e, "failed to send DIDComm reply");
}
}
}
}
Err(e) => warn!(recipient = %to, error = %e, "failed to pack DIDComm reply"),
}
}
#[cfg(feature = "tsp")]
fn tsp_sender(message: &affinidi_messaging_core::ReceivedMessage) -> Option<String> {
message.sender.clone().filter(|_| message.verified)
}
#[cfg(feature = "tsp")]
fn tsp_reply_document(
payload: &[u8],
) -> Option<(
trust_tasks_rs::TrustTask<serde_json::Value>,
serde_json::Value,
)> {
let value: serde_json::Value = serde_json::from_slice(payload).ok()?;
let inner = match value.get("document") {
Some(document) => document.clone(),
None => value,
};
let doc: trust_tasks_rs::TrustTask<serde_json::Value> =
serde_json::from_value(inner.clone()).ok()?;
doc.thread_id.as_ref()?;
let is_reply = doc.type_uri.is_response() || doc.type_uri.slug() == "trust-task-error";
is_reply.then_some((doc, inner))
}
#[cfg(feature = "tsp")]
async fn handle_tsp(
inbound: Inbound,
messaging: &Arc<VtcMessaging>,
state: &AppState,
mediator_did: &str,
) {
let Some(sender_vid) = tsp_sender(&inbound.message) else {
warn!("inbound TSP frame has no cryptographically-verified sender VID — dropping");
return;
};
state.tsp_reach.record(&sender_vid);
if let InboundKind::RelationshipControl {
request,
thread_digest,
reply_expected,
..
} = inbound.kind
{
handle_tsp_control(
&messaging.atm,
&messaging.profile,
&messaging.uncollected,
&sender_vid,
request,
thread_digest,
reply_expected,
)
.await;
return;
}
if let Some((doc, received)) = tsp_reply_document(&inbound.message.payload) {
let thread_id = doc.thread_id.clone().unwrap_or_default();
if !state
.pending_replies
.complete_verified(doc, &received, &state.trust_task_vm_resolver())
.await
{
debug!(%thread_id, sender = %sender_vid, "TSP reply had no waiter — dropping");
}
return;
}
let wrapped = vta_sdk::tsp_binding::open_envelope(&inbound.message.payload);
let document = match &wrapped {
Ok(document) => document.as_slice(),
Err(_) => inbound.message.payload.as_slice(),
};
let ctx = JoinAuthCtx {
transport: JoinTransport::Tsp,
sender_did: Some(sender_vid.clone()),
verified_signer: None,
};
let start = std::time::Instant::now();
let task = serde_json::from_slice::<serde_json::Value>(document)
.ok()
.and_then(|d| d.get("type").and_then(|t| t.as_str()).map(str::to_string));
let outcome = dispatch_trust_task_core(state, &ctx, document).await;
info!(
target: "tsp_server::request",
task = task.as_deref().unwrap_or("-"),
sender = %sender_vid,
status = outcome.status.as_u16(),
latency = ?start.elapsed(),
"Request processed"
);
if outcome.body.is_empty() {
return;
}
let reply = if wrapped.is_ok() {
vta_sdk::tsp_binding::wrap_envelope(&outcome.body)
} else {
outcome.body
};
match vti_common::tsp_route::send_reply(
&messaging.atm,
&messaging.profile,
mediator_did,
&sender_vid,
&reply,
)
.await
{
Ok(()) => messaging.uncollected.observe_delivered(&sender_vid),
Err(e) => {
if !messaging.uncollected.observe_send(
&sender_vid,
"TSP reply",
vti_common::inbox::refused_recipient_not_collecting(&e),
) {
warn!(recipient = %sender_vid, error = %e, "failed to send TSP reply");
}
}
}
}
#[cfg(feature = "tsp")]
#[derive(Debug, Clone, PartialEq, Eq)]
enum ControlDecision {
Accept,
Cancel(&'static str),
Nothing,
}
#[cfg(feature = "tsp")]
fn decide_control(
request: affinidi_messaging_core::RelationshipRequest,
reply_expected: bool,
) -> ControlDecision {
use affinidi_messaging_core::RelationshipRequest;
match request {
RelationshipRequest::Invite => ControlDecision::Accept,
RelationshipRequest::Accept => ControlDecision::Nothing,
RelationshipRequest::Cancel => {
if reply_expected {
ControlDecision::Cancel("the peer cancelled a mutual relationship (§7.3)")
} else {
ControlDecision::Nothing
}
}
_ => ControlDecision::Nothing,
}
}
#[cfg(feature = "tsp")]
async fn handle_tsp_control(
atm: &Arc<ATM>,
profile: &Arc<ATMProfile>,
uncollected: &vti_common::inbox::UncollectedPeers,
sender_vid: &str,
request: affinidi_messaging_core::RelationshipRequest,
thread_digest: [u8; 32],
reply_expected: bool,
) {
match decide_control(request, reply_expected) {
ControlDecision::Accept if uncollected.is_marked(sender_vid, std::time::Instant::now()) => {
debug!(
sender = %sender_vid, ?request,
"recorded an inbound TSP relationship request; not answering — the peer is not \
collecting its mediator inbox",
);
}
ControlDecision::Accept => {
match atm
.tsp()
.accept_relationship(profile, sender_vid, thread_digest)
.await
{
Ok(state) => {
uncollected.observe_delivered(sender_vid);
info!(
sender = %sender_vid, ?request, ?state,
"accepted an inbound TSP relationship request",
)
}
Err(e) => {
let after = atm.tsp().relationship_state(profile, sender_vid).await;
if matches!(after, Ok(s) if vti_common::relationship_store::accept_refusal_is_benign(s))
{
debug!(
sender = %sender_vid, ?request, error = %e,
"a TSP relationship accept was refused because a concurrent accept \
to the same peer completed the relationship first; the invite is \
answered",
);
} else if !uncollected.observe_send(
sender_vid,
"TSP relationship accept",
vti_common::inbox::refused_recipient_not_collecting(&e),
) {
warn!(
sender = %sender_vid, error = %e,
"could not send a TSP relationship accept; the relationship stays \
recorded, so traffic still flows, but the peer sees no answer",
)
}
}
}
}
ControlDecision::Cancel(why) => {
match atm
.tsp()
.answer_cancellation(profile, sender_vid, thread_digest)
.await
{
Ok(_) => info!(
sender = %sender_vid, ?request, reason = %why,
"answered an inbound TSP relationship cancellation (§7.3) after the \
transport's own answer failed",
),
Err(e) => warn!(
sender = %sender_vid, reason = %why, error = %e,
"could not send the §7.3 answer to a TSP relationship cancellation; \
the relationship is forgotten on this side, but the peer sees no answer",
),
}
}
ControlDecision::Nothing => info!(
sender = %sender_vid, ?request,
"recorded an inbound TSP relationship request; no answer is due",
),
}
}
#[cfg(all(test, feature = "tsp"))]
mod tsp_control_tests {
use super::{ControlDecision, decide_control};
use affinidi_messaging_core::RelationshipRequest;
#[test]
fn an_invite_is_accepted() {
assert_eq!(
decide_control(RelationshipRequest::Invite, false),
ControlDecision::Accept
);
assert_eq!(
decide_control(RelationshipRequest::Invite, true),
ControlDecision::Accept
);
}
#[test]
fn an_accept_is_recorded_only() {
assert_eq!(
decide_control(RelationshipRequest::Accept, true),
ControlDecision::Nothing
);
}
#[test]
fn a_cancel_is_answered_only_when_the_answer_is_still_owed() {
assert!(matches!(
decide_control(RelationshipRequest::Cancel, true),
ControlDecision::Cancel(_)
));
assert_eq!(
decide_control(RelationshipRequest::Cancel, false),
ControlDecision::Nothing
);
}
}
struct Reply {
type_: String,
body: serde_json::Value,
thid: String,
}
async fn dispatch(inbound: Inbound, state: &AppState) -> Option<Reply> {
let msg: Message = serde_json::from_slice(&inbound.message.payload).ok()?;
if msg.typ == MESSAGE_PICKUP_STATUS_TYPE {
return None;
}
if msg.typ == vti_common::capability_client::TRUST_TASK_ENVELOPE_TYPE
&& let Some((_thid, doc)) =
vti_common::capability_client::parse_envelope_document(&msg.body)
&& state
.pending_replies
.complete_verified(doc.clone(), &msg.body, &state.trust_task_vm_resolver())
.await
{
return None;
}
if msg.typ == vti_common::capability_client::TRUST_TASK_ENVELOPE_TYPE
&& vta_sdk::inbound::names_a_reply(&msg.body)
{
debug!(
task = msg.body.get("type").and_then(|t| t.as_str()).unwrap_or("-"),
thid = msg
.body
.get("threadId")
.and_then(|t| t.as_str())
.unwrap_or("-"),
"DIDComm reply had no waiter — dropping"
);
return None;
}
let auth_sender = inbound
.message
.sender
.clone()
.filter(|_| inbound.message.verified);
let start = std::time::Instant::now();
let message_type = msg.typ.clone();
let sender_log = auth_sender
.clone()
.or_else(|| msg.from.clone())
.unwrap_or_else(|| "<anon>".to_string());
let task = request_task_type(&msg);
let request_id = msg.id.clone();
let reply = route(&msg, auth_sender, state).await;
info!(
target: "didcomm_server::request",
message_type = %message_type,
task = task.as_deref().unwrap_or("-"),
id = %request_id,
sender = %sender_log,
status = if reply.is_some() { "ok(response)" } else { "ok(empty)" },
reply_type = reply.as_ref().map(|r| r.type_.as_str()).unwrap_or("-"),
latency = ?start.elapsed(),
"Request processed"
);
reply
}
fn request_task_type(msg: &Message) -> Option<String> {
if msg.typ != vti_common::capability_client::TRUST_TASK_ENVELOPE_TYPE {
return None;
}
msg.body
.get("type")
.and_then(|t| t.as_str())
.map(str::to_string)
}
async fn route(msg: &Message, auth_sender: Option<String>, state: &AppState) -> Option<Reply> {
match msg.typ.as_str() {
TRUST_PING_TYPE => trust_ping_reply(msg, auth_sender.as_deref()),
vti_common::capability_client::TRUST_TASK_ENVELOPE_TYPE => {
envelope_task_handler(msg, auth_sender, state).await
}
_ => unhandled_message(msg),
}
}
fn trust_ping_reply(msg: &Message, auth_sender: Option<&str>) -> Option<Reply> {
#[derive(serde::Deserialize)]
struct PingBody {
#[serde(default = "default_true")]
response_requested: bool,
}
fn default_true() -> bool {
true
}
let body: PingBody = serde_json::from_value(msg.body.clone()).unwrap_or(PingBody {
response_requested: true,
});
if !body.response_requested {
return None;
}
auth_sender?;
Some(Reply {
type_: TRUST_PONG_TYPE.to_string(),
body: serde_json::Value::Null,
thid: msg.id.clone(),
})
}
fn problem_report(thid: String, code: &str, comment: impl Into<String>) -> Reply {
Reply {
type_: PROBLEM_REPORT_TYPE.to_string(),
body: problem_report_body(code, comment),
thid,
}
}
fn problem_report_body(code: &str, comment: impl Into<String>) -> serde_json::Value {
json!({ "code": code, "comment": comment.into() })
}
fn problem_report_details(body: &serde_json::Value) -> (String, String, String) {
let field = |k: &str| {
body.get(k)
.and_then(|v| v.as_str())
.unwrap_or("<none>")
.to_string()
};
let args = match body.get("args").and_then(|v| v.as_array()) {
Some(a) if !a.is_empty() => a
.iter()
.map(|v| {
v.as_str()
.map(str::to_string)
.unwrap_or_else(|| v.to_string())
})
.collect::<Vec<_>>()
.join(", "),
_ => "<none>".to_string(),
};
(field("code"), field("comment"), args)
}
fn unhandled_message(message: &Message) -> Option<Reply> {
if message.typ.contains("problem-report") {
let (code, comment, args) = problem_report_details(&message.body);
warn!(
from = message.from.as_deref().unwrap_or("<anon>"),
thid = message.thid.as_deref().unwrap_or("<none>"),
code = %code,
comment = %comment,
args = %args,
body = %message.body,
id = %message.id,
"received unhandled problem-report — not replying (a reply would loop)"
);
return None;
}
if let Some(comment) = trust_task_needs_envelope(&message.typ) {
warn!(
message_type = %message.typ,
from = message.from.as_deref().unwrap_or("<anon>"),
id = %message.id,
"Trust Task arrived typed as its task URI, not in the DIDComm binding envelope — refused"
);
return Some(problem_report(
message.id.clone(),
codes::BAD_REQUEST,
comment,
));
}
warn!(
message_type = %message.typ,
from = message.from.as_deref().unwrap_or("<anon>"),
id = %message.id,
"inbound DIDComm message has no matching handler — dropping (unsupported message type)"
);
Some(problem_report(
message.id.clone(),
codes::BAD_REQUEST,
format!("unsupported message type: {}", message.typ),
))
}
pub(crate) fn trust_task_needs_envelope(typ: &str) -> Option<String> {
typ.starts_with(TRUST_TASK_SPEC_PREFIX).then(|| {
format!(
"unsupported message type: {typ} — Trust Tasks must be carried in the DIDComm \
binding envelope `{}` with the task document as the body",
vti_common::capability_client::TRUST_TASK_ENVELOPE_TYPE
)
})
}
const TRUST_TASK_SPEC_PREFIX: &str = "https://trusttasks.org/spec/";
fn tt_didcomm_reply(outcome: TrustTaskOutcome, thid: String) -> Option<Reply> {
let doc: serde_json::Value = match serde_json::from_slice(&outcome.body) {
Ok(d) => d,
Err(e) => {
return Some(problem_report(
thid,
codes::INTERNAL,
format!("reply document parse: {e}"),
));
}
};
let typ = doc
.get("type")
.and_then(|t| t.as_str())
.map(str::to_string)
.unwrap_or_else(|| crate::trust_tasks::framework_error_type_uri().to_string());
Some(Reply {
type_: typ,
body: doc,
thid,
})
}
fn inbound_doc_bytes(message: &Message) -> Result<Vec<u8>, String> {
serde_json::to_vec(&message.body).map_err(|e| format!("serialise inbound document: {e}"))
}
fn error_doc_summary(body: &[u8]) -> (String, Option<String>) {
let Ok(doc) = serde_json::from_slice::<serde_json::Value>(body) else {
return ("<unparseable error document>".to_string(), None);
};
let code = doc
.pointer("/payload/code")
.and_then(|c| c.as_str())
.unwrap_or("<unknown>")
.to_string();
let message = doc
.pointer("/payload/message")
.and_then(|m| m.as_str())
.map(str::to_string);
(code, message)
}
async fn envelope_task_handler(
msg: &Message,
auth_sender: Option<String>,
state: &AppState,
) -> Option<Reply> {
let thid = msg.id.clone();
let body = match inbound_doc_bytes(msg) {
Ok(b) => b,
Err(e) => return Some(problem_report(thid, codes::INTERNAL, e)),
};
let ctx = JoinAuthCtx {
transport: JoinTransport::DIDComm,
sender_did: auth_sender,
verified_signer: None,
};
let outcome = dispatch_trust_task_core(state, &ctx, &body).await;
if !outcome.status.is_success() {
let (code, reason) = error_doc_summary(&outcome.body);
warn!(
task = msg.body.get("type").and_then(|t| t.as_str()).unwrap_or("<none>"),
thid = %thid,
status = outcome.status.as_u16(),
code = %code,
reason = reason.as_deref().unwrap_or("<none>"),
"enveloped Trust Task refused — trust-task-error returned"
);
}
tt_didcomm_reply(outcome, thid)
}
pub(crate) fn parse_disposition(s: &str) -> Result<Disposition, String> {
match s.to_ascii_lowercase().as_str() {
"purge" => Ok(Disposition::Purge),
"tombstone" => Ok(Disposition::Tombstone),
"historical" => Ok(Disposition::Historical),
"policydefault" => Ok(Disposition::PolicyDefault),
other => Err(format!(
"unknown disposition '{other}' (expected purge|tombstone|historical|policydefault)"
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn frame(id: &str, sender: &str, protocol: Protocol) -> Inbound {
Inbound::new(
affinidi_messaging_core::ReceivedMessage {
id: id.into(),
sender: Some(sender.into()),
recipient: "did:example:vtc".into(),
payload: Vec::new(),
protocol,
verified: true,
encrypted: true,
},
None,
affinidi_messaging_core::InboundAck(id.into()),
)
}
#[tokio::test]
async fn a_handler_waiting_on_a_later_frame_is_not_waiting_on_itself() {
let (answered_tx, answered_rx) = tokio::sync::oneshot::channel::<()>();
let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>();
let answered_tx = Arc::new(std::sync::Mutex::new(Some(answered_tx)));
let answered_rx = Arc::new(std::sync::Mutex::new(Some(answered_rx)));
let done_tx = Arc::new(std::sync::Mutex::new(Some(done_tx)));
let stream = futures_util::stream::iter(vec![
frame("request", "did:key:zMember", Protocol::TSP),
frame("reply", "did:webvh:bridge", Protocol::TSP),
])
.chain(futures_util::stream::pending());
let (_shutdown_tx, mut shutdown_rx) = watch::channel(false);
let loop_task = tokio::spawn(async move {
run_inbound_loop(Box::pin(stream), &mut shutdown_rx, move |inbound| {
let answered_tx = answered_tx.clone();
let answered_rx = answered_rx.clone();
let done_tx = done_tx.clone();
async move {
if inbound.message.id == "request" {
let rx = answered_rx.lock().unwrap().take().unwrap();
rx.await.unwrap();
let _ = done_tx.lock().unwrap().take().unwrap().send(());
} else {
let _ = answered_tx.lock().unwrap().take().unwrap().send(());
}
}
})
.await;
});
tokio::time::timeout(Duration::from_secs(5), done_rx)
.await
.expect("the request completed once its later reply was read")
.unwrap();
loop_task.abort();
}
#[tokio::test]
async fn a_relationship_frame_is_handled_before_its_senders_later_traffic() {
let invite = frame("invite", "did:key:zPeer", Protocol::TSP).with_kind(
InboundKind::RelationshipControl {
request: affinidi_messaging_core::RelationshipRequest::Invite,
thread_digest: [0; 32],
reply_expected: false,
introduces: None,
},
);
let stream =
futures_util::stream::iter(vec![invite, frame("task", "did:key:zPeer", Protocol::TSP)])
.chain(futures_util::stream::pending());
let order = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let (done_tx, done_rx) = tokio::sync::mpsc::channel::<()>(2);
let (_shutdown_tx, mut shutdown_rx) = watch::channel(false);
let seen = order.clone();
let loop_task = tokio::spawn(async move {
run_inbound_loop(Box::pin(stream), &mut shutdown_rx, move |inbound| {
let seen = seen.clone();
let done_tx = done_tx.clone();
async move {
if inbound.message.id == "invite" {
tokio::time::sleep(Duration::from_millis(100)).await;
}
seen.lock().unwrap().push(inbound.message.id.clone());
let _ = done_tx.send(()).await;
}
})
.await;
});
let mut done_rx = done_rx;
for _ in 0..2 {
tokio::time::timeout(Duration::from_secs(5), done_rx.recv())
.await
.expect("both frames handled");
}
loop_task.abort();
assert_eq!(*order.lock().unwrap(), vec!["invite", "task"]);
}
fn inbox(
message_count: u32,
longest_waited_seconds: Option<u64>,
) -> vti_common::inbox::InboxStatus {
vti_common::inbox::InboxStatus::from(
&affinidi_tdk::messaging::protocols::message_pickup::MessagePickupStatusReply {
message_count,
longest_waited_seconds,
live_delivery: true,
..Default::default()
},
)
}
#[test]
fn vti_50_only_a_message_live_delivery_missed_triggers_catch_up() {
use vti_common::inbox::STALE_AFTER_SECS;
assert!(!inbox(0, None).needs_catch_up(), "empty inbox");
assert!(
!inbox(1, Some(STALE_AFTER_SECS - 1)).needs_catch_up(),
"a message just pushed is still being acked"
);
assert!(inbox(1, Some(STALE_AFTER_SECS)).needs_catch_up());
assert!(inbox(3, Some(600)).needs_catch_up());
assert!(
inbox(1, None).needs_catch_up(),
"a mediator that reports no ages still gets a redelivery"
);
}
#[test]
fn vti_51_connect_backoff_grows_to_its_cap() {
assert_eq!(connect_backoff_ceiling(0), CONNECT_RETRY_BASE);
assert_eq!(connect_backoff_ceiling(1), CONNECT_RETRY_BASE * 2);
assert_eq!(connect_backoff_ceiling(10), CONNECT_RETRY_CAP);
assert_eq!(connect_backoff_ceiling(u32::MAX), CONNECT_RETRY_CAP);
for attempt in [0, 3, 40] {
assert!(connect_backoff(attempt) <= connect_backoff_ceiling(attempt));
}
}
#[test]
fn vti_50_catch_up_runs_well_inside_a_short_message_expiry() {
let worst_case =
vti_common::inbox::CHECK_INTERVAL.as_secs() + vti_common::inbox::STALE_AFTER_SECS;
assert!(worst_case * 3 <= 300, "worst-case catch-up {worst_case}s");
}
#[cfg(feature = "tsp")]
#[test]
fn a_tsp_reply_is_recognised_bare_and_enveloped() {
let doc = json!({
"id": "urn:uuid:reply",
"type": "https://trusttasks.org/spec/registry/record/put/0.1#response",
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"threadId": "urn:uuid:request",
"payload": { "ok": true, "created": true },
});
let bare = serde_json::to_vec(&doc).unwrap();
assert_eq!(
tsp_reply_document(&bare).and_then(|(d, _)| d.thread_id),
Some("urn:uuid:request".to_string()),
);
let enveloped = serde_json::to_vec(&json!({
"type": "https://trusttasks.org/binding/tsp/0.1/envelope",
"document": doc,
}))
.unwrap();
assert_eq!(
tsp_reply_document(&enveloped).and_then(|(d, _)| d.thread_id),
Some("urn:uuid:request".to_string()),
);
}
#[cfg(feature = "tsp")]
#[test]
fn an_inbound_request_is_not_mistaken_for_a_reply() {
let request = serde_json::to_vec(&json!({
"id": "urn:uuid:req",
"type": "https://trusttasks.org/spec/registry/record/query/0.1",
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"payload": {},
}))
.unwrap();
assert!(tsp_reply_document(&request).is_none());
let unthreaded = serde_json::to_vec(&json!({
"id": "urn:uuid:resp",
"type": "https://trusttasks.org/spec/registry/record/put/0.1#response",
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"payload": { "ok": true },
}))
.unwrap();
assert!(tsp_reply_document(&unthreaded).is_none());
}
#[cfg(feature = "tsp")]
#[test]
fn an_error_document_counts_as_a_reply() {
let error = serde_json::to_vec(&json!({
"id": "urn:uuid:err",
"type": crate::trust_tasks::helpers::framework_error_type_uri().to_string(),
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"threadId": "urn:uuid:request",
"payload": { "code": "permissionDenied" },
}))
.unwrap();
assert!(tsp_reply_document(&error).is_some());
}
#[test]
fn problem_report_details_extracts_code_comment_and_args() {
let (code, comment, args) = problem_report_details(&json!({
"code": "e.p.xfer.cant-use-endpoint",
"comment": "Unable to use the {1} endpoint for {2}.",
"args": ["https://x.example/finance", "did:example:1234"],
}));
assert_eq!(code, "e.p.xfer.cant-use-endpoint");
assert_eq!(comment, "Unable to use the {1} endpoint for {2}.");
assert_eq!(args, "https://x.example/finance, did:example:1234");
}
#[test]
fn problem_report_details_marks_missing_fields() {
let (code, comment, args) = problem_report_details(&json!({}));
assert_eq!(code, "<none>");
assert_eq!(comment, "<none>");
assert_eq!(args, "<none>");
}
#[tokio::test]
async fn an_enveloped_reply_with_no_waiter_is_dropped_not_answered() {
let vtc = crate::test_support::build_test_vtc().await;
let msg = Message::build(
"urn:uuid:stray".to_string(),
vti_common::capability_client::TRUST_TASK_ENVELOPE_TYPE.to_string(),
json!({
"id": "urn:uuid:stray-doc",
"type": "https://trusttasks.org/spec/messaging/message/status/0.1#response",
"threadId": "urn:uuid:long-gone",
"payload": {}
}),
)
.finalize();
let mut inbound = frame("stray", "did:webvh:mediator", Protocol::DIDComm);
inbound.message.payload = serde_json::to_vec(&msg).unwrap();
assert!(dispatch(inbound, &vtc.state).await.is_none());
}
#[test]
fn the_request_log_names_the_enveloped_task() {
const VIEW: &str = "https://trusttasks.org/spec/git-ns/view/0.4";
let enveloped = Message::build(
"m1".to_string(),
vti_common::capability_client::TRUST_TASK_ENVELOPE_TYPE.to_string(),
serde_json::json!({ "type": VIEW, "id": "urn:uuid:q" }),
)
.finalize();
assert_eq!(request_task_type(&enveloped).as_deref(), Some(VIEW));
let ping = Message::build(
"m2".to_string(),
"https://didcomm.org/trust-ping/2.0/ping".to_string(),
serde_json::json!({ "type": VIEW }),
)
.finalize();
assert_eq!(
request_task_type(&ping),
None,
"only an envelope carries a task"
);
}
#[test]
fn problem_report_body_is_code_and_comment() {
let body = problem_report_body(codes::BAD_REQUEST, "malformed body");
let (code, comment) = vta_sdk::protocols::extract_problem_report(&body);
assert_eq!(code, codes::BAD_REQUEST);
assert_eq!(comment, "malformed body");
}
}
#[cfg(all(test, feature = "tsp"))]
mod tsp_sender_tests {
use super::tsp_sender;
use affinidi_messaging_core::{Protocol, ReceivedMessage};
const SENDER: &str = "did:key:z6MkTspSenderUnderTest";
fn frame(sender: Option<&str>, verified: bool) -> ReceivedMessage {
ReceivedMessage {
id: "urn:uuid:test".to_string(),
sender: sender.map(str::to_string),
recipient: "did:key:z6MkVtcUnderTest".to_string(),
payload: b"{}".to_vec(),
protocol: Protocol::TSP,
verified,
encrypted: true,
}
}
#[test]
fn a_verified_sender_is_the_proven_caller() {
assert_eq!(
tsp_sender(&frame(Some(SENDER), true)).as_deref(),
Some(SENDER)
);
}
#[test]
fn an_unverified_sender_is_refused() {
assert_eq!(tsp_sender(&frame(Some(SENDER), false)), None);
}
#[test]
fn an_anonymous_frame_is_refused() {
assert_eq!(tsp_sender(&frame(None, true)), None);
assert_eq!(tsp_sender(&frame(None, false)), None);
}
}