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
71 lines
2.4 KiB
JavaScript
71 lines
2.4 KiB
JavaScript
// ── 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 }
|