use std::cmp::Ordering;
use std::num::NonZeroU64;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tracing::{info, warn};
use crate::endorsements::VETTER_GRANT_ROW_TYPE;
use crate::endorsements::{Endorsement, endorsements_by_type};
use vta_sdk::protocols::vetting::vetters::{
list::v0_1 as list_wire, profile::v0_1 as profile_wire, show::v0_1 as show_wire,
};
use vta_sdk::protocols::vetting::{
CheckShape, DEFAULT_VETTER_LIST_LIMIT, VetterProfileSummary, has_event_filter,
};
use vta_sdk::protocols::vetting::{VETTER_ROLE, role_matches};
use vti_common::audit::{AuditEvent, VetterProfileDeletedData, VetterProfileUpdatedData};
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
use super::vetters;
use crate::server::AppState;
const PREFIX: &str = "profile:";
const CURSOR_PREFIX: &str = "offset:";
pub const DELETED_GRANT_REVOKED: &str = "grantRevoked";
pub const DELETED_DEPARTED: &str = "departed";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StoredProfile {
pub vetter_did: String,
pub profile: profile_wire::Payload,
pub updated_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issued_at: Option<DateTime<Utc>>,
}
impl StoredProfile {
pub fn summary(&self) -> VetterProfileSummary {
let p = &self.profile;
VetterProfileSummary {
listed: p.listed,
display_name: p.display_name.as_ref().map(|n| n.as_str().to_owned()),
country: p.location.as_ref().map(|l| l.country.as_str().to_owned()),
languages: p.languages.iter().map(|l| l.as_str().to_owned()).collect(),
methods: p.methods.0.clone(),
event_count: u32::try_from(p.events.len()).unwrap_or(u32::MAX),
updated_at: self.updated_at,
}
}
}
fn key(vetter_did: &str) -> String {
format!("{PREFIX}{vetter_did}")
}
pub async fn get_profile(
ks: &KeyspaceHandle,
vetter_did: &str,
) -> Result<Option<StoredProfile>, AppError> {
match ks.get_raw(key(vetter_did).into_bytes()).await? {
Some(bytes) => serde_json::from_slice(&bytes)
.map(Some)
.map_err(|e| AppError::Internal(format!("vetter profile decode: {e}"))),
None => Ok(None),
}
}
pub async fn list_profiles(ks: &KeyspaceHandle) -> Result<Vec<StoredProfile>, AppError> {
let rows = ks.prefix_iter_raw(PREFIX.as_bytes().to_vec()).await?;
Ok(rows
.into_iter()
.filter_map(|(_k, v)| match serde_json::from_slice(&v) {
Ok(p) => Some(p),
Err(e) => {
warn!(error = %e, "skipping an undecodable vetter profile row");
None
}
})
.collect())
}
pub async fn delete_profile(ks: &KeyspaceHandle, vetter_did: &str) -> Result<bool, AppError> {
let k = key(vetter_did);
let existed = ks.get_raw(k.clone().into_bytes()).await?.is_some();
if existed {
ks.remove(k.into_bytes()).await?;
}
Ok(existed)
}
pub async fn publish(
state: &AppState,
vetter_did: &str,
body: &profile_wire::Payload,
issued_at: Option<DateTime<Utc>>,
) -> Result<profile_wire::Response, AppError> {
body.check_shape()
.map_err(|e| AppError::Validation(e.to_string()))?;
let now = Utc::now();
let stored = StoredProfile {
vetter_did: vetter_did.to_string(),
profile: body.clone(),
updated_at: now,
issued_at,
};
{
let _guard = vetters::GRANT_LOCK.lock().await;
if vetters::live_grant(state, vetter_did, now).await?.is_none() {
return Err(AppError::Forbidden(format!(
"{vetter_did} is not an active member holding a live vetter grant"
)));
}
if let Some(new) = issued_at
&& let Some(held) = get_profile(&state.vetter_profiles_ks, vetter_did)
.await?
.and_then(|p| p.issued_at)
&& new < held
{
return Err(AppError::Validation(
"this profile document is older than the one the community holds".into(),
));
}
state
.vetter_profiles_ks
.insert(key(vetter_did), &stored)
.await?;
}
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
vetter_did,
Some(vetter_did),
AuditEvent::VetterProfileUpdated(VetterProfileUpdatedData {
listed: body.listed,
}),
)
.await?;
}
info!(vetter = %vetter_did, listed = body.listed, "vetter profile published");
profile_wire::Response::try_from(
profile_wire::Response::builder()
.listed(body.listed)
.updated_at(now),
)
.map_err(|e| AppError::Internal(format!("vetter profile response: {e}")))
}
pub(crate) async fn delete_unless_granted_locked(
state: &AppState,
actor_did: &str,
vetter_did: &str,
reason: &str,
) -> Result<bool, AppError> {
if vetters::live_grant(state, vetter_did, Utc::now())
.await?
.is_some()
{
return Ok(false);
}
if !delete_profile(&state.vetter_profiles_ks, vetter_did).await? {
return Ok(false);
}
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
actor_did,
Some(vetter_did),
AuditEvent::VetterProfileDeleted(VetterProfileDeletedData {
reason: reason.to_string(),
}),
)
.await?;
}
info!(vetter = %vetter_did, reason, "vetter profile deleted");
Ok(true)
}
pub(crate) async fn after_grant_revoked(
state: &AppState,
actor_did: &str,
vetter_did: &str,
) -> Result<bool, AppError> {
let _guard = vetters::GRANT_LOCK.lock().await;
delete_unless_granted_locked(state, actor_did, vetter_did, DELETED_GRANT_REVOKED).await
}
pub async fn list(
state: &AppState,
body: &list_wire::Payload,
) -> Result<list_wire::Response, AppError> {
body.check_shape()
.map_err(|e| AppError::Validation(e.to_string()))?;
let tag = filter_tag(body);
let offset = match body.cursor.as_deref() {
Some(cursor) => decode_cursor(cursor, &tag)?,
None => 0,
};
let limit = usize::try_from(
body.limit
.map_or(DEFAULT_VETTER_LIST_LIMIT, NonZeroU64::get),
)
.unwrap_or(usize::MAX)
.max(1);
let now = Utc::now();
let today = now.date_naive();
let live = vetters::live_grants(state, now).await?;
let mut rows: Vec<(SortKey, list_wire::ListedVetter)> = Vec::new();
for stored in list_profiles(&state.vetter_profiles_ks).await? {
if !stored.profile.listed {
continue;
}
let Some(grant) = live.get(&stored.vetter_did) else {
continue;
};
let Some(grant_valid_until) = grant.valid_until else {
continue;
};
let Some(event_date) = matches(&stored.profile, body, today) else {
continue;
};
let sort = SortKey {
event_date,
name: stored
.profile
.display_name
.as_ref()
.map(|n| n.as_str().to_owned()),
did: stored.vetter_did.clone(),
};
rows.push((sort, listed(stored, grant_valid_until, today)?));
}
rows.sort_by(|(a, _), (b, _)| a.cmp(b));
let total = rows.len();
let vetters: Vec<list_wire::ListedVetter> = rows
.into_iter()
.skip(offset)
.take(limit)
.map(|(_, v)| v)
.collect();
let next = offset.saturating_add(limit);
let next_cursor = (next < total)
.then(|| encode_cursor(next, &tag))
.map(list_wire::ResponseNextCursor::try_from)
.transpose()
.map_err(|e| AppError::Internal(format!("vetter listing cursor: {e}")))?;
list_wire::Response::try_from(
list_wire::Response::builder()
.vetters(vetters)
.next_cursor(next_cursor),
)
.map_err(|e| AppError::Internal(format!("vetter listing: {e}")))
}
pub async fn show(
state: &AppState,
body: &show_wire::Payload,
) -> Result<show_wire::Response, AppError> {
let vetter_did = body.vetter_did.as_str();
let now = Utc::now();
let mut builder = show_wire::Response::builder()
.vetter_did(
show_wire::ResponseVetterDid::try_from(vetter_did.to_owned())
.map_err(|e| AppError::Internal(format!("vetter show: {e}")))?,
)
.status(show_wire::GrantStatus::None);
if let Some(grant) = vetters::live_grant(state, vetter_did, now).await? {
let listed = get_profile(&state.vetter_profiles_ks, vetter_did)
.await?
.is_some_and(|stored| stored.profile.listed);
return build_show(
builder
.status(show_wire::GrantStatus::Live)
.grant_id(Some(grant_id(&grant)?))
.valid_until(grant.valid_until)
.listed(Some(listed)),
);
}
let mut rows: Vec<_> = endorsements_by_type(&state.endorsements_ks, VETTER_GRANT_ROW_TYPE)
.await?
.into_iter()
.filter(|row| row.subject_did == vetter_did)
.filter(|row| {
row.claim
.get("role")
.and_then(Value::as_str)
.is_some_and(|held| role_matches(held, VETTER_ROLE))
})
.collect();
rows.sort_by_key(|row| std::cmp::Reverse(row.created_at));
if let Some(revoked) = rows.iter().find(|row| row.revoked_at.is_some()) {
return build_show(
builder
.status(show_wire::GrantStatus::Revoked)
.grant_id(Some(grant_id(revoked)?))
.revoked_at(revoked.revoked_at),
);
}
if let Some(expired) = rows.first() {
return build_show(
builder
.status(show_wire::GrantStatus::Expired)
.grant_id(Some(grant_id(expired)?))
.valid_until(expired.valid_until),
);
}
builder = builder.status(show_wire::GrantStatus::None);
build_show(builder)
}
fn grant_id(row: &Endorsement) -> Result<show_wire::ResponseGrantId, AppError> {
show_wire::ResponseGrantId::try_from(row.id.to_string())
.map_err(|e| AppError::Internal(format!("vetter show grant id: {e}")))
}
fn build_show(builder: show_wire::builder::Response) -> Result<show_wire::Response, AppError> {
show_wire::Response::try_from(builder)
.map_err(|e| AppError::Internal(format!("vetter show: {e}")))
}
#[derive(Debug, PartialEq, Eq)]
struct SortKey {
event_date: Option<NaiveDate>,
name: Option<String>,
did: String,
}
impl Ord for SortKey {
fn cmp(&self, other: &Self) -> Ordering {
let name = match (&self.name, &other.name) {
(Some(a), Some(b)) => a.cmp(b),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => Ordering::Equal,
};
self.event_date
.cmp(&other.event_date)
.then(name)
.then_with(|| self.did.cmp(&other.did))
}
}
impl PartialOrd for SortKey {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
fn matches(
profile: &profile_wire::Payload,
body: &list_wire::Payload,
today: NaiveDate,
) -> Option<Option<NaiveDate>> {
if let Some(language) = &body.language {
let wanted = language.to_ascii_lowercase();
let prefix = format!("{wanted}-");
let any = profile.languages.iter().any(|tag| {
let tag = tag.to_ascii_lowercase();
tag == wanted || tag.starts_with(&prefix)
});
if !any {
return None;
}
}
let location = profile.location.as_ref();
if let Some(country) = &body.country
&& location.map(|l| l.country.as_str()) != Some(country.as_str())
{
return None;
}
if let Some(region) = &body.region
&& !location
.and_then(|l| l.region.as_ref())
.is_some_and(|r| same_text(r, region))
{
return None;
}
if let Some(city) = &body.city
&& !location
.and_then(|l| l.city.as_ref())
.is_some_and(|c| same_text(c, city))
{
return None;
}
if let Some(method) = body.method
&& !profile
.methods
.iter()
.any(|m| m.to_string() == method.to_string())
{
return None;
}
if !has_event_filter(body) {
return Some(None);
}
let name = body.event_name.as_ref().map(|n| n.to_lowercase());
profile
.events
.iter()
.filter(|e| e.end_date.0 >= today)
.filter(|e| {
body.event_from
.as_ref()
.is_none_or(|from| e.end_date.0 >= from.0)
})
.filter(|e| {
body.event_to
.as_ref()
.is_none_or(|to| e.start_date.0 <= to.0)
})
.filter(|e| {
name.as_deref()
.is_none_or(|n| e.name.to_lowercase().contains(n))
})
.map(|e| e.start_date.0)
.min()
.map(Some)
}
fn same_text(a: &str, b: &str) -> bool {
a.to_lowercase() == b.to_lowercase()
}
fn listed(
stored: StoredProfile,
grant_valid_until: DateTime<Utc>,
today: NaiveDate,
) -> Result<list_wire::ListedVetter, AppError> {
let internal =
|e: &dyn std::fmt::Display| AppError::Internal(format!("vetter listing row: {e}"));
let current: Vec<&profile_wire::VetterEvent> = stored
.profile
.events
.iter()
.filter(|e| e.end_date.0 >= today)
.collect();
let events = serde_json::to_value(current).map_err(|e| internal(&e))?;
let mut row = serde_json::to_value(&stored.profile).map_err(|e| internal(&e))?;
let fields = row
.as_object_mut()
.ok_or_else(|| internal(&"a profile is not an object"))?;
fields.remove("listed");
fields.remove("ext");
fields.insert("events".into(), events);
fields.insert("vetterDid".into(), Value::String(stored.vetter_did));
fields.insert(
"grantValidUntil".into(),
serde_json::to_value(grant_valid_until).map_err(|e| internal(&e))?,
);
fields.insert(
"updatedAt".into(),
serde_json::to_value(stored.updated_at).map_err(|e| internal(&e))?,
);
serde_json::from_value(row).map_err(|e| internal(&e))
}
fn filter_tag(body: &list_wire::Payload) -> String {
use sha2::{Digest, Sha256};
let mut filters = body.clone();
filters.limit = None;
filters.cursor = None;
filters.ext = None;
let bytes = serde_json::to_vec(&filters).unwrap_or_default();
hex::encode(&Sha256::digest(&bytes)[..8])
}
fn encode_cursor(offset: usize, tag: &str) -> String {
URL_SAFE_NO_PAD.encode(format!("{CURSOR_PREFIX}{offset}:{tag}"))
}
fn decode_cursor(cursor: &str, tag: &str) -> Result<usize, AppError> {
let refused =
|| AppError::Validation("cursor is not one this community issued for these filters".into());
let bytes = URL_SAFE_NO_PAD.decode(cursor).map_err(|_| refused())?;
let text = String::from_utf8(bytes).map_err(|_| refused())?;
let rest = text.strip_prefix(CURSOR_PREFIX).ok_or_else(refused)?;
let (digits, bound) = rest.split_once(':').ok_or_else(refused)?;
if bound != tag
|| digits.is_empty()
|| digits.len() > 9
|| !digits.bytes().all(|b| b.is_ascii_digit())
{
return Err(refused());
}
digits.parse().map_err(|_| refused())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn profile(value: serde_json::Value) -> profile_wire::Payload {
serde_json::from_value(value).unwrap()
}
fn today() -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 9, 11).unwrap()
}
fn carol() -> profile_wire::Payload {
profile(json!({
"listed": true,
"displayName": "Carol",
"languages": ["de-AT", "en"],
"location": { "country": "AT", "region": "Wien", "city": "Wien" },
"methods": ["inPerson"],
"acceptsDocumentation": ["passport"],
"events": [
{ "name": "Old Meetup", "startDate": "2026-08-01", "endDate": "2026-08-02" },
{ "name": "Kernel Maintainer Summit", "startDate": "2026-10-05", "endDate": "2026-10-08" },
{ "name": "Plumbers", "startDate": "2026-11-10", "endDate": "2026-11-12" }
]
}))
}
fn filter(value: serde_json::Value) -> list_wire::Payload {
serde_json::from_value(value).unwrap()
}
#[test]
fn a_language_matches_itself_or_as_a_primary_subtag_prefix() {
let p = carol();
assert!(matches(&p, &filter(json!({ "language": "de" })), today()).is_some());
assert!(matches(&p, &filter(json!({ "language": "DE-at" })), today()).is_some());
assert!(matches(&p, &filter(json!({ "language": "en" })), today()).is_some());
assert!(matches(&p, &filter(json!({ "language": "de-CH" })), today()).is_none());
assert!(matches(&p, &filter(json!({ "language": "fr" })), today()).is_none());
}
#[test]
fn place_and_method_filters_are_exact_and_region_city_ignore_case() {
let p = carol();
assert!(matches(&p, &filter(json!({ "country": "AT" })), today()).is_some());
assert!(matches(&p, &filter(json!({ "country": "DE" })), today()).is_none());
assert!(
matches(
&p,
&filter(json!({ "region": "WIEN", "city": "wien" })),
today()
)
.is_some()
);
assert!(matches(&p, &filter(json!({ "city": "Wiener Neustadt" })), today()).is_none());
assert!(matches(&p, &filter(json!({ "city": "Wie" })), today()).is_none());
assert!(matches(&p, &filter(json!({ "method": "inPerson" })), today()).is_some());
assert!(matches(&p, &filter(json!({ "method": "video" })), today()).is_none());
let mut nowhere = carol();
nowhere.location = None;
assert!(matches(&nowhere, &filter(json!({ "region": "Wien" })), today()).is_none());
}
#[test]
fn an_event_filter_matches_overlap_and_names_the_earliest_start() {
let p = carol();
let date = |from: Option<&str>, to: Option<&str>, name: Option<&str>| {
let mut f = json!({});
if let Some(v) = from {
f["eventFrom"] = json!(v);
}
if let Some(v) = to {
f["eventTo"] = json!(v);
}
if let Some(v) = name {
f["eventName"] = json!(v);
}
matches(&p, &filter(f), today())
};
let d = |s: &str| Some(Some(s.parse::<NaiveDate>().unwrap()));
assert_eq!(
date(Some("2026-10-08"), Some("2026-10-20"), None),
d("2026-10-05")
);
assert_eq!(
date(Some("2026-09-01"), Some("2026-10-05"), None),
d("2026-10-05")
);
assert_eq!(date(Some("2026-10-09"), None, None), d("2026-11-10"));
assert_eq!(date(None, Some("2026-12-31"), None), d("2026-10-05"));
assert_eq!(date(Some("2026-10-09"), Some("2026-11-09"), None), None);
assert_eq!(date(Some("2026-08-01"), Some("2026-08-02"), None), None);
assert_eq!(date(None, None, Some("PLUMB")), d("2026-11-10"));
assert_eq!(
date(Some("2026-10-01"), Some("2026-10-31"), Some("plumbers")),
None
);
assert_eq!(date(None, None, Some("old meetup")), None);
assert_eq!(
matches(&p, &list_wire::Payload::default(), today()),
Some(None)
);
}
#[test]
fn listing_order_is_event_date_then_named_before_unnamed_then_did_by_code_point() {
let key = |date: Option<&str>, name: Option<&str>, did: &str| SortKey {
event_date: date.map(|d| d.parse().unwrap()),
name: name.map(str::to_string),
did: did.into(),
};
let mut keys = [
key(None, None, "did:key:a"),
key(None, Some("alice"), "did:key:z"),
key(None, Some("Zed"), "did:key:b"),
key(None, Some("Alice"), "did:key:d"),
key(None, Some("Alice"), "did:key:c"),
];
keys.sort();
let dids: Vec<&str> = keys.iter().map(|k| k.did.as_str()).collect();
assert_eq!(
dids,
[
"did:key:c",
"did:key:d",
"did:key:b",
"did:key:z",
"did:key:a"
]
);
let mut by_event = [
key(Some("2026-11-01"), Some("Alice"), "did:key:a"),
key(Some("2026-10-05"), None, "did:key:e"),
key(Some("2026-10-05"), Some("Carol"), "did:key:c"),
];
by_event.sort();
let dids: Vec<&str> = by_event.iter().map(|k| k.did.as_str()).collect();
assert_eq!(dids, ["did:key:c", "did:key:e", "did:key:a"]);
}
#[test]
fn a_listed_vetter_shows_only_events_that_have_not_ended() {
let stored = StoredProfile {
vetter_did: "did:key:zCarol".into(),
profile: carol(),
updated_at: Utc::now(),
issued_at: None,
};
let row = listed(stored, Utc::now(), today()).unwrap();
let names: Vec<&str> = row.events.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, ["Kernel Maintainer Summit", "Plumbers"]);
assert_eq!(row.methods.0, vec![list_wire::VettingMethod::InPerson]);
assert_eq!(row.vetter_did.as_str(), "did:key:zCarol");
let wire = serde_json::to_value(&row).unwrap();
assert!(wire.get("listed").is_none() && wire.get("ext").is_none());
}
#[test]
fn cursors_round_trip_bound_to_their_filters_and_foreign_ones_are_refused() {
let body = filter(json!({ "language": "de", "limit": 10 }));
let tag = filter_tag(&body);
assert_eq!(decode_cursor(&encode_cursor(50, &tag), &tag).unwrap(), 50);
assert!(encode_cursor(usize::MAX, &tag).len() <= 512);
let more = filter(json!({ "language": "de", "limit": 20, "cursor": "x" }));
assert_eq!(filter_tag(&more), tag);
let other = filter_tag(&filter(json!({ "language": "en" })));
assert!(decode_cursor(&encode_cursor(50, &tag), &other).is_err());
for bad in [
String::new(),
"!!".into(),
URL_SAFE_NO_PAD.encode("offset:"),
URL_SAFE_NO_PAD.encode(format!("offset::{tag}")),
URL_SAFE_NO_PAD.encode(format!("offset:-1:{tag}")),
URL_SAFE_NO_PAD.encode(format!("offset:1e3:{tag}")),
URL_SAFE_NO_PAD.encode(format!("offset:9999999999:{tag}")),
URL_SAFE_NO_PAD.encode("offset:5"),
URL_SAFE_NO_PAD.encode(format!("page:2:{tag}")),
URL_SAFE_NO_PAD.encode([0xff, 0xfe]),
] {
assert!(decode_cursor(&bad, &tag).is_err(), "accepted {bad:?}");
}
}
}