use affinidi_vc::{CredentialBuilder, VerifiableCredential};
use chrono::{Duration, Utc};
use serde_json::{Map, Value as JsonValue, json};
use vti_common::error::AppError;
use crate::acl::VtcRole;
use super::LocalSigner;
use super::VEC_CONTEXT_URL;
pub const VEC_TYPE: &str = "VerifiableEndorsementCredential";
pub const COMMUNITY_ROLE_ENDORSEMENT_TYPE: &str = "CommunityRole";
pub const DEFAULT_ROLE_VEC_VALIDITY: Duration = Duration::days(30);
#[derive(Debug, Clone)]
pub struct RoleVecParams {
pub member_did: String,
pub id: Option<String>,
pub role: VtcRole,
pub validity: Duration,
}
impl RoleVecParams {
pub fn new(member_did: impl Into<String>, role: VtcRole) -> Self {
Self {
member_did: member_did.into(),
id: None,
role,
validity: DEFAULT_ROLE_VEC_VALIDITY,
}
}
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_role_vec(
signer: &LocalSigner,
params: RoleVecParams,
) -> Result<VerifiableCredential, AppError> {
let now = Utc::now();
let valid_until = now + params.validity;
let endorsement = json!({
"type": COMMUNITY_ROLE_ENDORSEMENT_TYPE,
"role": params.role.to_string(),
"communityDid": signer.issuer_did(),
});
let mut subject = Map::new();
subject.insert("id".into(), JsonValue::String(params.member_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!("VEC build: {e}")))?;
if let Some(id) = ¶ms.id {
attach_top_level_id(&mut vc, id)?;
}
signer.sign(&mut vc).await?;
Ok(vc)
}
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!("VEC -> value: {e}")))?;
as_value
.as_object_mut()
.ok_or_else(|| AppError::Internal("VEC 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 -> VEC: {e}")))?;
Ok(())
}
fn rfc3339(t: chrono::DateTime<Utc>) -> String {
t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
#[cfg(test)]
mod tests {
use super::*;
use affinidi_vc::SubjectValue;
const TEST_VTC_DID: &str = "did:webvh:vtc.example.com:abc";
const MEMBER_DID: &str = "did:key:zMember1";
fn signer() -> LocalSigner {
LocalSigner::from_ed25519_seed(TEST_VTC_DID.into(), &[0xBB; 32])
}
fn subject_map(vc: &VerifiableCredential) -> Map<String, JsonValue> {
match &vc.credential_subject {
SubjectValue::Single(m) => m.clone(),
SubjectValue::Multiple(v) => v[0].clone(),
}
}
#[tokio::test]
async fn role_vec_round_trips_for_each_standard_role() {
let signer = signer();
let cases = [
(VtcRole::Admin, "admin"),
(VtcRole::Moderator, "moderator"),
(VtcRole::Issuer, "issuer"),
(VtcRole::Member, "member"),
(VtcRole::Custom("editor".into()), "custom:editor"),
];
for (role, expected_wire) in cases {
let vc = build_role_vec(&signer, RoleVecParams::new(MEMBER_DID, role.clone()))
.await
.unwrap_or_else(|e| panic!("build VEC for {role:?}: {e:?}"));
assert!(vc.types.iter().any(|t| t == VEC_TYPE));
let subj = subject_map(&vc);
let endorsement = &subj["endorsement"];
assert_eq!(endorsement["type"], COMMUNITY_ROLE_ENDORSEMENT_TYPE);
assert_eq!(endorsement["role"], expected_wire);
assert_eq!(endorsement["communityDid"], TEST_VTC_DID);
assert_eq!(subj["id"], MEMBER_DID);
signer
.verify(&vc)
.unwrap_or_else(|e| panic!("VEC proof must verify for {role:?}: {e:?}"));
}
}
#[tokio::test]
async fn role_vec_tampered_role_invalidates_proof() {
let signer = signer();
let mut vc = build_role_vec(&signer, RoleVecParams::new(MEMBER_DID, VtcRole::Member))
.await
.unwrap();
let mut as_value = serde_json::to_value(&vc).unwrap();
as_value["credentialSubject"]["endorsement"]["role"] = JsonValue::String("admin".into());
vc = serde_json::from_value(as_value).unwrap();
let err = signer.verify(&vc).expect_err("tampered VEC must fail");
assert!(
matches!(err, AppError::Forbidden(_)),
"expected Forbidden, got {err:?}"
);
}
}