use affinidi_status_list::StatusPurpose;
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use serde::Serialize;
use serde_json::{Value as JsonValue, json};
use tracing::info;
use uuid::Uuid;
use vti_common::audit::{AuditEvent, MembershipRenewedData};
use vti_common::error::AppError;
use crate::acl::get_acl_entry;
use crate::auth::AuthClaims;
use crate::credentials::{
CredentialStatusRef, RoleVecParams, VmcParams, build_role_vec, build_vmc,
};
use crate::members::{get_member, store_member};
use crate::policy::{
PolicyPurpose, compile as compile_policy, evaluate as evaluate_policy, get_active_policy_id,
get_policy,
};
use crate::server::AppState;
use crate::status_list;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RenewResponse {
pub did: String,
pub vmc: JsonValue,
pub role_vec: JsonValue,
pub personhood: bool,
pub personhood_changed: bool,
}
pub async fn renew(
auth: AuthClaims,
State(state): State<AppState>,
) -> Result<(StatusCode, Json<RenewResponse>), AppError> {
let caller_did = auth.did.clone();
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
let _acl = get_acl_entry(&state.acl_ks, &caller_did)
.await?
.ok_or_else(|| AppError::NotFound(format!("no ACL row for {caller_did} — not a member")))?;
let mut member = get_member(&state.members_ks, &caller_did)
.await?
.ok_or_else(|| AppError::NotFound(format!("no Member row for {caller_did}")))?;
let signer = state.credential_signer.as_ref().ok_or_else(|| {
AppError::Internal(
"credential signer not initialised — cannot renew (run setup first)".into(),
)
})?;
let status_list_state =
status_list::get_state(&state.status_lists_ks, StatusPurpose::Revocation)
.await?
.ok_or_else(|| {
AppError::Internal(
"revocation status list not provisioned — set `public_url` + restart".into(),
)
})?;
let slot = match member.status_list_index {
Some(s) => s,
None => {
let mut row = status_list_state.clone();
let s = status_list::allocate(&mut row).ok_or_else(|| {
AppError::Internal(format!(
"revocation status list exhausted (capacity = {})",
row.capacity
))
})?;
status_list::store_state(&state.status_lists_ks, &row).await?;
s
}
};
let status_ref =
CredentialStatusRef::revocation(status_list_state.list_credential_id.clone(), slot);
let prior_personhood = member.personhood;
let policy_allow = evaluate_personhood(&state, &member).await?;
let fail_mode = state.config.read().await.renewal.on_personhood_fail;
let (personhood, downgrade_audit) = if prior_personhood && !policy_allow {
match fail_mode {
crate::config::PersonhoodFailMode::Refuse => {
return Err(AppError::Validation(
"personhood-renewal-refused: active personhood.rego \
no longer permits this member; re-assert via \
POST /v1/members/{did}/personhood/assert before \
retrying renewal"
.into(),
));
}
crate::config::PersonhoodFailMode::Downgrade => (false, true),
}
} else {
(policy_allow, false)
};
let personhood_changed = prior_personhood != personhood;
let vmc_id = format!("urn:uuid:{}", Uuid::new_v4());
let vmc = build_vmc(
signer,
VmcParams::new(&caller_did)
.with_id(vmc_id.clone())
.with_status_ref(status_ref)
.with_personhood(personhood),
)
.await?;
let vec_id = format!("urn:uuid:{}", Uuid::new_v4());
let role_vec_acl = get_acl_entry(&state.acl_ks, &caller_did)
.await?
.ok_or_else(|| AppError::Internal("ACL row disappeared mid-renewal".into()))?;
let role_vec = build_role_vec(
signer,
RoleVecParams::new(&caller_did, role_vec_acl.role.clone()).with_id(vec_id.clone()),
)
.await?;
member.status_list_index = Some(slot);
member.current_vmc_id = Some(vmc_id.clone());
member.current_role_vec_id = Some(vec_id.clone());
if downgrade_audit {
member.personhood = false;
member.personhood_asserted_at = None;
}
store_member(&state.members_ks, &member).await?;
audit_writer
.write(
&caller_did,
Some(&caller_did),
AuditEvent::MembershipRenewed(MembershipRenewedData {
vmc_id: vmc_id.clone(),
role_vec_id: vec_id.clone(),
personhood_changed,
}),
)
.await?;
if downgrade_audit {
let vtc_did = state
.config
.read()
.await
.vtc_did
.clone()
.unwrap_or_else(|| "did:key:vtc-unknown".into());
audit_writer
.write(
&vtc_did,
Some(&caller_did),
AuditEvent::PersonhoodRevoked(vti_common::audit::PersonhoodRevokedData {
vmc_id: Some(vmc_id.clone()),
reason: "renewal-policy".into(),
}),
)
.await?;
}
info!(
did = %caller_did,
slot,
personhood,
personhood_changed,
"membership renewed"
);
Ok((
StatusCode::OK,
Json(RenewResponse {
did: caller_did,
vmc: serde_json::to_value(&vmc)
.map_err(|e| AppError::Internal(format!("serialise VMC: {e}")))?,
role_vec: serde_json::to_value(&role_vec)
.map_err(|e| AppError::Internal(format!("serialise VEC: {e}")))?,
personhood,
personhood_changed,
}),
))
}
async fn evaluate_personhood(
state: &AppState,
member: &crate::members::Member,
) -> Result<bool, AppError> {
let active = get_active_policy_id(&state.active_policies_ks, PolicyPurpose::Personhood).await?;
let Some(id) = active else {
return Ok(false);
};
let policy = get_policy(&state.policies_ks, id)
.await?
.ok_or_else(|| AppError::Internal(format!("active personhood policy {id} not found")))?;
let compiled = compile_policy(&policy.rego_source, policy.id)?;
let asserted_at_seconds_ago: JsonValue = match member.personhood_asserted_at {
Some(t) => json!((chrono::Utc::now() - t).num_seconds().max(0)),
None => JsonValue::Null,
};
let input = json!({
"applicant_did": member.did,
"current_personhood": member.personhood,
"asserted_at_seconds_ago": asserted_at_seconds_ago,
"vp_claims": { "holder": member.did, "credentials": [] },
});
let result = evaluate_policy(&compiled, "data.vtc.personhood.allow", input)?;
Ok(result
.pointer("/result/0/expressions/0/value")
.and_then(|v| v.as_bool())
.unwrap_or(false))
}