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

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