use affinidi_openid4vci::issuer::create_credential_response;
use affinidi_vc::VerifiableCredential;
use serde_json::Value as JsonValue;
use vta_sdk::protocols::credential_exchange::{ISSUE as CREDENTIAL_ISSUE_TYPE, IssueBody};
use vti_common::capability_client::build_document;
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_vac]).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)?;
push_document(state, holder_did, CREDENTIAL_ISSUE_TYPE, body, Thread::New)
.await
.map(|_| ())
};
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) enum Thread<'a> {
New,
Root(&'a str),
Reply(&'a str),
}
pub(crate) async fn push_document(
state: &AppState,
holder_did: &str,
type_uri: &str,
payload: JsonValue,
thread: Thread<'_>,
) -> Result<String, 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 signer = state
.credential_signer
.as_ref()
.ok_or_else(|| AppError::Internal("credential signer not configured".into()))?;
let mut doc = build_document(&vtc_did, holder_did, type_uri, payload);
match thread {
Thread::New => {}
Thread::Root(id) => doc.id = id.to_string(),
Thread::Reply(thread_id) => doc.thread_id = Some(thread_id.to_string()),
}
let id = doc.id.clone();
doc.extra
.insert("idempotencyKey".to_string(), JsonValue::String(id.clone()));
let mut doc_value = serde_json::to_value(&doc)
.map_err(|e| AppError::Internal(format!("serialise {type_uri} document: {e}")))?;
signer.sign_operational_doc(&mut doc_value).await?;
crate::member_push::push_trust_task(
state,
holder_did,
doc_value,
crate::server::EXCHANGE_DELIVER_BY,
)
.await?;
Ok(id)
}
#[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 vac: VerifiableCredential = serde_json::from_value(json!({
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "AuthorityCredential"],
"issuer": "did:web:vtc.example",
"credentialSubject": { "id": "did:key:zHolder" },
}))
.expect("parse VAC");
let err = deliver_credentials(&tv.state, "did:key:zHolder", &[&vmc, &vac])
.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("AuthorityCredential"),
"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}"
);
}
}