feat(demo): DEMO_MODE lock, seed:demo and the demo banner (MODULE_API 1.18.0)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 43s
PR Checks / client-build (pull_request) Successful in 1m3s
PR Checks / server-tests (pull_request) Successful in 8m37s

Core half of the public demo (docs/website/DEMO_MODE.md §10 step 1, website#222).
Nothing here runs unless DEMO_MODE=true.

- server/src/demo/: the lock at /api/v1, ahead of every router. It covers every
  Tier A row of §3.3, the shared-login conditions, POST /auth/register (DM16),
  DELETE /auth/me/sessions/:id on a demo account (DM17), and the PUT
  /admin/settings keys that reach a Tier A lock. Any multipart request is refused
  app-wide. noindex and robots.txt. A condition that cannot decide refuses.
- Loader: demoLocks in module.json (writes only, under the module's own mounts),
  ctx.demoMode, and api.onDemoSeed (DM15). MODULE_API 1.18.0 on both halves.
- GET /public/settings carries demo: { resetsAt, logins } on the demo only.
- npm run seed:demo -- <fixture>: upserts the four accounts, sets the site live
  with registration disabled, seeds content on the first run only, calls each
  module's onDemoSeed, and stamps demo_state. It resolves $env: strings and
  refuses to run off the demo.
- Client: the banner, one DEMO_LOCKED toast fed from the request primitive (so
  module screens get it too), and the published logins on both sign-in pages.
  Dashboard and register say the demo message instead of blaming a role.
- OpenAPI: DemoLocked schema, the demo block, and a note that any write may
  answer DEMO_LOCKED on the demo.

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:09 -05:00
parent b5e0d01a91
commit 4ad746c43e
30 changed files with 1891 additions and 14 deletions

View File

@@ -41,6 +41,8 @@ const { MODULE_API_VERSION } = require('./version')
const semver = require('./semver')
const registries = require('./registries')
const { splitStatements } = require('../utils/sqlStatements')
const demoPaths = require('../demo/paths')
const { isDemo } = require('../demo/mode')
const log = require('../utils/logger')('modules')
@@ -62,9 +64,15 @@ const TIERS = ['public', 'admin', 'player']
const MANIFEST_KEYS = new Set([
'id', 'name', 'version', 'coreApi', 'server', 'client',
'schema', 'purge', 'mounts', 'extensions', 'capabilities',
'schema', 'purge', 'mounts', 'extensions', 'capabilities', 'demoLocks',
])
// What a `demoLocks` entry may lock (API 1.18.0, DEMO_MODE.md §3.2): writes
// only. Reads are never locked by declaration — the one read the demo refuses
// (a plugin config that holds credentials) depends on WHICH file is asked for,
// so the module decides it behind `ctx.demoMode` rather than by route.
const DEMO_LOCK_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE']
// Extension slots are declared by core, at require time, in the router that owns
// the resource (registries.declareSlot). The loader asks the registry which exist
// rather than keeping a list, for the same reason the prefix check probes the
@@ -338,6 +346,12 @@ function buildCtx(id, moduleRoot) {
// configuration, so core answers it. A getter, not a captured string: the
// value is read per call, so it cannot go stale against the env.
site: { get baseUrl() { return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '') } },
// API 1.18.0 (DEMO_MODE.md §3.4, §3.5). True on the public demo. For the
// locks a route list cannot express — a console command by its first word,
// a config file by which plugin owns it — which the module enforces in its
// own handlers. A boolean, read once: the flag is the deployment's, and a
// deployment does not change what it is while it runs.
demoMode: isDemo(),
}
// A guard against accident, not against a hostile module — the boundary is
// organisational, not a security boundary (MODULE_SYSTEM.md §2.2).
@@ -503,6 +517,12 @@ function buildApi(record) {
// nothing to undo when a sign-in identity goes.
onIdentityLinking: hook('onIdentityLinking'),
onIdentityLinked: hook('onIdentityLinked'),
// API 1.18.0 (DEMO_MODE.md §7.3, DM15). Core's `seed:demo` handing the
// module its part of the seed fixture, so the module writes its own rows —
// a game server link, a player's game link — through its own model. Core
// never writes a module's tables. Called from the seed script only, never
// by the running server, and only with DEMO_MODE on.
onDemoSeed: hook('onDemoSeed'),
}
}
@@ -741,6 +761,8 @@ function readManifest(dir, id, tierRouters) {
if (!registries.hasSlot(slot)) fail('extensions', `unknown extension slot "${slot}"`)
}
checkDemoLocks(manifest)
if (manifest.client !== undefined) {
if (typeof manifest.client !== 'object' || manifest.client === null || Array.isArray(manifest.client)) {
fail('manifest', 'client must be an object')
@@ -761,6 +783,40 @@ function readManifest(dir, id, tierRouters) {
return manifest
}
/**
* Validate `demoLocks` (API 1.18.0, DEMO_MODE.md §3.2), on every boot and not
* only on the demo: a module whose declaration is wrong should find out on the
* developer's machine, not on the one deployment where it is read.
*
* Each entry is `{ method, path }`, the path relative to /api/v1 and under one
* of the module's own declared mounts — a module may lock its own routes and
* nobody else's, the same boundary `mounts` draws for serving them.
*/
function checkDemoLocks(manifest) {
if (manifest.demoLocks === undefined) return
if (!Array.isArray(manifest.demoLocks)) fail('manifest', 'demoLocks must be an array')
for (const entry of manifest.demoLocks) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
fail('manifest', 'each demoLocks entry must be an object')
}
for (const key of Object.keys(entry)) {
if (key !== 'method' && key !== 'path') fail('manifest', `unknown key "demoLocks.${key}" in module.json`)
}
if (!DEMO_LOCK_METHODS.includes(entry.method)) {
fail('manifest', `demoLocks method "${entry.method}" must be one of ${DEMO_LOCK_METHODS.join(', ')}`)
}
try {
demoPaths.compile(entry.path)
} catch (err) {
fail('manifest', `demoLocks: ${err.message}`)
}
const [, tier, prefix] = entry.path.split('/')
if (!(manifest.mounts?.[tier] || []).includes(`/${prefix}`)) {
fail('mounts', `demoLocks path ${entry.path} is outside the module's mounts`)
}
}
}
// What the module registered must equal what it declared — in both directions.
function checkDeclared(record) {
const declared = record.manifest.mounts || {}
@@ -841,7 +897,9 @@ function load(tierRouters) {
staged: registries.stage(id),
tables: new Set(),
called: new Set(),
hooks: { onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null },
hooks: {
onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null, onDemoSeed: null,
},
client: null,
ctx: null,
state: 'installed',
@@ -1128,6 +1186,55 @@ function identityHooks(name) {
.map((r) => ({ id: r.id, hook: r.hooks[name] }))
}
/**
* Every installed module's `demoLocks`, compiled, for the demo lock in
* demo/locks.js (API 1.18.0).
*
* Every module whose manifest validated, whatever its state. A disabled or
* failed module's routes already answer 404 or 503, so its locks change
* nothing — and a lock that disappeared whenever its module was between states
* is a lock with a gap in it. Empty before load() has run, rather than throwing:
* the middleware is mounted before the loader and asks per request.
*
* @returns {{methods: string[], path: string, compiled: object, source: string}[]}
*/
let demoLockCache = null
function demoLocks() {
if (!loaded) return []
// Manifests are read once, in load(), which is synchronous — so the answer
// cannot change after the first request asks, and is compiled once.
if (demoLockCache) return demoLockCache
const out = []
for (const record of modules.values()) {
for (const entry of record.manifest?.demoLocks || []) {
out.push({
methods: [entry.method],
path: entry.path,
compiled: demoPaths.compile(entry.path),
source: record.id,
})
}
}
demoLockCache = out
return out
}
/**
* The `onDemoSeed` hooks, for `npm run seed:demo` (API 1.18.0, DM15).
*
* `registered` or `started`: the seed is its own process, where nothing calls
* onBoot, so its modules never get past `registered`. A module that failed to
* load is not handed a fixture for tables it may not have.
*
* @returns {{id: string, hook: Function, ctx: object}[]}
*/
function demoSeedHooks() {
assertLoaded('demoSeedHooks')
return [...modules.values()]
.filter((r) => (r.state === 'registered' || r.state === 'started') && r.hooks.onDemoSeed)
.map((r) => ({ id: r.id, hook: r.hooks.onDemoSeed, ctx: r.ctx }))
}
/**
* One module's `onShutdown`, for stopping it on its own rather than at exit.
*
@@ -1254,6 +1361,8 @@ module.exports = {
bootable,
shutdownHooks,
identityHooks,
demoLocks,
demoSeedHooks,
stopHook,
clientChunks,
clientEntryUrls,