Files
docs/website/BACKEND_DESIGN.md
wtclaude e4bec0caba docs(website): record trusted-device support on the SSO login paths
Doc side of website + Android-app feat/sso-trusted-device.

TRUSTED_DEVICES_MFA.md §6 gains an "SSO login paths" subsection: SSO is not
exempt from the second factor, and a trusted device skips it exactly as on the
password path (previously SSO consulted trust nowhere, so an external-identity
user was asked for a code on every sign-in). Documents the callback-side skip,
the new trustDevice/deviceName on POST /auth/sso/totp, and why recovery codes
stay password-login only.

Also writes down how this reaches the Android app, since it is not obvious: the
app's SSO runs in a Custom Tab that shares the system browser's cookie jar, so
the rg_trust cookie covers native SSO with no app change and no trust token in a
start URL (which would leak a secret into query strings and logs). The app's own
token is minted at /auth/mobile/sso/exchange instead — an authenticated
app→server call — so it never travels in the deep link, and the bridge row holds
only a boolean. Notes that one tick yields two independently-revocable rows.

§4 documents the new mobile_auth_sessions.trust_device column; BACKEND_DESIGN.md
gets the same column in its bridge table, the trust note on the /exchange row,
and a pointer from the bridge intro to the Custom Tab cookie model.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 01:01:49 -05:00

56 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 (+).

Complete. The monolithic route files (admin.routes.js especially, originally 1552 lines / 110 routes) have been split into one router file per business capability — in place, with every URL unchanged. See API_V2_PLAN.md § Phase 2.

users, account, invites, auth/providers (PR 1, 28 routes), moderation, bot-activity, activity (PR 2, 18 routes), posts, uploads, wiki, pages (PR 3, 31 routes) and shard, uo-link, email, discord-bot, settings, dashboard/site-mode (PR 4, 33 routes) each live in their own router under admin/, behind admin/index.js. PR 5 did the same for public/ (24), player/ (20) and the residual auth/ (10). admin.routes.js, public.routes.js, player.routes.js and auth.routes.js are all deleted; each group is now a directory whose index.js owns the group gate and the mount table and declares no routes of its own.

