- JavaScript 98.7%
- Shell 1%
- Dockerfile 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .gitea/workflows | ||
| src | ||
| test | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| package-lock.json | ||
| package.json | ||
| publish-image.sh | ||
| README.md | ||
| update.sh | ||
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 JWTGET /api/v1/accounts/me— read the current accountPATCH /api/v1/accounts/me— change the username and/or e-mail; the UUID stays the sameDELETE /api/v1/accounts/me— delete the account (password confirmation)GET /api/v1/accounts/check-username— check availabilityPOST /api/v1/accounts/me/resend-verification— resend the optional e-mail confirmationPOST /api/v1/accounts/verify-email— confirm an e-mail tokenGET /verify-email?token=…— confirmation landing page linked from the e-mailGET /api/v1/leaderboards— public top scores for all gamesGET /api/v1/leaderboards/:gameId— public top scores for one gamePOST /api/v1/leaderboards/:gameId/scores— submit the signed-in user's best scoreGET /api/v1/chess/matches— the signed-in user's challenges and matchesGET /api/v1/chess/matches/:id— one match with server-validated movesPOST /api/v1/chess/challenges— challenge a player by usernamePOST /api/v1/chess/challenges/:id/accept|decline|cancel— respond to a challengePOST /api/v1/chess/matches/:id/moves— submit a legal movePOST /api/v1/chess/matches/:id/resign— resign an active matchGET /api/v1/saves— the signed-in user's backed-up savesPUT /api/v1/saves/PUT /api/v1/saves/:gameId— upload saves (bulk or one)GET /api/v1/sessions— live-sync cursor: sessions changed since a revisionGET|PUT|DELETE /api/v1/sessions/:gameId— one synced gameGET /api/v1/app/releases— sanitized release list for the update checkerGET /api/v1/app/latest— newest stable releaseGET /healthz— container health check (includes the active database)GET /api/v1/meta— service name, version, feature flags and e-mail statusGET /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_URLcontains an unencoded URL-reserved character in the password (@,:,/,?,#). LeaveDATABASE_URLempty and setPOSTGRES_DB,POSTGRES_USERandPOSTGRES_PASSWORD; the backend builds the URL and percent-encodes the credentials. If you setDATABASE_URLexplicitly, percent-encode the password yourself. -
password authentication failed for user— the password in the connection URL does not match the role in PostgreSQL.POSTGRES_PASSWORDonly 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-datavolume 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.1for a local Caddy/Nginx or172.16.0.0/12for a Docker bridge network. Only these peers may setX-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_PROXIESis 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_LOGGINGwrites the client IP and user agent for each request to stdout. It never logs request bodies, passwords or tokens. Set it tofalseif 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/sessionsreturns{revision, sessions}with only the games newer thansince. PUTwith a stalebaseRevisionfrom a different device answers409and includes the stored session, so the client can decide who wins. A device may always overwrite its own previous write.finished: truemarks a game as over, and the other devices clear it.- Payloads are opaque to the server, capped at 32 kB, and the
deviceidentifier lets a client ignore the echo of its own writes. - Set
SESSION_SYNC_ENABLED=falseto 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_PASSWORDis 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_SECRETor the schema.
License
Private project. All rights reserved unless stated otherwise.