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>> {
|
pub fn routes() -> Router<Arc<AppState>> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/admin/auth/challenge", post(admin_challenge))
|
.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", delete(admin_keys_revoke))
|
||||||
.route("/admin/keys/:id/rotate", post(admin_keys_rotate))
|
.route("/admin/keys/:id/rotate", post(admin_keys_rotate))
|
||||||
.route("/admin/config", get(admin_config_get).put(admin_config_put))
|
.route("/admin/config", get(admin_config_get).put(admin_config_put))
|
||||||
|
.route("/admin/invites", post(admin_invites_mint).get(admin_invites_list))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -5,12 +5,28 @@
|
|||||||
//! single-statement claim (`UPDATE ... WHERE used_at IS NULL ... RETURNING`) so
|
//! single-statement claim (`UPDATE ... WHERE used_at IS NULL ... RETURNING`) so
|
||||||
//! two concurrent registrations presenting the same code can never both succeed.
|
//! two concurrent registrations presenting the same code can never both succeed.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::core::invite::{generate_invite_code, hash_invite_code};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
use crate::models::db::NewAdminAudit;
|
||||||
|
|
||||||
use super::Database;
|
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 {
|
impl Database {
|
||||||
/// Store a freshly-minted code by its `sha256` hash with an optional operator
|
/// 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
|
/// `label`. A duplicate hash (re-mint of the same raw code — astronomically
|
||||||
@@ -57,4 +73,47 @@ impl Database {
|
|||||||
.fetch_one(self.pool())
|
.fetch_one(self.pool())
|
||||||
.await?)
|
.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