use affinidi_vc::{CredentialBuilder, VerifiableCredential};
use chrono::{Duration, Utc};
use serde_json::{Map, Value as JsonValue, json};
use vti_common::error::AppError;
use super::LocalSigner;
use super::VEC_CONTEXT_URL;
use super::vec::VEC_TYPE;
use super::vmc::CredentialStatusRef;
pub const CLAIM_MAX_BYTES: usize = 8 * 1024;
pub const DEFAULT_CUSTOM_ENDORSEMENT_VALIDITY: Duration = Duration::days(30);
#[derive(Debug, Clone)]
pub struct CustomEndorsementParams {
pub subject_did: String,
pub endorsement_type: String,
pub claim: JsonValue,
pub id: Option<String>,
pub validity: Duration,
pub status_ref: CredentialStatusRef,
}
impl CustomEndorsementParams {
pub fn new(
subject_did: impl Into<String>,
endorsement_type: impl Into<String>,
claim: JsonValue,
status_ref: CredentialStatusRef,
) -> Self {
Self {
subject_did: subject_did.into(),
endorsement_type: endorsement_type.into(),
claim,
id: None,
validity: DEFAULT_CUSTOM_ENDORSEMENT_VALIDITY,
status_ref,
}
}
pub fn with_id(mut self, id: impl Into<String>) -> Self {
self.id = Some(id.into());
self
}
pub fn with_validity(mut self, validity: Duration) -> Self {
self.validity = validity;
self
}
}
pub async fn build_custom_endorsement(
signer: &LocalSigner,
params: CustomEndorsementParams,
) -> Result<VerifiableCredential, AppError> {
if params.endorsement_type.trim().is_empty() {
return Err(AppError::Validation(
"endorsement.type cannot be empty".into(),
));
}
if !params.claim.is_object() {
return Err(AppError::Validation(
"endorsement.claim must be a JSON object".into(),
));
}
let claim_bytes = serde_json::to_vec(¶ms.claim)
.map_err(|e| AppError::Internal(format!("serialise claim: {e}")))?;
if claim_bytes.len() > CLAIM_MAX_BYTES {
return Err(AppError::Validation(format!(
"endorsement.claim exceeds {CLAIM_MAX_BYTES} bytes (got {})",
claim_bytes.len()
)));
}
let now = Utc::now();
let valid_until = now + params.validity;
let endorsement = json!({
"type": params.endorsement_type,
"claim": params.claim,
"communityDid": signer.issuer_did(),
});
let mut subject = Map::new();
subject.insert("id".into(), JsonValue::String(params.subject_did.clone()));
subject.insert("endorsement".into(), endorsement);
let mut vc = CredentialBuilder::v2()
.context(VEC_CONTEXT_URL)
.issuer_uri(signer.issuer_did().to_string())
.add_type(VEC_TYPE)
.valid_from(rfc3339(now))
.valid_until(rfc3339(valid_until))
.subject(subject)
.build()
.map_err(|e| AppError::Internal(format!("custom endorsement build: {e}")))?;
if let Some(id) = ¶ms.id {
attach_top_level_id(&mut vc, id)?;
}
attach_credential_status(&mut vc, ¶ms.status_ref)?;
signer.sign(&mut vc).await?;
Ok(vc)
}
fn rfc3339(t: chrono::DateTime<Utc>) -> String {
t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
fn attach_top_level_id(vc: &mut VerifiableCredential, id: &str) -> Result<(), AppError> {
let mut as_value = serde_json::to_value(&*vc)
.map_err(|e| AppError::Internal(format!("custom endorsement -> value: {e}")))?;
as_value
.as_object_mut()
.ok_or_else(|| AppError::Internal("VC not an object".into()))?
.insert("id".into(), JsonValue::String(id.into()));
*vc = serde_json::from_value(as_value)
.map_err(|e| AppError::Internal(format!("value -> VC: {e}")))?;
Ok(())
}
fn attach_credential_status(
vc: &mut VerifiableCredential,
status_ref: &CredentialStatusRef,
) -> Result<(), AppError> {
let mut as_value = serde_json::to_value(&*vc)
.map_err(|e| AppError::Internal(format!("custom endorsement -> value: {e}")))?;
as_value
.as_object_mut()
.ok_or_else(|| AppError::Internal("VC not an object".into()))?
.insert(
"credentialStatus".into(),
serde_json::to_value(status_ref)
.map_err(|e| AppError::Internal(format!("status_ref -> value: {e}")))?,
);
*vc = serde_json::from_value(as_value)
.map_err(|e| AppError::Internal(format!("value -> VC: {e}")))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use affinidi_vc::SubjectValue;
const TEST_VTC_DID: &str = "did:webvh:vtc.example.com:abc";
fn signer() -> LocalSigner {
LocalSigner::from_ed25519_seed(TEST_VTC_DID.into(), &[0xBB; 32])
}
fn status_ref(idx: u32) -> CredentialStatusRef {
CredentialStatusRef::revocation(format!("{TEST_VTC_DID}#revocation"), idx)
}
#[tokio::test]
async fn builds_signs_and_verifies() {
let signer = signer();
let params = CustomEndorsementParams::new(
"did:key:zSubject",
"https://example.com/v1/skills/rust",
json!({ "level": "expert", "since": "2020" }),
status_ref(42),
)
.with_id("urn:uuid:11111111-1111-1111-1111-111111111111");
let vc = build_custom_endorsement(&signer, params).await.unwrap();
assert!(vc.types.iter().any(|t| t == "VerifiableCredential"));
assert!(vc.types.iter().any(|t| t == VEC_TYPE));
let subj = match &vc.credential_subject {
SubjectValue::Single(m) => m.clone(),
SubjectValue::Multiple(v) => v[0].clone(),
};
let endorsement = subj.get("endorsement").unwrap();
assert_eq!(endorsement["type"], "https://example.com/v1/skills/rust");
assert_eq!(endorsement["claim"]["level"], "expert");
assert_eq!(endorsement["communityDid"], TEST_VTC_DID);
signer.verify(&vc).unwrap();
}
#[tokio::test]
async fn rejects_empty_type() {
let signer = signer();
let params = CustomEndorsementParams::new("did:key:zS", "", json!({}), status_ref(0));
assert!(build_custom_endorsement(&signer, params).await.is_err());
}
#[tokio::test]
async fn rejects_non_object_claim() {
let signer = signer();
let params = CustomEndorsementParams::new(
"did:key:zS",
"https://x/t",
json!("not an object"),
status_ref(0),
);
assert!(build_custom_endorsement(&signer, params).await.is_err());
}
#[tokio::test]
async fn rejects_oversized_claim() {
let signer = signer();
let big_value = "x".repeat(10 * 1024); let params = CustomEndorsementParams::new(
"did:key:zS",
"https://x/t",
json!({ "blob": big_value }),
status_ref(0),
);
let err = build_custom_endorsement(&signer, params).await;
assert!(err.is_err(), "oversized claim must reject");
}
}