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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-23 18:52:09 -05:00
parent b5e0d01a91
commit 4ad746c43e
30 changed files with 1891 additions and 14 deletions

View File

@@ -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. */}
<ModuleFeaturesProvider>
{/* 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. */}
<DemoBanner />
<DemoToast />
<Routes>
{/* Landing hero — always public, even in maintenance mode. The hero is
itself the pre-launch "coming soon" page, so it sits outside the

View File

@@ -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)
}

View File

@@ -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 (
<div
role="note"
className="sans"
style={{
padding: '7px 16px',
textAlign: 'center',
fontSize: '0.82rem',
lineHeight: 1.45,
color: 'var(--head)',
background: 'var(--bg-deep)',
borderBottom: '1px solid var(--accent)',
}}
>
<strong style={{ color: 'var(--accent)' }}>Demo site</strong>
{' — anything you change is wiped '}
{at ? `at ${at}` : 'every two hours'}.
{names.length > 0 && (
<>
{' Sign-ins: '}
{names.join(' / ')}
{' — passwords on the '}
<Link to="/account/login" style={{ color: 'var(--accent)' }}>
sign-in page
</Link>
.
</>
)}
</div>
)
}

View File

@@ -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 (
<section
aria-label="Demo sign-ins"
className="sans"
style={{
marginTop: 18,
padding: '14px 16px',
border: '1px solid var(--line)',
borderRadius: 10,
fontSize: '0.84rem',
// Opaque: the admin sign-in page draws it over the hero image.
background: 'var(--bg-deep)',
}}
>
<p style={{ margin: '0 0 10px', color: 'var(--head)' }}>
<strong>Demo sign-ins</strong> <span style={{ color: 'var(--dim)' }}>— shared by everyone, reset every wipe</span>
</p>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
{logins.map((l) => (
<tr key={l.username}>
<td style={{ padding: '5px 0', verticalAlign: 'middle' }}>
<div style={{ color: 'var(--head)', whiteSpace: 'nowrap' }}>{l.username}</div>
<div style={{ color: 'var(--dim)', fontFamily: 'monospace', fontSize: '0.78rem', wordBreak: 'break-all' }}>
{l.password}
</div>
</td>
<td style={{ padding: '5px 8px', color: 'var(--dim)', verticalAlign: 'middle' }}>{l.role}</td>
<td style={{ padding: '5px 0', textAlign: 'right', verticalAlign: 'middle' }}>
<button type="button" className="btn btn-ghost" style={{ padding: '3px 10px', fontSize: '0.8rem' }} onClick={() => onPick(l.username, l.password)}>
Use
</button>
</td>
</tr>
))}
</tbody>
</table>
</section>
)
}

View File

@@ -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 (
<div
role="status"
aria-live="polite"
style={{ position: 'fixed', left: 16, right: 16, bottom: 20, zIndex: 1000, display: 'flex', justifyContent: 'center', pointerEvents: 'none' }}
>
{message && (
<div
className="sans"
style={{
maxWidth: 480,
padding: '11px 16px',
borderRadius: 10,
fontSize: '0.88rem',
color: 'var(--head)',
background: 'var(--bg-deep)',
border: '1px solid var(--accent)',
boxShadow: '0 12px 32px rgba(0,0,0,0.45)',
pointerEvents: 'auto',
}}
>
{message}
</div>
)}
</div>
)
}

View File

@@ -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`
}

View File

@@ -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'

View File

@@ -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.
</p>
</form>
{stage === 'creds' && (
<DemoLogins
onPick={(u, p) => {
setUsername(u)
setPassword(p)
}}
/>
)}
<p style={{ textAlign: 'center', margin: '20px 0 0' }}>
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>
← Back to site

View File

@@ -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)
}

View File

@@ -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() {
)}
</form>
{stage === 'creds' && (
<DemoLogins
onPick={(u, p) => {
setUsername(u)
setPassword(p)
}}
/>
)}
{trustLimit && (
<TrustLimitModal
devices={trustLimit.devices}

View File

@@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'
import ProviderIcon from '../../components/ProviderIcon.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
import { isDemoLocked } from '../../lib/demoNotice.js'
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
export default function PlayerRegister() {
@@ -54,6 +55,7 @@ export default function PlayerRegister() {
navigate('/player', { replace: true })
} catch (err) {
if (err.status === 409) setError('That username is already taken.')
else if (isDemoLocked(err)) setError(err.message)
else if (err.status === 403) setError('Registration is not open right now.')
else if (err.status === 400) setError(err.message || 'Please check your details and try again.')
else setError('Could not create your account right now.')