From dc7cfec897958ffda60e920a86740f036f9c4cef Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Tue, 10 Mar 2026 12:16:39 -0700 Subject: [PATCH] feat(quotesdb): add admin verify endpoint, remove reset-auth-code UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GET /api/admin/verify — side-effect-free code check used by the admin unlock flow; registered before reset-auth-code in the router - Remove "Reset auth code" section from admin panel (UI + dead API code); rotation is now CLI-only via `wrangler secret put ADMIN_AUTH_CODE` - Add rotate-admin-code justfile recipe using pwgen for local key rotation - Add pwgen to Nix dev shell - Update OpenAPI spec with /api/admin/verify definition Co-Authored-By: Claude Sonnet 4.6 --- flake.nix | 2 + quotesdb/.nbd/tickets/d4a624.md | 7 +++ quotesdb/.nbd/tickets/f3dc74.md | 2 +- quotesdb/api/openapi.yaml | 19 ++++++ quotesdb/justfile | 7 +++ quotesdb/src/bin/ui/api.rs | 72 ++-------------------- quotesdb/src/bin/ui/pages/admin.rs | 96 +----------------------------- quotesdb/src/handlers/mod.rs | 19 ++++++ 8 files changed, 61 insertions(+), 163 deletions(-) create mode 100644 quotesdb/.nbd/tickets/d4a624.md diff --git a/flake.nix b/flake.nix index 505414e..8db5c47 100644 --- a/flake.nix +++ b/flake.nix @@ -65,6 +65,8 @@ pkgs.mdbook pkgs.just + + pkgs.pwgen ]; shellHook = '' diff --git a/quotesdb/.nbd/tickets/d4a624.md b/quotesdb/.nbd/tickets/d4a624.md new file mode 100644 index 0000000..f4f77ab --- /dev/null +++ b/quotesdb/.nbd/tickets/d4a624.md @@ -0,0 +1,7 @@ ++++ +title = "Support ADMIN_AUTH_CODE Cloudflare secret for admin auth" +priority = 8 +status = "done" +ticket_type = "feature" +dependencies = [] ++++ diff --git a/quotesdb/.nbd/tickets/f3dc74.md b/quotesdb/.nbd/tickets/f3dc74.md index d86a825..40bf851 100644 --- a/quotesdb/.nbd/tickets/f3dc74.md +++ b/quotesdb/.nbd/tickets/f3dc74.md @@ -3,7 +3,7 @@ title = "quotesdb/api" priority = 7 status = "todo" ticket_type = "project" -dependencies = ["8a7fba", "77237f", "6c5904"] +dependencies = ["8a7fba", "77237f", "6c5904", "d4a624"] +++ diff --git a/quotesdb/api/openapi.yaml b/quotesdb/api/openapi.yaml index 73e5637..f9af8ec 100644 --- a/quotesdb/api/openapi.yaml +++ b/quotesdb/api/openapi.yaml @@ -297,6 +297,25 @@ paths: schema: $ref: "#/components/schemas/Error" + /api/admin/verify: + get: + operationId: verifyAdminCode + summary: Verify the admin auth code + description: > + Checks the X-Admin-Code header against the configured admin authority + (ADMIN_AUTH_CODE secret or database value). Has no side effects. + security: + - AdminCode: [] + responses: + "200": + description: Code is correct. + "403": + description: Code is missing or wrong. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/admin/lock: post: operationId: lockSubmissions diff --git a/quotesdb/justfile b/quotesdb/justfile index 03d2611..1be8c55 100644 --- a/quotesdb/justfile +++ b/quotesdb/justfile @@ -28,3 +28,10 @@ run-api: run-ui: trunk serve + +rotate-admin-code: + #!/usr/bin/env bash + set -euo pipefail + CODE="$(pwgen -s 32 1)" + echo "New ADMIN_AUTH_CODE: $CODE" + echo "$CODE" | wrangler secret put ADMIN_AUTH_CODE --config wrangler.toml diff --git a/quotesdb/src/bin/ui/api.rs b/quotesdb/src/bin/ui/api.rs index 9151c04..9e43910 100644 --- a/quotesdb/src/bin/ui/api.rs +++ b/quotesdb/src/bin/ui/api.rs @@ -56,25 +56,12 @@ pub struct StatusResponse { pub submissions_locked: bool, } -/// Response from `POST /api/admin/reset-auth-code`. -#[derive(Deserialize)] -struct ResetAuthCodeResponse { - pub auth_code: String, -} - /// Response from `POST /api/admin/lock` or `POST /api/admin/unlock`. #[derive(Deserialize)] struct LockResponse { pub submissions_locked: bool, } -/// Body sent to `POST /api/admin/reset-auth-code`. -#[derive(Serialize)] -struct ResetAuthCodeBody<'a> { - #[serde(skip_serializing_if = "Option::is_none")] - new_code: Option<&'a str>, -} - /// Fetch a paginated list of quotes. /// /// # Arguments @@ -202,24 +189,16 @@ pub async fn get_status() -> Result { /// Verify an admin code without side effects. /// -/// Calls `POST /api/admin/reset-auth-code` with `{"new_code": }` and -/// `X-Admin-Code: `. Because the new code equals the current code, the -/// operation is idempotent: the code remains unchanged on success. -/// -/// Returns `Ok(())` on HTTP 200 (code is correct) or [`ApiError::Forbidden`] -/// on HTTP 403 (wrong code). Other errors propagate as [`ApiError::Server`], -/// [`ApiError::Network`], or [`ApiError::Parse`]. +/// Calls `GET /api/admin/verify` with the `X-Admin-Code` header. Returns +/// `Ok(())` on `200 OK` (code is correct) or [`ApiError::Forbidden`] on +/// `403 Forbidden` (wrong or missing code). Other errors propagate as +/// [`ApiError::Server`], [`ApiError::Network`], or [`ApiError::Parse`]. /// /// # Arguments /// - `code` — the admin code to verify. pub async fn verify_admin_code(code: &str) -> Result<(), ApiError> { - let body = ResetAuthCodeBody { - new_code: Some(code), - }; - let resp = gloo::net::http::Request::post("/api/admin/reset-auth-code") + let resp = gloo::net::http::Request::get("/api/admin/verify") .header("X-Admin-Code", code) - .json(&body) - .map_err(|e| ApiError::Network(e.to_string()))? .send() .await .map_err(|e| ApiError::Network(e.to_string()))?; @@ -236,47 +215,6 @@ pub async fn verify_admin_code(code: &str) -> Result<(), ApiError> { } } -/// Call `POST /api/admin/reset-auth-code` to rotate the admin auth code. -/// -/// # Arguments -/// - `new_code` — an optional new passphrase; if `None` one is generated server-side. -/// - `admin_code` — the admin auth code (sent as `X-Admin-Code` header). -/// -/// Returns the new auth code string on HTTP 200, or: -/// - [`ApiError::Forbidden`] on HTTP 403 (wrong admin code), -/// - [`ApiError::Server`] for other non-200 responses, -/// - [`ApiError::Network`] / [`ApiError::Parse`] for connection/parse errors. -pub async fn admin_reset_auth_code( - new_code: Option<&str>, - admin_code: &str, -) -> Result { - let body = ResetAuthCodeBody { new_code }; - let resp = gloo::net::http::Request::post("/api/admin/reset-auth-code") - .header("X-Admin-Code", admin_code) - .json(&body) - .map_err(|e| ApiError::Network(e.to_string()))? - .send() - .await - .map_err(|e| ApiError::Network(e.to_string()))?; - match resp.status() { - 200 => { - let parsed: ResetAuthCodeResponse = resp - .json() - .await - .map_err(|e| ApiError::Parse(e.to_string()))?; - Ok(parsed.auth_code) - } - 403 => Err(ApiError::Forbidden), - status => { - let msg = resp.text().await.unwrap_or_default(); - Err(ApiError::Server { - status, - message: msg, - }) - } - } -} - /// Call `POST /api/admin/lock` to prevent new quote submissions. /// /// Sends `X-Admin-Code: admin_code` in the request header. No request body. diff --git a/quotesdb/src/bin/ui/pages/admin.rs b/quotesdb/src/bin/ui/pages/admin.rs index cdfec29..e2a8c70 100644 --- a/quotesdb/src/bin/ui/pages/admin.rs +++ b/quotesdb/src/bin/ui/pages/admin.rs @@ -4,7 +4,7 @@ //! input. Once the admin code is verified against the API, the full admin //! controls are revealed with two tabs: //! -//! - **Settings** — submission lock/unlock and auth code reset. +//! - **Settings** — submission lock/unlock. //! - **Moderation** — paginated list of reported quotes with a detail modal. //! //! Refreshing the page resets to the locked state. @@ -50,12 +50,6 @@ pub fn admin_page() -> Html { // --- Admin controls state (only used when unlocked) --- // Active tab (settings or moderation). let active_tab = use_state(|| AdminTab::Settings); - // Optional new passphrase for the reset section. - let new_passphrase = use_state(String::new); - // Newly returned auth code after a successful reset. - let reset_result: UseStateHandle> = use_state(|| None); - // Error message for the reset section. - let reset_error: UseStateHandle> = use_state(|| None); // Current submissions lock state, fetched after unlock. let submissions_locked: UseStateHandle> = use_state(|| None); // Error message for the lock/unlock section. @@ -125,51 +119,6 @@ pub fn admin_page() -> Html { }) }; - // --- Reset auth code handler --- - let on_reset = { - let admin_code = admin_code.clone(); - let new_passphrase = new_passphrase.clone(); - let reset_result = reset_result.clone(); - let reset_error = reset_error.clone(); - let loading = loading.clone(); - Callback::from(move |e: MouseEvent| { - e.prevent_default(); - if *loading { - return; - } - let code = (*admin_code).clone(); - let passphrase = (*new_passphrase).clone(); - let reset_result = reset_result.clone(); - let reset_error = reset_error.clone(); - let loading = loading.clone(); - - loading.set(true); - reset_result.set(None); - reset_error.set(None); - - spawn_local(async move { - let new_code_opt = if passphrase.is_empty() { - None - } else { - Some(passphrase.as_str()) - }; - match api::admin_reset_auth_code(new_code_opt, &code).await { - Ok(new_code) => { - reset_result.set(Some(new_code)); - reset_error.set(None); - } - Err(ApiError::Forbidden) => { - reset_error.set(Some("Wrong admin code.".to_string())); - } - Err(e) => { - reset_error.set(Some(format!("Error: {e}"))); - } - } - loading.set(false); - }); - }) - }; - // --- Lock submissions handler --- let on_lock = { let admin_code = admin_code.clone(); @@ -324,49 +273,6 @@ pub fn admin_page() -> Html { if *active_tab == AdminTab::Settings {
-
-

