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
58 lines
2.1 KiB
JavaScript
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`
|
|
}
|