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

196
server/src/demo/locks.js Normal file
View File

@@ -0,0 +1,196 @@
// ── The demo lock (docs/website/DEMO_MODE.md §3) ───────────────────────────
//
// With DEMO_MODE on, app.js mounts three middlewares from this file, and with it
// off none of them is mounted at all, so a real shard runs none of this code.
//
// • `refuseMultipart` — any request with a multipart body, anywhere, before a
// router sees it (§3.3 "Uploads — locked hard"). An upload route added later
// is locked without anyone remembering to list it, and a module cannot open one.
// • `lock` — each write request against core's list below plus every module's
// declared `demoLocks`, at `/api/v1`, ahead of every router.
// • `noindex` + `robots` — nothing a visitor writes reaches a search engine.
//
// **The server is the lock (DM10).** Everything here is decided from the request
// alone, so `curl`, an old app build and a hand-edited page are refused exactly
// like a click. The client explains a refusal; it never enforces one.
//
// A refusal is always the same answer, so the web client and the app can each
// recognise it by `code` and show one sentence for it.
const { compile, match } = require('./paths')
const { isDemoUsername } = require('./mode')
const log = require('../utils/logger')('demo')
const CODE = 'DEMO_LOCKED'
const MESSAGE = 'This is turned off on the demo site.'
const WRITES = ['POST', 'PUT', 'PATCH', 'DELETE']
function refuse(res, extra = {}) {
return res.status(403).json({ message: MESSAGE, code: CODE, ...extra })
}
// ── Conditions ─────────────────────────────────────────────────────────────
// A few rows lock a route only for some requests. Each condition is asked only
// after the method and path have matched, so the DB is touched on a handful of
// rare admin and self-service writes and on nothing else.
// Required lazily: this file is required by app.js, and the users model would
// otherwise make the DB pool a startup dependency of the lock.
/* eslint-disable global-require */
const users = () => require('../model/users/users.model')
const sessions = () => require('../auth/session.service')
/* eslint-enable global-require */
async function isDemoUserId(id) {
const n = Number(id)
if (!Number.isInteger(n) || n < 1) return false
const user = await users().getById(n)
return Boolean(user && isDemoUsername(user.username))
}
// `PUT /admin/users/:id` and its kin: an admin acting on one of the shared logins.
const targetIsDemo = (req, params) => isDemoUserId(params.id)
// `/auth/me/*`: the caller signed in as one of the shared logins. The identity is
// read the way requireAuth reads it, and the account row decides — not the
// username claim in the token. No session is not a refusal: the route's own
// requireAuth answers that with its usual 401.
async function callerIsDemo(req) {
const session = sessions().validateSession(req)
return Boolean(session && (await isDemoUserId(session.userId)))
}
// `PUT /admin/settings` stays open (DM2), but that one route writes arbitrary
// keys, and a few keys are another door into something Tier A locks: `site_mode`
// is what `PUT /admin/site-mode` writes, `module_source_hosts` what the Modules
// screen's sources do, `teams_voice_*` the voice settings. `demo_*` is the
// banner and the published logins, which the seed owns.
const PROTECTED_SETTINGS = [/^site_mode/, /^module_source_hosts$/, /^teams_voice_/, /^demo_/]
function protectedKeys(body) {
if (!body || typeof body !== 'object' || Array.isArray(body)) return []
return Object.keys(body).filter((key) => PROTECTED_SETTINGS.some((re) => re.test(key)))
}
// ── Core's list (§3.3) ─────────────────────────────────────────────────────
// Paths are relative to /api/v1 and written as the routers declare them;
// test/demoLocks.test.js holds every row against routes.manifest.json, so a
// row that stops naming a real route fails the build rather than reading as a lock.
const CORE_LOCKS = [
// Tier A — site configuration.
{ methods: ['PUT'], path: '/admin/site-mode' },
{ methods: WRITES, path: '/admin/modules/*' },
{ methods: WRITES, path: '/admin/auth/providers/*' },
{ methods: ['PUT'], path: '/admin/email/config' },
{ methods: ['POST'], path: '/admin/email/test' },
{ methods: ['POST'], path: '/admin/email/disconnect' },
{ methods: ['PUT'], path: '/admin/discord-bot/config' },
{ methods: WRITES, path: '/admin/teams/voice/*' },
{ methods: ['PUT'], path: '/admin/settings', when: (req) => protectedKeys(req.body).length > 0 },
// Tier A — the shared logins, by an admin.
{ methods: ['PUT', 'DELETE'], path: '/admin/users/:id', when: targetIsDemo },
{ methods: ['DELETE'], path: '/admin/users/:id/trusted-devices', when: targetIsDemo },
{ methods: ['DELETE'], path: '/admin/users/:id/trusted-devices/:deviceId', when: targetIsDemo },
{ methods: ['POST'], path: '/admin/users/:id/mfa/reset', when: targetIsDemo },
// Tier A — the shared logins, by whoever is signed in as one.
{ methods: ['PATCH'], path: '/auth/me/account/username', when: callerIsDemo },
{ methods: ['PATCH'], path: '/auth/me/account/password', when: callerIsDemo },
{ methods: ['PATCH'], path: '/auth/me/account/email', when: callerIsDemo },
{ methods: ['POST'], path: '/auth/me/account/totp/setup', when: callerIsDemo },
{ methods: ['POST'], path: '/auth/me/account/totp/enable', when: callerIsDemo },
{ methods: ['POST'], path: '/auth/me/account/totp/disable', when: callerIsDemo },
{ methods: ['POST'], path: '/auth/me/account/recovery-codes/generate', when: callerIsDemo },
{ methods: ['DELETE'], path: '/auth/me/account/identities/:provider', when: callerIsDemo },
// DM17: the whole route, because a mobile access token carries no refresh-row
// id and "a session that is not the caller's" cannot be decided. Signing out
// (POST /auth/mobile/logout) revokes the caller's own row and stays open.
{ methods: ['DELETE'], path: '/auth/me/sessions/:id', when: callerIsDemo },
// Tier A — credentials for everyone.
{ methods: ['POST'], path: '/auth/password/forgot' },
{ methods: ['POST'], path: '/auth/password/reset/:token' },
// DM16: settings are open, so `player_registration` can be switched on; the
// route is what keeps registration closed (§4).
{ methods: ['POST'], path: '/auth/register' },
// Tier C's one exception: the contact form mails a person.
{ methods: ['POST'], path: '/public/contact' },
// Uploads. `refuseMultipart` already stops every one of these; they are
// listed too so the table in §3.3 and this file say the same thing, and so a
// non-multipart body sent to an upload route is refused the same way.
{ methods: ['POST'], path: '/admin/uploads' },
{ methods: ['POST'], path: '/admin/posts/upload' },
{ methods: ['POST'], path: '/admin/settings/brand-asset/:slot' },
{ methods: ['POST'], path: '/player/teams/:slug/forum/uploads' },
].map((rule) => ({ ...rule, source: 'core', compiled: compile(rule.path) }))
/**
* Build the lock middleware.
*
* @param {() => object[]} moduleLocks returns the installed modules' compiled
* locks — asked per request, because the loader runs after app.js mounts this.
*/
function createLock(moduleLocks = () => []) {
return async function lock(req, res, next) {
if (!WRITES.includes(req.method)) return next()
const rules = CORE_LOCKS.concat(moduleLocks())
for (const rule of rules) {
if (!rule.methods.includes(req.method)) continue
const params = match(rule.compiled, req.path)
if (!params) continue
if (rule.when) {
let applies
try {
applies = await rule.when(req, params)
} catch (err) {
// Fail closed: a lock that cannot decide is a lock.
log.warn('lock condition failed; refusing', { path: rule.path, message: err.message })
applies = true
}
if (!applies) continue
}
log.info('refused', { method: req.method, path: req.originalUrl, rule: rule.path, source: rule.source })
const keys = rule.path === '/admin/settings' ? { keys: protectedKeys(req.body) } : {}
return refuse(res, keys)
}
return next()
}
}
// Any multipart content type, not only form-data: nothing on the demo takes one,
// and a narrower test is one more spelling to get wrong. The header alone
// decides, so a multipart request with no body is refused too.
function refuseMultipart(req, res, next) {
const type = String(req.headers['content-type'] || '').trim().toLowerCase()
if (!type.startsWith('multipart/')) return next()
log.info('refused upload', { method: req.method, path: req.originalUrl })
return refuse(res)
}
function noindex(req, res, next) {
res.set('X-Robots-Tag', 'noindex, nofollow')
next()
}
function robots(req, res) {
res.type('text/plain').send('User-agent: *\nDisallow: /\n')
}
module.exports = {
CODE,
MESSAGE,
WRITES,
CORE_LOCKS,
PROTECTED_SETTINGS,
createLock,
refuseMultipart,
noindex,
robots,
refuse,
protectedKeys,
}

