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
255 lines
11 KiB
JavaScript
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 }
|