feat(events): enablement, per-run caps and mayInvoke (Phase 6)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 30s
PR Checks / client-build (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Successful in 13m33s

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:
2026-09-03 05:50:58 -05:00
parent 4ac917c3a3
commit 4077c4e79e
31 changed files with 3890 additions and 24 deletions

View File

@@ -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)
}
})