Files
website/server/test/eventAuthorize.test.js
wtclaude 4077c4e79e
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
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
2026-09-03 05:50:58 -05:00

452 lines
20 KiB
JavaScript

// ── 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')
})