"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. 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 /player
        auth/    index.js          mounts the routers below; no group gate — /auth
                                   is where an anonymous caller becomes
                                   authenticated, so the authenticated parts gate
                                   themselves. Mount order is load-bearing (see
                                   session.router.js)
                 login.router.js        (2)  /auth/login + /login/totp — shared
                                             loginGuards stack
                 register.router.js     (1)  /auth/register — honours the
                                             player_registration setting
                 invite.router.js       (2)  /auth/invite/:token[/accept] — the
                                             token is its own authority, so it
                                             bypasses player_registration
                 password.router.js     (3)  /auth/password/forgot + reset/:token
                 session.router.js      (2)  POST /logout and GET /me — the two
                                             singletons owning no path segment, so
                                             mounted at the group root, LAST: the
                                             /me sub-routers below also match the
                                             bare /me and supply its noindex header
                 me.routes.js          (23)  /auth/me/account*, sessions, trusted
                                             devices — router-level requireAuth
                 notifications.routes.js (3) /auth/me/devices*, notifications/*
                 mobile.routes.js +          /auth/mobile/* — native bearer login
                 mobileSso.routes.js    (5)
                 sso.routes.js          (4)  mounted PATHLESS: owns two prefixes,
                                             /auth/providers and /auth/sso/*
                 loginGuards.js              shared backoff/slow/limiter stack for
                                             every credential-guessing surface
                                             (not a router)
                 auth.controller.js + invite/passwordReset/sso/mobile controllers
        public/  index.js          mounts the routers below; **no group gate** —
                                   this surface is anonymous by design (SPA
                                   logged-out, Discord bot, Android ShardStream)
                 posts.router.js        (2)  /public/posts/:category[/:idOrSlug]
                 wiki.router.js         (4)  /public/wiki — /categories and /tags
                                             MUST precede /:slug
                 pages.router.js        (2)  /public/pages — the draft-preview
                                             route precedes /:slug and is
                                             deliberately not site-mode gated
                 shard.router.js       (12)  /public/shard/* incl. the anonymous
                                             SSE stream; never site-mode gated
                 site.router.js         (4)  /settings /status /version /contact —
                                             the group-root singletons; declares no
                                             router-level middleware
                 public.controller.js + shard.controller.js
        player/  index.js          owns the shared `noindex, requireAuth` gate
                                   (authenticated, ANY role — staff are a superset
                                   of players) and the mount table
                 account.router.js      (8)  /player/account — credentials, TOTP,
                                             linked identities; handlers shared
                                             with /admin/account and /auth/me
                 shard.router.js        (8)  /player/shard — linking + own roster,
                                             vendors, chars, sales, houses
                 appeals.router.js      (4)  /player/appeals
                 shard.controller.js + appeals.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
                 moderation.router.js   (15)  /admin/moderation    — modAccess
                                              (admin+moderator) at router level
                 botActivity.router.js   (2)  /admin/bot-activity  — adminOnly
                 activity.router.js      (1)  /admin/activity      — staff-wide
                                              audit log, no extra gate
                 posts.router.js         (9)  /admin/posts    — editor tier, no
                                              gate beyond staffOnly
                 uploads.router.js       (1)  /admin/uploads  — rich-text editor
                                              image upload
                 wiki.router.js         (14)  /admin/wiki     — pages, revisions,
                                              categories, tags
                 pages.router.js         (7)  /admin/pages    — CMS page builder
                 imageUpload.js               shared multer config for the two
                                              upload routes above (not a router)
                 shard.router.js        (16)  /admin/shard    — 7 self-service
                                              account-linking routes (no extra
                                              gate, handlers shared with
                                              /player/shard) + 9 in-game staff
                                              ops on modAccess, per route
                 uoLink.router.js        (5)  /admin/uo-link  — sidecar config,
                                              town crier, admin SSE — adminOnly
                 email.router.js         (6)  /admin/email    — Gmail OAuth2
                                              delivery — adminOnly
                 discordBot.router.js    (2)  /admin/discord-bot — adminOnly
                 settings.router.js      (2)  /admin/settings — adminOnly
                 dashboard.router.js     (2)  GET /dashboard (staff-wide) and
                                              PUT /site-mode (adminOnly) — the
                                              two singletons owning no path
                                              segment, so mounted at the group
                                              root; declares no router-level
                                              middleware, which is what makes a
                                              root mount safe
                 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
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' pendingcompleted 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
trust_device TINYINT(1) NOT NULL DEFAULT 0 user ticked "trust this device" on the Custom Tab TOTP form. A boolean only — it tells /exchange to mint the app's own trust token; the token never rests here (only its sha256 reaches trusted_devices)
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/index.js → the capability routers in §2)

No group gate — /auth is where an anonymous caller becomes authenticated. The authenticated parts gate themselves: me.routes.js and notifications.routes.js each apply noindex, requireAuth at their own router level, and /sso/:provider/link carries requireAuth per route.

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 group (account.router.js, shard.router.js, appeals.router.js, mounted by player/index.js) sits behind the shared noindex, requireAuth gate 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.

The TOTP gate it reuses includes the trusted-device skip (see TRUSTED_DEVICES_MFA.md §6). Because the app opens this flow in a Custom Tab, which shares the system browser's cookie jar, the rg_trust cookie set on the TOTP form is presented back on the next app sign-in — so "don't ask me again" works for native SSO without the app injecting a header into a tab it does not control, and without a trust token ever appearing in a start URL.

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}. When the session carries trust_device, also mint a platform:'mobile' trusted device and add trustToken — minted here, on an authenticated app→server call, so it never travels in the deep link. Best-effort: at the trusted-device cap the response simply omits it rather than failing the sign-in
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://<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/index.js → the capability routers in §2) — all GET except /contact, no auth

No group gate, deliberately. This surface is anonymous by design: the SPA renders it logged-out, the Discord bot reads it with no credentials, and the Android ShardStreamClient consumes /public/shard/stream without an Authorization header. Content visibility during maintenance comes from the per-route siteMode middleware (§5), never from an auth gate.

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, auth/providers and bot-activity add adminOnly on top, and moderation adds modAccess (admin + moderator, so editors are excluded). The content capabilities — posts, uploads, wiki, pages — add nothing: managing content is the editor tier's job, so staffOnly is the whole gate. The ops/config capabilities — uo-link, email, discord-bot, settings, and PUT /site-mode — are adminOnly; shard is the one mixed prefix, where self-service account linking carries no extra gate and the in-game staff operations carry modAccess. There is no residual file: every admin route is declared in a capability router.

GET /dashboard and PUT /site-mode are the one place where a single screen spans two tiers: the dashboard is staff-wide, but the site-mode toggle on it is adminOnly. The client must therefore gate that control on its own (Dashboard.jsx renders it only for role === 'admin') rather than relying on the route gate that admitted them to the page — the same rule the sidebar follows, so a non-admin is never shown a control that would 403. The URLs below are unaffected by which file a route 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=autosecure: 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 <form action="https://evil"> 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 <server>/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