use std::sync::Arc;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use serde::{Deserialize, Serialize};
use tracing::info;
use uuid::Uuid;
use vti_common::auth::AdminAuth;
use vti_common::error::AppError;
use crate::acl::{VtcAclEntry, VtcRole, get_acl_entry, store_acl_entry};
use crate::auth::session::now_epoch;
use crate::install::{
INSTALL_TOKEN_DEFAULT_TTL_SECS, InstallTokenSigner, InstallTokenState, claim_secret,
mint_install_token,
};
use crate::server::AppState;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateInviteRequest {
pub did: String,
#[serde(default)]
pub ttl_seconds: Option<u64>,
#[serde(default)]
pub label: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateInviteResponse {
pub jti: String,
pub install_url: String,
pub claim_code: String,
pub expires_at: DateTime<Utc>,
pub acl_entry_created: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InviteSummary {
pub jti: String,
pub status: InviteStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_did: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub consumed_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum InviteStatus {
Issued,
Consumed,
Expired,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ListInvitesResponse {
pub invites: Vec<InviteSummary>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RevokeInviteResponse {
pub jti: String,
}
const MAX_TTL_SECONDS: u64 = 24 * 60 * 60;
pub async fn create_invite(
_admin: AdminAuth,
State(state): State<AppState>,
Json(req): Json<CreateInviteRequest>,
) -> Result<(StatusCode, Json<CreateInviteResponse>), AppError> {
if !req.did.starts_with("did:") {
return Err(AppError::Validation(format!(
"did must start with 'did:' (got '{}')",
req.did
)));
}
let ttl_seconds = req.ttl_seconds.unwrap_or(INSTALL_TOKEN_DEFAULT_TTL_SECS);
if ttl_seconds == 0 || ttl_seconds > MAX_TTL_SECONDS {
return Err(AppError::Validation(format!(
"ttl_seconds must be between 1 and {MAX_TTL_SECONDS}",
)));
}
let signer = require_install_signer(&state)?;
let base_url = require_public_url(&state).await?;
let vtc_did = require_vtc_did(&state).await?;
let acl_entry_created = match get_acl_entry(&state.acl_ks, &req.did).await? {
Some(existing) if existing.role == VtcRole::Admin => false,
Some(_) => {
return Err(AppError::Conflict(format!(
"did {} already has a non-admin ACL grant; revoke it first \
(DELETE /v1/acl/entries/{}) before inviting",
req.did, req.did
)));
}
None => {
let label = req
.label
.clone()
.unwrap_or_else(|| "admin invite (web)".into());
let entry = VtcAclEntry {
did: req.did.clone(),
role: VtcRole::Admin,
label: Some(label),
allowed_contexts: vec![],
created_at: now_epoch(),
created_by: format!("admin-ui/{}", env!("CARGO_PKG_VERSION")),
expires_at: None,
};
store_acl_entry(&state.acl_ks, &entry).await?;
true
}
};
let minted = mint_install_token(signer.as_ref(), &vtc_did, &req.did, ttl_seconds)?;
let claim_code = claim_secret::generate();
let claim_code_hash = claim_secret::hash(&claim_code)?;
let expires_at = Utc::now() + ChronoDuration::seconds(ttl_seconds as i64);
state
.install_store
.record_issued(
&minted.jti,
minted.cnonce_bytes,
*minted.ephemeral_signing_key,
expires_at,
Some(claim_code_hash),
Some(req.did.clone()),
)
.await?;
let install_url = format!(
"{}/admin/install?token={}",
base_url.trim_end_matches('/'),
minted.jwt
);
info!(
target_did = %req.did,
jti = %minted.jti,
ttl_seconds,
"admin invite minted via REST"
);
Ok((
StatusCode::OK,
Json(CreateInviteResponse {
jti: minted.jti.to_string(),
install_url,
claim_code,
expires_at,
acl_entry_created,
}),
))
}
pub async fn list_invites(
_admin: AdminAuth,
State(state): State<AppState>,
) -> Result<Json<ListInvitesResponse>, AppError> {
let now = Utc::now();
let mut invites: Vec<InviteSummary> = state
.install_store
.list_tokens()
.await?
.into_iter()
.map(|(jti, state)| summarise(jti, state, now))
.collect();
invites.sort_by(|a, b| {
let a_ts = a.consumed_at.or(a.expires_at);
let b_ts = b.consumed_at.or(b.expires_at);
b_ts.cmp(&a_ts).then_with(|| a.jti.cmp(&b.jti))
});
Ok(Json(ListInvitesResponse { invites }))
}
pub async fn revoke_invite(
_admin: AdminAuth,
State(state): State<AppState>,
Path(jti_str): Path<String>,
) -> Result<(StatusCode, Json<RevokeInviteResponse>), AppError> {
let jti = jti_str
.parse::<Uuid>()
.map_err(|_| AppError::Validation(format!("invalid jti: '{jti_str}'")))?;
let existed = state.install_store.get_token(&jti).await?;
if existed.is_none() {
return Err(AppError::NotFound(format!("no invite for jti {jti}")));
}
if !state.install_store.delete_token(&jti).await? {
return Err(AppError::NotFound(format!("no invite for jti {jti}")));
}
info!(%jti, "admin invite removed via REST");
Ok((
StatusCode::OK,
Json(RevokeInviteResponse {
jti: jti.to_string(),
}),
))
}
fn summarise(jti: Uuid, state: InstallTokenState, now: DateTime<Utc>) -> InviteSummary {
match state {
InstallTokenState::Issued { exp, admin_did, .. } => {
let status = if exp < now {
InviteStatus::Expired
} else {
InviteStatus::Issued
};
InviteSummary {
jti: jti.to_string(),
status,
target_did: admin_did,
expires_at: Some(exp),
consumed_at: None,
}
}
InstallTokenState::Consumed { at, admin_did } => InviteSummary {
jti: jti.to_string(),
status: InviteStatus::Consumed,
target_did: admin_did,
expires_at: None,
consumed_at: Some(at),
},
}
}
fn require_install_signer(state: &AppState) -> Result<&Arc<InstallTokenSigner>, AppError> {
state
.install_signer
.as_ref()
.ok_or_else(|| AppError::ServiceError {
status: StatusCode::SERVICE_UNAVAILABLE,
message: "install signer not configured (run setup first)".into(),
})
}
async fn require_public_url(state: &AppState) -> Result<String, AppError> {
state
.config
.read()
.await
.public_url
.clone()
.ok_or_else(|| AppError::ServiceError {
status: StatusCode::SERVICE_UNAVAILABLE,
message: "public_url not configured; cannot build install URL".into(),
})
}
async fn require_vtc_did(state: &AppState) -> Result<String, AppError> {
state
.config
.read()
.await
.vtc_did
.clone()
.ok_or_else(|| AppError::ServiceError {
status: StatusCode::SERVICE_UNAVAILABLE,
message: "vtc_did not configured (run setup first)".into(),
})
}