Amends §7.2 inline and marks phase 8 done in Part 12; adds the team_integration_config row to BACKEND_DESIGN.md's schema table. Two of the amendments are things the tree disproved rather than choices: - §7.2's DDL cannot hold its own default row. MariaDB coerces PRIMARY KEY columns to NOT NULL, so `team_id NULL` is unrepresentable and the override mechanism has no base case. Confirmed against a real MariaDB (error 1048). - §7.2's visibility gate has no data source on either side and cannot have one: the streams carry no visibility, a forum thread is members-only by construction rather than by a column, and core cannot see a channel's permissions. The gate becomes an attributed operator acknowledgement. Co-Authored-By: Claude <noreply@anthropic.com>
1258 lines
102 KiB
Markdown
1258 lines
102 KiB
Markdown
# 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](./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](./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](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 | |
|
||
|
||
### password_resets — self-service reset links
|
||
| col | type | notes |
|
||
|---|---|---|
|
||
| id | INT PK AUTO_INCREMENT | |
|
||
| token_hash | CHAR(64) UNIQUE NOT NULL | sha256 hex of the opaque token; **plaintext never stored** |
|
||
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | the account this reset targets |
|
||
| status | ENUM('pending','used') DEFAULT 'pending' | single-use (atomic `markUsed`) |
|
||
| requested_ip | VARCHAR(64) NULL | who asked (audit only) |
|
||
| expires_at | DATETIME NOT NULL | ~1h TTL, enforced in the model on top of this |
|
||
| created_at / used_at | DATETIME | |
|
||
|
||
Same "store only the hash of an opaque token" pattern as `user_invites` / `mobile_refresh_tokens`.
|
||
A DB read never yields a usable reset link. See §4 `/auth/password/*`.
|
||
|
||
### push_devices — opt-in push endpoints (M7)
|
||
| col | type | notes |
|
||
|---|---|---|
|
||
| id | INT PK AUTO_INCREMENT | |
|
||
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | owner |
|
||
| transport | ENUM('unifiedpush','fcm') DEFAULT 'unifiedpush' | UnifiedPush for the sideloaded APK; FCM reserved for a later Play flavor |
|
||
| endpoint | VARCHAR(512) NOT NULL | the distributor URL the app's ntfy topic was handed (or an FCM token). Unguessable but **not a secret** — stored in the clear (unlike refresh tokens), because pushes are content-free tickles |
|
||
| platform | VARCHAR(40) NULL | free-form label, e.g. `android` |
|
||
| created_at / last_seen_at | DATETIME | |
|
||
|
||
`UNIQUE(user_id, endpoint)` — re-registering the same endpoint is an idempotent upsert.
|
||
|
||
### notification_subscriptions — which streams a user opted into (M7)
|
||
| col | type | notes |
|
||
|---|---|---|
|
||
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
|
||
| stream_id | VARCHAR(64) NOT NULL | an id from the catalog (`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' | `pending`→`completed` when the code is minted; `consumed` after a successful exchange |
|
||
| user_id | INT NULL FK→users(id) ON DELETE CASCADE | set once SSO resolves the account |
|
||
| 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.compare`s 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`](../modules/uo/SCHEMA.md).
|
||
|
||
The prefixes are grandfathered ([`MODULE_API.md`](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 `COALESCE`d 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 `COALESCE`d 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`](MODULE_SYSTEM.md) §2.4; the loader's obligations are
|
||
[`MODULE_API.md`](MODULE_API.md) Part 4.
|
||
|
||
### The eleven Team tables — core's, populated by a module (Teams phases 2–5)
|
||
|
||
*Twelve rows in the table below: `content_reports` is listed here because Team forum content is its
|
||
first consumer, and it is deliberately **not** one of the eleven — it carries no `team_*` prefix, its
|
||
`target_type` is an open VARCHAR, and a wiki page or a news comment is meant to become a value in it
|
||
rather than a table of its own.*
|
||
|
||
A Team is a **core** entity that a **module** answers for. The module says what Teams exist and who is
|
||
in them, through the team provider; core stores that answer, gates it and displays it. Every table
|
||
here is core-internal — a module must never read or write one, even though a module is what fills
|
||
them — and none carries a `<moduleId>_` prefix, correctly: that rule binds modules, and these are
|
||
core's.
|
||
|
||
| Table | What it holds |
|
||
|---|---|
|
||
| `teams` | the Team itself. `external_id` is the module's own stable id, opaque to core; `name` is **immutable** for the life of the row; `slug` is derived once at create and frozen with it |
|
||
| `team_members` | the membership **projection**. Module-authoritative, and the sync is its only writer. Rows are soft-departed rather than deleted so history and rejoins survive |
|
||
| `team_sync_state` | one row per module: last attempt, last success, consecutive failures, last error, and the empty-answer quarantine |
|
||
| `team_leader_overrides` | a staff decision about leadership, applied **on top of** the synced value at read time and never written into the projection |
|
||
| `team_forum_grants` | the append-only forum grant/revoke ledger, which is also the current state. Created in phase 2 so the access resolver is written once; the grant flow is phase 4's |
|
||
| `team_moderation_requests` | the approval queue for the three actions that publish untrusted game-sourced strings |
|
||
| `team_activity` | the per-Team feed (phase 3). **Two writers, one table:** core writes its own membership and rename items with `source='core'`, and a module pushes game items through `ctx.teams.activity.push`. `summary` is already-rendered text and core never composes one; `kind` and `payload` are opaque to core |
|
||
|
||
| `team_forum_threads` | forum threads (phase 4). The FULL schema lands with announcements, including the `type`, `pinned` and `locked` columns only discussion uses — phase 5 opens paths rather than migrating data |
|
||
| `team_forum_posts` | post bodies, sanitised on write through the forum's **own** profile (`utils/forumHtml.js`) and served without re-sanitising. No stored body ever contains an `<img>` |
|
||
| `team_forum_moderation` | append-only, per Team, recording `actor_role` — WHICH authority was exercised. Deliberately not merged with `mod_actions`/`appeals`, which is Discord-sanction-shaped |
|
||
| `team_forum_uploads` | attribution for `uploads` mode: who uploaded what, when, how big, and to which post. Also the sweep's worklist |
|
||
| `team_notification_prefs` | per-Team notification preference (phase 6). **Opt-out for push, opt-IN for email** — `muted` defaults 0 and `email_mode` defaults `'off'`, so the two sinks default opposite ways and the asymmetry lives here rather than in a condition anyone has to remember. Team scoping lives in this table and in the recipient computation, never in a stream id. `last_digest_at` is the digest's only state and the worker is its only writer |
|
||
| `team_integration_config` | where a Team's notifications go on another platform (phase 8). One row per (platform, Team) plus a **deployment-wide default** whose `team_id` is NULL — expressed with a generated `team_key AS IFNULL(team_id, 0)` in the unique key, because a NULL cannot live in a primary key and the default row is the base case of the whole override mechanism. `members_ack` is a **precondition, not a preference**: forum posts and announcements are members-only always, core cannot see a channel's permissions, so enabling one requires an attributed operator acknowledgement that the destination is restricted — and changing the channel clears it |
|
||
| `content_reports` | member-raised abuse reports (phase 5). **Not a `team_*` table and not named for the forum** — `target_type` is a plain VARCHAR so a wiki page or a news comment becomes a value rather than a table. Team forum content is only the first consumer |
|
||
|
||
**Core had no user-facing report flow of any kind before `content_reports`.** `moderation`,
|
||
`mod_notes` and `appeals` are all either staff-initiated or Discord-sanction-shaped; nothing anywhere
|
||
let a *member* say "this is a problem". That was survivable while every piece of content on the site
|
||
came from staff, and stops being the moment a Team forum lets players write to each other. Four
|
||
properties are worth carrying:
|
||
|
||
- **Reports reach site staff and nobody else.** A Team's leaders moderate their own forum, so a
|
||
leader-visible queue would route a complaint *about* a leader back to that leader. There is one
|
||
queue, mounted at `/admin/moderation/reports` beside appeals — a staffer working a queue should have
|
||
one place to work — and no leader-facing counterpart anywhere
|
||
([`TEAMS.md`](TEAMS.md) §5.6, org lead 2026-08-18).
|
||
- **A report is not a moderation action.** Filing one changes nothing about the content; it opens a
|
||
queue item. That keeps it clear of `team_forum_moderation`, which records things that actually
|
||
happened, and stops "report" becoming a way for any participant to hide anything.
|
||
- **One OPEN report per (target, reporter)**, enforced by a unique key over a generated `open_marker`
|
||
that is `1` while open and `NULL` once closed — the same encoding as
|
||
`team_forum_grants.active_marker`, and for the same reason: only the *live* rows may collide. A
|
||
closed report frees the slot, so a member whose first report was dismissed may raise the same target
|
||
again if the behaviour recurs.
|
||
- **Every transition writes `activity_log`, `dismissed` included.** A queue where acting is audited and
|
||
declining to act is not is one where the cheapest way to make a report vanish leaves no trace.
|
||
|
||
**`teams_forum_edit_window_minutes`** (0–1440, default 15) bounds how long an author may edit their own
|
||
post; staff are not bound by it. It is resolved on the server **twice** — the read path stamps each
|
||
post with `canEdit`/`editableUntil` so a client knows whether to draw the control, and the write
|
||
re-derives it from `created_at` before allowing anything. The read is advice, the write is enforcement,
|
||
and the split exists because a time-bounded permission must not take its clock from the party it
|
||
bounds. It is deliberately **not** in `settings.getPublic()`: the client that needs the number is the
|
||
admin screen, and the client that needs the decision already has it per post.
|
||
|
||
**The forum's tables are guarded at the ROUTE and never at the data.** `teams_forums_enabled` off
|
||
means every forum route answers **404** — not 403, which would advertise a feature the operator
|
||
deliberately turned off — while threads, posts, grants and notification preferences are all untouched.
|
||
Re-enabling restores the forum exactly as it was. That is the same principle as the module disabled
|
||
guard ([`MODULE_API.md`](MODULE_API.md) §4.5).
|
||
|
||
**The author never writes an `<img>` tag, and that is what makes the image policy enforceable.** The
|
||
shared sanitiser (`utils/sanitizeHtml.js`) allows `<img>` from any host — it is tuned for the admin
|
||
editor, where the author is trusted — so the forum derives its own profile in which `img` is never
|
||
allowed in any mode. An author writes a URL; core's renderer decides at READ time whether it becomes a
|
||
picture, under `teams_forum_images` (`disabled` | `remote` | `uploads`). Three properties follow: the
|
||
policy cannot be evaded, since the only code that can emit an `<img>` is core's; flipping it back to
|
||
`disabled` un-renders every image on every existing post with **no data migration**; and there is no
|
||
author-supplied `srcset`, `onerror` or `style` to smuggle anything through. `https:` only, on an
|
||
extension allowlist, with `referrerpolicy="no-referrer"` and `loading="lazy"` — and **the server never
|
||
fetches a user-supplied URL**, which would be an SSRF vector; the browser does.
|
||
|
||
**`uploads` mode assumes a hostile uploader**, which the admin upload path never had to. Beyond that
|
||
path's 8 MB cap, mimetype allowlist and random filename it adds: magic-byte sniffing (a client's
|
||
`Content-Type` is a claim, not a fact), a rolling per-account byte quota, an attribution row per file,
|
||
and a nightly sweep that removes soft-deleted files past retention plus never-referenced orphans. The
|
||
sweep runs regardless of the current mode — an operator who turns uploads off still has the files.
|
||
|
||
**Selecting `uploads` requires a recorded acknowledgement.** `PUT teams_forum_images = 'uploads'` is
|
||
rejected **400** unless the same request carries `acknowledge: <version>`; the admin checkbox is how
|
||
the gate is presented, never the gate. The accepted TEXT VERSION is stored in
|
||
`teams_forum_uploads_ack`, whose `updated_by`/`updated_at` answer who and when, plus an `activity_log`
|
||
row. If the wording is ever revised the stored version goes stale — uploads **keep working**, a
|
||
persistent banner requires re-acknowledgement, and no other forum setting may be saved until it is
|
||
given. `teams_forums_enabled` and `teams_forum_images` are published in `settings.getPublic()`; the
|
||
acknowledgement is not.
|
||
|
||
**`team_activity` is bounded on purpose.** A feed fed by a game loop is the obvious unbounded-growth
|
||
failure, so retention ships with the feed rather than after someone notices: a nightly worker applies
|
||
an age horizon (`team_activity_retain_days`, default 90) **and** a per-Team row cap
|
||
(`team_activity_row_cap`, default 2000). Both, because either alone has a hole — age lets one busy
|
||
guild write a million rows inside the window, and a cap keeps a dead Team's feed forever.
|
||
|
||
`dedupe_key` is optional and unique per Team, written with `INSERT IGNORE` — the same idempotence
|
||
trick `shard_events` uses, and what makes a sidecar reconnect backfill safe to replay. Core
|
||
deliberately emits **no join items for a Team's first roster** (`roster_synced_at IS NULL`): importing
|
||
a 155-member guild is one Team arriving, not 155 people joining.
|
||
|
||
**A rename is an archive plus a create**, never an edit. Core's identity is (`module_id`,
|
||
`external_id`, `name`) taken together: a known id under a new name archives the old row
|
||
(`archived_reason='renamed'`, `succeeded_by` pointing at the successor) and creates a new one, so the
|
||
old Team keeps its activity, its grants and its forum as a read-only record and its old slug still
|
||
resolves. Whether two names are "really" the same guild is the module's judgement, expressed in
|
||
whether it reuses the external id.
|
||
|
||
**Uniqueness among ACTIVE rows only** is expressed with STORED generated columns, because MariaDB has
|
||
no partial index and NULL never collides in a UNIQUE key: `active_key` and `active_slug` on `teams`
|
||
are NULL for archived rows, so any number of them may share an `external_id`.
|
||
|
||
**`team_forum_grants` departs from the obvious encoding, and the reason matters.** Its marker is
|
||
`active_marker AS (IF(revoked_at IS NULL, 1, NULL))` with `user_id` in the KEY rather than the
|
||
generated column, because MariaDB refuses `ON DELETE SET NULL` on a foreign key whose column is a base
|
||
column of a stored generated column (error 1901) — and `SET NULL` is required here: `CASCADE` would
|
||
delete the audit trail of who granted whom, which is exactly what an audit exists to survive. The
|
||
semantics are identical: at most one active grant per (team, user), unlimited revoked rows.
|
||
|
||
**Account deletion is settled per column, not inherited from the defaults.** Content and audit
|
||
survive; preferences and links do not. `team_members.user_id` and every actor column on the grant
|
||
ledger and the approval queue go `SET NULL` with a **username snapshot** alongside, so the record
|
||
stays readable after the account is gone. Only `team_id` cascades.
|
||
|
||
**Two columns exist that the design of record did not contemplate**, both on `teams` and both
|
||
serving the refusal gates below: `roster_synced_at`, because `team_sync_state` holds one row per
|
||
*module* and a single Team's roster can be left untouched while the others sync — without a per-Team
|
||
stamp that Team's page would report the module's last success as its own; and `members_empty_since`,
|
||
the per-Team twin of `pending_empty_since`.
|
||
|
||
Design of record: [`TEAMS.md`](TEAMS.md) Parts 2 and 5. The contract surface a module sees is
|
||
[`MODULE_API.md`](MODULE_API.md); everything in these tables is explicitly *not* it.
|
||
|
||
---
|
||
|
||
## 4. API contract
|
||
|
||
Base path `/api/v1`. JSON in/out. Auth via httpOnly cookie (`isLoggedIn` reads it; also
|
||
accepts `Authorization: Bearer` for API testing).
|
||
|
||
### 4.0 The authoritative route list
|
||
|
||
The prose tables below are **orientation for a human reader** and can drift. Two generated artifacts
|
||
are authoritative, and they answer different questions:
|
||
|
||
| Artifact | Source of truth for | Generated by |
|
||
|---|---|---|
|
||
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs CORE serves.** Every core URL — the public app plus 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`](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`](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) |
|
||
| GET · PUT | `/me/notifications/teams` | cookie / bearer | `{teams:[{teamId,muted,emailMode}]}` on PUT | get / replace own **per-Team** preferences (phase 6, [`TEAMS.md`](TEAMS.md) §6.3). One entry per Team the caller could be notified about — active membership or an active forum grant — plus any Team they already hold a preference for; server-side defaults applied. An entry naming a Team the caller has no access to is **dropped, not refused**: a Team left between loading the screen and saving it is a race, not a client bug. The array is required even when empty (`../android/PLAN.md` §11) |
|
||
|
||
**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.
|
||
|
||
`teams.router.js` joins the group in Teams phase 2, and relies on exactly that rule: a moderator is in
|
||
guilds too, and gating this group on the role would 403 them off their own Teams.
|
||
|
||
| Method | Path | Notes |
|
||
|---|---|---|
|
||
| GET | `/teams` | the caller's Teams, each carrying the **reason** it is listed: `membership` \| `grant` \| `both`. Membership and forum access are separate authority paths and the reason is what keeps them distinguishable — `both` is a real state, and a Team **hidden** from public surfaces is still listed here, because suppression is a public-surface rule and a member is not a member of the public |
|
||
| GET | `/teams/:slug/access` | the caller's own resolved access on one Team: `allowed`, `viaMembership`, `viaGrant` (kept even when membership also holds, so the grant survives as audit history) and `isLeader` with any staff override applied |
|
||
|
||
**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`](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}` |
|
||
| GET | `/teams/by-external/:moduleId/:externalId` | one Team named the way the OWNING MODULE names it. Exists so a module's page can find core's Team without holding core's identifiers, which are core-internal. The module id is matched rather than trusted: an external id is unique only within a module |
|
||
| GET | `/teams` | active, publicly visible Teams, paged. Every payload carries `{ configured, stale, lastSyncAt }` so a page can say how recently the projection was confirmed rather than presenting a stale roster as current, plus `enabled` — whether this deployment has Teams at all |
|
||
| GET | `/teams/:slug` | one Team. An **archived** Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A **hidden** Team returns 404, indistinguishable from one that does not exist: "absent from every public surface" includes not confirming it is there. Carries `id`/`externalId`/`moduleId` — this route only, since the index has no use for them |
|
||
| GET | `/teams/:slug/members` | the roster. In-game display names only — the member key is a game-internal identifier and the user id names a site account, and **neither is published**; `linked` answers whether a character has an account behind it without saying which. **Which rows** appear is the module's audience projection (`projectRoster`), applied per caller: a module that has a rung system and cannot be asked yields an EMPTY roster, not an unprojected one, flagged as `projectionUnavailable`. A session is optional and may widen the result |
|
||
| GET | `/teams/:slug/activity` | the Team's activity feed, paged, newest first. `public` items to anyone who can see the Team; `members` items additionally to members and forum-granted users, resolved from the session and never from a parameter. `scope` reports which the caller got, so a client can say "some entries are hidden" instead of presenting a filtered feed as the whole one. A hidden Team's feed does not answer the public but does answer its members |
|
||
| POST · GET | `/teams/unsubscribe/:token` | one-click unsubscribe from a Team's notification emails (phase 6, [`TEAMS.md`](TEAMS.md) §6.4). **The only write in this tier and the only route with no `siteMode`** — the reader is in their mail client, not signed in, and the mail went out before the site went into maintenance. The token is a stateless HMAC whose whole capability is "set `muted` for one (user, Team) pair". POST acts and **always answers 200**, valid token or forged: distinguishing them would be an oracle for which (user, Team) pairs exist. GET acts on nothing and redirects to the site's own `/unsubscribe/:token` page, because a mail client's link scanner must not be able to mute Teams |
|
||
| — | `/shard/*` · `/atlas/*` | **Served by `module-uo`, not by core** (25 routes). Documented in [`../modules/uo/API.md`](../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](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`](../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](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`](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 |
|
||
| GET | `/teams` | every Team incl. hidden ones, plus the module's **sync state verbatim** — last attempt, last success, consecutive failures, the last error and any held empty answer. Verbatim because an operator debugging a stale projection needs what the provider actually said |
|
||
| GET | `/teams/:id` | one Team with its roster (departed members included), its grant ledger and its pending requests. Each roster row carries the **resolved** leadership and `isLeaderSynced` — what the game actually said — so an override reads as a decision rather than as fact |
|
||
| POST | `/teams/resync` | run a reconciliation now, **awaited**, so the response carries the outcome including the provider's own refusal reason. The four refusal gates still apply: a manual resync cannot make core act on an answer it does not trust |
|
||
| POST | `/teams/:id/archive` · `/teams/:id/hide` | staff archive / hide. **Not gated** — both withdraw a Team from public surfaces rather than publishing anything, and withdrawing has to be possible at once, by whoever is on duty |
|
||
| POST | `/teams/:id/unhide` · `/teams/:id/display-name` | the two **gated** actions (§2.9): an admin applies at once, a **moderator** files a pending request and nothing changes publicly. The caller does not choose — the server decides from the role it re-validates on the request |
|
||
| GET | `/teams/:id/grants` | the full forum-grant ledger, revoked rows included. Read-only in this phase; the grant flow lands with the forums |
|
||
| POST | `/teams/:id/leader-override` · DELETE `…/:memberKey` | set or clear a staff leadership decision, applied **on top of** the synced value at read time. Not gated: it publishes no game-sourced string |
|
||
| GET | `/moderation/reports` · POST `…/:id/handle` | the member-raised content-report queue (phase 5, [`TEAMS.md`](TEAMS.md) §5.6) and the staff decision on one. Mounted under **moderation**, not under Teams: a staffer working a queue should have one place to work, and `target_type` is open-ended so the next reportable thing arrives as a row rather than as a screen. Each row carries its target already resolved — a post's excerpt and author, a thread's title, or an upload's uploader, byte size and **sniffed** mimetype — in three batched reads, never one per row. A target hard-deleted since reporting comes back `null` and the row still lists. **There is no leader-facing counterpart to either route**, deliberately |
|
||
| GET | `/teams/review` | the reserved-name review queue — Teams auto-hidden because their name matched, each showing which term |
|
||
| GET | `/teams/requests` · POST `…/:id/decide` | the approval queue, and the decision. **Admin only** to decide, checked live rather than from a token claim; a request already decided returns `409`, so two admins deciding at once cannot double-apply |
|
||
| — | `/shard/*` · `/uo-link/*` | **Served by `module-uo`, not by core** (33 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md) |
|
||
|
||
Every admin write logs to `activity_log`.
|
||
|
||
### The SPA HTML shell (`app.js` → `utils/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=auto` → `secure: req.secure`).
|
||
- **Trusted-device MFA.** A second, separate httpOnly cookie (`rg_trust`, default 30d) — opaque, sha256-hashed server-side in `trusted_devices` — lets a browser/app **skip the TOTP step** (never the password) on future logins. It is a server-side, per-row-revocable record (never a JWT claim), so the stateless session JWT is unchanged and trust stays revocable. It only ever gates the **second factor**; it deliberately outlives logout, and is cleared on untrust / password change / password reset / TOTP disable. **Recovery codes** (bcrypt, single-use) are the 2FA-lockout fallback. All admin trusted-device/MFA actions and the self actions (`auth.login.trusted_device`, `account.trusted_device.*`, `account.recovery_code*`, `admin.trusted_device.*`, `admin.user.totp.reset`) are audit-logged. See `docs/website/TRUSTED_DEVICES_MFA.md`. This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
|
||
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
|
||
- **Rate limiting** (`express-rate-limit`) on `/auth/login`, `/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`](../modules/uo/API.md) §4 when Phase 4 closed, with the admin-facing guide
|
||
still at [`SHARD_VISIBILITY.md`](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 |
|
||
```
|