use chrono::{Datelike, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use vti_common::error::AppError;
use crate::error::TaskError;
use crate::schemas::accepts::AcceptsCriterion;
use crate::schemas::accepts::{get_accepts, store_accepts};
use crate::server::AppState;
use crate::trust_tasks::TrustTaskOutcome;
use crate::vetting::pcs::{HiddenVettingConfig, HiddenVettingEvent};
use crate::vetting::pcs_tasks::{
HIDDEN_PUBLISH_ERR_APPROVER_IN_EVENT, HIDDEN_PUBLISH_ERR_APPROVER_NOT_SIGNER,
HIDDEN_PUBLISH_ERR_NO_SUCH_CRITERION, HIDDEN_PUBLISH_ERR_NO_VETTING,
HIDDEN_PUBLISH_ERR_OTHER_CRITERION, HIDDEN_PUBLISH_ERR_SIGNER_CHANGED,
HIDDEN_SHOW_ERR_NO_SUCH_CRITERION, HIDDEN_WITHDRAW_ERR_NO_SUCH_CRITERION,
};
use trust_tasks_rs::TrustTask;
use vti_common::audit::{AuditEvent, HiddenVettingChangedData};
use vti_common::auth::extractor::AuthClaims;
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PublishHiddenVettingBody {
pub criterion_id: String,
#[serde(default)]
pub live_periods: Option<Vec<String>>,
#[serde(default)]
pub live_token_labels: Option<Vec<String>>,
#[serde(default)]
pub drip_per_tick: Option<usize>,
#[serde(default)]
#[schema(value_type = Option<Vec<Object>>)]
pub events: Option<Value>,
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PublishHiddenVettingResponse {
pub criterion_id: String,
#[schema(value_type = Object)]
pub stored: Value,
#[schema(value_type = Object)]
pub published: Value,
pub requirements_digest: Option<String>,
}
pub(crate) enum Halted {
Refused(TaskError),
Gate(TrustTaskOutcome),
}
impl Halted {
fn declared(code: &'static str, error: AppError) -> Self {
Self::Refused(TaskError::declared(code, error))
}
}
impl From<TaskError> for Halted {
fn from(e: TaskError) -> Self {
Self::Refused(e)
}
}
impl From<AppError> for Halted {
fn from(e: AppError) -> Self {
Self::Refused(e.into())
}
}
async fn settle(
state: &AppState,
actor: &AuthClaims,
doc: &TrustTask<Value>,
criterion_id: &str,
summary: &str,
) -> Result<(), Halted> {
crate::trust_tasks::surface_tasks::settle_criterion_consent(
state,
actor,
doc,
crate::acl::admin_consent::Act::ChangeHiddenVetting,
criterion_id,
summary,
)
.await
.map_err(Halted::Gate)
}
fn this_month() -> String {
let now = Utc::now().date_naive();
format!("{:04}-{:02}", now.year(), now.month())
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn publish_hidden_vetting_core(
state: &AppState,
actor: &AuthClaims,
doc: &TrustTask<Value>,
criterion_id: String,
live_periods: Option<Vec<String>>,
live_token_labels: Option<Vec<String>>,
drip_per_tick: Option<usize>,
tick_length: Option<String>,
events: Option<Value>,
) -> Result<PublishHiddenVettingResponse, Halted> {
let signer = actor.did.as_str();
let community_did = state
.config
.read()
.await
.vtc_did
.clone()
.filter(|d| !d.is_empty())
.ok_or_else(|| AppError::Internal("VTC DID not configured".into()))?;
let mut criterion = get_accepts(&state.schemas_ks, &criterion_id)
.await?
.ok_or_else(|| {
TaskError::declared(
HIDDEN_PUBLISH_ERR_NO_SUCH_CRITERION,
AppError::NotFound(format!("no criterion `{criterion_id}`")),
)
})?;
if criterion.vetting.is_none() {
return Err(Halted::declared(
HIDDEN_PUBLISH_ERR_NO_VETTING,
AppError::Validation(format!(
"criterion `{criterion_id}` asks for no vetting, so there is nothing for \
hidden-vetting parameters to qualify — give it `vetting` requirements first",
)),
));
}
if let Some(other) = crate::schemas::accepts::list_accepts(&state.schemas_ks)
.await?
.into_iter()
.find(|c| c.id != criterion_id && c.hidden_vetting.is_some())
{
return Err(Halted::declared(
HIDDEN_PUBLISH_ERR_OTHER_CRITERION,
AppError::Conflict(format!(
"hidden vetting is already on for criterion `{}` — a community runs it on one \
criterion; turn it off there first",
other.id
)),
));
}
let period = this_month();
let live_periods = live_periods.unwrap_or_else(|| vec![period.clone()]);
let live_token_labels = live_token_labels.unwrap_or_else(|| vec![format!("token/{period}")]);
let mut config: HiddenVettingConfig = crate::vetting::pcs_issue::publish(
state,
&community_did,
live_periods,
live_token_labels,
drip_per_tick.unwrap_or(3),
)?;
if let Some(stored) = criterion
.hidden_vetting
.as_ref()
.and_then(|v| serde_json::from_value::<HiddenVettingConfig>(v.clone()).ok())
&& (stored.hvk != config.hvk || stored.tvk != config.tvk)
{
return Err(Halted::declared(
HIDDEN_PUBLISH_ERR_SIGNER_CHANGED,
AppError::Conflict(format!(
"criterion `{criterion_id}`'s hidden-vetting keys were derived from a signing key \
this community no longer holds, so vetters' credentials no longer verify. Turn \
hidden vetting off for it, then on again under a new period; vetters enrol again"
)),
));
}
if let Some(tick_length) = tick_length {
if crate::vetting::pcs::parse_tick_length(&tick_length).is_none() {
return Err(AppError::Validation(format!(
"tickLength `{tick_length}`: an ISO 8601 duration in days and/or hours, at least PT1H"
))
.into());
}
config.tick_length = tick_length;
}
if let Some(events) = events {
config.events = serde_json::from_value::<Vec<HiddenVettingEvent>>(events)
.map_err(|e| AppError::Validation(format!("events: {e}")))?;
}
let previous: Vec<HiddenVettingEvent> = criterion
.hidden_vetting
.as_ref()
.and_then(|v| serde_json::from_value::<HiddenVettingConfig>(v.clone()).ok())
.map(|c| c.events)
.unwrap_or_default();
let approved_events = check_approvals(state, signer, &previous, &config.events).await?;
let stored = serde_json::to_value(&config)
.map_err(|e| AppError::Internal(format!("encode hidden-vetting parameters: {e}")))?;
if criterion.hidden_vetting.as_ref() != Some(&stored) {
let verb = if criterion.hidden_vetting.is_some() {
"Change hidden vetting on"
} else {
"Turn on hidden vetting for"
};
settle(
state,
actor,
doc,
&criterion_id,
&format!(
"{verb} the join criterion {criterion_id}, which decides who may become a member \
of this community"
),
)
.await?;
}
criterion.hidden_vetting = Some(stored.clone());
store_accepts(&state.schemas_ks, &criterion).await?;
audit_change(
state,
signer,
HiddenVettingChangedData {
criterion_id: criterion_id.clone(),
change: "published".into(),
vetter_labels: config
.live_periods
.iter()
.map(|p| format!("vetter/{p}"))
.collect(),
token_labels: config.live_token_labels.clone(),
drip_per_tick: Some(config.drip_per_tick),
events: config.events.iter().map(|e| e.event_id.clone()).collect(),
approved_events,
},
)
.await?;
let served = crate::routes::join_requests::manifest::manifest_criterion(criterion)?;
Ok(PublishHiddenVettingResponse {
criterion_id,
stored,
published: config.published(),
requirements_digest: served
.json
.get("requirementsDigest")
.and_then(Value::as_str)
.map(str::to_string),
})
}
async fn check_approvals(
state: &AppState,
signer: &str,
previous: &[HiddenVettingEvent],
events: &[HiddenVettingEvent],
) -> Result<Vec<String>, TaskError> {
let mut approved = Vec::new();
for event in events {
let Some(approver) = event.approved_by.as_deref() else {
continue;
};
let stored = previous
.iter()
.find(|p| p.event_id == event.event_id)
.and_then(|p| p.approved_by.as_deref());
if stored != Some(approver) {
if approver != signer {
return Err(TaskError::declared(
HIDDEN_PUBLISH_ERR_APPROVER_NOT_SIGNER,
AppError::Forbidden(format!(
"event `{}` would be approved by {approver}, but an approver names \
themselves — set `approvedBy` to your own DID ({signer})",
event.event_id
)),
));
}
approved.push(event.event_id.clone());
}
if crate::vetting::pcs_event::has_asked(state, &event.event_id, approver).await? {
return Err(TaskError::declared(
HIDDEN_PUBLISH_ERR_APPROVER_IN_EVENT,
AppError::Forbidden(format!(
"{approver} has asked to vet at event `{}`, so cannot approve it — \
another administrator has to",
event.event_id
)),
));
}
}
Ok(approved)
}
async fn audit_change(
state: &AppState,
actor: &str,
data: HiddenVettingChangedData,
) -> Result<(), AppError> {
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(actor, None, AuditEvent::HiddenVettingChanged(data))
.await?;
}
Ok(())
}
fn served_digest(criterion: AcceptsCriterion) -> Result<Option<String>, AppError> {
let served = crate::routes::join_requests::manifest::manifest_criterion(criterion)?;
Ok(served
.json
.get("requirementsDigest")
.and_then(Value::as_str)
.map(str::to_string))
}
pub(crate) async fn withdraw_hidden_vetting_core(
state: &AppState,
actor: &AuthClaims,
doc: &TrustTask<Value>,
criterion_id: String,
) -> Result<Value, Halted> {
let signer = actor.did.as_str();
let mut criterion = get_accepts(&state.schemas_ks, &criterion_id)
.await?
.ok_or_else(|| {
TaskError::declared(
HIDDEN_WITHDRAW_ERR_NO_SUCH_CRITERION,
AppError::NotFound(format!("no criterion `{criterion_id}`")),
)
})?;
let withdrawn = criterion.hidden_vetting.take().is_some();
if withdrawn {
settle(
state,
actor,
doc,
&criterion_id,
&format!(
"Turn off hidden vetting for the join criterion {criterion_id}, which decides \
who may become a member of this community"
),
)
.await?;
store_accepts(&state.schemas_ks, &criterion).await?;
audit_change(
state,
signer,
HiddenVettingChangedData {
criterion_id: criterion_id.clone(),
change: "withdrawn".into(),
vetter_labels: Vec::new(),
token_labels: Vec::new(),
drip_per_tick: None,
events: Vec::new(),
approved_events: Vec::new(),
},
)
.await?;
}
let response = serde_json::json!({
"criterionId": criterion_id,
"withdrawn": withdrawn,
"requirementsDigest": served_digest(criterion)?,
});
let typed: trust_tasks_rs::specs::vtc::vetting::hidden::withdraw::v0_1::Response =
serde_json::from_value(response)
.map_err(|e| AppError::Internal(format!("withdraw response: {e}")))?;
serde_json::to_value(typed)
.map_err(|e| AppError::Internal(format!("withdraw response: {e}")).into())
}
pub(crate) async fn show_hidden_vetting_core(
state: &AppState,
criterion_id: String,
) -> Result<Value, TaskError> {
let criterion = get_accepts(&state.schemas_ks, &criterion_id)
.await?
.ok_or_else(|| {
TaskError::declared(
HIDDEN_SHOW_ERR_NO_SUCH_CRITERION,
AppError::NotFound(format!("no criterion `{criterion_id}`")),
)
})?;
let config = match criterion.hidden_vetting.as_ref() {
Some(v) => Some(
serde_json::from_value::<HiddenVettingConfig>(v.clone())
.map_err(|e| AppError::Internal(format!("stored hidden vetting: {e}")))?,
),
None => None,
};
let mut response = serde_json::json!({
"criterionId": criterion_id,
"enabled": config.is_some(),
"requirementsDigest": served_digest(criterion)?,
});
if let Some(config) = config {
let labels: Vec<String> = config
.live_periods
.iter()
.map(|p| format!("vetter/{p}"))
.collect();
let enrolled = crate::vetting::pcs_issue::enrolled_counts(state, &labels).await?;
let today = Utc::now().date_naive();
let mut status = Vec::with_capacity(config.events.len());
for event in &config.events {
let size = crate::vetting::pcs_event::group_size(state, &event.event_id).await?;
let approver_in_event = match event.approved_by.as_deref() {
Some(a) => crate::vetting::pcs_event::has_asked(state, &event.event_id, a).await?,
None => false,
};
let approved = event.approved_by.is_some() && !approver_in_event;
status.push(serde_json::json!({
"eventId": event.event_id,
"groupFloor": event.group_floor,
"groupSize": size,
"approved": approved,
"live": approved && size >= event.group_floor && today <= event.closes_after(),
}));
}
let map = response.as_object_mut().expect("object literal");
map.insert(
"stored".into(),
serde_json::to_value(&config)
.map_err(|e| AppError::Internal(format!("encode stored: {e}")))?,
);
map.insert("published".into(), config.published());
map.insert(
"enrolledVetters".into(),
serde_json::to_value(enrolled)
.map_err(|e| AppError::Internal(format!("encode counts: {e}")))?,
);
map.insert("eventStatus".into(), Value::Array(status));
}
let typed: trust_tasks_rs::specs::vtc::vetting::hidden::show::v0_1::Response =
serde_json::from_value(response)
.map_err(|e| AppError::Internal(format!("show response: {e}")))?;
serde_json::to_value(typed)
.map_err(|e| AppError::Internal(format!("show response: {e}")).into())
}