From cee4ab8aeab4dd218724ae9f35b7bc87e97e91aa Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 26 Oct 2026 03:39:42 -0500 Subject: [PATCH] feat(demo): refuse an event step at save with demoRefuses (MODULE_API 1.19.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEMO_MODE.md §3.4 refuses a denied console command when the event is saved and when it runs. Saving is core's route, and core never asked a module about a step's params, so the save-time half had no seam (DM18). An event action may now declare demoRefuses(params), returning a sentence or null. With DEMO_MODE on, create, save and publish ask it for every step; the first sentence refuses the write 403 DEMO_LOCKED with that sentence as the message (and in errors). A hook that throws refuses too. Off the demo it is validated and never called, and the catalog strips it with the other callables. The events controller passes code and message through on create, update and publish, so the client's one DEMO_LOCKED toast fires. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- client/src/modules/version.js | 5 +- server/engagement-triggers.json | 2 +- .../model/events/eventDefinitions.model.js | 43 +++++ server/src/modules/registries.js | 14 +- server/src/modules/version.js | 11 +- .../src/router/v1/admin/events.controller.js | 16 +- server/swagger/swagger-output.json | 29 +++- server/swagger/swagger.js | 3 +- server/test/demoStepRefusal.test.js | 154 ++++++++++++++++++ server/test/eventModuleContract.test.js | 2 +- 10 files changed, 267 insertions(+), 12 deletions(-) create mode 100644 server/test/demoStepRefusal.test.js diff --git a/client/src/modules/version.js b/client/src/modules/version.js index a0fef7e..138f1b4 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -11,6 +11,9 @@ // that the two files can drift, so a test asserts they agree // (client/test/moduleRegistry.test.js) rather than trusting a bump to remember // both. +// 1.19.0 — an event action may declare `demoRefuses(params)` (DEMO_MODE.md §3.4, +// DM18): the demo refuses a step at save. Server-side only; the refusal reaches +// the browser as an ordinary DEMO_LOCKED answer. This file bumps for the reason at the top. // 1.18.0 — the public demo (DEMO_MODE.md §3.2): `demoLocks` in module.json, // `ctx.demoMode`, `api.onDemoSeed`. Server-side; the client half is core's own // banner and refusal toast, which an installed module's requests reach through @@ -102,4 +105,4 @@ // but the two halves state ONE version: a module declares a single coreApi range // and is served one chunk, so a client that claimed 1.0.0 while the server // answered 1.1.0 would be two answers to one question. -export const MODULE_API_VERSION = '1.18.0' +export const MODULE_API_VERSION = '1.19.0' diff --git a/server/engagement-triggers.json b/server/engagement-triggers.json index a748e2e..eba8631 100644 --- a/server/engagement-triggers.json +++ b/server/engagement-triggers.json @@ -1,6 +1,6 @@ { "_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.", - "moduleApiVersion": "1.18.0", + "moduleApiVersion": "1.19.0", "triggers": [ { "id": "event.phase.changed", diff --git a/server/src/model/events/eventDefinitions.model.js b/server/src/model/events/eventDefinitions.model.js index d968312..2613b00 100644 --- a/server/src/model/events/eventDefinitions.model.js +++ b/server/src/model/events/eventDefinitions.model.js @@ -29,6 +29,8 @@ const spec = require('../../events/spec') const authorize = require('../../events/authorize') const verifier = require('../../events/verify') const registries = require('../../modules/registries') +const { isDemo } = require('../../demo/mode') +const { CODE: DEMO_LOCKED } = require('../../demo/locks') const { slugify, uniqueSlug } = require('../teams/teamSlug') const { cleanBody } = require('../../utils/sanitizeHtml') const log = require('../../utils/logger')('events') @@ -181,6 +183,8 @@ async function create(input, userId, { role = null } = {}) { if (!result.ok) return result const floor = checkRoleFloor(result.definition.spec, role) if (floor) return floor + const refused = await checkDemoRefusals(result.definition.spec) + if (refused) return refused const id = await db.insert({ ...result.definition, created_by: userId }) return { ok: true, id, definition: await db.getById(id) } } @@ -203,6 +207,8 @@ async function save(id, input, userId, { role = null } = {}) { if (!result.ok) return result const floor = checkRoleFloor(result.definition.spec, role) if (floor) return floor + const refused = await checkDemoRefusals(result.definition.spec) + if (refused) return refused await db.update(id, { ...result.definition, updated_by: userId }) return { ok: true, id, definition: await db.getById(id) } } @@ -228,6 +234,11 @@ async function publish(id, userId) { }) if (!checked.ok) return { ok: false, status: 400, errors: checked.errors } + // Asked again here, as the registries are: a draft saved before the module's + // list grew must not become a version that runs (DM18). + const refused = await checkDemoRefusals(checked.spec) + if (refused) return refused + const publishable = spec.publishable(checked.spec) if (!publishable.ok) { return { @@ -365,6 +376,37 @@ function checkRoleFloor(specValue, role) { } } +/** + * The public demo's save-time refusal (MODULE_API 1.19.0, DEMO_MODE.md §3.4, + * DM18): each step whose action declares `demoRefuses(params)` is asked, and + * the first sentence back refuses the whole save `403 DEMO_LOCKED`. Null off + * the demo, where nothing is asked. + * + * A hook that throws refuses too, with core's sentence: this is a lock, and a + * lock that cannot decide fails closed, as the route lock does (§3.3). + */ +async function checkDemoRefusals(specValue) { + if (!isDemo()) return null + for (const phase of specValue?.phases || []) { + for (const step of phase.steps || []) { + const action = registries.eventAction(step.actionId) + if (!action || !action.demoRefuses) continue + let said + try { + said = await action.demoRefuses(step.params || {}) + } catch (err) { + log.error('demoRefuses failed', { action: action.id, message: err.message }) + said = `"${action.label}" could not be checked, so it is turned off on the demo site.` + } + if (typeof said === 'string' && said.trim()) { + const message = said.trim() + return { ok: false, status: 403, code: DEMO_LOCKED, message, errors: [message] } + } + } + } + return null +} + /** * Dry-run a definition, and record the pass when there is a version to record it * on (Phase 6). @@ -545,6 +587,7 @@ module.exports = { archive, verify, checkRoleFloor, + checkDemoRefusals, isTimezone, MIN_GRACE_SECONDS, MAX_GRACE_SECONDS, diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js index 8bdf12c..e6ab5c6 100644 --- a/server/src/modules/registries.js +++ b/server/src/modules/registries.js @@ -445,14 +445,14 @@ async function resolveAudience(id, params = {}) { /** * Every declaration WITHOUT its callables — what the admin catalog serves. * - * `perform`, `revert`, `reconcile`, `cost` and `progress` are stripped for the same reason `resolve` is + * `perform`, `revert`, `reconcile`, `cost`, `progress` and `demoRefuses` 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, reconcile, cost, progress, ...rest }) => rest) + [...eventActions.values()].map(({ perform, revert, reconcile, cost, progress, demoRefuses, ...rest }) => rest) /** One declaration, callables included. The runner's lookup (Phase 2). */ const eventAction = (id) => eventActions.get(id) || null @@ -1128,7 +1128,7 @@ function checkActionParam(actionId, entry, seen, call = 'registerEventActions') } /** - * `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert, reconcile, progress }])`. + * `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert, reconcile, progress, demoRefuses }])`. * * 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 @@ -1211,6 +1211,13 @@ function checkEventActionShape(entry) { if (a.progress !== undefined && typeof a.progress !== 'function') { throw new Error(`registerEventActions: ${a.id} progress must be a function`) } + // 1.19.0, optional: the public demo's save-time refusal (DEMO_MODE.md §3.4, + // DM18). Asked only with DEMO_MODE on, by the definition save path; a + // sentence back refuses the save `403 DEMO_LOCKED`. The module still checks + // at run, because a definition can predate the list it is checked against. + if (a.demoRefuses !== undefined && typeof a.demoRefuses !== 'function') { + throw new Error(`registerEventActions: ${a.id} demoRefuses must be a function of its params`) + } const version = a.version === undefined ? 1 : a.version if (!Number.isInteger(version) || version < 1) { @@ -1244,6 +1251,7 @@ function checkEventActionShape(entry) { revert: a.revert || null, reconcile: a.reconcile || null, progress: a.progress || null, + demoRefuses: a.demoRefuses || null, } } diff --git a/server/src/modules/version.js b/server/src/modules/version.js index 00db8cf..6c69e52 100644 --- a/server/src/modules/version.js +++ b/server/src/modules/version.js @@ -9,6 +9,15 @@ // Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and // has nothing to say about a website module) and from any module's own version. +// 1.19.0 — the public demo refuses a step at SAVE (docs/website/DEMO_MODE.md +// §3.4, DM18). An event action may declare `demoRefuses(params)`, returning a +// sentence or null. With DEMO_MODE on, core asks it for every step when a +// definition is created, saved or published, and a sentence refuses the whole +// write `403 DEMO_LOCKED` with that sentence as `message`; a hook that throws +// refuses too. Off the demo it is validated and never called. One optional +// action member, so minor. Module-uo declares `^1.10.0` and declares none. +// Module-Rust's console-command deny list is the first user. +// // 1.18.0 — the public demo (docs/website/DEMO_MODE.md §3.2, §3.4, §7.3; website#222). // Three additions, each optional: // • `demoLocks` in module.json — `[{ method, path }]`, writes only, each path @@ -245,6 +254,6 @@ // an admin action a module performs belongs in core's one audit log, the // extension slot needs the user its prefix names, and §2.7 forbids a module // reading core's `APP_BASE_URL` for itself. Additions only, so minor. -const MODULE_API_VERSION = '1.18.0' +const MODULE_API_VERSION = '1.19.0' module.exports = { MODULE_API_VERSION } diff --git a/server/src/router/v1/admin/events.controller.js b/server/src/router/v1/admin/events.controller.js index 1e16af5..165304b 100644 --- a/server/src/router/v1/admin/events.controller.js +++ b/server/src/router/v1/admin/events.controller.js @@ -42,6 +42,16 @@ const asId = (raw) => { return Number.isInteger(n) && n > 0 ? n : null } +/** + * A refused save's body: the errors, plus `code` and `message` when the model + * set them. The demo's step refusal does (DM18), so the client's one + * `DEMO_LOCKED` toast fires as it does for a route lock. + */ +const refusal = (result) => ({ + errors: result.errors, + ...(result.code ? { code: result.code, message: result.message } : {}), +}) + /** * The shape a definition takes on the wire. * @@ -413,7 +423,7 @@ exports.listVersions = async (req, res) => { /** POST /api/v1/admin/events */ exports.create = async (req, res) => { const result = await definitions.create(req.body, req.user.id, { role: req.user.role }) - if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors }) + if (!result.ok) return res.status(result.status || 400).json(refusal(result)) await activity.log({ req, action: 'event.definition.created', @@ -427,7 +437,7 @@ 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, { role: req.user.role }) - if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors }) + if (!result.ok) return res.status(result.status || 400).json(refusal(result)) await activity.log({ req, action: 'event.definition.updated', @@ -441,7 +451,7 @@ 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 }) + if (!result.ok) return res.status(result.status || 400).json(refusal(result)) await activity.log({ req, action: 'event.definition.published', diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 1fca305..88c14eb 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -18553,7 +18553,7 @@ }, "description": { "type": "string", - "example": "The public demo refused a write (DEMO_MODE.md §3). Only on a deployment with `DEMO_MODE=true`. `keys` is present on `PUT /admin/settings` and names the settings the demo keeps." + "example": "The public demo refused a write (DEMO_MODE.md §3). Only on a deployment with `DEMO_MODE=true`. `keys` is present on `PUT /admin/settings` and names the settings the demo keeps. `errors` is present when an event save or publish is refused because a step's action refused its params (MODULE_API 1.19.0 `demoRefuses`, DM18); `message` is then the module's sentence." }, "properties": { "type": "object", @@ -18615,6 +18615,33 @@ } } } + }, + "errors": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "example": { + "type": "array", + "example": [ + "\"quit\" is turned off on the demo site." + ], + "items": { + "type": "string" + } + } + } } } }, diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index a48d084..c96d9bc 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -109,11 +109,12 @@ const doc = { DemoLocked: { type: 'object', description: - 'The public demo refused a write (DEMO_MODE.md §3). Only on a deployment with `DEMO_MODE=true`. `keys` is present on `PUT /admin/settings` and names the settings the demo keeps.', + 'The public demo refused a write (DEMO_MODE.md §3). Only on a deployment with `DEMO_MODE=true`. `keys` is present on `PUT /admin/settings` and names the settings the demo keeps. `errors` is present when an event save or publish is refused because a step\'s action refused its params (MODULE_API 1.19.0 `demoRefuses`, DM18); `message` is then the module\'s sentence.', properties: { message: { type: 'string', example: 'This is turned off on the demo site.' }, code: { type: 'string', enum: ['DEMO_LOCKED'] }, keys: { type: 'array', items: { type: 'string' }, example: ['site_mode'] }, + errors: { type: 'array', items: { type: 'string' }, example: ['"quit" is turned off on the demo site.'] }, }, required: ['message', 'code'], }, diff --git a/server/test/demoStepRefusal.test.js b/server/test/demoStepRefusal.test.js new file mode 100644 index 0000000..317677d --- /dev/null +++ b/server/test/demoStepRefusal.test.js @@ -0,0 +1,154 @@ +// ── The demo refuses a step at save (MODULE_API 1.19.0, DEMO_MODE.md §3.4) ── +// +// DM18: an event action may declare `demoRefuses(params)`. With DEMO_MODE on, +// the definition save path asks it for every step at create, save and publish, +// and a sentence back refuses the write `403 DEMO_LOCKED`. Off the demo it is +// never called. The definitions table is stubbed on the module object the model +// holds; the pool points at a closed port. +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 db = require('../src/utils/db') +const registries = require('../src/modules/registries') +const definitions = require('../src/model/events/eventDefinitions.model') +const definitionsDb = require('../src/model/events/eventDefinitions.db') +const controller = require('../src/router/v1/admin/events.controller') + +after(() => db.close()) + +let asked + +const register = (extra = {}) => { + const api = registries.stage('demo') + api.registerEventActions([{ + id: 'demo.console.run', + label: 'Run console commands', + risk: 'irreversible', + reversible: 'none', + params: [{ name: 'command', type: 'string', required: true, example: 'say hi' }], + perform: async () => ({ ok: true }), + demoRefuses: (params) => { + asked.push(params) + return params.command === 'quit' ? '"quit" is turned off on the demo site.' : null + }, + ...extra, + }]) + registries.apply(api.staged) +} + +const input = (command) => ({ + title: 'Console night', + spec: { + schedule: { kind: 'manual' }, + phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'demo.console.run', params: { command } }] }], + }, +}) + +const stubTable = (t, existing = null) => { + const wrote = [] + t.mock.method(definitionsDb, 'list', async () => []) + t.mock.method(definitionsDb, 'insert', async (row) => { wrote.push(row); return 7 }) + t.mock.method(definitionsDb, 'update', async (_id, row) => { wrote.push(row) }) + t.mock.method(definitionsDb, 'getById', async () => existing || { id: 7, state: 'draft', title: 'Console night', spec: input('say hi').spec }) + return wrote +} + +beforeEach(() => { + asked = [] + registries._reset() + registries.registerCore() +}) +afterEach(() => { + registries._reset() + delete process.env.DEMO_MODE +}) + +test('demoRefuses must be a function, and never rides out to the catalog', () => { + assert.throws(() => register({ demoRefuses: 'quit' }), /demoRefuses must be a function/) + registries._reset() + register() + assert.equal(typeof registries.eventAction('demo.console.run').demoRefuses, 'function') + const served = registries.allEventActions().find((a) => a.id === 'demo.console.run') + assert.equal('demoRefuses' in served, false) +}) + +test('off the demo the hook is never asked, and a denied command saves', async (t) => { + register() + const wrote = stubTable(t) + const made = await definitions.create(input('quit'), 1, { role: 'admin' }) + assert.equal(made.ok, true) + assert.equal(wrote.length, 1) + assert.deepEqual(asked, []) +}) + +test('on the demo a create naming a denied command is refused 403 DEMO_LOCKED and writes nothing', async (t) => { + process.env.DEMO_MODE = 'true' + register() + const wrote = stubTable(t) + const made = await definitions.create(input('quit'), 1, { role: 'admin' }) + assert.deepEqual(made, { + ok: false, + status: 403, + code: 'DEMO_LOCKED', + message: '"quit" is turned off on the demo site.', + errors: ['"quit" is turned off on the demo site.'], + }) + assert.equal(wrote.length, 0) + // The hook is handed the step's checked params. + assert.deepEqual(asked, [{ command: 'quit' }]) +}) + +test('on the demo a command the hook allows saves', async (t) => { + process.env.DEMO_MODE = 'true' + register() + const wrote = stubTable(t) + assert.equal((await definitions.create(input('say hi'), 1, { role: 'admin' })).ok, true) + assert.equal((await definitions.save(7, input('say hi'), 1, { role: 'admin' })).ok, true) + assert.equal(wrote.length, 2) +}) + +test('on the demo a save and a publish are refused too', async (t) => { + process.env.DEMO_MODE = 'true' + register() + const wrote = stubTable(t, { id: 7, state: 'draft', title: 'Console night', spec: input('quit').spec }) + const saved = await definitions.save(7, input('quit'), 1, { role: 'admin' }) + assert.equal(saved.status, 403) + assert.equal(saved.code, 'DEMO_LOCKED') + // A draft written before the list grew: publish asks again. + const published = await definitions.publish(7, 1) + assert.equal(published.status, 403) + assert.equal(published.code, 'DEMO_LOCKED') + assert.equal(wrote.length, 0) +}) + +test('a hook that throws refuses with core\'s sentence (fails closed)', async (t) => { + process.env.DEMO_MODE = 'true' + register({ demoRefuses: () => { throw new Error('boom') } }) + stubTable(t) + const made = await definitions.create(input('say hi'), 1, { role: 'admin' }) + assert.equal(made.status, 403) + assert.equal(made.code, 'DEMO_LOCKED') + assert.match(made.message, /"Run console commands" could not be checked/) +}) + +test('the controller answers the refusal with code and message, so the toast fires', async (t) => { + process.env.DEMO_MODE = 'true' + register() + stubTable(t) + let status + let body + const res = { + status(s) { status = s; return this }, + json(b) { body = b; return this }, + } + await controller.create({ body: input('quit'), user: { id: 1, role: 'admin' } }, res) + assert.equal(status, 403) + assert.deepEqual(body, { + errors: ['"quit" is turned off on the demo site.'], + code: 'DEMO_LOCKED', + message: '"quit" is turned off on the demo site.', + }) +}) diff --git a/server/test/eventModuleContract.test.js b/server/test/eventModuleContract.test.js index aebbfcd..fe37060 100644 --- a/server/test/eventModuleContract.test.js +++ b/server/test/eventModuleContract.test.js @@ -112,7 +112,7 @@ test('the version a module declares against satisfies ^1.10.0', () => { // 1.10.0 loading — Module-uo declares exactly `^1.10.0` — and this is the // assertion that says it does. The number itself is pinned in version.js. assert.ok(semver.satisfies(MODULE_API_VERSION, '^1.10.0'), MODULE_API_VERSION) - assert.equal(MODULE_API_VERSION, '1.18.0') + assert.equal(MODULE_API_VERSION, '1.19.0') }) test('a module registers actions, budgets, leases and option sources', () => {