# UOMysticmoon Website — Backend Design > Phase 1 of 3: **backend design** → Claude Design (frontend mockup) → coding. > This document is the contract the later phases build against. Public contact email: **UOMysticmoon@gmail.com** --- ## 1. Stack & top-level decisions | Concern | Decision | Rationale | |---|---|---| | Runtime | Node.js + Express | serverlinkr pattern | | Database | MariaDB (own container) | spec; `mariadb` pool, parameterized SQL, no ORM (keeps the lightweight `model`/`db` split from serverlinkr) | | Auth | JWT in an **httpOnly cookie** | spec says "JWT auth" + "secure cookies when HTTPS"; httpOnly keeps the token out of JS (XSS-safe), `SameSite=Strict` covers CSRF for a same-origin admin panel | | Frontend | React + Vite, same repo, served by Express in prod | spec | | Hashing | bcrypt (`bcryptjs`) | spec; matches serverlinkr | | Deploy | Docker Compose (app + db) behind Pangolin | spec | **Adapting serverlinkr → this project** - `*.mongo.js` (mongoose) → `*.db.js` (MariaDB queries), exactly as the spec names them. - Drop the session/passport hybrid (`express-session`, `passport`, `passport-local`, `connect-mongo`). Pure stateless JWT instead — simpler and matches "JWT auth". - Routes grouped by **access level** (auth / public / admin) per spec, instead of serverlinkr's per-entity routers. Models stay grouped by **entity**. --- ## 2. Folder structure Skeleton from the spec, with a small number of justified additions marked **(+)**. > **Complete.** The monolithic route files (`admin.routes.js` especially, originally 1552 lines / > 110 routes) have been split into one router file per business capability — **in place, with every > URL unchanged**. See [API_V2_PLAN.md](./API_V2_PLAN.md) § Phase 2. > > `users`, `account`, `invites`, `auth/providers` (PR 1, 28 routes), `moderation`, `bot-activity`, > `activity` (PR 2, 18 routes), `posts`, `uploads`, `wiki`, `pages` (PR 3, 31 routes) and `shard`, > `uo-link`, `email`, `discord-bot`, `settings`, `dashboard`/`site-mode` (PR 4, 33 routes) each live > in their own router under `admin/`, behind `admin/index.js`. PR 5 did the same for `public/` (24), > `player/` (20) and the residual `auth/` (10). **`admin.routes.js`, `public.routes.js`, > `player.routes.js` and `auth.routes.js` are all deleted**; each group is now a directory whose > `index.js` owns the group gate and the mount table and declares no routes of its own. > > "Every URL unchanged" is enforced mechanically, not by review: `server/scripts/routeManifest.js` > (`npm run routes:manifest`) walks the live Express stack and writes the sorted > `{ method, path }` freeze to `server/routes.manifest.json`, mirrored here as > [api-route-inventory.json](./api-route-inventory.json). PR checks regenerate it and fail on any > diff, so a split PR that moves a URL cannot merge silently. See § 4.0. ``` server/ .env.example package.json db/ schema.sql (+) DDL, also auto-run by the MariaDB container seed.js (+) seed wiki pages, default settings, first admin src/ server.js bootstrap: ensure schema, then listen on 0.0.0.0 app.js express app + middleware wiring router/ api.router.js mounts /v1 v1/ v1.router.js mounts /auth /public /admin /player auth/ index.js mounts the routers below; no group gate — /auth is where an anonymous caller becomes authenticated, so the authenticated parts gate themselves. Mount order is load-bearing (see session.router.js) login.router.js (2) /auth/login + /login/totp — shared loginGuards stack register.router.js (1) /auth/register — honours the player_registration setting invite.router.js (2) /auth/invite/:token[/accept] — the token is its own authority, so it bypasses player_registration password.router.js (3) /auth/password/forgot + reset/:token session.router.js (2) POST /logout and GET /me — the two singletons owning no path segment, so mounted at the group root, LAST: the /me sub-routers below also match the bare /me and supply its noindex header me.routes.js (23) /auth/me/account*, sessions, trusted devices — router-level requireAuth notifications.routes.js (3) /auth/me/devices*, notifications/* mobile.routes.js + /auth/mobile/* — native bearer login mobileSso.routes.js (5) sso.routes.js (4) mounted PATHLESS: owns two prefixes, /auth/providers and /auth/sso/* loginGuards.js shared backoff/slow/limiter stack for every credential-guessing surface (not a router) auth.controller.js + invite/passwordReset/sso/mobile controllers public/ index.js mounts the routers below; **no group gate** — this surface is anonymous by design (SPA logged-out, Discord bot, Android ShardStream) posts.router.js (2) /public/posts/:category[/:idOrSlug] wiki.router.js (4) /public/wiki — /categories and /tags MUST precede /:slug pages.router.js (2) /public/pages — the draft-preview route precedes /:slug and is deliberately not site-mode gated shard.router.js (14) /public/shard/* incl. the anonymous SSE stream; never site-mode gated atlas.router.js (6) /public/atlas/* — the spawn atlas. NOT under /shard: nothing here touches the sidecar, and unlike /shard it IS 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 + shard.controller.js player/ index.js owns the shared `noindex, requireAuth` gate (authenticated, ANY role — staff are a superset of players) and the mount table account.router.js (8) /player/account — credentials, TOTP, linked identities; handlers shared with /admin/account and /auth/me shard.router.js (8) /player/shard — linking + own roster, vendors, chars, sales, houses appeals.router.js (4) /player/appeals shard.controller.js + appeals.controller.js settings/ index.js owns the shared `noindex, requireAuth` gate (authenticated, ANY role) and the mount table. A fifth group, for site-wide settings that need a login but no particular role — /public is anonymous, /admin/settings is adminOnly while AdminLayout renders for editors and moderators, and /player is self-scoped data nav.router.js (1) /settings/nav — the nav_admin and nav_player overrides, read by the layouts that render them theme.router.js (1) /settings/theme/options — the closed sets the admin appearance form is built from. Static; no DB read nav.controller.js + theme.controller.js admin/ index.js mounts the capability routers below at their own prefixes; owns the shared `noindex, isLoggedIn, staffOnly` gate and declares no routes itself account.router.js (6) /admin/account — self-service, no adminOnly users.router.js (15) /admin/users — adminOnly 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) shard.router.js (16) /admin/shard — 7 self-service account-linking routes (no extra gate, handlers shared with /player/shard) + 9 in-game staff ops on modAccess, per route uoLink.router.js (5) /admin/uo-link — sidecar config, town crier, admin SSE — adminOnly email.router.js (6) /admin/email — Gmail OAuth2 delivery — adminOnly discordBot.router.js (2) /admin/discord-bot — adminOnly settings.router.js (4) /admin/settings — adminOnly. The DELETE /:key is "reset to default" and carries its own key allowlist (theming/nav keys + the hero draft) so it can never drop site_mode or the uo-link config; 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) model/ users/ users.model.js + users.db.js posts/ posts.model.js + posts.db.js (news/five-on-friday/newsletter/screenshots) wiki/ wiki.model.js + wiki.db.js settings/ settings.model.js + settings.db.js activity/ activity.model.js + activity.db.js (+) admin activity log middleware/ (+) siteMode.js LIVE/MAINTENANCE gate for public content noindex.js X-Robots-Tag: noindex,nofollow on admin rateLimit.js login limiter validate.js express-validator error handler utils/ auth.js JWT sign/verify, isLoggedIn middleware db.js MariaDB pool + ensureSchema() mailer.js (+) nodemailer; mailto fallback if SMTP unset client/ built in Phase 2/3 (React + Vite) Dockerfile docker-compose.yml .env.example .gitignore ``` **Why the additions:** the spec's feature list requires an activity log, a maintenance-mode gate, login rate limiting, admin `noindex`, and SMTP email — none fit cleanly in the four listed models/two utils. They're isolated in `middleware/` + one `activity` model + `utils/mailer.js`, and the spec explicitly says the layout is "expandable." --- ## 3. Database schema (MariaDB) `utf8mb4` throughout. Created idempotently on boot (`ensureSchema()`) **and** shipped as `db/schema.sql` for the container's `/docker-entrypoint-initdb.d`. ### users | col | type | notes | |---|---|---| | id | INT PK AUTO_INCREMENT | | | username | VARCHAR(32) UNIQUE NOT NULL | | | password_hash | VARCHAR(72) NOT NULL | bcrypt; **never** returned by the API | | role | ENUM('admin','editor') NOT NULL DEFAULT 'admin' | room to grow | | created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | | | last_login_at | DATETIME NULL | shown in user management | ### posts — one table, four categories | col | type | notes | |---|---|---| | id | INT PK AUTO_INCREMENT | | | category | ENUM('news','five_on_friday','newsletter','screenshot') NOT NULL | | | title | VARCHAR(200) NOT NULL | | | slug | VARCHAR(220) NULL | optional clean URL | | excerpt | VARCHAR(400) NULL | list teaser | | body | MEDIUMTEXT NULL | markdown/HTML; main text for news/5oF/newsletter | | image_url | VARCHAR(500) NULL | required for `screenshot`, optional hero elsewhere | | published | TINYINT(1) NOT NULL DEFAULT 0 | publish/unpublish toggle | | author_id | INT NULL FK→users(id) | ON DELETE SET NULL | | created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | | | updated_at | DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | | | published_at | DATETIME NULL | set when first published; list order | Index: `(category, published, published_at DESC)`. ### wiki_pages | col | type | notes | |---|---|---| | id | INT PK AUTO_INCREMENT | | | slug | VARCHAR(120) UNIQUE NOT NULL | e.g. `new-player-guide` | | title | VARCHAR(200) NOT NULL | | | body | MEDIUMTEXT NULL | markdown/HTML | | updated_by | INT NULL FK→users(id) | | | created_at / updated_at | DATETIME | | Seeded with the 8 spec categories: `new-player-guide, maps-atlas, systems, items, monsters, crafting, lore, rules`. ### settings — key/value, expandable | col | type | notes | |---|---|---| | `key` | VARCHAR(64) PK | | | value | TEXT NULL | | | updated_by | INT NULL FK→users(id) | | | updated_at | DATETIME ON UPDATE CURRENT_TIMESTAMP | | Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`, `site_mode_changed_by`, `maintenance_message`, `status_message`, `homepage_teaser`, `contact_email` (=UOMysticmoon@gmail.com), `site_title`. **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 shard-feature filters in `SiteHeader.jsx` / `AdminLayout.jsx`, which are unchanged and 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. ### 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. ### shard_ruleset — the shard's published ruleset (Protocol 3.0) Singleton row (`id = 1`, CHECK-constrained) holding the latest `world.ruleset` frame: `rev`, `expansion`, `payload` JSON (the whole frame), `t`, `updated_at`. The shard re-emits the complete ruleset on every sidecar connect, so this is an **overwrite, not an append** — and the kind is deliberately **not** in `LOGGED_KINDS`, since logging it would put a duplicate row in `shard_events` on every reconnect while `server.hello` already marks each of those. The frame is stored whole rather than normalized into columns: it is a flat description of server config that is read as one page, so splitting it up would mean a schema change every time the shard grows a new block. `rev` (the shard's FNV-1a of the body) and `expansion` are hoisted only because they are cheap to display — the same payload-plus-hoisted-columns shape `shard_champs` uses. **No row means the shard has never published one** (an older plugin, or `Bridge.RulesetEnabled=false`), served as `null` rather than `{}`: "not published yet" and "published, everything off" are different answers and the page renders them differently. ### shard_points_boards — points / loyalty leaderboards (Protocol 3.0) One row per point system, keyed by the shard's own `PointsType` name (`QueensLoyalty`, `CleanUpBritannia`, …). The shard carries ~25 of these, each a standing players build over months. Columns: `system` (PK), `name`, `name_cliloc`, `max_points`, `players`, `show_on_gump`, `payload` JSON (the whole `points.board` frame), `t`, `updated_at`. **The top-N list stays inside `payload`** rather than being normalized into a `shard_points_entries` table. It is a fixed-size list (10 by default) that is only ever read whole — exactly like `shard_governors.candidates` — so normalizing buys nothing until something needs a per-character reverse lookup, and a character's own standings already ride inside `char.profile` instead. Board state, not events: `points.board` is **not** in `LOGGED_KINDS`, for the same reason `guild.update` isn't. The shard emits a frame every time anyone's score moves a top ten, so logging would grow `shard_events` without bound for something whose only interesting value is its latest version. There is also **no delete path** — the shard's set of systems is fixed at startup, so there is no `points.remove` to mirror. Two values carry non-obvious meanings, both set by the plugin and both documented in [`link/INTEGRATION.md`](../link/INTEGRATION.md) §4: - **`max_points = 0` means uncapped**, and on a real shard that is the *common* case (ServUO's uncapped idiom is `double.MaxValue`, which the plugin normalises to 0). Anything rendering `points / max_points` must special-case it. - **`name` is usually NULL**, with `name_cliloc` set instead — most systems name themselves with a cliloc rather than a literal. Listing therefore orders by `COALESCE(name, system)`, so boards awaiting cliloc resolution sort by their own key rather than clumping together under NULL. ### shard_vendors / shard_vendor_items — the player-vendor marketplace (Protocol 3.0) The shard-wide shop index, fed by `vendor.listing` / `vendor.listing.remove`. One row per player vendor and one per priced listing. Full operator detail in [`MARKETPLACE.md`](MARKETPLACE.md); the design is `docs/link/v3.md` §8. | Table | Shape | |---|---| | `shard_vendors` | `serial` (PK), `shop_name`, `owner_serial`, `owner_name`, `map`/`x`/`y`/`z`, `region`, `house`, `item_count`, `item_total`, `truncated`, `t`, `updated_at`. Indexes on owner, map, region and `updated_at`. | | `shard_vendor_items` | `id` (PK), `vendor_serial`, `serial`, `item_id`, `hue`, `amount`, `price`, `name`, `cliloc`, `display_name`, `child`. Indexes on `vendor_serial`, `price`, `item_id`, `display_name`, and `(display_name, price)`. | **Ingest is per-vendor and authoritative**: the frame is the whole shop, so ingest is delete-then-insert of that vendor's listings inside one transaction. All-or-nothing matters specifically because the two writes are "the shop" and "what is in it" — a failure between them leaves a shop advertising an inventory it no longer has, which is visibly wrong and indistinguishable from a genuinely empty shop. No foreign keys, consistent with every other `shard_*` table. **There is deliberately no `payload` column**, unlike `shard_points_boards` directly above. The board's top-N is a fixed-size list read whole, so it lives in JSON; here the items *are* the searchable rows, so they are normalized and nothing is left worth duplicating. The sidecar keeps the whole blob — outage resilience is its job, search is ours. Market state, not events: neither kind is in `LOGGED_KINDS`, and this is the strongest case of the three v3 kinds. One frame carries up to 250 listings and the sweep re-emits a shop on any price change, so logging would turn `shard_events` into a price history nobody reads. Two columns carry non-obvious meanings: - **`item_count` vs `item_total`.** `item_count` is what the frame published; `item_total` is what the shop actually holds. They differ when `truncated` — the shard caps listings per frame (`Bridge.MarketMaxListings`, 250 by default), and a commodity reseller with thousands of stacks genuinely exceeds it. Any UI must show both or it presents a partial shop as complete. - **`display_name` is denormalized at ingest**, resolved from the item's literal `name` (preferred — a player set it, so it is more specific) else its `cliloc` against `shard_clilocs`. Resolving at query time would put the cliloc table on the hot path and make search-by-name impossible. Because the shard's diff sweep will not re-send an unchanged shop just because the site learned what its items are called, **a cliloc import triggers a bulk re-resolution** of this column (after a boot import and after an admin import; ~50 ms per thousand rows, never throws). `updated_at` is written explicitly on every upsert rather than left to `ON UPDATE CURRENT_TIMESTAMP`, which MariaDB does not fire when every column is written back unchanged. A shop re-published identically is still *freshly confirmed*, and without this the staleness banner would age a perfectly current shop forever. ### shard_feature_visibility — per-feature audience config (Protocol 3.0) One row per shard feature: `feature` (PK), `enabled`, `audience` (a rung on the ladder in §6.5), `stream` (whether the feature's kinds fan out over SSE at all), `field_rules` JSON (`{field: rung}` for the sensitive fields only), `updated_by`, `updated_at`. **An absent row means "use the compiled default", and the compiled defaults reproduce pre-3.0 behavior — so an empty table is a no-op and there is nothing to seed.** Stored rows are merged over the defaults on read, which is also where the invariants are re-applied: a row naming an unknown feature is ignored (a stale row must not resurrect a removed feature), an invalid rung falls back to the default rather than failing open, and a rule touching a locked field (`acct` / `webId`) is discarded. See §6.5. ### shard_spawn_* / shard_regions / shard_landmarks / shard_champion_spawns / shard_atlas_meta — the spawn atlas (Protocol 3.0) Static shard **content**, not live shard state. Nothing here comes from the sidecar: the atlas is derived from the shard's own ServUO tree, re-read on **every server boot** and hash-gated so an unchanged tree costs one read pass and no write. Nothing is precomputed and committed — a shard's maps change over its life, and a snapshot in the repo would silently drift from the world players actually see. These tables stay populated whether the shard is up or not. Full operator detail in [`SPAWN_ATLAS.md`](SPAWN_ATLAS.md); the design is `docs/link/v3.md` §6. **No facet name appears anywhere in the code.** A shard may add facets, replace them, or rename them when its maps are updated; the facet set is discovered from the tree, and the loose spellings in `Data/Locations` are matched against it rather than looked up in a table. | Table | Key columns | |---|---| | `shard_spawn_creatures` | `slug` PK, `name`, `total`, `points`, `facets` JSON, `art` NULL | | `shard_spawn_points` | `id` PK, `facet`, `name`, `x`, `y`, `width`, `height`, `spawn_range`, `max_count`, `min_delay`, `max_delay`, `tod_start/end/mode`, `region`, `landmark`, `label` | | `shard_spawn_point_types` | `(point_id, slug)` PK, `max_count` | | `shard_regions` | `facet`, `name`, `type`, `priority`, `parent`, `rects` JSON | | `shard_landmarks` | `facet`, `name`, `grp`, `x`, `y`, `z` | | `shard_champion_spawns` | `slug` PK, `name`, `grp`, `type`, `random_type`, `facet`, `x`, `y`, `z`, `radius`, `label` | | `shard_atlas_meta` | Singleton (`id = 1`), `payload` JSON (counts, a sha256 per source file, `parserVersion`), `imported_at` | | `shard_atlas_pending` | Singleton (`id = 1`), `status` (`pending`/`rejected`), `payload` JSON, `detected_at` | The first seven are **import-owned**: a refresh empties and reloads every one inside a single transaction, so a failed reload leaves the previous atlas intact rather than a half-loaded world. Nothing else writes to them, and nothing holds a foreign key to them — no FKs at all, consistent with every other `shard_*` table. **`shard_atlas_pending` is the security-relevant one.** A refresh that would REMOVE a facet is never applied automatically: facet loss is indistinguishable at boot from a half-copied or mid-update tree, so it is staged here for an admin to approve or reject, and **startup is never blocked by it**. Only the decision is stored — source hashes plus the facet diff, a few KB — and approving re-parses the tree, so a multi-megabyte blob never lands in the database and what gets applied matches the tree at approval time. A rejection is remembered against those exact hashes so a declined refresh does not re-prompt on every restart. Everything else (new facets, renamed regions, changed spawns) applies immediately, since none of it can destroy data an operator would miss. The boot refresh is **best-effort by contract**: no configured path, an unreadable mount, a malformed file or a database error is caught and logged, and the site comes up serving whatever atlas it had. The tree path comes from the `spawn_atlas_servuo_path` setting, falling back to `SERVUO_PATH`. **A refresh re-derives when the tree changed OR the parser did.** `spawnAtlasSource.PARSER_VERSION` is stored in `shard_atlas_meta` beside the source hashes and bumped whenever the parser produces different data from identical files. Hashing the tree alone would strand an install whose maps never change on whatever an older build derived — a corrected parse would ship and never reach the data. Four column choices worth stating, because each one is a trap: - **`spawn_range`, not `range`**, and **`grp`, not `group`** — both are reserved words. - **`DELETE`, not `TRUNCATE`.** `TRUNCATE` is DDL in MariaDB and implicitly commits, which would defeat the all-or-nothing reload. At ~7k rows the difference does not matter. - **Point ids are assigned explicitly**, not left to `AUTO_INCREMENT`: the `shard_spawn_point_types` rows need to know them, and `conn.batch()` reports no usable `insertId` for a multi-row insert. - **Plain `INDEX` on `name`, deliberately not `FULLTEXT`.** ~800 creature rows makes a `LIKE` scan free, and FULLTEXT's minimum token length would break searches for names like "orc". `shard_champion_spawns` is the *configured* altar roster ("there is an Unholy Terror altar in Deceit"). The live `champ.update` feed in `shard_champs` is the separate answer to "it is on level 3 right now". Both exist; they are not the same data. **`shard_spawn_creatures.art` is always NULL on a fresh import.** The project ships no creature artwork: sprites live in the operator's own client `.mul`/`.uop` files and are theirs, not ours to redistribute. An operator supplies art via a gitignored map plus images under the (already gitignored) `server/uploads/atlas/`. Text-only is the normal, supported state. ### shard_clilocs / shard_cliloc_meta — UO's localization table (Protocol 3.0) Items on the wire carry a `LabelNumber`, not a name. The bridge has always sent it — `char.profile.equipment.cliloc`, reward titles as a cliloc number in string form, and one per marketplace listing — but with no table to resolve it against, the character sheet could only render `id 1023721` where the game renders "quarter staff". | Table | Shape | |---|---| | `shard_clilocs` | `number` INT PK, `flag`, `text` TEXT | | `shard_cliloc_meta` | Singleton (`id = 1`), `payload` JSON (source file, sha256, count, `parserVersion`), `imported_at` | Import-owned and all-or-nothing in one transaction, same contract as the atlas — including **`DELETE`, not `TRUNCATE`**, for the same reason. **Sourced from files the operator supplies**, at a path from the `cliloc_client_path` setting falling back to `UO_CLIENT_PATH`. Nothing client-derived is committed: UO's strings are EA's, exactly as the creature sprites are. A shard with nothing configured is fully supported — names render as ids. Full design and operator guide: [`CLILOCS.md`](CLILOCS.md). **It reads a SET of sources, not one file**, because shards edit items and add new ones and those carry cliloc ids no stock client table has. A base (the converted client table) plus every overlay under `custom/` are re-read on every boot and hash-gated **together**, exactly as the atlas re-reads `Regions.xml` + `Locations/*.xml` + `Spawns/*.xml` + `ChampionSpawns.xml`. Later sources win, so an overlay both adds ids and overrides stock ones, and adding one custom item never means re-exporting a 5 MB client file. Scale, measured on the live shard: its script tree references 16,434 cliloc ids and only 37 are absent from stock — tens of entries against a 67k base, which is why this is an overlay and not a second table. The conversion step is not avoidable: **every current client ships its cliloc files compressed** (first DWORD's high byte `0x8E`), and ServUO's own bundled `Ultima.StringList` cannot read that either — so the shard cannot supply names on our behalf. The plain layout and a delimited text export are both accepted, sniffed by header rather than extension. Three decisions worth stating: - **`text` is TEXT, not VARCHAR.** Long property descriptions reach 12 KB. The index that matters for marketplace search is the denormalized `shard_vendor_items.display_name`, not this table. - **Blank entries are dropped at import** — 123,490 parsed → **67,496** stored. Roughly half a cliloc table is empty strings for ids the client reserves and never uses; a row that resolves to no name is indistinguishable from no row at all, and dropping them makes the binary and text imports converge on identical content. - **Two refusals, one of them the atlas's.** A corrupt source fails the parse on a truncated record, so it is caught outright and leaves the previous table serving. But a source that has **vanished** parses perfectly and imports a table quietly missing everything it contributed — an unmounted volume and a deliberate deletion are indistinguishable from here, which is precisely the ambiguity the atlas stages a facet removal for. So it is escalated: `status: 'needsReview'`, nothing applied, `missingSources` reported by both the import and `status()`, and an admin accepts it with `{approve:true}`. That is a flag rather than the atlas's approve/reject pair because the atlas stores a pending decision so that approving **re-parses** the tree; here nothing is stored, so re-reading at approval time is automatic. **Resolution is server-side and there is no public route.** The table is never served *as* a table: 67k rows would dwarf any page using them, and the Android client consumes the same already-resolved JSON. `resolveMany()` returns only ids that resolved to something displayable — placeholders like `~1_val~` are stripped, since the bridge sends the id and never the property packet that carries the arguments — and it never throws, because a cliloc lookup is decoration on a character sheet. ### 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 | | `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. Design of record: [`MODULE_SYSTEM.md`](MODULE_SYSTEM.md) §2.4; the loader's obligations are [`MODULE_API.md`](MODULE_API.md) Part 4. --- ## 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 exist.** 228 public routes + 2 on the internal listener, sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack | | `server/swagger/swagger-output.json` — served at `/api/docs` | **What each route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations | 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. ### /auth (auth/index.js → the capability routers in §2) No group gate — `/auth` is where an anonymous caller becomes authenticated. The authenticated parts gate themselves: `me.routes.js` and `notifications.routes.js` each apply `noindex, requireAuth` at their own router level, and `/sso/:provider/link` carries `requireAuth` per route. | Method | Path | Auth | Body | Purpose | |---|---|---|---|---| | POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at`. If the account has TOTP **and this browser is a trusted device** (a valid `rg_trust` cookie bound to the user), the TOTP step is **skipped** and a session is issued directly (logs `auth.login.trusted_device`). Otherwise a 2FA account returns `{totpRequired, challenge}`. | | POST | `/login/totp` | — (rate-limited) | `{challenge, code? \| recoveryCode?, trustDevice?, deviceName?}` | complete 2FA with a TOTP **or** single-use recovery code. `trustDevice` sets the `rg_trust` cookie so future logins skip TOTP; at the device cap the session is still issued and the body carries `{trustLimitReached, devices}`. | | POST | `/logout` | cookie | — | clear cookie (the `rg_trust` trust cookie deliberately **survives** logout) | | GET | `/me` | cookie / bearer | — | current user (no hash) or 401 — client bootstraps auth state | | POST | `/password/forgot` | — (rate-limited) | `{email}` | email a single-use, ~1h reset link to **every active account** on the address; **always** returns the same generic 200 (no account enumeration). Email is non-unique, so several accounts may each get a link naming their username. Logs `account.password.reset.request`. | | GET | `/password/reset/:token` | — | — | validate a link → `{username}` for the form, else 404 (never distinguishes expired/used/never-existed) | | POST | `/password/reset/:token` | — (rate-limited) | `{password}` | consume the single-use link, rotate the hash, and revoke **all** sessions (web cutoff + mobile refresh tokens). Does **not** sign the user in — they log in fresh (so a 2FA account still passes TOTP). Logs `account.password.reset.complete`. | | GET | `/me/account` | cookie / bearer | — | full self account (`id, username, role, email, status, totp_enabled, has_password`) | | PATCH | `/me/account/username` | cookie / bearer (rate-limited) | `{username}` | change own username; re-issues the caller's session | | PATCH | `/me/account/password` | cookie / bearer (rate-limited) | `{newPassword, currentPassword?}` | change/set own password (current required unless the account has none); revokes other sessions, keeps the caller's | | POST | `/me/account/totp/setup` · `…/enable` · `…/disable` | cookie / bearer | `{code}` on enable/disable | self 2FA enrollment (disable needs a valid current code, not a password). **enable** returns the one-time `recoveryCodes`; **disable** clears the user's trusted devices + recovery codes | | GET | `/me/account/identities` · DELETE `…/:provider` | cookie / bearer | — | list / unlink own SSO identities | | GET | `/me/trusted-devices` | cookie / bearer | — | list own active trusted devices (never tokens) | | POST | `/me/trusted-devices` | cookie / bearer (rate-limited) | `{deviceName?}` | trust the current device; web gets an httpOnly `rg_trust` cookie, native gets `{trustToken}`. **409 `{error:'trusted_device_limit', devices}`** at the cap | | DELETE | `/me/trusted-devices` · `…/:id` | cookie / bearer | — | untrust all / one (ownership-scoped) | | GET | `/me/account/recovery-codes/status` | cookie / bearer | — | remaining unused code count (never the codes) | | POST | `/me/account/recovery-codes/generate` | cookie / bearer (rate-limited, **password step-up**) | `{currentPassword?}` | regenerate the one-time recovery codes (returned once); refused when 2FA is off | | POST | `/me/devices` | cookie / bearer | `{endpoint, transport?, platform?}` | register a push endpoint; **rejects a disallowed endpoint 400** (SSRF guard). Idempotent per (user, endpoint) | | GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices | | GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) | | GET · PUT | `/me/notifications/subscriptions` | cookie / bearer | `{streams:[id]}` on PUT | get / replace own opted-in streams (unknown ids dropped) | **Role-agnostic self-service (`/auth/me/*`).** The canonical "me" surface for **every** authenticated role. It reuses the exact `account.controller` handlers as `/player/account/*` and `/admin/account/*` (no logic duplication) behind `requireAuth` **only** — any active account, never a specific role. This lets a client (the Android app) manage its own account through one surface without ever touching `/admin` (docs/android/PLAN.md §6.4). The older `/player/account/*` + `/admin/account/*` routes stay for web back-compat. **The `/player/*` group is self-service, not player-only.** Staff are a **superset** of players — every player ability plus their staff tools on top — so the whole group (`account.router.js`, `shard.router.js`, `appeals.router.js`, mounted by `player/index.js`) 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). Staff also reach the identical self-scoped handlers under `/admin/shard/*` (same controller) for the web admin surface; the two are interchangeable. This is why a staff account with linked game characters gets its "My characters" and personal notification streams on the mobile client — the group no longer 403s a non-`player` role. **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 shard streams and their event→stream mapping are `config/shardStreams.js`, which belongs to module-uo and moves out with it (MODULE_SYSTEM.md §1.8). Security invariants: - **Same public/admin split as the SSE feed.** Public streams are drawn *only* from the SSE `PUBLIC_KINDS` allowlist; a sensitive kind (audit/cheat/IP/login-attempt) can never produce a public push. - **Personal streams are owner-keyed.** `vendor.sale` / `house.idoc` / `account.login` are delivered only to the *owning* user's devices, resolved via `shardLinks` (the same ownership check as `/player/shard/*`) in `utils/shardPush.js`. `utils/pushDispatch.js` itself only publishes to a stream id someone else resolved — it has no idea what a shard event is. - **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 `/public/shard/stream` without an `Authorization` header. 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, derived `registration`/`gameAccountSignup` flags, 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 `