From caf2246bffdfa76d8f8ea79441045e1f61f8294d Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Wed, 4 Mar 2026 09:54:10 -0800 Subject: [PATCH] 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 --- quotesdb/infra/schema.sql | 6 ++ quotesdb/src/bin/api/db/d1.rs | 69 +++++++++++++++++++--- quotesdb/src/bin/api/db/migrations.rs | 9 +++ quotesdb/src/bin/api/db/mod.rs | 14 ++++- quotesdb/src/bin/api/db/native.rs | 85 +++++++++++++++++++++++---- quotesdb/src/bin/api/handlers/mod.rs | 8 +++ quotesdb/src/bin/api/main.rs | 37 ++++++++++++ 7 files changed, 206 insertions(+), 22 deletions(-) diff --git a/quotesdb/infra/schema.sql b/quotesdb/infra/schema.sql index 9a74cab..e2ef562 100644 --- a/quotesdb/infra/schema.sql +++ b/quotesdb/infra/schema.sql @@ -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 +); diff --git a/quotesdb/src/bin/api/db/d1.rs b/quotesdb/src/bin/api/db/d1.rs index e3ec68a..70af19e 100644 --- a/quotesdb/src/bin/api/db/d1.rs +++ b/quotesdb/src/bin/api/db/d1.rs @@ -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 { // 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, DbError> { + #[derive(serde::Deserialize)] + struct ValueRow { + value: String, + } + + self.db + .prepare("SELECT value FROM admin_config WHERE key = 'admin_auth_code'") + .first::(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())) + } } diff --git a/quotesdb/src/bin/api/db/migrations.rs b/quotesdb/src/bin/api/db/migrations.rs index cb014c7..4111c76 100644 --- a/quotesdb/src/bin/api/db/migrations.rs +++ b/quotesdb/src/bin/api/db/migrations.rs @@ -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 +)"; diff --git a/quotesdb/src/bin/api/db/mod.rs b/quotesdb/src/bin/api/db/mod.rs index fef2729..3ee3133 100644 --- a/quotesdb/src/bin/api/db/mod.rs +++ b/quotesdb/src/bin/api/db/mod.rs @@ -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; + + /// 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, 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>; } diff --git a/quotesdb/src/bin/api/db/native.rs b/quotesdb/src/bin/api/db/native.rs index d752265..dfcd801 100644 --- a/quotesdb/src/bin/api/db/native.rs +++ b/quotesdb/src/bin/api/db/native.rs @@ -56,16 +56,18 @@ fn row_to_quote(row: &rusqlite::Row<'_>, tags: Vec) -> Result 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 { 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 = 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, DbError> { + self.conn + .call(|conn| { + let result: Option = 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)] diff --git a/quotesdb/src/bin/api/handlers/mod.rs b/quotesdb/src/bin/api/handlers/mod.rs index b12dc28..e787218 100644 --- a/quotesdb/src/bin/api/handlers/mod.rs +++ b/quotesdb/src/bin/api/handlers/mod.rs @@ -382,6 +382,14 @@ mod tests { } } } + + async fn get_admin_auth_code(&self) -> Result, DbError> { + Ok(None) + } + + async fn seed_admin_auth_code(&self, _code: &str) -> Result<(), DbError> { + Ok(()) + } } fn sample_quote() -> Quote { diff --git a/quotesdb/src/bin/api/main.rs b/quotesdb/src/bin/api/main.rs index ac2eb05..e9fee31 100644 --- a/quotesdb/src/bin/api/main.rs +++ b/quotesdb/src/bin/api/main.rs @@ -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 = 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 = Arc::new(repo);