Skip to content

v3.1.0: login, dependency refresh, dead-code cleanup#13

Merged
Sjeff merged 15 commits into
masterfrom
3.1.0
Jul 4, 2026
Merged

v3.1.0: login, dependency refresh, dead-code cleanup#13
Sjeff merged 15 commits into
masterfrom
3.1.0

Conversation

@Sjeff

@Sjeff Sjeff commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Summary

  • Login gate: setup wizard + session cookies, every API router + WebSocket now protected
  • Frontend deps updated, npm audit: 14 → 0 vulnerabilities
  • GitHub Actions bumped to latest majors
  • Dead code removed (orphaned migrations dir, unused hooks/deps)
  • Docker image: 419MB → 371MB
  • Bugfixes: "[object Object]" error toasts, account-not-found 500 crash

Full details: see CHANGELOG.md

Test plan

  • Backend: 737 tests passing
  • Frontend: 172 tests passing, build + lint clean
  • Manual: setup wizard → login → dashboard → logout, verified through Docker/nginx
  • docker compose ps reports "healthy"

Sjeff added 15 commits July 3, 2026 13:36
Applies the project's existing Prettier defaults consistently across
config files, source, tests, and workflow YAML; bumps app version to
3.1.0 ahead of the dependency/security/auth work landing on this branch.
Bumps react-router, vite, vitest, @vitejs/plugin-react,
@vitest/coverage-v8, and postcss to the versions proposed by the open
Dependabot PRs (#5, #9, #10, #11, #12), then runs npm audit fix to
clear the remaining transitive vulnerabilities (ajv, brace-expansion,
flatted, js-yaml, minimatch, ws, @babel/core) down to 0 findings.

Also fixes two issues the Vite 8 / Vitest 4.1 upgrade exposed:
- vitest.config.ts was missing the __APP_VERSION__ define that
  vite.config.ts already had, crashing every test that renders Sidebar
- the Sidebar nav list and its test had drifted: Settings was never
  linked in the sidebar despite the /settings route existing, so the
  page was only reachable by typing the URL directly
actions/checkout v4->v7, actions/setup-python v5->v6,
actions/setup-node v4->v6, docker/setup-buildx-action v3->v4,
docker/login-action v3->v4, docker/metadata-action v5->v6,
docker/build-push-action v6->v7
Adds a login gate so the dashboard and its API can no longer be used
without authenticating:

- One-time setup wizard creates the single admin account (username +
  bcrypt-hashed password); POST /auth/setup is rejected once a user
  already exists
- Server-side sessions stored in a new `sessions` table (survive a
  container restart), identified by a random token whose SHA-256 hash
  is persisted — the raw token only ever lives in an httpOnly cookie
- Login lockout: 5 failed attempts locks the account for 15 minutes
- Every existing API router now requires a valid session
  (CurrentUserDep), and the /ws/events WebSocket checks the same
  cookie during the handshake and closes the connection if missing or
  invalid — auth is enforced at the API/WebSocket layer, not just in
  the frontend
- SESSION_COOKIE_SECURE env var (default true) lets LAN/no-TLS
  deployments disable the cookie's Secure flag

Also fixes two issues found while wiring this up:
- get_db() rolled back the login-lockout counter every time a login
  attempt failed, because it treats any exception raised inside a
  request as a reason to discard all writes; authenticate() now
  commits the failed-attempt/lockout state before raising so it
  survives the expected 401
- greenlet was missing from dependencies — SQLAlchemy's async engine
  needs it, but its own install_requires markers don't cover macOS
  arm64, so importing AsyncEngine failed on Apple Silicon dev machines
Adds AuthProvider (mirrors the existing WebSocketProvider pattern) that
wraps the app: while /auth/status and /auth/me resolve it shows a
spinner, then renders either the Login page (setup wizard or login
form, depending on whether an admin account exists yet) or the routed
app. The provider sits above WebSocketProvider so the WebSocket only
connects once a session exists.

Also:
- api.ts now sends credentials: "include" so the session cookie is
  attached to every request
- Settings page gets an Account card with the logged-in username,
  change-password form, and logout button
The container HEALTHCHECK and both docker-compose healthchecks hit
/api/v1/system/health, which is now behind login — every check failed
with 401 and the container reported unhealthy forever. Added an nginx
location for /health that proxies straight to the backend's
unauthenticated /health endpoint (outside /api/, so it isn't gated),
and pointed all three healthchecks at it. Verified the container
reports "healthy" after this change.
backend/src/db/migrations/ was an orphaned leftover from before the
project moved to backend/src/alembic/ (the one alembic.ini actually
points at via script_location). Neither directory has an __init__.py,
and nothing in backend/src, Docker, or CI imports "db.migrations" —
confirmed while chasing a revision-mismatch bug earlier on this
branch. Also drops the stale "db.migrations" entry from
pyproject.toml's packages list.
backend/src/db/seeds/ contained only an empty __init__.py, never
imported or referenced by any script, CLI command, or docs. Also
removes NotificationService.get_logs_count(), which had zero callers
outside its own docstring example.
None of useGames/useSystem (already removed in the previous commit),
useEntry, useHistory, useGiveaway, useRefreshGiveawayGame,
useEntryTrends, or useWebSocketAnyEvent were imported by any page or
component - only re-exported through the hooks barrel and, for a few,
their own test file. Also drops the types that existed solely to
support them (SystemInfo, HealthCheck, TrendDataPoint, GameFilters)
and the date-fns dependency, which nothing in src/ imports.
README: adds a "Login Protection" feature bullet and a "First run:
account setup" section explaining the setup wizard and the
SESSION_COOKIE_SECURE env var for non-TLS deployments.

CHANGELOG: adds a Removed section for the v3.1.0 entry covering the
dead-code cleanup (orphaned migrations directory, unused seeds
package, unused frontend hooks/types, unused date-fns dependency).
Three low-risk trims, no architecture changes (kept nginx/supervisor
and the python:3.13-slim base as-is):

- Add a .dockerignore. There wasn't one at all, so COPY frontend/ ./
  in the frontend-build stage would copy the host's
  frontend/node_modules straight into the image if it happened to
  exist locally (it does, from earlier work this session) - silently
  overwriting the npm-ci-installed one and bloating the build context
  on every build. Also excludes .venv, caches, docs, and other files
  the image never needs.
- Drop curl from the final image (58.2MB apt layer, down from 69.8MB)
  and replace the curl-based HEALTHCHECK with a Python one-liner using
  the venv's Python that's already in the image. Updated both
  docker-compose files' healthcheck to match, since they override the
  image's built-in HEALTHCHECK.
- Drop uvicorn's [standard] extra (72.6MB venv layer, down from 96MB)
  - it pulls in uvloop/httptools/watchfiles/pyyaml, none of which are
  used since the container runs uvicorn without --reload. Plain
  uvicorn is a reasonable trade for a low-traffic single-user tool.
  python-dotenv (also part of [standard]) stays available regardless,
  since pydantic-settings depends on it directly.

Verified end-to-end: fresh setup wizard -> login -> dashboard through
nginx, `docker compose ps` reports "healthy", full backend/frontend
test suites still green.
Root cause: the backend has two different response shapes. Normal
responses are { success, data, meta }, but responses from the global
exception handlers in api/middleware.py (e.g. an expired SteamGifts
session, which is exactly what triggered this via Sync Wins) are
{ error: { message, code, details } } with no success/data keys at
all. Every hook does `throw new Error(response.error || fallback)`,
so whenever the second shape came back, response.error was an object
rather than a string, and `new Error(object)` stringifies to
"[object Object]" - shown as-is in the error toast.

Fixed at the single choke point (api.ts's request()) instead of
patching every hook individually: detect the exception-handler shape
and normalize it to { success: false, data: null, error: "<message>" }
before it reaches any hook. This fixes every action that can hit a
custom backend exception (session expired, rate limits, insufficient
points, scheduler errors, etc.), not just Sync Wins.

Reproduced against a running backend first (bogus PHPSESSID -> real
SG_004 "session expired" response) to confirm the exact payload shape
before writing the fix.
AppException.__init__() requires `code` with no default, but
AccountService.get_account() and .set_default() constructed
ResourceNotFoundError with only a message. The TypeError from that
malformed constructor call happened before the intended exception
object even existed, so it never reached resource_not_found_handler
(which would have returned a clean 404) - it fell through to
unhandled_exception_handler instead, producing a 500 and a traceback
repeating in the logs on every request for a stale/removed account.

Confirmed via grep this is the only missing-code call site in the
codebase: steamgifts_client.py, auth_service.py, and dependencies.py
all pass code correctly, and the SteamAPIError(...) calls in
utils/steam_client.py use an unrelated local exception class with the
same name, not core.exceptions.SteamAPIError.

Adds ACCT_001 to ERROR_CODES and a regression test file for
AccountService (which previously had none) that fails with the exact
TypeError from the logs when the fix is reverted.
@Sjeff
Sjeff merged commit 777b313 into master Jul 4, 2026
6 checks passed
@Sjeff
Sjeff deleted the 3.1.0 branch July 5, 2026 12:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant