Four documents still describe the pre-module-system website. None of them misconfigures anything, which is why they survived; all four mislead a reader trying to understand how the system is actually put together. ARCHITECTURE.md placed shardIngest.js and uoLinkClient.js inside the website backend. Both live in module-uo/server/utils/ - verified, they are not in website/server/src at all. The document claimed to be "the canonical copy of the diagram; the same diagram is embedded in the website's README", and the two had silently diverged: the live README's diagram has the module subgraph, the loader, and the game behind the module, and this one did not. The diagram is now the live one verbatim, the surrounding prose attributes the shard integration to the module, and the intro no longer frames core as game-aware. The SSE bullet gains the distinction the live README makes: the module declares which kinds are public, core enforces the split. website-README.md had drifted from the live README by 28 lines, all of them the "Three ways in, and none of them is a build" section - the admin panel, the MODULES environment variable, and by hand - which is now the primary module-install story. Re-synced verbatim, since a faithful snapshot is the file's whole purpose. The diff was purely additive; the snapshot contained nothing the live README had dropped. README.md's index was missing thirteen documents, not the four the audit had found: TEAMS.md, ARCHITECTURE.md, TRUSTED_DEVICES_MFA.md and MODERATION_APPEALS.md, and also link/v4.md - the current protocol - android/THEMING_AND_NAV.md, ci/SONARQUBE.md, installer/PROJECT_TREE.md, modules/kit-acceptance.md, modules/uo/API.md, modules/uo/SCHEMA.md, website/test-plan.md and the two API_V2 documents. The layout block already advertised a ci/ directory that had no section. Every markdown file outside the issue templates is now indexed, and every link resolves. API_V2_SKELETON.md is listed as superseded, which is what its own header says. BACKEND_DESIGN.md was titled "UOMysticmoon Website - Backend Design" though it is core's contract and core is game-agnostic. Retitled, with a note that nothing in it is instance-specific. Its hardcoded public contact address is now described as what it is - seeded from BRAND_CONTACT_EMAIL into the contact_email setting, with UOMysticmoon as the example instance. Co-Authored-By: Claude <noreply@anthropic.com>
1265 lines
103 KiB
Markdown
1265 lines
103 KiB
Markdown
# Runic Gateway Website — Backend Design
|
||
|
||
> Phase 1 of 3: **backend design** → Claude Design (frontend mockup) → coding.
|
||
> This document is the contract the later phases build against.
|
||
|
||
**This is core's contract, and core is game-agnostic.** Nothing here is specific to any one game or
|
||
instance: the site's name, colours, logo and public contact address are data
|
||
(`BRAND_*` / the `settings` table), and everything about a *particular* game arrives from an
|
||
installed module — see [MODULE_SYSTEM.md](MODULE_SYSTEM.md) and, for the worked example,
|
||
[../modules/uo/](../modules/uo/README.md). **UOMysticmoon** is the first instance, and appears
|
||
below only as an example value.
|
||
|
||
---
|
||
|
||
## 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` (seeded from `BRAND_CONTACT_EMAIL`; e.g. `UOMysticmoon@gmail.com` on the first
|
||
instance), `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 |
|
||
| `team_integrations` | a Team's provisioned resource on another platform — today its Discord **voice channel and the role that opens it** (§7.3, phase 9). Both refs on one row because they are one lifecycle: a role for a channel that no longer exists is a badge for nowhere. `state` is core's BELIEF about the platform, never the platform's answer — the reconciler writes what it just did and the next pass re-derives the truth. A Team that stops qualifying goes to `pending_removal` with `remove_after` rather than being deleted at once, so a Team hovering around the size threshold does not delete-and-recreate its channel and change its id. `synced_at` is separate from `updated_at`, which moves whenever core writes a belief including an error |
|
||
| `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 |
|
||
```
|