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 UserIdKeyExtractor that decodes the Authorization JWT to rate-limit
each user individually (falls back to client IP for unauthenticated
requests). Protected routes now throttle at 10-request burst / 1 token
per 10 s steady-state (6/min), matching the surface attack area of the
1 MB sync/push endpoint.
Also adds an integration test: sync_push_rate_limit_returns_429_on_11th_request.
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>
Five new integration tests against the in-process Axum router +
in-memory SQLite, covering the full HTTP transport + database layer
that the web replay viewer depends on:
- `replay_upload_then_fetch_round_trips_payload` — register → POST →
GET → assert the fetched JSON matches the upload byte-for-byte.
Canonical "the web viewer can play back what the desktop client
uploaded" coverage.
- `replay_fetch_unknown_id_returns_404` — exercises the
`AppError::NotFound` mapping (not a 500).
- `replay_recent_lists_newest_first_with_username` — two uploads,
asserts received_at DESC ordering and that the username join
populates the `username` field.
- `replay_upload_without_auth_returns_401` — guards against the
upload endpoint accidentally accepting anonymous inserts.
- `replay_upload_malformed_body_returns_400` — header projector
rejects payloads missing required fields with 400, not 500.
Schema-correctness (round-trip, version gate, atomic write) is still
covered by `solitaire_data::replay`'s unit tests; this file is
strictly for the HTTP transport.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- core/game_state.rs: move_from_empty_pile_returns_empty_source covers the
EmptySource error path in move_cards() that had no test
- sync/merge.rs: four new tests verifying ConflictReport field/value content
for win_streak_current and daily_challenge_streak divergence, plus negative
cases asserting no report is generated when values are equal
- server/tests: register_login_push_pull_full_roundtrip drives the full
register → login → push → pull sequence through the test router, confirming
that a login-derived JWT can push stats and retrieve them unchanged
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>
Pushes games_played=20, then pushes games_played=5 (lower). Pulls and
asserts games_played is still 20 — confirming the server merges (takes
the max) rather than overwriting with the lower value.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The leaderboard opt-in handler was calling `.len()` on the display name,
which returns byte count. Multi-byte Unicode characters (emoji, CJK, etc.)
would be rejected well before the 32-character visual limit and with a
misleading error message. Switched to `.chars().count()` to enforce the
limit in terms of Unicode scalar values as the error message advertises.
test(core): add boundary tests for 7 uncovered achievement conditions
test(server): add display_name validation integration tests (empty,
too-long ASCII, 32-emoji succeeds, 33-emoji rejected)
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>
When a user pushes sync data and is opted in to the leaderboard, the
server now updates their leaderboard row with the merged stats using
MAX(best_score) and MIN(best_time_secs) — scores never regress even if
the client sends stale data.
Eliminates the need for a separate score-submission API call: the sync
push already carries the full stats, so the leaderboard stays current
after every push.
Added two integration tests:
- push_after_opt_in_updates_leaderboard_score
- push_lower_score_does_not_overwrite_leaderboard_best
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>