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', () => {