- Move /avatars ServeDir behind require_auth middleware so avatar files
can only be fetched by authenticated users (H-11)
- Make avatar upload atomic via .tmp write + rename, cleaning up stale
extensions only after the rename succeeds (H-12)
- Return 401 instead of silently returning an empty username string when
the user row is unexpectedly missing a username (L-17)
- Add user_id mismatch guard to merge(): returns local payload unchanged
with a ConflictReport rather than silently cross-contaminating data (H-2)
- Truncate opt-in display_name to 32 chars client-side before sending,
matching the server's DISPLAY_NAME_MAX validation (L-5)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add migration 005: nullable avatar_url column on users table
- Add GET /api/me: returns id, username, avatar_url from DB (fixes UUID-on-profile bug)
- Add PUT /api/me/avatar: accepts raw image bytes (≤1 MB, jpeg/png/webp/gif),
writes to avatars/ dir, updates avatar_url in DB
- Serve /avatars via ServeDir so uploaded images are publicly accessible
- Update account.html: fetch username from /api/me instead of parsing JWT;
add circular avatar display with initials fallback and click-to-upload
- Add SolitaireServerClient::fetch_me() for desktop/Android profile display
- Add avatar_url field to SyncBackend::SolitaireServer settings (serde default None)
- Update sqlx offline query cache for new avatar_url queries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Self-hosters can now run:
./solitaire_server --reset-password <username>
to update a player's password and invalidate all their refresh tokens
(forcing re-login on every device). Password is read from stdin so it
can be piped from scripts or a password manager without appearing in
shell history.
Implementation:
- reset_password() in auth.rs: validates length, bcrypt-hashes new
password, updates users.password_hash, deletes all refresh_tokens
rows for the user.
- main.rs: --reset-password dispatch before HTTP server startup;
JWT_SECRET not required for this path.
- 4 integration tests covering: login works after reset, old password
rejected, refresh tokens invalidated, unknown user → NotFound,
short password → BadRequest.
- README_SERVER.md: admin password-reset section with examples.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a `refresh_tokens` table (migration 003) with one row per live
refresh token, keyed by UUID jti. On every POST /api/auth/refresh the
old jti row is deleted and a new token pair is issued and stored. Using
a consumed token returns 401. Expired rows are pruned inline on each
successful rotation.
Server: Claims gains an optional `jti` field; make_refresh_token now
returns (jwt, jti); register/login insert the jti row; RefreshResponse
now carries both tokens. Client: stores the rotated refresh token from
the response. ARCHITECTURE.md: API table + Security Model updated.
Three new integration tests cover rotation, consumed-token rejection,
and chained rotations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- engine/lib.rs: re-export CardFaceRevealedEvent so external crates can consume flip-midpoint audio events
- gpgs/stub.rs: add explicit impls for all six defaulted SyncProvider methods; future trait changes now cause a compile error in the stub rather than silently picking up wrong defaults
- core/game_state.rs: add /// doc comments to DrawMode and GameMode variants
- server/auth.rs: replace terse BCRYPT_COST comment with full /// doc comment matching ARCHITECTURE.md §19
- server/leaderboard.rs: add /// doc comment to DISPLAY_NAME_MAX; fix misplaced comment that was prepended to the opt_in handler instead of the constant
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Task #27: Double-click auto-move — best_destination() finds optimal target
(foundation over tableau); handle_double_click() fires MoveRequestEvent.
Task #28: Hint system — find_hint() returns first legal from/to/count triple;
H key tints the source stack HintHighlight (yellow pulse via tick_hint_highlight).
Task #29: No-moves detection — has_legal_moves() checks stock/waste/all face-up
cards; check_no_moves system fires InfoToastEvent("No moves available") once per
stalemate (debounced so it fires only once until the state changes).
Task #30: Forfeit — G key fires ForfeitEvent; StatsPlugin records abandoned game,
persists stats, starts a new deal.
Task #37: Mute-all (M) and mute-music (Shift+M) toggles; MuteState resource
applied in apply_volume_on_change.
Task #39: Daily challenge HUD constraint label (time limit / target score).
Task #40: Undo-count HUD label; amber colour when undos > 0.
Task #44: Win-streak and level line on pause screen.
Task #48: Undo sound routes UndoRequestEvent → lib.flip audio channel.
Task #49: Onboarding banner rich-text key highlights — D and H rendered as
orange KeyHighlightSpan children so they stand out from body text.
Also registers CursorPlugin in solitaire_app (tasks #31/#32 wire-up).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
register() strips leading/trailing whitespace from the username before
storing it; login() was not, so a user who typed " alice " at login
would get a 401 even though their account existed as "alice". Now both
handlers trim consistently.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Username: 3–32 chars, alphanumeric + underscore only.
Password: minimum 8 characters.
Both return HTTP 400 Bad Request with a human-readable message.
Adds three integration tests for the new validation rules.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 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>