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

@@ -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.<id>` 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
```