use crate::config::Config;
use crate::relationships::RelationshipState;
pub use affinidi_messaging_core::ConnState;
use affinidi_messaging_core::MessageTransport;
use affinidi_messaging_core::transport::InboundKind;
use affinidi_messaging_core::types::Protocol;
use affinidi_messaging_delivery::{
Delivery, InMemoryOutboxStore, MessagingService, OutboxStore, drain_loop_via,
};
use affinidi_messaging_sdk::DidCommTransport;
use affinidi_messaging_sdk::protocols::message_pickup::InboundFrame;
use affinidi_messaging_sdk::protocols::tsp::InboundTsp;
use affinidi_tdk::common::TDKSharedState;
use affinidi_tdk::common::config::TDKConfig;
use affinidi_tdk::common::profiles::TDKProfile;
use affinidi_tdk::didcomm::Message;
use affinidi_tdk::messaging::ATM;
use affinidi_tdk::messaging::config::ATMConfig;
use affinidi_tdk::messaging::profiles::ATMProfile;
use affinidi_tdk::secrets_resolver::SecretsResolver;
use affinidi_tdk::secrets_resolver::secrets::Secret;
use futures_util::StreamExt;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::mpsc;
use tracing::debug;
#[derive(Debug, thiserror::Error)]
pub enum MessagingError {
#[error("could not bring up listener {listener_id}: {reason}")]
Listener { listener_id: String, reason: String },
#[error("no listener installed for {0}")]
UnknownListener(String),
#[error("could not pack message for {recipient}: {reason}")]
Pack { recipient: String, reason: String },
#[error("send failed: {0}")]
Send(String),
}
#[derive(Clone)]
pub struct ListenerSpec {
pub id: String,
pub did: String,
pub mediator_did: String,
pub label: String,
pub secrets: Vec<Secret>,
}
impl std::fmt::Debug for ListenerSpec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ListenerSpec")
.field("id", &self.id)
.field("did", &self.did)
.field("mediator_did", &self.mediator_did)
.field("label", &self.label)
.field(
"secrets",
&format_args!("<{} redacted>", self.secrets.len()),
)
.finish()
}
}
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 DRAIN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(250);
const DELIVER_BY: std::time::Duration = std::time::Duration::from_secs(120);
const LIFECYCLE_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500);
const CYCLING_WINDOW: std::time::Duration = std::time::Duration::from_secs(10);
const RECONNECT_GRACE: std::time::Duration = std::time::Duration::from_secs(15);
const PENDING_DROP_SWEEP: std::time::Duration = std::time::Duration::from_secs(1);
const LISTENER_STATUS_CAPACITY: usize = 64;
const REBUILD_GRACE: std::time::Duration = std::time::Duration::from_secs(90);
const REBUILD_BACKOFF_BASE: std::time::Duration = std::time::Duration::from_secs(5);
const REBUILD_BACKOFF_CAP: std::time::Duration = std::time::Duration::from_secs(60);
const SUPERVISOR_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10);
const PICKUP_PAGE: usize = 50;
const PICKUP_MAX: usize = 200;
fn rebuild_backoff(attempts: u32) -> std::time::Duration {
if attempts == 0 {
return std::time::Duration::ZERO;
}
let shifted = REBUILD_BACKOFF_BASE
.checked_mul(1u32 << attempts.saturating_sub(1).min(4))
.unwrap_or(REBUILD_BACKOFF_CAP);
shifted.min(REBUILD_BACKOFF_CAP)
}
fn rebuild_due(
down_for: std::time::Duration,
attempts: u32,
since_last_attempt: Option<std::time::Duration>,
) -> bool {
if down_for < REBUILD_GRACE {
return false;
}
match since_last_attempt {
None => true,
Some(elapsed) => elapsed >= rebuild_backoff(attempts),
}
}
#[derive(Debug, Clone, Copy)]
struct DownSince {
first_seen: std::time::Instant,
attempts: u32,
last_attempt: Option<std::time::Instant>,
}
struct IdentityWire {
atm: ATM,
profile: Arc<ATMProfile>,
did: String,
spec: ListenerSpec,
drain: tokio::task::JoinHandle<()>,
}
async fn quiesce_wire(listener_id: &str, wire: IdentityWire) -> ATM {
wire.drain.abort();
if let Err(e) = wire.profile.stop_websocket().await {
debug!(
listener = %crate::display::truncate_did(listener_id, 32),
error = %e,
"closing the listener's websocket returned an error; the ATM shutdown will retry it"
);
}
wire.atm
}
#[derive(Clone)]
pub struct Messaging {
inner: Arc<MessagingInner>,
}
#[derive(Debug, Clone)]
pub enum ListenerStatus {
Connected {
listener_id: String,
},
Disconnected {
listener_id: String,
error: Option<String>,
},
}
struct MessagingInner {
service: Arc<MessagingService>,
status_tx: tokio::sync::broadcast::Sender<ListenerStatus>,
outbox: Arc<dyn OutboxStore>,
identities: tokio::sync::RwLock<HashMap<String, IdentityWire>>,
event_tx: mpsc::UnboundedSender<DIDCommEvent>,
pickup_in_flight: std::sync::Mutex<std::collections::HashSet<String>>,
tasks: std::sync::Mutex<Vec<tokio::task::JoinHandle<()>>>,
tsp_store: crate::tsp_store::TspStoreHandle,
stranded: std::sync::Mutex<HashMap<String, ListenerSpec>>,
supervisor_tx: tokio::sync::broadcast::Sender<LifecycleLog>,
}
impl Messaging {
pub fn start(event_tx: mpsc::UnboundedSender<DIDCommEvent>) -> Self {
let outbox: Arc<dyn OutboxStore> = Arc::new(InMemoryOutboxStore::new());
let service = Arc::new(MessagingService::empty(outbox.clone()));
let (status_tx, _) = tokio::sync::broadcast::channel(LISTENER_STATUS_CAPACITY);
let inner = Arc::new(MessagingInner {
service: service.clone(),
status_tx: status_tx.clone(),
outbox,
identities: tokio::sync::RwLock::new(HashMap::new()),
event_tx: event_tx.clone(),
pickup_in_flight: std::sync::Mutex::new(std::collections::HashSet::new()),
tasks: std::sync::Mutex::new(Vec::new()),
tsp_store: crate::tsp_store::TspStoreHandle::new(),
stranded: std::sync::Mutex::new(HashMap::new()),
supervisor_tx: tokio::sync::broadcast::channel(LISTENER_STATUS_CAPACITY).0,
});
let dispatcher = tokio::spawn(dispatch_inbound(service.clone(), event_tx));
let poller = tokio::spawn(poll_listener_status(service, status_tx));
{
let mut tasks = inner.tasks.lock().expect("tasks mutex");
tasks.push(dispatcher);
tasks.push(poller);
}
let messaging = Self { inner };
let supervised = messaging.clone();
let supervisor = tokio::spawn(async move { supervise_transports(supervised).await });
let collecting = messaging.clone();
let collector = tokio::spawn(async move { pickup_on_connect(collecting).await });
{
let mut tasks = messaging.inner.tasks.lock().expect("tasks mutex");
tasks.push(supervisor);
tasks.push(collector);
}
messaging
}
pub fn tsp_store(&self) -> crate::tsp_store::TspStoreHandle {
self.inner.tsp_store.clone()
}
pub async fn has_listener(&self, listener_id: &str) -> bool {
self.inner.identities.read().await.contains_key(listener_id)
}
pub async fn list_listeners(&self) -> Vec<String> {
self.inner.identities.read().await.keys().cloned().collect()
}
pub async fn remove_listener(&self, listener_id: &str) {
self.inner
.stranded
.lock()
.expect("stranded mutex")
.remove(listener_id);
self.inner.service.remove_transport(listener_id);
let removed = self.inner.identities.write().await.remove(listener_id);
if let Some(wire) = removed {
let atm = quiesce_wire(listener_id, wire).await;
tokio::spawn(async move { atm.graceful_shutdown().await });
}
}
pub fn listener_state(&self, listener_id: &str) -> Option<ConnState> {
self.inner.service.transport_state(listener_id)
}
pub async fn listener_did(&self, listener_id: &str) -> Option<String> {
self.inner
.identities
.read()
.await
.get(listener_id)
.map(|wire| wire.did.clone())
}
pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<ListenerStatus> {
self.inner.status_tx.subscribe()
}
pub async fn wait_connected(
&self,
listener_id: &str,
timeout: std::time::Duration,
) -> Result<(), String> {
let deadline = tokio::time::Instant::now() + timeout;
loop {
match self.listener_state(listener_id) {
Some(ConnState::Connected) => return Ok(()),
state if tokio::time::Instant::now() >= deadline => {
return Err(format!(
"listener {listener_id} did not connect within {timeout:?} \
(last state: {state:?})"
));
}
_ => tokio::time::sleep(std::time::Duration::from_millis(50)).await,
}
}
}
pub async fn pickup_stored(&self, listener_id: &str) -> Result<usize, MessagingError> {
let (atm, profile) = {
let identities = self.inner.identities.read().await;
let wire = identities
.get(listener_id)
.ok_or_else(|| MessagingError::UnknownListener(listener_id.to_string()))?;
(wire.atm.clone(), wire.profile.clone())
};
let fail = |reason: String| MessagingError::Listener {
listener_id: listener_id.to_string(),
reason,
};
let mut handed_off = 0usize;
while handed_off < PICKUP_MAX {
let batch = atm
.message_pickup()
.send_delivery_request_frames(&profile, Some(PICKUP_PAGE), true)
.await
.map_err(|e| fail(format!("delivery-request failed: {e}")))?;
if batch.is_empty() {
break;
}
let requested = batch.len();
let mut acks: Vec<String> = Vec::with_capacity(requested);
for (frame, attachment_id) in batch {
let Some(frame) = frame else {
acks.push(attachment_id);
continue;
};
let Some((message, transport, from)) =
frame_to_message(&atm, &profile, frame).await
else {
continue;
};
let events = classify_inbound(
message,
transport,
from.clone(),
from,
listener_id.to_string(),
);
if events.is_empty() {
acks.push(attachment_id);
continue;
}
let mut queued = true;
for event in events {
if self.inner.event_tx.send(event).is_err() {
tracing::debug!(
"state handler has gone away during pickup — leaving the message stored"
);
queued = false;
break;
}
}
if queued {
handed_off += 1;
acks.push(attachment_id);
}
}
if !acks.is_empty() {
let acked = acks.len();
if let Err(e) = atm
.message_pickup()
.send_messages_received(&profile, &acks, true)
.await
{
tracing::warn!(
listener = %crate::display::truncate_did(listener_id, 32),
acked,
error = %e,
"could not acknowledge picked-up messages; they will be offered again"
);
}
}
if acks.len() < requested || requested < PICKUP_PAGE {
break;
}
}
if handed_off >= PICKUP_MAX {
tracing::warn!(
listener = %crate::display::truncate_did(listener_id, 32),
limit = PICKUP_MAX,
"stored-message pickup hit its per-connect limit; the rest stays in the mailbox"
);
} else if handed_off > 0 {
tracing::info!(
listener = %crate::display::truncate_did(listener_id, 32),
count = handed_off,
"collected messages the mediator was holding"
);
}
Ok(handed_off)
}
pub async fn shutdown(&self) {
let handles: Vec<_> = std::mem::take(&mut *self.inner.tasks.lock().expect("tasks mutex"));
for handle in handles {
handle.abort();
}
let wires: Vec<(String, IdentityWire)> =
self.inner.identities.write().await.drain().collect();
for (id, wire) in wires {
self.inner.service.remove_transport(&id);
quiesce_wire(&id, wire).await.graceful_shutdown().await;
}
}
}
pub const PERSONA_LISTENER_ID: &str = "persona";
pub fn persona_listener_id(persona_did: &str) -> String {
if persona_did.is_empty() {
PERSONA_LISTENER_ID.to_string()
} else {
persona_did.to_string()
}
}
pub use crate::messaging::build_didcomm_message;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MessagingTransport {
DidComm,
Tsp,
}
impl std::fmt::Display for MessagingTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::DidComm => "DIDComm",
Self::Tsp => "TSP",
})
}
}
#[derive(Debug)]
pub enum DIDCommEvent {
InboundMessage {
message: Box<Message>,
#[allow(dead_code)]
from: Option<String>,
authenticated: Option<String>,
transport: MessagingTransport,
},
TrustPingReceived {
from: Option<String>,
listener_id: String,
message_id: String,
},
TrustPongReceived { from: Option<String> },
}
#[deprecated(
note = "the event channel is unbounded; this value is retained only to document what it was"
)]
pub const DIDCOMM_EVENT_CHANNEL_CAPACITY: usize = 256;
pub enum ReconnectOutcome {
Connected,
Failed(String),
}
pub async fn reconnect_persona_listener_io(
service: &Messaging,
listener_id: String,
new_config: ListenerSpec,
) -> ReconnectOutcome {
service.remove_listener(&listener_id).await;
if let Err(e) = add_listener(service, &new_config).await {
return ReconnectOutcome::Failed(format!("{e:#}"));
}
match service
.wait_connected(&listener_id, std::time::Duration::from_secs(30))
.await
{
Ok(()) => ReconnectOutcome::Connected,
Err(e) => ReconnectOutcome::Failed(e),
}
}
pub async fn add_listener(service: &Messaging, spec: &ListenerSpec) -> Result<(), MessagingError> {
let fail = |reason: String| MessagingError::Listener {
listener_id: spec.id.clone(),
reason,
};
let tdk_profile = make_profile(
&spec.did,
&spec.mediator_did,
&spec.label,
spec.secrets.clone(),
);
let tdk = TDKSharedState::new(
TDKConfig::builder()
.build()
.map_err(|e| fail(format!("TDK config: {e}")))?,
)
.await
.map_err(|e| fail(format!("TDK init: {e}")))?;
for secret in spec.secrets.clone() {
tdk.secrets_resolver().insert(secret).await;
}
let atm = ATM::new(
ATMConfig::builder()
.with_relationship_store(service.inner.tsp_store.relationship_store())
.build()
.map_err(|e| fail(format!("ATM config: {e}")))?,
Arc::new(tdk),
)
.await
.map_err(|e| fail(format!("ATM init: {e}")))?;
let atm_profile = ATMProfile::from_tdk_profile(&atm, &tdk_profile)
.await
.map_err(|e| fail(format!("profile: {e}")))?;
let profile = atm
.profile_add(&atm_profile, true)
.await
.map_err(|e| fail(format!("mediator connect: {e}")))?;
let transport: Arc<dyn MessageTransport> = Arc::new(
DidCommTransport::new(atm.clone(), profile.clone())
.await
.map_err(|e| fail(format!("transport bind: {e}")))?,
);
service
.inner
.service
.add_transport(spec.id.clone(), transport.clone());
let drain = tokio::spawn(drain_loop_via(
service.inner.outbox.clone(),
spec.id.clone(),
transport,
DRAIN_INTERVAL,
));
let displaced = service.inner.identities.write().await.insert(
spec.id.clone(),
IdentityWire {
atm,
profile,
did: spec.did.clone(),
spec: spec.clone(),
drain,
},
);
if let Some(old) = displaced {
tracing::warn!(
listener = %crate::display::truncate_did(&spec.id, 32),
"a listener was installed over an existing one; closing the one it replaced"
);
let atm = quiesce_wire(&spec.id, old).await;
tokio::spawn(async move { atm.graceful_shutdown().await });
}
debug!(listener = %spec.id, "listener installed on the delivery layer");
Ok(())
}
async fn dispatch_inbound(
service: Arc<MessagingService>,
event_tx: mpsc::UnboundedSender<DIDCommEvent>,
) {
let mut inbound = service.subscribe();
while let Some(item) = inbound.next().await {
if let InboundKind::RelationshipControl {
request,
reply_expected,
introduces,
..
} = &item.kind
{
tracing::debug!(
?request,
peer = item.message.sender.as_deref().unwrap_or("<unknown>"),
recipient = %item.message.recipient,
reply_expected,
introduces = introduces.as_deref().unwrap_or("<none>"),
"recorded an inbound TSP relationship control message",
);
continue;
}
let (message, transport) = match item.message.protocol {
Protocol::DIDComm => match serde_json::from_slice::<Message>(&item.message.payload) {
Ok(message) => (message, MessagingTransport::DidComm),
Err(e) => {
debug!(error = %e, "inbound DIDComm frame is not a Message — dropped");
continue;
}
},
Protocol::TSP => match tsp_frame_to_message(&item) {
Some(message) => (message, MessagingTransport::Tsp),
None => continue,
},
other => {
debug!(protocol = %other, "inbound frame on an unsupported protocol — dropped");
continue;
}
};
let authenticated = item.message.sender.clone();
let from = authenticated.clone().or_else(|| message.from.clone());
for event in classify_inbound(
message,
transport,
from,
authenticated,
item.message.recipient.clone(),
) {
if event_tx.send(event).is_err() {
tracing::debug!("state handler has gone away — ending inbound dispatch");
return;
}
}
}
}
async fn frame_to_message(
atm: &ATM,
profile: &Arc<ATMProfile>,
frame: InboundFrame,
) -> Option<(Message, MessagingTransport, Option<String>)> {
match frame {
InboundFrame::DidComm(message, meta) => {
let from = authenticated_sender(&message, &meta);
Some((*message, MessagingTransport::DidComm, from))
}
InboundFrame::Tsp(packed) => {
let qb2 = match atm.tsp().decode(&packed) {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "could not decode a stored TSP frame — leaving it in the mailbox");
return None;
}
};
let inbound = match atm.tsp().unpack_message(profile, &qb2).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "could not unpack a stored TSP frame — leaving it in the mailbox");
return None;
}
};
match inbound {
InboundTsp::Application { payload, sender } => {
let fallback_id = {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(packed.as_bytes()))
};
let recipient = profile.inner.did.clone();
let message =
tsp_document_to_message(&payload, Some(&sender), &recipient, &fallback_id)?;
Some((message, MessagingTransport::Tsp, Some(sender)))
}
InboundTsp::Control {
control, sender, ..
} => {
if let Err(e) = atm
.tsp()
.record_incoming_control(profile, &sender, &control)
.await
{
tracing::debug!(error = %e, %sender, "stored TSP control message not recorded — a protocol rule refused it");
}
None
}
InboundTsp::Padding { .. } | InboundTsp::UpperLayerControl { .. } => None,
other => {
debug!(kind = ?std::mem::discriminant(&other), "stored TSP frame of an unhandled kind — left stored");
None
}
}
}
other => {
debug!(frame = ?std::mem::discriminant(&other), "picked-up frame on an unsupported protocol — left stored");
None
}
}
}
fn authenticated_sender(
message: &Message,
meta: &affinidi_messaging_sdk::messages::compat::UnpackMetadata,
) -> Option<String> {
if !meta.authenticated || meta.anonymous_sender {
return None;
}
let kid = meta.encrypted_from_kid.as_deref()?;
let key_did = kid.split_once('#').map(|(did, _)| did).unwrap_or(kid);
match message.from.as_deref() {
Some(from) if from == key_did => Some(from.to_string()),
_ => None,
}
}
static CATCH_ALL: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
regex::Regex::new(&format!("^(?:{OPENVTC_CATCH_ALL_PATTERN})$"))
.expect("OPENVTC_CATCH_ALL_PATTERN is a constant, and `catch_all_tests` compiles it")
});
fn classify_inbound(
message: Message,
transport: MessagingTransport,
from: Option<String>,
authenticated: Option<String>,
listener_id: String,
) -> Vec<DIDCommEvent> {
if message.typ == TRUST_PING_TYPE {
return vec![DIDCommEvent::TrustPingReceived {
from,
listener_id,
message_id: message.id.clone(),
}];
}
if message.typ == TRUST_PONG_TYPE {
return vec![
DIDCommEvent::TrustPongReceived { from: from.clone() },
DIDCommEvent::InboundMessage {
from,
authenticated,
message: Box::new(message),
transport,
},
];
}
if CATCH_ALL.is_match(&message.typ) {
tracing::info!(
listener = %crate::display::truncate_did(&listener_id, 32),
msg_type = %message.typ,
from = ?from.as_deref().map(|d| crate::display::truncate_did(d, 32)),
thid = ?message.thid,
"inbound OpenVTC message received"
);
return vec![DIDCommEvent::InboundMessage {
from,
authenticated,
message: Box::new(message),
transport,
}];
}
debug!(typ = %message.typ, "unhandled message type — dropped");
Vec::new()
}
fn tsp_frame_to_message(item: &affinidi_messaging_core::transport::Inbound) -> Option<Message> {
tsp_document_to_message(
&item.message.payload,
item.message.sender.as_deref(),
&item.message.recipient,
&item.message.id,
)
}
fn tsp_document_to_message(
payload: &[u8],
sender: Option<&str>,
recipient: &str,
fallback_id: &str,
) -> Option<Message> {
let opened = vta_sdk::tsp_binding::open_envelope(payload);
let payload: &[u8] = match &opened {
Ok(document) => document,
Err(_) => payload,
};
let doc: serde_json::Value = match serde_json::from_slice(payload) {
Ok(doc) => doc,
Err(e) => {
tracing::warn!(error = %e, "inbound TSP frame is not JSON — dropped");
return None;
}
};
let Some(typ) = doc.get("type").and_then(|t| t.as_str()) else {
tracing::warn!(
"inbound TSP frame carries no `type` — dropped (a TSP frame has no \
envelope to recover it from)"
);
return None;
};
let id = doc
.get("id")
.and_then(|i| i.as_str())
.unwrap_or(fallback_id)
.to_string();
let mut builder = Message::build(id, typ.to_string(), doc.clone());
if let Some(thid) = doc.get("threadId").and_then(|t| t.as_str()) {
builder = builder.thid(thid.to_string());
}
if let Some(sender) = sender {
builder = builder.from(sender.to_string());
}
Some(builder.to(recipient.to_string()).finalize())
}
#[must_use]
pub fn open_didcomm_envelope(message: &Message) -> Option<Message> {
if message.typ != crate::capabilities::TRUST_TASK_ENVELOPE_TYPE {
return None;
}
let typ = message.body.get("type").and_then(|t| t.as_str())?;
let mut opened = message.clone();
opened.typ = typ.to_string();
if opened.thid.is_none() {
opened.thid = message
.body
.get("threadId")
.and_then(|t| t.as_str())
.map(str::to_string);
}
Some(opened)
}
#[must_use]
pub fn routes_inbound_type(type_uri: &str) -> bool {
type_uri == TRUST_PING_TYPE || type_uri == TRUST_PONG_TYPE || CATCH_ALL.is_match(type_uri)
}
pub const OPENVTC_CATCH_ALL_PATTERN: &str = concat!(
r"https://linuxfoundation\.org/openvtc/.*",
r"|https://firstperson\.network/.*",
r"|https://trusttasks\.org/openvtc/vtc/.*",
r"|https://trusttasks\.org/spec/vtc/.*",
r"|https://trusttasks\.org/spec/credential-exchange/.*",
r"|https://trusttasks\.org/spec/vetting/.*",
r"|https://trusttasks\.org/spec/trust-task-error/.*",
r"|https://trusttasks\.org/spec/governance/capability/.*",
r"|https://trusttasks\.org/binding/.*",
r"|https://didcomm\.org/report-problem/.*",
);
async fn get_secrets_for_did(
tdk: &affinidi_tdk::TDK,
config: &Config,
did: &str,
) -> Vec<affinidi_tdk::secrets_resolver::secrets::Secret> {
let resolver = tdk.shared().secrets_resolver();
let mut secrets = vec![];
for key_id in config.key_info.keys() {
if key_id.starts_with(did)
&& let Some(secret) = resolver.get_secret(key_id).await
{
secrets.push(secret);
}
}
secrets
}
fn make_profile(
did: &str,
mediator: &str,
alias: &str,
secrets: Vec<affinidi_tdk::secrets_resolver::secrets::Secret>,
) -> TDKProfile {
TDKProfile::new(alias, did, Some(mediator), secrets)
}
pub async fn build_listener_configs(config: &Config, tdk: &affinidi_tdk::TDK) -> Vec<ListenerSpec> {
let mut configs = Vec::new();
let mut persona_dids = std::collections::HashSet::new();
for identity in config.identities.values() {
let did = identity.did.as_str();
if !persona_dids.insert(did.to_string()) {
continue;
}
let persona_secrets = get_secrets_for_did(tdk, config, did).await;
let mediator = identity
.mediator_did
.as_deref()
.unwrap_or(config.mediator_did());
let label = config.persona_profile_label_for(identity.persona_id);
configs.push(ListenerSpec {
id: persona_listener_id(did),
did: did.to_string(),
mediator_did: mediator.to_string(),
label,
secrets: persona_secrets,
});
}
let mut seen_dids = std::collections::HashSet::new();
let r_did_entries: Vec<(String, String)> = config
.private
.relationships
.relationships
.iter()
.filter_map(|(remote_p_did, rel)| {
if matches!(
rel.state,
RelationshipState::Established
| RelationshipState::RequestSent
| RelationshipState::RequestAccepted
) && !persona_dids.contains(rel.our_did.as_str())
&& seen_dids.insert(rel.our_did.to_string())
{
Some((rel.our_did.to_string(), remote_p_did.to_string()))
} else {
None
}
})
.collect();
for (our_did, remote_p_did) in &r_did_entries {
let r_did_secrets = get_secrets_for_did(tdk, config, our_did).await;
configs.push(ListenerSpec {
id: format!("rel-{}", short_did_id(our_did)),
did: our_did.to_string(),
mediator_did: config.mediator_did().to_string(),
label: format!(
"R-DID for {}",
crate::display::truncate_did(remote_p_did, 32)
),
secrets: r_did_secrets,
});
}
debug!(
persona_listeners = persona_dids.len(),
r_did_listeners = r_did_entries.len(),
total = configs.len(),
"built listener configs"
);
configs
}
pub fn listener_id_for_did(our_did: &str, config: &Config) -> String {
if config.is_persona_did(our_did) {
persona_listener_id(our_did)
} else {
format!("rel-{}", short_did_id(our_did))
}
}
pub async fn send_message(
service: &Messaging,
config: &Config,
message: &Message,
from_did: &str,
to_did: &str,
) -> Result<(), MessagingError> {
let listener_id = listener_id_for_did(from_did, config);
send_message_via(service, message, &listener_id, to_did).await
}
pub async fn send_message_via(
service: &Messaging,
message: &Message,
listener_id: &str,
to_did: &str,
) -> Result<(), MessagingError> {
tracing::info!(
listener = %crate::display::truncate_did(listener_id, 32),
msg_type = %message.typ,
from = ?message
.from
.as_deref()
.map(|d| crate::display::truncate_did(d, 32)),
to = %crate::display::truncate_did(to_did, 32),
thid = ?message.thid,
"sending DIDComm message"
);
let packed = {
let identities = service.inner.identities.read().await;
let wire = identities
.get(listener_id)
.ok_or_else(|| MessagingError::UnknownListener(listener_id.to_string()))?;
wire.atm
.pack_encrypted(message, to_did, Some(&wire.did), Some(&wire.did))
.await
.map_err(|e| MessagingError::Pack {
recipient: to_did.to_string(),
reason: e.to_string(),
})?
.0
};
service
.inner
.service
.send_via(
listener_id,
to_did,
packed.into_bytes(),
Delivery::Guaranteed {
idempotency_key: Some(message.id.clone()),
ordering_key: None,
deliver_by: DELIVER_BY,
},
)
.await
.map(|_accepted| ())
.map_err(|e| MessagingError::Send(e.to_string()))
}
#[derive(Debug, Clone)]
pub enum LifecycleLog {
Connected { listener_id: String },
Disconnected {
listener_id: String,
error: Option<String>,
},
Reconnected {
listener_id: String,
down_for: std::time::Duration,
},
CyclingRapidly { listener_id: String },
Restarting {
listener_id: String,
attempt: u32,
delay: std::time::Duration,
},
RestartFailed {
listener_id: String,
attempt: u32,
error: String,
retry_in: std::time::Duration,
},
Missed { count: u64 },
}
#[derive(Debug, Clone)]
struct HeldDrop {
at: std::time::Instant,
error: Option<String>,
}
#[derive(Debug, Default)]
struct DropDebounce {
held: HashMap<String, HeldDrop>,
last_disconnect: HashMap<String, std::time::Instant>,
}
impl DropDebounce {
fn on_disconnect(
&mut self,
listener_id: String,
error: Option<String>,
now: std::time::Instant,
) -> Vec<LifecycleLog> {
let mut out = Vec::new();
if let Some(previous) = self.last_disconnect.get(&listener_id)
&& now.duration_since(*previous) < CYCLING_WINDOW
{
tracing::warn!(
listener = %crate::display::truncate_did(&listener_id, 32),
"rapid disconnect cycling detected"
);
out.push(LifecycleLog::CyclingRapidly {
listener_id: listener_id.clone(),
});
}
self.last_disconnect.insert(listener_id.clone(), now);
self.held
.entry(listener_id)
.or_insert(HeldDrop { at: now, error });
out
}
fn on_connect(&mut self, listener_id: String, now: std::time::Instant) -> LifecycleLog {
match self.held.remove(&listener_id) {
Some(drop) if now.duration_since(drop.at) < RECONNECT_GRACE => {
LifecycleLog::Reconnected {
listener_id,
down_for: now.duration_since(drop.at),
}
}
_ => LifecycleLog::Connected { listener_id },
}
}
fn due(&mut self, now: std::time::Instant) -> Vec<LifecycleLog> {
let expired: Vec<String> = self
.held
.iter()
.filter(|(_, drop)| now.duration_since(drop.at) >= RECONNECT_GRACE)
.map(|(id, _)| id.clone())
.collect();
expired
.into_iter()
.map(|listener_id| {
let drop = self.held.remove(&listener_id).expect("just listed");
LifecycleLog::Disconnected {
listener_id,
error: drop.error,
}
})
.collect()
}
}
pub fn spawn_lifecycle_logger(
service: &Messaging,
log_tx: mpsc::UnboundedSender<LifecycleLog>,
) -> tokio::task::JoinHandle<()> {
let mut status_rx = service.subscribe();
let mut supervisor_rx = service.inner.supervisor_tx.subscribe();
tokio::spawn(async move {
let mut debounce = DropDebounce::default();
let mut sweep = tokio::time::interval(PENDING_DROP_SWEEP);
loop {
tokio::select! {
event = status_rx.recv() => match event {
Ok(ListenerStatus::Connected { listener_id }) => {
let _ = log_tx.send(
debounce.on_connect(listener_id, std::time::Instant::now()),
);
}
Ok(ListenerStatus::Disconnected { listener_id, error }) => {
for log in debounce.on_disconnect(
listener_id,
error,
std::time::Instant::now(),
) {
let _ = log_tx.send(log);
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
let _ = log_tx.send(LifecycleLog::Missed { count });
}
},
event = supervisor_rx.recv() => match event {
Ok(log) => {
let _ = log_tx.send(log);
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
let _ = log_tx.send(LifecycleLog::Missed { count });
}
},
_ = sweep.tick() => {
for log in debounce.due(std::time::Instant::now()) {
let _ = log_tx.send(log);
}
}
}
}
})
}
async fn pickup_on_connect(service: Messaging) {
let mut transitions = service.subscribe();
loop {
let listener_id = match transitions.recv().await {
Ok(ListenerStatus::Connected { listener_id }) => listener_id,
Ok(ListenerStatus::Disconnected { .. }) => continue,
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
};
{
let mut in_flight = service.inner.pickup_in_flight.lock().expect("pickup mutex");
if !in_flight.insert(listener_id.clone()) {
continue;
}
}
let collector = service.clone();
tokio::spawn(async move {
if let Err(e) = collector.pickup_stored(&listener_id).await {
tracing::warn!(
listener = %crate::display::truncate_did(&listener_id, 32),
error = %e,
"could not collect stored messages after connect"
);
}
collector
.inner
.pickup_in_flight
.lock()
.expect("pickup mutex")
.remove(&listener_id);
});
}
}
async fn supervise_transports(service: Messaging) {
let mut down: HashMap<String, DownSince> = HashMap::new();
loop {
tokio::time::sleep(SUPERVISOR_INTERVAL).await;
supervise_once(&service, &mut down, std::time::Instant::now()).await;
}
}
async fn supervise_once(
service: &Messaging,
down: &mut HashMap<String, DownSince>,
now: std::time::Instant,
) {
let states = service.inner.service.transport_states();
let installed: std::collections::HashSet<String> =
states.iter().map(|(id, _)| id.clone()).collect();
let stranded: Vec<(String, ListenerSpec)> = {
let mut stranded = service.inner.stranded.lock().expect("stranded mutex");
stranded.retain(|id, _| !installed.contains(id));
stranded
.iter()
.map(|(id, spec)| (id.clone(), spec.clone()))
.collect()
};
let mut candidates: Vec<(String, Option<ListenerSpec>)> = Vec::new();
for (listener_id, state) in states {
if state == ConnState::Connected {
down.remove(&listener_id);
} else {
candidates.push((listener_id, None));
}
}
candidates.extend(stranded.into_iter().map(|(id, spec)| (id, Some(spec))));
for (listener_id, stranded_spec) in candidates {
let tracked = down.entry(listener_id.clone()).or_insert(DownSince {
first_seen: now,
attempts: 0,
last_attempt: None,
});
if !rebuild_due(
now.duration_since(tracked.first_seen),
tracked.attempts,
tracked.last_attempt.map(|at| now.duration_since(at)),
) {
continue;
}
let spec = match stranded_spec {
Some(spec) => spec,
None => {
let Some(spec) = service
.inner
.identities
.read()
.await
.get(&listener_id)
.map(|wire| wire.spec.clone())
else {
down.remove(&listener_id);
continue;
};
spec
}
};
tracked.attempts = tracked.attempts.saturating_add(1);
tracked.last_attempt = Some(now);
let attempt = tracked.attempts;
tracing::warn!(
listener = %crate::display::truncate_did(&listener_id, 32),
attempt,
down_for_secs = now.duration_since(tracked.first_seen).as_secs(),
"listener has not reconnected on its own — rebuilding its transport"
);
let _ = service.inner.supervisor_tx.send(LifecycleLog::Restarting {
listener_id: listener_id.clone(),
attempt,
delay: rebuild_backoff(attempt - 1),
});
service.remove_listener(&listener_id).await;
service
.inner
.stranded
.lock()
.expect("stranded mutex")
.insert(listener_id.clone(), spec.clone());
match add_listener(service, &spec).await {
Ok(()) => {
service
.inner
.stranded
.lock()
.expect("stranded mutex")
.remove(&listener_id);
tracing::info!(
listener = %crate::display::truncate_did(&listener_id, 32),
"transport rebuilt"
);
}
Err(e) => {
let retry_in = rebuild_backoff(attempt);
tracing::warn!(
listener = %crate::display::truncate_did(&listener_id, 32),
attempt,
error = %e,
"transport rebuild failed; will retry with backoff"
);
let _ = service
.inner
.supervisor_tx
.send(LifecycleLog::RestartFailed {
listener_id: listener_id.clone(),
attempt,
error: format!("{e:#}"),
retry_in,
});
}
}
}
let installed: std::collections::HashSet<String> = service
.inner
.service
.transport_states()
.into_iter()
.map(|(id, _)| id)
.collect();
let stranded = service.inner.stranded.lock().expect("stranded mutex");
down.retain(|id, _| installed.contains(id) || stranded.contains_key(id));
}
async fn poll_listener_status(
service: Arc<MessagingService>,
status_tx: tokio::sync::broadcast::Sender<ListenerStatus>,
) {
let mut seen: HashMap<String, ConnState> = HashMap::new();
loop {
tokio::time::sleep(LIFECYCLE_POLL_INTERVAL).await;
let states = service.transport_states();
for (listener_id, state) in &states {
if seen.insert(listener_id.clone(), *state) == Some(*state) {
continue;
}
let event = match state {
ConnState::Connected => ListenerStatus::Connected {
listener_id: listener_id.clone(),
},
_ => ListenerStatus::Disconnected {
listener_id: listener_id.clone(),
error: None,
},
};
let _ = status_tx.send(event);
}
let live: std::collections::HashSet<&String> = states.iter().map(|(id, _)| id).collect();
seen.retain(|id, _| live.contains(id));
}
}
pub async fn persona_listener_config(config: &Config, tdk: &affinidi_tdk::TDK) -> ListenerSpec {
let secrets = get_secrets_for_did(tdk, config, config.persona_did()).await;
ListenerSpec {
id: persona_listener_id(config.persona_did()),
did: config.persona_did().to_string(),
mediator_did: config.mediator_did().to_string(),
label: config.persona_profile_label(),
secrets,
}
}
pub async fn persona_listener_config_for(
config: &Config,
tdk: &affinidi_tdk::TDK,
persona_id: crate::config::account::PersonaId,
) -> Option<ListenerSpec> {
let ident = config.identities.get(&persona_id)?;
let did = ident.did.as_str();
let secrets = get_secrets_for_did(tdk, config, did).await;
let mediator = ident
.mediator_did
.as_deref()
.unwrap_or(config.mediator_did());
let label = config.persona_profile_label_for(persona_id);
Some(ListenerSpec {
id: persona_listener_id(did),
did: did.to_string(),
mediator_did: mediator.to_string(),
label,
secrets,
})
}
pub async fn start_service(
config: &Config,
tdk: &affinidi_tdk::TDK,
event_tx: mpsc::UnboundedSender<DIDCommEvent>,
shutdown: tokio_util::sync::CancellationToken,
) -> Result<Messaging, MessagingError> {
let service = start_empty_service(event_tx, shutdown);
service
.tsp_store()
.hydrate(&config.private.tsp_relationships)
.await;
install_listeners(&service, config, tdk).await;
Ok(service)
}
pub fn start_empty_service(
event_tx: mpsc::UnboundedSender<DIDCommEvent>,
shutdown: tokio_util::sync::CancellationToken,
) -> Messaging {
let service = Messaging::start(event_tx);
let on_cancel = service.clone();
tokio::spawn(async move {
shutdown.cancelled().await;
on_cancel.shutdown().await;
});
service
}
pub async fn install_listeners(service: &Messaging, config: &Config, tdk: &affinidi_tdk::TDK) {
connect_listeners(service, build_listener_configs(config, tdk).await).await;
}
pub async fn connect_listeners(service: &Messaging, specs: Vec<ListenerSpec>) {
for spec in specs {
if service.has_listener(&spec.id).await {
continue;
}
if let Err(e) = add_listener(service, &spec).await {
tracing::warn!(
listener = %crate::display::truncate_did(&spec.id, 32),
error = %e,
"listener failed to come up; continuing without it"
);
}
}
}
pub fn spawn_install_listeners(
service: Messaging,
specs: Vec<ListenerSpec>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
connect_listeners(&service, specs).await;
let listeners = service.list_listeners().await;
for id in &listeners {
tracing::debug!(
listener = %crate::display::truncate_did(id, 32),
state = ?service.listener_state(id),
"registered listener"
);
}
tracing::info!(count = listeners.len(), "DIDComm listeners registered");
})
}
fn short_did_id(did: &str) -> String {
use sha2::{Digest, Sha256};
let hash = Sha256::digest(did.as_bytes());
hex::encode(&hash[..8])
}
pub fn relationship_listener_config_from_secrets(
our_did: &str,
remote_p_did: &str,
mediator_did: &str,
secrets: Vec<Secret>,
) -> ListenerSpec {
ListenerSpec {
id: format!("rel-{}", short_did_id(our_did)),
did: our_did.to_string(),
mediator_did: mediator_did.to_string(),
label: format!(
"R-DID for {}",
crate::display::truncate_did(remote_p_did, 32)
),
secrets,
}
}
#[cfg(test)]
mod drop_debounce_tests {
use super::{CYCLING_WINDOW, DropDebounce, LifecycleLog, RECONNECT_GRACE};
use std::time::{Duration, Instant};
const LISTENER: &str = "did:webvh:QmListener";
#[test]
fn a_routine_refresh_reconnect_is_one_calm_line() {
let mut debounce = DropDebounce::default();
let dropped = Instant::now();
assert!(
debounce
.on_disconnect(LISTENER.into(), None, dropped)
.is_empty(),
"a first drop is held, not reported"
);
assert!(
debounce.due(dropped + Duration::from_secs(1)).is_empty(),
"and stays held while it is still inside the grace"
);
let back = dropped + Duration::from_secs(2);
match debounce.on_connect(LISTENER.into(), back) {
LifecycleLog::Reconnected {
listener_id,
down_for,
} => {
assert_eq!(listener_id, LISTENER);
assert_eq!(down_for, Duration::from_secs(2));
}
other => panic!("expected one Reconnected line, got {other:?}"),
}
assert!(
debounce.due(back + RECONNECT_GRACE * 2).is_empty(),
"nothing is left to report once the pair has been reported"
);
}
#[test]
fn a_drop_that_does_not_recover_is_reported() {
let mut debounce = DropDebounce::default();
let dropped = Instant::now();
debounce.on_disconnect(LISTENER.into(), None, dropped);
assert!(
debounce
.due(dropped + RECONNECT_GRACE - Duration::from_millis(1))
.is_empty(),
"not yet — this is still within the grace"
);
let due = debounce.due(dropped + RECONNECT_GRACE);
assert!(
matches!(&due[..], [LifecycleLog::Disconnected { listener_id, error: None }] if listener_id == LISTENER),
"got {due:?}"
);
assert!(
debounce.due(dropped + RECONNECT_GRACE * 2).is_empty(),
"reported once, not on every sweep"
);
}
#[test]
fn a_held_drop_keeps_its_transport_error() {
let mut debounce = DropDebounce::default();
let dropped = Instant::now();
debounce.on_disconnect(LISTENER.into(), Some("connection reset".into()), dropped);
let due = debounce.due(dropped + RECONNECT_GRACE);
assert!(
matches!(&due[..], [LifecycleLog::Disconnected { error: Some(e), .. }] if e == "connection reset"),
"got {due:?}"
);
}
#[test]
fn a_late_recovery_is_a_plain_connect() {
let mut debounce = DropDebounce::default();
let dropped = Instant::now();
debounce.on_disconnect(LISTENER.into(), None, dropped);
let _reported = debounce.due(dropped + RECONNECT_GRACE);
assert!(matches!(
debounce.on_connect(
LISTENER.into(),
dropped + RECONNECT_GRACE + Duration::from_secs(5)
),
LifecycleLog::Connected { .. }
));
}
#[test]
fn a_first_connect_is_a_plain_connect() {
let mut debounce = DropDebounce::default();
assert!(matches!(
debounce.on_connect(LISTENER.into(), Instant::now()),
LifecycleLog::Connected { .. }
));
}
#[test]
fn rapid_cycling_still_warns_immediately() {
let mut debounce = DropDebounce::default();
let first = Instant::now();
assert!(
debounce
.on_disconnect(LISTENER.into(), None, first)
.is_empty()
);
debounce.on_connect(LISTENER.into(), first + Duration::from_millis(500));
let second = first + Duration::from_secs(3);
let out = debounce.on_disconnect(LISTENER.into(), None, second);
assert!(
matches!(&out[..], [LifecycleLog::CyclingRapidly { listener_id }] if listener_id == LISTENER),
"a second drop inside the cycling window must warn at once, got {out:?}"
);
}
#[test]
fn drops_outside_the_cycling_window_do_not_warn() {
let mut debounce = DropDebounce::default();
let first = Instant::now();
debounce.on_disconnect(LISTENER.into(), None, first);
debounce.on_connect(LISTENER.into(), first + Duration::from_secs(2));
let later = first + CYCLING_WINDOW + Duration::from_secs(1);
assert!(
debounce
.on_disconnect(LISTENER.into(), None, later)
.is_empty(),
"a drop outside the window is not cycling"
);
}
#[test]
fn a_repeated_drop_does_not_extend_the_grace() {
let mut debounce = DropDebounce::default();
let first = Instant::now();
debounce.on_disconnect(LISTENER.into(), None, first);
debounce.on_disconnect(LISTENER.into(), None, first + RECONNECT_GRACE / 2);
assert_eq!(
debounce.due(first + RECONNECT_GRACE).len(),
1,
"the hold is dated from the first drop"
);
}
#[test]
fn listeners_are_held_independently() {
let mut debounce = DropDebounce::default();
let now = Instant::now();
debounce.on_disconnect("a".into(), None, now);
debounce.on_disconnect("b".into(), None, now + RECONNECT_GRACE);
let due = debounce.due(now + RECONNECT_GRACE);
assert!(
matches!(&due[..], [LifecycleLog::Disconnected { listener_id, .. }] if listener_id == "a"),
"only the one past its grace, got {due:?}"
);
assert!(matches!(
debounce.on_connect("b".into(), now + RECONNECT_GRACE + Duration::from_secs(1)),
LifecycleLog::Reconnected { .. }
));
}
}
#[cfg(test)]
mod supervisor_policy_tests {
use super::{
LifecycleLog, ListenerSpec, REBUILD_BACKOFF_CAP, REBUILD_GRACE, rebuild_backoff,
rebuild_due, spawn_install_listeners, start_empty_service, supervise_once,
};
use std::collections::HashMap;
use std::time::Duration;
use tokio::sync::mpsc;
#[test]
fn a_recent_drop_is_left_alone() {
assert!(!rebuild_due(Duration::from_secs(1), 0, None));
assert!(!rebuild_due(
REBUILD_GRACE - Duration::from_secs(1),
0,
None
));
}
#[tokio::test]
async fn installing_listeners_does_not_block_the_caller() {
let (event_tx, _event_rx) = mpsc::unbounded_channel();
let service = start_empty_service(event_tx, tokio_util::sync::CancellationToken::new());
let spec = ListenerSpec {
id: "did:example:unreachable".to_string(),
did: "did:example:unreachable".to_string(),
mediator_did: "did:example:no-such-mediator".to_string(),
label: "test".to_string(),
secrets: Vec::new(),
};
let started = std::time::Instant::now();
let handle = spawn_install_listeners(service.clone(), vec![spec]);
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_millis(200),
"spawning the install must return immediately, took {elapsed:?}"
);
assert!(
!service.has_listener("did:example:unreachable").await,
"the unreachable listener must not have been installed synchronously"
);
handle.abort();
}
#[test]
fn a_transport_down_past_the_grace_period_is_rebuilt() {
assert!(rebuild_due(REBUILD_GRACE, 0, None));
assert!(rebuild_due(
REBUILD_GRACE + Duration::from_secs(60),
0,
None
));
}
#[test]
fn a_failed_rebuild_waits_before_the_next_attempt() {
let down_for = REBUILD_GRACE + Duration::from_secs(600);
assert!(
!rebuild_due(down_for, 1, Some(Duration::from_secs(1))),
"one second after a failed attempt is too soon"
);
assert!(
rebuild_due(down_for, 1, Some(Duration::from_secs(5))),
"the first backoff step has elapsed"
);
}
#[test]
fn backoff_doubles_and_then_caps() {
assert_eq!(rebuild_backoff(0), Duration::ZERO);
assert_eq!(rebuild_backoff(1), Duration::from_secs(5));
assert_eq!(rebuild_backoff(2), Duration::from_secs(10));
assert_eq!(rebuild_backoff(3), Duration::from_secs(20));
assert_eq!(rebuild_backoff(4), Duration::from_secs(40));
assert_eq!(rebuild_backoff(5), REBUILD_BACKOFF_CAP);
}
#[test]
fn a_long_outage_never_stops_retrying() {
let forever = Duration::from_secs(60 * 60 * 24);
assert_eq!(rebuild_backoff(u32::MAX), REBUILD_BACKOFF_CAP);
assert!(rebuild_due(forever, u32::MAX, Some(REBUILD_BACKOFF_CAP)));
}
#[test]
fn the_grace_period_outlasts_the_backoff_ceiling() {
assert!(REBUILD_GRACE > REBUILD_BACKOFF_CAP);
}
fn unreachable_spec() -> ListenerSpec {
ListenerSpec {
id: "did:example:unreachable".to_string(),
did: "did:example:unreachable".to_string(),
mediator_did: "did:example:no-such-mediator".to_string(),
label: "test".to_string(),
secrets: Vec::new(),
}
}
#[tokio::test]
async fn a_failed_rebuild_is_retried_not_forgotten() {
let (event_tx, _event_rx) = mpsc::unbounded_channel();
let service = start_empty_service(event_tx, tokio_util::sync::CancellationToken::new());
let mut supervisor_rx = service.inner.supervisor_tx.subscribe();
let spec = unreachable_spec();
service
.inner
.stranded
.lock()
.unwrap()
.insert(spec.id.clone(), spec.clone());
let mut down = HashMap::new();
let t0 = std::time::Instant::now();
supervise_once(&service, &mut down, t0).await;
assert_eq!(
down[&spec.id].attempts, 0,
"inside the grace: no attempt yet"
);
let due = t0 + REBUILD_GRACE;
tokio::time::timeout(
Duration::from_secs(60),
supervise_once(&service, &mut down, due),
)
.await
.expect("a rebuild attempt is bounded");
assert_eq!(down[&spec.id].attempts, 1);
assert!(
service
.inner
.stranded
.lock()
.unwrap()
.contains_key(&spec.id),
"the failed listener must stay stranded so it is retried"
);
assert!(matches!(
supervisor_rx.try_recv(),
Ok(LifecycleLog::Restarting { attempt: 1, .. })
));
assert!(matches!(
supervisor_rx.try_recv(),
Ok(LifecycleLog::RestartFailed { attempt: 1, .. })
));
supervise_once(&service, &mut down, due + Duration::from_secs(1)).await;
assert_eq!(down[&spec.id].attempts, 1);
tokio::time::timeout(
Duration::from_secs(60),
supervise_once(&service, &mut down, due + rebuild_backoff(1)),
)
.await
.expect("a rebuild attempt is bounded");
assert_eq!(down[&spec.id].attempts, 2);
}
#[tokio::test]
async fn removing_a_stranded_listener_stops_its_rebuilds() {
let (event_tx, _event_rx) = mpsc::unbounded_channel();
let service = start_empty_service(event_tx, tokio_util::sync::CancellationToken::new());
let spec = unreachable_spec();
service
.inner
.stranded
.lock()
.unwrap()
.insert(spec.id.clone(), spec.clone());
let mut down = HashMap::new();
let t0 = std::time::Instant::now();
supervise_once(&service, &mut down, t0).await;
service.remove_listener(&spec.id).await;
supervise_once(&service, &mut down, t0 + REBUILD_GRACE).await;
assert!(service.inner.stranded.lock().unwrap().is_empty());
assert!(down.is_empty(), "nothing left to track");
}
}
#[cfg(test)]
mod tsp_carriage_tests {
use super::{open_didcomm_envelope, tsp_document_to_message};
use affinidi_tdk::didcomm::Message;
fn document(type_uri: &str) -> serde_json::Value {
serde_json::json!({
"id": "urn:uuid:11111111-1111-4111-8111-111111111111",
"type": type_uri,
"issuer": "did:webvh:example.com:community",
"recipient": "did:key:zPersona",
"issuedAt": "2026-01-01T00:00:00Z",
"payload": { "status": "accepted" },
})
}
const REPLY_TYPE: &str = "https://trusttasks.org/spec/vtc/join-requests/submit/0.1#response";
fn to_message(payload: &[u8]) -> Option<affinidi_tdk::didcomm::Message> {
tsp_document_to_message(
payload,
Some("did:webvh:example.com:community"),
"did:key:zPersona",
"fallback-frame-hash",
)
}
#[test]
fn a_didcomm_envelope_opens_to_the_task_it_carries() {
const REPLY: &str = "https://trusttasks.org/spec/vtc/join-requests/submit/0.1#response";
let doc =
serde_json::json!({ "type": REPLY, "id": "urn:uuid:r", "threadId": "urn:uuid:q" });
let enveloped = Message::build(
"m1",
crate::capabilities::TRUST_TASK_ENVELOPE_TYPE.to_string(),
doc.clone(),
)
.from("did:key:zVtc".to_string())
.finalize();
let opened = open_didcomm_envelope(&enveloped).expect("an envelope opens");
assert_eq!(opened.typ, REPLY);
assert_eq!(opened.body, doc, "the handlers read the document unchanged");
assert_eq!(opened.id, "m1");
assert_eq!(
opened.thid.as_deref(),
Some("urn:uuid:q"),
"no DIDComm thid: the document's threadId correlates"
);
assert_eq!(opened.from.as_deref(), Some("did:key:zVtc"));
let threaded = Message::build(
"m2",
crate::capabilities::TRUST_TASK_ENVELOPE_TYPE.to_string(),
doc.clone(),
)
.thid("urn:uuid:msg".to_string())
.finalize();
assert_eq!(
open_didcomm_envelope(&threaded).unwrap().thid.as_deref(),
Some("urn:uuid:msg")
);
let bare = Message::build("m3", REPLY, doc).finalize();
assert!(open_didcomm_envelope(&bare).is_none());
let empty = Message::build(
"m4",
crate::capabilities::TRUST_TASK_ENVELOPE_TYPE.to_string(),
serde_json::json!({ "payload": {} }),
)
.finalize();
assert!(open_didcomm_envelope(&empty).is_none());
}
#[test]
fn a_binding_envelope_yields_the_task_type_not_the_bindings() {
let payload = vta_sdk::tsp_binding::wrap_envelope(
&serde_json::to_vec(&document(REPLY_TYPE)).unwrap(),
);
let msg = to_message(&payload).expect("an enveloped reply must map to a message");
assert_eq!(
msg.typ, REPLY_TYPE,
"routing must see the task type; `{}` means the envelope was never opened",
msg.typ
);
}
#[test]
fn a_bare_document_is_still_accepted() {
let payload = serde_json::to_vec(&document(REPLY_TYPE)).unwrap();
let msg = to_message(&payload).expect("a bare reply must still map to a message");
assert_eq!(msg.typ, REPLY_TYPE);
}
#[test]
fn the_two_carriages_agree() {
let doc = document(REPLY_TYPE);
let bare = serde_json::to_vec(&doc).unwrap();
let wrapped = vta_sdk::tsp_binding::wrap_envelope(&bare);
let from_bare = to_message(&bare).expect("bare maps");
let from_wrapped = to_message(&wrapped).expect("wrapped maps");
assert_eq!(from_bare.typ, from_wrapped.typ);
assert_eq!(from_bare.id, from_wrapped.id);
assert_eq!(from_bare.body, from_wrapped.body);
}
}
#[cfg(test)]
mod persona_listener_id_tests {
use super::{PERSONA_LISTENER_ID, persona_listener_id};
#[test]
fn the_id_is_the_did_verbatim() {
let did = "did:webvh:QmR6e4:webvh.storm.ws:magic-depart";
assert_eq!(persona_listener_id(did), did);
}
#[test]
fn personas_sharing_a_trailing_segment_get_distinct_ids() {
let a = persona_listener_id("did:webvh:ScidA:host1.example:magic-depart");
let b = persona_listener_id("did:webvh:ScidB:host2.example:magic-depart");
assert_ne!(a, b, "listener_id is an identity key and must not collide");
}
#[test]
fn an_empty_did_falls_back_to_the_generic_id() {
assert_eq!(persona_listener_id(""), PERSONA_LISTENER_ID);
}
}
#[cfg(test)]
mod tsp_inbound_tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::tsp_frame_to_message;
use affinidi_messaging_core::transport::{Inbound, InboundAck};
use affinidi_messaging_core::types::{Protocol, ReceivedMessage};
const VERDICT_TYPE: &str = "https://trusttasks.org/spec/vtc/join-requests/submit/0.1#response";
fn tsp_frame(payload: serde_json::Value, sender: Option<&str>) -> Inbound {
Inbound::new(
ReceivedMessage {
id: "frame-hash".to_string(),
sender: sender.map(str::to_string),
recipient: "did:webvh:example:alice".to_string(),
payload: serde_json::to_vec(&payload).unwrap(),
protocol: Protocol::TSP,
verified: true,
encrypted: true,
},
None,
InboundAck("frame-hash".to_string()),
)
}
fn verdict_document(thread_id: &str) -> serde_json::Value {
serde_json::json!({
"id": "urn:uuid:11111111-1111-4111-8111-111111111111",
"threadId": thread_id,
"type": VERDICT_TYPE,
"issuer": "did:webvh:example:vtc",
"recipient": "did:webvh:example:alice",
"payload": {
"requestId": "22222222-2222-4222-8222-222222222222",
"verdict": { "effect": "allow", "with": {} },
},
})
}
#[test]
fn verdict_document_normalises_into_a_message() {
let thid = "urn:uuid:33333333-3333-4333-8333-333333333333";
let doc = verdict_document(thid);
let message =
tsp_frame_to_message(&tsp_frame(doc.clone(), Some("did:webvh:example:vtc"))).unwrap();
assert_eq!(message.typ, VERDICT_TYPE, "type comes from the document");
assert_eq!(
message.thid.as_deref(),
Some(thid),
"threadId is the correlation key on this transport"
);
assert_eq!(
message.id, "urn:uuid:11111111-1111-4111-8111-111111111111",
"the document id, not the frame hash, when the document has one"
);
assert_eq!(
message.body, doc,
"body is the whole document — the same value tt_didcomm_reply sets, \
which is what lets the handlers read it unchanged"
);
assert_eq!(
message.from.as_deref(),
Some("did:webvh:example:vtc"),
"from is the TSP-authenticated sender"
);
}
#[test]
fn unauthenticated_frame_carries_no_sender() {
let doc = verdict_document("urn:uuid:44444444-4444-4444-8444-444444444444");
let message = tsp_frame_to_message(&tsp_frame(doc, None)).unwrap();
assert!(
message.from.is_none(),
"a frame with no proven sender must not inherit the document's issuer"
);
}
#[test]
fn document_without_a_type_is_dropped() {
let doc = serde_json::json!({ "id": "urn:uuid:5", "payload": {} });
assert!(tsp_frame_to_message(&tsp_frame(doc, Some("did:webvh:example:vtc"))).is_none());
}
#[test]
fn non_json_frame_is_dropped() {
let mut frame = tsp_frame(serde_json::json!({}), Some("did:webvh:example:vtc"));
frame.message.payload = b"not json at all".to_vec();
assert!(tsp_frame_to_message(&frame).is_none());
}
#[test]
fn frame_hash_is_the_id_fallback() {
let doc = serde_json::json!({ "type": VERDICT_TYPE, "payload": {} });
let message = tsp_frame_to_message(&tsp_frame(doc, Some("did:webvh:example:vtc"))).unwrap();
assert_eq!(message.id, "frame-hash");
}
}
#[cfg(test)]
mod catch_all_tests {
use super::OPENVTC_CATCH_ALL_PATTERN;
use regex::Regex;
fn matches(uri: &str) -> bool {
Regex::new(&format!("^(?:{OPENVTC_CATCH_ALL_PATTERN})$"))
.expect("catch-all pattern compiles")
.is_match(uri)
}
#[test]
fn canonical_vtc_trust_tasks_are_routed() {
for uri in [
"https://trusttasks.org/spec/vtc/join-requests/submit/0.1",
"https://trusttasks.org/spec/vtc/join-requests/submit/0.1#response",
"https://trusttasks.org/spec/vtc/join-requests/status/0.1#response",
"https://trusttasks.org/spec/vtc/members/self-remove/0.1",
] {
assert!(matches(uri), "{uri} must reach the OpenVTC handler");
}
}
#[test]
fn vetting_tasks_and_trust_task_errors_are_routed() {
for uri in [
"https://trusttasks.org/spec/vetting/request/0.1",
"https://trusttasks.org/spec/vetting/request/0.1#response",
"https://trusttasks.org/spec/vetting/session/0.1",
"https://trusttasks.org/spec/vetting/session/0.1#response",
"https://trusttasks.org/spec/vetting/decline/0.1",
"https://trusttasks.org/spec/vtc/vetting/revoke-statement/0.1#response",
"https://trusttasks.org/spec/vtc/join-requests/manifest/0.2#response",
"https://trusttasks.org/spec/trust-task-error/0.5",
] {
assert!(matches(uri), "{uri} must reach the OpenVTC handler");
}
}
#[test]
fn legacy_vtc_trust_tasks_still_route() {
for uri in [
"https://trusttasks.org/openvtc/vtc/spec/join-requests/submit/1.0",
"https://trusttasks.org/openvtc/vtc/members/self-remove/1.0",
] {
assert!(matches(uri), "{uri} must still reach the handler");
}
}
#[test]
fn the_other_arms_are_intact() {
for uri in [
"https://linuxfoundation.org/openvtc/anything",
"https://firstperson.network/protocols/x",
"https://trusttasks.org/spec/credential-exchange/offer/0.1",
"https://didcomm.org/report-problem/2.0/problem-report",
] {
assert!(matches(uri), "{uri} must reach the handler");
}
}
#[test]
fn the_binding_envelopes_are_routed() {
for uri in [
"https://trusttasks.org/binding/didcomm/0.1/envelope",
"https://trusttasks.org/binding/tsp/0.1/envelope",
] {
assert!(matches(uri), "{uri} must reach the OpenVTC handler");
}
}
#[test]
fn the_envelope_type_this_crate_sends_is_routed() {
assert!(
matches(crate::capabilities::TRUST_TASK_ENVELOPE_TYPE),
"the type we send must be one we accept back"
);
}
#[test]
fn unrelated_types_are_not_routed() {
for uri in [
"https://trusttasks.org/spec/acl/list/0.1",
"https://trusttasks.org/spec/policy/upsert/0.2",
"https://example.com/whatever",
] {
assert!(!matches(uri), "{uri} must NOT be routed here");
}
}
}