26
server/src/demo/mode.js Normal file
View File

@@ -0,0 +1,26 @@
// ── Demo mode: the flag and the four shared accounts ───────────────────────
//
// `DEMO_MODE=true` on a deployment turns it into the public demo
// (docs/website/DEMO_MODE.md). Read per call rather than once at require time,
// so a test can flip it without re-requiring every file that asks — and so
// there is exactly one spelling of "is this the demo" in the codebase.
//
// Only the literal string `true` turns it on. A stray `DEMO_MODE=1` or `yes` on
// a real shard must not lock its admin panel, and the opposite mistake — a demo
// that forgot the flag — is caught by `seed:demo`, which refuses to run without it.
function isDemo() {
return process.env.DEMO_MODE === 'true'
}
// The static logins (DEMO_MODE.md §4). Fixed here, in core, rather than read
// from the seed fixture: the lock has to recognise them on every request, and a
// fixture that dropped one would quietly unlock the account everybody shares.
// The seed creates exactly these; their passwords come from the fixture.
const DEMO_ACCOUNTS = Object.freeze(['demo-admin', 'demo-moderator', 'demo-player', 'play-review'])
function isDemoUsername(username) {
return typeof username === 'string' && DEMO_ACCOUNTS.includes(username.toLowerCase())
}
module.exports = { isDemo, DEMO_ACCOUNTS, isDemoUsername }

