MODULE_API 1.10.0. Four names forwarded on the module-facing `api` -- registerEventActions, registerEventBudgets, registerEventLeases and registerEventOptionSources -- one new route, and one rule made real: a `cost()` naming a dimension no module declared is refused. Only one of the four is new machinery. The action registry has staged core's three actions on every boot since Phase 1; what it never had was a way in, because loader.js builds its own `api` facade and had no method that delegated to it. So the registry a module now reaches is one that has been exercised on every boot for six phases. Four decisions, settled 2026-09-03, all as recommended: - Option sources are their own registration, modelled on registerAudiences, because a catalog has more than one consumer. - An undeclared dimension is refused -- at save, at the dry run and at dispatch -- with its own code, because the fix is a module's declaration and not a deployment's cap. - A lease is declared here and acquired by nothing; the ledger is Phase 8. - Core registers core.options.legs, so an announce leg is a dropdown rather than the free-text box whose typo Phase 6's walk caught mid-run. Proved with a throwaway module through the real loader, not with module-uo: eventModuleContract.test.js writes a module to a real directory and lets the loader scan it, covering all five envelope failure shapes, verify: true, the four id spaces and dormancy on uninstall. The live walk found the one defect nothing else could: the option-source loader wrote its "already asked?" guard inside a setState updater and read it on the next line, so the request was never made and the field sat on "Reading the list..." for ever. It is a useRef now. Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
226 lines
10 KiB
JavaScript
226 lines
10 KiB
JavaScript
// ── 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']],
|
|
// Phase 7. Authoring data, so staff-wide like the catalog it belongs to: an
|
|
// editor who may write the step must be able to see which values it accepts.
|
|
['GET', '/events/catalog/options/core.options.legs', ['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)
|
|
}
|
|
})
|