From fd9fb50351813e44e74b10f97e6b9fadf73ba02c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 3 Sep 2026 14:15:04 -0500 Subject: [PATCH] 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 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL --- client/src/api/client.js | 7 + client/src/modules/version.js | 11 +- .../src/routes/admin/views/EventActions.jsx | 62 ++- client/src/routes/admin/views/EventEditor.jsx | 162 +++++- server/routes.guards.json | 9 + server/routes.manifest.json | 4 + server/src/config/coreEventActions.js | 32 +- server/src/events/authorize.js | 73 ++- server/src/events/spec.js | 28 + server/src/modules/loader.js | 35 ++ server/src/modules/registries.js | 347 ++++++++++++ server/src/modules/version.js | 23 +- .../src/router/v1/admin/events.controller.js | 53 +- server/src/router/v1/admin/events.router.js | 25 +- server/swagger/swagger-output.json | 83 +++ server/test/eventActionRegistry.test.js | 134 +++++ server/test/eventAuthorize.test.js | 143 +++-- server/test/eventModuleContract.test.js | 515 ++++++++++++++++++ server/test/eventRunner.test.js | 78 ++- server/test/eventVerify.test.js | 52 +- server/test/eventsAdmin.test.js | 63 ++- server/test/eventsRoles.test.js | 3 + 22 files changed, 1825 insertions(+), 117 deletions(-) create mode 100644 server/test/eventModuleContract.test.js diff --git a/client/src/api/client.js b/client/src/api/client.js index 5a1e939..7ffba08 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -486,6 +486,13 @@ export const api = { archiveEvent: (id) => req(`/admin/events/${id}`, { method: 'DELETE' }), listEventVersions: (id) => req(`/admin/events/${id}/versions`), eventCatalog: () => req('/admin/events/catalog'), + // Phase 7. The values behind a param's `source` — resolved by the module that + // registered the source, on a request of its own rather than inside the + // catalog, because a source can be slow or down and must not take the whole + // editor with it. A refusal comes back 200 with `ok: false`, so this never + // throws for the case the screen is meant to render: the field degrades to + // free text with the reason beside it. + eventOptions: (sourceId) => req(`/admin/events/catalog/options/${encodeURIComponent(sourceId)}`), // Phase 6. The dry run is admin+editor: it dispatches nothing, and the author // who wrote the definition is who should be able to price it against the caps // before asking an admin to publish it. A report with findings comes back 200 diff --git a/client/src/modules/version.js b/client/src/modules/version.js index bd6f97b..c2f0754 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -11,6 +11,15 @@ // that the two files can drift, so a test asserts they agree // (client/test/moduleRegistry.test.js) rather than trusting a bump to remember // both. +// 1.10.0 — the event contract opens to modules (EVENTS.md §F, EVENTS_PLAN.md +// Phase 7): a module may register event actions, budget dimensions, leases and +// param option sources. All four are server-side registrations and nothing on +// `window.__rg` changed — but what they produce is met on this half, in the step +// editor: an option source is what turns a param from a text box into a dropdown +// of real values, and a budget's label and unit are what the switchboard's cap +// box says beside its number. This file bumps for the reason at the top: the two +// halves state ONE version, and a module declares one `coreApi` range against +// both. // 1.9.0 - a module may ship its own message bodies and rules: // `api.registerEngagementSeeds({ templates, ruleGroups })` (ENGAGEMENT.md Phase // 11b, decision 7). Nothing on this half changed - a seed is server-side data @@ -65,4 +74,4 @@ // but the two halves state ONE version: a module declares a single coreApi range // and is served one chunk, so a client that claimed 1.0.0 while the server // answered 1.1.0 would be two answers to one question. -export const MODULE_API_VERSION = '1.9.0' +export const MODULE_API_VERSION = '1.10.0' diff --git a/client/src/routes/admin/views/EventActions.jsx b/client/src/routes/admin/views/EventActions.jsx index 8d8fd7a..dadfb29 100644 --- a/client/src/routes/admin/views/EventActions.jsx +++ b/client/src/routes/admin/views/EventActions.jsx @@ -99,7 +99,7 @@ export default function EventActions() { await load() setDrafts((d) => { const next = { ...d } - for (const dimension of action.dimensions) delete next[`${action.id}:${dimension}`] + for (const d of action.dimensions) delete next[`${action.id}:${d.id}`] return next }) setNotice(`Saved ${action.label}.`) @@ -113,7 +113,7 @@ export default function EventActions() { /** The caps this row would save: the drafts on top of what is stored. */ const capsOf = (action) => { const out = {} - for (const dimension of action.dimensions) { + for (const { id: dimension } of action.dimensions) { const draft = drafts[`${action.id}:${dimension}`] const value = draft !== undefined ? draft : action.caps[dimension] if (value === '' || value === undefined || value === null) continue @@ -130,7 +130,7 @@ export default function EventActions() { } const dirty = (action) => - action.dimensions.some((d) => drafts[`${action.id}:${d}`] !== undefined) + action.dimensions.some((d) => drafts[`${action.id}:${d.id}`] !== undefined) if (loading) return if (error) return @@ -218,20 +218,48 @@ export default function EventActions() { run gets.

- {action.dimensions.map((dimension) => ( -
+ } + if (entry.state === 'failed') { + return ( +
+ {entry.reason} — type the value by hand. +
+ ) + } + if (!entry.options.length) { + return ( +
+ {label} has nothing to offer right now — type the value by hand. +
+ ) + } + + const grouped = entry.options.some((o) => o.group) + const groups = grouped + ? [...new Set(entry.options.map((o) => o.group || 'Other'))] + : [] + + return ( + + ) +} + const DORMANT_NOTE = 'The module that registered this action is not installed. The step is kept exactly as authored — nothing was dropped — but the definition cannot be published until it is resolved.' @@ -118,6 +182,94 @@ export default function EventEditor() { const triggers = useMemo(() => catalog?.triggers || [], [catalog]) const triggerById = useMemo(() => new Map(triggers.map((t) => [t.id, t])), [triggers]) + // Phase 7. source id -> { state: 'loading' | 'ok' | 'failed', options, reason }. + // + // Resolved LAZILY, one request per source, and only for the sources the steps + // on this page actually name. A definition uses two or three of them; a + // deployment with a game module installed may register a dozen, and asking a + // shard for eight hundred creature names to draw a form that needs none of + // them is a page that opens slowly for no one's benefit. + const [sources, setSources] = useState({}) + + // **The guard is a ref, and it has to be.** `setSources` QUEUES its updater + // rather than running it, so a "have I already asked for this?" check written + // inside the updater cannot be read on the next line — it has not run yet. The + // first draft did exactly that and the field sat on *Reading the list…* for + // ever, having never made the request at all: state is the wrong tool for a + // question that must be answered synchronously, at the call. + const requested = useRef(new Set()) + const loadSource = useCallback(async (sourceId) => { + if (requested.current.has(sourceId)) return + requested.current.add(sourceId) + setSources((s) => ({ ...s, [sourceId]: { state: 'loading', options: [] } })) + try { + const answer = await api.admin.eventOptions(sourceId) + setSources((s) => ({ + ...s, + [sourceId]: answer?.ok + ? { state: 'ok', label: answer.label, options: answer.options || [] } + : { state: 'failed', options: [], reason: answer?.reason || 'this list could not be read' }, + })) + } catch (err) { + // The route answers a refusal with a 200, so reaching here means the + // REQUEST failed rather than the source — and the field's behaviour is the + // same either way: it degrades to free text and says why. + setSources((s) => ({ + ...s, + [sourceId]: { state: 'failed', options: [], reason: err.message || 'this list could not be read' }, + })) + } + }, []) + + /** + * Write one param into a step's JSON box, from a picked option. + * + * Re-serialising the whole object rather than splicing text: the box holds an + * object the save path parses, and a string edit that produced valid-looking + * JSON with a duplicate key would be a value the editor and the server read + * differently. `2` because that is what `blankStep` writes, so picking a value + * does not reformat the box under the author's cursor. + */ + const pickParam = (pi, si, step, name, value) => { + let parsed + try { + parsed = JSON.parse(step.paramsText || '{}') + } catch { + return + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return + setStep(pi, si, { paramsText: JSON.stringify({ ...parsed, [name]: value }, null, 2) }) + } + + /** Does this step's JSON box currently hold an object we can write into? */ + const paramsParse = (step) => { + try { + const parsed = JSON.parse(step.paramsText || '{}') + return Boolean(parsed) && typeof parsed === 'object' && !Array.isArray(parsed) + } catch { + return false + } + } + + // Phase 7. Every option source the steps on this page name, resolved once. + // + // An effect rather than a lookup at render time, because resolving one is a + // request and a request started during render is a render with a side effect. + // `sources` is deliberately NOT a dependency: `loadSource` keeps its own ref of + // what it has already asked for, so re-running this on every answer would be a + // pass over the same set, changing nothing. + useEffect(() => { + const wanted = new Set() + for (const phase of form?.phases || []) { + for (const step of phase.steps || []) { + for (const p of actionById.get(step.actionId)?.params || []) { + if (p.source) wanted.add(p.source) + } + } + } + for (const id of wanted) loadSource(id) + }, [form, actionById, loadSource]) + const set = (patch) => setForm((f) => ({ ...f, ...patch })) const setPhase = (pi, patch) => @@ -742,6 +894,14 @@ export default function EventEditor() { {p.description}
e.g. {JSON.stringify(p.example)}
+ {p.source && ( + pickParam(pi, si, step, p.name, v)} + /> + )} ))} diff --git a/server/routes.guards.json b/server/routes.guards.json index 6127ca2..63d1265 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -536,6 +536,15 @@ "requireAuth" ] }, + { + "method": "GET", + "path": "/api/v1/admin/events/catalog/options/:sourceId", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, { "method": "GET", "path": "/api/v1/admin/events/runs", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index c115a80..5a85a9e 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -237,6 +237,10 @@ "method": "GET", "path": "/api/v1/admin/events/catalog" }, + { + "method": "GET", + "path": "/api/v1/admin/events/catalog/options/:sourceId" + }, { "method": "GET", "path": "/api/v1/admin/events/runs" diff --git a/server/src/config/coreEventActions.js b/server/src/config/coreEventActions.js index cb6adc0..1d171f0 100644 --- a/server/src/config/coreEventActions.js +++ b/server/src/config/coreEventActions.js @@ -51,10 +51,17 @@ const ACTIONS = [ // A leg id, checked against the announce-leg registry at dispatch rather // than here: legs are registered by modules, and this file is evaluated // before any module has registered anything. + // + // **`source` is what moves that check earlier** (Phase 7). The dispatch + // check stays — a module can boot between authoring and the run — but + // until now a typo here was caught mid-run and nowhere else, which is the + // defect Phase 6's walk hit: an announce leg "site" no module registers, + // found by a dry run rather than by the form that accepted it. name: 'leg', type: 'string', required: true, example: 'discord', + source: 'core.options.legs', description: 'The announce leg to publish on. Registered legs only.', }, { @@ -209,4 +216,27 @@ const ACTIONS = [ }, ] -module.exports = { ACTIONS } +// ── Core's own param option sources (§F, Phase 7) ────────────────── +// +// One, and it is core's half of the seam it hands a module on the same boot: a +// param's `source` names a registered option source, core asks it for values, and +// the authoring form renders a dropdown instead of a text box. +// +// **The legs are already a registry with labels in it**, so this costs nothing +// new — which is what makes it the right first exercise. `resolve()` is called +// per request rather than read once, for the same reason `core.announce` looks a +// leg up inside `perform()`: a leg registered by a module that booted after this +// file was evaluated must still appear, and a module uninstalled since must stop +// appearing. +const OPTION_SOURCES = [ + { + id: 'core.options.legs', + label: 'Announce legs', + description: 'Every delivery leg registered on this deployment right now.', + async resolve() { + return registries.announceLegs().map((l) => ({ value: l.leg, label: l.label || l.leg })) + }, + }, +] + +module.exports = { ACTIONS, OPTION_SOURCES } diff --git a/server/src/events/authorize.js b/server/src/events/authorize.js index 8e09186..acf88b7 100644 --- a/server/src/events/authorize.js +++ b/server/src/events/authorize.js @@ -127,12 +127,14 @@ function priceOf(action, params) { * The dimensions an action can spend, discovered by pricing its declared * examples. * - * **This is a Phase 6 stand-in with a Phase 7 replacement already named.** §F's - * `registerEventBudgets` is what will declare a dimension's id, label and unit, - * and it arrives with the module contract. Until then the switchboard still has - * to render a cap editor, and it cannot offer a box for a dimension it cannot - * name — so core prices each action's own `example` values, which is a use every - * param already has a required `example` for. + * **Phase 7 replaced half of this and deliberately kept the other half.** §F's + * `registerEventBudgets` now declares a dimension's id, label and unit, so the + * switchboard no longer has to invent a name for a box — see `budgetsOf` below, + * which is what the screen reads. What a registry cannot answer is *which* + * dimensions THIS action spends, because `cost` is a function of params (§F) and + * the only honest way to ask it is to call it. So the discovery stays: core + * prices each action's own `example` values, which is a use every param already + * has a required `example` for. * * It is honest about its limits: a `cost()` that returns different dimension KEYS * for different params under-reports here. That costs an operator a cap box on @@ -148,6 +150,45 @@ function dimensionsOf(action) { return priced ? Object.keys(priced).sort() : [] } +/** + * The same dimensions, dressed with what the registry says they are called. + * + * The switchboard's read (Phase 7). `registered: false` is the case worth having + * a field for: an action that prices a dimension no module declares is a + * DECLARATION ERROR — `mayInvoke` refuses it, the dry run fails on it, and the + * save refuses it — so the screen has to be able to show the operator the reason + * their action will not run, rather than silently listing one fewer cap box than + * the action has dimensions. Hiding it would make a broken module look like a + * cheap one. + */ +function budgetsOf(action) { + return dimensionsOf(action).map((id) => { + const declared = registries.eventBudget(id) + return declared + ? { id, label: declared.label, unit: declared.unit, registered: true } + : { id, label: id, unit: '', registered: false } + }) +} + +/** + * Which of these dimensions does nobody declare? (§F, org lead 2026-09-03.) + * + * Fail closed. §F's *"a module cannot spend a budget it did not declare"* is a + * rule only if something asks, and this is what asks — from `mayInvoke` at + * dispatch and at the dry run, and from the spec validator at save. Three places + * because they answer at three different moments and only the first of them is + * cheap: catching it at save costs an editor a red line, catching it at dispatch + * costs a run a refused step at two in the morning. + * + * Not folded into `priceOf`, which answers *what does this cost* and should keep + * answering only that: a cost of 12 creatures is a true statement about the + * action whether or not anyone declared the dimension, and the two facts have + * different fixes. + */ +function undeclaredDimensions(cost) { + return Object.keys(cost || {}).filter((d) => !registries.isEventBudget(d)) +} + /** * The effective per-run cap for each dimension a set of steps will spend. * @@ -245,6 +286,24 @@ async function mayInvoke({ const dimensions = Object.keys(cost) if (!dimensions.length) return { ok: true, cost } + // Before any cap arithmetic, because a dimension nobody declared has no cap to + // be under and no meter to draw on — asking "is 12 within the limit" about a + // resource core has never been told the name of would be answering a question + // that has not been asked yet. It is also the honest reading of the refusal: + // this is a module whose declaration is incomplete, not a deployment whose + // allowance is spent, and an operator who is told the second will go and raise + // a cap that changes nothing. + const undeclared = undeclaredDimensions(cost) + if (undeclared.length) { + return { + ok: false, + code: 'undeclared', + reason: `spends "${undeclared[0]}", which no module declares as a budget`, + dimension: undeclared[0], + requested: cost[undeclared[0]], + } + } + if (!run) { // No run, so nothing to draw on: the question is whether the cost could EVER // fit, which is what the dry run and the editor are asking. A cost larger @@ -333,6 +392,8 @@ module.exports = { isEnabled, priceOf, dimensionsOf, + budgetsOf, + undeclaredDimensions, effectiveCaps, changesWorld, WORLD_CHANGING, diff --git a/server/src/events/spec.js b/server/src/events/spec.js index b20c119..f7beee7 100644 --- a/server/src/events/spec.js +++ b/server/src/events/spec.js @@ -29,6 +29,11 @@ // computes there. const registries = require('../modules/registries') +// For `priceOf` and `undeclaredDimensions` only — the save-time half of §F's +// fail-closed budget rule (Phase 7). Nothing here reaches the database: +// `authorize` requires two `.db.js` modules and requiring one opens no +// connection, which is the same rule this file already lives under. +const authorize = require('./authorize') const recurrence = require('./recurrence') const conditionGrammar = require('../engagement/conditions') const { checkLiteral } = conditionGrammar @@ -509,6 +514,29 @@ function validate(raw, { knownActionIds = [] } = {}) { const { params, errors: paramErrors } = checkParams(declaration, rawStep.params, spath) errors.push(...paramErrors) + // §F, fail closed (org lead, 2026-09-03): a step may not spend a dimension + // no module declares as a budget. Refused HERE as well as at dispatch + // because this is the cheap moment — the editor is open, the author is + // looking at the step, and the alternative is a run that refuses at two in + // the morning for a reason that was decidable when it was written. + // + // Only when the params validated. Pricing a step whose params were just + // refused would run a module's `cost()` over values core has already said + // are wrong, and report its answer as a second, confusing error about the + // same mistake. + if (!paramErrors.length) { + const priced = authorize.priceOf(declaration, params) + if (priced === null) { + errors.push(`${spath}: "${declaration.label}" could not report what it costs`) + } else { + for (const dimension of authorize.undeclaredDimensions(priced)) { + errors.push( + `${spath}: "${declaration.label}" spends "${dimension}", which no module declares as a budget`, + ) + } + } + } + if (rawStep.onFailure !== undefined && !ON_FAILURE.includes(rawStep.onFailure)) { errors.push(`${spath}.onFailure: must be one of ${ON_FAILURE.join(', ')}`) } diff --git a/server/src/modules/loader.js b/server/src/modules/loader.js index 51bec59..df0024d 100644 --- a/server/src/modules/loader.js +++ b/server/src/modules/loader.js @@ -370,6 +370,41 @@ function buildApi(record) { once('registerEngagementSeeds') record.staged.registerEngagementSeeds(seeds) }, + // The event contract (API 1.10.0, EVENTS.md §F). **This is the seam Phase 1 + // built and did not open**: `registerEventActions` has staged core's three + // actions on every boot since then and no module could reach it, because + // this facade had no method that delegated. The four lines below are what + // Phase 7 ships — core has been going through the same door for six phases, + // so the registry a module now reaches is one that has been exercised on + // every boot rather than one whose first registrant is a stranger. + // + // `once` on all four, for the reason every batch registration above takes + // it: a batch is a module's complete statement about what it declares, and a + // second call is a module changing its mind halfway through `register()` + // rather than adding to it. + // + // The four id spaces are separate and the loader does not police that — + // `registries.apply()` does, per space. An action names a VERB, a budget + // names a RESOURCE, a lease names a VALUE and an option source names a + // CATALOG, so `uo.creatures` may legitimately appear in more than one of + // them and reading that as a collision would forbid the most natural set of + // names a module will ever write. + registerEventActions(actions) { + once('registerEventActions') + record.staged.registerEventActions(actions) + }, + registerEventBudgets(budgets) { + once('registerEventBudgets') + record.staged.registerEventBudgets(budgets) + }, + registerEventLeases(leases) { + once('registerEventLeases') + record.staged.registerEventLeases(leases) + }, + registerEventOptionSources(sources) { + once('registerEventOptionSources') + record.staged.registerEventOptionSources(sources) + }, // The two lifecycle hooks (§2.5). Registered here, dispatched from // lifecycle.js — this file runs with no database and the hooks run with one. // Both are optional: a module with no warm-up and nothing to close simply diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js index 950014e..2823c52 100644 --- a/server/src/modules/registries.js +++ b/server/src/modules/registries.js @@ -138,6 +138,44 @@ const audiences = new Map() // order is what the admin catalog renders in. const eventActions = new Map() +// budget id → { owner, id, label, unit, description } (EVENTS.md §F, Phase 7). +// +// A dimension of consumption — "creatures spawned", "gate uptime" — declared so +// that the switchboard's cap editor has a NAME and a UNIT to put beside a number. +// Phase 6 discovered these by pricing an action's declared `example` values, +// which was a stand-in that could name a dimension and never label it. +// +// **Its own id space**, like `eventActions` above, and §F says why in one line: +// an action names a VERB and a budget names a RESOURCE. Nothing cross-checks the +// two maps, and nothing should. +// +// Data only. There is no function on a budget and nothing here is ever called — +// the module says a dimension exists and what to call it, `cost()` says how much +// of it a step spends, and core owns every piece of arithmetic in between. +const eventBudgets = new Map() + +// lease id → { owner, id, label, type, min, max, maxDurationMs, description, +// read, apply, restore } (§F "Leases: one more declaration", Phase 7). +// +// **Phase 7 registers a lease and nothing acquires one.** Core owns the duration +// and the conflict check, the module owns reading the current value and writing a +// new one — and both halves of that live in the resource ledger, which is Phase +// 8's. What is here is the declaration, its validation and its catalog entry, so +// that the module contract is one version rather than two. +const eventLeases = new Map() + +// source id → { owner, id, label, description, resolve } (§F "Param option +// sources", Phase 7). +// +// What turns an authoring field from a text box into a dropdown of real +// landmarks. Modelled on `audiences` rather than on anything else here, because +// it is the same shape of thing: an id, a label, and a `resolve()` core calls and +// waits for. What differs is the meaning of a refusal — an audience that refuses +// mails nobody, while a source that refuses degrades its field to free text with +// a warning, because refusing to let an operator type a value they already know +// is worse than the typo the dropdown existed to prevent. +const eventOptionSources = new Map() + // owner → { templates: [...], ruleGroups: [...] } (ENGAGEMENT.md Phase 11b, // decision 7). What a module ships as CONTENT rather than as contract: the // bodies its triggers render through, and the rules an operator switches on. @@ -181,6 +219,13 @@ const AUDIENCE_ID = EVENT_ID // spaces"). One grammar, three namespaces — the constant is what makes the // namespace visible at every use site. const ACTION_ID = EVENT_ID +// And three more id spaces on the same grammar, arriving with the module +// contract in Phase 7. Three constants rather than three uses of ACTION_ID, for +// the reason AUDIENCE_ID gets its own: the constant is what makes the namespace +// visible at the use site, so a future divergence has one place to happen. +const BUDGET_ID = EVENT_ID +const LEASE_ID = EVENT_ID +const OPTION_SOURCE_ID = EVENT_ID // A module's claim must carry its id. Core's ids are its own namespace, and the // grandfathered names are the ones that predate all of this. @@ -412,6 +457,86 @@ const eventAction = (id) => eventActions.get(id) || null */ const isEventAction = (id) => eventActions.has(id) +// ── Event budgets, leases and option sources (§F, Phase 7) ───────────── + +/** Every declared budget dimension, in registration order. */ +const allEventBudgets = () => [...eventBudgets.values()] + +/** One dimension's declaration, or null. The label-and-unit lookup. */ +const eventBudget = (id) => eventBudgets.get(id) || null + +/** + * Does anyone declare this dimension right now? + * + * The fail-closed question (org lead, 2026-09-03): a `cost()` naming a dimension + * nobody registered is REFUSED — at save, at the dry run and at dispatch. §F's + * *"a module cannot spend a budget it did not declare"* is only true if something + * asks, and this is what asks. + */ +const isEventBudget = (id) => eventBudgets.has(id) + +/** + * Every lease declaration WITHOUT its callables — what the catalog serves. + * + * Stripped for the reason `perform` is stripped from an action: this object + * leaves the process, and the browser's whole relationship with a lease is naming + * one by id. + */ +const allEventLeases = () => + [...eventLeases.values()].map(({ read, apply: applyValue, restore, ...rest }) => rest) + +/** One lease, callables included. Phase 8's lookup; nothing calls it yet. */ +const eventLease = (id) => eventLeases.get(id) || null + +/** Every option source WITHOUT its resolver — the authoring form's list. */ +const allEventOptionSources = () => + [...eventOptionSources.values()].map(({ resolve, ...rest }) => rest) + +/** + * Resolve one option source, or say why not. Never throws. + * + * **A refusal is not an error here, and that is the design.** §F: a source that + * cannot answer degrades its field to free text with a visible warning rather + * than blocking the form. So every failure shape — no such source, a throw, a + * rejected promise, a non-array — comes back as `{ ok: false, reason }` and the + * caller renders a text box. The alternative is an authoring screen that a + * module's outage can make unusable, for a field whose value the operator very + * often already knows. + * + * The options are normalised rather than trusted. This array is rendered into a + * `` 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"/) +}) diff --git a/server/test/eventRunner.test.js b/server/test/eventRunner.test.js index 88bd6a7..d441766 100644 --- a/server/test/eventRunner.test.js +++ b/server/test/eventRunner.test.js @@ -483,9 +483,43 @@ afterEach(() => { }) /** Register actions the way a module does, through the real staging area. */ +/** + * 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') => { const api = registries.stage(owner) api.registerEventActions(entries) + api.registerEventBudgets(declaredBudgets(entries, owner)) registries.apply(api.staged) } @@ -1161,22 +1195,22 @@ test('a step over its cap is refused with the dimension and the numbers on the l // "You asked for 40 and this deployment allows 30" is an authoring error, and // it has to arrive as those words rather than as a stack trace. register([ - scriptedAction('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) }), + scriptedAction('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) }), ]) - setSwitch('test.spawn', true, { 'x.creatures': 30 }) + setSwitch('test.spawn', true, { 'test.creatures': 30 }) const id = seedRun([{ key: 'main', steps: [step('test.spawn', { count: 12 }, 'skip')] }]) - seedBudget(id, 'x.creatures', { consumed: 28, cap: 30 }) + seedBudget(id, 'test.creatures', { consumed: 28, cap: 30 }) await runner.tick(T0) assert.equal(stepsOf(id)[0].status, 'refused') const line = store.log.find((l) => l.runId === id && l.kind === 'step.refused') assert.equal(line.detail.code, 'cap') - assert.equal(line.detail.dimension, 'x.creatures') + assert.equal(line.detail.dimension, 'test.creatures') assert.equal(line.detail.requested, 12) assert.equal(line.detail.cap, 30) assert.equal(line.detail.consumed, 28) - assert.equal(budgetOf(id, 'x.creatures').consumed, 28, 'a refused step spends nothing') + assert.equal(budgetOf(id, 'test.creatures').consumed, 28, 'a refused step spends nothing') }) test('two steps drawing on one cap spend it once each, and the second is refused when it will not fit', async () => { @@ -1184,8 +1218,8 @@ test('two steps drawing on one cap spend it once each, and the second is refused // spends arriving genuinely at once — is in `eventRunnerSql.test.js`, because // the guard lives in a WHERE and a stub reproduces the reading rather than the // server. - register([scriptedAction('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })]) - setSwitch('test.spawn', true, { 'x.creatures': 30 }) + register([scriptedAction('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })]) + setSwitch('test.spawn', true, { 'test.creatures': 30 }) const id = seedRun([ { key: 'main', @@ -1196,7 +1230,7 @@ test('two steps drawing on one cap spend it once each, and the second is refused ], }, ]) - seedBudget(id, 'x.creatures', { consumed: 0, cap: 30 }) + seedBudget(id, 'test.creatures', { consumed: 0, cap: 30 }) await runner.tick(T0) @@ -1204,7 +1238,7 @@ test('two steps drawing on one cap spend it once each, and the second is refused assert.equal(s[0].status, 'done') assert.equal(s[1].status, 'refused', 'the second 20 does not fit under 30') assert.equal(s[2].status, 'done', 'and a later step that DOES fit still runs') - assert.equal(budgetOf(id, 'x.creatures').consumed, 30) + assert.equal(budgetOf(id, 'test.creatures').consumed, 30) }) test('a retry does not pay the cap twice', async () => { @@ -1212,36 +1246,36 @@ test('a retry does not pay the cap twice', async () => { // idempotent operation against the same key, and charging a cap for a flaky // socket would exhaust a deployment's allowance through unreliability rather // than through effect. - register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })]) - setSwitch('test.spawn', true, { 'x.creatures': 30 }) + register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5 }) })]) + setSwitch('test.spawn', true, { 'test.creatures': 30 }) scripted['test.spawn'] = { calls: [], answers: [{ ok: false, retry: true, error: 'shard busy' }] } const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }]) - seedBudget(id, 'x.creatures', { consumed: 0, cap: 30 }) + seedBudget(id, 'test.creatures', { consumed: 0, cap: 30 }) await runner.tick(T0) assert.equal(stepsOf(id)[0].status, 'pending') - assert.equal(budgetOf(id, 'x.creatures').consumed, 5, 'the first attempt spends') + assert.equal(budgetOf(id, 'test.creatures').consumed, 5, 'the first attempt spends') // Past the retry backoff: the second attempt succeeds and must not spend again. await runner.tick(later(61_000)) assert.equal(stepsOf(id)[0].status, 'done') - assert.equal(budgetOf(id, 'x.creatures').consumed, 5, 'the retry must not pay twice') + assert.equal(budgetOf(id, 'test.creatures').consumed, 5, 'the retry must not pay twice') }) test('a step that spent and then failed for good keeps its spend', async () => { // The corollary, and it is deliberate: the attempt may have half-run, and a // refund would be core asserting that it did not. - register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })]) - setSwitch('test.spawn', true, { 'x.creatures': 30 }) + register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5 }) })]) + setSwitch('test.spawn', true, { 'test.creatures': 30 }) scripted['test.spawn'] = { calls: [], answer: { ok: false, retry: false, error: 'no' } } const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }]) - seedBudget(id, 'x.creatures', { consumed: 0, cap: 30 }) + seedBudget(id, 'test.creatures', { consumed: 0, cap: 30 }) await runner.tick(T0) assert.equal(stepsOf(id)[0].status, 'failed') - assert.equal(budgetOf(id, 'x.creatures').consumed, 5) + assert.equal(budgetOf(id, 'test.creatures').consumed, 5) }) test('a step spending a dimension its run has no budget row for is refused', async () => { @@ -1249,7 +1283,7 @@ test('a step spending a dimension its run has no budget row for is refused', asy // dimension seeded — uncapped ones included, as a row with a NULL cap — so a // missing row means the step is spending something its own version never // declared. - register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.ghosts': 1 }) })]) + register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'test.ghosts': 1 }) })]) setSwitch('test.spawn', true) const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }]) @@ -1261,15 +1295,15 @@ test('a step spending a dimension its run has no budget row for is refused', asy }) test('an uncapped dimension counts without ever refusing', async () => { - register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 99 }) })]) + register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 99 }) })]) setSwitch('test.spawn', true) const id = seedRun([{ key: 'main', steps: [step('test.spawn'), step('test.spawn')] }]) - seedBudget(id, 'x.creatures', { consumed: 0, cap: null }) + seedBudget(id, 'test.creatures', { consumed: 0, cap: null }) await runner.tick(T0) assert.deepEqual(stepsOf(id).map((s) => s.status), ['done', 'done']) - assert.equal(budgetOf(id, 'x.creatures').consumed, 198) + assert.equal(budgetOf(id, 'test.creatures').consumed, 198) }) test('the runner never re-checks the role of whoever started the run', async () => { diff --git a/server/test/eventVerify.test.js b/server/test/eventVerify.test.js index 3eb529d..2117485 100644 --- a/server/test/eventVerify.test.js +++ b/server/test/eventVerify.test.js @@ -48,9 +48,43 @@ afterEach(() => { 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') => { const api = registries.stage(owner) api.registerEventActions(entries) + api.registerEventBudgets(declaredBudgets(entries, owner)) registries.apply(api.staged) } @@ -94,8 +128,8 @@ test('every step is dispatched with verify true, and nothing is asked to act', a test('the cost of the whole plan is added up across steps, and a total over the cap is a finding', async () => { // The one check that only exists here. Each of the three steps fits under 30 on // its own; together they do not. - register([recorder('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })]) - setSetting('test.spawn', true, { 'x.creatures': 30 }) + register([recorder('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })]) + setSetting('test.spawn', true, { 'test.creatures': 30 }) const report = await verifySpec( spec([ @@ -109,31 +143,31 @@ test('the cost of the whole plan is added up across steps, and a total over the assert.equal(report.ok, false) const total = report.findings.find((f) => f.code === 'cap-total') assert.ok(total, 'the whole-plan total must be its own finding') - assert.match(total.message, /asks for 45 of "x.creatures" across all its steps, and this deployment allows 30/) + assert.match(total.message, /asks for 45 of "test.creatures" across all its steps, and this deployment allows 30/) assert.deepEqual(report.cost, [ - { dimension: 'x.creatures', total: 45, cap: 30, from: 'test.spawn', over: true }, + { dimension: 'test.creatures', total: 45, cap: 30, from: 'test.spawn', over: true }, ]) }) test('a plan that fits reports its cost without a finding', async () => { - register([recorder('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })]) - setSetting('test.spawn', true, { 'x.creatures': 30 }) + register([recorder('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })]) + setSetting('test.spawn', true, { 'test.creatures': 30 }) const report = await verifySpec(spec([step('test.spawn', { count: 12 }), step('test.spawn', { count: 8 })]), { user: ADMIN, }) assert.equal(report.ok, true) - assert.deepEqual(report.cost, [{ dimension: 'x.creatures', total: 20, cap: 30, from: 'test.spawn', over: false }]) + assert.deepEqual(report.cost, [{ dimension: 'test.creatures', total: 20, cap: 30, from: 'test.spawn', over: false }]) }) test('an uncapped dimension is reported with its total and no cap', async () => { // Worth showing rather than hiding: "this event will spawn 40 creatures and // nothing bounds that" is exactly what an operator opening the switchboard // wants to have seen first. - register([recorder('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 40 }) })]) + register([recorder('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 40 }) })]) setSetting('test.spawn', true, {}) const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN }) assert.equal(report.ok, true) - assert.deepEqual(report.cost, [{ dimension: 'x.creatures', total: 40, cap: null, from: null, over: false }]) + assert.deepEqual(report.cost, [{ dimension: 'test.creatures', total: 40, cap: null, from: null, over: false }]) }) test('a step naming an action nobody registers is a dormant finding, and the rest are still checked', async () => { diff --git a/server/test/eventsAdmin.test.js b/server/test/eventsAdmin.test.js index c8dd14c..ff3179d 100644 --- a/server/test/eventsAdmin.test.js +++ b/server/test/eventsAdmin.test.js @@ -420,9 +420,41 @@ test('the catalog serves the registry, callables stripped, with its vocabularies for (const action of res.body.actions) assert.equal(action.perform, undefined) assert.deepEqual(res.body.risks, ['notify', 'inspect', 'change', 'irreversible']) assert.deepEqual(res.body.onFailure, ['skip', 'pause', 'abort_run']) - // Phase 1 is honest about what it does not have: budget dimensions arrive with - // the module contract, so the catalog does not pretend to carry any. - assert.equal(res.body.budgets, undefined) + // The other three registrations of the module contract arrived in Phase 7, and + // they are served BESIDE the actions because the editor needs all four to draw + // one step. Core declares no budgets and no leases of its own — its three + // actions cost nothing and hold nothing — so those are empty here, and that is + // the fact worth asserting: present and empty, not absent. + assert.deepEqual(res.body.budgets, []) + assert.deepEqual(res.body.leases, []) + // One option source, and it is core's: `core.announce`'s leg param. It is here + // WITHOUT its resolver — the values are a request of their own. + assert.deepEqual( + res.body.optionSources.map((s) => s.id), + ['core.options.legs'], + ) + for (const s of res.body.optionSources) assert.equal(s.resolve, undefined) +}) + +test('an option source resolves its values, and a refusal is a 200 the form can render', async () => { + // §F: a source that cannot answer degrades its field to free text with a + // visible warning rather than blocking the form, so BOTH answers are 200s and + // the difference is `ok`. A 4xx here would make an authoring screen something a + // module's outage can take away. + const ok = await call(ctrl.options, { params: { sourceId: 'core.options.legs' }, user: ADMIN }) + assert.equal(ok.statusCode, 200) + assert.equal(ok.body.ok, true) + // Whatever legs this boot registered, each is a { value, label } pair — core + // renders no game word, so a leg's own label is the only text on the option. + for (const option of ok.body.options) { + assert.equal(typeof option.value, 'string') + assert.equal(typeof option.label, 'string') + } + + const missing = await call(ctrl.options, { params: { sourceId: 'nobody.at.all' }, user: ADMIN }) + assert.equal(missing.statusCode, 200) + assert.equal(missing.body.ok, false) + assert.match(missing.body.reason, /no module registers/) }) // ── Create, edit, slug ───────────────────────────────────────────────────── @@ -763,6 +795,10 @@ function registerCosting() { // that is not prefixed with the module registering it, which is what keeps an // action's id space its own (§F). const api = registries.stage('test') + // The dimension is DECLARED as well as priced (Phase 7): a `cost()` naming a + // dimension no module registers is refused at save and at dispatch, so an + // action that priced one without declaring it could never be put in a step. + api.registerEventBudgets([{ id: 'test.creatures', label: 'Creatures', unit: 'count' }]) api.registerEventActions([ { id: 'test.spawn', @@ -770,7 +806,7 @@ function registerCosting() { risk: 'change', reversible: 'none', params: [{ name: 'count', type: 'int', required: true, example: 4 }], - cost: (p) => ({ 'x.creatures': p.count }), + cost: (p) => ({ 'test.creatures': p.count }), perform: async () => ({ ok: true }), }, ]) @@ -862,7 +898,7 @@ test('a cap that is not a whole number of 0 or more is refused', async () => { for (const bad of [-1, 2.5, 'lots']) { const res = await call(ctrl.saveAction, { user: ADMIN, - body: { actionId: 'test.spawn', enabled: true, caps: { 'x.creatures': bad } }, + body: { actionId: 'test.spawn', enabled: true, caps: { 'test.creatures': bad } }, }) assert.equal(res.statusCode, 400, String(bad)) } @@ -875,19 +911,24 @@ test('a cap of zero is legal, and it means zero', async () => { registerCosting() const res = await call(ctrl.saveAction, { user: ADMIN, - body: { actionId: 'test.spawn', enabled: true, caps: { 'x.creatures': 0 } }, + body: { actionId: 'test.spawn', enabled: true, caps: { 'test.creatures': 0 } }, }) assert.equal(res.statusCode, 200) - assert.deepEqual(res.body.action.caps, { 'x.creatures': 0 }) + assert.deepEqual(res.body.action.caps, { 'test.creatures': 0 }) }) -test('the board offers a cap box per dimension, discovered from the declared examples', async () => { - // The Phase 6 stand-in for §F's `registerEventBudgets`, which arrives in Phase - // 7 — until then a param's required `example` is what tells core the names. +test('the board offers a cap box per dimension, named by its own declaration', async () => { + // WHICH dimensions an action spends is still discovered by pricing its declared + // examples — `cost` is a function of params, so calling it is the only honest + // way to ask. What Phase 7 added is what they are CALLED: the label and unit + // come from `registerEventBudgets`, because "30" on an unlabelled box is + // ambiguous in exactly the case that matters. registerCosting() const res = await call(ctrl.actions, { user: ADMIN }) const spawn = res.body.actions.find((a) => a.id === 'test.spawn') - assert.deepEqual(spawn.dimensions, ['x.creatures']) + assert.deepEqual(spawn.dimensions, [ + { id: 'test.creatures', label: 'Creatures', unit: 'count', registered: true }, + ]) assert.equal(spawn.enabled, false, 'a change action arrives disabled') assert.equal(spawn.changesWorld, true) }) diff --git a/server/test/eventsRoles.test.js b/server/test/eventsRoles.test.js index 3840388..9abd2dc 100644 --- a/server/test/eventsRoles.test.js +++ b/server/test/eventsRoles.test.js @@ -134,6 +134,9 @@ const forbidden = async (method, path, role) => (await dispatch(method, path, ro 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']],