use affinidi_status_list::StatusPurpose;
use chrono::Duration;
use serde_json::Value;
use uuid::Uuid;
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
use crate::status_list;
use super::dtg;
use super::signer::LocalSigner;
use super::vmc::CredentialStatusRef;
pub const DEFAULT_INVITATION_VALIDITY: Duration = Duration::days(7);
pub async fn issue_invitation(
signer: &LocalSigner,
status_lists_ks: &KeyspaceHandle,
schemas_ks: &KeyspaceHandle,
subject_did: &str,
validity: Duration,
) -> Result<Value, AppError> {
let mut row = status_list::get_state(status_lists_ks, StatusPurpose::Revocation)
.await?
.ok_or_else(|| {
AppError::Internal(
"revocation status list not provisioned — set `public_url` + restart".into(),
)
})?;
let slot = status_list::allocate(&mut row).ok_or_else(|| {
AppError::Internal(format!(
"revocation status list exhausted (capacity = {})",
row.capacity
))
})?;
let status_ref = CredentialStatusRef::revocation(row.list_credential_id.clone(), slot);
let id = format!("urn:uuid:{}", Uuid::new_v4());
let vic =
dtg::issue_invitation(signer, subject_did, Some(&id), Some(&status_ref), validity).await?;
crate::schemas::validate_issued(schemas_ks, &vic).await?;
status_list::store_state(status_lists_ks, &row).await?;
Ok(vic)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::status_list::{StatusListState, get_state};
use vti_common::config::StoreConfig;
use vti_common::store::Store;
const TEST_VTC_DID: &str = "did:webvh:vtc.example.com:abc";
fn signer() -> LocalSigner {
LocalSigner::from_ed25519_seed(TEST_VTC_DID.into(), &[0xCC; 32])
}
async fn provisioned() -> (tempfile::TempDir, Store, KeyspaceHandle, 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("status_lists")
.expect("status_lists keyspace");
let schemas_ks = store.keyspace("schemas").expect("schemas keyspace");
let state = StatusListState::new(
StatusPurpose::Revocation,
format!("{TEST_VTC_DID}/v1/status-lists/revocation"),
);
status_list::store_state(&ks, &state)
.await
.expect("seed list");
(dir, store, ks, schemas_ks)
}
#[tokio::test]
async fn issues_a_revocable_vic_and_burns_a_slot() {
let (_dir, _store, ks, schemas_ks) = provisioned().await;
let s = signer();
let assigned_before = get_state(&ks, StatusPurpose::Revocation)
.await
.unwrap()
.unwrap()
.count_assigned();
let vic = issue_invitation(&s, &ks, &schemas_ks, "did:key:zInvitee", Duration::days(7))
.await
.expect("issue VIC");
let types: Vec<String> = serde_json::from_value(vic["type"].clone()).unwrap();
assert!(
types.iter().any(|t| t == "InvitationCredential"),
"{types:?}"
);
assert_eq!(vic["credentialSubject"]["id"], "did:key:zInvitee");
assert!(
vic.get("credentialStatus").is_some(),
"VIC must be revocable"
);
s.verify(&serde_json::from_value(vic.clone()).unwrap())
.expect("VIC proof verifies");
let assigned_after = get_state(&ks, StatusPurpose::Revocation)
.await
.unwrap()
.unwrap()
.count_assigned();
assert_eq!(
assigned_after,
assigned_before + 1,
"issuing a VIC must burn exactly one revocation slot"
);
}
#[tokio::test]
async fn refuses_when_revocation_list_not_provisioned() {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
let ks = store.keyspace("status_lists").unwrap();
let schemas_ks = store.keyspace("schemas").unwrap();
let s = signer();
let err = issue_invitation(&s, &ks, &schemas_ks, "did:key:zInvitee", Duration::days(7))
.await
.expect_err("must refuse without a provisioned list");
assert!(matches!(err, AppError::Internal(_)), "{err:?}");
}
#[tokio::test]
async fn registered_schema_is_enforced_and_slot_not_burned_on_violation() {
use crate::schemas::{SchemaEntry, SchemaKind, store_schema};
let (_dir, _store, ks, schemas_ks) = provisioned().await;
let s = signer();
store_schema(
&schemas_ks,
&SchemaEntry {
type_uri: "InvitationCredential".into(),
dtg_type: Some("InvitationCredential".into()),
credential_schema: Some(serde_json::json!({
"type": "object",
"required": ["id", "invitedBy"]
})),
kind: SchemaKind::Issues,
description: None,
created_at: chrono::Utc::now(),
created_by_did: "did:key:zAdmin".into(),
},
)
.await
.unwrap();
let before = get_state(&ks, StatusPurpose::Revocation)
.await
.unwrap()
.unwrap()
.count_assigned();
let err = issue_invitation(&s, &ks, &schemas_ks, "did:key:zInvitee", Duration::days(7))
.await
.expect_err("non-conforming VIC must be refused");
assert!(matches!(err, AppError::Validation(_)), "{err:?}");
let after = get_state(&ks, StatusPurpose::Revocation)
.await
.unwrap()
.unwrap()
.count_assigned();
assert_eq!(after, before, "a rejected VIC must not burn a slot");
}
}