Commit Graph

15 Commits

Author SHA1 Message Date
funman300 75146847f6 feat(server): add --reset-password admin subcommand
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>
2026-05-12 14:10:13 -07:00
funman300 6e6f3ef1ff feat(server): per-user rate limiting on protected sync endpoints
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>
2026-05-12 13:55:07 -07:00
funman300 b129664344 feat(auth): refresh token rotation via jti tracking
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>
2026-05-12 13:34:42 -07:00
funman300 3081505a3d test(server): E2E coverage for replay upload → fetch path
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>
2026-05-05 18:58:54 +00:00
funman300 66695683eb chore(workspace): upgrade rand 0.9, edition 2024, expand server tests
- rand "0.8" → "0.9": StdRng/SliceRandom API unchanged; 142 core tests pass
- edition "2021" → "2024" workspace-wide: no gen keyword conflicts found;
  204 tests (core + sync) pass clean with zero warnings
- ARCHITECTURE.md: Edition 2021 → Edition 2024 in header
- solitaire_server tests: add 5 new integration tests covering
  refresh-with-garbage-token, expired-refresh-token, push-without-token,
  delete-account-without-token, and leaderboard-authenticated-but-empty

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:36:12 +00:00
funman300 4997356cb5 docs(project): add README, CI workflow, migration guide, and fix asset docs
- README.md: player-facing install, controls, features, and test instructions
- .github/workflows/ci.yml: clippy + headless tests + release build on push/PR
- solitaire_server/migrations/README.md: naming convention and workflow for
  adding future schema migrations
- ARCHITECTURE.md §14: rewrite Asset Pipeline to reflect procedural rendering
  (no image files used; audio only, embedded via include_bytes!)
- ARCHITECTURE.md §2 / §13: fix workspace structure and audio file listing
- CLAUDE.md: clarify asset embedding rule (audio only; visuals are procedural)
- server_tests.rs: add auth_rate_limit_returns_429_on_11th_request test using
  build_router() (rate limiting ON) to verify the GovernorLayer is wired correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:41:16 +00:00
funman300 8f957d919f test(core,sync,server): add EmptySource, ConflictReport, and roundtrip coverage
- 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>
2026-04-28 22:34:57 +00:00
funman300 ddd7502a06 feat(engine): playability improvements — input intelligence, audio, HUD, onboarding (#27–#30, #37, #39–#40, #44, #48–#49)
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>
2026-04-27 19:11:47 +00:00
funman300 0e7a34d6bf test(server): verify merge-on-push keeps higher stats across two pushes
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>
2026-04-27 04:54:47 +00:00
root 9e9ce2b752 fix(server): use char count (not byte length) for display_name limit
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>
2026-04-27 03:50:41 +00:00
root e174ed93a4 fix(server): trim username whitespace on login like register does
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>
2026-04-27 03:26:12 +00:00
root e3ac494e85 feat(server): validate username length/chars and minimum password length on register
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>
2026-04-27 02:59:27 +00:00
root 648c5c18d9 feat(leaderboard): opt-out support — server endpoint, client method, UI button
- Server: DELETE /api/leaderboard/opt-in sets leaderboard_opt_in=0,
  hiding the player without deleting their row (scores preserved for re-opt-in)
- SyncProvider trait: opt_out_leaderboard() default no-op method + blanket impl
- SolitaireServerClient: implements opt_out_leaderboard via DELETE request with JWT refresh
- Leaderboard UI: "Opt Out" button (dark red) alongside existing "Opt In" button
- Server integration test: opt-out hides, opt-in restores (round-trip verified)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 02:01:20 +00:00
root 303c78aa4c feat(server): update leaderboard scores from sync push
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>
2026-04-26 23:56:49 +00:00
root 34ba4dc6ed 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>
2026-04-26 23:32:56 +00:00