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.

+ {stage === 'creds' && ( + { + setUsername(u) + setPassword(p) + }} + /> + )}

← 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