feat(demo): DEMO_MODE lock, seed:demo and the demo banner (MODULE_API 1.18.0)
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
47
client/src/components/DemoBanner.jsx
Normal file
47
client/src/components/DemoBanner.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
client/src/components/DemoLogins.jsx
Normal file
51
client/src/components/DemoLogins.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
client/src/components/DemoToast.jsx
Normal file
47
client/src/components/DemoToast.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
client/src/lib/demoNotice.js
Normal file
57
client/src/lib/demoNotice.js
Normal 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`
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.')
|
||||
|
||||
71
client/test/demoNotice.test.js
Normal file
71
client/test/demoNotice.test.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { test, afterEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { api } from '../src/api/client.js'
|
||||
import { onDemoLocked, reportIfDemoLocked, formatResetTime, isDemoLocked } from '../src/lib/demoNotice.js'
|
||||
|
||||
// The public demo's refusal reaches the one toast from req() itself, so every
|
||||
// screen and every installed module gets it without handling the code — and
|
||||
// the caller still gets its ApiError, so a form's own error line is unchanged.
|
||||
|
||||
const realFetch = global.fetch
|
||||
afterEach(() => {
|
||||
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)
|
||||
})
|
||||
Reference in New Issue
Block a user