use serde_json::Value as JsonValue;
use vti_common::error::AppError;
use super::facts::{Facts, Purpose};
#[derive(Debug, Clone, PartialEq)]
pub struct VerifiedFacts(Facts);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VerifyError {
PresentationNotVerified,
InvitationNotVerified,
}
impl VerifyError {
pub fn code(&self) -> &'static str {
match self {
VerifyError::PresentationNotVerified => "presentation-not-verified",
VerifyError::InvitationNotVerified => "invitation-not-verified",
}
}
}
impl std::fmt::Display for VerifyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VerifyError::PresentationNotVerified => f.write_str("presented VP failed verification"),
VerifyError::InvitationNotVerified => {
f.write_str("presented invitation failed verification")
}
}
}
}
impl std::error::Error for VerifyError {}
impl From<VerifyError> for AppError {
fn from(e: VerifyError) -> Self {
AppError::Forbidden(format!("{} ({})", e, e.code()))
}
}
impl VerifiedFacts {
pub fn assemble(facts: Facts) -> Result<Self, VerifyError> {
if let Some(presentation) = &facts.evidence.presentation
&& !presentation.verified
{
return Err(VerifyError::PresentationNotVerified);
}
if let Some(invitation) = &facts.evidence.invitation
&& !invitation.verified
{
return Err(VerifyError::InvitationNotVerified);
}
Ok(VerifiedFacts(facts))
}
pub fn facts(&self) -> &Facts {
&self.0
}
pub fn purpose(&self) -> Purpose {
self.0.purpose
}
pub fn to_input(&self) -> Result<JsonValue, AppError> {
serde_json::to_value(&self.0).map_err(AppError::from)
}
pub fn into_inner(self) -> Facts {
self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ceremony::facts::{
Actor, Context, Credential, CredentialStatus, Evidence, Invitation, Presentation, State,
Subject,
};
use serde_json::json;
fn base_facts(purpose: Purpose) -> Facts {
Facts {
purpose,
now: "2026-05-30T12:00:00Z".parse().unwrap(),
actor: Actor {
did: "did:key:zActor".into(),
role: None,
authenticated: true,
},
subject: Subject {
did: "did:key:zActor".into(),
},
context: Context {
community_did: "did:webvh:acme.example".into(),
channel: "rest".into(),
member_count: 10,
},
evidence: Evidence::default(),
state: State::default(),
}
}
fn verified_presentation() -> Presentation {
Presentation {
verified: true,
holder: "did:key:zActor".into(),
credentials: vec![Credential {
credential_type: "WitnessCredential".into(),
issuer: "did:webvh:notary.example".into(),
issuer_trusted: true,
status: CredentialStatus::Valid,
holder_bound: true,
claims: json!({}),
valid_until: None,
}],
}
}
#[test]
fn empty_evidence_passes_the_gate() {
let facts = base_facts(Purpose::Directory);
let verified = VerifiedFacts::assemble(facts.clone()).expect("empty evidence is fine");
assert_eq!(verified.facts(), &facts);
assert_eq!(verified.purpose(), Purpose::Directory);
}
#[test]
fn verified_presentation_passes() {
let mut facts = base_facts(Purpose::Join);
facts.evidence.presentation = Some(verified_presentation());
let verified = VerifiedFacts::assemble(facts).expect("verified presentation passes");
let input = verified.to_input().unwrap();
assert_eq!(input["evidence"]["presentation"]["verified"], true);
}
#[test]
fn unverified_presentation_is_rejected() {
let mut facts = base_facts(Purpose::Join);
let mut pres = verified_presentation();
pres.verified = false;
facts.evidence.presentation = Some(pres);
let err = VerifiedFacts::assemble(facts).expect_err("unverified presentation must abort");
assert_eq!(err, VerifyError::PresentationNotVerified);
let app: AppError = err.into();
assert!(matches!(app, AppError::Forbidden(_)), "got {app:?}");
}
#[test]
fn unverified_invitation_is_rejected() {
let mut facts = base_facts(Purpose::Join);
facts.evidence.invitation = Some(Invitation {
verified: false,
issuer: "did:webvh:acme.example".into(),
issuer_role: Some("admin".into()),
scopes: vec![],
consumed: false,
});
let err = VerifiedFacts::assemble(facts).expect_err("unverified invitation must abort");
assert_eq!(err, VerifyError::InvitationNotVerified);
}
#[test]
fn revoked_but_verified_credential_passes_the_gate() {
let mut facts = base_facts(Purpose::Join);
let mut pres = verified_presentation();
pres.credentials[0].status = CredentialStatus::Revoked;
facts.evidence.presentation = Some(pres);
VerifiedFacts::assemble(facts).expect("revoked-but-verified is a fact, not an abort");
}
}