Files
docs/website/BACKEND_DESIGN.md
wtclaude 42867d7ef8 docs(modules): close Phase 4 — de-UO the backend reference, record the acceptance run
Phase 4 slice 4. BACKEND_DESIGN.md had never been de-UO'd: Phase 3 rewrote core's
code and README, but §5.2's identifier check reads code, not prose, so nothing
ever looked at the design document. It still described 27 shard_* tables, 20 UO
route rows and the shard visibility ladder as core's, a phase after core stopped
being able to serve any of them.

Moved, text unchanged:

  BACKEND_DESIGN §3   six shard_* schema sections (226 lines) → modules/uo/SCHEMA.md
  BACKEND_DESIGN §4   13 public + 7 admin UO route rows       → modules/uo/API.md
  BACKEND_DESIGN §6.5 the audience ladder (70 lines)          → modules/uo/API.md §4

Core keeps the seam and gains the eight /admin/modules routes it had never
documented. §6.5 becomes "Module-owned audience boundaries": core's security
boundary ends at authentication, roles and the session, and a module that serves
game data brings its own.

Also fixed on the way: users.router.js was still listed as 15 routes (it is 9 —
six went to the extension slot), and the push section still promised
config/shardStreams.js "moves out with it" four slices after it left.

The acceptance table now carries results. Criterion 2 was proved for real against
module-uo v0.3.0 on an empty database, which turned up the uninstall ordering
defect fixed in website#146.

AI disclosure: this contribution was AI-assisted (Claude Code).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 09:16:29 -05:00

