use serde::{Deserialize, Serialize};
use vti_common::vault::{LifecycleError, VaultStatus, default_active};
pub const BBS_PROVER_NYM_TAG: &str = "bbs:prover_nym";
pub const BBS_SECRET_PROVER_BLIND_TAG: &str = "bbs:secret_prover_blind";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CredentialFormat {
Bbs2023,
Zkp,
EddsaJcs2022,
SdJwtVc,
#[serde(untagged)]
Other(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CredentialStatus {
Valid,
Expired,
Revoked,
Unknown,
}
impl CredentialStatus {
pub fn as_index_token(&self) -> &'static str {
match self {
CredentialStatus::Valid => "valid",
CredentialStatus::Expired => "expired",
CredentialStatus::Revoked => "revoked",
CredentialStatus::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CredentialPurpose {
Invite,
Membership,
Role,
Endorsement,
Personhood,
#[serde(untagged)]
Other(String),
}
impl CredentialPurpose {
pub fn as_index_token(&self) -> String {
match self {
CredentialPurpose::Invite => "invite".to_string(),
CredentialPurpose::Membership => "membership".to_string(),
CredentialPurpose::Role => "role".to_string(),
CredentialPurpose::Endorsement => "endorsement".to_string(),
CredentialPurpose::Personhood => "personhood".to_string(),
CredentialPurpose::Other(s) => s.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StoredCredential {
pub id: String,
pub format: CredentialFormat,
#[serde(default)]
pub types: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub community_did: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject_did: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issuer_did: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub purpose: Option<CredentialPurpose>,
pub status: CredentialStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub valid_from: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub valid_until: Option<String>,
pub received_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub tags: std::collections::BTreeMap<String, String>,
pub body: Vec<u8>,
#[serde(
default = "default_active",
skip_serializing_if = "VaultStatus::is_active"
)]
pub lifecycle: VaultStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archived_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grace_until: Option<String>,
}
impl StoredCredential {
pub fn is_active(&self) -> bool {
self.lifecycle.is_active()
}
pub fn archive(&mut self, now: &str) -> Result<(), LifecycleError> {
if self.lifecycle != VaultStatus::Active {
return Err(LifecycleError::NotActive);
}
self.lifecycle = VaultStatus::Archived;
self.archived_at = Some(now.to_string());
Ok(())
}
pub fn unarchive(&mut self) -> Result<(), LifecycleError> {
if self.lifecycle != VaultStatus::Archived {
return Err(LifecycleError::NotArchived);
}
self.lifecycle = VaultStatus::Active;
self.archived_at = None;
Ok(())
}
pub fn soft_delete(&mut self, now: &str, grace_until: &str) -> Result<(), LifecycleError> {
if self.lifecycle == VaultStatus::Deleted {
return Err(LifecycleError::AlreadyDeleted);
}
self.lifecycle = VaultStatus::Deleted;
self.archived_at = None;
self.deleted_at = Some(now.to_string());
self.grace_until = Some(grace_until.to_string());
Ok(())
}
pub fn restore(&mut self, now: &str) -> Result<(), LifecycleError> {
if self.lifecycle != VaultStatus::Deleted {
return Err(LifecycleError::NotDeleted);
}
if let Some(grace) = self.grace_until.as_deref()
&& now >= grace
{
return Err(LifecycleError::GraceExpired);
}
self.lifecycle = VaultStatus::Active;
self.deleted_at = None;
self.grace_until = None;
Ok(())
}
}
impl StoredCredential {
pub(crate) fn index_terms(&self) -> Vec<(IndexField, String)> {
let mut terms = Vec::new();
for t in &self.types {
terms.push((IndexField::Type, t.clone()));
}
if let Some(c) = &self.community_did {
terms.push((IndexField::CommunityDid, c.clone()));
}
if let Some(i) = &self.issuer_did {
terms.push((IndexField::IssuerDid, i.clone()));
}
if let Some(p) = &self.purpose {
terms.push((IndexField::Purpose, p.as_index_token()));
}
terms.push((IndexField::Status, self.status.as_index_token().to_string()));
terms
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndexField {
Type,
CommunityDid,
IssuerDid,
Purpose,
Status,
}
impl IndexField {
pub fn token(&self) -> &'static str {
match self {
IndexField::Type => "type",
IndexField::CommunityDid => "community",
IndexField::IssuerDid => "issuer",
IndexField::Purpose => "purpose",
IndexField::Status => "status",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> StoredCredential {
StoredCredential {
id: "cred-1".into(),
format: CredentialFormat::SdJwtVc,
types: vec!["MembershipCredential".into()],
schema_id: None,
community_did: Some("did:web:acme".into()),
subject_did: None,
issuer_did: Some("did:web:issuer".into()),
purpose: Some(CredentialPurpose::Membership),
status: CredentialStatus::Valid,
valid_from: None,
valid_until: None,
received_at: "2026-06-03T00:00:00Z".into(),
source: None,
tags: std::collections::BTreeMap::new(),
body: b"opaque".to_vec(),
lifecycle: VaultStatus::Active,
archived_at: None,
deleted_at: None,
grace_until: None,
}
}
#[test]
fn legacy_credential_without_lifecycle_defaults_active() {
let legacy = r#"{
"id":"c","format":"sd-jwt-vc","types":[],"status":"valid",
"receivedAt":"2026-01-01T00:00:00Z","body":[1,2,3]
}"#;
let cred: StoredCredential = serde_json::from_str(legacy).expect("parse legacy");
assert_eq!(cred.lifecycle, VaultStatus::Active);
assert!(cred.is_active());
let re = serde_json::to_string(&cred).unwrap();
assert!(
!re.contains("lifecycle"),
"active cred omits lifecycle: {re}"
);
}
#[test]
fn credential_lifecycle_transitions() {
let t0 = "2026-06-18T10:00:00+00:00";
let grace = "2026-07-18T10:00:00+00:00";
let mut c = sample();
c.archive(t0).unwrap();
assert_eq!(c.lifecycle, VaultStatus::Archived);
assert_eq!(c.archived_at.as_deref(), Some(t0));
assert!(!c.is_active());
assert_eq!(c.archive(t0), Err(LifecycleError::NotActive));
c.unarchive().unwrap();
assert!(c.is_active() && c.archived_at.is_none());
assert_eq!(c.unarchive(), Err(LifecycleError::NotArchived));
let mut c = sample();
c.soft_delete(t0, grace).unwrap();
assert_eq!(c.lifecycle, VaultStatus::Deleted);
assert_eq!(c.grace_until.as_deref(), Some(grace));
assert_eq!(
c.soft_delete(t0, grace),
Err(LifecycleError::AlreadyDeleted)
);
c.restore(t0).unwrap();
assert!(c.is_active());
assert_eq!(c.restore(t0), Err(LifecycleError::NotDeleted));
let mut c = sample();
c.soft_delete(t0, grace).unwrap();
assert_eq!(
c.restore("2026-08-01T00:00:00+00:00"),
Err(LifecycleError::GraceExpired)
);
assert_eq!(
c.lifecycle,
VaultStatus::Deleted,
"failed restore is a no-op"
);
}
}