docs(website): demo mode core built — DM15-DM17, MODULE_API 1.18.0

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-23 18:52:12 -05:00
parent 6fae383255
commit 6ea268ea86
3 changed files with 149 additions and 26 deletions

View File

@@ -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@<version>=<manifest>`, 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 -- <fixture.json>` (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.<id>` 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.