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

70
server/src/demo/paths.js Normal file
View File

@@ -0,0 +1,70 @@
// ── Lock patterns: `:param` segments and a trailing `*` ────────────────────
//
// The demo's lock list names routes the way the routers declare them —
// `/admin/users/:id` — so a reviewer can hold the list against the route
// manifest line by line. A trailing `/*` means "this path and everything under
// it", for the areas DEMO_MODE.md §3.3 locks as a whole ("every write under
// /admin/modules").
//
// The matcher reproduces the two express 4 routing defaults that decide whether
// a request reaches a handler at all: matching is case-insensitive, and a
// trailing slash is optional. A lock that was stricter than the router about
// either would let `PUT /Admin/Site-Mode/` through to a handler that answers it.
const SEGMENT = /^(?::[A-Za-z][A-Za-z0-9]*|[A-Za-z0-9._-]+)$/
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
/**
* Compile a lock pattern.
*
* @param {string} pattern e.g. `/admin/users/:id` or `/admin/modules/*`
* @returns {{pattern: string, names: string[], re: RegExp}}
* @throws on a pattern that is not absolute, has an empty segment, or puts `*`
* anywhere but last — a lock that silently compiled to something else would
* be worse than no lock, because it would read as one.
*/
function compile(pattern) {
if (typeof pattern !== 'string' || !pattern.startsWith('/')) {
throw new Error(`lock path "${pattern}" must start with /`)
}
const segs = pattern.slice(1).split('/')
const names = []
let source = ''
segs.forEach((seg, i) => {
if (seg === '*') {
if (i !== segs.length - 1) throw new Error(`lock path "${pattern}": * must be the last segment`)
source += '(?:/.*)?'
return
}
if (!SEGMENT.test(seg)) throw new Error(`lock path "${pattern}": bad segment "${seg}"`)
if (seg.startsWith(':')) {
names.push(seg.slice(1))
source += '/([^/]+)'
} else {
source += `/${escape(seg)}`
}
})
return { pattern, names, re: new RegExp(`^${source}/?$`, 'i') }
}
/**
* Match a request path against a compiled pattern.
*
* @returns {Record<string,string>|null} the decoded params, or null
*/
function match(compiled, path) {
const m = compiled.re.exec(path)
if (!m) return null
const params = {}
compiled.names.forEach((name, i) => {
try {
params[name] = decodeURIComponent(m[i + 1])
} catch {
params[name] = m[i + 1]
}
})
return params
}
module.exports = { compile, match }