feat(engagement): the admin ceiling and core's news.post emitter (Phase 11a)
Core's half of ENGAGEMENT.md Phase 11a: the two decisions the org lead settled before any code that land in core rather than in module-uo. Pairs with Module-uo#22 and docs#194. ## Decision 1 -- a seventh ceiling, `admin`, as a child of `staff` Phase 11's operator-facing triggers (uo.audit.staff_action, uo.economy.milestone, uo.world.saved) are described as admin-audience everywhere, and the narrowest value the lattice had was `staff` -- which ceilings.js defines as admin, editor AND moderator. Ceilinging them there would have let an operator save a rule that mails the staff audit digest to every moderator in it. `admin` is the ONLY genuine refinement in the tree -- every admin is staff, which is exactly the containment every other pair of branches lacks -- so it is a child rather than a seventh leaf, and permits/meet/meetAll needed no change beyond the new PARENT entry. **The one non-obvious consequence, and the reason for ROLE_CEILINGS.** notificationChannelPrefs' `visibleTo` asked `item.ceiling !== 'staff'`. That was correct while `staff` was the only role-gated value, and the day `admin` arrived it would have silently published every admin-ceilinged id -- the staff audit digest, the economy thresholds -- to every player's preferences screen by name. It now reads a TABLE (`ceilings.reachableBy`), so a ceiling added without an entry fails closed instead. An EDITOR is the viewer that tells the two rules apart, and the new tests use one. MODULE_API_VERSION -> 1.8.0 on both halves. Additive: every declaration valid under 1.7.0 is valid now and no stored value changes. ## Decision 5 -- 7.1 Q9: news.post gets an emitter, and it REPLACES the tickle `news.post` has been a declared payload contract with no caller since Phase 2, so a rule naming it could never fire. utils/newsNotify.js is the caller; announceIfNewlyPublished now calls it instead of pushDispatch.publish, gated on the same enqueueIfNeeded job id -- the single "newly published news" transition signal, not re-derived. **News push therefore stops on upgrade** until an operator enables the seeded rule. That is the org lead's decision, taken over keeping the raw call beside the emit "for one release": an exception with a deadline nobody owns, which Phase 6 already refused for Teams. The Rules screen gains a second migration notice naming news, and Phase 13's release note carries it as an upgrade step. **The seed needed its own one-shot key, and this is the trap worth recording.** `engagement_team_rules_seeded` is already stamped on every deployment that has booted since Phase 6, and the guard reads its presence -- so appending news to RULES would have seeded it on fresh installs only, and on exactly the upgrades that lose their raw push, never. One key per seed GROUP is now the rule; seedGroup() is the shared implementation and seedCoreRules() is what boot calls. Also fixes news.post's `postUrl` example, which named `/news/<slug>` -- a path App.jsx does not mount. An example is what the template editor previews and test-sends with, so a wrong one is a preview that looks right and a mail that is not. It is `/site/news`, the list, which is what the Discord and town-crier announcements have always linked. 1550 tests pass (16 new), 327 client tests pass, client builds, check:modules clean -- core still names no module identifier with module-uo now registering 24 UO-named triggers. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -16,10 +16,10 @@ const assert = require('node:assert/strict')
|
||||
|
||||
const ceilings = require('../src/modules/ceilings')
|
||||
|
||||
test('the six ceilings are the vocabulary, and nothing else is', () => {
|
||||
test('the seven ceilings are the vocabulary, and nothing else is', () => {
|
||||
assert.deepEqual(
|
||||
[...ceilings.CEILINGS].sort(),
|
||||
['authenticated', 'everyone', 'members', 'owner', 'staff', 'subscribers'],
|
||||
['admin', 'authenticated', 'everyone', 'members', 'owner', 'staff', 'subscribers'],
|
||||
)
|
||||
for (const id of ceilings.CEILINGS) assert.ok(ceilings.LABELS[id], `${id} has an operator label`)
|
||||
assert.equal(ceilings.isCeiling('nobody'), false)
|
||||
@@ -33,9 +33,9 @@ test('everyone permits every ceiling; every ceiling permits itself', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('authenticated permits the four leaves but not everyone', () => {
|
||||
for (const leaf of ['subscribers', 'members', 'staff', 'owner']) {
|
||||
assert.equal(ceilings.permits('authenticated', leaf), true)
|
||||
test('authenticated permits every branch and admin beneath staff, but not everyone', () => {
|
||||
for (const below of ['subscribers', 'members', 'staff', 'owner', 'admin']) {
|
||||
assert.equal(ceilings.permits('authenticated', below), true)
|
||||
}
|
||||
assert.equal(ceilings.permits('authenticated', 'everyone'), false)
|
||||
})
|
||||
@@ -55,6 +55,70 @@ test('a staff ceiling does NOT permit owner — fewer people is not less exposur
|
||||
}
|
||||
})
|
||||
|
||||
// ── `admin`, added in Phase 11 ─────────────────────────────────────────────
|
||||
//
|
||||
// The one genuine refinement in the tree: every admin is staff, which is the
|
||||
// containment no other pair has. These assert that it is a NARROWING and not a
|
||||
// second way to widen — the failure this file exists to keep out, in its newest
|
||||
// possible costume.
|
||||
|
||||
test('staff permits admin and admin does not permit staff — the one true refinement', () => {
|
||||
assert.equal(ceilings.permits('staff', 'admin'), true)
|
||||
assert.equal(ceilings.permits('admin', 'staff'), false)
|
||||
assert.equal(ceilings.meet('staff', 'admin'), 'admin')
|
||||
assert.equal(ceilings.meet('admin', 'staff'), 'admin')
|
||||
})
|
||||
|
||||
test('admin is incomparable with every branch that is not staff', () => {
|
||||
for (const other of ['subscribers', 'members', 'owner']) {
|
||||
assert.equal(ceilings.permits('admin', other), false, `admin must not permit ${other}`)
|
||||
assert.equal(ceilings.permits(other, 'admin'), false, `${other} must not permit admin`)
|
||||
assert.equal(ceilings.meet('admin', other), null, `admin ∧ ${other} has no bound`)
|
||||
}
|
||||
})
|
||||
|
||||
test('an admin-ceilinged trigger refuses a staff audience', () => {
|
||||
// The acceptance criterion in as many words: a rule cannot give an
|
||||
// admin-ceiling trigger a `staff` audience. `permits` is what both the save
|
||||
// check and the send-time re-check call.
|
||||
assert.equal(ceilings.permits('admin', 'staff'), false)
|
||||
// …and the narrowing direction is allowed, which is what makes the node useful
|
||||
// rather than merely restrictive.
|
||||
assert.equal(ceilings.permits('staff', 'admin'), true)
|
||||
})
|
||||
|
||||
test('the role ceilings are a table, so a new one cannot be forgotten', () => {
|
||||
// `visibleTo` used to ask `ceiling !== 'staff'`. That spelling was correct
|
||||
// while `staff` was the only role-gated value and would have silently published
|
||||
// every admin-ceilinged id to every player's preferences screen the day `admin`
|
||||
// arrived. The table is what makes that impossible to get wrong quietly.
|
||||
assert.deepEqual(Object.keys(ceilings.ROLE_CEILINGS).sort(), ['admin', 'staff'])
|
||||
for (const id of Object.keys(ceilings.ROLE_CEILINGS)) {
|
||||
assert.ok(ceilings.isRoleCeiling(id), `${id} is a role ceiling`)
|
||||
assert.ok(ceilings.ROLE_CEILINGS[id].roles.length, `${id} names at least one role`)
|
||||
}
|
||||
assert.equal(ceilings.isRoleCeiling('subscribers'), false)
|
||||
})
|
||||
|
||||
test('reachableBy gates the role ceilings and lets everything else through', () => {
|
||||
assert.equal(ceilings.reachableBy('admin', 'admin'), true)
|
||||
assert.equal(ceilings.reachableBy('admin', 'editor'), false)
|
||||
assert.equal(ceilings.reachableBy('admin', 'moderator'), false)
|
||||
assert.equal(ceilings.reachableBy('admin', 'user'), false)
|
||||
assert.equal(ceilings.reachableBy('staff', 'editor'), true)
|
||||
assert.equal(ceilings.reachableBy('staff', 'user'), false)
|
||||
// Fails closed on a missing viewer, which is how a signed-out catalog read
|
||||
// reaches it.
|
||||
assert.equal(ceilings.reachableBy('staff', undefined), false)
|
||||
assert.equal(ceilings.reachableBy('admin', undefined), false)
|
||||
// Everything that is not role-gated is visible to anyone, including the `null`
|
||||
// a stream-only catalog item carries.
|
||||
for (const open of ['everyone', 'authenticated', 'subscribers', 'members', 'owner']) {
|
||||
assert.equal(ceilings.reachableBy(open, 'user'), true, `${open} is not role-gated`)
|
||||
}
|
||||
assert.equal(ceilings.reachableBy(null, 'user'), true)
|
||||
})
|
||||
|
||||
test('an unknown ceiling is permitted by nothing, on either side', () => {
|
||||
assert.equal(ceilings.permits('everyone', 'god'), false)
|
||||
assert.equal(ceilings.permits('god', 'owner'), false)
|
||||
@@ -70,6 +134,7 @@ test('A OR B takes the NARROWER of the two ceilings, not the wider', () => {
|
||||
|
||||
test('incomparable ceilings have no meet — the save is refused, not guessed', () => {
|
||||
assert.equal(ceilings.meet('staff', 'members'), null)
|
||||
assert.equal(ceilings.meet('admin', 'owner'), null)
|
||||
assert.equal(ceilings.meet('owner', 'subscribers'), null)
|
||||
assert.equal(ceilings.meet('staff', 'nonsense'), null)
|
||||
})
|
||||
|
||||
@@ -419,10 +419,14 @@ test('GET /admin/engagement/triggers serves core\'s declarations and the ceiling
|
||||
assert.ok(news.variables.some((v) => v.name === 'title' && v.example))
|
||||
// The lattice travels with the catalog so the rule editor never offers an
|
||||
// audience the server will refuse.
|
||||
// `staff` permits itself and `admin` beneath it — the one refinement in the
|
||||
// tree (Phase 11). Every other branch permits itself alone.
|
||||
const staff = res.body.ceilings.find((c) => c.id === 'staff')
|
||||
assert.deepEqual(staff.permits, ['staff'])
|
||||
assert.deepEqual(staff.permits, ['staff', 'admin'])
|
||||
const admin = res.body.ceilings.find((c) => c.id === 'admin')
|
||||
assert.deepEqual(admin.permits, ['admin'])
|
||||
const everyone = res.body.ceilings.find((c) => c.id === 'everyone')
|
||||
assert.equal(everyone.permits.length, 6)
|
||||
assert.equal(everyone.permits.length, 7)
|
||||
})
|
||||
|
||||
test('GET /admin/engagement/audiences never serves a resolver', () => {
|
||||
|
||||
127
server/test/newsNotify.test.js
Normal file
127
server/test/newsNotify.test.js
Normal file
@@ -0,0 +1,127 @@
|
||||
// ── Core's `news.post` emitter (ENGAGEMENT.md §7.1 Q9, Phase 11) ───────────
|
||||
//
|
||||
// `news.post` was a declared payload contract with NO CALLER from Phase 2 until
|
||||
// this phase — a rule naming it could never fire, so on a real deployment the
|
||||
// only mail or inbox item a rule could produce came from Teams. This is the test
|
||||
// for the call that fixes it, and for the two things about it that are decisions
|
||||
// rather than plumbing:
|
||||
//
|
||||
// 1. The emit REPLACED `pushDispatch.publish('news.post', …)`, so news push now
|
||||
// rides a rule. Core seeds that rule DISABLED, which is why news push stops
|
||||
// on upgrade — deliberately, on the Phase 6 Team precedent.
|
||||
// 2. The seed carries its OWN one-shot key. The Team key is already stamped on
|
||||
// every deployment that has booted since Phase 6, and those are exactly the
|
||||
// deployments that lose their raw push — so joining that group would have
|
||||
// seeded the news rule on fresh installs only.
|
||||
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const registries = require('../src/modules/registries')
|
||||
const newsNotify = require('../src/utils/newsNotify')
|
||||
const coreRules = require('../src/engagement/coreRules')
|
||||
|
||||
registries.registerCore()
|
||||
|
||||
const POST = {
|
||||
id: 412,
|
||||
title: 'Five on Friday — the Yew invasion',
|
||||
excerpt: 'Four new champion spawns, and the fate of the Yew moongate.',
|
||||
category: 'Five on Friday',
|
||||
}
|
||||
|
||||
test('a publish emits news.post with the declaration\'s own variables', () => {
|
||||
const ok = newsNotify.emitNewsPost(POST)
|
||||
assert.equal(ok, true)
|
||||
})
|
||||
|
||||
test('the emitted payload satisfies the declared contract', () => {
|
||||
// `emit` throws in dev on a payload that misses a required variable, so the
|
||||
// assertion that it did not throw above is already most of this. This says
|
||||
// which variables, so a future declaration change breaks here with a name.
|
||||
const declaration = registries.eventTrigger('news.post')
|
||||
const required = declaration.variables.filter((v) => v.required).map((v) => v.name)
|
||||
assert.deepEqual(required.sort(), ['postUrl', 'title'])
|
||||
})
|
||||
|
||||
test('postUrl is the news LIST, because the site has no per-post route', () => {
|
||||
// `App.jsx` mounts `/site/news` and nothing under it, which is why
|
||||
// `announceJobs.logic.js` links the list from the Discord and town-crier
|
||||
// announcements too. The declaration's `example` used to name `/news/<slug>`,
|
||||
// a path that 404s — and an example is what the template editor previews and
|
||||
// test-sends with, so a wrong one is a preview that looks right.
|
||||
assert.equal(newsNotify.NEWS_PATH, '/site/news')
|
||||
assert.ok(newsNotify.NEWS_PATH.startsWith('/'), 'site-relative, because it ends up in an href')
|
||||
assert.ok(!newsNotify.NEWS_PATH.startsWith('//'), 'not protocol-relative')
|
||||
const declaration = registries.eventTrigger('news.post')
|
||||
const postUrl = declaration.variables.find((v) => v.name === 'postUrl')
|
||||
assert.equal(postUrl.example, newsNotify.NEWS_PATH, 'the example is the value the emitter sends')
|
||||
})
|
||||
|
||||
test('a post with no excerpt falls back to the body, stripped of markup', () => {
|
||||
const text = newsNotify.excerptFrom({ body: '<p>Hello <b>world</b></p>' })
|
||||
assert.equal(text, 'Hello world')
|
||||
})
|
||||
|
||||
test('an empty post omits the excerpt rather than sending a blank one', () => {
|
||||
// `excerpt` is declared optional. An absent optional renders as absent; an
|
||||
// empty string renders as a blank line where a summary should be.
|
||||
assert.equal(newsNotify.excerptFrom({}), null)
|
||||
assert.equal(newsNotify.excerptFrom({ body: '<p> </p>' }), null)
|
||||
})
|
||||
|
||||
test('a long body is truncated rather than reproduced in the mail', () => {
|
||||
const text = newsNotify.excerptFrom({ body: 'x'.repeat(500) })
|
||||
assert.equal(text.length, newsNotify.EXCERPT_CHARS)
|
||||
assert.ok(text.endsWith('…'))
|
||||
})
|
||||
|
||||
test('a malformed post is a no-op, never an exception on the publish path', () => {
|
||||
// This runs inside `announceIfNewlyPublished`, after the post has been saved.
|
||||
// A notification that can fail the write behind it is a defect.
|
||||
assert.equal(newsNotify.emitNewsPost(null), false)
|
||||
assert.equal(newsNotify.emitNewsPost({}), false)
|
||||
})
|
||||
|
||||
// ── The seed, and why it is its own group ──────────────────────────────────
|
||||
|
||||
test('the news rule is seeded, disabled, on all three channels', () => {
|
||||
assert.equal(coreRules.NEWS_RULES.length, 1)
|
||||
const [rule] = coreRules.NEWS_RULES
|
||||
assert.equal(rule.trigger_id, 'news.post')
|
||||
assert.equal(rule.audience, 'subscribers')
|
||||
// **Push is on this rule** because push is what the raw tickle did. Leaving it
|
||||
// off would mean an operator who enabled the rule to restore news push got
|
||||
// mail instead.
|
||||
assert.deepEqual(rule.channels, ['email', 'inapp', 'push'])
|
||||
// Nothing core seeds is ever enabled — `seedGroup` stamps `enabled: 0` over
|
||||
// every entry, so the rule cannot ship on even by accident.
|
||||
assert.equal(rule.enabled, undefined)
|
||||
})
|
||||
|
||||
test('the news seed has its own one-shot key, separate from the Team group\'s', () => {
|
||||
// The failure this prevents: the Team key is already stamped on every
|
||||
// deployment that has booted since Phase 6, and the guard reads its presence.
|
||||
// Appending to the Team list would have seeded the news rule on fresh installs
|
||||
// only — and on exactly the upgrades that lose their raw news push, never.
|
||||
assert.notEqual(coreRules.NEWS_SEEDED_KEY, coreRules.SEEDED_KEY)
|
||||
assert.equal(coreRules.SEEDED_KEY, 'engagement_team_rules_seeded')
|
||||
assert.equal(coreRules.NEWS_SEEDED_KEY, 'engagement_news_rule_seeded')
|
||||
})
|
||||
|
||||
test('the audience the rule names is one the trigger\'s ceiling permits', () => {
|
||||
const ceilings = require('../src/modules/ceilings')
|
||||
const declaration = registries.eventTrigger('news.post')
|
||||
for (const rule of [...coreRules.RULES, ...coreRules.NEWS_RULES]) {
|
||||
const d = registries.eventTrigger(rule.trigger_id)
|
||||
assert.ok(d, `${rule.trigger_id} is declared`)
|
||||
assert.ok(
|
||||
ceilings.permits(d.ceiling, rule.audience),
|
||||
`${rule.trigger_id}: audience "${rule.audience}" is within ceiling "${d.ceiling}"`,
|
||||
)
|
||||
}
|
||||
// Named explicitly, because it is the one seeded rule whose ceiling is wider
|
||||
// than its audience: a rule editor may widen news to `authenticated`, and
|
||||
// deliberately may not widen a Team event past `members`.
|
||||
assert.equal(declaration.ceiling, 'authenticated')
|
||||
})
|
||||
@@ -35,6 +35,9 @@ after(() => db.close())
|
||||
const USER = 7
|
||||
const PLAYER = { id: USER, role: 'player' }
|
||||
const ADMIN = { id: USER, role: 'admin' }
|
||||
// Phase 11 added the `admin` ceiling beneath `staff`, so the interesting viewer
|
||||
// is no longer "player vs staff" but the one INSIDE `staff` and outside `admin`.
|
||||
const EDITOR = { id: USER, role: 'editor' }
|
||||
|
||||
// ── In-memory stand-ins for the two tables ─────────────────────────────────
|
||||
//
|
||||
@@ -338,6 +341,60 @@ test('a staff-ceilinged trigger is not offered to a player, and is to staff', as
|
||||
assert.equal(prefRows.size, 0)
|
||||
})
|
||||
|
||||
|
||||
// **The Phase 11 ceiling, and the reason `visibleTo` stopped asking about `staff`
|
||||
// by name.** Its rule used to be `ceiling !== 'staff'`, which was correct while
|
||||
// `staff` was the only role-gated value and would have silently published every
|
||||
// admin-ceilinged id to every player the day `admin` arrived. An editor is the
|
||||
// viewer that tells the two apart: inside `staff`, outside `admin`.
|
||||
test('an admin-ceilinged trigger is hidden from a player AND from an editor', async () => {
|
||||
const api = registries.stage('uo')
|
||||
api.registerEventTriggers([{
|
||||
id: 'uo.audit.staff_action',
|
||||
label: 'A staff member acted in game',
|
||||
ceiling: 'admin',
|
||||
audience: 'admin',
|
||||
variables: [{ name: 'action', type: 'string', required: true, example: 'set' }],
|
||||
}])
|
||||
registries.apply(api.staged)
|
||||
|
||||
const asPlayer = await prefs.getForUser(USER, PLAYER)
|
||||
assert.equal(item(asPlayer, 'uo.audit.staff_action'), undefined, 'a player is not told it exists')
|
||||
|
||||
// The one an `!== staff` test would have got wrong: an editor IS staff, and a
|
||||
// digest of what staff did in game is not for them.
|
||||
const asEditor = await prefs.getForUser(USER, EDITOR)
|
||||
assert.equal(item(asEditor, 'uo.audit.staff_action'), undefined, 'an editor is not told either')
|
||||
|
||||
const asAdmin = await prefs.getForUser(USER, ADMIN)
|
||||
assert.ok(item(asAdmin, 'uo.audit.staff_action'), 'an admin is')
|
||||
|
||||
// A gate, not a display rule: an editor who knows the id still cannot store a
|
||||
// preference for it.
|
||||
await notifCtrl.putChannelPrefs(
|
||||
{ user: EDITOR, body: { prefs: [{ id: 'uo.audit.staff_action', channel: 'email', mode: 'instant' }] } },
|
||||
mockRes(),
|
||||
)
|
||||
assert.equal(prefRows.size, 0)
|
||||
})
|
||||
|
||||
// The counterpart, so the generalisation did not quietly hide more than it should.
|
||||
test('a staff-ceilinged trigger is still offered to an editor', async () => {
|
||||
const api = registries.stage('uo')
|
||||
api.registerEventTriggers([{
|
||||
id: 'uo.page.new',
|
||||
label: 'A player opened a help page',
|
||||
ceiling: 'staff',
|
||||
audience: 'staff',
|
||||
variables: [{ name: 'pageType', type: 'string', required: true, example: 'Stuck' }],
|
||||
}])
|
||||
registries.apply(api.staged)
|
||||
|
||||
const asEditor = await prefs.getForUser(USER, EDITOR)
|
||||
assert.ok(item(asEditor, 'uo.page.new'), 'an editor is inside the staff ceiling')
|
||||
assert.equal(item(await prefs.getForUser(USER, PLAYER), 'uo.page.new'), undefined)
|
||||
})
|
||||
|
||||
// ── The channel registry itself ────────────────────────────────────────────
|
||||
|
||||
test('the registry refuses a channel that under-declares', async () => {
|
||||
|
||||
Reference in New Issue
Block a user