feat(quotesdb): admin super auth code for quote moderation

Add an admin_config table storing a single admin auth code that
bypasses per-quote auth checks for update and delete operations.
The code is auto-generated on first startup and printed to stderr.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Elijah Voigt 3 months ago
parent c9e4d10934
commit caf2246bff

@ -24,3 +24,9 @@ CREATE INDEX IF NOT EXISTS idx_quotes_author ON quotes(author COLLATE NOCASE);
-- Index for efficient tag lookups by quote ID.
CREATE INDEX IF NOT EXISTS idx_quote_tags_quote_id ON quote_tags(quote_id);
-- Stores global configuration values, including the admin auth code.
CREATE TABLE IF NOT EXISTS admin_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);

@ -102,9 +102,11 @@ impl D1Repository {
#[async_trait::async_trait(?Send)]
impl QuoteRepository for D1Repository {
/// Run the four DDL migration statements from [`super::migrations`].
/// Run the five DDL migration statements from [`super::migrations`].
///
/// Safe to call multiple times — all statements use `IF NOT EXISTS`.
/// Covers `quotes`, `quote_tags`, tag index, author index, and
/// `admin_config`. Safe to call multiple times — all statements use
/// `IF NOT EXISTS`.
async fn run_migrations(&self) -> Result<(), DbError> {
use super::migrations::*;
for sql in &[
@ -112,6 +114,7 @@ impl QuoteRepository for D1Repository {
CREATE_QUOTE_TAGS,
CREATE_TAG_INDEX,
CREATE_AUTHOR_INDEX,
CREATE_ADMIN_CONFIG,
] {
self.db
.exec(sql)
@ -358,8 +361,14 @@ impl QuoteRepository for D1Repository {
match auth_row {
None => return Err(DbError::NotFound),
Some(ref r) if r.auth_code != auth_code => return Err(DbError::Forbidden),
Some(_) => {}
Some(ref r) if r.auth_code == auth_code => {} // exact match, proceed
Some(_) => {
// Check admin code fallback
let admin = self.get_admin_auth_code().await?;
if admin.as_deref() != Some(auth_code) {
return Err(DbError::Forbidden);
}
}
}
// Phase 2: build dynamic SET clause with positional params
@ -465,9 +474,10 @@ impl QuoteRepository for D1Repository {
/// Delete a quote by ID after verifying the auth code.
///
/// Returns [`DeleteResult::NotFound`] if no quote has that ID,
/// [`DeleteResult::Forbidden`] if the auth code does not match,
/// or [`DeleteResult::Deleted`] on success. Tags are removed automatically
/// by the `ON DELETE CASCADE` constraint on `quote_tags`.
/// [`DeleteResult::Forbidden`] if neither the per-quote auth code nor the
/// admin super auth code matches, or [`DeleteResult::Deleted`] on success.
/// Tags are removed automatically by the `ON DELETE CASCADE` constraint on
/// `quote_tags`.
async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError> {
// Fetch stored auth_code
let auth_row = self
@ -481,8 +491,16 @@ impl QuoteRepository for D1Repository {
match auth_row {
None => return Ok(DeleteResult::NotFound),
Some(ref r) if r.auth_code != auth_code => return Ok(DeleteResult::Forbidden),
Some(_) => {}
Some(ref r) if r.auth_code == auth_code => {
// Per-quote auth matches — fall through to delete
}
Some(_) => {
// Check admin code as fallback
let admin = self.get_admin_auth_code().await?;
if admin.as_deref() != Some(auth_code) {
return Ok(DeleteResult::Forbidden);
}
}
}
self.db
@ -495,4 +513,37 @@ impl QuoteRepository for D1Repository {
Ok(DeleteResult::Deleted)
}
/// Retrieve the admin super auth code from `admin_config`.
///
/// Returns `Ok(None)` if the admin code has not been seeded yet.
async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError> {
#[derive(serde::Deserialize)]
struct ValueRow {
value: String,
}
self.db
.prepare("SELECT value FROM admin_config WHERE key = 'admin_auth_code'")
.first::<ValueRow>(None)
.await
.map(|opt| opt.map(|r| r.value))
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Insert the admin auth code if not already present.
///
/// Uses `INSERT OR IGNORE` so calling it multiple times is safe.
async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError> {
self.db
.prepare(
"INSERT OR IGNORE INTO admin_config (key, value) VALUES ('admin_auth_code', ?1)",
)
.bind(&[JsValue::from_str(code)])
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map(|_| ())
.map_err(|e| DbError::Internal(e.to_string()))
}
}

@ -38,3 +38,12 @@ CREATE INDEX IF NOT EXISTS idx_quote_tags_quote_id ON quote_tags(quote_id)";
/// Creates a case-insensitive index on `quotes.author` for filter queries.
pub const CREATE_AUTHOR_INDEX: &str = "\
CREATE INDEX IF NOT EXISTS idx_quotes_author ON quotes(author COLLATE NOCASE)";
/// Creates the `admin_config` key/value table for global configuration.
///
/// Stores a single row for the admin auth code under key `admin_auth_code`.
pub const CREATE_ADMIN_CONFIG: &str = "\
CREATE TABLE IF NOT EXISTS admin_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)";

@ -85,6 +85,7 @@ pub enum DbError {
pub trait QuoteRepository {
/// Run `CREATE TABLE IF NOT EXISTS` migrations.
///
/// Covers the `quotes`, `quote_tags`, index, and `admin_config` tables.
/// Must be called once on startup before any other operations.
async fn run_migrations(&self) -> Result<(), DbError>;
@ -132,7 +133,18 @@ pub trait QuoteRepository {
/// Delete a quote by ID.
///
/// The `auth_code` header value must match `quotes.auth_code`.
/// The `auth_code` header value must match `quotes.auth_code` or the
/// admin super auth code stored in `admin_config`.
/// Tags are removed automatically via `ON DELETE CASCADE`.
async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError>;
/// Retrieve the admin super auth code from `admin_config`.
///
/// Returns `Ok(None)` if the admin code has not been seeded yet.
async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError>;
/// Store the admin auth code in `admin_config` if not already present.
///
/// Uses `INSERT OR IGNORE` so calling it multiple times is safe.
async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError>;
}

@ -56,16 +56,18 @@ fn row_to_quote(row: &rusqlite::Row<'_>, tags: Vec<String>) -> Result<Quote, rus
#[async_trait::async_trait]
impl QuoteRepository for NativeRepository {
/// Run the four DDL migration statements from [`super::migrations`].
/// Run the five DDL migration statements from [`super::migrations`].
///
/// Safe to call multiple times — all statements use `IF NOT EXISTS`.
/// Covers `quotes`, `quote_tags`, tag index, author index, and
/// `admin_config`. Safe to call multiple times — all statements use
/// `IF NOT EXISTS`.
async fn run_migrations(&self) -> Result<(), DbError> {
self.conn
.call(|conn| {
use super::migrations::*;
conn.execute_batch(&format!(
"{CREATE_QUOTES}; {CREATE_QUOTE_TAGS}; \
{CREATE_TAG_INDEX}; {CREATE_AUTHOR_INDEX};"
{CREATE_TAG_INDEX}; {CREATE_AUTHOR_INDEX}; {CREATE_ADMIN_CONFIG};"
))?;
Ok(())
})
@ -324,8 +326,14 @@ impl QuoteRepository for NativeRepository {
match stored {
None => return Err(DbError::NotFound),
Some(ref s) if s.as_str() != auth_code.as_str() => return Err(DbError::Forbidden),
Some(_) => {}
Some(ref s) if s.as_str() == auth_code.as_str() => {} // exact match, proceed
Some(_) => {
// Check admin code fallback
let admin = self.get_admin_auth_code().await?;
if admin.as_deref() != Some(auth_code.as_str()) {
return Err(DbError::Forbidden);
}
}
}
// Phase 2: apply the update
@ -390,9 +398,10 @@ impl QuoteRepository for NativeRepository {
/// Delete a quote by ID after verifying the auth code.
///
/// Returns [`DeleteResult::NotFound`] if no quote has that ID,
/// [`DeleteResult::Forbidden`] if the auth code does not match,
/// or [`DeleteResult::Deleted`] on success. Tags are removed automatically
/// by the `ON DELETE CASCADE` constraint on `quote_tags`.
/// [`DeleteResult::Forbidden`] if neither the per-quote auth code nor the
/// admin super auth code matches, or [`DeleteResult::Deleted`] on success.
/// Tags are removed automatically by the `ON DELETE CASCADE` constraint on
/// `quote_tags`.
async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError> {
let id = id.to_owned();
let auth_code = auth_code.to_owned();
@ -408,17 +417,69 @@ impl QuoteRepository for NativeRepository {
.optional()?;
match stored {
None => Ok(DeleteResult::NotFound),
Some(s) if s != auth_code => Ok(DeleteResult::Forbidden),
Some(_) => {
None => return Ok(DeleteResult::NotFound),
Some(ref s) if s == &auth_code => {
conn.execute("DELETE FROM quotes WHERE id = ?", [&id as &str])?;
Ok(DeleteResult::Deleted)
return Ok(DeleteResult::Deleted);
}
Some(_) => {}
}
// Check admin code as fallback
let admin: Option<String> = conn
.query_row(
"SELECT value FROM admin_config WHERE key = 'admin_auth_code'",
[],
|row| row.get(0),
)
.optional()?;
if admin.as_deref() == Some(auth_code.as_str()) {
conn.execute("DELETE FROM quotes WHERE id = ?", [&id as &str])?;
Ok(DeleteResult::Deleted)
} else {
Ok(DeleteResult::Forbidden)
}
})
.await
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Retrieve the admin super auth code from `admin_config`.
///
/// Returns `Ok(None)` if the admin code has not been seeded yet.
async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError> {
self.conn
.call(|conn| {
let result: Option<String> = conn
.query_row(
"SELECT value FROM admin_config WHERE key = 'admin_auth_code'",
[],
|row| row.get(0),
)
.optional()?;
Ok(result)
})
.await
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Insert the admin auth code if not already present.
///
/// Uses `INSERT OR IGNORE` so calling it multiple times is safe.
async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError> {
let code = code.to_owned();
self.conn
.call(move |conn| {
conn.execute(
"INSERT OR IGNORE INTO admin_config (key, value) VALUES ('admin_auth_code', ?1)",
rusqlite::params![code],
)?;
Ok(())
})
.await
.map_err(|e| DbError::Internal(e.to_string()))
}
}
#[cfg(test)]

@ -382,6 +382,14 @@ mod tests {
}
}
}
async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError> {
Ok(None)
}
async fn seed_admin_auth_code(&self, _code: &str) -> Result<(), DbError> {
Ok(())
}
}
fn sample_quote() -> Quote {

@ -17,6 +17,9 @@ use std::sync::Arc;
#[cfg(not(target_arch = "wasm32"))]
use db::QuoteRepository as _;
#[cfg(not(target_arch = "wasm32"))]
use quotesdb::generate_auth_code;
#[cfg(not(target_arch = "wasm32"))]
#[tokio::main]
async fn main() {
@ -28,6 +31,27 @@ async fn main() {
repo.run_migrations().await.expect("migrations failed");
// Seed admin auth code on first startup
if repo
.get_admin_auth_code()
.await
.expect("failed to check admin code")
.is_none()
{
let code = generate_auth_code();
repo.seed_admin_auth_code(&code)
.await
.expect("failed to seed admin code");
}
let admin_code = repo
.get_admin_auth_code()
.await
.expect("failed to read admin code")
.unwrap();
eprintln!("╔══════════════════════════════════════════════╗");
eprintln!("║ ADMIN AUTH CODE: {admin_code:<28}║");
eprintln!("╚══════════════════════════════════════════════╝");
let repo: Arc<dyn db::QuoteRepository + Send + Sync> = Arc::new(repo);
let app = handlers::router(repo);
@ -75,6 +99,19 @@ pub async fn fetch(
.await
.map_err(|e| worker::Error::RustError(e.to_string()))?;
// Seed admin auth code on first startup (no-op if already present).
if repo
.get_admin_auth_code()
.await
.map_err(|e| worker::Error::RustError(e.to_string()))?
.is_none()
{
let code = quotesdb::generate_auth_code();
repo.seed_admin_auth_code(&code)
.await
.map_err(|e| worker::Error::RustError(e.to_string()))?;
}
// Wrap in Arc so it can be shared across handlers via Axum state.
// D1Repository is `unsafe impl Send + Sync` — safe on single-threaded wasm32.
let repo: Arc<dyn db::QuoteRepository + Send + Sync> = Arc::new(repo);

Loading…
Cancel
Save