// ── `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 -- (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 }