Optional self-hosted username/password accounts and release proxy for Universal Gaming.
  • JavaScript 98.7%
  • Shell 1%
  • Dockerfile 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-11-11 06:46:49 +01:00
.gitea/workflows Build DATABASE_URL from POSTGRES_* and simplify container workflow 2026-11-04 16:52:23 +01:00
src Report backend 1.6.0 in /api/v1/meta 2026-11-11 06:46:49 +01:00
test Report backend 1.6.0 in /api/v1/meta 2026-11-11 06:46:49 +01:00
.dockerignore Add optional accounts backend and release proxy 2026-11-04 10:35:50 +01:00
.env.example Add live game session sync and accept Sudoku difficulty saves 2026-11-11 05:47:19 +01:00
.gitignore Log PostgreSQL connection IPs and ignore bind-mounted data 2026-11-04 19:54:46 +01:00
docker-compose.yml Report backend 1.6.0 in /api/v1/meta 2026-11-11 06:46:49 +01:00
Dockerfile Default the account service to port 6767 2026-11-04 11:44:20 +01:00
package-lock.json Release 1.4.0: password reset and SMTP TLS fix 2026-11-04 21:55:38 +01:00
package.json Add live game session sync and accept Sudoku difficulty saves 2026-11-11 05:47:19 +01:00
publish-image.sh Release 1.4.0: password reset and SMTP TLS fix 2026-11-04 21:55:38 +01:00
README.md Report backend 1.6.0 in /api/v1/meta 2026-11-11 06:46:49 +01:00
update.sh Add versioned PostgreSQL migrations and pre-update backups 2026-11-04 20:35:31 +01:00

Universal Gaming Accounts Backend

Optional self-hosted account service for Universal Gaming. It provides username/password accounts, optional e-mail login and confirmation, stable UUID identities, optional renaming, a competitive leaderboard, online chess matches, a password-protected management panel, and a small release proxy used by the app's update checker and web footer.

The app is fully playable without this service. Games, saves and the local leaderboard stay on-device. When a player signs in and uses the competitive leaderboard, the server stores the account UUID, username, password hash, timestamps, and score metadata (game, mode, time, moves and optional detail). The public build defaults to https://account.games.emilyh.de; self-hosters can point the app at their own instance at build time or from the Account screen.

Features

  • POST /api/v1/accounts — create an account (optional e-mail)
  • POST /api/v1/auth/login — sign in with username or e-mail and receive a JWT
  • GET /api/v1/accounts/me — read the current account
  • PATCH /api/v1/accounts/me — change the username and/or e-mail; the UUID stays the same
  • DELETE /api/v1/accounts/me — delete the account (password confirmation)
  • GET /api/v1/accounts/check-username — check availability
  • POST /api/v1/accounts/me/resend-verification — resend the optional e-mail confirmation
  • POST /api/v1/accounts/verify-email — confirm an e-mail token
  • GET /verify-email?token=… — confirmation landing page linked from the e-mail
  • GET /api/v1/leaderboards — public top scores for all games
  • GET /api/v1/leaderboards/:gameId — public top scores for one game
  • POST /api/v1/leaderboards/:gameId/scores — submit the signed-in user's best score
  • GET /api/v1/chess/matches — the signed-in user's challenges and matches
  • GET /api/v1/chess/matches/:id — one match with server-validated moves
  • POST /api/v1/chess/challenges — challenge a player by username
  • POST /api/v1/chess/challenges/:id/accept|decline|cancel — respond to a challenge
  • POST /api/v1/chess/matches/:id/moves — submit a legal move
  • POST /api/v1/chess/matches/:id/resign — resign an active match
  • GET /api/v1/saves — the signed-in user's backed-up saves
  • PUT /api/v1/saves / PUT /api/v1/saves/:gameId — upload saves (bulk or one)
  • GET /api/v1/sessions — live-sync cursor: sessions changed since a revision
  • GET|PUT|DELETE /api/v1/sessions/:gameId — one synced game
  • GET /api/v1/app/releases — sanitized release list for the update checker
  • GET /api/v1/app/latest — newest stable release
  • GET /healthz — container health check (includes the active database)
  • GET /api/v1/meta — service name, version, feature flags and e-mail status
  • GET /management — read-only, password-protected operational panel

