use serde::{Deserialize, Serialize};
use serde_json::Value;
use tracing::warn;
use vta_sdk::client::VtaClient;
use crate::errors::OpenVTCError;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RebuiltPersona {
pub did: String,
pub context_id: String,
pub mediator_did: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RebuiltMembership {
pub vtc_did: String,
pub persona_did: String,
pub credential: Value,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RejectionReason {
NoIssuer,
NoSubject,
SubjectNotOurs {
subject: String,
},
Expired {
valid_until: String,
},
MalformedValidity {
valid_until: String,
},
Nonconformant {
reason: String,
},
Unverified {
reason: String,
},
}
impl RejectionReason {
#[must_use]
pub fn summary(&self) -> String {
match self {
RejectionReason::NoIssuer => "it names no issuing community".to_string(),
RejectionReason::NoSubject => "it names no subject".to_string(),
RejectionReason::SubjectNotOurs { subject } => {
format!("it was issued to {subject}, which is not a persona in this context")
}
RejectionReason::Expired { valid_until } => format!("it expired on {valid_until}"),
RejectionReason::MalformedValidity { valid_until } => {
format!("its validity window is unreadable ({valid_until})")
}
RejectionReason::Nonconformant { reason } => {
format!("it pre-dates DTG Credentials v1 and must be re-issued ({reason})")
}
RejectionReason::Unverified { reason } => {
format!("its signature could not be verified: {reason}")
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RejectedCredential {
pub id: Option<String>,
pub reason: RejectionReason,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RebuildPlan {
pub top_context_id: String,
pub personas: Vec<RebuiltPersona>,
pub memberships: Vec<RebuiltMembership>,
pub rejected: Vec<RejectedCredential>,
pub other_credential_count: usize,
}
impl RebuildPlan {
#[must_use]
pub fn is_empty(&self) -> bool {
self.personas.is_empty() && self.memberships.is_empty()
}
#[must_use]
pub fn summary(&self) -> String {
fn plural(n: usize, one: &str, many: &str) -> String {
format!("{n} {}", if n == 1 { one } else { many })
}
let mut parts = vec![plural(self.personas.len(), "persona", "personas")];
if !self.memberships.is_empty() {
parts.push(plural(self.memberships.len(), "membership", "memberships"));
}
if self.other_credential_count > 0 {
parts.push(plural(
self.other_credential_count,
"other credential",
"other credentials",
));
}
parts.join(", ")
}
#[must_use]
pub fn known_gaps() -> &'static [&'static str] {
&[
"Community names, favourites and archive flags",
"Relationships and their peer DIDs",
"Contacts and the names you gave them",
"Activity history and pending join requests",
]
}
}
fn issuer_of(credential: &Value) -> Option<&str> {
match credential.get("issuer")? {
Value::String(s) => Some(s.as_str()),
Value::Object(o) => o.get("id").and_then(Value::as_str),
_ => None,
}
}
fn validity(credential: &Value, now: chrono::DateTime<chrono::Utc>) -> Result<(), RejectionReason> {
let Some(raw) = credential.get("validUntil").and_then(Value::as_str) else {
return Ok(());
};
match chrono::DateTime::parse_from_rfc3339(raw) {
Ok(expiry) if expiry.with_timezone(&chrono::Utc) < now => Err(RejectionReason::Expired {
valid_until: raw.to_string(),
}),
Ok(_) => Ok(()),
Err(_) => Err(RejectionReason::MalformedValidity {
valid_until: raw.to_string(),
}),
}
}
pub fn membership_from_credential(
credential: &Value,
known_personas: &[String],
now: chrono::DateTime<chrono::Utc>,
) -> Result<RebuiltMembership, RejectionReason> {
let issuer = issuer_of(credential).ok_or(RejectionReason::NoIssuer)?;
let subject = credential
.get("credentialSubject")
.and_then(|s| s.get("id"))
.and_then(Value::as_str)
.ok_or(RejectionReason::NoSubject)?;
if !known_personas.iter().any(|p| p == subject) {
return Err(RejectionReason::SubjectNotOurs {
subject: subject.to_string(),
});
}
validity(credential, now)?;
match crate::dtg::parse_conformant(credential) {
Ok(parsed) if parsed.type_() == dtg_credentials::DTGCredentialType::Membership => {}
Ok(_) => {
return Err(RejectionReason::Nonconformant {
reason: "not a MembershipCredential".to_string(),
});
}
Err(reason) => return Err(RejectionReason::Nonconformant { reason }),
}
Ok(RebuiltMembership {
vtc_did: issuer.to_string(),
persona_did: subject.to_string(),
credential: credential.clone(),
})
}
pub async fn plan(
client: &VtaClient,
context_id: &str,
now: chrono::DateTime<chrono::Utc>,
) -> Result<RebuildPlan, OpenVTCError> {
let dids = client
.list_dids_webvh(Some(context_id), None)
.await
.map_err(|e| OpenVTCError::Vta(format!("could not list DIDs in {context_id}: {e}")))?
.dids;
let personas: Vec<RebuiltPersona> = dids
.into_iter()
.map(|d| RebuiltPersona {
did: d.did,
context_id: d.context_id,
mediator_did: None,
})
.collect();
let known: Vec<String> = personas.iter().map(|p| p.did.clone()).collect();
let mut memberships = Vec::new();
let mut rejected = Vec::new();
let descriptors = match client.cred_vault_query(membership_query_filter()).await {
Ok(listing) => descriptors_in(&listing),
Err(e) => {
warn!("credential vault not queryable during rebuild: {e}");
Vec::new()
}
};
let resolver = if descriptors.is_empty() {
Err("no DID resolver was needed".to_string())
} else {
affinidi_did_resolver_cache_sdk::DIDCacheClient::new(
affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder::default().build(),
)
.await
.map_err(|e| {
warn!("could not start a DID resolver for rebuild: {e}");
"no DID resolver was available to check it".to_string()
})
};
for id in descriptors {
let credential = match client.cred_vault_get(&id).await {
Ok(v) => v.get("credential").cloned().unwrap_or(v),
Err(e) => {
warn!(id = %id, "could not fetch a held credential during rebuild: {e}");
continue;
}
};
let checked = match membership_from_credential(&credential, &known, now) {
Ok(m) => match &resolver {
Ok(resolver) => crate::issued_credential::verify_issued_credential(
credential.clone(),
&m.vtc_did,
resolver,
now,
)
.await
.map(|_| m)
.map_err(|e| RejectionReason::Unverified {
reason: e.to_string(),
}),
Err(reason) => Err(RejectionReason::Unverified {
reason: reason.clone(),
}),
},
Err(reason) => Err(reason),
};
match checked {
Ok(m) => memberships.push(m),
Err(reason) => {
warn!(
reason = %reason.summary(),
"membership credential did not verify during rebuild"
);
rejected.push(RejectedCredential {
id: Some(id),
reason,
});
}
}
}
Ok(RebuildPlan {
top_context_id: context_id.to_string(),
personas,
memberships,
rejected,
other_credential_count: 0,
})
}
fn membership_query_filter() -> Value {
serde_json::json!({ "purpose": "membership" })
}
fn descriptors_in(listing: &Value) -> Vec<String> {
let array = if let Some(arr) = listing.as_array() {
arr.clone()
} else {
["credentials", "items", "results"]
.iter()
.find_map(|k| listing.get(*k).and_then(Value::as_array))
.cloned()
.unwrap_or_default()
};
array
.into_iter()
.filter_map(|d| {
d.get("id")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| d.as_str().map(str::to_string))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, Utc};
const VTC: &str = "did:webvh:QmV:vtc.example.com:acme";
const ALICE: &str = "did:webvh:QmA:example.com:alice";
const BOB: &str = "did:webvh:QmB:example.com:bob";
fn vmc(issuer: Value, subject: Option<&str>) -> Value {
let mut vc = serde_json::json!({
"@context": [dtg_credentials::W3C_VC_V2_CONTEXT, dtg_credentials::DTG_CONTEXT_V1],
"id": "vmc-1",
"type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
"issuer": issuer,
"issuerScope": "public",
"validFrom": "2026-01-01T00:00:00Z",
});
if let Some(s) = subject {
vc["credentialSubject"] = serde_json::json!({ "id": s });
}
vc
}
fn ours() -> Vec<String> {
vec![ALICE.to_string()]
}
#[test]
fn a_valid_credential_yields_its_membership() {
let m = membership_from_credential(&vmc(VTC.into(), Some(ALICE)), &ours(), Utc::now())
.expect("verifies");
assert_eq!(m.vtc_did, VTC);
assert_eq!(m.persona_did, ALICE);
assert_eq!(m.credential["id"], "vmc-1");
}
#[test]
fn an_object_issuer_is_not_a_conformant_membership() {
assert!(matches!(
membership_from_credential(
&vmc(serde_json::json!({ "id": VTC }), Some(ALICE)),
&ours(),
Utc::now(),
),
Err(RejectionReason::Nonconformant { .. })
));
}
#[test]
fn a_pre_v1_membership_credential_is_rejected() {
let mut vc = vmc(VTC.into(), Some(ALICE));
vc["@context"][1] = crate::dtg::fixtures::RETIRED_CONTEXT.into();
vc.as_object_mut().unwrap().remove("issuerScope");
assert!(matches!(
membership_from_credential(&vc, &ours(), Utc::now()),
Err(RejectionReason::Nonconformant { .. })
));
}
#[test]
fn a_credential_for_another_persona_is_rejected() {
let err = membership_from_credential(&vmc(VTC.into(), Some(BOB)), &ours(), Utc::now())
.expect_err("must not restore someone else's membership");
assert_eq!(
err,
RejectionReason::SubjectNotOurs {
subject: BOB.to_string()
}
);
}
#[test]
fn a_credential_with_no_issuer_or_subject_is_rejected() {
assert_eq!(
membership_from_credential(&vmc(Value::Null, Some(ALICE)), &ours(), Utc::now()),
Err(RejectionReason::NoIssuer)
);
assert_eq!(
membership_from_credential(&vmc(VTC.into(), None), &ours(), Utc::now()),
Err(RejectionReason::NoSubject)
);
}
#[test]
fn an_expired_credential_is_rejected() {
let now = Utc::now();
let mut vc = vmc(VTC.into(), Some(ALICE));
vc["validUntil"] = (now - Duration::days(1)).to_rfc3339().into();
assert!(matches!(
membership_from_credential(&vc, &ours(), now),
Err(RejectionReason::Expired { .. })
));
}
#[test]
fn a_credential_valid_until_tomorrow_is_accepted() {
let now = Utc::now();
let mut vc = vmc(VTC.into(), Some(ALICE));
vc["validUntil"] = (now + Duration::days(1)).to_rfc3339().into();
assert!(membership_from_credential(&vc, &ours(), now).is_ok());
}
#[test]
fn a_credential_with_no_validity_window_is_accepted() {
assert!(
membership_from_credential(&vmc(VTC.into(), Some(ALICE)), &ours(), Utc::now()).is_ok()
);
}
#[test]
fn a_malformed_validity_window_fails_closed() {
let mut vc = vmc(VTC.into(), Some(ALICE));
vc["validUntil"] = "next tuesday".into();
assert!(matches!(
membership_from_credential(&vc, &ours(), Utc::now()),
Err(RejectionReason::MalformedValidity { .. })
));
}
#[test]
fn every_rejection_reads_as_a_sentence() {
let reasons = [
RejectionReason::NoIssuer,
RejectionReason::NoSubject,
RejectionReason::SubjectNotOurs {
subject: BOB.to_string(),
},
RejectionReason::Nonconformant {
reason: "x".to_string(),
},
RejectionReason::Expired {
valid_until: "2020-01-01T00:00:00Z".to_string(),
},
RejectionReason::MalformedValidity {
valid_until: "soon".to_string(),
},
];
for r in reasons {
let s = r.summary();
assert!(
s.starts_with("it ") || s.starts_with("its "),
"not a sentence fragment: {s}"
);
assert!(s.len() > 12, "too terse to act on: {s}");
}
}
#[test]
fn the_summary_counts_what_would_be_restored() {
let plan = RebuildPlan {
top_context_id: "openvtc".to_string(),
personas: vec![RebuiltPersona {
did: ALICE.to_string(),
context_id: "openvtc".to_string(),
mediator_did: None,
}],
memberships: vec![RebuiltMembership {
vtc_did: VTC.to_string(),
persona_did: ALICE.to_string(),
credential: Value::Null,
}],
rejected: Vec::new(),
other_credential_count: 3,
};
assert_eq!(
plan.summary(),
"1 persona, 1 membership, 3 other credentials"
);
assert!(!plan.is_empty());
}
#[test]
fn the_gaps_are_stated_not_implied() {
let gaps = RebuildPlan::known_gaps();
assert!(!gaps.is_empty());
let all = gaps.join(" ").to_lowercase();
assert!(all.contains("relationship"), "{all}");
assert!(all.contains("contact"), "{all}");
}
#[test]
fn the_membership_query_carries_a_filter() {
let filter = membership_query_filter();
let obj = filter.as_object().expect("an object");
assert!(
!obj.is_empty(),
"a filterless vault query is refused by contract as an enumeration"
);
assert_eq!(
obj.get("purpose").and_then(serde_json::Value::as_str),
Some("membership"),
"the vault indexes on its own semantic purpose, not the issuer's type spelling"
);
}
#[test]
fn descriptor_ids_are_found_whatever_the_envelope() {
let one = serde_json::json!({ "id": "vmc-1", "types": ["MembershipCredential"] });
for key in ["credentials", "items", "results"] {
let listing = serde_json::json!({ key: [one.clone()] });
assert_eq!(
descriptors_in(&listing),
vec!["vmc-1".to_string()],
"key {key}"
);
}
assert_eq!(
descriptors_in(&serde_json::json!([one.clone()])),
vec!["vmc-1".to_string()]
);
assert!(descriptors_in(&serde_json::json!({ "nope": [one] })).is_empty());
}
#[test]
fn a_descriptor_without_an_id_is_skipped() {
let listing = serde_json::json!({ "credentials": [{ "types": ["X"] }, { "id": "keep" }] });
assert_eq!(descriptors_in(&listing), vec!["keep".to_string()]);
}
}