feat(workspace): full server + sync implementation, all tests green

- solitaire_server: Axum auth, sync push/pull, leaderboard, daily
  challenge, account deletion, JWT middleware, rate limiting via
  tower_governor, SQLite migrations, health endpoint
- solitaire_server: expose build_test_router (no rate limiting) so
  integration tests work without a peer IP in oneshot requests
- solitaire_sync: SyncPayload, merge logic, shared API types
- solitaire_data: SyncProvider trait, LocalOnlyProvider,
  SolitaireServerClient, auth_tokens keyring integration, blanket
  Box<dyn SyncProvider> impl
- solitaire_data/settings: derive Default on SyncBackend (clippy fix)
- .sqlx/: offline query cache so server compiles without a live DB
- sqlx: removed non-existent "offline" feature flag
- keyring v2: fixed Entry::new() returning Result<Entry>
- sqlx 0.8: all SQLite TEXT columns wrapped in Option<T>
- Integration tests: max_connections(1) on in-memory pool so all
  connections share the same schema

All 191 tests pass; cargo clippy -D warnings clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-04-26 23:32:56 +00:00
parent 13b428b81c
commit 34ba4dc6ed
55 changed files with 4372 additions and 270 deletions
+201
View File
@@ -0,0 +1,201 @@
//! Authentication handlers: register, login, refresh, delete account.
use axum::{extract::State, Json};
use bcrypt::{hash, verify};
use chrono::Utc;
use jsonwebtoken::{encode, EncodingKey, Header};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use uuid::Uuid;
use crate::{
error::AppError,
middleware::{validate_refresh_token, AuthenticatedUser, Claims},
};
// ---------------------------------------------------------------------------
// Request / response shapes
// ---------------------------------------------------------------------------
/// Body for `POST /api/auth/register` and `POST /api/auth/login`.
#[derive(Debug, Deserialize)]
pub struct AuthRequest {
pub username: String,
pub password: String,
}
/// Body for `POST /api/auth/refresh`.
#[derive(Debug, Deserialize)]
pub struct RefreshRequest {
pub refresh_token: String,
}
/// Successful auth response — contains both tokens.
#[derive(Debug, Serialize)]
pub struct AuthResponse {
pub access_token: String,
pub refresh_token: String,
}
/// Successful refresh response — contains only the new access token.
#[derive(Debug, Serialize)]
pub struct RefreshResponse {
pub access_token: String,
}
// ---------------------------------------------------------------------------
// Internal database row type
// ---------------------------------------------------------------------------
/// User row fetched from the database during login.
/// Fields are `Option<String>` because sqlx treats all SQLite TEXT columns
/// as nullable regardless of the NOT NULL constraint in the schema.
struct UserRow {
id: Option<String>,
password_hash: Option<String>,
}
// ---------------------------------------------------------------------------
// bcrypt cost used for password hashing
// ---------------------------------------------------------------------------
/// bcrypt cost factor. Per ARCHITECTURE.md §19 this must be 12.
const BCRYPT_COST: u32 = 12;
// ---------------------------------------------------------------------------
// Token generation helpers
// ---------------------------------------------------------------------------
/// Encode a JWT access token (24-hour expiry) for `user_id`.
pub fn make_access_token(user_id: &str, secret: &str) -> Result<String, AppError> {
let exp = (Utc::now() + chrono::Duration::hours(24)).timestamp() as usize;
let claims = Claims {
sub: user_id.to_string(),
exp,
kind: "access".to_string(),
};
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
.map_err(|e| AppError::Internal(e.to_string()))
}
/// Encode a JWT refresh token (30-day expiry) for `user_id`.
pub fn make_refresh_token(user_id: &str, secret: &str) -> Result<String, AppError> {
let exp = (Utc::now() + chrono::Duration::days(30)).timestamp() as usize;
let claims = Claims {
sub: user_id.to_string(),
exp,
kind: "refresh".to_string(),
};
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
.map_err(|e| AppError::Internal(e.to_string()))
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
/// `POST /api/auth/register` — create a new account and return tokens.
pub async fn register(
State(pool): State<SqlitePool>,
Json(body): Json<AuthRequest>,
) -> Result<Json<AuthResponse>, AppError> {
// Validate input minimally.
if body.username.trim().is_empty() || body.password.is_empty() {
return Err(AppError::BadRequest("username and password are required".into()));
}
// Check for duplicate username. SQLite returns TEXT as nullable so we
// flatten the Option<Option<String>> produced by fetch_optional.
let existing: Option<String> = sqlx::query_scalar!(
"SELECT id FROM users WHERE username = ?",
body.username
)
.fetch_optional(&pool)
.await?
.flatten();
if existing.is_some() {
return Err(AppError::UsernameTaken);
}
let user_id = Uuid::new_v4().to_string();
let password_hash = hash(&body.password, BCRYPT_COST)?;
let now = Utc::now().to_rfc3339();
sqlx::query!(
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)",
user_id,
body.username,
password_hash,
now
)
.execute(&pool)
.await?;
let secret = std::env::var("JWT_SECRET")
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
Ok(Json(AuthResponse {
access_token: make_access_token(&user_id, &secret)?,
refresh_token: make_refresh_token(&user_id, &secret)?,
}))
}
/// `POST /api/auth/login` — verify credentials and return tokens.
pub async fn login(
State(pool): State<SqlitePool>,
Json(body): Json<AuthRequest>,
) -> Result<Json<AuthResponse>, AppError> {
let row = sqlx::query_as!(
UserRow,
"SELECT id, password_hash FROM users WHERE username = ?",
body.username
)
.fetch_optional(&pool)
.await?;
let row = row.ok_or(AppError::InvalidCredentials)?;
let row_id = row.id.ok_or_else(|| AppError::Internal("user id missing".into()))?;
let row_hash = row.password_hash.ok_or_else(|| AppError::Internal("password hash missing".into()))?;
let valid = verify(&body.password, &row_hash)?;
if !valid {
return Err(AppError::InvalidCredentials);
}
let secret = std::env::var("JWT_SECRET")
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
Ok(Json(AuthResponse {
access_token: make_access_token(&row_id, &secret)?,
refresh_token: make_refresh_token(&row_id, &secret)?,
}))
}
/// `POST /api/auth/refresh` — exchange a refresh token for a new access token.
pub async fn refresh(
Json(body): Json<RefreshRequest>,
) -> Result<Json<RefreshResponse>, AppError> {
let secret = std::env::var("JWT_SECRET")
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
let claims = validate_refresh_token(&body.refresh_token, &secret)?;
Ok(Json(RefreshResponse {
access_token: make_access_token(&claims.sub, &secret)?,
}))
}
/// `DELETE /api/account` — permanently delete the authenticated user's account.
///
/// All related rows are removed via `ON DELETE CASCADE` in the schema.
pub async fn delete_account(
State(pool): State<SqlitePool>,
user: AuthenticatedUser,
) -> Result<Json<serde_json::Value>, AppError> {
sqlx::query!("DELETE FROM users WHERE id = ?", user.user_id)
.execute(&pool)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
+115
View File
@@ -0,0 +1,115 @@
//! Daily challenge endpoint.
//!
//! `GET /api/daily-challenge` — returns the challenge for today's date.
//!
//! The seed is deterministic (same for all players worldwide) and is
//! generated on first request for that date, then stored in the database
//! so subsequent calls return the same value.
use axum::{extract::State, Json};
use chrono::Utc;
use sqlx::SqlitePool;
use solitaire_sync::ChallengeGoal;
use crate::error::AppError;
// ---------------------------------------------------------------------------
// Seed generation
// ---------------------------------------------------------------------------
/// Compute a deterministic seed from a date string such as `"2026-04-26"`.
///
/// Uses a simple polynomial rolling hash over the UTF-8 bytes of the string.
/// The computation is identical across all server instances and all clients
/// that implement the same algorithm.
pub fn hash_date_to_u64(date: &str) -> u64 {
date.bytes()
.fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64))
}
/// Generate a [`ChallengeGoal`] from a seed and date.
///
/// The goal type and parameters are derived deterministically from the seed
/// so all players face exactly the same challenge on the same day.
fn generate_goal(date: &str, seed: u64) -> ChallengeGoal {
// Pick a goal variant based on seed modulo number-of-variants.
// Three variants cycle through: timed, high-score, and open.
match seed % 3 {
0 => ChallengeGoal {
date: date.to_string(),
seed,
description: "Win in under 5 minutes".to_string(),
target_score: None,
max_time_secs: Some(300),
},
1 => ChallengeGoal {
date: date.to_string(),
seed,
description: "Reach a score of 4 000 or more".to_string(),
target_score: Some(4_000),
max_time_secs: None,
},
_ => ChallengeGoal {
date: date.to_string(),
seed,
description: "Win today's deal".to_string(),
target_score: None,
max_time_secs: None,
},
}
}
// ---------------------------------------------------------------------------
// Database row helper
// ---------------------------------------------------------------------------
struct ChallengeRow {
goal_json: Option<String>,
}
// ---------------------------------------------------------------------------
// Handler
// ---------------------------------------------------------------------------
/// `GET /api/daily-challenge` — no auth required.
///
/// Looks up today's challenge in the database. If none exists yet, generates
/// one deterministically and stores it before returning.
pub async fn daily_challenge(
State(pool): State<SqlitePool>,
) -> Result<Json<ChallengeGoal>, AppError> {
let today = Utc::now().format("%Y-%m-%d").to_string();
// Try to load an existing row.
let row = sqlx::query_as!(
ChallengeRow,
"SELECT goal_json FROM daily_challenges WHERE date = ?",
today
)
.fetch_optional(&pool)
.await?;
if let Some(r) = row {
let json = r.goal_json.ok_or_else(|| AppError::Internal("missing goal_json".into()))?;
let goal: ChallengeGoal = serde_json::from_str(&json)?;
return Ok(Json(goal));
}
// No row yet — generate and store.
let seed = hash_date_to_u64(&today);
let goal = generate_goal(&today, seed);
let goal_json = serde_json::to_string(&goal)?;
let seed_i64 = seed as i64;
sqlx::query!(
"INSERT OR IGNORE INTO daily_challenges (date, seed, goal_json) VALUES (?, ?, ?)",
today,
seed_i64,
goal_json
)
.execute(&pool)
.await?;
Ok(Json(goal))
}
+79
View File
@@ -0,0 +1,79 @@
//! Application-level error type with automatic HTTP response conversion.
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
/// All errors that can be returned by the server.
///
/// Each variant maps to a specific HTTP status code when converted to a
/// response via [`IntoResponse`].
#[derive(Debug, Error)]
pub enum AppError {
/// The request is missing a valid `Authorization: Bearer` header, or the
/// JWT is expired / has an invalid signature.
#[error("unauthorized")]
Unauthorized,
/// The supplied credentials (username / password) were incorrect.
#[error("invalid credentials")]
InvalidCredentials,
/// The requested username is already registered.
#[error("username already taken")]
UsernameTaken,
/// The client sent a malformed or invalid request body.
#[error("bad request: {0}")]
BadRequest(String),
/// A database error occurred.
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
/// Password hashing failed.
#[error("internal server error")]
BcryptError(#[from] bcrypt::BcryptError),
/// JSON serialization / deserialization failed.
#[error("serialization error: {0}")]
Json(#[from] serde_json::Error),
/// A catch-all for unexpected internal failures.
#[error("internal server error")]
Internal(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
AppError::InvalidCredentials => (StatusCode::UNAUTHORIZED, self.to_string()),
AppError::UsernameTaken => (StatusCode::CONFLICT, self.to_string()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::Database(e) => {
tracing::error!("database error: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
}
AppError::BcryptError(e) => {
tracing::error!("bcrypt error: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
}
AppError::Json(e) => {
tracing::error!("json error: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
}
AppError::Internal(msg) => {
tracing::error!("internal error: {msg}");
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
}
};
let body = Json(json!({ "error": message }));
(status, body).into_response()
}
}
+125
View File
@@ -0,0 +1,125 @@
//! Leaderboard endpoints.
//!
//! `GET /api/leaderboard` — list all opted-in entries (requires auth).
//! `POST /api/leaderboard/opt-in` — opt in and set / update display name.
use axum::{extract::State, Json};
use chrono::Utc;
use serde::Deserialize;
use sqlx::SqlitePool;
use solitaire_sync::LeaderboardEntry;
use crate::{error::AppError, middleware::AuthenticatedUser};
// ---------------------------------------------------------------------------
// Request shapes
// ---------------------------------------------------------------------------
/// Body for `POST /api/leaderboard/opt-in`.
#[derive(Debug, Deserialize)]
pub struct OptInRequest {
/// The display name the player wants shown on the leaderboard.
pub display_name: String,
}
// ---------------------------------------------------------------------------
// Database row helper
// ---------------------------------------------------------------------------
struct LeaderboardRow {
display_name: Option<String>,
best_score: Option<i64>,
best_time_secs: Option<i64>,
recorded_at: Option<String>,
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
/// `GET /api/leaderboard` — return all opted-in leaderboard entries.
///
/// Returns entries sorted by `best_score` descending (nulls last).
pub async fn get_leaderboard(
State(pool): State<SqlitePool>,
_user: AuthenticatedUser,
) -> Result<Json<Vec<LeaderboardEntry>>, AppError> {
let rows = sqlx::query_as!(
LeaderboardRow,
r#"SELECT l.display_name, l.best_score, l.best_time_secs, l.recorded_at
FROM leaderboard l
JOIN users u ON u.id = l.user_id
WHERE u.leaderboard_opt_in = 1
ORDER BY
CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,
l.best_score DESC,
CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,
l.best_time_secs ASC"#
)
.fetch_all(&pool)
.await?;
let entries: Result<Vec<LeaderboardEntry>, AppError> = rows
.into_iter()
.map(|r| -> Result<LeaderboardEntry, AppError> {
let display_name = r
.display_name
.ok_or_else(|| AppError::Internal("missing display_name".into()))?;
let recorded_at_str = r
.recorded_at
.ok_or_else(|| AppError::Internal("missing recorded_at".into()))?;
let recorded_at = recorded_at_str
.parse::<chrono::DateTime<Utc>>()
.map_err(|e| AppError::Internal(format!("invalid recorded_at: {e}")))?;
Ok(LeaderboardEntry {
display_name,
best_score: r.best_score.map(|v| v as i32),
best_time_secs: r.best_time_secs.map(|v| v as u64),
recorded_at,
})
})
.collect();
Ok(Json(entries?))
}
/// `POST /api/leaderboard/opt-in` — opt in and upsert the player's entry.
///
/// Sets `leaderboard_opt_in = 1` on the user row and creates/updates the
/// leaderboard entry with the supplied display name.
pub async fn opt_in(
State(pool): State<SqlitePool>,
user: AuthenticatedUser,
Json(body): Json<OptInRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
if body.display_name.trim().is_empty() {
return Err(AppError::BadRequest("display_name must not be empty".into()));
}
// Mark the user as opted in.
sqlx::query!(
"UPDATE users SET leaderboard_opt_in = 1 WHERE id = ?",
user.user_id
)
.execute(&pool)
.await?;
let now = Utc::now().to_rfc3339();
// Upsert leaderboard row (preserve best_score / best_time if already present).
sqlx::query!(
r#"INSERT INTO leaderboard (user_id, display_name, recorded_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
display_name = excluded.display_name,
recorded_at = excluded.recorded_at"#,
user.user_id,
body.display_name,
now
)
.execute(&pool)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
+93
View File
@@ -0,0 +1,93 @@
//! Solitaire Quest sync server library.
//!
//! Exposes [`build_router`] so integration tests can construct the full Axum
//! application against an in-memory SQLite database without starting a real
//! TCP listener.
pub mod auth;
pub mod challenge;
pub mod error;
pub mod leaderboard;
pub mod middleware;
pub mod sync;
use axum::{
extract::DefaultBodyLimit,
middleware as axum_middleware,
routing::{delete, get, post},
Router,
};
use sqlx::SqlitePool;
use std::sync::Arc;
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
/// 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)
}
/// Construct the router without rate limiting.
///
/// Intended for integration tests only — do not use in production.
#[doc(hidden)]
pub fn build_test_router(pool: SqlitePool) -> Router {
build_router_inner(pool, false)
}
fn build_router_inner(pool: SqlitePool, 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))
.route("/api/sync/push", post(sync::push))
.route("/api/leaderboard", get(leaderboard::get_leaderboard))
.route("/api/leaderboard/opt-in", post(leaderboard::opt_in))
.route("/api/account", delete(auth::delete_account))
.layer(axum_middleware::from_fn(middleware::require_auth));
// Auth endpoints — rate-limited in production, unrestricted in tests.
let auth_routes = Router::new()
.route("/api/auth/register", post(auth::register))
.route("/api/auth/login", post(auth::login))
.route("/api/auth/refresh", post(auth::refresh));
let auth_routes = if rate_limit {
// Rate limiter: 10 requests per minute per IP.
// burst_size = 10, replenish every 6 seconds = 10/min steady-state.
let governor_conf = Arc::new(
GovernorConfigBuilder::default()
.per_second(6)
.burst_size(10)
.finish()
.expect("invalid governor config"),
);
auth_routes.layer(GovernorLayer {
config: governor_conf,
})
} else {
auth_routes
};
// Public endpoints (no auth, no rate limit beyond defaults).
let public = Router::new()
.route("/api/daily-challenge", get(challenge::daily_challenge))
.route("/health", get(health));
Router::new()
.merge(protected)
.merge(auth_routes)
.merge(public)
// Reject request bodies larger than 1 MB.
.layer(DefaultBodyLimit::max(1024 * 1024))
.with_state(pool)
}
/// `GET /health` — simple liveness probe, no auth required.
async fn health() -> axum::Json<serde_json::Value> {
axum::Json(serde_json::json!({
"status": "ok",
"version": env!("CARGO_PKG_VERSION"),
}))
}
+61 -2
View File
@@ -1,2 +1,61 @@
// Full server implementation added in Phase 8C.
fn main() {}
//! Solitaire Quest sync server entry point.
//!
//! Reads configuration from environment variables (via `dotenvy`), initialises
//! the SQLite database, runs migrations, then starts the Axum HTTP server.
//!
//! ## Required environment variables
//!
//! | Variable | Description |
//! |----------------|---------------------------------------------------|
//! | `DATABASE_URL` | SQLite connection string, e.g. `sqlite://sol.db` |
//! | `JWT_SECRET` | HS256 signing secret (min 32 chars recommended) |
//!
//! ## Optional
//!
//! | Variable | Default | Description |
//! |---------------|---------|-------------------------------|
//! | `SERVER_PORT` | `8080` | TCP port to listen on |
use solitaire_server::build_router;
use sqlx::SqlitePool;
use std::net::SocketAddr;
#[tokio::main]
async fn main() {
// Load .env file if present (silently ignored when absent).
dotenvy::dotenv().ok();
// Initialise structured logging.
tracing_subscriber::fmt::init();
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let port: u16 = std::env::var("SERVER_PORT")
.unwrap_or_else(|_| "8080".into())
.parse()
.expect("SERVER_PORT must be a valid port number");
// Connect to SQLite and run pending migrations.
let pool = SqlitePool::connect(&db_url)
.await
.expect("failed to connect to database");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("database migration failed");
tracing::info!("database ready at {db_url}");
let app = build_router(pool);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("listening on {addr}");
let listener = tokio::net::TcpListener::bind(addr)
.await
.expect("failed to bind TCP listener");
axum::serve(listener, app)
.await
.expect("server error");
}
+117
View File
@@ -0,0 +1,117 @@
//! Axum middleware for JWT authentication.
//!
//! Extracts and validates the `Authorization: Bearer <token>` header, then
//! injects the authenticated `user_id` into request extensions so handlers
//! can access it via `Extension<AuthenticatedUser>`.
use axum::{
extract::{FromRequestParts, Request},
http::request::Parts,
middleware::Next,
response::Response,
};
use jsonwebtoken::{decode, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use crate::error::AppError;
/// The claims encoded in our JWT access tokens.
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
/// Subject — the user's UUID string.
pub sub: String,
/// Expiry timestamp (Unix seconds).
pub exp: usize,
/// Token kind: `"access"` or `"refresh"`.
pub kind: String,
}
/// The authenticated user identity injected into request extensions after
/// successful JWT validation.
#[derive(Debug, Clone)]
pub struct AuthenticatedUser {
/// The authenticated user's UUID, as a string.
pub user_id: String,
}
/// Axum middleware function that validates the Bearer JWT and injects
/// [`AuthenticatedUser`] into request extensions.
///
/// Returns `401 Unauthorized` if the token is missing, expired, or invalid.
pub async fn require_auth(
mut req: Request,
next: Next,
) -> Result<Response, AppError> {
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)?;
req.extensions_mut().insert(AuthenticatedUser {
user_id: claims.sub,
});
Ok(next.run(req).await)
}
/// Extract the raw token string from `Authorization: Bearer <token>`.
fn extract_bearer_token(headers: &axum::http::HeaderMap) -> Option<String> {
let value = headers.get("Authorization")?.to_str().ok()?;
let token = value.strip_prefix("Bearer ")?;
Some(token.to_string())
}
/// Decode and validate a JWT access token, returning its claims on success.
pub fn validate_access_token(token: &str, secret: &str) -> Result<Claims, AppError> {
let key = DecodingKey::from_secret(secret.as_bytes());
let mut validation = Validation::default();
validation.validate_exp = true;
let data = decode::<Claims>(token, &key, &validation)
.map_err(|_| AppError::Unauthorized)?;
if data.claims.kind != "access" {
return Err(AppError::Unauthorized);
}
Ok(data.claims)
}
/// Decode and validate a JWT refresh token, returning its claims on success.
pub fn validate_refresh_token(token: &str, secret: &str) -> Result<Claims, AppError> {
let key = DecodingKey::from_secret(secret.as_bytes());
let mut validation = Validation::default();
validation.validate_exp = true;
let data = decode::<Claims>(token, &key, &validation)
.map_err(|_| AppError::Unauthorized)?;
if data.claims.kind != "refresh" {
return Err(AppError::Unauthorized);
}
Ok(data.claims)
}
// ---------------------------------------------------------------------------
// Axum extractor — allows handlers to receive AuthenticatedUser directly
// ---------------------------------------------------------------------------
#[axum::async_trait]
impl<S> FromRequestParts<S> for AuthenticatedUser
where
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<AuthenticatedUser>()
.cloned()
.ok_or(AppError::Unauthorized)
}
}
+164
View File
@@ -0,0 +1,164 @@
//! Sync pull and push handlers.
//!
//! `GET /api/sync/pull` — return the server's stored payload for this user.
//! `POST /api/sync/push` — receive the client's payload, merge, store, return.
use axum::{extract::State, Json};
use chrono::Utc;
use sqlx::SqlitePool;
use solitaire_sync::{
merge, AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, SyncResponse,
};
use crate::{error::AppError, middleware::AuthenticatedUser};
// ---------------------------------------------------------------------------
// Database row helpers
// ---------------------------------------------------------------------------
struct SyncRow {
stats_json: Option<String>,
achievements_json: Option<String>,
progress_json: Option<String>,
}
/// Load the stored `SyncPayload` for `user_id` from the database.
/// Returns `None` if this user has not pushed any data yet.
async fn load_sync_row(pool: &SqlitePool, user_id: &str) -> Result<Option<SyncRow>, AppError> {
let row = sqlx::query_as!(
SyncRow,
"SELECT stats_json, achievements_json, progress_json FROM sync_state WHERE user_id = ?",
user_id
)
.fetch_optional(pool)
.await?;
Ok(row)
}
/// Deserialize a stored `SyncRow` into a `SyncPayload`.
fn row_to_payload(row: &SyncRow, user_id: &str) -> Result<SyncPayload, AppError> {
let stats_json = row.stats_json.as_deref()
.ok_or_else(|| AppError::Internal("missing stats_json".into()))?;
let achievements_json = row.achievements_json.as_deref()
.ok_or_else(|| AppError::Internal("missing achievements_json".into()))?;
let progress_json = row.progress_json.as_deref()
.ok_or_else(|| AppError::Internal("missing progress_json".into()))?;
let stats: StatsSnapshot = serde_json::from_str(stats_json)?;
let achievements: Vec<AchievementRecord> = serde_json::from_str(achievements_json)?;
let progress: PlayerProgress = serde_json::from_str(progress_json)?;
Ok(SyncPayload {
user_id: user_id
.parse()
.map_err(|_| AppError::Internal("stored user_id is not a valid UUID".into()))?,
stats,
achievements,
progress,
last_modified: Utc::now(),
})
}
/// Persist a `SyncPayload` for `user_id` using an upsert.
async fn store_payload(
pool: &SqlitePool,
user_id: &str,
payload: &SyncPayload,
) -> Result<(), AppError> {
let stats_json = serde_json::to_string(&payload.stats)?;
let achievements_json = serde_json::to_string(&payload.achievements)?;
let progress_json = serde_json::to_string(&payload.progress)?;
let now = Utc::now().to_rfc3339();
sqlx::query!(
r#"INSERT INTO sync_state (user_id, stats_json, achievements_json, progress_json, last_modified)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
stats_json = excluded.stats_json,
achievements_json = excluded.achievements_json,
progress_json = excluded.progress_json,
last_modified = excluded.last_modified"#,
user_id,
stats_json,
achievements_json,
progress_json,
now
)
.execute(pool)
.await?;
Ok(())
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
/// `GET /api/sync/pull` — return the server's stored payload for this user.
///
/// If the user has never pushed any data, returns a default payload.
pub async fn pull(
State(pool): State<SqlitePool>,
user: AuthenticatedUser,
) -> Result<Json<SyncResponse>, AppError> {
let stored_payload = match load_sync_row(&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.
let uid = user
.user_id
.parse()
.map_err(|_| AppError::Internal("invalid user_id UUID".into()))?;
SyncPayload {
user_id: uid,
stats: StatsSnapshot::default(),
achievements: vec![],
progress: PlayerProgress::default(),
last_modified: Utc::now(),
}
}
};
Ok(Json(SyncResponse {
merged: stored_payload,
server_time: Utc::now(),
conflicts: vec![],
}))
}
/// `POST /api/sync/push` — merge the client's payload with the server's
/// stored payload, persist the result, and return it.
pub async fn push(
State(pool): State<SqlitePool>,
user: AuthenticatedUser,
Json(client_payload): Json<SyncPayload>,
) -> Result<Json<SyncResponse>, AppError> {
// Reject payloads that claim to belong to a different user.
if client_payload.user_id.to_string() != user.user_id {
return Err(AppError::BadRequest("user_id mismatch".into()));
}
let server_payload = match load_sync_row(&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?;
return Ok(Json(SyncResponse {
merged: client_payload,
server_time: Utc::now(),
conflicts: vec![],
}));
}
};
let (merged, conflicts) = merge(&client_payload, &server_payload);
store_payload(&pool, &user.user_id, &merged).await?;
Ok(Json(SyncResponse {
merged,
server_time: Utc::now(),
conflicts,
}))
}