Passwords are hashed with bcrypt (cost 12 by default). Usernames are unique case-insensitively. Accounts are identified by a server-generated UUID v4, so renaming never changes the identity.

Account and leaderboard data are stored in PostgreSQL in the Docker Compose deployment. Deleting an account also deletes its leaderboard scores. Every database transaction is logged to stdout as a single [db] line, so it appears in docker compose logs:

accounts-1  | [db] account.findByUsername ok 1ms rows=1
accounts-1  | [db] account.create ok 2ms rows=1
accounts-1  | [db] leaderboard.submit ok 1ms rows=1
accounts-1  | [db] leaderboard.list ok 2ms rows=10

The bundled Compose file starts PostgreSQL with log_connections and log_disconnections, so docker compose logs db also shows the source IP of every database connection, including failed logins.

Prebuilt container images

Published images are available from the Gitea container registry:

docker pull git.emilyh.de/emily/universal-gaming-backend:1.6.0
docker pull git.emilyh.de/emily/universal-gaming-backend:latest

The included .gitea/workflows/publish-image.yml builds and pushes on every v* tag. It needs two repository secrets: REGISTRY_USERNAME and REGISTRY_TOKEN (a Forgejo token with package write access), and a registered Forgejo Actions runner with Docker. Without a runner the job stays in waiting; it has not failed. To publish without Actions, run:

./publish-image.sh 1.6.0

The image targets linux/amd64; Apple Silicon runs it through Docker Desktop emulation. To deploy without building, use the published image in Compose:

ACCOUNT_IMAGE=git.emilyh.de/emily/universal-gaming-backend:1.6.0 \
  docker compose pull accounts
ACCOUNT_IMAGE=git.emilyh.de/emily/universal-gaming-backend:1.6.0 \
  docker compose up -d --no-build --remove-orphans

Updating and database migrations

./update.sh takes a pg_dump backup into backups/, pulls the new code, rebuilds the image, runs the versioned migration runner (node src/migrate.js) and then starts the services. Applied versions are recorded in the schema_migrations table. Migrations are ordered, idempotent baseline entries in src/migrations.js; add new entries with the next integer version instead of editing a released one.

To run migrations manually:

docker compose run --rm accounts node src/migrate.js

Quick start with Docker Compose

git clone https://git.emilyh.de/Emily/UniversalGamingApp-Backend.git
cd UniversalGamingApp-Backend

cp .env.example .env
# Edit .env:
#   - set JWT_SECRET (openssl rand -base64 48)
#   - set CORS_ORIGINS
#   - change POSTGRES_PASSWORD; the backend builds DATABASE_URL from the POSTGRES_* values

docker compose up -d --build
docker compose logs -f accounts

Check it:

curl http://localhost:6767/healthz

Compose starts two services: db (PostgreSQL 16) and accounts (the API). Account data lives in the postgres-data volume and survives container recreation. The API waits for PostgreSQL's health check before starting.

Stop it with docker compose down. Use docker compose down -v only if you intentionally want to delete all accounts and leaderboard scores.

Existing SQLite installations are not imported automatically. The SQLite fallback remains available for local development and tests, but Docker Compose now uses PostgreSQL.

Updating from Git

The compose file is designed to be updated in place. Keep deployment-specific values in .env (ignored by Git); optional local overrides can go in docker-compose.override.yml (also ignored). That keeps git pull clean.

Back up the database first, then run the included updater:

# 1. Back up PostgreSQL (*.dump is ignored by Git)
docker compose exec -T db sh -c \
  'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > backup-before-update.dump

# 2. Pull, rebuild and restart
./update.sh

Restore a backup with:

docker compose exec -T db sh -c \
  'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists' \
  < backup-before-update.dump

update.sh refuses to run with local Git changes, pulls with --ff-only, pulls the PostgreSQL base image, rebuilds the accounts image with --pull, and restarts the stack with --remove-orphans.

Equivalent manual steps:

git pull --ff-only
docker compose pull db
docker compose build --pull accounts
docker compose up -d --remove-orphans
docker compose ps

The database schema is created with CREATE TABLE IF NOT EXISTS, so upgrades from 1.0.x to 1.4.x add the leaderboard, e-mail, chess, password-reset and session-versioning columns automatically. No manual migration is required. Existing accounts and scores are preserved in the postgres-data volume.

To check the running version:

