From 8b408a063a2e35d366204b9a612513a27920d89b Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sat, 18 Jul 2026 15:39:51 -0400 Subject: [PATCH] feat(admin): invite mint/list endpoints (POST+GET /admin/invites) - invite_codes.rs: InviteCodeRow + list_invite_codes(limit,offset) + mint_invites_audited (generate + insert N + admin_audit append in one tx). Raw codes returned once (only the hash is stored, unrecoverable). - admin.rs: admin_invites_mint (clamp 1..=1000, audited) + admin_invites_list (non-secret view: hash prefix + label + created_at + unused/used/expired status + unused_count). - /admin/invites route (loopback plane, admin_guard). Promotes the CLI mint-invite to the console. cargo all-targets clean. --- src/api/admin.rs | 90 ++++++++++++++++++++++++++++++++++++ src/infra/db/invite_codes.rs | 59 +++++++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/src/api/admin.rs b/src/api/admin.rs index 9cab504..ac5c6ab 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -943,6 +943,95 @@ fn json_merge_skip_null(base: &mut serde_json::Value, patch: &serde_json::Value) } } +// ── Invites (POST + GET /admin/invites) ─────────────────────────────────────── + +#[derive(serde::Deserialize)] +pub struct MintInvitesRequest { + #[serde(default)] + count: Option, + #[serde(default)] + label: Option, +} + +#[derive(serde::Serialize)] +struct MintInvitesResponse { + /// RAW single-use codes — shown ONCE (only the hash is stored, unrecoverable). + codes: Vec, +} + +#[instrument(skip(state, headers, req))] +pub async fn admin_invites_mint( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, AppError> { + let ctx = admin_guard(&state, &headers).await?; + let secret = state.cfg().admin.key_integrity_secret.clone(); + let count = req.count.unwrap_or(1).clamp(1, 1000); + let audit = NewAdminAudit { + action: "invites_minted".into(), + actor_kind: "admin".into(), + actor_pubkey_prefix: Some(pubkey_prefix(&ctx.pubkey)), + target: req.label.clone(), + details: Some(serde_json::json!({ "count": count })), + ip_address: None, + }; + let codes = state + .db + .mint_invites_audited(count, req.label.as_deref(), &audit, &secret) + .await?; + Ok(Json(MintInvitesResponse { codes })) +} + +#[derive(serde::Serialize)] +struct InviteView { + /// Non-secret: first 12 hex chars of the code hash (operator eyeball only). + code_prefix: String, + label: Option, + created_at: String, + /// "unused" | "used" | "expired". + status: String, +} + +#[derive(serde::Serialize)] +struct InvitesListResponse { + invites: Vec, + unused_count: i64, +} + +#[instrument(skip(state, headers))] +pub async fn admin_invites_list( + State(state): State>, + headers: HeaderMap, +) -> Result, AppError> { + admin_guard(&state, &headers).await?; + let rows = state.db.list_invite_codes(200, 0).await?; + let now = Utc::now(); + let invites = rows + .into_iter() + .map(|r| { + let status = if r.used_at.is_some() { + "used" + } else if r.expires_at.map(|e| e <= now).unwrap_or(false) { + "expired" + } else { + "unused" + }; + InviteView { + code_prefix: r.code_hash.chars().take(12).collect(), + label: r.label, + created_at: r.created_at.to_rfc3339(), + status: status.into(), + } + }) + .collect(); + let unused_count = state.db.unused_invite_code_count().await?; + Ok(Json(InvitesListResponse { + invites, + unused_count, + })) +} + pub fn routes() -> Router> { Router::new() .route("/admin/auth/challenge", post(admin_challenge)) @@ -955,6 +1044,7 @@ pub fn routes() -> Router> { .route("/admin/keys/:id", delete(admin_keys_revoke)) .route("/admin/keys/:id/rotate", post(admin_keys_rotate)) .route("/admin/config", get(admin_config_get).put(admin_config_put)) + .route("/admin/invites", post(admin_invites_mint).get(admin_invites_list)) } #[cfg(test)] diff --git a/src/infra/db/invite_codes.rs b/src/infra/db/invite_codes.rs index 28a87dd..303e23b 100644 --- a/src/infra/db/invite_codes.rs +++ b/src/infra/db/invite_codes.rs @@ -5,12 +5,28 @@ //! single-statement claim (`UPDATE ... WHERE used_at IS NULL ... RETURNING`) so //! two concurrent registrations presenting the same code can never both succeed. +use chrono::{DateTime, Utc}; use tracing::instrument; +use uuid::Uuid; +use crate::core::invite::{generate_invite_code, hash_invite_code}; use crate::error::AppError; +use crate::models::db::NewAdminAudit; use super::Database; +/// A row of the invite-code table for the admin console. Never carries a usable raw +/// code (only the hash prefix is exposed by the handler). +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct InviteCodeRow { + pub code_hash: String, + pub created_at: DateTime, + pub label: Option, + pub expires_at: Option>, + pub used_at: Option>, + pub used_by: Option, +} + impl Database { /// Store a freshly-minted code by its `sha256` hash with an optional operator /// `label`. A duplicate hash (re-mint of the same raw code — astronomically @@ -57,4 +73,47 @@ impl Database { .fetch_one(self.pool()) .await?) } + + /// List invite codes newest-first (admin console). Never returns raw codes — the + /// handler exposes only a hash prefix + status. + pub async fn list_invite_codes( + &self, + limit: i64, + offset: i64, + ) -> Result, AppError> { + Ok(sqlx::query_as::<_, InviteCodeRow>( + "SELECT code_hash, created_at, label, expires_at, used_at, used_by \ + FROM invite_codes ORDER BY created_at DESC LIMIT $1 OFFSET $2", + ) + .bind(limit) + .bind(offset) + .fetch_all(self.pool()) + .await?) + } + + /// Mint `count` single-use invite codes + append one audit entry in a single tx. + /// Returns the RAW codes (bearer secrets, shown once — only the hash is stored). + #[instrument(skip(self, audit, secret))] + pub async fn mint_invites_audited( + &self, + count: u32, + label: Option<&str>, + audit: &NewAdminAudit, + secret: &str, + ) -> Result, AppError> { + let mut tx = self.pool().begin().await?; + let mut codes = Vec::with_capacity(count as usize); + for _ in 0..count { + let code = generate_invite_code(); + sqlx::query("INSERT INTO invite_codes (code_hash, label) VALUES ($1, $2)") + .bind(hash_invite_code(&code)) + .bind(label) + .execute(&mut *tx) + .await?; + codes.push(code); + } + self.append_admin_audit(&mut *tx, audit, secret).await?; + tx.commit().await?; + Ok(codes) + } }