Files
website/client/src/lib/demoNotice.js
wtclaude 4ad746c43e
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
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
2026-10-23 18:52:09 -05:00

58 lines
2.1 KiB
JavaScript

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