curl -sS http://localhost:6767/healthz

The response includes "version" and "database". After an upgrade it should report the version from package.json (currently 1.6.0).

Docker without Compose

docker build -t git.emilyh.de/emily/universal-gaming-backend:1.6.0 .

docker run -d \
  --name universal-gaming-accounts \
  --restart unless-stopped \
  --env-file .env \
  -e DATABASE_URL=postgres://universal_gaming:change-me@db.example.com:5432/universal_gaming \
  -p 6767:6767 \
  git.emilyh.de/emily/universal-gaming-backend:1.6.0

The container needs network access to a PostgreSQL server. With SQLite (development only), omit DATABASE_URL and mount a data volume:

docker run -d \
  --name universal-gaming-accounts \
  --env-file .env \
  -e DATABASE_PATH=/app/data/accounts.db \
  -p 6767:6767 \
  -v universal-gaming-data:/app/data \
  git.emilyh.de/emily/universal-gaming-backend:1.6.0

Environment variables

Copy .env.example to .env and adjust it. .env is ignored by Git.

Variable Default Purpose
NODE_ENV development Set to production in deployments
HOST 0.0.0.0 Interface to bind inside the container
PORT 6767 HTTP port
DATABASE_URL (empty) Optional explicit PostgreSQL URL. Leave empty to build it from the POSTGRES_* values. If set, percent-encode reserved characters in the password
DATABASE_PATH ./data/accounts.db SQLite file path used only when DATABASE_URL is empty (development/tests)
POSTGRES_DB universal_gaming Compose: database created by the db service, also used to build DATABASE_URL
POSTGRES_USER universal_gaming Compose: database user, also used to build DATABASE_URL
POSTGRES_PASSWORD change-me Compose: database password. DATABASE_URL is built from the POSTGRES_* values and percent-encoded
POSTGRES_HOST db Compose: database host used to build DATABASE_URL
POSTGRES_PORT 5432 Compose: database port used to build DATABASE_URL
JWT_SECRET required Signing secret, at least 32 characters. Generate with openssl rand -base64 48
JWT_TTL_SECONDS 2592000 Session lifetime in seconds (30 days)
BCRYPT_ROUNDS 12 Password hashing cost. Use 10 only for tests/slow hardware
CORS_ORIGINS * Comma-separated allowed browser origins, e.g. https://games.example.com. * reflects any origin
REGISTRATION_ENABLED true Set false to close new registrations while keeping logins working
APP_PUBLIC_URL https://games.emilyh.de Public app URL used in password-reset links (/account?reset=…)
PUBLIC_BASE_URL https://account.games.emilyh.de Public backend URL used in e-mail-confirmation links
EMAIL_CONFIRMATION_ENABLED true Send confirmation e-mails when SMTP is configured. Never blocks account use
SMTP_HOST (empty) SMTP server host. Leave empty to disable e-mail
SMTP_PORT 587 SMTP port. 465 defaults to implicit TLS; 25/587/2525 default to STARTTLS
SMTP_SECURE (auto) Set explicitly only if needed. Implicit TLS is inferred from port 465. true on a STARTTLS port is corrected with a warning
SMTP_REQUIRE_TLS (auto) Require STARTTLS. Defaults to true on ports 25/587/2525 when SMTP_SECURE is false
SMTP_USER (empty) SMTP username
SMTP_PASSWORD (empty) SMTP password. Never commit it
SMTP_FROM (empty) From header, e.g. Universal Gaming <no-reply@example.com>
PASSWORD_RESET_TTL_MINUTES 30 Password-reset link lifetime (5–1440 minutes)
LEADERBOARD_ENABLED true Set false to disable competitive score reads/writes. The app falls back to its local board
SAVE_BACKUP_ENABLED true Set false to disable per-account game save backups (/api/v1/saves)
SESSION_SYNC_ENABLED true Set false to disable live game sync between devices (/api/v1/sessions)
ACCOUNT_PORT 6767 Compose only: host port mapped to the accounts container
TRUSTED_PROXIES (empty) Comma-separated proxy IPs, CIDR ranges, or loopback/linklocal/uniquelocal presets. When set, overrides TRUST_PROXY
TRUST_PROXY false Legacy fallback. true trusts every proxy; prefer TRUSTED_PROXIES
UPDATE_ENABLED true Enable the release proxy endpoints
UPDATE_SOURCE_URL Universal Gaming Gitea releases API Upstream release feed
UPDATE_CACHE_SECONDS 300 Release proxy cache lifetime (30–3600)
REQUEST_LOGGING true Log one line per HTTP request with status, duration, client IP, peer IP and user agent. Bodies and tokens are never logged

