From 4077c4e79eef847555caf45fe274aa81794170c9 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 3 Sep 2026 05:50:58 -0500 Subject: [PATCH] feat(events): enablement, per-run caps and mayInvoke (Phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new tables — event_action_settings (the deployment switchboard) and event_run_budget (what a run has spent and the most it may) — plus verified_at and verified_by on event_versions. The whole authorisation decision moves behind one function, events/authorize.js: role, enablement, cap, and the shard's own switch named as the layer core deliberately does not duplicate. Three routes, none moved: GET/PUT /admin/events/actions (admin in both directions) and POST /admin/events/:id/verify (admin, editor — a dry run dispatches nothing). Four decisions, settled by the org lead 2026-09-03: - The default-off line falls between inspect and change, not between notify and inspect. Read literally, §K shipped core.wait disabled. The same line is the role floor. - The tightest cap wins where two actions spend one dimension, pinned into the run at creation with the action it came from. - A refusal follows the step's on_failure and takes health to degraded — its own status and its own log kind, because a refusal is not an outage. - The verify gate is enforced for scheduled starts only: a human pressing Start now is the review the gate exists to require. Derived and flagged for review: a dry run fails rather than warns on a disabled action or an over-cap plan, and the unattended path does not re-check the starter's role. +111 tests (1921/1847/73/1 — the one failure pre-existing and environmental), including a 403 walk over the real router and two concurrent spends against one cap on a real MariaDB. The live walk found two defects, both fixed here: the run console route dropped the budget it was handed, and the role refusal used a plural verb over a one-item list. Co-Authored-By: Claude Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL --- client/src/App.jsx | 5 + client/src/api/client.js | 11 + client/src/lib/eventAuthoring.js | 17 + client/src/routes/admin/AdminLayout.jsx | 6 + .../src/routes/admin/views/EventActions.jsx | 252 ++++++++++ client/src/routes/admin/views/EventEditor.jsx | 124 +++++ client/src/routes/admin/views/EventRun.jsx | 58 +++ client/test/eventAuthoring.test.js | 45 ++ server/db/schema.sql | 100 +++- server/routes.guards.json | 27 ++ server/routes.manifest.json | 12 + server/src/events/authorize.js | 339 +++++++++++++ server/src/events/verify.js | 153 ++++++ .../model/events/eventActionSettings.db.js | 86 ++++ .../src/model/events/eventDefinitions.db.js | 9 +- .../model/events/eventDefinitions.model.js | 112 ++++- server/src/model/events/eventRunBudget.db.js | 134 ++++++ server/src/model/events/eventRunLog.db.js | 7 + server/src/model/events/eventRuns.model.js | 65 ++- server/src/model/events/eventVersions.db.js | 22 +- .../src/router/v1/admin/events.controller.js | 148 +++++- server/src/router/v1/admin/events.router.js | 60 ++- server/src/utils/eventRunner.js | 92 +++- server/swagger/swagger-output.json | 294 +++++++++++- server/test/eventAuthorize.test.js | 451 ++++++++++++++++++ server/test/eventRunner.test.js | 252 +++++++++- server/test/eventRunnerSql.test.js | 206 ++++++++ server/test/eventSchedule.test.js | 20 + server/test/eventVerify.test.js | 230 +++++++++ server/test/eventsAdmin.test.js | 355 ++++++++++++++ server/test/eventsRoles.test.js | 222 +++++++++ 31 files changed, 3890 insertions(+), 24 deletions(-) create mode 100644 client/src/routes/admin/views/EventActions.jsx create mode 100644 server/src/events/authorize.js create mode 100644 server/src/events/verify.js create mode 100644 server/src/model/events/eventActionSettings.db.js create mode 100644 server/src/model/events/eventRunBudget.db.js create mode 100644 server/test/eventAuthorize.test.js create mode 100644 server/test/eventVerify.test.js create mode 100644 server/test/eventsRoles.test.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 311d326..fd7ab39 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -53,6 +53,7 @@ import EventsAdmin from './routes/admin/views/EventsAdmin.jsx' import EventsCalendar from './routes/admin/views/EventsCalendar.jsx' import EventEditor from './routes/admin/views/EventEditor.jsx' import EventRun from './routes/admin/views/EventRun.jsx' +import EventActions from './routes/admin/views/EventActions.jsx' import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx' import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' import Moderation from './routes/admin/views/Moderation.jsx' @@ -206,6 +207,10 @@ export default function App() { literal segment is never read as a definition id. */} } /> } /> + {/* The switchboard (Phase 6). A literal segment, declared before + `events/:id` the way the router declares `/actions` before + `/:id` — the same collision, on the other side of the wire. */} + } /> } /> } /> } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index d847b85..5a1e939 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -486,6 +486,17 @@ export const api = { archiveEvent: (id) => req(`/admin/events/${id}`, { method: 'DELETE' }), listEventVersions: (id) => req(`/admin/events/${id}/versions`), eventCatalog: () => req('/admin/events/catalog'), + // Phase 6. The dry run is admin+editor: it dispatches nothing, and the author + // who wrote the definition is who should be able to price it against the caps + // before asking an admin to publish it. A report with findings comes back 200 + // — the request succeeded, the plan has problems. + verifyEvent: (id) => req(`/admin/events/${id}/verify`, { method: 'POST' }), + // The switchboard, admin only in BOTH directions: reading which actions a + // deployment permits is as much configuration as writing it (§K). One action + // per write rather than the whole board, so an action that appeared between + // the read and the write cannot be overwritten with a default. + eventActions: () => req('/admin/events/actions'), + saveEventAction: (body) => req('/admin/events/actions', { method: 'PUT', body }), eventSeries: () => req('/admin/events/series'), // Series writes are admin+editor rather than admin: naming an arc is // authoring, and §N2's narrow gate is about committing the deployment to a diff --git a/client/src/lib/eventAuthoring.js b/client/src/lib/eventAuthoring.js index ee437c1..59fbf0e 100644 --- a/client/src/lib/eventAuthoring.js +++ b/client/src/lib/eventAuthoring.js @@ -460,6 +460,11 @@ const KIND_WORDS = { 'phase.gate': 'Advance condition set', 'condition.evaluated': 'Condition evaluated', 'phase.advanced': 'Phase advanced', + // Phase 6. "Refused" reads differently from "Step" on purpose: an operator + // scanning a stopped run needs to see that nothing is broken. + 'step.refused': 'Refused', + 'run.budget': 'Caps', + 'version.verified': 'Dry run passed', note: 'Note', } @@ -514,6 +519,18 @@ export function describeLogLine(line) { return d.because === 'forced' ? `${line.phase} advanced by hand after ${d.waitedSeconds}s${d.reason ? `: ${d.reason}` : ''}` : `${line.phase} advanced on its ${d.because === 'elapsed' ? 'deadline' : 'condition'} after ${d.waitedSeconds}s` + // Phase 6. `step.refused` is its own kind rather than a `step.status` for a + // reason an operator feels at 2am: a refusal is not a failure, and the line + // has to say which deployment rule stopped it -- the answer to "not enabled" + // is a switch, and the answer to "over the cap" is a number. + case 'step.refused': + return `${d.action} refused: ${d.error}` + case 'run.budget': + return (d.dimensions || []) + .map((x) => `${x.dimension} capped at ${x.cap === null ? 'nothing' : x.cap}${x.from ? ` (${x.from})` : ''}`) + .join(', ') || 'no caps apply to this run' + case 'version.verified': + return `Version ${d.version} passed its dry run — scheduled occurrences may start` default: return logKindWord(line?.kind) } diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index db77552..80c1db1 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -137,6 +137,11 @@ export const NAV = [ // and the arcs it manages are authoring gated on the buttons rather than // on the row. { to: '/admin/events/calendar', label: 'Calendar', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] }, + // Phase 6, and the one row in this group that is NOT staff-wide. §K puts + // the switchboard in the same row as the world-changing actions it + // governs: what a deployment permits at all is configuration, not a read, + // and the server gates both the GET and the PUT on `admin`. + { to: '/admin/events/actions', label: 'Actions', icon: IconGear, roles: ['admin'] }, ], }, { @@ -223,6 +228,7 @@ const TITLES = { '/admin/engagement/retention': 'Retention', '/admin/events': 'Events', '/admin/events/calendar': 'Event calendar', + '/admin/events/actions': 'Event actions', '/admin/events/new': 'New event', } diff --git a/client/src/routes/admin/views/EventActions.jsx b/client/src/routes/admin/views/EventActions.jsx new file mode 100644 index 0000000..8d8fd7a --- /dev/null +++ b/client/src/routes/admin/views/EventActions.jsx @@ -0,0 +1,252 @@ +import { useCallback, useEffect, useState } from 'react' +import { Loading, ErrorState } from '../../../components/PageState.jsx' +import { api } from '../../../api/client.js' + +// Admin → Events → Actions — the deployment's switchboard (EVENTS.md §K, Phase 6). +// +// **This screen is the whole of the permission model beyond the role.** A module +// declaring `uo.creature.spawn` is code the operator installed; it is not a +// permission they granted. Enablement is the grant, and the cap is how much of +// it — so this is the one screen in the feature where an operator decides what +// the deployment *can do at all*, rather than what it is going to do tonight. +// +// **Nothing above `notify` and `inspect` arrives enabled.** Installing a module +// must never start doing things, which is the posture a seeded engagement rule +// already takes by arriving `enabled = 0`. The line falls between `inspect` and +// `change` (org lead, 2026-09-03): an `inspect` action reads state and writes +// nothing, so a deployment gains no risk by having it on, and `core.wait` — which +// is `inspect` — arriving off would break every published event that waits. +// +// **A row with no stored setting is not "off".** It is "the default for its risk +// class", computed on the server by the same function the runner asks. The screen +// says which it is looking at, because "an admin turned this on" and "this has +// always been on" are different facts and only one of them is a decision. +// +// **Admin only in both directions**, including the read: §K puts the switchboard +// in the same row as the world-changing actions it governs, and knowing exactly +// what a deployment permits is not a staff-wide read. + +const RISK_WORD = { + notify: 'Tells people something', + inspect: 'Reads the world', + change: 'Changes the world', + irreversible: 'Changes the world irreversibly', +} + +const RISK_COLOR = { + notify: 'var(--muted)', + inspect: 'var(--muted)', + change: '#d9c184', + irreversible: '#d98b84', +} + +const REVERSIBLE_WORD = { + none: 'nothing to undo', + self: 'undoes itself', + ledger: 'undone from the ledger at teardown', + override: 'restores a baseline', +} + +export default function EventActions() { + const [actions, setActions] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(null) + const [problem, setProblem] = useState(null) + const [notice, setNotice] = useState(null) + // Cap edits are held here until they are saved, keyed `actionId:dimension`. + // A cap is a number somebody types digit by digit, and writing on every + // keystroke would put "3" in the database on the way to "30". + const [drafts, setDrafts] = useState({}) + + const load = useCallback(async () => { + const data = await api.admin.eventActions() + setActions(data.actions || []) + }, []) + + useEffect(() => { + let alive = true + ;(async () => { + setLoading(true) + try { + await load() + if (alive) setError(null) + } catch (err) { + if (alive) setError(err.message) + } finally { + if (alive) setLoading(false) + } + })() + return () => { + alive = false + } + }, [load]) + + /** + * Write one action's row. + * + * The whole row goes every time — the switch and every cap — because the route + * takes one action per request and a sparse write would have to decide what an + * omitted cap means. Here it can only mean one thing, so it is sent. + */ + const save = async (action, { enabled = action.enabled, caps } = {}) => { + setBusy(action.id) + setProblem(null) + setNotice(null) + const nextCaps = caps !== undefined ? caps : capsOf(action) + try { + await api.admin.saveEventAction({ actionId: action.id, enabled, caps: nextCaps }) + await load() + setDrafts((d) => { + const next = { ...d } + for (const dimension of action.dimensions) delete next[`${action.id}:${dimension}`] + return next + }) + setNotice(`Saved ${action.label}.`) + } catch (err) { + setProblem(err.message) + } finally { + setBusy(null) + } + } + + /** The caps this row would save: the drafts on top of what is stored. */ + const capsOf = (action) => { + const out = {} + for (const dimension of action.dimensions) { + const draft = drafts[`${action.id}:${dimension}`] + const value = draft !== undefined ? draft : action.caps[dimension] + if (value === '' || value === undefined || value === null) continue + out[dimension] = Number(value) + } + return out + } + + const capValue = (action, dimension) => { + const draft = drafts[`${action.id}:${dimension}`] + if (draft !== undefined) return draft + const stored = action.caps[dimension] + return stored === undefined || stored === null ? '' : String(stored) + } + + const dirty = (action) => + action.dimensions.some((d) => drafts[`${action.id}:${d}`] !== undefined) + + if (loading) return + if (error) return + + return ( +
+

Event actions

+

+ What this deployment permits an event to do, and how much of it per run. Anything that changes + the world arrives switched off — installing a module declares a verb, it does not grant + permission to use it. Caps are copied into a run when the run is created, so moving a switch + never changes what a run already in flight is allowed. +

+ + {problem && ( +
+ {problem} +
+ )} + {notice && ( +
+ {notice} +
+ )} + + {actions.length === 0 && ( +
+

+ No module registers an event action. Core always declares its own three, so an empty list + here means the registry did not load. +

+
+ )} + + {actions.map((action) => ( +
+
+
+
+ {action.label} + {action.id} +
+ {action.description && ( +

{action.description}

+ )} +

+ {RISK_WORD[action.risk] || action.risk} + {' · '} + {REVERSIBLE_WORD[action.reversible] || action.reversible} + {/* Which of the two facts this is. A default is not a decision, and + an operator auditing their own deployment needs to see the + difference without reading the risk table in their head. */} + {' · '} + {action.configured + ? `set by ${action.updatedBy || 'an administrator'}` + : 'never configured — showing the default for its risk class'} +

+
+ + +
+ + {action.dimensions.length > 0 && ( +
+

+ Per-run caps. Blank is uncapped — the run still counts what it spends, nothing bounds + it. Where another enabled action spends the same thing, the tightest cap is the one a + run gets. +

+
+ {action.dimensions.map((dimension) => ( + + ))} + +
+
+ )} +
+ ))} +
+ ) +} diff --git a/client/src/routes/admin/views/EventEditor.jsx b/client/src/routes/admin/views/EventEditor.jsx index 3ad300e..d178411 100644 --- a/client/src/routes/admin/views/EventEditor.jsx +++ b/client/src/routes/admin/views/EventEditor.jsx @@ -59,6 +59,10 @@ export default function EventEditor() { const [problems, setProblems] = useState([]) const [notice, setNotice] = useState(null) const [busy, setBusy] = useState(false) + // The dry run's answer (Phase 6). Cleared on every save and every publish, + // because a report is a statement about a spec and both of those change it — + // a stale green report beside an edited plan is worse than no report. + const [report, setReport] = useState(null) const isAdmin = user?.role === 'admin' // Reads here are staff-wide (§K), so a moderator reaches this screen legitimately @@ -171,6 +175,9 @@ export default function EventEditor() { setBusy(true) setProblems([]) setNotice(null) + // A report describes a spec, and saving changes it. A green report left + // standing beside an edited plan is worse than no report at all. + setReport(null) const built = payloadFromForm(form) if (!built.ok) { setProblems(built.errors) @@ -200,6 +207,7 @@ export default function EventEditor() { setBusy(true) setProblems([]) setNotice(null) + setReport(null) try { const result = await api.admin.publishEvent(id) setEvent(result.event) @@ -218,6 +226,34 @@ export default function EventEditor() { } } + /** + * The dry run (Phase 6). + * + * `admin, editor` — it dispatches nothing. What it verifies follows the + * definition's state, and the server says which: a `ready` definition is + * checked against its PUBLISHED version, because that is the only thing that + * ever actually runs and it is that pass §K's gate is about; a draft is checked + * against the working spec the author is still holding. + * + * Findings arrive with a 200 — the request succeeded, the plan has problems — + * so they are rendered rather than thrown into the error box. + */ + const verify = async () => { + setBusy(true) + setProblems([]) + setNotice(null) + setReport(null) + try { + const result = await api.admin.verifyEvent(id) + setReport(result) + if (result.recorded) setEvent(await api.admin.getEvent(id).then((r) => r.event)) + } catch (err) { + setProblems(err.body?.errors || [err.message]) + } finally { + setBusy(false) + } + } + const start = async () => { setBusy(true) setProblems([]) @@ -260,6 +296,14 @@ export default function EventEditor() { {isNew ? 'Create draft' : 'Save'} )} + {/* The dry run is admin+editor, deliberately wider than publish: an + author should be able to find out what their event would cost + before asking an admin to commit the deployment to it. */} + {!isNew && mayAuthor && ( + + )} {/* Publish and start are admin ONLY (§N2) and not the same gate as the live controls: publishing commits a definition a schedule will later start unattended. */} @@ -291,6 +335,86 @@ export default function EventEditor() {

)} + {/* ── §K's gate, said where it can still be acted on ── + A published version that nobody has dry-run will not start on its + schedule. The alternative to saying so here is an operator finding out + on the Friday it did not run, so it is a banner rather than a log line — + and only for a definition that actually HAS a schedule to be held. */} + {!isNew && event?.state === 'ready' && !event?.currentVersionVerifiedAt && !archived && ( +
+

+ This version has not been dry-run. Scheduled occurrences are held until it + is — an event that starts while nobody is watching gets one review, and this is it. + Starting it by hand is unaffected. +

+
+ )} + + {report && ( +
+

+ + {report.report.ok ? 'Dry run passed' : 'Dry run found problems'} + {' '} + + · {report.report.steps} step{report.report.steps === 1 ? '' : 's'} checked against{' '} + {/* Which spec was checked. The two answer different questions, and a + report that did not say would be read as the other one. */} + {report.target === 'version' ? `published v${report.version}` : 'the working draft'} + {report.recorded && ' · recorded, so scheduled occurrences may now start'} + +

+ + {report.report.findings.length > 0 && ( +
    + {report.report.findings.map((f, i) => ( +
  • + {f.phase !== null && ( + + {f.phase} · step {f.seq + 1} + {f.actionId ? ` · ${f.actionId}` : ''} + + )} + {f.phase !== null && ' — '} + {f.message} +
  • + ))} +
+ )} + + {/* The whole-plan cost, which is the finding no other path can make: a + step that fits on its own and does not fit alongside its siblings. */} + {report.report.cost.length > 0 && ( + + + {report.report.cost.map((c) => ( + + + + + + ))} + +
{c.dimension}{c.total} + {c.cap === null ? 'no cap' : `of ${c.cap} per run${c.from ? ` (${c.from})` : ''}`} +
+ )} + + {report.report.findings.length === 0 && report.report.cost.length === 0 && ( +

+ Nothing this event does costs a capped resource. +

+ )} +
+ )} + {notice &&

{notice}

} {problems.length > 0 && ( diff --git a/client/src/routes/admin/views/EventRun.jsx b/client/src/routes/admin/views/EventRun.jsx index d1b00a8..18aa3a6 100644 --- a/client/src/routes/admin/views/EventRun.jsx +++ b/client/src/routes/admin/views/EventRun.jsx @@ -144,6 +144,10 @@ export default function EventRun() { const [steps, setSteps] = useState([]) const [counts, setCounts] = useState({}) const [gates, setGates] = useState([]) + // The caps this run was given and what it has spent of them (Phase 6). Copied + // into the run when it was created, so this is what THIS run is allowed rather + // than what the switchboard says today. + const [budget, setBudget] = useState([]) const [lines, setLines] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -163,6 +167,7 @@ export default function EventRun() { setSteps(detail.steps || []) setCounts(detail.counts || {}) setGates(detail.gates || []) + setBudget(detail.budget || []) setLines(log.log || []) }, [runId]) @@ -342,6 +347,59 @@ export default function EventRun() { )} + {/* ── What this run is allowed, and what it has spent ── + A meter rather than a sentence: a cap is two numbers and a name, and + unlike a gate it needs no grammar rendered to be read. It is shown for + every run that has a budget at all, finished ones included — "how much + did last night's invasion actually spawn" is the same question asked + the morning after. */} + {budget.length > 0 && ( +
+

Caps

+ + + {budget.map((b) => { + const spent = b.cap === null ? 0 : Math.min(b.consumed / b.cap, 1) + const full = b.cap !== null && b.consumed >= b.cap + return ( + + + + + {/* Which switch set the number, so an operator can trace a cap + back to a thing they can change rather than wondering + where 30 came from. */} + + + ) + })} + +
+ {b.dimension} + + {b.cap === null ? `${b.consumed} spent` : `${b.consumed} of ${b.cap}`} + + {b.cap === null ? ( + no cap + ) : ( + + + + )} + + {b.from || ''} +
+
+ )} + {/* ── Waiting on a person ── */} {parked.length > 0 && (
diff --git a/client/test/eventAuthoring.test.js b/client/test/eventAuthoring.test.js index 60b6742..14a7477 100644 --- a/client/test/eventAuthoring.test.js +++ b/client/test/eventAuthoring.test.js @@ -11,6 +11,7 @@ import { blankStep, blankPhase, describeLogLine, + logKindWord, runStatusWord, describeSchedule, scheduleFormFrom, @@ -557,3 +558,47 @@ test('the log renders Phase 5\'s three kinds, including the near miss', () => { /loot advanced on its deadline after 600s/, ) }) + +test("the log renders Phase 6's three kinds, and a refusal does not read as a failure", () => { + // The distinction the whole kind exists for. An operator scanning a stopped run + // has to be able to see that nothing is broken — the deployment simply does not + // permit what the author asked for — and the answer differs by cause: a switch + // for "not enabled", a number for "over the cap". + assert.match( + describeLogLine({ + kind: 'step.refused', + detail: { action: 'uo.creature.spawn', error: 'asks for 12 of "uo.creatures"; 28 of 30 is already spent this run' }, + }), + /uo\.creature\.spawn refused: asks for 12 of "uo\.creatures"; 28 of 30 is already spent this run/, + ) + assert.match( + describeLogLine({ + kind: 'step.refused', + detail: { action: 'uo.creature.spawn', error: '"Spawn creatures" is not enabled on this deployment' }, + }), + /refused: "Spawn creatures" is not enabled/, + ) + assert.equal(logKindWord('step.refused'), 'Refused') + + // The caps a run was seeded with, and which switch set each — so a number on + // the meter can be traced back to something an operator can change. + assert.match( + describeLogLine({ + kind: 'run.budget', + detail: { dimensions: [{ dimension: 'uo.creatures', cap: 30, from: 'uo.creature.spawn' }] }, + }), + /uo\.creatures capped at 30 \(uo\.creature\.spawn\)/, + ) + assert.match( + describeLogLine({ kind: 'run.budget', detail: { dimensions: [{ dimension: 'uo.gate.minutes', cap: null, from: null }] } }), + /uo\.gate\.minutes capped at nothing/, + ) + // A run with no capped dimension at all still gets a sentence rather than an + // empty line, because an empty log entry reads as a bug. + assert.match(describeLogLine({ kind: 'run.budget', detail: { dimensions: [] } }), /no caps apply to this run/) + + assert.match( + describeLogLine({ kind: 'version.verified', detail: { versionId: 4, version: 2, by: 1 } }), + /Version 2 passed its dry run — scheduled occurrences may start/, + ) +}) diff --git a/server/db/schema.sql b/server/db/schema.sql index 8283e6a..fd86d56 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -2086,10 +2086,11 @@ CREATE TABLE IF NOT EXISTS engagement_suppressions ( -- ── The Event System (EVENTS.md §D — Phase 1) ────────────────────────────── --- Six of the nine core tables land here: the ones that do not depend on the --- module contract. `event_action_settings`, `event_run_budget`, --- `event_run_resources` and `event_run_participants` arrive with the phases that --- give them a writer (P6, P8, P10) rather than as empty tables nothing reads. +-- Six of the eleven core tables land here: the ones that do not depend on the +-- module contract. The rest arrive with the phases that give them a writer +-- rather than as empty tables nothing reads -- `event_run_phase_gates` in P5, +-- `event_action_settings` and `event_run_budget` in P6, `event_run_resources` in +-- P8 and `event_run_participants` in P10. -- -- Core tables, so no module prefix, and no game vocabulary anywhere below: an -- action id, a scope, a resource kind and a budget dimension are all opaque @@ -2410,3 +2411,94 @@ CREATE TABLE IF NOT EXISTS event_run_phase_gates ( -- index in this feature that is on a hot path rather than an admin screen. INDEX idx_evgate_open (trigger_id, satisfied_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ── Enablement, caps and the verify gate (EVENTS.md §D/§K — Phase 6) ─────── + +-- The deployment's switchboard, and the whole of the permission model beyond the +-- role (§K layer 2). +-- +-- **Not a grant table.** Nobody is named in it, because the role check already +-- answered who; this table answers *what this deployment permits at all*, and +-- how much of it per run. That is the distinction §K draws between a capability +-- and permission to invoke it: a module declaring `uo.creature.spawn` is code the +-- operator installed, not a permission they granted. +-- +-- **A missing row is not "disabled" — it is "the default for its risk class".** +-- Rows are written when an admin changes something, never seeded at boot, for a +-- reason that is structural rather than tidy: the registry is assembled in +-- `registerCore()` and by module `register()`, both of which run under +-- `routeManifest.js` and `swagger.js` against a DEAD POOL (MODULE_API.md §2.2). +-- A boot-time seed from the registry would be exactly the database write those +-- two forbid. Reading a default from the risk class costs one branch and means a +-- deployment that never opens this screen behaves correctly. +-- +-- The row survives its action: uninstalling a module leaves the settings behind, +-- so re-installing it restores the caps the operator chose rather than silently +-- resetting them. The switchboard only lists what is registered *now*, so a +-- stranded row is invisible until its action comes back. +-- +-- `action_id` is the primary key rather than an id column: there is exactly one +-- row per action and every read is by that id. +CREATE TABLE IF NOT EXISTS event_action_settings ( + action_id VARCHAR(96) NOT NULL PRIMARY KEY, + enabled TINYINT(1) NOT NULL DEFAULT 0, + -- `{dimension: perRunCap}`. A dimension absent from this object is uncapped by + -- this action; an empty object is an action that declares no cost at all. + caps JSON NULL, + updated_by INT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_evset_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- What one run has spent, and the most it may. +-- +-- **The cap is COPIED here at run start, not read live.** A run is already +-- reproducible in every other respect — it pins a version, and the version is +-- immutable — and a cap read live would be the one input to a run's behaviour +-- that an admin could change underneath it at three in the morning. Copying also +-- makes the console's meter answer the right question afterwards: "what was this +-- run allowed", not "what is allowed now". +-- +-- **One row per dimension, so the cap is the tightest of the actions the run's +-- version names** (org lead, 2026-09-03). Two actions that both spend +-- `uo.creatures` share this row, which is what makes a dimension a bound on the +-- run's total effect rather than a per-verb allowance. `effective_from` records +-- which action's cap won, so the console can say so. +-- +-- `consumed + ? <= cap` in the WHERE is the whole concurrency story (§E): two +-- steps drawing on one dimension in the same tick cannot both see 28/30 and both +-- spend, and no transaction is needed to say so. Same shape as the outbox claim +-- and the gate's conditional increment, and the same reason. +CREATE TABLE IF NOT EXISTS event_run_budget ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + run_id BIGINT NOT NULL, + dimension VARCHAR(96) NOT NULL, + consumed INT NOT NULL DEFAULT 0, + -- **NULL is uncapped, and it is a row rather than an absent one.** A dimension + -- every action naming it left uncapped still accumulates here, so the console's + -- meter can say "14 spawned, no cap" -- and so that a MISSING row keeps its one + -- unambiguous meaning: a step spending a dimension its own run's version never + -- priced, which `spend()` refuses. + cap INT NULL, + -- The action whose cap was the minimum. Documentary: it is what lets the run + -- console say "30, from uo.creature.spawn" rather than showing a number the + -- operator cannot trace back to a switch they set. + effective_from VARCHAR(96) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_evbud_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE, + -- Seeding is INSERT IGNORE against this, so a tick that overruns into the next + -- one cannot double-seed a run's budget. + UNIQUE KEY uq_evbud_dim (run_id, dimension) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- §K's last bound: "a scheduled definition that has never been verified is the +-- case worth refusing to start". A version is immutable, so a dry run that passed +-- against it stays true — which is what makes the pass a property of the VERSION +-- rather than of the definition, and what makes recording it two columns rather +-- than a table. +-- +-- Enforced for SCHEDULED starts only (org lead, 2026-09-03): a human pressing +-- start is watching, and that human is the review the gate exists to require. +ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_at DATETIME NULL; +ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_by INT NULL; diff --git a/server/routes.guards.json b/server/routes.guards.json index 1e112cc..6127ca2 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -482,6 +482,15 @@ "requireAuth" ] }, + { + "method": "POST", + "path": "/api/v1/admin/events/:id/verify", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, { "method": "GET", "path": "/api/v1/admin/events/:id/versions", @@ -491,6 +500,24 @@ "requireAuth" ] }, + { + "method": "GET", + "path": "/api/v1/admin/events/actions", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "PUT", + "path": "/api/v1/admin/events/actions", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, { "method": "GET", "path": "/api/v1/admin/events/calendar", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index f9e8c9d..c115a80 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -213,10 +213,22 @@ "method": "POST", "path": "/api/v1/admin/events/:id/runs" }, + { + "method": "POST", + "path": "/api/v1/admin/events/:id/verify" + }, { "method": "GET", "path": "/api/v1/admin/events/:id/versions" }, + { + "method": "GET", + "path": "/api/v1/admin/events/actions" + }, + { + "method": "PUT", + "path": "/api/v1/admin/events/actions" + }, { "method": "GET", "path": "/api/v1/admin/events/calendar" diff --git a/server/src/events/authorize.js b/server/src/events/authorize.js new file mode 100644 index 0000000..8e09186 --- /dev/null +++ b/server/src/events/authorize.js @@ -0,0 +1,339 @@ +// ── The whole authorisation decision, behind one function ────────────────── +// +// EVENTS.md §K, and Phase 6 of EVENTS_PLAN.md. Four layers stand between an +// action being *declared* and an action being *carried out* — role, enablement, +// cap, and the shard's own switch — and §K asks for them in one place rather +// than spread across route middleware: +// +// > **Keep the check in one function.** Not for tidiness: it is what makes an +// > EM-style delegation model a *later* option rather than a redesign. If a +// > deployment ever wants named coordinators with their own budgets, that is +// > one function learning to consult a second table, and nothing else in this +// > document changes. +// +// So `mayInvoke()` below is the only thing in this codebase that answers "may +// this happen". The router still calls `requireRole` — that gate is about +// reaching the ROUTE — but whether a particular verb may be aimed at the world is +// decided here, once, on every path that can cause it: authoring a step, +// publishing, the dry run, starting a run, and the runner's own unattended +// dispatch. +// +// ## Four layers, and where each one is actually enforced +// +// 1. **Declaration** — a module says a verb exists. Not a permission, and not +// checked here: `dispatch.js` already answers a step whose action nobody +// registers, and it answers it `dormant` rather than `refused`, because an +// uninstalled module is a different fact from a forbidden one. +// 2. **Role** — `change` and `irreversible` are `admin` only. See below. +// 3. **Enablement and caps** — this file, against `event_action_settings` and +// `event_run_budget`. +// 4. **The shard's own switches** — `AdminWriteEnabled` and `AdminAccessFloor` +// live on the shard host, outside the website's reach entirely, and core +// deliberately does not duplicate them. A module honours them when it +// translates an action into a sidecar command (P9); a second copy of that +// decision in core would be a copy that could disagree with the shard about +// whether the shard is accepting writes. It is named as a layer because +// leaving it unnamed is how it comes to be re-implemented. +// +// ## The role line, and why it is drawn at `change` +// +// §K's table says "any step whose action is above `notify`, and the action +// switchboard — `admin` only". Read literally that is the same sentence that made +// `core.wait` — `risk: 'inspect'` — ship disabled by default, and the org lead +// settled that on 2026-09-03: the line falls between `inspect` and `change`, not +// between `notify` and `inspect`. An `inspect` action reads state and writes +// nothing, so neither the default nor the role floor gains a deployment anything +// by excluding it, and an editor who cannot author a step that WAITS has an +// authoring role that cannot author. +// +// ## Why `user` may be null, and what that means +// +// The runner dispatches with nobody logged in. It is not "the system escalating": +// the role was checked when a human published the version and again when a human +// or the scheduler started the run, and **a run already in flight is not re-gated +// against its starter's current role**. Re-checking would mean that demoting an +// admin at midnight silently strands every event they started — an event stopping +// halfway through because of an unrelated personnel change. §K's "a demoted user +// loses access at once" is about reaching a route, and it still holds exactly +// there. Cancel is the control for a run that should stop. +// +// ## Why the cap check can spend +// +// `mayInvoke` reads on every path but ONE, and on that one it must also write. +// The cap is held by a conditional `UPDATE` whose WHERE carries the guard (§E), so +// checking and then spending would be two statements with a race between them — +// the exact race the conditional increment exists to remove. `spend: true` is +// therefore a parameter rather than a separate function: one decision procedure, +// one set of layers, and the authoritative check is the one that also commits. + +const settingsDb = require('../model/events/eventActionSettings.db') +const budgetDb = require('../model/events/eventRunBudget.db') +const registries = require('../modules/registries') +const log = require('../utils/logger')('events') + +// The risk classes that change the world, and the two things that follow from +// being on this list: the action arrives DISABLED on a fresh deployment, and only +// an admin may author a step that names it. Both were one sentence in §K and both +// were settled together (org lead, 2026-09-03). +const WORLD_CHANGING = ['change', 'irreversible'] + +/** Does this action alter the world, in the sense the switchboard and the role floor mean? */ +const changesWorld = (action) => WORLD_CHANGING.includes(action?.risk) + +/** + * Whether an action is enabled, given the deployment's stored opinion — or, when + * it has none, its risk class. + * + * Exported because the switchboard renders the same answer, and a screen that + * computed the default itself would be a second copy of the posture. + */ +function isEnabled(action, settingsRow) { + if (settingsRow) return Boolean(settingsRow.enabled) + return !changesWorld(action) +} + +/** + * What one invocation of `action` costs, as `{dimension: amount}`. + * + * **A module's `cost()` is called here and nowhere else.** It is declared as a + * function of params (§F) and it is called with the params a step actually + * carries, so the number core enforces is the number the module said. A `cost` + * that throws, or that answers something other than a flat object of + * non-negative finite numbers, is treated as an unpriceable action rather than a + * free one: `null` comes back, and every caller reads `null` as a refusal. That + * is the fail-closed direction, and it is the only honest one — an action whose + * own accounting is broken is not an action whose consumption is zero. + */ +function priceOf(action, params) { + if (typeof action?.cost !== 'function') return {} + let raw + try { + raw = action.cost(params || {}) + } catch (err) { + log.warn('event action cost() threw', { action: action.id, message: err.message }) + return null + } + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null + const out = {} + for (const [dimension, amount] of Object.entries(raw)) { + const n = Number(amount) + if (!Number.isFinite(n) || n < 0) return null + if (n > 0) out[dimension] = n + } + return out +} + +/** + * The dimensions an action can spend, discovered by pricing its declared + * examples. + * + * **This is a Phase 6 stand-in with a Phase 7 replacement already named.** §F's + * `registerEventBudgets` is what will declare a dimension's id, label and unit, + * and it arrives with the module contract. Until then the switchboard still has + * to render a cap editor, and it cannot offer a box for a dimension it cannot + * name — so core prices each action's own `example` values, which is a use every + * param already has a required `example` for. + * + * It is honest about its limits: a `cost()` that returns different dimension KEYS + * for different params under-reports here. That costs an operator a cap box on + * the switchboard, and it costs a run nothing at all — a run's budget is seeded + * from the params its steps were actually authored with, never from examples. + */ +function dimensionsOf(action) { + const params = {} + for (const p of action?.params || []) { + if (p.example !== undefined && p.example !== null) params[p.name] = p.example + } + const priced = priceOf(action, params) + return priced ? Object.keys(priced).sort() : [] +} + +/** + * The effective per-run cap for each dimension a set of steps will spend. + * + * **The tightest cap wins** (org lead, 2026-09-03). `event_action_settings.caps` + * is per action while `event_run_budget` is one row per dimension, so two actions + * both spending `uo.creatures` have to agree on one number, and the number a + * safety limit should settle on is the smaller. It is what keeps a dimension a + * bound on the RUN's total effect rather than a per-verb allowance that two verbs + * can each draw in full. + * + * A dimension no action caps comes back `{ cap: null }` — uncapped, and still + * seeded, so the meter counts it and a missing row keeps its one meaning. + * + * `steps` are `{ actionId, params }`; the answer is `{dimension: {cap, from}}`. + */ +function effectiveCaps(steps, settingsByAction) { + const out = {} + for (const step of steps || []) { + const action = registries.eventAction(step.actionId) + if (!action) continue + const priced = priceOf(action, step.params) + if (!priced) continue + const declared = (settingsByAction.get(action.id) || {}).caps || {} + for (const dimension of Object.keys(priced)) { + const raw = declared[dimension] + const cap = Number.isFinite(Number(raw)) && Number(raw) >= 0 ? Number(raw) : null + if (!(dimension in out)) { + out[dimension] = { cap, from: cap === null ? null : action.id } + continue + } + const held = out[dimension] + // `null` is uncapped, so it never wins a minimum — an action that declines + // to cap a dimension must not raise the ceiling another action set. + if (cap !== null && (held.cap === null || cap < held.cap)) { + out[dimension] = { cap, from: action.id } + } + } + } + return out +} + +/** + * May this action be carried out, and — when asked — spend its cost. + * + * Answers an envelope, never throws, and never answers a bare boolean: every + * refusal carries a `code` a caller can branch on and a `reason` a human reads. + * The reason is written here rather than at the four call sites for the same + * argument Phase 5 made about the diagnosis panel — one place the words are + * written, so the dry run, the editor, the run console and the log all say the + * same sentence about the same fact. + * + * `{ user }` null means the unattended runner; see the header. `{ run }` null + * means there is no budget to draw on yet — authoring and the dry run — and the + * cap layer then compares the cost against the effective cap instead of against + * what is left of it. + */ +async function mayInvoke({ + user = null, + action, + params = {}, + run = null, + settings = undefined, + spend = false, +} = {}) { + if (!action) return { ok: false, code: 'unregistered', reason: 'no module registers this action' } + + // ── Layer 2: the role ── + if (user && changesWorld(action) && user.role !== 'admin') { + return { + ok: false, + code: 'role', + reason: `"${action.label}" changes the world, so only an administrator may use it`, + } + } + + // ── Layer 3a: enablement ── + const row = settings === undefined ? await settingsDb.get(action.id) : settings + if (!isEnabled(action, row)) { + return { + ok: false, + code: 'disabled', + reason: `"${action.label}" is not enabled on this deployment`, + } + } + + // ── Layer 3b: the cap ── + const cost = priceOf(action, params) + if (cost === null) { + return { + ok: false, + code: 'unpriceable', + reason: `"${action.label}" could not report what it costs`, + } + } + const dimensions = Object.keys(cost) + if (!dimensions.length) return { ok: true, cost } + + if (!run) { + // No run, so nothing to draw on: the question is whether the cost could EVER + // fit, which is what the dry run and the editor are asking. A cost larger + // than the cap is an authoring error and it is answerable before anything is + // scheduled — which is the entire value of catching it here. + const caps = effectiveCaps([{ actionId: action.id, params }], new Map([[action.id, row || {}]])) + for (const dimension of dimensions) { + const { cap } = caps[dimension] || { cap: null } + if (cap !== null && cost[dimension] > cap) { + return { + ok: false, + code: 'cap', + reason: `asks for ${cost[dimension]} of "${dimension}" and this deployment allows ${cap} per run`, + dimension, + requested: cost[dimension], + cap, + } + } + } + return { ok: true, cost } + } + + if (!spend) { + // A read of the meter rather than a draw on it. Deliberately advisory: this + // answer is stale the moment another step in the same tick spends, which is + // exactly why the authoritative check is the one that commits. + const rows = await budgetDb.forRun(run.id) + const byDimension = new Map(rows.map((r) => [r.dimension, r])) + for (const dimension of dimensions) { + const held = byDimension.get(dimension) + if (!held) return refusal(dimension, cost[dimension], null, 0, 'unbudgeted') + if (held.cap !== null && held.consumed + cost[dimension] > held.cap) { + return refusal(dimension, cost[dimension], held.cap, held.consumed, 'cap') + } + } + return { ok: true, cost } + } + + // ── The committing path ── + // + // One statement per dimension, because the atomicity that matters is per + // dimension: a cap is a bound on one thing, and a transaction spanning three of + // them would serialise three unrelated counters to buy nothing. What it does + // create is a partial spend — creatures taken, bosses refused — and a step that + // did not run must not have spent anything, so the taken ones are given back. + const taken = [] + for (const dimension of dimensions) { + if (await budgetDb.spend(run.id, dimension, cost[dimension])) { + taken.push(dimension) + continue + } + for (const back of taken) await budgetDb.refund(run.id, back, cost[back]) + const rows = await budgetDb.forRun(run.id) + const held = rows.find((r) => r.dimension === dimension) + return held + ? refusal(dimension, cost[dimension], held.cap, held.consumed, 'cap') + : refusal(dimension, cost[dimension], null, 0, 'unbudgeted') + } + return { ok: true, cost, spent: true } +} + +/** The two cap refusals, written once so they cannot drift apart. */ +function refusal(dimension, requested, cap, consumed, code) { + if (code === 'unbudgeted') { + return { + ok: false, + code: 'unbudgeted', + reason: `spends "${dimension}", which this run has no budget for`, + dimension, + requested, + } + } + return { + ok: false, + code: 'cap', + reason: `asks for ${requested} of "${dimension}"; ${consumed} of ${cap} is already spent this run`, + dimension, + requested, + cap, + consumed, + } +} + +module.exports = { + mayInvoke, + isEnabled, + priceOf, + dimensionsOf, + effectiveCaps, + changesWorld, + WORLD_CHANGING, +} diff --git a/server/src/events/verify.js b/server/src/events/verify.js new file mode 100644 index 0000000..9aca95e --- /dev/null +++ b/server/src/events/verify.js @@ -0,0 +1,153 @@ +// ── The dry run ──────────────────────────────────────────────────────────── +// +// EVENTS.md §I ("four affordances worth building in from the start") and §K's +// last bound, in Phase 6. Materialise nothing, dispatch every step with +// `verify: true`, and report what would happen and what it would cost. +// +// > **Dry run before anything unattended.** A scheduled definition that has never +// > been verified is the case worth refusing to start; verification is cheap and +// > it is the last point a human sees the plan. +// +// **What it verifies depends on the definition's state, and that is not a +// compromise.** A `ready` definition is verified against its PUBLISHED VERSION, +// because a published version is the only thing that ever actually runs and §K's +// gate is about letting one run unattended. A draft is verified against its +// working spec, because §API's note is explicit that an author prices their work +// *before* asking an admin to publish it. The two readings do not conflict — they +// are the same act at two moments — and the answer says which one it did. +// +// **Only a pass against a version is recorded.** A version is immutable, so a dry +// run that passed against one stays true; a draft changes under the author's +// hands, so a pass on it would be a claim about a spec that no longer exists. +// +// ## The finding that only exists here +// +// Every per-step check — is the action registered, is it enabled, does this one +// invocation fit the cap — is a check something else also makes, at save or at +// dispatch. **The TOTAL is not.** Three steps each spawning 15 creatures under a +// cap of 30 pass every individual check and breach the cap on the third, at two +// in the morning, with the world half-changed. Adding the costs up across the +// whole version is the one thing that can only be done by looking at the plan as +// a whole, and it is the reason a dry run is worth more than the sum of its +// step checks. + +const { dispatchStep } = require('./dispatch') +const authorize = require('./authorize') +const settingsDb = require('../model/events/eventActionSettings.db') +const registries = require('../modules/registries') + +/** + * Dry-run a spec. + * + * `user` is the caller, so the role layer answers for *them* — an editor gets + * told that a step needs an administrator, at the moment they can still do + * something about it, rather than at the moment it does not run. + * + * Never throws: a `perform()` that explodes under `verify: true` is a finding + * about that action, not a 500 on the author's screen. `dispatchStep` already + * guarantees that, and this file adds no path around it. + */ +async function verifySpec(spec, { user = null, scope = '' } = {}) { + const phases = spec?.phases || [] + const flat = [] + for (const phase of phases) { + for (const [seq, step] of (phase.steps || []).entries()) { + flat.push({ phase: phase.key, seq, step }) + } + } + + const settings = await settingsDb.byIds(flat.map(({ step }) => step.actionId)) + const findings = [] + const totals = {} + + for (const { phase, seq, step } of flat) { + const where = { phase, seq, actionId: step.actionId, label: step.label || null } + const action = registries.eventAction(step.actionId) + if (!action) { + // The same fact `publishable()` refuses on, said in the dry run's voice. + // Reported rather than thrown so that an author sees EVERY problem in one + // pass — a verification that stops at the first finding makes fixing a + // twelve-step definition twelve round trips. + findings.push({ ...where, level: 'error', code: 'dormant', message: `no module registers "${step.actionId}"` }) + continue + } + + const verdict = await authorize.mayInvoke({ + user, + action, + params: step.params || {}, + settings: settings.get(action.id) || null, + }) + if (!verdict.ok) { + findings.push({ ...where, level: 'error', code: verdict.code, message: verdict.reason }) + continue + } + + for (const [dimension, amount] of Object.entries(verdict.cost || {})) { + totals[dimension] = (totals[dimension] || 0) + amount + } + + // The module's own answer. This is the half core cannot compute: whether the + // landmark exists, whether the creature is on the allowlist, whether the + // shard is reachable at all. `verify: true` rides through the real + // dispatcher rather than down a second path, because a dry run down a second + // path is a dry run OF the second path. + const result = await dispatchStep( + { + id: null, + run_id: null, + phase, + seq, + action_id: step.actionId, + params: step.params || {}, + action_version: step.actionVersion || null, + idempotency_key: null, + attempts: 0, + }, + { run: { id: null, scope }, actor: user ? user.id : null, verify: true }, + ) + if (result.outcome === 'retry' || result.outcome === 'terminal') { + findings.push({ ...where, level: 'error', code: 'refused', message: result.error }) + } else if (result.actionVersionDrift) { + findings.push({ + ...where, + level: 'warning', + code: 'version-drift', + message: `authored against version ${result.actionVersionDrift.authored}; ${step.actionId} is now version ${result.actionVersionDrift.registered}`, + }) + } + } + + // ── The whole-plan check ── + const caps = authorize.effectiveCaps( + flat.map(({ step }) => ({ actionId: step.actionId, params: step.params || {} })), + settings, + ) + const cost = Object.entries(totals) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([dimension, total]) => { + const cap = (caps[dimension] || {}).cap ?? null + const over = cap !== null && total > cap + if (over) { + findings.push({ + phase: null, + seq: null, + actionId: null, + label: null, + level: 'error', + code: 'cap-total', + message: `this event asks for ${total} of "${dimension}" across all its steps, and this deployment allows ${cap} per run`, + }) + } + return { dimension, total, cap, from: (caps[dimension] || {}).from || null, over } + }) + + return { + ok: !findings.some((f) => f.level === 'error'), + steps: flat.length, + findings, + cost, + } +} + +module.exports = { verifySpec } diff --git a/server/src/model/events/eventActionSettings.db.js b/server/src/model/events/eventActionSettings.db.js new file mode 100644 index 0000000..78c5e5c --- /dev/null +++ b/server/src/model/events/eventActionSettings.db.js @@ -0,0 +1,86 @@ +// ── event_action_settings — SQL only ─────────────────────────────────────── +// +// EVENTS.md §D/§K, and Phase 6 of EVENTS_PLAN.md. The deployment's switchboard: +// one row per action an admin has an opinion about, and **nothing else in the +// permission model beyond the role**. +// +// **A missing row is not "disabled".** It is "the default for this action's risk +// class", and that default is computed in `eventActionSettings.model.js` from the +// registry rather than stored here. The reason is structural: the registry is +// assembled by `registerCore()` and by module `register()`, both of which run +// under `routeManifest.js` and `swagger.js` against a dead pool (MODULE_API.md +// §2.2), so a boot-time seed of one row per registered action would be precisely +// the database write those two forbid. A deployment that never opens the +// switchboard has no rows at all and behaves correctly. +// +// **Rows outlive their actions on purpose.** Uninstalling a module leaves its +// settings standing, so re-installing restores the caps the operator chose +// instead of silently resetting them to the default. The switchboard lists what +// is registered *now*, so a stranded row is invisible until its action returns. + +const { query } = require('../../utils/db') +const { parseJson } = require('./eventJson') + +const hydrate = (row) => row && { ...row, caps: parseJson(row.caps, {}) || {} } + +/** Every stored row, action id first. Stranded rows included — the caller filters. */ +async function all() { + const rows = await query( + `SELECT s.action_id, s.enabled, s.caps, s.updated_by, s.updated_at, u.username AS updated_by_username + FROM event_action_settings s + LEFT JOIN users u ON u.id = s.updated_by + ORDER BY s.action_id`, + ) + return rows.map(hydrate) +} + +/** One row, or null when the deployment has never had an opinion about this action. */ +async function get(actionId) { + const rows = await query( + `SELECT action_id, enabled, caps, updated_by, updated_at + FROM event_action_settings WHERE action_id = ?`, + [actionId], + ) + return rows.length ? hydrate(rows[0]) : null +} + +/** + * The rows for a set of action ids, as a Map keyed by id. + * + * The shape the authorisation path wants: `mayInvoke` is asked about one action + * at a time but the run start prices a whole version at once, and one query per + * step of a twelve-step definition is twelve round trips to answer a question + * about a table with one row per action in the process. + */ +async function byIds(actionIds) { + const ids = [...new Set(actionIds || [])].filter(Boolean) + if (!ids.length) return new Map() + const rows = await query( + `SELECT action_id, enabled, caps, updated_by, updated_at + FROM event_action_settings + WHERE action_id IN (${ids.map(() => '?').join(',')})`, + ids, + ) + return new Map(rows.map((r) => [r.action_id, hydrate(r)])) +} + +/** + * Write one action's switch and caps. + * + * An upsert rather than a read-then-write, for the ordinary reason: two admins on + * the switchboard at once should leave one of their two opinions standing, not an + * error and not a row that never appeared. There is no compare-and-set here + * because there is nothing to race — this is configuration, and the value that + * matters is the last one a human chose. + */ +async function put(actionId, { enabled, caps }, userId = null) { + await query( + `INSERT INTO event_action_settings (action_id, enabled, caps, updated_by) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), caps = VALUES(caps), updated_by = VALUES(updated_by)`, + [actionId, enabled ? 1 : 0, JSON.stringify(caps || {}), userId], + ) + return get(actionId) +} + +module.exports = { all, get, byIds, put } diff --git a/server/src/model/events/eventDefinitions.db.js b/server/src/model/events/eventDefinitions.db.js index c237e79..a735abb 100644 --- a/server/src/model/events/eventDefinitions.db.js +++ b/server/src/model/events/eventDefinitions.db.js @@ -16,9 +16,16 @@ const hydrate = (row) => // `current_version` is joined rather than stored: the list screen shows "v3" and // the column that would hold it is a denormalisation of a row this query already // has to reach for the publish date anyway. +// +// `current_version_verified_at` rides along for the same reason and answers the +// same kind of question (Phase 6). A `ready` definition whose version has never +// been dry-run will not start on its schedule (§K), and the one place that fact +// is worth saying is the screen its author is already looking at -- the +// alternative is finding out on the Friday it did not run. const SELECT_LIST = ` SELECT d.*, s.name AS series_name, s.slug AS series_slug, - v.version AS current_version + v.version AS current_version, + v.verified_at AS current_version_verified_at FROM event_definitions d LEFT JOIN event_series s ON s.id = d.series_id LEFT JOIN event_versions v ON v.id = d.current_version_id diff --git a/server/src/model/events/eventDefinitions.model.js b/server/src/model/events/eventDefinitions.model.js index 813c2ce..c4277de 100644 --- a/server/src/model/events/eventDefinitions.model.js +++ b/server/src/model/events/eventDefinitions.model.js @@ -26,6 +26,9 @@ 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 { slugify, uniqueSlug } = require('../teams/teamSlug') const { cleanBody } = require('../../utils/sanitizeHtml') @@ -162,9 +165,11 @@ async function validate(input, { existing = null } = {}) { } /** Create a draft. */ -async function create(input, userId) { +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 id = await db.insert({ ...result.definition, created_by: userId }) return { ok: true, id, definition: await db.getById(id) } } @@ -177,7 +182,7 @@ async function create(input, userId) { * 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) { +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') { @@ -185,6 +190,8 @@ async function save(id, input, userId) { } const result = await validate(input, { existing }) if (!result.ok) return result + const floor = checkRoleFloor(result.definition.spec, role) + if (floor) return floor await db.update(id, { ...result.definition, updated_by: userId }) return { ok: true, id, definition: await db.getById(id) } } @@ -283,12 +290,113 @@ async function 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'}`, + ], + } +} + +/** + * 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 }) + + 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), + } +} + module.exports = { validate, create, save, publish, archive, + verify, + checkRoleFloor, isTimezone, MIN_GRACE_SECONDS, MAX_GRACE_SECONDS, diff --git a/server/src/model/events/eventRunBudget.db.js b/server/src/model/events/eventRunBudget.db.js new file mode 100644 index 0000000..ed660b2 --- /dev/null +++ b/server/src/model/events/eventRunBudget.db.js @@ -0,0 +1,134 @@ +// ── event_run_budget — SQL only ──────────────────────────────────────────── +// +// EVENTS.md §D/§E, and Phase 6 of EVENTS_PLAN.md. What one run has spent of one +// dimension, and the most it may. +// +// **The whole file exists for one statement.** `spend()` is the conditional +// increment §E names as the answer to "two steps spending one cap": +// +// UPDATE … SET consumed = consumed + ? WHERE run_id=? AND dimension=? AND consumed + ? <= cap +// +// A read-then-write would let two steps drawing on `uo.creatures` in the same +// tick each see 28 of 30 and each spend 5. The cap in the WHERE means the second +// one changes no rows, and `affectedRows === 0` *is* the refusal — no transaction, +// no lock, and no second opinion about the arithmetic. Same shape as the outbox +// claim, `runsDb.transition` and Phase 5's gate increment, and the same argument. +// +// **The SET list here has one assignment for the reason Phase 5's had three.** +// MariaDB evaluates an UPDATE's SET assignments left to right, each seeing what +// the ones before it assigned — which is how Phase 5's gate closed a firing early +// — so nothing in this statement may read `consumed` after it has been written. +// The guard is in the WHERE, where it reads the pre-update row, and it must stay +// there. + +const { query } = require('../../utils/db') + +/** + * Seed a run's budget rows. + * + * **INSERT IGNORE against `uq_evbud_dim`**, so a tick that overruns into the next + * one cannot double-seed and cannot reset a cap a run has already spent against — + * the idempotence `materialisePhase` and `gates.open` both have, for the same + * reason. + * + * The caps are COPIED here rather than read live at dispatch. A run pins its + * version and is reproducible in every other respect; a cap read live would be + * the one input to a run's behaviour an admin could change underneath it while + * nobody was watching, and the console's meter would answer "what is allowed now" + * when the question afterwards is "what was this run allowed". + */ +async function seed(runId, dimensions) { + const rows = Object.entries(dimensions || {}) + if (!rows.length) return 0 + const values = rows.map(() => '(?, ?, 0, ?, ?)').join(', ') + const params = rows.flatMap(([dimension, d]) => [runId, dimension, d.cap, d.from || null]) + const result = await query( + `INSERT IGNORE INTO event_run_budget (run_id, dimension, consumed, cap, effective_from) + VALUES ${values}`, + params, + ) + return result.affectedRows || 0 +} + +/** + * Spend `amount` of one dimension, or refuse. + * + * Answers `true` when the row moved and `false` when it did not — and `false` has + * exactly two causes, both of which mean the same thing to the caller: the spend + * would breach the cap, or there is no row for this dimension at all. The second + * is not a silent pass: a run whose version names an action that costs a + * dimension always has that dimension seeded — **uncapped ones included, as a row + * with a NULL cap** — so a missing row means the step is spending something its + * own version never declared, and refusing that is the fail-closed direction. + * + * A zero or negative amount is not a spend and never touches the database. An + * action whose `cost()` answers `0` for its params is telling core it consumes + * nothing, and pricing that as a query would put one round trip per step behind a + * fact the caller already has. + */ +async function spend(runId, dimension, amount) { + if (!(amount > 0)) return true + const result = await query( + `UPDATE event_run_budget + SET consumed = consumed + ? + WHERE run_id = ? AND dimension = ? AND (cap IS NULL OR consumed + ? <= cap)`, + [amount, runId, dimension, amount], + ) + return (result.affectedRows || 0) > 0 +} + +/** + * Give `amount` back. + * + * Called on exactly one path: a step that spent several dimensions and was then + * refused on a later one. The spends are separate statements — they must be, the + * atomicity that matters is per dimension — so a step costing 5 creatures and 2 + * bosses can take the creatures and be refused the bosses, and a step that did + * not run must not have spent anything. `GREATEST(consumed - ?, 0)` because the + * floor is worth more than a refund that is exactly right: a negative `consumed` + * would make the cap arithmetic lie in the permissive direction forever after. + */ +async function refund(runId, dimension, amount) { + if (!(amount > 0)) return + await query( + `UPDATE event_run_budget + SET consumed = GREATEST(consumed - ?, 0) + WHERE run_id = ? AND dimension = ?`, + [amount, runId, dimension], + ) +} + +/** Every dimension of one run, for the console's meter. */ +async function forRun(runId) { + return query( + `SELECT dimension, consumed, cap, effective_from + FROM event_run_budget WHERE run_id = ? ORDER BY dimension`, + [runId], + ) +} + +/** The dimensions of several runs at once, keyed by run id — the run LIST's read. */ +async function forRuns(runIds) { + const ids = [...new Set(runIds || [])].filter(Boolean) + if (!ids.length) return new Map() + const rows = await query( + `SELECT run_id, dimension, consumed, cap, effective_from + FROM event_run_budget + WHERE run_id IN (${ids.map(() => '?').join(',')}) + ORDER BY run_id, dimension`, + ids, + ) + const out = new Map() + for (const r of rows) { + if (!out.has(r.run_id)) out.set(r.run_id, []) + out.get(r.run_id).push({ + dimension: r.dimension, + consumed: r.consumed, + cap: r.cap, + effective_from: r.effective_from, + }) + } + return out +} + +module.exports = { seed, spend, refund, forRun, forRuns } diff --git a/server/src/model/events/eventRunLog.db.js b/server/src/model/events/eventRunLog.db.js index f2d2ed4..4cb291e 100644 --- a/server/src/model/events/eventRunLog.db.js +++ b/server/src/model/events/eventRunLog.db.js @@ -40,6 +40,13 @@ const KINDS = [ 'phase.gate', // a phase opened an advance gate, with what it waits for 'condition.evaluated', // a firing was tested against a gate, matched or not 'phase.advanced', // a gate opened: on a firing, on its deadline, or forced + // Phase 6's three. `step.refused` is the one worth naming separately from + // `step.status`: a refusal is not a failure, and an operator reading a run that + // stopped needs to see at a glance that nothing is broken -- the deployment + // simply does not permit what the author asked for. + 'run.budget', // the caps this run was seeded with, and which switch set each + 'step.refused', // a step was not permitted: disabled, or over a cap + 'version.verified', // a dry run passed against a version, unlocking scheduled starts ] const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) } diff --git a/server/src/model/events/eventRuns.model.js b/server/src/model/events/eventRuns.model.js index 9ee853e..5a41f57 100644 --- a/server/src/model/events/eventRuns.model.js +++ b/server/src/model/events/eventRuns.model.js @@ -25,6 +25,9 @@ const gatesDb = require('./eventPhaseGates.db') const gates = require('../../events/gates') const definitionsDb = require('./eventDefinitions.db') const versionsDb = require('./eventVersions.db') +const settingsDb = require('./eventActionSettings.db') +const budgetDb = require('./eventRunBudget.db') +const authorize = require('../../events/authorize') const MAX_SCOPE = 190 @@ -85,6 +88,22 @@ async function create( return { ok: false, status: 409, errors: ['the published version has no phases'] } } + // §K's last bound, enforced for SCHEDULED starts only (org lead, 2026-09-03): + // *"a scheduled definition that has never been verified is the case worth + // refusing to start"*. An admin pressing start is watching, and that human IS + // the review the gate exists to require — so the gate falls on the path where + // nobody is. A version is immutable, so a dry run that passed against it stays + // true, which is what makes the pass a property of the version rather than + // something re-earned every occurrence. + if (source === 'schedule' && !version.verified_at) { + return { + ok: false, + status: 409, + code: 'unverified', + errors: ['this version has not been verified, so it will not start unattended'], + } + } + const scopeValue = String(scope || '').slice(0, MAX_SCOPE) const when = scheduledFor ? new Date(scheduledFor) : new Date() if (Number.isNaN(when.getTime())) { @@ -130,6 +149,32 @@ async function create( }, }) + // The run's budget, seeded from EVERY phase's steps rather than from the first + // one's (Phase 6). The version is pinned and immutable, so all of its steps are + // knowable now — and a budget that grew as phases were entered would let a + // phase-1 step spend a cap that a phase-3 step was going to need, which is the + // opposite of a per-run bound. The caps are copied here, so an admin moving a + // switch tomorrow does not change what a run already in flight is allowed. + const allSteps = version.spec.phases.flatMap((p) => + (p.steps || []).map((s) => ({ actionId: s.actionId, params: s.params || {} })), + ) + const settingsByAction = await settingsDb.byIds(allSteps.map((s) => s.actionId)) + const budget = authorize.effectiveCaps(allSteps, settingsByAction) + if (Object.keys(budget).length) { + await budgetDb.seed(runId, budget) + await logDb.write({ + runId, + kind: 'run.budget', + detail: { + dimensions: Object.entries(budget).map(([dimension, d]) => ({ + dimension, + cap: d.cap, + from: d.from, + })), + }, + }) + } + // The first phase's steps, materialised at creation rather than at start. // Phase 2 materialises each LATER phase as the run enters it; doing the first // one here is what makes a Phase 1 run row inspectable — an operator can see @@ -159,13 +204,29 @@ async function create( async function detail(runId) { const run = await db.getById(runId) if (!run) return null - const [steps, counts, gateRows] = await Promise.all([ + const [steps, counts, gateRows, budget] = await Promise.all([ stepsDb.listForRun(runId), stepsDb.statusCounts(runId), gatesDb.listForRun(runId), + budgetDb.forRun(runId), ]) const now = new Date() - return { run, steps, counts, gates: gateRows.map((g) => gates.describe(g, now)) } + return { + run, + steps, + counts, + gates: gateRows.map((g) => gates.describe(g, now)), + // The meter, as rows rather than as a sentence: a cap is two numbers and a + // name, and unlike a gate it needs no grammar rendered to be read. `cap: + // null` is uncapped and the client says so — a dimension the run counts but + // nothing bounds. + budget: budget.map((b) => ({ + dimension: b.dimension, + consumed: b.consumed, + cap: b.cap, + from: b.effective_from, + })), + } } module.exports = { create, detail, renderConcurrencyKey } diff --git a/server/src/model/events/eventVersions.db.js b/server/src/model/events/eventVersions.db.js index c77c207..3eed11d 100644 --- a/server/src/model/events/eventVersions.db.js +++ b/server/src/model/events/eventVersions.db.js @@ -51,4 +51,24 @@ const insert = async (definitionId, version, spec, userId) => { return result.insertId } -module.exports = { listForDefinition, getById, nextVersion, insert } +/** + * Record that a dry run passed against this version (Phase 6). + * + * A version is immutable in every respect that describes the EVENT — its spec, + * its number, who published it. These two columns describe something that + * happened to it afterwards, which is why they can be written at all: a pass is + * a fact about a review, not a change to the plan reviewed. + * + * Deliberately not idempotent-checked: verifying twice stamps the second one, and + * the later reviewer is the more useful answer to "who last looked at this + * before it ran unattended". + */ +const markVerified = async (id, userId, at = new Date()) => { + const result = await query( + 'UPDATE event_versions SET verified_at = ?, verified_by = ? WHERE id = ?', + [at, userId, id], + ) + return (result.affectedRows || 0) > 0 +} + +module.exports = { listForDefinition, getById, nextVersion, insert, markVerified } diff --git a/server/src/router/v1/admin/events.controller.js b/server/src/router/v1/admin/events.controller.js index 32b1a5e..5d7c291 100644 --- a/server/src/router/v1/admin/events.controller.js +++ b/server/src/router/v1/admin/events.controller.js @@ -32,6 +32,8 @@ const runs = require('../../../model/events/eventRuns.model') const controls = require('../../../model/events/eventRunControls.model') const logDb = require('../../../model/events/eventRunLog.db') const activity = require('../../../model/activity/activity.model') +const settingsDb = require('../../../model/events/eventActionSettings.db') +const authorize = require('../../../events/authorize') const asId = (raw) => { const n = Number(raw) @@ -57,6 +59,10 @@ const shapeDefinition = (d) => ({ state: d.state, currentVersionId: d.current_version_id, currentVersion: d.current_version, + // §K's gate, rendered where it can still be acted on. `null` on a draft -- + // there is no version to have verified -- and a date once a dry run has passed + // against the published one. + currentVersionVerifiedAt: d.current_version_verified_at || null, seriesId: d.series_id, seriesName: d.series_name, seriesOrder: d.series_order, @@ -289,6 +295,11 @@ exports.getRun = async (req, res) => { // `eventRuns.model.detail` for why the sentence is built here and not in // the browser. gates: found.gates, + // The caps this run was given and what it has spent (Phase 6). Copied into + // the run when it was created, so it answers "what was THIS run allowed" + // rather than "what is allowed now" — which is the question that survives + // an admin moving a switch tomorrow. + budget: found.budget, }) } @@ -342,7 +353,7 @@ exports.listVersions = async (req, res) => { /** POST /api/v1/admin/events */ exports.create = async (req, res) => { - const result = await definitions.create(req.body, req.user.id) + const result = await definitions.create(req.body, req.user.id, { role: req.user.role }) if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors }) await activity.log({ req, @@ -356,7 +367,7 @@ exports.create = async (req, res) => { exports.update = async (req, res) => { const id = asId(req.params.id) if (!id) return res.status(400).json({ error: 'bad event id' }) - const result = await definitions.save(id, req.body, req.user.id) + const result = await definitions.save(id, req.body, req.user.id, { role: req.user.role }) if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors }) await activity.log({ req, @@ -388,6 +399,139 @@ exports.publish = async (req, res) => { }) } +/** + * POST /api/v1/admin/events/:id/verify — the dry run. + * + * `admin, editor` rather than `admin` (§ API surface): a dry run dispatches + * nothing and changes nothing, and the author who wrote the definition is + * exactly who should be able to price it against the caps before asking an + * admin to publish it. + * + * **A report with findings is a 200, not a 400.** The request succeeded; the + * plan has problems. Answering 4xx would make "this event asks for 45 creatures + * and you allow 30" indistinguishable to the client from "you sent a bad event + * id", and the whole value of the screen is rendering the findings. + */ +exports.verify = async (req, res) => { + const id = asId(req.params.id) + if (!id) return res.status(400).json({ error: 'bad event id' }) + const result = await definitions.verify(id, req.user) + if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors }) + await activity.log({ + req, + action: 'event.definition.verified', + detail: { + id, + target: result.target, + versionId: result.versionId, + passed: result.report.ok, + findings: result.report.findings.length, + }, + }) + return res.json({ + target: result.target, + versionId: result.versionId, + version: result.version, + recorded: result.recorded, + report: result.report, + }) +} + +/** + * GET /api/v1/admin/events/actions — the deployment's switchboard. + * + * Every registered action, each with the deployment's stored opinion of it or, + * where there is none, **the default its risk class implies**. The default is + * computed by `authorize.isEnabled` rather than here, because a screen that + * worked out the posture for itself would be a second copy of the posture, and + * the copy that drifts is always the one on the screen. + * + * `configured` says whether a row exists, which the client needs to tell "an + * admin turned this on" from "this has always been on" — the same fact, arrived + * at two ways, and only one of them is a decision somebody made. + */ +exports.actions = async (_req, res) => { + const all = registries.allEventActions() + const stored = await settingsDb.byIds(all.map((a) => a.id)) + return res.json({ + actions: all.map((a) => { + const row = stored.get(a.id) || null + const full = registries.eventAction(a.id) + return { + ...a, + enabled: authorize.isEnabled(full, row), + configured: Boolean(row), + changesWorld: authorize.changesWorld(full), + // The dimensions this action can spend, so the screen can offer a cap + // box per dimension. Discovered by pricing the action's own declared + // examples until §F's `registerEventBudgets` lands in Phase 7 — see + // `authorize.dimensionsOf`. + dimensions: authorize.dimensionsOf(full), + caps: row?.caps || {}, + updatedAt: row?.updated_at || null, + updatedBy: row?.updated_by_username || null, + } + }), + // The rule the screen explains to the operator, served rather than written + // into the client twice. + worldChangingRisks: authorize.WORLD_CHANGING, + }) +} + +/** + * PUT /api/v1/admin/events/actions — set one action's switch and caps. + * + * One action per request rather than the whole board: the board is rendered from + * the registry and a whole-board PUT would have to say what an action MISSING + * from the body means. On a screen listing what is registered right now, that is + * "a module booted between the GET and the PUT", and answering it by writing a + * default over an admin's stored choice is the kind of quiet data loss a sparse + * write does not have. + */ +exports.saveAction = async (req, res) => { + const actionId = String(req.body?.actionId || '') + const action = registries.eventAction(actionId) + if (!action) return res.status(404).json({ error: 'no module registers that action' }) + + if (typeof req.body?.enabled !== 'boolean') { + return res.status(400).json({ error: 'enabled must be true or false' }) + } + + // Caps are validated against the dimensions this action can actually spend. + // A cap on a dimension it never names is not a harmless extra row — it is a + // number an operator believes is protecting them, on a screen that would + // render it back to them forever, bounding nothing. + const known = new Set(authorize.dimensionsOf(action)) + const caps = {} + for (const [dimension, raw] of Object.entries(req.body?.caps || {})) { + if (raw === null || raw === '') continue + if (!known.has(dimension)) { + return res.status(400).json({ error: `"${action.id}" does not spend "${dimension}"` }) + } + const n = Number(raw) + if (!Number.isInteger(n) || n < 0) { + return res.status(400).json({ error: `the cap for "${dimension}" must be a whole number of 0 or more` }) + } + caps[dimension] = n + } + + const row = await settingsDb.put(actionId, { enabled: req.body.enabled, caps }, req.user.id) + await activity.log({ + req, + action: 'event.action.configured', + detail: { actionId, enabled: Boolean(req.body.enabled), caps }, + }) + return res.json({ + action: { + id: actionId, + enabled: Boolean(row.enabled), + configured: true, + caps: row.caps, + updatedAt: row.updated_at, + }, + }) +} + /** DELETE /api/v1/admin/events/:id — archive, never a hard delete */ exports.archive = async (req, res) => { const id = asId(req.params.id) diff --git a/server/src/router/v1/admin/events.router.js b/server/src/router/v1/admin/events.router.js index d7673de..7699553 100644 --- a/server/src/router/v1/admin/events.router.js +++ b/server/src/router/v1/admin/events.router.js @@ -13,10 +13,12 @@ // gate nobody notices was missing. // // Reads are staff-wide. The live run controls landed in Phase 3 and are `admin` -// + `moderator`, deliberately wider than start (§N2). `verify` (admin, editor), -// `advance`, `cleanup` and the action switchboard are still absent rather than -// stubbed — there is no advance condition until Phase 5, no resource ledger -// until Phase 8 and no caps to price against until Phase 6. +// + `moderator`, deliberately wider than start (§N2). `advance` arrived in Phase +// 5; **`verify` and the action switchboard arrived in Phase 6** — `verify` at +// `admin, editor` because a dry run dispatches nothing, and both halves of +// `/actions` at `admin`, because §K puts the switchboard in the same row as the +// world-changing actions it governs. `cleanup` is still absent rather than +// stubbed: there is no resource ledger until Phase 8. // // **Literal paths are declared before `/:id`**, so `/catalog`, `/series`, // `/calendar` and `/runs` are never read as an event id. @@ -52,6 +54,42 @@ eventsRouter.get( controller.catalog, ) +// ── The switchboard (Phase 6) ────────────────────────────────────────────── +// +// A literal path, so it is declared up here with `/catalog` rather than beside +// the definition routes -- `/:id` would otherwise read `actions` as an event id. +// Both halves are `adminOnly`: §K puts the action switchboard in the same row as +// the world-changing actions it governs, because deciding what a deployment may +// do at all is configuration that can break things, which is exactly the line +// module-uo's split already draws. + +eventsRouter.get( + '/actions', + // #swagger.tags = ['Admin · Events'] + // #swagger.summary = 'Which actions are enabled on this deployment, and their per-run caps' + // #swagger.description = 'The deployment switchboard (EVENTS.md K, Phase 6). One entry per action registered on THIS boot, each carrying the deployment stored opinion of it or, where there is none, the default its risk class implies: change and irreversible actions arrive disabled, notify and inspect arrive enabled. `configured` says whether a row exists at all, which is how the screen tells "an admin turned this on" from "this has always been on". `dimensions` is what the action can spend, so the screen can offer one cap box per dimension. Nothing is seeded at boot: a deployment that has never opened this screen has no rows and behaves correctly.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Every registered action with its switch, its caps and the dimensions it can spend', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", items: { type: "object", additionalProperties: true } }, worldChangingRisks: { type: "array", items: { type: "string" } } } } } } } */ + /* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + controller.actions, +) + +eventsRouter.put( + '/actions', + // #swagger.tags = ['Admin · Events'] + // #swagger.summary = 'Enable or disable one action, and set its per-run caps' + // #swagger.description = 'One action per request rather than the whole board, because the board is rendered from the registry and a whole-board write would have to decide what an action missing from the body means -- on a screen listing what is registered right now that is "a module booted between the read and the write", and writing a default over an admin stored choice is quiet data loss. A cap must name a dimension the action actually spends: a cap on a dimension it never names would be a number an operator believes is protecting them while it bounds nothing. Caps are copied into a run budget when the run is created, so moving a switch never changes what a run already in flight is allowed.' + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["actionId", "enabled"], properties: { actionId: { type: "string", example: "core.announce" }, enabled: { type: "boolean", example: true }, caps: { type: "object", additionalProperties: { type: "integer" }, example: { "uo.creatures": 30 } } } } } } } */ + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The stored setting', content: { "application/json": { schema: { type: "object", properties: { action: { type: "object", additionalProperties: true } } } } } } */ + /* #swagger.responses[400] = { description: 'enabled is missing, or a cap names a dimension this action does not spend', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'No module registers that action', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + controller.saveAction, +) + eventsRouter.get( '/series', // #swagger.tags = ['Admin · Events'] @@ -343,6 +381,20 @@ eventsRouter.get( controller.listVersions, ) +eventsRouter.post( + '/:id/verify', + // #swagger.tags = ['Admin · Events'] + // #swagger.summary = 'Dry run: dispatch every step with verify true, change nothing, and report the cost against the caps' + // #swagger.description = 'EVENTS.md I. admin AND editor rather than admin, deliberately: a dry run dispatches nothing, and the author who wrote the definition is exactly who should be able to price it before asking an admin to publish it. What is verified follows the state -- a ready definition is checked against its PUBLISHED version, which is the only thing that ever actually runs, and a draft against the working spec the author is still holding; `target` says which. A pass against a version is RECORDED on it, and that is EVENTS.md K last bound: a scheduled occurrence of a version that has never been verified is held rather than started unattended. Findings come back with a 200 -- the request succeeded, the plan has problems -- and the whole-plan cost check is the one thing no other path makes: three steps each spawning 15 under a cap of 30 pass every individual check and breach it on the third, at two in the morning, with the world half-changed.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The report: findings per step, and the total cost per budget dimension', content: { "application/json": { schema: { type: "object", properties: { target: { type: "string", example: "version" }, versionId: { type: "integer" }, version: { type: "integer" }, recorded: { type: "boolean" }, report: { type: "object", properties: { ok: { type: "boolean" }, steps: { type: "integer" }, findings: { type: "array", items: { type: "object", additionalProperties: true } }, cost: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } } } */ + /* #swagger.responses[404] = { description: 'No such definition', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[409] = { description: 'The definition is archived, or has no phases to verify', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */ + /* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOrEditor, + controller.verify, +) + eventsRouter.post( '/:id/publish', // #swagger.tags = ['Admin · Events'] diff --git a/server/src/utils/eventRunner.js b/server/src/utils/eventRunner.js index 0ae0c5b..d4ef550 100644 --- a/server/src/utils/eventRunner.js +++ b/server/src/utils/eventRunner.js @@ -69,6 +69,7 @@ const gates = require('../events/gates') const spec = require('../events/spec') const registries = require('../modules/registries') const { dispatchStep } = require('../events/dispatch') +const authorize = require('../events/authorize') const log = require('./logger')('event-runner') const POLL_MS = Number(process.env.EVENT_POLL_MS) || 15_000 @@ -141,14 +142,30 @@ function leaseFor(step, now) { // `skipped` is reserved for a step a human skipped from the run console (Phase // 3) — a status that meant both "nobody ran this" and "this failed and we moved // on" would make the run console's summary line unreadable. -async function applyFailure(run, step, error) { - await stepsDb.finish(step.id, 'failed', error) +async function applyFailure(run, step, error, { status = 'failed', kind = 'step.status', extra = null } = {}) { + // **`refused` shares this whole function with `failed`, and that is decision 3 + // of Phase 6** (org lead, 2026-09-03): a cap breach or a disabled action takes + // the same disposition a terminal failure takes, so a `change` step's default + // `pause` stops the run where it stands and an operator raises the cap, edits, + // and resumes. What differs is the two words on the record — the STATUS the + // step ends in and the KIND the log line carries — because "nothing here is + // broken, this deployment does not permit that" is a different sentence from + // "the shard did not answer", and an operator reading a stopped run at 2am + // needs to tell them apart at a glance. + await stepsDb.finish(step.id, status, error) await logDb.write({ runId: run.id, stepId: step.id, - kind: 'step.status', + kind, phase: step.phase, - detail: { to: 'failed', action: step.action_id, attempts: step.attempts + 1, onFailure: step.on_failure, error }, + detail: { + to: status, + action: step.action_id, + attempts: step.attempts + 1, + onFailure: step.on_failure, + error, + ...(extra || {}), + }, }) // A run that lost a step is degraded whatever happens next. Health is not @@ -200,6 +217,50 @@ async function applyFailure(run, step, error) { async function drainStep(run, step, now, carry = {}) { if (!(await stepsDb.claim(step.id, OWNER, leaseFor(step, now), now))) return 'taken' + // ── The permission check, and it is the LAST thing before the dispatch ── + // + // §K's four layers behind one function (Phase 6). It sits after the claim, not + // before it: the cap is held by a conditional UPDATE and two ticks that both + // priced a step before either claimed it would both spend. It sits before the + // dispatch because a refusal means the action does not happen — nothing is + // sent, nothing is created, and the step never reaches the module at all. + // + // **`user` is null here, and that is the design rather than an omission.** The + // role was checked when a human published the version and again when a human + // or the scheduler started the run; a run in flight is deliberately not + // re-gated against its starter's current role, because demoting an admin at + // midnight should not silently strand every event they started. Cancel is the + // control for a run that should stop. + // + // **A retry does not pay twice.** The spend happens on the first attempt only. + // A retry re-dispatches the same idempotent operation against the same key, and + // charging a cap for a flaky socket would exhaust a deployment's allowance + // through unreliability rather than through effect. The corollary is that a + // step which spent and then failed for good keeps its spend: the attempt may + // have half-run, and a refund would be core asserting that it did not. + const action = registries.eventAction(step.action_id) + if (action) { + const verdict = await authorize.mayInvoke({ + action, + params: step.params || {}, + run, + spend: step.attempts === 0, + }) + if (!verdict.ok) { + return applyFailure(run, step, verdict.reason, { + status: 'refused', + kind: 'step.refused', + extra: { + code: verdict.code, + dimension: verdict.dimension, + requested: verdict.requested, + cap: verdict.cap, + consumed: verdict.consumed, + }, + }) + } + } + const result = await dispatchStep(step, { run }) if (result.actionVersionDrift) { @@ -572,6 +633,7 @@ async function processRun(run, now = new Date()) { async function expandSchedules(now) { const definitions = await definitionsDb.findSchedulable() let created = 0 + const heldUnverified = new Set() for (const definition of definitions) { const schedule = definition.version_spec?.schedule @@ -610,7 +672,27 @@ async function expandSchedules(now) { { scope: '', scheduledFor: occurrence.at, source: 'schedule' }, null, ) - if (!result.ok || !result.created) continue + if (!result.ok) { + if (result.code === 'unverified') { + // §K's gate, and it must not be silent. There is no run row to hang + // a diagnostic line on — that is the point, nothing was created — so + // it is said once per definition per tick rather than once per + // occurrence, and the admin surface says it where the author is + // looking: a `ready` definition carries `versionVerified: false` and + // the editor shows the one button that clears it. + if (!heldUnverified.has(definition.id)) { + heldUnverified.add(definition.id) + log.warn('scheduled occurrences held: the published version has never been verified', { + definition: definition.id, + title: definition.title, + version: definition.current_version_id, + }) + } + break + } + continue + } + if (!result.created) continue created += 1 if (occurrence.adjusted) { // Why the clock reads oddly, recorded where an operator will look for diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index ae8fcd8..46319ca 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -3534,6 +3534,156 @@ } } }, + "/api/v1/admin/events/actions": { + "get": { + "tags": [ + "Admin · Events" + ], + "summary": "Which actions are enabled on this deployment, and their per-run caps", + "description": "The deployment switchboard (EVENTS.md K, Phase 6). One entry per action registered on THIS boot, each carrying the deployment stored opinion of it or, where there is none, the default its risk class implies: change and irreversible actions arrive disabled, notify and inspect arrive enabled. `configured` says whether a row exists at all, which is how the screen tells \"an admin turned this on\" from \"this has always been on\". `dimensions` is what the action can spend, so the screen can offer one cap box per dimension. Nothing is seeded at boot: a deployment that has never opened this screen has no rows and behaves correctly.", + "responses": { + "200": { + "description": "Every registered action with its switch, its caps and the dimensions it can spend", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "actions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "worldChangingRisks": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "403": { + "description": "Not an admin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "tags": [ + "Admin · Events" + ], + "summary": "Enable or disable one action, and set its per-run caps", + "description": "One action per request rather than the whole board, because the board is rendered from the registry and a whole-board write would have to decide what an action missing from the body means -- on a screen listing what is registered right now that is \"a module booted between the read and the write\", and writing a default over an admin stored choice is quiet data loss. A cap must name a dimension the action actually spends: a cap on a dimension it never names would be a number an operator believes is protecting them while it bounds nothing. Caps are copied into a run budget when the run is created, so moving a switch never changes what a run already in flight is allowed.", + "responses": { + "200": { + "description": "The stored setting", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "action": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "400": { + "description": "enabled is missing, or a cap names a dimension this action does not spend", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not an admin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "No module registers that action", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "actionId", + "enabled" + ], + "properties": { + "actionId": { + "type": "string", + "example": "core.announce" + }, + "enabled": { + "type": "boolean", + "example": true + }, + "caps": { + "type": "object", + "additionalProperties": { + "type": "integer" + }, + "example": { + "uo.creatures": 30 + } + } + } + } + } + } + } + } + }, "/api/v1/admin/events/calendar": { "get": { "tags": [ @@ -3708,10 +3858,10 @@ "Admin · Events" ], "summary": "List every registered event action, with its param schema, risk class and reversibility", - "description": "Served from the module registries, not from a table: an action is declared in code by core or by an installed module, so this is whatever registered on this boot, and an uninstalled module simply stops appearing. Core always declares core.announce, core.wait and core.cue. Also carries the closed vocabularies the authoring form renders — risk classes, reversibility classes, param types, failure dispositions and the spec size limits — so the editor offers exactly the set the save path checks against.", + "description": "Served from the module registries, not from a table: an action is declared in code by core or by an installed module, so this is whatever registered on this boot, and an uninstalled module simply stops appearing. Core always declares core.announce, core.wait and core.cue. Also carries the closed vocabularies the authoring form renders — risk classes, reversibility classes, param types, failure dispositions and the spec size limits — so the editor offers exactly the set the save path checks against. Phase 5 added `triggers` and `operators`: the trigger catalog a module already ships IS the catalog of things a phase can advance on, and it is served here rather than borrowed from /admin/engagement/triggers because that route is admin-only while an event definition is authored by admin AND editor. Each trigger is reduced to its id, label and declared variables — a trigger", "responses": { "200": { - "description": "The registered actions and the vocabularies over them", + "description": "The registered actions and triggers, and the vocabularies over them", "content": { "application/json": { "schema": { @@ -3724,6 +3874,20 @@ "additionalProperties": true } }, + "triggers": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "operators": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, "risks": { "type": "array", "items": { @@ -3758,6 +3922,12 @@ "type": "string" } }, + "advanceKinds": { + "type": "array", + "items": { + "type": "string" + } + }, "limits": { "type": "object", "additionalProperties": true @@ -5504,6 +5674,126 @@ } } }, + "/api/v1/admin/events/{id}/verify": { + "post": { + "tags": [ + "Admin · Events" + ], + "summary": "Dry run: dispatch every step with verify true, change nothing, and report the cost against the caps", + "description": "EVENTS.md I. admin AND editor rather than admin, deliberately: a dry run dispatches nothing, and the author who wrote the definition is exactly who should be able to price it before asking an admin to publish it. What is verified follows the state -- a ready definition is checked against its PUBLISHED version, which is the only thing that ever actually runs, and a draft against the working spec the author is still holding; `target` says which. A pass against a version is RECORDED on it, and that is EVENTS.md K last bound: a scheduled occurrence of a version that has never been verified is held rather than started unattended. Findings come back with a 200 -- the request succeeded, the plan has problems -- and the whole-plan cost check is the one thing no other path makes: three steps each spawning 15 under a cap of 30 pass every individual check and breach it on the third, at two in the morning, with the world half-changed.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The report: findings per step, and the total cost per budget dimension", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "target": { + "type": "string", + "example": "version" + }, + "versionId": { + "type": "integer" + }, + "version": { + "type": "integer" + }, + "recorded": { + "type": "boolean" + }, + "report": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "steps": { + "type": "integer" + }, + "findings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "cost": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Not an admin or editor", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "No such definition", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "The definition is archived, or has no phases to verify", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/admin/events/{id}/versions": { "get": { "tags": [ diff --git a/server/test/eventAuthorize.test.js b/server/test/eventAuthorize.test.js new file mode 100644 index 0000000..40dad78 --- /dev/null +++ b/server/test/eventAuthorize.test.js @@ -0,0 +1,451 @@ +// ── mayInvoke: the whole authorisation decision (EVENTS_PLAN.md Phase 6) ─── +// +// §K asks for four layers behind ONE function — role, enablement, cap, and the +// shard's own switch — and says why: *"it is what makes an EM-style delegation +// model a later option rather than a redesign"*. This file is that function's +// contract, and it is worth stating what each test is actually protecting, +// because three of them protect a decision rather than a mechanism. +// +// • **The default-off line falls between `inspect` and `change`** (org lead, +// 2026-09-03). §K's sentence read literally would have shipped `core.wait` +// disabled — it is `risk: 'inspect'` — so every published event that waits +// would break on a fresh deployment. The same line is the role floor. +// • **The tightest cap wins.** `event_action_settings.caps` is per action while +// `event_run_budget` is one row per dimension, so two actions spending +// `uo.creatures` must agree on one number. +// • **An unpriceable action is refused, not free.** A `cost()` that throws is +// an action whose own accounting is broken, and reading that as zero would +// make the broken one the only one nothing bounds. +// +// The registry is the REAL one, staged and applied the way a module does it, for +// `eventRunner.test.js`'s reason: an action that would not register is not one +// this function has to survive. + +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, beforeEach, afterEach, after } = require('node:test') +const assert = require('node:assert/strict') + +const registries = require('../src/modules/registries') +const authorize = require('../src/events/authorize') +const settingsDb = require('../src/model/events/eventActionSettings.db') +const budgetDb = require('../src/model/events/eventRunBudget.db') +const db = require('../src/utils/db') + +after(() => db.close()) + +const originals = { settings: { ...settingsDb }, budget: { ...budgetDb } } + +let store + +beforeEach(() => { + registries._reset() + store = { settings: new Map(), budget: new Map() } + + settingsDb.get = async (id) => store.settings.get(id) || null + settingsDb.byIds = async (ids) => + new Map([...new Set(ids || [])].filter((i) => store.settings.has(i)).map((i) => [i, store.settings.get(i)])) + budgetDb.forRun = async (runId) => + [...store.budget.values()].filter((b) => Number(b.run_id) === Number(runId)) + budgetDb.spend = async (runId, dimension, amount) => { + if (!(amount > 0)) return true + const row = store.budget.get(`${runId}:${dimension}`) + if (!row) return false + if (row.cap !== null && row.consumed + amount > row.cap) return false + row.consumed += amount + return true + } + budgetDb.refund = async (runId, dimension, amount) => { + const row = store.budget.get(`${runId}:${dimension}`) + if (row && amount > 0) row.consumed = Math.max(row.consumed - amount, 0) + } +}) + +afterEach(() => { + Object.assign(settingsDb, originals.settings) + Object.assign(budgetDb, originals.budget) + registries._reset() +}) + +const register = (entries, owner = 'test') => { + const api = registries.stage(owner) + api.registerEventActions(entries) + registries.apply(api.staged) +} + +const action = (id, over = {}) => ({ + id, + label: over.label || id, + risk: over.risk || 'notify', + reversible: over.reversible || 'none', + params: over.params || [], + ...(over.cost ? { cost: over.cost } : {}), + async perform() { + return { ok: true } + }, +}) + +const setSetting = (id, enabled, caps = {}) => + store.settings.set(id, { action_id: id, enabled: enabled ? 1 : 0, caps }) + +const setBudget = (runId, dimension, { consumed = 0, cap = null } = {}) => + store.budget.set(`${runId}:${dimension}`, { run_id: runId, dimension, consumed, cap }) + +const RUN = { id: 1, scope: '' } +const ADMIN = { id: 1, role: 'admin' } +const EDITOR = { id: 2, role: 'editor' } +const MODERATOR = { id: 3, role: 'moderator' } + +// ── Layer 2: the role floor ──────────────────────────────────────────────── + +test('the role floor falls between inspect and change, not between notify and inspect', async () => { + // The decision, held as a test rather than as a comment. Read §K's sentence + // literally and an editor cannot author a step that WAITS, which is an + // authoring role that cannot author. + register([ + action('test.tell', { risk: 'notify' }), + action('test.look', { risk: 'inspect' }), + action('test.change', { risk: 'change' }), + action('test.burn', { risk: 'irreversible' }), + ]) + + for (const id of ['test.tell', 'test.look']) { + const v = await authorize.mayInvoke({ user: EDITOR, action: registries.eventAction(id) }) + assert.equal(v.ok, true, `${id} should be open to an editor`) + } + for (const id of ['test.change', 'test.burn']) { + const v = await authorize.mayInvoke({ user: EDITOR, action: registries.eventAction(id) }) + assert.equal(v.ok, false) + assert.equal(v.code, 'role') + assert.match(v.reason, /only an administrator/) + } +}) + +test('a moderator is no more able to invoke a world-changing action than an editor', async () => { + // The moderator's entire power over this feature is the run console (§K). The + // floor is `admin`, not "not a player". + register([action('test.change', { risk: 'change' })]) + setSetting('test.change', true) + const v = await authorize.mayInvoke({ user: MODERATOR, action: registries.eventAction('test.change') }) + assert.equal(v.code, 'role') +}) + +test('an admin passes the role layer for every risk class', async () => { + register([action('test.burn', { risk: 'irreversible' })]) + setSetting('test.burn', true) + const v = await authorize.mayInvoke({ user: ADMIN, action: registries.eventAction('test.burn') }) + assert.equal(v.ok, true) +}) + +test('a null user skips the role layer, because the runner is not a person', async () => { + // The unattended path. The role was checked when a human published the version + // and again when a human or the scheduler started the run; re-checking here + // would mean demoting an admin at midnight silently strands every event they + // started. + register([action('test.burn', { risk: 'irreversible' })]) + setSetting('test.burn', true) + const v = await authorize.mayInvoke({ action: registries.eventAction('test.burn') }) + assert.equal(v.ok, true) +}) + +// ── Layer 3a: enablement ─────────────────────────────────────────────────── + +test('nothing that changes the world is enabled by default, and everything else is', async () => { + register([ + action('test.tell', { risk: 'notify' }), + action('test.look', { risk: 'inspect' }), + action('test.change', { risk: 'change' }), + action('test.burn', { risk: 'irreversible' }), + ]) + const enabled = (id) => authorize.isEnabled(registries.eventAction(id), null) + assert.equal(enabled('test.tell'), true) + assert.equal(enabled('test.look'), true) + assert.equal(enabled('test.change'), false) + assert.equal(enabled('test.burn'), false) +}) + +test('a stored row beats the risk-class default in both directions', async () => { + // The switch is the operator's, and it has to be able to turn a `notify` action + // OFF as well as a `change` action on. An "enable only" switchboard would leave + // a deployment unable to stop an announcement it did not want. + register([action('test.tell', { risk: 'notify' }), action('test.change', { risk: 'change' })]) + setSetting('test.tell', false) + setSetting('test.change', true) + + const off = await authorize.mayInvoke({ action: registries.eventAction('test.tell') }) + assert.equal(off.ok, false) + assert.equal(off.code, 'disabled') + + const on = await authorize.mayInvoke({ action: registries.eventAction('test.change') }) + assert.equal(on.ok, true) +}) + +test('the refusal names the action by its LABEL, not its id', async () => { + // The reason is rendered to an operator on the run console. `"Spawn creatures" + // is not enabled on this deployment` is a sentence; the id is a slug. + register([action('test.change', { risk: 'change', label: 'Spawn creatures' })]) + const v = await authorize.mayInvoke({ action: registries.eventAction('test.change') }) + assert.match(v.reason, /"Spawn creatures" is not enabled/) +}) + +// ── Pricing ──────────────────────────────────────────────────────────────── + +test('cost() is called with the step params and its answer is what core enforces', async () => { + register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })]) + setSetting('test.spawn', true) + const priced = authorize.priceOf(registries.eventAction('test.spawn'), { count: 12 }) + assert.deepEqual(priced, { 'x.creatures': 12 }) +}) + +test('a cost() that throws makes the action unpriceable, never free', async () => { + register([ + action('test.spawn', { + risk: 'change', + cost: () => { + throw new Error('nope') + }, + }), + ]) + setSetting('test.spawn', true) + assert.equal(authorize.priceOf(registries.eventAction('test.spawn'), {}), null) + const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN }) + assert.equal(v.ok, false) + assert.equal(v.code, 'unpriceable') +}) + +test('a cost() answering a non-object, a negative or a NaN is unpriceable too', async () => { + register([ + action('test.a', { risk: 'change', cost: () => 5 }), + action('test.b', { risk: 'change', cost: () => ({ d: -1 }) }), + action('test.c', { risk: 'change', cost: () => ({ d: 'lots' }) }), + action('test.d', { risk: 'change', cost: () => [1, 2] }), + ]) + for (const id of ['test.a', 'test.b', 'test.c', 'test.d']) { + assert.equal(authorize.priceOf(registries.eventAction(id), {}), null, id) + } +}) + +test('a zero cost is dropped rather than becoming a dimension with no spend', async () => { + // A dimension present at 0 would be seeded as a budget row nothing ever draws + // on, and it would appear on the console's meter as "0 of 30" for a verb this + // event never uses. + register([action('test.spawn', { risk: 'change', cost: () => ({ a: 0, b: 3 }) })]) + assert.deepEqual(authorize.priceOf(registries.eventAction('test.spawn'), {}), { b: 3 }) +}) + +test('an action with no cost() costs nothing and never touches the budget', async () => { + register([action('test.tell')]) + const v = await authorize.mayInvoke({ action: registries.eventAction('test.tell'), run: RUN, spend: true }) + assert.equal(v.ok, true) + assert.deepEqual(v.cost, {}) + // No budget row exists for this run at all, and that is not a refusal: an + // action that spends nothing has nothing to be refused over. + assert.equal(store.budget.size, 0) +}) + +test('dimensions are discovered by pricing the declared examples', async () => { + // The Phase 6 stand-in for §F's `registerEventBudgets`, which arrives in Phase + // 7. Every param carries a required `example` precisely so a form has something + // to show, and pricing them is what lets the switchboard offer a cap box. + register([ + action('test.spawn', { + risk: 'change', + params: [{ name: 'count', type: 'int', required: true, example: 4 }], + cost: (p) => ({ 'x.creatures': p.count, 'x.bosses': 1 }), + }), + ]) + assert.deepEqual(authorize.dimensionsOf(registries.eventAction('test.spawn')), ['x.bosses', 'x.creatures']) +}) + +test('an action whose cost() cannot survive its own examples reports no dimensions', async () => { + // Honest rather than clever: the operator loses a cap box, and the RUN loses + // nothing, because a run's budget is seeded from the params its steps were + // actually authored with. + register([ + action('test.spawn', { + risk: 'change', + cost: (p) => ({ d: p.missing.count }), + }), + ]) + assert.deepEqual(authorize.dimensionsOf(registries.eventAction('test.spawn')), []) +}) + +// ── effectiveCaps: the tightest cap wins ─────────────────────────────────── + +test('two actions spending one dimension resolve to the tightest cap, and it says whose', async () => { + register([ + action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }), + action('test.horde', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }), + ]) + setSetting('test.spawn', true, { 'x.creatures': 30 }) + setSetting('test.horde', true, { 'x.creatures': 10 }) + + const caps = authorize.effectiveCaps( + [{ actionId: 'test.spawn', params: {} }, { actionId: 'test.horde', params: {} }], + await settingsDb.byIds(['test.spawn', 'test.horde']), + ) + assert.deepEqual(caps, { 'x.creatures': { cap: 10, from: 'test.horde' } }) +}) + +test('an action that declines to cap a dimension does not raise a ceiling another one set', async () => { + // `null` is uncapped and must never win a minimum. Otherwise adding a second + // verb to an event would silently remove the bound on the first. + register([ + action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }), + action('test.horde', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }), + ]) + setSetting('test.spawn', true, { 'x.creatures': 30 }) + setSetting('test.horde', true, {}) + + const caps = authorize.effectiveCaps( + [{ actionId: 'test.horde', params: {} }, { actionId: 'test.spawn', params: {} }], + await settingsDb.byIds(['test.spawn', 'test.horde']), + ) + assert.deepEqual(caps, { 'x.creatures': { cap: 30, from: 'test.spawn' } }) +}) + +test('a dimension nobody caps is still a row, uncapped', async () => { + // Seeded so the meter counts it. The distinction it preserves is that a MISSING + // row means something else entirely: a step spending a dimension its own run's + // version never priced. + register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) })]) + setSetting('test.spawn', true, {}) + const caps = authorize.effectiveCaps([{ actionId: 'test.spawn', params: {} }], await settingsDb.byIds(['test.spawn'])) + assert.deepEqual(caps, { 'x.creatures': { cap: null, from: null } }) +}) + +test('a step naming an unregistered action contributes no dimension', async () => { + assert.deepEqual(authorize.effectiveCaps([{ actionId: 'nobody.registers', params: {} }], new Map()), {}) +}) + +// ── Layer 3b: the cap ────────────────────────────────────────────────────── + +test('with no run, the question is whether the cost could EVER fit', async () => { + // The dry run's and the editor's question. A step asking for 40 under a cap of + // 30 is an authoring error answerable before anything is scheduled, which is + // the entire value of catching it here rather than at 2am. + register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })]) + setSetting('test.spawn', true, { 'x.creatures': 30 }) + + const bad = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), params: { count: 40 } }) + assert.equal(bad.ok, false) + assert.equal(bad.code, 'cap') + assert.equal(bad.dimension, 'x.creatures') + assert.equal(bad.requested, 40) + assert.equal(bad.cap, 30) + + const fine = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), params: { count: 30 } }) + assert.equal(fine.ok, true) +}) + +test('with a run and spend, the check IS the spend', async () => { + register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })]) + setSetting('test.spawn', true, { 'x.creatures': 30 }) + setBudget(RUN.id, 'x.creatures', { consumed: 0, cap: 30 }) + + const v = await authorize.mayInvoke({ + action: registries.eventAction('test.spawn'), + params: { count: 12 }, + run: RUN, + spend: true, + }) + assert.equal(v.ok, true) + assert.equal(v.spent, true) + assert.equal(store.budget.get('1:x.creatures').consumed, 12) +}) + +test('without spend the cap check is advisory and moves nothing', async () => { + register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })]) + setSetting('test.spawn', true, { 'x.creatures': 30 }) + setBudget(RUN.id, 'x.creatures', { consumed: 0, cap: 30 }) + + const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN }) + assert.equal(v.ok, true) + assert.equal(v.spent, undefined) + assert.equal(store.budget.get('1:x.creatures').consumed, 0) +}) + +test('a refusal names the numbers an operator needs, not just "refused"', async () => { + register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })]) + setSetting('test.spawn', true, { 'x.creatures': 30 }) + setBudget(RUN.id, 'x.creatures', { consumed: 28, cap: 30 }) + + const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true }) + assert.equal(v.ok, false) + assert.equal(v.code, 'cap') + assert.equal(v.consumed, 28) + assert.equal(v.cap, 30) + assert.equal(v.requested, 5) + assert.match(v.reason, /28 of 30 is already spent this run/) +}) + +test('a partial spend across dimensions is given back when a later one is refused', async () => { + // The property the whole multi-dimension path turns on. The spends must be + // separate statements — the atomicity that matters is per dimension — so a step + // costing creatures AND bosses can take the creatures and be refused the + // bosses, and a step that did not run must not have spent anything. + register([ + action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5, 'x.bosses': 2 }) }), + ]) + setSetting('test.spawn', true) + setBudget(RUN.id, 'x.creatures', { consumed: 0, cap: 30 }) + setBudget(RUN.id, 'x.bosses', { consumed: 1, cap: 2 }) + + const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true }) + assert.equal(v.ok, false) + assert.equal(v.dimension, 'x.bosses') + assert.equal(store.budget.get('1:x.creatures').consumed, 0, 'the creatures must have been given back') + assert.equal(store.budget.get('1:x.bosses').consumed, 1) +}) + +test('spending a dimension the run has no row for is refused, and says so in its own words', async () => { + // Fail-closed, and a distinct code: "this run has no budget for that" is a + // different diagnosis from "the cap is spent", and an operator who raises the + // cap in answer to the wrong one has not fixed anything. + register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.ghosts': 1 }) })]) + setSetting('test.spawn', true) + const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true }) + assert.equal(v.ok, false) + assert.equal(v.code, 'unbudgeted') + assert.match(v.reason, /no budget for/) +}) + +test('an uncapped budget row never refuses', async () => { + register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 999 }) })]) + setSetting('test.spawn', true) + setBudget(RUN.id, 'x.creatures', { consumed: 5, cap: null }) + const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true }) + assert.equal(v.ok, true) + assert.equal(store.budget.get('1:x.creatures').consumed, 1004) +}) + +// ── The layers are ordered ───────────────────────────────────────────────── + +test('the layers answer in order: role before enablement before cap', async () => { + // Not cosmetic. An editor told "that action is disabled" would go and ask an + // admin to enable it, and still not be allowed to author the step — the honest + // first answer is the role. + register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 99 }) })]) + // Disabled AND over cap AND world-changing, all at once. + const v = await authorize.mayInvoke({ + user: EDITOR, + action: registries.eventAction('test.spawn'), + run: RUN, + }) + assert.equal(v.code, 'role') + + setSetting('test.spawn', false, { 'x.creatures': 1 }) + const asAdmin = await authorize.mayInvoke({ + user: ADMIN, + action: registries.eventAction('test.spawn'), + run: RUN, + }) + assert.equal(asAdmin.code, 'disabled') +}) + +test('an action nobody registers is refused rather than thrown at', async () => { + const v = await authorize.mayInvoke({ action: null }) + assert.equal(v.ok, false) + assert.equal(v.code, 'unregistered') +}) diff --git a/server/test/eventRunner.test.js b/server/test/eventRunner.test.js index 1295e24..88bd6a7 100644 --- a/server/test/eventRunner.test.js +++ b/server/test/eventRunner.test.js @@ -37,6 +37,14 @@ const logDb = require('../src/model/events/eventRunLog.db') const versionsDb = require('../src/model/events/eventVersions.db') const definitionsDb = require('../src/model/events/eventDefinitions.db') const gatesDb = require('../src/model/events/eventPhaseGates.db') +// Phase 6 put a permission check in front of every dispatch, and it reads two +// tables. **For the third time in this feature, a leg the stubbing file did not +// know about is a ten-second ECONNREFUSED that says nothing about the route it +// was testing** — Phase 4's expansion leg and Phase 5's gate read did the same. +// The rule the three of them add up to: when the runner or a model gains a leg, +// every file that stubs the layer under it needs the stub. +const settingsDb = require('../src/model/events/eventActionSettings.db') +const budgetDb = require('../src/model/events/eventRunBudget.db') const gates = require('../src/events/gates') const db = require('../src/utils/db') @@ -50,7 +58,7 @@ const later = (ms) => new Date(T0.getTime() + ms) let store const originals = {} -for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], ['definitionsDb', definitionsDb], ['gatesDb', gatesDb]]) { +for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], ['definitionsDb', definitionsDb], ['gatesDb', gatesDb], ['settingsDb', settingsDb], ['budgetDb', budgetDb]]) { originals[name] = { mod, fns: { ...mod } } } @@ -69,6 +77,8 @@ function installStubs() { versions: new Map(), definitions: new Map(), gates: new Map(), + settings: new Map(), + budget: new Map(), nextStepId: 1, nextGateId: 1, } @@ -374,6 +384,46 @@ function installStubs() { Object.assign(g, { satisfied_at: now, satisfied_by: by, forced_by: userId }) return true } + + // ── Phase 6's two tables ── + // + // `store.settings` is empty by default, which is not a gap: an empty + // switchboard is what a fresh deployment HAS, and `authorize.isEnabled` then + // answers from the risk class. Every test in this file that does not set a + // switch is therefore exercising the default posture, which is the posture + // almost every deployment will run under. + settingsDb.get = async (actionId) => store.settings.get(actionId) || null + settingsDb.byIds = async (ids) => + new Map( + [...new Set(ids || [])] + .filter((id) => store.settings.has(id)) + .map((id) => [id, store.settings.get(id)]), + ) + + budgetDb.seed = async (runId, dimensions) => { + for (const [dimension, d] of Object.entries(dimensions || {})) { + const key = `${runId}:${dimension}` + if (store.budget.has(key)) continue + store.budget.set(key, { run_id: runId, dimension, consumed: 0, cap: d.cap, effective_from: d.from || null }) + } + return Object.keys(dimensions || {}).length + } + // The conditional increment, read the way the server reads it — the guard is + // evaluated against the PRE-update value, and a NULL cap is uncapped. + budgetDb.spend = async (runId, dimension, amount) => { + if (!(amount > 0)) return true + const row = store.budget.get(`${runId}:${dimension}`) + if (!row) return false + if (row.cap !== null && row.consumed + amount > row.cap) return false + row.consumed += amount + return true + } + budgetDb.refund = async (runId, dimension, amount) => { + const row = store.budget.get(`${runId}:${dimension}`) + if (row && amount > 0) row.consumed = Math.max(row.consumed - amount, 0) + } + budgetDb.forRun = async (runId) => + [...store.budget.values()].filter((b) => b.run_id === runId).sort((a, b) => a.dimension.localeCompare(b.dimension)) } // ── Fixtures ─────────────────────────────────────────────────────────────── @@ -1035,3 +1085,203 @@ test('re-entering a phase does not open a second gate', async () => { assert.equal(store.gates.size, 1) assert.equal(gateOf(id, 'one').entered_at, entered, 'and the deadline it was given does not move') }) + +// ── Phase 6: enablement and caps, in front of the dispatch ───────────────── +// +// The runner gained one thing this phase: it asks `mayInvoke` before it asks a +// module to do anything. What follows is the behaviour that produces, and the +// three properties that are decisions rather than mechanisms. + +const seedBudget = (runId, dimension, { consumed = 0, cap = null } = {}) => + store.budget.set(`${runId}:${dimension}`, { run_id: runId, dimension, consumed, cap, effective_from: null }) + +const budgetOf = (runId, dimension) => store.budget.get(`${runId}:${dimension}`) + +const setSwitch = (id, enabled, caps = {}) => + store.settings.set(id, { action_id: id, enabled: enabled ? 1 : 0, caps }) + +test('a disabled action is REFUSED, not failed, and the two look different on the record', async () => { + // Decision 3 (org lead, 2026-09-03): a refusal takes the same disposition a + // failure takes, and says a different thing. `refused` and `step.refused` are + // what let an operator reading a stopped run at 2am see at a glance that + // nothing is broken — the deployment simply does not permit what was asked. + register([scriptedAction('test.change', { risk: 'change', label: 'Change things' })]) + const id = seedRun([{ key: 'main', steps: [step('test.change')] }]) + + await runner.tick(T0) + + assert.equal(stepsOf(id)[0].status, 'refused') + assert.equal(stepsOf(id)[0].last_error, '"Change things" is not enabled on this deployment') + assert.ok(kinds(id).includes('step.refused')) + assert.ok(!kinds(id).includes('step.status'), 'a refusal is not a step.status line') + assert.equal(scripted['test.change'], undefined, 'a refused action is never dispatched') +}) + +test('a refusal follows the step’s on_failure, exactly as a failure does', async () => { + // The whole of decision 3. `change` defaults to `pause`, so the run stops where + // it stands and waits for a human to raise the cap or edit the plan. + register([scriptedAction('test.change', { risk: 'change' }), scriptedAction('test.after')]) + const id = seedRun([ + { key: 'main', steps: [step('test.change', {}, 'pause'), step('test.after')] }, + ]) + + await runner.tick(T0) + + assert.equal(run(id).status, 'paused') + assert.equal(run(id).health, 'degraded') + assert.equal(stepsOf(id)[1].status, 'pending', 'nothing after a pausing refusal runs') +}) + +test('a refusal with on_failure skip lets the run carry on, degraded', async () => { + register([scriptedAction('test.change', { risk: 'change' }), scriptedAction('test.after')]) + const id = seedRun([{ key: 'main', steps: [step('test.change', {}, 'skip'), step('test.after')] }]) + + await runner.tick(T0) + + assert.equal(stepsOf(id)[0].status, 'refused') + assert.equal(stepsOf(id)[1].status, 'done') + assert.equal(run(id).status, 'completed') + assert.equal(run(id).health, 'degraded') +}) + +test('an enabled world-changing action runs, because the switch is the whole gate', async () => { + // The other half of the default-off posture, and the one that proves the switch + // is read rather than the risk class being a refusal on its own. + register([scriptedAction('test.change', { risk: 'change' })]) + setSwitch('test.change', true) + const id = seedRun([{ key: 'main', steps: [step('test.change')] }]) + + await runner.tick(T0) + + assert.equal(stepsOf(id)[0].status, 'done') + assert.equal(run(id).status, 'completed') +}) + +test('a step over its cap is refused with the dimension and the numbers on the log line', async () => { + // "You asked for 40 and this deployment allows 30" is an authoring error, and + // it has to arrive as those words rather than as a stack trace. + register([ + scriptedAction('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) }), + ]) + setSwitch('test.spawn', true, { 'x.creatures': 30 }) + const id = seedRun([{ key: 'main', steps: [step('test.spawn', { count: 12 }, 'skip')] }]) + seedBudget(id, 'x.creatures', { consumed: 28, cap: 30 }) + + await runner.tick(T0) + + assert.equal(stepsOf(id)[0].status, 'refused') + const line = store.log.find((l) => l.runId === id && l.kind === 'step.refused') + assert.equal(line.detail.code, 'cap') + assert.equal(line.detail.dimension, 'x.creatures') + assert.equal(line.detail.requested, 12) + assert.equal(line.detail.cap, 30) + assert.equal(line.detail.consumed, 28) + assert.equal(budgetOf(id, 'x.creatures').consumed, 28, 'a refused step spends nothing') +}) + +test('two steps drawing on one cap spend it once each, and the second is refused when it will not fit', async () => { + // The stub's half of the plan's acceptance criterion. The SERVER's half — two + // spends arriving genuinely at once — is in `eventRunnerSql.test.js`, because + // the guard lives in a WHERE and a stub reproduces the reading rather than the + // server. + register([scriptedAction('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })]) + setSwitch('test.spawn', true, { 'x.creatures': 30 }) + const id = seedRun([ + { + key: 'main', + steps: [ + step('test.spawn', { count: 20 }, 'skip'), + step('test.spawn', { count: 20 }, 'skip'), + step('test.spawn', { count: 10 }, 'skip'), + ], + }, + ]) + seedBudget(id, 'x.creatures', { consumed: 0, cap: 30 }) + + await runner.tick(T0) + + const s = stepsOf(id) + assert.equal(s[0].status, 'done') + assert.equal(s[1].status, 'refused', 'the second 20 does not fit under 30') + assert.equal(s[2].status, 'done', 'and a later step that DOES fit still runs') + assert.equal(budgetOf(id, 'x.creatures').consumed, 30) +}) + +test('a retry does not pay the cap twice', async () => { + // The spend happens on the first attempt only. A retry re-dispatches the same + // idempotent operation against the same key, and charging a cap for a flaky + // socket would exhaust a deployment's allowance through unreliability rather + // than through effect. + register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })]) + setSwitch('test.spawn', true, { 'x.creatures': 30 }) + scripted['test.spawn'] = { calls: [], answers: [{ ok: false, retry: true, error: 'shard busy' }] } + + const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }]) + seedBudget(id, 'x.creatures', { consumed: 0, cap: 30 }) + + await runner.tick(T0) + assert.equal(stepsOf(id)[0].status, 'pending') + assert.equal(budgetOf(id, 'x.creatures').consumed, 5, 'the first attempt spends') + + // Past the retry backoff: the second attempt succeeds and must not spend again. + await runner.tick(later(61_000)) + assert.equal(stepsOf(id)[0].status, 'done') + assert.equal(budgetOf(id, 'x.creatures').consumed, 5, 'the retry must not pay twice') +}) + +test('a step that spent and then failed for good keeps its spend', async () => { + // The corollary, and it is deliberate: the attempt may have half-run, and a + // refund would be core asserting that it did not. + register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })]) + setSwitch('test.spawn', true, { 'x.creatures': 30 }) + scripted['test.spawn'] = { calls: [], answer: { ok: false, retry: false, error: 'no' } } + + const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }]) + seedBudget(id, 'x.creatures', { consumed: 0, cap: 30 }) + + await runner.tick(T0) + assert.equal(stepsOf(id)[0].status, 'failed') + assert.equal(budgetOf(id, 'x.creatures').consumed, 5) +}) + +test('a step spending a dimension its run has no budget row for is refused', async () => { + // Fail-closed. A run whose version names a costing action always has that + // dimension seeded — uncapped ones included, as a row with a NULL cap — so a + // missing row means the step is spending something its own version never + // declared. + register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.ghosts': 1 }) })]) + setSwitch('test.spawn', true) + const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }]) + + await runner.tick(T0) + + assert.equal(stepsOf(id)[0].status, 'refused') + const line = store.log.find((l) => l.runId === id && l.kind === 'step.refused') + assert.equal(line.detail.code, 'unbudgeted') +}) + +test('an uncapped dimension counts without ever refusing', async () => { + register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 99 }) })]) + setSwitch('test.spawn', true) + const id = seedRun([{ key: 'main', steps: [step('test.spawn'), step('test.spawn')] }]) + seedBudget(id, 'x.creatures', { consumed: 0, cap: null }) + + await runner.tick(T0) + + assert.deepEqual(stepsOf(id).map((s) => s.status), ['done', 'done']) + assert.equal(budgetOf(id, 'x.creatures').consumed, 198) +}) + +test('the runner never re-checks the role of whoever started the run', async () => { + // §K's "a demoted user loses access at once" is about reaching a ROUTE. A run + // already in flight is deliberately not re-gated against its starter's current + // role: demoting an admin at midnight must not silently strand every event they + // started. Cancel is the control for a run that should stop. + register([scriptedAction('test.burn', { risk: 'irreversible' })]) + setSwitch('test.burn', true) + const id = seedRun([{ key: 'main', steps: [step('test.burn')] }]) + + await runner.tick(T0) + + assert.equal(stepsOf(id)[0].status, 'done') +}) diff --git a/server/test/eventRunnerSql.test.js b/server/test/eventRunnerSql.test.js index 21de1cb..ac66978 100644 --- a/server/test/eventRunnerSql.test.js +++ b/server/test/eventRunnerSql.test.js @@ -196,6 +196,25 @@ CREATE TABLE event_run_phase_gates ( UNIQUE KEY uq_evgate_phase (run_id, phase), INDEX idx_evgate_open (trigger_id, satisfied_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE event_run_budget ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + run_id BIGINT NOT NULL, + dimension VARCHAR(96) NOT NULL, + consumed INT NOT NULL DEFAULT 0, + cap INT NULL, + effective_from VARCHAR(96) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_evbud_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE, + UNIQUE KEY uq_evbud_dim (run_id, dimension) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE event_action_settings ( + action_id VARCHAR(96) NOT NULL PRIMARY KEY, + enabled TINYINT(1) NOT NULL DEFAULT 0, + caps JSON NULL, + updated_by INT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ` // The statements under test, verbatim from `eventRuns.db.js` and @@ -1097,3 +1116,190 @@ test('a gate goes with its run', async (t) => { await pool.query('DELETE FROM event_runs WHERE id = ?', [runId]) assert.equal((await pool.query('SELECT * FROM event_run_phase_gates WHERE id = ?', [gateId])).length, 0) }) + +// ── Phase 6: the cap's conditional increment ─────────────────────────────── +// +// **The plan's own acceptance criterion**: *"two concurrent steps against one cap +// proving neither over-spends"*. It belongs here rather than beside the stub for +// the reason the gate's increment does — the guard lives in a WHERE that the +// server evaluates against the pre-update row, and a stub reproduces the reading +// rather than the server. Engagement's cooldown claim was green against its stub +// and always allowed the send, because the connector defaults `foundRows: true` +// and a no-op UPDATE reports 1: exactly the mistake this shape of statement +// invites, and the reason `spend()` reads `affectedRows` at all. +// +// The SET list has ONE assignment, and that is Phase 5's lesson applied rather +// than rediscovered: MariaDB evaluates SET assignments left to right with the +// values already assigned, so nothing in this statement may read `consumed` +// after writing it. The guard stays in the WHERE. + +// **Requiring a shipping model into THIS file starts a second pool**, and it is +// the only file in the suite where that matters. Everywhere else the environment +// points at a dead port and `utils/db`'s pool never connects; here it points at a +// live server, so the pool holds open connections and the process never exits — +// 49 green tests and a file that hangs until the harness kills it. Every other +// event test file already closes it in an `after`; this one now has a reason to. +const budgetDb = require('../src/model/events/eventRunBudget.db') +const appDb = require('../src/utils/db') +after(() => appDb.close()) + +const seedBudget = async (over = {}) => { + const { runId } = await seedRun({ status: 'running' }) + await pool.query( + 'INSERT INTO event_run_budget (run_id, dimension, consumed, cap, effective_from) VALUES (?, ?, ?, ?, ?)', + // `'cap' in over`, not `over.cap ?? 30` -- `null` is a MEANINGFUL cap here + // (uncapped) and `??` coalesces it straight back to 30, which made the + // uncapped test seed a capped row and fail against the correct statement. + [ + runId, + over.dimension ?? 'uo.creatures', + over.consumed ?? 0, + 'cap' in over ? over.cap : 30, + over.from ?? 'uo.creature.spawn', + ], + ) + return runId +} + +const consumedOf = async (runId, dimension = 'uo.creatures') => + Number( + ( + await pool.query('SELECT consumed FROM event_run_budget WHERE run_id = ? AND dimension = ?', [ + runId, + dimension, + ]) + )[0].consumed, + ) + +// The statement, lifted verbatim from `eventRunBudget.db.js`. Run through this +// file's pool rather than through the module, because the module builds its own +// pool from the environment while this pool points at the throwaway database. +const SPEND = ` + UPDATE event_run_budget + SET consumed = consumed + ? + WHERE run_id = ? AND dimension = ? AND (cap IS NULL OR consumed + ? <= cap)` + +const spend = async (runId, amount, dimension = 'uo.creatures') => + Number((await pool.query(SPEND, [amount, runId, dimension, amount])).affectedRows) > 0 + +test('two steps spending one cap at once: the second is refused, not queued behind the first', async (t) => { + if (needDb(t)) return + // 28 of 30 spent, and two steps each wanting 5 arrive together. A + // read-then-write would let both see 28 and both spend, ending at 38 of 30 — + // the exact over-spend the conditional increment exists to make impossible. + const runId = await seedBudget({ consumed: 28, cap: 30 }) + const [a, b] = await Promise.all([spend(runId, 5), spend(runId, 5)]) + assert.equal(a, false) + assert.equal(b, false) + assert.equal(await consumedOf(runId), 28) +}) + +test('two steps that BOTH fit both spend, and the total is exact', async (t) => { + if (needDb(t)) return + // The other half, and the one a too-strict guard would break: a cap is not a + // lock. Three steps of 5 against 30 must all succeed and land on 15, or the + // statement is refusing legal work. + const runId = await seedBudget({ consumed: 0, cap: 30 }) + const results = await Promise.all([spend(runId, 5), spend(runId, 5), spend(runId, 5)]) + assert.deepEqual(results, [true, true, true]) + assert.equal(await consumedOf(runId), 15) +}) + +test('a spend that exactly reaches the cap is allowed; one over it is not', async (t) => { + if (needDb(t)) return + // `<=`, not `<`. A cap of 30 means thirty creatures are permitted, and an + // off-by-one here is a deployment that can never use the last unit of anything + // it configured. + const runId = await seedBudget({ consumed: 25, cap: 30 }) + assert.equal(await spend(runId, 5), true) + assert.equal(await consumedOf(runId), 30) + assert.equal(await spend(runId, 1), false) + assert.equal(await consumedOf(runId), 30) +}) + +test('a NULL cap is uncapped, and still counts', async (t) => { + if (needDb(t)) return + // The row exists so the meter has something to show; nothing bounds it. The + // distinction matters because a MISSING row is a refusal — a step spending a + // dimension its own run's version never priced — and the two must not collapse + // into one behaviour. + const runId = await seedBudget({ consumed: 0, cap: null }) + assert.equal(await spend(runId, 1000000), true) + assert.equal(await consumedOf(runId), 1000000) +}) + +test('spending a dimension with no row is refused', async (t) => { + if (needDb(t)) return + const runId = await seedBudget() + assert.equal(await spend(runId, 1, 'uo.bosses'), false) +}) + +test('seeding is INSERT IGNORE: a tick that overruns cannot reset a spent cap', async (t) => { + if (needDb(t)) return + // The idempotence `materialisePhase` and `gates.open` both have. Without it a + // second seed would either error on the unique key or — worse, written as an + // upsert — hand a run that has spent 28 of 30 a fresh 0. + const { runId } = await seedRun({ status: 'running' }) + const SEED = + 'INSERT IGNORE INTO event_run_budget (run_id, dimension, consumed, cap, effective_from) VALUES (?, ?, 0, ?, ?)' + await pool.query(SEED, [runId, 'uo.creatures', 30, 'uo.creature.spawn']) + await spend(runId, 28) + await pool.query(SEED, [runId, 'uo.creatures', 30, 'uo.creature.spawn']) + assert.equal(await consumedOf(runId), 28) + assert.equal((await pool.query('SELECT * FROM event_run_budget WHERE run_id = ?', [runId])).length, 1) +}) + +test('a refund floors at zero rather than going negative', async (t) => { + if (needDb(t)) return + // `GREATEST(consumed - ?, 0)`. A negative `consumed` would make every later cap + // check lie in the permissive direction, permanently — a worse outcome than a + // refund that is slightly too small. + const runId = await seedBudget({ consumed: 3, cap: 30 }) + await pool.query( + 'UPDATE event_run_budget SET consumed = GREATEST(consumed - ?, 0) WHERE run_id = ? AND dimension = ?', + [10, runId, 'uo.creatures'], + ) + assert.equal(await consumedOf(runId), 0) +}) + +test('a budget goes with its run', async (t) => { + if (needDb(t)) return + const runId = await seedBudget() + await pool.query('DELETE FROM event_runs WHERE id = ?', [runId]) + assert.equal((await pool.query('SELECT * FROM event_run_budget WHERE run_id = ?', [runId])).length, 0) +}) + +test('a version records that a dry run passed against it', async (t) => { + if (needDb(t)) return + // §K's gate is two columns on an immutable table, and the ALTER that adds them + // has to actually apply — `verified_at` is what `runsModel.create` reads to + // decide whether a scheduled occurrence may start at all, so a column that + // silently did not exist would hold every schedule on the deployment. + await pool.query('ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_at DATETIME NULL') + await pool.query('ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_by INT NULL') + const v = await pool.query('INSERT INTO event_versions (definition_id, spec) VALUES (1, ?)', ['{}']) + const before = (await pool.query('SELECT verified_at FROM event_versions WHERE id = ?', [v.insertId]))[0] + assert.equal(before.verified_at, null) + const moved = await pool.query('UPDATE event_versions SET verified_at = ?, verified_by = ? WHERE id = ?', [ + T0, + 1, + v.insertId, + ]) + assert.equal(Number(moved.affectedRows), 1) + const after = ( + await pool.query('SELECT verified_at, verified_by FROM event_versions WHERE id = ?', [v.insertId]) + )[0] + assert.ok(after.verified_at instanceof Date) + assert.equal(Number(after.verified_by), 1) +}) + +test('a non-positive spend never reaches the database', async (t) => { + if (needDb(t)) return + // Through the shipping module rather than a copy of its statement. An action + // whose `cost()` answers 0 for its params is telling core it consumes nothing, + // and pricing that as a query would be one round trip per step behind a fact + // the caller already has. + const runId = await seedBudget({ consumed: 0, cap: 10 }) + assert.equal(await budgetDb.spend(runId, 'uo.creatures', 0), true) + assert.equal(await consumedOf(runId), 0) +}) diff --git a/server/test/eventSchedule.test.js b/server/test/eventSchedule.test.js index 0b8652e..fd67dd1 100644 --- a/server/test/eventSchedule.test.js +++ b/server/test/eventSchedule.test.js @@ -32,6 +32,11 @@ const definitionsDb = require('../src/model/events/eventDefinitions.db') const runsDb = require('../src/model/events/eventRuns.db') const stepsDb = require('../src/model/events/eventRunSteps.db') const logDb = require('../src/model/events/eventRunLog.db') +// Phase 6: `runsModel.create` prices the version against the switchboard and +// seeds the run's budget, so expansion now reaches two more tables. Unstubbed +// they are a ten-second ECONNREFUSED per occurrence. +const settingsDb = require('../src/model/events/eventActionSettings.db') +const budgetDb = require('../src/model/events/eventRunBudget.db') const versionsDb = require('../src/model/events/eventVersions.db') const db = require('../src/utils/db') @@ -55,6 +60,8 @@ for (const [name, mod] of [ ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], + ['settingsDb', settingsDb], + ['budgetDb', budgetDb], ]) { originals[name] = { mod, fns: { ...mod } } } @@ -88,6 +95,14 @@ function addDefinition(id, overrides = {}) { definition_id: id, version: 1, spec: definition.spec, + // Verified by default (Phase 6). §K holds a scheduled occurrence of a version + // nobody has dry-run, so an unverified fixture would make every test in this + // file assert nothing about recurrence and everything about that one gate. + // The gate has its own test below, where an occurrence is what is being + // measured rather than what is in the way. + verified_at: new Date('2026-08-01T00:00:00Z'), + verified_by: 1, + ...(overrides.version || {}), }) return definition } @@ -152,6 +167,11 @@ function installStubs() { Object.assign(stepsDb, { materialisePhase: async () => [] }) Object.assign(logDb, { write: async (line) => { store.log.push(line); return 1 } }) + + // Phase 6. No stored switch anywhere in this file: an empty switchboard is a + // fresh deployment, and expansion is not what this file is measuring. + Object.assign(settingsDb, { byIds: async () => new Map(), get: async () => null }) + Object.assign(budgetDb, { seed: async () => 0, forRun: async () => [] }) } beforeEach(() => { diff --git a/server/test/eventVerify.test.js b/server/test/eventVerify.test.js new file mode 100644 index 0000000..3eb529d --- /dev/null +++ b/server/test/eventVerify.test.js @@ -0,0 +1,230 @@ +// ── The dry run (EVENTS_PLAN.md Phase 6, EVENTS.md §I) ───────────────────── +// +// *"Materialise the steps, dispatch each with `verify: true`, report what would +// happen and what it would cost against the caps."* +// +// **The finding worth the most is the one no other path can make.** Every +// per-step check here is also made at save or at dispatch; the TOTAL is not. +// Three steps each spawning 15 under a cap of 30 pass every individual check and +// breach the cap on the third — at two in the morning, unattended, with the world +// half-changed. Adding the costs up across the whole version is the thing only a +// look at the plan as a whole can do, and it is why a dry run is worth more than +// the sum of its step checks. +// +// The second property this file holds is the one §I states outright: **`verify: +// true` must change nothing and must answer honestly.** So the actions here +// record what they were asked and assert that the flag arrived — a dry run that +// silently dispatched for real is the single worst bug this feature could ship, +// and it would look exactly like a passing test otherwise. + +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, beforeEach, afterEach, after } = require('node:test') +const assert = require('node:assert/strict') + +const registries = require('../src/modules/registries') +const { verifySpec } = require('../src/events/verify') +const settingsDb = require('../src/model/events/eventActionSettings.db') +const db = require('../src/utils/db') + +after(() => db.close()) + +const originals = { ...settingsDb } +let store +let seen + +beforeEach(() => { + registries._reset() + store = new Map() + seen = [] + settingsDb.byIds = async (ids) => + new Map([...new Set(ids || [])].filter((i) => store.has(i)).map((i) => [i, store.get(i)])) + settingsDb.get = async (id) => store.get(id) || null +}) + +afterEach(() => { + Object.assign(settingsDb, originals) + registries._reset() +}) + +const register = (entries, owner = 'test') => { + const api = registries.stage(owner) + api.registerEventActions(entries) + registries.apply(api.staged) +} + +const setSetting = (id, enabled, caps = {}) => + store.set(id, { action_id: id, enabled: enabled ? 1 : 0, caps }) + +/** An action that records every envelope it is handed and then answers `answer`. */ +const recorder = (id, over = {}) => ({ + id, + label: over.label || id, + risk: over.risk || 'notify', + reversible: 'none', + version: over.version || 1, + params: over.params || [], + ...(over.cost ? { cost: over.cost } : {}), + async perform(envelope) { + seen.push({ id, ...envelope }) + if (over.throws) throw new Error(over.throws) + return over.answer || { ok: true } + }, +}) + +const spec = (steps) => ({ phases: [{ key: 'one', steps }] }) +const step = (actionId, params = {}, extra = {}) => ({ actionId, params, ...extra }) + +const ADMIN = { id: 1, role: 'admin' } +const EDITOR = { id: 2, role: 'editor' } + +test('every step is dispatched with verify true, and nothing is asked to act', async () => { + // §I's promise, held as an assertion about the envelope each action received. + register([recorder('test.tell'), recorder('test.wait', { risk: 'inspect' })]) + const report = await verifySpec(spec([step('test.tell', { body: 'x' }), step('test.wait')]), { user: ADMIN }) + + assert.equal(report.ok, true) + assert.equal(report.steps, 2) + assert.deepEqual(report.findings, []) + assert.equal(seen.length, 2) + for (const envelope of seen) assert.equal(envelope.verify, true) +}) + +test('the cost of the whole plan is added up across steps, and a total over the cap is a finding', async () => { + // The one check that only exists here. Each of the three steps fits under 30 on + // its own; together they do not. + register([recorder('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })]) + setSetting('test.spawn', true, { 'x.creatures': 30 }) + + const report = await verifySpec( + spec([ + step('test.spawn', { count: 15 }), + step('test.spawn', { count: 15 }), + step('test.spawn', { count: 15 }), + ]), + { user: ADMIN }, + ) + + assert.equal(report.ok, false) + const total = report.findings.find((f) => f.code === 'cap-total') + assert.ok(total, 'the whole-plan total must be its own finding') + assert.match(total.message, /asks for 45 of "x.creatures" across all its steps, and this deployment allows 30/) + assert.deepEqual(report.cost, [ + { dimension: 'x.creatures', total: 45, cap: 30, from: 'test.spawn', over: true }, + ]) +}) + +test('a plan that fits reports its cost without a finding', async () => { + register([recorder('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })]) + setSetting('test.spawn', true, { 'x.creatures': 30 }) + const report = await verifySpec(spec([step('test.spawn', { count: 12 }), step('test.spawn', { count: 8 })]), { + user: ADMIN, + }) + assert.equal(report.ok, true) + assert.deepEqual(report.cost, [{ dimension: 'x.creatures', total: 20, cap: 30, from: 'test.spawn', over: false }]) +}) + +test('an uncapped dimension is reported with its total and no cap', async () => { + // Worth showing rather than hiding: "this event will spawn 40 creatures and + // nothing bounds that" is exactly what an operator opening the switchboard + // wants to have seen first. + register([recorder('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 40 }) })]) + setSetting('test.spawn', true, {}) + const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN }) + assert.equal(report.ok, true) + assert.deepEqual(report.cost, [{ dimension: 'x.creatures', total: 40, cap: null, from: null, over: false }]) +}) + +test('a step naming an action nobody registers is a dormant finding, and the rest are still checked', async () => { + // Reported rather than thrown, so an author sees EVERY problem in one pass. A + // verification that stopped at the first finding would make fixing a + // twelve-step definition twelve round trips. + register([recorder('test.tell')]) + const report = await verifySpec(spec([step('gone.away'), step('test.tell')]), { user: ADMIN }) + + assert.equal(report.ok, false) + assert.equal(report.findings.length, 1) + assert.equal(report.findings[0].code, 'dormant') + assert.equal(report.findings[0].actionId, 'gone.away') + assert.equal(seen.length, 1, 'the registered step is still dispatched') +}) + +test('the caller’s own role is what the report answers against', async () => { + // The value of doing it here: an editor is told a step needs an administrator + // at the moment they can still do something about it, rather than at the moment + // it does not run. + register([recorder('test.change', { risk: 'change', label: 'Change things' })]) + setSetting('test.change', true) + + const asEditor = await verifySpec(spec([step('test.change')]), { user: EDITOR }) + assert.equal(asEditor.ok, false) + assert.equal(asEditor.findings[0].code, 'role') + + const asAdmin = await verifySpec(spec([step('test.change')]), { user: ADMIN }) + assert.equal(asAdmin.ok, true) +}) + +test('a disabled action is a finding, and its step is never dispatched', async () => { + // The order matters: dispatching a disabled action under `verify: true` would + // change nothing, but it would call code the deployment has switched off, and + // "we only ran it to ask whether we could run it" is not a defence anyone wants + // to make. + register([recorder('test.change', { risk: 'change' })]) + const report = await verifySpec(spec([step('test.change')]), { user: ADMIN }) + assert.equal(report.findings[0].code, 'disabled') + assert.equal(seen.length, 0) +}) + +test('the module’s own refusal is a finding in the module’s words', async () => { + // The half core cannot compute: whether the landmark exists, whether the + // creature is on the allowlist, whether the shard is reachable at all. + register([recorder('test.spawn', { answer: { ok: false, retry: false, error: 'no such landmark "Bratain"' } })]) + const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN }) + assert.equal(report.ok, false) + assert.equal(report.findings[0].code, 'refused') + assert.match(report.findings[0].message, /no such landmark/) +}) + +test('an action that throws under verify is a finding, not a 500 on the author’s screen', async () => { + register([recorder('test.spawn', { throws: 'exploded' })]) + const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN }) + assert.equal(report.ok, false) + assert.equal(report.findings[0].code, 'refused') + assert.match(report.findings[0].message, /exploded/) +}) + +test('a step authored against an older action version is a WARNING, not an error', async () => { + // §F: a bump makes the editor render a warning rather than refusing to run. The + // level is the whole point — a warning does not hold a scheduled start, and an + // error does. + register([recorder('test.tell', { version: 3 })]) + const report = await verifySpec(spec([step('test.tell', {}, { actionVersion: 1 })]), { user: ADMIN }) + assert.equal(report.ok, true, 'a drift warning must not fail the dry run') + assert.equal(report.findings[0].level, 'warning') + assert.equal(report.findings[0].code, 'version-drift') + assert.match(report.findings[0].message, /authored against version 1/) +}) + +test('a finding says which phase and which step it is about', async () => { + // A twelve-step definition needs the finding anchored, or the author is left + // reading the message and counting rows. + register([recorder('test.tell')]) + const twoPhases = { + phases: [ + { key: 'open', steps: [step('test.tell')] }, + { key: 'close', steps: [step('test.tell'), step('gone.away')] }, + ], + } + const report = await verifySpec(twoPhases, { user: ADMIN }) + assert.equal(report.steps, 3) + assert.deepEqual( + report.findings.map((f) => ({ phase: f.phase, seq: f.seq })), + [{ phase: 'close', seq: 1 }], + ) +}) + +test('an empty spec verifies clean and costs nothing', async () => { + const report = await verifySpec({ phases: [] }, { user: ADMIN }) + assert.deepEqual(report, { ok: true, steps: 0, findings: [], cost: [] }) +}) diff --git a/server/test/eventsAdmin.test.js b/server/test/eventsAdmin.test.js index 97af609..c8dd14c 100644 --- a/server/test/eventsAdmin.test.js +++ b/server/test/eventsAdmin.test.js @@ -37,6 +37,11 @@ const versionsDb = require('../src/model/events/eventVersions.db') const runsDb = require('../src/model/events/eventRuns.db') const stepsDb = require('../src/model/events/eventRunSteps.db') const logDb = require('../src/model/events/eventRunLog.db') +// Phase 6: the run console reads the budget meter and the switchboard route +// reads the settings table. Same rule as Phases 4 and 5 -- a new leg under a +// model needs a stub in every file that stubs that layer. +const settingsDb = require('../src/model/events/eventActionSettings.db') +const budgetDb = require('../src/model/events/eventRunBudget.db') const seriesDb = require('../src/model/events/eventSeries.db') const gatesDb = require('../src/model/events/eventPhaseGates.db') const activity = require('../src/model/activity/activity.model') @@ -56,6 +61,8 @@ for (const [name, mod] of [ ['logDb', logDb], ['seriesDb', seriesDb], ['gatesDb', gatesDb], + ['settingsDb', settingsDb], + ['budgetDb', budgetDb], ['activity', activity], ]) { originals[name] = { mod, fns: { ...mod } } @@ -79,6 +86,8 @@ function installStubs() { log: [], series: new Map(), gates: [], + settings: new Map(), + budget: new Map(), occurrences: new Set(), nextDefinition: 1, nextVersion: 1, @@ -91,6 +100,10 @@ function installStubs() { series_name: store.series.get(d.series_id)?.name ?? null, series_slug: store.series.get(d.series_id)?.slug ?? null, current_version: store.versions.get(d.current_version_id)?.version ?? null, + // Phase 6: joined in `SELECT_LIST` alongside `current_version`, and it has to + // be joined HERE too or the stub answers a shape the real query never + // returns — which is a test agreeing with itself rather than with the server. + current_version_verified_at: store.versions.get(d.current_version_id)?.verified_at ?? null, }) definitionsDb.list = async ({ state = null } = {}) => @@ -147,9 +160,19 @@ function installStubs() { spec: JSON.parse(JSON.stringify(spec)), published_at: new Date(), published_by: userId, + // Phase 6. A version is unverified the moment it is cut, which is what + // makes §K's gate mean anything: publishing is not the review. + verified_at: null, + verified_by: null, }) return id } + versionsDb.markVerified = async (id, userId, at = new Date()) => { + const v = store.versions.get(id) + if (!v) return false + Object.assign(v, { verified_at: at, verified_by: userId }) + return true + } const shapeRun = (r) => ({ ...r, @@ -278,6 +301,46 @@ function installStubs() { store.log.push({ audit: true, ...entry }) return true } + + // ── Phase 6's two tables ── + // + // `store.settings` is a real store here rather than an empty stand-in, because + // this file tests the switchboard ROUTES: the GET has to be able to tell a + // stored opinion from a risk-class default, and the PUT has to be readable + // back. + settingsDb.all = async () => [...store.settings.values()] + settingsDb.get = async (actionId) => store.settings.get(actionId) || null + settingsDb.byIds = async (ids) => + new Map( + [...new Set(ids || [])] + .filter((id) => store.settings.has(id)) + .map((id) => [id, store.settings.get(id)]), + ) + settingsDb.put = async (actionId, { enabled, caps }, userId = null) => { + const row = { + action_id: actionId, + enabled: enabled ? 1 : 0, + caps: caps || {}, + updated_by: userId, + updated_at: new Date(), + } + store.settings.set(actionId, row) + return row + } + + budgetDb.seed = async (runId, dimensions) => { + for (const [dimension, d] of Object.entries(dimensions || {})) { + const key = `${runId}:${dimension}` + if (!store.budget.has(key)) { + store.budget.set(key, { run_id: runId, dimension, consumed: 0, cap: d.cap, effective_from: d.from || null }) + } + } + return Object.keys(dimensions || {}).length + } + budgetDb.forRun = async (runId) => + [...store.budget.values()] + .filter((b) => Number(b.run_id) === Number(runId)) + .sort((a, b) => a.dimension.localeCompare(b.dimension)) } // ── Fixtures ─────────────────────────────────────────────────────────────── @@ -675,3 +738,295 @@ test('re-publishing with nothing scheduled ahead re-pins nothing', async () => { const again = await call(ctrl.publish, { params: { id: String(id) } }) assert.equal(again.body.repinned, 0) }) + +// ── Phase 6: the switchboard, the dry run, and the role floor on a step ──── +// +// `eventAuthorize.test.js` holds `mayInvoke`'s layers and `eventVerify.test.js` +// holds the dry run's report. What is genuinely new HERE is what the surface +// decides on top of them: what the board serves when nobody has ever touched it, +// what a cap is allowed to name, which spec a dry run is run against, and the one +// gate that cannot live in route middleware because it depends on the BODY. + +const ADMIN = { id: 1, role: 'admin' } +const EDITOR = { id: 2, role: 'editor' } + +/** + * A module whose action declares a COST, which `demo.world.change` does not. + * + * Separate rather than folded into `registerDemoModule`, because the two answer + * different questions: that one is "a world-changing verb exists", this one is "a + * verb that spends something exists", and the switchboard's cap editor only has + * anything to render for the second. + */ +function registerCosting() { + // The owner must match the id's namespace: the registry refuses an action id + // that is not prefixed with the module registering it, which is what keeps an + // action's id space its own (§F). + const api = registries.stage('test') + api.registerEventActions([ + { + id: 'test.spawn', + label: 'Spawn creatures', + risk: 'change', + reversible: 'none', + params: [{ name: 'count', type: 'int', required: true, example: 4 }], + cost: (p) => ({ 'x.creatures': p.count }), + perform: async () => ({ ok: true }), + }, + ]) + registries.apply(api.staged) +} + +// ── The switchboard ──────────────────────────────────────────────────────── + +test('the board serves every registered action with its risk-class default, and says nothing is configured', async () => { + // A fresh deployment has no rows at all — nothing is seeded at boot, because + // registration runs against a dead pool (MODULE_API §2.2) — so the board's + // first render is entirely computed. `configured: false` is how the screen + // tells "an admin turned this on" from "this has always been on". + const res = await call(ctrl.actions, { user: ADMIN }) + assert.equal(res.statusCode, 200) + + const byId = Object.fromEntries(res.body.actions.map((a) => [a.id, a])) + assert.deepEqual(Object.keys(byId).sort(), ['core.announce', 'core.cue', 'core.wait']) + // core.wait is `inspect`, and it arrives ENABLED. Read §K's sentence literally + // and it would not, and every published event that waits would break on a fresh + // deployment (org lead, 2026-09-03). + assert.equal(byId['core.wait'].enabled, true) + assert.equal(byId['core.wait'].changesWorld, false) + assert.equal(byId['core.announce'].enabled, true) + for (const a of res.body.actions) assert.equal(a.configured, false) + assert.deepEqual(res.body.worldChangingRisks, ['change', 'irreversible']) +}) + +test('the board never serves a callable', async () => { + // `allEventActions()` strips `perform`, `revert` and `cost`. This board adds + // fields to that object, and adding them back by spreading the full + // registration would be how a module's function comes to leave the process. + const res = await call(ctrl.actions, { user: ADMIN }) + for (const a of res.body.actions) { + assert.equal(a.perform, undefined) + assert.equal(a.revert, undefined) + assert.equal(a.cost, undefined) + } +}) + +test('a stored switch is served back, marked configured, with who set it', async () => { + await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.announce', enabled: false } }) + const res = await call(ctrl.actions, { user: ADMIN }) + const announce = res.body.actions.find((a) => a.id === 'core.announce') + assert.equal(announce.enabled, false) + assert.equal(announce.configured, true) + assert.ok(announce.updatedAt) +}) + +test('the switch works in both directions, because an operator must be able to turn things OFF', async () => { + const off = await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.cue', enabled: false } }) + assert.equal(off.statusCode, 200) + assert.equal(off.body.action.enabled, false) + + const on = await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.cue', enabled: true } }) + assert.equal(on.body.action.enabled, true) +}) + +test('a switch for an action nobody registers is a 404, not a stored row', async () => { + // The board is rendered from the registry, so a write against something not in + // it is a client out of date — and storing it would put a row on the screen + // that no action can ever claim. + const res = await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'gone.away', enabled: true } }) + assert.equal(res.statusCode, 404) +}) + +test('enabled must be stated, because there is no safe value to guess', async () => { + const res = await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.announce' } }) + assert.equal(res.statusCode, 400) + assert.match(res.body.error, /enabled must be true or false/) +}) + +test('a cap must name a dimension the action actually spends', async () => { + // Not pedantry. A cap on a dimension an action never names is a number an + // operator believes is protecting them, rendered back to them for ever, + // bounding nothing. None of core's three actions declares a cost at all, so + // every cap is refused here — which is itself the honest state of a deployment + // with no module installed. + const res = await call(ctrl.saveAction, { + user: ADMIN, + body: { actionId: 'core.announce', enabled: true, caps: { 'uo.creatures': 30 } }, + }) + assert.equal(res.statusCode, 400) + assert.match(res.body.error, /does not spend "uo.creatures"/) +}) + +test('a cap that is not a whole number of 0 or more is refused', async () => { + registerCosting() + for (const bad of [-1, 2.5, 'lots']) { + const res = await call(ctrl.saveAction, { + user: ADMIN, + body: { actionId: 'test.spawn', enabled: true, caps: { 'x.creatures': bad } }, + }) + assert.equal(res.statusCode, 400, String(bad)) + } +}) + +test('a cap of zero is legal, and it means zero', async () => { + // "This deployment permits this verb, and permits none of it" is a coherent + // thing to say, and refusing 0 would make an operator disable the action + // instead — which is a different fact with a different audit trail. + registerCosting() + const res = await call(ctrl.saveAction, { + user: ADMIN, + body: { actionId: 'test.spawn', enabled: true, caps: { 'x.creatures': 0 } }, + }) + assert.equal(res.statusCode, 200) + assert.deepEqual(res.body.action.caps, { 'x.creatures': 0 }) +}) + +test('the board offers a cap box per dimension, discovered from the declared examples', async () => { + // The Phase 6 stand-in for §F's `registerEventBudgets`, which arrives in Phase + // 7 — until then a param's required `example` is what tells core the names. + registerCosting() + const res = await call(ctrl.actions, { user: ADMIN }) + const spawn = res.body.actions.find((a) => a.id === 'test.spawn') + assert.deepEqual(spawn.dimensions, ['x.creatures']) + assert.equal(spawn.enabled, false, 'a change action arrives disabled') + assert.equal(spawn.changesWorld, true) +}) + +// ── The dry run ──────────────────────────────────────────────────────────── + +test('a draft is verified against its working spec, and the pass is not recorded', async () => { + // There is no version to record it on, and a pass on a draft would be a claim + // about a spec that changes under the author's hands. + const { body } = await createDraft() + const res = await call(ctrl.verify, { user: EDITOR, params: { id: String(body.event.id) } }) + + assert.equal(res.statusCode, 200) + assert.equal(res.body.target, 'draft') + assert.equal(res.body.versionId, null) + assert.equal(res.body.recorded, false) + assert.equal(res.body.report.ok, true) + assert.equal(res.body.report.steps, 1) +}) + +test('a ready definition is verified against the version that would actually run, and the pass IS recorded', async () => { + // §K's last bound. A version is immutable, so a dry run that passed against one + // stays true — which is what makes the pass a property of the version. + const { body } = await createDraft() + const id = body.event.id + await call(ctrl.publish, { user: ADMIN, params: { id: String(id) } }) + + const res = await call(ctrl.verify, { user: ADMIN, params: { id: String(id) } }) + assert.equal(res.body.target, 'version') + assert.equal(res.body.recorded, true) + assert.equal(res.body.version, 1) + + // And the definition now says so, on the screen its author is already looking + // at rather than on the Friday it did not run. + const after = await call(ctrl.get, { user: ADMIN, params: { id: String(id) } }) + assert.ok(after.body.event.currentVersionVerifiedAt) +}) + +test('a definition that has never been verified says so', async () => { + const { body } = await createDraft() + await call(ctrl.publish, { user: ADMIN, params: { id: String(body.event.id) } }) + const res = await call(ctrl.get, { user: ADMIN, params: { id: String(body.event.id) } }) + assert.equal(res.body.event.currentVersionVerifiedAt, null) +}) + +test('a report with findings is a 200, and it does not record a pass', async () => { + // The request succeeded; the plan has problems. A 4xx would make "this event + // asks for 45 and you allow 30" indistinguishable from "you sent a bad id", + // and rendering the findings is the whole value of the screen. + const { body } = await createDraft({ + spec: { + schedule: { kind: 'manual' }, + phases: [{ key: 'main', label: 'Main', steps: [announceStep()] }], + }, + }) + const id = body.event.id + await call(ctrl.publish, { user: ADMIN, params: { id: String(id) } }) + + // Switch the action off underneath the published version: the plan is now one + // this deployment will not carry out. + await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.announce', enabled: false } }) + + const res = await call(ctrl.verify, { user: ADMIN, params: { id: String(id) } }) + assert.equal(res.statusCode, 200) + assert.equal(res.body.report.ok, false) + assert.equal(res.body.recorded, false, 'a failing dry run must not unlock a scheduled start') + assert.equal(res.body.report.findings[0].code, 'disabled') +}) + +test('an archived definition cannot be verified', async () => { + const { body } = await createDraft() + await call(ctrl.archive, { user: ADMIN, params: { id: String(body.event.id) } }) + const res = await call(ctrl.verify, { user: ADMIN, params: { id: String(body.event.id) } }) + assert.equal(res.statusCode, 409) +}) + +test('verifying a definition that does not exist is a 404', async () => { + const res = await call(ctrl.verify, { user: ADMIN, params: { id: '9999' } }) + assert.equal(res.statusCode, 404) +}) + +// ── The role floor, which cannot live in route middleware ────────────────── + +test('an editor cannot save a step whose action changes the world', async () => { + // §K's "any step whose action is above notify — admin only", with the line + // drawn between `inspect` and `change` (org lead, 2026-09-03). It is checked in + // the model rather than on the route because it depends on the BODY: the route + // is `admin, editor` and stays that way, and which of the two you have to be + // depends on what you put in the spec. + registerCosting() + const res = await call(ctrl.create, { + user: EDITOR, + body: draftBody({ + spec: { + schedule: { kind: 'manual' }, + phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'test.spawn', params: { count: 1 } }] }], + }, + }), + }) + assert.equal(res.statusCode, 403) + assert.match(res.body.errors[0], /only an administrator may author a step/) +}) + +test('an editor may still save a step that only announces or waits', async () => { + // The other half, and the one the literal reading of §K would have broken: an + // editor who cannot author a step that waits has an authoring role that cannot + // author. + const res = await call(ctrl.create, { user: EDITOR, body: draftBody() }) + assert.equal(res.statusCode, 201) +}) + +test('an admin may save the same world-changing step', async () => { + registerCosting() + const res = await call(ctrl.create, { + user: ADMIN, + body: draftBody({ + spec: { + schedule: { kind: 'manual' }, + phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'test.spawn', params: { count: 1 } }] }], + }, + }), + }) + assert.equal(res.statusCode, 201) +}) + +test('the floor is checked on EDIT as well as on create', async () => { + // Otherwise an editor writes a legal draft and then edits a world-changing step + // into it, which is the same escalation with one more click. + registerCosting() + const { body } = await call(ctrl.create, { user: EDITOR, body: draftBody() }) + const res = await call(ctrl.update, { + user: EDITOR, + params: { id: String(body.event.id) }, + body: draftBody({ + spec: { + schedule: { kind: 'manual' }, + phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'test.spawn', params: { count: 1 } }] }], + }, + }), + }) + assert.equal(res.statusCode, 403) +}) diff --git a/server/test/eventsRoles.test.js b/server/test/eventsRoles.test.js new file mode 100644 index 0000000..3840388 --- /dev/null +++ b/server/test/eventsRoles.test.js @@ -0,0 +1,222 @@ +// ── The 403 walk (EVENTS_PLAN.md Phase 6, EVENTS.md §K) ──────────────────── +// +// The phase's second acceptance criterion: *"a 403 walk across all four roles on +// every route."* Phase 3 put the gates on the routes; this file is what makes +// them a contract rather than a line of code nobody re-reads. +// +// **It walks the real router, not the controllers.** `eventsAdmin.test.js` calls +// handlers directly, which is the right shape for testing what a handler +// DECIDES and exactly the wrong shape for testing what stands in front of it: a +// controller called directly has passed no gate at all. So the requests here go +// through `events.router.js` mounted under the same `staffOnly` tier gate +// `admin/index.js` applies, and the assertion is only ever "403 or not" — what +// the handler then answers is another file's subject. +// +// **The two lines this is protecting are §K's, and one of them is deliberately +// inconsistent:** +// +// • Publishing a version and starting a run are `admin` ONLY (§N2). Starting +// commits the deployment to everything the definition contains, unattended, +// up to every cap it declares — it wants the narrowest gate there is. +// • Cancelling, pausing, advancing and the step controls are `admin` AND +// `moderator`. The incident is "the event is doing something wrong at 2am", +// and it wants the widest. A split that read consistent, with one role owning +// both buttons, would behave badly in exactly the case the moderator role +// exists for. +// +// Phase 6 adds the switchboard to the `admin` column — §K puts it in the same row +// as the world-changing actions it governs — and `verify` to the `admin, editor` +// one, because a dry run dispatches nothing and the author who wrote the +// definition is who should be able to price it before asking an admin to publish. + +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, after } = require('node:test') +const assert = require('node:assert/strict') +const express = require('express') + +// ── Every handler is replaced before the router captures it ──────────────── +// +// The router does `controller.catalog` at route-definition time, so mutating the +// controller module BEFORE requiring the router replaces what each route +// actually runs. That is the difference between a walk that measures gates and +// one that measures gates plus twenty-seven handlers reaching a dead database: +// the first draft let the handlers run and cut them off on a timer, and every +// one of them then rejected AFTER its test had ended — thirty-one green +// assertions and a file that failed, which is the least useful failure there is. +// +// It also makes the walk honest in the other direction. "Did it reach the +// handler" is now a fact this file establishes rather than infers from the +// absence of a 403. +const controller = require('../src/router/v1/admin/events.controller') + +const REACHED = Symbol('reached the handler') +for (const name of Object.keys(controller)) { + if (typeof controller[name] === 'function') { + controller[name] = (_req, res) => res.status(299).json({ [REACHED]: true }) + } +} + +const eventsRouter = require('../src/router/v1/admin/events.router') +const { requireRole } = require('../src/utils/auth') +// Requiring the chain builds `utils/db`'s pool at require time. Every other event +// test file closes it; a file that does not leaves the process alive after the +// last assertion. +const db = require('../src/utils/db') + +after(() => db.close()) + +const ROLES = ['admin', 'editor', 'moderator', 'player'] + +// The tier gate from `admin/index.js`, applied here the same way, because a walk +// that skipped it would report `player` reaching routes that no player can reach. +const staffOnly = requireRole('admin', 'editor', 'moderator') + +const app = express() +app.use(express.json()) +app.use((req, _res, next) => { + req.user = req.headers['x-test-role'] ? { id: 1, role: req.headers['x-test-role'] } : null + next() +}) +app.use('/events', staffOnly, eventsRouter) + +/** + * Dispatch one request and answer the status it ended on. + * + * `403` is a gate refusing; `299` is the stand-in handler saying it was reached; + * `404` is a path this file spelled wrong, which is worth telling apart from + * both — a walk that silently asserted "not forbidden" over a route that does + * not exist would pass for ever while protecting nothing. + */ +function dispatch(method, path, role) { + return new Promise((resolve, reject) => { + const req = new (require('http').IncomingMessage)(null) + req.method = method + req.url = path + req.headers = { 'x-test-role': role, 'content-type': 'application/json' } + req.push(null) + + let status = 200 + const done = () => resolve(status) + const res = { + statusCode: 200, + headersSent: false, + locals: {}, + setHeader() {}, + getHeader() {}, + removeHeader() {}, + status(c) { + status = c + this.statusCode = c + return this + }, + json() { + done() + return this + }, + send() { + done() + return this + }, + end() { + done() + return this + }, + } + app(req, res, (err) => (err ? reject(err) : resolve(404))) + }) +} + +const forbidden = async (method, path, role) => (await dispatch(method, path, role)) === 403 + +// method, path, and the roles §K says may reach the handler. +const SURFACE = [ + // Reads: staff-wide, the tier gate and nothing added. + ['GET', '/events/catalog', ['admin', 'editor', 'moderator']], + ['GET', '/events/series', ['admin', 'editor', 'moderator']], + ['GET', '/events/calendar', ['admin', 'editor', 'moderator']], + ['GET', '/events/runs', ['admin', 'editor', 'moderator']], + ['GET', '/events/runs/1', ['admin', 'editor', 'moderator']], + ['GET', '/events/runs/1/log', ['admin', 'editor', 'moderator']], + ['GET', '/events', ['admin', 'editor', 'moderator']], + ['GET', '/events/1', ['admin', 'editor', 'moderator']], + ['GET', '/events/1/versions', ['admin', 'editor', 'moderator']], + + // Authoring: admin and editor. Naming an arc is authoring too (Phase 4). + ['POST', '/events', ['admin', 'editor']], + ['PUT', '/events/1', ['admin', 'editor']], + ['POST', '/events/series', ['admin', 'editor']], + ['PUT', '/events/series/1', ['admin', 'editor']], + ['DELETE', '/events/series/1', ['admin', 'editor']], + // Phase 6. A dry run dispatches nothing and changes nothing. + ['POST', '/events/1/verify', ['admin', 'editor']], + + // Committing the deployment: admin only (§N2). + ['POST', '/events/1/publish', ['admin']], + ['POST', '/events/1/runs', ['admin']], + ['DELETE', '/events/1', ['admin']], + // Phase 6's switchboard — configuration that can break things. + ['GET', '/events/actions', ['admin']], + ['PUT', '/events/actions', ['admin']], + + // Live control of a run in flight: admin and moderator, deliberately WIDER + // than start. + ['POST', '/events/runs/1/pause', ['admin', 'moderator']], + ['POST', '/events/runs/1/resume', ['admin', 'moderator']], + ['POST', '/events/runs/1/cancel', ['admin', 'moderator']], + ['POST', '/events/runs/1/advance', ['admin', 'moderator']], + ['POST', '/events/runs/1/steps/1/confirm', ['admin', 'moderator']], + ['POST', '/events/runs/1/steps/1/skip', ['admin', 'moderator']], + ['POST', '/events/runs/1/steps/1/retry', ['admin', 'moderator']], +] + +for (const [method, path, allowed] of SURFACE) { + test(`${method} ${path} is reachable by ${allowed.join(', ')} and nobody else`, async () => { + for (const role of ROLES) { + const status = await dispatch(method, path, role) + if (allowed.includes(role)) { + // 299 is the stand-in handler. Asserting on it rather than on "not 403" + // is what stops a mistyped path in the table above passing as a 404 for + // every role and protecting nothing. + assert.equal(status, 299, `${method} ${path} as ${role}: expected to reach the handler`) + } else { + assert.equal(status, 403, `${method} ${path} as ${role}: expected a 403`) + } + } + }) +} + +test('a player reaches nothing at all under /events', async () => { + // Stated once as its own claim rather than left implicit in twenty-seven rows. + // The tier gate is what excludes them, not the per-route gates, and a refactor + // that moved a route out from under the mount would pass every row above. + for (const [method, path] of SURFACE) { + assert.equal(await forbidden(method, path, 'player'), true, `${method} ${path}`) + } +}) + +test('start and stop are NOT the same gate, and that is the point', async () => { + // §K's deliberate inconsistency, held as its own test so that a later tidying + // pass which "fixed" it has to delete an assertion that says why. + assert.equal(await forbidden('POST', '/events/1/runs', 'moderator'), true, 'a moderator may not start a run') + assert.equal(await forbidden('POST', '/events/runs/1/cancel', 'moderator'), false, 'but must be able to stop one') +}) + +test('an editor may price an event but not publish or start it', async () => { + // Phase 6's addition to the same shape: the author who wrote the definition can + // find out what it would cost before asking an admin to commit the deployment. + assert.equal(await forbidden('POST', '/events/1/verify', 'editor'), false) + assert.equal(await forbidden('POST', '/events/1/publish', 'editor'), true) + assert.equal(await forbidden('POST', '/events/1/runs', 'editor'), true) +}) + +test('the switchboard is admin only in both directions', async () => { + // Reading which actions are enabled is as much `admin` as writing it: the board + // is the deployment's posture, and §K puts it in the same row as the actions it + // governs rather than with the staff-wide reads. + for (const role of ['editor', 'moderator', 'player']) { + assert.equal(await forbidden('GET', '/events/actions', role), true, role) + assert.equal(await forbidden('PUT', '/events/actions', role), true, role) + } +})