use affinidi_openid4vp::DcqlQuery;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
use super::schema_exists;
pub const ACCEPTS_PREFIX: &[u8] = b"accepts:";
fn key(id: &str) -> Vec<u8> {
let mut k = ACCEPTS_PREFIX.to_vec();
k.extend_from_slice(id.as_bytes());
k
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AcceptsCriterion {
pub id: String,
pub query: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub created_at: DateTime<Utc>,
pub created_by_did: String,
}
fn referenced_types(query: &DcqlQuery) -> Vec<String> {
let mut out = Vec::new();
for cq in &query.credentials {
if let Some(meta) = &cq.meta
&& let Some(vcts) = meta.get("vct_values").and_then(|v| v.as_array())
{
out.extend(vcts.iter().filter_map(|v| v.as_str()).map(String::from));
}
}
out
}
pub async fn validate_accepts_query(
schemas_ks: &KeyspaceHandle,
query: &Value,
) -> Result<DcqlQuery, AppError> {
let dcql = DcqlQuery::from_json(query)
.map_err(|e| AppError::Validation(format!("invalid DCQL query: {e}")))?;
for type_uri in referenced_types(&dcql) {
if !schema_exists(schemas_ks, &type_uri).await? {
return Err(AppError::Validation(format!(
"DCQL Accepts criterion references unregistered credential type `{type_uri}` \
— register it in the schema store first"
)));
}
}
Ok(dcql)
}
pub async fn store_accepts(
schemas_ks: &KeyspaceHandle,
criterion: &AcceptsCriterion,
) -> Result<(), AppError> {
validate_accepts_query(schemas_ks, &criterion.query).await?;
schemas_ks
.insert(
String::from_utf8(key(&criterion.id)).expect("ascii key"),
criterion,
)
.await
}
pub async fn get_accepts(
schemas_ks: &KeyspaceHandle,
id: &str,
) -> Result<Option<AcceptsCriterion>, AppError> {
match schemas_ks.get_raw(key(id)).await? {
Some(bytes) => Ok(Some(serde_json::from_slice(&bytes).map_err(|e| {
AppError::Internal(format!("AcceptsCriterion decode: {e}"))
})?)),
None => Ok(None),
}
}
pub async fn list_accepts(schemas_ks: &KeyspaceHandle) -> Result<Vec<AcceptsCriterion>, AppError> {
let mut pairs = schemas_ks.prefix_iter_raw(ACCEPTS_PREFIX.to_vec()).await?;
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
pairs
.iter()
.map(|(_, v)| {
serde_json::from_slice(v)
.map_err(|e| AppError::Internal(format!("AcceptsCriterion decode: {e}")))
})
.collect()
}
pub async fn delete_accepts(schemas_ks: &KeyspaceHandle, id: &str) -> Result<(), AppError> {
schemas_ks.remove(key(id)).await
}
#[cfg(test)]
mod tests {
use super::super::{SchemaEntry, SchemaKind, store_schema};
use super::*;
use chrono::Utc;
use serde_json::json;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
const MEMBERSHIP_VCT: &str = "https://openvtc.org/credentials/MembershipCredential";
async fn ks() -> (tempfile::TempDir, Store, KeyspaceHandle) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
let ks = store.keyspace("schemas").unwrap();
(dir, store, ks)
}
async fn register_membership(ks: &KeyspaceHandle) {
store_schema(
ks,
&SchemaEntry {
type_uri: MEMBERSHIP_VCT.into(),
dtg_type: Some("MembershipCredential".into()),
credential_schema: None,
kind: SchemaKind::Accepts,
description: None,
created_at: Utc::now(),
created_by_did: "did:key:zAdmin".into(),
},
)
.await
.unwrap();
}
fn criterion(id: &str, vct: &str) -> AcceptsCriterion {
AcceptsCriterion {
id: id.into(),
query: json!({
"credentials": [{
"id": "membership",
"format": "dc+sd-jwt",
"meta": { "vct_values": [vct] },
"claims": [{ "path": ["givenName"] }]
}]
}),
description: Some("join evidence".into()),
created_at: Utc::now(),
created_by_did: "did:key:zAdmin".into(),
}
}
#[tokio::test]
async fn stores_a_criterion_that_references_a_registered_type() {
let (_d, _s, ks) = ks().await;
register_membership(&ks).await;
let c = criterion("join", MEMBERSHIP_VCT);
store_accepts(&ks, &c)
.await
.expect("valid criterion stores");
let got = get_accepts(&ks, "join").await.unwrap().unwrap();
assert_eq!(got, c);
assert_eq!(list_accepts(&ks).await.unwrap().len(), 1);
let dcql = DcqlQuery::from_json(&got.query).expect("stored query is valid DCQL");
assert_eq!(dcql.credentials.len(), 1);
delete_accepts(&ks, "join").await.unwrap();
assert!(get_accepts(&ks, "join").await.unwrap().is_none());
}
#[tokio::test]
async fn rejects_a_criterion_referencing_an_unregistered_type() {
let (_d, _s, ks) = ks().await;
let c = criterion("join", "https://openvtc.org/credentials/Unknown");
let err = store_accepts(&ks, &c)
.await
.expect_err("dangling type reference must be rejected");
assert!(matches!(err, AppError::Validation(_)), "{err:?}");
assert!(
get_accepts(&ks, "join").await.unwrap().is_none(),
"not stored"
);
}
#[tokio::test]
async fn rejects_a_structurally_invalid_dcql_query() {
let (_d, _s, ks) = ks().await;
let bad = AcceptsCriterion {
id: "bad".into(),
query: json!({ "credentials": [] }),
description: None,
created_at: Utc::now(),
created_by_did: "did:key:zAdmin".into(),
};
let err = store_accepts(&ks, &bad)
.await
.expect_err("invalid DCQL must be rejected");
assert!(matches!(err, AppError::Validation(_)), "{err:?}");
}
}