use super::protocol::{Admission, CriterionMeta, JoinProtocol};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use vta_sdk::protocols::join_requests::manifest;
use vta_sdk::protocols::vetting::{CheckShape, VettingRequirements, documentation};
use super::applicant::{Application, RequestState, VettingPath};
use super::queries::CommunityQuery;
use super::registry::VetterProfileRecord;
use super::tickets::{GuessThrottle, Ticket};
use super::vetter::{DeskEntry, DeskState, IssuedStatement, VetterError};
use crate::config::account::{Account, CommunityRecord, PersonaId};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct VetterPolicy {
pub max_open_requests: usize,
pub session_minutes: i64,
pub statement_validity_days: i64,
pub card_retention_days: i64,
pub accepts_documentation: Vec<String>,
}
impl Default for VetterPolicy {
fn default() -> Self {
Self {
max_open_requests: 10,
session_minutes: 15,
statement_validity_days: 180,
card_retention_days: 0,
accepts_documentation: vec![
documentation::PASSPORT.into(),
documentation::NATIONAL_ID.into(),
documentation::DRIVER_LICENCE.into(),
],
}
}
}
impl VetterPolicy {
fn is_default(&self) -> bool {
self == &Self::default()
}
#[must_use]
pub fn session_length(&self) -> Duration {
Duration::minutes(self.session_minutes.clamp(1, 60))
}
#[must_use]
pub fn statement_validity(&self) -> Duration {
Duration::days(self.statement_validity_days.max(1))
}
}
pub const FALLBACK_REQUIRED_CLAIMS: &[&str] = &["name.legal"];
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KnownCriterion {
pub community: String,
pub criterion_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requirements_digest: Option<String>,
pub requirements: VettingRequirements,
pub fetched_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
impl KnownCriterion {
#[must_use]
pub fn hidden_vetting(&self) -> bool {
self.hidden_params().is_some()
}
#[must_use]
pub fn as_raw(&self) -> serde_json::Value {
serde_json::json!({
"id": self.criterion_id,
"vetting": serde_json::to_value(&self.requirements).unwrap_or(serde_json::Value::Null),
})
}
#[must_use]
pub fn hidden_params(&self) -> Option<super::hidden::HiddenParams> {
match super::hidden::read_mode(&self.as_raw()) {
Ok(super::hidden::Mode::Hidden(p)) => Some(*p),
_ => None,
}
}
#[must_use]
pub fn paths(&self) -> CriterionPaths {
match (
self.hidden_vetting(),
super::hidden::accepts_named(&self.as_raw()),
) {
(false, _) => CriterionPaths::Named,
(true, true) => CriterionPaths::Either,
(true, false) => CriterionPaths::HiddenOnly,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CriterionPaths {
Named,
Either,
HiddenOnly,
}
impl CriterionPaths {
#[must_use]
pub fn words(self) -> &'static str {
match self {
CriterionPaths::Named => "named vetting",
CriterionPaths::Either => "PCS ZKP or named vetting",
CriterionPaths::HiddenOnly => "PCS ZKP only",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VettingOption {
pub criterion_id: String,
pub description: Option<String>,
pub path: VettingPath,
pub paths: CriterionPaths,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RequestVetting {
Hidden,
Named {
criterion: Option<String>,
},
HiddenWithoutId {
criterion: String,
},
Unreadable(super::hidden::HiddenError),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct VettedRecord {
pub community: String,
pub closed_at: DateTime<Utc>,
pub outcome: VettedOutcome,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VettedOutcome {
Signed { statement_id: String },
Declined,
}
pub const VETTER_REFRESH_RETRIES: u8 = 12;
pub const CLOSED_GRACE: Duration = Duration::minutes(10);
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct VetterGrant {
pub community: String,
pub persona: PersonaId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credential_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub valid_until: Option<DateTime<Utc>>,
pub received_at: DateTime<Utc>,
pub credential: serde_json::Value,
}
pub const MAX_PENDING_ENROLMENTS: usize = 8;
#[derive(Clone)]
pub struct PendingEnrolment {
pub document_id: String,
pub community: String,
pub persona: PersonaId,
pub blinding: std::sync::Arc<openvtc_vetting_pcs::vetter::EnrolmentBlinding>,
}
impl std::fmt::Debug for PendingEnrolment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PendingEnrolment")
.field("document_id", &self.document_id)
.field("community", &self.community)
.field("persona", &self.persona)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct HiddenVetterState {
pub community: String,
pub persona: PersonaId,
pub params: super::hidden::HiddenParams,
pub snapshot: openvtc_vetting_pcs::snapshot::VetterSnapshot,
#[serde(default)]
pub last_ticks: std::collections::BTreeMap<String, u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_drawn_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub events: Vec<HiddenEventState>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub enrolled_at: std::collections::BTreeMap<String, DateTime<Utc>>,
#[serde(default, skip_serializing_if = "is_zero")]
pub tokens_spent: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_draw: Option<HiddenDraw>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_refusal: Option<HiddenRefusal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rekeyed_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "is_zero")]
pub unanswered: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lost_enrolment: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub lost_reasked: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub enrolments_asked: Vec<AskedEnrolment>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub params_read_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub over_quota: Option<OverQuota>,
#[serde(skip)]
pub awaiting_read: bool,
}
pub const PARAMS_FRESH_FOR: chrono::Duration = super::mode::MODE_TTL;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct OverQuota {
pub label: String,
pub asked: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowed: Option<usize>,
pub at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DrawHold {
Reading,
Unread,
Disagrees {
label: String,
asked: usize,
published: usize,
},
}
#[must_use]
pub fn over_quota_allowed(detail: &str) -> Option<usize> {
number_after(detail, "drips ")
}
#[must_use]
pub fn over_quota_asked(detail: &str) -> Option<usize> {
number_after(detail, "asked for ")
}
fn number_after(text: &str, marker: &str) -> Option<usize> {
let rest = &text[text.find(marker)? + marker.len()..];
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
digits.parse().ok()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AskedEnrolment {
pub document_id: String,
pub period: String,
pub blinding: String,
pub asked_at: DateTime<Utc>,
}
fn is_zero(n: &u32) -> bool {
*n == 0
}
pub const HIDDEN_RETRY_FIRST: chrono::Duration = chrono::Duration::minutes(1);
pub const HIDDEN_RETRY_CAP: chrono::Duration = chrono::Duration::hours(1);
#[must_use]
pub fn hidden_retry_after(n: u32) -> chrono::Duration {
let doublings = n.saturating_sub(1).min(16);
(HIDDEN_RETRY_FIRST * 2_i32.pow(doublings)).min(HIDDEN_RETRY_CAP)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HiddenOutlook {
pub usable: usize,
pub enrolled: bool,
pub owed: Option<String>,
pub enrolment_lost: bool,
pub asking: bool,
pub unanswered: u32,
pub retry_at: Option<DateTime<Utc>>,
pub next_window: Option<DateTime<Utc>>,
pub rekeyed: bool,
pub events_offered: bool,
pub last_refusal: Option<HiddenRefusal>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct HiddenDraw {
pub label: String,
pub tick: u32,
pub taken: usize,
pub at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct HiddenRefusal {
pub what: String,
pub code: String,
pub at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HiddenPlan {
pub adopted: Adopted,
pub settled: bool,
pub owed: Vec<super::hidden::Due>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Adopted {
Unchanged,
Updated,
Rekeyed {
first: bool,
},
}
impl HiddenVetterState {
#[must_use]
pub fn new(
community: impl Into<String>,
persona: PersonaId,
params: super::hidden::HiddenParams,
snapshot: openvtc_vetting_pcs::snapshot::VetterSnapshot,
) -> Self {
Self {
community: community.into(),
persona,
params,
snapshot,
last_ticks: std::collections::BTreeMap::new(),
last_drawn_at: None,
events: Vec::new(),
enrolled_at: std::collections::BTreeMap::new(),
tokens_spent: 0,
last_draw: None,
last_refusal: None,
retry_at: None,
rekeyed_at: None,
unanswered: 0,
lost_enrolment: None,
lost_reasked: false,
enrolments_asked: Vec::new(),
params_read_at: None,
over_quota: None,
awaiting_read: false,
}
}
pub fn take_reading(
&mut self,
live: &super::hidden::HiddenParams,
now: DateTime<Utc>,
) -> Adopted {
if !self.params.same_keys(live) {
return Adopted::Unchanged;
}
let adopted = self.adopt_published(live, now);
self.params_read_at = Some(now);
if adopted == Adopted::Updated {
self.over_quota = None;
}
adopted
}
#[must_use]
pub fn rate_for(&self, label: &str, today: chrono::NaiveDate) -> usize {
let published = self.published_rate(label, today);
match &self.over_quota {
Some(q) if q.label == label => q.allowed.map_or(published, |m| published.min(m)),
_ => published,
}
}
fn published_rate(&self, label: &str, today: chrono::NaiveDate) -> usize {
self.event_draws(today)
.into_iter()
.find(|e| e.label == label)
.map_or(self.params.drip_per_tick, |e| e.rate)
}
pub fn refused_over_quota(&mut self, label: &str, detail: Option<&str>, now: DateTime<Utc>) {
let current = self.rate_for(label, now.date_naive());
let asked = detail.and_then(over_quota_asked).unwrap_or(current);
let allowed = detail.and_then(over_quota_allowed);
let reread_owed = self.reread_owed();
if let Some(q) = &mut self.over_quota
&& q.label == label
&& reread_owed
{
q.allowed = allowed.or(q.allowed);
return;
}
if asked != current {
return;
}
self.over_quota = Some(OverQuota {
label: label.to_string(),
asked,
allowed,
at: now,
});
self.awaiting_read = true;
}
#[must_use]
pub fn reread_owed(&self) -> bool {
self.over_quota
.as_ref()
.is_some_and(|q| self.params_read_at.is_none_or(|r| r <= q.at))
}
#[must_use]
pub fn draw_hold(&self, reading: bool, now: DateTime<Utc>) -> Option<DrawHold> {
if reading {
return Some(DrawHold::Reading);
}
let fresh = self
.params_read_at
.is_some_and(|r| now - r <= PARAMS_FRESH_FOR);
if !fresh || self.reread_owed() {
return Some(DrawHold::Unread);
}
let q = self.over_quota.as_ref()?;
let rate = self.rate_for(&q.label, now.date_naive());
(rate >= q.asked).then(|| DrawHold::Disagrees {
label: q.label.clone(),
asked: q.asked,
published: self.published_rate(&q.label, now.date_naive()),
})
}
pub fn remember_asked(&mut self, asked: AskedEnrolment) {
self.enrolments_asked
.retain(|a| a.document_id != asked.document_id);
self.enrolments_asked.push(asked);
let excess = self
.enrolments_asked
.len()
.saturating_sub(MAX_PENDING_ENROLMENTS);
self.enrolments_asked.drain(..excess);
}
pub fn take_asked(&mut self, thread: &str) -> Option<AskedEnrolment> {
let i = self
.enrolments_asked
.iter()
.position(|a| a.document_id == thread)?;
Some(self.enrolments_asked.remove(i))
}
#[must_use]
pub fn enrolment_owed(&self) -> Option<String> {
let period = self
.params
.vetter_labels
.first()?
.trim_start_matches("vetter/")
.to_string();
(!self.snapshot.credentials.contains_key(&period)).then_some(period)
}
pub fn unanswered_at(&mut self, now: DateTime<Utc>) -> DateTime<Utc> {
self.unanswered = self.unanswered.saturating_add(1);
let retry = now + hidden_retry_after(self.unanswered);
self.retry_at = Some(retry);
retry
}
pub fn answered(&mut self) {
if self.unanswered > 0 {
self.unanswered = 0;
self.retry_at = None;
}
}
pub fn adopt_published(
&mut self,
live: &super::hidden::HiddenParams,
now: DateTime<Utc>,
) -> Adopted {
if !self.params.same_keys(live) {
let first = self.rekeyed_at.is_none();
self.rekeyed_at.get_or_insert(now);
return Adopted::Rekeyed { first };
}
let mut changed = self.rekeyed_at.take().is_some();
if self.params != *live {
self.params = live.clone();
changed = true;
}
if changed {
Adopted::Updated
} else {
Adopted::Unchanged
}
}
pub fn plan(
&mut self,
live: Option<&super::hidden::HiddenParams>,
in_flight: &[(String, u32)],
now: DateTime<Utc>,
) -> HiddenPlan {
use super::hidden::{Due, MAX_DRAWS_PER_PASS, due, settle_ticks};
let adopted = live.map_or(Adopted::Unchanged, |live| self.adopt_published(live, now));
let mut plan = HiddenPlan {
adopted,
settled: false,
owed: Vec::new(),
};
if self.rekeyed_at.is_some() {
return plan;
}
let events = self.event_draws(now.date_naive());
plan.settled = settle_ticks(&mut self.last_ticks, &self.params, &events, now);
if self.retry_at.is_some_and(|t| t > now) {
return plan;
}
let mut ticks = self.last_ticks.clone();
for (label, tick) in in_flight {
let entry = ticks.entry(label.clone()).or_insert(*tick);
*entry = (*entry).max(*tick);
}
while plan.owed.len() < MAX_DRAWS_PER_PASS {
match due(&self.params, Some(&self.snapshot), &ticks, &events, now) {
Due::Nothing => break,
Due::Enrol { period } if self.lost_enrolment.as_deref() == Some(&period) => {
if !self.lost_reasked {
self.lost_reasked = true;
plan.owed.push(Due::Enrol { period });
}
break;
}
enrol @ Due::Enrol { .. } => {
plan.owed.push(enrol);
break;
}
Due::Draw { label, tick, rate } => {
ticks.insert(label.clone(), tick);
let rate = match &self.over_quota {
Some(q) if q.label == label => q.allowed.map_or(rate, |m| rate.min(m)),
_ => rate,
};
plan.owed.push(Due::Draw { label, tick, rate });
}
}
}
plan
}
pub fn record_served(&mut self, label: &str, tick: u32) {
let entry = self.last_ticks.entry(label.to_string()).or_insert(tick);
*entry = (*entry).max(tick);
}
#[must_use]
pub fn tokens(&self) -> (usize, usize) {
let held = self.snapshot.tokens.len();
let usable = self
.snapshot
.tokens
.iter()
.filter(|t| self.params.token_labels.contains(&t.label))
.count();
(held, usable)
}
#[must_use]
pub fn event_draws(&self, today: chrono::NaiveDate) -> Vec<super::hidden::EventDraw> {
self.events
.iter()
.filter(|e| e.state == super::wire::pcs::EVENT_APPROVED)
.filter_map(|e| {
let label = e.label.clone()?;
let closes_after = e.closes_after?;
let opens_on = self
.params
.events
.iter()
.find(|o| o.event_id == e.event_id)?
.start_date;
(today <= closes_after).then_some(super::hidden::EventDraw {
label,
rate: e.drip_per_tick.unwrap_or(self.params.drip_per_tick),
opens_on,
closes_after,
})
})
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct HiddenEventState {
pub event_id: String,
pub tier: String,
pub state: String,
pub group_size: usize,
pub group_floor: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub drip_per_tick: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub closes_after: Option<chrono::NaiveDate>,
pub answered_at: DateTime<Utc>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct VetterStanding {
pub community: String,
pub persona: PersonaId,
pub valid_until: Option<DateTime<Utc>>,
pub live: bool,
pub expiring: bool,
pub profile: Option<super::registry::ProfileState>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GrantWarning {
pub community: String,
pub persona: PersonaId,
pub expired: bool,
pub valid_until: DateTime<Utc>,
}
impl GrantWarning {
#[must_use]
pub fn id(&self) -> String {
format!(
"vetter-grant:{}:{}:{}:{}",
self.community,
self.persona,
self.valid_until.to_rfc3339(),
if self.expired { "expired" } else { "expiring" }
)
}
}
pub const GRANT_EXPIRY_WARNING_DAYS: i64 = 14;
impl VetterGrant {
#[must_use]
pub fn is_live(&self, now: DateTime<Utc>) -> bool {
self.valid_until.is_some_and(|until| until > now)
}
#[must_use]
pub fn is_expiring(&self, now: DateTime<Utc>) -> bool {
self.is_live(now)
&& self.valid_until.is_some_and(|until| {
until <= now + chrono::TimeDelta::days(GRANT_EXPIRY_WARNING_DAYS)
})
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Branding {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accent_color: Option<String>,
}
impl Branding {
fn is_default(&self) -> bool {
self == &Self::default()
}
fn from_manifest(branding: &manifest::v0_2::CommunityBranding) -> Self {
if branding.check_shape().is_err() {
return Self::default();
}
Self {
display_name: branding
.display_name
.as_ref()
.map(|n| n.as_str().to_string()),
accent_color: branding
.accent_color
.as_ref()
.map(|c| c.as_str().to_string()),
}
}
#[must_use]
pub fn accent_rgb(&self) -> Option<(u8, u8, u8)> {
self.accent_color.as_deref().and_then(parse_accent)
}
}
#[must_use]
pub fn parse_accent(color: &str) -> Option<(u8, u8, u8)> {
let hex = color.strip_prefix('#')?;
if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let channel = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
Some((channel(0)?, channel(2)?, channel(4)?))
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KnownCommunity {
pub community: String,
#[serde(default, skip_serializing_if = "Branding::is_default")]
pub branding: Branding,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requested: Vec<crate::persona::join_answers::Asked>,
pub fetched_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub protocol: Option<JoinProtocol>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub routes: Vec<CommunityCriterion>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub post_quantum_key: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vetter_mode: Option<super::mode::ModeRead>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CommunityCriterion {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requirements_digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub admission: Option<Admission>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub invitation_required: Option<bool>,
#[serde(default)]
pub vetting: bool,
}
#[derive(Clone, Copy, Debug)]
pub enum Knowledge<'a> {
Unknown,
NoVetting,
Vetting(&'a KnownCriterion),
}
#[must_use]
pub fn same_requirements(a: &VettingRequirements, b: &VettingRequirements) -> bool {
serde_json::to_value(a).ok() == serde_json::to_value(b).ok()
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct VettingBook {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub applications: Vec<Application>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tickets: Vec<Ticket>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub desk: Vec<DeskEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub issued: Vec<IssuedStatement>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub vetted: Vec<VettedRecord>,
#[serde(skip)]
pub vetter_refresh_due: bool,
#[serde(skip)]
pub vetter_refresh_failures: u8,
#[serde(default, skip_serializing_if = "GuessThrottle::is_empty")]
pub throttle: GuessThrottle,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub criteria: Vec<KnownCriterion>,
#[serde(default, skip_serializing_if = "VetterPolicy::is_default")]
pub policy: VetterPolicy,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub vetter_grants: Vec<VetterGrant>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub grant_warnings: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub communities: Vec<KnownCommunity>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub protocol_hints: std::collections::BTreeMap<String, JoinProtocol>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub vetter_profiles: Vec<VetterProfileRecord>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hidden_vetter: Vec<HiddenVetterState>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub hidden_published: std::collections::BTreeMap<String, super::hidden::HiddenParams>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub retired: Vec<String>,
#[serde(skip)]
pub queries: Vec<CommunityQuery>,
#[serde(skip)]
pub pending_enrolments: Vec<PendingEnrolment>,
#[serde(skip)]
pub draws_in_flight: std::collections::HashMap<String, (String, u32)>,
#[serde(skip)]
pub mode_checks: super::mode::ModeChecks,
#[serde(skip)]
pub mode_switches: Vec<super::mode::ModeSwitch>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl VettingBook {
pub fn retire_nonconformant(&mut self) -> usize {
let mut notes = Vec::new();
self.vetter_grants.retain(|grant| {
match crate::dtg::nonconformance(&grant.credential) {
None => true,
Some(reason) => {
tracing::warn!(
community = %grant.community,
%reason,
"dropping a stored vetter role credential that does not conform to DTG Credentials v1"
);
notes.push(format!(
"Your vetter role credential from {} pre-dates DTG Credentials v1 and \
was set aside. Ask the community to grant the vetter role again.",
grant.community
));
false
}
}
});
for application in &mut self.applications {
let community = application.community.clone();
application.statements.retain(|held| {
match crate::dtg::nonconformance(&held.credential) {
None => true,
Some(reason) => {
tracing::warn!(
%community,
vetter = %held.vetter,
%reason,
"dropping a stored vetting statement that does not conform to DTG Credentials v1"
);
notes.push(format!(
"A vetting statement from {} for {community} pre-dates DTG \
Credentials v1 and was set aside. Ask to be vetted again.",
held.vetter
));
false
}
}
});
}
let count = notes.len();
for note in notes {
if !self.retired.contains(¬e) {
self.retired.push(note);
}
}
count
}
#[must_use]
pub fn hidden_vetter(&self, community: &str, persona: PersonaId) -> Option<&HiddenVetterState> {
self.hidden_vetter
.iter()
.find(|h| h.community == community && h.persona == persona)
}
pub fn remember_enrolment(&mut self, pending: PendingEnrolment) {
self.pending_enrolments
.retain(|p| p.document_id != pending.document_id);
self.pending_enrolments.push(pending);
let excess = self
.pending_enrolments
.len()
.saturating_sub(MAX_PENDING_ENROLMENTS);
self.pending_enrolments.drain(..excess);
}
pub fn take_enrolment(&mut self, community: &str, thread: &str) -> Option<PendingEnrolment> {
let i = self
.pending_enrolments
.iter()
.position(|p| p.document_id == thread && p.community == community)?;
Some(self.pending_enrolments.remove(i))
}
#[must_use]
pub fn hidden_outlook(
&self,
community: &str,
persona: PersonaId,
now: DateTime<Utc>,
) -> Option<HiddenOutlook> {
use super::queries::QueryKind;
let held = self.hidden_vetter(community, persona)?;
let owed = held.enrolment_owed();
let events = held.event_draws(now.date_naive());
let refused_as_enrolled = held.last_refusal.as_ref().is_some_and(|r| {
r.code.ends_with(":alreadyEnrolled")
&& owed.as_deref() == Some(r.at.format("%Y-%m").to_string().as_str())
});
let asking = self.queries.iter().any(|q| {
q.community == community
&& q.persona == persona
&& matches!(q.kind, QueryKind::PcsRoot | QueryKind::PcsTokens)
});
Some(HiddenOutlook {
usable: held.tokens().1,
enrolled: owed.is_none(),
enrolment_lost: owed.is_some()
&& ((owed == held.lost_enrolment && held.lost_reasked && !asking)
|| (held.lost_enrolment.is_none() && refused_as_enrolled)),
owed,
asking,
unanswered: held.unanswered,
retry_at: held.retry_at.filter(|t| *t > now),
next_window: super::hidden::next_window(&held.params, &events, now),
rekeyed: held.rekeyed_at.is_some(),
events_offered: !held.params.events.is_empty(),
last_refusal: held.last_refusal.clone(),
})
}
pub fn hidden_vetter_mut(
&mut self,
community: &str,
persona: PersonaId,
) -> Option<&mut HiddenVetterState> {
self.hidden_vetter
.iter_mut()
.find(|h| h.community == community && h.persona == persona)
}
pub fn attest_hidden<R: rand::RngCore + rand::CryptoRng>(
&mut self,
request_id: &str,
vetter_did: &str,
attestation: super::vetter::Attestation,
now: DateTime<Utc>,
rng: &mut R,
) -> Result<serde_json::Value, VetterError> {
let entry = self
.desk_entry(request_id)
.ok_or(VetterError::NoSuchRequest)?
.clone();
let applicant_id = super::hidden::read_request_ext(
entry
.request
.ext
.as_ref()
.map(|e| serde_json::to_value(e).unwrap_or(serde_json::Value::Null))
.as_ref(),
)
.map_err(VetterError::Hidden)?
.ok_or_else(|| {
VetterError::Hidden(super::hidden::HiddenError::Unreadable(
"this request carries no hidden-vetting identifier, so there is nobody to attest \
to with a PCS ZKP proof — sign a named statement instead where its criterion \
accepts one, or ask the applicant to refresh their requirements (m) and send a \
new request"
.into(),
))
})?;
let draft = self.statement_draft(request_id, vetter_did, attestation, now)?;
let digest = entry
.request
.requirements_digest
.as_ref()
.map(|d| d.as_str().to_string())
.unwrap_or_default();
let meta =
super::hidden::statement_meta(&draft, &entry.community, &digest).ok_or_else(|| {
VetterError::Hidden(super::hidden::HiddenError::Unreadable(
"the statement carries no vetter members, so it is not a vetter's to attest \
with"
.into(),
))
})?;
let state = self
.hidden_vetter_mut(&entry.community, entry.persona)
.ok_or(VetterError::Hidden(super::hidden::HiddenError::NotEnrolled))?;
let params = state.params.clone();
let wire = super::hidden::attest(
&entry.community,
¶ms,
&mut state.snapshot,
&applicant_id,
meta,
rng,
)
.map_err(VetterError::Hidden)?;
state.tokens_spent = state.tokens_spent.saturating_add(1);
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"));
};
entry.state = DeskState::Attested {
statement_id: format!("urn:openvtc:hidden-attestation:{request_id}"),
issued_at: now,
card: card.clone(),
};
entry.updated_at = now;
Ok(wire)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.applications.is_empty()
&& self.tickets.is_empty()
&& self.desk.is_empty()
&& self.issued.is_empty()
&& self.throttle.is_empty()
&& self.criteria.is_empty()
&& self.policy.is_default()
&& self.vetter_grants.is_empty()
&& self.communities.is_empty()
&& self.vetter_profiles.is_empty()
&& self.retired.is_empty()
&& self.extra.is_empty()
}
pub fn keep_vetter_grant(&mut self, grant: VetterGrant) -> bool {
let same = |g: &VetterGrant| g.community == grant.community && g.persona == grant.persona;
if let Some(existing) = self.vetter_grants.iter_mut().find(|g| same(g)) {
if existing.credential == grant.credential {
return false;
}
let community = grant.community.clone();
*existing = grant;
self.clear_retired_for(&community);
} else {
self.clear_retired_for(&grant.community.clone());
self.vetter_grants.push(grant);
}
true
}
pub fn clear_retired_for(&mut self, community: &str) {
self.retired.retain(|note| !note.contains(community));
}
#[must_use]
pub fn vetter_grant(
&self,
community: &str,
persona: PersonaId,
now: DateTime<Utc>,
) -> Option<&VetterGrant> {
self.vetter_grants
.iter()
.find(|g| g.community == community && g.persona == persona && g.is_live(now))
}
#[must_use]
pub fn vetter_standing(&self, now: DateTime<Utc>) -> Vec<VetterStanding> {
let mut standing: Vec<VetterStanding> = self
.vetter_grants
.iter()
.map(|grant| VetterStanding {
community: grant.community.clone(),
persona: grant.persona,
valid_until: grant.valid_until,
live: grant.is_live(now),
expiring: grant.is_expiring(now),
profile: self
.vetter_profile(&grant.community, grant.persona)
.map(|record| record.state.clone()),
})
.collect();
standing.sort_by(|a, b| {
a.community
.cmp(&b.community)
.then(a.persona.cmp(&b.persona))
});
standing
}
#[must_use]
pub fn grants_needing_attention(&self, now: DateTime<Utc>) -> Vec<&VetterGrant> {
self.vetter_grants
.iter()
.filter(|g| g.valid_until.is_some() && (!g.is_live(now) || g.is_expiring(now)))
.collect()
}
pub fn take_grant_warnings(&mut self, now: DateTime<Utc>) -> Vec<GrantWarning> {
let owed: Vec<GrantWarning> = self
.grants_needing_attention(now)
.into_iter()
.map(|g| GrantWarning {
community: g.community.clone(),
persona: g.persona,
expired: !g.is_live(now),
valid_until: g.valid_until.unwrap_or(now),
})
.collect();
let live_ids: Vec<String> = owed.iter().map(GrantWarning::id).collect();
self.grant_warnings.retain(|id| live_ids.contains(id));
let mut new = Vec::new();
for warning in owed {
let id = warning.id();
if !self.grant_warnings.contains(&id) {
self.grant_warnings.push(id);
new.push(warning);
}
}
new
}
#[must_use]
pub fn requested_attributes(
&self,
community: &str,
) -> Vec<crate::persona::join_answers::Asked> {
self.communities
.iter()
.find(|c| c.community == community)
.map(|c| c.requested.clone())
.unwrap_or_default()
}
pub fn learn_manifest(
&mut self,
community: &str,
manifest: &manifest::v0_2::Response,
now: DateTime<Utc>,
) -> bool {
self.learn_manifest_in(community, manifest, None, &[], now)
}
pub fn learn_manifest_in(
&mut self,
community: &str,
manifest: &manifest::v0_2::Response,
protocol: Option<JoinProtocol>,
meta: &[CriterionMeta],
now: DateTime<Utc>,
) -> bool {
let routes: Vec<CommunityCriterion> = manifest
.criteria
.iter()
.map(|c| {
let id = c.id.as_str().to_string();
let m = meta.iter().find(|m| m.id == id);
CommunityCriterion {
requirements_digest: c
.requirements_digest
.as_ref()
.map(|d| d.as_str().to_string()),
admission: m.and_then(|m| m.admission),
invitation_required: m.and_then(|m| m.invitation_required),
vetting: c.vetting.is_some(),
id,
}
})
.collect();
let fresh: Vec<KnownCriterion> = manifest
.criteria
.iter()
.filter_map(|c| {
let requirements = c.vetting.clone()?;
requirements.check_shape().ok()?;
Some(KnownCriterion {
community: community.to_string(),
criterion_id: c.id.as_str().to_string(),
requirements_digest: c
.requirements_digest
.as_ref()
.map(|d| d.as_str().to_string()),
requirements,
fetched_at: now,
description: c.description.as_ref().map(|d| d.to_string()),
})
})
.collect();
let known: Vec<&KnownCriterion> = self
.criteria
.iter()
.filter(|k| k.community == community)
.collect();
let unchanged = known.len() == fresh.len()
&& known.iter().zip(&fresh).all(|(a, b)| {
a.criterion_id == b.criterion_id
&& a.requirements_digest == b.requirements_digest
&& same_requirements(&a.requirements, &b.requirements)
});
self.criteria.retain(|k| k.community != community);
self.criteria.extend(fresh);
let branding = manifest
.branding
.as_ref()
.map(Branding::from_manifest)
.unwrap_or_default();
let requested = crate::persona::join_answers::asked(manifest);
let branding_changed = match self
.communities
.iter_mut()
.find(|c| c.community == community)
{
Some(known) => {
known.fetched_at = now;
let changed = known.branding != branding
|| known.requested != requested
|| known.routes != routes
|| (protocol.is_some() && known.protocol != protocol);
known.branding = branding;
known.requested = requested;
known.routes = routes;
if protocol.is_some() {
known.protocol = protocol;
}
changed
}
None => {
self.communities.push(KnownCommunity {
community: community.to_string(),
branding,
requested,
fetched_at: now,
protocol,
routes,
post_quantum_key: None,
vetter_mode: None,
});
true
}
};
!unchanged || branding_changed
}
#[must_use]
pub fn knowledge(&self, community: &str) -> Knowledge<'_> {
if let Some(criterion) = self.criteria.iter().find(|k| k.community == community) {
return Knowledge::Vetting(criterion);
}
if self.communities.iter().any(|c| c.community == community) {
Knowledge::NoVetting
} else {
Knowledge::Unknown
}
}
#[must_use]
pub fn protocol_for(&self, community: &str) -> JoinProtocol {
self.communities
.iter()
.find(|c| c.community == community)
.and_then(|c| c.protocol)
.or_else(|| self.protocol_hints.get(community).copied())
.unwrap_or_default()
}
pub fn fall_back_from(&mut self, community: &str, asked: JoinProtocol) -> Option<JoinProtocol> {
let next = asked.fallback()?;
self.protocol_hints.insert(community.to_string(), next);
if let Some(known) = self
.communities
.iter_mut()
.find(|c| c.community == community)
&& known.protocol == Some(asked)
{
known.protocol = Some(next);
}
Some(next)
}
pub fn note_post_quantum_key(&mut self, community: &str, publishes: bool) {
if let Some(known) = self
.communities
.iter_mut()
.find(|c| c.community == community)
{
known.post_quantum_key = Some(publishes);
}
}
#[must_use]
pub fn post_quantum_key(&self, community: &str) -> Option<bool> {
self.communities
.iter()
.find(|c| c.community == community)
.and_then(|c| c.post_quantum_key)
}
#[must_use]
pub fn hidden_vetting(&self, community: &str) -> bool {
self.criteria
.iter()
.any(|k| k.community == community && k.hidden_vetting())
}
#[must_use]
pub fn routes(&self, community: &str) -> &[CommunityCriterion] {
self.communities
.iter()
.find(|c| c.community == community)
.map_or(&[], |c| c.routes.as_slice())
}
#[must_use]
pub fn branding(&self, community: &str) -> Option<&Branding> {
self.communities
.iter()
.find(|c| c.community == community)
.map(|c| &c.branding)
.filter(|b| !b.is_default())
}
pub fn adopt_known_requirements(
&mut self,
application_id: &str,
) -> Result<bool, super::applicant::ApplicantError> {
let Some(app) = self.applications.iter().find(|a| a.id == application_id) else {
return Ok(false);
};
let known: Vec<&KnownCriterion> = self
.criteria
.iter()
.filter(|k| k.community == app.community)
.collect();
let chosen = app
.criterion_id
.as_deref()
.and_then(|id| known.iter().find(|k| k.criterion_id == id))
.or_else(|| {
(!app.under_way())
.then(|| known.iter().find(|k| k.hidden_vetting()))
.flatten()
})
.or_else(|| known.first())
.map(|k| (*k).clone());
let (Some(criterion), Some(app)) = (chosen, self.application_by_id_mut(application_id))
else {
return Ok(false);
};
let raw = criterion.as_raw();
app.adopt_criterion(
&criterion.criterion_id,
criterion.requirements,
criterion.requirements_digest,
Some(&raw),
)
}
#[must_use]
pub fn vetting_options(&self, community: &str) -> Vec<VettingOption> {
self.criteria
.iter()
.filter(|k| k.community == community)
.flat_map(|k| {
let paths = k.paths();
let ways: &[VettingPath] = match paths {
CriterionPaths::Named => &[VettingPath::Named],
CriterionPaths::Either => &[VettingPath::Hidden, VettingPath::Named],
CriterionPaths::HiddenOnly => &[VettingPath::Hidden],
};
ways.iter().map(move |path| VettingOption {
criterion_id: k.criterion_id.clone(),
description: k.description.clone(),
path: *path,
paths,
})
})
.collect()
}
#[must_use]
pub fn application_vetting(&self, application_id: &str) -> Option<VettingOption> {
let app = self.applications.iter().find(|a| a.id == application_id)?;
let criterion_id = app.criterion_id.clone()?;
let known = self
.criteria
.iter()
.find(|k| k.community == app.community && k.criterion_id == criterion_id);
Some(VettingOption {
description: known.and_then(|k| k.description.clone()),
paths: known.map_or(
if app.hidden.is_some() {
CriterionPaths::HiddenOnly
} else {
CriterionPaths::Named
},
KnownCriterion::paths,
),
path: if app.hidden.is_some() {
VettingPath::Hidden
} else {
VettingPath::Named
},
criterion_id,
})
}
pub fn switch_vetting(&mut self, application_id: &str) -> Result<VettingOption, String> {
let Some(current) = self.application_vetting(application_id) else {
return Err(
"This application's requirements are not known yet — refresh them (m) first."
.to_string(),
);
};
let Some(app) = self.applications.iter().find(|a| a.id == application_id) else {
return Err("That application is gone.".to_string());
};
if app.holds_evidence() {
return Err(format!(
"This application already holds vetting made under criterion {} ({}), so its \
criterion and path are fixed.",
current.criterion_id,
current.path.words()
));
}
let options = self.vetting_options(&app.community);
let here = options
.iter()
.position(|o| o.criterion_id == current.criterion_id && o.path == current.path);
let next = match here {
Some(i) => options.get((i + 1) % options.len()),
None => options.first(),
}
.filter(|o| **o != current)
.cloned()
.ok_or_else(|| {
format!(
"This community offers one way to be vetted: criterion {} ({}).",
current.criterion_id,
current.paths.words()
)
})?;
let known = self
.criteria
.iter()
.find(|k| k.community == app.community && k.criterion_id == next.criterion_id)
.cloned()
.expect("an option is a known criterion");
let app = self
.application_by_id_mut(application_id)
.expect("found above");
app.vetting_path = Some(next.path);
let raw = known.as_raw();
app.adopt_criterion(
&known.criterion_id,
known.requirements,
known.requirements_digest,
Some(&raw),
)
.map_err(|e| format!("Could not switch: {e}"))?;
app.criterion_repicked = None;
Ok(next)
}
#[must_use]
pub fn request_vetting(&self, request_id: &str) -> RequestVetting {
let Some(entry) = self.desk_entry(request_id) else {
return RequestVetting::Named { criterion: None };
};
let ext = entry
.request
.ext
.as_ref()
.and_then(|e| serde_json::to_value(e).ok());
match super::hidden::read_request_ext(ext.as_ref()) {
Err(e) => RequestVetting::Unreadable(e),
Ok(Some(_)) => RequestVetting::Hidden,
Ok(None) => {
let digest = entry
.request
.requirements_digest
.as_ref()
.map(|d| d.as_str());
let ours: Vec<&KnownCriterion> = self
.criteria
.iter()
.filter(|k| k.community == entry.community)
.collect();
let named = ours
.iter()
.find(|k| digest.is_some() && k.requirements_digest.as_deref() == digest);
match named {
Some(k) if k.paths() == CriterionPaths::HiddenOnly => {
RequestVetting::HiddenWithoutId {
criterion: k.criterion_id.clone(),
}
}
Some(k) => RequestVetting::Named {
criterion: Some(k.criterion_id.clone()),
},
None => match ours.first() {
Some(first)
if ours.iter().all(|k| k.paths() == CriterionPaths::HiddenOnly) =>
{
RequestVetting::HiddenWithoutId {
criterion: first.criterion_id.clone(),
}
}
_ => RequestVetting::Named { criterion: None },
},
}
}
}
}
#[must_use]
pub fn named_alongside_hidden(&self, community: &str) -> bool {
let ours = || self.criteria.iter().filter(|k| k.community == community);
ours().any(KnownCriterion::hidden_vetting)
&& ours().any(|k| k.paths() != CriterionPaths::HiddenOnly)
}
#[must_use]
pub fn resend_candidates<'a>(
&self,
account: &'a Account,
now: DateTime<Utc>,
) -> Vec<&'a CommunityRecord> {
account
.memberships()
.filter(|m| m.status.is_active())
.filter(|m| self.vetter_grant(&m.vtc_did, m.persona_ref, now).is_none())
.collect()
}
#[must_use]
pub fn required_claims_for(
&self,
community: &str,
digest: Option<&str>,
) -> (Vec<String>, bool) {
let mut ours = self.criteria.iter().filter(|k| k.community == community);
let chosen = digest
.and_then(|d| {
self.criteria.iter().find(|k| {
k.community == community && k.requirements_digest.as_deref() == Some(d)
})
})
.or_else(|| ours.next());
match chosen {
Some(k) => (
k.requirements
.required_claims
.iter()
.flatten()
.map(|c| c.as_str().to_string())
.collect(),
true,
),
None => (
FALLBACK_REQUIRED_CLAIMS
.iter()
.map(ToString::to_string)
.collect(),
false,
),
}
}
#[must_use]
pub fn decision_sla(&self, community: &str, persona: PersonaId) -> Option<Duration> {
let from_application = self
.application(community, persona)
.and_then(|a| a.requirements.as_ref())
.and_then(|r| r.decision_sla.clone());
let from_criteria = || {
self.criteria
.iter()
.filter(|k| k.community == community)
.find_map(|k| k.requirements.decision_sla.clone())
};
from_application
.or_else(from_criteria)
.and_then(|sla| vta_sdk::protocols::vetting::parse_iso8601_duration(sla.as_str()))
}
#[must_use]
pub fn application(&self, community: &str, persona: PersonaId) -> Option<&Application> {
self.applications
.iter()
.find(|a| a.community == community && a.persona == persona)
}
pub fn application_mut(
&mut self,
community: &str,
persona: PersonaId,
) -> Option<&mut Application> {
self.applications
.iter_mut()
.find(|a| a.community == community && a.persona == persona)
}
pub fn application_by_id_mut(&mut self, id: &str) -> Option<&mut Application> {
self.applications.iter_mut().find(|a| a.id == id)
}
pub fn start_application(
&mut self,
community: &str,
persona: PersonaId,
join_did: &str,
now: DateTime<Utc>,
) -> Result<&mut Application, vta_sdk::vetting::VettingError> {
if let Some(i) = self
.applications
.iter()
.position(|a| a.community == community && a.persona == persona)
{
return Ok(&mut self.applications[i]);
}
self.applications
.push(Application::new(community, persona, join_did, now)?);
Ok(self.applications.last_mut().expect("just pushed"))
}
pub fn retire_joined(
&mut self,
joined: impl Fn(&Application) -> bool,
now: DateTime<Utc>,
) -> usize {
let before = self.applications.len();
self.applications.retain(|a| {
!(joined(a)
&& a.presentable_statements(now).is_empty()
&& !a.holds_hidden_attestation())
});
before - self.applications.len()
}
pub fn abandon_application(&mut self, id: &str) -> Option<Application> {
let i = self.applications.iter().position(|a| a.id == id)?;
Some(self.applications.remove(i))
}
#[must_use]
pub fn desk_entry(&self, request_id: &str) -> Option<&DeskEntry> {
self.desk.iter().find(|e| e.request_id == request_id)
}
pub fn desk_entry_mut(&mut self, request_id: &str) -> Option<&mut DeskEntry> {
self.desk.iter_mut().find(|e| e.request_id == request_id)
}
#[must_use]
pub fn open_requests(&self, persona: PersonaId) -> usize {
self.desk
.iter()
.filter(|e| e.persona == persona && e.state.is_open())
.count()
}
pub fn prune(&mut self, now: DateTime<Utc>) -> bool {
let mut changed = false;
let before = self.tickets.len();
self.tickets
.retain(|t| t.is_live(now) || now - t.expires_at.min(now) < Duration::days(1));
changed |= self.tickets.len() != before;
let retention = Duration::days(self.policy.card_retention_days.max(0));
for entry in &mut self.desk {
changed |= entry.expire_session(now);
changed |= entry.forget_card_after(retention, now);
}
let mut kept = Vec::with_capacity(self.desk.len());
for entry in std::mem::take(&mut self.desk) {
let closed = match &entry.state {
DeskState::Attested {
statement_id,
issued_at,
card,
} if card.card.is_none() => Some((
*issued_at,
VettedOutcome::Signed {
statement_id: statement_id.clone(),
},
)),
DeskState::Declined { at, card, .. }
if card.as_ref().is_none_or(|c| c.card.is_none()) =>
{
Some((*at, VettedOutcome::Declined))
}
_ => None,
};
match closed {
Some((closed_at, outcome)) if now - closed_at >= CLOSED_GRACE => {
self.vetted.push(VettedRecord {
community: entry.community,
closed_at,
outcome,
});
changed = true;
}
_ => kept.push(entry),
}
}
self.desk = kept;
for application in &mut self.applications {
for request in &mut application.requests {
if let RequestState::Session {
request_id,
session,
..
} = &request.state
&& session.expires_at <= now
{
request.state = RequestState::Accepted {
request_id: request_id.clone(),
accepts_documentation: Vec::new(),
session_hint: None,
};
request.updated_at = now;
changed = true;
}
}
}
changed
}
}
impl DeskState {
#[must_use]
pub fn is_open(&self) -> bool {
matches!(
self,
DeskState::Accepted | DeskState::Session { .. } | DeskState::CardReceived { .. }
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_joined_application_goes_once_nothing_in_it_could_be_presented() {
use super::super::applicant::HeldStatement;
use vta_sdk::protocols::vetting::{VettingMethod, VettingRelationship};
let now = Utc::now();
let statement = |valid_until| HeldStatement {
id: "s1".into(),
vetter: "did:key:zVetter".into(),
method: VettingMethod::InPerson,
declared_relationship: VettingRelationship::None,
document_classes: vec![],
claims_verified: vec!["name.legal".into()],
identity_commitment: "c".into(),
valid_from: now - Duration::days(1),
valid_until,
received_at: now - Duration::days(1),
credential: serde_json::json!({}),
};
let mut book = VettingBook::default();
let joined = PersonaId::new();
let other = PersonaId::new();
book.start_application("did:web:a", joined, "did:key:zA", now)
.unwrap();
book.start_application("did:web:b", joined, "did:key:zA", now)
.unwrap()
.statements
.push(statement(now + Duration::days(30)));
book.start_application("did:web:c", other, "did:key:zB", now)
.unwrap();
let member = |a: &Application| a.persona == joined;
assert_eq!(book.retire_joined(member, now), 1);
let left: Vec<&str> = book
.applications
.iter()
.map(|a| a.community.as_str())
.collect();
assert_eq!(left, ["did:web:b", "did:web:c"]);
assert_eq!(book.retire_joined(member, now + Duration::days(31)), 1);
assert_eq!(book.applications.len(), 1);
}
#[test]
fn an_empty_book_is_not_written() {
let book = VettingBook::default();
assert!(book.is_empty());
assert_eq!(serde_json::to_value(&book).unwrap(), serde_json::json!({}));
}
#[test]
fn one_application_per_community_and_persona() {
let mut book = VettingBook::default();
let persona = PersonaId::new();
let now = Utc::now();
let first = book
.start_application("did:web:vtc", persona, "did:key:zA", now)
.unwrap()
.id
.clone();
let again = book
.start_application("did:web:vtc", persona, "did:key:zA", now)
.unwrap()
.id
.clone();
assert_eq!(first, again);
book.start_application("did:web:other", persona, "did:key:zA", now)
.unwrap();
assert_eq!(book.applications.len(), 2);
}
#[test]
fn a_second_persona_gets_its_own_application_to_the_same_community() {
let mut book = VettingBook::default();
let now = Utc::now();
let alice = PersonaId::new();
let bob = PersonaId::new();
book.start_application("did:web:vtc", alice, "did:key:zA", now)
.unwrap();
book.start_application("did:web:vtc", bob, "did:key:zB", now)
.unwrap();
assert_eq!(book.applications.len(), 2);
}
#[test]
fn an_application_can_be_abandoned() {
let mut book = VettingBook::default();
let now = Utc::now();
let persona = PersonaId::new();
let id = book
.start_application("did:web:vtc", persona, "did:key:zA", now)
.unwrap()
.id
.clone();
let gone = book.abandon_application(&id).expect("it was there");
assert_eq!(gone.id, id);
assert!(book.applications.is_empty());
assert!(book.abandon_application(&id).is_none());
book.start_application("did:web:vtc", persona, "did:key:zA", now)
.unwrap();
assert_eq!(book.applications.len(), 1);
}
const DIGEST: &str = "zQmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR";
fn manifest(
branding: Option<manifest::v0_2::CommunityBranding>,
vetting: bool,
) -> manifest::v0_2::Response {
let requirements: VettingRequirements = serde_json::from_value(serde_json::json!({
"version": "0.1",
"statementType": vta_sdk::protocols::vetting::VETTED_PREDICATE,
"minStatements": 1,
"acceptedMethods": ["inPerson"],
"requiredClaims": ["name.legal"],
"eligibleVetters": { "role": "vetter" }
}))
.unwrap();
let criterion = manifest::v0_2::Criterion::try_from(
manifest::v0_2::Criterion::builder()
.id("c1")
.presentation_definition(serde_json::Map::new())
.vetting(vetting.then_some(requirements))
.requirements_digest(Some(
manifest::v0_2::DigestMultibase::try_from(DIGEST).unwrap(),
)),
)
.unwrap();
manifest::v0_2::Response::try_from(
manifest::v0_2::Response::builder()
.community_did("did:web:vtc")
.criteria(vec![criterion])
.branding(branding),
)
.unwrap()
}
#[test]
fn a_manifest_says_whether_a_community_vets_and_how_it_looks() {
let mut book = VettingBook::default();
let now = Utc::now();
assert!(matches!(book.knowledge("did:web:vtc"), Knowledge::Unknown));
let branding = manifest::v0_2::CommunityBranding::try_from(
manifest::v0_2::CommunityBranding::builder()
.display_name(Some(
manifest::v0_2::CommunityBrandingDisplayName::try_from("Kernel").unwrap(),
))
.accent_color(Some(
manifest::v0_2::CommunityBrandingAccentColor::try_from("#1a2B3c").unwrap(),
)),
)
.unwrap();
assert!(book.learn_manifest("did:web:vtc", &manifest(Some(branding.clone()), true), now));
assert!(matches!(
book.knowledge("did:web:vtc"),
Knowledge::Vetting(_)
));
let known = book.branding("did:web:vtc").unwrap();
assert_eq!(known.display_name.as_deref(), Some("Kernel"));
assert_eq!(known.accent_rgb(), Some((0x1a, 0x2b, 0x3c)));
assert!(
!book.learn_manifest("did:web:vtc", &manifest(Some(branding), true), now),
"the same manifest again is not a change"
);
assert!(book.learn_manifest("did:web:open", &manifest(None, false), now));
assert!(matches!(
book.knowledge("did:web:open"),
Knowledge::NoVetting
));
assert!(book.branding("did:web:open").is_none());
assert!(manifest::v0_2::CommunityBrandingAccentColor::try_from("red").is_err());
let broken: manifest::v0_2::CommunityBranding = serde_json::from_value(serde_json::json!({
"displayName": "Elsewhere",
"logoUrl": "http://vtc.example/logo.png"
}))
.expect("the schema admits it");
book.learn_manifest("did:web:broken", &manifest(Some(broken), false), now);
assert!(
book.branding("did:web:broken").is_none(),
"a bad branding is dropped whole"
);
}
#[test]
fn only_rrggbb_is_an_accent() {
assert_eq!(parse_accent("#ff0080"), Some((255, 0, 128)));
for bad in ["ff0080", "#ff008", "#ff00800", "#gg0080", "#ff0 80"] {
assert_eq!(parse_accent(bad), None, "{bad}");
}
}
#[test]
fn a_new_application_takes_the_requirements_already_known() {
let mut book = VettingBook::default();
let now = Utc::now();
book.learn_manifest("did:web:vtc", &manifest(None, true), now);
let id = book
.start_application("did:web:vtc", PersonaId::new(), "did:key:zA", now)
.unwrap()
.id
.clone();
assert!(book.adopt_known_requirements(&id).unwrap());
let app = book.application_by_id_mut(&id).unwrap();
assert_eq!(app.criterion_id.as_deref(), Some("c1"));
assert_eq!(app.requirements_digest.as_deref(), Some(DIGEST));
assert!(
!book.adopt_known_requirements(&id).unwrap(),
"nothing new the second time"
);
}
#[test]
fn a_member_without_a_live_grant_can_ask_for_it_again() {
let now = Utc::now();
let mut account = Account::default();
let persona = PersonaId::new();
for (vtc, active) in [
("did:web:a", true),
("did:web:b", true),
("did:web:c", false),
] {
let mut record = CommunityRecord::new_pending(
vtc.to_string(),
None,
"openvtc/test".to_string(),
persona,
uuid::Uuid::new_v4(),
now,
);
if active {
record.activate(now);
}
account.add_membership(record);
}
let mut book = VettingBook::default();
book.keep_vetter_grant(VetterGrant {
community: "did:web:a".into(),
persona,
credential_id: None,
valid_until: Some(now + Duration::days(30)),
received_at: now,
credential: serde_json::json!({}),
});
let candidates: Vec<&str> = book
.resend_candidates(&account, now)
.into_iter()
.map(|m| m.vtc_did.as_str())
.collect();
assert_eq!(candidates, vec!["did:web:b"]);
}
#[test]
fn a_lapsing_grant_is_warned_about_once_per_state() {
let now = Utc::now();
let persona = PersonaId::new();
let mut book = VettingBook::default();
book.keep_vetter_grant(VetterGrant {
community: "did:web:a".into(),
persona,
credential_id: None,
valid_until: Some(now + Duration::days(3)),
received_at: now,
credential: serde_json::json!({}),
});
let first = book.take_grant_warnings(now);
assert_eq!(first.len(), 1, "about to lapse");
assert!(!first[0].expired);
assert!(
book.take_grant_warnings(now).is_empty(),
"said once, not every sweep"
);
let later = now + Duration::days(4);
let second = book.take_grant_warnings(later);
assert_eq!(second.len(), 1);
assert!(second[0].expired);
assert!(book.take_grant_warnings(later).is_empty());
assert_eq!(
book.grant_warnings.len(),
1,
"the superseded 'expiring' warning is forgotten, not accumulated"
);
}
#[test]
fn a_renewed_grant_can_be_warned_about_again() {
let now = Utc::now();
let persona = PersonaId::new();
let mut book = VettingBook::default();
let grant = |until: DateTime<Utc>| VetterGrant {
community: "did:web:a".into(),
persona,
credential_id: None,
valid_until: Some(until),
received_at: now,
credential: serde_json::json!({ "validUntil": until.to_rfc3339() }),
};
book.keep_vetter_grant(grant(now + Duration::days(90)));
assert!(
book.take_grant_warnings(now).is_empty(),
"a grant with months to run is not news"
);
book.keep_vetter_grant(grant(now + Duration::days(2)));
assert_eq!(book.take_grant_warnings(now).len(), 1);
book.keep_vetter_grant(grant(now + Duration::days(400)));
assert!(book.take_grant_warnings(now).is_empty());
assert!(
book.grant_warnings.is_empty(),
"the old warning is forgotten once the grant no longer needs it"
);
let much_later = now + Duration::days(395);
assert_eq!(book.take_grant_warnings(much_later).len(), 1);
}
#[test]
fn the_standing_keeps_a_lapsed_grant_that_every_other_read_drops() {
let now = Utc::now();
let persona = PersonaId::new();
let mut book = VettingBook::default();
book.keep_vetter_grant(VetterGrant {
community: "did:web:gone".into(),
persona,
credential_id: None,
valid_until: Some(now - Duration::days(1)),
received_at: now - Duration::days(400),
credential: serde_json::json!({}),
});
assert!(
book.vetter_grant("did:web:gone", persona, now).is_none(),
"a lapsed grant is not a live one"
);
let standing = book.vetter_standing(now);
assert_eq!(standing.len(), 1, "but the desk still shows it");
assert!(!standing[0].live);
assert!(
!standing[0].expiring,
"already gone is not 'about to go' — they are different sentences"
);
assert!(standing[0].profile.is_none(), "no profile was ever sent");
}
#[test]
fn unknown_members_survive_a_round_trip() {
let mut v = serde_json::to_value(VettingBook::default()).unwrap();
v["vetterDirectory"] = serde_json::json!({ "listed": true });
let book: VettingBook = serde_json::from_value(v).unwrap();
assert!(!book.is_empty());
assert_eq!(
serde_json::to_value(&book).unwrap()["vetterDirectory"]["listed"],
true
);
}
#[test]
fn a_pre_v1_vetter_grant_is_set_aside_with_a_notice() {
let community = "did:example:kernel";
let grant = |credential: serde_json::Value| VetterGrant {
community: community.to_string(),
persona: PersonaId::new(),
credential_id: None,
valid_until: None,
received_at: Utc::now(),
credential,
};
let mut book = VettingBook::default();
book.vetter_grants
.push(grant(crate::dtg::fixtures::retired_role_endorsement(
community,
"did:example:m",
)));
book.vetter_grants
.push(grant(crate::dtg::fixtures::role_vac(
"did:example:other",
"did:example:m",
"vetter",
)));
assert_eq!(book.retire_nonconformant(), 1);
assert_eq!(book.vetter_grants.len(), 1);
assert_eq!(book.retired.len(), 1);
assert!(book.retired[0].contains(community));
assert!(!book.is_empty());
book.keep_vetter_grant(grant(crate::dtg::fixtures::role_vac(
community,
"did:example:m",
"vetter",
)));
assert!(
book.retired.is_empty(),
"the replacement answers the notice"
);
}
}