Files
website/server/db/seedDemo.js
wtclaude ebedee146f
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 54s
PR Checks / server-tests (pull_request) Successful in 6m6s
PR Checks / client-build (pull_request) Successful in 7m33s
fix(demo): switch on the actions the seeded events use (DM28)
The step-5 walk found the demo's three seeded events (Server greeting,
Treasure drop, Payday) showing as ready but failing when run: a fresh
database has no switchboard rows, so every change and irreversible action
starts disabled ("not enabled on this deployment").

seed:demo now switches on, with the events, every action their steps use,
uncapped, as an admin flipping the switch without filling the cap boxes
would. Nothing else is switched on. The deny list (DM11) still keeps a
visitor's own command step harmless, and the wipe drops the rows with the
rest of the database.

Checked on a scratch database with the v0.6.1 module: the seed leaves
rust.command.run, rust.dt.start and rust.coins.pay enabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-28 04:44:25 -05:00

285 lines
12 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).
//
// With the events it also switches on, in the events switchboard, every action
// those events' steps use — otherwise a seeded event cannot run (§7.3).
//
// 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
}
/**
* Every action a seeded event's steps use, once each, in first-use order.
*
* A fresh database has no switchboard rows, so every `change` and `irreversible`
* action starts disabled (EVENTS.md §K) — and a seeded event that answers "not
* enabled on this deployment" when a visitor runs it is a showcase that does not
* show (step-5 walk, W1). The seed switches on exactly these and nothing else.
*/
function eventActionIds(events = []) {
const ids = []
for (const e of events) {
for (const phase of e?.spec?.phases || []) {
for (const step of phase?.steps || []) {
if (step?.actionId && !ids.includes(step.actionId)) ids.push(step.actionId)
}
}
}
return ids
}
// ── 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('; ')}`)
}
}
// Uncapped, like a switch an admin flips without filling the cap boxes: the
// deny list (DM11) is what keeps a visitor's own command step harmless, and the
// wipe takes the row away with the rest of the database.
const actionSettings = require('../src/model/events/eventActionSettings.db')
const enabled = eventActionIds(content.events)
for (const actionId of enabled) await actionSettings.put(actionId, { enabled: true, caps: {} }, authorId)
log.info('content written', {
posts: (content.posts || []).length,
wiki: (content.wiki || []).length,
pages: (content.pages || []).length,
events: (content.events || []).length,
actionsEnabled: enabled,
})
}
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, eventActionIds, run }