3 Commits

Author SHA1 Message Date
1d4cd4adae feat(engagement): the admin ceiling and core's news.post emitter (Phase 11a)
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / bot-tests (pull_request) Successful in 28s
PR Checks / server-tests (pull_request) Successful in 13m3s
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>
2026-08-31 20:33:02 -05:00
49a61fdafa Merge pull request 'feat(engagement): deliverability — suppression, bounces and the verification gate (Phase 9)' (#176) from feature/engagement-deliverability into edge
Reviewed-on: #176
2026-08-31 15:52:47 +00:00
c208543044 feat(engagement): deliverability — suppression, bounces and the verification gate
All checks were successful
PR Checks / client-build (pull_request) Successful in 36s
PR Checks / bot-tests (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Successful in 5m12s
ENGAGEMENT.md Phase 9, closing gap G16. Two mechanisms decide that somebody in a
rule's audience does not get the mail, and they sit at deliberately different
points in the pipeline.

`engagement_suppressions` is checked at DELIVERY: an outbox row can sit through a
rule's `delay_seconds` grace window and an address can bounce inside it, so the
only correct check is the one taken immediately before the transport call — which
is also what produces the `status='suppressed'` row with no transport call at all.

The Phase 1b verification gate is applied at ENQUEUE, through a new optional
`registerDeliveryChannel({ eligible })` that only `email` declares. Filtering the
shared audience would have silenced the wrong sink: a rule spanning email and
in-app must still put an item in an unverified user's inbox. The excluded counts
reach `summary.ineligible` and the admin reach preview, which until now reported
an audience size that was never the number of people who would be mailed.

`bounceClassify.js` is the only thing that may write a `bounce` row, and it is
deliberately NOT `mailer.PERMANENT_CODES`. That set answers "is retrying
pointless?" and contains EAUTH and 554 — an auth failure and a relay-wide policy
refusal, neither of which is a fact about the recipient. Reusing it would mean one
stale SMTP password suppressing every address the worker touched, silently. The
classifier reads the RFC 3463 enhanced status first, falls back to a phrase match
only past a veto list and only for 550/551/553, and does not suppress anything it
is unsure about.

Scope is engagement rules only: resets, invites, verification and the contact form
still attempt, matching the posture passwordReset.controller.js already stated.

Found on the live rig, against a real MariaDB and a real SMTP conversation: a hard
bounce was being recorded as `failed`, so the Send Log's "Bounced" filter — a
status `engagement_sends` has carried since §4.5 — matched nothing and always
would have. It is now its own outcome; the outbox row stays `failed`, since that
ENUM has no `bounced` and a bounced row is one that finished unsuccessfully.

`address_masked` is this phase's one addition to §4.5's DDL. A hash-only table
cannot be operated — an operator cannot tell three typos from a whole domain
refusing mail — and the domain survives while the local part is destroyed, so the
column can never be read back as an address book.

- schema: `engagement_suppressions` (+ `address_masked`, `created_by`)
- `GET/POST/DELETE /api/v1/admin/engagement/suppressions`, and Admin → Engagement
  → Suppressions, the only way out of the list
- `sendNotification` returns `smtp: { code, responseCode, response }`
- 26 new tests; swagger, routes manifest and guards regenerated

Docs: RunicGateway/docs#191.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 10:47:34 -05:00
39 changed files with 2879 additions and 93 deletions

View File

@@ -47,6 +47,7 @@ import EngagementAudiences from './routes/admin/views/EngagementAudiences.jsx'
import EngagementTemplates from './routes/admin/views/EngagementTemplates.jsx'
import EngagementTriggers from './routes/admin/views/EngagementTriggers.jsx'
import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx'
import EngagementSuppressions from './routes/admin/views/EngagementSuppressions.jsx'
import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx'
@@ -208,6 +209,7 @@ export default function App() {
<Route path="templates" element={<EngagementTemplates />} />
<Route path="triggers" element={<EngagementTriggers />} />
<Route path="sends" element={<EngagementSendLog />} />
<Route path="suppressions" element={<EngagementSuppressions />} />
</Route>
<Route path="account" element={<AccountAdmin />} />
{/* Staff have an inbox and channel preferences like anyone else —

View File

@@ -434,6 +434,25 @@ export const api = {
return req(`/admin/engagement/sends${withQs(qs.toString())}`)
},
// Suppressions (Phase 9). `unsuppressAddress` sends the address in the BODY
// of a DELETE rather than in the path, and that is not style: a path
// parameter lands in the access log, the browser history and every proxy in
// front of the deployment, and this one is a real person's address. The list
// never returns a hash to use instead.
listEngagementSuppressions: ({ limit, offset, reason, channel, search } = {}) => {
const qs = new URLSearchParams()
if (limit) qs.set('limit', String(limit))
if (offset) qs.set('offset', String(offset))
if (reason) qs.set('reason', reason)
if (channel) qs.set('channel', channel)
if (search) qs.set('search', search)
return req(`/admin/engagement/suppressions${withQs(qs.toString())}`)
},
suppressAddress: (address, detail) =>
req('/admin/engagement/suppressions', { method: 'POST', body: { address, detail } }),
unsuppressAddress: (address, channel) =>
req('/admin/engagement/suppressions', { method: 'DELETE', body: { address, channel } }),
// Teams (docs/website/TEAMS.md §2.11). Three of these mean something
// different depending on who calls them: for a moderator, unhide and
// setTeamDisplayName file a request and the response says `pending: true`.

View File

@@ -11,6 +11,11 @@
// 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.8.0 - the ceiling lattice gains `admin` (ENGAGEMENT.md Phase 11). Nothing on
// this half changed: a ceiling is declared on the server's `api` and enforced
// there, and the admin screens that render one read the vocabulary from
// `GET /admin/engagement/triggers` rather than holding a copy. This file bumps
// anyway, for the reason at the top - the two halves state ONE version.
// 1.7.0 — the engagement contract (docs/website/ENGAGEMENT.md Phase 2). Nothing
// on this half changed: every member the version adds is on the server's `api`
// and `ctx` (registerEventTriggers, registerAudiences, ctx.events.emit,
@@ -53,4 +58,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.7.0'
export const MODULE_API_VERSION = '1.8.0'

View File

@@ -98,9 +98,9 @@ export const NAV = [
},
{
// Its own top-level group (ENGAGEMENT.md §7.1 Q4), not a section of
// Settings. Settings is already one long page of sections, and these five
// screens are two editors, a catalog and a paged table, none of which is a
// settings section. Email Delivery stays under Settings: configuring a
// Settings. Settings is already one long page of sections, and these six
// screens are two editors, a catalog and two paged tables, none of which is
// a settings section. Email Delivery stays under Settings: configuring a
// transport is not the same job as deciding who gets mail.
title: 'Engagement',
items: [
@@ -109,6 +109,10 @@ export const NAV = [
{ to: '/admin/engagement/templates', label: 'Templates', icon: IconTemplate, roles: ['admin'] },
{ to: '/admin/engagement/triggers', label: 'Triggers', icon: IconSpark, roles: ['admin'] },
{ to: '/admin/engagement/sends', label: 'Send Log', icon: IconLog, roles: ['admin'] },
// Beside the Send Log rather than inside it (Phase 9): the log answers
// "did that message go out", and this answers "why is this person not
// getting any" - and it is the only screen that can lift a suppression.
{ to: '/admin/engagement/suppressions', label: 'Suppressions', icon: IconLog, roles: ['admin'] },
],
},
{
@@ -190,6 +194,7 @@ const TITLES = {
'/admin/engagement/audiences': 'Engagement Audiences',
'/admin/engagement/templates': 'Message Templates',
'/admin/engagement/triggers': 'Triggers',
'/admin/engagement/suppressions': 'Suppressions',
'/admin/engagement/sends': 'Send Log',
}

View File

@@ -494,6 +494,15 @@ function RuleEditor({ catalog, segments, rule, onSaved, onCancel }) {
// It reads the RULES rather than a flag, so it disappears the moment one is
// switched on and comes back if every one is switched off again. A deployment
// that deleted them all sees nothing, which is right: they made that choice.
//
// **Phase 11 added a second notice of exactly the same shape, for news**
// (ENGAGEMENT.md §7.1 Q9). Publishing a news post used to tickle every subscriber
// directly, and that call is now an emit through the engine, so news push stops
// on upgrade until the seeded `news.post` rule is switched on. Two notices rather
// than one generalised "some rules are off" banner, deliberately: each names a
// capability that USED to work without configuration and now does not, which is
// a different statement from "you have a disabled rule" — and a rule an operator
// created and disabled themselves must never produce a warning.
const TEAM_TRIGGERS = [
'team.forum.post',
'team.announcement',
@@ -501,11 +510,32 @@ const TEAM_TRIGGERS = [
'team.leadership.changed',
]
function teamRulesAllOff(rules) {
const team = rules.filter((r) => TEAM_TRIGGERS.includes(r.triggerId || r.trigger_id))
return team.length > 0 && team.every((r) => !r.enabled)
const NEWS_TRIGGERS = ['news.post']
// One style for both notices, so the pair reads as one kind of message rather
// than two that happen to look alike.
const NOTICE_STYLE = {
fontSize: '0.85rem',
borderRadius: 8,
padding: '10px 12px',
marginBottom: 16,
border: '1px solid #7a6440',
color: '#e0b070',
}
const triggerOf = (rule) => rule.triggerId || rule.trigger_id
// True only when rules for these triggers EXIST and every one of them is off.
// Zero matching rules means the operator deleted them, which is a choice, not a
// regression to warn about.
function allOff(rules, triggers) {
const group = rules.filter((r) => triggers.includes(triggerOf(r)))
return group.length > 0 && group.every((r) => !r.enabled)
}
const teamRulesAllOff = (rules) => allOff(rules, TEAM_TRIGGERS)
const newsRulesAllOff = (rules) => allOff(rules, NEWS_TRIGGERS)
export default function EngagementRules() {
const [catalog, setCatalog] = useState(null)
const [segments, setSegments] = useState([])
@@ -583,13 +613,7 @@ export default function EngagementRules() {
return (
<section>
{teamRulesAllOff(rules) && (
<div
className="sans"
style={{
fontSize: '0.85rem', borderRadius: 8, padding: '10px 12px', marginBottom: 16,
border: '1px solid #7a6440', color: '#e0b070',
}}
>
<div className="sans" style={NOTICE_STYLE}>
<strong>Team notification emails are off.</strong> They used to be sent automatically; they
are now rules, and the four below arrived switched off so that nothing starts mailing on its
own. Switch on the ones this deployment wants — per-member preferences and per-Team mutes
@@ -597,6 +621,16 @@ export default function EngagementRules() {
</div>
)}
{newsRulesAllOff(rules) && (
<div className="sans" style={NOTICE_STYLE}>
<strong>News notifications are off.</strong> Publishing a news post used to send a push
notification to everyone subscribed to it. That is now the “News posts” rule below, and it
arrived switched off for the same reason the Team rules did. Switch it on to resume news
push — it also carries email and the in-app inbox, each still subject to each person’s own
preferences. The in-game town crier and the Discord announcement are unaffected either way.
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 640 }}>
A rule turns an event into mail: which event, who hears about it, on which channels, and how

View File

@@ -0,0 +1,259 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// Admin → Engagement → Suppressions (ENGAGEMENT.md §4.5 gap G16, Phase 9).
//
// **This screen is the only way out of the suppression list**, which is the whole
// reason it exists rather than the list living as a filter on the Send Log. A
// hard bounce is written by a background worker with no human in the loop, so
// without a lift button a mistyped-then-corrected mailbox is silenced for good
// and nobody ever finds out why that person stopped hearing from the deployment.
//
// **Addresses are shown masked, and the mask is deliberate on both ends.** The
// table holds a sha256 and an `address_masked` — `d***@example.com` — and the
// route never returns the hash, for the same reason the Send Log strips it: a
// digest of every address on the deployment, handed to a browser, is an offline
// dictionary attack waiting to be run. The domain survives because the signal an
// operator is actually hunting is domain-shaped ("everything to this company is
// bouncing" is a different problem from three people mistyping their own
// address), and the local part is destroyed rather than shortened so the list can
// never be read back as an address book.
//
// The consequence to keep in mind while reading this file: **lifting a
// suppression needs the WHOLE address typed in**, because the screen genuinely
// does not have it. That is not a rough edge to be smoothed later — it is the
// privacy design working, and the confirm dialog says so.
const REASON_LABEL = {
bounce: 'Hard bounce',
complaint: 'Marked as spam',
manual: 'Added by an admin',
unverified: 'Unverified',
}
const REASON_HELP = {
bounce: 'The receiving server said this mailbox does not exist.',
complaint: 'The recipient reported a message as spam.',
manual: 'Somebody here added it — usually a bounce reported another way.',
unverified: 'Reserved: the verification gate excludes these before a send is queued.',
}
const PAGE = 50
export default function EngagementSuppressions() {
const [rows, setRows] = useState([])
const [total, setTotal] = useState(0)
const [byReason, setByReason] = useState({})
const [offset, setOffset] = useState(0)
const [reason, setReason] = useState('')
const [search, setSearch] = useState('')
// Debounced separately from `search` so typing a domain does not fire a request
// per keystroke; `search` is what the input shows, `applied` is what was asked.
const [applied, setApplied] = useState('')
const [adding, setAdding] = useState('')
const [note, setNote] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const load = useCallback(async (nextOffset, nextReason, nextSearch) => {
const result = await api.admin.listEngagementSuppressions({
limit: PAGE,
offset: nextOffset,
reason: nextReason || undefined,
search: nextSearch || undefined,
})
setRows(result.suppressions || [])
setTotal(result.total || 0)
setByReason(result.byReason || {})
}, [])
useEffect(() => {
const t = setTimeout(() => { setOffset(0); setApplied(search.trim()) }, 300)
return () => clearTimeout(t)
}, [search])
const refresh = useCallback(async () => {
setLoading(true)
try {
await load(offset, reason, applied)
setError(null)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [load, offset, reason, applied])
useEffect(() => { refresh() }, [refresh])
async function addByHand(e) {
e.preventDefault()
const address = adding.trim()
if (!address) return
setNote(null)
try {
const result = await api.admin.suppressAddress(address)
// `created: false` is not a failure — the operator asked for the address to
// be suppressed and it is. Saying so plainly beats an error dialog for an
// outcome that is exactly what was wanted.
setNote(result.created
? `${result.address} will no longer be mailed.`
: `${result.address} was already suppressed.`)
setAdding('')
await refresh()
} catch (err) {
setNote(err.message)
}
}
async function lift() {
// The address cannot come from the row — the screen has only the mask. Asking
// for it in full is the cost of not storing it, and the prompt says why so it
// does not read as a missing feature.
const address = window.prompt(
'Type the full address to let it be mailed again.\n\n'
+ 'Suppressed addresses are stored one-way, so this screen never has the address itself.',
)
if (!address || !address.trim()) return
setNote(null)
try {
await api.admin.unsuppressAddress(address.trim())
setNote(`${address.trim()} can be mailed again.`)
await refresh()
} catch (err) {
setNote(err.message)
}
}
if (loading && rows.length === 0 && !applied && !reason) return <Loading />
if (error) return <ErrorState message={error} />
const to = Math.min(offset + PAGE, total)
const summary = Object.entries(byReason).filter(([, n]) => n > 0)
return (
<section>
<p className="sans" style={{ margin: '0 0 16px', fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 620 }}>
Addresses this deployment has stopped mailing. Engagement rules skip them; password resets,
invites and verification mails still go out, because those are asked for by the person
themselves. Addresses are stored one-way and shown masked.
</p>
{summary.length > 0 && (
<div className="panel-flat" style={{ display: 'flex', gap: 24, flexWrap: 'wrap', padding: '12px 16px', marginBottom: 16 }}>
{summary.map(([r, n]) => (
<div key={r}>
<div className="sans" style={{ fontSize: '1.1rem', fontWeight: 600 }}>{n}</div>
<div className="sans dim" style={{ fontSize: '0.76rem' }} title={REASON_HELP[r] || ''}>
{REASON_LABEL[r] || r}
</div>
</div>
))}
</div>
)}
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap', marginBottom: 16 }}>
<label style={{ flex: '1 1 220px' }}>
<span className="field-label">Search</span>
<input
className="input"
value={search}
placeholder="a domain, or part of one"
onChange={(e) => setSearch(e.target.value)}
/>
</label>
<label>
<span className="field-label">Reason</span>
<select className="select" value={reason} onChange={(e) => { setOffset(0); setReason(e.target.value) }}>
<option value="">Any</option>
{Object.keys(REASON_LABEL).map((r) => (
<option key={r} value={r}>{REASON_LABEL[r]}</option>
))}
</select>
</label>
<form onSubmit={addByHand} style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flex: '1 1 280px' }}>
<label style={{ flex: 1 }}>
<span className="field-label">Suppress an address</span>
<input
className="input"
type="email"
value={adding}
placeholder="someone@example.com"
onChange={(e) => setAdding(e.target.value)}
/>
</label>
<button type="submit" className="pill" style={{ fontSize: '0.74rem' }} disabled={!adding.trim()}>
Suppress
</button>
</form>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} onClick={lift}>
Lift a suppression
</button>
</div>
{note && (
<p className="sans" style={{ fontSize: '0.82rem', margin: '0 0 14px' }}>{note}</p>
)}
{total === 0 ? (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
{reason || applied ? 'Nothing matches that filter.' : 'No addresses are suppressed.'}
</p>
) : (
<>
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Address</th>
<th className="adm-th">Reason</th>
<th className="adm-th">Detail</th>
<th className="adm-th">Channel</th>
<th className="adm-th">Since</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={`${r.channel}:${r.address_masked}:${r.created_at}`}>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
{r.address_masked
? <code style={{ fontSize: '0.8rem' }}>{r.address_masked}</code>
: <span className="dim">not recorded</span>}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }} title={REASON_HELP[r.reason] || ''}>
{REASON_LABEL[r.reason] || r.reason}
</td>
<td className="adm-td" style={{ fontSize: '0.8rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
{r.detail || ''}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>{r.channel}</td>
<td className="adm-td" style={{ whiteSpace: 'nowrap', fontSize: '0.8rem' }}>
{new Date(r.created_at).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
<span className="sans dim" style={{ fontSize: '0.82rem' }}>
{offset + 1}–{to} of {total}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE))}>
Newer
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
disabled={to >= total} onClick={() => setOffset(offset + PAGE)}>
Older
</button>
</div>
</div>
</>
)}
</section>
)
}

View File

@@ -24,6 +24,7 @@ const CEILING_NOTE = {
members: 'only members of the thing it is about',
subscribers: 'only people who opted in',
staff: 'only staff',
admin: 'only administrators',
authenticated: 'any signed-in account',
everyone: 'anyone',
}

View File

@@ -1994,3 +1994,45 @@ CREATE TABLE IF NOT EXISTS user_notifications (
-- notification this deployment has ever written.
INDEX idx_un_prune (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Deliverability: suppression and bounces (ENGAGEMENT.md §4.5 G16 — Phase 9) ──
-- The addresses this deployment has stopped mailing, and why.
--
-- **Keyed on the ADDRESS, not the user** (§4.5), and after Phase 1b that is a
-- deliberate choice rather than a workaround for a missing unique index. Two
-- accounts can no longer share an address, but a bounce arrives as an ADDRESS —
-- it does not know which account was behind it, and it stays true after the
-- account that held it changed its address or was deleted. Keying on the user
-- would forget a dead mailbox the moment anybody moved.
--
-- `address_masked` is Phase 9's one addition to §4.5's DDL, and it exists because
-- the hash-only table cannot be operated. An operator looking at a screen of
-- sha256 digests cannot tell whether the list is three typos or a whole domain
-- refusing mail, and un-suppressing somebody who fixed their mailbox is the one
-- action this table has to support. `d***@example.com` is enough to act on and to
-- see a domain-wide pattern in, and — the reason it is safe — the local part is
-- destroyed rather than shortened, so the column is not an address book and
-- cannot be turned back into one. It is NULLable because a row written from a
-- correlation that only ever held a hash has nothing to mask.
--
-- **`reason` is not a synonym for "the send failed".** `mailer.PERMANENT_CODES`
-- classifies a failure as not-worth-retrying, and that set contains EAUTH and 554
-- — an authentication failure and a relay-wide policy refusal, neither of which
-- is a fact about the recipient. Writing a suppression on every terminal failure
-- would mean one wrong SMTP password suppresses every address the worker touches
-- before anyone notices. Only recipient-scoped evidence reaches this table; see
-- `src/engagement/bounceClassify.js`.
CREATE TABLE IF NOT EXISTS engagement_suppressions (
address_hash CHAR(64) NOT NULL PRIMARY KEY, -- sha256 of the lowercased address
address_masked VARCHAR(190) NULL, -- d***@example.com; never the local part
channel VARCHAR(32) NOT NULL DEFAULT 'email',
reason ENUM('bounce','complaint','manual','unverified') NOT NULL,
detail VARCHAR(500) NULL,
created_by INT NULL, -- the admin, for a manual row; NULL for automatic
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_engsup_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
-- The screen's two orderings: newest first, and filtered by reason.
INDEX idx_engsup_created (created_at),
INDEX idx_engsup_reason (reason, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -5,7 +5,7 @@ const wikiDb = require('../src/model/wiki/wiki.db')
const users = require('../src/model/users/users.model')
const { ensureSchema, close } = require('../src/utils/db')
const { seedTemplates } = require('../src/engagement/templates')
const { seedTeamRules } = require('../src/engagement/coreRules')
const { seedCoreRules } = require('../src/engagement/coreRules')
const brand = require('../src/config/brand')
const log = require('../src/utils/logger')('seed')
@@ -82,10 +82,13 @@ async function seedDefaults() {
// failed to seed costs the shipped default, which `renderByKey` falls back to
// anyway, and must not stop a boot.
await seedTemplates()
// The four Team rules, seeded ONCE and all disabled (ENGAGEMENT.md Phase 6).
// Guarded by a settings key rather than re-ensured, so a rule an operator
// deleted stays deleted and one they enabled stays enabled.
await seedTeamRules()
// Core's five rules — the four Team ones (Phase 6) and news (Phase 11) —
// seeded ONCE and all disabled. Each GROUP carries its own settings-key guard
// rather than re-ensured, so a rule an operator deleted stays deleted and one
// they enabled stays enabled; and so the news rule reaches the deployments that
// were already stamped for Teams, which are exactly the ones that lose their
// raw news push to the engine (ENGAGEMENT.md §7.1 Q9).
await seedCoreRules()
log.info('settings and wiki defaults ensured')
}

View File

@@ -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.7.0",
"moduleApiVersion": "1.8.0",
"triggers": [
{
"id": "news.post",
@@ -38,8 +38,8 @@
"name": "postUrl",
"type": "url",
"required": true,
"example": "/news/five-on-friday-yew-invasion",
"description": "Site-relative path to the post."
"example": "/site/news",
"description": "Site-relative path to the post. The news list today — the site has no per-post route."
}
]
},

View File

@@ -293,6 +293,33 @@
"requireAuth"
]
},
{
"method": "DELETE",
"path": "/api/v1/admin/engagement/suppressions",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/suppressions",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/engagement/suppressions",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/templates",

View File

@@ -129,6 +129,18 @@
"method": "GET",
"path": "/api/v1/admin/engagement/sends"
},
{
"method": "DELETE",
"path": "/api/v1/admin/engagement/suppressions"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/suppressions"
},
{
"method": "POST",
"path": "/api/v1/admin/engagement/suppressions"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/templates"

View File

@@ -49,8 +49,15 @@ const TRIGGERS = [
description: 'A plain-text summary, already stripped of markup.' },
{ name: 'category', type: 'string', required: false, example: 'Five on Friday',
description: 'The post category, when it has one.' },
{ name: 'postUrl', type: 'url', required: true, example: '/news/five-on-friday-yew-invasion',
description: 'Site-relative path to the post.' },
// **`/site/news`, the LIST, and not a per-post path.** The example said
// `/news/<slug>` when this was declared with no caller; Phase 11 gave it
// one and the path turned out not to exist — `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. An `example` is what
// the template editor previews and test-sends with (§4.3 property 3), so an
// example naming a 404 is a preview that looks right and a mail that is not.
{ name: 'postUrl', type: 'url', required: true, example: '/site/news',
description: 'Site-relative path to the post. The news list today — the site has no per-post route.' },
],
},

View File

@@ -1,7 +1,7 @@
// ── Resolving a rule's audience to recipients ──────────────────────────────
//
// ENGAGEMENT.md §5.1a / §4.5, Phase 4a. A rule names an audience two ways and
// only ever one at a time: a **plain ceiling name** (`owner`, `staff`,
// only ever one at a time: a **plain ceiling name** (`owner`, `staff`, `admin`,
// `subscribers`, `authenticated`, `everyone`) resolved from core's own tables, or
// an **`audience_segment_id`** pointing at an operator-composed tree of
// module-declared audiences (segments.js). This file turns either into user ids.
@@ -88,9 +88,14 @@ async function resolveForRule(rule, event) {
}
}
case 'staff':
case 'admin':
// Both role-gated, and resolved through the ONE query rather than two.
// `ceilings.ROLE_CEILINGS` holds which roles each names, so the day a
// third is added the resolver does not need a third case — and, more to
// the point, cannot get one of them wrong while the others stay right.
return {
userIds: await recipients.staff(ceilings.STAFF_CEILING_ROLES),
ceiling: 'staff',
userIds: await recipients.staff(ceilings.ROLE_CEILINGS[rule.audience].roles),
ceiling: rule.audience,
dormant: false,
reason: null,
}

View File

@@ -0,0 +1,216 @@
// ── Which send failures are facts about the RECIPIENT ───────────────────────
//
// ENGAGEMENT.md Phase 9. The suppression list's whole value is that an address on
// it is genuinely undeliverable; the moment it fills with addresses that were
// fine, an operator learns to ignore it and it may as well not exist. This file
// is the one place that judgement is made.
//
// **It is deliberately NOT `mailer.PERMANENT_CODES`, and reusing that set would
// have been a mass-suppression bug.** That set answers "is retrying pointless?"
// and holds `EAUTH` and `554` alongside `550` — an authentication failure and a
// relay-wide policy refusal. Both are permanent and neither says anything about
// the person: one wrong SMTP password would suppress every address the outbox
// worker touched before anybody noticed the mail had stopped. "Do not retry" and
// "this mailbox does not exist" are different questions, and this file only
// answers the second.
//
// **The primary signal is the enhanced status code (RFC 3463), not the reply
// code.** `550` alone is the catch-all every refusal arrives as; `5.1.1` means
// one specific thing — no such mailbox. Every relay worth configuring emits an
// enhanced code, so it is read first and, when present, decides on its own.
//
// **The fallback is narrow on purpose.** Without an enhanced code a phrase match
// is all that is left, and phrase matching is how a classifier quietly starts
// suppressing everything. So it applies only after the reply code has already
// narrowed the failure to the recipient address — 550, 551 and 553 are RFC 5321's
// recipient-address codes — and only for phrases that cannot mean anything else,
// with a veto list checked first. `552` (storage exceeded) and `554` (transaction
// failed) are excluded from even that: a full mailbox gets emptied, and a generic
// transaction failure is generic.
//
// Anything this file is unsure about is NOT suppressed. The cost of a false
// negative is mailing a dead address again next month; the cost of a false
// positive is a person who silently stops hearing from the deployment and has no
// way to find out.
// RFC 3463 subject.detail pairs that mean "this address will not accept mail,
// today or ever". Kept as strings because `5.1.10` and `5.1.1` are different
// codes and numeric parsing loses that.
const PERMANENT_RECIPIENT = new Set([
'1.1', // bad destination mailbox address — no such user
'1.2', // bad destination system address — the domain does not take mail
'1.3', // bad destination mailbox address syntax
'1.6', // mailbox has moved, no forwarding address
'1.10', // recipient address has a null MX (RFC 7505)
'2.1', // mailbox disabled, not accepting messages
])
// Enhanced subjects that are permanent but are NOT about the recipient. Listed
// rather than merely omitted, because each is a plausible-looking 5.x.y that a
// later edit would otherwise be tempted to add:
// 2.2 — mailbox full. Permanent-coded by some relays, emptied by every user.
// 7.x — policy. Our sending reputation, our SPF, our content; the recipient is
// the one party it is not about.
// 3.x — the destination MAIL SYSTEM is full or refusing. Not the mailbox.
// 5.x — protocol failure. A bug at one end or the other.
const NEVER_RECIPIENT_SUBJECTS = new Set(['3', '5', '7'])
// RFC 5321 reply codes that name the recipient address specifically. 554 is
// absent deliberately: "transaction failed" is what a relay reaches for when it
// does not want to say why, and it is the commonest shape of a content or policy
// rejection.
const RECIPIENT_REPLY_CODES = new Set([550, 551, 553])
// Phrases that only ever mean "no such mailbox", checked only once a reply code
// above has established the failure is about the address. Each is a substring of
// a real refusal from a widely deployed MTA (Postfix, Exim, Exchange, Google,
// Microsoft 365).
const NO_SUCH_MAILBOX = [
'user unknown',
'unknown user',
'no such user',
'no such recipient',
'unknown recipient',
'invalid recipient',
'recipient address rejected',
'recipient not found',
'address does not exist',
'does not exist',
'mailbox unavailable',
'mailbox not found',
'no mailbox',
'user does not exist',
'address rejected',
]
// Phrases that appear alongside the ones above and mean the opposite, checked
// FIRST. "Mailbox unavailable" is a substring of the sentence a relay sends when
// a mailbox is merely full, so a substring match with no veto list would read a
// temporary condition as a dead address.
const NOT_A_DEAD_MAILBOX = [
'full',
'quota',
'storage',
'temporar',
'try again',
'greylist',
'rate limit',
'too many',
'spam',
'blocked',
'blacklist',
'blocklist',
'reputation',
'policy',
'authentication',
'not authorized',
]
/**
* The enhanced status code in an SMTP response, as `{ class, subject, detail }`,
* or null.
*
* Anchored to the start of the line rather than searched for anywhere in it: a
* bounce that quotes another server's answer ("...said: 550 5.1.1...") contains
* two, and the one that matters is the one this relay just gave us. A free search
* finds whichever comes first, which is not the same thing.
*/
function parseEnhanced(response) {
if (!response) return null
const m = /^\s*(\d{3})[\s-]+(\d)\.(\d{1,3})\.(\d{1,3})\b/.exec(String(response))
if (!m) return null
return { class: m[2], subject: m[3], detail: m[4] }
}
/** The three-digit reply code, off the error object or out of the response text. */
function replyCode(err) {
const direct = Number(err && err.responseCode)
if (Number.isInteger(direct) && direct >= 400 && direct <= 599) return direct
const m = /^\s*(\d{3})\b/.exec(String((err && err.response) || ''))
return m ? Number(m[1]) : null
}
const lower = (s) => String(s || '').toLowerCase()
/**
* Should this send failure suppress the address?
*
* @param {object} err the error a transport's send threw, or an object carrying
* the `responseCode` / `response` / `code` lifted off one
* @returns {{ suppress: boolean, reason: string, evidence: string|null }}
*
* `reason` is populated on a refusal too, and that is not decoration: it becomes
* the send log's `detail`, so "not suppressed: 554 does not name the recipient
* address" is the line that stops somebody re-deriving this decision from an
* unexplained non-event six months from now.
*/
function classify(err) {
const e = err || {}
const response = e.response || e.message || ''
const enhanced = parseEnhanced(response)
const code = replyCode(e)
// No reply code at all means the failure happened before or outside the SMTP
// transaction: the connection, the credentials, the socket. Never the
// recipient. `EAUTH` lands here, which is the whole reason this file exists.
if (!code) {
return {
suppress: false,
reason: `no SMTP reply code (${e.code || 'transport failure'}); not a recipient failure`,
evidence: null,
}
}
if (code < 500) {
return { suppress: false, reason: `${code} is a temporary failure`, evidence: null }
}
if (enhanced) {
const pair = `${enhanced.subject}.${enhanced.detail}`
if (enhanced.class !== '5') {
return { suppress: false, reason: `enhanced status ${enhanced.class}.${pair} is not permanent`, evidence: null }
}
if (PERMANENT_RECIPIENT.has(pair)) {
return { suppress: true, reason: 'bounce', evidence: `5.${pair}` }
}
if (NEVER_RECIPIENT_SUBJECTS.has(enhanced.subject)) {
return {
suppress: false,
reason: `5.${pair} is about the server or our standing with it, not the address`,
evidence: `5.${pair}`,
}
}
// A permanent 5.x.y this file has no opinion on. Unknown means no.
return {
suppress: false,
reason: `5.${pair} is not a known recipient failure`,
evidence: `5.${pair}`,
}
}
// No enhanced code: the narrow fallback.
if (!RECIPIENT_REPLY_CODES.has(code)) {
return { suppress: false, reason: `${code} does not name the recipient address`, evidence: null }
}
const text = lower(response)
const veto = NOT_A_DEAD_MAILBOX.find((p) => text.includes(p))
if (veto) {
return { suppress: false, reason: `${code}, but the response says "${veto}"`, evidence: null }
}
const hit = NO_SUCH_MAILBOX.find((p) => text.includes(p))
if (hit) {
return { suppress: true, reason: 'bounce', evidence: `${code} "${hit}"` }
}
return { suppress: false, reason: `${code} with no enhanced status and no recognised reason`, evidence: null }
}
module.exports = {
classify,
parseEnhanced,
replyCode,
PERMANENT_RECIPIENT,
NEVER_RECIPIENT_SUBJECTS,
RECIPIENT_REPLY_CODES,
NO_SUCH_MAILBOX,
NOT_A_DEAD_MAILBOX,
}

View File

@@ -53,6 +53,24 @@ const isMode = (value) => MODES.includes(value)
* without one is declared but not yet deliverable, which is exactly what
* `inapp` is until Phase 7; the worker finishes such a row `failed` and
* says so in the send log rather than pretending it was sent.
* @param {(userIds: number[]) => Promise<{userIds: number[], excluded: object}>} [def.eligible]
* Phase 9. Narrow an already-resolved audience to the users this channel
* may write an outbox row for, and say how many it dropped and why.
*
* **It exists so the engine can stay channel-agnostic.** The verification
* gate is an email fact — an unverified address is a reason not to mail
* somebody and no reason at all not to put an item in their inbox — and a
* rule may name both channels. Filtering the shared audience before the
* per-channel loop would have silenced the wrong sink; an `if (channel ===
* 'email')` in `engine.js` would have put a channel's rule inside the
* generic engine. This is the seam that is neither.
*
* Distinct from `deliver`'s refusals on purpose: this runs at ENQUEUE and
* is for standing properties of a user (is this address verified), which
* are stable across a delay window and are worth not writing a row for.
* A suppression is not one of those — it can appear between the enqueue
* and the send — so it is checked in `deliver`, where it produces a
* `suppressed` row in the send log the acceptance criterion asks for.
*/
function registerDeliveryChannel(def) {
if (!def || typeof def !== 'object') throw new Error('registerDeliveryChannel: definition required')
@@ -81,7 +99,7 @@ function registerDeliveryChannel(def) {
// silently never delivers — the failure Phase 3 deferred the whole behavioural
// half to avoid freezing, and the one the worker's "no delivery implementation
// yet" branch would report as if it were by design.
for (const fn of ['addressFor', 'deliver']) {
for (const fn of ['addressFor', 'deliver', 'eligible']) {
if (def[fn] !== undefined && typeof def[fn] !== 'function') {
throw new Error(`registerDeliveryChannel(${id}): ${fn} must be a function`)
}
@@ -96,6 +114,7 @@ function registerDeliveryChannel(def) {
supportsDigest,
addressFor: def.addressFor,
deliver: def.deliver,
eligible: def.eligible,
})
return id
}
@@ -103,13 +122,14 @@ function registerDeliveryChannel(def) {
/**
* Every channel, in registration order. The preferences screen's column set.
*
* **Declarative fields only** — `addressFor` and `deliver` are stripped. This is
* what a route serializes, and a function on an object bound for `res.json` is a
* key that silently disappears rather than an error; keeping the boundary here
* means the API shape is decided in one place instead of by JSON.stringify.
* **Declarative fields only** — `addressFor`, `deliver` and `eligible` are
* stripped. This is what a route serializes, and a function on an object bound
* for `res.json` is a key that silently disappears rather than an error; keeping
* the boundary here means the API shape is decided in one place instead of by
* JSON.stringify.
*/
const all = () =>
[...channels.values()].map(({ addressFor, deliver, ...declared }) => ({ ...declared }))
[...channels.values()].map(({ addressFor, deliver, eligible, ...declared }) => ({ ...declared }))
/** Just the ids. */
const ids = () => [...channels.keys()]
@@ -137,6 +157,25 @@ const modesFor = (id) => {
/** Is `mode` a mode this channel accepts? The gate on every preference write. */
const acceptsMode = (id, mode) => modesFor(id).includes(mode)
/**
* Narrow an audience to the users this channel may enqueue for (Phase 9).
*
* The default for a channel that declares no `eligible` is "everyone the
* audience resolved to", which is what every channel but email does. It lives
* here rather than at each call site so the two consumers — the engine and the
* admin reach preview — cannot answer the question differently, which is exactly
* how a preview comes to promise a number the engine will not deliver.
*/
async function eligibleFor(id, userIds) {
const c = channels.get(id)
if (!c || typeof c.eligible !== 'function') return { userIds: userIds.slice(), excluded: {} }
const result = await c.eligible(userIds)
return {
userIds: (result && result.userIds) || [],
excluded: (result && result.excluded) || {},
}
}
// Test-only: the registry is module-level state.
function _reset() {
channels.clear()
@@ -153,5 +192,6 @@ module.exports = {
defaultMode,
modesFor,
acceptsMode,
eligibleFor,
_reset,
}

View File

@@ -66,6 +66,10 @@ const CHANNELS = [
// time (§4.2b), which is a different delivery path rather than a batched one.
addressFor: emailChannel.addressFor,
deliver: emailChannel.deliver,
// Phase 9: the only channel that declares one. The Phase 1b verification
// gate is an email fact, and this is the seam that keeps it out of the
// generic engine - see channels.js's `eligible` docs.
eligible: emailChannel.eligible,
},
{
id: 'inapp',

View File

@@ -1,4 +1,4 @@
// ── The four Team rules core ships, all of them OFF ────────────────────────
// ── The five rules core ships, all of them OFF ─────────────────────────────
//
// ENGAGEMENT.md Phase 6, decision 3. Before this phase the Team pipeline mailed
// people with no operator configuration at all: the code decided who was mailed
@@ -24,6 +24,17 @@
// that has seen this seed never sees it again — deleted rules stay deleted, and
// an enabled rule stays enabled rather than being reset to off.
// **Phase 11 added a fifth, for `news.post`, and it needed its OWN one-shot key
// rather than an entry in the list above.** The Team key is already stamped on
// every deployment that has booted since Phase 6, and the guard reads its
// presence — so appending to `RULES` would have seeded the news rule on fresh
// installs only, and on exactly the upgrades that need it, never. Those are the
// deployments where `pushDispatch.publish('news.post', …)` used to run and no
// longer does (§7.1 Q9): they would have lost news push with no rule to switch
// on and no way to tell why. One key per seed GROUP is the rule this establishes;
// a sixth rule for a new trigger takes a sixth key, and a rule added to an
// existing group is a rule that only fresh installs will ever see.
const rulesDb = require('../model/engagement/engagementRules.db')
const settingsDb = require('../model/settings/settings.db')
const log = require('../utils/logger')('engagement')
@@ -32,6 +43,9 @@ const log = require('../utils/logger')('engagement')
// the settings table can tell when it ran; only its presence is read.
const SEEDED_KEY = 'engagement_team_rules_seeded'
// Phase 11's, and separate for the reason above. Same shape, same semantics.
const NEWS_SEEDED_KEY = 'engagement_news_rule_seeded'
const RULES = [
{
trigger_id: 'team.forum.post',
@@ -98,20 +112,57 @@ const RULES = [
},
]
// Phase 11's one rule, in its own list so it can carry its own one-shot key.
const NEWS_RULES = [
{
trigger_id: 'news.post',
name: 'News posts',
// `subscribers`, which is the trigger's declared default and the population
// `pushDispatch.publish('news.post', …)` used to reach directly: users who
// opted into this id on at least one channel. Not `authenticated`, even
// though the trigger's ceiling permits it — a news post is worth telling
// people who asked to be told, and mailing the whole user table on every
// publish is how a notification feature earns a spam complaint.
audience: 'subscribers',
// **All three channels, unlike the Team rules' `email` alone**, and that is
// the continuity half of §7.1 Q9's answer. 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. In-app rides along
// because the inbox is the surface a tickle deep-links into (Phase 7).
channels: ['email', 'inapp', 'push'],
// The generic body plus the structural projection (§4.6.1 property 1):
// `news.post` declares its own `title` and `postUrl`, which the projection
// leaves exactly as emitted, so an unauthored mail already names the post and
// links it. `inapp.event` is the in-app renderer's; push carries no content
// by construction and needs no template.
template_keys: { email: 'notify.event', inapp: 'inapp.event', digest: 'notify.digest' },
// An hour, per USER — `news.post` declares no `subjectKey`, so the cooldown
// subject is the recipient. "Do not tell me about news more than once an
// hour" is the useful rule; keying it per post would make it a no-op, since
// every post is a new subject.
cooldown_seconds: 3600,
max_sends_per_hour: 1000,
},
]
/**
* Seed the four rules, once. Returns a small summary for the boot log.
* Seed one group of rules, once, under its own guard key.
*
* Never throws: it is on the boot path beside `seedTemplates`, and a rule that
* failed to seed costs an operator one visit to the "new rule" form, not a
* deployment.
*
* @param {string} key the one-shot settings guard for THIS group
* @param {object[]} rules
* @param {string} note what the boot log should say when it inserts
*/
async function seedTeamRules() {
async function seedGroup(key, rules, note) {
const summary = { inserted: 0, skipped: 0 }
try {
const seen = await settingsDb.get(SEEDED_KEY)
if (seen) return { ...summary, skipped: RULES.length }
const seen = await settingsDb.get(key)
if (seen) return { ...summary, skipped: rules.length }
for (const rule of RULES) {
for (const rule of rules) {
try {
await rulesDb.insert({
audience_segment_id: null,
@@ -133,17 +184,46 @@ async function seedTeamRules() {
// Stamped even on a partial run. Re-running would duplicate the rules that
// did insert, and a duplicate rule is two mails per event — a worse outcome
// than the one missing rule an operator can add from the screen.
await settingsDb.set(SEEDED_KEY, new Date().toISOString())
await settingsDb.set(key, new Date().toISOString())
if (summary.inserted) {
log.info('seeded Team engagement rules, all disabled', {
rules: summary.inserted,
note: 'Team email stays off until an operator enables one',
})
log.info('seeded engagement rules, all disabled', { rules: summary.inserted, note })
}
} catch (err) {
log.error('team rule seeding failed', { message: err.message })
log.error('rule seeding failed', { key, message: err.message })
}
return summary
}
module.exports = { seedTeamRules, RULES, SEEDED_KEY }
/** The four Team rules (Phase 6). */
const seedTeamRules = () =>
seedGroup(SEEDED_KEY, RULES, 'Team email stays off until an operator enables one')
/** The one news rule (Phase 11). */
const seedNewsRule = () =>
seedGroup(NEWS_SEEDED_KEY, NEWS_RULES, 'News notifications stay off until an operator enables this rule')
/**
* Both groups, which is what the boot path calls.
*
* Sequential rather than concurrent, and not for correctness — each group has its
* own guard key and its own rows — but so the boot log reads in a fixed order and
* a failure names one group rather than an interleaving of two.
*/
async function seedCoreRules() {
const team = await seedTeamRules()
const news = await seedNewsRule()
return {
inserted: team.inserted + news.inserted,
skipped: team.skipped + news.skipped,
}
}
module.exports = {
seedCoreRules,
seedTeamRules,
seedNewsRule,
RULES,
NEWS_RULES,
SEEDED_KEY,
NEWS_SEEDED_KEY,
}

View File

@@ -29,11 +29,12 @@
// `teamName` (a display string the cooldown keys on) and the scope is `team:12`.
// A Team renamed between the mail and the click must not orphan the link in it.
const crypto = require('crypto')
const rulesDb = require('../model/engagement/engagementRules.db')
const recipients = require('../model/engagement/engagementRecipients.db')
const templates = require('./templates')
const projection = require('./projection')
const suppressions = require('./suppressions')
const settings = require('../model/settings/settings.model')
const unsubscribeToken = require('../utils/unsubscribeToken')
const log = require('../utils/logger')('engagement')
@@ -54,12 +55,10 @@ const DEFAULT_TEMPLATE = 'notify.event'
const baseUrl = () => templates.baseUrl()
// Lower-cased first: a bounce reported for "Darrow@example.com" has to match the
// row written for "darrow@example.com", and a hash of two spellings is two
// different rows. The local part is technically case-sensitive per RFC 5321 and
// no relay anybody deploys treats it that way.
const hashAddress = (address) =>
crypto.createHash('sha256').update(String(address).trim().toLowerCase()).digest('hex')
// Re-exported rather than defined here since Phase 9: the send log's hash and
// the suppression list's key have to be the same function or a bounce never finds
// the row it belongs to. `suppressions.js` owns it, next to the masking.
const hashAddress = suppressions.hashAddress
/**
* The two unsubscribe URLs for one recipient of one scope, or nulls.
@@ -94,6 +93,52 @@ function unsubscribeUrls(userId, scopeKey) {
/** Where this channel would send to, or null. */
const addressFor = (userId) => recipients.addressFor(userId)
/**
* Narrow an audience to the users this channel may write an outbox row for
* (Phase 9, decision 4).
*
* **One gate, and it is the Phase 1b verification setting.** With
* `email_verification_required` on, a user whose address is unverified is
* excluded here rather than refused at delivery, and the org lead settled it that
* way for two reasons. It is a STANDING property — unlike a suppression, which
* can appear inside a `delay_seconds` window and therefore has to be re-checked
* at send time — so the outbox row would be written only to be thrown away. And a
* deployment that upgraded before verifying anybody has an audience that is
* almost entirely unverified: excluding at delivery would write a `suppressed`
* row per person per rule firing, which is a send log nobody can read.
*
* The count comes back so the admin reach preview can say "1,204 excluded:
* unverified" instead of quietly promising a number the engine will not deliver.
*
* **It fails OPEN, and the try/catch is load-bearing rather than defensive
* habit.** `settings.isEmailVerificationRequired` swallows its own errors and
* answers `off`, but `unverifiedAmong` does not, and an uncaught throw here does
* not fail one recipient — `applyRule` awaits this before the per-user loop, so
* it would abandon the whole rule for every channel it names. A database having
* a bad minute would become a rule that silently sent nothing, with a clean send
* log and nothing in the outbox to retry. Same direction as the suppression
* check, for the same reason: the recoverable mistake is mail going out, not mail
* silently stopping.
*/
async function eligible(userIds) {
const list = userIds || []
if (!list.length) return { userIds: [], excluded: {} }
try {
if (!(await settings.isEmailVerificationRequired())) {
return { userIds: list.slice(), excluded: {} }
}
const unverified = await recipients.unverifiedAmong(list)
if (!unverified.size) return { userIds: list.slice(), excluded: {} }
return {
userIds: list.filter((id) => !unverified.has(Number(id))),
excluded: { unverified: unverified.size },
}
} catch (err) {
log.error('verification gate could not be evaluated; not excluding anyone', { message: err.message })
return { userIds: list.slice(), excluded: {} }
}
}
/**
* Deliver one claimed outbox row.
*
@@ -130,11 +175,60 @@ async function deliver(row) {
log.debug('template variables had no value', { key, missing: rendered.missing })
}
// **The suppression check is HERE and not at enqueue** (Phase 9). An outbox
// row can sit through a `delay_seconds` grace window, and an address can hard
// bounce inside it — so the only check that can be correct is the one taken
// immediately before the transport call. It is also the check the acceptance
// criterion describes: a `suppressed` row in the send log, and no transport
// call at all.
const blocked = await suppressions.isSuppressed(to.address)
if (blocked) {
return {
ok: false,
suppressed: true,
detail: `address is suppressed (${blocked.reason})`,
addressHash: hashAddress(to.address),
}
}
const result = await mailer().sendNotification({ to: to.address, rendered, ...unsub })
// A failed send is where a hard bounce enters the system on SMTP, and the
// reason it is worth catching rather than waiting for an API provider: a
// single-recipient send refused at RCPT TO is a synchronous 5.1.1, which is
// the most valuable deliverability signal there is and it was already being
// thrown away. `considerFailure` is narrow — see bounceClassify.js — and its
// note goes into the log on BOTH outcomes, so "this failed and was not
// suppressed" says why.
if (result && !result.ok && result.smtp) {
const verdict = await suppressions.considerFailure({ address: to.address, error: result.smtp })
// A bounce is terminal by definition. Overriding `retry` matters because
// `PERMANENT_CODES` does not contain every code that can carry a 5.1.x, so
// without this a genuine dead mailbox could still be retried four more
// times — each one another refusal on our record with the relay.
if (verdict.suppressed) {
return {
ok: false,
retry: false,
// `engagement_sends.status` has carried 'bounced' since §4.5 and
// nothing wrote it until here, so the Send Log's "Bounced" filter
// matched nothing — the live rig is what showed that. It is a distinct
// status rather than a flavour of 'failed' because the two need
// different actions: a failure means look at the relay, and a bounce
// means that person's address is gone.
bounced: true,
transport: result.transport,
detail: `${result.detail} — ${verdict.note}`,
addressHash: hashAddress(to.address),
}
}
return { ...result, detail: `${result.detail} — ${verdict.note}`, addressHash: hashAddress(to.address) }
}
// The send log stores a sha256 of the address and never the address itself
// (schema.sql): enough to correlate a bounce in Phase 9, useless as a mailing
// list. Attached on every outcome, because a failure is exactly the row a
// bounce would need to be matched against.
// (schema.sql): enough to correlate a bounce, useless as a mailing list.
// Attached on every outcome, because a failure is exactly the row a bounce
// would need to be matched against.
return { ...result, addressHash: hashAddress(to.address) }
} catch (err) {
// See the header: a throw here would be retried as if it were the relay's
@@ -144,4 +238,4 @@ async function deliver(row) {
}
}
module.exports = { addressFor, deliver, unsubscribeUrls, hashAddress, DEFAULT_TEMPLATE }
module.exports = { addressFor, eligible, deliver, unsubscribeUrls, hashAddress, DEFAULT_TEMPLATE }

View File

@@ -120,7 +120,18 @@ async function subscribedTo(userIds, streamId, channel, scopeKey = null) {
* for tests; it is not read by the caller for control flow.
*/
async function applyRule(rule, event, now) {
const summary = { ruleId: rule.id, enqueued: 0, deduped: 0, cooled: 0, capped: 0, skipped: null }
const summary = {
ruleId: rule.id,
enqueued: 0,
deduped: 0,
cooled: 0,
capped: 0,
// Phase 9: people a CHANNEL refused to enqueue for, by reason. Counted
// separately from `cooled` and `capped` because those are the engine holding
// a message back and this is a channel saying it cannot carry one at all.
ineligible: {},
skipped: null,
}
if (!conditions.evaluate(rule.conditions, event.data)) {
summary.skipped = 'conditions'
@@ -176,7 +187,17 @@ async function applyRule(rule, event, now) {
const dueAt = new Date(now.getTime() + Math.max(0, rule.delay_seconds) * 1000)
for (const channel of live) {
const eligible = await subscribedTo(resolved.userIds, event.triggerId, channel, event.scopeKey)
// Phase 9, and it runs BEFORE the preference filter rather than after. Both
// orders reach the same recipients; this one costs one query against the
// narrower set only when the channel declares an `eligible` at all, and it
// means `summary.ineligible` counts people the CHANNEL cannot reach rather
// than people who happened to also be opted in. Channels that declare none —
// push and in-app — pass straight through.
const gated = await channels.eligibleFor(channel, resolved.userIds)
for (const [why, n] of Object.entries(gated.excluded)) {
summary.ineligible[why] = (summary.ineligible[why] || 0) + n
}
const eligible = await subscribedTo(gated.userIds, event.triggerId, channel, event.scopeKey)
for (const userId of eligible) {
if (budget <= 0) {
summary.capped += 1

View File

@@ -0,0 +1,160 @@
// ── The suppression list ───────────────────────────────────────────────────
//
// ENGAGEMENT.md G16, Phase 9. Addresses this deployment has stopped mailing,
// and the two questions asked of them: "may I send to this one?" at delivery
// time, and "why did this one stop?" on the admin screen.
//
// **Scope: the engagement email channel only** (Phase 9 decision 2). A password
// reset, an invite, a verification mail and the contact form are all
// user-INITIATED and still attempt, exactly as they still attempt to an
// unverified address (`passwordReset.controller.js`). The posture is the same one
// that file already states: a background system's opinion about an address must
// not be able to lock somebody out of their own account. One reset to a dead
// mailbox is not a reputation problem; a rule mailing three thousand people every
// week is, and that is what this list guards.
//
// **The table holds a hash and a mask, never an address.** The hash is what
// correlates a bounce back to an `engagement_sends` row (Phase 6 was already
// writing `address_hash` on every outcome for this). The mask —
// `d***@example.com` — is Phase 9's one addition to §4.5's DDL and exists because
// a screen of sha256 digests cannot be operated: an operator has to be able to
// see that a whole domain is refusing mail, and to find the person who fixed
// their mailbox and let them back in. The local part is DESTROYED rather than
// shortened, so the column cannot be turned back into an address book.
//
// **Nothing here writes a suppression from "the send failed".** What may write
// one is `bounceClassify.classify`, which is a much narrower question — see that
// file's header for why reusing `mailer.PERMANENT_CODES` would have suppressed
// every address the moment an SMTP password went stale.
const crypto = require('crypto')
const db = require('../model/engagement/engagementSuppressions.db')
const bounceClassify = require('./bounceClassify')
const log = require('../utils/logger')('engagement')
const REASONS = ['bounce', 'complaint', 'manual', 'unverified']
/**
* The key an address is stored under.
*
* Lower-cased first, and that matters more here than anywhere else in the
* subsystem: a bounce reported for `Darrow@example.com` has to find the row
* written for `darrow@example.com`, and a hash of two spellings is two rows that
* never meet. RFC 5321 says the local part is technically case-sensitive; no
* relay anybody deploys treats it that way.
*/
const hashAddress = (address) =>
crypto.createHash('sha256').update(String(address).trim().toLowerCase()).digest('hex')
/**
* `darrow@example.com` → `d***@example.com`. Null for anything that is not an
* address.
*
* The domain survives intact because domain-level patterns are the signal an
* operator is actually looking for — "everything to this company is bouncing" is
* a different problem from three people mistyping their own address, and only the
* domain distinguishes them.
*
* The first character of the local part survives only when there are at least
* three, which is not fussiness: for a two-letter local part, one revealed
* character plus the domain is most of the address.
*/
function maskAddress(address) {
const s = String(address || '').trim()
const at = s.lastIndexOf('@')
if (at <= 0 || at === s.length - 1) return null
const local = s.slice(0, at)
const domain = s.slice(at + 1).toLowerCase()
const head = local.length >= 3 ? local[0].toLowerCase() : ''
return `${head}***@${domain}`.slice(0, 190)
}
/** Is this address suppressed on this channel? */
async function isSuppressed(address, channel = 'email') {
if (!address) return null
try {
return await db.get(hashAddress(address), channel)
} catch (err) {
// Fail OPEN, and the direction is deliberate. A database that cannot answer
// "is this suppressed" must not stop the deployment's mail; the failure mode
// it would otherwise produce is total silence with a clean send log, which is
// exactly G22's shape. Mailing one dead address during an outage is the
// cheaper mistake.
log.error('suppression check failed; sending anyway', { message: err.message })
return null
}
}
/**
* Suppress an address. Returns true when this call created the row.
*
* `reason` is validated rather than trusted: it is an ENUM in the schema, so an
* unknown value is a 500 from the driver at the worst possible moment (inside a
* failure handler), and the callers include an admin route.
*/
async function suppress({ address, reason, detail = null, channel = 'email', createdBy = null }) {
if (!address) return false
if (!REASONS.includes(reason)) throw new Error(`suppress: unknown reason "${reason}"`)
const created = await db.add({
address_hash: hashAddress(address),
address_masked: maskAddress(address),
channel,
reason,
detail,
created_by: createdBy,
})
if (created) {
// Masked, never the address — the same rule every other log line in this
// subsystem follows. It is logged at all because an address dropping off the
// mailing list is the kind of change an operator finds out about weeks later
// otherwise.
log.info('address suppressed', { address: maskAddress(address), reason, channel })
}
return created
}
/** Un-suppress. Returns true when a row was removed. */
async function unsuppress(address, channel = 'email') {
if (!address) return false
const removed = await db.remove(hashAddress(address), channel)
if (removed) log.info('suppression lifted', { address: maskAddress(address), channel })
return removed
}
/**
* Consider a failed send for suppression, and say what was decided.
*
* The seam between a delivery failure and this list, and the only one — nothing
* else in the codebase writes a `bounce` row. Called from `emailChannel.deliver`
* with the error the transport threw.
*
* @returns {Promise<{suppressed: boolean, note: string}>} `note` goes into the
* send log's detail, on both outcomes.
*/
async function considerFailure({ address, error, channel = 'email' }) {
const verdict = bounceClassify.classify(error)
if (!verdict.suppress) {
return { suppressed: false, note: `not suppressed (${verdict.reason})` }
}
try {
const detail = verdict.evidence ? `hard bounce: ${verdict.evidence}` : 'hard bounce'
await suppress({ address, reason: 'bounce', detail, channel })
return { suppressed: true, note: detail }
} catch (err) {
// A failure to record the suppression must not change how the send itself is
// reported. The mail failed either way, and that is the row the log owes.
log.error('could not record a suppression', { message: err.message })
return { suppressed: false, note: `hard bounce, not recorded: ${err.message}` }
}
}
module.exports = {
hashAddress,
maskAddress,
isSuppressed,
suppress,
unsuppress,
considerFailure,
REASONS,
}

View File

@@ -132,11 +132,14 @@ const storedModes = async (userIds, streamId, channel) => {
* between the emit and the send is exactly the case this catches. The cost is one
* primary-key lookup on a path that is about to open an SMTP conversation.
*
* **It does not gate on `email_verified`.** Whether an unverified address may
* receive opt-in mail is §7.1 Q1's narrower half, and it is a Phase 9 decision
* with the suppression list in front of it; deciding it here by accident would
* mean every deployment that upgraded before verifying its users stopped mailing
* them.
* **It still does not gate on `email_verified`, and Phase 9 kept it that way.**
* §7.1 Q1's narrower half was settled by the org lead 2026-08-31: the gate
* excludes an unverified user at ENQUEUE, in `emailChannel.eligible`, so no
* outbox row is written and the admin reach preview can say how many were
* dropped. Adding the same condition here as well would look like defence in
* depth and would in fact be a second, invisible answer to the question — this
* function is also what a password reset would reach if it ever routed through
* the channel, and reset mail is deliberately ungated (`passwordReset.controller.js`).
*/
const addressFor = async (userId) => {
const rows = await query(
@@ -147,4 +150,63 @@ const addressFor = async (userId) => {
return rows.length ? { address: rows[0].email } : null
}
module.exports = { active, staff, subscribers, filterActive, storedModes, addressFor, MAX_AUDIENCE }
/**
* Which of these users hold an UNVERIFIED address - the set the Phase 1b gate
* excludes when it is on (Phase 9).
*
* A user with no address at all is in this set, and that is not incidental: the
* gate's question is "may we mail this person", and nowhere to send is a stronger
* no than an unconfirmed somewhere. `addressFor` refuses them at delivery either
* way; including them here is what stops an outbox row being written for a send
* that is already known to be impossible.
*
* One query for a whole audience. The engine calls it once per rule per event
* with up to MAX_AUDIENCE ids, so a per-user lookup would be five thousand round
* trips on the path that is supposed to be the cheap one.
*/
const unverifiedAmong = async (userIds) => {
const wanted = [...new Set(userIds.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
if (!wanted.length) return new Set()
const capped = wanted.slice(0, MAX_AUDIENCE)
const rows = await query(
`SELECT id FROM users
WHERE id IN (${marks(capped)})
AND (email_verified = 0 OR email IS NULL OR email = '')`,
capped,
)
return new Set(ids(rows))
}
/**
* The addresses for a set of active users, as a Map - what the admin reach
* preview hashes to count how many of them are suppressed.
*
* It returns PLAINTEXT, which is the one thing this subsystem otherwise avoids,
* and there is no way around it: a suppression is keyed on the sha256 of an
* address, so answering "how many of these people are suppressed" requires
* hashing each one. The caller (`engagement.controller`) hashes immediately and
* returns only a count - no route ever serializes what this returns.
*/
const addressesFor = async (userIds) => {
const wanted = [...new Set(userIds.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
if (!wanted.length) return new Map()
const capped = wanted.slice(0, MAX_AUDIENCE)
const rows = await query(
`SELECT id, email FROM users
WHERE id IN (${marks(capped)}) AND status = 'active' AND email IS NOT NULL AND email <> ''`,
capped,
)
return new Map(rows.map((r) => [Number(r.id), r.email]))
}
module.exports = {
active,
staff,
subscribers,
filterActive,
storedModes,
addressFor,
unverifiedAmong,
addressesFor,
MAX_AUDIENCE,
}

View File

@@ -0,0 +1,139 @@
const { query } = require('../../utils/db')
/**
* The suppression list (ENGAGEMENT.md G16, Phase 9).
*
* Every function here takes an ALREADY HASHED address. Hashing lives in
* `engagement/suppressions.js` beside the masking, so the two can never disagree
* about what a row for one address looks like; this file only reads and writes.
*/
/** Is this address suppressed on this channel? The row, or null. */
const get = async (addressHash, channel = 'email') => {
const rows = await query(
'SELECT * FROM engagement_suppressions WHERE address_hash = ? AND channel = ?',
[addressHash, channel],
)
return rows.length ? rows[0] : null
}
/**
* Suppress an address, or leave an existing row exactly as it is.
*
* `INSERT IGNORE`, deliberately, rather than an upsert. The FIRST reason an
* address was suppressed is the true one and the one an operator needs: an
* address that hard-bounced in March and was then manually re-added in June
* should still read `bounce`, because that is the fact that explains the mail
* stopping. An upsert would let the most recent write overwrite the diagnosis.
*
* @returns {Promise<boolean>} true when this call created the row
*/
const add = async (entry) => {
const result = await query(
`INSERT IGNORE INTO engagement_suppressions
(address_hash, address_masked, channel, reason, detail, created_by)
VALUES (?, ?, ?, ?, ?, ?)`,
[
entry.address_hash,
entry.address_masked ?? null,
entry.channel || 'email',
entry.reason,
entry.detail ? String(entry.detail).slice(0, 500) : null,
entry.created_by ?? null,
],
)
return Number(result.affectedRows || 0) > 0
}
/**
* Un-suppress an address. The one way out of this table, which is why the admin
* screen exists at all (Phase 9 decision 3).
*
* @returns {Promise<boolean>} true when a row was removed
*/
const remove = async (addressHash, channel = 'email') => {
const result = await query(
'DELETE FROM engagement_suppressions WHERE address_hash = ? AND channel = ?',
[addressHash, channel],
)
return Number(result.affectedRows || 0) > 0
}
/** WHERE-clause builder shared by `list` and `count`, so the two cannot disagree. */
const filters = ({ reason = null, channel = null, search = null } = {}) => {
const where = []
const params = []
if (reason) {
where.push('reason = ?')
params.push(reason)
}
if (channel) {
where.push('channel = ?')
params.push(channel)
}
if (search) {
// Against the MASKED column only. Searching the hash would need the caller to
// hash first, which makes a partial search impossible, and there is no
// plaintext column to search — that is the point of the table. A domain
// ("example.com") is what an operator actually types here, and the mask keeps
// the domain intact precisely so this works.
where.push('address_masked LIKE ?')
params.push(`%${String(search).slice(0, 120)}%`)
}
return { clause: where.length ? `WHERE ${where.join(' AND ')}` : '', params }
}
/** The admin list, newest first. */
const list = (opts = {}) => {
const { clause, params } = filters(opts)
return query(
`SELECT * FROM engagement_suppressions ${clause} ORDER BY created_at DESC, address_hash LIMIT ? OFFSET ?`,
[...params, opts.limit || 50, opts.offset || 0],
)
}
/** How many rows match the same filters — the paged screen's total. */
const count = async (opts = {}) => {
const { clause, params } = filters(opts)
const [row] = await query(`SELECT COUNT(*) AS n FROM engagement_suppressions ${clause}`, params)
return Number(row?.n || 0)
}
/**
* How many suppressions per reason — the summary the screen leads with.
*
* A count by reason is the difference between "eleven addresses are suppressed"
* and "eleven addresses hard-bounced", and only the second tells an operator
* whether to go and look at their relay.
*/
const countsByReason = async () => {
const rows = await query(
'SELECT reason, COUNT(*) AS n FROM engagement_suppressions GROUP BY reason',
)
const out = {}
for (const r of rows) out[r.reason] = Number(r.n || 0)
return out
}
/**
* The subset of these hashes that is suppressed, as a Set.
*
* One query for a whole audience rather than one per recipient. Not used by the
* delivery path — that checks a single address at send time, after the delay
* window, which is the only check that can be correct — but by the admin reach
* preview, which is asked about thousands of users at once and must not become
* thousands of round trips.
*/
const suppressedAmong = async (addressHashes, channel = 'email') => {
const wanted = [...new Set((addressHashes || []).filter(Boolean))]
if (!wanted.length) return new Set()
const marks = wanted.map(() => '?').join(', ')
const rows = await query(
`SELECT address_hash FROM engagement_suppressions
WHERE channel = ? AND address_hash IN (${marks})`,
[channel, ...wanted],
)
return new Set(rows.map((r) => r.address_hash))
}
module.exports = { get, add, remove, list, count, countsByReason, suppressedAmong }

View File

@@ -20,16 +20,29 @@
// ├── subscribers logged-in users who opted into this id
// ├── members a module-declared list (a Team, the governors)
// ├── staff admin / editor / moderator
// │ └── admin admins only
// └── owner the one user the event is about
//
// The four leaves are mutually INCOMPARABLE, deliberately. `owner` is not a
// The four branches are mutually INCOMPARABLE, deliberately. `owner` is not a
// subset of `subscribers` (an owner need not have subscribed), `staff` is not a
// subset of `members`, and no pair of them has a common descendant. That is what
// makes `meet()` below return null rather than guessing, and a null meet is a
// refused save (§5.1a rule 3) rather than a silent widening.
//
// **`admin` was added in Phase 11 and it is the one genuine refinement in the
// tree.** Phase 2 shipped six values, and §8.6 then turned out to describe three
// triggers as admin-audience — `uo.audit.staff_action`, `uo.economy.milestone`,
// `uo.world.saved` — for which the narrowest available value was `staff`, i.e.
// admin / editor / moderator. Ceilinging them there would have permitted a rule
// that mails the staff audit digest to every editor, which is the same class of
// mistake the whole file exists to prevent. It is a CHILD rather than a seventh
// leaf because every admin is staff — the containment the other pairs lack — and
// that is why `permits`, `meet` and `meetAll` needed no change at all beyond the
// new `PARENT` entry. It is a module-contract change (a module may now declare
// `ceiling: 'admin'`) and took MODULE_API_VERSION to 1.8.0.
//
// Nothing here reaches the database, the network or a user record. It is
// arithmetic over six constants, so it is safe to require anywhere.
// arithmetic over seven constants, so it is safe to require anywhere.
// child → parent. A tree, which is what makes `permits` a walk to the root and
// `meet` a comparison rather than a search: two nodes in a tree have a greatest
@@ -40,6 +53,7 @@ const PARENT = {
subscribers: 'authenticated',
members: 'authenticated',
staff: 'authenticated',
admin: 'staff',
owner: 'authenticated',
}
@@ -51,6 +65,7 @@ const LABELS = {
subscribers: 'Signed-in users subscribed to this event',
members: 'Members of a module-declared list',
staff: 'Staff only',
admin: 'Administrators only',
owner: 'Only the user the event is about',
}
@@ -66,9 +81,50 @@ const LABELS = {
// TOLD, which is the tier gate's population.
const STAFF_CEILING_ROLES = ['admin', 'editor', 'moderator']
// And which the `admin` ceiling names. One role, and it is written as a list for
// the same reason `STAFF_CEILING_ROLES` is: `recipients.staff(roles)` takes a
// list, so the two ceilings resolve through one query rather than through two
// that could drift.
const ADMIN_CEILING_ROLES = ['admin']
/** Does this user fall inside the `staff` ceiling? */
const isStaffRole = (role) => STAFF_CEILING_ROLES.includes(role)
/** Does this user fall inside the `admin` ceiling? */
const isAdminRole = (role) => ADMIN_CEILING_ROLES.includes(role)
// The ceilings that name a ROLE, and the test for each. Two consumers read this
// rather than asking about `staff` by name: the audience resolver, and the
// preferences catalog's `visibleTo`.
//
// **`visibleTo` is why this is a table and not two `if`s.** Its rule is that an
// id nobody outside a role can ever be reached by must not appear by name in a
// player's preferences screen — `uo.cheat.detected` is the case the lattice was
// written for. That rule was expressed as `ceiling !== 'staff'` when `staff` was
// the only role-gated value; the day `admin` was added, that spelling would have
// silently published every admin-ceilinged id to every player. A table cannot
// drift the same way: adding a role-gated ceiling without adding it here is a
// registration that fails its own test, not a leak.
const ROLE_CEILINGS = {
staff: { roles: STAFF_CEILING_ROLES, test: isStaffRole },
admin: { roles: ADMIN_CEILING_ROLES, test: isAdminRole },
}
/** Is this ceiling one that only certain roles can ever be reached by? */
const isRoleCeiling = (ceiling) => Object.prototype.hasOwnProperty.call(ROLE_CEILINGS, ceiling)
/**
* Could a viewer with this role EVER be reached by an id ceilinged here?
*
* The question a catalog asks before offering a toggle, and it fails closed: a
* role-gated ceiling with no role, or an unknown role, is a no.
*/
function reachableBy(ceiling, role) {
const gate = ROLE_CEILINGS[ceiling]
if (!gate) return true
return gate.test(role)
}
const CEILINGS = Object.keys(PARENT)
/** Is this one of the six? The gate every registration and every rule save runs. */
@@ -119,4 +175,18 @@ function meetAll(list) {
return list.reduce((acc, next) => (acc === null ? null : meet(acc, next)), list[0])
}
module.exports = { CEILINGS, LABELS, STAFF_CEILING_ROLES, isStaffRole, isCeiling, permits, meet, meetAll }
module.exports = {
CEILINGS,
LABELS,
STAFF_CEILING_ROLES,
ADMIN_CEILING_ROLES,
ROLE_CEILINGS,
isStaffRole,
isAdminRole,
isRoleCeiling,
reachableBy,
isCeiling,
permits,
meet,
meetAll,
}

View File

@@ -9,6 +9,26 @@
// 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.8.0 - a seventh value in the audience ceiling lattice: `admin`, a child of
// `staff` (docs/website/ENGAGEMENT.md Phase 11, decision 1). A module may now
// declare `ceiling: 'admin'` on a trigger or an audience, so the set of values
// `registerEventTriggers` and `registerAudiences` accept grew. Additions only,
// so minor: every declaration valid before is valid now, no stored value
// changes, and module-uo's `coreApi: "^1.3.0"` still resolves.
//
// It exists because Phase 11's operator-facing triggers - `uo.audit.staff_action`,
// `uo.economy.milestone`, `uo.world.saved` - are described everywhere as
// admin-audience, and the narrowest value the lattice had was `staff`, which
// means admin / editor / moderator. Ceilinging them there would have permitted a
// rule that mails the staff audit digest to every editor.
//
// **What a module has to know about it beyond the new name.** `admin` is the one
// pair in the tree with real containment - every admin is staff - so it is the
// only place `permits` is true between two non-`authenticated` values:
// `permits('staff', 'admin')` holds and nothing else of that shape does. A
// trigger ceilinged `staff` therefore accepts an `admin` audience, which is the
// intended narrowing, and the reverse is refused as it should be.
// 1.7.0 — the engagement contract (docs/website/ENGAGEMENT.md Phase 2).
// Additions only, so minor: `api.registerEventTriggers([...])`,
// `api.registerAudiences([...])`, `ctx.events.emit(triggerId, envelope)` and
@@ -81,6 +101,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.7.0'
const MODULE_API_VERSION = '1.8.0'
module.exports = { MODULE_API_VERSION }

View File

@@ -12,12 +12,12 @@ const announceJobs = require('../../../model/announceJobs/announceJobs.model')
const emailConfig = require('../../../model/emailConfig/emailConfig.model')
const emailDedupe = require('../../../model/emailDedupe/emailDedupe.model')
const forumSettings = require('../../../model/teams/teamForumSettings.model')
const pushDispatch = require('../../../utils/pushDispatch')
const { cleanBody } = require('../../../utils/sanitizeHtml')
const { parseJsonSetting } = require('../../../utils/settingsJson')
const { validateThemeVisual } = require('../../../utils/themeResolve')
const { validateBrandAssets, resolveBrandAssets } = require('../../../utils/brandAssets')
const { validateNavOverrides, resolveNavOverrides, NAV_KEYS } = require('../../../utils/navOverrides')
const newsEmit = require('../../../utils/newsNotify')
const htmlShell = require('../../../utils/htmlShell')
const log = require('../../../utils/logger')('admin')
@@ -47,13 +47,36 @@ async function announceIfNewlyPublished(post, transition) {
// sidecar hiccup cannot break saving a post: the same guarantee the enqueue
// above gives.
await registries.dispatchPostHook('onSaved', { post, transition })
// Opt-in push tickle to news.post subscribers, on the same transition.
// Fire-and-forget + self-guarding, so a dead ntfy relay never breaks saving.
if (jobId) {
Promise.resolve(pushDispatch.publish('news.post', { ref: String(post.id) })).catch((err) =>
log.warn('news push failed', { postId: post.id, message: err.message }),
)
}
// **The engagement engine, and it REPLACES the raw push tickle that used to be
// here** (ENGAGEMENT.md §7.1 Q9, decided 2026-08-31 at the start of Phase 11).
//
// `news.post` has been a declared payload contract with no caller since Phase 2
// — 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 call that
// fixes that, and it is deliberately the only thing about this function that
// changed: the announce legs above (a one-shot DELIVERY to a channel of the
// deployment, with retry) and the post hooks (idempotent STATE mirroring, which
// also runs on delete) are different KINDS of thing and both still fire exactly
// as they did. `registries.js` already states those two apart; this adds a third
// distinction of the same kind rather than replacing either.
//
// **What it replaced, and what that costs.** `pushDispatch.publish('news.post',
// …)` stood here and tickled every subscriber directly. It is gone, so push now
// rides the engine like every other channel — which means it goes nowhere until
// an operator enables the `news.post` rule core seeds `enabled = 0` beside the
// four Team ones (engagement/coreRules.js). That IS a behaviour change on
// upgrade and it is the org lead's decision, taken over keeping the raw call
// beside the emit "for one release": that is an exception with a deadline
// nobody owns, and Phase 6 refused the analogous carve-out for Teams. The admin
// Rules screen says so, and the release note names it.
//
// **Gated on `jobId`, the same value the push was gated on.** That is the single
// "newly published news" transition test and re-deriving it here would be a
// second chance to disagree with the first — an edit or a re-publish must not
// re-fire. Fire-and-forget, like everything else in this function: `emit` does
// not await delivery by design, and a rule lookup must not be able to fail
// saving a post.
if (jobId) newsEmit.emitNewsPost(post)
}
// ── Dashboard & site mode ─────────────────────────────────────────────

View File

@@ -32,6 +32,8 @@ const segments = require('../../../model/engagement/engagementSegments.model')
const recipients = require('../../../model/engagement/engagementRecipients.db')
const templates = require('../../../model/engagement/engagementTemplates.model')
const sendsDb = require('../../../model/engagement/engagementSends.db')
const suppressionsDb = require('../../../model/engagement/engagementSuppressions.db')
const suppressions = require('../../../engagement/suppressions')
// The lattice, flattened for a client: for each ceiling, the ones a rule may
// choose under it. Served with the catalog rather than hardcoded in the admin
@@ -245,6 +247,37 @@ exports.deleteSegment = async (req, res, next) => {
// ── Reach preview ──────────────────────────────────────────────────────────
/**
* How many of a resolved audience would actually receive an email, and what
* removed the rest (Phase 9).
*
* The two mechanisms are asked in the order the engine applies them, and the
* order is what makes the numbers add up: the verification gate runs at enqueue,
* so a user it excludes is never checked for suppression, and counting both
* independently would double-count anybody who is unverified AND bounced.
*
* **It never returns an address.** Addresses are read only to hash them, and only
* the counts leave this function — the send log route already refuses to return
* `address_hash` for exactly this reason, and a reach preview that leaked a list
* would be the same hole through a different door.
*/
async function emailReach(userIds) {
const gated = await channels.eligibleFor('email', userIds)
const addresses = await recipients.addressesFor(gated.userIds)
const hashes = [...addresses.values()].map((a) => suppressions.hashAddress(a))
const blocked = await suppressionsDb.suppressedAmong(hashes)
return {
// Everyone the audience resolved to who is not excluded by the gate and is
// not suppressed. Users with no address at all are already out: the gate
// drops them when it is on, and `addressesFor` does not return them when it
// is off, so they never reach the count either way.
deliverable: Math.max(0, addresses.size - blocked.size),
excluded: gated.excluded,
suppressed: blocked.size,
}
}
/**
* GET /api/v1/admin/engagement/audience-preview
*
@@ -270,6 +303,16 @@ exports.deleteSegment = async (req, res, next) => {
* at all. Without it the editor shows a healthy count beside a save the server
* will refuse, which reads as a bug in the save rather than as the G24 ceiling
* doing its job.
*
* **A fourth arrived with Phase 9: `email`.** `count` is how many people the
* audience resolves to, and that has never been how many will get a mail — the
* verification gate drops unverified users at enqueue and the suppression list
* drops bounced addresses at send. An operator reading "3,000" beside a rule that
* will mail 1,796 people has been told something false by a screen whose only job
* is that number, so the breakdown is computed the same way the engine computes
* it: `channels.eligibleFor('email', ...)` is the identical call `applyRule`
* makes. It is reported for the email channel only because it is the only channel
* either mechanism applies to.
*/
exports.previewAudience = async (req, res, next) => {
try {
@@ -297,6 +340,7 @@ exports.previewAudience = async (req, res, next) => {
dormant: resolved.dormant,
reason: resolved.reason,
permitted: triggerId && resolved.ceiling ? audiences.permitted(triggerId, resolved.ceiling) : null,
email: await emailReach(resolved.userIds),
})
} catch (err) {
next(err)
@@ -444,3 +488,110 @@ exports.listSends = async (req, res, next) => {
next(err)
}
}
// ── Suppressions (Phase 9) ─────────────────────────────────────────────────
//
// The one surface that can take an address OUT of the suppression list, which is
// why it exists at all: a hard bounce is written by a background worker with no
// human in the loop, and without a way back a mistyped-then-corrected mailbox is
// silenced permanently.
//
// **The list returns `address_masked`, never `address_hash`.** The send log route
// above strips the hash for a stated reason — shipping a sha256 of every address
// on the deployment to a browser is an offline dictionary attack waiting to be
// run — and the same reasoning applies twice over here, where the rows are
// exactly the addresses somebody would most want to confirm. The mask is what an
// operator can act on and is not reversible.
/** GET /api/v1/admin/engagement/suppressions */
exports.listSuppressions = async (req, res, next) => {
try {
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 200)
const offset = Math.max(Number(req.query.offset) || 0, 0)
const reason = typeof req.query.reason === 'string' ? req.query.reason : null
if (reason && !suppressions.REASONS.includes(reason)) {
return res.status(400).json({ message: `reason must be one of ${suppressions.REASONS.join(', ')}` })
}
const filters = {
reason,
channel: typeof req.query.channel === 'string' ? req.query.channel : null,
search: typeof req.query.search === 'string' ? req.query.search.trim() || null : null,
}
const [rows, total, byReason] = await Promise.all([
suppressionsDb.list({ ...filters, limit, offset }),
suppressionsDb.count(filters),
// Unfiltered on purpose: it is the summary strip above the table, and a
// count that moved with the filter would say "0 bounces" while the operator
// was looking at the manual ones.
suppressionsDb.countsByReason(),
])
res.json({
suppressions: rows.map(({ address_hash: _hash, ...row }) => row),
total,
limit,
offset,
byReason,
reasons: suppressions.REASONS,
})
} catch (err) {
next(err)
}
}
/**
* POST /api/v1/admin/engagement/suppressions
*
* Suppress an address by hand — the operator-side half of a bounce they were
* told about out of band (a person emailing to say "stop", a relay's dashboard).
*
* The reason is forced to `manual` rather than taken from the body. An admin
* typing an address is not evidence of a bounce or a complaint, and a list where
* `reason` sometimes means "the relay said so" and sometimes means "somebody
* chose this word" cannot be used to diagnose anything.
*/
exports.createSuppression = async (req, res, next) => {
try {
const address = typeof req.body?.address === 'string' ? req.body.address.trim() : ''
// The same shape check the rest of the codebase uses for an address, and no
// more: this is not a deliverability test, it is a guard against storing a
// hash of a typo that can never be matched or found again.
if (!address || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address)) {
return res.status(400).json({ message: 'A valid email address is required' })
}
const detail = typeof req.body?.detail === 'string' ? req.body.detail.slice(0, 500) : null
const created = await suppressions.suppress({
address,
reason: 'manual',
detail,
createdBy: req.user?.id ?? null,
})
// 200 rather than 409 for an address already on the list: the operator asked
// for it to be suppressed and it is, which is the outcome they wanted. `created`
// says which of the two happened, so the screen can say "already suppressed"
// without it reading as a failure.
res.status(created ? 201 : 200).json({ created, address: suppressions.maskAddress(address) })
} catch (err) {
next(err)
}
}
/**
* DELETE /api/v1/admin/engagement/suppressions
*
* Un-suppress. The address goes in the BODY rather than the path, and that is
* not a REST preference: a path parameter lands in the access log, the browser's
* history and any proxy in front of the deployment, and this one is a real
* address belonging to a real person. The hash cannot be used instead — the
* screen never receives one.
*/
exports.deleteSuppression = async (req, res, next) => {
try {
const address = typeof req.body?.address === 'string' ? req.body.address.trim() : ''
if (!address) return res.status(400).json({ message: 'An email address is required' })
const removed = await suppressions.unsuppress(address, req.body?.channel || 'email')
if (!removed) return res.status(404).json({ message: 'That address is not suppressed' })
res.json({ removed: true })
} catch (err) {
next(err)
}
}

View File

@@ -331,4 +331,54 @@ engagementRouter.get(
controller.listSends,
)
// -- Suppressions (Phase 9) ------------------------------------------------
engagementRouter.get(
'/suppressions',
// #swagger.tags = ['Admin - Engagement']
// #swagger.summary = 'Addresses this deployment has stopped mailing'
// #swagger.description = 'G16. Rows carry `address_masked` (`d***@example.com`) and never `address_hash` - the same rule the send log follows, and for the same reason: a sha256 of every address on the deployment, handed to a browser, is an offline dictionary attack. The mask keeps the domain intact so a whole-domain delivery failure is visible, and destroys the local part so the list cannot be turned back into an address book. `byReason` is deliberately unfiltered - it is the summary strip above the table.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['limit'] = { in: 'query', description: 'Page size, 1-200 (default 50)', required: false, schema: { type: 'integer' } }
// #swagger.parameters['offset'] = { in: 'query', description: 'Rows to skip', required: false, schema: { type: 'integer' } }
// #swagger.parameters['reason'] = { in: 'query', description: 'bounce, complaint, manual or unverified', required: false, schema: { type: 'string' } }
// #swagger.parameters['channel'] = { in: 'query', description: 'Only this channel (default: all)', required: false, schema: { type: 'string' } }
// #swagger.parameters['search'] = { in: 'query', description: 'Substring of the masked address - a domain is what this is for', required: false, schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'One page of the list, with per-reason totals', content: { "application/json": { schema: { type: "object", properties: { suppressions: { type: "array", items: { type: "object", additionalProperties: true } }, total: { type: "integer" }, limit: { type: "integer" }, offset: { type: "integer" }, byReason: { type: "object", additionalProperties: { type: "integer" } }, reasons: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[400] = { description: 'Unknown reason', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.listSuppressions,
)
engagementRouter.post(
'/suppressions',
// #swagger.tags = ['Admin - Engagement']
// #swagger.summary = 'Suppress an address by hand'
// #swagger.description = 'For a bounce or a complaint reported out of band. The reason is forced to `manual` rather than read from the body: an admin typing an address is not evidence of a bounce, and a `reason` column that sometimes means "the relay said so" and sometimes means "somebody chose this word" cannot diagnose anything. An address already on the list answers 200 with `created: false` rather than 409 - the operator asked for it to be suppressed and it is.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { address: { type: "string" }, detail: { type: "string", nullable: true } }, required: ["address"] } } } } */
/* #swagger.responses[201] = { description: 'Suppressed', content: { "application/json": { schema: { type: "object", properties: { created: { type: "boolean" }, address: { type: "string", nullable: true } } } } } } */
/* #swagger.responses[200] = { description: 'Already suppressed; nothing changed', content: { "application/json": { schema: { type: "object", properties: { created: { type: "boolean" }, address: { type: "string", nullable: true } } } } } } */
/* #swagger.responses[400] = { description: 'Not a valid address', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.createSuppression,
)
engagementRouter.delete(
'/suppressions',
// #swagger.tags = ['Admin - Engagement']
// #swagger.summary = 'Lift a suppression'
// #swagger.description = 'The only way out of the list, and the reason the screen exists: a hard bounce is written by a background worker with no human in the loop, so a mistyped-then-corrected mailbox would otherwise be silenced permanently. The address goes in the BODY, not the path - a path parameter lands in the access log, the browser history and every proxy in front of the deployment, and this one belongs to a real person. The hash cannot be used instead because the screen is never given one.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { address: { type: "string" }, channel: { type: "string", nullable: true } }, required: ["address"] } } } } */
/* #swagger.responses[200] = { description: 'Lifted', content: { "application/json": { schema: { type: "object", properties: { removed: { type: "boolean" } } } } } } */
/* #swagger.responses[400] = { description: 'No address given', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'That address is not suppressed', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.deleteSuppression,
)
module.exports = engagementRouter

View File

@@ -56,7 +56,7 @@ const STALE_MS = 15 * 60 * 1000
/**
* Deliver one claimed row.
*
* @returns {{ outcome: 'sent'|'retry'|'terminal', detail?: string, transport?: string, addressHash?: string }}
* @returns {{ outcome: 'sent'|'retry'|'terminal'|'suppressed'|'bounced', detail?: string, transport?: string, addressHash?: string }}
*/
async function deliver(row) {
const channel = channels.get(row.channel)
@@ -78,6 +78,21 @@ async function deliver(row) {
if (result && result.ok) {
return { outcome: 'sent', transport: result.transport, detail: result.detail, addressHash: hash }
}
// Its own outcome rather than a flavour of 'terminal' (Phase 9). Both statuses
// the ENUMs already carried for it say something a 'failed' row cannot: the
// outbox row was not attempted, and the send log's `suppressed` is the
// difference between "we tried and the relay refused" and "we declined to
// try". An operator reading a screen of failures needs those separated, and
// so does anybody counting deliverability.
if (result && result.suppressed) {
return { outcome: 'suppressed', detail: result.detail || 'suppressed', addressHash: hash }
}
// A hard bounce. Terminal like any other refusal, but recorded under its own
// name: "the relay would not take this" and "this mailbox does not exist"
// send an operator to two different places.
if (result && result.bounced) {
return { outcome: 'bounced', detail: result.detail || 'hard bounce', addressHash: hash }
}
if (result && result.retry) {
return { outcome: 'retry', detail: result.detail || 'transient failure', addressHash: hash }
}
@@ -107,8 +122,18 @@ async function processRow(row, now = new Date(), deliverFn = deliver) {
return 'retry'
}
const status = result.outcome === 'sent' ? 'sent' : 'failed'
await outboxDb.finish(row.id, status, status === 'failed' ? result.detail : null)
// **The two tables diverge here, deliberately.** `engagement_outbox.status` is
// the ROW's lifecycle and its ENUM has no 'bounced' - from the queue's point of
// view a bounced message is a row that finished unsuccessfully, which is
// 'failed'. `engagement_sends.status` is what happened to the MESSAGE, and
// there 'bounced' is the whole point: it is the difference between "look at
// your relay" and "this person's mailbox is gone".
let status = 'failed'
if (result.outcome === 'sent') status = 'sent'
else if (result.outcome === 'suppressed') status = 'suppressed'
else if (result.outcome === 'bounced') status = 'bounced'
const outboxStatus = status === 'bounced' ? 'failed' : status
await outboxDb.finish(row.id, outboxStatus, status === 'sent' ? null : result.detail)
// The send log is written for every terminal outcome, not only success. G15's
// question is "did user X get the mail?", and "no, and here is why" is an
// answer that table has to be able to give.
@@ -142,7 +167,7 @@ async function tick(now = new Date()) {
}
if (!due || !due.length) return
const counts = { sent: 0, failed: 0, retry: 0, taken: 0 }
const counts = { sent: 0, failed: 0, suppressed: 0, bounced: 0, retry: 0, taken: 0 }
for (const row of due) {
try {
const outcome = await processRow(row, now)

View File

@@ -411,7 +411,17 @@ const PERMANENT_CODES = new Set([550, 553, 554, 'EENVELOPE', 'EAUTH'])
* itself. A FAILURE is still recorded, because a relay that has started refusing
* mail is precisely what that screen exists to show.
*
* @returns {Promise<{ok: boolean, retry?: boolean, transport?: string, detail?: string}>}
* **`smtp` carries the refusal itself, and Phase 9 is why.** `retry` says whether
* to try again; `detail` is a sentence for a human. Neither can answer "was this
* the recipient's fault?", which is the question the suppression list turns on —
* `550 5.1.1` and `550 5.7.1` produce an identical `retry: false` and mean
* completely different things. So the reply code, the enhanced status and the
* error code ride back untouched for `bounceClassify` to read. Three scalar
* fields rather than the error object: an `Error` from nodemailer carries the
* whole failed message, envelope included, and this return value is logged.
*
* @returns {Promise<{ok: boolean, retry?: boolean, transport?: string, detail?: string,
* smtp?: {code: string|null, responseCode: number|null, response: string|null}}>}
*/
async function sendNotification({ to, rendered, unsubscribeUrl, unsubscribeApiUrl }) {
const built = await buildTransport()
@@ -445,7 +455,20 @@ async function sendNotification({ to, rendered, unsubscribeUrl, unsubscribeApiUr
const code = err && (err.responseCode || err.code)
log.warn('engagement send failed', { message: err.message })
await emailConfig.recordStatus({ status: 'error', statusDetail: detail }).catch(() => {})
return { ok: false, retry: !PERMANENT_CODES.has(code), transport: config.transport, detail }
return {
ok: false,
retry: !PERMANENT_CODES.has(code),
transport: config.transport,
detail,
smtp: {
code: err && err.code ? String(err.code) : null,
responseCode: err && Number.isInteger(err.responseCode) ? err.responseCode : null,
// Truncated: a relay may answer with a multi-line essay, and this string
// reaches a 500-character log column. The reply code and the enhanced
// status are both at the front, which is where the classifier reads them.
response: err && err.response ? String(err.response).slice(0, 400) : null,
},
}
}
}

View File

@@ -0,0 +1,112 @@
// ── The `news.post` emitter (ENGAGEMENT.md §7.1 Q9, Phase 11) ──────────────
//
// Core's own trigger, and until this phase the only one of its five with no
// caller at all: `config/coreTriggers.js` declared the payload contract in Phase
// 2 and said in as many words that nothing emitted yet, and Phase 6 migrated
// only the four `team.*` ones onto the engine. So an operator could write a rule
// on `news.post` and it could never fire.
//
// It is one call, and every line of care here is about what it must NOT disturb.
// `announceIfNewlyPublished` fans one publish three ways now, and they are
// different in kind:
//
// • `announceJobs.enqueueIfNeeded` — a one-shot DELIVERY to a channel of the
// deployment (the in-game town crier, Discord #news), with retry and
// classification. Not per-person. Not the engine's.
// • `registries.dispatchPostHook('onSaved')` — idempotent STATE mirroring,
// which also runs on delete and refreshes silently on an edit. Not the
// engine's either.
// • this — a PER-PERSON notification, subject to a rule, a preference, a
// suppression and a channel. The engine's, and the only one that was missing.
//
// A module keeps both of its doors onto a publish (the announce leg and the post
// hook) and gains no third: `news.post` is core's id, `ctx.events.emit` binds the
// owner at the call and never reads it from the arguments, and §7.2's one
// namespace means an id has exactly one owner across both facets. A module that
// wants a person-facing notification of its own declares its own trigger.
//
// **Nothing here throws.** It is called from a path that has already saved the
// post; a notification is a courtesy and a courtesy that can fail the write
// behind it is a defect. Same posture as `teamNotify.js`, for the same reason.
const engagementEmit = require('./engagementEmit')
const log = require('./logger')('news-notify')
// How much of a post body the mail carries when the post has no excerpt of its
// own. Same length as the Team excerpt: long enough to tell whether it is worth
// opening, short enough that the mail is not a copy of the article.
const EXCERPT_CHARS = 200
// **The site has no per-post route.** `App.jsx` mounts `/site/news` (the list)
// and nothing under it, which is why `announceJobs.logic.js` links the list from
// the Discord and town-crier announcements too. So the mail links what exists.
// Declared `required: true` on the trigger, so this is a constant rather than an
// optional — a variable that is sometimes absent is a template that sometimes
// renders a dead button.
//
// Site-RELATIVE, because `engagementEmit.RELATIVE_URL` validates `url` variables
// that way: a payload value that ends up in an href must not be able to carry an
// absolute one somewhere else. The mail renderer absolutizes it against the
// deployment's base.
const NEWS_PATH = '/site/news'
/** Markup out, whitespace collapsed, truncated. The mail is plain text. */
function excerptFrom(post) {
const source = post.excerpt || post.body || ''
const text = String(source)
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/\s+/g, ' ')
.trim()
if (!text) return null
return text.length > EXCERPT_CHARS ? `${text.slice(0, EXCERPT_CHARS - 1)}…` : text
}
/**
* Emit `news.post` for a post that has just transitioned into published news.
*
* The caller gates on `announceJobs.enqueueIfNeeded`'s job id — the single
* transition signal — so this does not re-derive it. Passing a post that did not
* transition would produce a duplicate mail, which is why this function does not
* take the transition and cannot be tempted to read it differently.
*
* @param {object} post the saved post row
* @returns {boolean} whether the emit was accepted (for tests; callers ignore it)
*/
function emitNewsPost(post) {
try {
if (!post || post.id == null) return false
const excerpt = excerptFrom(post)
const result = engagementEmit.emit('core', 'news.post', {
data: {
title: post.title || 'A new post',
// Omitted rather than sent empty when the post has neither an excerpt nor
// a body worth quoting. `excerpt` is `required: false`, and an absent
// optional renders as absent; an empty string renders as a blank line
// where a summary should be.
...(excerpt ? { excerpt } : {}),
...(post.category ? { category: String(post.category) } : {}),
postUrl: NEWS_PATH,
},
// **The cooldown subject is the USER, not the post**, which is why there is
// no `subject` here and no `subjectKey` on the declaration. "Do not mail me
// about news more than once an hour" is the useful rule; keying it per post
// would make every cooldown a no-op, since every post is a new subject.
//
// `dedupeKey` IS per post, and that is the other half of the same thought:
// the engine must not write two outbox rows for one publish if this is ever
// reached twice, and the post id is the only stable name for "this publish".
dedupeKey: `news.post:${post.id}`,
})
return Boolean(result && result.ok)
} catch (err) {
log.warn('news event not emitted', { postId: post && post.id, message: err.message })
return false
}
}
module.exports = { emitNewsPost, excerptFrom, NEWS_PATH, EXCERPT_CHARS }

View File

@@ -2183,6 +2183,313 @@
]
}
},
"/api/v1/admin/engagement/suppressions": {
"get": {
"tags": [
"Admin - Engagement"
],
"summary": "Addresses this deployment has stopped mailing",
"description": "G16. Rows carry `address_masked` (`d***@example.com`) and never `address_hash` - the same rule the send log follows, and for the same reason: a sha256 of every address on the deployment, handed to a browser, is an offline dictionary attack. The mask keeps the domain intact so a whole-domain delivery failure is visible, and destroys the local part so the list cannot be turned back into an address book. `byReason` is deliberately unfiltered - it is the summary strip above the table.",
"parameters": [
{
"name": "limit",
"in": "query",
"description": "Page size, 1-200 (default 50)",
"required": false,
"schema": {
"type": "integer"
}
},
{
"name": "offset",
"in": "query",
"description": "Rows to skip",
"required": false,
"schema": {
"type": "integer"
}
},
{
"name": "reason",
"in": "query",
"description": "bounce, complaint, manual or unverified",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "channel",
"in": "query",
"description": "Only this channel (default: all)",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "search",
"in": "query",
"description": "Substring of the masked address - a domain is what this is for",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "One page of the list, with per-reason totals",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"suppressions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
},
"total": {
"type": "integer"
},
"limit": {
"type": "integer"
},
"offset": {
"type": "integer"
},
"byReason": {
"type": "object",
"additionalProperties": {
"type": "integer"
}
},
"reasons": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
}
},
"400": {
"description": "Unknown reason",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not an admin",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
},
"post": {
"tags": [
"Admin - Engagement"
],
"summary": "Suppress an address by hand",
"description": "For a bounce or a complaint reported out of band. The reason is forced to `manual` rather than read from the body: an admin typing an address is not evidence of a bounce, and a `reason` column that sometimes means \"the relay said so\" and sometimes means \"somebody chose this word\" cannot diagnose anything. An address already on the list answers 200 with `created: false` rather than 409 - the operator asked for it to be suppressed and it is.",
"responses": {
"200": {
"description": "Already suppressed; nothing changed",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"created": {
"type": "boolean"
},
"address": {
"type": "string",
"nullable": true
}
}
}
}
}
},
"201": {
"description": "Suppressed",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"created": {
"type": "boolean"
},
"address": {
"type": "string",
"nullable": true
}
}
}
}
}
},
"400": {
"description": "Not a valid address",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not an admin",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"address": {
"type": "string"
},
"detail": {
"type": "string",
"nullable": true
}
},
"required": [
"address"
]
}
}
}
}
},
"delete": {
"tags": [
"Admin - Engagement"
],
"summary": "Lift a suppression",
"description": "The only way out of the list, and the reason the screen exists: a hard bounce is written by a background worker with no human in the loop, so a mistyped-then-corrected mailbox would otherwise be silenced permanently. The address goes in the BODY, not the path - a path parameter lands in the access log, the browser history and every proxy in front of the deployment, and this one belongs to a real person. The hash cannot be used instead because the screen is never given one.",
"responses": {
"200": {
"description": "Lifted",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"removed": {
"type": "boolean"
}
}
}
}
}
},
"400": {
"description": "No address given",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not an admin",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "That address is not suppressed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"address": {
"type": "string"
},
"channel": {
"type": "string",
"nullable": true
}
},
"required": [
"address"
]
}
}
}
}
}
},
"/api/v1/admin/engagement/templates": {
"get": {
"tags": [

View File

@@ -40,6 +40,9 @@ const ctrl = require('../src/router/v1/admin/engagement.controller')
const rulesDb = require('../src/model/engagement/engagementRules.db')
const segmentsDb = require('../src/model/engagement/engagementSegments.db')
const recipients = require('../src/model/engagement/engagementRecipients.db')
const suppressionsDb = require('../src/model/engagement/engagementSuppressions.db')
const suppressions = require('../src/engagement/suppressions')
const settings = require('../src/model/settings/settings.model')
const db = require('../src/utils/db')
after(() => db.close())
@@ -48,7 +51,13 @@ after(() => db.close())
let store
const originals = {}
for (const [name, mod] of [['rulesDb', rulesDb], ['segmentsDb', segmentsDb], ['recipients', recipients]]) {
for (const [name, mod] of [
['rulesDb', rulesDb],
['segmentsDb', segmentsDb],
['recipients', recipients],
['suppressionsDb', suppressionsDb],
['settings', settings],
]) {
originals[name] = { mod, fns: { ...mod } }
}
const restoreOriginals = () => {
@@ -56,7 +65,16 @@ const restoreOriginals = () => {
}
function installStubs() {
store = { rules: new Map(), segments: new Map(), users: new Map(), nextRule: 1, nextSegment: 1 }
store = {
rules: new Map(),
segments: new Map(),
users: new Map(),
// Phase 9: address hashes, and the verification gate's setting.
suppressed: new Set(),
verificationRequired: false,
nextRule: 1,
nextSegment: 1,
}
rulesDb.list = async () => [...store.rules.values()].map((r) => ({ ...r }))
rulesDb.getById = async (id) => (store.rules.has(id) ? { ...store.rules.get(id) } : null)
@@ -102,11 +120,40 @@ function installStubs() {
recipients.subscribers = async () => []
recipients.filterActive = async (ids) =>
[...new Set(ids)].filter((id) => store.users.get(id)?.status === 'active')
// Phase 9: the reach preview now asks the email channel what it would actually
// deliver, so the fake world has to be able to answer the two questions that
// makes it ask - who is unverified, and who is suppressed.
recipients.unverifiedAmong = async (ids) =>
new Set(ids.filter((id) => store.users.get(Number(id))?.email_verified === 0))
recipients.addressesFor = async (ids) =>
new Map(
ids
.filter((id) => store.users.get(Number(id))?.status === 'active')
.map((id) => [Number(id), store.users.get(Number(id)).email]),
)
suppressionsDb.suppressedAmong = async (hashes) =>
new Set(hashes.filter((h) => store.suppressed.has(h)))
settings.isEmailVerificationRequired = async () => store.verificationRequired
}
// ── Fixtures ───────────────────────────────────────────────────────────────
const addUser = (id, over = {}) => store.users.set(id, { id, role: 'player', status: 'active', ...over })
const addUser = (id, over = {}) =>
store.users.set(id, {
id,
role: 'player',
status: 'active',
// Verified with an address by default: the reach preview counts deliverable
// people, and a fixture that was unverified by accident would make every
// count in this file read as zero.
email: `u${id}@example.test`,
email_verified: 1,
...over,
})
const suppressUser = (id) =>
store.suppressed.add(suppressions.hashAddress(store.users.get(id).email))
function register(owner, fn) {
const api = registries.stage(owner)
@@ -438,6 +485,51 @@ test('an `owner` audience previews as 0 with the reason, because it resolves per
assert.equal(res.body.permitted, true)
})
// Phase 9. `count` has never been how many people get a mail, and after this
// phase there are two mechanisms that make the gap real. An operator reading
// "3,000" beside a rule that will mail 1,796 people has been told something false
// by the one screen whose entire job is that number.
test('the preview says how many would actually be mailed, not just how many resolved', async () => {
addUser(1)
addUser(2)
addUser(3)
suppressUser(2)
const res = await call(ctrl.previewAudience, { query: { audience: 'authenticated' } })
assert.equal(res.body.count, 3)
assert.equal(res.body.email.deliverable, 2)
assert.equal(res.body.email.suppressed, 1)
})
test('with the verification gate on, the preview counts the exclusion rather than hiding it', async () => {
addUser(1)
addUser(2, { email_verified: 0 })
store.verificationRequired = true
const res = await call(ctrl.previewAudience, { query: { audience: 'authenticated' } })
assert.equal(res.body.count, 2)
assert.deepEqual(res.body.email.excluded, { unverified: 1 })
assert.equal(res.body.email.deliverable, 1)
})
// The two mechanisms are asked in the order the engine applies them, so somebody
// who is both unverified and suppressed is removed once. Counting them
// independently would report more exclusions than there are people.
test('a user who is both unverified and suppressed is not counted twice', async () => {
addUser(1)
addUser(2, { email_verified: 0 })
suppressUser(2)
store.verificationRequired = true
const res = await call(ctrl.previewAudience, { query: { audience: 'authenticated' } })
assert.equal(res.body.email.deliverable, 1)
assert.deepEqual(res.body.email.excluded, { unverified: 1 })
assert.equal(res.body.email.suppressed, 0, 'the gate already removed them')
})
test('the preview reports when the trigger ceiling would refuse what it just counted', async () => {
addUser(1, { role: 'admin' })

View File

@@ -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)
})

View File

@@ -0,0 +1,423 @@
// ── Deliverability: suppression and bounces (ENGAGEMENT.md Phase 9) ────────
//
// The phase's acceptance criteria, plus the two things building it showed were
// worth pinning because getting either wrong is silent:
//
// • **the classifier is not `PERMANENT_CODES`** — an EAUTH or a 5.7.1 policy
// refusal must not suppress anybody. Reusing that set would have meant one
// stale SMTP password suppressing every address the worker touched, and
// nothing would have said so.
// • **the verification gate excludes at ENQUEUE, on the email channel only** —
// a rule spanning email and in-app must still reach an unverified user's
// inbox, which is the failure a shared audience filter would have shipped.
//
// Point the DB at a closed port before requiring anything: the registries reach
// utils/discordAnnounce, which builds the pool at require time.
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 channels = require('../src/engagement/channels')
const engine = require('../src/engagement/engine')
const emailChannel = require('../src/engagement/emailChannel')
const bounceClassify = require('../src/engagement/bounceClassify')
const suppressions = require('../src/engagement/suppressions')
const templates = require('../src/engagement/templates')
const audiences = require('../src/engagement/audiences')
const worker = require('../src/utils/engagementWorker')
const mailer = require('../src/utils/mailer')
const rulesDb = require('../src/model/engagement/engagementRules.db')
const recipients = require('../src/model/engagement/engagementRecipients.db')
const suppressionsDb = require('../src/model/engagement/engagementSuppressions.db')
const settings = require('../src/model/settings/settings.model')
const db = require('../src/utils/db')
require('../src/engagement')
registries.registerCore()
after(() => db.close())
const saved = new Map()
function patch(mod, name, fn) {
if (!saved.has(mod)) saved.set(mod, new Map())
if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name])
mod[name] = fn
}
function restore() {
for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn
saved.clear()
}
const TRIGGER = 'team.forum.post'
let world
beforeEach(() => {
world = {
mails: [],
// address_hash → row
suppressed: new Map(),
verificationRequired: false,
unverified: new Set(),
sendResult: { ok: true, transport: 'smtp' },
}
patch(mailer, 'sendNotification', async (msg) => {
world.mails.push(msg)
return world.sendResult
})
patch(templates, 'renderByKey', async (key) => ({
subject: `[${key}]`,
html: '<p>x</p>',
text: 'x',
missing: [],
values: {},
}))
patch(recipients, 'addressFor', async (userId) => ({ address: `u${userId}@example.test` }))
patch(recipients, 'filterActive', async (ids) => ids)
patch(recipients, 'storedModes', async () => new Map())
patch(recipients, 'unverifiedAmong', async (ids) =>
new Set(ids.map(Number).filter((id) => world.unverified.has(id))))
patch(recipients, 'addressesFor', async (ids) =>
new Map(ids.map((id) => [Number(id), `u${id}@example.test`])))
patch(rulesDb, 'getById', async () => ({ id: 1, trigger_id: TRIGGER, template_keys: {} }))
patch(settings, 'isEmailVerificationRequired', async () => world.verificationRequired)
patch(suppressionsDb, 'get', async (hash) => world.suppressed.get(hash) || null)
patch(suppressionsDb, 'add', async (entry) => {
if (world.suppressed.has(entry.address_hash)) return false
world.suppressed.set(entry.address_hash, entry)
return true
})
patch(suppressionsDb, 'remove', async (hash) => world.suppressed.delete(hash))
patch(suppressionsDb, 'suppressedAmong', async (hashes) =>
new Set(hashes.filter((h) => world.suppressed.has(h))))
})
afterEach(restore)
const outboxRow = (over = {}) => ({
id: 1,
rule_id: 1,
trigger_id: TRIGGER,
user_id: 11,
channel: 'email',
subject_key: 'The Silver Hand',
scope_key: 'team:1',
payload: {},
attempts: 0,
...over,
})
const suppress = (address, reason = 'bounce') =>
suppressions.suppress({ address, reason, detail: 'seeded by the test' })
// ── The classifier: what counts as the recipient's fault ───────────────────
//
// The single most important group in this file. Everything below it assumes
// `classify` is right about which failures are about a person.
test('a 5.1.1 is a hard bounce', () => {
const v = bounceClassify.classify({
responseCode: 550,
response: '550 5.1.1 <a@b.test>: Recipient address rejected: User unknown',
})
assert.equal(v.suppress, true)
assert.equal(v.reason, 'bounce')
})
// The bug this whole file exists to prevent. `mailer.PERMANENT_CODES` contains
// EAUTH, so "terminal failure → suppress" would have emptied the mailing list
// the first time an operator's SMTP password expired — with a clean send log and
// no warning anywhere.
test('an authentication failure suppresses nobody', () => {
const v = bounceClassify.classify({ code: 'EAUTH', message: 'Invalid login: 535 5.7.8' })
assert.equal(v.suppress, false)
assert.match(v.reason, /not a recipient failure/)
})
test('a policy refusal suppresses nobody — 5.7.x is about us, not the address', () => {
const v = bounceClassify.classify({ responseCode: 554, response: '554 5.7.1 Message rejected by policy' })
assert.equal(v.suppress, false)
})
test('a full mailbox is not a dead one, with or without an enhanced code', () => {
assert.equal(bounceClassify.classify({ responseCode: 552, response: '552 5.2.2 Mailbox full' }).suppress, false)
// The veto list earning its place: "mailbox unavailable" is in the
// no-such-mailbox phrases, and this sentence contains it.
assert.equal(
bounceClassify.classify({ responseCode: 550, response: '550 Mailbox unavailable: mailbox is full' }).suppress,
false,
)
})
test('a temporary failure suppresses nobody', () => {
assert.equal(
bounceClassify.classify({ responseCode: 451, response: '451 4.7.1 Greylisted, try again later' }).suppress,
false,
)
})
test('a bare 550 with an unambiguous reason still bounces', () => {
assert.equal(bounceClassify.classify({ responseCode: 550, response: '550 No such user here' }).suppress, true)
})
test('a bare 554 does not, because "transaction failed" means nothing in particular', () => {
assert.equal(bounceClassify.classify({ responseCode: 554, response: '554 Transaction failed' }).suppress, false)
})
// A refusal this file has no opinion about must be a refusal, not a guess. The
// asymmetry is deliberate: mailing a dead address again costs a retry, and
// suppressing a live one costs a person who silently stops hearing from us.
test('an unrecognised permanent code is not suppressed', () => {
const v = bounceClassify.classify({ responseCode: 550, response: '550 5.4.1 Access denied' })
assert.equal(v.suppress, false)
assert.match(v.reason, /not a known recipient failure/)
})
// A bounce that quotes another relay's answer carries two enhanced codes. The
// one that matters is the one THIS relay gave us, which is the one at the front.
test('the enhanced code is read from the front of the response, not found anywhere in it', () => {
const v = bounceClassify.classify({
responseCode: 554,
response: '554 5.7.1 rejected; remote host said: 550 5.1.1 user unknown',
})
assert.equal(v.suppress, false)
})
// ── The address key ────────────────────────────────────────────────────────
test('the hash is case- and whitespace-folded, so a bounce finds the row it belongs to', () => {
assert.equal(suppressions.hashAddress(' Darrow@Example.TEST '), suppressions.hashAddress('darrow@example.test'))
})
test('the mask keeps the domain and destroys the local part', () => {
assert.equal(suppressions.maskAddress('darrow@example.test'), 'd***@example.test')
// Two characters is most of a two-character local part, so nothing is kept.
assert.equal(suppressions.maskAddress('ab@example.test'), '***@example.test')
assert.equal(suppressions.maskAddress('not-an-address'), null)
})
// ── The acceptance criteria ────────────────────────────────────────────────
test('a suppressed address is skipped with no transport call at all', async () => {
await suppress('u11@example.test')
const result = await emailChannel.deliver(outboxRow())
assert.equal(result.ok, false)
assert.equal(result.suppressed, true)
assert.equal(world.mails.length, 0, 'the transport must not be called')
assert.match(result.detail, /suppressed \(bounce\)/)
})
test("the worker writes that as status='suppressed' in both tables, not as a failure", async () => {
const finished = []
const logged = []
const outboxDb = require('../src/model/engagement/engagementOutbox.db')
const sendsDb = require('../src/model/engagement/engagementSends.db')
patch(outboxDb, 'claim', async () => true)
patch(outboxDb, 'finish', async (id, status, detail) => finished.push({ id, status, detail }))
patch(sendsDb, 'record', async (entry) => logged.push(entry))
await suppress('u11@example.test')
const status = await worker.processRow(outboxRow())
assert.equal(status, 'suppressed')
assert.equal(finished[0].status, 'suppressed')
assert.equal(logged[0].status, 'suppressed')
// The correlation key rides along even here: it is what a later manual audit
// matches a relay's own bounce report against.
assert.match(logged[0].address_hash, /^[0-9a-f]{64}$/)
})
test('a hard bounce suppresses the address, and the next send never reaches the relay', async () => {
world.sendResult = {
ok: false,
retry: false,
transport: 'smtp',
detail: 'the relay refused the message',
smtp: { code: 'EENVELOPE', responseCode: 550, response: '550 5.1.1 User unknown' },
}
const first = await emailChannel.deliver(outboxRow())
assert.equal(first.ok, false)
assert.equal(world.mails.length, 1, 'the first attempt does reach the transport')
assert.match(first.detail, /hard bounce: 5\.1\.1/)
world.sendResult = { ok: true, transport: 'smtp' }
const second = await emailChannel.deliver(outboxRow())
assert.equal(second.suppressed, true)
assert.equal(world.mails.length, 1, 'the second attempt does not')
})
// Without this a genuine dead mailbox could be retried four more times, because
// PERMANENT_CODES does not contain every reply code that can carry a 5.1.x —
// each retry another refusal on our record with the relay.
// `engagement_sends.status` has carried 'bounced' since §4.5 and nothing wrote
// it until this phase, so the Send Log's "Bounced" filter matched nothing. The
// live rig is what showed that; this is what keeps it fixed. The outbox row is
// still 'failed' — that ENUM has no 'bounced', and from the queue's point of
// view a bounced row is simply one that finished unsuccessfully.
test("a hard bounce is logged as 'bounced', while the outbox row is 'failed'", async () => {
const finished = []
const logged = []
const outboxDb = require('../src/model/engagement/engagementOutbox.db')
const sendsDb = require('../src/model/engagement/engagementSends.db')
patch(outboxDb, 'claim', async () => true)
patch(outboxDb, 'finish', async (id, status) => finished.push(status))
patch(sendsDb, 'record', async (entry) => logged.push(entry))
world.sendResult = {
ok: false,
retry: true,
transport: 'smtp',
detail: 'refused',
smtp: { code: 'EENVELOPE', responseCode: 550, response: '550 5.1.1 User unknown' },
}
const status = await worker.processRow(outboxRow())
assert.equal(status, 'bounced')
assert.equal(logged[0].status, 'bounced')
assert.equal(finished[0], 'failed')
})
test('a bounce is terminal even when the mailer called the failure retryable', async () => {
world.sendResult = {
ok: false,
retry: true,
transport: 'smtp',
detail: 'refused',
smtp: { code: null, responseCode: 550, response: '550 5.1.1 User unknown' },
}
const result = await emailChannel.deliver(outboxRow())
assert.equal(result.retry, false)
})
test('a failure that is NOT a bounce stays retryable and suppresses nobody', async () => {
world.sendResult = {
ok: false,
retry: true,
transport: 'smtp',
detail: 'connection refused',
smtp: { code: 'ECONNECTION', responseCode: null, response: null },
}
const result = await emailChannel.deliver(outboxRow())
assert.equal(result.retry, true)
assert.equal(world.suppressed.size, 0)
// The send log says why it was not suppressed, so the non-event is explained
// rather than merely absent.
assert.match(result.detail, /not suppressed/)
})
// The suppression list must never be able to stop mail going out. A database
// that cannot answer "is this suppressed" is a database problem, and turning it
// into total silence with a clean send log is G22's shape all over again.
test('a suppression check that throws sends the mail anyway', async () => {
patch(suppressionsDb, 'get', async () => { throw new Error('db is down') })
const result = await emailChannel.deliver(outboxRow())
assert.equal(result.ok, true)
assert.equal(world.mails.length, 1)
})
test('the first reason an address was suppressed is the one that survives', async () => {
await suppress('u11@example.test', 'bounce')
await suppressions.suppress({ address: 'u11@example.test', reason: 'manual' })
const row = world.suppressed.get(suppressions.hashAddress('u11@example.test'))
assert.equal(row.reason, 'bounce')
})
test('un-suppressing is the way back, and it is the only one', async () => {
await suppress('u11@example.test')
assert.equal(await suppressions.unsuppress('u11@example.test'), true)
const result = await emailChannel.deliver(outboxRow())
assert.equal(result.ok, true)
})
// ── The verification gate (§7.1 Q1's narrower half) ────────────────────────
test('with the gate off, an unverified address is mailed', async () => {
world.unverified.add(11)
const gated = await channels.eligibleFor('email', [11, 12])
assert.deepEqual(gated.userIds, [11, 12])
assert.deepEqual(gated.excluded, {})
})
test('with the gate on, an unverified user is excluded and counted', async () => {
world.verificationRequired = true
world.unverified.add(11)
const gated = await channels.eligibleFor('email', [11, 12])
assert.deepEqual(gated.userIds, [12])
assert.deepEqual(gated.excluded, { unverified: 1 })
})
// The reason the gate is a CHANNEL hook rather than a filter on the shared
// audience. A rule spanning both channels must still put an item in an
// unverified user's inbox — being unverified is a reason not to mail somebody
// and no reason at all to hide their notifications from them.
test('the gate is email-only: in-app still reaches an unverified user', async () => {
world.verificationRequired = true
world.unverified.add(11)
const inapp = await channels.eligibleFor('inapp', [11, 12])
assert.deepEqual(inapp.userIds, [11, 12])
const push = await channels.eligibleFor('push', [11, 12])
assert.deepEqual(push.userIds, [11, 12])
})
// The gate must not be able to stop mail by failing, and the blast radius is
// wider than one recipient: `applyRule` awaits this before the per-user loop, so
// a throw would abandon the whole rule for every channel it names — a rule that
// silently sent nothing, with a clean send log and an empty outbox.
test('a gate that cannot be read is off, for every recipient and every channel', async () => {
patch(settings, 'isEmailVerificationRequired', async () => { throw new Error('db is down') })
const gated = await channels.eligibleFor('email', [11, 12])
assert.deepEqual(gated.userIds, [11, 12])
assert.deepEqual(gated.excluded, {})
})
test('and so is a gate whose user lookup fails', async () => {
world.verificationRequired = true
patch(recipients, 'unverifiedAmong', async () => { throw new Error('db is down') })
const gated = await channels.eligibleFor('email', [11, 12])
assert.deepEqual(gated.userIds, [11, 12])
})
// ── The engine end to end ──────────────────────────────────────────────────
test('the engine writes no outbox row for an excluded user, and counts the exclusion', async () => {
const enqueued = []
const outboxDb = require('../src/model/engagement/engagementOutbox.db')
const cooldownsDb = require('../src/model/engagement/engagementCooldowns.db')
const sendsDb = require('../src/model/engagement/engagementSends.db')
patch(outboxDb, 'enqueue', async (row) => { enqueued.push(row); return enqueued.length })
patch(cooldownsDb, 'claim', async () => true)
patch(sendsDb, 'countSentSince', async () => 0)
patch(audiences, 'resolveForRule', async () => ({
userIds: [11, 12],
ceiling: 'authenticated',
dormant: false,
}))
patch(audiences, 'permitted', () => true)
// Both channels default to a mode that enqueues only where the user opted in;
// email is opt-in, so say so for both users.
patch(recipients, 'storedModes', async (ids) => new Map(ids.map((id) => [Number(id), 'instant'])))
world.verificationRequired = true
world.unverified.add(11)
const rule = {
id: 1,
trigger_id: TRIGGER,
channels: ['email', 'inapp'],
max_sends_per_hour: 100,
cooldown_seconds: 0,
delay_seconds: 0,
conditions: null,
}
const summary = await engine.applyRule(rule, { triggerId: TRIGGER, data: {}, subject: 's' }, new Date())
assert.deepEqual(summary.ineligible, { unverified: 1 })
const emailRows = enqueued.filter((r) => r.channel === 'email').map((r) => r.user_id)
const inappRows = enqueued.filter((r) => r.channel === 'inapp').map((r) => r.user_id)
assert.deepEqual(emailRows, [12], 'the unverified user gets no mail')
assert.deepEqual(inappRows.sort(), [11, 12], 'and still gets an inbox item')
})

View File

@@ -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', () => {

View 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&nbsp;<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')
})

View File

@@ -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 () => {