use std::sync::Arc;
use std::time::Duration;
use affinidi_messaging_didcomm::Message;
use affinidi_openid4vci::issuer::create_credential_response;
use affinidi_tdk::messaging::profiles::ATMProfile;
use affinidi_vc::VerifiableCredential;
use serde_json::Value as JsonValue;
use uuid::Uuid;
use vta_sdk::protocols::credential_exchange::{ISSUE as CREDENTIAL_ISSUE_TYPE, IssueBody};
use vti_common::error::AppError;
use crate::ceremony::AdmitOutcome;
use crate::server::AppState;
pub(crate) async fn deliver_membership_credentials(
state: &AppState,
holder_did: &str,
admit: &AdmitOutcome,
) -> Result<(), AppError> {
deliver_credentials(state, holder_did, &[&admit.vmc, &admit.role_vec]).await
}
pub(crate) async fn deliver_credentials(
state: &AppState,
holder_did: &str,
credentials: &[&VerifiableCredential],
) -> Result<(), AppError> {
for credential in credentials {
let credential_json = serde_json::to_value(credential)
.map_err(|e| AppError::Internal(format!("issued credential serialise: {e}")))?;
let body = issue_message_body(credential_json)?;
let msg_id = Uuid::new_v4().to_string();
push_to_holder(state, holder_did, &msg_id, CREDENTIAL_ISSUE_TYPE, body).await?;
}
Ok(())
}
fn issue_message_body(credential_json: JsonValue) -> Result<JsonValue, AppError> {
let issue = IssueBody {
credential_response: Some(create_credential_response(credential_json, None, None)),
sealed: None,
};
serde_json::to_value(&issue)
.map_err(|e| AppError::Internal(format!("issue body serialise: {e}")))
}
pub(crate) async fn push_to_holder(
state: &AppState,
holder_did: &str,
msg_id: &str,
msg_type: &str,
body: JsonValue,
) -> Result<(), AppError> {
let atm = state
.atm
.as_ref()
.ok_or_else(|| AppError::Internal("messaging (ATM) not configured".into()))?;
let (vtc_did, mediator_did) = {
let config = state.config.read().await;
let vtc_did = config
.vtc_did
.clone()
.ok_or_else(|| AppError::Internal("VTC DID not configured".into()))?;
let mediator_did = config
.messaging
.as_ref()
.map(|m| m.mediator_did.clone())
.ok_or_else(|| AppError::Internal("no mediator configured for messaging".into()))?;
(vtc_did, mediator_did)
};
let target_mediator = resolve_holder_mediator(state, holder_did)
.await
.unwrap_or_else(|| mediator_did.clone());
let profile = Arc::new(
ATMProfile::new(atm, None, vtc_did.clone(), Some(mediator_did.clone()))
.await
.map_err(|e| AppError::Internal(format!("ATM profile setup failed: {e}")))?,
);
let msg = Message::build(msg_id.to_string(), msg_type.to_string(), body)
.from(vtc_did.clone())
.to(holder_did.to_string())
.finalize();
send_with_retry(
atm,
&profile,
&msg,
msg_id,
holder_did,
&vtc_did,
&target_mediator,
)
.await
}
const DELIVERY_RETRY_BACKOFF: [Duration; 3] = [
Duration::from_millis(500),
Duration::from_secs(1),
Duration::from_secs(2),
];
async fn send_with_retry(
atm: &affinidi_tdk::messaging::ATM,
profile: &Arc<ATMProfile>,
msg: &Message,
msg_id: &str,
holder_did: &str,
vtc_did: &str,
target_mediator: &str,
) -> Result<(), AppError> {
let mut attempt = 0usize;
loop {
let result: Result<(), AppError> = async {
atm.profile_enable_websocket(profile)
.await
.map_err(|e| AppError::Internal(format!("mediator websocket failed: {e}")))?;
let (jwe, _meta) = atm
.pack_encrypted(msg, holder_did, Some(vtc_did), None)
.await
.map_err(|e| AppError::Internal(format!("pack_encrypted failed: {e}")))?;
atm.forward_and_send_message(
profile,
false,
&jwe,
Some(msg_id),
target_mediator,
holder_did,
None,
None,
false,
)
.await
.map_err(|e| AppError::Internal(format!("mediator forward failed: {e}")))?;
Ok(())
}
.await;
match result {
Ok(()) => return Ok(()),
Err(e) => match DELIVERY_RETRY_BACKOFF.get(attempt) {
Some(delay) => {
tracing::warn!(
holder_did,
msg_id,
attempt = attempt + 1,
error = %e,
"credential delivery send failed; retrying after backoff"
);
tokio::time::sleep(*delay).await;
attempt += 1;
}
None => return Err(e),
},
}
}
}
async fn resolve_holder_mediator(state: &AppState, holder_did: &str) -> Option<String> {
let resolver = state.did_resolver.as_ref()?;
let resolved = resolver.resolve(holder_did).await.ok()?;
for svc in &resolved.doc.service {
if svc.type_.iter().any(|t| t == "DIDCommMessaging")
&& let Some(mediator) = svc
.service_endpoint
.get_uris()
.into_iter()
.map(|u| u.trim_matches('"').to_string())
.find(|u| u.starts_with("did:"))
{
return Some(mediator);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn issue_message_body_matches_the_vta_receive_shape() {
let vmc = json!({
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "MembershipCredential"],
"issuer": "did:web:vtc.example",
"credentialSubject": { "id": "did:key:zHolder", "community": "acme" },
"proof": { "type": "DataIntegrityProof", "cryptosuite": "eddsa-jcs-2022" },
});
let body = issue_message_body(vmc.clone()).expect("wrap issue body");
let issue: IssueBody = serde_json::from_value(body).expect("parse as IssueBody");
assert!(
issue.sealed.is_none(),
"a proven holder gets authcrypt, not a seal"
);
let credential = issue
.credential_response
.expect("credential_response present")
.credential
.expect("credential present");
assert_eq!(
credential, vmc,
"the delivered credential round-trips intact"
);
}
}