use std::time::Duration;
use affinidi_did_resolver_cache_sdk::{DIDCacheClient, config::DIDCacheConfigBuilder};
use affinidi_tdk_common::profiles::TDKProfile;
use tokio_util::sync::CancellationToken;
use tracing::{info, warn};
use super::error::AppError;
use super::identity::ServiceIdentity;
pub async fn resolve_mediator_did(
peer_did: &str,
did_resolver: Option<&DIDCacheClient>,
) -> Option<String> {
let owned;
let resolver = match did_resolver {
Some(r) => r,
None => match DIDCacheClient::new(DIDCacheConfigBuilder::default().build()).await {
Ok(r) => {
owned = r;
&owned
}
Err(e) => {
warn!("failed to create DID resolver for mediator discovery: {e}");
return None;
}
},
};
let doc = match resolver.resolve(peer_did).await {
Ok(response) => response.doc,
Err(e) => {
warn!("failed to resolve {peer_did} for mediator discovery: {e}");
return None;
}
};
let mediator = doc
.service
.iter()
.find(|s| s.type_.iter().any(|t| t == "DIDCommMessaging"))
.and_then(|s| s.service_endpoint.get_uri())
.map(|uri| uri.trim_matches('"').to_string())?;
info!(peer = peer_did, mediator = %mediator, "discovered mediator from DID document");
Some(mediator)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PeerTransport {
Tsp,
Didcomm,
Https,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ObservedTransport {
Tsp,
Didcomm,
Https,
}
impl ObservedTransport {
pub fn from_binding_uri(binding_uri: &str) -> Option<Self> {
match binding_uri {
crate::server::trust_tasks::transport::TSP_BINDING_URI => Some(Self::Tsp),
trust_tasks_didcomm::BINDING_URI => Some(Self::Didcomm),
trust_tasks_https::BINDING_URI => Some(Self::Https),
_ => None,
}
}
}
impl From<PeerTransport> for ObservedTransport {
fn from(t: PeerTransport) -> Self {
match t {
PeerTransport::Tsp => Self::Tsp,
PeerTransport::Didcomm => Self::Didcomm,
PeerTransport::Https => Self::Https,
}
}
}
pub async fn resolve_service_types(
peer_did: &str,
did_resolver: Option<&DIDCacheClient>,
) -> Option<Vec<String>> {
let owned;
let resolver = match did_resolver {
Some(r) => r,
None => match DIDCacheClient::new(DIDCacheConfigBuilder::default().build()).await {
Ok(r) => {
owned = r;
&owned
}
Err(e) => {
warn!("failed to create DID resolver for service discovery: {e}");
return None;
}
},
};
let doc = match resolver.resolve(peer_did).await {
Ok(response) => response.doc,
Err(e) => {
warn!("failed to resolve {peer_did} for service discovery: {e}");
return None;
}
};
let mut out: Vec<String> = Vec::new();
for svc in &doc.service {
let implicit = svc
.id
.as_ref()
.is_some_and(|id| crate::did::is_implicit_webvh_service(id.as_str()));
if implicit {
continue;
}
for t in &svc.type_ {
if !t.is_empty() && !out.iter().any(|seen| seen == t) {
out.push(t.clone());
}
}
}
Some(out)
}
pub async fn resolve_transport(
peer_did: &str,
did_resolver: Option<&DIDCacheClient>,
) -> Option<(PeerTransport, String)> {
let owned;
let resolver = match did_resolver {
Some(r) => r,
None => match DIDCacheClient::new(DIDCacheConfigBuilder::default().build()).await {
Ok(r) => {
owned = r;
&owned
}
Err(e) => {
warn!("failed to create DID resolver for transport discovery: {e}");
return None;
}
},
};
let doc = match resolver.resolve(peer_did).await {
Ok(response) => response.doc,
Err(e) => {
warn!("failed to resolve {peer_did} for transport discovery: {e}");
return None;
}
};
let find_uri = |type_name: &str| {
doc.service
.iter()
.find(|s| s.type_.iter().any(|t| t == type_name))
.and_then(|s| s.service_endpoint.get_uri())
.map(|uri| uri.trim_matches('"').to_string())
};
if let Some(tsp) = find_uri("TSPTransport") {
info!(peer = peer_did, mediator = %tsp, "peer advertises TSPTransport — preferring TSP");
return Some((PeerTransport::Tsp, tsp));
}
if let Some(didcomm) = find_uri("DIDCommMessaging") {
info!(peer = peer_did, mediator = %didcomm, "peer advertises DIDCommMessaging — using DIDComm");
return Some((PeerTransport::Didcomm, didcomm));
}
if let Some(https) = find_uri("TrustTaskHTTPS") {
if crate::did::is_https_or_loopback(&https) {
info!(peer = peer_did, endpoint = %https, "peer advertises TrustTaskHTTPS — using HTTPS");
return Some((PeerTransport::Https, https));
}
warn!(
peer = peer_did,
endpoint = %https,
"peer advertises TrustTaskHTTPS at a non-https, non-loopback endpoint — refusing to \
route trust tasks there"
);
}
None
}
#[derive(Debug, Clone, Default)]
pub struct TransportFallback {
pub binding: Option<(PeerTransport, String)>,
}
impl TransportFallback {
pub fn from_config(mediator_did: Option<&str>, prefer_tsp: bool) -> Self {
let binding = mediator_did.map(|m| {
let t = if prefer_tsp {
PeerTransport::Tsp
} else {
PeerTransport::Didcomm
};
(t, m.to_string())
});
Self { binding }
}
}
pub async fn resolve_send_binding(
peer_did: &str,
fallback: &TransportFallback,
did_resolver: Option<&DIDCacheClient>,
) -> Option<(PeerTransport, String)> {
decide_binding(
peer_did,
resolve_transport(peer_did, did_resolver).await,
fallback,
)
}
fn decide_binding(
peer_did: &str,
from_doc: Option<(PeerTransport, String)>,
fallback: &TransportFallback,
) -> Option<(PeerTransport, String)> {
if let Some((transport, mediator)) = from_doc {
info!(peer = peer_did, ?transport, mediator = %mediator, "send-binding: using document-advertised transport");
return Some((transport, mediator));
}
if let Some((transport, mediator)) = &fallback.binding {
info!(
peer = peer_did,
?transport,
mediator = %mediator,
"send-binding: document silent — using configured mediator fallback"
);
return Some((*transport, mediator.clone()));
}
warn!(
peer = peer_did,
"send-binding: peer advertises no messaging service and no mediator is \
configured — cannot route (peer is unreachable for trust-task push)"
);
None
}
pub async fn advertised_protocols(
did: &str,
did_resolver: Option<&DIDCacheClient>,
) -> Option<crate::server::identity::ProtocolSet> {
let owned;
let resolver = match did_resolver {
Some(r) => r,
None => match DIDCacheClient::new(DIDCacheConfigBuilder::default().build()).await {
Ok(r) => {
owned = r;
&owned
}
Err(e) => {
warn!("failed to create DID resolver for advertised-transport check: {e}");
return None;
}
},
};
let doc = match resolver.resolve(did).await {
Ok(response) => response.doc,
Err(e) => {
warn!("failed to resolve {did} for advertised-transport check: {e}");
return None;
}
};
let advertises = |type_name: &str| {
doc.service
.iter()
.any(|s| s.type_.iter().any(|t| t == type_name))
};
Some(crate::server::identity::ProtocolSet {
didcomm: advertises("DIDCommMessaging"),
tsp: advertises("TSPTransport"),
})
}
pub fn reconcile_listener_protocols(
config: crate::server::identity::ProtocolSet,
advertised: Option<crate::server::identity::ProtocolSet>,
did: &str,
) -> crate::server::identity::ProtocolSet {
let Some(advertised) = advertised else {
warn!(
did = %did,
"could not resolve own DID document — deriving listener transports from config \
only; if the published DID advertises a transport this node has disabled, peers' \
messages on it will be dropped"
);
return config;
};
if advertised.tsp && !config.tsp {
warn!(
did = %did,
"DID document advertises TSPTransport but features.tsp is off — enabling the TSP \
listener to match the document (set features.tsp = true to silence this)"
);
}
if advertised.didcomm && !config.didcomm {
warn!(
did = %did,
"DID document advertises DIDCommMessaging but features.didcomm is off — enabling \
the DIDComm listener to match the document"
);
}
config.union(advertised)
}
pub async fn wait_for_did_resolution(
did: &str,
label: &str,
did_resolver: &DIDCacheClient,
shutdown: &CancellationToken,
) -> Result<(), AppError> {
const INITIAL_BACKOFF_SECS: u64 = 2;
const MAX_BACKOFF_SECS: u64 = 60;
let mut backoff_secs = INITIAL_BACKOFF_SECS;
let mut attempt: u32 = 0;
loop {
attempt += 1;
match did_resolver.resolve(did).await {
Ok(_) => {
if attempt == 1 {
info!(did, label, "DID resolved");
} else {
info!(did, label, attempt, "DID resolved after retries");
}
return Ok(());
}
Err(e) => {
warn!(
did,
label,
attempt,
error = %e,
"DID not yet resolvable — retrying in {backoff_secs}s"
);
}
}
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(backoff_secs)) => {}
_ = shutdown.cancelled() => {
return Err(AppError::Internal(format!(
"shutdown signalled while waiting for {label} DID {did} to resolve"
)));
}
}
backoff_secs = (backoff_secs.saturating_mul(2)).min(MAX_BACKOFF_SECS);
}
}
pub async fn build_tdk_profile_for_identity(
alias: &str,
identity: &ServiceIdentity,
peer_did: Option<&str>,
) -> Result<TDKProfile, AppError> {
let mediator_did = discover_mediator(peer_did, Some(&identity.did_resolver)).await;
Ok(TDKProfile::new(
alias,
&identity.did,
mediator_did.as_deref(),
identity.secrets(),
))
}
async fn discover_mediator(
peer_did: Option<&str>,
did_resolver: Option<&DIDCacheClient>,
) -> Option<String> {
let peer = peer_did?;
match resolve_mediator_did(peer, did_resolver).await {
Some(mediator) if mediator.starts_with("did:") => Some(mediator),
Some(_url) => {
info!("peer {peer} is a mediator (endpoint is a URL) — using it directly");
Some(peer.to_string())
}
None => {
warn!(
"could not discover mediator from {peer} — \
falling back to using it directly as mediator"
);
Some(peer.to_string())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const PEER: &str = "did:webvh:Qm:peer.example";
const DOC_MED: &str = "did:web:mediator.doc.example";
const CFG_MED: &str = "did:web:mediator.config.example";
fn cfg(mediator: Option<&str>, prefer_tsp: bool) -> TransportFallback {
TransportFallback::from_config(mediator, prefer_tsp)
}
#[test]
fn https_binding_from_document_wins_over_config_like_the_others() {
let https = decide_binding(
PEER,
Some((PeerTransport::Https, "https://peer.example/api".into())),
&cfg(Some(CFG_MED), true),
);
assert_eq!(
https,
Some((PeerTransport::Https, "https://peer.example/api".into()))
);
}
#[test]
fn https_observed_transport_round_trips() {
assert_eq!(
ObservedTransport::from(PeerTransport::Https),
ObservedTransport::Https
);
}
#[test]
fn tier1_document_transport_wins_over_config() {
let tsp = decide_binding(
PEER,
Some((PeerTransport::Tsp, DOC_MED.into())),
&cfg(Some(CFG_MED), false),
);
assert_eq!(tsp, Some((PeerTransport::Tsp, DOC_MED.into())));
let didcomm = decide_binding(
PEER,
Some((PeerTransport::Didcomm, DOC_MED.into())),
&cfg(Some(CFG_MED), true),
);
assert_eq!(didcomm, Some((PeerTransport::Didcomm, DOC_MED.into())));
}
#[test]
fn tier2_config_fallback_used_when_document_silent() {
let tsp = decide_binding(PEER, None, &cfg(Some(CFG_MED), true));
assert_eq!(tsp, Some((PeerTransport::Tsp, CFG_MED.into())));
let didcomm = decide_binding(PEER, None, &cfg(Some(CFG_MED), false));
assert_eq!(didcomm, Some((PeerTransport::Didcomm, CFG_MED.into())));
}
#[test]
fn tier3_none_when_nothing_routes() {
let got = decide_binding(PEER, None, &cfg(None, false));
assert_eq!(got, None);
}
#[test]
fn from_config_empty_without_mediator() {
assert!(TransportFallback::from_config(None, true).binding.is_none());
let f = TransportFallback::from_config(Some(CFG_MED), true);
assert_eq!(f.binding, Some((PeerTransport::Tsp, CFG_MED.to_string())));
let f = TransportFallback::from_config(Some(CFG_MED), false);
assert_eq!(
f.binding,
Some((PeerTransport::Didcomm, CFG_MED.to_string()))
);
}
fn pset(didcomm: bool, tsp: bool) -> crate::server::identity::ProtocolSet {
crate::server::identity::ProtocolSet { didcomm, tsp }
}
#[test]
fn reconcile_enables_advertised_transport_config_left_off() {
let got = reconcile_listener_protocols(pset(true, false), Some(pset(true, true)), "did:x");
assert_eq!(got, pset(true, true));
}
#[test]
fn reconcile_keeps_config_transports_not_advertised() {
let got = reconcile_listener_protocols(pset(true, true), Some(pset(true, false)), "did:x");
assert_eq!(got, pset(true, true));
}
#[test]
fn reconcile_falls_back_to_config_when_document_unresolvable() {
let cfg = pset(true, false);
assert_eq!(reconcile_listener_protocols(cfg, None, "did:x"), cfg);
}
}