use axum::Json;
use axum::extract::Path;
use axum::extract::State;
use axum::http::StatusCode;
use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use tracing::info;
use vti_common::audit::{AuditEvent, InvitationIssuedData, InvitationRevokedData};
use vti_common::auth::AuthClaims;
use vti_common::error::AppError;
use crate::acl::{VtcRole, get_acl_entry};
use crate::credentials::invitation::{DEFAULT_INVITATION_VALIDITY, issue_invitation};
use crate::credentials::invitation_registry::{
InvitationRecord, get_invitation, list_invitations, store_invitation,
};
use crate::server::AppState;
use crate::status_list;
const MAX_VALIDITY_DAYS: i64 = 90;
#[derive(Debug, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct IssueInvitationBody {
pub subject_did: String,
#[serde(default)]
pub validity_days: Option<u32>,
#[serde(default)]
pub role: Option<String>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct IssueInvitationResponse {
pub subject_did: String,
pub valid_until: Option<String>,
pub vic: JsonValue,
}
#[utoipa::path(
post, path = "/invitations", tag = "invitations",
security(("bearer_jwt" = [])),
request_body = IssueInvitationBody,
responses(
(status = 201, description = "Invitation issued", body = IssueInvitationResponse),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Caller is not Admin / Moderator / Issuer"),
(status = 409, description = "Subject is already a member"),
),
)]
pub async fn issue(
auth: AuthClaims,
State(state): State<AppState>,
Json(body): Json<IssueInvitationBody>,
) -> Result<(StatusCode, Json<IssueInvitationResponse>), AppError> {
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::Moderator | VtcRole::Issuer
) {
return Err(AppError::Forbidden(
"only Admin, Moderator, or Issuer members can issue invitations".into(),
));
}
if !body.subject_did.starts_with("did:") {
return Err(AppError::Validation("subjectDid must be a DID".into()));
}
if get_acl_entry(&state.acl_ks, &body.subject_did)
.await?
.is_some()
{
return Err(AppError::Conflict(format!(
"{} is already a current member — no invitation needed",
body.subject_did
)));
}
let validity = match body.validity_days {
Some(d) if d == 0 || (d as i64) > MAX_VALIDITY_DAYS => {
return Err(AppError::Validation(format!(
"validityDays must be between 1 and {MAX_VALIDITY_DAYS}"
)));
}
Some(d) => Duration::days(d as i64),
None => DEFAULT_INVITATION_VALIDITY,
};
if let Some(role) = body.role.as_deref() {
let parsed = role
.parse::<VtcRole>()
.map_err(|_| AppError::Validation(format!("unknown role `{role}`")))?;
if matches!(parsed, VtcRole::Admin) {
return Err(AppError::Validation(
"an invitation may not grant `admin` (no admin via join)".into(),
));
}
}
let vic = issue_invitation(
signer,
&state.status_lists_ks,
&state.schemas_ks,
&body.subject_did,
validity,
body.role.as_deref(),
)
.await?;
let valid_until = vic
.get("validUntil")
.and_then(JsonValue::as_str)
.map(str::to_string);
let id = vic
.get("id")
.and_then(JsonValue::as_str)
.ok_or_else(|| AppError::Internal("issued VIC has no `id`".into()))?
.to_string();
let slot = vic
.pointer("/credentialStatus/statusListIndex")
.and_then(JsonValue::as_str)
.and_then(|s| s.parse::<u32>().ok())
.ok_or_else(|| AppError::Internal("issued VIC has no usable statusListIndex".into()))?;
store_invitation(
&state.invitations_ks,
&InvitationRecord {
id: id.clone(),
subject_did: body.subject_did.clone(),
slot,
role: body.role.clone(),
issued_by: auth.did.clone(),
issued_at: Utc::now(),
valid_until: valid_until.clone(),
revoked_at: None,
},
)
.await?;
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
&auth.did,
Some(&body.subject_did),
AuditEvent::InvitationIssued(InvitationIssuedData {
invitation_id: id.clone(),
subject_did: body.subject_did.clone(),
role: body.role.clone(),
valid_until: valid_until.clone().unwrap_or_default(),
status_list_index: Some(slot),
}),
)
.await?;
}
info!(
actor = %auth.did,
subject = %body.subject_did,
vic_id = %id,
"issued an invitation credential (VIC)"
);
Ok((
StatusCode::CREATED,
Json(IssueInvitationResponse {
subject_did: body.subject_did,
valid_until,
vic,
}),
))
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct InvitationListItem {
pub id: String,
pub subject_did: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
pub issued_by: String,
pub issued_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub valid_until: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub revoked_at: Option<String>,
}
impl From<InvitationRecord> for InvitationListItem {
fn from(r: InvitationRecord) -> Self {
Self {
id: r.id,
subject_did: r.subject_did,
role: r.role,
issued_by: r.issued_by,
issued_at: r.issued_at.to_rfc3339(),
valid_until: r.valid_until,
revoked_at: r.revoked_at.map(|t| t.to_rfc3339()),
}
}
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct InvitationListResponse {
pub invitations: Vec<InvitationListItem>,
}
async fn require_inviter(state: &AppState, did: &str) -> Result<(), AppError> {
let acl = get_acl_entry(&state.acl_ks, did)
.await?
.ok_or_else(|| AppError::Forbidden("caller has no ACL row".into()))?;
if !matches!(
acl.role,
VtcRole::Admin | VtcRole::Moderator | VtcRole::Issuer
) {
return Err(AppError::Forbidden(
"only Admin, Moderator, or Issuer members can manage invitations".into(),
));
}
Ok(())
}
#[utoipa::path(
get, path = "/invitations", tag = "invitations",
security(("bearer_jwt" = [])),
responses(
(status = 200, description = "Issued invitations", body = InvitationListResponse),
(status = 403, description = "Caller is not Admin / Moderator / Issuer"),
),
)]
pub async fn list(
auth: AuthClaims,
State(state): State<AppState>,
) -> Result<Json<InvitationListResponse>, AppError> {
require_inviter(&state, &auth.did).await?;
let invitations = list_invitations(&state.invitations_ks)
.await?
.into_iter()
.map(InvitationListItem::from)
.collect();
Ok(Json(InvitationListResponse { invitations }))
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct RevokeResponse {
pub id: String,
pub revoked_at: String,
pub newly_revoked: bool,
}
#[utoipa::path(
delete, path = "/invitations/{id}", tag = "invitations",
params(("id" = String, Path, description = "VIC id (urn:uuid)")),
security(("bearer_jwt" = [])),
responses(
(status = 200, description = "Invitation revoked", body = RevokeResponse),
(status = 403, description = "Caller is not Admin / Moderator / Issuer"),
(status = 404, description = "No such invitation"),
),
)]
pub async fn revoke(
auth: AuthClaims,
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<RevokeResponse>, AppError> {
require_inviter(&state, &auth.did).await?;
let mut record = get_invitation(&state.invitations_ks, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("no invitation with id {id}")))?;
if let Some(revoked_at) = record.revoked_at {
return Ok(Json(RevokeResponse {
id,
revoked_at: revoked_at.to_rfc3339(),
newly_revoked: false,
}));
}
let slot = record.slot;
status_list::with_locked(
&state.status_lists_ks,
affinidi_status_list::StatusPurpose::Revocation,
|sl| {
status_list::flip(sl, slot, true)
.map_err(|e| AppError::Internal(format!("flip revocation bit {slot}: {e}")))
},
)
.await?;
let now = Utc::now();
record.revoked_at = Some(now);
store_invitation(&state.invitations_ks, &record).await?;
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
&auth.did,
Some(&record.subject_did),
AuditEvent::InvitationRevoked(InvitationRevokedData {
invitation_id: id.clone(),
subject_did: Some(record.subject_did.clone()),
newly_revoked: true,
}),
)
.await?;
}
info!(actor = %auth.did, vic_id = %id, slot, "revoked an invitation credential (VIC)");
Ok(Json(RevokeResponse {
id,
revoked_at: now.to_rfc3339(),
newly_revoked: true,
}))
}