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

@@ -44,7 +44,13 @@ const doc = {
'- **Native / mobile clients** use bearer access tokens from ' +
'`POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\n' +
'Endpoints under `/api/v1/admin/**` require a valid session; some are further ' +
'restricted to the `admin` role (editors are limited to content).',
'restricted to the `admin` role (editors are limited to content).\n\n' +
'### The public demo\n' +
'A deployment run with `DEMO_MODE=true` (the public demo, docs/website/DEMO_MODE.md) ' +
'refuses a fixed set of write routes, and any `multipart/*` request, before the ' +
'route runs. **Any write route may then answer** `403` with the `DemoLocked` body ' +
'(`code: "DEMO_LOCKED"`); clients recognise the refusal by `code`, not by status. ' +
'Reads are never refused this way. With the flag unset no route answers it.',
},
servers: [
{ url: '/', description: 'Same-origin (current host)' },
@@ -100,6 +106,17 @@ const doc = {
type: 'object',
properties: { message: { type: 'string', example: 'Not found' } },
},
DemoLocked: {
type: 'object',
description:
'The public demo refused a write (DEMO_MODE.md §3). Only on a deployment with `DEMO_MODE=true`. `keys` is present on `PUT /admin/settings` and names the settings the demo keeps.',
properties: {
message: { type: 'string', example: 'This is turned off on the demo site.' },
code: { type: 'string', enum: ['DEMO_LOCKED'] },
keys: { type: 'array', items: { type: 'string' }, example: ['site_mode'] },
},
required: ['message', 'code'],
},
ValidationError: {
type: 'object',
properties: {
@@ -1080,6 +1097,25 @@ const doc = {
ntfyUrl: { type: 'string', nullable: true, example: 'https://ntfy.example.com' },
},
},
demo: {
type: 'object',
description:
'Present only on the public demo (`DEMO_MODE=true`, DEMO_MODE.md §4, §6). `resetsAt` is when the next wipe is due, for the banner; `logins` are the published static sign-ins — public by design, not secrets. Both are written by `npm run seed:demo` at every wipe.',
properties: {
resetsAt: { type: 'string', format: 'date-time', nullable: true, example: '2026-10-24T14:00:00.000Z' },
logins: {
type: 'array',
items: {
type: 'object',
properties: {
username: { type: 'string', example: 'demo-admin' },
password: { type: 'string', example: 'demo-admin' },
role: { type: 'string', nullable: true, example: 'admin' },
},
},
},
},
},
},
additionalProperties: true,
},