Two new tables — event_action_settings (the deployment switchboard) and event_run_budget (what a run has spent and the most it may) — plus verified_at and verified_by on event_versions. The whole authorisation decision moves behind one function, events/authorize.js: role, enablement, cap, and the shard's own switch named as the layer core deliberately does not duplicate. Three routes, none moved: GET/PUT /admin/events/actions (admin in both directions) and POST /admin/events/:id/verify (admin, editor — a dry run dispatches nothing). Four decisions, settled by the org lead 2026-09-03: - The default-off line falls between inspect and change, not between notify and inspect. Read literally, §K shipped core.wait disabled. The same line is the role floor. - The tightest cap wins where two actions spend one dimension, pinned into the run at creation with the action it came from. - A refusal follows the step's on_failure and takes health to degraded — its own status and its own log kind, because a refusal is not an outage. - The verify gate is enforced for scheduled starts only: a human pressing Start now is the review the gate exists to require. Derived and flagged for review: a dry run fails rather than warns on a disabled action or an over-cap plan, and the unattended path does not re-check the starter's role. +111 tests (1921/1847/73/1 — the one failure pre-existing and environmental), including a 403 walk over the real router and two concurrent spends against one cap on a real MariaDB. The live walk found two defects, both fixed here: the run console route dropped the budget it was handed, and the role refusal used a plural verb over a one-item list. Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
452 lines
20 KiB
JavaScript
452 lines
20 KiB
JavaScript
// ── mayInvoke: the whole authorisation decision (EVENTS_PLAN.md Phase 6) ───
|
|
//
|
|
// §K asks for four layers behind ONE function — role, enablement, cap, and the
|
|
// shard's own switch — and says why: *"it is what makes an EM-style delegation
|
|
// model a later option rather than a redesign"*. This file is that function's
|
|
// contract, and it is worth stating what each test is actually protecting,
|
|
// because three of them protect a decision rather than a mechanism.
|
|
//
|
|
// • **The default-off line falls between `inspect` and `change`** (org lead,
|
|
// 2026-09-03). §K's sentence read literally would have shipped `core.wait`
|
|
// disabled — it is `risk: 'inspect'` — so every published event that waits
|
|
// would break on a fresh deployment. The same line is the role floor.
|
|
// • **The tightest cap wins.** `event_action_settings.caps` is per action while
|
|
// `event_run_budget` is one row per dimension, so two actions spending
|
|
// `uo.creatures` must agree on one number.
|
|
// • **An unpriceable action is refused, not free.** A `cost()` that throws is
|
|
// an action whose own accounting is broken, and reading that as zero would
|
|
// make the broken one the only one nothing bounds.
|
|
//
|
|
// The registry is the REAL one, staged and applied the way a module does it, for
|
|
// `eventRunner.test.js`'s reason: an action that would not register is not one
|
|
// this function has to survive.
|
|
|
|
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 registries = require('../src/modules/registries')
|
|
const authorize = require('../src/events/authorize')
|
|
const settingsDb = require('../src/model/events/eventActionSettings.db')
|
|
const budgetDb = require('../src/model/events/eventRunBudget.db')
|
|
const db = require('../src/utils/db')
|
|
|
|
after(() => db.close())
|
|
|
|
const originals = { settings: { ...settingsDb }, budget: { ...budgetDb } }
|
|
|
|
let store
|
|
|
|
beforeEach(() => {
|
|
registries._reset()
|
|
store = { settings: new Map(), budget: new Map() }
|
|
|
|
settingsDb.get = async (id) => store.settings.get(id) || null
|
|
settingsDb.byIds = async (ids) =>
|
|
new Map([...new Set(ids || [])].filter((i) => store.settings.has(i)).map((i) => [i, store.settings.get(i)]))
|
|
budgetDb.forRun = async (runId) =>
|
|
[...store.budget.values()].filter((b) => Number(b.run_id) === Number(runId))
|
|
budgetDb.spend = async (runId, dimension, amount) => {
|
|
if (!(amount > 0)) return true
|
|
const row = store.budget.get(`${runId}:${dimension}`)
|
|
if (!row) return false
|
|
if (row.cap !== null && row.consumed + amount > row.cap) return false
|
|
row.consumed += amount
|
|
return true
|
|
}
|
|
budgetDb.refund = async (runId, dimension, amount) => {
|
|
const row = store.budget.get(`${runId}:${dimension}`)
|
|
if (row && amount > 0) row.consumed = Math.max(row.consumed - amount, 0)
|
|
}
|
|
})
|
|
|
|
afterEach(() => {
|
|
Object.assign(settingsDb, originals.settings)
|
|
Object.assign(budgetDb, originals.budget)
|
|
registries._reset()
|
|
})
|
|
|
|
const register = (entries, owner = 'test') => {
|
|
const api = registries.stage(owner)
|
|
api.registerEventActions(entries)
|
|
registries.apply(api.staged)
|
|
}
|
|
|
|
const action = (id, over = {}) => ({
|
|
id,
|
|
label: over.label || id,
|
|
risk: over.risk || 'notify',
|
|
reversible: over.reversible || 'none',
|
|
params: over.params || [],
|
|
...(over.cost ? { cost: over.cost } : {}),
|
|
async perform() {
|
|
return { ok: true }
|
|
},
|
|
})
|
|
|
|
const setSetting = (id, enabled, caps = {}) =>
|
|
store.settings.set(id, { action_id: id, enabled: enabled ? 1 : 0, caps })
|
|
|
|
const setBudget = (runId, dimension, { consumed = 0, cap = null } = {}) =>
|
|
store.budget.set(`${runId}:${dimension}`, { run_id: runId, dimension, consumed, cap })
|
|
|
|
const RUN = { id: 1, scope: '' }
|
|
const ADMIN = { id: 1, role: 'admin' }
|
|
const EDITOR = { id: 2, role: 'editor' }
|
|
const MODERATOR = { id: 3, role: 'moderator' }
|
|
|
|
// ── Layer 2: the role floor ────────────────────────────────────────────────
|
|
|
|
test('the role floor falls between inspect and change, not between notify and inspect', async () => {
|
|
// The decision, held as a test rather than as a comment. Read §K's sentence
|
|
// literally and an editor cannot author a step that WAITS, which is an
|
|
// authoring role that cannot author.
|
|
register([
|
|
action('test.tell', { risk: 'notify' }),
|
|
action('test.look', { risk: 'inspect' }),
|
|
action('test.change', { risk: 'change' }),
|
|
action('test.burn', { risk: 'irreversible' }),
|
|
])
|
|
|
|
for (const id of ['test.tell', 'test.look']) {
|
|
const v = await authorize.mayInvoke({ user: EDITOR, action: registries.eventAction(id) })
|
|
assert.equal(v.ok, true, `${id} should be open to an editor`)
|
|
}
|
|
for (const id of ['test.change', 'test.burn']) {
|
|
const v = await authorize.mayInvoke({ user: EDITOR, action: registries.eventAction(id) })
|
|
assert.equal(v.ok, false)
|
|
assert.equal(v.code, 'role')
|
|
assert.match(v.reason, /only an administrator/)
|
|
}
|
|
})
|
|
|
|
test('a moderator is no more able to invoke a world-changing action than an editor', async () => {
|
|
// The moderator's entire power over this feature is the run console (§K). The
|
|
// floor is `admin`, not "not a player".
|
|
register([action('test.change', { risk: 'change' })])
|
|
setSetting('test.change', true)
|
|
const v = await authorize.mayInvoke({ user: MODERATOR, action: registries.eventAction('test.change') })
|
|
assert.equal(v.code, 'role')
|
|
})
|
|
|
|
test('an admin passes the role layer for every risk class', async () => {
|
|
register([action('test.burn', { risk: 'irreversible' })])
|
|
setSetting('test.burn', true)
|
|
const v = await authorize.mayInvoke({ user: ADMIN, action: registries.eventAction('test.burn') })
|
|
assert.equal(v.ok, true)
|
|
})
|
|
|
|
test('a null user skips the role layer, because the runner is not a person', async () => {
|
|
// The unattended path. The role was checked when a human published the version
|
|
// and again when a human or the scheduler started the run; re-checking here
|
|
// would mean demoting an admin at midnight silently strands every event they
|
|
// started.
|
|
register([action('test.burn', { risk: 'irreversible' })])
|
|
setSetting('test.burn', true)
|
|
const v = await authorize.mayInvoke({ action: registries.eventAction('test.burn') })
|
|
assert.equal(v.ok, true)
|
|
})
|
|
|
|
// ── Layer 3a: enablement ───────────────────────────────────────────────────
|
|
|
|
test('nothing that changes the world is enabled by default, and everything else is', async () => {
|
|
register([
|
|
action('test.tell', { risk: 'notify' }),
|
|
action('test.look', { risk: 'inspect' }),
|
|
action('test.change', { risk: 'change' }),
|
|
action('test.burn', { risk: 'irreversible' }),
|
|
])
|
|
const enabled = (id) => authorize.isEnabled(registries.eventAction(id), null)
|
|
assert.equal(enabled('test.tell'), true)
|
|
assert.equal(enabled('test.look'), true)
|
|
assert.equal(enabled('test.change'), false)
|
|
assert.equal(enabled('test.burn'), false)
|
|
})
|
|
|
|
test('a stored row beats the risk-class default in both directions', async () => {
|
|
// The switch is the operator's, and it has to be able to turn a `notify` action
|
|
// OFF as well as a `change` action on. An "enable only" switchboard would leave
|
|
// a deployment unable to stop an announcement it did not want.
|
|
register([action('test.tell', { risk: 'notify' }), action('test.change', { risk: 'change' })])
|
|
setSetting('test.tell', false)
|
|
setSetting('test.change', true)
|
|
|
|
const off = await authorize.mayInvoke({ action: registries.eventAction('test.tell') })
|
|
assert.equal(off.ok, false)
|
|
assert.equal(off.code, 'disabled')
|
|
|
|
const on = await authorize.mayInvoke({ action: registries.eventAction('test.change') })
|
|
assert.equal(on.ok, true)
|
|
})
|
|
|
|
test('the refusal names the action by its LABEL, not its id', async () => {
|
|
// The reason is rendered to an operator on the run console. `"Spawn creatures"
|
|
// is not enabled on this deployment` is a sentence; the id is a slug.
|
|
register([action('test.change', { risk: 'change', label: 'Spawn creatures' })])
|
|
const v = await authorize.mayInvoke({ action: registries.eventAction('test.change') })
|
|
assert.match(v.reason, /"Spawn creatures" is not enabled/)
|
|
})
|
|
|
|
// ── Pricing ────────────────────────────────────────────────────────────────
|
|
|
|
test('cost() is called with the step params and its answer is what core enforces', async () => {
|
|
register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
|
|
setSetting('test.spawn', true)
|
|
const priced = authorize.priceOf(registries.eventAction('test.spawn'), { count: 12 })
|
|
assert.deepEqual(priced, { 'x.creatures': 12 })
|
|
})
|
|
|
|
test('a cost() that throws makes the action unpriceable, never free', async () => {
|
|
register([
|
|
action('test.spawn', {
|
|
risk: 'change',
|
|
cost: () => {
|
|
throw new Error('nope')
|
|
},
|
|
}),
|
|
])
|
|
setSetting('test.spawn', true)
|
|
assert.equal(authorize.priceOf(registries.eventAction('test.spawn'), {}), null)
|
|
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN })
|
|
assert.equal(v.ok, false)
|
|
assert.equal(v.code, 'unpriceable')
|
|
})
|
|
|
|
test('a cost() answering a non-object, a negative or a NaN is unpriceable too', async () => {
|
|
register([
|
|
action('test.a', { risk: 'change', cost: () => 5 }),
|
|
action('test.b', { risk: 'change', cost: () => ({ d: -1 }) }),
|
|
action('test.c', { risk: 'change', cost: () => ({ d: 'lots' }) }),
|
|
action('test.d', { risk: 'change', cost: () => [1, 2] }),
|
|
])
|
|
for (const id of ['test.a', 'test.b', 'test.c', 'test.d']) {
|
|
assert.equal(authorize.priceOf(registries.eventAction(id), {}), null, id)
|
|
}
|
|
})
|
|
|
|
test('a zero cost is dropped rather than becoming a dimension with no spend', async () => {
|
|
// A dimension present at 0 would be seeded as a budget row nothing ever draws
|
|
// on, and it would appear on the console's meter as "0 of 30" for a verb this
|
|
// event never uses.
|
|
register([action('test.spawn', { risk: 'change', cost: () => ({ a: 0, b: 3 }) })])
|
|
assert.deepEqual(authorize.priceOf(registries.eventAction('test.spawn'), {}), { b: 3 })
|
|
})
|
|
|
|
test('an action with no cost() costs nothing and never touches the budget', async () => {
|
|
register([action('test.tell')])
|
|
const v = await authorize.mayInvoke({ action: registries.eventAction('test.tell'), run: RUN, spend: true })
|
|
assert.equal(v.ok, true)
|
|
assert.deepEqual(v.cost, {})
|
|
// No budget row exists for this run at all, and that is not a refusal: an
|
|
// action that spends nothing has nothing to be refused over.
|
|
assert.equal(store.budget.size, 0)
|
|
})
|
|
|
|
test('dimensions are discovered by pricing the declared examples', async () => {
|
|
// The Phase 6 stand-in for §F's `registerEventBudgets`, which arrives in Phase
|
|
// 7. Every param carries a required `example` precisely so a form has something
|
|
// to show, and pricing them is what lets the switchboard offer a cap box.
|
|
register([
|
|
action('test.spawn', {
|
|
risk: 'change',
|
|
params: [{ name: 'count', type: 'int', required: true, example: 4 }],
|
|
cost: (p) => ({ 'x.creatures': p.count, 'x.bosses': 1 }),
|
|
}),
|
|
])
|
|
assert.deepEqual(authorize.dimensionsOf(registries.eventAction('test.spawn')), ['x.bosses', 'x.creatures'])
|
|
})
|
|
|
|
test('an action whose cost() cannot survive its own examples reports no dimensions', async () => {
|
|
// Honest rather than clever: the operator loses a cap box, and the RUN loses
|
|
// nothing, because a run's budget is seeded from the params its steps were
|
|
// actually authored with.
|
|
register([
|
|
action('test.spawn', {
|
|
risk: 'change',
|
|
cost: (p) => ({ d: p.missing.count }),
|
|
}),
|
|
])
|
|
assert.deepEqual(authorize.dimensionsOf(registries.eventAction('test.spawn')), [])
|
|
})
|
|
|
|
// ── effectiveCaps: the tightest cap wins ───────────────────────────────────
|
|
|
|
test('two actions spending one dimension resolve to the tightest cap, and it says whose', async () => {
|
|
register([
|
|
action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }),
|
|
action('test.horde', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }),
|
|
])
|
|
setSetting('test.spawn', true, { 'x.creatures': 30 })
|
|
setSetting('test.horde', true, { 'x.creatures': 10 })
|
|
|
|
const caps = authorize.effectiveCaps(
|
|
[{ actionId: 'test.spawn', params: {} }, { actionId: 'test.horde', params: {} }],
|
|
await settingsDb.byIds(['test.spawn', 'test.horde']),
|
|
)
|
|
assert.deepEqual(caps, { 'x.creatures': { cap: 10, from: 'test.horde' } })
|
|
})
|
|
|
|
test('an action that declines to cap a dimension does not raise a ceiling another one set', async () => {
|
|
// `null` is uncapped and must never win a minimum. Otherwise adding a second
|
|
// verb to an event would silently remove the bound on the first.
|
|
register([
|
|
action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }),
|
|
action('test.horde', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }),
|
|
])
|
|
setSetting('test.spawn', true, { 'x.creatures': 30 })
|
|
setSetting('test.horde', true, {})
|
|
|
|
const caps = authorize.effectiveCaps(
|
|
[{ actionId: 'test.horde', params: {} }, { actionId: 'test.spawn', params: {} }],
|
|
await settingsDb.byIds(['test.spawn', 'test.horde']),
|
|
)
|
|
assert.deepEqual(caps, { 'x.creatures': { cap: 30, from: 'test.spawn' } })
|
|
})
|
|
|
|
test('a dimension nobody caps is still a row, uncapped', async () => {
|
|
// Seeded so the meter counts it. The distinction it preserves is that a MISSING
|
|
// row means something else entirely: a step spending a dimension its own run's
|
|
// version never priced.
|
|
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) })])
|
|
setSetting('test.spawn', true, {})
|
|
const caps = authorize.effectiveCaps([{ actionId: 'test.spawn', params: {} }], await settingsDb.byIds(['test.spawn']))
|
|
assert.deepEqual(caps, { 'x.creatures': { cap: null, from: null } })
|
|
})
|
|
|
|
test('a step naming an unregistered action contributes no dimension', async () => {
|
|
assert.deepEqual(authorize.effectiveCaps([{ actionId: 'nobody.registers', params: {} }], new Map()), {})
|
|
})
|
|
|
|
// ── Layer 3b: the cap ──────────────────────────────────────────────────────
|
|
|
|
test('with no run, the question is whether the cost could EVER fit', async () => {
|
|
// The dry run's and the editor's question. A step asking for 40 under a cap of
|
|
// 30 is an authoring error answerable before anything is scheduled, which is
|
|
// the entire value of catching it here rather than at 2am.
|
|
register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
|
|
setSetting('test.spawn', true, { 'x.creatures': 30 })
|
|
|
|
const bad = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), params: { count: 40 } })
|
|
assert.equal(bad.ok, false)
|
|
assert.equal(bad.code, 'cap')
|
|
assert.equal(bad.dimension, 'x.creatures')
|
|
assert.equal(bad.requested, 40)
|
|
assert.equal(bad.cap, 30)
|
|
|
|
const fine = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), params: { count: 30 } })
|
|
assert.equal(fine.ok, true)
|
|
})
|
|
|
|
test('with a run and spend, the check IS the spend', async () => {
|
|
register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
|
|
setSetting('test.spawn', true, { 'x.creatures': 30 })
|
|
setBudget(RUN.id, 'x.creatures', { consumed: 0, cap: 30 })
|
|
|
|
const v = await authorize.mayInvoke({
|
|
action: registries.eventAction('test.spawn'),
|
|
params: { count: 12 },
|
|
run: RUN,
|
|
spend: true,
|
|
})
|
|
assert.equal(v.ok, true)
|
|
assert.equal(v.spent, true)
|
|
assert.equal(store.budget.get('1:x.creatures').consumed, 12)
|
|
})
|
|
|
|
test('without spend the cap check is advisory and moves nothing', async () => {
|
|
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })])
|
|
setSetting('test.spawn', true, { 'x.creatures': 30 })
|
|
setBudget(RUN.id, 'x.creatures', { consumed: 0, cap: 30 })
|
|
|
|
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN })
|
|
assert.equal(v.ok, true)
|
|
assert.equal(v.spent, undefined)
|
|
assert.equal(store.budget.get('1:x.creatures').consumed, 0)
|
|
})
|
|
|
|
test('a refusal names the numbers an operator needs, not just "refused"', async () => {
|
|
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })])
|
|
setSetting('test.spawn', true, { 'x.creatures': 30 })
|
|
setBudget(RUN.id, 'x.creatures', { consumed: 28, cap: 30 })
|
|
|
|
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
|
|
assert.equal(v.ok, false)
|
|
assert.equal(v.code, 'cap')
|
|
assert.equal(v.consumed, 28)
|
|
assert.equal(v.cap, 30)
|
|
assert.equal(v.requested, 5)
|
|
assert.match(v.reason, /28 of 30 is already spent this run/)
|
|
})
|
|
|
|
test('a partial spend across dimensions is given back when a later one is refused', async () => {
|
|
// The property the whole multi-dimension path turns on. The spends must be
|
|
// separate statements — the atomicity that matters is per dimension — so a step
|
|
// costing creatures AND bosses can take the creatures and be refused the
|
|
// bosses, and a step that did not run must not have spent anything.
|
|
register([
|
|
action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5, 'x.bosses': 2 }) }),
|
|
])
|
|
setSetting('test.spawn', true)
|
|
setBudget(RUN.id, 'x.creatures', { consumed: 0, cap: 30 })
|
|
setBudget(RUN.id, 'x.bosses', { consumed: 1, cap: 2 })
|
|
|
|
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
|
|
assert.equal(v.ok, false)
|
|
assert.equal(v.dimension, 'x.bosses')
|
|
assert.equal(store.budget.get('1:x.creatures').consumed, 0, 'the creatures must have been given back')
|
|
assert.equal(store.budget.get('1:x.bosses').consumed, 1)
|
|
})
|
|
|
|
test('spending a dimension the run has no row for is refused, and says so in its own words', async () => {
|
|
// Fail-closed, and a distinct code: "this run has no budget for that" is a
|
|
// different diagnosis from "the cap is spent", and an operator who raises the
|
|
// cap in answer to the wrong one has not fixed anything.
|
|
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.ghosts': 1 }) })])
|
|
setSetting('test.spawn', true)
|
|
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
|
|
assert.equal(v.ok, false)
|
|
assert.equal(v.code, 'unbudgeted')
|
|
assert.match(v.reason, /no budget for/)
|
|
})
|
|
|
|
test('an uncapped budget row never refuses', async () => {
|
|
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 999 }) })])
|
|
setSetting('test.spawn', true)
|
|
setBudget(RUN.id, 'x.creatures', { consumed: 5, cap: null })
|
|
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
|
|
assert.equal(v.ok, true)
|
|
assert.equal(store.budget.get('1:x.creatures').consumed, 1004)
|
|
})
|
|
|
|
// ── The layers are ordered ─────────────────────────────────────────────────
|
|
|
|
test('the layers answer in order: role before enablement before cap', async () => {
|
|
// Not cosmetic. An editor told "that action is disabled" would go and ask an
|
|
// admin to enable it, and still not be allowed to author the step — the honest
|
|
// first answer is the role.
|
|
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 99 }) })])
|
|
// Disabled AND over cap AND world-changing, all at once.
|
|
const v = await authorize.mayInvoke({
|
|
user: EDITOR,
|
|
action: registries.eventAction('test.spawn'),
|
|
run: RUN,
|
|
})
|
|
assert.equal(v.code, 'role')
|
|
|
|
setSetting('test.spawn', false, { 'x.creatures': 1 })
|
|
const asAdmin = await authorize.mayInvoke({
|
|
user: ADMIN,
|
|
action: registries.eventAction('test.spawn'),
|
|
run: RUN,
|
|
})
|
|
assert.equal(asAdmin.code, 'disabled')
|
|
})
|
|
|
|
test('an action nobody registers is refused rather than thrown at', async () => {
|
|
const v = await authorize.mayInvoke({ action: null })
|
|
assert.equal(v.ok, false)
|
|
assert.equal(v.code, 'unregistered')
|
|
})
|