diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index 2edd04e..f2c884a 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -1,1643 +1,1670 @@ -# 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 '' | | -| 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_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. - -### 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) | - -**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 `