83 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: core schema, seed, resolve MODULES, require app, module
                                schema fragments, module onBoot, 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
                 modules.router.js      (1)  /public/modules — the installed-module
                                             list a client feature-detects against.
                                             A real prefix and not a fifth singleton
                                             below, so the module loader's
                                             collision probe (which skips
                                             root-mounted layers) sees it
                 site.router.js         (4)  /settings /status /version /contact —
                                             the group-root singletons; declares no
                                             router-level middleware
                 public.controller.js
                 (/public/shard and /public/atlas are module-uo's — see
                  ../modules/uo/API.md)
        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
                 appeals.router.js      (4)  /player/appeals
                 appeals.controller.js
                 (/player/shard is module-uo's)
      settings/  index.js          owns the shared `noindex, requireAuth` gate
                                   (authenticated, ANY role) and the mount table.
                                   A fifth group, for site-wide settings that
                                   need a login but no particular role — /public
                                   is anonymous, /admin/settings is adminOnly
                                   while AdminLayout renders for editors and
                                   moderators, and /player is self-scoped data
                 nav.router.js          (1)  /settings/nav — the nav_admin and
                                             nav_player overrides, read by the
                                             layouts that render them
                 theme.router.js        (1)  /settings/theme/options — the closed
                                             sets the admin appearance form is
                                             built from. Static; no DB read
                 nav.controller.js + theme.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         (9)  /admin/users    — adminOnly. The six
                                              /users/:id/shard/* routes are a
                                              MODULE's, reached through the
                                              admin.users.detail extension slot
                 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)
                 modules.router.js       (8)  /admin/modules  — adminOnly, the
                                              module delivery surface: install
                                              from a manifest URL, enable,
                                              disable, uninstall, purge, restart
                                              and the source allowlist
                 email.router.js         (6)  /admin/email    — Gmail OAuth2
                                              delivery — adminOnly
                 discordBot.router.js    (2)  /admin/discord-bot — adminOnly
                 settings.router.js      (4)  /admin/settings — adminOnly. The
                                              DELETE /:key is "reset to default"
                                              and carries its own key allowlist
                                              (theming/nav keys + the hero draft)
                                              so it can never drop site_mode or
                                              a module's own seeded row; POST
                                              /brand-asset/:slot uploads a
                                              logo/hero/favicon and writes the
                                              brand_assets row in the same call
                 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)
                 (/admin/shard and /admin/uo-link are module-uo's)
    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, player_registration (default disabled), mobile_app_links_enabled, module_source_hosts.

module_source_hosts is the allowlist of hostnames a module may be installed from (MODULE_SYSTEM.md §2.7.2 decision 6), edited in Admin → Modules and audited as module.sources. It is bootstrapped from MODULE_SOURCE_HOSTS and not owned by it: seedDefault is an INSERT IGNORE, so the environment supplies a default on a fresh install and changing the variable later cannot reach back in and overwrite what an operator chose. Installs are https-only, every redirect hop is re-checked against this list, and an empty value forbids every install rather than allowing every host.

The MODULES environment variable (MODULE_SYSTEM.md §2.7.2 decision 4) installs through the same allowlist and the same verification, without a request: each <id>@<version>=<manifest URL> entry is resolved onto the modules volume during boot, between seedDefaults() and the require of app.js that scans it. It is not a settings row and is not editable from the panel — a deployment declares what it runs, the panel shows that it did, and neither owns the other: the variable decides what is on the volume and installed_modules.state decides whether a module answers.

Keys a MODULE seeds into this table. settings is core's, but a module's schema fragment may INSERT IGNORE its own rows into it, and module-uo seeds two: game_account_signup (default disabled) and the one-shot migration marker uo_link_protocol_3_migrated. Core seeded both until Phase 3 slice 4, which is worth knowing for one reason beyond tidiness — a fragment runs after core's schema is replayed in full, so a marker in core guarding a statement in a fragment fires before the statement reads it. That exact ordering silently disabled the protocol-3 migration between slices 1 and 4; see MODULE_SYSTEM.md §2.7.1.

Deliberately unseeded keys — the theming & navigation overrides (theme_visual, brand_assets, nav_public, nav_admin, nav_player). All five are JSON strings, and the absence of the row is the "use the default" state: colors/fonts/radii fall back to theme.css, assets to BRAND_*, navs to the hardcoded NAV arrays. No migration writes defaults into them, because a stored copy of a default would stop tracking the default. Resetting one is therefore a DELETE, not a write — see DELETABLE_KEYS in settings.model.js and THEMING_AND_NAV.md §2.

Values are TEXT, so a JSON-valued key arrives as a string and every consumer parses it. Server side that is utils/settingsJson.js (parseJsonSetting), client side client/src/lib/settingsJson.js and parseLayout; both treat a malformed or wrong-shaped value as absent rather than as an error, so a hand-edited row degrades to the default instead of rendering something broken.

The three nav_* rows are presentation, never authorization. An entry is keyed by an item's existing to and may carry only label, order, hidden and — admin nav only — group; utils/navOverrides.js rejects anything else on write, naming the key. It deliberately does not check that a to exists: the base NAV arrays are client constants, and duplicating them server-side would create a second source of truth for navigation that drifts the first time a route is added. client/src/lib/navOverrides.js drops an unknown to at merge time instead, which is also what makes deleting a route in code safe. The merge runs before the role and feature filters in SiteHeader.jsx / AdminLayout.jsx — a feature on a nav row is resolved by the module that registered the row (client/src/modules/featureGate.js), so no flag string carries a parsed prefix and core learns nothing about a game — and those filters remain the boundary: a stored hidden: false on a gated item shows nobody anything. hidden: false is accepted (the editor sends it mid-edit) but never stored, so hiding stays subtractive. hidden on /admin/navigation is dropped for nav_admin, because that screen is the only UI that can un-hide anything.

nav_public may also carry dropdown sections and admin-authored links, as { items, sections, links } — a bare map still reads as items, and a nav with no sections still stores one. A section has a label and a position and no route at all: it only opens, so it adds no reachable surface. A link is the one place a path may be named that the code does not declare, and is therefore the one place the path rule applies: same-origin only, no scheme and no protocol-relative //host. A link carries no gate of its own and needs none — the page behind it enforces its own access, so an added link advertises a route and never grants one. Coded entries stay in items, keyed by a route the base array must declare, which is what keeps "an override cannot introduce a route" structurally true. Sections and links are dropped for nav_admin / nav_player, whose layouts cannot render them.

theme_visual is resolved server-side, not shipped raw to the browser. utils/themeResolve.js layers :root ← preset ← custom, field by field, into the CSS custom properties getPublic() returns as theme; the SPA's only job is to write them onto <html> and take back what it wrote last time (client/src/lib/themeVars.js). One authority for the merge means the effective accent in brand.accent — the cross-repo contract the Android app and the Discord bot theme themselves from — always agrees with what the website paints. Values reaching a CSS variable are checked against closed sets on both paths: strictly on write (400, naming the field) and forgivingly on read (drop the bad field, keep its neighbours).

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 (modules/registries.js — core's plus every installed module's), 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.

The 27 shard tables — module-owned (module system)

shard_* and uo_link_config are not core's. They are created and dropped by module-uo's own schema fragment, and a core running without that module has none of them. Their shapes and the reasoning behind them live with the module: ../modules/uo/SCHEMA.md.

The prefixes are grandfathered (MODULE_API.md §6.5) — a new module prefixes its tables with its own id.

installed_modules — what is installed, and what happened to it (module system)

One row per installed module, keyed by the id from its module.json — the same id that names its directory on the modules volume and its URL segment.

Column Shape
id VARCHAR(32) PK — the module id
name, version the manifest's label and semver, for the admin Modules screen
state ENUM installed / enabled / disabled / started / startup_failed
failure_stage, failure_reason the stage a failure happened at (manifest, core_api, mounts, extensions, schema, require, register, boot) and its recorded reason
source, sha256 the release the bundle came from and the digest verified before unpacking; both NULL for a directory placed on the volume by hand. Written only by an admin-panel install, and COALESCEd on upsert — see below
installed_at, started_at, updated_at started_at is the last successful start

This table never decides which routes exist. The module loader scans the filesystem at require time, before the database is reachable, so the URL surface is a property of the volume — which is what lets routes.manifest.json be generated against a dead database. A disabled module stays mounted and is guarded; the row decides whether it answers, not whether it is there.

Every boot resets each non-disabled row to enabled and clears its recorded failure, then the load writes that boot's outcome. So a startup_failed module is retried on the next restart (an operator who fixes the cause needs no admin-panel visit), a running module can never display a stale reason, and disabled — the one operator decision rather than outcome — survives untouched. A re-install or upgrade refreshes the metadata and leaves state alone.

The write happens in one place, src/modules/lifecycle.js, on the boot path after ensureSchema() and before the listener binds: it resets the last boot's outcomes, writes a row for every module found on the volume (with NULL provenance for a hand-placed directory), marks any row whose directory is gone startup_failed, and then runs each surviving module's onBoot and records what happened. A disabled row is guarded, not booted, and never has its failure re-recorded — an outcome must not overwrite the operator's decision. Every one of those writes is individually caught: a row that will not update is worse reporting, never a failed boot.

Provenance is COALESCEd on upsert, and that is load-bearing. The boot write above passes NULL for source and sha256 — honestly, since a scan finds a directory and never where it came from — so a plain source = VALUES(source) overwrites both columns on every boot, and an admin-panel install's provenance survives only until the restart that install asks for. The statement is source = COALESCE(VALUES(source), source): a value overwrites, a NULL leaves what is there. The cost is that hand-placing a different bundle over a row installed from a URL keeps the old provenance, which is stale rather than blank. Found in Phase 4 by installing a module and restarting; it could not have been found earlier, because until then no caller had ever passed a non-null value.

Design of record: MODULE_SYSTEM.md §2.4; the loader's obligations are MODULE_API.md Part 4.


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 CORE serves. 166 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 — merged into /api/docs What each core route means. Parameters, bodies, response codes, security. npm run swagger, from #swagger.* annotations

Both are core's. An installed module's routes are in neither: they are in that module's own frozen manifest and its swagger-fragment.json, in its own repo, and core merges the fragment into /api/docs.json at request time (§4.0.1). So on a running instance the served document describes more than the committed one does, which is the intended arrangement rather than a drift — swagger-output.json has to regenerate identically on any machine, whatever happens to be installed on it.

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, /brand and /modules are filesystem-conditional static mounts — not API contract, and including them would make the output depend on whether CI had built the client, or on which modules happened to be on the volume of the machine that generated it.

/modules/<id>/ is the last of those and the newest: an installed module's prebuilt client chunk, served from the directory its client.entry sits in and never from the module root, behind the module's own state guard (503 when it failed to start, 404 when disabled) and with Cache-Control: no-cache, because Vite's library build emits an unhashed entry.js. Anything else under /modules is a 404 rather than the SPA shell. The full contract is MODULE_API.md §3.1.

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.

4.0.1 /api/docs.json is assembled per request

GET /api/docs.json and the Swagger UI at /api/docs do not serve swagger-output.json directly. swagger/docsSpec.js merges the swagger-fragment.json of every started module over it first, cached on the module loader's state version and rebuilt when a module's state moves.

It exists because swagger-autogen is static analysis: it parses src/app.js as text and follows the literal app.use(…) chain, which reaches neither an installed module (required by a filesystem loop, from a volume that had nothing on it when the image was built) nor an extension slot (whose router is created empty by declareSlot() and filled later). Slots are handled at generation time by swagger/slotSpecs.js and are therefore in the committed file; modules cannot be, because core never has their sources.

Three rules, all from MODULE_API.md §6.1a:

  • started only. A registered, disabled or startup_failed module's paths are absent — documenting a route that answers 503 or 404 sends a client somewhere it cannot go.
  • Core wins every key collision, in all three merged sections (paths, tags, components.schemas); the collision is logged and the module's version dropped. This is what makes the naming rule work: a module namespaces the schemas it defines (UoShardStatus) and references core's shared ones (Error, ValidationError) by core's name, and both resolve in the merged document.
  • A bad fragment costs that module its paths and nothing else. Missing, unreadable or not JSON is logged and skipped; /api/docs.json still answers with everything else.

The committed spec is never mutated — it is a require()d JSON module, so an in-place merge would be permanent for the life of the process and cumulative across rebuilds.

/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, appeals.router.js, mounted by player/index.js, plus whatever a module mounts here) 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). module-uo inherits the rule and relies on it: its /player/shard/* handlers are the identical self-scoped ones it also serves under /admin/shard/*, so 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 catalog is assembled at boot by modules/registries.js from core's own streams (config/coreStreams.js — just news.post) plus each installed module's. The seven shard streams and their event→stream mapping left with module-uo in Phase 3 and are registered by it; their ids are grandfathered to that module (MODULE_API.md §6.5) because they are stored in notification_subs and read by the shipped Android app. Security invariants:

  • Whether a stream is safe to publish is the registering module's decision, and it stays inside that module. module-uo applies the same public/admin split as its SSE feed — public streams are drawn only from its own allowlist, so a sensitive kind (audit/cheat/IP/login-attempt) can never produce a public push — and resolves personal streams (vendor.sale, house.idoc, account.login) to the owning user's devices through its own ownership check. Core never sees a shard event. utils/pushDispatch.js publishes to a stream id someone else resolved and knows nothing about what produced it, which is what lets a second game's module reuse the whole pipe.
  • 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 module-uo's /public/shard/stream without an Authorization header — a module mounting here inherits the same "no gate" and owns whatever gate it adds. 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, the derived registration flags (gameAccountSignup was one of these until the module extraction moved game-account policy to module-uo — it is on that module's GET /public/shard/features now, and the game_account_signup settings row is unchanged), 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); these are effective values, so an admin theme (theme_visual) beats BRAND_ACCENT_COLOR and an uploaded brand_assets asset beats its BRAND_* path — an optional theme block, the resolved CSS custom properties for that admin theme (absent when the instance was never themed, which is what makes it render from the shipped stylesheet unchanged) — 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 /modules { modules: [{ id, name, version, capabilities }] } — the modules this backend is currently serving, in scan order (module system, MODULE_API.md §2.9). A module that is disabled or failed to load is absent, not listed with a state: its routes and nav are absent too, so the client renders a site without that capability rather than advertising one that 503s. The recorded failure stage and reason are admin-panel detail and are never published here. capabilities are opaque strings the module declares — feature-detect against them and treat an unknown one as absent. Like /status and /version it is DB-free and not site-mode gated, so a client can still feature-detect during maintenance. It is not how a module's client chunk loads — htmlShell injects a <script type="module"> per started module.
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}
/shard/* · /atlas/* Served by module-uo, not by core (25 routes). Documented in ../modules/uo/API.md; absent entirely when the module is not installed, which is a 404 and not an error.

Public content GETs pass through the siteMode gate (§5).

/settings (settings/index.js → §2) — behind requireAuth + noindex, no role gate

Site-wide settings that need a login but no particular role. It exists because the other four groups each answer a different question: /public is anonymous, /admin/settings is adminOnly, and /player is data scoped to req.user.id. These rows are configuration that happens to need a login.

Method Path Purpose
GET /settings/nav { nav_admin, nav_player } — the stored nav overrides as raw JSON strings (or null), for the two authenticated layouts that render them. Deliberately not public: an anonymous visitor has no use for either, and the admin nav's labels describe the shape of the admin surface. Open to any role because AdminLayout renders for editors and moderators and PlayerPortalLayout for players, none of whom can read GET /admin/settings. Presentation-only — the role/feature filters in those layouts still decide what is shown, and an override can never un-hide a gated item (see THEMING_AND_NAV.md §7)
GET /settings/theme/options The closed sets an admin may pick from when theming the site: the presets (each with its full token map, so a form can show what an unset field currently resolves to), the curated Google Fonts shortlist per role, the shadow depths, the editable color/radius field names paired with the CSS variable each drives, and shippedTokens (what theme.css's :root declares). Static — derived from config/themePresets.js, no DB read. Served rather than duplicated in client code so the options the form offers can never drift from the ones PUT /admin/settings accepts

/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 — modules, email, discord-bot, settings, and PUT /site-mode — are adminOnly. There is no residual file: every admin route is declared in a capability router.

A module mounts into this group as a peer, at a prefix it claims and core has verified nothing else owns; the shared gate above applies to it, and any gate beyond that is the module's own. So the mixed-tier prefixes here are module-uo's /admin/shard and /admin/uo-link, documented in ../modules/uo/API.md — not core's, and absent from this table.

/admin/modules is adminOnly rather than staffOnly for the reason the endpoint exists: it installs code that will run inside the server process at the next boot. An editor or a moderator has no business doing that, and the group gate alone would let them.

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,...}. Enum-constrained keys are validated on the way in; theme_visual additionally has every value checked against the closed sets in config/themePresets.js (hex color, shortlisted font stack, bounded px radius, listed shadow) and is stored stringified, and brand_assets has every slot checked against utils/brandAssets.js — a same-origin path under /uploads/, /brand/ or /assets/, never an off-origin or protocol-relative URL, since these values are written straight into the page as an <img src> / <link rel=icon> / og:image. Cleared slots are dropped rather than stored as null. The three nav_* keys go through utils/navOverrides.js on the same path — shape only (label/order/hidden/group keyed by an app path), since whether a key names a route the nav declares is settled client-side at merge time; without this they would reach the store as "[object Object]" and read as absent for ever. A write to brand_assets or theme_visual invalidates the cached HTML shell (a nav write does not — nav is not in the shell). The read path drops bad fields anyway, so the 400 is about feedback — a save that appears to succeed and then does nothing is worse than a rejection
DELETE /settings/:key reset one setting to its default by deleting the row. Allowlisted to the keys whose default lives outside the store (theme_visual, brand_assets, nav_public, nav_admin, nav_player, hero_layout_draft) — anything else is 400. Idempotent: resetting a key that was never set succeeds
POST /settings/brand-asset/:slot upload one brand asset (logo · hero · favicon) and point brand_assets at it, in one call → { url, brand_assets }. One call rather than "upload, then PUT" so a half-completed save never leaves an unreferenced file in /uploads. Uses the shared imageUpload.js multer config — the mimetype allowlist is never widened, only tightened per slot: favicons are PNG only (§4.10 of THEMING_AND_NAV.md) and capped at 512 KB, logos at 1 MB, heroes at the shared 8 MB. A refused file is unlinked before the response. Merges into the existing overrides, so uploading a logo never clears a hero. adminOnly — tighter than the generic POST /admin/uploads, which editors may reach
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)
GET /modules installed modules reconciled across all four sources of truth — the installed_modules row, the live loader record, the modules volume, and the MODULES declaration — plus the install-source allowlist. They are allowed to disagree, and the screen renders the disagreement rather than picking one (module system, MODULE_SYSTEM.md §2.4)
POST /modules install or upgrade from a release install-manifest URL: allowlisted https host, declared sha256, whole-archive inspection, unpack into a scratch dir, move into place last. Takes effect at the next restart. adminOnly, rate-limited, audit-logged — this endpoint installs code that will run in the server process
PUT /modules/sources replace the host allowlist. Seeded from MODULE_SOURCE_HOSTS on a fresh install and DB-owned from then on, so changing the variable never overwrites an operator's choice. An empty list forbids every install, never permits all
POST /modules/restart graceful shutdown so module changes take effect; the supervisor brings the process back (restart: unless-stopped on the shipped compose). Emits SIGTERM as an event rather than signalling the pid — process.kill is unconditional termination on Windows
POST /modules/:id/enable move the row to enabled. Deliberately does not touch the loader: there is no onBoot re-dispatch, so the screen asks for a restart
POST /modules/:id/disable the one module action that takes effect immediately — dispatches that module's onShutdown, then its routes, nav and client chunk answer 404. A real kill switch, not a visibility flag
POST /modules/:id/purge run a disabled module's purge.sql, dropping its tables and data. 409 while it is still running; 400 if it ships no purge.sql
DELETE /modules/:id[?purge=true] uninstall: stop, then (with purge=true) drop its data, then delete its directory. Non-destructive by default — the row stays disabled and the data is left for a reinstall to pick up. The purge option lives here because it cannot live after: purge.sql is a file inside the directory being deleted
/shard/* · /uo-link/* Served by module-uo, not by core (33 routes). Documented in ../modules/uo/API.md

Every admin write logs to activity_log.

The SPA HTML shell (app.jsutils/htmlShell.js)

The SPA catch-all serves client/dist/index.html with this instance's branding templated into the <head> — title, meta description, Open Graph / Twitter tags, <link rel="icon"> — so one prebuilt image serves per-instance metadata to a crawler that never runs the JavaScript.

That used to be a single render at module load, from BRAND_* env only. It cannot be, now that the favicon and OG image can come from the admin's brand_assets row: the shell depends on state that changes while the process runs. utils/htmlShell.js owns the lifecycle, and three properties are deliberate:

  • A cached string in the steady state. The shell is rendered lazily on first request and reused; a settings read per page view would put the database on the critical path of every SPA route, including during an outage where the API is already degraded. Concurrent first requests share one render.
  • A DB fault never fails the page. A failed read renders the env-only shell — exactly the pre-feature behavior — and that result is cached like any other, so an outage does not become a failing query per page view.
  • Byte-identical with no rows. An instance that has never been themed and has uploaded nothing gets the same bytes it got before the feature existed. Locked by test/htmlShell.test.js, which keeps a verbatim copy of the old renderer as its reference.

Invalidation is explicit — the settings controller calls htmlShell.invalidate() after a successful write to brand_assets or theme_visual — with a 5-minute TTL as a safety net, because the cache is per process: in a scaled deployment the worker that handled the write is the only one that learns of it, and without the TTL every other worker would serve the old favicon until the next restart.

The shell also carries the resolved theme as a <style id="theme-boot">:root{…}</style> block, last in <head> so it follows the built stylesheet and wins the equal-specificity tie. It exists only to stop a themed instance painting the shipped palette for one frame; SiteContext removes it once the /public/settings payload has arrived and applied — gated on a successful fetch, since dropping it after a failed one would strip a themed instance back to the shipped colors. Token names and values are re-checked against conservative patterns on the way into the block: everything there comes from a closed set already, and this keeps that a property of the HTML writer rather than of a validator three modules away.


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, /public/contact, and the account-change routes. Every core public read is an indexed lookup of bounded size, so none is limited. A module gets the same factory through ctx.middleware.rateLimit and is expected to use it on any read that is expensive to serve — module-uo limits its marketplace search (60/min/IP), the one public GET in the system that costs real money to answer.
  • 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.

6.5 Module-owned audience boundaries

Core's security boundaries end at authentication, roles and the session. A module that serves game data brings its own audience rules, and core does not police them beyond the gates it hands over (requireAuth, requireRole, the tier group gates).

module-uo's is the worked example, and it is a real boundary rather than a convenience filter: an admin-configurable, per-feature and per-field audience ladder with fail-closed defaults, applied at routes, at SSE subscribe time and at the nav. It used to be documented here as core's; it moved to ../modules/uo/API.md §4 when Phase 4 closed, with the admin-facing guide still at SHARD_VISIBILITY.md.


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