Files
website/server/db/seedDemo.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

255 lines
11 KiB
JavaScript

// ── `npm run seed:demo` — the public demo's base (DEMO_MODE.md §7.3) ────────
//
// Run by the wipe workflow inside the app container, after `compose down -v` and
// a fresh `up` have left an empty database with the schema in place:
//
// docker compose exec app npm run seed:demo -- /app/deploy/demo/seed.json
//
// It writes, from one fixture file:
// 1. the four static logins (§4) — upserted, so a password a visitor somehow
// changed is back within one cycle and a re-run is harmless;
// 2. the site settings the demo starts from (live, registration disabled, and
// whatever the fixture adds);
// 3. the seeded content — posts, wiki pages, pages, events — on the first run
// against a database only, so a re-run never duplicates it;
// 4. each installed module's own rows, through the module's `onDemoSeed` hook
// (API 1.18.0, DM15): core never writes a module's tables;
// 5. `demo_state` — when the next wipe is due and the published logins, for
// the banner and the sign-in page (§6).
//
// A string `"$env:NAME"` anywhere in the fixture is replaced with that variable,
// so the rig's sidecar token lives in the host's .env and never in the repo. An
// unset variable fails the run rather than seeding an empty credential.
//
// Refuses to run without DEMO_MODE=true: pointed at a real shard by mistake, it
// would reset four accounts' passwords and flip the site live.
require('dotenv').config()
const fs = require('fs')
const path = require('path')
const { isDemo, DEMO_ACCOUNTS } = require('../src/demo/mode')
const ROLES = ['admin', 'editor', 'moderator', 'player']
const log = require('../src/utils/logger')('seed-demo')
// ── Pure helpers (tested in test/seedDemo.test.js) ─────────────────────────
/** Replace every `"$env:NAME"` string, at any depth. Throws on an unset one. */
function resolveEnv(value, env = process.env, where = 'fixture') {
if (typeof value === 'string') {
const m = /^\$env:([A-Z_][A-Z0-9_]*)$/.exec(value)
if (!m) return value
const got = env[m[1]]
if (got === undefined || got === '') throw new Error(`${where}: environment variable ${m[1]} is not set`)
return got
}
if (Array.isArray(value)) return value.map((v, i) => resolveEnv(v, env, `${where}[${i}]`))
if (value && typeof value === 'object') {
const out = {}
for (const [k, v] of Object.entries(value)) out[k] = resolveEnv(v, env, `${where}.${k}`)
return out
}
return value
}
/**
* When the next wipe is due: the next whole multiple of `everyHours` past
* midnight UTC, strictly after `now` — the schedule the wipe workflow's cron
* runs on (DM7: every 2 hours on the hour). The seed runs a minute or two after
* a wipe starts, so "strictly after" lands on the following slot.
*/
function nextResetAt(now = new Date(), everyHours = 2) {
const step = everyHours * 60 * 60 * 1000
const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
const slots = Math.floor((now.getTime() - midnight) / step) + 1
return new Date(midnight + slots * step)
}
/** Check the fixture's shape before anything is written. Returns a list of problems. */
function checkFixture(fixture) {
const problems = []
if (!fixture || typeof fixture !== 'object' || Array.isArray(fixture)) return ['the fixture must be a JSON object']
const accounts = fixture.accounts || {}
for (const name of DEMO_ACCOUNTS) {
const a = accounts[name]
if (!a) {
problems.push(`accounts.${name} is missing`)
continue
}
if (typeof a.password !== 'string' || a.password.length < 8) {
problems.push(`accounts.${name}.password must be a string of at least 8 characters`)
}
if (!ROLES.includes(a.role)) problems.push(`accounts.${name}.role must be one of ${ROLES.join(', ')}`)
}
for (const name of Object.keys(accounts)) {
if (!DEMO_ACCOUNTS.includes(name)) {
problems.push(`accounts.${name} is not a demo account (the lock only knows ${DEMO_ACCOUNTS.join(', ')})`)
}
}
if (fixture.settings !== undefined) {
if (!fixture.settings || typeof fixture.settings !== 'object' || Array.isArray(fixture.settings)) {
problems.push('settings must be an object')
} else if ('demo_state' in fixture.settings) {
problems.push('settings.demo_state is written by the seed itself')
}
}
for (const key of ['posts', 'wiki', 'pages', 'events']) {
const list = fixture.content?.[key]
if (list !== undefined && !Array.isArray(list)) problems.push(`content.${key} must be an array`)
}
if (fixture.modules !== undefined && (typeof fixture.modules !== 'object' || Array.isArray(fixture.modules))) {
problems.push('modules must be an object keyed by module id')
}
return problems
}
// ── The run ────────────────────────────────────────────────────────────────
/* eslint-disable global-require */
async function upsertAccounts(fixture) {
const users = require('../src/model/users/users.model')
const ids = {}
for (const username of DEMO_ACCOUNTS) {
const { password, role } = fixture.accounts[username]
const existing = await users.getRawByUsername(username)
let id
if (existing) {
id = existing.id
// A password change bumps the session cutoff, which signs every visitor out
// of this account — the right outcome at a wipe, and on a fresh database
// (the normal case) there is nobody to sign out.
await users.update(id, { password, role, status: 'active', emailVerified: true })
await users.disableTotp(id)
} else {
const created = await users.createUser({ username, password, role, status: 'active', emailVerified: true })
id = created.id
}
ids[username] = id
log.info(`account ${username} (${role}) ready`, { id })
}
return ids
}
async function writeSettings(fixture) {
const settings = require('../src/model/settings/settings.model')
await settings.setMany({
site_mode: 'live',
site_mode_changed_at: new Date().toISOString(),
site_mode_changed_by: 'seed:demo',
// §4: registration is closed, and the route is locked too (DM16).
[settings.REGISTRATION_KEY]: 'disabled',
...(fixture.settings || {}),
})
}
async function writeContent(fixture, authorId) {
const content = fixture.content || {}
const posts = require('../src/model/posts/posts.model')
const wiki = require('../src/model/wiki/wiki.model')
const pages = require('../src/model/pages/pages.model')
const events = require('../src/model/events/eventDefinitions.model')
for (const p of content.posts || []) {
const category = posts.toDbCategory(p.category || 'news')
if (!category) throw new Error(`post "${p.title}": unknown category "${p.category}"`)
await posts.create({ ...p, category, published: p.published !== false, author_id: authorId })
}
for (const w of content.wiki || []) {
const category = w.category ? await wiki.getCategoryBySlug(w.category) : null
if (w.category && !category) throw new Error(`wiki "${w.slug}": unknown category "${w.category}"`)
await wiki.create({ ...w, categoryId: category ? category.id : null, updatedBy: authorId })
}
for (const pg of content.pages || []) {
await pages.create(pg, authorId)
}
for (const e of content.events || []) {
const { publish, ...input } = e
const made = await events.create(input, authorId, { role: 'admin' })
if (!made.ok) throw new Error(`event "${input.name || input.title}": ${(made.errors || []).join('; ')}`)
if (publish) {
const pub = await events.publish(made.id, authorId)
if (!pub.ok) throw new Error(`event "${input.name || input.title}" did not publish: ${(pub.errors || []).join('; ')}`)
}
}
log.info('content written', {
posts: (content.posts || []).length,
wiki: (content.wiki || []).length,
pages: (content.pages || []).length,
events: (content.events || []).length,
})
}
async function seedModules(fixture, accounts) {
const modules = require('../src/modules/loader')
const hooks = modules.demoSeedHooks()
const wanted = fixture.modules || {}
for (const id of Object.keys(wanted)) {
if (!hooks.some((h) => h.id === id)) {
throw new Error(`modules.${id}: no installed module "${id}" takes a demo seed (onDemoSeed)`)
}
}
for (const { id, hook } of hooks) {
if (wanted[id] === undefined) continue
// The module gets its part of the fixture and the demo accounts' ids, so it
// can link one of them to a game identity without reading core's users table.
await hook(wanted[id], { accounts: { ...accounts } })
log.info(`module ${id} seeded`)
}
}
async function writeState(fixture, now = new Date()) {
const settings = require('../src/model/settings/settings.model')
const everyHours = Number(process.env.DEMO_RESET_EVERY_HOURS) || 2
const logins = DEMO_ACCOUNTS
.filter((name) => fixture.accounts[name].published !== false)
.map((name) => ({ username: name, password: fixture.accounts[name].password, role: fixture.accounts[name].role }))
const state = { resetsAt: nextResetAt(now, everyHours).toISOString(), logins }
await settings.set(settings.DEMO_STATE_KEY, JSON.stringify(state))
log.info('demo state stamped', { resetsAt: state.resetsAt, logins: logins.length })
}
async function run(file) {
if (!isDemo()) throw new Error('seed:demo runs only with DEMO_MODE=true')
if (!file) throw new Error('usage: npm run seed:demo -- <fixture.json> (or set DEMO_SEED_FILE)')
const fixture = resolveEnv(JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')))
const problems = checkFixture(fixture)
if (problems.length) throw new Error(`the fixture has problems:\n - ${problems.join('\n - ')}`)
// The app itself, without listening: this is what loads the installed modules
// (and core's registries, which an event definition is validated against)
// exactly as the server does. Nothing here calls onBoot, so no module starts
// its workers in this process.
require('../src/app')
const settings = require('../src/model/settings/settings.model')
const firstRun = !(await settings.get(settings.DEMO_STATE_KEY))
const accounts = await upsertAccounts(fixture)
await writeSettings(fixture)
if (firstRun) await writeContent(fixture, accounts['demo-admin'])
else log.info('content already seeded on this database; left as is')
await seedModules(fixture, accounts)
await writeState(fixture)
}
/* eslint-enable global-require */
if (require.main === module) {
;(async () => {
const { close } = require('../src/utils/db') // eslint-disable-line global-require
try {
await run(process.argv[2] || process.env.DEMO_SEED_FILE)
log.info('demo seeded')
} catch (err) {
log.error(`seed:demo failed: ${err.message}`)
process.exitCode = 1
} finally {
await close()
}
})()
}
module.exports = { resolveEnv, nextResetAt, checkFixture, run }