Files
website/server/test/eventModuleContract.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

516 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── The module contract, proved with a module (EVENTS_PLAN.md Phase 7) ─────
//
// §F is the seam this phase opens: `registerEventActions`, `registerEventBudgets`,
// `registerEventLeases` and `registerEventOptionSources`, at MODULE_API 1.10.0.
// Every one of them was reachable only by `registerCore()` before this phase, and
// the plan is explicit about how to prove they are reachable now:
//
// > **Prove it with a throwaway module, not with module-uo.** A contract
// > validated only against the module it was carved out of has not been
// > validated, and P9 should be the *second* consumer of this seam.
//
// So every test below writes a real module to a real directory, points
// MODULES_DIR at it and lets the real loader scan, validate, `register()` and
// commit it. Nothing here stubs the loader or calls `registries.stage()` by hand:
// a test that staged directly would pass just as happily against the code before
// this phase, when `buildApi` forwarded none of these four names.
//
// **The half this file cares most about is the failure half.** §F's load-bearing
// envelope rule is that *no shape a failure can take may read as success* — a
// rejected promise, a throw, a timeout, a non-object and a missing `ok` are all
// `{ ok: false, retry: true }`, which is `registerTeamProvider`'s default
// inverted, because the expensive mistake here is recording a world change that
// did not happen. Those five shapes are dispatched from a module below, not
// constructed as literals, so what is under test is the path a module actually
// takes.
//
// The pool points at a closed port before anything is required: the loader's
// `buildCtx` pulls in the models, which build a mariadb pool at require time. No
// query is ever run.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const fs = require('fs')
const os = require('os')
const path = require('path')
const { test, beforeEach, after } = require('node:test')
const assert = require('node:assert/strict')
const express = require('express')
const db = require('../src/utils/db')
const registries = require('../src/modules/registries')
const dispatch = require('../src/events/dispatch')
const authorize = require('../src/events/authorize')
const { MODULE_API_VERSION } = require('../src/modules/version')
after(() => db.close())
let tmpRoot
const emptyTiers = () => ({
public: express.Router(),
admin: express.Router(),
player: express.Router(),
})
/**
* Write a module and let the real loader scan it.
*
* MODULES_DIR is read into a const at require time — it has to be, the scan is
* synchronous and happens while app.js is being required — so busting the cache
* is the only honest way to point the loader somewhere else.
*/
function loadModule(id, source, manifest = {}) {
const dir = path.join(tmpRoot, id)
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(
path.join(dir, 'module.json'),
JSON.stringify({ id, name: id, version: '1.0.0', coreApi: '^1.10.0', server: 'index.js', ...manifest }),
)
fs.writeFileSync(path.join(dir, 'index.js'), source)
process.env.MODULES_DIR = tmpRoot
registries._reset()
delete require.cache[require.resolve('../src/modules/loader')]
// eslint-disable-next-line global-require
const loader = require('../src/modules/loader')
loader.load(emptyTiers())
return loader.list().find((m) => m.id === id)
}
/** The state a module that loaded cleanly is in, with its reason if it did not. */
const assertRegistered = (record) => {
assert.equal(record.state, 'registered', record.reason || 'expected the module to register')
}
const RUN = { id: 1, scope: '' }
const step = (actionId, params = {}) => ({
id: 1,
action_id: actionId,
params,
idempotency_key: 'k-1',
})
beforeEach(() => {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-events-module-'))
})
// ── The seam is open ───────────────────────────────────────────────────────
test('the version a module declares against is 1.10.0', () => {
// Not decoration. `coreApi: "^1.10.0"` on every module below is what makes
// these tests fail loudly rather than quietly if the bump is ever reverted —
// the loader would refuse the manifest and every assertion would become "the
// module did not register", which is the same failure the seam closing would
// produce. Asserting the number here says which of the two it was.
assert.equal(MODULE_API_VERSION, '1.10.0')
})
test('a module registers actions, budgets, leases and option sources', () => {
const record = loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventBudgets([
{ id: 'demo.wisps', label: 'Wisps summoned', unit: 'count' },
])
api.registerEventActions([{
id: 'demo.wisp.summon',
label: 'Summon wisps',
risk: 'change',
reversible: 'ledger',
cost: (p) => ({ 'demo.wisps': p.count }),
params: [{ name: 'count', type: 'int', required: true, example: 3 }],
async perform() { return { ok: true, resources: [{ kind: 'wisp', ref: '0x1' }] } },
async revert() { return { ok: true } },
}])
api.registerEventLeases([{
id: 'demo.rate.gain',
label: 'Gain rate',
type: 'float',
min: 0.5,
max: 5,
maxDurationMs: 3600000,
async read() { return { ok: true, value: 1 } },
async apply() { return { ok: true } },
async restore() { return { ok: true } },
}])
api.registerEventOptionSources([{
id: 'demo.options.hues',
label: 'Hues',
async resolve() { return [{ value: '1157', label: 'Blood', group: 'Reds' }] },
}])
}`)
assertRegistered(record)
assert.equal(registries.eventAction('demo.wisp.summon').owner, 'demo')
assert.deepEqual(registries.eventBudget('demo.wisps'), {
owner: 'demo', id: 'demo.wisps', label: 'Wisps summoned', unit: 'count', description: '',
})
assert.equal(registries.eventLease('demo.rate.gain').maxDurationMs, 3600000)
assert.deepEqual(registries.allEventOptionSources().map((s) => s.id), ['demo.options.hues'])
})
test('the catalog never carries a callable, whichever registration it came from', async () => {
// The rule every registry in this file already keeps, restated for four new
// shapes at once: these objects LEAVE THE PROCESS, and the browser's whole
// relationship with any of them is naming one by id. A `perform` or a
// `resolve` riding out would make §F's "a module registers actions server-side
// and adds no routes for them" false in the one direction nobody would notice.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'demo.a', label: 'A', risk: 'notify', reversible: 'none',
async perform() { return { ok: true } },
}])
api.registerEventLeases([{
id: 'demo.l', label: 'L', type: 'bool', maxDurationMs: 1000,
async read() { return { ok: true } },
async apply() { return { ok: true } },
async restore() { return { ok: true } },
}])
api.registerEventOptionSources([
{ id: 'demo.o', label: 'O', async resolve() { return [] } },
])
}`))
for (const a of registries.allEventActions()) {
assert.equal(a.perform, undefined)
assert.equal(a.revert, undefined)
assert.equal(a.cost, undefined)
}
for (const l of registries.allEventLeases()) {
assert.equal(l.read, undefined)
assert.equal(l.apply, undefined)
assert.equal(l.restore, undefined)
}
for (const s of registries.allEventOptionSources()) assert.equal(s.resolve, undefined)
})
test('a second call is a module changing its mind, and it fails the module alone', () => {
// `once`, on all four, for the reason every batch registration takes it: a
// batch is a module's complete statement about what it declares. And the
// failure is the loader's §4.4 guarantee — recorded against the module, the
// site still up, nothing committed.
const record = loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventBudgets([{ id: 'demo.a', label: 'A', unit: 'count' }])
api.registerEventBudgets([{ id: 'demo.b', label: 'B', unit: 'count' }])
}`)
assert.equal(record.state, 'startup_failed')
assert.match(record.reason, /registerEventBudgets\(\) called twice/)
// Validate-then-commit, per registrant: the FIRST batch is gone too.
assert.equal(registries.eventBudget('demo.a'), null)
})
test('a module may not name a budget outside its own prefix', () => {
const record = loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventBudgets([{ id: 'uo.creatures', label: 'Creatures', unit: 'count' }])
}`)
assert.equal(record.state, 'startup_failed')
assert.match(record.reason, /not namespaced "demo\."/)
})
test('a module that registers nothing at all is a module, not a failure', () => {
// §F states it once because it governs every member: everything a module
// registers is optional, and core with none of it is still an event engine
// that can announce, wait, cue a human and publish results.
const record = loadModule('quiet', 'module.exports = () => {}')
assertRegistered(record)
assert.deepEqual(registries.allEventBudgets(), [])
assert.deepEqual(registries.allEventActions().map((a) => a.id), [])
})
// ── The envelope: no failure shape reads as success ────────────────────────
test('every shape a module failure can take is dispatched as a retry, not a success', async () => {
// The five §F names, answered by a real module through the real dispatcher.
// Written as one module with five verbs rather than five modules, because what
// is under test is the CLASSIFIER and a module per shape would be four extra
// loader scans saying nothing.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
const verb = (id, perform) => ({
id, label: id, risk: 'notify', reversible: 'none', budgetMs: 200, perform,
})
api.registerEventActions([
verb('demo.rejects', async () => { return Promise.reject(new Error('the socket went away')) }),
verb('demo.throws', async () => { throw new Error('a typo in the module') }),
verb('demo.hangs', () => new Promise(() => {})),
verb('demo.lies', async () => 'fine'),
verb('demo.forgets', async () => ({ resources: [] })),
])
}`))
for (const id of ['demo.rejects', 'demo.throws', 'demo.hangs', 'demo.lies', 'demo.forgets']) {
const result = await dispatch.dispatchStep(step(id), { run: RUN })
assert.equal(result.outcome, 'retry', `${id} must not read as success`)
assert.ok(result.error, `${id} must say what went wrong`)
}
})
test('a module that means "never" says so, and only then is it terminal', async () => {
// The inverse of the rule above, and the reason `retry` is opted OUT of rather
// than into: an envelope that forgot to say anything gets the benefit of the
// doubt on the transient question, so only a module that states `retry: false`
// gets a step marked as never going to work.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([
{ id: 'demo.never', label: 'Never', risk: 'notify', reversible: 'none',
async perform() { return { ok: false, retry: false, error: 'there is no such gate' } } },
{ id: 'demo.later', label: 'Later', risk: 'notify', reversible: 'none',
async perform() { return { ok: false, error: 'the shard is restarting' } } },
])
}`))
const never = await dispatch.dispatchStep(step('demo.never'), { run: RUN })
assert.equal(never.outcome, 'terminal')
assert.equal(never.error, 'there is no such gate')
const later = await dispatch.dispatchStep(step('demo.later'), { run: RUN })
assert.equal(later.outcome, 'retry')
})
test('budgetMs is the contracts deadline, and a wedged module does not hold the tick', async () => {
// Declared by the module, bounded by the registry, enforced by the dispatcher.
// Without it a `perform()` awaiting a socket that never answers holds the
// step's claim until the lease expires and the reclaim re-dispatches it, which
// is how one wedged sidecar becomes an infinite loop rather than a failed step.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'demo.hangs', label: 'Hangs', risk: 'notify', reversible: 'none',
budgetMs: 120,
perform: () => new Promise(() => {}),
}])
}`))
const started = Date.now()
const result = await dispatch.dispatchStep(step('demo.hangs'), { run: RUN })
assert.equal(result.outcome, 'retry')
assert.match(result.error, /exceeded its 120ms budget/)
assert.ok(Date.now() - started < 2000, 'the runner stopped waiting long before any lease would expire')
})
test('the two success shapes that mean "not finished" reach a module through the same door', async () => {
// §F, and the org lead's 2026-09-02 decision: both are ordinary envelope
// members rather than special cases keyed on an action id, so the runner never
// names a verb — and a module's own long-running action gets `await: 'human'`
// and `holdFor` for free, exactly as `core.cue` and `core.wait` do.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([
{ id: 'demo.parks', label: 'Parks', risk: 'notify', reversible: 'none',
async perform() { return { ok: true, await: 'human' } } },
{ id: 'demo.holds', label: 'Holds', risk: 'notify', reversible: 'none',
async perform() { return { ok: true, holdFor: 300 } } },
])
}`))
const parked = await dispatch.dispatchStep(step('demo.parks'), { run: RUN })
assert.equal(parked.outcome, 'parked')
const held = await dispatch.dispatchStep(step('demo.holds'), { run: RUN })
assert.equal(held.outcome, 'done')
assert.equal(held.holdSeconds, 300)
})
// ── verify: true is a parameter a module must honour ───────────────────────
test('verify rides through to the module unchanged, and a dry run changes nothing', async () => {
// §F: *"`verify: true` must change nothing and must answer honestly"*. Core
// cannot enforce the first half — only the module knows what its own writes
// are — so what IS testable is that the flag arrives, and that it arrives down
// the same dispatcher the real run uses. A dry run down a second code path is
// a dry run of the second path.
const written = []
global.__rgDemoWrites = written
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'demo.writes', label: 'Writes', risk: 'change', reversible: 'none',
async perform({ verify, params }) {
if (verify) return { ok: true, wouldWrite: params.what }
global.__rgDemoWrites.push(params.what)
return { ok: true }
},
}])
}`))
const dry = await dispatch.dispatchStep(step('demo.writes', { what: 'a gate' }), {
run: RUN,
verify: true,
})
assert.equal(dry.outcome, 'done')
assert.deepEqual(written, [], 'a dry run wrote something')
await dispatch.dispatchStep(step('demo.writes', { what: 'a gate' }), { run: RUN })
assert.deepEqual(written, ['a gate'])
delete global.__rgDemoWrites
})
test('the whole envelope reaches the module, idempotency key included', async () => {
// The key is the module's half of a retry that is safe on the game side, and a
// module cannot pass it down its own wire if core does not hand it over. It is
// asserted here rather than in the runner's tests because THIS is the surface
// a module author reads.
global.__rgDemoEnvelope = null
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'demo.echo', label: 'Echo', risk: 'notify', reversible: 'none',
async perform(envelope) { global.__rgDemoEnvelope = envelope; return { ok: true } },
}])
}`))
await dispatch.dispatchStep(step('demo.echo', { a: 1 }), { run: { id: 7, scope: 'atlantic' } })
const envelope = global.__rgDemoEnvelope
assert.deepEqual(envelope, {
runId: 7,
stepId: 1,
idempotencyKey: 'k-1',
scope: 'atlantic',
params: { a: 1 },
actor: null,
verify: false,
})
delete global.__rgDemoEnvelope
})
// ── Budgets: a module cannot spend what it did not declare ─────────────────
test('a cost naming a dimension the module never declared is refused', async () => {
// §F, fail closed (org lead, 2026-09-03). The module here is not malicious and
// not exotic — it is one that declared two budgets and priced a third, which is
// what a rename looks like. The refusal has its own code because the fix is a
// module's declaration, not a deployment's cap.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventBudgets([{ id: 'demo.wisps', label: 'Wisps', unit: 'count' }])
api.registerEventActions([{
id: 'demo.summon', label: 'Summon', risk: 'notify', reversible: 'none',
cost: () => ({ 'demo.wraiths': 2 }),
async perform() { return { ok: true } },
}])
}`))
// `settings: null` because this file has no database: passing it explicitly is
// what tells `mayInvoke` not to go and read the switchboard row, and the
// enablement layer answers before the cap layer either way.
const verdict = await authorize.mayInvoke({
action: registries.eventAction('demo.summon'),
params: {},
settings: null,
})
assert.equal(verdict.ok, false)
assert.equal(verdict.code, 'undeclared')
assert.match(verdict.reason, /demo\.wraiths/)
})
test('a declared dimension is priced, named and unbounded until an operator says otherwise', async () => {
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventBudgets([{ id: 'demo.wisps', label: 'Wisps summoned', unit: 'count' }])
api.registerEventActions([{
id: 'demo.summon', label: 'Summon', risk: 'notify', reversible: 'none',
cost: (p) => ({ 'demo.wisps': p.count }),
params: [{ name: 'count', type: 'int', required: true, example: 4 }],
async perform() { return { ok: true } },
}])
}`))
// What the switchboard renders: the id the action prices, dressed with what the
// module called it. Discovered by pricing the declared example, which is why
// §F makes `example` required on every param.
assert.deepEqual(authorize.budgetsOf(registries.eventAction('demo.summon')), [
{ id: 'demo.wisps', label: 'Wisps summoned', unit: 'count', registered: true },
])
// And with no settings row and no run, the answer is yes: a declared budget is
// a dimension that can be counted, not a bound that has been set.
const verdict = await authorize.mayInvoke({
action: registries.eventAction('demo.summon'),
params: { count: 4 },
settings: null,
})
assert.deepEqual(verdict, { ok: true, cost: { 'demo.wisps': 4 } })
})
// ── Option sources ─────────────────────────────────────────────────────────
test('a module answers its own option source, and core normalises what comes back', async () => {
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventOptionSources([{
id: 'demo.options.hues', label: 'Hues',
async resolve() {
return [
{ value: 1157, label: 'Blood', group: 'Reds' },
{ value: '2213', label: 'Ice' },
{ value: '', label: 'a blank nobody can pick' },
'not an option at all',
]
},
}])
}`))
const answer = await registries.resolveOptionSource('demo.options.hues')
assert.equal(answer.ok, true)
assert.equal(answer.owner, 'demo')
// Coerced to strings, because this array is rendered into a `<select>` and
// submitted back as text; and the two entries that could not become an option
// are dropped rather than becoming one that submits "undefined".
assert.deepEqual(answer.options, [
{ value: '1157', label: 'Blood', group: 'Reds' },
{ value: '2213', label: 'Ice' },
])
})
test('every way an option source can fail degrades the field rather than breaking it', async () => {
// §F: a refusal degrades the field to free text with a visible warning. So none
// of these throws, and each says something an operator can act on — an
// authoring form a shard outage can make unusable is a worse failure than the
// typo the dropdown exists to prevent.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventOptionSources([
{ id: 'demo.options.throws', label: 'Throws', async resolve() { throw new Error('sidecar down') } },
{ id: 'demo.options.lies', label: 'Lies', async resolve() { return { nope: true } } },
{ id: 'demo.options.empty', label: 'Empty', async resolve() { return [] } },
])
}`))
const threw = await registries.resolveOptionSource('demo.options.throws')
assert.equal(threw.ok, false)
assert.match(threw.reason, /could not be read/)
const lied = await registries.resolveOptionSource('demo.options.lies')
assert.equal(lied.ok, false)
assert.match(lied.reason, /no option list/)
// An EMPTY list is not a failure. A source that legitimately has nothing to
// offer today — no landmarks configured yet — must not be reported as broken,
// because the two have different fixes.
const empty = await registries.resolveOptionSource('demo.options.empty')
assert.equal(empty.ok, true)
assert.deepEqual(empty.options, [])
const missing = await registries.resolveOptionSource('demo.options.gone')
assert.equal(missing.ok, false)
assert.match(missing.reason, /no module registers/)
})
// ── Uninstall is dormancy, never an error ──────────────────────────────────
test('an action whose module is gone goes dormant, and a step naming it fails terminal with the module named', async () => {
// §F and §L, verbatim: *"a step naming one fails terminal with the module named
// and the run degrades — never a silent skip"*. The scenario is real and
// ordinary: a module was uninstalled between the publish that pinned the
// version and the run that dispatches it.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'demo.summon', label: 'Summon', risk: 'notify', reversible: 'none',
async perform() { return { ok: true } },
}])
}`))
assert.equal(registries.isEventAction('demo.summon'), true)
// The uninstall: a fresh scan of a directory the module is no longer in.
fs.rmSync(path.join(tmpRoot, 'demo'), { recursive: true, force: true })
loadModule('other', 'module.exports = () => {}')
assert.equal(registries.isEventAction('demo.summon'), false)
const result = await dispatch.dispatchStep(step('demo.summon'), { run: RUN })
assert.equal(result.outcome, 'terminal')
assert.equal(result.dormant, true)
assert.match(result.error, /no module registers "demo\.summon"/)
})