Files
website/server/test/eventAuthorize.test.js
wtclaude fd9fb50351
Some checks failed
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Failing after 8m41s
feat(events): open the event contract to modules (Phase 7)
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
2026-09-03 14:15:04 -05:00

509 lines
22 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()
})
/**
* Declare the budget dimensions a batch of actions prices.
*
* §F, fail closed (Phase 7): a `cost()` naming a dimension no module registered
* is refused BEFORE any cap arithmetic runs. Without this, every cap test below
* would pass for the WRONG reason — refused by the layer above the one under
* test. Discovered the way the switchboard discovers them, by pricing the
* action's own declared examples, so a test never has to keep a second list of
* its dimensions in step with its `cost()`.
*
* Written out here rather than borrowed from `authorize.dimensionsOf` so this
* file keeps stubbing exactly what it means to stub. The undeclared case is not
* an omission; it has its own test.
*/
const declaredBudgets = (entries, owner) => {
const dimensions = new Set()
for (const e of entries) {
if (typeof e.cost !== 'function') continue
const params = {}
for (const p of e.params || []) if (p.example !== undefined) params[p.name] = p.example
let priced
try {
priced = e.cost(params)
} catch {
continue
}
for (const d of Object.keys(priced || {})) dimensions.add(d)
}
return [...dimensions]
.filter((id) => id.startsWith(`${owner}.`))
.map((id) => ({ id, label: id, unit: 'count' }))
}
const register = (entries, { owner = 'test', budgets = true } = {}) => {
const api = registries.stage(owner)
api.registerEventActions(entries)
if (budgets) api.registerEventBudgets(declaredBudgets(entries, owner))
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) => ({ 'test.creatures': p.count }) })])
setSetting('test.spawn', true)
const priced = authorize.priceOf(registries.eventAction('test.spawn'), { count: 12 })
assert.deepEqual(priced, { 'test.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) => ({ 'test.creatures': p.count, 'test.bosses': 1 }),
}),
])
assert.deepEqual(authorize.dimensionsOf(registries.eventAction('test.spawn')), ['test.bosses', 'test.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: () => ({ 'test.creatures': 1 }) }),
action('test.horde', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) }),
])
setSetting('test.spawn', true, { 'test.creatures': 30 })
setSetting('test.horde', true, { 'test.creatures': 10 })
const caps = authorize.effectiveCaps(
[{ actionId: 'test.spawn', params: {} }, { actionId: 'test.horde', params: {} }],
await settingsDb.byIds(['test.spawn', 'test.horde']),
)
assert.deepEqual(caps, { 'test.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: () => ({ 'test.creatures': 1 }) }),
action('test.horde', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) }),
])
setSetting('test.spawn', true, { 'test.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, { 'test.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: () => ({ 'test.creatures': 1 }) })])
setSetting('test.spawn', true, {})
const caps = authorize.effectiveCaps([{ actionId: 'test.spawn', params: {} }], await settingsDb.byIds(['test.spawn']))
assert.deepEqual(caps, { 'test.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) => ({ 'test.creatures': p.count }) })])
setSetting('test.spawn', true, { 'test.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, 'test.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) => ({ 'test.creatures': p.count }) })])
setSetting('test.spawn', true, { 'test.creatures': 30 })
setBudget(RUN.id, 'test.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:test.creatures').consumed, 12)
})
test('without spend the cap check is advisory and moves nothing', async () => {
register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5 }) })])
setSetting('test.spawn', true, { 'test.creatures': 30 })
setBudget(RUN.id, 'test.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:test.creatures').consumed, 0)
})
test('a refusal names the numbers an operator needs, not just "refused"', async () => {
register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5 }) })])
setSetting('test.spawn', true, { 'test.creatures': 30 })
setBudget(RUN.id, 'test.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: () => ({ 'test.creatures': 5, 'test.bosses': 2 }) }),
])
setSetting('test.spawn', true)
setBudget(RUN.id, 'test.creatures', { consumed: 0, cap: 30 })
setBudget(RUN.id, 'test.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, 'test.bosses')
assert.equal(store.budget.get('1:test.creatures').consumed, 0, 'the creatures must have been given back')
assert.equal(store.budget.get('1:test.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: () => ({ 'test.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('a dimension no module declares is refused before any cap arithmetic', async () => {
// §F, fail closed (org lead, 2026-09-03). The refusal has to be its OWN code:
// told "the cap is spent", an operator goes and raises a cap, and nothing
// changes, because the problem is a module whose declaration is incomplete.
register([action('test.spawn', { risk: 'notify', cost: () => ({ 'test.wisps': 3 }) })], {
budgets: false,
})
setSetting('test.spawn', true)
setBudget(1, 'test.wisps', { cap: 100 })
const verdict = await authorize.mayInvoke({
action: registries.eventAction('test.spawn'),
params: {},
run: RUN,
spend: true,
})
assert.equal(verdict.ok, false)
assert.equal(verdict.code, 'undeclared')
assert.match(verdict.reason, /no module declares/)
// And it did not spend: a step refused for this reason never ran.
assert.equal(store.budget.get('1:test.wisps').consumed, 0)
})
test('an uncapped budget row never refuses', async () => {
register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 999 }) })])
setSetting('test.spawn', true)
setBudget(RUN.id, 'test.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:test.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: () => ({ 'test.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, { 'test.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')
})