use affinidi_status_list::StatusPurpose;
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::credentials::{
CredentialStatusRef, RoleVacParams, VmcParams, build_role_vac, build_vmc,
};
use crate::error::TaskError;
use crate::members::get_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;
pub const RENEW_ERR_NOT_MEMBER: &str =
trust_tasks_rs::specs::vtc::members::renew::v0_1::error_codes::NOT_MEMBER.code;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct RenewResponse {
pub did: String,
pub vmc: JsonValue,
pub role_vac: JsonValue,
pub personhood: bool,
pub personhood_changed: bool,
}
pub(crate) async fn renew_inner(
state: &AppState,
caller_did: &str,
) -> Result<RenewResponse, TaskError> {
let caller_did = caller_did.to_string();
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(|| {
TaskError::declared(
RENEW_ERR_NOT_MEMBER,
AppError::NotFound(format!("no ACL row for {caller_did} — not a member")),
)
})?;
let member = get_member(&state.members_ks, &caller_did)
.await?
.ok_or_else(|| {
TaskError::declared(
RENEW_ERR_NOT_MEMBER,
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 => {
status_list::with_locked(&state.status_lists_ks, StatusPurpose::Revocation, |row| {
status_list::allocate(row).ok_or_else(|| {
AppError::Internal(format!(
"revocation status list exhausted (capacity = {})",
row.capacity
))
})
})
.await?
}
};
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(),
)
.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 vac_id = format!("urn:uuid:{}", Uuid::new_v4());
let role_vac_acl = get_acl_entry(&state.acl_ks, &caller_did)
.await?
.ok_or_else(|| AppError::Internal("ACL row disappeared mid-renewal".into()))?;
let role_vac = build_role_vac(
signer,
RoleVacParams::new(&caller_did, role_vac_acl.role.clone()).with_id(vac_id.clone()),
)
.await?;
let vmc_value = serde_json::to_value(&vmc)
.map_err(|e| AppError::Internal(format!("serialise VMC: {e}")))?;
let role_vac_value = serde_json::to_value(&role_vac)
.map_err(|e| AppError::Internal(format!("serialise role VAC: {e}")))?;
crate::members::storage::edit_member(&state.members_ks, &caller_did, |m| {
m.status_list_index = Some(slot);
m.record_issued_credentials(vmc_value, role_vac_value);
if downgrade_audit {
m.personhood = false;
m.personhood_asserted_at = None;
}
true
})
.await?
.ok_or_else(|| AppError::Conflict("the member left while this was in progress".into()))?;
audit_writer
.write(
&caller_did,
Some(&caller_did),
AuditEvent::MembershipRenewed(MembershipRenewedData {
vmc_id: vmc_id.clone(),
role_vac_id: vac_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(RenewResponse {
did: caller_did,
vmc: serde_json::to_value(&vmc)
.map_err(|e| AppError::Internal(format!("serialise VMC: {e}")))?,
role_vac: serde_json::to_value(&role_vac)
.map_err(|e| AppError::Internal(format!("serialise VAC: {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 community_did = state
.credential_signer
.as_ref()
.map(|s| JsonValue::from(s.issuer_did()))
.unwrap_or(JsonValue::Null);
let input = json!({
"applicant_did": member.did,
"community_did": community_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))
}