Files
docs/website/BACKEND_DESIGN.md
wtclaude 9aee5920af docs(website): the public surface, and the flag that decides what reaches it (Phase 14a)
EVENTS.md gains what the phase settled -- a new §I section on the public
surface, the four API surface rows filled in, `listed` in §D, and the §J
rows for player profiles and mobile. EVENTS_PLAN.md records the 14a/14b
split and 14a as built. BACKEND_DESIGN.md points at the two tiers the
Phase 14a reads live on. MODULE_API.md records core's own capability list
beside a module's -- the same word, a separate list, and why.

Two things this phase corrected in the document rather than in code.

"Venue" was never a field. §I's screens table and the API surface table had
both described one since the first revision; there has never been a column,
a spec key, an input on Phase 13's form, or a string anywhere in either
repo. Rather than add a field on the way past to a public page, both
descriptions dropped it.

And the six public triggers' missing url variable, which this document has
carried as a promise since Phase 10, is now kept: `eventUrl`, carrying
`?run=`, arriving with the page it points at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-08 06:18:55 -05:00

2107 lines
184 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 at the time;
> `email` has since lost its two Gmail connect routes, §7) 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
emailVerify.router.js (2) /auth/email/verify/:token — public and
token-gated like password.router: the
link arrives in a mailbox, so the
REQUEST half is at /auth/me/account/email
and only the CONFIRM half is here
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.
The ONLY self-service account surface
(see below); account.controller.js
sits beside it and is reached from
nowhere else
account.controller.js the self-service handlers: username,
password, TOTP, identities, device
sessions, trusted devices, recovery
codes
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
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
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
engagement.router.js (22) /admin/engagement — adminOnly,
the declared event catalog (three
table-free reads, served from the
module registries) plus the rules
and audience segments an operator
configures over it, the count-only
reach preview, the message
templates and their sandboxed
preview / test send, and the send
log (G15). Two of these are POSTs
that write nothing: preview and
test-send act on the draft in the
request, not the stored row
email.router.js (4) /admin/email — outbound mail:
transport + credentials + send
test — adminOnly. The two
/connect/* routes went with Gmail
OAuth2 (§7)
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 over a registered transport;
mailto fallback when unconfigured (§7)
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 COLLATE utf8mb4_general_ci | the `_ci` collation is the case-insensitive uniqueness backstop |
| password_hash | VARCHAR(72) **NULL** | bcrypt; **never** returned by the API. Nullable: an SSO-provisioned account has none until it sets one, and a NULL hash makes password login impossible |
| role | ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'admin' | |
| email | VARCHAR(255) NULL | the account's one contact address and the destination for password-reset mail. **Unique since engagement Phase 1b — but the index is on `email_norm`, never on this column** (below) |
| email_norm | VARCHAR(255) COLLATE utf8mb4_bin **GENERATED** `AS (LOWER(email)) STORED`, UNIQUE | the uniqueness key. Every `_ci` collation MariaDB offers is also **accent**-insensitive, so a UNIQUE index on `email` would refuse `jose@x.com` once `josé@x.com` existed — two different mailboxes. `LOWER()` under `_bin` folds case without folding accents. Keeping the fold in a generated column rather than in application code means no caller can bypass it. Multiple NULLs stay legal, which is what lets the de-duplication clear an address without deleting an account |
| email_verified | TINYINT(1) NOT NULL DEFAULT 0 | set only by opening a verification link (or by an invite, which proves the address by construction). SSO sets it from the IdP's actual `email_verified`/`verified` claim — **not** from the mere presence of an address, which is what it used to do |
| email_pending | VARCHAR(255) NULL | an address requested but not yet proved. It does **not** displace `email`, so a mistyped address cannot silently redirect account-recovery mail. Deliberately **not** unique: a pending address reserves nothing, and the UNIQUE index above arbitrates at confirmation time |
| status | ENUM('active','pending','disabled','banned') NOT NULL DEFAULT 'active' | lifecycle, independent of role; enforced in `requireAuth` + login |
| totp_secret / totp_enabled | VARCHAR(64) NULL / TINYINT(1) | opt-in 2FA |
| tokens_valid_after | DATETIME NULL | session-revocation cutoff; bumped on password change / "log out everywhere" |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | also the **tie-break for de-duplication**: oldest account keeps a shared address |
| last_login_at / last_login_ip | DATETIME NULL / VARCHAR(45) NULL | shown in user management |
### email_verifications *(engagement Phase 1b)*
Same shape as `password_resets`, deliberately — an opaque random token whose **sha256 only** is stored, single-use, ~24h.
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| token_hash | CHAR(64) UNIQUE NOT NULL | sha256 of the opaque token; a DB read never yields a usable link |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
| email | VARCHAR(255) NOT NULL | **the address this token proves.** On the row, not read from the user at confirm time: a token proves control of the address it was mailed to and nothing else, so a later request for a different address cannot be confirmed by an older link |
| status | ENUM('pending','used') NOT NULL DEFAULT 'pending' | consumed atomically |
| requested_ip | VARCHAR(64) NULL | audit only |
| expires_at / created_at / used_at | DATETIME | |
### email_dedupe_report *(engagement Phase 1b)*
Who lost an address when addresses became unique. Written by `schema.sql`'s migration in pure SQL — `ensureSchema()` runs that file statement-by-statement and there is no JS migration hook — and only ever read afterwards.
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| user_id | INT NOT NULL, UNIQUE | the UNIQUE is what makes the migration's `INSERT IGNORE` strictly idempotent |
| username | VARCHAR(32) NOT NULL | captured at clear time |
| lost_address | VARCHAR(255) NOT NULL | the report is the only place this value survives |
| cleared_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
| acknowledged_at | DATETIME NULL | set when an admin dismisses the dashboard warning; rows are kept as the record of what the upgrade did |
No FK to `users`, on purpose — same reasoning as `posts.announce_job_id`: a constraint re-added on every boot is a constraint that can fail a boot, and this is a historical record rather than a live relation.
### 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.
Engagement phase 3 made this the **push projection** of `notification_channel_prefs` below. It keeps
its exact shape and stays what `utils/pushDispatch` reads — the shipped Android client cannot be
changed from this side — and the general table carries the channel dimension it lacks.
### notification_channel_prefs — which channel, in which mode (engagement phase 3)
| col | type | notes |
|---|---|---|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
| stream_id | VARCHAR(64) NOT NULL | a stream id **or** a trigger id — **one namespace** ([`ENGAGEMENT.md`](ENGAGEMENT.md) §7.2), which is what keeps this key single-column |
| channel | VARCHAR(32) NOT NULL | `email` / `push` / `inapp`, from the delivery-channel registry (`src/engagement/channels.js`) |
| mode | ENUM('off','instant','digest') NOT NULL DEFAULT 'off' | `digest` only where the channel declares `supportsDigest` |
| updated_at | DATETIME | |
`PRIMARY KEY(user_id, stream_id, channel)`, `INDEX(channel, mode)`.
**A row exists only where the user has expressed something, and absence is the *channel's* default,
not `off`.** That default lives in the channel registry and nowhere else (§3.1, G9: push, email and
in-app do not agree on it). All three currently declare `off`, so absence and off happen to coincide
today — a fact about the declarations, not about this table, and code must not assume it. The column
`DEFAULT` is the value a write with no mode takes, not the meaning of a missing row.
**It is a superset of `notification_subscriptions`, which becomes its push projection.** The shipped
Android client's wire shape is frozen (`{streams:[…]}`), so the old table stays exactly what
`utils/pushDispatch` reads and every write to either fans out to the other. The invariant both
directions maintain: **a `push` row with `mode <> 'off'` ⟺ a `notification_subscriptions` row.** An
explicit `off` is *stored* rather than deleted — folding "I turned this off" back into "I never said"
is only harmless while the default is off. Existing subscriptions are carried across by an
`INSERT IGNORE … SELECT` backfill in `schema.sql`, replay-safe on every boot like the
`announce_jobs → announce_job_legs` one it copies.
### engagement_rules — the operator's configuration (engagement phase 4a)
| col | type | notes |
|---|---|---|
| id | INT AUTO_INCREMENT PK | |
| trigger_id | VARCHAR(96) NOT NULL | a declared trigger id. **No FK and no existence check** — a trigger is declared in code, so a rule naming one no module currently registers is *dormant*, never deleted ([`ENGAGEMENT.md`](ENGAGEMENT.md) §7.3) |
| name | VARCHAR(160) NOT NULL | |
| enabled | TINYINT(1) NOT NULL DEFAULT **0** | off by default, so no import, seed or restore can start mailing on its own (§7.1 Q3) |
| audience | VARCHAR(32) NOT NULL DEFAULT 'owner' | a ceiling name — `owner` / `staff` / `subscribers` / `members` / `authenticated` / `everyone` |
| audience_segment_id | INT NULL | a composed segment (§5.1a). **Deliberately no FK** — see below |
| max_sends_per_hour | INT NOT NULL DEFAULT 100 | the hard per-rule ceiling (§7.1 Q3), counted in `engagement_sends` and enforced before an outbox row is written |
| channels | JSON NOT NULL | `['email','inapp']` — a rule may span channels |
| template_keys | JSON NOT NULL | `{ email: 'idoc-warning' }`. Keys are shape-checked, not existence-checked: templates are Phase 5. Every key is one of the rule's `channels` **plus one that is not a channel at all**: `digest` names the body the digest worker renders for a rule whose email channel an individual set to digest mode, so it belongs to a mode and can never appear in `channels`. The rules validator rejected it until website#181, which made every rule shipping one — core's own Team and news rules included — unsaveable from the Rules screen |
| conditions | JSON NULL | a small closed and/or/not grammar over the trigger's **declared** variables |
| cooldown_seconds | INT NOT NULL DEFAULT 0 | 0 = no cooldown |
| delay_seconds | INT NOT NULL DEFAULT 0 | the grace window (§4.2a) |
| cancel_on | JSON NULL | trigger ids that cancel a pending row for the same subject |
| updated_by | INT NULL FK→users(id) ON DELETE SET NULL | |
| created_at / updated_at | DATETIME | |
`INDEX(trigger_id, enabled)` — the engine's one indexed read per emit.
**`audience_segment_id` carries no foreign key on purpose.** The two options a database offers are
both wrong here: `ON DELETE CASCADE` would delete an operator's rules, and `ON DELETE SET NULL` would
silently fall the rule back to its plain `audience` column — and that fallback reaches a **different
set of people**, which is the failure §5.1a rule 4 exists to prevent. A rule whose segment is gone is
dormant and sends nothing, and deleting a segment a rule still uses is refused in the model.
### engagement_audience_segments — operator-composed audiences (engagement phase 4a)
| col | type | notes |
|---|---|---|
| id | INT AUTO_INCREMENT PK | |
| name | VARCHAR(160) NOT NULL | |
| expression | JSON NOT NULL | a boolean tree of module-declared audience ids + params |
| ceiling | VARCHAR(32) NOT NULL | **derived, never operator-typed** — the narrowest ceiling in the tree |
| updated_by | INT NULL FK→users(id) ON DELETE SET NULL | |
| created_at / updated_at | DATETIME | |
**Composition narrows, never widens.** `A OR B` takes the *tighter* of the two ceilings, not the
looser: a ceiling states what an expression is allowed to reach, not what it will resolve to, so the
boolean operator's direction is irrelevant. Two incomparable ceilings have no meet and the save is
refused rather than resolved to a guess (`src/modules/ceilings.js`). `not` is legal only inside an
`and` — a complement needs a set to be taken from, and "everyone except…" is a broadcast built out of
a narrow audience — and it contributes no ceiling of its own, since excluding people cannot widen.
The ceiling is a **stored column rather than a runtime computation** so an audit can read what a rule
was allowed to reach without re-resolving it, and so a module that later widens its own audience's
ceiling cannot retroactively widen a segment saved under the old one.
### engagement_cooldowns — one fire per (rule, user, subject, channel) (engagement phase 4a; `channel` added 11b)
| col | type | notes |
|---|---|---|
| rule_id | INT NOT NULL FK→engagement_rules(id) ON DELETE CASCADE | |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
| subject_key | VARCHAR(190) NOT NULL DEFAULT '' | opaque to core: a house serial, a vendor id. `''` = this rule cools per user, not per subject |
| channel | VARCHAR(32) NOT NULL DEFAULT '' | the delivery channel. VARCHAR like `engagement_outbox.channel`, and for the same reason: the channel set is data a module can extend |
| last_fired_at | DATETIME NOT NULL | |
| fire_count | INT NOT NULL DEFAULT 1 | |
`PRIMARY KEY(rule_id, user_id, subject_key, channel)`, `INDEX(last_fired_at)` for a prune.
**`subject_key` is why this is not a per-user counter.** "One IDOC mail per player per day" is the
wrong rule: a player with four houses decaying should hear about all four, once each, and cooling on
(rule, user) alone silently drops three of them.
**`channel` is why a two-channel rule delivers on both, and it was added after a live walk found that
it did not** (ENGAGEMENT.md Phase 11b, decision 12). The engine claims INSIDE its per-channel loop, so
without the channel in the key the first channel of a rule claimed the cooldown and every later one
was refused as still cooling — and `inapp` is ranked first deliberately, so a rule naming email and
in-app delivered the inbox item and silently never the mail. A cooldown is per delivery, not per
occasion: an operator who says "one a day about this house" means one mail and one inbox item.
Migrated in place behind a guarded `DROP PRIMARY KEY`, because **MariaDB has no conditional form of a
key change** — replaying `schema.sql` on every boot would fail after the first run without the
`information_schema` guard that reads whether the key already carries the column. Rows written before
the migration keep `channel = ''` and expire on their own interval; dropping the table instead would
let a storm through the window.
**The claim is two statements, not the one §4.1 originally described** — a guarded `UPDATE` (the
interval in a WHERE clause) falling back to `INSERT IGNORE` for a first fire. The single
`INSERT … ON DUPLICATE KEY UPDATE` form reads its answer out of `affectedRows`, and the mariadb
connector's default `foundRows: true` makes a no-op update report 1 rather than 0 — under which every
cooldown passes, always. See `ENGAGEMENT.md` Phase 4a.
### engagement_outbox — the send queue (engagement phase 4a)
| col | type | notes |
|---|---|---|
| id | BIGINT AUTO_INCREMENT PK | |
| rule_id | INT NOT NULL FK→engagement_rules(id) ON DELETE CASCADE | |
| trigger_id | VARCHAR(96) NOT NULL | denormalized; survives a rule edit |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
| channel | VARCHAR(32) NOT NULL | VARCHAR, never ENUM: the channel set is data, and a module must not require an ALTER |
| subject_key | VARCHAR(190) NOT NULL DEFAULT '' | what a COOLDOWN counts, from the trigger's declared `subjectKey`. A display string is fine here: it is only ever compared with itself |
| scope_key | VARCHAR(190) NULL | what a PREFERENCE and an UNSUBSCRIBE are keyed on (engagement phase 6), e.g. `team:12`. Deliberately **not** `subject_key`: an unsubscribe token is signed over this and sits in a mailbox for months, so it has to be a stable identifier — signing over a display name orphans every link the first time somebody renames a Team. NULL means an unscoped event; `''` is reserved for "deployment-wide" in `engagement_digest_state` |
| payload | JSON NOT NULL | the declared variables, snapshotted at emit |
| dedupe_key | VARCHAR(190) NULL | the emitter's replay guard; NULL never collides |
| status | ENUM('scheduled','sending','sent','failed','cancelled','suppressed') | |
| due_at | DATETIME NOT NULL | the grace window's clock, and the retry backoff's |
| attempts / last_error / sent_at | | |
| created_at / updated_at | DATETIME | `updated_at` is what a stale-claim reclaim measures; `created_at` is what the retention sweep measures |
`UNIQUE(rule_id, user_id, channel, dedupe_key)`, `INDEX(status, due_at)`,
`INDEX(rule_id, user_id, subject_key, status)`.
**The unique key is scoped, and a global one would have been a data-loss bug.** A dedupe key names the
*event*; one event legitimately becomes one row per (rule, user, channel), so a fifty-person audience
on two channels is a hundred rows carrying the same key. A global `UNIQUE(dedupe_key)` admits the first
and silently ignores the rest.
**A row is claimed with a compare-and-set**`UPDATE … SET status='sending' WHERE id=? AND
status='scheduled'` — and the sweeper the server reports `affectedRows = 1` to owns it (§7.1 Q2). That
makes the outbox safe for two app instances; the other four workers in this codebase are still
single-instance, so the deployment as a whole is not. A row stranded in `sending` by a crashed process
is reclaimed after a window, because `status='scheduled'` would otherwise never match it again.
### engagement_sends — the send log (engagement phase 4a)
| col | type | notes |
|---|---|---|
| id | BIGINT AUTO_INCREMENT PK | |
| outbox_id | BIGINT NULL | |
| rule_id | INT NULL | |
| trigger_id | VARCHAR(96) NOT NULL | |
| user_id | INT NULL FK→users(id) **ON DELETE SET NULL** | the log survives an account deletion |
| channel / transport | VARCHAR(32) | which channel, and which mail transport actually carried it |
| address_hash | CHAR(64) NULL | sha256 — enough to correlate a bounce (Phase 9), useless as a mailing list |
| status | ENUM('sent','failed','suppressed','bounced','complained') | |
| detail | VARCHAR(500) NULL | |
| created_at | DATETIME | |
`INDEX(trigger_id, created_at)`, `INDEX(user_id, created_at)`, `INDEX(rule_id, created_at)` — the last
of those is the per-rule hourly ceiling's count, which runs once per rule per event.
G15: "did user X get the mail?" has never been answerable on this deployment. A row is written for
**every terminal outcome**, not only success — "no, and here is why" is an answer this table has to be
able to give — and the hourly ceiling counts only `sent`, so a broken transport cannot silently consume
a rule's budget and mute it.
**It is deliberately not a second address book.** The address is a hash; the values of a payload never
appear here, and neither do they appear in the engagement log lines, which carry variable *names* and
counts only.
Phase 9 gave two of those statuses their first writers. `suppressed` means the address was on the
suppression list and **no transport call was made**; `bounced` means one was, and the mailbox does
not exist. `complained` still has none — it needs a provider feedback loop, which SMTP has not got.
### engagement_suppressions — addresses we have stopped mailing (engagement phase 9)
| col | type | notes |
|---|---|---|
| address_hash | CHAR(64) NOT NULL PK | sha256 of the **lower-cased, trimmed** address |
| address_masked | VARCHAR(190) NULL | `d***@example.com`. Phase 9's one addition to the planned DDL |
| channel | VARCHAR(32) NOT NULL DEFAULT 'email' | |
| reason | ENUM('bounce','complaint','manual','unverified') | |
| detail | VARCHAR(500) NULL | e.g. `hard bounce: 5.1.1` |
| created_by | INT NULL FK→users(id) ON DELETE SET NULL | the admin, for a manual row; **NULL for an automatic one**, which is what separates the two |
| created_at | DATETIME | |
`INDEX(created_at)`, `INDEX(reason, created_at)` — the screen's two orderings.
G16. **Keyed on the address, not the user**, and after Phase 1b made addresses unique that is a
choice rather than a workaround: a bounce arrives as an address, it does not know which account was
behind it, and it stays true after that account changed its address or was deleted.
Writes are `INSERT IGNORE`, so **the first reason an address was suppressed is the one that
survives** — an address that hard-bounced in March and was manually re-added in June still reads
`bounce`, because that is the fact explaining why the mail stopped. An upsert would let the most
recent write overwrite the diagnosis.
`address_masked` exists because a hash-only table cannot be operated; the reasoning and the routes
are in §7's *Deliverability* subsection.
### engagement_digest_state — how far each digest has got (engagement phase 6)
| col | type | notes |
|---|---|---|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
| channel | VARCHAR(32) NOT NULL | |
| scope_key | VARCHAR(190) NOT NULL DEFAULT '' | `''` = deployment-wide; `team:12` = one Team. NOT NULL with a `''` default because it is a PRIMARY KEY column and MariaDB coerces a nullable one anyway — the same workaround `team_integration_config` and `teams.active_key` both carry |
| last_digest_at | DATETIME NULL | stamped **only on a successful send** |
| updated_at | DATETIME | |
`PRIMARY KEY (user_id, channel, scope_key)`, `INDEX(channel, last_digest_at)` — the worker's driving
question is "whose digest is due?", which is a range scan of that index rather than of every digest ever
sent.
**This is a digest's only state, and deliberately not a digest queue.** The engine writes an outbox row
per (rule, user, channel) at emit time carrying a snapshot of the payload; that is right for an instant
send and wrong for a digest, which is re-derived from the source tables when it goes out. Three
properties depend on the re-derivation: a two-day outage sends one digest rather than replaying two
days, a post hidden after it was written is not in the query, and — the security one — a user who lost
access between the post and the send is no longer in the recipient set. So `engine.subscribedTo`
enqueues **`instant` recipients only**.
Lifted out of `team_notification_prefs.last_digest_at`, which was a worker's column on a user's
preferences row; `schema.sql` backfills it with an `INSERT IGNORE … SELECT`, replay-safe by the primary
key rather than by a flag.
### engagement_templates — the message bodies (engagement phase 5a)
| col | type | notes |
|---|---|---|
| id | INT AUTO_INCREMENT PK | |
| `key` | VARCHAR(96) NOT NULL **UNIQUE** | the stable id a rule's `template_keys` map and `mailer` name |
| name | VARCHAR(160) NOT NULL | what the admin list shows |
| trigger_id / trigger_version | VARCHAR(96) NULL / INT NULL | **no foreign key**, for the reason `engagement_rules.trigger_id` has none: a trigger is declared in code. NULL = a reusable template not tied to one trigger, which is what every transactional seed is |
| channel | VARCHAR(32) NOT NULL | one template per channel; a rule names a set |
| subject | VARCHAR(300) NULL | email only, and it interpolates. NULL is how a non-email template says it has none |
| blocks | MEDIUMTEXT NOT NULL | a JSON block array, validated + sanitized on write against the `email.*` registry — never raw operator HTML |
| text_body | MEDIUMTEXT NULL | an authored plain-text part that **replaces** the generated one; NULL = generated from each block's `toText` |
| status | ENUM('draft','published') DEFAULT 'draft' | |
| protected | TINYINT(1) DEFAULT 0 | editable, not deletable — the `pages.protected` flag, for the same reason: the system breaks without a password-reset body |
| seed_key / seed_version / customized | VARCHAR(96) NULL / INT NULL / TINYINT(1) DEFAULT 0 | the "ship a better default without stealing an operator's work" mechanism — see below |
| updated_by | INT NULL FK→users(id) ON DELETE SET NULL | |
| created_at / updated_at | DATETIME | |
`INDEX(trigger_id, channel, status)`, `INDEX(seed_key)`.
**The three seed columns are one mechanism, and the guard lives in SQL.** On boot the seeder runs an
`INSERT IGNORE` per shipped template and, when the row already exists, a single
`UPDATE … WHERE seed_key = ? AND customized = 0 AND seed_version < ?`. A read-then-write would leave a
window in which a concurrent boot overwrites an edit an operator made a moment earlier; putting
`customized = 0` in the UPDATE's own WHERE closes it. (MariaDB's `ON DUPLICATE KEY UPDATE` cannot carry
a WHERE, which is why this is two statements rather than the upsert ENGAGEMENT.md §4.6.1 sketches.) A
customized row whose shipped default has moved on is **surfaced**, never applied.
**A missing or unusable row renders the shipped default rather than nothing.** `renderByKey` falls back
to the in-code seed whenever the row is absent or its `blocks` will not parse — before the first seed
runs, after a restore that dropped the table, or on a row hand-edited in the database. That fallback is
what makes it safe for a password-reset mail to depend on this table at all.
### user_notifications — the in-app inbox (engagement phase 7)
| col | type | notes |
|---|---|---|
| id | BIGINT AUTO_INCREMENT PK | |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | CASCADE, unlike `engagement_sends`: this is content addressed to a person, not an audit of what the deployment sent |
| trigger_id | VARCHAR(96) NOT NULL | denormalized, **no foreign key** — a trigger is declared in code |
| title | VARCHAR(300) NOT NULL | rendered from the template's first `email.heading`; falls back to the projected `title`, then to the key. Truncated rather than refused |
| body | TEXT NULL | the **text** render of the template's remaining blocks. Not the email HTML — see below |
| url | VARCHAR(500) NULL | **site-relative only**, validated with the same character class `pageUrlTemplate` and the engine's `url` variables use. An absolute url on this deployment's own base is reduced to a relative one; anything else is dropped to NULL |
| dedupe_key | VARCHAR(190) NULL | NULL = this item does not dedupe |
| read_at | DATETIME NULL | |
| created_at | DATETIME | |
`UNIQUE (user_id, dedupe_key)`, `INDEX(user_id, read_at, created_at)`, `INDEX(created_at)`.
**The unique key is scoped to the USER, and that is deliberately narrower than the outbox's.**
`engagement_outbox` scopes its dedupe to `(rule, user, channel)` because one event legitimately becomes
one row per channel; an inbox has no channel dimension, so two rows for one event would be one item
shown twice. Multiple NULLs are permitted by a UNIQUE index, which is what "does not dedupe" means, and
`INSERT IGNORE` is what makes a replay, a retry and a module writing the same item twice all one no-op.
**`body` is text, and that is the load-bearing choice rather than a shortcut.** The `email.*` renderer
produces markup built for mail clients — table rows, inline hex colours, a light-only palette declared
with `color-scheme` — which dropped into a page that follows the viewer's theme renders as a pale card
floating in a dark one. `toText` is the same content with none of that, and it is the part the block
contract already promises every block can produce. It also means there is no operator markup on this
surface to sanitize, and no way for one to appear: every renderer treats the column as text.
**The template maps onto the three columns by block ROLE** (`templates.renderInappByKey`): the first
`email.heading` is the title, the first `email.button` is the url, and everything else is the body. So
an operator editing `inapp.event` in the Phase 5b editor changes what appears in the inbox, which is the
only reason the template exists at all.
**Retention: `utils/userNotificationsPrune.js`, nightly, READ items only.** Age alone would delete the
evidence for "I was never told", which is the complaint this table answers, and an inbox that quietly
drops unread items is one whose badge means nothing. The horizon is `settings.user_notifications_retain_days`
(default 90), so an operator tightens a busy shard without a deploy — `team_activity`'s posture, in the
worker that file is modelled on.
### Engagement retention — three sweeps and one recorded refusal (engagement phase 14)
Four engagement tables grow, and until Phase 14 nothing deleted from any of them.
`utils/engagementRetentionPrune.js` is one nightly worker over three of them —
`setInterval` + `unref` + `stop()`, wired into `server.js` beside `teamActivityPrune` and
`inboxPrune`, batched 1000 × 50 per table, each table's failure caught on its own so a lock
timeout on one does not leave the other two unbounded.
| table | horizon | setting | what is eligible |
|---|---|---|---|
| `engagement_sends` | 180 days | `engagement_sends_retain_days` (73650) | every row; they are all terminal |
| `engagement_cooldowns` | 30 days | `engagement_cooldowns_retain_days` (23650) | every row, by `last_fired_at` |
| `engagement_outbox` | 30 days | `engagement_outbox_retain_days` (23650) | **terminal rows only**`sent`, `failed`, `cancelled`, `suppressed` — by `created_at` |
| `engagement_suppressions` | **never** | — | nothing. See below |
**The outbox sweep is terminal-only, and that is a correctness rule, not a preference.** A
`scheduled` row is a send this deployment still intends to make — `delay_seconds` can legitimately
put one a day out — and a `sending` row may be a worker mid-flight. A sweep by age alone would
cancel sends nobody cancelled, and the only symptom would be mail that never arrived.
**The floors are not UI niceties.** Below two days, a pruned cooldown row makes the next fire a
FIRST fire — the rule sends twice; `MAX_COOLDOWN_SECONDS` is a validated 86 400, so two days is the
smallest provably-safe value against any rule that can be saved. The send log's floor is a week
because `engagement_sends` has two live readers: the per-rule hourly ceiling counts it
(§7.1 Q3), and Admin → Engagement → Send Log is the operator's only answer to "was this person
told".
**The cooldown horizon is checked, not assumed.** `engagementRules.db.maxEnabledCooldownSeconds()`
(enabled rules only — a disabled rule writes no cooldown row) is compared against the horizon on
every read of the policy and on every sweep. A horizon that does not clear it produces a warning on
the screen and in the log, **and the sweep runs anyway**: refusing to prune would trade a bounded,
describable fault for the unbounded one this phase exists to end.
**`engagement_suppressions` does not expire, and that is the recorded decision** (org lead,
2026-09-01). A suppression is a standing decision, not a record of something that happened; ageing
out a hard bounce re-mails an address that already bounced, which is how a sender loses a domain's
reputation. The way out stays deliberate — see the per-row lift in the route table.
**Retention has a screen**, unlike `team_activity` and `user_notifications`, whose horizons are
invisible settings rows. The send-log horizon changes what an operator-facing page is *able to show*,
so it has to be visible and settable; having made one visible, hiding the other two would split one
question ("what does this deployment keep") across two places. `EngagementSendLog.jsx` reads the
policy and prints *"entries older than N days are removed automatically"* beneath its pager, so the
total it shows stops being quietly wrong.
**One shipped defect this phase had to fix to be a bound at all.** `outboxDb.reclaimStale` returned
every stale `sending` row to `scheduled`, and `MAX_ATTEMPTS` is consulted only on a graceful `retry`
outcome — so a send that killed the process mid-flight cycled sending → scheduled → sending forever,
never reached a terminal status, and was therefore never eligible for any sweep. It now fails a row
that has burned its attempts **before** reclaiming the rest; the order is the fix, and reversing it
hands the exhausted row straight back to `findDue`.
### The two block registries — pages and mail (engagement phase 5a)
`server/src/blocks/` (the CMS page family) and `server/src/emailBlocks/` (`email.heading`, `email.text`,
`email.button`, `email.divider`, `email.image`, `email.itemList`) are **siblings, not one registry**.
Three reasons, in order of what they cost if ignored:
1. **Email blocks render on the server.** A page block carries `schema` / `sanitize` / `cacheTTL` and is
drawn by React in `client/src/blocks/`; a mail body is a string this process produces, so an email
definition carries `toHtml` and `toText`. `registerBlock` freezes a fixed field set and would drop
both silently.
2. **One registry would be one namespace.** The page registry's only server consumer is
`pages.model.js`; putting `email.heading` in that Map makes a CMS page containing an email block
validate and save, with nothing on the client able to draw it.
3. The entry shapes differ — `cacheTTL` and `container` mean nothing to a mail body, a renderer nothing
to a cached page block.
What *is* shared is shared by binding rather than by copy: `propHelpers`, the envelope/id/nesting walk
(`makeValidateBlocks`) and the validate-then-sanitize order (`makeSanitizeBlocks`) are factories the two
registries each bind. ENGAGEMENT.md §4.4's "do not build a second editor" is honoured where it is about
the editor — Phase 5b drives the `email.*` family through the existing block/prop-panel machinery.
### Template variables — the token grammar (engagement phase 5a)
`{{ name }}`, a bare declared variable name, and nothing else: no filters, no conditionals, no loops, no
dotted paths. Repetition is a block (`email.itemList` renders a declared *list* variable), which is why
the grammar needs no loop. Three consequences worth knowing before authoring one:
- **Interpolation is HTML-escaped in the HTML part and raw in the text part.** There is no raw-HTML
variable type (§4.6.2) — a module supplies data, not markup.
- **A URL built from a variable is re-checked after substitution.** A stored `{{resetUrl}}` says nothing
about where it points; a substituted value that is not http(s)/same-origin loses its href and renders
as inert text rather than as a link a reader has no reason to distrust.
- **Presentational conditionals live at the call site**, not in the template. `mailer` computes
` for the account “Darrow”` with a ternary and passes the *result* as a variable, whose declared
`example` shows exactly what it produces.
Four **ambient** variables — `siteName`, `siteUrl`, `logoUrl`, `year` — are available to every template
and are merged **over** whatever a caller passes. A caller supplies the message; the deployment supplies
its identity, and letting a caller override it would mean mail that claims to be from somewhere else.
### 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 25)
*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`** (01440, 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.
---
### The six event tables — the engine's, game-agnostic (events phase 1)
Design of record: [`EVENTS.md`](EVENTS.md) §D, which is where the full column list of every event
table lives. **Six land in Phase 1** — the ones that do not depend on the module contract — and are
spelled out below. The rest arrive with the phase that gives each a writer, rather than as empty
tables nothing reads: `event_run_phase_gates` in Phase 5, `event_action_settings` and
`event_run_budget` in Phase 6, `event_run_resources` in Phase 8, and **`event_run_participants` in
Phase 10** — `run_id` + a module-opaque `member_key` that is `UNIQUE` together, a nullable `user_id`
that `SET NULL`s so a record of what happened survives an account deletion, a `DECIMAL(18,4)` score
and a `rank_at` written only when results are published. Core writes it and sources none of it: a
`member_key` → account mapping is one game's, and a module reports both halves on its action's
success envelope.
Phase 10 also put two columns on tables that already existed — `event_runs.results_published_at`
(below) and a nullable `announce_jobs.run_id`, which is what lets an event announce a post the news
pipeline has already announced without either job standing on the other's toes. Everything that
means *"the post's job"* — the post admin panel, its retry button, `posts.announced_at` — still means
the one with a NULL `run_id`.
Core owns the engine; a module owns the meaning. No column below carries a game noun: an action id,
a `scope`, a resource kind and a budget dimension are opaque strings core stores and never
interprets.
#### event_series — the arc
| col | type | notes |
|---|---|---|
| id | INT AUTO_INCREMENT PK | |
| name / slug | VARCHAR(160) NOT NULL, `UNIQUE(slug)` | |
| description | TEXT NULL | |
| ordering | INT NOT NULL DEFAULT 0 | where this series sits among the others. **Not** a position within it — that is `event_definitions.series_order`, which is the column an editor drags |
| created_by | INT NULL FK→users(id) ON DELETE SET NULL | |
| created_at / updated_at | DATETIME | |
Read-only through Phase 1: a definition may be pointed at a series, and creating or ordering one
arrives with the calendar.
#### event_definitions — the thing that is listed, scheduled and audited
| col | type | notes |
|---|---|---|
| id | INT AUTO_INCREMENT PK | |
| title | VARCHAR(200) NOT NULL | |
| slug | VARCHAR(200) NOT NULL, `UNIQUE` | derived from the title **once** and frozen, like a Team's: the public event page lives at it |
| summary | VARCHAR(500) NULL | |
| body | MEDIUMTEXT NULL | the storyline. Sanitized on write through `utils/sanitizeHtml.cleanBody`, exactly as a wiki page is |
| image_url | VARCHAR(500) NULL | |
| owner_module | VARCHAR(64) NULL | the module that SHIPPED this definition as content — not the module whose actions its steps call. A definition may call three modules' verbs and belong to none of them; NULL is the ordinary case |
| state | ENUM('draft','ready','archived') NOT NULL DEFAULT 'draft' | three states, not five. An admin publishes their own work, so there is nobody to submit it to |
| current_version_id | INT NULL | **no foreign key**, the one column in this group without one: `event_versions.definition_id` already points back here, and a second FK the other way makes the pair a chicken and an egg on insert |
| spec | JSON NOT NULL | **the working copy** — phases and their steps, as the author last saved it. Not in §D's column list; see below |
| series_id | INT NULL FK→event_series(id) ON DELETE SET NULL | |
| series_order | INT NOT NULL DEFAULT 0 | this definition's place within its arc |
| concurrency_key | VARCHAR(190) NULL | stored as the **template** (`invasion:{region}`), rendered from a run's own params at materialisation. A flat definition-id key would wrongly stop one definition running in two regions at once |
| grace_seconds | INT NOT NULL DEFAULT 900 | a schedule that passed this long ago while the process was down is `missed`, never a late silent start. Validated 60..86 400 |
| timezone | VARCHAR(64) NOT NULL DEFAULT 'UTC' | IANA, and it belongs to the **event**: every listing this replaces is written in the shard's local zone, and a recurrence computed in UTC puts a Friday-8pm event at 7pm for half the year. Validated against the platform's own tzdata via `Intl.DateTimeFormat` |
| created_by / updated_by | INT NULL FK→users(id) ON DELETE SET NULL | |
| created_at / updated_at | DATETIME | |
`INDEX(state, updated_at)` — the admin list's ordering and the public calendar's filter.
`INDEX(series_id, series_order)` — the arc.
**`spec` is Phase 1's one addition to §D's column list, and it is forced by the versioning rule.**
"Editing a draft is free; no version exists yet" means the working copy has to live somewhere, and it
cannot be an `event_versions` row: that table is immutable and a run pins one, so a mutable
unpublished row in it would be exactly what versioning exists to prevent. Publishing copies this
column into a version and leaves it standing as the next draft.
#### event_versions — the immutable snapshot a run pins
| col | type | notes |
|---|---|---|
| id | INT AUTO_INCREMENT PK | |
| definition_id | INT NOT NULL FK→event_definitions(id) ON DELETE CASCADE | |
| version | INT NOT NULL, `UNIQUE(definition_id, version)` | two publishes racing for version 4 is one 1062, not two rows called 4 |
| spec | JSON NOT NULL | phases, steps, schedule — the whole authored tree |
| published_at | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| published_by | INT NULL FK→users(id) ON DELETE SET NULL | |
**Nothing updates a row here and nothing deletes one.** Editing a `ready` definition creates the
*next* version on publish; a live run keeps the version it pinned and is unaffected. That pin is what
makes a run reproducible and an audit answerable after the definition has moved on.
#### event_runs — one occurrence, in one scope
| col | type | notes |
|---|---|---|
| id | BIGINT AUTO_INCREMENT PK | |
| definition_id | INT NOT NULL FK→event_definitions(id) ON DELETE CASCADE | |
| version_id | INT NOT NULL FK→event_versions(id) | **no ON DELETE clause, so it RESTRICTs**: a run whose pinned spec had been deleted could not be explained afterwards, which is the one thing this table is for |
| scope | VARCHAR(190) NOT NULL DEFAULT `''` | module-opaque; core never parses it. `''` and **not NULL**, because it is part of a UNIQUE key and multiple NULLs do not collide in MariaDB — a NULL scope would silently permit two runs of one occurrence |
| status | ENUM('scheduled','starting','running','paused','ending','completed','cancelled','failed','missed') NOT NULL DEFAULT 'scheduled' | `starting` and `ending` exist for the reason `sending` does in the outbox: they are what a claim sets. `missed` is terminal |
| health | ENUM('ok','degraded','stalled') NOT NULL DEFAULT 'ok' | separate from `status`, because a run can be genuinely running *and* degraded — announcements landing, world writes parked — and one column cannot say both |
| cleanup_status | ENUM('not_required','pending','complete','incomplete') NOT NULL DEFAULT 'not_required' | also separate: a run reaches `completed` with `incomplete` cleanup rather than being held open, and stays on the admin screen until a human resolves it |
| current_phase | VARCHAR(64) NULL | |
| scheduled_for | DATETIME NOT NULL | **UTC**. The definition's zone is what an occurrence is computed *in*; what is stored is the instant |
| timezone | VARCHAR(64) NOT NULL DEFAULT 'UTC' | copied from the definition at materialisation |
| concurrency_key | VARCHAR(190) NULL | the definition's template, rendered against this run's params |
| params | JSON NULL | |
| rehearsal | TINYINT(1) NOT NULL DEFAULT 0 | dispatches for real; excluded from the public calendar and from participation history |
| started_at / ended_at | DATETIME NULL | |
| results_published_at | DATETIME NULL | when the run's results table was last ranked and published (phase 10). A stamp rather than a status: *"may I show this table"* and *"when was it settled"* are the same column. `core.results.publish` re-stamps rather than guarding on NULL, because a second publication after a late correction is a real one |
| claimed_by / claim_expires_at | VARCHAR(64) NULL / DATETIME NULL | the lease. Written by the runner |
| started_by | INT NULL FK→users(id) ON DELETE SET NULL | |
| last_error | VARCHAR(500) NULL | |
| created_at / updated_at | DATETIME | |
`UNIQUE(definition_id, scope, scheduled_for)` — **and it, not the claim, is what makes "one run per
occurrence per scope" true.** The claim decides *who* advances an occurrence; this index is what
stops two of them existing. `scope` is inside the key so a worldwide event fans out to many servers
without colliding with itself. Materialisation is `INSERT IGNORE` against it, so asking twice for one
occurrence answers with the existing row rather than raising a duplicate-key error a caller has to
interpret.
`INDEX(status, scheduled_for)` the runner's scan · `INDEX(definition_id, scheduled_for)` the run list
· `INDEX(concurrency_key, status)` the overlap check.
#### event_run_steps — the work queue
| col | type | notes |
|---|---|---|
| id | BIGINT AUTO_INCREMENT PK | |
| run_id | BIGINT NOT NULL FK→event_runs(id) ON DELETE CASCADE | |
| phase / seq | VARCHAR(64) NOT NULL / INT NOT NULL | |
| action_id | VARCHAR(96) NOT NULL | a declared action id. **No FK and no existence check**, for the reason `engagement_rules.trigger_id` has none: an action is declared in code, so a step naming one no module currently registers is *dormant*, never deleted |
| params | JSON NULL | validated against the action's declared params at save |
| action_version | INT NOT NULL DEFAULT 1 | what the step was AUTHORED against. A bump makes the editor warn rather than dispatch a mistyped parameter |
| status | ENUM('pending','running','done','failed','skipped','refused','cancelled') NOT NULL DEFAULT 'pending' | **`refused` is the cap breach and is deliberately not `failed`**: nothing is wrong with the system, an author asked for more than this deployment allows |
| due_at | DATETIME NULL | |
| attempts | INT NOT NULL DEFAULT 0 | |
| on_failure | VARCHAR(32) NOT NULL DEFAULT 'pause' | `skip` · `pause` · `abort_run`, defaulted from the action's risk class at save: `notify`/`inspect` → skip, `change` → pause, `irreversible` → abort_run |
| idempotency_key | CHAR(40) NOT NULL | `sha256(runId\|stepId)` truncated to 40 hex, the shape `shardEvents.dedupeKey` uses. **Minted once at materialisation and it does not vary by attempt** — a retry re-sends the same key so the game side can recognise the repeat |
| claimed_by / claim_expires_at | VARCHAR(64) NULL / DATETIME NULL | |
| last_error | VARCHAR(500) NULL | |
| started_at / finished_at | DATETIME NULL | |
| created_at / updated_at | DATETIME | |
`UNIQUE(run_id, phase, seq)` — materialisation is `INSERT IGNORE` against it, so a tick that overran
into the next one cannot double-materialise a phase. `INDEX(status, due_at)` the drain scan ·
`INDEX(run_id, phase, seq)` the run console.
#### event_run_log — "why didn't phase 3 start?" must be a query
| col | type | notes |
|---|---|---|
| id | BIGINT AUTO_INCREMENT PK | |
| run_id | BIGINT NOT NULL FK→event_runs(id) ON DELETE CASCADE | |
| step_id | BIGINT NULL FK→event_run_steps(id) ON DELETE SET NULL | |
| kind | VARCHAR(48) NOT NULL | a **closed set enforced in `eventRunLog.db.js`, not an ENUM**: the set grows with almost every later phase, and an ENUM change is a table alter this project has no migration system for. Phase 1's five: `run.created`, `run.status`, `phase.entered`, `step.status`, `note` |
| phase | VARCHAR(64) NULL | |
| detail | JSON NULL | structured, and that is the whole point — `activity_log.detail` is TEXT and unqueryable |
| at | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
`INDEX(run_id, at)` the console · `INDEX(at)` the retention sweep the runner phase adds.
**This table sits beside `activity_log`, not instead of it.** Both are written: the administrative
audit of *who published what* goes to the activity log, the diagnosis of *why a run did what it did*
goes here. They are different questions with different readers and different retention. The writer
**never throws** — a failure to record why something went wrong must not become a second failure on
top of the first.
**The log is high-cardinality and grows per event, so it needs a retention sweep from the start.**
`engagementRetentionPrune` is the pattern and the rule that work learned is that only *terminal* rows
are eligible; the sweep lands with the runner, and the index it needs is in the DDL from the
beginning.
#### The action registry — declared, never stored
Actions, budget dimensions and conditions are **registry entries, not tables** (§D "Not tables,
deliberately"): a module declares them at `register()`, like streams and audiences, and a stored one
would outlive the module that can perform it. `modules/registries.js` gained
`registerEventActions` in this phase, with core as its first registrant —
`config/coreEventActions.js` declares `core.announce`, `core.wait` and `core.cue`, so the seam is
exercised on every boot long before a module uses it.
The declaration is shape-checked at the call: the id grammar (its **own** namespace — an action names
a verb and a trigger names an event, so one id may legitimately be both), a required `risk` over the
closed four-value set, a required `reversible` over its own four, `revert()` required **iff and only
iff** `reversible: 'ledger'`, a bounded `budgetMs`, and a param list whose every entry needs a type
and an `example`. `perform`, `revert` and `cost` are stripped from everything the admin catalog
serves, exactly as an audience's `resolve` is: the browser's whole relationship with an action is
naming one by id.
`registerEventActions` is on the staging area and is reached **only** by `registerCore()`. `loader.js`
builds its own `api` facade for a module and has no method that delegates to it, so no module can
call it yet and `MODULE_API_VERSION` is untouched — the module contract, and the bump, are a later
phase's.
---
## 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 the active account on the address; **always** returns the same generic 200 (no account enumeration). Addresses are unique since Phase 1b, so this matches at most one account. Reset mail is deliberately **not** gated on `email_verified` — that gate governs opt-in engagement mail, and applying it to account recovery would lock out every user carrying an address from before verification existed. 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 | `/email/verify/:token` | — | — | validate an email-confirmation link → `{username, email}` for the page, else 404 |
| POST | `/email/verify/:token` | — (rate-limited) | — | consume the single-use link, install `email_pending` as `email` and set `email_verified`. **Issues no session** — it proves control of a mailbox, not of an account. Unauthenticated on purpose: the link is opened from a mailbox, routinely on a device with no session, and the token is the proof. **Answers 404 for an unusable link AND for an address another account confirmed first**, deliberately — the two must be indistinguishable, or the endpoint becomes an oracle for which addresses hold accounts. Logs `account.email.verified`. |
| GET | `/me/account` | cookie / bearer | — | full self account (`id, username, role, email, email_verified, email_pending, 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 |
| PATCH | `/me/account/email` | cookie / bearer (rate-limited, **password step-up**) | `{email, currentPassword?}` | request an address. **Stages it in `email_pending`; `email` is untouched**, so the account keeps receiving password-reset mail at the address it already has until the emailed link is opened — a typo cannot redirect account recovery. `currentPassword` is required when the account has one (an address is where recovery lands); an SSO-provisioned account with a null hash is exempt, the same carve-out `/me/account/password` makes. Returns `{email_pending, emailed, reason}``emailed:false` is reported honestly rather than pretending, because the caller typed this address themselves and there is no enumeration reason to hide it. **429** past the per-user send ceiling |
| POST | `/me/account/email/resend` | cookie / bearer (rate-limited) | — | re-send the link for the staged address; **400** when nothing is pending |
| DELETE | `/me/account/email/pending` | cookie / bearer | — | abandon the staged address **and retire its outstanding links**, so a confirmation email already delivered can no longer install it |
| 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/channels` | cookie / bearer | `{prefs:[{id,channel,mode}]}` on PUT | get / update own **per-channel** preferences ([`ENGAGEMENT.md`](ENGAGEMENT.md) §4.5, phase 3). Returns the delivery-channel registry (`email`/`push`/`inapp`, each with `defaultMode`, `supportsDigest`, `modes`) plus one item per subscribable id — the **union** of push streams and event triggers, one namespace (§7.2) — carrying the **effective** mode on each channel that applies to it. A trigger-only id has no `push` toggle; a mode with no stored row reads as that channels default, so a client never sees which is which. The PUT is **sparse**: only the `(id, channel)` pairs listed are written and every other pair is untouched, so setting `email` cannot disturb `push`. `off` is a mode, never an omission — which is why this endpoint has no required-empty-array case. Entries naming an unknown id, an inapplicable channel or a mode that channel does not accept are **dropped, not refused**; the full stored state is echoed back. A `push` entry is mirrored into `/me/notifications/subscriptions`, whose wire shape is unchanged |
| 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) |
| GET | `/me/notifications` | cookie / bearer | `?limit&before&unread` | **one page of the caller's in-app inbox** ([`ENGAGEMENT.md`](ENGAGEMENT.md) §4.5 G17, phase 7), newest first. `before` is a **keyset cursor** (the previous page's last id), never an offset: the list gains rows at the top while it is being read. `limit` defaults to 30, capped at 100. Carries `unread`, the count for the whole inbox rather than the page, so a client rendering both a list and a badge cannot show them disagreeing. **No parameter names a user** — the caller is the only account any of these four routes can read |
| GET | `/me/notifications/unread-count` | cookie / bearer | — | `{unread}`. Its own route because it is **polled**: asking "is there anything new" must not make the server assemble a page of bodies to answer with one integer |
| POST | `/me/notifications/:id/read` | cookie / bearer | — | mark one item read. **Idempotent** — the statement carries `read_at IS NULL`, so a second call does not move the stamp. **404 both** when no such item exists and when it belongs to another account: the same answer on purpose, so this cannot be used to ask whether an id is anybody's |
| POST | `/me/notifications/read-all` | cookie / bearer | — | mark the whole inbox read; returns `{ok, changed, unread:0}` |
**Role-agnostic self-service (`/auth/me/*`).** The **only** self-service account surface, for every
authenticated role, behind `requireAuth` **only** — any active account, never a specific role. A
client (the Android app) manages its own account through it without ever touching `/admin`
(docs/android/PLAN.md §6.4).
It used to be the third of three URL surfaces onto `account.controller`, beside `/player/account/*`
and `/admin/account/*`. **Those 14 routes were deleted.** Both were strictly *smaller* than this one —
neither carried recovery codes, and `/admin/account` carried no username or password change — so the
web client already reached in here for part of a single screen. Gating was equivalent where it
overlapped (`/player` and `/auth/me` are byte-identical `noindex, requireAuth`; `staffOnly` on
`/admin/account` was strictly narrower and bought nothing, since every handler is self-scoped to
`req.user.id`). The controller moved to `router/v1/auth/account.controller.js` beside its one
remaining router. **New self-service fields go here and only here.**
**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 (`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. (Core no longer does this for account
security — see `/auth/me/*` above — but the rule the module depends on is unchanged.) 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 — from
**ENGAGEMENT.md Phase 11** — the **engagement engine** for `news.post`.
> **The news publish path changed in Phase 11 and it is an operator-visible change.** Publishing a
> news post used to call `pushDispatch.publish('news.post', …)` directly from
> `admin.controller.js`'s `announceIfNewlyPublished`; it now calls
> `utils/newsNotify.emitNewsPost`, which emits core's declared `news.post` trigger and lets the engine
> decide. Push therefore rides a **rule** like every other channel, and core seeds that rule
> `enabled = 0` beside the four Team ones — so **news push stops on upgrade** until an operator enables
> it in Admin → Engagement → Rules, where a banner says so. The `news.post` seed carries its own
> one-shot settings key (`engagement_news_rule_seeded`) rather than joining the Team group's, because
> the Team key is already stamped on exactly the deployments this affects.
>
> **The other two things a publish fires are untouched.** `announceJobs.enqueueIfNeeded` (a one-shot
> delivery to a channel of the deployment — the in-game town crier, Discord #news, with retry) and
> `registries.dispatchPostHook('onSaved')` (idempotent state mirroring, which also runs on delete) are
> different kinds of thing and still fire exactly as they did. The emit is gated on the same
> `enqueueIfNeeded` job id the push was gated on — the single "newly published news" transition
> signal — so an edit or a re-publish still does not re-fire.
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 the configured transport (§7); 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 | `/engagement/unsubscribe/:token` | one-click unsubscribe (engagement phase 6, [`ENGAGEMENT.md`](ENGAGEMENT.md)). **The only write in this tier and the only routes 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 naming a **channel and a scope**, and its whole capability is "turn that channel off for that scope, for one account": it reads nothing, cannot turn anything back on, and names no other scope. POST acts and **always answers 200**, valid token or forged: distinguishing them would be an oracle for which (user, scope) 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 unsubscribe people who asked for nothing |
| POST · GET | `/teams/unsubscribe/:token` | **the same two handlers, at the path mail sent before phase 6 points at.** Kept permanently: mail is not editable once sent, so a route that moves is a person who cannot unsubscribe. A pre-phase-6 token verifies and reads as `{ channel: 'email', scopeKey: 'team:<id>' }` — it turns that Team's email off and, unlike before, no longer mutes its push |
| — | `/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`, `engagement`, `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, and `warnings[]` — operator conditions that are quietly not working and would otherwise be discovered by somebody not receiving an email. Normally empty. Each entry is `{code, message, href}` and each one is computed defensively: a warning that can 500 the admin landing page is a worse bug than the one it reports. Today there is one, `EMAIL_TRANSPORT_MIGRATION` (§7) |
| 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 | `/engagement/triggers` | every **declared** event trigger, its payload contract (each variable with a type, a `required` flag and an `example`) and its audience **ceiling** — plus the ceiling vocabulary itself and the closed variable-type set. Served from the module registries, **not from a table**: a trigger is declared in code by core or an installed module, so this is whatever registered on this boot and a module that was uninstalled simply stops appearing. `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §4.3 |
| GET | `/engagement/audiences` | every declared audience a rule may be pointed at, with its params and ceiling. The `resolve` function is **never served** — an audience answers with user ids on the server side only, so a module still cannot enumerate addresses. `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §5.1a |
| GET | `/engagement/channels` | every registered delivery channel a rule may send on, with its `defaultMode`. From the delivery-channel registry, so the rule editor offers exactly the set the save path checks and a module-registered channel appears with no client release. `adminOnly` |
| GET | `/engagement/audience-preview` | `?audience=` **or** `?audienceSegmentId=`, plus an optional `?triggerId=`. Runs the **same resolver the engine runs** and answers `{count, capped, ceiling, dormant, reason, permitted}` — a **count only**, never names or ids, because a module-declared segment resolves over game data and the rule editor must not become a user-enumeration surface. `capped` is true at the 5000-row audience bound, where the count is a floor and not a total; an `owner` audience answers 0 with a reason, because it resolves per event from an id the event carries; `permitted` is whether the trigger's G24 ceiling allows the reach just counted. `adminOnly` |
| GET · POST | `/engagement/rules` | list every rule annotated with **dormancy** (and why), or create one. A new rule must name a currently-registered trigger, arrives `enabled: 0` (§7.1 Q3) and has its audience checked against that trigger's ceiling. `400` carries every problem in `errors[]`, not just the first. `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §4.5 |
| GET · PUT · DELETE | `/engagement/rules/:id` | read, replace or delete one rule. The **trigger is not updatable** — a rule's cooldowns, its pending outbox rows and its send-log history are all about one trigger id, and re-pointing it would silently re-attribute all three. An existing rule may keep naming an unregistered trigger, so a dormant rule stays editable. `DELETE` cascades its cooldowns and pending outbox rows; `engagement_sends` carries **no foreign key**, so the send log outlives the rule |
| PATCH | `/engagement/rules/:id/enabled` | flip that column and no other, **without re-validating the rule**. Turning a rule off is the panic button: a rule whose module has been uninstalled, or whose trigger has since narrowed its ceiling under a saved audience, is the rule an operator most urgently wants stopped and the one a re-validating `PUT` refuses to save. Turning one on is safe unvalidated because the engine re-checks the ceiling at send time |
| GET · POST | `/engagement/segments` | list every saved audience segment annotated with dormancy (and which audience ids are missing), or save a new one. The stored `ceiling` is **derived** as the narrowest in the expression and is never taken from the caller; `not` is legal only as a child of `and`; two incomparable ceilings have no meet and the composition is refused rather than guessed. `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §5.1a |
| PUT · DELETE | `/engagement/segments/:id` | update (re-deriving the ceiling) or delete. **`409` while any rule still points at it**, with the count in the message. No foreign key does this on purpose: `CASCADE` would delete an operator's rules and `SET NULL` would silently fall each rule back to its plain `audience` column, which reaches a *different set of people* |
| GET | `/engagement/templates` | every message template, each annotated with three separately-meaningful warnings: `dormant` (pinned to a trigger no installed module declares, so its variables cannot be checked and nothing will send it), `triggerBehind` (the module is installed but its declaration has moved on past the version this template was authored against) and `seedBehind` (a newer shipped default exists and was **not** applied, because a person had edited this row). `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §4.6.2 |
| GET | `/engagement/templates/:id` | one template plus `variables` — the palette the editor offers, resolved from the trigger declaration or, for a template tied to no trigger, from the shipped seed, merged with the ambient variables every template may use. Served with the row so the editor never guesses what is legal |
| PUT | `/engagement/templates/:id` | edit any template, **including a shipped default, in place**: the save sets `customized = 1`, which is what stops the next seed bump from taking the edit back. `key` and `channel` are **immutable and the attempt is refused rather than ignored**`mailer` renders by key, so a rename would break the message it names with no error anywhere. Two refusals are the point of the route: a token (or an `email.itemList` naming a bare variable) referencing something the trigger does not declare is refused **with the variable named**, and a `published` template whose plain-text part renders empty is refused — checked by *rendering* with the declared examples, because whether a text part exists depends on what each block's `toText` does with these props |
| POST | `/engagement/templates/:id/duplicate` | the **only** way a template that is not a shipped seed comes into being, so every template on a deployment descends from one that renders. The copy always starts as a `draft`, is never `protected`, and **inherits the source's `seed_key`** — that is what carries its variable palette, not bookkeeping: a seedless, triggerless copy would resolve to the ambient variables alone and be refused for the tokens it was copied with. `409` on a taken key |
| DELETE | `/engagement/templates/:id` | `409` for a `protected` template — the system breaks without a password-reset body, so those are editable and not deletable — and `409` while any rule's `template_keys` points at the key, **naming the rules**. The same answer a segment in use gets, for the same reason: the alternative is a rule that silently stops producing mail |
| POST | `/engagement/templates/:id/preview` | renders the body **in the request**, not the stored row, using each variable's declared `example` — which is why `example` is a required part of a trigger declaration rather than documentation. A `POST` that writes nothing: an editor that could only preview what was already saved would make saving the way to find out whether a change was right. Returns both parts as JSON strings; the client renders the HTML inside `<iframe sandbox="" srcdoc>` with **no `allow-scripts`**. Serving it as a document from this origin would run operator-authored HTML under the site's own CSP with access to its cookies |
| POST | `/engagement/templates/:id/test-send` | sends what is on screen, saved or not, through the configured transport, and records the attempt in `engagement_sends` **including when it fails** — the outcome an operator most needs a record of. `trigger_id` is `NOT NULL` and a transactional template has no trigger, so the row is logged under the synthetic **`core.admin.test-send`**, which is deliberately not a registered trigger. It does not consult channel preferences or the suppression list: the address is typed by an admin about their own deployment and is not derived from a user. `409` when mail is unconfigured, `502` when the relay refuses |
| GET | `/engagement/sends` | the send log, newest first, paged (`limit` 1200, `offset`) and filterable by `triggerId`, `ruleId`, `userId` and `status`, with a `total` matching the same filters. **G15's answer.** `address_hash` is stored but **never returned**: the log keeps it so a bounce can be correlated back to a recipient (Phase 9) without becoming a second address book, and shipping it to a browser would turn a delivery screen into an offline dictionary attack against every address on the deployment |
| 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 |
| GET | `/events` | every definition with its state and current version. `?state=` filters to `draft`/`ready`/`archived` |
| GET | `/events/:id` | one definition **including its working spec** — the list serves a summary, this is the authored tree the editor renders |
| POST | `/events` | **admin, editor.** Create a draft. The slug is derived from the title once and frozen: the public event page lives at it, so a retitle must not break a posted link. A step naming an action no module registers is refused |
| PUT | `/events/:id` | **admin, editor.** Editing never touches a published version — a live run keeps the one it pinned. A step whose module has since been uninstalled is **kept and marked dormant**, not refused: the rule `engagement_rules` established for a dormant trigger, because an uninstall must not be destructive after the fact. `409` on an archived definition |
| GET | `/events/:id/versions` | the version history. Nothing edits a version; the row flagged `current` is what a new run pins |
| POST | `/events/:id/publish` | **admin only** ([`EVENTS.md`](EVENTS.md) §N2) — publishing commits a definition that a schedule will later start unattended, which is deliberately not the same gate as the live run controls. Snapshots the working spec into an immutable version. **Re-validates against the registries as they stand right now**, not from the save that wrote it: `409` naming the action when a step went dormant in between, `400` when no phase has any steps |
| DELETE | `/events/:id` | **admin only.** Archive — there is no hard delete at all, because a run pins a version and a run that could not be explained afterwards defeats the audit this system exists to provide. `409` while a run of it is still in flight |
| POST | `/events/:id/runs` | **admin only**, on the same reasoning as publish. Creates an occurrence. `INSERT IGNORE` against `UNIQUE(definition_id, scope, scheduled_for)`, so asking twice answers `200` with `created: false` and the existing row rather than creating a second. Optional `scope`, `scheduledFor`, `rehearsal`, `params` |
| GET | `/events/runs` · `/events/runs/:runId` · `/events/runs/:runId/log` | the run list, the run console (steps, their params and their idempotency keys, plus status counts) and the diagnostic log |
| GET | `/events/catalog` | the registered actions with their param schemas, risk classes and reversibility, plus the closed vocabularies the authoring form renders. **Served from the registries, not from a table** — a module that was uninstalled simply stops appearing |
| GET | `/events/series` | the arcs a definition may belong to. Read-only in this phase |
| — | `/shard/*` · `/uo-link/*` | **Served by `module-uo`, not by core** (33 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md) |
> **The `/events/*` rows above are the surface as of Phase 1**, and they are not the whole of it.
> The live run controls (Phase 3), the calendar and series writes (Phase 4), `advance` (Phase 5),
> the dry run and the action switchboard (Phase 6), the option-source route (Phase 7), `cleanup`
> (Phase 8), the cap meter (Phase 13) and the public/player reads (Phase 14a) are not listed here.
> **[`EVENTS.md`](EVENTS.md) § API surface is the canonical table** and carries every one of them
> with the reasoning for its gate; re-listing them here would be a second copy of a contract that
> file owns, and the copy that drifts is always the second one.
>
> The Phase 14a reads are the only ones outside this tier: `GET /public/events`,
> `/public/events/:slug` and `/public/events/series/:slug` on the anonymous surface, and
> `GET /player/events/history` on the self-service one. What makes something visible there is
> `listed` **and** `ready` **and** not a rehearsal, and all three are predicates in SQL rather than
> checks a caller performs — a draft, an archived definition and an unlisted one all answer 404,
> indistinguishable from a slug that never existed.
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 a **registered mail transport**, configured in
Admin → Settings → Email — never env, and never a compiled-in provider. Gmail OAuth2 and its consent
flow were **removed** in engagement Phase 1 ([`ENGAGEMENT.md`](ENGAGEMENT.md) §1.2a); SMTP is the
baseline and the only transport core ships.
**Transport, not provider.** `server/src/engagement/transports/` holds the registry and its one
registration. A transport declares an id, a label and its own **`credentialFields`**, and that
declaration is the single thing the admin form renders, the request sanitizer filters against, and
the "is this value secret" answer comes from — so adding a relay is a registration, not four edits
across a form, a validator, a column set and a model. `registerDeliveryChannel`, the other half of
§3.1, arrives with the engine that consumes it.
**Configuration** lives in the `email_config` singleton: `transport` (default `smtp`),
`sender_email`, `sender_name`, `reply_to`, and `credential_enc` — the transport's whole credential
set as ONE AES-256-GCM JSON blob (`utils/secretBox.js`), because the field list belongs to the
transport and a column per union member would make each new transport a schema change. The blob is
**write-only over the API**: secret fields are never returned, only a per-field `secretsSet` flag,
and a blob that will not decrypt reads as *absent* rather than raising — a rotated `SECRET_ENC_KEY`
must land an admin on a screen that says "unconfigured", not a 500 that takes the contact form with
it. `provider` and `refresh_token_enc` remain as **deprecated, unread columns** under the
additive-only discipline.
**What each sender still owns is its recipient, its headers and its failure contract — not what it
says.** Engagement Phase 5a moved every subject and body out of `mailer.js` into `engagement_templates`
rows (§4.6.1); the file's five senders call one seam, `engagement/templates.renderByKey`, which falls
back to the shipped seed when the row is missing or unusable. Two consequences:
- **Mail is now `multipart/alternative`.** Nothing here had an HTML part before. The **text part is
byte-identical** to what the deleted literals built — pinned by `test/emailTemplates.test.js`, whose
expected strings *are* those literals — and the HTML part is new, table-based and inline-styled.
- **Subjects now resolve the deployment's own name.** They interpolate `{{siteName}}`, which is
`settings.getInstanceName()` — the admin-set `site_title`, falling back to `BRAND_NAME`. On a
deployment that never set a site title nothing changes; on one that did, the subject finally says
what the site calls itself.
**No phone-home.** No transport may ship a default host, port, endpoint or sender
([`ENGAGEMENT.md`](ENGAGEMENT.md) §3.2). A transport with no operator configuration is
`unconfigured` and its channel is off — it never falls back to a destination we chose.
`npm run check:hosts` is the CI guardrail; it reads code, not prose, so documentation naming a host
is fine and a literal in a transport is not.
**Three supported SMTP postures**, in the order an operator should consider them:
| Posture | Shape | When |
| --- | --- | --- |
| **A relay** (recommended) | Mailgun / SES / Postmark, host + port 587 + API-key-as-password | Anything with real volume. Reputation, bounce handling and DKIM are the relay's problem, not the operator's |
| **A mailbox provider over SMTP** | e.g. `smtp.gmail.com` port 587 with an **app password** (not the account password) | A small deployment, and the migration path off the removed OAuth2 flow. Subject to the provider's own daily send caps |
| **A self-hosted MTA** | An unauthenticated relay on port 25 on the same host | An operator who already runs mail. `user` and `password` are left blank; the transport treats a username with no password as incomplete, since that authenticates as nobody |
`secure` is the field operators get wrong: on for implicit TLS on 465, **off** for 587, which
nodemailer upgrades with STARTTLS. 587-with-secure-on hangs rather than erroring cleanly.
**"Send test" is the verification, and it has to be.** Under the removed consent flow the sending
address came back from Google's userinfo and was guaranteed to be a mailbox the credential owned.
Operator-typed, it can be refused by the relay — a silent SPF/DMARC deliverability failure, not an
error — so `POST /admin/email/test` is the only thing that proves the whole configuration, and its
failures name the sender and the likely cause rather than passing a bare `550` through.
**Failure contracts.** Recipient for the contact form is the `contact_email` site setting. If email
is unconfigured or disabled, `POST /public/contact` returns `{fallback:"mailto", email}` so the
client renders a `mailto:` link, invites return the accept link for an admin to share by hand,
password resets still answer a generic 200, and Team notifications are logged and swallowed. Only
the admin test send throws — it is the only one with someone waiting to be told. Errors never leak
credentials.
**The upgrade is silent by design and therefore announced.** An existing deployment backfills to
`transport='smtp'` with no credentials, so every sink above politely does nothing and **mail simply
stops**. The admin dashboard warns whenever the deprecated Gmail token is present and no replacement
credential is; see [`UPGRADE_NOTES.md`](UPGRADE_NOTES.md).
### Deliverability: suppression, bounces and the verification gate *(engagement phase 9)*
Engagement Phase 9 ([`ENGAGEMENT.md`](ENGAGEMENT.md) Phase 9). Two mechanisms decide that a person
who is *in* a rule's audience does not get the mail, and they are deliberately at different points
in the pipeline.
**`engagement_suppressions` — checked at DELIVERY.** Keyed on `address_hash` (sha256 of the
lower-cased address), because a bounce arrives as an address and stays true after the account behind
it changed its address or was deleted. An outbox row can sit through a rule's `delay_seconds` grace
window and an address can bounce inside it, so the only correct check is the one taken immediately
before the transport call — which is also what produces the `status='suppressed'` row in
`engagement_sends` with no transport call at all.
**The verification gate — applied at ENQUEUE.** With the `email_verification_required` setting on
(seeded in Phase 1b: `on` for a fresh install, `off` for an upgrade), an unverified address is
excluded before an outbox row is written. It hangs off a channel's optional **`eligible(userIds)`**
registration rather than living in the engine: being unverified is an *email* fact, and a rule
spanning email and in-app must still reach that person's inbox. Only `email` declares one. The
excluded count comes back so the admin reach preview reports it instead of quietly promising a
number the engine will not deliver.
**Scope: engagement rules only.** Password resets, invites, verification mails and the contact form
still attempt to a suppressed or unverified address. This is the posture `passwordReset.controller.js`
already took — user-initiated mail must not be blocked by a background system's opinion, and one
reset to a dead mailbox is not a reputation problem, whereas a rule mailing thousands of people
weekly is.
**What may write a `bounce` row is narrower than "the send failed".** `src/engagement/bounceClassify.js`
is the only judge, and it is deliberately **not** `mailer.PERMANENT_CODES` — that set answers "is
retrying pointless?" and contains `EAUTH` and `554`, so reusing it would mean one stale SMTP password
suppressing every address the worker touched, silently. The classifier reads the **RFC 3463 enhanced
status** first (`5.1.1`, `5.1.2`, `5.1.3`, `5.1.6`, `5.1.10`, `5.2.1` suppress; `5.3.x`, `5.5.x` and
`5.7.x` never do, being about the server or our standing with it), and falls back — only for `550`,
`551` and `553`, and only past a veto list — to a phrase match. **Anything it is unsure about is not
suppressed:** a false negative costs one retry next month, a false positive costs a person who
silently stops hearing from the deployment.
SMTP has no *asynchronous* bounce or complaint feed — that is where an API-based provider would earn
its place — but a single-recipient send refused at `RCPT TO` throws synchronously with the reply
code intact, which is the highest-value signal there is and is what this reads. `sendNotification`
therefore returns an `smtp: { code, responseCode, response }` triple alongside its classification;
`retry` and `detail` cannot answer "was this the recipient's fault", since `550 5.1.1` and
`550 5.7.1` are an identical `retry: false`.
**Statuses.** `engagement_sends.status` gains two real writers: `suppressed` (declined to try) and
`bounced` (tried, the mailbox does not exist). `engagement_outbox.status` records `bounced` as
`failed` — its ENUM has no such value and, from the queue's point of view, a bounced row is one that
finished unsuccessfully. `complained` still has no writer: it needs a provider feedback loop.
**Routes** (all `adminOnly`, under `/api/v1/admin/engagement`):
| Route | Notes |
| --- | --- |
| `GET /suppressions` | Paged, filterable by `reason` / `channel` / `search`, plus unfiltered `byReason` totals. **Returns `address_hash` from Phase 14 on**, reversing Phase 9's decision to strip it: without a handle the only way out of the list was a `window.prompt` asking the operator to retype an address the screen has never shown them. The trade — this route is admin-only, and an admin can already suppress and unsuppress any address they can name, so the hash grants no capability they lack. `GET /sends` still strips its own hash: nothing there needs to act on a row |
| `POST /suppressions` | `reason` is forced to `manual` — an admin typing an address is not evidence of a bounce. An address already listed answers 200 with `created: false`, not 409 |
| `DELETE /suppressions` | The way out for an address the operator can type. It goes in the **body**, not the path: a path parameter lands in the access log, the browser history and every proxy in front of the deployment |
| `DELETE /suppressions/by-hash/:hash` | The per-row Lift button (Phase 14). Same effect, different input — the operator is looking at a mask and knows only the row's handle. The handle **is** safe in the path where an address is not: a sha256 already served only to an admin session leaks nothing further by being logged. Shape-validated to 64 hex characters before it reaches a WHERE clause; `404` rather than `200` when nothing matched, so a stale screen says so instead of claiming success |
| `GET · PUT /retention` | The three sweep horizons in days, with the bounds each is validated against and a `warnings` array carrying the one check that is not a static bound — a cooldown horizon shorter than the longest cooldown on an ENABLED rule. `engagement_suppressions` is deliberately absent: it does not expire. The `PUT` is **sparse** (saving one select cannot clobber another admin's concurrent change) and **refuses out-of-range rather than clamping**, because storing something other than what was typed would leave the screen describing a policy the deployment is not running |
**Neither route ever returns `address_hash`**, the same rule `GET /sends` follows: a sha256 of every
address on the deployment, handed to a browser, is an offline dictionary attack. What the list
returns is `address_masked``d***@example.com` — which Phase 9 added to §4.5's DDL because a
hash-only table cannot be operated: an operator has to be able to see a whole domain refusing mail
and to let back in somebody who fixed their mailbox. The domain survives intact for the first; the
local part is destroyed rather than shortened, so the column can never be read back as an address
book. The consequence is that **lifting a suppression needs the full address typed in** — the screen
genuinely does not have it, which is the privacy design working rather than a rough edge.
---
## 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 |
```