diff --git a/server/db/schema.sql b/server/db/schema.sql index f7fa01b..b1e3bea 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -2083,3 +2083,269 @@ CREATE TABLE IF NOT EXISTS engagement_suppressions ( INDEX idx_engsup_created (created_at), INDEX idx_engsup_reason (reason, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ── The Event System (EVENTS.md §D — Phase 1) ────────────────────────────── + +-- Six of the nine core tables land here: the ones that do not depend on the +-- module contract. `event_action_settings`, `event_run_budget`, +-- `event_run_resources` and `event_run_participants` arrive with the phases that +-- give them a writer (P6, P8, P10) rather than as empty tables nothing reads. +-- +-- Core tables, so no module prefix, and no game vocabulary anywhere below: an +-- action id, a scope, a resource kind and a budget dimension are all opaque +-- strings core stores and never interprets (§C). + +-- The arc. Definitions optionally belong to one, and the series is what carries +-- continuity across them — "Royal Spy Mission -> Risky Partner -> Message From +-- the Void" is a thing the tooling this replaces cannot express at all. +-- +-- The table lands in Phase 1 because `event_definitions.series_id` points at it; +-- the routes that create and order one are Phase 4's, where the calendar makes +-- an arc visible. +CREATE TABLE IF NOT EXISTS event_series ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(160) NOT NULL, + slug VARCHAR(160) NOT NULL, + description TEXT NULL, + -- Where this series sits among the others on the calendar. Not a position + -- WITHIN the series: a definition's place in its arc is `event_definitions`' + -- own `series_order` below, because that is the column an editor drags. + ordering INT NOT NULL DEFAULT 0, + created_by INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_evser_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + UNIQUE KEY uq_evser_slug (slug) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- The thing that is listed, searched, scheduled and audited. +-- +-- **Three states, not five** (§E). There is no `submitted` and no `approved`: an +-- admin publishes their own work, so there is nobody to submit it to, and a +-- review state nobody uses is a state every query has to remember anyway. +-- +-- `owner_module` is NULLable and is the module that SHIPPED this definition as +-- content, not the module whose actions its steps call — a definition may call +-- three modules' verbs and belong to none of them. NULL means an operator +-- authored it here, which is the ordinary case. +-- +-- `concurrency_key` is stored as the TEMPLATE, not as the rendered value +-- (`invasion:{region}`), because it is rendered from a run's own params at +-- materialisation (§E). A flat definition-id key would wrongly stop one +-- definition running in two regions at once. +-- +-- `current_version_id` carries NO foreign key, deliberately, and it is the one +-- column in this group without one: `event_versions.definition_id` already +-- points back here, and a second FK in the other direction makes the pair a +-- chicken and an egg on insert. +CREATE TABLE IF NOT EXISTS event_definitions ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(200) NOT NULL, + slug VARCHAR(200) NOT NULL, + summary VARCHAR(500) NULL, + -- The storyline. Sanitized HTML, the same treatment a wiki page gets. + body MEDIUMTEXT NULL, + image_url VARCHAR(500) NULL, + owner_module VARCHAR(64) NULL, + state ENUM('draft','ready','archived') NOT NULL DEFAULT 'draft', + current_version_id INT NULL, + -- The WORKING COPY of the spec - phases and their steps - as the author last + -- saved it. §D's column list does not name it because §D describes what a + -- PUBLISHED event is made of, and a version row is where a spec ends up. But + -- "editing a draft is free; no version exists yet" (EVENTS.md "Versioning") + -- has to mean the draft lives somewhere, and it cannot be an `event_versions` + -- row: that table is immutable and a run pins one, so a mutable unpublished + -- row in it would be the exact thing versioning exists to prevent. Publishing + -- copies this column into a version and leaves it here as the next draft. + spec JSON NOT NULL, + series_id INT NULL, + series_order INT NOT NULL DEFAULT 0, + concurrency_key VARCHAR(190) NULL, + -- The grace window (§E). A schedule that passed this many seconds ago while the + -- process was down is `missed`, never a late silent start. + grace_seconds INT NOT NULL DEFAULT 900, + -- IANA, and it belongs to the EVENT rather than to the viewer: every listing + -- this replaces is written in the shard's local zone, and a recurrence computed + -- in UTC puts a Friday-8pm event at 7pm for half the year. + timezone VARCHAR(64) NOT NULL DEFAULT 'UTC', + created_by INT NULL, + updated_by INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_evdef_series FOREIGN KEY (series_id) REFERENCES event_series(id) ON DELETE SET NULL, + CONSTRAINT fk_evdef_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_evdef_updater FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, + UNIQUE KEY uq_evdef_slug (slug), + -- The admin list's default ordering, and the public calendar's filter. + INDEX idx_evdef_state (state, updated_at), + INDEX idx_evdef_series (series_id, series_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- An immutable snapshot of a whole definition spec: phases, steps, schedule, +-- conditions, announcements. A run pins one, and that pin is the entire reason +-- this table exists — it is what makes a run reproducible, and an audit +-- answerable, after the definition has been edited underneath it. +-- +-- Nothing updates a row here. Editing a `ready` definition creates the NEXT +-- version on publish; a live run keeps the version it pinned and is unaffected +-- (EVENTS.md "Versioning, and editing a live event"). +CREATE TABLE IF NOT EXISTS event_versions ( + id INT AUTO_INCREMENT PRIMARY KEY, + definition_id INT NOT NULL, + version INT NOT NULL, + spec JSON NOT NULL, + published_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + published_by INT NULL, + CONSTRAINT fk_evver_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE, + CONSTRAINT fk_evver_user FOREIGN KEY (published_by) REFERENCES users(id) ON DELETE SET NULL, + -- Two publishes racing for version 4 is one 1062, not two rows called 4. + UNIQUE KEY uq_evver_def_version (definition_id, version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- One occurrence of one definition, in one scope. +-- +-- The UNIQUE key below — not the claim — is what makes "one run per occurrence +-- per scope" true (§E). The claim decides WHO advances an occurrence; this index +-- is what stops two of them existing. `scope` is inside the key so a worldwide +-- event fans out to many servers without colliding with itself, and it is +-- module-opaque: core stores the string and never parses it. +-- +-- `scheduled_for` is UTC. The definition's IANA zone is what the occurrence was +-- COMPUTED in (Phase 4); what is stored is the instant. +-- +-- `health` is a separate column from `status` because a run can be genuinely +-- running and degraded at once — announcements landing, world writes parked — +-- and one column cannot say both. `cleanup_status` is separate for the mirror +-- reason: a run reaches `completed` with `cleanup_status = 'incomplete'` rather +-- than being held open, and stays on the admin screen until a human resolves it. +-- +-- `version_id`'s foreign key has no ON DELETE clause, so it RESTRICTs: a run +-- whose pinned spec had been deleted could not be explained afterwards, which is +-- the one thing this table is for. +CREATE TABLE IF NOT EXISTS event_runs ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + definition_id INT NOT NULL, + version_id INT NOT NULL, + -- Module-opaque, and '' rather than NULL for the single-scope case: it is part + -- of a UNIQUE key, and multiple NULLs do not collide in MariaDB, so a NULL + -- scope would silently permit two runs of one occurrence. + scope VARCHAR(190) NOT NULL DEFAULT '', + status ENUM('scheduled','starting','running','paused','ending', + 'completed','cancelled','failed','missed') + NOT NULL DEFAULT 'scheduled', + health ENUM('ok','degraded','stalled') NOT NULL DEFAULT 'ok', + cleanup_status ENUM('not_required','pending','complete','incomplete') + NOT NULL DEFAULT 'not_required', + current_phase VARCHAR(64) NULL, + scheduled_for DATETIME NOT NULL, + timezone VARCHAR(64) NOT NULL DEFAULT 'UTC', + concurrency_key VARCHAR(190) NULL, -- rendered from this run's params + params JSON NULL, + -- A rehearsal dispatches for real but is excluded from the public calendar and + -- from participation history. Declared here in Phase 1 so the column exists + -- before anything can create a run without it. + rehearsal TINYINT(1) NOT NULL DEFAULT 0, + started_at DATETIME NULL, + ended_at DATETIME NULL, + claimed_by VARCHAR(64) NULL, + claim_expires_at DATETIME NULL, + started_by INT NULL, + last_error VARCHAR(500) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_evrun_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE, + CONSTRAINT fk_evrun_version FOREIGN KEY (version_id) REFERENCES event_versions(id), + CONSTRAINT fk_evrun_user FOREIGN KEY (started_by) REFERENCES users(id) ON DELETE SET NULL, + UNIQUE KEY uq_evrun_occurrence (definition_id, scope, scheduled_for), + -- The runner's materialise/advance scan: due runs by status. + INDEX idx_evrun_due (status, scheduled_for), + -- The admin run list, newest first, and the per-definition history. + INDEX idx_evrun_def (definition_id, scheduled_for), + -- Phase 2's overlap check. NULL keys are skipped by the index, which is right: + -- a definition with no key never contends. + INDEX idx_evrun_concurrency (concurrency_key, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- The work queue: one action invocation, claimed with the outbox's +-- compare-and-set. This is the shape `engagement_outbox` already proved, and +-- retries, timeouts, duplicate execution and resumption are all properties of +-- this row rather than of a scheduler's memory. +-- +-- `idempotency_key` is minted ONCE at materialisation and does not vary by +-- attempt (§E) — a retry re-sends the same key so the game side can recognise +-- the repeat. It is generated by core rather than by the module because core is +-- what guarantees its stability. +-- +-- `action_version` records what the step was AUTHORED against. A module that +-- bumps its action makes the step render a warning in the editor rather than +-- dispatch a mistyped parameter. +-- +-- `refused` is in the status set and is deliberately not `failed`: a cap breach +-- means nothing is wrong with the system, and an author asked for more than this +-- deployment allows. +CREATE TABLE IF NOT EXISTS event_run_steps ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + run_id BIGINT NOT NULL, + phase VARCHAR(64) NOT NULL, + seq INT NOT NULL, + action_id VARCHAR(96) NOT NULL, + params JSON NULL, + action_version INT NOT NULL DEFAULT 1, + status ENUM('pending','running','done','failed','skipped','refused','cancelled') + NOT NULL DEFAULT 'pending', + due_at DATETIME NULL, + attempts INT NOT NULL DEFAULT 0, + -- Defaulted from the action's risk class at materialisation (§L): retry->skip + -- for notify, retry->pause for change, retry->abort_run for irreversible. + on_failure VARCHAR(32) NOT NULL DEFAULT 'skip', + idempotency_key CHAR(40) NOT NULL, + claimed_by VARCHAR(64) NULL, + claim_expires_at DATETIME NULL, + last_error VARCHAR(500) NULL, + started_at DATETIME NULL, + finished_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_evstep_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE, + -- The drain scan, exactly as written: due, pending, oldest first. + INDEX idx_evstep_due (status, due_at), + -- The run console: every step of one run in authored order. + INDEX idx_evstep_run (run_id, phase, seq), + -- Materialisation is INSERT IGNORE against this, so a tick that overruns into + -- the next one cannot double-materialise a phase. + UNIQUE KEY uq_evstep_slot (run_id, phase, seq) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- "Why didn't phase 3 start?" must be a query. +-- +-- `activity_log.detail` is TEXT and unqueryable, which is the whole reason this +-- table exists rather than the audit log being reused: an operator diagnosing a +-- stalled phase needs to filter by kind and read structured detail, and an +-- administrative audit of WHO published WHAT is a different question with a +-- different retention. Both are written — the audit to `activity_log`, the +-- diagnosis here. +-- +-- `kind` is a closed set enforced in `eventRunLog.db.js` rather than an ENUM, +-- because the set grows with almost every later phase and an ENUM change is a +-- table alter this project has no migration system for. +-- +-- The log is high-cardinality and grows per event, so it needs a retention sweep +-- from the start — `engagementRetentionPrune` is the pattern, and the rule that +-- work learned is that only TERMINAL rows are eligible. The sweep itself lands +-- with the runner in Phase 2; the index it needs is here from the beginning. +CREATE TABLE IF NOT EXISTS event_run_log ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + run_id BIGINT NOT NULL, + step_id BIGINT NULL, + kind VARCHAR(48) NOT NULL, + phase VARCHAR(64) NULL, + detail JSON NULL, + at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_evlog_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE, + CONSTRAINT fk_evlog_step FOREIGN KEY (step_id) REFERENCES event_run_steps(id) ON DELETE SET NULL, + -- The run console reads this whole index and nothing else. + INDEX idx_evlog_run (run_id, at), + -- What the Phase 2 retention sweep queries. Without it the sweep is a table + -- scan of every line this deployment has ever logged. + INDEX idx_evlog_at (at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server/routes.guards.json b/server/routes.guards.json index 56551b2..c53a92f 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -419,6 +419,123 @@ "requireAuth" ] }, + { + "method": "GET", + "path": "/api/v1/admin/events", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/events", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/admin/events/:id", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/events/:id", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "PUT", + "path": "/api/v1/admin/events/:id", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/events/:id/publish", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/events/:id/runs", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/events/:id/versions", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/events/catalog", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/events/runs", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/events/runs/:runId", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/events/runs/:runId/log", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/events/series", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, { "method": "GET", "path": "/api/v1/admin/invites", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index 4dce8bf..1adec3c 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -185,6 +185,58 @@ "method": "GET", "path": "/api/v1/admin/engagement/triggers" }, + { + "method": "GET", + "path": "/api/v1/admin/events" + }, + { + "method": "POST", + "path": "/api/v1/admin/events" + }, + { + "method": "DELETE", + "path": "/api/v1/admin/events/:id" + }, + { + "method": "GET", + "path": "/api/v1/admin/events/:id" + }, + { + "method": "PUT", + "path": "/api/v1/admin/events/:id" + }, + { + "method": "POST", + "path": "/api/v1/admin/events/:id/publish" + }, + { + "method": "POST", + "path": "/api/v1/admin/events/:id/runs" + }, + { + "method": "GET", + "path": "/api/v1/admin/events/:id/versions" + }, + { + "method": "GET", + "path": "/api/v1/admin/events/catalog" + }, + { + "method": "GET", + "path": "/api/v1/admin/events/runs" + }, + { + "method": "GET", + "path": "/api/v1/admin/events/runs/:runId" + }, + { + "method": "GET", + "path": "/api/v1/admin/events/runs/:runId/log" + }, + { + "method": "GET", + "path": "/api/v1/admin/events/series" + }, { "method": "GET", "path": "/api/v1/admin/invites" diff --git a/server/src/config/coreEventActions.js b/server/src/config/coreEventActions.js new file mode 100644 index 0000000..d26eb21 --- /dev/null +++ b/server/src/config/coreEventActions.js @@ -0,0 +1,153 @@ +// ── Core's own event actions ─────────────────────────────────────────────── +// +// EVENTS.md §F, and Phase 1 of EVENTS_PLAN.md. The twin of config/coreTriggers.js +// and registered through the same staging area a module will use in Phase 7 — +// which is the entire reason these three exist this early. A registry whose first +// real registrant is a module is a registry that has already drifted, and §F's +// claim that core is "an event engine that can announce, wait, cue a human and +// publish results" with NO module installed is only true if core declares the +// verbs that do it. +// +// **Three actions, and between them they cover the three things an event can do +// that name no game noun at all**: tell people something, let time pass, and ask +// a human to go and do something. A deployment with no game module installed has +// a working event system made of exactly these. +// +// **Nothing here dispatches yet.** Phase 1 builds the registry, the id grammar, +// the risk classes and the param validation; Phase 2 builds `utils/eventRunner.js` +// and is what calls `perform()`. The bodies below therefore answer with the +// envelope §F defines for a refusal — and specifically NOT with `{ ok: true }`, +// which is the one wrong answer a placeholder can give: `ok: true` on an action +// that did nothing is a recorded world change that did not occur, which is the +// exact mistake the envelope's failure default exists to prevent. `retry: false` +// because a missing runner is not a transient condition. +// +// **This file must not touch the database.** It is required from `registerCore()`, +// which runs under `routeManifest.js` and `swagger.js` against a dead pool +// (MODULE_API.md §2.2). It is pure data plus three functions that are not called. + +// A factory rather than one shared function, because `perform`'s argument is +// §F's dispatch envelope — `{ runId, stepId, idempotencyKey, scope, params, +// actor, verify }` — and it does not carry the action's own id. Closing over it +// is what lets the refusal name which action refused. +const notWiredYet = (actionId) => async () => ({ + ok: false, + retry: false, + error: `${actionId} is declared in Phase 1 and dispatched from Phase 2`, +}) + +const ACTIONS = [ + { + id: 'core.announce', + label: 'Announce', + description: + 'Publish a line of text to an announce leg — Discord, the in-game town crier, or any leg a module has registered.', + + // Nothing in the world changes and nothing is created: a message goes out. + // That is what makes the default `on_failure` for this step `retry -> skip` + // (§L) rather than `pause`, and it is the honest class even though the + // message itself cannot be unsent. + risk: 'notify', + // A sent announcement is gone. `none` rather than `ledger` is not an + // omission — there is no undo to write, and declaring `ledger` would put a + // row in the cleanup ledger that teardown could never resolve. + reversible: 'none', + version: 1, + + params: [ + { + // 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. + name: 'leg', + type: 'string', + required: true, + example: 'discord', + description: 'The announce leg to publish on. Registered legs only.', + }, + { + name: 'title', + type: 'string', + required: false, + example: 'The gates of Britain open at dusk', + description: 'Optional heading, for legs that render one.', + }, + { + name: 'body', + type: 'string', + required: true, + example: 'A caravan has been sighted on the road east of Cove.', + description: 'The announcement itself. Plain text.', + }, + ], + + perform: notWiredYet('core.announce'), + }, + + { + id: 'core.wait', + label: 'Wait', + description: 'Let a fixed amount of time pass before the next step of this phase runs.', + + // `inspect` rather than `notify`: nothing is sent and nobody is told. It is + // the weakest class the closed set has for an action that is not a broadcast. + risk: 'inspect', + reversible: 'none', + version: 1, + + params: [ + { + name: 'seconds', + type: 'int', + required: true, + example: 300, + description: 'How long to wait. The runner sets the next step due_at from this.', + }, + ], + + // A wait is a genuine no-op at dispatch, and it will stay one: the delay is + // the NEXT step's `due_at`, which the runner owns, not something this + // function sleeps through. A `perform` that slept would hold a step's claim + // for the duration and turn a five-minute pause into a five-minute lease. + perform: notWiredYet('core.wait'), + }, + + { + id: 'core.cue', + label: 'Cue a human', + description: + 'Post an instruction for staff and wait for someone to confirm it was done before the run advances.', + + // The action itself only posts an instruction. Whatever the human then does + // is outside this system entirely, which is precisely why the cue exists: + // it is how an event uses a capability no module has automated. + risk: 'notify', + reversible: 'none', + version: 1, + + params: [ + { + name: 'instruction', + type: 'string', + required: true, + example: 'Open the north gate and read the herald script in Britain bank.', + description: 'What the staff member is being asked to do.', + }, + { + name: 'assignee', + type: 'string', + required: false, + example: 'Event Team', + description: 'Who the cue is addressed to. A label, not an account.', + }, + ], + + // Phase 2 gives this its parking semantics — a cue step does not complete + // when `perform` answers, it completes when a human presses confirm, and the + // control that does so is Phase 3's. Both of those are what make this the + // one action whose runtime shape is deliberately not decided here. + perform: notWiredYet('core.cue'), + }, +] + +module.exports = { ACTIONS } diff --git a/server/src/engagement/conditions.js b/server/src/engagement/conditions.js index 2f99031..e6e182c 100644 --- a/server/src/engagement/conditions.js +++ b/server/src/engagement/conditions.js @@ -248,4 +248,18 @@ const vocabulary = () => /** Convenience for a caller holding only a trigger id. */ const validateFor = (triggerId, raw) => validate(registries.eventTrigger(triggerId), raw) -module.exports = { validate, validateFor, evaluate, vocabulary, OPERATORS, MAX_LIST, MAX_DEPTH } +// `checkLiteral` is exported for the event system's step-param validator +// (EVENTS.md §F, Phase 1), which checks an authored param value against an +// action's declared param type — the same six types over the same coercion. A +// second copy of this switch would be a second answer to "is this a datetime", +// and the two would drift on the first zone-suffixed string somebody typed. +module.exports = { + validate, + validateFor, + evaluate, + vocabulary, + checkLiteral, + OPERATORS, + MAX_LIST, + MAX_DEPTH, +} diff --git a/server/src/events/spec.js b/server/src/events/spec.js new file mode 100644 index 0000000..7551d96 --- /dev/null +++ b/server/src/events/spec.js @@ -0,0 +1,317 @@ +// ── The event spec, and the one place it is validated ────────────────────── +// +// EVENTS.md §C ("phases and actions are configuration inside a version snapshot, +// not tables") and §D. A spec is the authored tree a definition carries and a +// version freezes: phases, and the steps inside them. It is stored as JSON in +// `event_definitions`' working copy and in `event_versions.spec`, and this file +// is the only thing that decides whether one is well formed. +// +// **Every check here is a boundary, not a convenience.** The authoring UI (Phase +// 3, then Phase 13) will re-implement some of them for the sake of a good +// inline error, and that second copy is expected to drift — so this one is the +// one that decides. A spec arriving by any other route (a restore, a fixture, a +// module shipping a definition as content) gets the same answer. +// +// **What Phase 1 knows, and what it deliberately refuses.** Two top-level keys +// exist today: `schedule` and `phases`. `schedule` accepts only `{ kind: +// 'manual' }`, because Phase 4 is what computes an occurrence from a recurrence +// in an IANA zone and a spec that could name `weekly` before then would be a +// schedule nothing honours. Unknown top-level keys are REFUSED rather than +// preserved: a spec that silently carries `announcements` today is a spec whose +// author believes announcements work, and the later phase that gives the key +// meaning would inherit a corpus of unvalidated ones. The refusal list is the +// changelog — Phase 4 adds the recurrence shapes, Phase 5 adds a phase's +// `advance`, Phase 10 adds `announcements`. + +const registries = require('../modules/registries') +const { checkLiteral } = require('../engagement/conditions') + +// A phase key is a slug: it is stored in `event_run_steps.phase`, it is what the +// run console groups by, and it is what an operator reads in "phase 3 has not +// started". Same grammar as a template key's segment. +const PHASE_KEY = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/ +const MAX_PHASE_KEY = 64 + +// Bounds, not guesses. They exist so that a paste of the wrong JSON is a refusal +// with a number in it rather than a run that materialises fifty thousand step +// rows — the same argument `MAX_SENDS_PER_HOUR` makes on the engagement side. +const MAX_PHASES = 40 +const MAX_STEPS_PER_PHASE = 100 +const MAX_STEPS = 500 + +// The schedule shapes this phase understands. Phase 4 replaces this list with +// the four closed shapes of §E — `once`, `weekly`, `monthly`, `manual` — and +// their timezone arithmetic. It is a list of one rather than an implicit default +// so that the widening is a diff on this line. +const SCHEDULE_KINDS = ['manual'] + +// What a step does when its attempts are exhausted (§L). The disposition only — +// retry is not one of the values, it is what happens BEFORE one of them. Each +// risk class has a default, which is the whole reason `risk` is required at +// registration: a `change` action that fell back to `skip` would leave a run +// advancing over a half-changed world. +const ON_FAILURE = ['skip', 'pause', 'abort_run'] +const ON_FAILURE_BY_RISK = { + notify: 'skip', + inspect: 'skip', + change: 'pause', + irreversible: 'abort_run', +} + +const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) + +/** The default disposition for an action whose risk class core knows. */ +const defaultOnFailure = (risk) => ON_FAILURE_BY_RISK[risk] || 'pause' + +/** + * Check one authored param object against an action's declared params. + * + * Returns `{ params, errors }`. Unknown params are an ERROR rather than a silent + * drop: an author who typed `creatures` where the action declares `creature` has + * written a step that would dispatch with the count missing, and dropping the key + * makes that look like it saved cleanly. + */ +function checkParams(declaration, raw, path) { + const errors = [] + const params = {} + const given = isPlainObject(raw) ? raw : {} + + if (raw !== undefined && raw !== null && !isPlainObject(raw)) { + return { params, errors: [`${path}.params: expected an object`] } + } + + const declared = new Map((declaration.params || []).map((p) => [p.name, p])) + for (const name of Object.keys(given)) { + if (!declared.has(name)) { + errors.push(`${path}.params: "${name}" is not a param of ${declaration.id}`) + } + } + + for (const p of declaration.params || []) { + const value = given[p.name] + if (value === undefined || value === null || value === '') { + if (p.required) errors.push(`${path}.params: "${p.name}" is required`) + continue + } + const checked = checkLiteral(p.type, value) + if (checked.error) { + errors.push(`${path}.params: "${p.name}" ${checked.error}`) + continue + } + params[p.name] = checked.value + } + + return { params, errors } +} + +/** + * Validate and normalise a whole spec. + * + * `{ ok: true, spec }` with a new normalised tree, or `{ ok: false, errors }` + * listing EVERY problem rather than the first — the posture `conditions.validate` + * takes, and for the same reason: an author fixing one step at a time is an + * author making six round trips through a form. + * + * `knownActionIds` widens what may be named beyond what is registered right now, + * and it is how a definition survives its module being uninstalled. The rule, + * lifted verbatim from `engagementRules.model`'s treatment of a dormant trigger: + * **a step already in the saved spec may keep an unregistered action; a new step + * may not add one.** Refusing to save the whole definition would make an + * uninstall destructive after the fact, and silently dropping the step would + * delete authored work to make a form submit. A kept step is marked + * `dormant: true` and its params pass through unvalidated, because the only + * thing that could validate them left with the module. + */ +function validate(raw, { knownActionIds = [] } = {}) { + const errors = [] + const known = new Set(knownActionIds) + + if (!isPlainObject(raw)) return { ok: false, errors: ['spec: expected an object'] } + + const allowed = new Set(['schedule', 'phases']) + for (const key of Object.keys(raw)) { + if (!allowed.has(key)) { + errors.push(`spec: unknown key "${key}" (Phase 1 understands ${[...allowed].join(', ')})`) + } + } + + // ── schedule ── + const rawSchedule = raw.schedule === undefined || raw.schedule === null ? { kind: 'manual' } : raw.schedule + let schedule = { kind: 'manual' } + if (!isPlainObject(rawSchedule)) { + errors.push('spec.schedule: expected an object') + } else if (!SCHEDULE_KINDS.includes(rawSchedule.kind)) { + errors.push( + `spec.schedule: kind must be one of ${SCHEDULE_KINDS.join(', ')} (recurrence arrives in Phase 4)`, + ) + } else { + const extra = Object.keys(rawSchedule).filter((k) => k !== 'kind') + if (extra.length) errors.push(`spec.schedule: unknown key(s) ${extra.join(', ')}`) + schedule = { kind: rawSchedule.kind } + } + + // ── phases ── + const rawPhases = raw.phases + const phases = [] + if (!Array.isArray(rawPhases) || rawPhases.length === 0) { + errors.push('spec.phases: expected a non-empty array') + return { ok: false, errors } + } + if (rawPhases.length > MAX_PHASES) { + errors.push(`spec.phases: at most ${MAX_PHASES} phases`) + return { ok: false, errors } + } + + const seenKeys = new Set() + let totalSteps = 0 + + rawPhases.forEach((rawPhase, pi) => { + const path = `spec.phases[${pi}]` + if (!isPlainObject(rawPhase)) { + errors.push(`${path}: expected an object`) + return + } + const extra = Object.keys(rawPhase).filter((k) => !['key', 'label', 'steps'].includes(k)) + if (extra.length) { + errors.push(`${path}: unknown key(s) ${extra.join(', ')} (a phase gains "advance" in Phase 5)`) + } + + const key = rawPhase.key + if (typeof key !== 'string' || !PHASE_KEY.test(key) || key.length > MAX_PHASE_KEY) { + errors.push(`${path}.key: bad phase key "${key}"`) + } else if (seenKeys.has(key)) { + // Not cosmetic: `event_run_steps` is UNIQUE on (run_id, phase, seq), so two + // phases sharing a key would silently collapse into one at materialisation + // and half the authored steps would never exist. + errors.push(`${path}.key: "${key}" is used by more than one phase`) + } else { + seenKeys.add(key) + } + + if (!rawPhase.label) errors.push(`${path}.label: a phase needs a label`) + + const rawSteps = rawPhase.steps + if (!Array.isArray(rawSteps)) { + errors.push(`${path}.steps: expected an array`) + return + } + if (rawSteps.length > MAX_STEPS_PER_PHASE) { + errors.push(`${path}.steps: at most ${MAX_STEPS_PER_PHASE} steps in one phase`) + return + } + totalSteps += rawSteps.length + + const steps = [] + rawSteps.forEach((rawStep, si) => { + const spath = `${path}.steps[${si}]` + if (!isPlainObject(rawStep)) { + errors.push(`${spath}: expected an object`) + return + } + // `actionVersion` and `dormant` are in this list because **validate must + // accept its own output**. A saved spec is re-validated on every later + // save and again at publish, so a normalised field that the validator + // itself added and then refused would make the second save of any + // definition impossible. They are accepted and then RECOMPUTED below + // rather than trusted: the version comes from the declaration, and + // dormancy from whether anyone registers the action right now. + const stepExtra = Object.keys(rawStep).filter( + (k) => !['actionId', 'params', 'onFailure', 'label', 'actionVersion', 'dormant'].includes(k), + ) + if (stepExtra.length) errors.push(`${spath}: unknown key(s) ${stepExtra.join(', ')}`) + + const actionId = rawStep.actionId + const declaration = typeof actionId === 'string' ? registries.eventAction(actionId) : null + + if (typeof actionId !== 'string' || !actionId) { + errors.push(`${spath}.actionId: a step needs an action`) + return + } + if (!declaration && !known.has(actionId)) { + errors.push(`${spath}.actionId: no module registers "${actionId}"`) + return + } + + if (!declaration) { + // Dormant: kept verbatim, params untouched, and flagged so the editor and + // the run console can both say WHY rather than showing an empty step. + steps.push({ + actionId, + label: rawStep.label || actionId, + params: isPlainObject(rawStep.params) ? rawStep.params : {}, + actionVersion: Number.isInteger(rawStep.actionVersion) ? rawStep.actionVersion : 1, + onFailure: ON_FAILURE.includes(rawStep.onFailure) ? rawStep.onFailure : 'pause', + dormant: true, + }) + return + } + + const { params, errors: paramErrors } = checkParams(declaration, rawStep.params, spath) + errors.push(...paramErrors) + + if (rawStep.onFailure !== undefined && !ON_FAILURE.includes(rawStep.onFailure)) { + errors.push(`${spath}.onFailure: must be one of ${ON_FAILURE.join(', ')}`) + } + + steps.push({ + actionId, + label: rawStep.label || declaration.label, + params, + // Captured at SAVE time, from the declaration this step was authored + // against (§F). It is what lets a later bump render a warning in the + // editor instead of dispatching a mistyped parameter. + actionVersion: declaration.version, + onFailure: ON_FAILURE.includes(rawStep.onFailure) + ? rawStep.onFailure + : defaultOnFailure(declaration.risk), + dormant: false, + }) + }) + + phases.push({ key, label: rawPhase.label, steps }) + }) + + if (totalSteps > MAX_STEPS) errors.push(`spec: at most ${MAX_STEPS} steps in one definition`) + + if (errors.length) return { ok: false, errors } + return { ok: true, spec: { schedule, phases } } +} + +/** Every action id a spec names, dormant ones included. */ +const actionIdsIn = (spec) => + (spec?.phases || []).flatMap((p) => (p.steps || []).map((s) => s.actionId)).filter(Boolean) + +/** + * A spec is publishable when nothing in it is dormant. + * + * Separate from `validate` on purpose: a dormant step must not stop an author + * SAVING (that is what makes an uninstall non-destructive), and it must stop + * them PUBLISHING, because publishing is what makes a version a thing runs are + * pinned to and a run cannot dispatch a verb nobody registers. + */ +function publishable(spec) { + const dormant = (spec?.phases || []) + .flatMap((p) => (p.steps || []).filter((s) => s.dormant).map((s) => s.actionId)) + return dormant.length ? { ok: false, dormant: [...new Set(dormant)] } : { ok: true, dormant: [] } +} + +/** An empty, valid spec — what a newly created draft carries. */ +const emptySpec = () => ({ + schedule: { kind: 'manual' }, + phases: [{ key: 'main', label: 'Main', steps: [] }], +}) + +module.exports = { + validate, + publishable, + actionIdsIn, + emptySpec, + defaultOnFailure, + PHASE_KEY, + SCHEDULE_KINDS, + ON_FAILURE, + ON_FAILURE_BY_RISK, + MAX_PHASES, + MAX_STEPS_PER_PHASE, + MAX_STEPS, +} diff --git a/server/src/model/events/eventDefinitions.db.js b/server/src/model/events/eventDefinitions.db.js new file mode 100644 index 0000000..0794cfe --- /dev/null +++ b/server/src/model/events/eventDefinitions.db.js @@ -0,0 +1,134 @@ +// ── event_definitions — SQL only ─────────────────────────────────────────── +// +// EVENTS.md §D. The `.db.js` half of the pair: parameterised SQL and hydration, +// no policy. Everything that decides whether a write is allowed lives in +// `eventDefinitions.model.js`. + +const { query } = require('../../utils/db') +const { parseJson } = require('./eventJson') + +const hydrate = (row) => + row && { + ...row, + spec: parseJson(row.spec, null), + } + +// `current_version` is joined rather than stored: the list screen shows "v3" and +// the column that would hold it is a denormalisation of a row this query already +// has to reach for the publish date anyway. +const SELECT_LIST = ` + SELECT d.*, s.name AS series_name, s.slug AS series_slug, + v.version AS current_version + FROM event_definitions d + LEFT JOIN event_series s ON s.id = d.series_id + LEFT JOIN event_versions v ON v.id = d.current_version_id +` + +const list = async ({ state = null } = {}) => { + const rows = state + ? await query(`${SELECT_LIST} WHERE d.state = ? ORDER BY d.updated_at DESC, d.id DESC`, [state]) + : await query(`${SELECT_LIST} ORDER BY d.updated_at DESC, d.id DESC`) + return rows.map(hydrate) +} + +const getById = async (id) => { + const [row] = await query(`${SELECT_LIST} WHERE d.id = ?`, [id]) + return hydrate(row) +} + +const getBySlug = async (slug) => { + const [row] = await query(`${SELECT_LIST} WHERE d.slug = ?`, [slug]) + return hydrate(row) +} + +/** Does any OTHER definition hold this slug? The uniqueness pre-check. */ +const slugTaken = async (slug, exceptId = null) => { + const rows = exceptId + ? await query('SELECT id FROM event_definitions WHERE slug = ? AND id <> ?', [slug, exceptId]) + : await query('SELECT id FROM event_definitions WHERE slug = ?', [slug]) + return rows.length > 0 +} + +const insert = async (d) => { + const result = await query( + `INSERT INTO event_definitions + (title, slug, summary, body, image_url, owner_module, series_id, series_order, + concurrency_key, grace_seconds, timezone, spec, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + d.title, + d.slug, + d.summary, + d.body, + d.image_url, + d.owner_module, + d.series_id, + d.series_order, + d.concurrency_key, + d.grace_seconds, + d.timezone, + JSON.stringify(d.spec), + d.created_by, + d.created_by, + ], + ) + return result.insertId +} + +const update = (id, d) => + query( + `UPDATE event_definitions + SET title = ?, slug = ?, summary = ?, body = ?, image_url = ?, series_id = ?, + series_order = ?, concurrency_key = ?, grace_seconds = ?, timezone = ?, + spec = ?, updated_by = ? + WHERE id = ?`, + [ + d.title, + d.slug, + d.summary, + d.body, + d.image_url, + d.series_id, + d.series_order, + d.concurrency_key, + d.grace_seconds, + d.timezone, + JSON.stringify(d.spec), + d.updated_by, + id, + ], + ) + +/** + * Point a definition at the version it just published, and mark it `ready`. + * + * One statement, because the two halves are the same fact: `ready` means "a + * version has been published and the schedule is live" (§E), so a state without + * a `current_version_id` is a lie the scheduler would act on. + */ +const markReady = (id, versionId, userId) => + query( + `UPDATE event_definitions + SET state = 'ready', current_version_id = ?, updated_by = ? + WHERE id = ?`, + [versionId, userId, id], + ) + +/** + * Archive. Never a hard delete while runs reference it (§ API surface) — and the + * schema would refuse one anyway, because `event_runs.version_id` RESTRICTs. + * Archiving is what "delete" means on this screen, and the row keeps its history. + */ +const archive = (id, userId) => + query("UPDATE event_definitions SET state = 'archived', updated_by = ? WHERE id = ?", [userId, id]) + +module.exports = { + list, + getById, + getBySlug, + slugTaken, + insert, + update, + markReady, + archive, +} diff --git a/server/src/model/events/eventDefinitions.model.js b/server/src/model/events/eventDefinitions.model.js new file mode 100644 index 0000000..be5008f --- /dev/null +++ b/server/src/model/events/eventDefinitions.model.js @@ -0,0 +1,270 @@ +// ── Event definitions — the save path ────────────────────────────────────── +// +// EVENTS.md §D and "Versioning, and editing a live event". A definition is +// operator-authored data, and this file is the boundary that decides whether a +// version of it may exist. The authoring UI (Phase 3, then Phase 13) will +// re-check some of this for the sake of a good inline error; that second copy is +// expected to drift, so this one is the one that decides, and a definition +// arriving by any other route gets the same answer. +// +// **The three rules with teeth, and each is a rule about time rather than about +// shape:** +// +// 1. Publishing SNAPSHOTS. It copies the working spec into an immutable +// `event_versions` row and points `current_version_id` at it. Editing +// afterwards is free and does not touch the row a live run pinned. +// 2. A dormant step blocks a PUBLISH and never a SAVE. An uninstalled module +// must not make an author's work uneditable, and it must not let a version be +// published that names a verb nobody can perform. +// 3. Archiving is what "delete" means here. `event_runs.version_id` RESTRICTs, so +// a hard delete of a definition that has ever run is refused by the database +// anyway — and the row's history is the thing an audit reads. + +const db = require('./eventDefinitions.db') +const versionsDb = require('./eventVersions.db') +const runsDb = require('./eventRuns.db') +const seriesDb = require('./eventSeries.db') +const spec = require('../../events/spec') +const { slugify, uniqueSlug } = require('../teams/teamSlug') +const { cleanBody } = require('../../utils/sanitizeHtml') + +const MAX_TITLE = 200 +const MAX_SUMMARY = 500 +const MAX_URL = 500 +const MAX_CONCURRENCY_KEY = 190 + +// A day either side of §D's default. Below a minute the grace window cannot +// survive a single slow boot; above a day a "missed" occurrence would start +// silently the following afternoon, which is the exact behaviour §E forbids. +const MIN_GRACE_SECONDS = 60 +const MAX_GRACE_SECONDS = 86_400 + +/** + * IANA zone names, checked against the platform's own database rather than a + * list. `Intl.DateTimeFormat` throws `RangeError` on an unknown zone, and Node + * ships the full tzdata — so this is the same check Phase 4's occurrence + * arithmetic will make, asked one screen earlier where an operator can fix it. + */ +function isTimezone(tz) { + try { + Intl.DateTimeFormat(undefined, { timeZone: tz }) + return true + } catch { + return false + } +} + +const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) + +const trimOrNull = (v, max) => { + if (v === undefined || v === null) return null + const s = String(v).trim() + return s === '' ? null : s.slice(0, max) +} + +/** + * Validate an incoming definition against the registries and the schema. + * + * `{ ok: true, definition }` with a normalised row ready for insert/update, or + * `{ ok: false, errors }` listing every problem rather than the first. + * + * `existing` is the row being edited, or null on a create. It is what lets a + * dormant step survive: the ids already in the saved spec widen what the spec + * validator will accept, so an uninstall is never destructive after the fact. + */ +async function validate(input, { existing = null } = {}) { + const errors = [] + const body = isPlainObject(input) ? input : {} + + const title = trimOrNull(body.title, MAX_TITLE) + if (!title) errors.push('title is required') + + const summary = trimOrNull(body.summary, MAX_SUMMARY) + const imageUrl = trimOrNull(body.imageUrl, MAX_URL) + const concurrencyKey = trimOrNull(body.concurrencyKey, MAX_CONCURRENCY_KEY) + + // The storyline. Sanitized on write, exactly as a wiki page and a forum post + // are: it is author-supplied HTML that ends up on a public page. + const storyline = body.body === undefined || body.body === null ? null : cleanBody(String(body.body)) + + const timezone = trimOrNull(body.timezone, 64) || existing?.timezone || 'UTC' + if (!isTimezone(timezone)) errors.push(`timezone: "${timezone}" is not an IANA zone name`) + + const graceRaw = body.graceSeconds === undefined ? (existing?.grace_seconds ?? 900) : body.graceSeconds + const graceSeconds = Number(graceRaw) + if ( + !Number.isInteger(graceSeconds) || + graceSeconds < MIN_GRACE_SECONDS || + graceSeconds > MAX_GRACE_SECONDS + ) { + errors.push(`graceSeconds must be an integer ${MIN_GRACE_SECONDS}..${MAX_GRACE_SECONDS}`) + } + + let seriesId = null + if (body.seriesId !== undefined && body.seriesId !== null && body.seriesId !== '') { + seriesId = Number(body.seriesId) + if (!Number.isInteger(seriesId) || seriesId < 1) { + errors.push('seriesId must be an integer') + seriesId = null + } else if (!(await seriesDb.exists(seriesId))) { + // Checked here as well as by the foreign key, because a 1452 reaching a + // controller is a 500 and this is a 400 an author can act on. + errors.push(`seriesId ${seriesId} does not exist`) + seriesId = null + } + } else if (existing) { + seriesId = existing.series_id + } + + const seriesOrderRaw = body.seriesOrder === undefined ? (existing?.series_order ?? 0) : body.seriesOrder + const seriesOrder = Number(seriesOrderRaw) + if (!Number.isInteger(seriesOrder)) errors.push('seriesOrder must be an integer') + + // ── the spec ── + const rawSpec = body.spec === undefined ? existing?.spec ?? spec.emptySpec() : body.spec + const known = existing?.spec ? spec.actionIdsIn(existing.spec) : [] + const checked = spec.validate(rawSpec, { knownActionIds: known }) + if (!checked.ok) errors.push(...checked.errors) + + // ── the slug ── + // + // Derived from the title on create and FROZEN afterwards, like a Team's: the + // public event page lives at it, and a retitle must not break a link somebody + // posted in Discord. An author who genuinely needs a different address makes a + // new definition. + let slug = existing?.slug || null + if (!slug) { + const stem = slugify(title || '') || 'event' + const taken = (await db.list()).map((d) => d.slug) + slug = uniqueSlug(stem, taken, { fallback: 'event' }) + } + + if (errors.length) return { ok: false, errors } + + return { + ok: true, + definition: { + title, + slug, + summary, + body: storyline, + image_url: imageUrl, + owner_module: existing?.owner_module ?? null, + series_id: seriesId, + series_order: seriesOrder, + concurrency_key: concurrencyKey, + grace_seconds: graceSeconds, + timezone, + spec: checked.spec, + }, + } +} + +/** Create a draft. */ +async function create(input, userId) { + const result = await validate(input) + if (!result.ok) return result + const id = await db.insert({ ...result.definition, created_by: userId }) + return { ok: true, id, definition: await db.getById(id) } +} + +/** + * Edit a definition. + * + * An `archived` definition is not editable. That is the one state check here, + * and it is a real one rather than a formality: archiving is how a definition is + * retired, and a retired definition that can still be edited is a definition + * somebody will edit and then wonder why it never runs. + */ +async function save(id, input, userId) { + const existing = await db.getById(id) + if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] } + if (existing.state === 'archived') { + return { ok: false, status: 409, errors: ['an archived definition cannot be edited'] } + } + const result = await validate(input, { existing }) + if (!result.ok) return result + await db.update(id, { ...result.definition, updated_by: userId }) + return { ok: true, id, definition: await db.getById(id) } +} + +/** + * Publish: snapshot the working spec into an immutable version and go `ready`. + * + * The spec is re-validated here against the registries as they stand RIGHT NOW, + * not trusted from the save that wrote it. A module uninstalled between the two + * is the whole reason: the save was legitimate, and publishing a version whose + * steps name a verb nobody can perform would be a run that fails at dispatch + * with the world half-changed. + */ +async function publish(id, userId) { + const existing = await db.getById(id) + if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] } + if (existing.state === 'archived') { + return { ok: false, status: 409, errors: ['an archived definition cannot be published'] } + } + + const checked = spec.validate(existing.spec, { + knownActionIds: spec.actionIdsIn(existing.spec), + }) + if (!checked.ok) return { ok: false, status: 400, errors: checked.errors } + + const publishable = spec.publishable(checked.spec) + if (!publishable.ok) { + return { + ok: false, + status: 409, + errors: [ + `cannot publish: no module registers ${publishable.dormant.join(', ')}`, + ], + } + } + if (!checked.spec.phases.some((p) => p.steps.length)) { + // An empty event publishes cleanly and then does nothing, which looks like a + // broken run rather than an empty one. Refusing costs an author one click and + // saves an operator a diagnosis. + return { ok: false, status: 400, errors: ['cannot publish: no phase has any steps'] } + } + + const version = await versionsDb.nextVersion(id) + const versionId = await versionsDb.insert(id, version, checked.spec, userId) + await db.markReady(id, versionId, userId) + return { ok: true, versionId, version, definition: await db.getById(id) } +} + +/** + * Archive. + * + * Refused while a run of this definition is still in flight — not because the + * database would object (it would not; archiving is an UPDATE), but because the + * screen the run is on reads its title and state from here, and retiring a + * definition mid-run makes the console describe something that is no longer + * supposed to exist. Cancel the run, then archive. + */ +async function archive(id, userId) { + const existing = await db.getById(id) + if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] } + if (existing.state === 'archived') return { ok: true, definition: existing } + + const active = await runsDb.countActiveForDefinition(id) + if (active > 0) { + return { + ok: false, + status: 409, + errors: [`cannot archive: ${active} run(s) of this definition are still in flight`], + } + } + await db.archive(id, userId) + return { ok: true, definition: await db.getById(id) } +} + +module.exports = { + validate, + create, + save, + publish, + archive, + isTimezone, + MIN_GRACE_SECONDS, + MAX_GRACE_SECONDS, +} diff --git a/server/src/model/events/eventJson.js b/server/src/model/events/eventJson.js new file mode 100644 index 0000000..56cebbf --- /dev/null +++ b/server/src/model/events/eventJson.js @@ -0,0 +1,23 @@ +// ── One JSON reader for the whole events model ───────────────────────────── +// +// JSON columns come back from the driver already parsed on some MariaDB/driver +// combinations and as a string on others — it depends on whether the column is a +// real JSON type or the LONGTEXT + CHECK alias MariaDB implements it as. Every +// read in this directory goes through this, so no caller has to know which it +// got. +// +// Lifted from `engagementRules.db.js`, which learned it first, and hoisted into +// its own file here rather than copied into six: six copies of a fallback is six +// chances for one of them to fall back to `{}` where the reader expects `[]`. + +function parseJson(value, fallback) { + if (value === null || value === undefined) return fallback + if (typeof value !== 'string') return value + try { + return JSON.parse(value) + } catch { + return fallback + } +} + +module.exports = { parseJson } diff --git a/server/src/model/events/eventRunLog.db.js b/server/src/model/events/eventRunLog.db.js new file mode 100644 index 0000000..a32732c --- /dev/null +++ b/server/src/model/events/eventRunLog.db.js @@ -0,0 +1,67 @@ +// ── event_run_log — SQL only ─────────────────────────────────────────────── +// +// EVENTS.md § Observability. "Why didn't phase 3 start?" must be a query, and +// `activity_log.detail` is TEXT and unqueryable, which is why this table exists +// beside the audit log rather than instead of it. Both are written: the audit of +// WHO published WHAT goes to `activity_log`, the diagnosis goes here. +// +// **`kind` is a closed set enforced here rather than an ENUM in the DDL.** The +// set grows with almost every later phase — conditions in Phase 5, cap draws in +// Phase 6, ledger movements in Phase 8 — and an ENUM change is a table alter +// this project has no migration system for. A constant in a file is the same +// guarantee with a cheaper hinge. + +const log = require('../../utils/logger')('events') +const { query } = require('../../utils/db') +const { parseJson } = require('./eventJson') + +// Phase 1's kinds. Later phases append; nothing here is ever renamed, because a +// stored row would then name a kind no reader knows. +const KINDS = [ + 'run.created', // an occurrence was materialised + 'run.status', // a status transition, with from/to + 'phase.entered', // a phase's steps were materialised + 'step.status', // a step transition, with the module's answer + 'note', // a human action taken from the admin surface +] + +const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) } + +const listForRun = async (runId, { limit = 500 } = {}) => { + const n = Math.min(Math.max(Number(limit) || 500, 1), 2000) + return ( + await query(`SELECT * FROM event_run_log WHERE run_id = ? ORDER BY at DESC, id DESC LIMIT ${n}`, [ + runId, + ]) + ).map(hydrate) +} + +/** + * Write one line. **Never throws.** + * + * The diagnostic log is what an operator reads when something has already gone + * wrong, so a failure to write it must not become a second failure on top of the + * first — a runner that aborted a run because it could not record why would be + * the worst possible reading of "observability". The same posture + * `uoLinkClient.js` takes: answer, do not throw. + */ +async function write({ runId, stepId = null, kind, phase = null, detail = null }) { + if (!KINDS.includes(kind)) { + // A programming error, not an operational one, and it is louder than a + // silent drop for exactly that reason. + log.warn('event run log: unknown kind', { kind, runId }) + return false + } + try { + await query( + 'INSERT INTO event_run_log (run_id, step_id, kind, phase, detail) VALUES (?, ?, ?, ?, ?)', + [runId, stepId, kind, phase, detail === null ? null : JSON.stringify(detail)], + ) + return true + } catch (err) { + log.error('event run log write failed', { runId, kind, message: err.message }) + return false + } +} + +module.exports = { KINDS, listForRun, write } diff --git a/server/src/model/events/eventRunSteps.db.js b/server/src/model/events/eventRunSteps.db.js new file mode 100644 index 0000000..bf0a7dc --- /dev/null +++ b/server/src/model/events/eventRunSteps.db.js @@ -0,0 +1,95 @@ +// ── event_run_steps — SQL only ───────────────────────────────────────────── +// +// EVENTS.md §D and §E. Phase 1 materialises a run's steps and reads them back +// for the run console. **Draining them is Phase 2's**: the CAS claim, the lease, +// the attempt counter and the classification of a module's answer are the +// runner, and none of them is stubbed here. +// +// The one runtime property Phase 1 does have to get right is the idempotency key +// (§E). Core mints it ONCE, at materialisation, and it does NOT vary by attempt — +// a retry re-sends the same key so the game side can recognise the repeat. That +// makes it a property of the INSERT below rather than of the dispatch, which is +// the only reason it can be stable at all. + +const crypto = require('crypto') + +const { query } = require('../../utils/db') +const { parseJson } = require('./eventJson') + +const hydrate = (row) => row && { ...row, params: parseJson(row.params, {}) } + +/** + * `sha256(runId | stepId)`, truncated to 40 hex — the shape `shardEvents.dedupeKey` + * already uses, so the two dedupe keys on this codebase read alike. + * + * The step id is not known until the row exists, so materialisation inserts with + * a provisional key and stamps the real one immediately afterwards. That is one + * extra statement per step and it buys the property the whole retry story rests + * on: the key is a function of identity, never of attempt or of clock. + */ +const idempotencyKey = (runId, stepId) => + crypto.createHash('sha256').update(`${runId}|${stepId}`).digest('hex').slice(0, 40) + +const listForRun = async (runId) => + ( + await query( + 'SELECT * FROM event_run_steps WHERE run_id = ? ORDER BY phase, seq, id', + [runId], + ) + ).map(hydrate) + +const getById = async (id) => { + const [row] = await query('SELECT * FROM event_run_steps WHERE id = ?', [id]) + return hydrate(row) +} + +/** + * Materialise one phase's steps. + * + * `INSERT IGNORE` against `UNIQUE (run_id, phase, seq)`, so a tick that overran + * into the next one cannot double-materialise a phase — the same argument the + * occurrence key makes one table up, at the other end of the run. + * + * Returns the rows as they now stand, created or pre-existing, so a caller that + * lost the race still gets the step ids. + */ +const materialisePhase = async (runId, phase, steps) => { + for (let i = 0; i < steps.length; i++) { + const step = steps[i] + const result = await query( + `INSERT IGNORE INTO event_run_steps + (run_id, phase, seq, action_id, params, action_version, on_failure, idempotency_key) + VALUES (?, ?, ?, ?, ?, ?, ?, '')`, + [ + runId, + phase, + i, + step.actionId, + JSON.stringify(step.params || {}), + step.actionVersion || 1, + step.onFailure || 'pause', + ], + ) + if (Number(result?.affectedRows || 0) === 1) { + // Stamped in a second statement because the key is a function of the row's + // own id. Scoped by the empty key so a re-run of this loop over an existing + // phase can never overwrite a key a dispatch has already sent. + await query( + "UPDATE event_run_steps SET idempotency_key = ? WHERE id = ? AND idempotency_key = ''", + [idempotencyKey(runId, result.insertId), result.insertId], + ) + } + } + return listForRun(runId) +} + +/** The run console's summary line: how many steps sit in each status. */ +const statusCounts = async (runId) => { + const rows = await query( + 'SELECT status, COUNT(*) AS n FROM event_run_steps WHERE run_id = ? GROUP BY status', + [runId], + ) + return Object.fromEntries(rows.map((r) => [r.status, Number(r.n)])) +} + +module.exports = { listForRun, getById, materialisePhase, statusCounts, idempotencyKey } diff --git a/server/src/model/events/eventRuns.db.js b/server/src/model/events/eventRuns.db.js new file mode 100644 index 0000000..bf8a59f --- /dev/null +++ b/server/src/model/events/eventRuns.db.js @@ -0,0 +1,105 @@ +// ── event_runs — SQL only ────────────────────────────────────────────────── +// +// EVENTS.md §D and §E. Phase 1 writes exactly one kind of row — a `scheduled` +// occurrence — and reads them back for the admin surface. **The claim, the CAS +// transitions and the lease reclaim are Phase 2's** and are deliberately not +// stubbed here: a half-written claim is worse than no claim, because it reads as +// protection. +// +// What Phase 1 does own is the INSERT, and it owns the important half of it: +// materialisation is `INSERT IGNORE` against `UNIQUE (definition_id, scope, +// scheduled_for)`, so a second attempt at one occurrence writes nothing and +// answers honestly rather than raising a duplicate-key error a caller has to +// interpret. + +const { query } = require('../../utils/db') +const { parseJson } = require('./eventJson') + +const hydrate = (row) => row && { ...row, params: parseJson(row.params, null), rehearsal: Boolean(row.rehearsal) } + +const SELECT_LIST = ` + SELECT r.*, d.title AS definition_title, d.slug AS definition_slug, v.version AS version_number + FROM event_runs r + JOIN event_definitions d ON d.id = r.definition_id + JOIN event_versions v ON v.id = r.version_id +` + +/** + * The admin run list. Newest occurrence first, across every definition. + * + * `limit` is interpolated after an integer coercion rather than bound, because + * MariaDB will not take a placeholder in LIMIT on a prepared statement. It never + * reaches SQL as anything but a number. + */ +const list = async ({ definitionId = null, status = null, limit = 100 } = {}) => { + const where = [] + const args = [] + if (definitionId) { + where.push('r.definition_id = ?') + args.push(definitionId) + } + if (status) { + where.push('r.status = ?') + args.push(status) + } + const clause = where.length ? `WHERE ${where.join(' AND ')}` : '' + const n = Math.min(Math.max(Number(limit) || 100, 1), 500) + const rows = await query( + `${SELECT_LIST} ${clause} ORDER BY r.scheduled_for DESC, r.id DESC LIMIT ${n}`, + args, + ) + return rows.map(hydrate) +} + +const getById = async (id) => { + const [row] = await query(`${SELECT_LIST} WHERE r.id = ?`, [id]) + return hydrate(row) +} + +/** + * Materialise one occurrence. Answers the row id, or `null` when one already + * existed — which is not an error and is the ordinary answer under a tick that + * overran into the next one. + */ +const materialise = async (run) => { + const result = await query( + `INSERT IGNORE INTO event_runs + (definition_id, version_id, scope, scheduled_for, timezone, concurrency_key, + params, rehearsal, started_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + run.definition_id, + run.version_id, + run.scope || '', + run.scheduled_for, + run.timezone || 'UTC', + run.concurrency_key, + run.params === null || run.params === undefined ? null : JSON.stringify(run.params), + run.rehearsal ? 1 : 0, + run.started_by, + ], + ) + return Number(result?.affectedRows || 0) === 1 ? result.insertId : null +} + +/** The occurrence the unique key names, whether or not this call created it. */ +const findOccurrence = async (definitionId, scope, scheduledFor) => { + const [row] = await query( + `${SELECT_LIST} WHERE r.definition_id = ? AND r.scope = ? AND r.scheduled_for = ?`, + [definitionId, scope || '', scheduledFor], + ) + return hydrate(row) +} + +/** Is anything of this definition not yet terminal? The archive pre-check. */ +const countActiveForDefinition = async (definitionId) => { + const [row] = await query( + `SELECT COUNT(*) AS n FROM event_runs + WHERE definition_id = ? + AND status IN ('scheduled','starting','running','paused','ending')`, + [definitionId], + ) + return Number(row?.n || 0) +} + +module.exports = { list, getById, materialise, findOccurrence, countActiveForDefinition } diff --git a/server/src/model/events/eventRuns.model.js b/server/src/model/events/eventRuns.model.js new file mode 100644 index 0000000..34294c5 --- /dev/null +++ b/server/src/model/events/eventRuns.model.js @@ -0,0 +1,134 @@ +// ── Event runs — creating an occurrence ──────────────────────────────────── +// +// EVENTS.md §E. Phase 1 creates a run row and materialises the first phase's +// steps. **It does not start anything**: there is no runner until Phase 2, so a +// row created here sits at `scheduled` indefinitely. That is the correct +// behaviour for this phase and it has to be VISIBLE as such rather than looking +// broken, which is why `create()` answers with the row and the admin surface +// renders the status verbatim. +// +// Two properties this file owns, both of which are the reason it exists before +// the runner rather than with it: +// +// - **Materialisation is `INSERT IGNORE` against the occurrence key.** Two +// attempts at one occurrence produce one row and an honest answer, not a +// duplicate-key error a caller has to interpret. The unique index — not a +// claim — is what makes "one run per occurrence per scope" true (§E). +// - **The idempotency key is minted with the step row and never varies by +// attempt.** It is a function of identity, so it can only be stable if it is +// stamped where the identity is created. + +const db = require('./eventRuns.db') +const stepsDb = require('./eventRunSteps.db') +const logDb = require('./eventRunLog.db') +const definitionsDb = require('./eventDefinitions.db') +const versionsDb = require('./eventVersions.db') + +const MAX_SCOPE = 190 + +/** + * Render a definition's `concurrency_key` template against a run's params. + * + * `invasion:{region}` with `{ region: 'Yew' }` becomes `invasion:Yew` (§E). A + * placeholder with no matching param is left standing rather than replaced with + * an empty string: `invasion:` would collide with every other unrendered key on + * the deployment, which is the opposite of what a concurrency key is for, and a + * literal `invasion:{region}` in the column is a visible mistake. + */ +function renderConcurrencyKey(template, params) { + if (!template) return null + return String(template) + .replace(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g, (whole, name) => { + const value = params && params[name] + return value === undefined || value === null || value === '' ? whole : String(value) + }) + .slice(0, MAX_SCOPE) +} + +/** + * Create one occurrence of a definition and materialise its first phase. + * + * `scheduledFor` defaults to now — "start now" is an occurrence whose instant is + * the present, not a separate concept, which is what keeps the runner's one + * materialise/advance path honest when Phase 4 adds recurrence on top. + */ +async function create(definitionId, { scope = '', scheduledFor = null, rehearsal = false, params = null } = {}, userId) { + const definition = await definitionsDb.getById(definitionId) + if (!definition) return { ok: false, status: 404, errors: ['no such event definition'] } + if (definition.state !== 'ready') { + return { + ok: false, + status: 409, + errors: [`a ${definition.state} definition has no published version to run`], + } + } + if (!definition.current_version_id) { + return { ok: false, status: 409, errors: ['this definition has no published version'] } + } + + const version = await versionsDb.getById(definition.current_version_id) + if (!version?.spec?.phases?.length) { + return { ok: false, status: 409, errors: ['the published version has no phases'] } + } + + const scopeValue = String(scope || '').slice(0, MAX_SCOPE) + const when = scheduledFor ? new Date(scheduledFor) : new Date() + if (Number.isNaN(when.getTime())) { + return { ok: false, status: 400, errors: ['scheduledFor is not a date'] } + } + + const runId = await db.materialise({ + definition_id: definitionId, + version_id: version.id, + scope: scopeValue, + // Stored as UTC. The definition's zone is what an occurrence is COMPUTED in + // (Phase 4); what is stored is the instant. + scheduled_for: when, + timezone: definition.timezone, + concurrency_key: renderConcurrencyKey(definition.concurrency_key, params), + params, + rehearsal, + started_by: userId, + }) + + if (runId === null) { + // The occurrence already existed. Not an error — it is what the unique index + // is for — so the existing row is the answer. + const existing = await db.findOccurrence(definitionId, scopeValue, when) + return { ok: true, created: false, run: existing } + } + + await logDb.write({ + runId, + kind: 'run.created', + detail: { + definitionId, + versionId: version.id, + version: version.version, + scope: scopeValue, + rehearsal: Boolean(rehearsal), + by: userId, + }, + }) + + // The first phase's steps, materialised at creation rather than at start. + // Phase 2 materialises each LATER phase as the run enters it; doing the first + // one here is what makes a Phase 1 run row inspectable — an operator can see + // the steps that would run, with their params and their idempotency keys, + // before there is anything to run them. + const first = version.spec.phases[0] + await stepsDb.materialisePhase(runId, first.key, first.steps || []) + await logDb.write({ runId, kind: 'phase.entered', phase: first.key, detail: { steps: (first.steps || []).length } }) + + return { ok: true, created: true, run: await db.getById(runId) } +} + +/** A run, its steps and its status counts — what the run console reads. */ +async function detail(runId) { + const run = await db.getById(runId) + if (!run) return null + const [steps, counts] = await Promise.all([stepsDb.listForRun(runId), stepsDb.statusCounts(runId)]) + return { run, steps, counts } +} + +module.exports = { create, detail, renderConcurrencyKey } diff --git a/server/src/model/events/eventSeries.db.js b/server/src/model/events/eventSeries.db.js new file mode 100644 index 0000000..2ed3729 --- /dev/null +++ b/server/src/model/events/eventSeries.db.js @@ -0,0 +1,23 @@ +// ── event_series — SQL only ──────────────────────────────────────────────── +// +// EVENTS.md §D. The arc a definition may belong to. Phase 1 needs the reads — +// `event_definitions.series_id` is a foreign key and the definition save path +// has to check it resolves — and creating one is Phase 4's, where the calendar +// is what makes an arc visible. + +const { query } = require('../../utils/db') + +const list = async () => + query('SELECT * FROM event_series ORDER BY ordering, name, id') + +const getById = async (id) => { + const [row] = await query('SELECT * FROM event_series WHERE id = ?', [id]) + return row || null +} + +const exists = async (id) => { + const [row] = await query('SELECT id FROM event_series WHERE id = ?', [id]) + return Boolean(row) +} + +module.exports = { list, getById, exists } diff --git a/server/src/model/events/eventVersions.db.js b/server/src/model/events/eventVersions.db.js new file mode 100644 index 0000000..c77c207 --- /dev/null +++ b/server/src/model/events/eventVersions.db.js @@ -0,0 +1,54 @@ +// ── event_versions — SQL only ────────────────────────────────────────────── +// +// EVENTS.md §D. Immutable: there is an insert and there are reads, and there is +// deliberately no update and no delete. A run pins a version, and that pin is +// what makes the run reproducible and an audit answerable after the definition +// has been edited underneath it. + +const { query } = require('../../utils/db') +const { parseJson } = require('./eventJson') + +const hydrate = (row) => row && { ...row, spec: parseJson(row.spec, null) } + +const listForDefinition = async (definitionId) => + ( + await query( + `SELECT v.id, v.definition_id, v.version, v.published_at, v.published_by, u.username AS published_by_username + FROM event_versions v + LEFT JOIN users u ON u.id = v.published_by + WHERE v.definition_id = ? + ORDER BY v.version DESC`, + [definitionId], + ) + ).map((row) => row) + +const getById = async (id) => { + const [row] = await query('SELECT * FROM event_versions WHERE id = ?', [id]) + return hydrate(row) +} + +/** + * The next version number for a definition. + * + * Read separately and then INSERTed, which is a read-then-write — and it is safe + * only because `UNIQUE (definition_id, version)` is behind it. Two publishes + * racing for version 4 is one 1062 the caller reports, not two rows called 4. + * The unique index is the mechanism; this query is the ergonomics. + */ +const nextVersion = async (definitionId) => { + const [row] = await query( + 'SELECT COALESCE(MAX(version), 0) + 1 AS next FROM event_versions WHERE definition_id = ?', + [definitionId], + ) + return Number(row?.next || 1) +} + +const insert = async (definitionId, version, spec, userId) => { + const result = await query( + 'INSERT INTO event_versions (definition_id, version, spec, published_by) VALUES (?, ?, ?, ?)', + [definitionId, version, JSON.stringify(spec), userId], + ) + return result.insertId +} + +module.exports = { listForDefinition, getById, nextVersion, insert } diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js index 2831359..950014e 100644 --- a/server/src/modules/registries.js +++ b/server/src/modules/registries.js @@ -124,6 +124,20 @@ const triggers = new Map() // trigger of the same name would be a collision between two unrelated things. const audiences = new Map() +// action id → { owner, id, label, description, risk, reversible, version, +// budgetMs, params, cost, perform, revert } (EVENTS.md §F, Phase 1). +// +// **Its own id space**, like `audiences` above and for the same kind of reason: +// an action names a VERB and a trigger names an EVENT, so `uo.champ.start` as +// the thing a module can be asked to do and `uo.champ.start` as the thing that +// happened are two unrelated declarations that must not collide with — or +// silently satisfy — each other. Nothing cross-checks this map against the +// stream/trigger namespace, and nothing should. +// +// A Map, and read by id on the dispatch path exactly as `triggers` is; insertion +// order is what the admin catalog renders in. +const eventActions = 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. @@ -163,6 +177,10 @@ const LEG_ID = /^[a-z][a-z0-9.]{1,62}$/ // Audiences are their own id space (see the `audiences` Map), so they get their // own constant even though the grammar is the same one. const AUDIENCE_ID = EVENT_ID +// Likewise event actions (EVENTS.md §F, "Actions and budgets are their own id +// spaces"). One grammar, three namespaces — the constant is what makes the +// namespace visible at every use site. +const ACTION_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. @@ -367,6 +385,33 @@ async function resolveAudience(id, params = {}) { } } +// ── Event actions (EVENTS.md §F) ─────────────────────────────────────────── + +/** + * Every declaration WITHOUT its callables — what the admin catalog serves. + * + * `perform`, `revert` and `cost` are stripped for the same reason `resolve` is + * stripped from an audience and `handler` from a slash command: this is the + * object that leaves the process, and the browser's whole relationship with an + * action is naming one by id. §F's "a module registers actions server-side and + * adds no routes for them" is only true if the functions never ride out. + */ +const allEventActions = () => + [...eventActions.values()].map(({ perform, revert, cost, ...rest }) => rest) + +/** One declaration, callables included. The runner's lookup (Phase 2). */ +const eventAction = (id) => eventActions.get(id) || null + +/** + * Does anyone register this id right now? + * + * The authoring path's question, and it is deliberately not `eventAction(id) !== + * null` at every call site: a step naming an action whose module is uninstalled + * is DORMANT, not an error (§F), and the difference between "never existed" and + * "not installed today" is a distinction only the caller can draw. + */ +const isEventAction = (id) => eventActions.has(id) + // ── Shape checks, run the moment a registrant calls ──────────────────────── // // Split from the collision checks below on the same line PR 3 drew through @@ -745,6 +790,170 @@ function checkAudienceShape(entry) { } } +// ── Event action shape (EVENTS.md §F) ────────────────────────────────────── + +// Four values, closed, core-owned (§N6). Deliberately NOT "world-read" and +// "world-write", which are game words a chess ladder has no use for — and +// deliberately not extensible by a module, because the class is what core +// derives a step's `on_failure` from (§L) and a module that could invent +// `harmless` would be choosing its own retry policy. +const ACTION_RISKS = ['notify', 'inspect', 'change', 'irreversible'] + +// What core must know in order to clean up after a run (§L). `none` is gone once +// done; `self` undoes itself; `ledger` needs a `revert` over the rows core +// recorded; `override` is a lease, whose baseline core restores. +const ACTION_REVERSIBLE = ['none', 'self', 'ledger', 'override'] + +// The same six types a trigger variable uses. One vocabulary over both, because +// the authoring form that renders an action param and the template editor that +// renders a trigger variable are the same widget over the same six types, and a +// second list is a list that drifts. +const ACTION_PARAM_TYPES = VARIABLE_TYPES + +// The param name grammar, shared with trigger variables for the same reason: a +// param ends up as a key in a JSON object an operator reads. +const PARAM_NAME = VARIABLE_NAME + +// The default per-invocation deadline. Ten seconds is §F's own figure and it is +// the number the sidecar's own request timeout is set near — long enough for a +// round trip through a module, a sidecar and a game tick, short enough that a +// wedged action does not hold a step's claim past its lease. +const DEFAULT_BUDGET_MS = 10_000 +// An hour. Not "unlimited by another name": the bound exists so that a typo in a +// declaration is a slow action rather than a step that never times out at all, +// and Phase 2's lease has to be longer than this to mean anything. +const MAX_BUDGET_MS = 3_600_000 + +function checkActionParam(actionId, entry, seen) { + const { name, type, required, example, description, source } = entry || {} + const where = `registerEventActions: ${actionId}` + if (!PARAM_NAME.test(name || '')) throw new Error(`${where}: bad param name "${name}"`) + if (seen.has(name)) throw new Error(`${where}: param "${name}" declared twice`) + seen.add(name) + if (!ACTION_PARAM_TYPES.includes(type)) { + throw new Error(`${where}: param "${name}" has unsupported type "${type}"`) + } + // REQUIRED, on every param including the optional ones, and it is the same + // argument `checkTriggerVariable` makes: without it the authoring form has no + // placeholder and the operator is typing into a blank box, which is exactly + // how an unattended world write comes to be scheduled with a typo in it. It is + // one word at declaration time and unreconstructable afterwards. + if (example === undefined || example === null || example === '') { + throw new Error(`${where}: param "${name}" needs an example (it is the authoring placeholder)`) + } + // A `source` names a module-served option endpoint, so the field is a dropdown + // of real values rather than a text box (§F "Param option sources"). It is + // checked as an id here and resolved nowhere yet — the endpoint that answers it + // is Phase 7's, and a `source` naming nothing degrades the field to free text + // with a warning rather than blocking the form. + if (source !== undefined && !ACTION_ID.test(source || '')) { + throw new Error(`${where}: param "${name}" has a bad option source "${source}"`) + } + return { + name, + type, + required: Boolean(required), + example, + source: source === undefined ? null : source, + description: description || '', + } +} + +/** + * `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert }])`. + * + * A typed verb core may ask a registrant to carry out. Everything decidable from + * the argument alone is decided here, at the call; the collision — is this id + * already someone's action? — waits for `apply()`. + * + * The copy at the end is explicit rather than a spread, like every other shape + * check in this file: this object is served to the admin catalog and is what a + * step's params are validated against, so anything not named here is not part of + * the contract and must not ride along. + * + * **Nothing here executes and nothing here may touch the database.** Registration + * runs under `routeManifest.js` and `swagger.js` against a dead pool + * (MODULE_API.md §2.2), and core's own registration is subject to the same rule + * as a module's. + */ +function checkEventActionShape(entry) { + const a = entry || {} + if (!ACTION_ID.test(a.id || '')) { + throw new Error(`registerEventActions: bad action id "${a.id}"`) + } + if (!a.label) throw new Error(`registerEventActions: action "${a.id}" has no label`) + + // Both required with no default, for the reason a trigger's ceiling is: there + // is no safe value to guess. Defaulting `risk` to `notify` would give a world + // write the retry policy of a broadcast, and defaulting `reversible` to `none` + // would tell the cleanup generator there is nothing to undo. + if (!ACTION_RISKS.includes(a.risk)) { + throw new Error( + `registerEventActions: ${a.id} needs a risk class, one of ${ACTION_RISKS.join(', ')}`, + ) + } + if (!ACTION_REVERSIBLE.includes(a.reversible)) { + throw new Error( + `registerEventActions: ${a.id} needs a reversible class, one of ${ACTION_REVERSIBLE.join(', ')}`, + ) + } + + if (typeof a.perform !== 'function') { + throw new Error(`registerEventActions: ${a.id} has no perform()`) + } + // §F: `revert` is required iff `reversible === 'ledger'`. Checked here rather + // than discovered at teardown, because the moment it matters is the moment a + // run has already created something and the answer "there is no undo" is the + // one answer cleanup cannot act on. + if (a.reversible === 'ledger' && typeof a.revert !== 'function') { + throw new Error(`registerEventActions: ${a.id} is reversible: 'ledger' but has no revert()`) + } + // The mirror check, and it is not pedantry: a `revert` on a `reversible: + // 'none'` action is a module author who believes their action can be undone + // and a cleanup generator that will never call it. Silence there is a promise + // core does not keep. + if (a.revert !== undefined && a.reversible !== 'ledger') { + throw new Error( + `registerEventActions: ${a.id} declares revert() but is reversible: '${a.reversible}'`, + ) + } + if (a.cost !== undefined && typeof a.cost !== 'function') { + throw new Error(`registerEventActions: ${a.id} cost must be a function of its params`) + } + + const version = a.version === undefined ? 1 : a.version + if (!Number.isInteger(version) || version < 1) { + throw new Error(`registerEventActions: ${a.id} has a bad version "${a.version}"`) + } + + const budgetMs = a.budgetMs === undefined ? DEFAULT_BUDGET_MS : a.budgetMs + if (!Number.isInteger(budgetMs) || budgetMs <= 0 || budgetMs > MAX_BUDGET_MS) { + throw new Error( + `registerEventActions: ${a.id} budgetMs must be 1..${MAX_BUDGET_MS} ms, got "${a.budgetMs}"`, + ) + } + + if (a.params !== undefined && !Array.isArray(a.params)) { + throw new Error(`registerEventActions: ${a.id} params must be an array`) + } + const seen = new Set() + const params = (a.params || []).map((p) => checkActionParam(a.id, p, seen)) + + return { + id: a.id, + label: a.label, + description: a.description || '', + risk: a.risk, + reversible: a.reversible, + version, + budgetMs, + params, + cost: a.cost || null, + perform: a.perform, + revert: a.revert || null, + } +} + // ── Engagement seeds (Phase 11b, decision 7) ─────────────────────────────── // // **Two mechanisms, and the asymmetry between them is the whole design.** @@ -983,6 +1192,7 @@ function stage(owner) { slashCommands: [], triggers: [], audiences: [], + eventActions: [], engagementSeeds: [], } return { @@ -1015,6 +1225,16 @@ function stage(owner) { if (!Array.isArray(entries)) throw new Error('registerAudiences: expected an array') for (const e of entries) staged.audiences.push(checkAudienceShape(e)) }, + // EVENTS.md §F, Phase 1. Present on the staging area from this phase and + // reached ONLY by `registerCore()` below — `loader.js` builds its own `api` + // facade and has no method that delegates here, so a module cannot call this + // yet. Phase 7 adds that facade and bumps MODULE_API to 1.10.0; until then + // the seam is exercised on every boot by core's own three actions and by + // nothing else, which is the point of registering them through it. + registerEventActions(entries) { + if (!Array.isArray(entries)) throw new Error('registerEventActions: expected an array') + for (const e of entries) staged.eventActions.push(checkEventActionShape(e)) + }, registerEngagementSeeds(entry) { staged.engagementSeeds.push(checkEngagementSeeds(owner, entry)) }, @@ -1040,6 +1260,7 @@ function apply({ slashCommands: newSlashCommands = [], triggers: newTriggers = [], audiences: newAudiences = [], + eventActions: newEventActions = [], engagementSeeds: newSeeds = [], }) { // ── validate ── @@ -1096,6 +1317,23 @@ function apply({ seenAudiences.add(a.id) } + // Actions, against their OWN map and nothing else. No cross-facet check with + // streams or triggers: an action id and a trigger id are different namespaces + // (§F), so `uo.champ.start` may legitimately be both a verb and an event, and + // reading a collision there would forbid the most natural pair of names a + // module will ever want. No legacy allowlist either — nothing predates this, + // so the prefix rule has no exceptions and should never grow one. + const seenActions = new Set() + for (const a of newEventActions) { + const held = eventActions.get(a.id) + if (held) throw new Error(`event action "${a.id}" is already registered by "${held.owner}"`) + if (seenActions.has(a.id)) throw new Error(`event action "${a.id}" registered twice`) + if (!namespaced(owner, a.id, {})) { + throw new Error(`event action "${a.id}" is not namespaced "${owner}."`) + } + seenActions.add(a.id) + } + const seenLegs = new Set() for (const l of newLegs) { const held = legs.get(l.leg) @@ -1160,6 +1398,7 @@ function apply({ for (const c of newSlashCommands) slashCommands.set(c.name, { owner, ...c }) for (const t of newTriggers) triggers.set(t.id, { owner, ...t }) for (const a of newAudiences) audiences.set(a.id, { owner, ...a }) + for (const a of newEventActions) eventActions.set(a.id, { owner, ...a }) for (const seeds of newSeeds) engagementSeeds.set(owner, seeds) } @@ -1182,6 +1421,7 @@ function registerCore() { /* eslint-disable global-require */ const coreStreams = require('../config/coreStreams') const coreTriggers = require('../config/coreTriggers') + const coreEventActions = require('../config/coreEventActions') const discordLeg = require('../utils/discordAnnounce') /* eslint-enable global-require */ @@ -1192,6 +1432,11 @@ function registerCore() { // its five stream ids — the same-owner upgrade the one-namespace rule above is // written for — so this batch exercises the cross-facet check on every boot. api.registerEventTriggers(coreTriggers.TRIGGERS) + // The event contract (EVENTS.md §F, Phase 1). Core registers `core.announce`, + // `core.wait` and `core.cue` through the SAME staging area Phase 7 will hand a + // module, so the registry is exercised on every boot long before a module uses + // it — the argument registerCore() has made since the module system's Phase 3. + api.registerEventActions(coreEventActions.ACTIONS) // The three lines that used to follow — the shard stream catalog, the town // crier leg and the `admin.users.detail` filling — were shard CONTENT held @@ -1205,6 +1450,7 @@ function registerCore() { log.info('core registrations complete', { streams: streams.length, eventTriggers: triggers.size, + eventActions: eventActions.size, announceLegs: legs.size, extensions: [...slots.keys()].filter(slotFilledBy), }) @@ -1236,6 +1482,7 @@ function _reset() { slashCommands.clear() triggers.clear() audiences.clear() + eventActions.clear() engagementSeeds.clear() coreRegistered = false } @@ -1264,11 +1511,18 @@ module.exports = { allAudiences, audience, resolveAudience, + allEventActions, + eventAction, + isEventAction, allEngagementSeeds, engagementSeedsFor, SEEDABLE_CHANNELS, VARIABLE_TYPES, TRIGGER_KINDS, + ACTION_RISKS, + ACTION_REVERSIBLE, + ACTION_PARAM_TYPES, + DEFAULT_BUDGET_MS, stage, apply, registerCore, diff --git a/server/src/router/v1/admin/events.controller.js b/server/src/router/v1/admin/events.controller.js new file mode 100644 index 0000000..7080dee --- /dev/null +++ b/server/src/router/v1/admin/events.controller.js @@ -0,0 +1,315 @@ +// ── Admin: events ────────────────────────────────────────────────────────── +// +// EVENTS.md § API surface, Phase 1. Definitions CRUD, publish, archive, the +// action catalog and the run reads. +// +// This file reads ids out of URLs and shapes responses; it validates nothing. +// Every decision lives in `model/events/*.model.js` and in `events/spec.js`, so +// a definition arriving from a future import or a restore gets the same answer +// this screen does. +// +// **What is deliberately absent**: pause, resume, advance, cancel, step +// skip/retry/confirm, cleanup and the action switchboard. Each of them acts on a +// run in flight, and nothing is in flight until Phase 2 builds the runner. A +// control that returns 200 and does nothing is worse than one that is not there. + +const registries = require('../../../modules/registries') +const spec = require('../../../events/spec') +const definitionsDb = require('../../../model/events/eventDefinitions.db') +const definitions = require('../../../model/events/eventDefinitions.model') +const versionsDb = require('../../../model/events/eventVersions.db') +const seriesDb = require('../../../model/events/eventSeries.db') +const runsDb = require('../../../model/events/eventRuns.db') +const runs = require('../../../model/events/eventRuns.model') +const logDb = require('../../../model/events/eventRunLog.db') +const activity = require('../../../model/activity/activity.model') + +const asId = (raw) => { + const n = Number(raw) + return Number.isInteger(n) && n > 0 ? n : null +} + +/** + * The shape a definition takes on the wire. + * + * Explicit rather than the row, like every other admin surface here: the row + * carries `created_by`, `updated_by` and the joined series columns, and a + * response that spreads it is a response that gains a column the day somebody + * adds one. + */ +const shapeDefinition = (d) => ({ + id: d.id, + title: d.title, + slug: d.slug, + summary: d.summary, + body: d.body, + imageUrl: d.image_url, + ownerModule: d.owner_module, + state: d.state, + currentVersionId: d.current_version_id, + currentVersion: d.current_version, + seriesId: d.series_id, + seriesName: d.series_name, + seriesOrder: d.series_order, + concurrencyKey: d.concurrency_key, + graceSeconds: d.grace_seconds, + timezone: d.timezone, + spec: d.spec, + createdAt: d.created_at, + updatedAt: d.updated_at, +}) + +const shapeRun = (r) => ({ + id: r.id, + definitionId: r.definition_id, + definitionTitle: r.definition_title, + definitionSlug: r.definition_slug, + versionId: r.version_id, + version: r.version_number, + scope: r.scope, + status: r.status, + health: r.health, + cleanupStatus: r.cleanup_status, + currentPhase: r.current_phase, + scheduledFor: r.scheduled_for, + timezone: r.timezone, + concurrencyKey: r.concurrency_key, + params: r.params, + rehearsal: r.rehearsal, + startedAt: r.started_at, + endedAt: r.ended_at, + lastError: r.last_error, + createdAt: r.created_at, +}) + +const shapeStep = (s) => ({ + id: s.id, + runId: s.run_id, + phase: s.phase, + seq: s.seq, + actionId: s.action_id, + params: s.params, + actionVersion: s.action_version, + status: s.status, + dueAt: s.due_at, + attempts: s.attempts, + onFailure: s.on_failure, + idempotencyKey: s.idempotency_key, + lastError: s.last_error, + startedAt: s.started_at, + finishedAt: s.finished_at, +}) + +/** GET /api/v1/admin/events */ +exports.list = async (req, res) => { + const state = ['draft', 'ready', 'archived'].includes(req.query.state) ? req.query.state : null + const rows = await definitionsDb.list({ state }) + res.json({ events: rows.map(shapeDefinition) }) +} + +/** + * GET /api/v1/admin/events/catalog + * + * The registered actions, their param schemas, their risk classes and the + * vocabularies over both — served from the registries, so there is no table + * behind it and a module that was uninstalled simply stops appearing. Same + * argument the engagement trigger catalog makes: the editor offers exactly the + * set the save path checks against, so the two cannot drift. + * + * Budget dimensions are absent, and that is Phase 1 being honest rather than an + * omission: `registerEventBudgets` is Phase 7's and nothing declares one yet. + */ +exports.catalog = (_req, res) => { + res.json({ + actions: registries.allEventActions(), + risks: registries.ACTION_RISKS, + reversible: registries.ACTION_REVERSIBLE, + paramTypes: registries.ACTION_PARAM_TYPES, + onFailure: spec.ON_FAILURE, + onFailureByRisk: spec.ON_FAILURE_BY_RISK, + scheduleKinds: spec.SCHEDULE_KINDS, + limits: { + maxPhases: spec.MAX_PHASES, + maxStepsPerPhase: spec.MAX_STEPS_PER_PHASE, + maxSteps: spec.MAX_STEPS, + defaultBudgetMs: registries.DEFAULT_BUDGET_MS, + }, + }) +} + +/** GET /api/v1/admin/events/series */ +exports.listSeries = async (_req, res) => { + const rows = await seriesDb.list() + res.json({ + series: rows.map((s) => ({ + id: s.id, + name: s.name, + slug: s.slug, + description: s.description, + ordering: s.ordering, + })), + }) +} + +/** GET /api/v1/admin/events/runs */ +exports.listRuns = async (req, res) => { + const rows = await runsDb.list({ + definitionId: asId(req.query.definitionId), + status: req.query.status || null, + limit: req.query.limit, + }) + res.json({ runs: rows.map(shapeRun) }) +} + +/** GET /api/v1/admin/events/runs/:runId */ +exports.getRun = async (req, res) => { + const runId = asId(req.params.runId) + if (!runId) return res.status(400).json({ error: 'bad run id' }) + const found = await runs.detail(runId) + if (!found) return res.status(404).json({ error: 'no such run' }) + return res.json({ + run: shapeRun(found.run), + steps: found.steps.map(shapeStep), + counts: found.counts, + }) +} + +/** GET /api/v1/admin/events/runs/:runId/log */ +exports.getRunLog = async (req, res) => { + const runId = asId(req.params.runId) + if (!runId) return res.status(400).json({ error: 'bad run id' }) + const run = await runsDb.getById(runId) + if (!run) return res.status(404).json({ error: 'no such run' }) + const lines = await logDb.listForRun(runId, { limit: req.query.limit }) + return res.json({ + log: lines.map((l) => ({ + id: l.id, + stepId: l.step_id, + kind: l.kind, + phase: l.phase, + detail: l.detail, + at: l.at, + })), + kinds: logDb.KINDS, + }) +} + +/** GET /api/v1/admin/events/:id */ +exports.get = async (req, res) => { + const id = asId(req.params.id) + if (!id) return res.status(400).json({ error: 'bad event id' }) + const row = await definitionsDb.getById(id) + if (!row) return res.status(404).json({ error: 'no such event definition' }) + return res.json({ event: shapeDefinition(row) }) +} + +/** GET /api/v1/admin/events/:id/versions */ +exports.listVersions = async (req, res) => { + const id = asId(req.params.id) + if (!id) return res.status(400).json({ error: 'bad event id' }) + const row = await definitionsDb.getById(id) + if (!row) return res.status(404).json({ error: 'no such event definition' }) + const rows = await versionsDb.listForDefinition(id) + return res.json({ + versions: rows.map((v) => ({ + id: v.id, + version: v.version, + publishedAt: v.published_at, + publishedBy: v.published_by, + publishedByUsername: v.published_by_username, + current: v.id === row.current_version_id, + })), + }) +} + +/** POST /api/v1/admin/events */ +exports.create = async (req, res) => { + const result = await definitions.create(req.body, req.user.id) + if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors }) + await activity.log({ + req, + action: 'event.definition.created', + detail: { id: result.id, title: result.definition.title }, + }) + return res.status(201).json({ event: shapeDefinition(result.definition) }) +} + +/** PUT /api/v1/admin/events/:id */ +exports.update = async (req, res) => { + const id = asId(req.params.id) + if (!id) return res.status(400).json({ error: 'bad event id' }) + const result = await definitions.save(id, req.body, req.user.id) + if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors }) + await activity.log({ + req, + action: 'event.definition.updated', + detail: { id, title: result.definition.title }, + }) + return res.json({ event: shapeDefinition(result.definition) }) +} + +/** POST /api/v1/admin/events/:id/publish */ +exports.publish = async (req, res) => { + const id = asId(req.params.id) + if (!id) return res.status(400).json({ error: 'bad event id' }) + const result = await definitions.publish(id, req.user.id) + if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors }) + await activity.log({ + req, + action: 'event.definition.published', + detail: { id, version: result.version, versionId: result.versionId }, + }) + return res.json({ + event: shapeDefinition(result.definition), + version: result.version, + versionId: result.versionId, + }) +} + +/** DELETE /api/v1/admin/events/:id — archive, never a hard delete */ +exports.archive = async (req, res) => { + const id = asId(req.params.id) + if (!id) return res.status(400).json({ error: 'bad event id' }) + const result = await definitions.archive(id, req.user.id) + if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors }) + await activity.log({ req, action: 'event.definition.archived', detail: { id } }) + return res.json({ event: shapeDefinition(result.definition) }) +} + +/** + * POST /api/v1/admin/events/:id/runs + * + * Creates the occurrence. It stays `scheduled` until Phase 2's runner exists, + * and the response says so through `pending: true` rather than by pretending + * something started. + */ +exports.startRun = async (req, res) => { + const id = asId(req.params.id) + if (!id) return res.status(400).json({ error: 'bad event id' }) + const result = await runs.create( + id, + { + scope: req.body?.scope, + scheduledFor: req.body?.scheduledFor, + rehearsal: Boolean(req.body?.rehearsal), + params: req.body?.params ?? null, + }, + req.user.id, + ) + if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors }) + if (result.created) { + await activity.log({ + req, + action: 'event.run.created', + detail: { + definitionId: id, + runId: result.run.id, + rehearsal: Boolean(req.body?.rehearsal), + }, + }) + } + return res.status(result.created ? 201 : 200).json({ + run: shapeRun(result.run), + created: result.created, + }) +} diff --git a/server/src/router/v1/admin/events.router.js b/server/src/router/v1/admin/events.router.js new file mode 100644 index 0000000..36c28b6 --- /dev/null +++ b/server/src/router/v1/admin/events.router.js @@ -0,0 +1,201 @@ +// Admin · Events — definitions, versions, the action catalog and run reads +// (EVENTS.md § API surface, Phase 1). +// +// Mounted at /api/v1/admin/events by admin/index.js, which has already applied +// `noindex, isLoggedIn, staffOnly`. Every route below re-gates to the tier +// EVENTS.md § API surface names for it. +// +// **The gates are the real ones from this phase, not placeholders.** §N2 decided +// that publish and start are `admin` ONLY — a moderator keeps live control of a +// run already in flight and nothing more — and the switchboard those gates will +// eventually consult (`event_action_settings`, Phase 6) does not exist yet. They +// are here anyway, because a button that is admin-only later and open now is a +// gate nobody notices was missing. +// +// Reads are staff-wide. `verify` (admin, editor) is Phase 6's, and the live run +// controls (admin, moderator) are Phase 3's — neither is stubbed here, because +// nothing is in flight until Phase 2 builds the runner. +// +// **Literal paths are declared before `/:id`**, so `/catalog`, `/series` and +// `/runs` are never read as an event id. + +const express = require('express') + +const controller = require('./events.controller') +const { requireRole } = require('../../../utils/auth') + +const eventsRouter = express.Router() +const adminOnly = requireRole('admin') +const adminOrEditor = requireRole('admin', 'editor') + +// ── The catalog and the vocabularies, served from the registries ─────────── + +eventsRouter.get( + '/catalog', + // #swagger.tags = ['Admin · Events'] + // #swagger.summary = 'List every registered event action, with its param schema, risk class and reversibility' + // #swagger.description = 'Served from the module registries, not from a table: an action is declared in code by core or by an installed module, so this is whatever registered on this boot, and an uninstalled module simply stops appearing. Core always declares core.announce, core.wait and core.cue. Also carries the closed vocabularies the authoring form renders — risk classes, reversibility classes, param types, failure dispositions and the spec size limits — so the editor offers exactly the set the save path checks against.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The registered actions and the vocabularies over them', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", items: { type: "object", additionalProperties: true } }, risks: { type: "array", items: { type: "string" } }, reversible: { type: "array", items: { type: "string" } }, paramTypes: { type: "array", items: { type: "string" } }, onFailure: { type: "array", items: { type: "string" } }, onFailureByRisk: { type: "object", additionalProperties: true }, scheduleKinds: { type: "array", items: { type: "string" } }, limits: { type: "object", additionalProperties: true } } } } } } */ + /* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + controller.catalog, +) + +eventsRouter.get( + '/series', + // #swagger.tags = ['Admin · Events'] + // #swagger.summary = 'List the event series a definition may belong to' + // #swagger.description = 'A series is the arc several definitions form together. Read-only in this phase: creating and ordering one arrives with the calendar.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The series', content: { "application/json": { schema: { type: "object", properties: { series: { type: "array", items: { type: "object", properties: { id: { type: "integer" }, name: { type: "string" }, slug: { type: "string" }, description: { type: "string", nullable: true }, ordering: { type: "integer" } } } } } } } } } */ + /* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + controller.listSeries, +) + +// ── Runs ────────────────────────────────────────────────────────────────── +// +// Declared ahead of /:id so the literal path is never read as a definition id. + +eventsRouter.get( + '/runs', + // #swagger.tags = ['Admin · Events'] + // #swagger.summary = 'List event runs across every definition, newest occurrence first' + // #swagger.description = 'A run is one occurrence of one definition in one scope. Until the runner ships, every row here sits at `scheduled` — that is correct for this phase rather than a stalled run.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['definitionId'] = { in: 'query', description: 'Only runs of this definition', required: false, schema: { type: 'integer' } } + // #swagger.parameters['status'] = { in: 'query', description: 'Only runs in this status', required: false, schema: { type: 'string' } } + // #swagger.parameters['limit'] = { in: 'query', description: 'How many rows, 1..500 (default 100)', required: false, schema: { type: 'integer' } } + /* #swagger.responses[200] = { description: 'The runs', content: { "application/json": { schema: { type: "object", properties: { runs: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */ + /* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + controller.listRuns, +) + +eventsRouter.get( + '/runs/:runId', + // #swagger.tags = ['Admin · Events'] + // #swagger.summary = 'One run: its status, health, cleanup state and every step with its params and idempotency key' + // #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The run, its steps and the status counts', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, steps: { type: "array", items: { type: "object", additionalProperties: true } }, counts: { type: "object", additionalProperties: true } } } } } } */ + /* #swagger.responses[404] = { description: 'No such run', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + controller.getRun, +) + +eventsRouter.get( + '/runs/:runId/log', + // #swagger.tags = ['Admin · Events'] + // #swagger.summary = 'The diagnostic log for one run' + // #swagger.description = 'Structured and queryable, unlike activity_log.detail: this is what answers "why did not phase 3 start?" without reading server logs. The audit of who published what is written separately to the activity log.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['limit'] = { in: 'query', description: 'How many lines, 1..2000 (default 500)', required: false, schema: { type: 'integer' } } + /* #swagger.responses[200] = { description: 'The log, newest first, and the closed set of line kinds', content: { "application/json": { schema: { type: "object", properties: { log: { type: "array", items: { type: "object", additionalProperties: true } }, kinds: { type: "array", items: { type: "string" } } } } } } } */ + /* #swagger.responses[404] = { description: 'No such run', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + controller.getRunLog, +) + +// ── Definitions ─────────────────────────────────────────────────────────── + +eventsRouter.get( + '/', + // #swagger.tags = ['Admin · Events'] + // #swagger.summary = 'List every event definition with its state and current version' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['state'] = { in: 'query', description: 'Only definitions in this state: draft, ready or archived', required: false, schema: { type: 'string' } } + /* #swagger.responses[200] = { description: 'The definitions', content: { "application/json": { schema: { type: "object", properties: { events: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */ + /* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + controller.list, +) + +eventsRouter.post( + '/', + // #swagger.tags = ['Admin · Events'] + // #swagger.summary = 'Create a draft event definition' + // #swagger.description = 'Creates a draft. The slug is derived from the title once and frozen afterwards, because the public event page lives at it. The spec defaults to one empty phase; steps are validated against the action catalog, and an unknown action id is refused.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { title: { type: "string" }, summary: { type: "string", nullable: true }, body: { type: "string", nullable: true }, imageUrl: { type: "string", nullable: true }, seriesId: { type: "integer", nullable: true }, seriesOrder: { type: "integer" }, concurrencyKey: { type: "string", nullable: true }, graceSeconds: { type: "integer" }, timezone: { type: "string" }, spec: { type: "object", additionalProperties: true } }, required: ["title"] } } } } */ + /* #swagger.responses[201] = { description: 'The created draft', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true } } } } } } */ + /* #swagger.responses[400] = { description: 'Validation failed; every problem is listed', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */ + /* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOrEditor, + controller.create, +) + +eventsRouter.get( + '/:id', + // #swagger.tags = ['Admin · Events'] + // #swagger.summary = 'One event definition, including its working spec' + // #swagger.description = 'The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The definition', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true } } } } } } */ + /* #swagger.responses[404] = { description: 'No such definition', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + controller.get, +) + +eventsRouter.put( + '/:id', + // #swagger.tags = ['Admin · Events'] + // #swagger.summary = 'Edit a definition and its working spec' + // #swagger.description = 'Editing is free and never touches a published version: a live run keeps the version it pinned. A step naming an action whose module has since been uninstalled is KEPT and marked dormant rather than refused, so an uninstall is never destructive after the fact — but a dormant step blocks publish. An archived definition cannot be edited.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { title: { type: "string" }, summary: { type: "string", nullable: true }, body: { type: "string", nullable: true }, imageUrl: { type: "string", nullable: true }, seriesId: { type: "integer", nullable: true }, seriesOrder: { type: "integer" }, concurrencyKey: { type: "string", nullable: true }, graceSeconds: { type: "integer" }, timezone: { type: "string" }, spec: { type: "object", additionalProperties: true } } } } } } */ + /* #swagger.responses[200] = { description: 'The saved definition', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true } } } } } } */ + /* #swagger.responses[400] = { description: 'Validation failed; every problem is listed', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */ + /* #swagger.responses[409] = { description: 'The definition is archived', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */ + adminOrEditor, + controller.update, +) + +eventsRouter.get( + '/:id/versions', + // #swagger.tags = ['Admin · Events'] + // #swagger.summary = 'The version history of one definition' + // #swagger.description = 'Versions are immutable and nothing edits one. The row flagged `current` is what a new run pins.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The versions, newest first', content: { "application/json": { schema: { type: "object", properties: { versions: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */ + /* #swagger.responses[404] = { description: 'No such definition', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + controller.listVersions, +) + +eventsRouter.post( + '/:id/publish', + // #swagger.tags = ['Admin · Events'] + // #swagger.summary = 'Snapshot the working spec into an immutable version and mark the definition ready' + // #swagger.description = 'Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The definition, now ready, and the version that was cut', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true }, version: { type: "integer" }, versionId: { type: "integer" } } } } } } */ + /* #swagger.responses[400] = { description: 'The spec is invalid, or no phase has any steps', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */ + /* #swagger.responses[409] = { description: 'A step names an action no module registers, or the definition is archived', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */ + /* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + controller.publish, +) + +eventsRouter.delete( + '/:id', + // #swagger.tags = ['Admin · Events'] + // #swagger.summary = 'Archive a definition — never a hard delete' + // #swagger.description = 'Archiving keeps the definition history without it ever running again. Refused while a run of it is still in flight: cancel the run first. A hard delete is not offered at all, because a run pins a version and a run that could not be explained afterwards defeats the audit this system exists to provide.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The archived definition', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true } } } } } } */ + /* #swagger.responses[409] = { description: 'A run of this definition is still in flight', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */ + /* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + controller.archive, +) + +eventsRouter.post( + '/:id/runs', + // #swagger.tags = ['Admin · Events'] + // #swagger.summary = 'Create an occurrence of a published definition' + // #swagger.description = 'Admin only, on the same reasoning as publish: starting commits the deployment to a run. Materialised with INSERT IGNORE against UNIQUE (definition_id, scope, scheduled_for), so asking twice for one occurrence answers with the existing row and `created: false` rather than creating a second. Until the runner ships the row stays `scheduled` and nothing dispatches — its steps and their idempotency keys are inspectable in the meantime.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { scope: { type: "string", description: "Module-opaque. Core stores it verbatim and never parses it." }, scheduledFor: { type: "string", description: "UTC instant; defaults to now" }, rehearsal: { type: "boolean" }, params: { type: "object", additionalProperties: true, description: "Rendered into the definition concurrency_key template" } } } } } } */ + /* #swagger.responses[201] = { description: 'The occurrence was created', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, created: { type: "boolean" } } } } } } */ + /* #swagger.responses[200] = { description: 'The occurrence already existed and is returned unchanged', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, created: { type: "boolean" } } } } } } */ + /* #swagger.responses[409] = { description: 'The definition is not ready, or has no published version', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */ + /* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + controller.startRun, +) + +module.exports = eventsRouter diff --git a/server/src/router/v1/admin/index.js b/server/src/router/v1/admin/index.js index f4bf0e2..8d0f668 100644 --- a/server/src/router/v1/admin/index.js +++ b/server/src/router/v1/admin/index.js @@ -31,6 +31,7 @@ const discordBotRouter = require('./discordBot.router') const settingsRouter = require('./settings.router') const modulesRouter = require('./modules.router') const engagementRouter = require('./engagement.router') +const eventsRouter = require('./events.router') const teamsRouter = require('./teams.router') const teamsVoiceRouter = require('./teamsVoice.router') const dashboardRouter = require('./dashboard.router') @@ -87,6 +88,13 @@ adminRouter.use('/modules', modulesRouter) // /modules above and for a related reason: this is the surface that decides who // the site sends mail to. adminRouter.use('/engagement', engagementRouter) +// The Event System (EVENTS.md § API surface, Phase 1). Staff-wide for the reads +// and gated per route for the writes, which is where §N2's asymmetry lives: +// publish and start are `admin` ONLY, while the live run controls Phase 3 adds +// are `admin` + `moderator`. Starting commits the deployment to an unattended +// world change; cancelling is incident response, and they are deliberately not +// the same gate. +adminRouter.use('/events', eventsRouter) // Teams. Staff-wide, like /activity: a moderator runs the reserved-name review // queue. The three actions that PUBLISH untrusted game-sourced strings are gated // per request inside the controller, not per route — a moderator may call them, diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 9ce1599..608068f 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -3363,6 +3363,1093 @@ ] } }, + "/api/v1/admin/events": { + "get": { + "tags": [ + "Admin · Events" + ], + "summary": "List every event definition with its state and current version", + "description": "", + "parameters": [ + { + "name": "state", + "in": "query", + "description": "Only definitions in this state: draft, ready or archived", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The definitions", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "events": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + }, + "403": { + "description": "Not staff", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Admin · Events" + ], + "summary": "Create a draft event definition", + "description": "Creates a draft. The slug is derived from the title once and frozen afterwards, because the public event page lives at it. The spec defaults to one empty phase; steps are validated against the action catalog, and an unknown action id is refused.", + "responses": { + "201": { + "description": "The created draft", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "event": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "400": { + "description": "Validation failed; every problem is listed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "403": { + "description": "Not an admin or editor", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "summary": { + "type": "string", + "nullable": true + }, + "body": { + "type": "string", + "nullable": true + }, + "imageUrl": { + "type": "string", + "nullable": true + }, + "seriesId": { + "type": "integer", + "nullable": true + }, + "seriesOrder": { + "type": "integer" + }, + "concurrencyKey": { + "type": "string", + "nullable": true + }, + "graceSeconds": { + "type": "integer" + }, + "timezone": { + "type": "string" + }, + "spec": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "title" + ] + } + } + } + } + } + }, + "/api/v1/admin/events/catalog": { + "get": { + "tags": [ + "Admin · Events" + ], + "summary": "List every registered event action, with its param schema, risk class and reversibility", + "description": "Served from the module registries, not from a table: an action is declared in code by core or by an installed module, so this is whatever registered on this boot, and an uninstalled module simply stops appearing. Core always declares core.announce, core.wait and core.cue. Also carries the closed vocabularies the authoring form renders — risk classes, reversibility classes, param types, failure dispositions and the spec size limits — so the editor offers exactly the set the save path checks against.", + "responses": { + "200": { + "description": "The registered actions and the vocabularies over them", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "actions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "risks": { + "type": "array", + "items": { + "type": "string" + } + }, + "reversible": { + "type": "array", + "items": { + "type": "string" + } + }, + "paramTypes": { + "type": "array", + "items": { + "type": "string" + } + }, + "onFailure": { + "type": "array", + "items": { + "type": "string" + } + }, + "onFailureByRisk": { + "type": "object", + "additionalProperties": true + }, + "scheduleKinds": { + "type": "array", + "items": { + "type": "string" + } + }, + "limits": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "403": { + "description": "Not staff", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/events/runs": { + "get": { + "tags": [ + "Admin · Events" + ], + "summary": "List event runs across every definition, newest occurrence first", + "description": "A run is one occurrence of one definition in one scope. Until the runner ships, every row here sits at `scheduled` — that is correct for this phase rather than a stalled run.", + "parameters": [ + { + "name": "definitionId", + "in": "query", + "description": "Only runs of this definition", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "status", + "in": "query", + "description": "Only runs in this status", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "How many rows, 1..500 (default 100)", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "The runs", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "runs": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + }, + "403": { + "description": "Not staff", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/events/runs/{runId}": { + "get": { + "tags": [ + "Admin · Events" + ], + "summary": "One run: its status, health, cleanup state and every step with its params and idempotency key", + "description": "The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat.", + "parameters": [ + { + "name": "runId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The run, its steps and the status counts", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "run": { + "type": "object", + "additionalProperties": true + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "counts": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No such run", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/events/runs/{runId}/log": { + "get": { + "tags": [ + "Admin · Events" + ], + "summary": "The diagnostic log for one run", + "description": "Structured and queryable, unlike activity_log.detail: this is what answers \"why did not phase 3 start?\" without reading server logs. The audit of who published what is written separately to the activity log.", + "parameters": [ + { + "name": "runId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "How many lines, 1..2000 (default 500)", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "The log, newest first, and the closed set of line kinds", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "log": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "kinds": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No such run", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/events/series": { + "get": { + "tags": [ + "Admin · Events" + ], + "summary": "List the event series a definition may belong to", + "description": "A series is the arc several definitions form together. Read-only in this phase: creating and ordering one arrives with the calendar.", + "responses": { + "200": { + "description": "The series", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "series": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "ordering": { + "type": "integer" + } + } + } + } + } + } + } + } + }, + "403": { + "description": "Not staff", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/events/{id}": { + "get": { + "tags": [ + "Admin · Events" + ], + "summary": "One event definition, including its working spec", + "description": "The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The definition", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "event": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No such definition", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "tags": [ + "Admin · Events" + ], + "summary": "Edit a definition and its working spec", + "description": "Editing is free and never touches a published version: a live run keeps the version it pinned. A step naming an action whose module has since been uninstalled is KEPT and marked dormant rather than refused, so an uninstall is never destructive after the fact — but a dormant step blocks publish. An archived definition cannot be edited.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The saved definition", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "event": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "400": { + "description": "Validation failed; every problem is listed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "409": { + "description": "The definition is archived", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "summary": { + "type": "string", + "nullable": true + }, + "body": { + "type": "string", + "nullable": true + }, + "imageUrl": { + "type": "string", + "nullable": true + }, + "seriesId": { + "type": "integer", + "nullable": true + }, + "seriesOrder": { + "type": "integer" + }, + "concurrencyKey": { + "type": "string", + "nullable": true + }, + "graceSeconds": { + "type": "integer" + }, + "timezone": { + "type": "string" + }, + "spec": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + }, + "delete": { + "tags": [ + "Admin · Events" + ], + "summary": "Archive a definition — never a hard delete", + "description": "Archiving keeps the definition history without it ever running again. Refused while a run of it is still in flight: cancel the run first. A hard delete is not offered at all, because a run pins a version and a run that could not be explained afterwards defeats the audit this system exists to provide.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The archived definition", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "event": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Not an admin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "A run of this definition is still in flight", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/events/{id}/publish": { + "post": { + "tags": [ + "Admin · Events" + ], + "summary": "Snapshot the working spec into an immutable version and mark the definition ready", + "description": "Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The definition, now ready, and the version that was cut", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "event": { + "type": "object", + "additionalProperties": true + }, + "version": { + "type": "integer" + }, + "versionId": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "The spec is invalid, or no phase has any steps", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "403": { + "description": "Not an admin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "A step names an action no module registers, or the definition is archived", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/events/{id}/runs": { + "post": { + "tags": [ + "Admin · Events" + ], + "summary": "Create an occurrence of a published definition", + "description": "Admin only, on the same reasoning as publish: starting commits the deployment to a run. Materialised with INSERT IGNORE against UNIQUE (definition_id, scope, scheduled_for), so asking twice for one occurrence answers with the existing row and `created: false` rather than creating a second. Until the runner ships the row stays `scheduled` and nothing dispatches — its steps and their idempotency keys are inspectable in the meantime.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The occurrence already existed and is returned unchanged", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "run": { + "type": "object", + "additionalProperties": true + }, + "created": { + "type": "boolean" + } + } + } + } + } + }, + "201": { + "description": "The occurrence was created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "run": { + "type": "object", + "additionalProperties": true + }, + "created": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Not an admin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "The definition is not ready, or has no published version", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "Module-opaque. Core stores it verbatim and never parses it." + }, + "scheduledFor": { + "type": "string", + "description": "UTC instant; defaults to now" + }, + "rehearsal": { + "type": "boolean" + }, + "params": { + "type": "object", + "additionalProperties": true, + "description": "Rendered into the definition concurrency_key template" + } + } + } + } + } + } + } + }, + "/api/v1/admin/events/{id}/versions": { + "get": { + "tags": [ + "Admin · Events" + ], + "summary": "The version history of one definition", + "description": "Versions are immutable and nothing edits one. The row flagged `current` is what a new run pins.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The versions, newest first", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "versions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No such definition", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/admin/invites": { "post": { "tags": [ diff --git a/server/test/eventActionRegistry.test.js b/server/test/eventActionRegistry.test.js new file mode 100644 index 0000000..c3882e1 --- /dev/null +++ b/server/test/eventActionRegistry.test.js @@ -0,0 +1,196 @@ +// ── The event action registry (EVENTS.md §F, Phase 1) ────────────────────── +// +// Phase 1's acceptance criteria for the registry half, one test apiece: +// +// • core's three actions register on every boot and appear in the catalog +// • the catalog never carries a callable — no `perform`, `revert` or `cost` +// • a module registering an un-namespaced action fails, with the holder named +// • the closed sets are closed: an invented risk or reversibility is refused +// • `reversible: 'ledger'` without a `revert()` is refused AT REGISTRATION, +// not discovered at teardown when something has already been created +// • an action id and a trigger id are DIFFERENT namespaces, so one id may +// legitimately be both — the property the audience registry established and +// this one inherits +// +// Point the DB at a closed port BEFORE requiring anything: registries.js reaches +// utils/discordAnnounce, which reaches the pool at require time. +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 coreEventActions = require('../src/config/coreEventActions') +const db = require('../src/utils/db') + +after(() => db.close()) + +beforeEach(() => registries._reset()) +afterEach(() => registries._reset()) + +const ok = (over = {}) => ({ + id: 'demo.thing.do', + label: 'Do the thing', + risk: 'change', + reversible: 'none', + perform: async () => ({ ok: true }), + ...over, +}) + +const register = (owner, entries) => { + const api = registries.stage(owner) + api.registerEventActions(entries) + registries.apply(api.staged) +} + +test('core registers its three actions on every boot', () => { + registries.registerCore() + const ids = registries.allEventActions().map((a) => a.id) + assert.deepEqual(ids, ['core.announce', 'core.wait', 'core.cue']) + assert.equal(ids.length, coreEventActions.ACTIONS.length) +}) + +test('the catalog carries no callable', () => { + registries.registerCore() + for (const action of registries.allEventActions()) { + assert.equal(action.perform, undefined, `${action.id} leaked perform`) + assert.equal(action.revert, undefined, `${action.id} leaked revert`) + assert.equal(action.cost, undefined, `${action.id} leaked cost`) + } + // …and the runner's own lookup still has it, which is the half that makes the + // stripping a boundary rather than a deletion. + assert.equal(typeof registries.eventAction('core.wait').perform, 'function') +}) + +test('core placeholders refuse rather than claiming success', async () => { + // Phase 1 declares; Phase 2 dispatches. The placeholder's answer matters + // because `ok: true` on an action that did nothing is a recorded world change + // that did not occur — the one wrong answer a stub can give. + registries.registerCore() + for (const id of ['core.announce', 'core.wait', 'core.cue']) { + const answer = await registries.eventAction(id).perform({}) + assert.equal(answer.ok, false) + assert.equal(answer.retry, false) + assert.match(answer.error, new RegExp(id.replace('.', '\\.'))) + } +}) + +test('an action must be namespaced to its owner, and the holder is named', () => { + assert.throws(() => register('demo', [ok({ id: 'other.thing.do' })]), /not namespaced "demo\."/) + + register('demo', [ok()]) + assert.throws( + () => register('rival', [ok({ id: 'demo.thing.do' })]), + /already registered by "demo"/, + ) +}) + +test('the same id twice in one batch is refused', () => { + assert.throws(() => register('demo', [ok(), ok()]), /registered twice/) +}) + +test('risk and reversibility are closed sets with no default', () => { + assert.throws(() => register('demo', [ok({ risk: undefined })]), /needs a risk class/) + assert.throws(() => register('demo', [ok({ risk: 'world-write' })]), /needs a risk class/) + assert.throws( + () => register('demo', [ok({ reversible: undefined })]), + /needs a reversible class/, + ) + assert.throws(() => register('demo', [ok({ reversible: 'maybe' })]), /needs a reversible class/) +}) + +test("reversible: 'ledger' without revert() is refused at registration", () => { + assert.throws( + () => register('demo', [ok({ reversible: 'ledger' })]), + /is reversible: 'ledger' but has no revert\(\)/, + ) + // And the mirror: a revert() nothing will ever call is a promise core does not + // keep, so it is refused just as loudly. + assert.throws( + () => register('demo', [ok({ reversible: 'none', revert: async () => ({ ok: true }) })]), + /declares revert\(\) but is reversible: 'none'/, + ) + register('demo', [ok({ reversible: 'ledger', revert: async () => ({ ok: true }) })]) + assert.equal(typeof registries.eventAction('demo.thing.do').revert, 'function') +}) + +test('perform() is required and cost must be a function', () => { + assert.throws(() => register('demo', [ok({ perform: undefined })]), /has no perform\(\)/) + assert.throws(() => register('demo', [ok({ cost: { 'demo.things': 1 } })]), /cost must be a function/) +}) + +test('every param needs a type and an example', () => { + const withParams = (params) => ok({ params }) + assert.throws(() => register('demo', [withParams([{ name: 'x' }])]), /unsupported type/) + assert.throws( + () => register('demo', [withParams([{ name: 'x', type: 'int' }])]), + /needs an example/, + ) + assert.throws( + () => register('demo', [withParams([{ name: '9bad', type: 'int', example: 1 }])]), + /bad param name/, + ) + assert.throws( + () => + register('demo', [ + withParams([ + { name: 'x', type: 'int', example: 1 }, + { name: 'x', type: 'int', example: 2 }, + ]), + ]), + /declared twice/, + ) + register('demo', [withParams([{ name: 'x', type: 'int', example: 12, source: 'demo.options.x' }])]) + const [param] = registries.eventAction('demo.thing.do').params + assert.deepEqual(param, { + name: 'x', + type: 'int', + required: false, + example: 12, + source: 'demo.options.x', + description: '', + }) +}) + +test('budgetMs defaults, and is bounded', () => { + register('demo', [ok()]) + assert.equal(registries.eventAction('demo.thing.do').budgetMs, registries.DEFAULT_BUDGET_MS) + registries._reset() + assert.throws(() => register('demo', [ok({ budgetMs: 0 })]), /budgetMs must be/) + assert.throws(() => register('demo', [ok({ budgetMs: 3_600_001 })]), /budgetMs must be/) +}) + +test('actions and triggers are different namespaces, so one id may be both', () => { + // The property §F states and the audience registry established first. A verb + // called `demo.raid.start` and an event called `demo.raid.start` are two + // unrelated declarations, and forbidding the pair would forbid the most + // natural names a module will ever want. + const api = registries.stage('demo') + api.registerEventTriggers([ + { id: 'demo.raid.start', label: 'A raid started', ceiling: 'everyone' }, + ]) + api.registerEventActions([ok({ id: 'demo.raid.start', label: 'Start a raid' })]) + registries.apply(api.staged) + + assert.equal(registries.eventTrigger('demo.raid.start').label, 'A raid started') + assert.equal(registries.eventAction('demo.raid.start').label, 'Start a raid') +}) + +test('a whole batch is refused or taken, never half', () => { + assert.throws( + () => register('demo', [ok(), ok({ id: 'demo.other.do', risk: 'nope' })]), + /needs a risk class/, + ) + // The shape check throws at the CALL, before anything is staged, so nothing + // from the batch is visible. + assert.equal(registries.eventAction('demo.thing.do'), null) +}) + +test('_reset() hands the process back', () => { + registries.registerCore() + assert.equal(registries.allEventActions().length, 3) + registries._reset() + assert.equal(registries.allEventActions().length, 0) + assert.equal(registries.isEventAction('core.wait'), false) +}) diff --git a/server/test/eventSpec.test.js b/server/test/eventSpec.test.js new file mode 100644 index 0000000..307cdad --- /dev/null +++ b/server/test/eventSpec.test.js @@ -0,0 +1,231 @@ +// ── The event spec validator (EVENTS.md §C/§D, Phase 1) ──────────────────── +// +// The boundary that decides whether a version may exist. Its interesting cases +// are all about time rather than shape: +// +// • a step's params are checked against the action's DECLARED params, and the +// action version it was authored against is captured at save +// • `on_failure` is defaulted from the risk class, because a `change` action +// that fell back to `skip` would advance a run over a half-changed world +// • an unregistered action is refused on a NEW step and KEPT on an existing +// one — the rule `engagementRules.model` established for a dormant trigger, +// for the same reason: an uninstall must not be destructive after the fact +// • a dormant step blocks a PUBLISH and never a SAVE +// • two phases may not share a key, because `UNIQUE (run_id, phase, seq)` +// would silently collapse them into one at materialisation +// • a key a later phase owns (`advance`, `announcements`) is REFUSED rather +// than preserved, so no corpus of unvalidated specs accumulates +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 spec = require('../src/events/spec') +const db = require('../src/utils/db') + +after(() => db.close()) + +beforeEach(() => { + registries._reset() + registries.registerCore() + const api = registries.stage('demo') + api.registerEventActions([ + { + id: 'demo.world.change', + label: 'Change the world', + risk: 'change', + reversible: 'none', + version: 4, + params: [ + { name: 'region', type: 'string', required: true, example: 'Yew' }, + { name: 'count', type: 'int', required: true, example: 12 }, + { name: 'hue', type: 'int', required: false, example: 1157 }, + ], + perform: async () => ({ ok: true }), + }, + { + id: 'demo.world.wreck', + label: 'Wreck the world', + risk: 'irreversible', + reversible: 'none', + perform: async () => ({ ok: true }), + }, + ]) + registries.apply(api.staged) +}) +afterEach(() => registries._reset()) + +const oneStep = (step) => ({ + schedule: { kind: 'manual' }, + phases: [{ key: 'main', label: 'Main', steps: [step] }], +}) + +test('the empty spec is valid, and is what a new draft carries', () => { + const result = spec.validate(spec.emptySpec()) + assert.equal(result.ok, true) + assert.equal(result.spec.phases.length, 1) + assert.deepEqual(result.spec.schedule, { kind: 'manual' }) +}) + +test('params are checked against the declaration and coerced', () => { + const result = spec.validate( + oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 12 } }), + ) + assert.equal(result.ok, true) + const [step] = result.spec.phases[0].steps + assert.deepEqual(step.params, { region: 'Yew', count: 12 }) + // Captured from the declaration, not from the request: it is what lets a later + // bump warn in the editor instead of dispatching a mistyped parameter. + assert.equal(step.actionVersion, 4) + assert.equal(step.dormant, false) +}) + +test('a missing required param, a wrong type and an unknown param are all refused', () => { + const missing = spec.validate(oneStep({ actionId: 'demo.world.change', params: { region: 'Yew' } })) + assert.equal(missing.ok, false) + assert.match(missing.errors.join('\n'), /"count" is required/) + + const wrong = spec.validate( + oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 'twelve' } }), + ) + assert.equal(wrong.ok, false) + assert.match(wrong.errors.join('\n'), /"count" expected an integer/) + + // An unknown param is an ERROR, not a silent drop: an author who typed + // `regions` has written a step that would dispatch with the region missing, + // and dropping the key makes that look like it saved cleanly. + const typo = spec.validate( + oneStep({ actionId: 'demo.world.change', params: { regions: 'Yew', count: 1 } }), + ) + assert.equal(typo.ok, false) + assert.match(typo.errors.join('\n'), /"regions" is not a param of demo\.world\.change/) +}) + +test('on_failure is defaulted from the risk class', () => { + const notify = spec.validate( + oneStep({ actionId: 'core.announce', params: { leg: 'discord', body: 'hi' } }), + ) + assert.equal(notify.spec.phases[0].steps[0].onFailure, 'skip') + + const change = spec.validate( + oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 1 } }), + ) + assert.equal(change.spec.phases[0].steps[0].onFailure, 'pause') + + const irreversible = spec.validate(oneStep({ actionId: 'demo.world.wreck' })) + assert.equal(irreversible.spec.phases[0].steps[0].onFailure, 'abort_run') + + // An author may still choose, within the closed set. + const chosen = spec.validate(oneStep({ actionId: 'demo.world.wreck', onFailure: 'skip' })) + assert.equal(chosen.spec.phases[0].steps[0].onFailure, 'skip') + const invented = spec.validate(oneStep({ actionId: 'demo.world.wreck', onFailure: 'shrug' })) + assert.equal(invented.ok, false) + assert.match(invented.errors.join('\n'), /onFailure: must be one of/) +}) + +test('a NEW step may not name an unregistered action', () => { + const result = spec.validate(oneStep({ actionId: 'gone.module.verb' })) + assert.equal(result.ok, false) + assert.match(result.errors.join('\n'), /no module registers "gone\.module\.verb"/) +}) + +test('an EXISTING step keeps its action when the module goes away, and is marked dormant', () => { + const saved = spec.validate( + oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 3 } }), + ).spec + + // The module is uninstalled between one save and the next. + registries._reset() + registries.registerCore() + + const again = spec.validate(saved, { knownActionIds: spec.actionIdsIn(saved) }) + assert.equal(again.ok, true, again.errors && again.errors.join('\n')) + const [step] = again.spec.phases[0].steps + assert.equal(step.dormant, true) + // Params pass through untouched: the only thing that could validate them left + // with the module. + assert.deepEqual(step.params, { region: 'Yew', count: 3 }) + + // …and that is exactly what publish refuses. + const publishable = spec.publishable(again.spec) + assert.equal(publishable.ok, false) + assert.deepEqual(publishable.dormant, ['demo.world.change']) +}) + +test('validate accepts its own output — a saved spec is re-validated on every save', () => { + // The property the dormancy test above found the hard way: `validate` adds + // `actionVersion` and `dormant`, and a validator that then refused its own + // fields would make the SECOND save of any definition impossible, and publish + // — which re-validates before snapshotting — impossible full stop. + const once = spec.validate( + oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 3 } }), + ) + const twice = spec.validate(once.spec) + assert.equal(twice.ok, true, twice.errors && twice.errors.join('\n')) + assert.deepEqual(twice.spec, once.spec) +}) + +test('two phases may not share a key', () => { + const result = spec.validate({ + schedule: { kind: 'manual' }, + phases: [ + { key: 'main', label: 'One', steps: [] }, + { key: 'main', label: 'Two', steps: [] }, + ], + }) + assert.equal(result.ok, false) + assert.match(result.errors.join('\n'), /used by more than one phase/) +}) + +test('a key a later phase owns is refused, not silently preserved', () => { + const top = spec.validate({ schedule: { kind: 'manual' }, phases: [], announcements: [] }) + assert.equal(top.ok, false) + assert.match(top.errors.join('\n'), /unknown key "announcements"/) + + const phase = spec.validate({ + schedule: { kind: 'manual' }, + phases: [{ key: 'main', label: 'Main', steps: [], advance: { after: '30m' } }], + }) + assert.equal(phase.ok, false) + assert.match(phase.errors.join('\n'), /unknown key\(s\) advance .*Phase 5/) +}) + +test('only the manual schedule exists in this phase', () => { + const weekly = spec.validate({ + schedule: { kind: 'weekly', days: ['fri'], time: '20:00' }, + phases: [{ key: 'main', label: 'Main', steps: [] }], + }) + assert.equal(weekly.ok, false) + assert.match(weekly.errors.join('\n'), /recurrence arrives in Phase 4/) +}) + +test('every problem is reported, not just the first', () => { + const result = spec.validate({ + schedule: { kind: 'manual' }, + phases: [ + { key: 'BAD KEY', label: '', steps: [{ actionId: 'demo.world.change', params: {} }] }, + ], + }) + assert.equal(result.ok, false) + const joined = result.errors.join('\n') + assert.match(joined, /bad phase key/) + assert.match(joined, /a phase needs a label/) + assert.match(joined, /"region" is required/) + assert.match(joined, /"count" is required/) +}) + +test('the size bounds hold', () => { + const many = { + schedule: { kind: 'manual' }, + phases: Array.from({ length: spec.MAX_PHASES + 1 }, (_, i) => ({ + key: `p${i}`, + label: `P${i}`, + steps: [], + })), + } + const result = spec.validate(many) + assert.equal(result.ok, false) + assert.match(result.errors.join('\n'), new RegExp(`at most ${spec.MAX_PHASES} phases`)) +}) diff --git a/server/test/eventsAdmin.test.js b/server/test/eventsAdmin.test.js new file mode 100644 index 0000000..3f589a9 --- /dev/null +++ b/server/test/eventsAdmin.test.js @@ -0,0 +1,597 @@ +// ── The events admin surface (EVENTS.md § API surface, Phase 1) ──────────── +// +// `eventSpec.test.js` covers the spec validator and `eventActionRegistry.test.js` +// the registry; re-asserting either here would be a second copy of a test rather +// than a second test. What is genuinely new is what the SURFACE decides: +// +// • **publish snapshots.** It cuts an immutable version, points the definition +// at it, and a later edit does not touch the version a run would pin. +// • **publish re-validates against the registries as they stand now**, not +// against the save that wrote the spec. A module uninstalled in between must +// block the publish, because the alternative is a run that fails at dispatch +// with the world half-changed. +// • **an empty event does not publish.** It would run cleanly and do nothing, +// which reads as a broken run rather than an empty one. +// • **the slug is frozen after create**, because the public event page lives +// at it and a retitle must not break a posted link. +// • **archiving is refused while a run is in flight**, and there is no hard +// delete at all. +// • **creating an occurrence twice creates ONE run.** The unique index is what +// makes that true, so the second call answers `created: false` with the +// existing row rather than erroring. +// • **a created run stays `scheduled`.** There is no runner until Phase 2, and +// that has to be visible as the correct state rather than as a stall. +// +// The `.db` layer is stubbed in-memory and the real models and controllers run +// against it, the shape `engagementAdmin.test.js` uses. +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 ctrl = require('../src/router/v1/admin/events.controller') +const definitionsDb = require('../src/model/events/eventDefinitions.db') +const versionsDb = require('../src/model/events/eventVersions.db') +const runsDb = require('../src/model/events/eventRuns.db') +const stepsDb = require('../src/model/events/eventRunSteps.db') +const logDb = require('../src/model/events/eventRunLog.db') +const seriesDb = require('../src/model/events/eventSeries.db') +const activity = require('../src/model/activity/activity.model') +const db = require('../src/utils/db') + +after(() => db.close()) + +// ── In-memory stand-ins ──────────────────────────────────────────────────── + +let store +const originals = {} +for (const [name, mod] of [ + ['definitionsDb', definitionsDb], + ['versionsDb', versionsDb], + ['runsDb', runsDb], + ['stepsDb', stepsDb], + ['logDb', logDb], + ['seriesDb', seriesDb], + ['activity', activity], +]) { + originals[name] = { mod, fns: { ...mod } } +} +const restoreOriginals = () => { + for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns) +} + +// The occurrence key, as a string, so the stub can enforce the UNIQUE index the +// real table enforces. Reproducing it is the point of several tests below: the +// index — not the claim — is what makes "one run per occurrence per scope" true. +const occurrenceKey = (definitionId, scope, when) => + `${definitionId}|${scope || ''}|${new Date(when).toISOString()}` + +function installStubs() { + store = { + definitions: new Map(), + versions: new Map(), + runs: new Map(), + steps: new Map(), + log: [], + series: new Map(), + occurrences: new Set(), + nextDefinition: 1, + nextVersion: 1, + nextRun: 1, + nextStep: 1, + } + + const shape = (d) => ({ + ...d, + series_name: store.series.get(d.series_id)?.name ?? null, + series_slug: store.series.get(d.series_id)?.slug ?? null, + current_version: store.versions.get(d.current_version_id)?.version ?? null, + }) + + definitionsDb.list = async ({ state = null } = {}) => + [...store.definitions.values()].filter((d) => !state || d.state === state).map(shape) + definitionsDb.getById = async (id) => + store.definitions.has(id) ? shape(store.definitions.get(id)) : undefined + definitionsDb.getBySlug = async (slug) => + [...store.definitions.values()].filter((d) => d.slug === slug).map(shape)[0] + definitionsDb.slugTaken = async (slug, exceptId = null) => + [...store.definitions.values()].some((d) => d.slug === slug && d.id !== exceptId) + definitionsDb.insert = async (d) => { + const id = store.nextDefinition++ + store.definitions.set(id, { + id, + state: 'draft', + current_version_id: null, + created_at: new Date(), + updated_at: new Date(), + ...d, + }) + return id + } + definitionsDb.update = async (id, d) => { + const existing = store.definitions.get(id) + if (existing) Object.assign(existing, d, { updated_at: new Date() }) + } + definitionsDb.markReady = async (id, versionId, userId) => { + const existing = store.definitions.get(id) + if (existing) { + Object.assign(existing, { state: 'ready', current_version_id: versionId, updated_by: userId }) + } + } + definitionsDb.archive = async (id, userId) => { + const existing = store.definitions.get(id) + if (existing) Object.assign(existing, { state: 'archived', updated_by: userId }) + } + + versionsDb.listForDefinition = async (definitionId) => + [...store.versions.values()] + .filter((v) => v.definition_id === definitionId) + .sort((a, b) => b.version - a.version) + versionsDb.getById = async (id) => store.versions.get(id) || undefined + versionsDb.nextVersion = async (definitionId) => + [...store.versions.values()].filter((v) => v.definition_id === definitionId).length + 1 + versionsDb.insert = async (definitionId, version, spec, userId) => { + const id = store.nextVersion++ + // Deep-copied on the way in, because the whole point of a version is that a + // later edit of the working spec cannot reach it. A stub that stored the + // reference would make the snapshot test pass for the wrong reason. + store.versions.set(id, { + id, + definition_id: definitionId, + version, + spec: JSON.parse(JSON.stringify(spec)), + published_at: new Date(), + published_by: userId, + }) + return id + } + + const shapeRun = (r) => ({ + ...r, + definition_title: store.definitions.get(r.definition_id)?.title ?? null, + definition_slug: store.definitions.get(r.definition_id)?.slug ?? null, + version_number: store.versions.get(r.version_id)?.version ?? null, + }) + + runsDb.list = async ({ definitionId = null, status = null } = {}) => + [...store.runs.values()] + .filter((r) => (!definitionId || r.definition_id === definitionId) && (!status || r.status === status)) + .map(shapeRun) + runsDb.getById = async (id) => (store.runs.has(id) ? shapeRun(store.runs.get(id)) : undefined) + runsDb.materialise = async (run) => { + const key = occurrenceKey(run.definition_id, run.scope, run.scheduled_for) + if (store.occurrences.has(key)) return null // the UNIQUE index, doing its job + store.occurrences.add(key) + const id = store.nextRun++ + store.runs.set(id, { + id, + status: 'scheduled', + health: 'ok', + cleanup_status: 'not_required', + current_phase: null, + started_at: null, + ended_at: null, + last_error: null, + created_at: new Date(), + ...run, + scheduled_for: new Date(run.scheduled_for), + rehearsal: Boolean(run.rehearsal), + }) + return id + } + runsDb.findOccurrence = async (definitionId, scope, when) => + [...store.runs.values()] + .filter( + (r) => occurrenceKey(r.definition_id, r.scope, r.scheduled_for) === occurrenceKey(definitionId, scope, when), + ) + .map(shapeRun)[0] + runsDb.countActiveForDefinition = async (definitionId) => + [...store.runs.values()].filter( + (r) => + r.definition_id === definitionId && + ['scheduled', 'starting', 'running', 'paused', 'ending'].includes(r.status), + ).length + + stepsDb.listForRun = async (runId) => + [...store.steps.values()].filter((s) => s.run_id === runId).sort((a, b) => a.seq - b.seq) + stepsDb.getById = async (id) => store.steps.get(id) || undefined + stepsDb.materialisePhase = async (runId, phase, steps) => { + steps.forEach((step, seq) => { + const taken = [...store.steps.values()].some( + (s) => s.run_id === runId && s.phase === phase && s.seq === seq, + ) + if (taken) return + const id = store.nextStep++ + store.steps.set(id, { + id, + run_id: runId, + phase, + seq, + action_id: step.actionId, + params: step.params || {}, + action_version: step.actionVersion || 1, + status: 'pending', + due_at: null, + attempts: 0, + on_failure: step.onFailure || 'pause', + // The real materialiser stamps this from the row's own id, so the stub + // does too: a key that varied by attempt would defeat the whole retry + // story, and a stub that faked it would hide that. + idempotency_key: stepsDb.idempotencyKey(runId, id), + last_error: null, + started_at: null, + finished_at: null, + }) + }) + return stepsDb.listForRun(runId) + } + stepsDb.statusCounts = async (runId) => { + const counts = {} + for (const s of store.steps.values()) { + if (s.run_id === runId) counts[s.status] = (counts[s.status] || 0) + 1 + } + return counts + } + + logDb.listForRun = async (runId) => store.log.filter((l) => l.run_id === runId).reverse() + logDb.write = async ({ runId, stepId = null, kind, phase = null, detail = null }) => { + store.log.push({ id: store.log.length + 1, run_id: runId, step_id: stepId, kind, phase, detail, at: new Date() }) + return true + } + + seriesDb.list = async () => [...store.series.values()] + seriesDb.getById = async (id) => store.series.get(id) || null + seriesDb.exists = async (id) => store.series.has(id) + + // The audit log is a side effect, not a subject: it writes to a real table and + // never throws into the request path, so the stub records and stays quiet. + activity.log = async (entry) => { + store.log.push({ audit: true, ...entry }) + return true + } +} + +// ── Fixtures ─────────────────────────────────────────────────────────────── + +beforeEach(() => { + registries._reset() + registries.registerCore() + installStubs() +}) +afterEach(() => { + registries._reset() + restoreOriginals() +}) + +/** A module whose one action a definition can be built around. */ +function registerDemoModule() { + const api = registries.stage('demo') + api.registerEventActions([ + { + id: 'demo.world.change', + label: 'Change the world', + risk: 'change', + reversible: 'none', + params: [{ name: 'region', type: 'string', required: true, example: 'Yew' }], + perform: async () => ({ ok: true }), + }, + ]) + registries.apply(api.staged) +} + +const announceStep = (body = 'The gates open at dusk.') => ({ + actionId: 'core.announce', + params: { leg: 'discord', body }, +}) + +const draftBody = (over = {}) => ({ + title: 'The Siege of Cove', + summary: 'An invasion, in three phases.', + timezone: 'Europe/Berlin', + spec: { + schedule: { kind: 'manual' }, + phases: [{ key: 'main', label: 'Main', steps: [announceStep()] }], + }, + ...over, +}) + +function mockRes() { + return { + statusCode: 200, + body: null, + status(c) { this.statusCode = c; return this }, + json(b) { this.body = b; return this }, + } +} + +async function call(handler, req) { + const res = mockRes() + let thrown = null + await handler({ body: {}, params: {}, query: {}, user: { id: 1 }, ...req }, res, (err) => { + thrown = err + }) + if (thrown) throw thrown + return res +} + +const createDraft = async (over = {}) => call(ctrl.create, { body: draftBody(over) }) + +// ── The catalog ──────────────────────────────────────────────────────────── + +test('the catalog serves the registry, callables stripped, with its vocabularies', async () => { + const res = await call(ctrl.catalog, {}) + assert.equal(res.statusCode, 200) + assert.deepEqual( + res.body.actions.map((a) => a.id), + ['core.announce', 'core.wait', 'core.cue'], + ) + 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) +}) + +// ── Create, edit, slug ───────────────────────────────────────────────────── + +test('a draft is created with a derived slug and no version', async () => { + const res = await createDraft() + assert.equal(res.statusCode, 201) + assert.equal(res.body.event.state, 'draft') + assert.equal(res.body.event.slug, 'the-siege-of-cove') + assert.equal(res.body.event.currentVersionId, null) + assert.equal(res.body.event.timezone, 'Europe/Berlin') +}) + +test('the slug is frozen after create — a retitle does not move the public page', async () => { + const created = await createDraft() + const id = created.body.event.id + const res = await call(ctrl.update, { + params: { id: String(id) }, + body: draftBody({ title: 'The Second Siege of Cove' }), + }) + assert.equal(res.statusCode, 200) + assert.equal(res.body.event.title, 'The Second Siege of Cove') + assert.equal(res.body.event.slug, 'the-siege-of-cove') +}) + +test('a bad timezone and a bad grace window are refused with both problems named', async () => { + const res = await createDraft({ timezone: 'Middle/Earth', graceSeconds: 5 }) + assert.equal(res.statusCode, 400) + const joined = res.body.errors.join('\n') + assert.match(joined, /not an IANA zone name/) + assert.match(joined, /graceSeconds must be an integer/) +}) + +test('a step naming an unregistered action is refused at create', async () => { + const res = await createDraft({ + spec: { + schedule: { kind: 'manual' }, + phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'ghost.verb.do' }] }], + }, + }) + assert.equal(res.statusCode, 400) + assert.match(res.body.errors.join('\n'), /no module registers "ghost\.verb\.do"/) +}) + +// ── Publish ──────────────────────────────────────────────────────────────── + +test('publish snapshots the spec, and a later edit does not touch the version', async () => { + const created = await createDraft() + const id = created.body.event.id + + const published = await call(ctrl.publish, { params: { id: String(id) } }) + assert.equal(published.statusCode, 200) + assert.equal(published.body.version, 1) + assert.equal(published.body.event.state, 'ready') + + // Edit the working copy afterwards. + await call(ctrl.update, { + params: { id: String(id) }, + body: draftBody({ + spec: { + schedule: { kind: 'manual' }, + phases: [{ key: 'main', label: 'Main', steps: [announceStep('Something else entirely.')] }], + }, + }), + }) + + const version = await versionsDb.getById(published.body.versionId) + assert.equal(version.spec.phases[0].steps[0].params.body, 'The gates open at dusk.') + + // …and publishing again cuts version 2 rather than mutating version 1. + const again = await call(ctrl.publish, { params: { id: String(id) } }) + assert.equal(again.body.version, 2) + const versions = await call(ctrl.listVersions, { params: { id: String(id) } }) + assert.deepEqual(versions.body.versions.map((v) => v.version), [2, 1]) + assert.deepEqual(versions.body.versions.map((v) => v.current), [true, false]) +}) + +test('publish is refused when a step went dormant after the save that wrote it', async () => { + registerDemoModule() + const created = await createDraft({ + spec: { + schedule: { kind: 'manual' }, + phases: [ + { key: 'main', label: 'Main', steps: [{ actionId: 'demo.world.change', params: { region: 'Yew' } }] }, + ], + }, + }) + const id = created.body.event.id + + // The module is uninstalled between the save and the publish. + registries._reset() + registries.registerCore() + + const res = await call(ctrl.publish, { params: { id: String(id) } }) + assert.equal(res.statusCode, 409) + assert.match(res.body.errors.join('\n'), /no module registers demo\.world\.change/) + + // …and the definition is still editable, which is the other half of the rule: + // an uninstall must not be destructive after the fact. + const saved = await call(ctrl.update, { params: { id: String(id) }, body: { title: 'Renamed' } }) + assert.equal(saved.statusCode, 200) + assert.equal(saved.body.event.spec.phases[0].steps[0].dormant, true) +}) + +test('an event with no steps does not publish', async () => { + const created = await createDraft({ + spec: { schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [] }] }, + }) + const res = await call(ctrl.publish, { params: { id: String(created.body.event.id) } }) + assert.equal(res.statusCode, 400) + assert.match(res.body.errors.join('\n'), /no phase has any steps/) +}) + +// ── Runs ─────────────────────────────────────────────────────────────────── + +test('a draft has nothing to run', async () => { + const created = await createDraft() + const res = await call(ctrl.startRun, { params: { id: String(created.body.event.id) } }) + assert.equal(res.statusCode, 409) + assert.match(res.body.errors.join('\n'), /no published version to run/) +}) + +test('a created run stays scheduled, with its first phase materialised', async () => { + const created = await createDraft() + const id = created.body.event.id + await call(ctrl.publish, { params: { id: String(id) } }) + + const started = await call(ctrl.startRun, { params: { id: String(id) } }) + assert.equal(started.statusCode, 201) + assert.equal(started.body.created, true) + // The correct state for this phase, and it has to be visible as such rather + // than looking like a stall: there is no runner until Phase 2. + assert.equal(started.body.run.status, 'scheduled') + + const detail = await call(ctrl.getRun, { params: { runId: String(started.body.run.id) } }) + assert.equal(detail.body.steps.length, 1) + assert.equal(detail.body.steps[0].actionId, 'core.announce') + assert.equal(detail.body.steps[0].status, 'pending') + assert.deepEqual(detail.body.counts, { pending: 1 }) + // Minted at materialisation, 40 hex, and a function of identity alone. + assert.match(detail.body.steps[0].idempotencyKey, /^[0-9a-f]{40}$/) +}) + +test('one occurrence, asked for twice, is one run', async () => { + const created = await createDraft() + const id = created.body.event.id + await call(ctrl.publish, { params: { id: String(id) } }) + + const when = '2026-10-31T20:00:00.000Z' + const first = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when } }) + const second = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when } }) + + assert.equal(first.statusCode, 201) + assert.equal(first.body.created, true) + // Not an error — the unique index doing exactly what it is for. The existing + // row is the answer. + assert.equal(second.statusCode, 200) + assert.equal(second.body.created, false) + assert.equal(second.body.run.id, first.body.run.id) + + const runs = await call(ctrl.listRuns, { query: { definitionId: String(id) } }) + assert.equal(runs.body.runs.length, 1) +}) + +test('the same instant in two scopes is two runs', async () => { + const created = await createDraft() + const id = created.body.event.id + await call(ctrl.publish, { params: { id: String(id) } }) + + const when = '2026-10-31T20:00:00.000Z' + const a = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when, scope: 'europa' } }) + const b = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when, scope: 'atlantic' } }) + assert.equal(a.body.created, true) + assert.equal(b.body.created, true) + assert.notEqual(a.body.run.id, b.body.run.id) +}) + +test('the concurrency key is rendered from the run params', async () => { + const created = await createDraft({ concurrencyKey: 'invasion:{region}' }) + const id = created.body.event.id + await call(ctrl.publish, { params: { id: String(id) } }) + const res = await call(ctrl.startRun, { + params: { id: String(id) }, + body: { params: { region: 'Yew' } }, + }) + assert.equal(res.body.run.concurrencyKey, 'invasion:Yew') +}) + +test('an unrendered placeholder is left standing rather than emptied', async () => { + // `invasion:` would collide with every other unrendered key on the deployment, + // which is the opposite of what a concurrency key is for. + const created = await createDraft({ concurrencyKey: 'invasion:{region}' }) + const id = created.body.event.id + await call(ctrl.publish, { params: { id: String(id) } }) + const res = await call(ctrl.startRun, { params: { id: String(id) } }) + assert.equal(res.body.run.concurrencyKey, 'invasion:{region}') +}) + +test('the run log records the creation and the phase entry', async () => { + const created = await createDraft() + const id = created.body.event.id + await call(ctrl.publish, { params: { id: String(id) } }) + const started = await call(ctrl.startRun, { params: { id: String(id) } }) + + const res = await call(ctrl.getRunLog, { params: { runId: String(started.body.run.id) } }) + const kinds = res.body.log.map((l) => l.kind) + assert.ok(kinds.includes('run.created')) + assert.ok(kinds.includes('phase.entered')) +}) + +test('an unknown log kind is refused rather than stored', async () => { + // The closed set is enforced in the db layer, not by an ENUM, because it grows + // with almost every later phase — so it has to actually refuse. + restoreOriginals() + const ok = await logDb.write({ runId: 1, kind: 'not.a.kind' }) + assert.equal(ok, false) + installStubs() +}) + +// ── Archive ──────────────────────────────────────────────────────────────── + +test('archiving is refused while a run is in flight, and allowed once it is not', async () => { + const created = await createDraft() + const id = created.body.event.id + await call(ctrl.publish, { params: { id: String(id) } }) + const started = await call(ctrl.startRun, { params: { id: String(id) } }) + + const refused = await call(ctrl.archive, { params: { id: String(id) } }) + assert.equal(refused.statusCode, 409) + assert.match(refused.body.errors.join('\n'), /still in flight/) + + store.runs.get(started.body.run.id).status = 'completed' + + const res = await call(ctrl.archive, { params: { id: String(id) } }) + assert.equal(res.statusCode, 200) + assert.equal(res.body.event.state, 'archived') +}) + +test('an archived definition can be neither edited nor published', async () => { + const created = await createDraft() + const id = created.body.event.id + await call(ctrl.archive, { params: { id: String(id) } }) + + const edited = await call(ctrl.update, { params: { id: String(id) }, body: draftBody() }) + assert.equal(edited.statusCode, 409) + const published = await call(ctrl.publish, { params: { id: String(id) } }) + assert.equal(published.statusCode, 409) +}) + +test('the list filters by state, and an unknown id is 404 rather than 500', async () => { + await createDraft() + const second = await createDraft({ title: 'A Second Event' }) + await call(ctrl.publish, { params: { id: String(second.body.event.id) } }) + + const ready = await call(ctrl.list, { query: { state: 'ready' } }) + assert.deepEqual(ready.body.events.map((e) => e.title), ['A Second Event']) + + const missing = await call(ctrl.get, { params: { id: '9999' } }) + assert.equal(missing.statusCode, 404) + const bad = await call(ctrl.get, { params: { id: 'not-a-number' } }) + assert.equal(bad.statusCode, 400) +})