PostgreSQL connection errors

  • Invalid URL — DATABASE_URL contains an unencoded URL-reserved character in the password (@, :, /, ?, #). Leave DATABASE_URL empty and set POSTGRES_DB, POSTGRES_USER and POSTGRES_PASSWORD; the backend builds the URL and percent-encodes the credentials. If you set DATABASE_URL explicitly, percent-encode the password yourself.

  • password authentication failed for user — the password in the connection URL does not match the role in PostgreSQL. POSTGRES_PASSWORD only initializes a new data volume. If the volume already exists and you changed the value later, either restore the old password or align the role:

    docker compose exec db psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
      -c "ALTER ROLE \"$POSTGRES_USER\" WITH PASSWORD '$POSTGRES_PASSWORD';"
    

    Then restart the accounts service. Never delete the postgres-data volume to fix a password mismatch unless you have a backup; deleting it removes all accounts and scores.

Important production settings

  • JWT_SECRET must be long, random, and kept secret. Rotating it signs out every existing session.
  • CORS_ORIGINS should list the exact web origins that host the game. Do not use * on a public deployment unless you intentionally allow every site.
  • TRUSTED_PROXIES should list the exact IPs or CIDR ranges of reverse proxies you control, for example 127.0.0.1 for a local Caddy/Nginx or 172.16.0.0/12 for a Docker bridge network. Only these peers may set X-Forwarded-For.
  • TRUST_PROXY=true trusts every proxy. Use it only when the service cannot be reached directly, otherwise clients can spoof their IP for rate limiting. When TRUSTED_PROXIES is non-empty it takes precedence.
  • Serve the API over HTTPS. The app sends passwords in the request body; plain HTTP exposes them on the network.
  • REQUEST_LOGGING writes the client IP and user agent for each request to stdout. It never logs request bodies, passwords or tokens. Set it to false if your deployment should not retain IP addresses; Docker logging is capped at 10 MB × 3 files by default.

Reverse proxy example (Caddy)

accounts.example.com {
    encode gzip zstd
    reverse_proxy 127.0.0.1:6767
}

Caddy obtains a certificate automatically. With Nginx, proxy to 127.0.0.1:6767, forward Host, X-Forwarded-For, and X-Forwarded-Proto, and set the proxy address:

TRUSTED_PROXIES=127.0.0.1

If the proxy runs in another container, use its container IP or the Docker network CIDR instead. TRUST_PROXY=true still works as a legacy fallback but trusts every peer.

Connect the Flutter app

Build the app with the backend URL baked in:

flutter build apk --release \
  --dart-define=UNIVERSAL_GAMING_API_URL=https://accounts.example.com

Or leave the build flag unset and set the server URL at runtime from Main menu → Account → Account server. The URL is stored locally.

For Android emulator development, use http://10.0.2.2:6767 to reach a server running on the host. For iOS simulator, use http://127.0.0.1:6767.

Leaderboard API

View the global top five for every game:

curl -sS 'https://accounts.example.com/api/v1/leaderboards?limit=5'

View one game:

curl -sS 'https://accounts.example.com/api/v1/leaderboards/sudoku?limit=5'

Submit a score (the token comes from login or registration):

curl -sS -X POST \
  https://accounts.example.com/api/v1/leaderboards/sudoku/scores \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "mode": "Easy",
    "seconds": 95,
    "moves": 3,
    "detail": "0 mistakes · 0 hints"
  }'

Response:

{
  "improved": true,
  "rank": 2,
  "score": {
    "gameId": "sudoku",
    "mode": "Easy",
    "username": "PlayerOne",
    "seconds": 95,
    "moves": 3,
    "detail": "0 mistakes · 0 hints",
    "achievedAt": "2026-11-04T12:00:00.000Z"
  }
}

The server keeps one best score per user, game and mode. Ranking is by seconds, then moves, then submission time. Re-submitting a slower result returns "improved": false and leaves the stored best untouched. Scores are validated for known games, mode length, and numeric bounds; writes are rate-limited. Scores are client-reported, so this is a friendly competitive board, not an anti-cheat system.

