mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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:
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user