use affinidi_messaging_didcomm::Message;
use affinidi_openid4vci::issuer::create_credential_response;
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> {
let mut failures: Vec<String> = Vec::new();
for (index, credential) in credentials.iter().enumerate() {
let push = async {
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
};
if let Err(e) = push.await {
let kind = credential_kind(credential);
tracing::warn!(
holder = %holder_did,
credential = %kind,
error = %e,
"credential delivery failed; continuing with the rest"
);
failures.push(format!("{kind} (#{}): {e}", index + 1));
}
}
if failures.is_empty() {
return Ok(());
}
Err(AppError::Internal(format!(
"{} of {} credential(s) failed to deliver to {holder_did}: {}",
failures.len(),
credentials.len(),
failures.join("; ")
)))
}
fn credential_kind(credential: &VerifiableCredential) -> String {
serde_json::to_value(credential)
.ok()
.and_then(|v| {
v.get("type").and_then(|t| t.as_array()).and_then(|types| {
types
.iter()
.filter_map(|t| t.as_str())
.find(|t| *t != "VerifiableCredential")
.map(str::to_string)
})
})
.unwrap_or_else(|| "credential".to_string())
}
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 vtc_did = state
.config
.read()
.await
.vtc_did
.clone()
.filter(|d| !d.is_empty())
.ok_or_else(|| AppError::Internal("VTC DID not configured".into()))?;
let message = Message::build(msg_id.to_string(), msg_type.to_string(), body)
.from(vtc_did)
.to(holder_did.to_string())
.finalize();
state.send_to_member(holder_did, message).await
}
#[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"
);
}
#[test]
fn credential_kind_names_the_specific_type() {
let vmc: VerifiableCredential = serde_json::from_value(json!({
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "MembershipCredential"],
"issuer": "did:web:vtc.example",
"credentialSubject": { "id": "did:key:zHolder" },
}))
.expect("parse VMC");
assert_eq!(credential_kind(&vmc), "MembershipCredential");
}
#[tokio::test]
async fn a_failed_credential_does_not_abandon_the_rest() {
let tv = crate::test_support::build_test_vtc().await;
let vmc: VerifiableCredential = serde_json::from_value(json!({
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "MembershipCredential"],
"issuer": "did:web:vtc.example",
"credentialSubject": { "id": "did:key:zHolder" },
}))
.expect("parse VMC");
let vec_: VerifiableCredential = serde_json::from_value(json!({
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "EndorsementCredential"],
"issuer": "did:web:vtc.example",
"credentialSubject": { "id": "did:key:zHolder" },
}))
.expect("parse VEC");
let err = deliver_credentials(&tv.state, "did:key:zHolder", &[&vmc, &vec_])
.await
.expect_err("messaging is not running, so both pushes fail");
let msg = err.to_string();
assert!(
msg.contains("MembershipCredential"),
"the first credential must be named: {msg}"
);
assert!(
msg.contains("EndorsementCredential"),
"the second must be attempted too — naming only the first is the \
short-circuit this test exists to catch: {msg}"
);
assert!(
msg.contains("2 of 2"),
"the summary should say how many of how many failed: {msg}"
);
}
}