use chrono::{DateTime, Duration, Utc};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub use trust_tasks_rs::specs::vetting::{decline, request, session};
pub use trust_tasks_rs::specs::vtc::join_requests::manifest::v0_2::{
ClaimType, VettingDocumentation, VettingMethod, VettingRelationship, VettingRequirements,
VettingRequirementsEligibleVetters, VettingRequirementsIndependence,
VettingRequirementsInvitation,
};
pub use trust_tasks_rs::specs::vtc::vetting::{revoke_statement, vetters};
use trust_tasks_rs::specs::vtc::join_requests::manifest::v0_2 as manifest;
pub const VETTING_REQUEST_TYPE: &str =
<request::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const VETTING_REQUEST_RESPONSE_TYPE: &str =
<request::v0_1::Response as trust_tasks_rs::Payload>::TYPE_URI;
pub const VETTING_SESSION_TYPE: &str =
<session::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const VETTING_SESSION_RESPONSE_TYPE: &str =
<session::v0_1::Response as trust_tasks_rs::Payload>::TYPE_URI;
pub const VETTING_DECLINE_TYPE: &str =
<decline::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const VETTING_REVOKE_STATEMENT_TYPE: &str =
<revoke_statement::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const VETTING_REVOKE_STATEMENT_RESPONSE_TYPE: &str =
<revoke_statement::v0_1::Response as trust_tasks_rs::Payload>::TYPE_URI;
pub const VETTING_VETTER_GRANT_TYPE: &str =
<vetters::grant::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const VETTING_VETTER_GRANT_RESPONSE_TYPE: &str =
<vetters::grant::v0_1::Response as trust_tasks_rs::Payload>::TYPE_URI;
pub const VETTING_VETTER_PROFILE_TYPE: &str =
<vetters::profile::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const VETTING_VETTER_PROFILE_RESPONSE_TYPE: &str =
<vetters::profile::v0_1::Response as trust_tasks_rs::Payload>::TYPE_URI;
pub const VETTING_VETTER_LIST_TYPE: &str =
<vetters::list::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const VETTING_VETTER_LIST_RESPONSE_TYPE: &str =
<vetters::list::v0_1::Response as trust_tasks_rs::Payload>::TYPE_URI;
pub const VETTING_VETTER_RESEND_TYPE: &str =
<vetters::resend::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const VETTING_VETTER_RESEND_RESPONSE_TYPE: &str =
<vetters::resend::v0_1::Response as trust_tasks_rs::Payload>::TYPE_URI;
pub const VETTING_REQUEST_ERR_INVALID_TICKET: &str =
request::v0_1::error_codes::INVALID_TICKET.code;
pub const VETTING_REQUEST_ERR_CAPACITY: &str = request::v0_1::error_codes::CAPACITY.code;
pub const VETTING_REQUEST_ERR_NOT_ELIGIBLE: &str = request::v0_1::error_codes::NOT_ELIGIBLE.code;
pub const VETTING_REQUEST_ERR_DECLINED: &str = request::v0_1::error_codes::DECLINED.code;
pub const VETTING_REQUEST_ERR_METHOD_UNAVAILABLE: &str =
request::v0_1::error_codes::METHOD_UNAVAILABLE.code;
pub const VETTING_VETTER_PROFILE_ERR_NOT_ELIGIBLE: &str =
vetters::profile::v0_1::error_codes::NOT_ELIGIBLE.code;
pub const VETTING_VETTER_RESEND_ERR_NOT_GRANTED: &str =
vetters::resend::v0_1::error_codes::NOT_GRANTED.code;
pub const COMMUNITY_ROLE_ENDORSEMENT_TYPE: &str = "CommunityRole";
pub const VETTER_ROLE: &str = "vetter";
#[must_use]
pub fn role_matches(held: &str, required: &str) -> bool {
fn bare(role: &str) -> &str {
role.strip_prefix("custom:").unwrap_or(role)
}
held == required || (bare(held) == VETTER_ROLE && bare(required) == VETTER_ROLE)
}
pub const IDENTITY_VETTING_ENDORSEMENT_TYPE: &str =
"https://firstperson.network/endorsements/identity-vetting/0.1";
pub const VETTING_CARD_TYPES: [session::v0_1::VettingCardTypeItem; 3] = [
session::v0_1::VettingCardTypeItem::VerifiableDataStructure,
session::v0_1::VettingCardTypeItem::RelationshipCard,
session::v0_1::VettingCardTypeItem::VettingCard,
];
pub const VETTING_VAULT_PURPOSE: &str = "vetting";
pub const PORTRAIT_CLAIM_TYPE: &str = "person.portrait";
pub mod documentation {
pub const PASSPORT: &str = "passport";
pub const NATIONAL_ID: &str = "nationalId";
pub const DRIVER_LICENCE: &str = "driverLicence";
pub const NONE: &str = "none";
}
pub const MIN_VETTER_GRANT_VALIDITY_SECONDS: u64 = 86_400;
pub const MAX_VETTER_GRANT_VALIDITY_SECONDS: u64 = 2 * 365 * 86_400;
pub const DEFAULT_VETTER_GRANT_VALIDITY_SECONDS: u64 = 365 * 86_400;
pub const DEFAULT_VETTER_LIST_LIMIT: u64 = 50;
pub const MAX_VETTER_EVENT_SPAN_DAYS: i64 = 31;
#[must_use]
pub fn parse_iso8601_duration(s: &str) -> Option<Duration> {
fn accumulate(part: &str, units: &[(char, i64)]) -> Option<(i64, bool)> {
let mut seconds: i64 = 0;
let mut digits = String::new();
let mut next_unit = 0;
let mut any = false;
for c in part.chars() {
if c.is_ascii_digit() {
digits.push(c);
continue;
}
let offset = units[next_unit..].iter().position(|(unit, _)| *unit == c)?;
let (_, multiplier) = units[next_unit + offset];
next_unit += offset + 1;
let n: i64 = digits.parse().ok()?;
digits.clear();
seconds = seconds.checked_add(n.checked_mul(multiplier)?)?;
any = true;
}
if !digits.is_empty() {
return None;
}
Some((seconds, any))
}
let rest = s.strip_prefix('P')?;
let (date, time) = match rest.split_once('T') {
Some((date, time)) => (date, Some(time)),
None => (rest, None),
};
let (date_seconds, date_any) = accumulate(date, &[('W', 604_800), ('D', 86_400)])?;
let (time_seconds, time_any) = match time {
Some(time) => {
let (seconds, any) = accumulate(time, &[('H', 3_600), ('M', 60), ('S', 1)])?;
if !any {
return None;
}
(seconds, any)
}
None => (0, false),
};
if !date_any && !time_any {
return None;
}
Duration::try_seconds(date_seconds.checked_add(time_seconds)?)
}
#[must_use]
pub fn max_statement_age(requirements: &VettingRequirements) -> Option<Duration> {
requirements
.max_statement_age
.as_deref()
.and_then(|d| parse_iso8601_duration(d))
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ShapeError {
#[error("breaks its published schema: {0}")]
Schema(String),
#[error("joinDid must be the document issuer")]
JoinDidNotIssuer,
#[error("`{field}` {rule}")]
Field {
field: &'static str,
rule: &'static str,
},
}
pub trait CheckShape {
fn check_shape(&self) -> Result<(), ShapeError>;
}
fn schema_error(e: trust_tasks_rs::validate::ValidationError) -> ShapeError {
ShapeError::Schema(e.messages().join("; "))
}
fn against_own_schema<P: trust_tasks_rs::Payload + Serialize>(value: &P) -> Result<(), ShapeError> {
use trust_tasks_rs::validate::ValidatedPayload;
let json = serde_json::to_value(value).map_err(|e| ShapeError::Schema(e.to_string()))?;
P::validate_value(&json).map_err(schema_error)
}
pub(crate) fn against_definition<P: trust_tasks_rs::Payload>(
name: &str,
json: &Value,
) -> Result<(), ShapeError> {
let unavailable = |why: &str| ShapeError::Schema(format!("{}: {why}", P::TYPE_URI));
let embedded = P::PAYLOAD_SCHEMA.ok_or_else(|| unavailable("embeds no schema"))?;
let parsed: Value = serde_json::from_str(embedded).map_err(|e| unavailable(&e.to_string()))?;
let defs = parsed
.get("$defs")
.filter(|defs| defs.get(name).is_some())
.ok_or_else(|| unavailable(&format!("defines no `{name}`")))?;
let schema = serde_json::json!({
"$defs": defs,
"$ref": format!("#/$defs/{name}"),
});
trust_tasks_rs::validate::against_schema(&schema.to_string(), json).map_err(schema_error)
}
fn against_own_definition<P: trust_tasks_rs::Payload>(
name: &str,
value: &impl Serialize,
) -> Result<(), ShapeError> {
let json = serde_json::to_value(value).map_err(|e| ShapeError::Schema(e.to_string()))?;
against_definition::<P>(name, &json)
}
pub fn read_checked<P>(json: &Value) -> Result<P, ShapeError>
where
P: trust_tasks_rs::Payload + DeserializeOwned + CheckShape,
{
use trust_tasks_rs::validate::ValidatedPayload;
P::validate_value(json).map_err(schema_error)?;
let parsed: P =
serde_json::from_value(json.clone()).map_err(|e| ShapeError::Schema(e.to_string()))?;
parsed.check_shape()?;
Ok(parsed)
}
fn read_checked_definition<C, T>(name: &str, json: &Value) -> Result<T, ShapeError>
where
C: trust_tasks_rs::Payload,
T: DeserializeOwned + CheckShape,
{
against_definition::<C>(name, json)?;
let parsed: T =
serde_json::from_value(json.clone()).map_err(|e| ShapeError::Schema(e.to_string()))?;
parsed.check_shape()?;
Ok(parsed)
}
pub fn read_requirements(json: &Value) -> Result<VettingRequirements, ShapeError> {
read_checked_definition::<manifest::Response, _>("VettingRequirements", json)
}
pub fn read_branding(json: &Value) -> Result<manifest::CommunityBranding, ShapeError> {
read_checked_definition::<manifest::Response, _>("CommunityBranding", json)
}
pub fn read_request(
json: &Value,
document_issuer: &str,
) -> Result<request::v0_1::Payload, ShapeError> {
use trust_tasks_rs::validate::ValidatedPayload;
request::v0_1::Payload::validate_value(json).map_err(schema_error)?;
let parsed: request::v0_1::Payload =
serde_json::from_value(json.clone()).map_err(|e| ShapeError::Schema(e.to_string()))?;
check_request(&parsed, document_issuer)?;
Ok(parsed)
}
macro_rules! checked_by_schema {
($($ty:ty),+ $(,)?) => {
$(
impl CheckShape for $ty {
fn check_shape(&self) -> Result<(), ShapeError> {
against_own_schema(self)
}
}
)+
};
}
checked_by_schema!(
request::v0_1::Response,
session::v0_1::Payload,
session::v0_1::Response,
decline::v0_1::Payload,
revoke_statement::v0_1::Payload,
revoke_statement::v0_1::Response,
vetters::grant::v0_1::Payload,
vetters::grant::v0_1::Response,
vetters::profile::v0_1::Response,
vetters::resend::v0_1::Payload,
vetters::resend::v0_1::Response,
);
pub fn check_request(
payload: &request::v0_1::Payload,
document_issuer: &str,
) -> Result<(), ShapeError> {
against_own_schema(payload)?;
if payload.join_did.as_str() != document_issuer {
return Err(ShapeError::JoinDidNotIssuer);
}
Ok(())
}
impl CheckShape for session::v0_1::VettingCard {
fn check_shape(&self) -> Result<(), ShapeError> {
against_own_definition::<session::v0_1::Response>("VettingCard", self)
}
}
impl CheckShape for vetters::profile::v0_1::Payload {
fn check_shape(&self) -> Result<(), ShapeError> {
against_own_schema(self)?;
for event in &self.events {
if let Some(url) = &event.url {
shape::https_uri("events.url", url)?;
}
let (start, end) = (event.start_date.0, event.end_date.0);
if end < start {
return Err(ShapeError::Field {
field: "events.endDate",
rule: "must not be before startDate",
});
}
if (end - start).num_days() > MAX_VETTER_EVENT_SPAN_DAYS {
return Err(ShapeError::Field {
field: "events.endDate",
rule: "must be at most 31 days after startDate",
});
}
}
Ok(())
}
}
impl CheckShape for vetters::list::v0_1::Payload {
fn check_shape(&self) -> Result<(), ShapeError> {
against_own_schema(self)?;
if let (Some(from), Some(to)) = (&self.event_from, &self.event_to)
&& to.0 < from.0
{
return Err(ShapeError::Field {
field: "eventTo",
rule: "must not be before eventFrom",
});
}
Ok(())
}
}
impl CheckShape for vetters::list::v0_1::Response {
fn check_shape(&self) -> Result<(), ShapeError> {
against_own_schema(self)?;
for event in self.vetters.iter().flat_map(|vetter| &vetter.events) {
if let Some(url) = &event.url {
shape::https_uri("vetters.events.url", url)?;
}
}
Ok(())
}
}
impl CheckShape for VettingRequirements {
fn check_shape(&self) -> Result<(), ShapeError> {
against_own_definition::<manifest::Response>("VettingRequirements", self)?;
if let Some(url) = &self.governance_framework_url {
shape::https_uri("governanceFrameworkUrl", url)?;
}
if self
.min_by_method
.keys()
.any(|method| !self.accepted_methods.contains(method))
{
return Err(ShapeError::Field {
field: "minByMethod",
rule: "names a method acceptedMethods does not accept",
});
}
Ok(())
}
}
impl CheckShape for manifest::CommunityBranding {
fn check_shape(&self) -> Result<(), ShapeError> {
against_own_definition::<manifest::Response>("CommunityBranding", self)?;
if let Some(url) = &self.logo_url {
shape::https_uri("logoUrl", url)?;
}
Ok(())
}
}
#[must_use]
pub fn has_event_filter(body: &vetters::list::v0_1::Payload) -> bool {
body.event_from.is_some() || body.event_to.is_some() || body.event_name.is_some()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct IdentityVettingEndorsement {
#[serde(rename = "type")]
pub endorsement_type: String,
pub community: String,
pub method: VettingMethod,
#[serde(default)]
pub document_classes: Vec<VettingDocumentation>,
pub claims_verified: Vec<ClaimType>,
pub liveness_confirmed: bool,
pub identity_commitment: String,
pub card_digest_multibase: String,
pub declared_relationship: VettingRelationship,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attestation_text_digest: Option<String>,
}
impl CheckShape for IdentityVettingEndorsement {
fn check_shape(&self) -> Result<(), ShapeError> {
if self.endorsement_type.is_empty() || self.endorsement_type.chars().count() > 512 {
return Err(ShapeError::Field {
field: "type",
rule: "is empty or longer than 512 characters",
});
}
shape::did("community", &self.community)?;
if shape::repeats(&self.document_classes) {
return Err(ShapeError::Field {
field: "documentClasses",
rule: "repeats a documentation class",
});
}
if self
.document_classes
.iter()
.any(|d| d.as_str() == documentation::NONE)
{
return Err(ShapeError::Field {
field: "documentClasses",
rule: "never lists `none`: no document is the empty list",
});
}
if shape::repeats(&self.claims_verified) {
return Err(ShapeError::Field {
field: "claimsVerified",
rule: "repeats a claim type",
});
}
shape::no_portrait(
"claimsVerified",
self.claims_verified.iter().map(|c| c.as_str()),
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub enum GrantOrigin {
Auto,
Manual,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct VetterProfileSummary {
pub listed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub country: Option<String>,
#[serde(default)]
pub languages: Vec<String>,
#[serde(default)]
#[cfg_attr(
feature = "openapi",
schema(value_type = Vec<crate::openapi::VetterProfile01VettingMethod>)
)]
pub methods: Vec<vetters::profile::v0_1::VettingMethod>,
pub event_count: u32,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct VetterGrantRow {
pub endorsement_id: String,
pub member_did: String,
pub credential_id: String,
pub valid_from: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub valid_until: Option<DateTime<Utc>>,
pub revoked: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revoked_at: Option<DateTime<Utc>>,
pub live: bool,
pub origin: GrantOrigin,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<VetterProfileSummary>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct VetterGrantListResponse {
pub vetters: Vec<VetterGrantRow>,
}
pub const DEFAULT_AUTO_GRANT_SWEEP_MINUTES: u32 = 60;
pub const MIN_AUTO_GRANT_SWEEP_MINUTES: u32 = 5;
pub const MAX_AUTO_GRANT_SWEEP_MINUTES: u32 = 1440;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AutoGrantConfig {
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sweep_minutes: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub validity_seconds: Option<u64>,
}
impl CheckShape for AutoGrantConfig {
fn check_shape(&self) -> Result<(), ShapeError> {
if self.sweep_minutes.is_some_and(|m| {
!(MIN_AUTO_GRANT_SWEEP_MINUTES..=MAX_AUTO_GRANT_SWEEP_MINUTES).contains(&m)
}) {
return Err(ShapeError::Field {
field: "sweepMinutes",
rule: "must be between 5 and 1440",
});
}
if self.validity_seconds.is_some_and(|s| {
!(MIN_VETTER_GRANT_VALIDITY_SECONDS..=MAX_VETTER_GRANT_VALIDITY_SECONDS).contains(&s)
}) {
return Err(ShapeError::Field {
field: "validitySeconds",
rule: "must be between one day and two years",
});
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct AutoGrantSweep {
pub ran_at: DateTime<Utc>,
pub granted: u32,
pub revoked: u32,
pub errors: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct AutoGrantStatus {
pub enabled: bool,
pub sweep_minutes: u32,
pub validity_seconds: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_sweep: Option<AutoGrantSweep>,
}
pub(crate) mod shape {
use super::{PORTRAIT_CLAIM_TYPE, ShapeError};
pub(crate) fn did(field: &'static str, value: &str) -> Result<(), ShapeError> {
if value.starts_with("did:") {
return Ok(());
}
Err(ShapeError::Field {
field,
rule: "must be a DID",
})
}
pub(crate) fn repeats<T: PartialEq>(values: &[T]) -> bool {
values
.iter()
.enumerate()
.any(|(i, v)| values[..i].contains(v))
}
const MAX_URI_CHARS: usize = 2048;
pub(crate) fn https_uri(field: &'static str, value: &str) -> Result<(), ShapeError> {
let fail = |rule: &'static str| -> Result<(), ShapeError> {
Err(ShapeError::Field { field, rule })
};
if value.chars().any(|c| c.is_whitespace() || c.is_control()) {
return fail("must not contain whitespace or control characters");
}
if value.chars().count() > MAX_URI_CHARS {
return fail("must be at most 2048 characters");
}
let Some(rest) = value.strip_prefix("https://") else {
return fail("must be an absolute https URI");
};
let uri_character =
|b: u8| b.is_ascii_alphanumeric() || b"-._~:/?#[]@!$&'()*+,;=%".contains(&b);
let escapes_are_well_formed = value.bytes().enumerate().all(|(i, b)| {
b != b'%'
|| value
.as_bytes()
.get(i + 1..i + 3)
.is_some_and(|hex| hex.iter().all(u8::is_ascii_hexdigit))
});
let has_authority = !rest.is_empty() && !rest.starts_with(['/', '?', '#']);
let parses = url::Url::parse(value)
.is_ok_and(|u| u.scheme() == "https" && u.host_str().is_some_and(|h| !h.is_empty()));
if value.bytes().all(uri_character) && escapes_are_well_formed && has_authority && parses {
return Ok(());
}
fail("must be an absolute https URI")
}
pub(crate) fn no_portrait<'a>(
field: &'static str,
mut claim_types: impl Iterator<Item = &'a str>,
) -> Result<(), ShapeError> {
if claim_types.any(|t| t == PORTRAIT_CLAIM_TYPE) {
return Err(ShapeError::Field {
field,
rule: "must not name person.portrait",
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::de::DeserializeOwned;
use serde_json::json;
fn refused<T: trust_tasks_rs::Payload + DeserializeOwned + CheckShape>(value: Value) -> bool {
read_checked::<T>(&value).is_err()
}
fn profile_json() -> Value {
json!({
"listed": true,
"displayName": "Carol",
"languages": ["en", "de-AT"],
"location": { "country": "CZ", "city": "Prague" },
"methods": ["inPerson", "video"],
"acceptsDocumentation": ["passport", "none"],
"availability": "Weekday evenings",
"contactHint": "Ask on the kernel list",
"events": [{
"name": "Kernel Maintainer Summit",
"startDate": "2026-10-05",
"endDate": "2026-10-08",
"location": { "country": "CZ", "city": "Prague" },
"url": "https://events.example.org/kms"
}]
})
}
#[test]
fn a_vetter_profile_round_trips_as_published_and_is_closed() {
let p: vetters::profile::v0_1::Payload = serde_json::from_value(profile_json()).unwrap();
p.check_shape().unwrap();
assert_eq!(serde_json::to_value(&p).unwrap(), profile_json());
let minimal_json = json!({
"listed": false, "languages": [], "methods": ["video"],
"acceptsDocumentation": [], "events": []
});
let minimal: vetters::profile::v0_1::Payload =
serde_json::from_value(minimal_json.clone()).unwrap();
minimal.check_shape().unwrap();
assert_eq!(
serde_json::to_value(&minimal).unwrap(),
minimal_json,
"the empty arrays are written, optionals are absent"
);
for required in ["languages", "methods", "acceptsDocumentation", "events"] {
let mut missing = minimal_json.clone();
missing.as_object_mut().unwrap().remove(required);
assert!(
serde_json::from_value::<vetters::profile::v0_1::Payload>(missing).is_err(),
"{required} is required"
);
}
for (member, value) in [
("email", json!("c@example.com")),
("location", json!({ "country": "CZ", "street": "x" })),
] {
let mut extra = minimal_json.clone();
extra[member] = value;
assert!(serde_json::from_value::<vetters::profile::v0_1::Payload>(extra).is_err());
}
assert!(
serde_json::from_value::<vetters::profile::v0_1::Response>(json!({
"listed": true, "updatedAt": "2026-09-15T08:30:01Z", "ext": {}
}))
.is_err(),
"the response has no ext"
);
}
#[test]
fn a_vetter_profile_is_bounded_by_its_schema_and_its_event_rule() {
type Profile = vetters::profile::v0_1::Payload;
let broken = |f: &dyn Fn(&mut Value)| {
let mut p = profile_json();
f(&mut p);
refused::<Profile>(p)
};
assert!(!broken(&|_| {}));
assert!(broken(&|p| p["displayName"] = json!("")));
assert!(broken(&|p| p["displayName"] = json!("x".repeat(129))));
assert!(!broken(&|p| p["displayName"] = json!("é".repeat(128))));
assert!(broken(&|p| p["languages"] = json!(["x"])));
assert!(broken(&|p| p["languages"] = json!(["en", "en"])));
assert!(broken(&|p| {
p["languages"] = json!((0..17).map(|i| format!("en-{i}")).collect::<Vec<_>>());
}));
assert!(broken(&|p| p["methods"] = json!([])));
assert!(broken(&|p| p["methods"] = json!(["video", "video"])));
assert!(broken(&|p| p["acceptsDocumentation"] = json!(["Passport"])));
assert!(broken(
&|p| p["acceptsDocumentation"] = json!(["none", "none"])
));
assert!(broken(&|p| {
p["acceptsDocumentation"] =
json!((0..17).map(|i| format!("doc{i}")).collect::<Vec<_>>());
}));
assert!(broken(&|p| p["availability"] = json!("x".repeat(501))));
assert!(broken(&|p| p["contactHint"] = json!("x".repeat(301))));
assert!(broken(&|p| p["location"]["country"] = json!("cz")));
assert!(broken(&|p| p["location"]["country"] = json!("CZE")));
assert!(broken(&|p| p["location"]["region"] = json!("")));
assert!(broken(&|p| {
let event = p["events"][0].clone();
p["events"] = json!(vec![event; 33]);
}));
assert!(broken(&|p| p["events"][0]["name"] = json!("x".repeat(201))));
assert!(broken(
&|p| p["events"][0]["url"] = json!("http://events.example.org")
));
assert!(broken(
&|p| p["events"][0]["url"] = json!("https://events.example.org/kernel meetup")
));
assert!(broken(
&|p| p["events"][0]["url"] = json!("https://events.example.org/\u{7}")
));
assert!(broken(&|p| p["events"][0]["endDate"] = json!("2026-10-04")));
assert!(broken(&|p| p["events"][0]["endDate"] = json!("2026-11-06")));
assert!(!broken(&|p| p["events"][0]["endDate"] = json!("2026-11-05")));
}
#[test]
fn event_dates_are_exactly_year_month_day() {
for bad in [
"2026-1-05",
"26-10-05",
"2026-10-05T00:00:00Z",
"+2026-10-05",
"2026-13-01",
"2026-02-30",
"",
] {
let mut p = profile_json();
p["events"][0]["startDate"] = json!(bad);
assert!(
refused::<vetters::profile::v0_1::Payload>(p),
"accepted {bad}"
);
}
}
#[test]
fn a_listing_request_is_closed_and_bounded() {
type List = vetters::list::v0_1::Payload;
let body = json!({
"language": "de", "country": "AT", "region": "Wien", "city": "Wien",
"method": "inPerson", "eventFrom": "2026-10-01", "eventTo": "2026-10-31",
"eventName": "summit", "limit": 100, "cursor": "abc"
});
let parsed: List = serde_json::from_value(body.clone()).unwrap();
parsed.check_shape().unwrap();
assert!(has_event_filter(&parsed));
assert!(!has_event_filter(&List::default()));
List::default().check_shape().unwrap();
assert_eq!(
serde_json::to_value(List::default()).unwrap(),
json!({}),
"every filter is absent, never null"
);
assert!(refused::<List>(json!({ "memberDid": "did:key:z" })));
for (member, value) in [
("language", json!("deutsch-")),
("country", json!("at")),
("limit", json!(0)),
("limit", json!(101)),
("cursor", json!("x".repeat(513))),
("eventName", json!("x".repeat(201))),
] {
let mut b = body.clone();
b[member] = value;
assert!(refused::<List>(b), "{member}");
}
let mut backwards = body;
backwards["eventTo"] = json!("2026-09-30");
let backwards: List = serde_json::from_value(backwards).unwrap();
assert_eq!(
backwards.check_shape(),
Err(ShapeError::Field {
field: "eventTo",
rule: "must not be before eventFrom"
})
);
}
#[test]
fn a_resend_carries_nothing_and_answers_with_the_credential() {
serde_json::from_value::<vetters::resend::v0_1::Payload>(json!({})).unwrap();
assert!(
serde_json::from_value::<vetters::resend::v0_1::Payload>(
json!({ "memberDid": "did:key:z" })
)
.is_err()
);
let r: vetters::resend::v0_1::Response = vetters::resend::v0_1::Response::builder()
.credential_id("urn:uuid:5b0e1c2a-7d4f-4a51-9c6e-2f1b8d3a9e70")
.valid_until(Utc::now())
.try_into()
.unwrap();
r.check_shape().unwrap();
let v = serde_json::to_value(&r).unwrap();
assert!(v["credentialId"].is_string() && v["validUntil"].is_string());
assert!(v.get("ext").is_none());
}
#[test]
fn the_auto_grant_config_is_bounded() {
let config = |sweep: Option<u32>, validity: Option<u64>| AutoGrantConfig {
enabled: true,
sweep_minutes: sweep,
validity_seconds: validity,
};
assert!(config(None, None).check_shape().is_ok());
assert!(
config(Some(5), Some(MIN_VETTER_GRANT_VALIDITY_SECONDS))
.check_shape()
.is_ok()
);
assert!(
config(Some(1440), Some(MAX_VETTER_GRANT_VALIDITY_SECONDS))
.check_shape()
.is_ok()
);
assert!(config(Some(4), None).check_shape().is_err());
assert!(config(Some(1441), None).check_shape().is_err());
assert!(
config(None, Some(MIN_VETTER_GRANT_VALIDITY_SECONDS - 1))
.check_shape()
.is_err()
);
assert!(
serde_json::from_value::<AutoGrantConfig>(json!({ "enabled": true, "x": 1 })).is_err()
);
let status = AutoGrantStatus {
enabled: false,
sweep_minutes: DEFAULT_AUTO_GRANT_SWEEP_MINUTES,
validity_seconds: DEFAULT_VETTER_GRANT_VALIDITY_SECONDS,
last_sweep: None,
};
let v = serde_json::to_value(status).unwrap();
assert_eq!(v["sweepMinutes"], 60);
assert!(v.get("lastSweep").is_none());
assert_eq!(serde_json::to_value(GrantOrigin::Auto).unwrap(), "auto");
}
#[test]
fn the_vetter_role_matches_in_both_spellings_and_nothing_else_does() {
assert!(role_matches("vetter", "vetter"));
assert!(role_matches("custom:vetter", "vetter"));
assert!(role_matches("vetter", "custom:vetter"));
assert!(role_matches("custom:vetter", "custom:vetter"));
assert!(role_matches("moderator", "moderator"));
assert!(!role_matches("custom:moderator", "moderator"));
assert!(!role_matches("member", "vetter"));
assert!(!role_matches("vetters", "vetter"));
}
#[test]
fn a_vetter_grant_is_closed_and_bounded_by_its_schema() {
type Grant = vetters::grant::v0_1::Payload;
let body: Grant = serde_json::from_value(json!({
"memberDid": "did:key:zCarol",
"validitySeconds": 86_400
}))
.unwrap();
body.check_shape().unwrap();
assert!(
refused::<Grant>(json!({ "memberDid": "did:key:zCarol", "role": "admin" })),
"the grant names no role: it only ever names a vetter"
);
assert!(refused::<Grant>(json!({ "memberDid": "carol" })));
for (validity, ok) in [
(MIN_VETTER_GRANT_VALIDITY_SECONDS - 1, false),
(MIN_VETTER_GRANT_VALIDITY_SECONDS, true),
(MAX_VETTER_GRANT_VALIDITY_SECONDS, true),
(MAX_VETTER_GRANT_VALIDITY_SECONDS + 1, false),
] {
assert_eq!(
!refused::<Grant>(
json!({ "memberDid": "did:key:zCarol", "validitySeconds": validity })
),
ok,
"{validity}"
);
}
}
#[test]
fn the_grant_bounds_are_the_grant_schemas() {
let schema: Value = serde_json::from_str(
<vetters::grant::v0_1::Payload as trust_tasks_rs::Payload>::PAYLOAD_SCHEMA.unwrap(),
)
.unwrap();
let validity = &schema["properties"]["validitySeconds"];
assert_eq!(validity["minimum"], MIN_VETTER_GRANT_VALIDITY_SECONDS);
assert_eq!(validity["maximum"], MAX_VETTER_GRANT_VALIDITY_SECONDS);
}
fn requirements_json() -> Value {
json!({
"version": "0.1",
"statementType": IDENTITY_VETTING_ENDORSEMENT_TYPE,
"minStatements": 2,
"minByMethod": { "inPerson": 1 },
"acceptedMethods": ["inPerson", "video", "priorAcquaintance"],
"requiredClaims": ["name.legal"],
"maxStatementAge": "P120D",
"eligibleVetters": { "role": "vetter" },
"independence": { "maxByDeclaredRelationship": { "family": 0 } }
})
}
#[test]
fn requirements_round_trip_as_published() {
let req: VettingRequirements = serde_json::from_value(requirements_json()).unwrap();
assert_eq!(req.min_by_method.get(&VettingMethod::InPerson), Some(&1));
assert_eq!(
req.independence
.as_ref()
.unwrap()
.max_by_declared_relationship
.get(&VettingRelationship::Family),
Some(&0)
);
let back = serde_json::to_value(&req).unwrap();
assert_eq!(back, requirements_json());
assert!(back.get("acceptedDocumentClasses").is_none());
req.check_shape().unwrap();
assert_eq!(max_statement_age(&req), Duration::try_days(120));
}
#[test]
fn requirements_tolerate_members_a_newer_community_adds() {
let mut v = requirements_json();
v["vetterDirectory"] = json!(true);
let req: VettingRequirements = serde_json::from_value(v).unwrap();
req.check_shape().unwrap();
}
#[test]
fn requirements_that_cannot_be_evaluated_are_refused() {
let with = |member: &str, value: Value| {
let mut v = requirements_json();
v[member] = value;
read_requirements(&v).is_err()
};
assert!(with("minStatements", json!(0)));
assert!(with("acceptedMethods", json!([])));
assert!(with("acceptedMethods", json!(["video", "video"])));
assert!(
with("maxStatementAge", json!("P4M")),
"months are calendar-dependent"
);
assert!(with("version", json!("1")), "version is MAJOR.MINOR");
assert!(with("eligibleVetters", json!({ "role": "vet ter" })));
assert!(with("acceptedDocumentClasses", json!(["national-id"])));
assert!(!with(
"acceptedDocumentClasses",
json!([documentation::NATIONAL_ID])
));
assert!(
with("governanceFrameworkUrl", json!("http://gov.example")),
"governance text is served over https"
);
assert!(with(
"governanceFrameworkUrl",
json!("https://gov.example/frame work")
));
assert!(with(
"governanceFrameworkUrl",
json!("https://gov.example/\u{1b}")
));
assert!(
with("decisionSla", json!(format!("PT{}S", "1".repeat(30)))),
"durations are at most 32 characters"
);
let mut floor_unaccepted = requirements_json();
floor_unaccepted["acceptedMethods"] = json!(["video"]);
let req: VettingRequirements = serde_json::from_value(floor_unaccepted).unwrap();
assert_eq!(
req.check_shape(),
Err(ShapeError::Field {
field: "minByMethod",
rule: "names a method acceptedMethods does not accept"
}),
"a floor on a method that never counts is unsatisfiable"
);
}
#[test]
fn durations_parse_the_supported_subset_only() {
assert_eq!(parse_iso8601_duration("P120D"), Duration::try_days(120));
assert_eq!(parse_iso8601_duration("P2W"), Duration::try_days(14));
assert_eq!(parse_iso8601_duration("P1DT12H"), Duration::try_hours(36));
assert_eq!(parse_iso8601_duration("PT15M"), Duration::try_minutes(15));
for bad in [
"", "P", "PT", "120D", "P1Y", "P3M", "P1D2", "PTX", "P-1D", "P1D2W", "PT1M1H", "P1D1D",
] {
assert_eq!(parse_iso8601_duration(bad), None, "{bad:?}");
}
}
fn request_json() -> Value {
json!({
"community": "did:web:vtc.example",
"joinDid": "did:key:zApplicant",
"ticket": { "code": "K7QF-2M9X" },
"languages": ["en", "pt-BR", "zh-Hant-TW"],
"message": "Hello"
})
}
fn request_refused(value: Value) -> bool {
read_request(&value, "did:key:zApplicant").is_err()
}
#[test]
fn a_request_is_checked_against_its_schema_and_its_issuer() {
let body: request::v0_1::Payload = serde_json::from_value(request_json()).unwrap();
assert!(matches!(
body.ticket,
Some(request::v0_1::Ticket::ShortCodeTicket(_))
));
check_request(&body, "did:key:zApplicant").unwrap();
assert_eq!(
check_request(&body, "did:key:zSomeoneElse"),
Err(ShapeError::JoinDidNotIssuer)
);
let mut both = request_json();
both["introduction"] = json!({ "type": ["VerifiableCredential"] });
assert!(
matches!(
read_request(&both, "did:key:zApplicant"),
Err(ShapeError::Schema(_))
),
"a ticket or an introduction, not both"
);
let mut empty_introduction = request_json();
empty_introduction["introduction"] = json!({});
assert!(read_request(&empty_introduction, "did:key:zApplicant").is_err());
}
#[test]
fn request_bounds_follow_the_schema() {
assert!(!request_refused(request_json()));
for (member, value) in [
("ticket", json!({ "code": "K7QF-2M9O" })),
(
"ticket",
json!({ "ticketId": "t-1", "secret": "too-short" }),
),
(
"ticket",
json!({ "ticketId": "t 1", "secret": "A".repeat(43) }),
),
(
"ticket",
json!({ "code": "K7QF-2M9X", "secret": "A".repeat(43) }),
),
("languages", json!(["en", "en"])),
("languages", json!(["english"])),
(
"languages",
json!((0..17).map(|i| format!("en-x{i}")).collect::<Vec<_>>()),
),
("message", json!("x".repeat(1001))),
("message", json!("")),
("availability", json!("x".repeat(257))),
("community", json!("vtc.example")),
("tikcet", json!({ "code": "K7QF-2M9X" })),
] {
let mut b = request_json();
b[member] = value.clone();
assert!(request_refused(b), "{member}: {value}");
}
let scanned: request::v0_1::Ticket =
serde_json::from_value(json!({ "ticketId": "t1", "secret": "A".repeat(43) })).unwrap();
assert!(matches!(scanned, request::v0_1::Ticket::QrTicket(_)));
}
#[test]
fn reply_and_notice_bounds_follow_the_schema() {
let accepted = json!({
"requestId": "r1",
"acceptsDocumentation": ["passport", "nationalId"],
"sessionHint": "Hallway, 3pm"
});
assert!(!refused::<request::v0_1::Response>(accepted.clone()));
let mut a = accepted.clone();
a["acceptsDocumentation"] = json!(["national-id"]);
assert!(refused::<request::v0_1::Response>(a));
let mut a = accepted;
a["requestId"] = json!("r".repeat(129));
assert!(refused::<request::v0_1::Response>(a));
let session = json!({
"requestId": "r1",
"challenge": "A".repeat(43),
"domain": "did:web:vtc.example",
"method": "video",
"requiredClaims": ["name.legal"],
"expiresAt": "2026-09-17T15:17:00Z"
});
assert!(!refused::<session::v0_1::Payload>(session.clone()));
let mut s = session.clone();
s["optionalClaims"] = json!([PORTRAIT_CLAIM_TYPE]);
assert!(refused::<session::v0_1::Payload>(s));
let mut s = session;
s["challenge"] = json!("A".repeat(44));
assert!(refused::<session::v0_1::Payload>(s));
assert!(refused::<decline::v0_1::Payload>(json!({
"requestId": "r1", "code": "notComfortable", "message": "x".repeat(501)
})));
for (id, ok) in [
("urn:uuid:5b0e1c2a-7d4f-4a51-9c6e-2f1b8d3a9e70", true),
("https://vetter.example/statements/1", true),
("statement-1", false),
("urn:uuid:has space", false),
("1urn:x", false),
("urn:", false),
] {
let notice = json!({
"statementId": id,
"statementDigestMultibase": "zQmYimQAvAKzznkjph8xTTpuLhf21jAiUPMy7qdBp7qsU7Z"
});
assert_eq!(
!refused::<revoke_statement::v0_1::Payload>(notice),
ok,
"{id}"
);
}
}
#[test]
fn branding_is_bounded_by_the_manifest_schema() {
assert!(read_branding(&json!({})).is_ok());
assert!(
read_branding(&json!({
"displayName": "Linux Kernel", "accentColor": "#1A2b3c",
"logoUrl": "https://kernel.example.org/logo.svg"
}))
.is_ok()
);
for bad in [
json!({ "accentColor": "red" }),
json!({ "accentColor": "#12345g" }),
json!({ "logoUrl": "http://kernel.example.org/logo.svg" }),
json!({ "logoUrl": "https://kernel.example.org/my logo.svg" }),
json!({ "logoUrl": "https://kernel.example.org/logo\u{0}.svg" }),
json!({ "displayName": "x".repeat(129) }),
json!({ "displayName": "" }),
json!({ "tagline": "x" }),
] {
assert!(read_branding(&bad).is_err(), "{bad}");
}
let spaced: manifest::CommunityBranding =
serde_json::from_value(json!({ "logoUrl": "https://kernel.example.org/my logo.svg" }))
.unwrap();
assert!(spaced.check_shape().is_err());
}
#[test]
fn a_format_uri_member_is_an_absolute_https_uri() {
for good in [
"https://events.example.org",
"https://events.example.org/kms?day=1#hall-b",
"https://example.org/caf%C3%A9",
"https://[2001:db8::1]:8443/logo.svg",
] {
assert_eq!(shape::https_uri("url", good), Ok(()), "{good}");
}
let whitespace = "must not contain whitespace or control characters";
let not_a_uri = "must be an absolute https URI";
let too_long = format!("https://example.org/{}", "a".repeat(2048));
for (bad, rule) in [
("https://events.example.org/kernel meetup", whitespace),
("https://events.example.org/\u{7}", whitespace),
("https://events.example.org/\tx", whitespace),
("https://events.example.org/\u{85}", whitespace),
("http://events.example.org", not_a_uri),
("events.example.org", not_a_uri),
("https://", not_a_uri),
("https:///path", not_a_uri),
("https://?q=1", not_a_uri),
("https://example.org/%zz", not_a_uri),
("https://example.org/%4", not_a_uri),
("https://example.org/café", not_a_uri),
("https://example.org/<logo>", not_a_uri),
(too_long.as_str(), "must be at most 2048 characters"),
] {
assert_eq!(
shape::https_uri("url", bad),
Err(ShapeError::Field { field: "url", rule }),
"{bad:?}"
);
}
}
#[test]
fn a_listed_event_url_is_an_absolute_https_uri() {
let listing = |url: &str| {
let mut vetter = profile_json();
vetter["events"][0]["url"] = json!(url);
let members = vetter.as_object_mut().unwrap();
members.remove("listed");
members.insert("vetterDid".into(), json!("did:key:zVetter"));
members.insert("grantValidUntil".into(), json!("2027-01-01T00:00:00Z"));
members.insert("updatedAt".into(), json!("2026-09-01T00:00:00Z"));
json!({ "vetters": [vetter] })
};
type Listing = vetters::list::v0_1::Response;
assert!(!refused::<Listing>(listing(
"https://events.example.org/kms"
)));
for bad in [
"https://events.example.org/kernel meetup",
"https://events.example.org/\u{7}",
"http://events.example.org",
] {
assert!(refused::<Listing>(listing(bad)), "{bad:?}");
}
}
fn endorsement() -> IdentityVettingEndorsement {
serde_json::from_value(json!({
"type": IDENTITY_VETTING_ENDORSEMENT_TYPE,
"community": "did:web:vtc.example",
"method": "video",
"documentClasses": ["passport"],
"claimsVerified": ["name.legal"],
"livenessConfirmed": true,
"identityCommitment": "zCommitment",
"cardDigestMultibase": "zCard",
"declaredRelationship": "none"
}))
.unwrap()
}
#[test]
fn an_endorsement_follows_its_shared_definition() {
endorsement().check_shape().unwrap();
let broken = |f: &dyn Fn(&mut IdentityVettingEndorsement)| {
let mut e = endorsement();
f(&mut e);
e.check_shape().is_err()
};
assert!(broken(&|e| e.community = "vtc.example".into()));
assert!(broken(&|e| e.endorsement_type = String::new()));
assert!(broken(&|e| {
e.document_classes
.push(documentation::PASSPORT.try_into().unwrap());
}));
assert!(broken(&|e| {
e.document_classes = vec![documentation::NONE.try_into().unwrap()];
}));
assert!(broken(&|e| {
e.claims_verified
.push(PORTRAIT_CLAIM_TYPE.try_into().unwrap());
}));
assert!(!broken(&|e| e.document_classes.clear()));
assert!(
serde_json::from_value::<IdentityVettingEndorsement>(json!({
"type": IDENTITY_VETTING_ENDORSEMENT_TYPE,
"community": "did:web:vtc.example",
"method": "video",
"documentClasses": ["national-id"],
"claimsVerified": ["name.legal"],
"livenessConfirmed": true,
"identityCommitment": "zC",
"cardDigestMultibase": "zD",
"declaredRelationship": "none"
}))
.is_err(),
"documentation is a lowerCamelCase token"
);
}
#[test]
fn method_strings_round_trip() {
for m in [
VettingMethod::InPerson,
VettingMethod::Video,
VettingMethod::PriorAcquaintance,
] {
assert_eq!(m.to_string().parse::<VettingMethod>().unwrap(), m);
assert_eq!(serde_json::to_value(m).unwrap(), json!(m.to_string()));
}
}
}