Documentation half of the first of five domain-split PRs (API_V2_PLAN.md § Phase 2). BACKEND_DESIGN.md - §2 folder structure: admin/ now shows index.js (shared gate + mount table) and the four capability routers with their route counts, prefixes and extra gates; admin.routes.js is labelled as the 82-route residual that goes away with PR 5. - §4 /admin heading: was "admin.routes.js -> admin.controller.js", now points at admin/index.js and notes staffOnly, which the old heading omitted. - The "planned change" note becomes "in progress" with what has landed. API_V2_PLAN.md - Status: planning -> in progress; PR 1 marked landed in the sequencing list. - New "PR 1 — as landed" section: the route-count table (6+15+3+4+82 = 110) and three findings for PRs 2-5 — why the self-service /shard/* routes stay with the shard capability despite their Admin · Account tag, why a prefix mount must not be "simplified" to a pathless one (a bare use(gate) would then run for requests headed to later mounts), and that routes.guards.json came back zero-diff too. - New section on the swagger path-normalization prerequisite and its consequence: with sorted path keys, a pure route move produces no spec diff, so the spec becomes a third zero-diff gate alongside the manifest and guards files. - Correction to step 6: PROJECT_TREE.md is auto-generated by the sync-project-tree workflow since website#98 and must not be hand-edited in split PRs. api-route-inventory.json is unchanged — verified still byte-identical to server/routes.manifest.json (200 public + 2 internal), which is the point. Co-Authored-By: Claude <noreply@anthropic.com>
46 KiB
UOMysticmoon Website — Backend Design
Phase 1 of 3: backend design → Claude Design (frontend mockup) → coding. This document is the contract the later phases build against.
Public contact email: UOMysticmoon@gmail.com
1. Stack & top-level decisions
| Concern | Decision | Rationale |
|---|---|---|
| Runtime | Node.js + Express | serverlinkr pattern |
| Database | MariaDB (own container) | spec; mariadb pool, parameterized SQL, no ORM (keeps the lightweight model/db split from serverlinkr) |
| Auth | JWT in an httpOnly cookie | spec says "JWT auth" + "secure cookies when HTTPS"; httpOnly keeps the token out of JS (XSS-safe), SameSite=Strict covers CSRF for a same-origin admin panel |
| Frontend | React + Vite, same repo, served by Express in prod | spec |
| Hashing | bcrypt (bcryptjs) |
spec; matches serverlinkr |
| Deploy | Docker Compose (app + db) behind Pangolin | spec |
Adapting serverlinkr → this project
*.mongo.js(mongoose) →*.db.js(MariaDB queries), exactly as the spec names them.- Drop the session/passport hybrid (
express-session,passport,passport-local,connect-mongo). Pure stateless JWT instead — simpler and matches "JWT auth". - Routes grouped by access level (auth / public / admin) per spec, instead of serverlinkr's per-entity routers. Models stay grouped by entity.
2. Folder structure
Skeleton from the spec, with a small number of justified additions marked (+).
In progress: the monolithic route files below (
admin.routes.jsespecially, originally 1552 lines / 110 routes) are being split into one router file per business capability — in place, with every URL unchanged. This section and §4 get updated as each split PR lands. See API_V2_PLAN.md § Phase 2.Landed so far: admin
users,account,invitesandauth/providers(28 routes) now live in their own routers underadmin/, behind a newadmin/index.js. The remaining 82 admin routes are still inadmin.routes.js, andpublic/andplayer/are untouched."Every URL unchanged" is enforced mechanically, not by review:
server/scripts/routeManifest.js(npm run routes:manifest) walks the live Express stack and writes the sorted{ method, path }freeze toserver/routes.manifest.json, mirrored here as api-route-inventory.json. PR checks regenerate it and fail on any diff, so a split PR that moves a URL cannot merge silently. See § 4.0.
server/
.env.example
package.json
db/
schema.sql (+) DDL, also auto-run by the MariaDB container
seed.js (+) seed wiki pages, default settings, first admin
src/
server.js bootstrap: ensure schema, then listen on 0.0.0.0
app.js express app + middleware wiring
router/
api.router.js mounts /v1
v1/
v1.router.js mounts /auth /public /admin
auth/ auth.routes.js + auth.controller.js
public/ public.routes.js + public.controller.js
admin/ index.js mounts the capability routers below at their
own prefixes; owns the shared
`noindex, isLoggedIn, staffOnly` gate and
declares no routes itself
account.router.js (6) /admin/account — self-service, no adminOnly
users.router.js (15) /admin/users — adminOnly
invites.router.js (3) /admin/invites — adminOnly
authProviders.router.js (4) /admin/auth — adminOnly
admin.routes.js (82) everything not yet split, mounted
last at the group root; goes away
when the final split PR lands
admin.controller.js + the per-capability controllers
(already domain-split; the split PRs re-wire
routes, not logic)
model/
users/ users.model.js + users.db.js
posts/ posts.model.js + posts.db.js (news/five-on-friday/newsletter/screenshots)
wiki/ wiki.model.js + wiki.db.js
settings/ settings.model.js + settings.db.js
activity/ activity.model.js + activity.db.js (+) admin activity log
middleware/ (+)
siteMode.js LIVE/MAINTENANCE gate for public content
noindex.js X-Robots-Tag: noindex,nofollow on admin
rateLimit.js login limiter
validate.js express-validator error handler
utils/
auth.js JWT sign/verify, isLoggedIn middleware
db.js MariaDB pool + ensureSchema()
mailer.js (+) nodemailer; mailto fallback if SMTP unset
client/ built in Phase 2/3 (React + Vite)
Dockerfile
docker-compose.yml
.env.example
.gitignore
Why the additions: the spec's feature list requires an activity log, a maintenance-mode
gate, login rate limiting, admin noindex, and SMTP email — none fit cleanly in the four
listed models/two utils. They're isolated in middleware/ + one activity model +
utils/mailer.js, and the spec explicitly says the layout is "expandable."
3. Database schema (MariaDB)
utf8mb4 throughout. Created idempotently on boot (ensureSchema()) and shipped as
db/schema.sql for the container's /docker-entrypoint-initdb.d.
users
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| username | VARCHAR(32) UNIQUE NOT NULL | |
| password_hash | VARCHAR(72) NOT NULL | bcrypt; never returned by the API |
| role | ENUM('admin','editor') NOT NULL DEFAULT 'admin' | room to grow |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
| last_login_at | DATETIME NULL | shown in user management |
posts — one table, four categories
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| category | ENUM('news','five_on_friday','newsletter','screenshot') NOT NULL | |
| title | VARCHAR(200) NOT NULL | |
| slug | VARCHAR(220) NULL | optional clean URL |
| excerpt | VARCHAR(400) NULL | list teaser |
| body | MEDIUMTEXT NULL | markdown/HTML; main text for news/5oF/newsletter |
| image_url | VARCHAR(500) NULL | required for screenshot, optional hero elsewhere |
| published | TINYINT(1) NOT NULL DEFAULT 0 | publish/unpublish toggle |
| author_id | INT NULL FK→users(id) | ON DELETE SET NULL |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
| updated_at | DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | |
| published_at | DATETIME NULL | set when first published; list order |
Index: (category, published, published_at DESC).
wiki_pages
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| slug | VARCHAR(120) UNIQUE NOT NULL | e.g. new-player-guide |
| title | VARCHAR(200) NOT NULL | |
| body | MEDIUMTEXT NULL | markdown/HTML |
| updated_by | INT NULL FK→users(id) | |
| created_at / updated_at | DATETIME |
Seeded with the 8 spec categories: new-player-guide, maps-atlas, systems, items, monsters, crafting, lore, rules.
settings — key/value, expandable
| col | type | notes |
|---|---|---|
key |
VARCHAR(64) PK | |
| value | TEXT NULL | |
| updated_by | INT NULL FK→users(id) | |
| updated_at | DATETIME ON UPDATE CURRENT_TIMESTAMP |
Seeded keys: site_mode (default maintenance), site_mode_changed_at,
site_mode_changed_by, maintenance_message, status_message, homepage_teaser,
contact_email (=UOMysticmoon@gmail.com), site_title.
activity_log — append-only
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| user_id | INT NULL FK→users(id) | |
| action | VARCHAR(64) NOT NULL | e.g. auth.login, site_mode.change, post.create |
| detail | TEXT NULL | JSON string of what changed |
| ip | VARCHAR(45) NULL | from req.ip (needs trust proxy) |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP |
password_resets — self-service reset links
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| token_hash | CHAR(64) UNIQUE NOT NULL | sha256 hex of the opaque token; plaintext never stored |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | the account this reset targets |
| status | ENUM('pending','used') DEFAULT 'pending' | single-use (atomic markUsed) |
| requested_ip | VARCHAR(64) NULL | who asked (audit only) |
| expires_at | DATETIME NOT NULL | ~1h TTL, enforced in the model on top of this |
| created_at / used_at | DATETIME |
Same "store only the hash of an opaque token" pattern as user_invites / mobile_refresh_tokens.
A DB read never yields a usable reset link. See §4 /auth/password/*.
push_devices — opt-in push endpoints (M7)
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | owner |
| transport | ENUM('unifiedpush','fcm') DEFAULT 'unifiedpush' | UnifiedPush for the sideloaded APK; FCM reserved for a later Play flavor |
| endpoint | VARCHAR(512) NOT NULL | the distributor URL the app's ntfy topic was handed (or an FCM token). Unguessable but not a secret — stored in the clear (unlike refresh tokens), because pushes are content-free tickles |
| platform | VARCHAR(40) NULL | free-form label, e.g. android |
| created_at / last_seen_at | DATETIME |
UNIQUE(user_id, endpoint) — re-registering the same endpoint is an idempotent upsert.
notification_subscriptions — which streams a user opted into (M7)
| col | type | notes |
|---|---|---|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
| stream_id | VARCHAR(64) NOT NULL | an id from the catalog (config/notificationStreams.js), validated on write |
| created_at | DATETIME |
PRIMARY KEY(user_id, stream_id). Subscriptions are per-user (applied to every device); a PUT
replaces the whole set. Nothing is pushed unless the user subscribed.
mobile_auth_sessions / mobile_auth_codes — mobile SSO bridge (M9)
Two short-lived, self-pruning tables that bridge a browser SSO redirect flow to a native client. They
carry the app ↔ website PKCE + CSRF state (a second PKCE layer, distinct from the website ↔ IdP
PKCE the sso_tx cookie already carries) and the one-time authorization code the app exchanges for
bearer tokens. Neither holds a secret in the clear — the PKCE code_challenge is a hash by
construction, and the authorization code is stored as a sha256 hash only (same pattern as
user_invites / password_resets / mobile_refresh_tokens).
mobile_auth_sessions — one row per /auth/mobile/sso/start:
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| session_id | CHAR(36) UNIQUE | opaque uuid; carried inside the signed sso_tx (mode mobile) so the callback can find this row |
| provider | VARCHAR(40) NOT NULL | provider id validated enabled at /start |
| code_challenge | VARCHAR(255) NOT NULL | app-supplied PKCE S256 challenge (base64url); verified at /exchange |
| redirect_uri | VARCHAR(255) NOT NULL | the requested app callback — exact-match against the allowlist (never prefix) |
| state | VARCHAR(255) NOT NULL | app-generated opaque CSRF value, echoed on the callback for the app to verify |
| status | ENUM('pending','completed','consumed') DEFAULT 'pending' | pending→completed when the code is minted; consumed after a successful exchange |
| user_id | INT NULL FK→users(id) ON DELETE CASCADE | set once SSO resolves the account |
| expires_at | DATETIME NOT NULL | short (~10 min — one redirect round-trip incl. TOTP) |
| created_at / used_at | DATETIME | used_at stamped at exchange |
mobile_auth_codes — one row per completed SSO callback (the code the app redeems):
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| code_hash | CHAR(64) UNIQUE | sha256 hex of the opaque ≥128-bit code; the raw code never touches the DB |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | the authenticated account |
| session_id | CHAR(36) NOT NULL | the owning mobile_auth_sessions.session_id (ties the code to its PKCE challenge) |
| expires_at | DATETIME NOT NULL | very short (~5 min) |
| used_at | DATETIME NULL | set on first successful exchange — single use (a reused code fails) |
| created_at | DATETIME |
Both self-prune (indexed expires_at): a best-effort sweep runs at boot beside the existing
revoked_sessions prune, and each bridge write opportunistically deletes expired rows — so no cron
infra is added (same approach as revoked_sessions).
mobile_refresh_tokens additions (M9). Two nullable columns are added to support the device
list/revoke surface: device_name VARCHAR(100) NULL (a friendly label) and last_used_at DATETIME NULL (bumped on each refresh). Existing rows get them via the schema's ALTER section; the token model
is otherwise unchanged.
trusted_devices — MFA "Trust this device"
Lets a browser/app skip the TOTP step at login (never the password) for 30 days. Pattern-identical
to mobile_refresh_tokens: the opaque trust token lives client-side (the rg_trust httpOnly cookie on
web, X-Trust-Token / EncryptedSharedPreferences on native) and only its sha256 hash is stored
(token_hash CHAR(64) UNIQUE) — sha256, not bcrypt, because a 256-bit random token is looked up by
its hash via the unique index (a per-row salt would break that). Columns mirror the mobile table
(platform, device_name, device_hash, user_agent, created_at, last_used_at, expires_at,
revoked_at). Capped at 10 rows/user in application code — no silent pruning (an over-cap trust is
refused so the client can prompt the user to revoke one first). Consulted only at the login/password
step, never at token refresh, and revoked wholesale on untrust / password change / password reset /
TOTP disable. See docs/website/TRUSTED_DEVICES_MFA.md.
recovery_codes — single-use MFA backup codes
Generated at TOTP enrollment (10 at a time, shown to the user once) so a user who loses their
authenticator can complete login without an admin reset. code_hash VARCHAR(72) is a bcrypt hash
(not sha256): a recovery code is a human-typed, lower-entropy fallback credential — the closest
analogue to a password — and there is no hash-lookup constraint (verification fetches the user's ≤10
unused rows and bcrypt.compares each, like password verification). used_at is the single-use
marker. Cleared wholesale on TOTP disable / password change / password reset.
4. API contract
Base path /api/v1. JSON in/out. Auth via httpOnly cookie (isLoggedIn reads it; also
accepts Authorization: Bearer for API testing).
4.0 The authoritative route list
The prose tables below are orientation for a human reader and can drift. Two generated artifacts are authoritative, and they answer different questions:
| Artifact | Source of truth for | Generated by |
|---|---|---|
server/routes.manifest.json — mirrored as api-route-inventory.json |
What URLs exist. 200 public routes + 2 on the internal listener, sorted, method + path only. | npm run routes:manifest, by walking the live Express stack |
server/swagger/swagger-output.json — served at /api/docs |
What each route means. Parameters, bodies, response codes, security. | npm run swagger, from #swagger.* annotations |
The split is deliberate: Swagger is annotation-derived, so an unannotated route is invisible in it and
it churns whenever a description is reworded — it documents intent. The manifest is introspection-
derived and records reality, which is why it, not Swagger, is the thing PR checks freeze
(npm run routes:manifest -- --check).
Both artifacts are emitted with sorted keys, so a diff in either is proportional to the change
rather than to how the routers happen to be traversed. swagger.js additionally strips trailing
slashes from generated path keys — see Regenerating the spec in the website README for why the
domain split makes that necessary.
Scope: the manifest keeps /api/** and /.well-known/** from the public app plus everything on the
internal listener. The SPA catch-all, /uploads and /brand are filesystem-conditional static
mounts — not API contract, and including them would make the output depend on whether CI had built
the client.
A third generated file, server/routes.guards.json, is a review aid and not a contract: per route,
the middleware handler count plus the named middleware on its mount chain. It exists because a
router-level router.use(noindex, isLoggedIn, staffOnly) gate never appears in an individual route's
own stack, so a capability router extracted without re-applying the gate would otherwise publish
authenticated endpoints silently. Names are a hint only — requireRole(...) returns an anonymous
arrow and cannot be observed — but a missing requireAuth is unambiguous, and the server test suite
asserts every /admin/** and /player/** route still carries it.
/auth (auth.routes.js → auth.controller.js)
| Method | Path | Auth | Body | Purpose |
|---|---|---|---|---|
| POST | /login |
— (rate-limited) | {username,password} |
verify, set cookie, log auth.login, update last_login_at. If the account has TOTP and this browser is a trusted device (a valid rg_trust cookie bound to the user), the TOTP step is skipped and a session is issued directly (logs auth.login.trusted_device). Otherwise a 2FA account returns {totpRequired, challenge}. |
| POST | /login/totp |
— (rate-limited) | {challenge, code? | recoveryCode?, trustDevice?, deviceName?} |
complete 2FA with a TOTP or single-use recovery code. trustDevice sets the rg_trust cookie so future logins skip TOTP; at the device cap the session is still issued and the body carries {trustLimitReached, devices}. |
| POST | /logout |
cookie | — | clear cookie (the rg_trust trust cookie deliberately survives logout) |
| GET | /me |
cookie / bearer | — | current user (no hash) or 401 — client bootstraps auth state |
| POST | /password/forgot |
— (rate-limited) | {email} |
email a single-use, ~1h reset link to every active account on the address; always returns the same generic 200 (no account enumeration). Email is non-unique, so several accounts may each get a link naming their username. Logs account.password.reset.request. |
| GET | /password/reset/:token |
— | — | validate a link → {username} for the form, else 404 (never distinguishes expired/used/never-existed) |
| POST | /password/reset/:token |
— (rate-limited) | {password} |
consume the single-use link, rotate the hash, and revoke all sessions (web cutoff + mobile refresh tokens). Does not sign the user in — they log in fresh (so a 2FA account still passes TOTP). Logs account.password.reset.complete. |
| GET | /me/account |
cookie / bearer | — | full self account (id, username, role, email, status, totp_enabled, has_password) |
| PATCH | /me/account/username |
cookie / bearer (rate-limited) | {username} |
change own username; re-issues the caller's session |
| PATCH | /me/account/password |
cookie / bearer (rate-limited) | {newPassword, currentPassword?} |
change/set own password (current required unless the account has none); revokes other sessions, keeps the caller's |
| POST | /me/account/totp/setup · …/enable · …/disable |
cookie / bearer | {code} on enable/disable |
self 2FA enrollment (disable needs a valid current code, not a password). enable returns the one-time recoveryCodes; disable clears the user's trusted devices + recovery codes |
| GET | /me/account/identities · DELETE …/:provider |
cookie / bearer | — | list / unlink own SSO identities |
| GET | /me/trusted-devices |
cookie / bearer | — | list own active trusted devices (never tokens) |
| POST | /me/trusted-devices |
cookie / bearer (rate-limited) | {deviceName?} |
trust the current device; web gets an httpOnly rg_trust cookie, native gets {trustToken}. 409 {error:'trusted_device_limit', devices} at the cap |
| DELETE | /me/trusted-devices · …/:id |
cookie / bearer | — | untrust all / one (ownership-scoped) |
| GET | /me/account/recovery-codes/status |
cookie / bearer | — | remaining unused code count (never the codes) |
| POST | /me/account/recovery-codes/generate |
cookie / bearer (rate-limited, password step-up) | {currentPassword?} |
regenerate the one-time recovery codes (returned once); refused when 2FA is off |
| POST | /me/devices |
cookie / bearer | {endpoint, transport?, platform?} |
register a push endpoint; rejects a disallowed endpoint 400 (SSRF guard). Idempotent per (user, endpoint) |
| GET | /me/devices · DELETE …/:id |
cookie / bearer | — | list / unregister own push devices |
| GET | /me/notifications/streams |
cookie / bearer | — | the subscribable catalog (personal/requiresLinkedAccount flags) |
| GET · PUT | /me/notifications/subscriptions |
cookie / bearer | {streams:[id]} on PUT |
get / replace own opted-in streams (unknown ids dropped) |
Role-agnostic self-service (/auth/me/*). The canonical "me" surface for every authenticated
role. It reuses the exact account.controller handlers as /player/account/* and /admin/account/*
(no logic duplication) behind requireAuth only — any active account, never a specific role. This
lets a client (the Android app) manage its own account through one surface without ever touching
/admin (docs/android/PLAN.md §6.4). The older /player/account/* + /admin/account/* routes stay
for web back-compat.
The /player/* group is self-service, not player-only. Staff are a superset of players — every
player ability plus their staff tools on top — so the whole /player/* router (game-account linking,
character/vendor/house reads, credential changes, appeals) sits behind requireAuth only, never
requireRole('player'). Every handler is self-scoped to the caller by req.user.id, so an admin/editor/
moderator using it sees only their own linked accounts and characters (with the pre-existing
isAdmin bypass still letting a genuine admin read any character). Staff also reach the identical
self-scoped handlers under /admin/shard/* (same controller) for the web admin surface; the two are
interchangeable. This is why a staff account with linked game characters gets its "My characters" and
personal notification streams on the mobile client — the group no longer 403s a non-player role.
Password reset. Uses the same audited pattern as user_invites: an opaque 32-byte token
whose sha256 hash only is stored in password_resets, single-use and short-lived (~1h). It
also serves SSO-only accounts (null password_hash) as their "set an initial password" path. The
reset link points at the web front end (/account/reset/:token); the Android app hands off here
rather than shipping its own reset screen (docs/android/PLAN.md §4.2). First admin is bootstrapped
by seed.js from env (see §6); further staff are created under /admin/users or via email invites.
Push notifications (M7, opt-in). The app subscribes per stream (/auth/me/notifications/*) and
registers device endpoints (/auth/me/devices); nothing is pushed unless subscribed. Delivery is a
content-free tickle — { stream, ref }, no sensitive data — POSTed to each subscribed device's
self-hosted ntfy endpoint (utils/pushDispatch); the app wakes and pulls the real, ownership-
checked content over the authenticated API. Two producers fan out through the one publisher: the shard
ingest dispatcher (utils/shardIngest, beside the SSE broadcast) for shard-derived streams, and the
create/publish-post path for news.post. The stream catalog + event→stream mapping is
config/notificationStreams.js. Security invariants:
- Same public/admin split as the SSE feed. Public streams are drawn only from the SSE
PUBLIC_KINDSallowlist; a sensitive kind (audit/cheat/IP/login-attempt) can never produce a public push. - Personal streams are owner-keyed.
vendor.sale/house.idoc/account.loginare delivered only to the owning user's devices, resolved viashardLinks(the same ownership check as/player/shard/*). - SSRF guard. A device
endpointis a client-supplied URL the server POSTs to, so registration and every publish validate it is HTTPS, non-private/loopback, and (when configured) on the shard's ntfy allow-set (NTFY_BASE_URL/NTFY_ALLOWED_ORIGINS). - ntfy is treated as an untrusted relay — no per-user accounts, unguessable topics; an optional
NTFY_PUBLISH_TOKENhardens backend→ntfy publishes but is not required. See docs/android/PLAN.md §11.
Mobile SSO Authorization Bridge (/auth/mobile/sso/*, M9)
Native "Sign in with Google/Discord" for the Android app without shipping any OAuth secret in the
app. The website stays the identity authority: each shard owner's provider credentials live in
auth_providers (encrypted at rest) and are only ever used server-side. The bridge is a new
consumer of the existing SSO + mobile-bearer machinery, not a parallel auth path — it reuses the
/auth/sso/:provider/* redirect flow, the link-only + opt-in-provisioning policy, the TOTP gate, and
issues the same token pair as /auth/mobile/login.
| Method | Path | Auth | Body / Query | Purpose |
|---|---|---|---|---|
| GET | /auth/providers |
— | — | reused discovery; the app renders provider buttons from this (never exposes secrets) |
| GET | /auth/mobile/sso/start |
— (rate-limited per-IP + per-provider) | ?provider&code_challenge&state&redirect_uri |
validate provider enabled + redirect_uri exact-match allowlist; insert a mobile_auth_sessions row; create the existing sso_tx tagged mode:'mobile' carrying session_id; 302 to the IdP (existing authorize URL) |
| GET | /auth/sso/:provider/callback |
— (signed sso_tx) |
?code&state |
existing endpoint; a new branch when tx.mode==='mobile': resolve the account (same policy as web login incl. TOTP), mint a single-use hashed authorization code into mobile_auth_codes, mark the session completed, and 302 to redirect_uri?code=…&state=… (the app's original state) — no cookie is set |
| POST | /auth/mobile/sso/exchange |
— (rate-limited per-IP) | {code, code_verifier} |
validate the code exists / unexpired / unused (mark used) and sha256(code_verifier) matches the stored challenge → issue the existing mobile access + refresh pair (createMobileSession) → {accessToken, refreshToken, expiresIn, user} |
| POST | /auth/mobile/refresh |
— | {refreshToken} |
reused unchanged — rotate the pair |
| POST | /auth/mobile/logout |
bearer | {refreshToken?, all?} |
reused unchanged — revoke this (or all) refresh token(s) |
| GET | /auth/me/sessions · DELETE …/:id |
cookie / bearer | — | list / revoke own mobile sessions (device_name, last_used_at, created_at) — the "Active Devices" surface (distinct from /auth/me/devices, which is push endpoints) |
Two PKCE layers (do not conflate).
- Layer A (existing): website ↔ IdP. The
code_verifieris generated at/start, kept only in the httpOnlysso_txcookie, sent to the IdP token endpoint at the callback. Unchanged. - Layer B (new): app ↔ website. The app generates
code_verifier/code_challenge; the challenge is stored inmobile_auth_sessionsat/start; the verifier is presented at/exchange. This is what stops an intercepted callback code from being redeemed by anyone but the real app.
State / CSRF. The app-generated state is stored at /start, echoed on the callback redirect,
and verified by the app before it calls /exchange — a CSRF guard independent of both PKCE
layers (a different app instance triggering /start cannot complete someone else's flow).
Redirect-URI allowlist. /start and the callback validate redirect_uri by exact match
against a configured allowlist (MOBILE_AUTH_REDIRECT_URIS, default the one fixed application-owned
callback runicgateway://auth/callback) — never prefix match (prefix matching on custom schemes
is a known open-redirect vector). Tokens are never placed in the callback URL — only the
short-lived authorization code.
App Links (implemented). When the admin toggle mobile_app_links_enabled is on, /start also
accepts the self-origin HTTPS callback https://<request-host>/mobile/callback — one additive
exact-match entry, derived from the request/APP_BASE_URL and never from client input; the
custom-scheme allowlist is never narrowed. The shard then auto-serves GET /.well-known/assetlinks.json (fixed package com.runicgateway.app + MOBILE_APP_CERT_SHA256
fingerprints; 404 when the toggle is off or no fingerprint is configured), and
settings.getPublic() advertises mobileAppLinks: <bool>. These two things — one static file route
and one more allowlist entry — are the entire server surface App Links require. See
docs/android/APP_LINKS.md.
TOTP through the bridge. A 2FA account keeps full parity: the callback stages the existing
pending-TOTP cookie (now also carrying the bridge session_id) and bounces the Custom Tab through the
web TOTP form; on a correct code the completion mints the authorization code and deep-links back to
the app — it never mints a session cookie for a mobile flow.
Revocation latency (documented tradeoff). Revoking a refresh token (device revoke / logout) stops
future renewals but does not invalidate an already-issued access token until it expires — up to
the access-token lifetime (MOBILE_ACCESS_TTL, default 15 min) of continued access. This is an
accepted tradeoff given the short lifetime. If instant revocation is ever required, add an
access-token (jti) blocklist check on the requireAuth path — the same revoked_sessions mechanism
web sessions already use.
Authorization code. Cryptographically random, ≥128 bits, stored hash-only, single-use, short
expiry (~5 min); /exchange is rate-limited per-IP. The bridge tables self-prune (§3).
/public (public.routes.js → public.controller.js) — all GET, no auth
| Method | Path | Notes |
|---|---|---|
| GET | /settings |
whitelisted public keys, derived registration/gameAccountSignup flags, the per-shard brand block (name, accent color, logo/hero/favicon) a client themes itself from — one image runs as any shard, asset fields may be site-relative paths (resolve against the base URL) — and a push block { ntfyUrl } (M7): the client-facing ntfy relay URL the app's embedded distributor registers its device topic against, from NTFY_PUBLIC_URL / first NTFY_ALLOWED_ORIGINS (never the internal NTFY_BASE_URL); null when push isn't configured for the shard. |
| GET | /status |
status message + current mode, plus a version block ({ service:'runic-gateway', api, server }) so a client first-run probe recognizes the backend and can run a version-mismatch guard |
| GET | /version |
lightweight, DB-free backend identity/version ({ service, api, server }) — the canonical target for the version guard and a cheap liveness check |
| GET | /posts/:category |
published only; category ∈ news|five-on-friday|newsletter|screenshots |
| GET | /posts/:category/:idOrSlug |
single published post |
| GET | /wiki |
list of pages (slug + title) |
| GET | /wiki/:slug |
single page |
| POST | /contact |
(rate-limited) send mail via SMTP; if unconfigured, respond {fallback:"mailto", email} |
Public content GETs pass through the siteMode gate (§5).
/admin (admin/index.js → the capability routers in §2) — all behind isLoggedIn + noindex + staffOnly
admin/index.js applies the shared gate and mounts each capability router at the prefix it owns;
users, invites and auth/providers add adminOnly on top. Routes not yet extracted still live
in admin.routes.js, mounted last at the group root. The URLs below are unaffected by which file a
route currently sits in — that is the property the route manifest freezes.
| Method | Path | Purpose |
|---|---|---|
| GET | /dashboard |
current mode, last change time + who, content counts, recent activity |
| PUT | /site-mode |
{mode} → update settings, stamp who/when, log site_mode.change |
| GET | /posts?category= |
all posts incl. unpublished |
| POST | /posts |
create |
| GET | /posts/:id |
one |
| PUT | /posts/:id |
edit |
| DELETE | /posts/:id |
delete |
| PATCH | /posts/:id/publish |
{published} toggle (sets published_at) |
| POST | /posts/upload |
multipart image upload (multer) → {image_url} for screenshots |
| GET | /wiki · GET /wiki/:slug |
read incl. unpublished |
| POST | /wiki · PUT /wiki/:slug · DELETE /wiki/:slug |
manage pages |
| GET | /settings · PUT /settings |
read all / update {key:value,...} |
| GET | /activity?limit=&offset= |
paginated activity log |
| GET | /users · POST /users · PUT /users/:id · DELETE /users/:id |
user mgmt (can't delete self / last admin; password hashed on write) |
| GET | /users/:id/trusted-devices |
list a user's active trusted devices (never tokens) |
| DELETE | /users/:id/trusted-devices · …/:deviceId |
revoke all / one of a user's trusted devices (logs admin.trusted_device.revoke[_all]) |
| POST | /users/:id/mfa/reset |
recover a locked-out user: disable TOTP + revoke all trusted devices + clear recovery codes (logs admin.user.totp.reset) |
Every admin write logs to activity_log.
5. Site mode (LIVE / MAINTENANCE)
State in settings.site_mode (live|maintenance), default maintenance.
middleware/siteMode.js, applied only to public content routes:
live→ pass through.maintenance→ respond 503 with{mode:"maintenance", message}unless the request carries a valid admin cookie (admin preview). This hides content server-side, not just in the UI.
Always reachable regardless of mode: static assets / SPA shell, /api/v1/auth/*, all
/api/v1/admin/*. So admin login + panel + the maintenance "coming soon" page always load.
Client behavior (Phase 3): reads GET /public/settings; if maintenance and not an
admin previewing, render the polished dark coming-soon page (message + contact email).
Admin "preview live" simply hits the content APIs with the admin cookie, which bypass the gate.
Dashboard reads site_mode + site_mode_changed_at/_by for "current mode + last change +
who"; activity_log provides the history feed.
6. Auth & security
- JWT signed with
JWT_SECRET,expiresIn=JWT_EXPIRES_IN(default1d); payload{id,username,role}. - Cookie:
httpOnly,sameSite=Lax,path=/, andsecuredecided per-request (COOKIE_SECURE=auto→secure: req.secure). - Trusted-device MFA. A second, separate httpOnly cookie (
rg_trust, default 30d) — opaque, sha256-hashed server-side intrusted_devices— lets a browser/app skip the TOTP step (never the password) on future logins. It is a server-side, per-row-revocable record (never a JWT claim), so the stateless session JWT is unchanged and trust stays revocable. It only ever gates the second factor; it deliberately outlives logout, and is cleared on untrust / password change / password reset / TOTP disable. Recovery codes (bcrypt, single-use) are the 2FA-lockout fallback. All admin trusted-device/MFA actions and the self actions (auth.login.trusted_device,account.trusted_device.*,account.recovery_code*,admin.trusted_device.*,admin.user.totp.reset) are audit-logged. Seedocs/website/TRUSTED_DEVICES_MFA.md. This is the key to dual access: the cookie isSecurewhen reached through Pangolin (HTTPS,X-Forwarded-Proto: https) but notSecurewhen reached directly over the LAN IP on plain HTTP — so login works in both.COOKIE_SECURE=true|falsecan force it. Requirestrust proxy(below).localhost:5173(Vite) andlocalhost:3000are same-site, so the cookie flows in dev too. - bcrypt hashing (cost 10+); plaintext passwords never stored, logged, or returned.
- Rate limiting (
express-rate-limit) on/auth/loginand/public/contact. - Validation (
express-validator) on all writes; centralized error handler. - helmet with a Content-Security-Policy tuned for the built React SPA. The policies now live in
server/src/config/csp.js(app.jsonly wires them up):default-src 'self';script-src 'self'(the Vite build emits only external module chunks — the inline module-preload polyfill is disabled inclient/vite.config.jsto keep this valid);style-src 'self' 'unsafe-inline' https://fonts.googleapis.com(React's pervasive inlinestyle={{…}}attributes can't be nonce'd, plus the Google Fonts stylesheet);font-src 'self' https://fonts.gstatic.com(Cinzel);img-src 'self' data: https:(same-origin uploads, plus external https images embedded in wiki/news bodies orBRAND_*logo/hero/favicon);connect-src 'self'(REST + SSE are same-origin);frame-ancestors 'self';object-src 'none';base-uri 'self';form-action 'self'(blocks an injected<form action="https://evil">from POSTing credentials off-origin — an exfil pathconnect-srcdoes not cover; it was always emitted via helmet'suseDefaultsand is now pinned explicitly so it cannot vanish under a helmet upgrade).upgrade-insecure-requestsis intentionally not set (TLS terminates at the proxy, there are no mixed-content subresources, and it would break a localnpm startover plain http). The/api/docsSwagger UI route gets a looser policy that additionally allows inline script/style, since swagger-ui-express injects an inline bootstrap. helmet also stripsX-Powered-By; the two internal-only listeners (internalApp.js,bot/src/app.js) disable it explicitly too. - A second, tightened policy ships alongside on
Content-Security-Policy-Report-Onlyfor one release before it replaces the enforced one (docs/website/API_V2_PLAN.md§ Phase 1). It is derived from the enforced policy so the two cannot drift, and differs by exactly one directive:frame-ancestors 'self'→'none'. Serving both headers at once means the live policy keeps protecting users while anything the tightened version would break arrives as a report rather than as a broken page — and forframe-ancestorsspecifically, a report from the browser of whoever framed the site is the only way to learn that something does. POST /api/csp-reportis the same-origin violation sink thatreport-to/report-uripoint at (report-toadditionally requires theReporting-Endpointsresponse header, which is set alongside). Same-origin on purpose: reports describe attacks against this site and are not handed to a third-party collector. It parses both wire formats (application/csp-reportfrom Firefox/Safari,application/reports+jsonfrom Chrome's Reporting API — handling one drops half the browsers), writes to thecsplog tag and stores nothing. Necessarily unauthenticated (browsers send reports with no session), so it is bounded on every axis: 16 KB body cap, per-IP rate limit, fixed field allowlist, every logged field truncated, and always 204 — even for malformed input, since a 4xx would make the global error handler log the attacker-supplied body and turn an open endpoint into a log-flood primitive. Mounted outside/api/v1next to/api/health: the browser learns the path from the policy header, never from a client build, so it is not part of the versioned client contract.- Admin not indexed:
X-Robots-Tag: noindex, nofollowon/api/v1/adminand the admin SPA routes;robots.txtdisallows/admin. - No directory browsing (express.static doesn't list; no
serve-index). - No hardcoded credentials: first admin via
seed.jsreadingADMIN_USERNAME/ADMIN_PASSWORDfrom env (created only if no users exist);.envgit-ignored,.env.examplecommitted. app.set('trust proxy', 1)so secure cookies,req.ip, and rate-limiting work behind Pangolin.- CORS: same-origin in prod (SPA served by Express). Dev only: allow
CLIENT_ORIGIN(Vite,http://localhost:5173) withcredentials:true.
7. Email
utils/mailer.js (nodemailer) sends through Gmail over OAuth2 (SMTP XOAUTH2), configured in
Admin → Settings → Email — not env. The mailbox is authorized by an in-app "Connect Gmail" consent
flow (/admin/email/*) that captures a refresh token, stored AES-GCM-encrypted in the email_config
singleton (never returned over the API). The OAuth client id/secret are reused from the google
auth-providers row. Recipient is the contact_email site setting. If email is unconfigured/disabled,
POST /public/contact returns {fallback:"mailto", email} so the client renders a mailto: link
instead. Errors never leak credentials.
7.5 Logging & observability
utils/logger.js — a small dependency-free logger with two transports, console + file,
and four levels (error/warn/info/debug). Each line is timestamped and tagged by
subsystem ([server], [http], [db], [auth], [admin], [ratelimit], [csp], …).
During the CSP report-only soak,
[csp]is the tag to watch: acsp violationwarn line withdirective: frame-ancestorsmeans something really does frame the site and the enforce PR would break it. Silence across one release is the green light to flip.
- Console: color on a TTY, plain in Docker; verbosity =
LOG_LEVEL(defaultinfo). - File: plain text appended to
LOG_DIR/LOG_FILE(default<server>/logs/app.log,/app/logs/app.login Docker, bind-mounted to./logs); verbosity =FILE_LOG_LEVEL(defaultdebug, so the file keeps a complete record while the console stays readable). Toggle withLOG_TO_FILE. The stream is flushed on graceful shutdown. - HTTP access logs via morgan piped into the logger: real client IP (
trust proxy), authenticated admin username, method, URL, status, response time, size. - Captured events: startup config banner, schema/seed steps, login success/failure,
rate-limit hits, site-mode changes, maintenance-gate blocks (debug), all errors with
stack traces (5xx), and SIGINT/SIGTERM shutdown. Passwords and request bodies are never
logged.
unhandledRejection/uncaughtExceptionare caught and logged.
8. Deployment
docker-compose.yml — two services on a private network:
db:mariadb:11, envMARIADB_DATABASE/USER/PASSWORD/ROOT_PASSWORD, volumedbdata:/var/lib/mysql, mountsschema.sqlinto/docker-entrypoint-initdb.d, healthcheck.app: builds the Dockerfile (installs client+server, builds Vite, serves via Express),env_file: .env,DB_HOST=db,depends_on: db (healthy), volumeuploads:/app/uploads,ports: "3000:3000"— binds 0.0.0.0 (no127.0.0.1:prefix) so Pangolin reaches it.ntfy(M7): pinned upstreambinwiederhier/ntfyimage, declarative config only (./ntfy/server.ymlmounted:ro+NTFY_BASE_URL), volumentfydata:/var/lib/ntfy, publishes:80on a host port (${NTFY_HOST_PORT:-2586}:80, binds 0.0.0.0) so Pangolin — which runs outside the compose network — can forward the notification subdomain to it, the same reasonapppublishes3000. Both devices (SSE subscribe) and the backend publisher (POSTing tickles to registered device endpoints) reach ntfy on that public origin. Anonymous read-write to unguessable topics (no accounts to provision) — safe because pushes are content-free tickles. Bringing the stack up provisions a working push relay with zero interactive setup.- Volumes:
dbdata,uploads,ntfydata.
Express listens on 0.0.0.0:${PORT||3000}. Pangolin terminates TLS and proxies to app.
.env.example (committed; real .env ignored):
NODE_ENV=production
PORT=3000
DB_HOST=db
DB_PORT=3306
DB_NAME=uomysticmoon
DB_USER=uomm
DB_PASSWORD=
DB_ROOT_PASSWORD=
JWT_SECRET=
JWT_EXPIRES_IN=1d
COOKIE_SECURE=true
COOKIE_NAME=uomm_token
ADMIN_USERNAME=
ADMIN_PASSWORD=
# Email: configured in Admin → Settings → Email (Gmail OAuth2), not via env
CLIENT_ORIGIN=http://localhost:5173
# Push (M7): the ntfy relay URL — also the backend's SSRF allow-set for device
# endpoints. NTFY_ALLOWED_ORIGINS / NTFY_PUBLISH_TOKEN are optional.
NTFY_BASE_URL=https://ntfy.example.com
# The client-facing ntfy URL surfaced to the app via /public/settings.push.ntfyUrl
# (the app registers its topic endpoint here). Defaults to the first
# NTFY_ALLOWED_ORIGINS entry; set explicitly when the public URL differs from the
# internal NTFY_BASE_URL. Without it (and without NTFY_ALLOWED_ORIGINS) the app
# shows push as unavailable for the shard.
NTFY_PUBLIC_URL=https://ntfy.example.com
NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
# Host port the ntfy container publishes :80 on (default 2586); the reverse proxy
# forwards the notification subdomain to host:NTFY_HOST_PORT. Change on a conflict.
NTFY_HOST_PORT=2586
.gitignore: node_modules/, .env, _reference/, client/dist/, uploads/.
9. Dependencies (server)
express, cors, helmet, morgan, dotenv, mariadb, jsonwebtoken, bcryptjs, cookie-parser, express-rate-limit, express-validator, multer, nodemailer · dev: nodemon.
Removed vs serverlinkr: mongoose, mongodb, connect-mongo, express-session, passport, passport-local.
10. Spec coverage
| Spec requirement | Covered by |
|---|---|
Public pages (/, /site/*, /wiki/*) |
/public/* API + Phase-3 SPA routes; content from posts/wiki/settings |
| News / 5-on-Friday / Newsletter / Screenshots | posts table, category column; admin CRUD + publish |
| Wiki 8 categories, editable later | wiki_pages seeded with 8 slugs; admin CRUD |
| Status page | settings.status_message + mode via /public/status |
| Admin dashboard (mode, last change, who) | /admin/dashboard + settings stamps + activity log |
| Site mode toggle | PUT /admin/site-mode + siteMode middleware |
| Admin activity log | activity_log + /admin/activity |
| Admin user management | /admin/users CRUD |
| Site settings editing | /admin/settings |
| JWT, bcrypt, rate limit, secure cookies, noindex, no dir browsing, no hardcoded creds, .env | §6 |
| Maintenance page, admin always in, static always loads, admin preview | §5 |
| SMTP via env, mailto fallback | §7 |
| Docker Compose + MariaDB + Pangolin, 0.0.0.0 bind | §8 |
| Design tokens / hero | reused from existing assets/css/mysticmoon.css + hero PNG in Phase 2/3 |
| Expandable | key/value settings, role enum, modular routers/models |