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};
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: 7,
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>,
}
#[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,
}
#[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>,
}
impl HiddenVetterState {
#[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?;
(today <= closes_after).then_some(super::hidden::EventDraw {
label,
rate: e.drip_per_tick.unwrap_or(self.params.drip_per_tick),
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>,
}
#[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 = "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_enrolment: Option<std::sync::Arc<openvtc_vetting_pcs::vetter::EnrolmentBlinding>>,
#[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 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 under this community's criterion"
.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_else(|| {
VetterError::Hidden(super::hidden::HiddenError::Unreadable(
"this persona holds no hidden-vetting credential for the community".into(),
))
})?;
let params = state.params.clone();
let wire = super::hidden::attest(
&entry.community,
¶ms,
&mut state.snapshot,
&applicant_id,
meta,
rng,
)
.map_err(VetterError::Hidden)?;
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,
})
})
.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,
});
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)
}
#[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) -> bool {
let Some(app) = self.applications.iter().find(|a| a.id == application_id) else {
return false;
};
let mut known = self
.criteria
.iter()
.filter(|k| k.community == app.community);
let chosen = app
.criterion_id
.as_deref()
.and_then(|id| {
self.criteria
.iter()
.find(|k| k.community == app.community && k.criterion_id == id)
})
.or_else(|| known.next())
.cloned();
let (Some(criterion), Some(app)) = (chosen, self.application_by_id_mut(application_id))
else {
return false;
};
let changed = !app
.requirements
.as_ref()
.is_some_and(|r| same_requirements(r, &criterion.requirements))
|| app.requirements_digest != criterion.requirements_digest
|| app.criterion_id.as_deref() != Some(criterion.criterion_id.as_str());
app.criterion_id = Some(criterion.criterion_id);
app.requirements = Some(criterion.requirements);
app.requirements_digest = criterion.requirements_digest;
changed
}
#[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 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);
}
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 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));
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),
"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"
);
}
}