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 (synced copies return on first launch)",
"Relationships and their peer DIDs (synced copies return on first launch)",
"Contacts and the names you gave them (synced copies return on first launch)",
"Activity history",
]
}
}
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()]);
}
}