API examples

Register:

curl -sS https://accounts.example.com/api/v1/accounts \
  -H 'Content-Type: application/json' \
  -d '{"username":"PlayerOne","password":"correct-horse"}'

Response:

{
  "token": "eyJ...",
  "user": {
    "id": "0d9f...",
    "username": "PlayerOne",
    "createdAt": "2026-11-04T10:00:00.000Z",
    "updatedAt": "2026-11-04T10:00:00.000Z"
  }
}

Sign in:

curl -sS https://accounts.example.com/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"PlayerOne","password":"correct-horse"}'

Read the current account:

curl -sS https://accounts.example.com/api/v1/accounts/me \
  -H "Authorization: Bearer $TOKEN"

Rename (UUID unchanged):

curl -sS -X PATCH https://accounts.example.com/api/v1/accounts/me \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"username":"PlayerTwo"}'

Delete (password confirmation required):

curl -sS -X DELETE https://accounts.example.com/api/v1/accounts/me \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"password":"correct-horse"}'

Release proxy and web update checks

The app checks for updates on startup. Native builds call the public Gitea API directly. Browsers may be blocked by CORS when calling Gitea, so the app checks this backend's /api/v1/app/releases endpoint first when an account server is configured. The backend fetches and caches the Gitea release feed server-side.

If you do not want the update proxy, set UPDATE_ENABLED=false. The app will still work; web update notices will fall back to a same-origin latest.json file if the host provides one.

E-mail login and optional confirmation

E-mail support is built in but never required. Registration accepts an optional email field. Login accepts either:

{"identifier":"PlayerOne","password":"correct-horse"}

or:

{"identifier":"player@example.com","password":"correct-horse"}

E-mail addresses are stored lower-cased and unique case-insensitively. A confirmation mail is sent only when EMAIL_CONFIRMATION_ENABLED=true and SMTP is configured with SMTP_HOST + SMTP_FROM. Accounts remain usable while unverified; the app shows the address as “Not confirmed” and offers a Resend button. Confirmation links expire after 48 hours. The link opens PUBLIC_BASE_URL/verify-email?token=… and shows a small result page.

If SMTP is not configured, everything else still works. The resend endpoint returns HTTP 503 with a clear message instead of failing silently. Never put SMTP credentials in Git; keep them in .env.

SMTP TLS mode

The TLS mode is inferred from the port unless you set it explicitly:

  • 465 → implicit TLS (SMTP_SECURE=true, SMTP_REQUIRE_TLS=false).
  • 25 / 587 / 2525 → STARTTLS (SMTP_SECURE=false, SMTP_REQUIRE_TLS=true).

If SMTP_SECURE=true is set on a STARTTLS port, the backend logs a warning and switches to STARTTLS automatically. This prevents the common error:0A00010B:SSL routines:tls_validate_record_header:wrong version number failure, which happens when implicit TLS is attempted against a STARTTLS port. The startup log prints the effective mode:

[mail] SMTP host=mail.example.com port=587 secure=false requireTLS=true

Password reset

POST /api/v1/auth/forgot-password accepts {"email":"player@example.com"}. It always returns HTTP 202 so the endpoint cannot be used to discover which addresses have accounts. A reset mail is sent only when the account has an assigned and confirmed e-mail address. The link opens APP_PUBLIC_URL/account?reset=<token> in the app.

POST /api/v1/auth/reset-password accepts {"token":"…","password":"…"}. Tokens are single-use, stored only as SHA-256 hashes, expire after PASSWORD_RESET_TTL_MINUTES, and resetting a password invalidates every existing session by incrementing the account's auth_version.

Online chess API

Chess moves are validated server-side with the chess.js rules engine, so clients cannot skip turns or play illegal moves. A match stores only player UUIDs, UCI moves (e2e4, e7e8q), turn, status and result.

Challenge a player:

curl -sS -X POST https://accounts.example.com/api/v1/chess/challenges \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"opponentUsername":"Rival"}'

The challenged player accepts or declines. Both then poll GET /api/v1/chess/matches and submit moves:

curl -sS -X POST \
  https://accounts.example.com/api/v1/chess/matches/$MATCH_ID/moves \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"from":"e2","to":"e4"}'