70
server/src/demo/paths.js Normal file
View File

@@ -0,0 +1,70 @@
// ── Lock patterns: `:param` segments and a trailing `*` ────────────────────
//
// The demo's lock list names routes the way the routers declare them —
// `/admin/users/:id` — so a reviewer can hold the list against the route
// manifest line by line. A trailing `/*` means "this path and everything under
// it", for the areas DEMO_MODE.md §3.3 locks as a whole ("every write under
// /admin/modules").
//
// The matcher reproduces the two express 4 routing defaults that decide whether
// a request reaches a handler at all: matching is case-insensitive, and a
// trailing slash is optional. A lock that was stricter than the router about
// either would let `PUT /Admin/Site-Mode/` through to a handler that answers it.
const SEGMENT = /^(?::[A-Za-z][A-Za-z0-9]*|[A-Za-z0-9._-]+)$/
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
/**
* Compile a lock pattern.
*
* @param {string} pattern e.g. `/admin/users/:id` or `/admin/modules/*`
* @returns {{pattern: string, names: string[], re: RegExp}}
* @throws on a pattern that is not absolute, has an empty segment, or puts `*`
* anywhere but last — a lock that silently compiled to something else would
* be worse than no lock, because it would read as one.
*/
function compile(pattern) {
if (typeof pattern !== 'string' || !pattern.startsWith('/')) {
throw new Error(`lock path "${pattern}" must start with /`)
}
const segs = pattern.slice(1).split('/')
const names = []
let source = ''
segs.forEach((seg, i) => {
if (seg === '*') {
if (i !== segs.length - 1) throw new Error(`lock path "${pattern}": * must be the last segment`)
source += '(?:/.*)?'
return
}
if (!SEGMENT.test(seg)) throw new Error(`lock path "${pattern}": bad segment "${seg}"`)
if (seg.startsWith(':')) {
names.push(seg.slice(1))
source += '/([^/]+)'
} else {
source += `/${escape(seg)}`
}
})
return { pattern, names, re: new RegExp(`^${source}/?$`, 'i') }
}
/**
* Match a request path against a compiled pattern.
*
* @returns {Record<string,string>|null} the decoded params, or null
*/
function match(compiled, path) {
const m = compiled.re.exec(path)
if (!m) return null
const params = {}
compiled.names.forEach((name, i) => {
try {
params[name] = decodeURIComponent(m[i + 1])
} catch {
params[name] = m[i + 1]
}
})
return params
}
module.exports = { compile, match }