feat(demo): refuse an event step at save with demoRefuses (MODULE_API 1.19.0)
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user