The response contains the updated match and SAN/check status. Optimistic version checks prevent two simultaneous moves from overwriting each other. Set CHESS_ENABLED=false to disable the feature; the app then keeps chess local/computer-only.

Live game sync

/api/v1/sessions keeps an unfinished game in sync between the devices of one account. It is separate from the manual save backup: the app polls a cheap cursor endpoint every few seconds and only downloads the games that changed.

# What changed since revision 12? (metadata only, no payloads)
curl -s -H "Authorization: Bearer $TOKEN" \
  'https://account.example.com/api/v1/sessions?since=12'

# Upload a game. `baseRevision` is the revision this device last saw.
curl -s -X PUT -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"payload":{"moves":3},"device":"a1b2c3d4","baseRevision":12}' \
  'https://account.example.com/api/v1/sessions/mahjong'
  • Every account has its own monotonic revision counter; GET /api/v1/sessions returns {revision, sessions} with only the games newer than since.
  • PUT with a stale baseRevision from a different device answers 409 and includes the stored session, so the client can decide who wins. A device may always overwrite its own previous write.
  • finished: true marks a game as over, and the other devices clear it.
  • Payloads are opaque to the server, capped at 32 kB, and the device identifier lets a client ignore the echo of its own writes.
  • Set SESSION_SYNC_ENABLED=false to disable the feature; the app then keeps games on the device only.

Management panel

GET /management is a read-only operational overview: account count, verified e-mails, leaderboard rows, active/all chess matches, and the 100 newest accounts (UUID, username, e-mail status, creation date). It never exposes password hashes, JWTs or e-mail tokens.

Set a long random password to enable it:

MANAGEMENT_ENABLED=true
MANAGEMENT_USER=admin
MANAGEMENT_PASSWORD=replace-with-a-long-random-password

The panel uses HTTP Basic authentication and sends Cache-Control: no-store. Leave MANAGEMENT_PASSWORD empty (the default) to make /management return 404. Put it behind HTTPS and, ideally, an additional reverse-proxy allow-list.

Backup and restore

The whole service state is the PostgreSQL database. Create a compressed dump:

docker compose exec -T db sh -c \
  'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > accounts.dump

Restore into a fresh database:

cat accounts.dump | docker compose exec -T db sh -c \
  'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists'

For the SQLite fallback, back up the database file or copy the volume while the container is stopped.

Local development and tests

Requires Node.js 20+.

npm install
npm test
JWT_SECRET="$(openssl rand -base64 48)" npm start

The test suite covers registration, case-insensitive usernames, e-mail login, UUID stability across renames, deletion, password reset (including session invalidation and one-time tokens), SMTP TLS mode selection, leaderboard ranking and best-score replacement, online chess challenge/accept/move/resign flows, management authentication, validation, CORS, migrations, and the release proxy. It uses an in-memory SQLite database, so no PostgreSQL server is needed for tests.

Security notes

  • This is a basic account service, not a full identity platform. E-mail confirmation and password reset are available but optional; there is no MFA or other account recovery method yet. Password reset requires an assigned and confirmed e-mail address.
  • Leaderboard scores are client-reported. The API validates types and bounds and rate-limits submissions, but it cannot prove that a reported time was actually played. Do not use it for prizes or other high-trust rankings.
  • Usernames and scores on the competitive board are public by design. Online chess exposes the two players' usernames and moves to each other. Players who want to stay private can use local/computer chess and the local leaderboard.
  • The management panel is read-only and disabled unless MANAGEMENT_PASSWORD is set. It is protected with HTTP Basic auth, so serve it over HTTPS.
  • Passwords are never stored in plaintext and are never returned by the API. Passwords are pre-hashed with SHA-256 before bcrypt, so the full password contributes even beyond bcrypt's 72-byte limit. Legacy bcrypt hashes are upgraded automatically after the next successful sign-in.
  • JWTs are stateless and pinned to HS256 with a fixed issuer/audience. Deleting an account invalidates future requests because the user row no longer exists.
  • Registration and login are rate limited per IP. Oversized request bodies are rejected with HTTP 413 before they reach a handler.
  • Run behind HTTPS and keep the database volume private.
  • Back up the database before changing JWT_SECRET or the schema.

License

Private project. All rights reserved unless stated otherwise.