Files
website/server/src/model/events/eventDefinitions.model.js
wtclaude cee4ab8aea
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 46s
PR Checks / server-tests (pull_request) Successful in 7m0s
PR Checks / client-build (pull_request) Successful in 7m29s
feat(demo): refuse an event step at save with demoRefuses (MODULE_API 1.19.0)
DEMO_MODE.md §3.4 refuses a denied console command when the event is
saved and when it runs. Saving is core's route, and core never asked a
module about a step's params, so the save-time half had no seam (DM18).

An event action may now declare demoRefuses(params), returning a
sentence or null. With DEMO_MODE on, create, save and publish ask it
for every step; the first sentence refuses the write 403 DEMO_LOCKED
with that sentence as the message (and in errors). A hook that throws
refuses too. Off the demo it is validated and never called, and the
catalog strips it with the other callables.

The events controller passes code and message through on create,
update and publish, so the client's one DEMO_LOCKED toast fires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-26 03:39:42 -05:00

595 lines
24 KiB
JavaScript

// ── Event definitions — the save path ──────────────────────────────────────
//
// EVENTS.md §D and "Versioning, and editing a live event". A definition is
// operator-authored data, and this file is the boundary that decides whether a
// version of it may exist. The authoring UI (Phase 3, then Phase 13) will
// re-check some of this for the sake of a good inline error; that second copy is
// expected to drift, so this one is the one that decides, and a definition
// arriving by any other route gets the same answer.
//
// **The three rules with teeth, and each is a rule about time rather than about
// shape:**
//
// 1. Publishing SNAPSHOTS. It copies the working spec into an immutable
// `event_versions` row and points `current_version_id` at it. Editing
// afterwards is free and does not touch the row a live run pinned.
// 2. A dormant step blocks a PUBLISH and never a SAVE. An uninstalled module
// must not make an author's work uneditable, and it must not let a version be
// published that names a verb nobody can perform.
// 3. Archiving is what "delete" means here. `event_runs.version_id` RESTRICTs, so
// a hard delete of a definition that has ever run is refused by the database
// anyway — and the row's history is the thing an audit reads.
const db = require('./eventDefinitions.db')
const versionsDb = require('./eventVersions.db')
const runsDb = require('./eventRuns.db')
const logDb = require('./eventRunLog.db')
const seriesDb = require('./eventSeries.db')
const spec = require('../../events/spec')
const authorize = require('../../events/authorize')
const verifier = require('../../events/verify')
const registries = require('../../modules/registries')
const { isDemo } = require('../../demo/mode')
const { CODE: DEMO_LOCKED } = require('../../demo/locks')
const { slugify, uniqueSlug } = require('../teams/teamSlug')
const { cleanBody } = require('../../utils/sanitizeHtml')
const log = require('../../utils/logger')('events')
const MAX_TITLE = 200
const MAX_SUMMARY = 500
const MAX_URL = 500
const MAX_CONCURRENCY_KEY = 190
// A day either side of §D's default. Below a minute the grace window cannot
// survive a single slow boot; above a day a "missed" occurrence would start
// silently the following afternoon, which is the exact behaviour §E forbids.
const MIN_GRACE_SECONDS = 60
const MAX_GRACE_SECONDS = 86_400
/**
* IANA zone names, checked against the platform's own database rather than a
* list. `Intl.DateTimeFormat` throws `RangeError` on an unknown zone, and Node
* ships the full tzdata — so this is the same check Phase 4's occurrence
* arithmetic will make, asked one screen earlier where an operator can fix it.
*/
function isTimezone(tz) {
try {
Intl.DateTimeFormat(undefined, { timeZone: tz })
return true
} catch {
return false
}
}
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
const trimOrNull = (v, max) => {
if (v === undefined || v === null) return null
const s = String(v).trim()
return s === '' ? null : s.slice(0, max)
}
/**
* Validate an incoming definition against the registries and the schema.
*
* `{ ok: true, definition }` with a normalised row ready for insert/update, or
* `{ ok: false, errors }` listing every problem rather than the first.
*
* `existing` is the row being edited, or null on a create. It is what lets a
* dormant step survive: the ids already in the saved spec widen what the spec
* validator will accept, so an uninstall is never destructive after the fact.
*/
async function validate(input, { existing = null } = {}) {
const errors = []
const body = isPlainObject(input) ? input : {}
const title = trimOrNull(body.title, MAX_TITLE)
if (!title) errors.push('title is required')
const summary = trimOrNull(body.summary, MAX_SUMMARY)
const imageUrl = trimOrNull(body.imageUrl, MAX_URL)
const concurrencyKey = trimOrNull(body.concurrencyKey, MAX_CONCURRENCY_KEY)
// The storyline. Sanitized on write, exactly as a wiki page and a forum post
// are: it is author-supplied HTML that ends up on a public page.
const storyline = body.body === undefined || body.body === null ? null : cleanBody(String(body.body))
const timezone = trimOrNull(body.timezone, 64) || existing?.timezone || 'UTC'
if (!isTimezone(timezone)) errors.push(`timezone: "${timezone}" is not an IANA zone name`)
const graceRaw = body.graceSeconds === undefined ? (existing?.grace_seconds ?? 900) : body.graceSeconds
const graceSeconds = Number(graceRaw)
if (
!Number.isInteger(graceSeconds) ||
graceSeconds < MIN_GRACE_SECONDS ||
graceSeconds > MAX_GRACE_SECONDS
) {
errors.push(`graceSeconds must be an integer ${MIN_GRACE_SECONDS}..${MAX_GRACE_SECONDS}`)
}
let seriesId = null
if (body.seriesId !== undefined && body.seriesId !== null && body.seriesId !== '') {
seriesId = Number(body.seriesId)
if (!Number.isInteger(seriesId) || seriesId < 1) {
errors.push('seriesId must be an integer')
seriesId = null
} else if (!(await seriesDb.exists(seriesId))) {
// Checked here as well as by the foreign key, because a 1452 reaching a
// controller is a 500 and this is a 400 an author can act on.
errors.push(`seriesId ${seriesId} does not exist`)
seriesId = null
}
} else if (existing) {
seriesId = existing.series_id
}
const seriesOrderRaw = body.seriesOrder === undefined ? (existing?.series_order ?? 0) : body.seriesOrder
const seriesOrder = Number(seriesOrderRaw)
if (!Number.isInteger(seriesOrder)) errors.push('seriesOrder must be an integer')
// Whether this event is announced on the public calendar (Phase 14a). It is
// NOT whether it may run: `state` answers that, and the two are separate
// precisely because publishing is what makes a definition runnable — an
// unlisted event still schedules, still runs and is still on the admin
// calendar. A missing key means "leave it as it was", and a NEW definition
// defaults to listed, which is the column's own default and the ordinary
// case; unlisting is the deliberate act.
const listed = body.listed === undefined ? (existing ? Boolean(existing.listed) : true) : Boolean(body.listed)
// ── the spec ──
const rawSpec = body.spec === undefined ? existing?.spec ?? spec.emptySpec() : body.spec
const known = existing?.spec ? spec.actionIdsIn(existing.spec) : []
const checked = spec.validate(rawSpec, { knownActionIds: known })
if (!checked.ok) errors.push(...checked.errors)
// ── the slug ──
//
// Derived from the title on create and FROZEN afterwards, like a Team's: the
// public event page lives at it, and a retitle must not break a link somebody
// posted in Discord. An author who genuinely needs a different address makes a
// new definition.
let slug = existing?.slug || null
if (!slug) {
const stem = slugify(title || '') || 'event'
const taken = (await db.list()).map((d) => d.slug)
slug = uniqueSlug(stem, taken, { fallback: 'event' })
}
if (errors.length) return { ok: false, errors }
return {
ok: true,
definition: {
title,
slug,
summary,
body: storyline,
image_url: imageUrl,
owner_module: existing?.owner_module ?? null,
series_id: seriesId,
series_order: seriesOrder,
concurrency_key: concurrencyKey,
grace_seconds: graceSeconds,
timezone,
listed,
spec: checked.spec,
},
}
}
/** Create a draft. */
async function create(input, userId, { role = null } = {}) {
const result = await validate(input)
if (!result.ok) return result
const floor = checkRoleFloor(result.definition.spec, role)
if (floor) return floor
const refused = await checkDemoRefusals(result.definition.spec)
if (refused) return refused
const id = await db.insert({ ...result.definition, created_by: userId })
return { ok: true, id, definition: await db.getById(id) }
}
/**
* Edit a definition.
*
* An `archived` definition is not editable. That is the one state check here,
* and it is a real one rather than a formality: archiving is how a definition is
* retired, and a retired definition that can still be edited is a definition
* somebody will edit and then wonder why it never runs.
*/
async function save(id, input, userId, { role = null } = {}) {
const existing = await db.getById(id)
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
if (existing.state === 'archived') {
return { ok: false, status: 409, errors: ['an archived definition cannot be edited'] }
}
const result = await validate(input, { existing })
if (!result.ok) return result
const floor = checkRoleFloor(result.definition.spec, role)
if (floor) return floor
const refused = await checkDemoRefusals(result.definition.spec)
if (refused) return refused
await db.update(id, { ...result.definition, updated_by: userId })
return { ok: true, id, definition: await db.getById(id) }
}
/**
* Publish: snapshot the working spec into an immutable version and go `ready`.
*
* The spec is re-validated here against the registries as they stand RIGHT NOW,
* not trusted from the save that wrote it. A module uninstalled between the two
* is the whole reason: the save was legitimate, and publishing a version whose
* steps name a verb nobody can perform would be a run that fails at dispatch
* with the world half-changed.
*/
async function publish(id, userId) {
const existing = await db.getById(id)
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
if (existing.state === 'archived') {
return { ok: false, status: 409, errors: ['an archived definition cannot be published'] }
}
const checked = spec.validate(existing.spec, {
knownActionIds: spec.actionIdsIn(existing.spec),
})
if (!checked.ok) return { ok: false, status: 400, errors: checked.errors }
// Asked again here, as the registries are: a draft saved before the module's
// list grew must not become a version that runs (DM18).
const refused = await checkDemoRefusals(checked.spec)
if (refused) return refused
const publishable = spec.publishable(checked.spec)
if (!publishable.ok) {
return {
ok: false,
status: 409,
errors: [
`cannot publish: no module registers ${publishable.dormant.join(', ')}`,
],
}
}
if (!checked.spec.phases.some((p) => p.steps.length)) {
// An empty event publishes cleanly and then does nothing, which looks like a
// broken run rather than an empty one. Refusing costs an author one click and
// saves an operator a diagnosis.
return { ok: false, status: 400, errors: ['cannot publish: no phase has any steps'] }
}
const version = await versionsDb.nextVersion(id)
const versionId = await versionsDb.insert(id, version, checked.spec, userId)
await db.markReady(id, versionId, userId)
// A dry run that passed on this exact draft counts for the version made from
// it (W5), stamped with that pass's reviewer and time rather than the
// publisher's and now: "who last looked at this before it ran unattended" is
// the person who ran the check. Any edit since is a different fingerprint.
const verified = Boolean(
existing.draft_verified_hash &&
existing.draft_verified_hash === spec.fingerprint(checked.spec) &&
(await versionsDb.markVerified(versionId, existing.draft_verified_by || null, existing.draft_verified_at || new Date())),
)
// Occurrences already materialised ahead of their instant move to the new
// version; ones that have begun do not (org lead, 2026-09-02). Logged per run
// rather than only counted, because "which version did this run actually use"
// is the first question an audit asks and the pin is no longer immutable while
// a run is still `scheduled`.
const pending = await runsDb.listScheduledFor(id)
const stale = pending.filter((r) => Number(r.version_id) !== Number(versionId))
const repinned = stale.length ? await runsDb.repinScheduled(id, versionId) : 0
for (const run of stale) {
await logDb.write({
runId: run.id,
kind: 'run.status',
detail: {
to: 'scheduled',
repinned: true,
fromVersionId: run.version_id,
toVersionId: versionId,
toVersion: version,
by: userId,
},
})
// The held occurrence is now going to start, and the line saying why
// belongs on it, as it does when the version is dry-run by hand.
if (verified) {
await logDb.write({
runId: run.id,
kind: 'version.verified',
detail: { versionId, version, by: existing.draft_verified_by || null, fromDraft: true },
})
}
}
return { ok: true, versionId, version, repinned, verified, definition: await db.getById(id) }
}
/**
* Archive.
*
* Refused while a run of this definition is still in flight — not because the
* database would object (it would not; archiving is an UPDATE), but because the
* screen the run is on reads its title and state from here, and retiring a
* definition mid-run makes the console describe something that is no longer
* supposed to exist. Cancel the run, then archive.
*/
async function archive(id, userId) {
const existing = await db.getById(id)
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
if (existing.state === 'archived') return { ok: true, definition: existing }
const active = await runsDb.countActiveForDefinition(id)
if (active > 0) {
return {
ok: false,
status: 409,
errors: [`cannot archive: ${active} run(s) of this definition are still in flight`],
}
}
await db.archive(id, userId)
return { ok: true, definition: await db.getById(id) }
}
/**
* The role floor on a spec's steps (§K, Phase 6).
*
* §K's table reads "any step whose action is above `notify` — `admin` only", and
* the line falls between `inspect` and `change` for the reason the default-off
* rule does (org lead, 2026-09-03): an `inspect` action reads state and writes
* nothing, and an editor who cannot author a step that WAITS has an authoring
* role that cannot author.
*
* Checked at SAVE rather than only at publish, which is the difference between
* telling an editor now and telling them after they have written twelve steps.
* Publish re-checks anyway — it re-checks everything, against the registries as
* they stand at that moment — because an action's risk class is a module's
* declaration and a module can be upgraded between the two.
*/
function worldChangingSteps(specValue) {
const out = []
for (const phase of specValue?.phases || []) {
for (const step of phase.steps || []) {
const action = registries.eventAction(step.actionId)
if (action && authorize.changesWorld(action)) out.push(action)
}
}
return out
}
function checkRoleFloor(specValue, role) {
if (!role || role === 'admin') return null
const blocked = worldChangingSteps(specValue)
if (!blocked.length) return null
const names = [...new Set(blocked.map((a) => `"${a.label}"`))]
// Agreement, because this sentence is read by the person it refuses. The list
// is almost always one long -- an editor adds one world-changing step and is
// stopped -- so `"Spawn creatures" change the world` is the case that shows,
// and it reads as a bug in the sentence rather than a rule about the step.
const one = names.length === 1
return {
ok: false,
status: 403,
errors: [
`${names.join(', ')} ${one ? 'changes' : 'change'} the world, so only an administrator may author a step that uses ${one ? 'it' : 'them'}`,
],
}
}
/**
* The public demo's save-time refusal (MODULE_API 1.19.0, DEMO_MODE.md §3.4,
* DM18): each step whose action declares `demoRefuses(params)` is asked, and
* the first sentence back refuses the whole save `403 DEMO_LOCKED`. Null off
* the demo, where nothing is asked.
*
* A hook that throws refuses too, with core's sentence: this is a lock, and a
* lock that cannot decide fails closed, as the route lock does (§3.3).
*/
async function checkDemoRefusals(specValue) {
if (!isDemo()) return null
for (const phase of specValue?.phases || []) {
for (const step of phase.steps || []) {
const action = registries.eventAction(step.actionId)
if (!action || !action.demoRefuses) continue
let said
try {
said = await action.demoRefuses(step.params || {})
} catch (err) {
log.error('demoRefuses failed', { action: action.id, message: err.message })
said = `"${action.label}" could not be checked, so it is turned off on the demo site.`
}
if (typeof said === 'string' && said.trim()) {
const message = said.trim()
return { ok: false, status: 403, code: DEMO_LOCKED, message, errors: [message] }
}
}
}
return null
}
/**
* Dry-run a definition, and record the pass when there is a version to record it
* on (Phase 6).
*
* The target follows the definition's state: a `ready` definition is verified
* against the version that would actually run, a draft against the working spec
* the author is still holding. See `events/verify.js` for why that is one act at
* two moments rather than two rules.
*/
async function verify(id, user) {
const existing = await db.getById(id)
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
if (existing.state === 'archived') {
return { ok: false, status: 409, errors: ['an archived definition cannot be verified'] }
}
const version =
existing.state === 'ready' && existing.current_version_id
? await versionsDb.getById(existing.current_version_id)
: null
const target = version?.spec || existing.spec
if (!target?.phases?.length) {
return { ok: false, status: 409, errors: ['this definition has no phases to verify'] }
}
const report = await verifier.verifySpec(target, { user })
// A pass on the draft is kept with the fingerprint of the spec it passed on,
// for publish to compare (W5). Fingerprinted from `validate`'s output, as
// publish fingerprints the spec it freezes; a draft that no longer validates
// could not be published anyway, so there is nothing to keep.
let draftRecorded = false
if (!version && report.ok) {
const checked = spec.validate(existing.spec, { knownActionIds: spec.actionIdsIn(existing.spec) })
if (checked.ok) {
await db.markDraftVerified(id, { userId: user?.id || null, hash: spec.fingerprint(checked.spec) })
draftRecorded = true
}
}
if (version && report.ok) {
await versionsDb.markVerified(version.id, user?.id || null)
// Written to every run already pinned to this version, because that is where
// an operator asks the question: a scheduled occurrence that was being held
// is now going to start, and the line saying why belongs on it.
for (const run of await runsDb.listScheduledFor(id)) {
if (Number(run.version_id) !== Number(version.id)) continue
await logDb.write({
runId: run.id,
kind: 'version.verified',
detail: { versionId: version.id, version: version.version, by: user?.id || null },
})
}
}
return {
ok: true,
report,
// Which spec was verified, said plainly, because the two answer different
// questions and a report that did not say would be read as the other one.
target: version ? 'version' : 'draft',
versionId: version?.id || null,
version: version?.version || null,
recorded: Boolean(version && report.ok),
// A pass on the draft, kept so that publishing this spec unchanged makes a
// verified version (W5).
draftRecorded,
}
}
// How long a template's `build()` may take. It may read the module's own data or
// ask a sidecar, and an author is waiting on the other side of the click.
const TEMPLATE_BUILD_MS = 10_000
/**
* Fill an event template in (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2.5, D192).
*
* `{ ok: true, draft: { title, summary, spec } }`, or `{ ok: false, status,
* errors }`. **Nothing is saved.** The draft goes back to the editor, which opens
* it like any new event; the author saves it through `create()` and its rules,
* so a template cannot make a definition the save path would refuse.
*
* The module is not trusted at either end. The inputs are checked against the
* template's declaration before `build()` sees them, and what `build()` returns
* is validated as a spec after. A `build()` that refuses says why in
* `{ ok: false, error }` — the author's sentence — and one that throws or hangs
* is logged and answered with a sentence of core's, because a stack trace is
* not something to put on an author's screen.
*/
async function buildFromTemplate(templateId, rawInputs) {
const template = registries.eventTemplate(String(templateId || ''))
if (!template) {
return { ok: false, status: 404, errors: [`no module registers the event template "${templateId}"`] }
}
const checked = spec.checkParams({ id: template.id, params: template.inputs }, rawInputs, 'inputs')
if (checked.errors.length) {
return { ok: false, status: 400, errors: checked.errors.map((e) => e.replace(/^inputs\.params: /, '')) }
}
let built
let timer
try {
built = await Promise.race([
Promise.resolve(template.build({ inputs: checked.params })),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`build() took longer than ${TEMPLATE_BUILD_MS} ms`)), TEMPLATE_BUILD_MS)
}),
])
} catch (err) {
log.error('event template build failed', { template: template.id, owner: template.owner, message: err.message })
return { ok: false, status: 502, errors: [`"${template.label}" could not be filled in — the module that provides it failed`] }
} finally {
clearTimeout(timer)
}
if (!built || typeof built !== 'object') {
return { ok: false, status: 502, errors: [`"${template.label}" answered with no draft`] }
}
if (built.ok === false) {
return { ok: false, status: 400, errors: [String(built.error || `"${template.label}" refused those inputs`)] }
}
const checkedSpec = spec.validate(built.spec)
if (!checkedSpec.ok) {
// The module's mistake, not the author's, and named as such: the author
// cannot fix a template by retyping its inputs.
log.error('event template built an invalid spec', { template: template.id, errors: checkedSpec.errors })
return {
ok: false,
status: 502,
errors: [`"${template.label}" made a plan core cannot accept`, ...checkedSpec.errors],
}
}
return {
ok: true,
draft: {
title: String(built.title || template.label).slice(0, MAX_TITLE),
summary: built.summary ? String(built.summary).slice(0, MAX_SUMMARY) : null,
spec: checkedSpec.spec,
},
}
}
/**
* Does the working copy differ from the published version? (Rust PLAN_REDESIGNS
* §2.3, U-5.)
*
* A run is pinned to the PUBLISHED version, so an author who edited, saved and
* pressed Start ran the old plan without being told. The editor asks only when
* this is true. Compared as data, keys sorted, because the two are stored as
* JSON by different writes and a key order is not an edit. A definition never
* published has nothing to be ahead of, and answers false.
*/
async function draftAhead(row) {
if (!row || !row.current_version_id) return false
const version = await versionsDb.getById(row.current_version_id)
if (!version) return false
return stableJson(row.spec) !== stableJson(version.spec)
}
function stableJson(value) {
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`
if (value && typeof value === 'object') {
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stableJson(value[k])}`).join(',')}}`
}
return JSON.stringify(value === undefined ? null : value)
}
module.exports = {
validate,
create,
buildFromTemplate,
draftAhead,
save,
publish,
archive,
verify,
checkRoleFloor,
checkDemoRefusals,
isTimezone,
MIN_GRACE_SECONDS,
MAX_GRACE_SECONDS,
}