# 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.js` especially, 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](./API_V2_PLAN.md) § Phase 2. > > **Landed so far:** admin `users`, `account`, `invites` and `auth/providers` (28 routes) now live in > their own routers under `admin/`, behind a new `admin/index.js`. The remaining 82 admin routes are > still in `admin.routes.js`, and `public/` and `player/` 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 to `server/routes.manifest.json`, mirrored here as > [api-route-inventory.json](./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.compare`s 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](./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_KINDS` allowlist; a sensitive kind (audit/cheat/IP/login-attempt) can never produce a public push. - **Personal streams are owner-keyed.** `vendor.sale` / `house.idoc` / `account.login` are delivered only to the *owning* user's devices, resolved via `shardLinks` (the same ownership check as `/player/shard/*`). - **SSRF guard.** A device `endpoint` is 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_TOKEN` hardens 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_verifier` is generated at `/start`, kept only in the httpOnly `sso_tx` cookie, 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 in `mobile_auth_sessions` at `/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:///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: `. 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` (default `1d`); payload `{id,username,role}`. - **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided 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 in `trusted_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. See `docs/website/TRUSTED_DEVICES_MFA.md`. This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are 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/login` and `/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.js` only wires them up): `default-src 'self'`; `script-src 'self'` (the Vite build emits only external module chunks — the inline module-preload polyfill is disabled in `client/vite.config.js` to keep this valid); `style-src 'self' 'unsafe-inline' https://fonts.googleapis.com` (React's pervasive inline `style={{…}}` 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 or `BRAND_*` 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 `
` from POSTing credentials off-origin — an exfil path `connect-src` does not cover; it was always emitted via helmet's `useDefaults` and is now pinned explicitly so it cannot vanish under a helmet upgrade). `upgrade-insecure-requests` is intentionally **not** set (TLS terminates at the proxy, there are no mixed-content subresources, and it would break a local `npm start` over plain http). The `/api/docs` Swagger UI route gets a **looser** policy that additionally allows inline script/style, since swagger-ui-express injects an inline bootstrap. helmet also strips `X-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-Only`** for 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 for `frame-ancestors` specifically, a report from the browser of whoever framed the site is the only way to learn that something does. - **`POST /api/csp-report`** is the same-origin violation sink that `report-to` / `report-uri` point at (`report-to` additionally requires the `Reporting-Endpoints` response 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-report` from Firefox/Safari, `application/reports+json` from Chrome's Reporting API — handling one drops half the browsers), writes to the `csp` log 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/v1` next 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, nofollow` on `/api/v1/admin` and the admin SPA routes; `robots.txt` disallows `/admin`. - **No directory browsing** (express.static doesn't list; no `serve-index`). - **No hardcoded credentials**: first admin via `seed.js` reading `ADMIN_USERNAME`/`ADMIN_PASSWORD` from env (created only if no users exist); `.env` git-ignored, `.env.example` committed. - **`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`) with `credentials: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: a `csp violation` warn line with > `directive: frame-ancestors` means 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` (default `info`). - **File**: plain text appended to `LOG_DIR/LOG_FILE` (default `/logs/app.log`, `/app/logs/app.log` in Docker, bind-mounted to `./logs`); verbosity = `FILE_LOG_LEVEL` (default `debug`, so the file keeps a complete record while the console stays readable). Toggle with `LOG_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`/`uncaughtException` are caught and logged. ## 8. Deployment **docker-compose.yml** — two services on a private network: - `db`: `mariadb:11`, env `MARIADB_DATABASE/USER/PASSWORD/ROOT_PASSWORD`, volume `dbdata:/var/lib/mysql`, mounts `schema.sql` into `/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)`, volume `uploads:/app/uploads`, `ports: "3000:3000"` — **binds 0.0.0.0** (no `127.0.0.1:` prefix) so Pangolin reaches it. - `ntfy` (M7): pinned upstream `binwiederhier/ntfy` image, declarative config only (`./ntfy/server.yml` mounted `:ro` + `NTFY_BASE_URL`), volume `ntfydata:/var/lib/ntfy`, **publishes `:80` on 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 reason `app` publishes `3000`. 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 | ```