diff --git a/client/src/App.jsx b/client/src/App.jsx
index aac4eee..a34646b 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -2,6 +2,8 @@ import { Routes, Route, Navigate, Outlet } from 'react-router-dom'
import { AuthProvider } from './contexts/AuthContext.jsx'
import { SiteProvider } from './contexts/SiteContext.jsx'
import MaintenanceGate from './components/MaintenanceGate.jsx'
+import DemoBanner from './components/DemoBanner.jsx'
+import DemoToast from './components/DemoToast.jsx'
import RequireAuth from './components/RequireAuth.jsx'
import RequirePlayer from './components/RequirePlayer.jsx'
import RoleGate from './components/RoleGate.jsx'
@@ -90,6 +92,10 @@ export default function App() {
the provider hooks are called once for the whole app rather than
once per screen. */}
+ {/* The public demo's strip and refusal toast (DEMO_MODE.md §3.1, §6).
+ Both render nothing unless the server says this is the demo. */}
+
+
{/* Landing hero — always public, even in maintenance mode. The hero is
itself the pre-launch "coming soon" page, so it sits outside the
diff --git a/client/src/api/client.js b/client/src/api/client.js
index 279a35c..bfa41b6 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -1,5 +1,7 @@
// Thin fetch wrapper. Always sends cookies (httpOnly JWT) and talks to the
// same-origin API (/api/v1) — proxied to the Express server in dev.
+import { reportIfDemoLocked } from '../lib/demoNotice.js'
+
const BASE = '/api/v1'
// Prefix a non-empty query string with "?" (and nothing when it is empty), so
@@ -28,6 +30,9 @@ async function req(path, { method = 'GET', body, headers, raw } = {}) {
const text = await res.text()
const data = text ? safeParse(text) : null
if (!res.ok) {
+ // The public demo's lock (DEMO_MODE.md §3.1): shown once, as a toast, for
+ // every caller. The ApiError below still goes to the form as usual.
+ reportIfDemoLocked(data)
const message = (data && data.message) || res.statusText || 'Request failed'
throw new ApiError(res.status, message, data)
}
diff --git a/client/src/components/DemoBanner.jsx b/client/src/components/DemoBanner.jsx
new file mode 100644
index 0000000..9faa11f
--- /dev/null
+++ b/client/src/components/DemoBanner.jsx
@@ -0,0 +1,47 @@
+import { Link } from 'react-router-dom'
+import { useSite } from '../contexts/SiteContext.jsx'
+import { formatResetTime } from '../lib/demoNotice.js'
+
+// The public demo's strip, on every page (docs/website/DEMO_MODE.md §6).
+// Renders nothing unless GET /public/settings carries a `demo` block, which the
+// server sends only with DEMO_MODE on — so a real shard never draws it, and the
+// banner needs no schedule of its own: `resetsAt` is stamped by every wipe.
+export default function DemoBanner() {
+ const { settings } = useSite()
+ const demo = settings.demo
+ if (!demo) return null
+
+ const at = demo.resetsAt ? formatResetTime(demo.resetsAt) : null
+ const names = (demo.logins || []).map((l) => l.username)
+
+ return (
+
+ Demo site
+ {' — anything you change is wiped '}
+ {at ? `at ${at}` : 'every two hours'}.
+ {names.length > 0 && (
+ <>
+ {' Sign-ins: '}
+ {names.join(' / ')}
+ {' — passwords on the '}
+
+ sign-in page
+
+ .
+ >
+ )}
+
+ )
+}
diff --git a/client/src/components/DemoLogins.jsx b/client/src/components/DemoLogins.jsx
new file mode 100644
index 0000000..ea0d863
--- /dev/null
+++ b/client/src/components/DemoLogins.jsx
@@ -0,0 +1,51 @@
+import { useSite } from '../contexts/SiteContext.jsx'
+
+// The published sign-ins, on the sign-in pages of the public demo
+// (DEMO_MODE.md §4). They are public by design — printed here and on
+// runicgateway.com/beta — so "Use" fills the form rather than making anyone
+// copy a password. Renders nothing off the demo.
+export default function DemoLogins({ onPick }) {
+ const { settings } = useSite()
+ const logins = settings.demo?.logins || []
+ if (logins.length === 0) return null
+
+ return (
+
+
+ Demo sign-ins— shared by everyone, reset every wipe
+
+
+
+ {logins.map((l) => (
+
+
+
{l.username}
+
+ {l.password}
+
+
+
{l.role}
+
+
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/client/src/components/DemoToast.jsx b/client/src/components/DemoToast.jsx
new file mode 100644
index 0000000..5ca6a5a
--- /dev/null
+++ b/client/src/components/DemoToast.jsx
@@ -0,0 +1,47 @@
+import { useEffect, useState } from 'react'
+import { onDemoLocked } from '../lib/demoNotice.js'
+
+// The one toast for a write the public demo refused (DEMO_MODE.md §3.1). Fed
+// by api/client.js through lib/demoNotice.js, so every screen — and every
+// installed module's screens — gets it without handling the code itself. On a
+// real shard nothing is ever refused that way, so it never appears.
+const SHOW_MS = 5000
+
+export default function DemoToast() {
+ const [message, setMessage] = useState(null)
+
+ useEffect(() => onDemoLocked((text) => setMessage(text)), [])
+
+ useEffect(() => {
+ if (!message) return undefined
+ const t = setTimeout(() => setMessage(null), SHOW_MS)
+ return () => clearTimeout(t)
+ }, [message])
+
+ return (
+
+ {message && (
+
+ {message}
+
+ )}
+
+ )
+}
diff --git a/client/src/lib/demoNotice.js b/client/src/lib/demoNotice.js
new file mode 100644
index 0000000..fe31948
--- /dev/null
+++ b/client/src/lib/demoNotice.js
@@ -0,0 +1,57 @@
+// The public demo's refusal (docs/website/DEMO_MODE.md §3.1).
+//
+// With DEMO_MODE on, the server answers a locked write `403 { code:
+// 'DEMO_LOCKED', message }`. Every request goes through api/client.js's req(),
+// installed modules' included, so req() reports the refusal here and the one
+// toast (components/DemoToast.jsx) shows it — wherever the action was, without
+// each form learning the code. The form still gets its ApiError as usual.
+//
+// The lock is the server's (DM10). Nothing here decides what is allowed; it
+// only explains what was refused.
+
+export const DEMO_LOCKED = 'DEMO_LOCKED'
+
+const listeners = new Set()
+
+/** Subscribe to refusals. Returns the unsubscribe function. */
+export function onDemoLocked(fn) {
+ listeners.add(fn)
+ return () => listeners.delete(fn)
+}
+
+/** Report a refusal, if this response body is one. Returns whether it was. */
+export function reportIfDemoLocked(body) {
+ if (!body || typeof body !== 'object' || body.code !== DEMO_LOCKED) return false
+ const message = typeof body.message === 'string' && body.message ? body.message : 'This is turned off on the demo site.'
+ for (const fn of listeners) {
+ try {
+ fn(message)
+ } catch {
+ // A broken listener must not turn a refusal into a different error.
+ }
+ }
+ return true
+}
+
+/**
+ * Was this ApiError the demo's refusal? For the few screens that turn a 403
+ * into a sentence of their own ("only an administrator can…"), which would
+ * otherwise blame the role of somebody who is signed in as an admin.
+ */
+export function isDemoLocked(err) {
+ return Boolean(err && err.body && err.body.code === DEMO_LOCKED)
+}
+
+/**
+ * "14:00 UTC" for the banner. The wipe runs on the hour in UTC (DM7), so the
+ * banner says UTC rather than converting — a converted time in a reviewer's
+ * zone reads as a different schedule from the one published on /beta.
+ */
+export function formatResetTime(iso) {
+ const ms = Date.parse(iso)
+ if (Number.isNaN(ms)) return null
+ const d = new Date(ms)
+ const hh = String(d.getUTCHours()).padStart(2, '0')
+ const mm = String(d.getUTCMinutes()).padStart(2, '0')
+ return `${hh}:${mm} UTC`
+}
diff --git a/client/src/modules/version.js b/client/src/modules/version.js
index a4665bd..a0fef7e 100644
--- a/client/src/modules/version.js
+++ b/client/src/modules/version.js
@@ -11,6 +11,10 @@
// that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both.
+// 1.18.0 — the public demo (DEMO_MODE.md §3.2): `demoLocks` in module.json,
+// `ctx.demoMode`, `api.onDemoSeed`. Server-side; the client half is core's own
+// banner and refusal toast, which an installed module's requests reach through
+// the shared request primitive. This file bumps for the reason at the top.
// 1.17.0 — a module may take part in linking a sign-in identity (STEAM_SIGNIN.md
// §4): `ctx.identities.list`, `api.onIdentityLinking`, `api.onIdentityLinked`.
// Server-side; the client half is core's own account pages showing Steam. This
@@ -98,4 +102,4 @@
// but the two halves state ONE version: a module declares a single coreApi range
// and is served one chunk, so a client that claimed 1.0.0 while the server
// answered 1.1.0 would be two answers to one question.
-export const MODULE_API_VERSION = '1.17.0'
+export const MODULE_API_VERSION = '1.18.0'
diff --git a/client/src/routes/admin/AdminLogin.jsx b/client/src/routes/admin/AdminLogin.jsx
index d6492ef..07e125a 100644
--- a/client/src/routes/admin/AdminLogin.jsx
+++ b/client/src/routes/admin/AdminLogin.jsx
@@ -4,6 +4,7 @@ import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import ProviderIcon from '../../components/ProviderIcon.jsx'
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
+import DemoLogins from '../../components/DemoLogins.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
import { api } from '../../api/client.js'
@@ -342,6 +343,14 @@ export default function AdminLogin() {
Protected area — not indexed. Sessions expire after 1 day.
← Back to site
diff --git a/client/src/routes/admin/views/Dashboard.jsx b/client/src/routes/admin/views/Dashboard.jsx
index 1dd5fd4..5d8a7cb 100644
--- a/client/src/routes/admin/views/Dashboard.jsx
+++ b/client/src/routes/admin/views/Dashboard.jsx
@@ -3,6 +3,7 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { ago, dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
+import { isDemoLocked } from '../../../lib/demoNotice.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
import { useAuth } from '../../../contexts/AuthContext.jsx'
@@ -49,11 +50,10 @@ export default function Dashboard() {
await refreshSite()
reload()
} catch (err) {
- setModeError(
- err.status === 403
- ? 'Only an administrator can change the site mode.'
- : 'Could not change the site mode. Try again.',
- )
+ let message = 'Could not change the site mode. Try again.'
+ if (isDemoLocked(err)) message = err.message
+ else if (err.status === 403) message = 'Only an administrator can change the site mode.'
+ setModeError(message)
} finally {
setBusy(false)
}
diff --git a/client/src/routes/player/PlayerLogin.jsx b/client/src/routes/player/PlayerLogin.jsx
index cda99ab..e4b28b9 100644
--- a/client/src/routes/player/PlayerLogin.jsx
+++ b/client/src/routes/player/PlayerLogin.jsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import ProviderIcon from '../../components/ProviderIcon.jsx'
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
+import DemoLogins from '../../components/DemoLogins.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
@@ -272,6 +273,15 @@ export default function PlayerLogin() {
)}
+ {stage === 'creds' && (
+ {
+ setUsername(u)
+ setPassword(p)
+ }}
+ />
+ )}
+
{trustLimit && (
{
+ global.fetch = realFetch
+})
+
+function replyOnce(status, body) {
+ global.fetch = async () => ({
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: 'Forbidden',
+ text: async () => JSON.stringify(body),
+ })
+}
+
+test('a DEMO_LOCKED answer is reported once and still thrown to the caller', async () => {
+ const heard = []
+ const off = onDemoLocked((m) => heard.push(m))
+ replyOnce(403, { code: 'DEMO_LOCKED', message: 'This is turned off on the demo site.' })
+ await assert.rejects(() => api.publicSettings(), (err) => err.status === 403 && err.message === 'This is turned off on the demo site.')
+ off()
+ assert.deepEqual(heard, ['This is turned off on the demo site.'])
+})
+
+test('any other refusal is not a demo refusal', async () => {
+ const heard = []
+ const off = onDemoLocked((m) => heard.push(m))
+ replyOnce(403, { message: 'Admin role required' })
+ await assert.rejects(() => api.publicSettings())
+ off()
+ assert.deepEqual(heard, [])
+})
+
+test('reportIfDemoLocked recognises the code alone and survives a broken listener', () => {
+ const off1 = onDemoLocked(() => {
+ throw new Error('boom')
+ })
+ const heard = []
+ const off2 = onDemoLocked((m) => heard.push(m))
+ assert.equal(reportIfDemoLocked({ code: 'DEMO_LOCKED' }), true)
+ assert.equal(reportIfDemoLocked({ code: 'OTHER' }), false)
+ assert.equal(reportIfDemoLocked('text'), false)
+ assert.equal(reportIfDemoLocked(null), false)
+ off1()
+ off2()
+ assert.deepEqual(heard, ['This is turned off on the demo site.'])
+})
+
+test('isDemoLocked tells the demo refusal from a role 403', async () => {
+ replyOnce(403, { code: 'DEMO_LOCKED', message: 'This is turned off on the demo site.' })
+ const locked = await api.publicSettings().catch((e) => e)
+ replyOnce(403, { message: 'Admin role required' })
+ const role = await api.publicSettings().catch((e) => e)
+ assert.equal(isDemoLocked(locked), true)
+ assert.equal(isDemoLocked(role), false)
+ assert.equal(isDemoLocked(null), false)
+})
+
+test('the reset time is said in UTC, as the wipe runs', () => {
+ assert.equal(formatResetTime('2026-10-24T14:00:00.000Z'), '14:00 UTC')
+ assert.equal(formatResetTime('2026-10-24T09:05:00+02:00'), '07:05 UTC')
+ assert.equal(formatResetTime('nope'), null)
+})
diff --git a/server/db/seedDemo.js b/server/db/seedDemo.js
new file mode 100644
index 0000000..66fbd34
--- /dev/null
+++ b/server/db/seedDemo.js
@@ -0,0 +1,254 @@
+// ── `npm run seed:demo` — the public demo's base (DEMO_MODE.md §7.3) ────────
+//
+// Run by the wipe workflow inside the app container, after `compose down -v` and
+// a fresh `up` have left an empty database with the schema in place:
+//
+// docker compose exec app npm run seed:demo -- /app/deploy/demo/seed.json
+//
+// It writes, from one fixture file:
+// 1. the four static logins (§4) — upserted, so a password a visitor somehow
+// changed is back within one cycle and a re-run is harmless;
+// 2. the site settings the demo starts from (live, registration disabled, and
+// whatever the fixture adds);
+// 3. the seeded content — posts, wiki pages, pages, events — on the first run
+// against a database only, so a re-run never duplicates it;
+// 4. each installed module's own rows, through the module's `onDemoSeed` hook
+// (API 1.18.0, DM15): core never writes a module's tables;
+// 5. `demo_state` — when the next wipe is due and the published logins, for
+// the banner and the sign-in page (§6).
+//
+// A string `"$env:NAME"` anywhere in the fixture is replaced with that variable,
+// so the rig's sidecar token lives in the host's .env and never in the repo. An
+// unset variable fails the run rather than seeding an empty credential.
+//
+// Refuses to run without DEMO_MODE=true: pointed at a real shard by mistake, it
+// would reset four accounts' passwords and flip the site live.
+
+require('dotenv').config()
+
+const fs = require('fs')
+const path = require('path')
+
+const { isDemo, DEMO_ACCOUNTS } = require('../src/demo/mode')
+
+const ROLES = ['admin', 'editor', 'moderator', 'player']
+
+const log = require('../src/utils/logger')('seed-demo')
+
+// ── Pure helpers (tested in test/seedDemo.test.js) ─────────────────────────
+
+/** Replace every `"$env:NAME"` string, at any depth. Throws on an unset one. */
+function resolveEnv(value, env = process.env, where = 'fixture') {
+ if (typeof value === 'string') {
+ const m = /^\$env:([A-Z_][A-Z0-9_]*)$/.exec(value)
+ if (!m) return value
+ const got = env[m[1]]
+ if (got === undefined || got === '') throw new Error(`${where}: environment variable ${m[1]} is not set`)
+ return got
+ }
+ if (Array.isArray(value)) return value.map((v, i) => resolveEnv(v, env, `${where}[${i}]`))
+ if (value && typeof value === 'object') {
+ const out = {}
+ for (const [k, v] of Object.entries(value)) out[k] = resolveEnv(v, env, `${where}.${k}`)
+ return out
+ }
+ return value
+}
+
+/**
+ * When the next wipe is due: the next whole multiple of `everyHours` past
+ * midnight UTC, strictly after `now` — the schedule the wipe workflow's cron
+ * runs on (DM7: every 2 hours on the hour). The seed runs a minute or two after
+ * a wipe starts, so "strictly after" lands on the following slot.
+ */
+function nextResetAt(now = new Date(), everyHours = 2) {
+ const step = everyHours * 60 * 60 * 1000
+ const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
+ const slots = Math.floor((now.getTime() - midnight) / step) + 1
+ return new Date(midnight + slots * step)
+}
+
+/** Check the fixture's shape before anything is written. Returns a list of problems. */
+function checkFixture(fixture) {
+ const problems = []
+ if (!fixture || typeof fixture !== 'object' || Array.isArray(fixture)) return ['the fixture must be a JSON object']
+ const accounts = fixture.accounts || {}
+ for (const name of DEMO_ACCOUNTS) {
+ const a = accounts[name]
+ if (!a) {
+ problems.push(`accounts.${name} is missing`)
+ continue
+ }
+ if (typeof a.password !== 'string' || a.password.length < 8) {
+ problems.push(`accounts.${name}.password must be a string of at least 8 characters`)
+ }
+ if (!ROLES.includes(a.role)) problems.push(`accounts.${name}.role must be one of ${ROLES.join(', ')}`)
+ }
+ for (const name of Object.keys(accounts)) {
+ if (!DEMO_ACCOUNTS.includes(name)) {
+ problems.push(`accounts.${name} is not a demo account (the lock only knows ${DEMO_ACCOUNTS.join(', ')})`)
+ }
+ }
+ if (fixture.settings !== undefined) {
+ if (!fixture.settings || typeof fixture.settings !== 'object' || Array.isArray(fixture.settings)) {
+ problems.push('settings must be an object')
+ } else if ('demo_state' in fixture.settings) {
+ problems.push('settings.demo_state is written by the seed itself')
+ }
+ }
+ for (const key of ['posts', 'wiki', 'pages', 'events']) {
+ const list = fixture.content?.[key]
+ if (list !== undefined && !Array.isArray(list)) problems.push(`content.${key} must be an array`)
+ }
+ if (fixture.modules !== undefined && (typeof fixture.modules !== 'object' || Array.isArray(fixture.modules))) {
+ problems.push('modules must be an object keyed by module id')
+ }
+ return problems
+}
+
+// ── The run ────────────────────────────────────────────────────────────────
+
+/* eslint-disable global-require */
+async function upsertAccounts(fixture) {
+ const users = require('../src/model/users/users.model')
+ const ids = {}
+ for (const username of DEMO_ACCOUNTS) {
+ const { password, role } = fixture.accounts[username]
+ const existing = await users.getRawByUsername(username)
+ let id
+ if (existing) {
+ id = existing.id
+ // A password change bumps the session cutoff, which signs every visitor out
+ // of this account — the right outcome at a wipe, and on a fresh database
+ // (the normal case) there is nobody to sign out.
+ await users.update(id, { password, role, status: 'active', emailVerified: true })
+ await users.disableTotp(id)
+ } else {
+ const created = await users.createUser({ username, password, role, status: 'active', emailVerified: true })
+ id = created.id
+ }
+ ids[username] = id
+ log.info(`account ${username} (${role}) ready`, { id })
+ }
+ return ids
+}
+
+async function writeSettings(fixture) {
+ const settings = require('../src/model/settings/settings.model')
+ await settings.setMany({
+ site_mode: 'live',
+ site_mode_changed_at: new Date().toISOString(),
+ site_mode_changed_by: 'seed:demo',
+ // §4: registration is closed, and the route is locked too (DM16).
+ [settings.REGISTRATION_KEY]: 'disabled',
+ ...(fixture.settings || {}),
+ })
+}
+
+async function writeContent(fixture, authorId) {
+ const content = fixture.content || {}
+ const posts = require('../src/model/posts/posts.model')
+ const wiki = require('../src/model/wiki/wiki.model')
+ const pages = require('../src/model/pages/pages.model')
+ const events = require('../src/model/events/eventDefinitions.model')
+
+ for (const p of content.posts || []) {
+ const category = posts.toDbCategory(p.category || 'news')
+ if (!category) throw new Error(`post "${p.title}": unknown category "${p.category}"`)
+ await posts.create({ ...p, category, published: p.published !== false, author_id: authorId })
+ }
+ for (const w of content.wiki || []) {
+ const category = w.category ? await wiki.getCategoryBySlug(w.category) : null
+ if (w.category && !category) throw new Error(`wiki "${w.slug}": unknown category "${w.category}"`)
+ await wiki.create({ ...w, categoryId: category ? category.id : null, updatedBy: authorId })
+ }
+ for (const pg of content.pages || []) {
+ await pages.create(pg, authorId)
+ }
+ for (const e of content.events || []) {
+ const { publish, ...input } = e
+ const made = await events.create(input, authorId, { role: 'admin' })
+ if (!made.ok) throw new Error(`event "${input.name || input.title}": ${(made.errors || []).join('; ')}`)
+ if (publish) {
+ const pub = await events.publish(made.id, authorId)
+ if (!pub.ok) throw new Error(`event "${input.name || input.title}" did not publish: ${(pub.errors || []).join('; ')}`)
+ }
+ }
+ log.info('content written', {
+ posts: (content.posts || []).length,
+ wiki: (content.wiki || []).length,
+ pages: (content.pages || []).length,
+ events: (content.events || []).length,
+ })
+}
+
+async function seedModules(fixture, accounts) {
+ const modules = require('../src/modules/loader')
+ const hooks = modules.demoSeedHooks()
+ const wanted = fixture.modules || {}
+ for (const id of Object.keys(wanted)) {
+ if (!hooks.some((h) => h.id === id)) {
+ throw new Error(`modules.${id}: no installed module "${id}" takes a demo seed (onDemoSeed)`)
+ }
+ }
+ for (const { id, hook } of hooks) {
+ if (wanted[id] === undefined) continue
+ // The module gets its part of the fixture and the demo accounts' ids, so it
+ // can link one of them to a game identity without reading core's users table.
+ await hook(wanted[id], { accounts: { ...accounts } })
+ log.info(`module ${id} seeded`)
+ }
+}
+
+async function writeState(fixture, now = new Date()) {
+ const settings = require('../src/model/settings/settings.model')
+ const everyHours = Number(process.env.DEMO_RESET_EVERY_HOURS) || 2
+ const logins = DEMO_ACCOUNTS
+ .filter((name) => fixture.accounts[name].published !== false)
+ .map((name) => ({ username: name, password: fixture.accounts[name].password, role: fixture.accounts[name].role }))
+ const state = { resetsAt: nextResetAt(now, everyHours).toISOString(), logins }
+ await settings.set(settings.DEMO_STATE_KEY, JSON.stringify(state))
+ log.info('demo state stamped', { resetsAt: state.resetsAt, logins: logins.length })
+}
+
+async function run(file) {
+ if (!isDemo()) throw new Error('seed:demo runs only with DEMO_MODE=true')
+ if (!file) throw new Error('usage: npm run seed:demo -- (or set DEMO_SEED_FILE)')
+
+ const fixture = resolveEnv(JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')))
+ const problems = checkFixture(fixture)
+ if (problems.length) throw new Error(`the fixture has problems:\n - ${problems.join('\n - ')}`)
+
+ // The app itself, without listening: this is what loads the installed modules
+ // (and core's registries, which an event definition is validated against)
+ // exactly as the server does. Nothing here calls onBoot, so no module starts
+ // its workers in this process.
+ require('../src/app')
+ const settings = require('../src/model/settings/settings.model')
+
+ const firstRun = !(await settings.get(settings.DEMO_STATE_KEY))
+ const accounts = await upsertAccounts(fixture)
+ await writeSettings(fixture)
+ if (firstRun) await writeContent(fixture, accounts['demo-admin'])
+ else log.info('content already seeded on this database; left as is')
+ await seedModules(fixture, accounts)
+ await writeState(fixture)
+}
+/* eslint-enable global-require */
+
+if (require.main === module) {
+ ;(async () => {
+ const { close } = require('../src/utils/db') // eslint-disable-line global-require
+ try {
+ await run(process.argv[2] || process.env.DEMO_SEED_FILE)
+ log.info('demo seeded')
+ } catch (err) {
+ log.error(`seed:demo failed: ${err.message}`)
+ process.exitCode = 1
+ } finally {
+ await close()
+ }
+ })()
+}
+
+module.exports = { resolveEnv, nextResetAt, checkFixture, run }
diff --git a/server/engagement-triggers.json b/server/engagement-triggers.json
index b4f945a..a748e2e 100644
--- a/server/engagement-triggers.json
+++ b/server/engagement-triggers.json
@@ -1,6 +1,6 @@
{
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
- "moduleApiVersion": "1.17.0",
+ "moduleApiVersion": "1.18.0",
"triggers": [
{
"id": "event.phase.changed",
diff --git a/server/package.json b/server/package.json
index a9e5966..1f2b4a9 100644
--- a/server/package.json
+++ b/server/package.json
@@ -7,6 +7,7 @@
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"seed": "node db/seed.js",
+ "seed:demo": "node db/seedDemo.js",
"swagger": "node swagger/swagger.js",
"routes:manifest": "node scripts/routeManifest.js",
"engagement:manifest": "node scripts/engagementManifest.js",
diff --git a/server/src/app.js b/server/src/app.js
index f7d8e1c..1519d2d 100644
--- a/server/src/app.js
+++ b/server/src/app.js
@@ -21,6 +21,8 @@ const createLogger = require('./utils/logger')
const htmlShell = require('./utils/htmlShell')
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
const botScore = require('./middleware/botScore')
+const { isDemo } = require('./demo/mode')
+const demoLock = require('./demo/locks')
const httpLog = createLogger('http')
const errLog = createLogger('error')
@@ -84,6 +86,23 @@ app.use(morgan(accessFormat, { stream: { write: (line) => httpLog.info(line.trim
app.use(express.json({ limit: '2mb' }))
app.use(cookieParser())
+// ── The public demo ───────────────────────────────────────────────────
+// DEMO_MODE=true turns this deployment into demo.runicgateway.com
+// (docs/website/DEMO_MODE.md). Unset, nothing here is mounted, so a real shard
+// runs none of it. After the body parser, because one lock reads the body of
+// PUT /admin/settings; after cookieParser, because the shared-login locks read
+// the session; ahead of `/api` and of the SPA's static files (robots.txt).
+// The module locks are asked per request: the loader runs further down.
+if (isDemo()) {
+ app.use(demoLock.noindex)
+ app.get('/robots.txt', (req, res) => {
+ // #swagger.ignore = true
+ demoLock.robots(req, res)
+ })
+ app.use(demoLock.refuseMultipart)
+ app.use('/api/v1', demoLock.createLock(() => modules.demoLocks()))
+}
+
// ── Paths ─────────────────────────────────────────────────────────────
const SERVER_ROOT = path.join(__dirname, '..')
const REPO_ROOT = path.join(SERVER_ROOT, '..')
diff --git a/server/src/demo/locks.js b/server/src/demo/locks.js
new file mode 100644
index 0000000..ffe8091
--- /dev/null
+++ b/server/src/demo/locks.js
@@ -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,
+}
diff --git a/server/src/demo/mode.js b/server/src/demo/mode.js
new file mode 100644
index 0000000..22fa6b2
--- /dev/null
+++ b/server/src/demo/mode.js
@@ -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 }
diff --git a/server/src/demo/paths.js b/server/src/demo/paths.js
new file mode 100644
index 0000000..2765066
--- /dev/null
+++ b/server/src/demo/paths.js
@@ -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|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 }
diff --git a/server/src/model/settings/settings.model.js b/server/src/model/settings/settings.model.js
index 14ae934..f2a6fbb 100644
--- a/server/src/model/settings/settings.model.js
+++ b/server/src/model/settings/settings.model.js
@@ -3,6 +3,7 @@ const brand = require('../../config/brand')
const { parseJsonSetting } = require('../../utils/settingsJson')
const { resolveThemeTokens } = require('../../utils/themeResolve')
const { resolveBrandAssets } = require('../../utils/brandAssets')
+const { isDemo } = require('../../demo/mode')
// Keys safe to expose on the public site.
const PUBLIC_KEYS = [
@@ -215,9 +216,30 @@ async function getPublic() {
// before doing so (the server would otherwise reject an unallowlisted one). The
// custom-scheme callback works regardless of this flag.
out.mobileAppLinks = String(all[MOBILE_APP_LINKS_KEY]) === 'true'
+ // The public demo's banner and sign-in panel (DEMO_MODE.md §4, §6). Present
+ // only with DEMO_MODE on, so a real shard's payload is unchanged. Written by
+ // `seed:demo` at every wipe; the demo lock keeps `demo_*` out of PUT
+ // /admin/settings, so a visitor cannot rewrite the published passwords.
+ if (isDemo()) out.demo = demoBlock(all[DEMO_STATE_KEY])
return out
}
+// `demo_state` is one JSON object: `{ resetsAt, logins: [{ username, password,
+// role }] }`. The passwords are public by design (§4) — the logins are printed
+// on the sign-in page and on runicgateway.com/beta. Read forgivingly: a missing
+// or broken row is a banner with no time and no logins, never a 500.
+const DEMO_STATE_KEY = 'demo_state'
+function demoBlock(raw) {
+ const state = parseJsonSetting(raw) || {}
+ const resetsAt = typeof state.resetsAt === 'string' && !Number.isNaN(Date.parse(state.resetsAt))
+ ? state.resetsAt
+ : null
+ const logins = (Array.isArray(state.logins) ? state.logins : [])
+ .filter((l) => l && typeof l.username === 'string' && typeof l.password === 'string')
+ .map((l) => ({ username: l.username, password: l.password, role: typeof l.role === 'string' ? l.role : null }))
+ return { resetsAt, logins }
+}
+
/**
* What the HTML shell needs, resolved exactly as getPublic() resolves it: the
* effective favicon and logo, plus the theme token map for the boot