# 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 `@=` 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 `` 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 | | 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) (engagement phase 4a) | 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 | | last_fired_at | DATETIME NOT NULL | | | fire_count | INT NOT NULL DEFAULT 1 | | `PRIMARY KEY(rule_id, user_id, subject_key)`, `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. **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 | `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. ### 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. ### 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 2–5) *Twelve rows in the table below: `content_reports` is listed here because Team forum content is its first consumer, and it is deliberately **not** one of the eleven — it carries no `team_*` prefix, its `target_type` is an open VARCHAR, and a wiki page or a news comment is meant to become a value in it rather than a table of its own.* A Team is a **core** entity that a **module** answers for. The module says what Teams exist and who is in them, through the team provider; core stores that answer, gates it and displays it. Every table here is core-internal — a module must never read or write one, even though a module is what fills them — and none carries a `_` 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 `` | | `team_forum_moderation` | append-only, per Team, recording `actor_role` — WHICH authority was exercised. Deliberately not merged with `mod_actions`/`appeals`, which is Discord-sanction-shaped | | `team_forum_uploads` | attribution for `uploads` mode: who uploaded what, when, how big, and to which post. Also the sweep's worklist | | `team_notification_prefs` | per-Team notification preference (phase 6). **Opt-out for push, opt-IN for email** — `muted` defaults 0 and `email_mode` defaults `'off'`, so the two sinks default opposite ways and the asymmetry lives here rather than in a condition anyone has to remember. Team scoping lives in this table and in the recipient computation, never in a stream id. `last_digest_at` is the digest's only state and the worker is its only writer | | `team_integration_config` | where a Team's notifications go on another platform (phase 8). One row per (platform, Team) plus a **deployment-wide default** whose `team_id` is NULL — expressed with a generated `team_key AS IFNULL(team_id, 0)` in the unique key, because a NULL cannot live in a primary key and the default row is the base case of the whole override mechanism. `members_ack` is a **precondition, not a preference**: forum posts and announcements are members-only always, core cannot see a channel's permissions, so enabling one requires an attributed operator acknowledgement that the destination is restricted — and changing the channel clears it | | `team_integrations` | a Team's provisioned resource on another platform — today its Discord **voice channel and the role that opens it** (§7.3, phase 9). Both refs on one row because they are one lifecycle: a role for a channel that no longer exists is a badge for nowhere. `state` is core's BELIEF about the platform, never the platform's answer — the reconciler writes what it just did and the next pass re-derives the truth. A Team that stops qualifying goes to `pending_removal` with `remove_after` rather than being deleted at once, so a Team hovering around the size threshold does not delete-and-recreate its channel and change its id. `synced_at` is separate from `updated_at`, which moves whenever core writes a belief including an error | | `content_reports` | member-raised abuse reports (phase 5). **Not a `team_*` table and not named for the forum** — `target_type` is a plain VARCHAR so a wiki page or a news comment becomes a value rather than a table. Team forum content is only the first consumer | **Core had no user-facing report flow of any kind before `content_reports`.** `moderation`, `mod_notes` and `appeals` are all either staff-initiated or Discord-sanction-shaped; nothing anywhere let a *member* say "this is a problem". That was survivable while every piece of content on the site came from staff, and stops being the moment a Team forum lets players write to each other. Four properties are worth carrying: - **Reports reach site staff and nobody else.** A Team's leaders moderate their own forum, so a leader-visible queue would route a complaint *about* a leader back to that leader. There is one queue, mounted at `/admin/moderation/reports` beside appeals — a staffer working a queue should have one place to work — and no leader-facing counterpart anywhere ([`TEAMS.md`](TEAMS.md) §5.6, org lead 2026-08-18). - **A report is not a moderation action.** Filing one changes nothing about the content; it opens a queue item. That keeps it clear of `team_forum_moderation`, which records things that actually happened, and stops "report" becoming a way for any participant to hide anything. - **One OPEN report per (target, reporter)**, enforced by a unique key over a generated `open_marker` that is `1` while open and `NULL` once closed — the same encoding as `team_forum_grants.active_marker`, and for the same reason: only the *live* rows may collide. A closed report frees the slot, so a member whose first report was dismissed may raise the same target again if the behaviour recurs. - **Every transition writes `activity_log`, `dismissed` included.** A queue where acting is audited and declining to act is not is one where the cheapest way to make a report vanish leaves no trace. **`teams_forum_edit_window_minutes`** (0–1440, default 15) bounds how long an author may edit their own post; staff are not bound by it. It is resolved on the server **twice** — the read path stamps each post with `canEdit`/`editableUntil` so a client knows whether to draw the control, and the write re-derives it from `created_at` before allowing anything. The read is advice, the write is enforcement, and the split exists because a time-bounded permission must not take its clock from the party it bounds. It is deliberately **not** in `settings.getPublic()`: the client that needs the number is the admin screen, and the client that needs the decision already has it per post. **The forum's tables are guarded at the ROUTE and never at the data.** `teams_forums_enabled` off means every forum route answers **404** — not 403, which would advertise a feature the operator deliberately turned off — while threads, posts, grants and notification preferences are all untouched. Re-enabling restores the forum exactly as it was. That is the same principle as the module disabled guard ([`MODULE_API.md`](MODULE_API.md) §4.5). **The author never writes an `` tag, and that is what makes the image policy enforceable.** The shared sanitiser (`utils/sanitizeHtml.js`) allows `` 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 `` 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: `; the admin checkbox is how the gate is presented, never the gate. The accepted TEXT VERSION is stored in `teams_forum_uploads_ack`, whose `updated_by`/`updated_at` answer who and when, plus an `activity_log` row. If the wording is ever revised the stored version goes stale — uploads **keep working**, a persistent banner requires re-acknowledgement, and no other forum setting may be saved until it is given. `teams_forums_enabled` and `teams_forum_images` are published in `settings.getPublic()`; the acknowledgement is not. **`team_activity` is bounded on purpose.** A feed fed by a game loop is the obvious unbounded-growth failure, so retention ships with the feed rather than after someone notices: a nightly worker applies an age horizon (`team_activity_retain_days`, default 90) **and** a per-Team row cap (`team_activity_row_cap`, default 2000). Both, because either alone has a hole — age lets one busy guild write a million rows inside the window, and a cap keeps a dead Team's feed forever. `dedupe_key` is optional and unique per Team, written with `INSERT IGNORE` — the same idempotence trick `shard_events` uses, and what makes a sidecar reconnect backfill safe to replay. Core deliberately emits **no join items for a Team's first roster** (`roster_synced_at IS NULL`): importing a 155-member guild is one Team arriving, not 155 people joining. **A rename is an archive plus a create**, never an edit. Core's identity is (`module_id`, `external_id`, `name`) taken together: a known id under a new name archives the old row (`archived_reason='renamed'`, `succeeded_by` pointing at the successor) and creates a new one, so the old Team keeps its activity, its grants and its forum as a read-only record and its old slug still resolves. Whether two names are "really" the same guild is the module's judgement, expressed in whether it reuses the external id. **Uniqueness among ACTIVE rows only** is expressed with STORED generated columns, because MariaDB has no partial index and NULL never collides in a UNIQUE key: `active_key` and `active_slug` on `teams` are NULL for archived rows, so any number of them may share an `external_id`. **`team_forum_grants` departs from the obvious encoding, and the reason matters.** Its marker is `active_marker AS (IF(revoked_at IS NULL, 1, NULL))` with `user_id` in the KEY rather than the generated column, because MariaDB refuses `ON DELETE SET NULL` on a foreign key whose column is a base column of a stored generated column (error 1901) — and `SET NULL` is required here: `CASCADE` would delete the audit trail of who granted whom, which is exactly what an audit exists to survive. The semantics are identical: at most one active grant per (team, user), unlimited revoked rows. **Account deletion is settled per column, not inherited from the defaults.** Content and audit survive; preferences and links do not. `team_members.user_id` and every actor column on the grant ledger and the approval queue go `SET NULL` with a **username snapshot** alongside, so the record stays readable after the account is gone. Only `team_id` cascades. **Two columns exist that the design of record did not contemplate**, both on `teams` and both serving the refusal gates below: `roster_synced_at`, because `team_sync_state` holds one row per *module* and a single Team's roster can be left untouched while the others sync — without a per-Team stamp that Team's page would report the module's last success as its own; and `members_empty_since`, the per-Team twin of `pending_empty_since`. Design of record: [`TEAMS.md`](TEAMS.md) Parts 2 and 5. The contract surface a module sees is [`MODULE_API.md`](MODULE_API.md); everything in these tables is explicitly *not* it. --- ## 4. API contract Base path `/api/v1`. JSON in/out. Auth via httpOnly cookie (`isLoggedIn` reads it; also accepts `Authorization: Bearer` for API testing). ### 4.0 The authoritative route list The prose tables below are **orientation for a human reader** and can drift. Two generated artifacts are authoritative, and they answer different questions: | Artifact | Source of truth for | Generated by | |---|---|---| | `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs CORE serves.** Every core URL — the public app plus the internal listener — sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack | | `server/swagger/swagger-output.json` — merged into `/api/docs` | **What each core route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations | Both are **core's**. An installed module's routes are in neither: they are in that module's own frozen manifest and its `swagger-fragment.json`, in its own repo, and core merges the fragment into `/api/docs.json` at request time (§4.0.1). So on a running instance the served document describes more than the committed one does, which is the intended arrangement rather than a drift — `swagger-output.json` has to regenerate identically on any machine, whatever happens to be installed on it. The split is deliberate: Swagger is annotation-derived, so an unannotated route is invisible in it and it churns whenever a description is reworded — it documents *intent*. The manifest is introspection- derived and records *reality*, which is why it, not Swagger, is the thing PR checks freeze (`npm run routes:manifest -- --check`). Both artifacts are emitted with **sorted** keys, so a diff in either is proportional to the change rather than to how the routers happen to be traversed. `swagger.js` additionally strips trailing slashes from generated path keys — see *Regenerating the spec* in the website README for why the domain split makes that necessary. Scope: the manifest keeps `/api/**` and `/.well-known/**` from the public app plus everything on the internal listener. The SPA catch-all, `/uploads`, `/brand` and `/modules` are filesystem-conditional static mounts — not API contract, and including them would make the output depend on whether CI had built the client, or on which modules happened to be on the volume of the machine that generated it. `/modules//` 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 channel’s 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 the create/publish-post path for `news.post`. The catalog is assembled at boot by `modules/registries.js` from core's own streams (`config/coreStreams.js` — just `news.post`) plus each installed module's. The seven shard streams and their event→stream mapping left with `module-uo` in Phase 3 and are registered by it; their ids are grandfathered to that module ([`MODULE_API.md`](MODULE_API.md) §6.5) because they are stored in `notification_subs` and read by the shipped Android app. Security invariants: - **Whether a stream is safe to publish is the registering module's decision, and it stays inside that module.** `module-uo` applies the same public/admin split as its SSE feed — public streams are drawn only from its own allowlist, so a sensitive kind (audit/cheat/IP/login-attempt) can never produce a public push — and resolves personal streams (`vendor.sale`, `house.idoc`, `account.login`) to the *owning* user's devices through its own ownership check. Core never sees a shard event. **`utils/pushDispatch.js` publishes to a stream id someone else resolved** and knows nothing about what produced it, which is what lets a second game's module reuse the whole pipe. - **SSRF guard.** A device `endpoint` is a client-supplied URL the server POSTs to, so registration and every publish validate it is HTTPS, non-private/loopback, and (when configured) on the shard's ntfy allow-set (`NTFY_BASE_URL` / `NTFY_ALLOWED_ORIGINS`). - ntfy is treated as an **untrusted relay** — no per-user accounts, unguessable topics; an optional `NTFY_PUBLISH_TOKEN` hardens backend→ntfy publishes but is not required. See docs/android/PLAN.md §11. ### Mobile SSO Authorization Bridge (`/auth/mobile/sso/*`, M9) Native "Sign in with Google/Discord" for the Android app **without shipping any OAuth secret in the app**. The website stays the identity authority: each shard owner's provider credentials live in `auth_providers` (encrypted at rest) and are only ever used server-side. The bridge is a **new consumer of the existing SSO + mobile-bearer machinery**, not a parallel auth path — it reuses the `/auth/sso/:provider/*` redirect flow, the link-only + opt-in-provisioning policy, the TOTP gate, and issues the **same** token pair as `/auth/mobile/login`. The TOTP gate it reuses includes the **trusted-device skip** (see `TRUSTED_DEVICES_MFA.md` §6). Because the app opens this flow in a Custom Tab, which shares the system browser's cookie jar, the `rg_trust` cookie set on the TOTP form is presented back on the next app sign-in — so "don't ask me again" works for native SSO without the app injecting a header into a tab it does not control, and without a trust token ever appearing in a start URL. | Method | Path | Auth | Body / Query | Purpose | |---|---|---|---|---| | GET | `/auth/providers` | — | — | **reused** discovery; the app renders provider buttons from this (never exposes secrets) | | GET | `/auth/mobile/sso/start` | — (rate-limited per-IP + per-provider) | `?provider&code_challenge&state&redirect_uri` | validate provider enabled + `redirect_uri` **exact-match** allowlist; insert a `mobile_auth_sessions` row; create the existing `sso_tx` tagged `mode:'mobile'` carrying `session_id`; **302 to the IdP** (existing authorize URL) | | GET | `/auth/sso/:provider/callback` | — (signed `sso_tx`) | `?code&state` | **existing** endpoint; a new branch when `tx.mode==='mobile'`: resolve the account (same policy as web login incl. TOTP), mint a single-use hashed authorization code into `mobile_auth_codes`, mark the session `completed`, and **302 to `redirect_uri?code=…&state=…`** (the app's original `state`) — **no cookie is set** | | POST | `/auth/mobile/sso/exchange` | — (rate-limited per-IP) | `{code, code_verifier}` | validate the code exists / unexpired / unused (mark used) and `sha256(code_verifier)` matches the stored challenge → issue the existing mobile access + refresh pair (`createMobileSession`) → `{accessToken, refreshToken, expiresIn, user}`. When the session carries `trust_device`, also mint a `platform:'mobile'` trusted device and add `trustToken` — minted here, on an authenticated app→server call, so it never travels in the deep link. Best-effort: at the trusted-device cap the response simply omits it rather than failing the sign-in | | POST | `/auth/mobile/refresh` | — | `{refreshToken}` | **reused** unchanged — rotate the pair | | POST | `/auth/mobile/logout` | bearer | `{refreshToken?, all?}` | **reused** unchanged — revoke this (or all) refresh token(s) | | GET | `/auth/me/sessions` · DELETE `…/:id` | cookie / bearer | — | list / revoke own **mobile sessions** (device_name, last_used_at, created_at) — the "Active Devices" surface (distinct from `/auth/me/devices`, which is push endpoints) | **Two PKCE layers (do not conflate).** - *Layer A (existing):* website ↔ IdP. The `code_verifier` is generated at `/start`, kept only in the httpOnly `sso_tx` cookie, sent to the IdP token endpoint at the callback. Unchanged. - *Layer B (new):* app ↔ website. The **app** generates `code_verifier`/`code_challenge`; the challenge is stored in `mobile_auth_sessions` at `/start`; the verifier is presented at `/exchange`. This is what stops an intercepted callback code from being redeemed by anyone but the real app. **State / CSRF.** The app-generated `state` is stored at `/start`, echoed on the callback redirect, and **verified by the app** before it calls `/exchange` — a CSRF guard independent of both PKCE layers (a different app instance triggering `/start` cannot complete someone else's flow). **Redirect-URI allowlist.** `/start` and the callback validate `redirect_uri` by **exact match** against a configured allowlist (`MOBILE_AUTH_REDIRECT_URIS`, default the one fixed application-owned callback `runicgateway://auth/callback`) — **never prefix match** (prefix matching on custom schemes is a known open-redirect vector). Tokens are **never** placed in the callback URL — only the short-lived authorization code. *App Links (implemented).* When the admin toggle `mobile_app_links_enabled` is **on**, `/start` also accepts the self-origin HTTPS callback `https:///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: `. 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 `