use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use vta_sdk::protocols::vetting::{
CheckShape, ClaimType, ShapeError, VETTING_REQUEST_ERR_CAPACITY, VETTING_REQUEST_ERR_DECLINED,
VETTING_REQUEST_ERR_METHOD_UNAVAILABLE, VETTING_REQUEST_ERR_NOT_ELIGIBLE, VettedObjectValue,
VettingDocumentation, VettingMethod, VettingRelationship, check_request, decline,
documentation, request, revoke_statement, session,
};
use vta_sdk::trust_task_proof::TrustTaskVmResolver;
use vta_sdk::vetting::VettingError;
use vta_sdk::vetting::card::{CardExpectations, new_commitment_salt, verify_card};
use vta_sdk::vetting::match_code::vetting_match_code;
use vta_sdk::vetting::statement::{StatementDraft, verify_statement};
use super::book::{VetterPolicy, VettingBook};
use super::tickets::{self, Redemption};
use crate::config::account::PersonaId;
#[derive(Debug, thiserror::Error)]
pub enum VetterError {
#[error("no such vetting request")]
NoSuchRequest,
#[error("the request cannot {0} in its current state")]
WrongState(&'static str),
#[error(transparent)]
Shape(#[from] ShapeError),
#[error(transparent)]
Vetting(#[from] VettingError),
#[error("this session predates statement citations — open the session again")]
SessionDocumentMissing,
#[error("confirm the match code with the person before attesting")]
LivenessNotConfirmed,
#[error("say which documentation you relied on, or `none`")]
NoDocumentation,
#[error("the card has no `{0}` claim, so it cannot be marked verified")]
ClaimNotOnCard(String),
#[error("`{0}` is required and has not been verified")]
RequiredClaimNotVerified(String),
#[error("no statement of ours has that id")]
NoSuchStatement,
#[error("the community has already recorded this withdrawal")]
AlreadyWithdrawn,
#[error(transparent)]
Hidden(#[from] crate::vetting::hidden::HiddenError),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeskEntry {
pub request_id: String,
pub request_document_id: String,
pub applicant: String,
pub community: String,
pub persona: PersonaId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ticket_id: Option<String>,
pub request: request::v0_1::Payload,
pub state: DeskState,
pub received_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum DeskState {
Accepted,
Session {
session: DeskSession,
},
CardReceived {
session: DeskSession,
card: ReceivedCard,
},
Attested {
statement_id: String,
issued_at: DateTime<Utc>,
card: ReceivedCard,
},
Declined {
#[serde(default, skip_serializing_if = "Option::is_none")]
code: Option<decline::v0_1::PayloadCode>,
at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
card: Option<ReceivedCard>,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DeskSession {
pub id: String,
pub challenge: String,
pub method: VettingMethod,
pub required_claims: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub optional_claims: Vec<String>,
pub expires_at: DateTime<Utc>,
pub match_code: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub document: Option<Box<Value>>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ReceivedCard {
pub digest_multibase: String,
pub identity_commitment: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub claims: Vec<session::v0_1::VettingCardClaim>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub card: Option<Value>,
pub received_at: DateTime<Utc>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct IssuedStatement {
pub id: String,
pub digest_multibase: String,
pub applicant: String,
pub community: String,
pub persona: PersonaId,
pub method: VettingMethod,
pub issued_at: DateTime<Utc>,
pub valid_until: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub withdrawal: Option<Withdrawal>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Withdrawal {
pub document_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<revoke_statement::v0_1::PayloadReason>,
pub sent_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recorded_at: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug)]
pub struct IncomingRequest<'a> {
pub document_id: &'a str,
pub sender: &'a str,
pub persona: PersonaId,
pub body: request::v0_1::Payload,
pub eligible: bool,
}
#[derive(Clone, Debug)]
pub enum Intake {
Accepted(Box<request::v0_1::Response>),
Refused(&'static str),
Silent,
}
#[derive(Clone, Debug)]
pub struct Attestation {
pub method: VettingMethod,
pub document_classes: Vec<String>,
pub claims_verified: Vec<String>,
pub liveness_confirmed: bool,
pub declared_relationship: VettingRelationship,
pub attestation_text_digest: Option<String>,
}
impl DeskEntry {
#[must_use]
pub fn match_code(&self) -> Option<&str> {
match &self.state {
DeskState::Session { session } | DeskState::CardReceived { session, .. } => {
Some(&session.match_code)
}
_ => None,
}
}
pub(crate) fn expire_session(&mut self, now: DateTime<Utc>) -> bool {
if let DeskState::Session { session } = &self.state
&& session.expires_at <= now
{
self.state = DeskState::Accepted;
self.updated_at = now;
return true;
}
false
}
pub(crate) fn forget_card_after(&mut self, retention: Duration, now: DateTime<Utc>) -> bool {
let (closed_at, card) = match &mut self.state {
DeskState::Attested {
issued_at, card, ..
} => (*issued_at, Some(card)),
DeskState::Declined { at, card, .. } => (*at, card.as_mut()),
_ => return false,
};
match card {
Some(card) if now - closed_at >= retention && card.card.is_some() => {
card.card = None;
card.claims.clear();
true
}
_ => false,
}
}
}
fn accepted_reply(
entry: &DeskEntry,
policy: &VetterPolicy,
) -> Result<request::v0_1::Response, ShapeError> {
let schema = |e: &dyn std::fmt::Display| ShapeError::Schema(e.to_string());
let mut accepted: Vec<request::v0_1::VettingDocumentation> = Vec::new();
for token in &policy.accepts_documentation {
let parsed = request::v0_1::VettingDocumentation::try_from(token.as_str())
.map_err(|e| schema(&e))?;
if !accepted.iter().any(|d| d.as_str() == parsed.as_str()) {
accepted.push(parsed);
}
}
request::v0_1::Response::try_from(
request::v0_1::Response::builder()
.request_id(entry.request_id.as_str())
.accepts_documentation((!accepted.is_empty()).then_some(accepted)),
)
.map_err(|e| schema(&e))
}
fn accept(entry: &DeskEntry, policy: &VetterPolicy) -> Intake {
match accepted_reply(entry, policy) {
Ok(body) => Intake::Accepted(Box::new(body)),
Err(_) => Intake::Refused(VETTING_REQUEST_ERR_DECLINED),
}
}
impl VettingBook {
pub fn take_request(&mut self, incoming: IncomingRequest<'_>, now: DateTime<Utc>) -> Intake {
let IncomingRequest {
document_id,
sender,
persona,
body,
eligible,
} = incoming;
if check_request(&body, sender).is_err() {
return Intake::Silent;
}
if let Some(existing) = self
.desk
.iter()
.find(|e| e.request_document_id == document_id && e.applicant == sender)
{
return accept(existing, &self.policy);
}
let Some(ticket) = body.ticket.as_ref() else {
return if body.introduction.is_empty() {
Intake::Silent
} else {
Intake::Refused(VETTING_REQUEST_ERR_DECLINED)
};
};
let ticket_id = match tickets::check(
&self.tickets,
&mut self.throttle,
ticket,
sender,
body.community.as_str(),
persona,
now,
) {
Redemption::Matched { ticket_id } => ticket_id,
Redemption::Silent => return Intake::Silent,
Redemption::Refused(code) => return Intake::Refused(code),
};
let preferred = body
.preferred_method
.as_ref()
.and_then(|m| super::same_token::<_, VettingMethod>(m).ok());
if !self
.tickets
.iter()
.find(|t| t.id == ticket_id)
.is_some_and(|t| t.offers(preferred))
{
return Intake::Refused(VETTING_REQUEST_ERR_METHOD_UNAVAILABLE);
}
if !eligible {
return Intake::Refused(VETTING_REQUEST_ERR_NOT_ELIGIBLE);
}
if self.open_requests(persona) >= self.policy.max_open_requests {
return Intake::Refused(VETTING_REQUEST_ERR_CAPACITY);
}
tickets::consume(&mut self.tickets, &ticket_id);
let entry = DeskEntry {
request_id: Uuid::new_v4().to_string(),
request_document_id: document_id.to_string(),
applicant: sender.to_string(),
community: body.community.as_str().to_string(),
persona,
ticket_id: Some(ticket_id),
request: body,
state: DeskState::Accepted,
received_at: now,
updated_at: now,
};
let reply = accept(&entry, &self.policy);
self.desk.push(entry);
reply
}
pub fn open_session(
&mut self,
request_id: &str,
method: VettingMethod,
required_claims: Vec<String>,
optional_claims: Vec<String>,
session_document_id: &str,
now: DateTime<Utc>,
) -> Result<session::v0_1::Payload, VetterError> {
let length = self.policy.session_length();
let entry = self
.desk_entry_mut(request_id)
.ok_or(VetterError::NoSuchRequest)?;
if !entry.state.is_open() {
return Err(VetterError::WrongState("open a session"));
}
let schema = |e: &dyn std::fmt::Display| ShapeError::Schema(e.to_string());
let challenge = new_commitment_salt()?;
let task_method = super::same_token::<_, session::v0_1::VettingMethod>(&method)
.map_err(|e| schema(&e))?;
let required = required_claims
.iter()
.map(|c| {
session::v0_1::PayloadRequiredClaimsItem::try_from(c.as_str())
.map_err(|e| schema(&e))
})
.collect::<Result<Vec<_>, ShapeError>>()?;
let optional = optional_claims
.iter()
.map(|c| {
session::v0_1::PayloadOptionalClaimsItem::try_from(c.as_str())
.map_err(|e| schema(&e))
})
.collect::<Result<Vec<_>, ShapeError>>()?;
let body = session::v0_1::Payload::try_from(
session::v0_1::Payload::builder()
.request_id(request_id)
.challenge(challenge.as_str())
.domain(entry.community.as_str())
.method(task_method)
.required_claims(required)
.optional_claims((!optional.is_empty()).then_some(optional))
.expires_at(now + length),
)
.map_err(|e| schema(&e))?;
body.check_shape()?;
entry.state = DeskState::Session {
session: DeskSession {
id: session_document_id.to_string(),
challenge,
method,
required_claims: required_claims.clone(),
optional_claims: optional_claims.clone(),
expires_at: body.expires_at,
match_code: vetting_match_code(session_document_id),
document: None,
},
};
entry.updated_at = now;
Ok(body)
}
pub fn record_session_document(
&mut self,
request_id: &str,
document: Value,
) -> Result<(), VetterError> {
let entry = self
.desk_entry_mut(request_id)
.ok_or(VetterError::NoSuchRequest)?;
let DeskState::Session { session } = &mut entry.state else {
return Err(VetterError::WrongState("record its session document"));
};
if document.get("id").and_then(Value::as_str) != Some(session.id.as_str()) {
return Err(VetterError::WrongState("record another session's document"));
}
session.document = Some(Box::new(document));
Ok(())
}
pub async fn receive_card(
&mut self,
vetter_did: &str,
applicant: &str,
session_id: &str,
card: &Value,
resolver: &TrustTaskVmResolver,
now: DateTime<Utc>,
) -> Result<&DeskEntry, VetterError> {
let entry = self
.desk
.iter_mut()
.find(|e| {
e.applicant == applicant
&& matches!(&e.state, DeskState::Session { session } if session.id == session_id)
})
.ok_or(VetterError::NoSuchRequest)?;
let DeskState::Session { session } = &entry.state else {
unreachable!("matched a session above");
};
let session = session.clone();
if session.expires_at <= now {
return Err(VetterError::WrongState(
"take a card after its session closed",
));
}
let verified = verify_card(
card,
&CardExpectations {
audience: vetter_did,
publisher: applicant,
community: &entry.community,
challenge: &session.challenge,
domain: &entry.community,
required_claims: &session.required_claims,
now,
},
resolver,
)
.await?;
let received = ReceivedCard {
digest_multibase: verified.digest_multibase().to_string(),
identity_commitment: verified.card().identity_commitment.as_str().to_string(),
claims: verified.card().claims.clone(),
card: Some(card.clone()),
received_at: now,
};
entry.state = DeskState::CardReceived {
session,
card: received,
};
entry.updated_at = now;
Ok(entry)
}
pub fn statement_draft(
&self,
request_id: &str,
vetter_did: &str,
attestation: Attestation,
now: DateTime<Utc>,
) -> Result<StatementDraft, VetterError> {
let entry = self
.desk_entry(request_id)
.ok_or(VetterError::NoSuchRequest)?;
let DeskState::CardReceived { session, card } = &entry.state else {
return Err(VetterError::WrongState("be attested"));
};
let schema = |e: &dyn std::fmt::Display| ShapeError::Schema(e.to_string());
let documentary = attestation.method != VettingMethod::PriorAcquaintance;
if documentary && !attestation.liveness_confirmed {
return Err(VetterError::LivenessNotConfirmed);
}
let document_classes = attestation
.document_classes
.iter()
.filter(|d| d.as_str() != documentation::NONE)
.map(|d| VettingDocumentation::try_from(d.as_str()).map_err(|e| schema(&e)))
.collect::<Result<Vec<_>, ShapeError>>()?;
if documentary && document_classes.is_empty() {
return Err(VetterError::NoDocumentation);
}
if let Some(missing) = attestation
.claims_verified
.iter()
.find(|t| !card.claims.iter().any(|c| c.type_.as_str() == t.as_str()))
{
return Err(VetterError::ClaimNotOnCard(missing.clone()));
}
if let Some(unverified) = session
.required_claims
.iter()
.find(|t| !attestation.claims_verified.contains(t))
{
return Err(VetterError::RequiredClaimNotVerified(unverified.clone()));
}
let claims_verified = attestation
.claims_verified
.iter()
.map(|c| ClaimType::try_from(c.as_str()).map_err(|e| schema(&e)))
.collect::<Result<Vec<_>, ShapeError>>()?;
let session_document = session
.document
.as_deref()
.cloned()
.ok_or(VetterError::SessionDocumentMissing)?;
let value = VettedObjectValue {
community: entry.community.clone(),
method: attestation.method,
document_classes,
claims_verified,
liveness_confirmed: attestation.liveness_confirmed,
identity_commitment: Some(card.identity_commitment.clone()),
card_digest_multibase: Some(card.digest_multibase.clone()),
declared_relationship: Some(attestation.declared_relationship),
attestation_text_digest: attestation.attestation_text_digest,
};
value.check_shape()?;
Ok(StatementDraft {
id: format!("urn:uuid:{}", Uuid::new_v4()),
issuer: vetter_did.to_string(),
issuer_scope: crate::dtg::MEMBER_IDENTIFIER_SCOPE,
subject: entry.applicant.clone(),
value,
valid_from: now,
valid_until: now + self.policy.statement_validity(),
session: session_document,
})
}
pub async fn record_statement(
&mut self,
request_id: &str,
signed: &Value,
resolver: &TrustTaskVmResolver,
now: DateTime<Utc>,
) -> Result<IssuedStatement, VetterError> {
let verified = verify_statement(signed, now, resolver).await?;
let entry = self
.desk_entry_mut(request_id)
.ok_or(VetterError::NoSuchRequest)?;
let DeskState::CardReceived { card, .. } = &entry.state else {
return Err(VetterError::WrongState("be attested"));
};
let issued = IssuedStatement {
id: verified.id().to_string(),
digest_multibase: verified.digest_multibase().to_string(),
applicant: entry.applicant.clone(),
community: entry.community.clone(),
persona: entry.persona,
method: verified.value().method,
issued_at: now,
valid_until: verified.valid_until(),
withdrawal: None,
};
entry.state = DeskState::Attested {
statement_id: issued.id.clone(),
issued_at: now,
card: card.clone(),
};
entry.updated_at = now;
self.issued.push(issued.clone());
Ok(issued)
}
pub fn decline(
&mut self,
request_id: &str,
code: Option<decline::v0_1::PayloadCode>,
message: Option<String>,
now: DateTime<Utc>,
) -> Result<decline::v0_1::Payload, VetterError> {
let entry = self
.desk_entry_mut(request_id)
.ok_or(VetterError::NoSuchRequest)?;
if !entry.state.is_open() {
return Err(VetterError::WrongState("be declined"));
}
let schema = |e: &dyn std::fmt::Display| ShapeError::Schema(e.to_string());
let message = message
.map(|m| decline::v0_1::PayloadMessage::try_from(m).map_err(|e| schema(&e)))
.transpose()?;
let body = decline::v0_1::Payload::try_from(
decline::v0_1::Payload::builder()
.request_id(request_id)
.code(code)
.message(message),
)
.map_err(|e| schema(&e))?;
body.check_shape()?;
let card = match &entry.state {
DeskState::CardReceived { card, .. } => Some(card.clone()),
_ => None,
};
entry.state = DeskState::Declined {
code,
at: now,
card,
};
entry.updated_at = now;
Ok(body)
}
pub fn withdrawal(
&mut self,
statement_id: &str,
reason: Option<revoke_statement::v0_1::PayloadReason>,
document_id: &str,
now: DateTime<Utc>,
) -> Result<(revoke_statement::v0_1::Payload, IssuedStatement), VetterError> {
let issued = self
.issued
.iter_mut()
.find(|s| s.id == statement_id)
.ok_or(VetterError::NoSuchStatement)?;
if issued
.withdrawal
.as_ref()
.is_some_and(|w| w.recorded_at.is_some())
{
return Err(VetterError::AlreadyWithdrawn);
}
let schema = |e: &dyn std::fmt::Display| ShapeError::Schema(e.to_string());
let body = revoke_statement::v0_1::Payload::try_from(
revoke_statement::v0_1::Payload::builder()
.statement_id(issued.id.as_str())
.statement_digest_multibase(issued.digest_multibase.as_str())
.reason(reason),
)
.map_err(|e| schema(&e))?;
body.check_shape()?;
issued.withdrawal = Some(Withdrawal {
document_id: document_id.to_string(),
reason,
sent_at: now,
recorded_at: None,
});
Ok((body, issued.clone()))
}
pub fn on_withdrawal_recorded(
&mut self,
community: &str,
document_id: &str,
recorded_at: DateTime<Utc>,
) -> Option<&IssuedStatement> {
let issued = self.issued.iter_mut().find(|s| {
s.community == community
&& s.withdrawal
.as_ref()
.is_some_and(|w| w.document_id == document_id)
})?;
if let Some(w) = issued.withdrawal.as_mut() {
w.recorded_at = Some(recorded_at);
}
Some(issued)
}
}