{ "Reset auth code" }

-
- - -
-
- -
- if let Some(new_code) = (*reset_result).clone() { -

- { "New code: " } - { new_code } -

- } - if let Some(err) = (*reset_error).clone() { -

{ err }

- } -
- -
-

{ "Submissions" }

{ diff --git a/quotesdb/src/handlers/mod.rs b/quotesdb/src/handlers/mod.rs index 8d35ab0..5c3606c 100644 --- a/quotesdb/src/handlers/mod.rs +++ b/quotesdb/src/handlers/mod.rs @@ -519,6 +519,24 @@ struct ResetAuthCodeResponse { auth_code: String, } +/// `GET /api/admin/verify` — verify the admin code without side effects. +/// +/// Checks the `X-Admin-Code` header against the configured authority (secret +/// or database). Returns `200 OK` if the code is correct, `403 Forbidden` if +/// it is missing or wrong. Has no side effects and works regardless of whether +/// the admin code comes from the `ADMIN_AUTH_CODE` secret or the DB. +#[cfg_attr(target_arch = "wasm32", worker::send)] +async fn verify_admin_code_handler(State(state): State, headers: HeaderMap) -> Response { + let Some(code) = extract_admin_code(&headers) else { + return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required"); + }; + if verify_admin_code(&state, &code).await { + StatusCode::OK.into_response() + } else { + error_response(StatusCode::FORBIDDEN, "invalid admin code") + } +} + /// `POST /api/admin/reset-auth-code` — replace the stored admin auth code. /// /// Requires the `X-Admin-Code` header containing the **current** admin @@ -787,6 +805,7 @@ pub fn router( // Public status — exposes whether submissions are currently locked. .route("/api/status", get(get_status)) // Admin endpoints — toggle the global submissions lock and reset auth code. + .route("/api/admin/verify", get(verify_admin_code_handler)) .route("/api/admin/lock", post(lock_submissions)) .route("/api/admin/unlock", post(unlock_submissions)) .route("/api/admin/reset-auth-code", post(reset_auth_code))