feat(demo): refuse an event step at save with demoRefuses (MODULE_API 1.19.0)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 46s
PR Checks / server-tests (pull_request) Successful in 7m0s
PR Checks / client-build (pull_request) Successful in 7m29s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-26 03:39:42 -05:00
parent c6f53a1509
commit cee4ab8aea
10 changed files with 267 additions and 12 deletions

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