diff --git a/solitaire_server/src/challenge.rs b/solitaire_server/src/challenge.rs index b59a8d6..16afef5 100644 --- a/solitaire_server/src/challenge.rs +++ b/solitaire_server/src/challenge.rs @@ -8,11 +8,10 @@ use axum::{extract::State, Json}; use chrono::Utc; -use sqlx::SqlitePool; use solitaire_sync::ChallengeGoal; -use crate::error::AppError; +use crate::{error::AppError, AppState}; // --------------------------------------------------------------------------- // Seed generation @@ -97,18 +96,22 @@ struct ChallengeRow { /// /// Looks up today's challenge in the database. If none exists yet, generates /// one deterministically and stores it before returning. +/// +/// The `INSERT OR IGNORE` followed by a re-SELECT ensures that concurrent +/// requests racing to create today's row all return the same persisted value +/// rather than each returning their own locally-generated copy. pub async fn daily_challenge( - State(pool): State, + State(state): State, ) -> Result, AppError> { let today = Utc::now().format("%Y-%m-%d").to_string(); - // Try to load an existing row. + // Try to load an existing row first (fast path — no generation needed). let row = sqlx::query_as!( ChallengeRow, "SELECT goal_json FROM daily_challenges WHERE date = ?", today ) - .fetch_optional(&pool) + .fetch_optional(&state.pool) .await?; if let Some(r) = row { @@ -117,7 +120,10 @@ pub async fn daily_challenge( return Ok(Json(goal)); } - // No row yet — generate and store. + // No row yet — generate the goal locally and attempt to store it. + // `INSERT OR IGNORE` means a concurrent request that wins the race will + // silently ignore our insert. We then re-SELECT to ensure both requests + // return the same persisted row regardless of which one won. let seed = hash_date_to_u64(&today); let goal = generate_goal(&today, seed); let goal_json = serde_json::to_string(&goal)?; @@ -129,10 +135,22 @@ pub async fn daily_challenge( seed_i64, goal_json ) - .execute(&pool) + .execute(&state.pool) .await?; - Ok(Json(goal)) + // Re-SELECT to return exactly what is stored — handles the race where + // another request inserted a row between our initial SELECT and INSERT. + let stored = sqlx::query_as!( + ChallengeRow, + "SELECT goal_json FROM daily_challenges WHERE date = ?", + today + ) + .fetch_one(&state.pool) + .await?; + + let stored_json = stored.goal_json.ok_or_else(|| AppError::Internal("missing goal_json after insert".into()))?; + let stored_goal: ChallengeGoal = serde_json::from_str(&stored_json)?; + Ok(Json(stored_goal)) } #[cfg(test)] diff --git a/solitaire_server/src/lib.rs b/solitaire_server/src/lib.rs index a5f483c..6cdfff5 100644 --- a/solitaire_server/src/lib.rs +++ b/solitaire_server/src/lib.rs @@ -21,23 +21,41 @@ use sqlx::SqlitePool; use std::sync::Arc; use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer}; +/// Shared application state injected into every Axum handler via [`axum::extract::State`]. +/// +/// Loaded once at startup so a missing `JWT_SECRET` causes an immediate startup +/// failure rather than a 500 error on the first request. +#[derive(Clone)] +pub struct AppState { + /// SQLite connection pool. + pub pool: SqlitePool, + /// HS256 signing secret for JWT access and refresh tokens. + pub jwt_secret: String, +} + /// Construct the full Axum [`Router`]. /// /// Separated from `main` so it can be instantiated in integration tests without /// starting a real TCP listener. -pub fn build_router(pool: SqlitePool) -> Router { - build_router_inner(pool, true) +pub fn build_router(state: AppState) -> Router { + build_router_inner(state, true) } /// Construct the router without rate limiting. /// /// Intended for integration tests only — do not use in production. +/// Uses a fixed test JWT secret (`"test_secret_32_chars_minimum_ok!"`) so +/// integration tests do not need to set `JWT_SECRET` in the environment. #[doc(hidden)] pub fn build_test_router(pool: SqlitePool) -> Router { - build_router_inner(pool, false) + let state = AppState { + pool, + jwt_secret: "test_secret_32_chars_minimum_ok!".to_string(), + }; + build_router_inner(state, false) } -fn build_router_inner(pool: SqlitePool, rate_limit: bool) -> Router { +fn build_router_inner(state: AppState, rate_limit: bool) -> Router { // Protected routes require a valid JWT (injected by require_auth middleware). let protected = Router::new() .route("/api/sync/pull", get(sync::pull)) @@ -46,7 +64,10 @@ fn build_router_inner(pool: SqlitePool, rate_limit: bool) -> Router { .route("/api/leaderboard/opt-in", post(leaderboard::opt_in)) .route("/api/leaderboard/opt-in", delete(leaderboard::opt_out)) .route("/api/account", delete(auth::delete_account)) - .layer(axum_middleware::from_fn(middleware::require_auth)); + .layer(axum_middleware::from_fn_with_state( + state.clone(), + middleware::require_auth, + )); // Auth endpoints — rate-limited in production, unrestricted in tests. let auth_routes = Router::new() @@ -80,7 +101,7 @@ fn build_router_inner(pool: SqlitePool, rate_limit: bool) -> Router { .merge(public) // Reject request bodies larger than 1 MB. .layer(DefaultBodyLimit::max(1024 * 1024)) - .with_state(pool) + .with_state(state) } /// `GET /health` — simple liveness probe, no auth required. diff --git a/solitaire_server/src/main.rs b/solitaire_server/src/main.rs index bcbae9a..158d0a7 100644 --- a/solitaire_server/src/main.rs +++ b/solitaire_server/src/main.rs @@ -16,7 +16,7 @@ //! |---------------|---------|-------------------------------| //! | `SERVER_PORT` | `8080` | TCP port to listen on | -use solitaire_server::build_router; +use solitaire_server::{build_router, AppState}; use sqlx::SqlitePool; use std::net::SocketAddr; @@ -29,6 +29,8 @@ async fn main() { tracing_subscriber::fmt::init(); let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + // Load JWT_SECRET once at startup — a missing secret is a fatal configuration error. + let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set"); let port: u16 = std::env::var("SERVER_PORT") .unwrap_or_else(|_| "8080".into()) .parse() @@ -46,7 +48,8 @@ async fn main() { tracing::info!("database ready at {db_url}"); - let app = build_router(pool); + let state = AppState { pool, jwt_secret }; + let app = build_router(state); let addr = SocketAddr::from(([0, 0, 0, 0], port)); tracing::info!("listening on {addr}"); diff --git a/solitaire_server/src/middleware.rs b/solitaire_server/src/middleware.rs index 0cae1f5..bf1b682 100644 --- a/solitaire_server/src/middleware.rs +++ b/solitaire_server/src/middleware.rs @@ -5,7 +5,7 @@ //! can access it via `Extension`. use axum::{ - extract::{FromRequestParts, Request}, + extract::{FromRequestParts, Request, State}, http::request::Parts, middleware::Next, response::Response, @@ -13,7 +13,7 @@ use axum::{ use jsonwebtoken::{decode, DecodingKey, Validation}; use serde::{Deserialize, Serialize}; -use crate::error::AppError; +use crate::{error::AppError, AppState}; /// The claims encoded in our JWT access tokens. #[derive(Debug, Serialize, Deserialize)] @@ -37,18 +37,19 @@ pub struct AuthenticatedUser { /// Axum middleware function that validates the Bearer JWT and injects /// [`AuthenticatedUser`] into request extensions. /// +/// Reads the JWT secret from [`AppState`] rather than the environment, so a +/// missing secret causes a startup failure rather than a per-request 500. +/// /// Returns `401 Unauthorized` if the token is missing, expired, or invalid. pub async fn require_auth( + State(state): State, mut req: Request, next: Next, ) -> Result { - let secret = std::env::var("JWT_SECRET") - .map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?; - let token = extract_bearer_token(req.headers()) .ok_or(AppError::Unauthorized)?; - let claims = validate_access_token(&token, &secret)?; + let claims = validate_access_token(&token, &state.jwt_secret)?; req.extensions_mut().insert(AuthenticatedUser { user_id: claims.sub, diff --git a/solitaire_server/src/sync.rs b/solitaire_server/src/sync.rs index cead8d6..8dfd18f 100644 --- a/solitaire_server/src/sync.rs +++ b/solitaire_server/src/sync.rs @@ -11,7 +11,7 @@ use solitaire_sync::{ merge, AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, SyncResponse, }; -use crate::{error::AppError, middleware::AuthenticatedUser}; +use crate::{error::AppError, middleware::AuthenticatedUser, AppState}; // --------------------------------------------------------------------------- // Database row helpers @@ -99,10 +99,10 @@ async fn store_payload( /// /// If the user has never pushed any data, returns a default payload. pub async fn pull( - State(pool): State, + State(state): State, user: AuthenticatedUser, ) -> Result, AppError> { - let stored_payload = match load_sync_row(&pool, &user.user_id).await? { + let stored_payload = match load_sync_row(&state.pool, &user.user_id).await? { Some(row) => row_to_payload(&row, &user.user_id)?, None => { // First pull — no server data yet; return an empty default payload. @@ -134,7 +134,7 @@ pub async fn pull( /// updated with the merged `best_single_score` and `fastest_win_seconds` so /// scores stay in sync without a separate submission step. pub async fn push( - State(pool): State, + State(state): State, user: AuthenticatedUser, Json(client_payload): Json, ) -> Result, AppError> { @@ -143,12 +143,12 @@ pub async fn push( return Err(AppError::BadRequest("user_id mismatch".into())); } - let server_payload = match load_sync_row(&pool, &user.user_id).await? { + let server_payload = match load_sync_row(&state.pool, &user.user_id).await? { Some(row) => row_to_payload(&row, &user.user_id)?, None => { // First push — nothing to merge against; store directly. - store_payload(&pool, &user.user_id, &client_payload).await?; - update_leaderboard_if_opted_in(&pool, &user.user_id, &client_payload).await?; + store_payload(&state.pool, &user.user_id, &client_payload).await?; + update_leaderboard_if_opted_in(&state.pool, &user.user_id, &client_payload).await?; return Ok(Json(SyncResponse { merged: client_payload, server_time: Utc::now(), @@ -159,8 +159,8 @@ pub async fn push( let (merged, conflicts) = merge(&client_payload, &server_payload); - store_payload(&pool, &user.user_id, &merged).await?; - update_leaderboard_if_opted_in(&pool, &user.user_id, &merged).await?; + store_payload(&state.pool, &user.user_id, &merged).await?; + update_leaderboard_if_opted_in(&state.pool, &user.user_id, &merged).await?; Ok(Json(SyncResponse { merged,