feat(events): enablement, per-run caps and mayInvoke (Phase 6)
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 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user