use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
use super::model::{CredentialPurpose, CredentialStatus, IndexField, StoredCredential};
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CredentialDescriptor {
pub id: String,
pub types: Vec<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>,
}
impl CredentialDescriptor {
fn from_record(cred: &StoredCredential) -> Self {
CredentialDescriptor {
id: cred.id.clone(),
types: cred.types.clone(),
issuer_did: cred.issuer_did.clone(),
purpose: cred.purpose.clone(),
status: cred.status,
valid_from: cred.valid_from.clone(),
valid_until: cred.valid_until.clone(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CredentialQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub r#type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub community_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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<CredentialStatus>,
}
impl CredentialQuery {
pub fn is_empty(&self) -> bool {
self.r#type.is_none()
&& self.community_did.is_none()
&& self.issuer_did.is_none()
&& self.purpose.is_none()
&& self.status.is_none()
}
fn constraints(&self) -> Vec<(IndexField, String)> {
let mut c = Vec::new();
if let Some(t) = &self.r#type {
c.push((IndexField::Type, t.clone()));
}
if let Some(d) = &self.community_did {
c.push((IndexField::CommunityDid, d.clone()));
}
if let Some(d) = &self.issuer_did {
c.push((IndexField::IssuerDid, d.clone()));
}
if let Some(p) = &self.purpose {
c.push((IndexField::Purpose, p.as_index_token()));
}
if let Some(s) = &self.status {
c.push((IndexField::Status, s.as_index_token().to_string()));
}
c
}
}
fn matches_constraint(cred: &StoredCredential, field: IndexField, value: &str) -> bool {
match field {
IndexField::Type => cred.types.iter().any(|t| t == value),
IndexField::CommunityDid => cred.community_did.as_deref() == Some(value),
IndexField::IssuerDid => cred.issuer_did.as_deref() == Some(value),
IndexField::Purpose => cred
.purpose
.as_ref()
.map(|p| p.as_index_token() == value)
.unwrap_or(false),
IndexField::Status => cred.status.as_index_token() == value,
}
}
pub async fn search(
vault: &KeyspaceHandle,
query: &CredentialQuery,
) -> Result<Vec<CredentialDescriptor>, AppError> {
if query.is_empty() {
return Err(AppError::Validation(
"credential search requires at least one filter \
(type, community_did, issuer_did, purpose, or status); \
an unfiltered query would enumerate the wallet and is refused"
.to_string(),
));
}
let constraints = query.constraints();
let (anchor_field, anchor_value) = &constraints[0];
let candidates = super::storage::find_by_index(vault, *anchor_field, anchor_value).await?;
let mut out = Vec::new();
for cred in &candidates {
if !cred.is_active() {
continue;
}
if is_excluded_status(cred.status) {
continue;
}
let all_match = constraints[1..]
.iter()
.all(|(field, value)| matches_constraint(cred, *field, value));
if all_match {
out.push(CredentialDescriptor::from_record(cred));
}
}
Ok(out)
}
fn is_excluded_status(status: CredentialStatus) -> bool {
matches!(
status,
CredentialStatus::Revoked | CredentialStatus::Expired
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::model::{CredentialFormat, CredentialPurpose, CredentialStatus};
use crate::vault::storage::put;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
fn fresh_vault() -> (tempfile::TempDir, Store, KeyspaceHandle) {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.expect("open store");
let ks = store
.keyspace(crate::keyspaces::VAULT)
.expect("vault keyspace");
(dir, store, ks)
}
fn sample(id: &str) -> StoredCredential {
StoredCredential {
id: id.to_string(),
format: CredentialFormat::SdJwtVc,
types: vec!["VerifiableCredential".into(), "InvitationCredential".into()],
schema_id: Some("schema:invite:1".into()),
community_did: Some("did:web:acme".into()),
subject_did: Some("did:key:zAlice".into()),
issuer_did: Some("did:web:issuer.example".into()),
purpose: Some(CredentialPurpose::Invite),
status: CredentialStatus::Unknown,
valid_from: Some("2026-01-01T00:00:00Z".into()),
valid_until: Some("2027-01-01T00:00:00Z".into()),
received_at: "2026-06-03T00:00:00Z".into(),
source: Some("exchange:thread-42".into()),
tags: std::collections::BTreeMap::from([("label".into(), "alice-invite".into())]),
body: b"opaque.credential.bytes".to_vec(),
lifecycle: vti_common::vault::VaultStatus::Active,
archived_at: None,
deleted_at: None,
grace_until: None,
}
}
#[tokio::test]
async fn search_by_indexed_field_returns_descriptors() {
let (_dir, _store, vault) = fresh_vault();
put(&vault, &sample("cred-1")).await.unwrap();
let q = CredentialQuery {
issuer_did: Some("did:web:issuer.example".into()),
..Default::default()
};
let hits = search(&vault, &q).await.unwrap();
assert_eq!(hits.len(), 1);
let d = &hits[0];
assert_eq!(d.id, "cred-1");
assert_eq!(
d.types,
vec![
"VerifiableCredential".to_string(),
"InvitationCredential".to_string()
]
);
assert_eq!(d.issuer_did.as_deref(), Some("did:web:issuer.example"));
assert_eq!(d.purpose, Some(CredentialPurpose::Invite));
assert_eq!(d.status, CredentialStatus::Unknown);
assert_eq!(d.valid_from.as_deref(), Some("2026-01-01T00:00:00Z"));
assert_eq!(d.valid_until.as_deref(), Some("2027-01-01T00:00:00Z"));
}
#[tokio::test]
async fn search_matches_any_type_tag() {
let (_dir, _store, vault) = fresh_vault();
put(&vault, &sample("cred-1")).await.unwrap();
for tag in ["VerifiableCredential", "InvitationCredential"] {
let q = CredentialQuery {
r#type: Some(tag.into()),
..Default::default()
};
let hits = search(&vault, &q).await.unwrap();
assert_eq!(hits.len(), 1, "type tag {tag} must match");
assert_eq!(hits[0].id, "cred-1");
}
}
#[tokio::test]
async fn descriptors_never_contain_the_body() {
let (_dir, _store, vault) = fresh_vault();
put(&vault, &sample("cred-1")).await.unwrap();
let q = CredentialQuery {
community_did: Some("did:web:acme".into()),
..Default::default()
};
let hits = search(&vault, &q).await.unwrap();
assert_eq!(hits.len(), 1);
let json = serde_json::to_string(&hits[0]).unwrap();
assert!(
!json.contains("opaque.credential.bytes"),
"descriptor JSON must not contain the credential body"
);
assert!(
!json.contains("body"),
"descriptor must not even have a body field"
);
assert!(json.contains("cred-1"));
}
#[tokio::test]
async fn unfiltered_query_is_rejected_no_enumeration() {
let (_dir, _store, vault) = fresh_vault();
put(&vault, &sample("cred-1")).await.unwrap();
let mut other = sample("cred-2");
other.issuer_did = Some("did:web:other".into());
other.community_did = Some("did:web:other-co".into());
put(&vault, &other).await.unwrap();
let empty = CredentialQuery::default();
assert!(empty.is_empty());
let err = search(&vault, &empty).await.unwrap_err();
assert!(
matches!(err, AppError::Validation(_)),
"an unfiltered (enumerate-all) query must be rejected, got {err:?}"
);
let q = CredentialQuery {
issuer_did: Some("did:web:other".into()),
..Default::default()
};
let hits = search(&vault, &q).await.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, "cred-2");
}
#[tokio::test]
async fn multiple_filters_are_and_combined() {
let (_dir, _store, vault) = fresh_vault();
let mut a = sample("cred-a");
a.issuer_did = Some("did:web:issuer-a".into());
a.purpose = Some(CredentialPurpose::Membership);
let mut b = sample("cred-b");
b.issuer_did = Some("did:web:issuer-b".into());
b.purpose = Some(CredentialPurpose::Invite);
put(&vault, &a).await.unwrap();
put(&vault, &b).await.unwrap();
let q = CredentialQuery {
community_did: Some("did:web:acme".into()),
..Default::default()
};
let mut ids = search(&vault, &q)
.await
.unwrap()
.into_iter()
.map(|d| d.id)
.collect::<Vec<_>>();
ids.sort();
assert_eq!(ids, vec!["cred-a", "cred-b"]);
let q = CredentialQuery {
community_did: Some("did:web:acme".into()),
purpose: Some(CredentialPurpose::Membership),
..Default::default()
};
let hits = search(&vault, &q).await.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, "cred-a");
let q = CredentialQuery {
community_did: Some("did:web:acme".into()),
issuer_did: Some("did:web:nobody".into()),
..Default::default()
};
assert!(search(&vault, &q).await.unwrap().is_empty());
}
#[tokio::test]
async fn search_by_status_filter_surfaces_valid() {
let (_dir, _store, vault) = fresh_vault();
let mut valid = sample("cred-valid");
valid.status = CredentialStatus::Valid;
put(&vault, &valid).await.unwrap();
let q = CredentialQuery {
status: Some(CredentialStatus::Valid),
..Default::default()
};
let hits = search(&vault, &q).await.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, "cred-valid");
assert_eq!(hits[0].status, CredentialStatus::Valid);
}
#[tokio::test]
async fn revoked_and_expired_are_excluded_from_search() {
let (_dir, _store, vault) = fresh_vault();
let mut valid = sample("cred-valid");
valid.status = CredentialStatus::Valid;
let mut revoked = sample("cred-revoked");
revoked.status = CredentialStatus::Revoked;
revoked.issuer_did = Some("did:web:issuer-r".into());
let mut expired = sample("cred-expired");
expired.status = CredentialStatus::Expired;
expired.issuer_did = Some("did:web:issuer-e".into());
put(&vault, &valid).await.unwrap();
put(&vault, &revoked).await.unwrap();
put(&vault, &expired).await.unwrap();
let q = CredentialQuery {
community_did: Some("did:web:acme".into()),
..Default::default()
};
let hits = search(&vault, &q).await.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, "cred-valid");
let q = CredentialQuery {
status: Some(CredentialStatus::Revoked),
..Default::default()
};
assert!(
search(&vault, &q).await.unwrap().is_empty(),
"there is no search surface that returns revoked credentials"
);
let q = CredentialQuery {
status: Some(CredentialStatus::Expired),
..Default::default()
};
assert!(search(&vault, &q).await.unwrap().is_empty());
}
#[tokio::test]
async fn no_match_returns_empty_not_error() {
let (_dir, _store, vault) = fresh_vault();
put(&vault, &sample("cred-1")).await.unwrap();
let q = CredentialQuery {
issuer_did: Some("did:web:nonexistent".into()),
..Default::default()
};
assert!(search(&vault, &q).await.unwrap().is_empty());
}
}