feat(demo): refuse an event step at save with demoRefuses (MODULE_API 1.19.0) #224
@@ -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'
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
154
server/test/demoStepRefusal.test.js
Normal file
154
server/test/demoStepRefusal.test.js
Normal 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.',
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user