use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use tracing::info;
use uuid::Uuid;
use vti_common::audit::{
AuditEvent, CredentialIssuedData, CustomEndorsementIssuedData, CustomEndorsementRevokedData,
StatusListFlippedData,
};
use vti_common::auth::AuthClaims;
use vti_common::error::AppError;
use vti_common::pagination::{Cursor, Paginated};
use crate::acl::{VtcRole, get_acl_entry};
use crate::credentials::{CredentialStatusRef, CustomEndorsementParams, build_custom_endorsement};
use crate::endorsement_types::type_exists;
use crate::endorsements::{
Endorsement, get_endorsement, list_endorsements, mark_revoked, store_endorsement,
};
use crate::server::AppState;
use crate::status_list;
const LIST_MAX_LIMIT: usize = 200;
const CLAIM_MAX_BYTES: usize = 8 * 1024;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IssueBody {
pub subject_did: String,
#[serde(rename = "type")]
pub endorsement_type: String,
pub claim: JsonValue,
#[serde(default)]
pub validity_seconds: Option<u64>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IssueResponse {
pub id: Uuid,
pub vec_id: String,
pub vec: JsonValue,
}
pub async fn issue(
auth: AuthClaims,
State(state): State<AppState>,
Json(body): Json<IssueBody>,
) -> Result<(StatusCode, Json<IssueResponse>), AppError> {
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
let signer = state
.credential_signer
.as_ref()
.ok_or_else(|| AppError::Internal("credential signer not configured".into()))?;
let acl = get_acl_entry(&state.acl_ks, &auth.did)
.await?
.ok_or_else(|| AppError::Forbidden("caller has no ACL row".into()))?;
if !matches!(acl.role, VtcRole::Admin | VtcRole::Issuer) {
return Err(AppError::Forbidden(
"only Admin or Issuer-role members can mint custom endorsements".into(),
));
}
if !type_exists(&state.endorsement_types_ks, &body.endorsement_type).await? {
return Err(AppError::Validation(format!(
"endorsement-type-not-registered: '{}' is not in the endorsement type registry",
body.endorsement_type
)));
}
if !body.claim.is_object() {
return Err(AppError::Validation("claim must be a JSON object".into()));
}
let claim_bytes = serde_json::to_vec(&body.claim)
.map_err(|e| AppError::Internal(format!("serialise claim: {e}")))?;
if claim_bytes.len() > CLAIM_MAX_BYTES {
return Err(AppError::Validation(format!(
"claim exceeds {CLAIM_MAX_BYTES} bytes"
)));
}
if get_acl_entry(&state.acl_ks, &body.subject_did)
.await?
.is_none()
{
return Err(AppError::Validation(format!(
"subject DID {} is not a current community member",
body.subject_did
)));
}
let mut sl_state = status_list::get_state(
&state.status_lists_ks,
affinidi_status_list::StatusPurpose::Revocation,
)
.await?
.ok_or_else(|| AppError::Internal("revocation status list not initialised".into()))?;
let slot = status_list::allocate(&mut sl_state).ok_or_else(|| {
AppError::Internal(
"revocation status list is full — cannot allocate slot for endorsement".into(),
)
})?;
status_list::store_state(&state.status_lists_ks, &sl_state).await?;
let status_ref = CredentialStatusRef::revocation(sl_state.list_credential_id.clone(), slot);
let id = Uuid::new_v4();
let vec_id = format!("urn:uuid:{id}");
let validity = body
.validity_seconds
.map(|s| Duration::seconds(s as i64))
.unwrap_or_else(|| {
crate::credentials::custom_endorsement::DEFAULT_CUSTOM_ENDORSEMENT_VALIDITY
});
let params = CustomEndorsementParams::new(
&body.subject_did,
&body.endorsement_type,
body.claim.clone(),
status_ref,
)
.with_id(&vec_id)
.with_validity(validity);
let vec = build_custom_endorsement(signer, params).await?;
let now = Utc::now();
let valid_until = now + validity;
let end = Endorsement {
id,
endorsement_type: body.endorsement_type.clone(),
issuer_did: signer.issuer_did().to_string(),
subject_did: body.subject_did.clone(),
claim: body.claim.clone(),
status_list_index: slot,
vec_id: vec_id.clone(),
created_at: now,
revoked_at: None,
};
store_endorsement(&state.endorsements_ks, &end).await?;
audit_writer
.write(
&auth.did,
Some(&body.subject_did),
AuditEvent::CustomEndorsementIssued(CustomEndorsementIssuedData {
endorsement_id: id.to_string(),
endorsement_type: body.endorsement_type.clone(),
status_list_index: slot,
}),
)
.await?;
audit_writer
.write(
&auth.did,
Some(&body.subject_did),
AuditEvent::VecIssued(CredentialIssuedData {
credential_id: vec_id.clone(),
credential_type: "VerifiableEndorsementCredential".into(),
valid_from: rfc3339(now),
valid_until: rfc3339(valid_until),
status_list_index: Some(slot),
}),
)
.await?;
info!(
endorsement_id = %id,
endorsement_type = %body.endorsement_type,
subject = %body.subject_did,
slot,
"custom endorsement issued"
);
let vec_value = serde_json::to_value(&vec)
.map_err(|e| AppError::Internal(format!("serialise VEC: {e}")))?;
Ok((
StatusCode::CREATED,
Json(IssueResponse {
id,
vec_id,
vec: vec_value,
}),
))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListQuery {
pub cursor: Option<String>,
pub limit: Option<usize>,
}
pub async fn list(
auth: AuthClaims,
State(state): State<AppState>,
Query(query): Query<ListQuery>,
) -> Result<Json<Paginated<Endorsement>>, AppError> {
let acl = get_acl_entry(&state.acl_ks, &auth.did)
.await?
.ok_or_else(|| AppError::Forbidden("caller has no ACL row".into()))?;
if !matches!(acl.role, VtcRole::Admin | VtcRole::Issuer) {
return Err(AppError::Forbidden(
"only Admin or Issuer-role members can list custom endorsements".into(),
));
}
let limit = query.limit.unwrap_or(50).clamp(1, LIST_MAX_LIMIT);
let audit_key = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?
.active_key()
.await?;
let cursor = query
.cursor
.as_deref()
.map(|c| Cursor::decode(c, &audit_key.key))
.transpose()
.map_err(|e| AppError::Validation(format!("invalid cursor: {e}")))?;
let page =
list_endorsements(&state.endorsements_ks, &audit_key, cursor.as_ref(), limit).await?;
Ok(Json(page))
}
pub async fn show(
auth: AuthClaims,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<Endorsement>, AppError> {
let acl = get_acl_entry(&state.acl_ks, &auth.did)
.await?
.ok_or_else(|| AppError::Forbidden("caller has no ACL row".into()))?;
if !matches!(acl.role, VtcRole::Admin | VtcRole::Issuer) {
return Err(AppError::Forbidden(
"only Admin or Issuer-role members can read custom endorsements".into(),
));
}
let row = get_endorsement(&state.endorsements_ks, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("endorsement {id} not found")))?;
Ok(Json(row))
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RevokeResponse {
pub id: String,
}
pub async fn revoke(
auth: AuthClaims,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<(StatusCode, Json<RevokeResponse>), AppError> {
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
let row = get_endorsement(&state.endorsements_ks, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("endorsement {id} not found")))?;
let acl = get_acl_entry(&state.acl_ks, &auth.did)
.await?
.ok_or_else(|| AppError::Forbidden("caller has no ACL row".into()))?;
let is_admin = matches!(acl.role, VtcRole::Admin);
let is_issuer_role = matches!(acl.role, VtcRole::Issuer);
if !is_admin && !is_issuer_role {
return Err(AppError::Forbidden(
"only Admin or Issuer-role members can revoke endorsements".into(),
));
}
if row.is_revoked() {
return Ok((StatusCode::OK, Json(RevokeResponse { id: id.to_string() })));
}
let mut sl_state = status_list::get_state(
&state.status_lists_ks,
affinidi_status_list::StatusPurpose::Revocation,
)
.await?
.ok_or_else(|| AppError::Internal("revocation status list not initialised".into()))?;
status_list::flip(&mut sl_state, row.status_list_index, true).map_err(|e| {
AppError::Internal(format!(
"flip status-list bit {}: {e}",
row.status_list_index
))
})?;
status_list::store_state(&state.status_lists_ks, &sl_state).await?;
let updated = mark_revoked(&state.endorsements_ks, id)
.await?
.ok_or_else(|| AppError::Internal("row disappeared mid-revoke".into()))?;
audit_writer
.write(
&auth.did,
Some(&row.subject_did),
AuditEvent::CustomEndorsementRevoked(CustomEndorsementRevokedData {
endorsement_id: id.to_string(),
endorsement_type: row.endorsement_type.clone(),
}),
)
.await?;
audit_writer
.write(
&auth.did,
Some(&row.subject_did),
AuditEvent::StatusListFlipped(StatusListFlippedData {
purpose: "revocation".into(),
index: row.status_list_index,
revoked: true,
}),
)
.await?;
info!(
endorsement_id = %id,
endorsement_type = %row.endorsement_type,
slot = row.status_list_index,
by = %auth.did,
"custom endorsement revoked"
);
let _ = updated;
Ok((StatusCode::OK, Json(RevokeResponse { id: id.to_string() })))
}
fn rfc3339(t: chrono::DateTime<Utc>) -> String {
t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}