feat(demo): refuse an event step at save with demoRefuses (MODULE_API 1.19.0) #224

Merged
whitlocktech merged 1 commits from feature/demo-step-refusal into main 2026-10-26 05:52:10 -05:00
10 changed files with 267 additions and 12 deletions

View File

@@ -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'

View File

@@ -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",

View File

@@ -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,

View File

@@ -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,
}
}

View File

@@ -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 }

View File

@@ -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',

View File

@@ -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"
}
}
}
}
}
},

View File

@@ -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'],
},

View File

@@ -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.',
})
})

View File

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