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 })))
}