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.
This commit is contained in:
jwinterm
2026-07-18 15:39:51 -04:00
parent a80fa46daf
commit 8b408a063a
2 changed files with 149 additions and 0 deletions
+90
View File
@@ -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<u32>,
#[serde(default)]
label: Option<String>,
}
#[derive(serde::Serialize)]
struct MintInvitesResponse {
/// RAW single-use codes — shown ONCE (only the hash is stored, unrecoverable).
codes: Vec<String>,
}
#[instrument(skip(state, headers, req))]
pub async fn admin_invites_mint(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<MintInvitesRequest>,
) -> Result<Json<MintInvitesResponse>, 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<String>,
created_at: String,
/// "unused" | "used" | "expired".
status: String,
}
#[derive(serde::Serialize)]
struct InvitesListResponse {
invites: Vec<InviteView>,
unused_count: i64,
}
#[instrument(skip(state, headers))]
pub async fn admin_invites_list(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<InvitesListResponse>, 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<Arc<AppState>> {
Router::new()
.route("/admin/auth/challenge", post(admin_challenge))
@@ -955,6 +1044,7 @@ pub fn routes() -> Router<Arc<AppState>> {
.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)]
+59
View File
@@ -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<Utc>,
pub label: Option<String>,
pub expires_at: Option<DateTime<Utc>>,
pub used_at: Option<DateTime<Utc>>,
pub used_by: Option<Uuid>,
}
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<Vec<InviteCodeRow>, 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<Vec<String>, 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)
}
}