From 6ea268ea8689e98a9da924a7945ca347f6c538bc Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 23 Oct 2026 18:52:12 -0500 Subject: [PATCH] =?UTF-8?q?docs(website):=20demo=20mode=20core=20built=20?= =?UTF-8?q?=E2=80=94=20DM15-DM17,=20MODULE=5FAPI=201.18.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DEMO_MODE.md: DM15 (onDemoSeed), DM16 (POST /auth/register locked), DM17 (sessions route locked outright on a demo account). It also covers the settings keys that reach a Tier A lock, how demo accounts are recognised, failing closed, the published logins, the toast, the seed's fixture shape and $env, and the fact that there is no seeded team (Teams come from the rig's clans). The §3.2 example drops the config-file POST that DM14 opened. Step 1 is marked built. - MODULE_API.md: 1.18.0. demoLocks in module.json, ctx.demoMode, and api.onDemoSeed, which must upsert because it runs on every seed. - BACKEND_DESIGN.md: §5.1 demo mode, and demo_state in the settings table. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- website/BACKEND_DESIGN.md | 32 ++++++++++++- website/DEMO_MODE.md | 98 +++++++++++++++++++++++++++++---------- website/MODULE_API.md | 45 +++++++++++++++++- 3 files changed, 149 insertions(+), 26 deletions(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index 653e291..c49e695 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -338,7 +338,9 @@ 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`. +(default `disabled`), `mobile_app_links_enabled`, `module_source_hosts`. On the public demo only, +`demo_state` (JSON `{ resetsAt, logins }`, written by `seed:demo`, never writable through +`PUT /admin/settings`; §5.1). `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 @@ -1808,6 +1810,34 @@ Admin "preview live" simply hits the content APIs with the admin cookie, which b Dashboard reads `site_mode` + `site_mode_changed_at`/`_by` for "current mode + last change + who"; `activity_log` provides the history feed. +### 5.1 Demo mode (`DEMO_MODE=true`) + +The public demo, `demo.runicgateway.com`, runs the normal image with one env var. The design of +record is `DEMO_MODE.md`; this is the contract in brief. With the flag unset, `app.js` mounts none of +it. + +- **The lock** (`server/src/demo/locks.js`). It is mounted at `/api/v1` ahead of every router, after the + body parser and `cookieParser`. Each `POST`/`PUT`/`PATCH`/`DELETE` is checked against core's list + (DEMO_MODE.md §3.3) and every installed module's `demoLocks` (`MODULE_API` 1.18.0). A match answers + `403 { message: "This is turned off on the demo site.", code: "DEMO_LOCKED" }` before the route's own + auth check. Reads are never locked. Matching follows express 4's defaults: case-insensitive, and the + trailing slash optional. A few rows are conditional: + - the target of `/admin/users/:id…` is a demo account; + - the caller of `/auth/me/…` is signed in as one; + - the `PUT /admin/settings` body names a protected key (`site_mode*`, `module_source_hosts`, + `teams_voice_*`, `demo_*`). + + A condition that cannot decide refuses. +- **Uploads.** Any request with a `multipart/*` content type is refused app-wide, before a router or + multer sees it. +- **Search engines.** Every response carries `X-Robots-Tag: noindex, nofollow`. `/robots.txt` is + `Disallow: /`, served ahead of the SPA's static file. +- **`GET /public/settings`** gains `demo: { resetsAt, logins }`, from the `demo_state` settings row. +- **`npm run seed:demo -- `** (`server/db/seedDemo.js`) refuses to run without the flag. It + upserts the four accounts (`demo-admin`, `demo-moderator`, `demo-player`, `play-review`, fixed in + `server/src/demo/mode.js`), sets the site live with registration disabled, and seeds content on a + database's first run only. It calls each module's `onDemoSeed`, then stamps `demo_state`. + --- ## 6. Auth & security diff --git a/website/DEMO_MODE.md b/website/DEMO_MODE.md index b851b0a..a31a126 100644 --- a/website/DEMO_MODE.md +++ b/website/DEMO_MODE.md @@ -1,7 +1,7 @@ # Demo mode — `demo.runicgateway.com` -**Status:** design, **fully decided** 2026-10-23 (DM1-DM14, §9). Ready to build per §10. Nothing is -built yet. +**Status:** design **fully decided** 2026-10-23 (DM1-DM17, §9). §10 step 1 (core, `MODULE_API` +1.18.0) is **built**; steps 2-6 are not. **Issue:** `RunicGateway/website#222`. **Why now:** Google Play (`../android/PLAN.md` item 9, M8) needs a public HTTPS site with working sign-ins for its reviewers and for the 12 testers who must be opted in for 14 days before the app can apply for production. @@ -67,16 +67,19 @@ routes in `module.json` (DM1, §3.2). ```json "demoLocks": [ { "method": "PUT", "path": "/admin/rust/servers/:id" }, - { "method": "DELETE", "path": "/admin/rust/servers/:id" }, - { "method": "POST", "path": "/admin/rust/config/:serverId/file" } + { "method": "DELETE", "path": "/admin/rust/servers/:id" } ] ``` -Paths use the same `:param` syntax as the module's routers and must sit under the module's own -mounts, which the loader checks the same way it checks `mounts`. A module without the field is -unaffected. It is a new manifest field, so `MODULE_API_VERSION` gets a **minor** bump, -`MODULE_API.md` documents it, and the Integration Kit's module chapter gains a paragraph (its -`ci/core-ref.json` pin then moves, as for any API bump). +Paths use the same `:param` syntax as the module's routers, with an optional trailing `/*` for a +path and everything under it. They must sit under the module's own mounts, which the loader checks +the same way it checks `mounts`. Only writes can be declared. The config editor's one locked read +(§3.5) depends on which file is asked for, so the module enforces it behind `ctx.demoMode` rather +than by route. A module without the field is unaffected. The field is validated on every boot, so a +wrong declaration fails on a developer's machine and not only on the demo. It is a new manifest field, +so `MODULE_API_VERSION` gets a **minor** bump to **1.18.0**, `MODULE_API.md` documents it, and the +Integration Kit's module chapter gains a paragraph (its `ci/core-ref.json` pin then moves, as for any +API bump). ### 3.3 What is locked @@ -96,8 +99,14 @@ wipe puts them back (§7). | Team voice | writes under `/admin/teams/voice` | Configure or sync Discord voice channels | | Demo accounts, by an admin | `PUT`/`DELETE /admin/users/:id`, `DELETE …/trusted-devices…`, `POST …/mfa/reset` **when `:id` is a demo account** | Demote, disable or delete the account everyone signs in with | | Own account, on a demo account | `PATCH /auth/me/account/username`, `…/password`, `…/email`; `POST /auth/me/account/totp/setup`, `…/enable`, `…/disable`; `POST /auth/me/account/recovery-codes/generate`; `DELETE /auth/me/account/identities/:provider` | Change the shared password, or turn on 2FA so nobody else can sign in | -| Other people's sessions | `DELETE /auth/me/sessions/:id` for a session that is not the caller's | Sign every other visitor out of the shared account | +| Other people's sessions | `DELETE /auth/me/sessions/:id` **on a demo account**, whichever session it names (DM17) | Sign every other visitor out of the shared account | | Password reset | `POST /auth/password/forgot`, `POST /auth/password/reset/:token` | Reset a demo account's password | +| Registration | `POST /auth/register` (DM16) | Settings are open, so a visitor could switch `player_registration` on. The route is what keeps registration closed (§4) | +| Settings that reach a lock | `PUT /admin/settings` when the body names `site_mode*`, `module_source_hosts`, `teams_voice_*` or `demo_*` | That route writes arbitrary keys, so these would get around the site-mode, Modules and team-voice rows above, or rewrite the banner and the published logins. The answer names the keys (`keys: [...]`) | + +**Why DM17 locks the whole route.** A mobile access token carries no id for its refresh-token row, +so the server cannot tell which row in `GET /auth/me/sessions` is the caller's own. Signing out is +`POST /auth/mobile/logout`, which revokes the caller's own row and stays open. **Tier A — always locked (Rust module)** @@ -107,7 +116,17 @@ wipe puts them back (§7). | The bridge's config, **read or written** | `GET`/`POST /admin/rust/config/:serverId/file`, `GET …/writes…` for the `RunicGateway` plugin's files, which are also left out of `GET …/files` | It holds the bridge's link to the sidecar and its credentials. Read, it would let someone talk to the sidecar directly over HTTP (DM14, §3.5) | **Settings stay open (DM2).** Theme, navigation and the other `PUT /admin/settings` values can be -changed and come back at the wipe. Brand images are uploads, so they fall under the upload lock. +changed and come back at the wipe, except the keys in the "Settings that reach a lock" row above. +Brand images are uploads, so they fall under the upload lock. + +**Which accounts are demo accounts.** The four usernames in §4 are fixed in core +(`server/src/demo/mode.js`), not read from the seed fixture: the lock checks them on every request, +and a fixture that dropped one would unlock the account everybody shares. The lock decides by the +account row in the database (`users.getById`), not by the username claim in the token. An account an +admin creates on the demo (`POST /admin/users` stays open) is an ordinary account. It is gone at the +next wipe. + +**Failing closed.** A conditional row that cannot decide, because the database is down, refuses. **Uploads — locked hard (DM4)** @@ -211,7 +230,9 @@ All of this is enforced in the module's server code behind `ctx.demoMode` (DM10) | `play-review` | admin | Google's reviewer. Same powers as `demo-admin`, but a separate account, so a reviewer is never signed out because someone else revoked a session on the shared one | Usernames and passwords are published on the demo's sign-in page and on runicgateway.com `/beta`. -They are not secrets. The seed recreates them at every wipe, so a password that somehow changed is +They are not secrets. The seed writes them into `demo_state`, `GET /public/settings` serves them as +`demo.logins`, and both sign-in pages (`/account/login`, `/admin/login`) list them with a **Use** +button that fills the form. A fixture account with `"published": false` is created but not listed. The seed recreates them at every wipe, so a password that somehow changed is back within one cycle. Public registration is **closed** on the demo: there is no mail to verify an address with (§5), and the static logins cover every role. @@ -226,10 +247,16 @@ address with (§5), and the static logins cover every role. ## 6. The banner -`GET /api/v1/public/settings` gains `demo: { resetsAt }` while the flag is on. The web client shows a -thin strip on every page: "Demo site — anything you change is wiped at 14:00 UTC. Sign-ins: -demo-admin / demo-player …". The app shows the same strip on Home. Both read `resetsAt`, so neither -needs to know the schedule. +`GET /api/v1/public/settings` gains `demo: { resetsAt, logins }` while the flag is on, and only then. +The seed writes both into one settings row, `demo_state`, which `PUT /admin/settings` cannot touch +(§3.3). The web client shows a thin strip on every page: "Demo site — anything you change is wiped at +14:00 UTC. Sign-ins: demo-admin / demo-player … — passwords on the sign-in page". The app shows the same +strip on Home. Both read `resetsAt`, so neither needs to know the schedule. The time is said in UTC, +not converted, so it matches the schedule published on `/beta`. + +**The toast.** Every web request goes through one request primitive (`client/src/api/client.js`), +installed modules' requests included. A `DEMO_LOCKED` answer is reported from there to one toast +(`DemoToast`), and the caller still gets its error as usual. So no screen has to learn the code. ## 7. The wipe @@ -284,13 +311,28 @@ org lead created the subuser and added the secret on 2026-10-23. 2. `docker compose pull && docker compose up -d` — the app boots on the latest `main` image, creates the schema, and installs the Rust module from `MODULES=rust@=`, which the workflow writes (§7.5). 3. Wait for the app's healthcheck. -4. `docker compose exec app npm run seed:demo` — one core script that, from a fixture file in the - deploy directory: - - creates the four accounts (§4) and `demo-player`'s stand-in Steam link (DM6); - - creates the Rust server row with the rig's sidecar URL and token taken from `.env`, so the - link survives every wipe without being stored in the fixture; - - writes the seeded site content: news posts, wiki pages, the demo events and a team; - - stamps `resetsAt` for the banner. +4. `docker compose exec app npm run seed:demo -- ` (or `DEMO_SEED_FILE`) — one core + script (`server/db/seedDemo.js`) that, from a fixture file in the deploy directory: + - creates the four accounts (§4), or resets them if they exist: password, role, active, 2FA off; + - sets the site live with registration disabled, plus any `settings` the fixture adds; + - writes the seeded site content: news posts, wiki pages, pages and the demo events. It does + this only on the first run against a database, so a re-run never duplicates it; + - hands each installed module its `modules.` section through the module's `onDemoSeed` hook + (DM15, `MODULE_API` 1.18.0). The Rust module writes its own server row, with the rig's sidecar URL + and token, and `demo-player`'s stand-in Steam link (DM6). Core never writes a module's tables; + - stamps `demo_state`: `resetsAt`, the next `DEMO_RESET_EVERY_HOURS` slot (default 2) on the hour + UTC, and the published logins. + + The fixture is JSON: `{ accounts, settings?, content?: { posts, wiki, pages, events }, modules? }`. + Any string `"$env:NAME"` in it is replaced with that variable from the host's `.env`, so the + sidecar token is never in the repo. An unset variable fails the run instead of seeding an empty + credential. The script checks the fixture before writing anything. It must name exactly the four + accounts, each with a role and a password of 8 or more characters. It refuses to run at all without + `DEMO_MODE=true`. The demo's `.env` leaves `ADMIN_USERNAME` unset, so the boot seed creates no fifth + admin. + + **There is no seeded team.** Core's Teams are synced from the game (a Rust clan becomes a Team), so + the demo's team comes from a clan in the rig's baseline (§7.2), not from the fixture. 5. The module's sync connects when the rig finishes booting, and its first import adopts the baseline's permissions and NPCs (§7.1). 6. Smoke check, after a bounded wait: `GET /api/v1/public/status` answers, and the Rust server @@ -369,6 +411,9 @@ down. | DM12 | (F2) What "latest" means across the Rust link | The latest **Rust bundle** for the rig's plugins and sidecar, the newest module release on the same protocol, and the website's `main` image; the wipe updates the rig when the bundle changes (§7.5) | | DM13 | (F3) DM2 read correctly? | Yes: only the settings stay open; every other Tier A row stays locked | | DM14 | (after DM13) May visitors change plugin configs? | Yes, every plugin except the bridge. The bridge's config can be **neither read nor written**, because it holds credentials that would let someone talk to the sidecar directly (§3.5) | +| DM15 | (build) How does the seed create rows in a module's tables (the Rust server link, `demo-player`'s Steam link)? | Through a new optional module hook, **`api.onDemoSeed(data, { accounts })`**, in the same `MODULE_API` minor as `demoLocks`. The module writes its own rows with its own model; core never writes a module's tables (§7.3) | +| DM16 | (build) Settings are open, so `player_registration` can be switched on. Lock registration? | Yes: **`POST /auth/register`** is a Tier A lock. Invites stay open (§3.3) | +| DM17 | (build) The server cannot tell which mobile session is the caller's own. What does the other-sessions lock do? | **`DELETE /auth/me/sessions/:id` is locked outright on a demo account**; `POST /auth/mobile/logout` stays open (§3.3) | DNS and HTTPS for `demo.runicgateway.com` come from an external reverse proxy, which the org lead is setting up. The app sits behind it on plain HTTP; core already trusts one proxy hop for @@ -389,7 +434,12 @@ through that proxy. demo-account and other-session checks), `demoLocks` loading, `ctx.demoMode`, the banner field, `seed:demo`, `noindex`, the web banner and toast, the `MODULE_API` minor bump, and the OpenAPI note that a route may answer `DEMO_LOCKED`. Tests for every lock. One PR, plus `MODULE_API.md` - and this doc. + and this doc. **Built 2026-10-23** as `MODULE_API` 1.18.0, with `onDemoSeed` (DM15), the + registration lock (DM16), the protected settings keys (§3.3) and the published logins on the + sign-in pages (§4). The code is in `server/src/demo/` (`mode.js`, `paths.js`, `locks.js`), + `server/db/seedDemo.js`, and the client's `DemoBanner`, `DemoToast` and `DemoLogins`. + `test/demoLocks.test.js` holds every core row against `routes.manifest.json`, so a row that stops + naming a real route fails the build. 2. **Module** (`Module-Rust`): `demoLocks` in `module.json`; behind `ctx.demoMode`, the console-command deny list (§3.4) and the config-editor rules (§3.5); and `protocol` in the release's install manifest (§7.5). Each with tests. One PR. diff --git a/website/MODULE_API.md b/website/MODULE_API.md index 76831b0..35f1fa6 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -26,13 +26,30 @@ here extends the contract first, in this file, before the module is written agai Core exports a single integer-major semver string from `server/src/modules/version.js`: ```js -const MODULE_API_VERSION = '1.17.0' +const MODULE_API_VERSION = '1.18.0' ``` The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first module chunk evaluates, which is earlier than any network round trip could answer. +**1.18.0 — the public demo** (`website/DEMO_MODE.md` §3.2, §3.4, §7.3; website#222). Three optional +additions and no removal, so minor. With `DEMO_MODE` unset, none of them changes anything a module sees, +except that `demoLocks` is validated. + +- **`demoLocks` in `module.json`** (§2.1): the module's own write routes that the demo refuses. Core + answers a matching request `403 { message, code: 'DEMO_LOCKED' }` before the module's handler runs, + exactly as it does for its own locked routes. Writes only, under the module's own mounts. +- **`ctx.demoMode`** (§2.3): `true` on the demo. It is for the locks a route list cannot express, + which the module enforces in its own handlers: a console command refused by its first word, or a + config file refused by the plugin that owns it. A refusal should answer with the same body. +- **`api.onDemoSeed(fn)`** (§2.4): the demo's seed hands the module its section of the fixture, so the + module writes its own rows (DM15). +- A module that **declares `demoLocks`** needs `coreApi` `^1.18.0`, because an older core rejects the + unknown key. A module that only reads `ctx.demoMode` or registers `onDemoSeed` should declare it too: + an older core has no `onDemoSeed` on `api`, and its `ctx.demoMode` is `undefined`. Module-uo's + `^1.10.0` still resolves and uses none of them. + **1.17.0 — a module may take part in linking a sign-in identity** (`website/STEAM_SIGNIN.md` §4; Rust `modules/rust/PLAN_REDESIGNS.md` §15, D392 and D396). Three optional additions and no removal, so minor. Linking stays core's: a module can read what core verified, refuse a link, and hear one, and @@ -521,6 +538,10 @@ branches lacks — so it is the only place `permits` is true between two values between `admin` and `owner`, `members` or `subscribers`. `permits`, `meet` and `meetAll` are otherwise unchanged, and so is every rule about composition narrowing rather than widening. +**1.18.0 — the public demo** (`website/DEMO_MODE.md` §3.2, §3.4, §7.3). `demoLocks` in `module.json`, +`ctx.demoMode` and `api.onDemoSeed`; three optional additions, so minor; see §1.1. Module-uo's +`^1.10.0` still resolves. + **1.17.0 — identity links** (`website/STEAM_SIGNIN.md` §4; Rust PLAN_REDESIGNS §15, D392, D396). `ctx.identities.list`, and `api.onIdentityLinking` (refuse-only, fails closed) and `api.onIdentityLinked`; three optional additions, so minor; see §1.1. Module-uo's `^1.10.0` still @@ -788,6 +809,7 @@ rejected rather than ignored, so a typo is a loud failure and not a silently-ine | `mounts` | no | Declared prefixes per tier (§2.3). Declaration is the contract; the loader compares it against what the module actually registers and rejects a mismatch. | | `extensions` | no | Core extension slots this module mounts into (§2.4). | | `capabilities` | no | Opaque strings published by `GET /api/v1/public/modules` (§2.9), for clients (the SPA, the Android app) to feature-detect against. Published only while the module is `started`. | +| `demoLocks` | no | *(1.18.0)* `[{ method, path }]`: the module's write routes the public demo refuses (`website/DEMO_MODE.md` §3.2). `method` is `POST`, `PUT`, `PATCH` or `DELETE`; `path` is relative to `/api/v1`, in the routers' own `:param` syntax with an optional trailing `/*`, and must sit under one of the module's declared `mounts`. Validated on every boot; read only with `DEMO_MODE=true`, where core answers a match `403 DEMO_LOCKED` before the module's handler runs. A module declaring it needs `coreApi` `^1.18.0`, since an older core rejects the unknown key. | ### 2.2 The entry point @@ -838,6 +860,7 @@ module-uo does not need is on the list. | `ctx.events.expired` | `({ kind, ref }) => void`, fire-and-forget, owner bound | `events/cleanup` `expireResource` | `module-rust`'s ingest of `world.expired` (1.11.0) | | `ctx.inbox.push` | `(userId, item) => void`, fire-and-forget | the in-app channel (live since Phase 7) | `module-uo` reaches both through `server/core.js` (1.7.0) | | `ctx.identities.list` | `(userId, provider) => Promise<[{ provider, subject, displayName, avatarUrl, linkedAt }]>`, read only | `model/userIdentities` | Steam sign-in (1.17.0) | +| `ctx.demoMode` | boolean, read once when `ctx` is built | `DEMO_MODE=true` (`demo/mode.js`) | Module-Rust's console-command deny list and config-editor rules (1.18.0) | **`ctx.events.emit(triggerId, envelope)`** fires an event the module DECLARED with `api.registerEventTriggers` (§2.4). It is the push half of the engagement seam @@ -967,6 +990,7 @@ api.onBoot(async (ctx) => {}) api.onShutdown(async () => {}) api.onIdentityLinking(async ({ userId, provider, subject }) => ({ ok: true })) // 1.17.0 api.onIdentityLinked(async ({ userId, provider, subject, displayName }) => {}) // 1.17.0 +api.onDemoSeed(async (data, { accounts }) => {}) // 1.18.0 ``` **Every call STAGES; nothing is committed until the module as a whole is known good.** A claim's @@ -1460,6 +1484,25 @@ api.onIdentityLinked(async ({ userId, provider, subject, displayName }) => { /* identity already linked to another account is refused by core before any module is asked). There is no unlink hook (D393). +**`onDemoSeed(fn)`** (1.18.0) — the module's part of the public demo's seed (`DEMO_MODE.md` §7.3, +DM15). `npm run seed:demo` loads the installed modules the way the server does (but never calls +`onBoot`) and calls `fn(data, { accounts })`, where `data` is the fixture's `modules.` object with +every `"$env:NAME"` already replaced, and `accounts` maps each demo username to its user id. The module +writes its own rows through its own model; core never writes a module's tables. + +```js +api.onDemoSeed(async ({ servers = [], links = [] }, { accounts }) => { + for (const s of servers) await serversModel.upsert({ ...s, tokenEnc: ctx.secretBox.encrypt(s.sidecarToken) }) + for (const l of links) await linksModel.upsert({ userId: accounts[l.username], steamId: l.steamId }) +}) +``` + +- **Upsert, never insert.** The seed may run more than once on one database. Core seeds content only + on the first run, but it calls every module hook on every run. +- Called only with `DEMO_MODE=true`, only for a module whose fixture section exists, and only in the + seed's own process. A fixture section for a module that registered no hook fails the seed. So does a + hook that throws. + ### 2.5 Lifecycle ```