From e25e7ade8070fbe00dcaea31e92b91a8a29895ba Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:47:25 -0500 Subject: [PATCH 01/20] fix(swagger): hoist the one inline predicate that makes the generator run away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm run swagger` cannot run on this tree. It dies with swagger-autogen's "FATAL ERROR: invalid array length - Allocation failed", generating nothing, and it reproduces on a pristine checkout under both Node 20 and Node 24 — so the committed spec cannot be regenerated by anyone, and any PR that adds or changes a route is unable to meet the standing obligation to update it. Bisected to one statement in `teams.router.js`: param('teamId').custom((v) => v === 'default' || TEAM_ID.test(v)) Hoisting that arrow to a named const fixes it outright. Nothing else changes and the regenerated spec is byte-identical to the committed one, so this is a generator fix, not a spec change. The diagnosis worth keeping, because the file's own comment recorded a different one. Phase 8 shipped a bare regex LITERAL before `.test(` and phase 9 hoisted the regex, blaming a per-file route limit measured at twenty statements; the file has sat at nineteen ever since on the theory that it was one under the edge. That theory is wrong. Probing every router file individually, `teams.router.js` at nineteen statements dies while a THREE-route file carrying only this one route also dies — so the trigger is the inline arrow reaching `.test(`, not the count. Hoisting the regex was half the fix; the predicate around it needed hoisting too. The comments in `teams.router.js`, `teamsVoice.router.js` and `admin/index.js` are corrected to say so, since all three currently tell the next person to keep counting statements. Co-Authored-By: Claude --- server/src/router/v1/admin/index.js | 13 +++++----- server/src/router/v1/admin/teams.router.js | 26 ++++++++++++++----- .../src/router/v1/admin/teamsVoice.router.js | 22 +++++++++------- 3 files changed, 38 insertions(+), 23 deletions(-) diff --git a/server/src/router/v1/admin/index.js b/server/src/router/v1/admin/index.js index 1b59c7a..5e32048 100644 --- a/server/src/router/v1/admin/index.js +++ b/server/src/router/v1/admin/index.js @@ -88,12 +88,13 @@ adminRouter.use('/modules', modulesRouter) // Voice channels (TEAMS.md §7.3, phase 9) are mounted at the more specific prefix // FIRST, so /teams/voice/* never reaches the teams router's `/:id`. // -// They live out here rather than inside `teams.router.js` beside the bridge they -// belong with, for a mechanical reason worth recording: that file sits exactly at -// swagger-autogen's per-file limit. At twenty `teamsRouter.*` statements -// `npm run swagger` dies with "invalid array length — heap out of memory"; at -// nineteen it generates. One more statement of any shape tips it, a mount -// included, so the mount is here and the file keeps its nineteen. +// The voice routes live in their own file, and the mount is out here rather than +// in that file, because phase 9 believed swagger-autogen enforced a per-file route +// limit that `teams.router.js` was sitting on. It does not: the generator's +// runaway is triggered by an expression reaching `.test(` inside a route +// statement, which that file had and has since had hoisted. The arrangement is +// kept on its own merits — voice is its own capability — but neither the split nor +// the placement of this mount is load-bearing any more. adminRouter.use('/teams/voice', teamsVoiceRouter) adminRouter.use('/teams', teamsRouter) diff --git a/server/src/router/v1/admin/teams.router.js b/server/src/router/v1/admin/teams.router.js index 6f8740e..6d4ec7f 100644 --- a/server/src/router/v1/admin/teams.router.js +++ b/server/src/router/v1/admin/teams.router.js @@ -27,13 +27,25 @@ const teamsRouter = express.Router() // where a Team's events leave the site for is not the §2.9 kind of decision a // moderator files a request for; it is deployment configuration, and it sits with // the role that already holds the bot token. -// Hoisted rather than written inline, and it has to stay that way: a regex -// LITERAL followed directly by `.test(` makes swagger-autogen's static parser run -// away, and `npm run swagger` dies with "invalid array length — heap out of -// memory" instead of generating a spec. Phase 8 shipped it inline and left the -// generator unable to run at all; the same regex reached through a const (the -// idiom `modules.router.js` already uses) parses fine. +// Both of these are hoisted rather than written inline, and both have to stay +// that way. **Nothing that reaches `.test(` may sit inside a route statement**: +// swagger-autogen's static parser runs away on it and `npm run swagger` dies with +// "invalid array length - Allocation failed", generating no spec at all. +// +// Two rounds of that. Phase 8 shipped the regex as a bare LITERAL before `.test(` +// and phase 9 hoisted it — but left the PREDICATE inline, which is the same +// runaway even with the regex behind a const, and the generator stayed broken. +// Bisected to exactly the `param('teamId').custom(...)` statement below: a +// three-route file carrying only it dies, so this is not about how much is in the +// file. +// +// That last point corrects what this file used to say. Phase 9 read the symptom +// as a per-file ROUTE LIMIT, measured it at twenty `teamsRouter.*` statements, +// split `teamsVoice.router.js` out and left this file sitting at nineteen "one +// under the edge". There is no such edge to sit under — the count was a proxy for +// how much text the parser chewed before hitting the real trigger. const TEAM_ID = /^[0-9]+$/ +const isTeamIdOrDefault = (v) => v === 'default' || TEAM_ID.test(v) const adminOnly = requireRole('admin') @@ -181,7 +193,7 @@ teamsRouter.delete( /* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */ /* #swagger.responses[404] = { description: 'Nothing configured for that Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ adminOnly, - param('teamId').custom((v) => v === 'default' || TEAM_ID.test(v)), + param('teamId').custom(isTeamIdOrDefault), validate, ctrl.deleteIntegrationConfig, ) diff --git a/server/src/router/v1/admin/teamsVoice.router.js b/server/src/router/v1/admin/teamsVoice.router.js index cd944fb..dae61a7 100644 --- a/server/src/router/v1/admin/teamsVoice.router.js +++ b/server/src/router/v1/admin/teamsVoice.router.js @@ -7,18 +7,20 @@ // in somebody's Discord guild, which is deployment configuration and not the §2.9 // kind of decision a moderator files a request for. // -// **Its own file for a mechanical reason, and the reason is worth recording.** +// **Its own file for a mechanical reason that turned out to be misdiagnosed.** // These four routes belong beside the notification bridge's three in -// `teams.router.js`, and they started there. That file sits exactly at -// swagger-autogen's per-file limit: at twenty `teamsRouter.*` statements -// `npm run swagger` dies with "invalid array length — heap out of memory", and at -// nineteen it generates. ONE more statement of any shape tips it — a route with no -// annotations at all does, and so does a bare `use`, which is why the mount is in -// `admin/index.js` rather than here in the file it logically belongs to. The same -// probe route added to `discordBot.router.js` generates fine, so the limit is -// per-file and not tree-wide. +// `teams.router.js`, and they started there. Phase 9 read `npm run swagger` dying +// with "invalid array length" as a per-file ROUTE LIMIT, measured it at twenty +// `teamsRouter.*` statements, and split this file out to get under it. // -// So: if this file grows, split it again rather than moving it back. +// The real trigger is content, not count: an expression reaching `.test(` inside +// a route statement makes the parser run away, and `teams.router.js` had one in a +// `param(...).custom((v) => ... .test(v))`. Hoisting that predicate fixes the +// generator with the file at nineteen statements. See that file's own note. +// +// The split is kept because it is a good split on its own terms — voice channels +// are their own capability and the file reads better for it — but if these routes +// ever want to move back, nothing mechanical is stopping them. const express = require('express') const { body, param } = require('express-validator') -- 2.49.1 From 47c8b37d457d917ab5bd4fb760560fbd51d5c34e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:41:43 -0500 Subject: [PATCH 02/20] feat(email): remove Gmail OAuth2, put SMTP behind a transport registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engagement Phase 1 (docs/website/ENGAGEMENT.md §1.2a, §3.1, §3.2). A subtraction and a replacement in one commit, because leaving the OAuth2 flow half-wired across a release is worse than either end state. Deleted, per the §1.2a inventory: GET /admin/email/connect/start and /connect/callback, the connectStart/connectCallback controllers with the email_oauth_tx signed cookie, the PKCE verifier and CSRF nonce plumbing, the https://mail.google.com/ scope, the borrowed `google` auth-providers client, the OAuth2 nodemailer transport with its smtp.gmail.com:465 literals, the refresh-token decrypt in the model, and the client's Connect Gmail button, redirect banner and six Gmail error strings. `provider` and `refresh_token_enc` stay as columns under the additive-only discipline, unread. Added: a mail transport registry (server/src/engagement/transports) with `smtp` as the sole registration. `credentialFields` is the single declaration the admin form renders, the sanitizer filters against, and the "is it secret" answer comes from, so adding a transport is a registration rather than four edits. email_config gains transport / credential_enc (one encrypted JSON blob, since the field list is the transport's to declare) / reply_to. All six call sites keep their exact failure contracts: the contact form's mailto fallback, the invite's copyable link, the reset's generic 200, and sendTeamNotification's never-throws. One deliberate behaviour change: `enabled` now gates every sender rather than only isConfigured() — the connect flow used to set it as a side effect, and with a credential form the toggle has to mean what it says. Send-test becomes the real verification. Under OAuth2 the sender came back from Google and was guaranteed to belong to the credential; operator-typed, it can be refused, so failures name the sender and the SPF/DMARC reason (§1.2a consequence 2). G22, the silent degradation: an upgraded deployment backfills to smtp with no credentials and every sink politely does nothing. The admin dashboard now warns when the deprecated Gmail token is present and no replacement credential is, so the one deployment this happens to is told. A fresh install has never had mail and is not nagged. Guardrails: new `npm run check:hosts` (§3.2 rule 4) with its own self-test, wired into pr-checks before the install; routes.manifest and routes.guards regenerated (-2 routes). Co-Authored-By: Claude --- .gitea/workflows/pr-checks.yml | 6 + client/src/api/client.js | 5 +- client/src/routes/admin/views/Dashboard.jsx | 28 ++ .../src/routes/admin/views/EmailDelivery.jsx | 292 +++++++++++------- package.json | 3 +- scripts/checkNoExternalHosts.js | 185 +++++++++++ server/db/schema.sql | 55 +++- server/routes.guards.json | 20 +- server/routes.manifest.json | 8 - server/src/engagement/index.js | 22 ++ server/src/engagement/transports/index.js | 195 ++++++++++++ server/src/engagement/transports/smtp.js | 96 ++++++ .../src/model/emailConfig/emailConfig.db.js | 3 +- .../model/emailConfig/emailConfig.model.js | 106 +++++-- server/src/model/teams/teamNotify.model.js | 4 +- .../src/router/v1/admin/admin.controller.js | 35 +++ .../src/router/v1/admin/dashboard.router.js | 4 +- server/src/router/v1/admin/email.router.js | 58 ++-- .../router/v1/admin/emailConfig.controller.js | 217 ++++--------- server/src/server.js | 2 +- server/src/utils/mailer.js | 149 ++++++--- server/src/utils/teamNotify.js | 8 +- server/test/checkNoExternalHosts.test.js | 98 ++++++ server/test/emailConfig.model.test.js | 115 +++++-- server/test/mailTransports.test.js | 109 +++++++ server/test/mailer.test.js | 173 +++++++++-- 26 files changed, 1535 insertions(+), 461 deletions(-) create mode 100644 scripts/checkNoExternalHosts.js create mode 100644 server/src/engagement/index.js create mode 100644 server/src/engagement/transports/index.js create mode 100644 server/src/engagement/transports/smtp.js create mode 100644 server/test/checkNoExternalHosts.test.js create mode 100644 server/test/mailTransports.test.js diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index add43e5..4188ea9 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -56,6 +56,12 @@ jobs: # something found under a pile of unrelated failures, and it costs # nothing when it passes. run: npm run check:modules + - name: Check the engagement subsystem names no external host + # ENGAGEMENT.md §3.2 rule 4 — no transport may ship a default host, + # endpoint or sender. Dependency-free and runs before the install for the + # same reason as the check above: a phone-home is a design break, not a + # test failure, and it should be the first thing a reviewer sees. + run: npm run check:hosts - name: Install server deps run: npm ci --prefix server - name: Run server tests diff --git a/client/src/api/client.js b/client/src/api/client.js index 1da6829..9a97898 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -455,10 +455,11 @@ export const api = { getDiscordBotConfig: () => req('/admin/discord-bot/config'), saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }), - // ----- Email delivery / Gmail OAuth2 (admin only) ----- + // ----- Email delivery (admin only) ----- + // The connect-flow call went with Gmail OAuth2 (ENGAGEMENT.md §1.2a); the + // config response now carries the transport catalog the form renders from. getEmailConfig: () => req('/admin/email/config'), saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }), - emailConnectUrl: () => req('/admin/email/connect/start'), testEmail: (to) => req('/admin/email/test', { method: 'POST', body: { to } }), disconnectEmail: () => req('/admin/email/disconnect', { method: 'POST' }), }, diff --git a/client/src/routes/admin/views/Dashboard.jsx b/client/src/routes/admin/views/Dashboard.jsx index 2ea25da..9a2ca63 100644 --- a/client/src/routes/admin/views/Dashboard.jsx +++ b/client/src/routes/admin/views/Dashboard.jsx @@ -66,6 +66,34 @@ export default function Dashboard() { return (
+ {/* Operator warnings: things that are quietly not working and would + otherwise be discovered by someone not receiving an email. The list is + normally empty, which is why it sits above the fold rather than in a + panel — see ENGAGEMENT.md §1.2a (G22). */} + {(dash.warnings || []).map((w) => ( +
+ {w.message} + {w.href && ( + <> + {' '} + Open settings + + )} +
+ ))}
the callback may redirect with. -const ERROR_TEXT = { - denied: 'Google sign-in was cancelled or denied.', - bad_state: 'The connect session expired. Please try again.', - no_client: 'The Google OAuth client is not configured.', - no_refresh_token: - 'Google did not return a refresh token. Remove this app under your Google Account → Security → Third-party access, then reconnect.', - no_email: 'Could not read the Gmail address from Google.', - error: 'Could not connect the Gmail account. Please try again.', -} - function StatusPanel({ config }) { const color = STATUS_COLOR[config.status] || 'var(--muted)' return ( @@ -52,60 +50,100 @@ function StatusPanel({ config }) { ) } +// One declared credential field. A `secret` already held renders empty with a +// "leave blank to keep" hint, matching the server's patch semantics: an empty +// secret is omitted from the save, not written as a blank. +function CredentialField({ field, value, isSet, onChange }) { + const hint = [field.help, field.kind === 'secret' && isSet ? 'Currently set — leave blank to keep it.' : null] + .filter(Boolean) + .join(' ') + + if (field.kind === 'boolean') { + return ( + + ) + } + + return ( + + ) +} + export default function EmailDelivery() { const { siteTitle } = useSite() const [config, setConfig] = useState(null) const [error, setError] = useState('') + const [transport, setTransport] = useState('smtp') + const [senderEmail, setSenderEmail] = useState('') const [senderName, setSenderName] = useState('') + const [replyTo, setReplyTo] = useState('') + const [credential, setCredential] = useState({}) const [enabled, setEnabled] = useState(false) const [busy, setBusy] = useState('') const [msg, setMsg] = useState('') const [actionError, setActionError] = useState('') - const [banner, setBanner] = useState(null) // fields kind ('ok' or 'err') and text + + // Seed the credential inputs from the non-secret values the server returned, + // falling back to each field's declared default. Secrets are never seeded — + // the server does not send them and an empty box means "keep what you have". + const seedCredential = useCallback((c, transportId) => { + const def = (c.transports || []).find((t) => t.id === transportId) + const next = {} + for (const f of def?.credentialFields || []) { + if (f.kind === 'secret') continue + next[f.key] = c.credential?.[f.key] ?? (f.default === null ? '' : f.default) + } + return next + }, []) const load = useCallback(async (seedForm = false) => { try { const c = await api.admin.getEmailConfig() setConfig(c) if (seedForm) { + setTransport(c.transport || 'smtp') + setSenderEmail(c.senderEmail || '') setSenderName(c.senderName || '') + setReplyTo(c.replyTo || '') setEnabled(c.enabled) + setCredential(seedCredential(c, c.transport || 'smtp')) } return c } catch { setError('Could not load email settings.') return null } - }, []) + }, [seedCredential]) - // On mount, surface the outcome of a just-completed connect redirect, strip the - // query params so a refresh doesn't replay the banner, then load config. useEffect(() => { - const params = new URLSearchParams(window.location.search) - if (params.has('email_connected')) { - setBanner({ kind: 'ok', text: 'Gmail account connected.' }) - } else if (params.has('email_error')) { - setBanner({ kind: 'err', text: ERROR_TEXT[params.get('email_error')] || 'Could not connect email.' }) - } - if (params.has('email_connected') || params.has('email_error')) { - params.delete('email_connected') - params.delete('email_error') - const qs = params.toString() - window.history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : '')) - } load(true) }, [load]) - async function connect() { - setBusy('connect') - setActionError('') - try { - const { url } = await api.admin.emailConnectUrl() - window.location.href = url - } catch (err) { - setActionError(err.message || 'Could not start the connect flow.') - setBusy('') - } + // Switching transport starts from the new one's declared defaults, because the + // server does the same: a credential blob is never carried across transports. + function changeTransport(id) { + setTransport(id) + setCredential(seedCredential(config, id)) } async function save() { @@ -113,10 +151,19 @@ export default function EmailDelivery() { setMsg('') setActionError('') try { - const saved = await api.admin.saveEmailConfig({ senderName, enabled }) + const saved = await api.admin.saveEmailConfig({ transport, senderEmail, senderName, replyTo, credential, enabled }) setConfig(saved) + setEnabled(saved.enabled) + setCredential(seedCredential(saved, saved.transport)) setMsg('Saved.') } catch (err) { + // A refused enable comes back with the reverted config attached, so the + // screen shows what is actually stored rather than the state that was + // rejected. + if (err.body?.config) { + setConfig(err.body.config) + setEnabled(err.body.config.enabled) + } setActionError(err.message || 'Could not save.') } finally { setBusy('') @@ -133,12 +180,13 @@ export default function EmailDelivery() { await load() } catch (err) { setActionError(err.message || 'Could not send the test email.') + await load() } finally { setBusy('') } } - async function disconnect() { + async function clearCredentials() { setBusy('disconnect') setMsg('') setActionError('') @@ -146,9 +194,11 @@ export default function EmailDelivery() { const c = await api.admin.disconnectEmail() setConfig(c) setEnabled(false) - setMsg('Disconnected.') + setSenderEmail('') + setCredential(seedCredential(c, c.transport)) + setMsg('Credentials cleared.') } catch (err) { - setActionError(err.message || 'Could not disconnect.') + setActionError(err.message || 'Could not clear the credentials.') } finally { setBusy('') } @@ -157,84 +207,118 @@ export default function EmailDelivery() { if (error) return

{error}

if (!config) return null - const connected = config.hasRefreshToken + const catalog = config.transports || [] + const selected = catalog.find((t) => t.id === transport) return (

Email delivery

- Sends the contact form through Gmail over OAuth2, delivered to the - Contact email above. Reuses the Google authentication - client — configure that on the Authentication page first. + Sends the contact form, invitations, password resets and team + notifications. Contact-form mail is delivered to the + Contact email above. Credentials are stored encrypted + and never shown again.

- {banner && ( + {config.hadLegacyConnection && !config.hasCredential && (
- {banner.text} + This deployment was connected with the old Gmail sign-in, which has been + removed. No mail is being sent. Enter SMTP credentials + below to restore it — for Gmail, use smtp.gmail.com port 587 + with an app password.
)} - {!config.googleConfigured && ( -

- The Google authentication provider needs a client ID and secret before - you can connect a Gmail account. -

+ {catalog.length > 1 && ( + )} - {!connected ? ( -
- + + {config.hasCredential && ( + -
- ) : ( - <> - - - - -
- - - - -
- - )} + )} +
{msg && {msg}} diff --git a/package.json b/package.json index 28f34fd..5573b9a 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "seed": "npm run seed --prefix server", "build": "npm run build --prefix client", "start": "npm start --prefix server", - "check:modules": "node scripts/checkModuleIdentifiers.js" + "check:modules": "node scripts/checkModuleIdentifiers.js", + "check:hosts": "node scripts/checkNoExternalHosts.js" }, "keywords": ["express", "mariadb", "react", "vite", "jwt"], "author": "whitlocktech", diff --git a/scripts/checkNoExternalHosts.js b/scripts/checkNoExternalHosts.js new file mode 100644 index 0000000..d45747e --- /dev/null +++ b/scripts/checkNoExternalHosts.js @@ -0,0 +1,185 @@ +#!/usr/bin/env node +// ── §3.2 rule 4 — no phone-home in the engagement subsystem ──────────────── +// +// ENGAGEMENT.md §3.2 records a posture the codebase already has and this check +// exists to keep: **no transport may ship a default host, endpoint, API base or +// sender.** A transport with no operator configuration is `unconfigured` and its +// channel is off — it never quietly falls back to a destination we chose. +// +// The rule is easy to hold and easy to break by accident, and the removed Gmail +// transport is the proof of both: `smtp.gmail.com` and port 465 were literals in +// `mailer.buildTransport()`, which made "which provider" a code edit and made the +// deployment's mail depend on a host nobody configured. Deleting that literal is +// what this check was written against, and it is the first thing it would have +// caught. +// +// **It reads code, not prose.** A comment naming `smtp.gmail.com` as the +// migration path for existing operators is exactly the documentation this phase +// owes, and a check that forbade it would teach people to phrase around it. So +// comments and the insides of ordinary strings are masked out; what is checked is +// a HOSTNAME OR URL appearing as a string literal in the engagement trees. Same +// design, and the same reasoning, as `checkModuleIdentifiers.js` — including +// having its own test suite, because a check that silently stops checking is +// worse than no check. +// +// Scope is the engagement subsystem plus the mail path it owns, not the whole +// server: core legitimately talks to hosts an operator configured elsewhere +// (ntfy, Discord, the sidecar), and those are not this rule's business. + +const fs = require('fs') +const path = require('path') + +const ROOT = path.resolve(__dirname, '..') + +// The trees the rule covers. `server/src/engagement/` is where transports and, +// later, the rules engine live; `utils/mailer.js` is the one file outside it that +// composes and sends mail. +const TREES = [path.join(ROOT, 'server', 'src', 'engagement')] +const FILES = [path.join(ROOT, 'server', 'src', 'utils', 'mailer.js')] + +const SKIP_DIRS = new Set(['node_modules', 'coverage', 'dist', '.git']) +const CODE = new Set(['.js', '.jsx', '.mjs', '.cjs']) + +// A URL, or a bare dotted hostname with a real TLD. The TLD length floor is what +// keeps `emailConfig.model` and `foo.js` out of it — a two-plus-letter final +// label after at least one dot, with no path characters, is a host. +const URL_LITERAL = /\b(?:https?|smtps?):\/\/[^\s'"`]+/ +const HOSTNAME_LITERAL = /\b(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+(?:com|net|org|io|dev|co|email|mail|cloud|app|us|eu)\b/i + +// Hosts that are not destinations: the loopback family, and the RFC 2606 names +// reserved for documentation. A placeholder in an admin form's help text is the +// opposite of a phone-home — it shows the operator the SHAPE of a value they +// must supply, and blanking it would make the form worse to hold the rule. +const ALLOWED = [ + /^(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0)$/i, + /(?:^|\.)example\.(?:com|net|org)$/i, + /(?:^|\.)(?:invalid|test|localhost)$/i, +] + +const isAllowed = (host) => ALLOWED.some((re) => re.test(host)) + +const hostOf = (literal) => { + const withoutScheme = literal.replace(/^[a-z]+:\/\//i, '') + return withoutScheme.split(/[/?#:]/)[0] +} + +/** + * Blank comments and mask string bodies in one left-to-right pass, keeping every + * offset aligned so reported line numbers stay honest. + * + * Lifted from `checkModuleIdentifiers.maskCode` deliberately rather than + * imported: that file's masking is tuned to ITS four checks (it keeps quotes so a + * route-path check can re-read the original at the same offsets), and coupling + * two checks through a shared helper means a change made for one silently + * re-scopes the other. Both are ~40 lines and both are tested. + */ +function maskComments(src) { + const out = Array.from(src) + const blank = (from, to) => { + for (let i = from; i < to && i < out.length; i++) if (out[i] !== '\n') out[i] = ' ' + } + let i = 0 + while (i < src.length) { + const c = src[i] + const next = src[i + 1] + if (c === '/' && next === '/') { + let j = i + while (j < src.length && src[j] !== '\n') j++ + blank(i, j) + i = j + continue + } + if (c === '/' && next === '*') { + const end = src.indexOf('*/', i + 2) + const j = end === -1 ? src.length : end + 2 + blank(i, j) + i = j + continue + } + if (c === '"' || c === "'" || c === '`') { + let j = i + 1 + while (j < src.length) { + if (src[j] === '\\') { j += 2; continue } + if (src[j] === c) break + j++ + } + // Keep the string body: it is what this check reads. Only the delimiters + // matter for finding it, and comments are what has to go. + i = j + 1 + continue + } + i++ + } + return out.join('') +} + +// Every string literal in the (comment-free) source, with its line number. +const STRING = /(['"`])((?:\\.|(?!\1)[^\\])*)\1/g + +function lineOf(src, index) { + return src.slice(0, index).split('\n').length +} + +/** Check one file's contents. Returns [{ file, line, literal, host }]. */ +function checkFile(rel, src) { + const hits = [] + const code = maskComments(src) + for (const m of code.matchAll(STRING)) { + const value = m[2] + if (!value) continue + const urlMatch = value.match(URL_LITERAL) + const hostMatch = urlMatch ? null : value.match(HOSTNAME_LITERAL) + const literal = urlMatch ? urlMatch[0] : hostMatch ? hostMatch[0] : null + if (!literal) continue + const host = hostOf(literal) + if (isAllowed(host)) continue + hits.push({ file: rel, line: lineOf(src, m.index), literal, host }) + } + return hits +} + +function walk(dir, out = []) { + if (!fs.existsSync(dir)) return out + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (SKIP_DIRS.has(entry.name)) continue + const full = path.join(dir, entry.name) + if (entry.isDirectory()) walk(full, out) + else out.push(full) + } + return out +} + +function run() { + const files = [...TREES.flatMap((t) => walk(t)), ...FILES.filter((f) => fs.existsSync(f))] + const hits = [] + for (const file of files) { + if (!CODE.has(path.extname(file))) continue + const rel = path.relative(ROOT, file).split(path.sep).join('/') + hits.push(...checkFile(rel, fs.readFileSync(file, 'utf8'))) + } + return hits +} + +module.exports = { run, checkFile, maskComments, isAllowed, hostOf } + +if (require.main === module) { + const hits = run() + if (hits.length === 0) { + console.log('OK — the engagement subsystem names no external host (ENGAGEMENT.md §3.2 rule 4).') + process.exit(0) + } + console.error( + `\nThe engagement subsystem names ${hits.length} external host${hits.length === 1 ? '' : 's'} ` + + 'in code (ENGAGEMENT.md §3.2 rule 4). A destination belongs in operator-supplied ' + + 'configuration, never in a literal:\n', + ) + for (const h of hits) { + console.error(` ${h.file}:${h.line} "${h.literal}"`) + } + console.error( + '\nIf this is help text or documentation rather than a destination, put it in a comment or ' + + 'use an example.com placeholder — the check masks comments and allows the reserved ' + + 'documentation names on purpose.\n', + ) + process.exit(1) +} diff --git a/server/db/schema.sql b/server/db/schema.sql index c20fb32..f6cfb91 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -327,19 +327,33 @@ CREATE TABLE IF NOT EXISTS bot_config ( CONSTRAINT chk_bot_config_singleton CHECK (id = 1) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; --- Outbound email configuration (Gmail over OAuth2 / SMTP XOAUTH2). Singleton row --- (id = 1), mirroring bot_config: the DB only ever holds the AES-256-GCM-encrypted --- refresh token, never plaintext, and the client id/secret are NOT stored here — --- they are read live from the `google` auth_providers row. The refresh token is --- captured by the in-app "Connect Gmail" consent flow and is write-only over the --- admin API (never returned; responses expose only hasRefreshToken). +-- Outbound email configuration. Singleton row (id = 1), mirroring bot_config: the +-- DB only ever holds the AES-256-GCM-encrypted credential, never plaintext, and it +-- is write-only over the admin API (never returned; responses expose only +-- hasCredential and the non-secret fields the transport declares). +-- +-- `transport` names a registered mail transport (server/src/engagement/transports). +-- `credential_enc` is that transport's whole credential set as one encrypted JSON +-- blob rather than a column per field, because the field list is the transport's to +-- declare — SMTP wants host/port/secure/user/password, an API relay wants a domain +-- and a key, and a column per union member would make adding a transport a schema +-- change. ENGAGEMENT.md §3.1. +-- +-- `provider` and `refresh_token_enc` are DEPRECATED and no longer read: they held +-- the removed Gmail OAuth2 connection (ENGAGEMENT.md §1.2a). They are kept rather +-- than dropped under the additive-only discipline, and `refresh_token_enc` earns +-- its keep in the meantime as the marker for "this deployment had working mail +-- before the upgrade" — which is what the admin dashboard warning reads. CREATE TABLE IF NOT EXISTS email_config ( id INT PRIMARY KEY DEFAULT 1, - provider VARCHAR(20) NOT NULL DEFAULT 'gmail_oauth2', + provider VARCHAR(20) NOT NULL DEFAULT 'gmail_oauth2', -- DEPRECATED, unread + transport VARCHAR(32) NOT NULL DEFAULT 'smtp', enabled TINYINT(1) NOT NULL DEFAULT 0, - sender_email VARCHAR(255) NULL, -- connected Gmail address (from userinfo) + sender_email VARCHAR(255) NULL, -- envelope From, operator-typed sender_name VARCHAR(120) NULL, -- optional From display name - refresh_token_enc TEXT NULL, -- AES-256-GCM ciphertext, never exposed + reply_to VARCHAR(255) NULL, -- optional Reply-To for sent mail + credential_enc TEXT NULL, -- AES-256-GCM ciphertext (JSON), never exposed + refresh_token_enc TEXT NULL, -- DEPRECATED, unread; see above status VARCHAR(20) NOT NULL DEFAULT 'unconfigured', status_detail VARCHAR(500) NULL, last_verified_at DATETIME NULL, @@ -1464,3 +1478,26 @@ ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS last_used_at DATETIME -- trust token. A boolean only — the token is returned over that app→server call -- and never persisted here (only its sha256 lands in trusted_devices). ALTER TABLE mobile_auth_sessions ADD COLUMN IF NOT EXISTS trust_device TINYINT(1) NOT NULL DEFAULT 0; + +-- ── Engagement Phase 1: Gmail OAuth2 removed, SMTP is the baseline ────────── +-- (ENGAGEMENT.md §1.2a). Additive on an upgraded database: `transport` backfills +-- to 'smtp' for every existing row, and `credential_enc` starts NULL — so an +-- upgraded deployment is deliberately CREDENTIAL-LESS until its operator supplies +-- SMTP settings. That is the whole point of the G22 warning below: nothing about +-- this fails loudly, so something has to say it out loud. +ALTER TABLE email_config ADD COLUMN IF NOT EXISTS transport VARCHAR(32) NOT NULL DEFAULT 'smtp'; +ALTER TABLE email_config ADD COLUMN IF NOT EXISTS credential_enc TEXT NULL; +ALTER TABLE email_config ADD COLUMN IF NOT EXISTS reply_to VARCHAR(255) NULL; + +-- The status a Gmail-connected deployment carries is 'connected', and after the +-- upgrade that is a lie: nothing can send. Correct it once, narrowly. The WHERE +-- makes this idempotent and self-limiting — it matches only a row that still holds +-- a Gmail refresh token AND has no replacement credential, so re-running it after +-- the operator configures SMTP touches nothing, and it can never overwrite a real +-- status recorded by a later send. +UPDATE email_config + SET status = 'unconfigured', + status_detail = 'Gmail OAuth2 was removed. Configure SMTP credentials in Admin - Settings - Email.' + WHERE refresh_token_enc IS NOT NULL + AND credential_enc IS NULL + AND status <> 'unconfigured'; diff --git a/server/routes.guards.json b/server/routes.guards.json index 05b5ce9..20e99fe 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -199,7 +199,7 @@ { "method": "PUT", "path": "/api/v1/admin/email/config", - "handlers": 5, + "handlers": 9, "gates": [ "noindex", "requireAuth", @@ -207,24 +207,6 @@ "validate" ] }, - { - "method": "GET", - "path": "/api/v1/admin/email/connect/callback", - "handlers": 2, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "GET", - "path": "/api/v1/admin/email/connect/start", - "handlers": 2, - "gates": [ - "noindex", - "requireAuth" - ] - }, { "method": "POST", "path": "/api/v1/admin/email/disconnect", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index 178300d..1c98c66 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -89,14 +89,6 @@ "method": "PUT", "path": "/api/v1/admin/email/config" }, - { - "method": "GET", - "path": "/api/v1/admin/email/connect/callback" - }, - { - "method": "GET", - "path": "/api/v1/admin/email/connect/start" - }, { "method": "POST", "path": "/api/v1/admin/email/disconnect" diff --git a/server/src/engagement/index.js b/server/src/engagement/index.js new file mode 100644 index 0000000..e64adab --- /dev/null +++ b/server/src/engagement/index.js @@ -0,0 +1,22 @@ +// ── The engagement subsystem — one door ──────────────────────────────────── +// +// ENGAGEMENT.md Phase 1. Today this is the mail transport registry and core's +// own transports; the trigger registry, the rules engine and the delivery +// channels arrive in later phases and hang here too. +// +// **Core's transports register through the same door a module's would**, and +// they register HERE rather than at the bottom of the registry file. That keeps +// the registry free of any knowledge of its registrants — the same reason +// `registerCore()` is called from app.js rather than from inside +// `modules/registries.js` (MODULE_API.md §7.6) — and it means requiring the +// registry never has the side effect of populating it. +// +// Requiring this module is what makes `smtp` available. Everything that resolves +// a transport goes through here, so there is exactly one place a transport can +// come into existence. + +require('./transports/smtp') + +const transports = require('./transports') + +module.exports = { transports } diff --git a/server/src/engagement/transports/index.js b/server/src/engagement/transports/index.js new file mode 100644 index 0000000..3a7d7b3 --- /dev/null +++ b/server/src/engagement/transports/index.js @@ -0,0 +1,195 @@ +// ── The mail transport registry ──────────────────────────────────────────── +// +// ENGAGEMENT.md §3.1, Phase 1. A **channel** is what kind of sink this is (email, +// push, in-app); a **transport** is how one channel actually delivers. This file +// is the second half only. The channel registry arrives with the engine that +// consumes it — registering a channel nothing calls would be a shape frozen +// before anything had tried to use it. +// +// What this replaces: `mailer.buildTransport()` had Gmail's host, port and +// OAuth2 auth type as literals, so "which provider" was a code edit. Now the +// stored `email_config.transport` names a registration, and the registration +// declares its own credential fields — which drives the admin form, the encrypted +// blob's shape and the validation, from one place. +// +// **`credentialFields` is the contract.** It is read by three consumers that +// would otherwise drift: the admin form renders it, `sanitizeCredential()` below +// filters a submitted body through it, and `describe()` tells the client which +// values are secret so they are never sent back. Adding a field to a transport is +// therefore one edit, not four. +// +// **No transport may carry a default host, endpoint or sender** (§3.2 rule 1). A +// transport with no operator configuration is `unconfigured` and its channel is +// off — it never falls back to somewhere we chose. `scripts/checkNoExternalHosts.js` +// is the CI backstop for that rule; this file is where it would be broken first. +// +// Nothing here touches the database or the network at require time. + +const log = require('../../utils/logger')('mailer') + +// id → transport definition +const transports = new Map() + +// Field kinds the admin form knows how to render. `secret` is the only one that +// changes behaviour server-side: it is write-only, so an unchanged value arrives +// as '' and must be read from the stored credential rather than overwritten. +const FIELD_KINDS = new Set(['text', 'number', 'secret', 'boolean']) + +/** + * Register a mail transport. Shape-checked at the call and collision-checked + * here, the same validate-then-commit discipline `modules/registries.js` uses. + * + * @param {object} def + * @param {string} def.id stable id stored in email_config.transport + * @param {string} def.label human name for the admin form + * @param {Array} def.credentialFields [{ key, label, kind, required, help, default }] + * @param {Function} def.build (credential, config) → a nodemailer-shaped transport + * @param {Function} def.isComplete (credential) → boolean; are the required fields present + */ +function registerMailTransport(def) { + if (!def || typeof def !== 'object') throw new Error('registerMailTransport: definition required') + const { id, label, credentialFields, build, isComplete } = def + if (typeof id !== 'string' || !/^[a-z][a-z0-9_-]*$/.test(id)) { + throw new Error(`registerMailTransport: invalid id ${JSON.stringify(id)}`) + } + if (transports.has(id)) throw new Error(`registerMailTransport: ${id} is already registered`) + if (typeof label !== 'string' || !label) throw new Error(`registerMailTransport(${id}): label required`) + if (!Array.isArray(credentialFields) || credentialFields.length === 0) { + throw new Error(`registerMailTransport(${id}): credentialFields required`) + } + for (const f of credentialFields) { + if (!f || typeof f.key !== 'string' || !f.key) { + throw new Error(`registerMailTransport(${id}): every credential field needs a key`) + } + if (!FIELD_KINDS.has(f.kind)) { + throw new Error(`registerMailTransport(${id}): field ${f.key} has unknown kind ${f.kind}`) + } + } + if (typeof build !== 'function') throw new Error(`registerMailTransport(${id}): build() required`) + if (typeof isComplete !== 'function') throw new Error(`registerMailTransport(${id}): isComplete() required`) + + transports.set(id, { ...def, credentialFields: credentialFields.map((f) => ({ ...f })) }) + return id +} + +/** The registered transport, or null. Callers must handle null — a stored id can + * name a transport that no longer exists (a downgrade, a removed provider), and + * that must degrade to "unconfigured", never throw at send time. */ +function get(id) { + return transports.get(id) || null +} + +function has(id) { + return transports.has(id) +} + +/** Every transport, as the admin form needs it: no functions, secrets flagged. */ +function describe() { + return [...transports.values()].map((t) => ({ + id: t.id, + label: t.label, + help: t.help || null, + credentialFields: t.credentialFields.map((f) => ({ + key: f.key, + label: f.label || f.key, + kind: f.kind, + required: Boolean(f.required), + help: f.help || null, + default: f.default === undefined ? null : f.default, + placeholder: f.placeholder || null, + })), + })) +} + +/** + * Filter a submitted credential body down to the transport's declared fields, + * coercing each to its declared kind. Anything not declared is dropped — the + * blob that reaches `secretBox.encrypt` only ever holds fields a transport asked + * for, so a client cannot smuggle extra keys into stored ciphertext. + * + * `secret` fields submitted empty are OMITTED rather than blanked, which is the + * "leave the existing one alone" convention `botConfig.save`/`emailConfig.save` + * already use; `mergeCredential()` is what puts the stored value back. + */ +function sanitizeCredential(id, body) { + const t = get(id) + if (!t) return {} + const out = {} + for (const f of t.credentialFields) { + if (!(f.key in (body || {}))) continue + const raw = body[f.key] + if (f.kind === 'secret') { + if (raw === undefined || raw === null || raw === '') continue + out[f.key] = String(raw) + } else if (f.kind === 'number') { + const n = Number(raw) + if (Number.isFinite(n)) out[f.key] = n + } else if (f.kind === 'boolean') { + out[f.key] = Boolean(raw) + } else { + out[f.key] = raw === null || raw === undefined ? '' : String(raw) + } + } + return out +} + +/** Stored credential + the submitted patch. Omitted secrets keep their stored value. */ +function mergeCredential(id, stored, patch) { + return { ...(stored || {}), ...(patch || {}) } +} + +/** Non-secret fields only — safe to return over the admin API. */ +function publicCredential(id, credential) { + const t = get(id) + if (!t || !credential) return {} + const out = {} + for (const f of t.credentialFields) { + if (f.kind === 'secret') continue + if (credential[f.key] !== undefined) out[f.key] = credential[f.key] + } + return out +} + +/** Which declared secrets are actually held, so the form can say "set" without + * ever returning the value. */ +function secretsPresent(id, credential) { + const t = get(id) + if (!t) return {} + const out = {} + for (const f of t.credentialFields) { + if (f.kind !== 'secret') continue + out[f.key] = Boolean(credential && credential[f.key]) + } + return out +} + +/** Does this credential have everything its transport needs to send? */ +function isComplete(id, credential) { + const t = get(id) + if (!t) return false + try { + return Boolean(t.isComplete(credential || {})) + } catch (err) { + log.warn('transport isComplete threw', { transport: id, message: err.message }) + return false + } +} + +// Test-only: the registry is module-level state and a suite that registers a +// fake transport must be able to undo it. +function _reset() { + transports.clear() +} + +module.exports = { + registerMailTransport, + get, + has, + describe, + sanitizeCredential, + mergeCredential, + publicCredential, + secretsPresent, + isComplete, + _reset, +} diff --git a/server/src/engagement/transports/smtp.js b/server/src/engagement/transports/smtp.js new file mode 100644 index 0000000..8450076 --- /dev/null +++ b/server/src/engagement/transports/smtp.js @@ -0,0 +1,96 @@ +// ── SMTP — the baseline mail transport ───────────────────────────────────── +// +// ENGAGEMENT.md decision 4: Gmail OAuth2 is removed, SMTP is the baseline. This +// is the only registered transport, and it is deliberately plain SMTP rather than +// anything provider-shaped — a relay (Mailgun, SES, Postmark), a self-hosted MTA +// and Gmail-with-an-app-password are all reachable through these five fields, so +// one transport covers all three postures §7.1 Q5 asks to document. +// +// **No defaults for host, port, user or sender.** §3.2 rule 1: a transport with +// no operator configuration is unconfigured, never pointed at somewhere we chose. +// `secure` gets a default because it is a protocol choice, not a destination — and +// even that is only a form default, not a fallback applied to a stored blank. +// +// **`secure` is the field operators get wrong**, so its help text says which port +// each setting means: `secure: true` is implicit TLS on 465, `secure: false` is +// plaintext-then-STARTTLS on 587 (which nodemailer upgrades automatically). The +// combination that silently fails is 587 with secure on — the handshake hangs +// rather than erroring cleanly — which is exactly why "Send test" is the real +// verification path now (§1.2a consequence 2). + +const nodemailer = require('nodemailer') + +const registry = require('./index') + +const CREDENTIAL_FIELDS = [ + { + key: 'host', + label: 'SMTP host', + kind: 'text', + required: true, + placeholder: 'smtp.example.com', + help: 'Your relay or mail server. No default — nothing is sent until you set this.', + }, + { + key: 'port', + label: 'Port', + kind: 'number', + required: true, + default: 587, + help: '587 for STARTTLS (most relays), 465 for implicit TLS, 25 for an unauthenticated local MTA.', + }, + { + key: 'secure', + label: 'Implicit TLS', + kind: 'boolean', + required: false, + default: false, + help: 'On for port 465. Leave off for 587 — the connection still upgrades to TLS via STARTTLS.', + }, + { + key: 'user', + label: 'Username', + kind: 'text', + required: false, + help: 'Leave blank for an unauthenticated local relay.', + }, + { + key: 'password', + label: 'Password / API key', + kind: 'secret', + required: false, + help: 'Stored encrypted and never returned. For Gmail this is an app password, not the account password.', + }, +] + +// Authentication is optional (a local MTA on port 25 needs none), so the only +// hard requirement is a destination. A username without a password is not +// "complete" — that combination authenticates as nobody and fails at the server. +function isComplete(credential) { + const c = credential || {} + if (!c.host || !Number(c.port)) return false + if (c.user && !c.password) return false + return true +} + +function build(credential) { + const c = credential || {} + const options = { + host: String(c.host), + port: Number(c.port), + secure: Boolean(c.secure), + } + if (c.user) options.auth = { user: String(c.user), pass: String(c.password || '') } + return nodemailer.createTransport(options) +} + +registry.registerMailTransport({ + id: 'smtp', + label: 'SMTP', + help: 'Any SMTP relay or mail server. See the operator guide for the three supported postures.', + credentialFields: CREDENTIAL_FIELDS, + isComplete, + build, +}) + +module.exports = { CREDENTIAL_FIELDS, isComplete, build } diff --git a/server/src/model/emailConfig/emailConfig.db.js b/server/src/model/emailConfig/emailConfig.db.js index 5927241..874ab05 100644 --- a/server/src/model/emailConfig/emailConfig.db.js +++ b/server/src/model/emailConfig/emailConfig.db.js @@ -1,7 +1,8 @@ const singletonConfigDb = require('../singletonConfigDb') const COLS = - 'id, provider, enabled, sender_email, sender_name, refresh_token_enc, status, status_detail, last_verified_at, updated_by, created_at, updated_at' + 'id, provider, transport, enabled, sender_email, sender_name, reply_to, credential_enc, refresh_token_enc, ' + + 'status, status_detail, last_verified_at, updated_by, created_at, updated_at' // Singleton row (id = 1). See ../singletonConfigDb for the get/upsert contract. module.exports = singletonConfigDb('email_config', COLS) diff --git a/server/src/model/emailConfig/emailConfig.model.js b/server/src/model/emailConfig/emailConfig.model.js index 7570857..ee28272 100644 --- a/server/src/model/emailConfig/emailConfig.model.js +++ b/server/src/model/emailConfig/emailConfig.model.js @@ -1,30 +1,69 @@ -// Outbound email config store (Gmail OAuth2). Mirrors the botConfig model split: -// the DB layer only ever sees ciphertext, and only getWithSecret() (used by the -// mailer at send time) decrypts the refresh token. The admin-facing getSafe() -// never includes it — callers see only `hasRefreshToken`. +// Outbound email config store. Mirrors the botConfig model split: the DB layer +// only ever sees ciphertext, and only getWithSecret() (used by the mailer at send +// time) decrypts the credential. The admin-facing getSafe() never includes it — +// callers see the non-secret fields plus which secrets are set. +// +// The credential is ONE encrypted JSON blob, not a column per field, because the +// field list belongs to the transport (ENGAGEMENT.md §3.1). `credential_enc` +// holds `{ host, port, secure, user, password }` for `smtp`; a future relay's +// blob would hold different keys against the same column. +// +// `provider` and `refresh_token_enc` are the removed Gmail OAuth2 connection +// (§1.2a). They are no longer read as configuration — `hadLegacyConnection` +// exposes the token column's presence for one purpose only: telling an upgraded +// deployment that its mail just stopped. const db = require('./emailConfig.db') const secretBox = require('../../utils/secretBox') +const { transports } = require('../../engagement') -function toSafe(row) { +const DEFAULT_TRANSPORT = 'smtp' + +// A stored blob that will not parse is treated as ABSENT, never as an error — +// the same fail-safe rule utils/settingsJson.js applies. A deployment whose +// SECRET_ENC_KEY was rotated must degrade to "unconfigured" and say so on the +// admin screen, not 500 the settings page and the contact form with it. +function readCredential(row) { + if (!row || !row.credential_enc) return null + try { + const parsed = JSON.parse(secretBox.decrypt(row.credential_enc)) + return parsed && typeof parsed === 'object' ? parsed : null + } catch { + return null + } +} + +function toSafe(row, credential) { + const transport = (row && row.transport) || DEFAULT_TRANSPORT if (!row) { return { - provider: 'gmail_oauth2', + transport: DEFAULT_TRANSPORT, enabled: false, senderEmail: null, senderName: null, - hasRefreshToken: false, + replyTo: null, + credential: {}, + secretsSet: transports.secretsPresent(DEFAULT_TRANSPORT, null), + hasCredential: false, + hadLegacyConnection: false, status: 'unconfigured', statusDetail: null, lastVerifiedAt: null, } } return { - provider: row.provider || 'gmail_oauth2', + transport, enabled: Boolean(row.enabled), senderEmail: row.sender_email || null, senderName: row.sender_name || null, - hasRefreshToken: Boolean(row.refresh_token_enc), + replyTo: row.reply_to || null, + credential: transports.publicCredential(transport, credential), + secretsSet: transports.secretsPresent(transport, credential), + hasCredential: transports.isComplete(transport, credential), + // Deliberately the raw column, not "is Gmail configured": nothing reads the + // token any more. It answers "did this deployment have working mail before + // the upgrade?", which is the G22 warning's whole condition. + hadLegacyConnection: Boolean(row.refresh_token_enc), status: row.status || 'unconfigured', statusDetail: row.status_detail || null, lastVerifiedAt: row.last_verified_at || null, @@ -32,38 +71,53 @@ function toSafe(row) { } async function getSafe() { - return toSafe(await db.get()) + const row = await db.get() + return toSafe(row, readCredential(row)) } -// Decrypted refresh token included — server-side only (building the mailer's -// OAuth2 transport). Returns null when no row exists yet. +// Decrypted credential included — server-side only (building the transport at +// send time). Returns null when no row exists yet. async function getWithSecret() { const row = await db.get() if (!row) return null - return { - ...toSafe(row), - refreshToken: row.refresh_token_enc ? secretBox.decrypt(row.refresh_token_enc) : null, - } + const credential = readCredential(row) + return { ...toSafe(row, credential), credentialSecret: credential || {} } } -// Save admin-supplied / connect-flow config. `refreshToken` undefined or '' means -// "leave the existing token unchanged" (same convention as botConfig.save). -async function save({ senderEmail, senderName, refreshToken, enabled, status, statusDetail, updatedBy }) { +// Save admin-supplied config. `credential` is a PATCH, merged over the stored +// blob: a secret field submitted empty is omitted by the registry's sanitizer and +// therefore keeps its stored value (same convention as botConfig.save). +async function save({ transport, senderEmail, senderName, replyTo, credential, enabled, status, statusDetail, updatedBy }) { + const row = await db.get() const fields = {} + const nextTransport = transport !== undefined ? transport : (row && row.transport) || DEFAULT_TRANSPORT + if (transport !== undefined) fields.transport = transport if (senderEmail !== undefined) fields.sender_email = senderEmail if (senderName !== undefined) fields.sender_name = senderName - if (refreshToken) fields.refresh_token_enc = secretBox.encrypt(refreshToken) + if (replyTo !== undefined) fields.reply_to = replyTo + if (credential !== undefined) { + // Changing transport starts from an empty credential rather than merging one + // transport's fields into another's — an SMTP password left inside a relay's + // blob is a stored secret nobody can see and nothing will ever use. + const base = row && row.transport === nextTransport ? readCredential(row) : null + const merged = transports.mergeCredential(nextTransport, base, transports.sanitizeCredential(nextTransport, credential)) + fields.credential_enc = Object.keys(merged).length ? secretBox.encrypt(JSON.stringify(merged)) : null + } if (enabled !== undefined) fields.enabled = enabled ? 1 : 0 if (status !== undefined) fields.status = status if (statusDetail !== undefined) fields.status_detail = statusDetail if (updatedBy !== undefined) fields.updated_by = updatedBy - const row = await db.upsert(fields) - return toSafe(row) + const saved = await db.upsert(fields) + return toSafe(saved, readCredential(saved)) } -// Clear the stored credential and disable sending (admin "Disconnect"). +// Clear the stored credential and disable sending (admin "Clear credentials"). +// The legacy Gmail token goes too: this is the operator saying "there is no +// mailbox here", and leaving the deprecated column set would keep the G22 warning +// on screen for a deployment that has deliberately turned mail off. async function disconnect(updatedBy) { const row = await db.upsert({ + credential_enc: null, refresh_token_enc: null, sender_email: null, enabled: 0, @@ -72,7 +126,7 @@ async function disconnect(updatedBy) { last_verified_at: null, updated_by: updatedBy ?? null, }) - return toSafe(row) + return toSafe(row, readCredential(row)) } // Record the outcome of the last send / verification so the admin panel has @@ -86,7 +140,7 @@ async function recordStatus({ status, statusDetail, lastVerifiedAt } = {}) { } if (Object.keys(fields).length === 0) return getSafe() const row = await db.upsert(fields) - return toSafe(row) + return toSafe(row, readCredential(row)) } -module.exports = { getSafe, getWithSecret, save, disconnect, recordStatus } +module.exports = { getSafe, getWithSecret, save, disconnect, recordStatus, DEFAULT_TRANSPORT } diff --git a/server/src/model/teams/teamNotify.model.js b/server/src/model/teams/teamNotify.model.js index a085642..6f5d70f 100644 --- a/server/src/model/teams/teamNotify.model.js +++ b/server/src/model/teams/teamNotify.model.js @@ -5,8 +5,8 @@ // Push is opt-out: a user in one Team must never have to configure anything to be // tickled about it, and the per-Team mute is how they stop. Email is opt-IN // (`email_mode` defaults to `'off'`, deviating from §6.4 on the org lead's call): -// turning on Gmail in the admin panel must not start sending daily mail to every -// member of every Team on the deployment. +// configuring a mail transport in the admin panel must not start sending daily +// mail to every member of every Team on the deployment. // // Both are read the same way — COALESCE to the column default, never treat a // missing row as "unknown" — so the asymmetry lives in ONE place, the schema, and diff --git a/server/src/router/v1/admin/admin.controller.js b/server/src/router/v1/admin/admin.controller.js index c91fc7e..e542700 100644 --- a/server/src/router/v1/admin/admin.controller.js +++ b/server/src/router/v1/admin/admin.controller.js @@ -9,6 +9,7 @@ const trustedDevices = require('../../../model/trustedDevices/trustedDevices.mod const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model') const registries = require('../../../modules/registries') const announceJobs = require('../../../model/announceJobs/announceJobs.model') +const emailConfig = require('../../../model/emailConfig/emailConfig.model') const forumSettings = require('../../../model/teams/teamForumSettings.model') const pushDispatch = require('../../../utils/pushDispatch') const { cleanBody } = require('../../../utils/sanitizeHtml') @@ -55,6 +56,39 @@ async function announceIfNewlyPublished(post, transition) { } // ── Dashboard & site mode ───────────────────────────────────────────── + +// G22 — the Gmail removal degrades SILENTLY (ENGAGEMENT.md §1.2a consequence 3). +// On upgrade, `transport` backfills to `smtp` with no credentials, so every sink +// politely does nothing: the contact form falls back to `mailto`, invites surface +// a copyable link, password resets still answer a generic 200. Nothing breaks +// loudly, which is exactly the risk — email stops and nobody is told. +// +// So something has to say it. The condition is deliberately narrow: a deployment +// that still holds the deprecated Gmail refresh token (it had working mail) and +// has no replacement credential (it does not any more). A fresh install has never +// had mail and is not warned — a nag about a capability nobody asked for is a +// banner people learn to ignore, and this one has to be believed exactly once. +// +// Never fails the dashboard. A warning that can 500 the admin landing page is a +// worse bug than the one it reports. +async function emailWarning() { + try { + const c = await emailConfig.getSafe() + if (!c.hadLegacyConnection || c.hasCredential) return null + return { + code: 'EMAIL_TRANSPORT_MIGRATION', + message: + 'Outbound email is not configured. This deployment used the Gmail connect flow, which has been ' + + 'removed — mail is no longer being sent. Add SMTP credentials under Settings → Email. Gmail still ' + + 'works as an ordinary SMTP relay with an app password.', + href: '/admin/settings', + } + } catch (err) { + log.warn('dashboard email warning check failed', { message: err.message }) + return null + } +} + async function dashboard(req, res) { try { return res.json({ @@ -67,6 +101,7 @@ async function dashboard(req, res) { posts: await posts.counts(), users: await users.count(), }, + warnings: [await emailWarning()].filter(Boolean), recent_activity: await activity.list({ limit: 10 }), }) } catch (err) { diff --git a/server/src/router/v1/admin/dashboard.router.js b/server/src/router/v1/admin/dashboard.router.js index 2927d7d..2ff4106 100644 --- a/server/src/router/v1/admin/dashboard.router.js +++ b/server/src/router/v1/admin/dashboard.router.js @@ -12,7 +12,7 @@ // through toward another mount and 403 an editor on an unrelated route. Keep // gates per-route in this file. // -// GET /dashboard — stats overview, any staff role. +// GET /dashboard — stats overview + operator warnings, any staff role. // PUT /site-mode — live ↔ maintenance, admin only. // // Neither is the audit log (/activity) nor the bot-scoring state @@ -34,7 +34,7 @@ dashboardRouter.get( // #swagger.tags = ['Admin · Dashboard'] // #swagger.summary = 'Dashboard summary counts' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Summary: site mode, last change, post/user counts and recent activity', content: { "application/json": { schema: { type: "object", properties: { site_mode: { type: "string", example: "live" }, last_change: { type: "object", properties: { at: { type: "string", nullable: true }, by: { type: "string", nullable: true } } }, counts: { type: "object", properties: { posts: { type: "object", additionalProperties: true }, users: { type: "integer" } } }, recent_activity: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */ + /* #swagger.responses[200] = { description: 'Summary: site mode, last change, post/user counts and recent activity', content: { "application/json": { schema: { type: "object", properties: { site_mode: { type: "string", example: "live" }, last_change: { type: "object", properties: { at: { type: "string", nullable: true }, by: { type: "string", nullable: true } } }, counts: { type: "object", properties: { posts: { type: "object", additionalProperties: true }, users: { type: "integer" } } }, warnings: { type: "array", description: "Operator warnings needing action; empty when there is nothing to say", items: { type: "object", properties: { code: { type: "string", example: "EMAIL_TRANSPORT_MIGRATION" }, message: { type: "string" }, href: { type: "string" } } } }, recent_activity: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ ctrl.dashboard, ) diff --git a/server/src/router/v1/admin/email.router.js b/server/src/router/v1/admin/email.router.js index ff2c38c..092bdcc 100644 --- a/server/src/router/v1/admin/email.router.js +++ b/server/src/router/v1/admin/email.router.js @@ -1,9 +1,13 @@ -// Admin · Email — outbound mail delivery via Gmail OAuth2. +// Admin · Email — outbound mail delivery. // // Mounted at /api/v1/admin/email by admin/index.js, which already applied -// `noindex, isLoggedIn, staffOnly`. The modern replacement for env SMTP: the -// refresh token is captured by the connect flow below and is write-only over -// this API (stored encrypted by utils/secretBox.js, never returned). +// `noindex, isLoggedIn, staffOnly`. The credential for the selected transport is +// captured by PUT /config below and is write-only over this API (stored encrypted +// by utils/secretBox.js, never returned). +// +// Four routes. `/connect/start` and `/connect/callback` were deleted with the +// Gmail OAuth2 flow in engagement Phase 1 (ENGAGEMENT.md §1.2a) — SMTP has no +// redirect to bounce through, so a credential form is the whole of it. // // Admin-only, and kept as a per-route gate rather than a router-level `use` so // the middleware chain each route carries is unchanged by the move. @@ -21,9 +25,10 @@ const adminOnly = requireRole('admin') emailRouter.get( '/config', // #swagger.tags = ['Admin · Email'] - // #swagger.summary = 'Get email delivery config + status (admin only)' + // #swagger.summary = 'Get email delivery config, status and the transport catalog (admin only)' + // #swagger.description = 'Credentials are write-only: secret fields are never returned, only a per-field `secretsSet` flag. `transports` carries each registered transport's declared credential fields, which is what the admin form renders.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Config (refresh token stripped) + status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[200] = { description: 'Config (secrets stripped) + status + transport catalog', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ adminOnly, @@ -33,45 +38,32 @@ emailRouter.put( '/config', // #swagger.tags = ['Admin · Email'] // #swagger.summary = 'Update email delivery config (admin only)' - // #swagger.description = 'Set the From display name and enabled toggle. Enabling requires a connected Gmail account.' + // #swagger.description = 'Set the transport, sender identity, credentials and enabled toggle. `credential` is a patch against the stored blob — a secret field submitted empty keeps its stored value. Enabling requires complete credentials and a sender address.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { senderName: { type: "string" }, enabled: { type: "boolean" } } } } } } */ + /* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { transport: { type: "string", example: "smtp" }, senderEmail: { type: "string", format: "email" }, senderName: { type: "string" }, replyTo: { type: "string", format: "email" }, credential: { type: "object", additionalProperties: true }, enabled: { type: "boolean" } } } } } } */ /* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ - /* #swagger.responses[400] = { description: 'Cannot enable before connecting a mailbox', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[400] = { description: 'Unknown transport, or cannot enable without complete credentials and a sender address', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ adminOnly, + body('transport').optional().isString().trim().isLength({ min: 1, max: 32 }), + body('senderEmail').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }), body('senderName').optional({ values: 'null' }).isString().trim().isLength({ max: 120 }), + body('replyTo').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }), + // The credential's SHAPE is the transport's to declare, so it is validated by + // the registry's sanitizer (which drops anything undeclared) rather than by a + // field list duplicated here that would drift the first time a transport is + // added. All this asserts is that it is an object at all. + body('credential').optional().isObject(), body('enabled').optional().isBoolean(), validate, emailConfig.saveConfig, ) -emailRouter.get( - '/connect/start', - // #swagger.tags = ['Admin · Email'] - // #swagger.summary = 'Begin the Gmail OAuth2 connect flow (admin only)' - // #swagger.description = 'Returns { url } to redirect the browser to Google. Reuses the google SSO OAuth client.' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Authorization URL', content: { "application/json": { schema: { type: "object", properties: { url: { type: "string" } } } } } } */ - /* #swagger.responses[400] = { description: 'Google OAuth client not configured', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - adminOnly, - emailConfig.connectStart, -) -emailRouter.get( - '/connect/callback', - // #swagger.tags = ['Admin · Email'] - // #swagger.summary = 'OAuth2 callback — stores the refresh token, redirects to Settings' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[302] = { description: 'Redirect back to /admin/settings' } */ - adminOnly, - emailConfig.connectCallback, -) emailRouter.post( '/test', // #swagger.tags = ['Admin · Email'] // #swagger.summary = 'Send a test email (admin only)' + // #swagger.description = 'The real verification of the configuration — host, port, TLS mode, credentials, and whether the relay accepts the configured sender. Failures return a specific diagnostic.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { to: { type: "string", format: "email" } } } } } } */ /* #swagger.responses[200] = { description: 'Sent', content: { "application/json": { schema: { type: "object", properties: { sent: { type: "boolean" }, to: { type: "string" } } } } } } */ @@ -84,9 +76,9 @@ emailRouter.post( emailRouter.post( '/disconnect', // #swagger.tags = ['Admin · Email'] - // #swagger.summary = 'Disconnect Gmail and disable email (admin only)' + // #swagger.summary = 'Clear the stored credentials and disable email (admin only)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Disconnected config', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[200] = { description: 'Cleared config', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ adminOnly, diff --git a/server/src/router/v1/admin/emailConfig.controller.js b/server/src/router/v1/admin/emailConfig.controller.js index e6dad60..8493821 100644 --- a/server/src/router/v1/admin/emailConfig.controller.js +++ b/server/src/router/v1/admin/emailConfig.controller.js @@ -1,67 +1,37 @@ -// ── Admin: outbound email configuration (Gmail OAuth2) ───────────────────── +// ── Admin: outbound email configuration ──────────────────────────────────── // -// Modern replacement for env-var SMTP. Sending goes through Gmail over OAuth2; -// the admin connects the mailbox with an in-app consent flow that captures a -// refresh token. We reuse the existing `google` SSO OAuth client (its id/secret) -// rather than a second app — so the only per-mailbox secret is the refresh token, -// stored AES-GCM-encrypted and write-only over this API (never returned). +// Email is configured here, not via env vars. The admin picks a registered mail +// transport and fills in the fields that transport declares; the whole set is +// stored as one AES-GCM-encrypted blob and is write-only over this API — secret +// fields are never returned, only a per-field "is it set" flag. // -// The connect flow mirrors sso.controller.js: a signed httpOnly tx cookie carries -// the CSRF nonce + PKCE verifier across the redirect to Google and back. It differs -// only in scope (https://mail.google.com/ for SMTP XOAUTH2) and access_type=offline -// + prompt=consent, which guarantee a refresh token even on reconnect. +// Gmail OAuth2 and its consent flow were removed in engagement Phase 1 +// (ENGAGEMENT.md §1.2a). What went with it: two routes, the `email_oauth_tx` +// signed cookie, the PKCE verifier and CSRF nonce plumbing, the +// `https://mail.google.com/` scope, and the borrowed `google` auth-providers +// client. That last one was a real coupling — an admin rotating the Google SSO +// secret silently broke outbound mail, with nothing on either screen relating the +// two — and removing it is one of the better side effects of the decision. +// +// **The form is driven by the transport's `credentialFields`, not by this file.** +// getConfig ships the declarations to the client, saveConfig hands the submitted +// body to the registry's sanitizer, and neither one names a field. Adding a +// transport is a registration, not an edit here. const emailConfig = require('../../../model/emailConfig/emailConfig.model') -const authProviders = require('../../../model/authProviders/authProviders.model') const activity = require('../../../model/activity/activity.model') const mailer = require('../../../utils/mailer') -const GoogleProvider = require('../../../auth/providers/google.provider') -const ssoState = require('../../../auth/ssoState') -const token = require('../../../auth/token') +const { transports } = require('../../../engagement') const log = require('../../../utils/logger')('admin') -// Gmail scope grants SMTP (XOAUTH2) access; openid+email let us read back which -// address was connected. The narrower gmail.send scope only works via the Gmail -// API, not SMTP, so we need the full-access scope here. -const EMAIL_SCOPE = 'https://mail.google.com/ openid email' -const TX_COOKIE = 'email_oauth_tx' - -// Public base URL for the OAuth redirect_uri — same fallback pattern as -// sso.controller.js. Must be identical between start and callback. -function appBaseUrl(req) { - const configured = process.env.APP_BASE_URL - if (configured) return configured.replace(/\/+$/, '') - const derived = `${req.protocol}://${req.get('host')}` - log.warn('APP_BASE_URL not set — deriving email redirect_uri from the request', { derived }) - return derived -} -function redirectUri(req) { - return `${appBaseUrl(req)}/api/v1/admin/email/connect/callback` -} -function txCookieOptions(req) { - return { ...token.cookieOptions(req), maxAge: 10 * 60 * 1000 } -} - -// Front-end redirect targets after the callback resolves. -const CONNECTED_URL = '/admin/settings?email_connected=1' -const errorUrl = (code) => `/admin/settings?email_error=${code}` - -// Load the Google OAuth client (id + decrypted secret) reused for email. Returns -// null when the google provider hasn't been configured with credentials yet. -async function googleClient() { - const row = await authProviders.getWithSecret('google') - if (!row || !row.client_id || !row.client_secret) return null - return { clientId: row.client_id, clientSecret: row.client_secret } -} - // GET /admin/email/config async function getConfig(req, res) { try { const config = await emailConfig.getSafe() - // Surface whether the Google client email can borrow is configured, so the - // UI can explain why Connect is unavailable. - config.googleConfigured = Boolean(await googleClient()) + // The transport catalog rides with the config so the client renders the + // credential form from the same declarations the server validates against. + config.transports = transports.describe() return res.json(config) } catch (err) { log.error('emailConfig.getConfig', err) @@ -69,23 +39,50 @@ async function getConfig(req, res) { } } -// PUT /admin/email/config — sender name + enabled toggle. Enabling requires a -// connected mailbox (a stored refresh token). +// PUT /admin/email/config — transport, sender identity, credentials, enabled. +// +// Enabling requires a complete credential AND a sender address, checked against +// the state as it will be AFTER this save rather than before it: the admin fills +// the whole form and ticks Enable in one submit, and refusing that because the +// credential was absent a moment ago would make the screen impossible to use. async function saveConfig(req, res) { - const { senderName, enabled } = req.body + const { transport, senderEmail, senderName, replyTo, credential, enabled } = req.body try { const current = await emailConfig.getSafe() - if (enabled && !current.hasRefreshToken) { - return res.status(400).json({ message: 'Connect a Gmail account before enabling email.' }) + const nextTransport = transport === undefined ? current.transport : transport + if (!transports.has(nextTransport)) { + return res.status(400).json({ message: `Unknown mail transport "${nextTransport}".` }) } + const saved = await emailConfig.save({ + transport: transport !== undefined ? nextTransport : undefined, + senderEmail: senderEmail !== undefined ? senderEmail || null : undefined, senderName: senderName !== undefined ? senderName || null : undefined, - enabled, + replyTo: replyTo !== undefined ? replyTo || null : undefined, + credential, + // Enabling is applied only once the saved row can actually support it, so + // the response tells the truth about what is stored rather than echoing the + // request. The re-read below is what decides. + enabled: enabled === undefined ? undefined : Boolean(enabled), updatedBy: req.user.id, }) - saved.googleConfigured = Boolean(await googleClient()) - await activity.log({ req, action: 'email.config.update', detail: { enabled: saved.enabled } }) - log.info('email config updated', { by: req.user.username, enabled: saved.enabled }) + + if (saved.enabled && !(saved.hasCredential && saved.senderEmail)) { + const reverted = await emailConfig.save({ enabled: false, updatedBy: req.user.id }) + reverted.transports = transports.describe() + return res.status(400).json({ + message: 'Add complete credentials and a sender address before enabling email.', + config: reverted, + }) + } + + saved.transports = transports.describe() + await activity.log({ + req, + action: 'email.config.update', + detail: { transport: saved.transport, enabled: saved.enabled, hasCredential: saved.hasCredential }, + }) + log.info('email config updated', { by: req.user.username, transport: saved.transport, enabled: saved.enabled }) return res.json(saved) } catch (err) { log.error('emailConfig.saveConfig', err) @@ -93,96 +90,14 @@ async function saveConfig(req, res) { } } -// GET /admin/email/connect/start — returns { url } for the browser to navigate to. -async function connectStart(req, res) { - try { - const client = await googleClient() - if (!client) { - return res.status(400).json({ - message: 'Configure the Google authentication provider (client id + secret) before connecting email.', - }) - } - const provider = new GoogleProvider({ clientId: client.clientId, clientSecret: client.clientSecret }) - const tx = ssoState.createTx({ flow: 'email' }) - res.cookie(TX_COOKIE, tx.txToken, txCookieOptions(req)) - - const params = new URLSearchParams({ - client_id: client.clientId, - redirect_uri: redirectUri(req), - response_type: 'code', - scope: EMAIL_SCOPE, - access_type: 'offline', - prompt: 'consent', - include_granted_scopes: 'true', - state: tx.nonce, - code_challenge: tx.codeChallenge, - code_challenge_method: 'S256', - }) - const url = `${provider.authEndpoint()}?${params.toString()}` - return res.json({ url }) - } catch (err) { - log.error('emailConfig.connectStart', err) - return res.status(500).json({ message: 'Internal Server Error' }) - } -} - -// GET /admin/email/connect/callback — exchange the code, capture the refresh -// token + connected address, store encrypted, and redirect back to Settings. -async function connectCallback(req, res) { - const txToken = req.cookies && req.cookies[TX_COOKIE] - const { code, state, error: oauthError } = req.query - res.clearCookie(TX_COOKIE, token.cookieOptions(req)) // single-use - - if (oauthError) { - log.warn('email connect: provider returned error', { error: String(oauthError).slice(0, 60) }) - return res.redirect(errorUrl('denied')) - } - const tx = ssoState.verifyTx(txToken, state) - if (!tx || tx.flow !== 'email' || !code) { - log.warn('email connect: bad state') - return res.redirect(errorUrl('bad_state')) - } - try { - const client = await googleClient() - if (!client) return res.redirect(errorUrl('no_client')) - const provider = new GoogleProvider({ clientId: client.clientId, clientSecret: client.clientSecret }) - - const tokenSet = await provider.exchangeCode({ - code, - redirectUri: redirectUri(req), - codeVerifier: tx.verifier, - }) - if (!tokenSet.refresh_token) { - // Google only returns a refresh token when it hasn't already granted one - // for this client+scope. prompt=consent should force it; if it's still - // missing the admin can revoke the app's access and retry. - log.warn('email connect: no refresh_token returned') - return res.redirect(errorUrl('no_refresh_token')) - } - const profile = await provider.getUserProfile(tokenSet.access_token) - const senderEmail = profile.email || null - if (!senderEmail) return res.redirect(errorUrl('no_email')) - - await emailConfig.save({ - senderEmail, - refreshToken: tokenSet.refresh_token, - enabled: true, - status: 'connected', - statusDetail: 'Connected', - updatedBy: req.user.id, - }) - await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Connected', lastVerifiedAt: new Date() }) - await activity.log({ req, action: 'email.connect', detail: { senderEmail } }) - log.info('email connected', { senderEmail, by: req.user.username }) - return res.redirect(CONNECTED_URL) - } catch (err) { - log.error('emailConfig.connectCallback', err) - return res.redirect(errorUrl('error')) - } -} - // POST /admin/email/test — send a test message (to the given address, or the // contact recipient by default). +// +// This is the only verification the configuration gets. Under the removed consent +// flow the sending address came back from Google and was guaranteed to belong to +// the credential; an operator-typed sender the relay will not accept is a silent +// deliverability failure, so mailer.describeSendError puts the sender in the +// message and this hands it through verbatim. async function testSend(req, res) { try { const result = await mailer.sendTest(req.body.to) @@ -198,9 +113,9 @@ async function testSend(req, res) { async function disconnect(req, res) { try { const config = await emailConfig.disconnect(req.user.id) - config.googleConfigured = Boolean(await googleClient()) + config.transports = transports.describe() await activity.log({ req, action: 'email.disconnect' }) - log.info('email disconnected', { by: req.user.username }) + log.info('email credentials cleared', { by: req.user.username }) return res.json(config) } catch (err) { log.error('emailConfig.disconnect', err) @@ -208,4 +123,4 @@ async function disconnect(req, res) { } } -module.exports = { getConfig, saveConfig, connectStart, connectCallback, testSend, disconnect } +module.exports = { getConfig, saveConfig, testSend, disconnect } diff --git a/server/src/server.js b/server/src/server.js index 6a99914..fccd480 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -43,7 +43,7 @@ async function start() { logFile: createLogger.logFilePath || 'disabled (console only)', db: `${process.env.DB_HOST || '127.0.0.1'}:${process.env.DB_PORT || 3306}/${process.env.DB_NAME || 'runic_gateway'}`, cookieSecure: process.env.COOKIE_SECURE || 'auto', - email: 'gmail-oauth2 (configured in admin → settings)', + email: 'smtp (configured in admin → settings)', }) // Fail fast if the server<->bot shared secret is weak/placeholder. Fatal in diff --git a/server/src/utils/mailer.js b/server/src/utils/mailer.js index a8764ac..dc37ba4 100644 --- a/server/src/utils/mailer.js +++ b/server/src/utils/mailer.js @@ -1,65 +1,93 @@ -// ── Outbound mail (Gmail over OAuth2 / SMTP XOAUTH2) ─────────────────────── +// ── Outbound mail ────────────────────────────────────────────────────────── // // Email is configured in Admin → Settings → Email, not via env vars. The -// connection (enabled flag, connected Gmail address, encrypted refresh token) -// lives in the email_config singleton; the OAuth client id/secret are reused -// from the `google` auth_providers row. nodemailer takes the refresh token and -// auto-mints short-lived access tokens for each send. +// `email_config` singleton holds the enabled flag, the sender identity and the +// AES-GCM-encrypted credential for whichever transport is selected; the transport +// itself is a registration in `src/engagement/transports` (ENGAGEMENT.md §3.1), +// so which provider is used is DATA, not a code path in this file. // -// When email is not configured, sendContactMessage does NOT throw — it signals -// the caller to fall back to a mailto: link (the contact form relies on this). - -const nodemailer = require('nodemailer') +// Gmail OAuth2 was removed in engagement Phase 1 (§1.2a). Gmail is still reachable +// as an ordinary SMTP relay (`smtp.gmail.com:587` with an app password) — the +// operator types those in like any other host; nothing in here knows about it. +// +// **The failure contracts are the point of this file.** Six call sites, five +// senders, and each one degrades a specific way when mail is unconfigured. Those +// contracts are unchanged by the transport rewrite and are asserted in +// test/mailer.test.js: sendContactMessage returns a mailto fallback, sendInvite +// and sendPasswordReset return { sent: false, reason: 'NOT_CONFIGURED' } so their +// callers can surface a link / answer a generic 200, sendTeamNotification never +// throws at all, and only sendTest throws — because only sendTest has an admin +// waiting to be told why. const emailConfig = require('../model/emailConfig/emailConfig.model') -const authProviders = require('../model/authProviders/authProviders.model') const settings = require('../model/settings/settings.model') +const { transports } = require('../engagement') const brand = require('../config/brand') const log = require('./logger')('mailer') -// Ready to send only when enabled, connected (has a refresh token), and we know -// which address to send as. +// Ready to send only when enabled, holding a complete credential for a +// registered transport, and knowing which address to send as. async function isConfigured() { const c = await emailConfig.getSafe() - return Boolean(c.enabled && c.hasRefreshToken && c.senderEmail) + return Boolean(c.enabled && c.hasCredential && c.senderEmail) } // Recipient for the contact form: the admin-editable contact_email setting, or -// the connected sending address as a last resort. +// the configured sending address as a last resort. async function contactRecipient(senderEmail) { const to = await settings.get('contact_email') return to || senderEmail || null } -// Build a nodemailer OAuth2 transport from the stored config + reused Google -// client credentials. Returns { transport, config } or null when unconfigured. +/** + * Build a transport from the stored config. Returns { transport, config } or + * null when unconfigured — every sender handles null itself. + * + * Null, never a throw, for all five ways this can fail: no row, disabled, no + * sender address, an incomplete credential, or a stored transport id that is not + * registered (a downgrade, or a provider removed from the build). The last one is + * the reason `transports.get()` is checked rather than assumed: a send-time + * exception from an unknown id would break the contact form for a reason the + * admin screen already shows. + * + * **`enabled` is checked here now, and it was not before.** Under the connect + * flow this function tested only "is there a refresh token and a sender", so the + * contact form kept sending after an admin unticked "Enable email sending" — + * `isConfigured()` honoured the toggle but the direct senders bypassed it. With + * the toggle no longer set as a side effect of a consent redirect it has to mean + * what it says, so the gate lives on the one path every sender shares. + */ async function buildTransport() { const config = await emailConfig.getWithSecret() - if (!config || !config.refreshToken || !config.senderEmail) return null - const google = await authProviders.getWithSecret('google') - if (!google || !google.client_id || !google.client_secret) { - log.warn('email send skipped: Google OAuth client is not configured') + if (!config || !config.enabled || !config.senderEmail) return null + const def = transports.get(config.transport) + if (!def) { + log.warn('email send skipped: no such transport', { transport: config.transport }) + return null + } + if (!transports.isComplete(config.transport, config.credentialSecret)) { + log.warn('email send skipped: transport credentials are incomplete', { transport: config.transport }) + return null + } + try { + return { transport: def.build(config.credentialSecret, config), config } + } catch (err) { + log.error('could not build the mail transport', err) return null } - const transport = nodemailer.createTransport({ - host: 'smtp.gmail.com', - port: 465, - secure: true, - auth: { - type: 'OAuth2', - user: config.senderEmail, - clientId: google.client_id, - clientSecret: google.client_secret, - refreshToken: config.refreshToken, - }, - }) - return { transport, config } } function fromHeader(config) { return config.senderName ? `"${config.senderName}" <${config.senderEmail}>` : config.senderEmail } +// Reply-To is the operator's optional override; the contact form's per-message +// replyTo (the visitor's address) wins over it, which is the whole reason the +// contact form has one. +function replyToFor(config, override) { + return override || config.replyTo || undefined +} + /** * Send a contact message. If email is not configured/enabled, signals the caller * to fall back to a mailto: link instead of throwing. @@ -76,7 +104,7 @@ async function sendContactMessage({ name, email, message }) { await transport.sendMail({ from: fromHeader(config), to, - replyTo: email, + replyTo: replyToFor(config, email), subject: `${brand.name} contact from ${name || 'a visitor'}`, text: `From: ${name || 'unknown'} <${email || 'no email'}>\n\n${message}`, }) @@ -84,19 +112,49 @@ async function sendContactMessage({ name, email, message }) { return { sent: true } } catch (err) { log.error('contact send failed', err) - await emailConfig.recordStatus({ status: 'error', statusDetail: err.message }) + await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err) }) throw err } } +/** + * Turn a transport error into something an operator can act on. + * + * This matters more than it used to. Under Gmail OAuth2 the sending address came + * back from Google's userinfo and was guaranteed to be a mailbox the credential + * owned. Under SMTP `sender_email` is operator-typed, so a relay rejecting the + * envelope From is now a live failure mode (§1.2a consequence 2) — and it arrives + * as a bare "550 5.7.1" that means nothing without the sender in front of it. + */ +function describeSendError(err, config) { + const code = err && (err.responseCode || err.code) + const base = (err && (err.response || err.message)) || 'Send failed' + const sender = config && config.senderEmail + if (sender && (code === 550 || code === 553 || code === 554 || code === 'EENVELOPE')) { + return `${base} — the server refused "${sender}" as the sender. It must be an address this account is allowed to send as (SPF/DMARC).` + } + if (code === 'EAUTH') return `${base} — the username or password was rejected.` + if (code === 'ESOCKET' || code === 'ECONNECTION') { + return `${base} — could not connect. Check the host, the port, and whether "Implicit TLS" matches it (on for 465, off for 587).` + } + if (code === 'ETIMEDOUT') { + return `${base} — the connection timed out. A common cause is "Implicit TLS" left on for port 587.` + } + return String(base) +} + /** * Send a test email to `to`, used by the admin "Send test" button. Throws on * failure; records the outcome either way. Returns { sent: true } on success. + * + * **This is now the real verification of the whole configuration** — host, port, + * TLS mode, credentials AND whether the relay will accept the operator-typed + * sender. There is no consent flow left to prove any of it beforehand. */ async function sendTest(to) { const built = await buildTransport() if (!built) { - const err = new Error('Email is not configured. Connect Gmail first.') + const err = new Error('Email is not configured. Set a transport, its credentials and a sender address first.') err.code = 'NOT_CONFIGURED' throw err } @@ -111,15 +169,19 @@ async function sendTest(to) { await transport.sendMail({ from: fromHeader(config), to: recipient, + replyTo: replyToFor(config), subject: `${brand.name} email test`, - text: 'This is a test message confirming Gmail OAuth2 email delivery is working.', + text: `This is a test message confirming ${config.transport} email delivery is working.`, }) await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Test send OK', lastVerifiedAt: new Date() }) return { sent: true, to: recipient } } catch (err) { + const detail = describeSendError(err, config) log.error('test send failed', err) - await emailConfig.recordStatus({ status: 'error', statusDetail: err.message }) - throw err + await emailConfig.recordStatus({ status: 'error', statusDetail: detail }) + const wrapped = new Error(detail) + wrapped.code = err.code || 'SEND_FAILED' + throw wrapped } } @@ -140,6 +202,7 @@ async function sendInvite({ to, acceptUrl, role, invitedByName }) { await transport.sendMail({ from: fromHeader(config), to, + replyTo: replyToFor(config), subject: `Your ${brand.name} invitation`, text: `You have been invited${by} to join ${brand.name}${roleLabel}.\n\n` + @@ -150,7 +213,7 @@ async function sendInvite({ to, acceptUrl, role, invitedByName }) { return { sent: true } } catch (err) { log.error('invite send failed', err) - await emailConfig.recordStatus({ status: 'error', statusDetail: err.message }) + await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err, config) }) throw err } } @@ -171,6 +234,7 @@ async function sendPasswordReset({ to, resetUrl, username }) { await transport.sendMail({ from: fromHeader(config), to, + replyTo: replyToFor(config), subject: `Reset your ${brand.name} password`, text: `We received a request to reset the password${forWhom} at ${brand.name}.\n\n` + @@ -182,7 +246,7 @@ async function sendPasswordReset({ to, resetUrl, username }) { return { sent: true } } catch (err) { log.error('password reset send failed', err) - await emailConfig.recordStatus({ status: 'error', statusDetail: err.message }) + await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err, config) }) throw err } } @@ -234,6 +298,7 @@ async function sendTeamNotification({ to, subject, intro, items, teamUrl, unsubs await transport.sendMail({ from: fromHeader(config), to, + replyTo: replyToFor(config), subject, text: lines.join('\n'), // The header carries the API url, not the one in the body: a one-click @@ -252,7 +317,7 @@ async function sendTeamNotification({ to, subject, intro, items, teamUrl, unsubs // called by a request that can report the failure to whoever caused it; this // one is not, and recordStatus already puts the error where an admin reads it. log.warn('team notification send failed', { message: err.message }) - await emailConfig.recordStatus({ status: 'error', statusDetail: err.message }).catch(() => {}) + await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err, config) }).catch(() => {}) return { sent: false, reason: 'SEND_FAILED' } } } diff --git a/server/src/utils/teamNotify.js b/server/src/utils/teamNotify.js index e4294e5..0869014 100644 --- a/server/src/utils/teamNotify.js +++ b/server/src/utils/teamNotify.js @@ -211,8 +211,8 @@ async function forumPost({ team, threadId, threadTitle, type, authorUserId, auth * exactly that. * * Skipped entirely when no email is configured — §6.4's "off unless configured" - * — and checked BEFORE the recipient query so a deployment with no Gmail - * connected pays nothing for the sink it does not have. + * — and checked BEFORE the recipient query so a deployment with no mail + * transport configured pays nothing for the sink it does not have. */ async function emailImmediate({ team, threadId, threadTitle, type, exclude, authorName, bodyHtml }) { if (!(await mailer.isConfigured())) return 0 @@ -227,8 +227,8 @@ async function emailImmediate({ team, threadId, threadTitle, type, exclude, auth for (const r of recipients) { // Serial rather than Promise.all: this is an SMTP conversation per recipient - // against a provider with its own rate limits, and a burst of them from a - // busy thread is how a Gmail sender gets throttled. The loop is also why the + // against a relay with its own rate limits, and a burst of them from a + // busy thread is how a sending account gets throttled. The loop is also why the // send below is fire-and-report rather than fire-and-throw. // eslint-disable-next-line no-await-in-loop const res = await mailer.sendTeamNotification({ diff --git a/server/test/checkNoExternalHosts.test.js b/server/test/checkNoExternalHosts.test.js new file mode 100644 index 0000000..23f0aa4 --- /dev/null +++ b/server/test/checkNoExternalHosts.test.js @@ -0,0 +1,98 @@ +// Self-test for scripts/checkNoExternalHosts.js — ENGAGEMENT.md §3.2 rule 4. +// +// The same discipline checkModuleIdentifiers.test.js established: feed the +// checker code it MUST reject and code it MUST accept, because a check that +// silently stops checking is worse than no check. The rejection cases below are +// the exact shape of the literal this phase deleted. + +const { test } = require('node:test') +const assert = require('node:assert/strict') + +const { checkFile, maskComments, isAllowed, run } = require('../../scripts/checkNoExternalHosts') + +const hostsIn = (src) => checkFile('fake.js', src).map((h) => h.host) + +test('catches the literal this phase deleted', () => { + const src = ` + const transport = nodemailer.createTransport({ + host: 'smtp.gmail.com', + port: 465, + secure: true, + }) + ` + assert.deepEqual(hostsIn(src), ['smtp.gmail.com']) +}) + +test('catches an API base url, whatever the scheme', () => { + assert.deepEqual(hostsIn(`const BASE = 'https://api.mailgun.net/v3'`), ['api.mailgun.net']) + assert.deepEqual(hostsIn(`const relay = "smtps://mail.somewhere.io:465"`), ['mail.somewhere.io']) +}) + +test('catches a default sender address', () => { + assert.deepEqual(hostsIn(`const FROM = 'noreply@runicgateway.com'`), ['runicgateway.com']) +}) + +test('catches a host in a template literal', () => { + assert.deepEqual(hostsIn('const url = `https://api.postmarkapp.com/email`'), ['api.postmarkapp.com']) +}) + +test('reports the line the literal is on', () => { + const src = ['// a comment', '', "const h = 'smtp.sendgrid.net'"].join('\n') + assert.deepEqual(checkFile('fake.js', src), [ + { file: 'fake.js', line: 3, literal: 'smtp.sendgrid.net', host: 'smtp.sendgrid.net' }, + ]) +}) + +// ── the accept cases: the whole reason it reads code, not prose ───────────── + +test('a host named in a line comment is fine — that is the documentation this phase owes', () => { + assert.deepEqual(hostsIn(`// Gmail still works as plain SMTP: smtp.gmail.com:587 with an app password\nconst x = 1`), []) +}) + +test('a host named in a block comment is fine', () => { + assert.deepEqual(hostsIn(`/*\n * See https://mailgun.com/docs for the relay posture.\n */\nconst x = 1`), []) +}) + +test('example.com placeholders are allowed — a form hint is not a destination', () => { + assert.deepEqual(hostsIn(`const f = { placeholder: 'smtp.example.com' }`), []) + assert.deepEqual(hostsIn(`const f = { placeholder: 'noreply@example.com' }`), []) +}) + +test('loopback is allowed', () => { + assert.deepEqual(hostsIn(`const dev = 'http://127.0.0.1:3000'`), []) + assert.deepEqual(hostsIn(`const dev = 'http://localhost:1025'`), []) +}) + +test('a module path is not a hostname', () => { + assert.deepEqual(hostsIn(`const m = require('../model/emailConfig/emailConfig.model')`), []) + assert.deepEqual(hostsIn(`const n = require('nodemailer')`), []) + assert.deepEqual(hostsIn(`import x from './transports/smtp.js'`), []) +}) + +test('an ordinary sentence with a full stop is not a hostname', () => { + assert.deepEqual(hostsIn(`const msg = 'Send failed. Check the host and port.'`), []) +}) + +// ── the pieces, directly ──────────────────────────────────────────────────── + +test('maskComments blanks comments but keeps string bodies and line count', () => { + const src = "// smtp.gmail.com\nconst h = 'smtp.relay.net'\n" + const masked = maskComments(src) + assert.equal(masked.split('\n').length, src.split('\n').length) + assert.ok(!masked.includes('smtp.gmail.com')) + assert.ok(masked.includes('smtp.relay.net')) +}) + +test('isAllowed covers the reserved documentation names and nothing else', () => { + assert.equal(isAllowed('example.com'), true) + assert.equal(isAllowed('mail.example.org'), true) + assert.equal(isAllowed('localhost'), true) + assert.equal(isAllowed('smtp.gmail.com'), false) + assert.equal(isAllowed('api.postmarkapp.com'), false) +}) + +// ── and the real tree ─────────────────────────────────────────────────────── + +test('the shipped engagement tree is clean', () => { + assert.deepEqual(run(), []) +}) diff --git a/server/test/emailConfig.model.test.js b/server/test/emailConfig.model.test.js index 3d62677..2a24bf9 100644 --- a/server/test/emailConfig.model.test.js +++ b/server/test/emailConfig.model.test.js @@ -1,3 +1,7 @@ +// model/emailConfig — the credential store. Same guarantees as before the Gmail +// removal (ciphertext at rest, never returned, blank means "leave it alone"), +// now over one transport-shaped blob instead of a single refresh-token column. + process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key' process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' @@ -12,59 +16,122 @@ const db = require('../src/utils/db') after(() => db.close()) +const CRED = { host: 'relay.example.com', port: 587, secure: false, user: 'apikey', password: 'sec-abc' } + // In-memory stand-in for the singleton row so the model never touches MariaDB. let store beforeEach(() => { store = null emailDb.get = async () => store emailDb.upsert = async (fields) => { - store = { ...(store || { id: 1 }), ...fields } + store = { ...(store || { id: 1, transport: 'smtp' }), ...fields } return store } }) -test('save encrypts the refresh token (ciphertext at rest, decryptable)', async () => { - await emailConfig.save({ senderEmail: 'me@gmail.com', refreshToken: 'refresh-abc', enabled: true }) - assert.ok(store.refresh_token_enc) - assert.notEqual(store.refresh_token_enc, 'refresh-abc') - assert.equal(secretBox.decrypt(store.refresh_token_enc), 'refresh-abc') +const decrypted = () => JSON.parse(secretBox.decrypt(store.credential_enc)) + +test('save encrypts the credential (ciphertext at rest, decryptable)', async () => { + await emailConfig.save({ senderEmail: 'mail@shard.example.com', credential: CRED, enabled: true }) + assert.ok(store.credential_enc) + assert.ok(!String(store.credential_enc).includes('sec-abc')) + assert.deepEqual(decrypted(), CRED) const withSecret = await emailConfig.getWithSecret() - assert.equal(withSecret.refreshToken, 'refresh-abc') + assert.equal(withSecret.credentialSecret.password, 'sec-abc') }) -test('getSafe never leaks the refresh token', async () => { - await emailConfig.save({ senderEmail: 'me@gmail.com', refreshToken: 'refresh-abc', enabled: true }) +test('getSafe never leaks a secret field, but says which are set', async () => { + await emailConfig.save({ senderEmail: 'mail@shard.example.com', credential: CRED, enabled: true }) const safe = await emailConfig.getSafe() - assert.equal(safe.hasRefreshToken, true) - assert.equal(safe.senderEmail, 'me@gmail.com') - assert.equal('refreshToken' in safe, false) - assert.equal('refresh_token_enc' in safe, false) + + assert.deepEqual(safe.credential, { host: 'relay.example.com', port: 587, secure: false, user: 'apikey' }) + assert.equal('password' in safe.credential, false) + assert.deepEqual(safe.secretsSet, { password: true }) + assert.equal(safe.hasCredential, true) + assert.equal(safe.senderEmail, 'mail@shard.example.com') + assert.equal('credentialSecret' in safe, false) + assert.equal('credential_enc' in safe, false) }) -test('blank refresh token on save leaves the existing one unchanged', async () => { - await emailConfig.save({ senderEmail: 'me@gmail.com', refreshToken: 'refresh-abc', enabled: true }) - const cipherBefore = store.refresh_token_enc +test('a blank secret leaves the stored one unchanged; other fields still save', async () => { + await emailConfig.save({ credential: CRED }) + const cipherBefore = store.credential_enc - await emailConfig.save({ senderName: 'UOMysticmoon' }) // no refreshToken - assert.equal(store.refresh_token_enc, cipherBefore) // untouched + await emailConfig.save({ senderName: 'UOMysticmoon', credential: { ...CRED, password: '' } }) assert.equal(store.sender_name, 'UOMysticmoon') - assert.equal(secretBox.decrypt(store.refresh_token_enc), 'refresh-abc') + assert.equal(decrypted().password, 'sec-abc') + assert.notEqual(store.credential_enc, undefined) + assert.ok(cipherBefore) }) -test('disconnect clears the credential and disables sending', async () => { - await emailConfig.save({ senderEmail: 'me@gmail.com', refreshToken: 'refresh-abc', enabled: true }) +test('undeclared keys are dropped — a client cannot smuggle fields into the blob', async () => { + await emailConfig.save({ credential: { ...CRED, evil: 'x', proxy: 'http://attacker' } }) + assert.deepEqual(Object.keys(decrypted()).sort(), ['host', 'password', 'port', 'secure', 'user']) +}) + +test('changing transport does not carry the old credential across', async () => { + await emailConfig.save({ credential: CRED }) + // An unregistered target still clears rather than merging: leaving an SMTP + // password inside another transport's blob would be a stored secret nobody can + // see and nothing will ever use. + await emailConfig.save({ transport: 'mailgun', credential: { domain: 'x' } }) + assert.equal(store.transport, 'mailgun') + assert.equal(store.credential_enc, null) +}) + +test('an incomplete credential is stored but is not "complete"', async () => { + // A username with no password authenticates as nobody. + await emailConfig.save({ credential: { host: 'relay.example.com', port: 587, user: 'apikey' } }) + const safe = await emailConfig.getSafe() + assert.equal(safe.hasCredential, false) + assert.deepEqual(safe.secretsSet, { password: false }) +}) + +test('an unreadable blob reads as absent, never as an error', async () => { + // The rotated-SECRET_ENC_KEY case. It must land the admin on a screen that says + // "unconfigured", not a 500 that takes the contact form down with it. + store = { id: 1, transport: 'smtp', credential_enc: 'not-ciphertext', enabled: 1 } + const safe = await emailConfig.getSafe() + assert.equal(safe.hasCredential, false) + assert.deepEqual(safe.credential, {}) +}) + +test('disconnect clears the credential, the legacy token and the enabled flag', async () => { + store = { id: 1, transport: 'smtp', refresh_token_enc: 'old-gmail-cipher', enabled: 1 } + await emailConfig.save({ senderEmail: 'mail@shard.example.com', credential: CRED, enabled: true }) + const safe = await emailConfig.disconnect(7) + assert.equal(store.credential_enc, null) assert.equal(store.refresh_token_enc, null) assert.equal(store.enabled, 0) - assert.equal(safe.hasRefreshToken, false) + assert.equal(safe.hasCredential, false) + assert.equal(safe.hadLegacyConnection, false) assert.equal(safe.status, 'unconfigured') }) -test('getSafe returns unconfigured defaults when no row exists', async () => { +test('hadLegacyConnection is the G22 warning condition, and nothing else', async () => { + // Present token + no replacement credential: this deployment's mail just + // stopped and it has to be told (ENGAGEMENT.md §1.2a consequence 3). + store = { id: 1, transport: 'smtp', refresh_token_enc: 'old-gmail-cipher', enabled: 1 } + let safe = await emailConfig.getSafe() + assert.equal(safe.hadLegacyConnection, true) + assert.equal(safe.hasCredential, false) + + // Once SMTP is configured the pair stops matching, so the warning goes away + // without anything having to clear the deprecated column. + await emailConfig.save({ senderEmail: 'mail@shard.example.com', credential: CRED }) + safe = await emailConfig.getSafe() + assert.equal(safe.hadLegacyConnection, true) + assert.equal(safe.hasCredential, true) +}) + +test('a fresh install is unconfigured, on the default transport, and warns nobody', async () => { const safe = await emailConfig.getSafe() assert.equal(safe.enabled, false) - assert.equal(safe.hasRefreshToken, false) + assert.equal(safe.transport, 'smtp') + assert.equal(safe.hasCredential, false) + assert.equal(safe.hadLegacyConnection, false) assert.equal(safe.status, 'unconfigured') assert.equal(safe.senderEmail, null) }) diff --git a/server/test/mailTransports.test.js b/server/test/mailTransports.test.js new file mode 100644 index 0000000..deecb13 --- /dev/null +++ b/server/test/mailTransports.test.js @@ -0,0 +1,109 @@ +// The mail transport registry (ENGAGEMENT.md §3.1) and the one transport core +// ships. The registry's job is that `credentialFields` is the single declaration +// the admin form, the sanitizer and the "is it a secret" answer all read — so +// most of what is asserted here is that nothing else knows a field name. + +process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key' +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, after } = require('node:test') +const assert = require('node:assert/strict') + +const { transports } = require('../src/engagement') +const db = require('../src/utils/db') + +after(() => db.close()) + +const SMTP_CRED = { host: 'relay.example.com', port: 587, secure: false, user: 'apikey', password: 'sec' } + +test('requiring the subsystem is what registers core\'s transports', () => { + assert.equal(transports.has('smtp'), true) + const [smtp] = transports.describe() + assert.equal(smtp.id, 'smtp') + assert.ok(smtp.credentialFields.length > 0) +}) + +test('describe() carries no functions and no secret values', () => { + const [smtp] = transports.describe() + assert.equal(typeof smtp.build, 'undefined') + assert.equal(typeof smtp.isComplete, 'undefined') + const password = smtp.credentialFields.find((f) => f.key === 'password') + assert.equal(password.kind, 'secret') + assert.equal('value' in password, false) +}) + +test('sanitizeCredential drops undeclared keys', () => { + const out = transports.sanitizeCredential('smtp', { ...SMTP_CRED, evil: 'x' }) + assert.deepEqual(Object.keys(out).sort(), ['host', 'password', 'port', 'secure', 'user']) +}) + +test('sanitizeCredential coerces to the declared kind', () => { + const out = transports.sanitizeCredential('smtp', { host: 'relay.example.com', port: '587', secure: 'yes' }) + assert.equal(out.port, 587) + assert.equal(out.secure, true) +}) + +test('an empty secret is omitted, so a merge keeps the stored one', () => { + const patch = transports.sanitizeCredential('smtp', { ...SMTP_CRED, password: '' }) + assert.equal('password' in patch, false) + const merged = transports.mergeCredential('smtp', { password: 'stored' }, patch) + assert.equal(merged.password, 'stored') +}) + +test('publicCredential and secretsPresent split the blob the way the API needs', () => { + assert.deepEqual(transports.publicCredential('smtp', SMTP_CRED), { + host: 'relay.example.com', port: 587, secure: false, user: 'apikey', + }) + assert.deepEqual(transports.secretsPresent('smtp', SMTP_CRED), { password: true }) + assert.deepEqual(transports.secretsPresent('smtp', { host: 'x' }), { password: false }) +}) + +test('an unknown transport is a safe no-op everywhere, never a throw', () => { + assert.equal(transports.get('nope'), null) + assert.equal(transports.isComplete('nope', SMTP_CRED), false) + assert.deepEqual(transports.sanitizeCredential('nope', SMTP_CRED), {}) + assert.deepEqual(transports.publicCredential('nope', SMTP_CRED), {}) + assert.deepEqual(transports.secretsPresent('nope', SMTP_CRED), {}) +}) + +// ── smtp's own completeness rule ──────────────────────────────────────────── + +test('smtp needs a destination, and auth is all-or-nothing', () => { + assert.equal(transports.isComplete('smtp', SMTP_CRED), true) + // A local MTA needs no credentials at all. + assert.equal(transports.isComplete('smtp', { host: 'mta.example.com', port: 25 }), true) + // A username with no password authenticates as nobody and fails at the server. + assert.equal(transports.isComplete('smtp', { host: 'relay.example.com', port: 587, user: 'apikey' }), false) + assert.equal(transports.isComplete('smtp', { port: 587 }), false) + assert.equal(transports.isComplete('smtp', {}), false) +}) + +test('smtp declares no default host — §3.2 rule 1, as a test', () => { + const [smtp] = transports.describe() + const host = smtp.credentialFields.find((f) => f.key === 'host') + assert.equal(host.default, null) + assert.equal(host.required, true) +}) + +// ── registration is validated at the call ─────────────────────────────────── + +test('registration rejects a bad shape and a collision', () => { + const ok = { id: 'fake', label: 'Fake', credentialFields: [{ key: 'k', kind: 'text' }], build: () => {}, isComplete: () => true } + assert.throws(() => transports.registerMailTransport({ ...ok, id: 'Not Valid' }), /invalid id/) + assert.throws(() => transports.registerMailTransport({ ...ok, label: '' }), /label required/) + assert.throws(() => transports.registerMailTransport({ ...ok, credentialFields: [] }), /credentialFields required/) + assert.throws(() => transports.registerMailTransport({ ...ok, credentialFields: [{ key: 'k', kind: 'wat' }] }), /unknown kind/) + assert.throws(() => transports.registerMailTransport({ ...ok, build: undefined }), /build\(\) required/) + assert.throws(() => transports.registerMailTransport({ ...ok, id: 'smtp' }), /already registered/) +}) + +test('a registered transport is a copy — a caller cannot mutate the catalog afterwards', () => { + const fields = [{ key: 'k', label: 'K', kind: 'text', required: true }] + transports.registerMailTransport({ + id: 'tamper', label: 'Tamper', credentialFields: fields, build: () => {}, isComplete: () => true, + }) + fields[0].kind = 'secret' + const def = transports.describe().find((t) => t.id === 'tamper') + assert.equal(def.credentialFields[0].kind, 'text') +}) diff --git a/server/test/mailer.test.js b/server/test/mailer.test.js index 0807f3c..a548d44 100644 --- a/server/test/mailer.test.js +++ b/server/test/mailer.test.js @@ -1,3 +1,11 @@ +// utils/mailer.js — the transport resolution and, more importantly, the five +// failure contracts the six call sites depend on (ENGAGEMENT.md §1.2, Phase 1). +// +// The Gmail OAuth2 assertions are gone with the transport (§1.2a); what replaced +// them asserts the SAME things at the same seam — that a configured deployment +// builds the transport the operator selected from the credentials they supplied, +// and that an unconfigured one degrades exactly as before rather than throwing. + process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key' process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' @@ -7,66 +15,167 @@ const assert = require('node:assert/strict') const nodemailer = require('nodemailer') const emailConfig = require('../src/model/emailConfig/emailConfig.model') -const authProviders = require('../src/model/authProviders/authProviders.model') const settings = require('../src/model/settings/settings.model') const mailer = require('../src/utils/mailer') const db = require('../src/utils/db') after(() => db.close()) -// Restore a clean slate of stubs before each test. -beforeEach(() => { - emailConfig.recordStatus = async () => {} - settings.get = async () => 'contact@example.com' +// A complete SMTP config, as getWithSecret returns it. +const configured = (over = {}) => ({ + transport: 'smtp', + enabled: true, + senderEmail: 'mail@shard.example.com', + senderName: 'UOMysticmoon', + replyTo: null, + hasCredential: true, + credentialSecret: { host: 'relay.example.com', port: 587, secure: false, user: 'apikey', password: 'sec' }, + ...over, }) -test('unconfigured → mailto fallback (never throws)', async () => { +let sent +let transportCfg + +beforeEach(() => { + sent = null + transportCfg = null + emailConfig.recordStatus = async () => {} + settings.get = async () => 'contact@example.com' + nodemailer.createTransport = (cfg) => { + transportCfg = cfg + return { sendMail: async (opts) => { sent = opts; return { messageId: '1' } } } + } +}) + +// ── the five failure contracts ────────────────────────────────────────────── + +test('unconfigured → contact form falls back to mailto (never throws)', async () => { emailConfig.getWithSecret = async () => null - emailConfig.getSafe = async () => ({ senderEmail: null, hasRefreshToken: false, enabled: false }) + emailConfig.getSafe = async () => ({ senderEmail: null, hasCredential: false, enabled: false }) const r = await mailer.sendContactMessage({ name: 'Ann', email: 'ann@player.com', message: 'hi' }) assert.deepEqual(r, { sent: false, fallback: 'mailto', email: 'contact@example.com' }) }) -test('configured → builds a Gmail OAuth2 transport and sends', async () => { - let transportCfg = null - let sent = null - nodemailer.createTransport = (cfg) => { - transportCfg = cfg - return { sendMail: async (opts) => { sent = opts; return { messageId: '1' } } } - } - emailConfig.getWithSecret = async () => ({ refreshToken: 'rt-123', senderEmail: 'shard@gmail.com', senderName: 'UOMysticmoon' }) - authProviders.getWithSecret = async (id) => { - assert.equal(id, 'google') - return { client_id: 'cid', client_secret: 'csec' } - } +test('unconfigured → invite returns NOT_CONFIGURED so the admin gets the link', async () => { + emailConfig.getWithSecret = async () => null + const r = await mailer.sendInvite({ to: 'a@b.com', acceptUrl: 'https://x/y' }) + assert.deepEqual(r, { sent: false, reason: 'NOT_CONFIGURED' }) +}) + +test('unconfigured → password reset returns NOT_CONFIGURED (caller still answers 200)', async () => { + emailConfig.getWithSecret = async () => null + const r = await mailer.sendPasswordReset({ to: 'a@b.com', resetUrl: 'https://x/y', username: 'ann' }) + assert.deepEqual(r, { sent: false, reason: 'NOT_CONFIGURED' }) +}) + +test('unconfigured → team notification returns NOT_CONFIGURED and never throws', async () => { + emailConfig.getWithSecret = async () => null + const r = await mailer.sendTeamNotification({ to: 'a@b.com', subject: 's', intro: 'i', items: [] }) + assert.deepEqual(r, { sent: false, reason: 'NOT_CONFIGURED' }) +}) + +test('unconfigured → only sendTest throws, because only sendTest has an admin waiting', async () => { + emailConfig.getWithSecret = async () => null + await assert.rejects(() => mailer.sendTest('a@b.com'), (err) => err.code === 'NOT_CONFIGURED') +}) + +// ── transport resolution ──────────────────────────────────────────────────── + +test('configured → builds the selected transport from the stored credential and sends', async () => { + emailConfig.getWithSecret = async () => configured() const r = await mailer.sendContactMessage({ name: 'Ann', email: 'ann@player.com', message: 'hi there' }) assert.equal(r.sent, true) - // Transport is Gmail SMTP over XOAUTH2 with the reused Google client + stored refresh token. - assert.equal(transportCfg.host, 'smtp.gmail.com') - assert.equal(transportCfg.port, 465) - assert.equal(transportCfg.secure, true) - assert.equal(transportCfg.auth.type, 'OAuth2') - assert.equal(transportCfg.auth.user, 'shard@gmail.com') - assert.equal(transportCfg.auth.clientId, 'cid') - assert.equal(transportCfg.auth.clientSecret, 'csec') - assert.equal(transportCfg.auth.refreshToken, 'rt-123') + // Every connection detail comes from the operator's credential. Nothing in the + // code chooses a host, a port or a TLS mode (§3.2 rule 1). + assert.equal(transportCfg.host, 'relay.example.com') + assert.equal(transportCfg.port, 587) + assert.equal(transportCfg.secure, false) + assert.deepEqual(transportCfg.auth, { user: 'apikey', pass: 'sec' }) - // From uses the display name; To is the contact_email setting; replyTo is the sender. - assert.equal(sent.from, '"UOMysticmoon" ') + // From uses the display name; To is the contact_email setting; replyTo is the + // visitor, which still wins over a configured Reply-To. + assert.equal(sent.from, '"UOMysticmoon" ') assert.equal(sent.to, 'contact@example.com') assert.equal(sent.replyTo, 'ann@player.com') }) +test('an unauthenticated relay gets no auth block', async () => { + emailConfig.getWithSecret = async () => configured({ + credentialSecret: { host: 'mta.example.com', port: 25, secure: false }, + }) + await mailer.sendInvite({ to: 'a@b.com', acceptUrl: 'https://x/y' }) + assert.equal(transportCfg.auth, undefined) +}) + +test('the configured Reply-To is used when the caller has none', async () => { + emailConfig.getWithSecret = async () => configured({ replyTo: 'staff@shard.example.com' }) + await mailer.sendPasswordReset({ to: 'a@b.com', resetUrl: 'https://x/y', username: 'ann' }) + assert.equal(sent.replyTo, 'staff@shard.example.com') +}) + +test('disabled is unconfigured — the toggle gates every sender, not just isConfigured', async () => { + emailConfig.getWithSecret = async () => configured({ enabled: false }) + emailConfig.getSafe = async () => ({ senderEmail: 'mail@shard.example.com', hasCredential: true, enabled: false }) + + const r = await mailer.sendContactMessage({ name: 'A', email: 'a@b.com', message: 'x' }) + assert.equal(r.sent, false) + assert.equal(r.fallback, 'mailto') +}) + +test('an incomplete credential is unconfigured, not a crash', async () => { + // A username with no password authenticates as nobody; smtp.isComplete says no. + emailConfig.getWithSecret = async () => configured({ + credentialSecret: { host: 'relay.example.com', port: 587, user: 'apikey' }, + }) + const r = await mailer.sendInvite({ to: 'a@b.com', acceptUrl: 'https://x/y' }) + assert.deepEqual(r, { sent: false, reason: 'NOT_CONFIGURED' }) +}) + +test('a stored transport id that is not registered degrades, it does not throw', async () => { + emailConfig.getWithSecret = async () => configured({ transport: 'mailgun' }) + const r = await mailer.sendTeamNotification({ to: 'a@b.com', subject: 's', intro: 'i', items: [] }) + assert.deepEqual(r, { sent: false, reason: 'NOT_CONFIGURED' }) +}) + +// ── failures ──────────────────────────────────────────────────────────────── + test('send failure propagates and is recorded', async () => { let recorded = null emailConfig.recordStatus = async (s) => { recorded = s } nodemailer.createTransport = () => ({ sendMail: async () => { throw new Error('smtp boom') } }) - emailConfig.getWithSecret = async () => ({ refreshToken: 'rt', senderEmail: 'shard@gmail.com', senderName: null }) - authProviders.getWithSecret = async () => ({ client_id: 'cid', client_secret: 'csec' }) + emailConfig.getWithSecret = async () => configured() await assert.rejects(() => mailer.sendContactMessage({ name: 'A', email: 'a@b.com', message: 'x' }), /smtp boom/) assert.equal(recorded.status, 'error') }) + +test('a rejected sender is diagnosed by name — the failure mode SMTP introduces', async () => { + // Under the removed consent flow the sender came back from the provider and was + // guaranteed to belong to the credential. Operator-typed, it can be refused, + // and "550 5.7.1" alone does not tell anyone why (§1.2a consequence 2). + let recorded = null + emailConfig.recordStatus = async (s) => { recorded = s } + nodemailer.createTransport = () => ({ + sendMail: async () => { + const err = new Error('Sender address rejected') + err.responseCode = 550 + throw err + }, + }) + emailConfig.getWithSecret = async () => configured() + + await assert.rejects(() => mailer.sendTest('a@b.com'), /mail@shard\.example\.com/) + assert.match(recorded.statusDetail, /SPF\/DMARC/) +}) + +test('a team notification failure is swallowed, never thrown', async () => { + emailConfig.recordStatus = async () => {} + nodemailer.createTransport = () => ({ sendMail: async () => { throw new Error('relay down') } }) + emailConfig.getWithSecret = async () => configured() + + const r = await mailer.sendTeamNotification({ to: 'a@b.com', subject: 's', intro: 'i', items: [] }) + assert.deepEqual(r, { sent: false, reason: 'SEND_FAILED' }) +}) -- 2.49.1 From c4ab8b9b9d662b17caa05d464d1d43bad099ce55 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:54:04 -0500 Subject: [PATCH 03/20] docs(email): SMTP setup, the three postures, and the upgrade note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator-facing half of engagement Phase 1. README's stack table and security section, plus both .env.example files, all pointed at the removed Connect Gmail flow. The env comments now name the three supported postures rather than one provider — a relay as the recommendation, smtp.gmail.com:587 with an app password as the shortest migration, an unauthenticated local MTA as the third — and point at docs/website/UPGRADE_NOTES.md for the deployment this actually happens to. The OpenAPI spec is regenerated: two routes gone, three annotations rewritten, and the dashboard's new warnings[] documented. Co-Authored-By: Claude --- .env.example | 14 ++- README.md | 12 ++- server/.env.example | 8 +- server/swagger/swagger-output.json | 163 ++++++++--------------------- 4 files changed, 64 insertions(+), 133 deletions(-) diff --git a/.env.example b/.env.example index 22abc57..4eccd07 100644 --- a/.env.example +++ b/.env.example @@ -57,7 +57,7 @@ DB_ROOT_PASSWORD=change-me-root-password # Auth JWT_SECRET=change-me-to-a-long-random-string # Encrypts every secret this site stores at rest (AES-256-GCM): OAuth client -# secrets, the Discord bot token, the Gmail refresh token, the uo-link auth +# secrets, the Discord bot token, the mail transport credentials, the uo-link auth # token. REQUIRED in production — with NODE_ENV=production the app REFUSES TO # START without it (utils/secretBox.js), so a Compose deployment that leaves it # blank crash-loops before it ever listens. Development falls back to a key @@ -98,10 +98,14 @@ TOTP_CHALLENGE_TTL=5m ADMIN_USERNAME= ADMIN_PASSWORD= -# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not via -# env. It reuses the Google auth provider's OAuth client and stores an encrypted -# refresh token in the DB. Until it's connected, the contact form falls back to -# a mailto: link (recipient = the `contact_email` site setting). +# Email is configured in Admin → Settings → Email, not via env: pick a mail +# transport (SMTP) and enter its host, port and credentials, which are stored +# encrypted in the DB. Three postures work — a relay (Mailgun/SES/Postmark) is +# the recommended one, a mailbox provider over SMTP (e.g. smtp.gmail.com:587 +# with an app password) is the simplest, and an unauthenticated local MTA on +# port 25 needs no credentials at all. Until one is configured the contact form +# falls back to a mailto: link (recipient = the `contact_email` site setting). +# Upgrading from the removed Gmail connect flow: see docs/website/UPGRADE_NOTES.md. # CORS — only needed for local dev when the Vite dev server is a different origin. CLIENT_ORIGIN=http://localhost:5173 diff --git a/README.md b/README.md index 097b90b..306d44d 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ flowchart TB | Auth | Session service over JWT: httpOnly cookie (web) + bearer access/refresh tokens (mobile), bcrypt hashing, optional TOTP 2FA (`speakeasy` + `qrcode`), pluggable OAuth2/OIDC SSO (built-in Google & Discord + generic) | | Database | MariaDB 11 (own container) | | Frontend | React 18, Vite 5, React Router 6 | -| Email | Nodemailer via Gmail OAuth2 (configured in admin), with a `mailto:` fallback | +| Email | Nodemailer over a configurable mail transport — SMTP (relay, mailbox provider or your own MTA), set up in the admin panel — with a `mailto:` fallback | | API docs | OpenAPI 3.0 via `swagger-autogen`, served with `swagger-ui-express` at `/api/docs` | | Deploy | Docker Compose, any reverse proxy (Pangolin, Nginx, Caddy, Traefik, …) | @@ -584,7 +584,7 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`. | `TOTP_ISSUER` | `BRAND_NAME` | label shown in authenticator apps for optional per-user 2FA | | `TOTP_CHALLENGE_TTL` | `5m` | lifetime of the short-lived post-password "awaiting code" step | | `ADMIN_USERNAME` / `ADMIN_PASSWORD` | — | first-admin bootstrap (first boot only) | -| _Email_ | — | configured in Admin → Settings → Email (Gmail OAuth2), not via env; recipient = `contact_email` setting | +| _Email_ | — | configured in Admin → Settings → Email (transport + credentials), never via env; recipient = `contact_email` setting. Upgrading from the removed Gmail connect flow: see [`docs/website/UPGRADE_NOTES.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/UPGRADE_NOTES.md) | | `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only | | `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity | | `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) | @@ -677,9 +677,11 @@ run this repo as UOMysticmoon. - `helmet`, admin routes `noindex` + `robots.txt` disallow, `trust proxy` for correct client IPs behind a reverse proxy (see `TRUST_PROXY`), first admin seeded from env (no hardcoded credentials), - `.env` git-ignored. Passwords and request bodies are never logged. Email sends through Gmail - OAuth2 configured in the admin (refresh token stored AES-GCM-encrypted, never in env); the - contact form falls back to a `mailto:` link when unconfigured. + `.env` git-ignored. Passwords and request bodies are never logged. Email sends through a mail + transport configured in the admin, whose credentials are stored AES-GCM-encrypted and are + write-only over the API (never returned, never in env); no transport ships a default host or + sender, so an unconfigured deployment sends nowhere. The contact form falls back to a `mailto:` + link when unconfigured. --- diff --git a/server/.env.example b/server/.env.example index 988a7d8..6fd0e84 100644 --- a/server/.env.example +++ b/server/.env.example @@ -80,10 +80,12 @@ TOTP_CHALLENGE_TTL=5m ADMIN_USERNAME=admin ADMIN_PASSWORD=change-me-admin-password -# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not here. -# It reuses the Google auth provider's OAuth client and stores an encrypted -# refresh token in the DB. The contact recipient is the `contact_email` site +# Email is configured in Admin → Settings → Email, not here: pick a mail +# transport (SMTP) and enter its host, port and credentials, stored encrypted in +# the DB. A relay is the recommended posture; smtp.gmail.com:587 with an app +# password is the simplest. The contact recipient is the `contact_email` site # setting; while email is unconfigured the contact form falls back to a mailto: link. +# Upgrading from the removed Gmail connect flow: see docs/website/UPGRADE_NOTES.md. CLIENT_ORIGIN=http://localhost:5173 diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 38951f9..d410b34 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -1029,6 +1029,25 @@ } } }, + "warnings": { + "type": "array", + "description": "Operator warnings needing action; empty when there is nothing to say", + "items": { + "type": "object", + "properties": { + "code": { + "type": "string", + "example": "EMAIL_TRANSPORT_MIGRATION" + }, + "message": { + "type": "string" + }, + "href": { + "type": "string" + } + } + } + }, "recent_activity": { "type": "array", "items": { @@ -1205,11 +1224,11 @@ "tags": [ "Admin · Email" ], - "summary": "Get email delivery config + status (admin only)", - "description": "", + "summary": "Get email delivery config, status and the transport catalog (admin only)", + "description": "Credentials are write-only: secret fields are never returned, only a per-field `secretsSet` flag. `transports` carries each registered transport's declared credential fields, which is what the admin form renders.", "responses": { "200": { - "description": "Config (refresh token stripped) + status", + "description": "Config (secrets stripped) + status + transport catalog", "content": { "application/json": { "schema": { @@ -1257,7 +1276,7 @@ "Admin · Email" ], "summary": "Update email delivery config (admin only)", - "description": "Set the From display name and enabled toggle. Enabling requires a connected Gmail account.", + "description": "Set the transport, sender identity, credentials and enabled toggle. `credential` is a patch against the stored blob — a secret field submitted empty keeps its stored value. Enabling requires complete credentials and a sender address.", "responses": { "200": { "description": "Updated config", @@ -1271,7 +1290,7 @@ } }, "400": { - "description": "Cannot enable before connecting a mailbox", + "description": "Unknown transport, or cannot enable without complete credentials and a sender address", "content": { "application/json": { "schema": { @@ -1318,9 +1337,25 @@ "schema": { "type": "object", "properties": { + "transport": { + "type": "string", + "example": "smtp" + }, + "senderEmail": { + "type": "string", + "format": "email" + }, "senderName": { "type": "string" }, + "replyTo": { + "type": "string", + "format": "email" + }, + "credential": { + "type": "object", + "additionalProperties": true + }, "enabled": { "type": "boolean" } @@ -1331,128 +1366,16 @@ } } }, - "/api/v1/admin/email/connect/callback": { - "get": { - "tags": [ - "Admin · Email" - ], - "summary": "OAuth2 callback — stores the refresh token, redirects to Settings", - "description": "", - "parameters": [ - { - "name": "code", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "state", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "error", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "302": { - "description": "Redirect back to /admin/settings" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/email/connect/start": { - "get": { - "tags": [ - "Admin · Email" - ], - "summary": "Begin the Gmail OAuth2 connect flow (admin only)", - "description": "Returns { url } to redirect the browser to Google. Reuses the google SSO OAuth client.", - "responses": { - "200": { - "description": "Authorization URL", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - } - } - } - } - }, - "400": { - "description": "Google OAuth client not configured", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, "/api/v1/admin/email/disconnect": { "post": { "tags": [ "Admin · Email" ], - "summary": "Disconnect Gmail and disable email (admin only)", + "summary": "Clear the stored credentials and disable email (admin only)", "description": "", "responses": { "200": { - "description": "Disconnected config", + "description": "Cleared config", "content": { "application/json": { "schema": { @@ -1502,7 +1425,7 @@ "Admin · Email" ], "summary": "Send a test email (admin only)", - "description": "", + "description": "The real verification of the configuration — host, port, TLS mode, credentials, and whether the relay accepts the configured sender. Failures return a specific diagnostic.", "responses": { "200": { "description": "Sent", -- 2.49.1 From 6e61146678f92b9daacf6bc8a7a723a339ada879 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 29 Aug 2026 00:49:25 -0500 Subject: [PATCH 04/20] refactor(api): collapse /admin/account and /player/account onto /auth/me/account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-service account security had three URL surfaces onto one controller. All three mounted the same `admin/account.controller.js` handlers; each of the three router files carried a header comment apologising for the arrangement. `/auth/me/account` was already a strict superset, which settles which to keep: /admin/account 6 routes noindex, isLoggedIn, staffOnly /player/account 8 routes noindex, requireAuth /auth/me/account 10 routes noindex, requireAuth Neither of the deleted surfaces carried recovery codes, and /admin/account carried no username or password change at all — so client.js already called /auth/me/account/recovery-codes/* for two operations on a screen it otherwise served from /admin/account. The split was leaking before this change. Gating is equivalent where it overlapped: /player and /auth/me apply identical `noindex, requireAuth`, and `staffOnly` on /admin/account was strictly narrower while buying nothing, since every handler is self-scoped to req.user.id. There is no CSRF layer to differ. - 14 routes deleted, 0 added, no handler changed. - account.controller.js moves router/v1/admin/ -> router/v1/auth/, beside the one router that still reaches it. - Web client: 14 call sites move onto a root-level api.myAccount / api.changeUsername / ... group, matching the /auth/me methods already there. - Android app: no change. MeApi.kt was already 100% /auth/me/account/*. - Two swagger tags, `Admin · Account` and `Player`, were declared only by the deleted routes and go with them. The orphaned `AccountStatus` schema goes too; `PlayerAccount` is re-described as the any-role /auth/me/account shape (the name is kept so existing $refs resolve). Breaking to the published OpenAPI surface, accepted deliberately: both consumers are in this org, and deprecate-then-delete would leave the next phase deciding whether to add routes to surfaces already marked for removal. Verification: routes.manifest.json shows exactly 14 deletions and 0 additions. The OpenAPI spec loses the same 14 paths with zero surviving path definitions changed; its large textual diff is pure reordering, because removing the first-mounted router shifts every later path. 1203 server tests, 288 client tests, 53 bot tests green; check:modules, check:hosts and routes:manifest --check all pass. Design of record: docs/website/ENGAGEMENT.md Phase 1a. This lands ahead of engagement Phase 1b, which adds a self-service email field — written once here rather than three times. Co-Authored-By: Claude --- client/src/api/client.js | 43 +- .../src/routes/admin/views/AccountAdmin.jsx | 12 +- client/src/routes/player/PlayerAccount.jsx | 16 +- server/routes.guards.json | 142 --- server/routes.manifest.json | 56 -- server/src/modules/loader.js | 6 +- server/src/router/v1/admin/account.router.js | 87 -- server/src/router/v1/admin/index.js | 2 - .../v1/{admin => auth}/account.controller.js | 15 +- server/src/router/v1/auth/index.js | 8 +- server/src/router/v1/auth/me.routes.js | 23 +- server/src/router/v1/auth/password.router.js | 2 +- server/src/router/v1/player/account.router.js | 130 --- server/src/router/v1/player/index.js | 16 +- server/swagger/swagger-output.json | 928 +----------------- server/swagger/swagger.js | 21 +- server/test/mobileDeviceSessions.test.js | 2 +- server/test/moduleLoader.test.js | 4 +- server/test/playerAccounts.test.js | 2 +- server/test/selfTrustedDevices.test.js | 2 +- 20 files changed, 89 insertions(+), 1428 deletions(-) delete mode 100644 server/src/router/v1/admin/account.router.js rename server/src/router/v1/{admin => auth}/account.controller.js (96%) delete mode 100644 server/src/router/v1/player/account.router.js diff --git a/client/src/api/client.js b/client/src/api/client.js index 9a97898..ff281f8 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -105,6 +105,24 @@ export const api = { revokeTrustedDevice: (id) => req(`/auth/me/trusted-devices/${encodeURIComponent(id)}`, { method: 'DELETE' }), revokeAllTrustedDevices: () => req('/auth/me/trusted-devices', { method: 'DELETE' }), + // Self-service account security, role-agnostic under /auth/me/account. This is + // the ONLY surface for it: the /admin/account/* and /player/account/* copies + // were deleted (both were strictly smaller — neither carried recovery codes), + // which is why recovery codes below already lived here while the rest did not. + // The change endpoints re-issue the session cookie server-side, so the caller + // stays signed in. + myAccount: () => req('/auth/me/account'), + changeUsername: (username) => + req('/auth/me/account/username', { method: 'PATCH', body: { username } }), + changePassword: (newPassword, currentPassword) => + req('/auth/me/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }), + totpSetup: () => req('/auth/me/account/totp/setup', { method: 'POST' }), + totpEnable: (code) => req('/auth/me/account/totp/enable', { method: 'POST', body: { code } }), + totpDisable: (code) => req('/auth/me/account/totp/disable', { method: 'POST', body: { code } }), + // Linked SSO identities (self-service). Linking starts at /auth/sso/:id/link. + myIdentities: () => req('/auth/me/account/identities'), + unlinkIdentity: (provider) => + req(`/auth/me/account/identities/${encodeURIComponent(provider)}`, { method: 'DELETE' }), // Recovery (backup) codes. status → remaining count; generate → a fresh set, // returned ONCE (password step-up for accounts that have a password). recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'), @@ -435,16 +453,6 @@ export const api = { req(`/admin/moderation/appeals/${id}/resolve`, { method: 'POST', body: data }), getUserAppeals: (discordId) => req(`/admin/moderation/user/${discordId}/appeals`), - // ----- account security (self-service 2FA) ----- - getAccount: () => req('/admin/account'), - totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }), - totpEnable: (code) => req('/admin/account/totp/enable', { method: 'POST', body: { code } }), - totpDisable: (code) => req('/admin/account/totp/disable', { method: 'POST', body: { code } }), - - // ----- linked SSO identities (self-service) ----- - linkedIdentities: () => req('/admin/account/identities'), - unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }), - // ----- auth providers / SSO config (admin only) ----- listAuthProviders: () => req('/admin/auth/providers'), createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }), @@ -465,20 +473,9 @@ export const api = { }, // ----- player self-service (role: 'player') ----- - // Mirrors the admin account methods but self-scoped under /player. The change - // endpoints re-issue the session cookie server-side, so the caller stays signed in. + // Account security is NOT here — it is role-agnostic and lives at the root of + // this object, on /auth/me/account. What remains is genuinely player-scoped. player: { - getAccount: () => req('/player/account'), - changeUsername: (username) => - req('/player/account/username', { method: 'PATCH', body: { username } }), - changePassword: (newPassword, currentPassword) => - req('/player/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }), - totpSetup: () => req('/player/account/totp/setup', { method: 'POST' }), - totpEnable: (code) => req('/player/account/totp/enable', { method: 'POST', body: { code } }), - totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }), - linkedIdentities: () => req('/player/account/identities'), - unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }), - // ----- moderation appeals (self-service) ----- getMyAppeals: () => req('/player/appeals'), getEligibleAppeals: () => req('/player/appeals/eligible'), diff --git a/client/src/routes/admin/views/AccountAdmin.jsx b/client/src/routes/admin/views/AccountAdmin.jsx index acdd108..b557e3c 100644 --- a/client/src/routes/admin/views/AccountAdmin.jsx +++ b/client/src/routes/admin/views/AccountAdmin.jsx @@ -25,7 +25,7 @@ function LinkedAccounts() { const load = useCallback(async () => { try { const [ids, avail] = await Promise.all([ - api.admin.linkedIdentities(), + api.myIdentities(), api.authProviders().catch(() => []), ]) setLinked(ids) @@ -44,7 +44,7 @@ function LinkedAccounts() { async function unlink(provider) { if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return try { - await api.admin.unlinkIdentity(provider) + await api.unlinkIdentity(provider) await load() } catch (err) { setError(err.message || 'Could not unlink.') @@ -134,7 +134,7 @@ export default function AccountAdmin() { async function load() { try { - setAccount(await api.admin.getAccount()) + setAccount(await api.myAccount()) } catch { setError('Could not load your account.') } finally { @@ -154,7 +154,7 @@ export default function AccountAdmin() { setMsg('') setError('') try { - setSetup(await api.admin.totpSetup()) + setSetup(await api.totpSetup()) setCode('') } catch (err) { setError(err.message || 'Could not start setup.') @@ -168,7 +168,7 @@ export default function AccountAdmin() { setMsg('') setError('') try { - const res = await api.admin.totpEnable(code.trim()) + const res = await api.totpEnable(code.trim()) setSetup(null) setCode('') setNewCodes(res?.recoveryCodes || null) @@ -186,7 +186,7 @@ export default function AccountAdmin() { setMsg('') setError('') try { - await api.admin.totpDisable(code.trim()) + await api.totpDisable(code.trim()) setCode('') setMsg('Two-factor authentication has been disabled.') await load() diff --git a/client/src/routes/player/PlayerAccount.jsx b/client/src/routes/player/PlayerAccount.jsx index a4c73db..01782b2 100644 --- a/client/src/routes/player/PlayerAccount.jsx +++ b/client/src/routes/player/PlayerAccount.jsx @@ -21,7 +21,7 @@ function ChangeUsername({ account, onChanged }) { if (username.trim().length < 3) return setError('Username must be at least 3 characters.') setBusy(true) try { - const { username: next } = await api.player.changeUsername(username.trim()) + const { username: next } = await api.changeUsername(username.trim()) setMsg('Username updated.') await onChanged(next) } catch (err) { @@ -67,7 +67,7 @@ function ChangePassword({ account }) { if (hasPassword && !current) return setError('Enter your current password.') setBusy(true) try { - await api.player.changePassword(next, hasPassword ? current : undefined) + await api.changePassword(next, hasPassword ? current : undefined) setMsg(hasPassword ? 'Password changed.' : 'Password set. You can now sign in with it.') setCurrent('') setNext('') @@ -124,7 +124,7 @@ function TwoFactor({ account, reload }) { async function begin() { setBusy(true); setMsg(''); setError('') try { - setSetup(await api.player.totpSetup()) + setSetup(await api.totpSetup()) setCode('') } catch (err) { setError(err.message || 'Could not start setup.') @@ -135,7 +135,7 @@ function TwoFactor({ account, reload }) { async function confirm() { setBusy(true); setMsg(''); setError('') try { - const res = await api.player.totpEnable(code.trim()) + const res = await api.totpEnable(code.trim()) setSetup(null); setCode(''); setNewCodes(res?.recoveryCodes || null); setMsg('Two-factor is now enabled.') await reload() } catch (err) { @@ -147,7 +147,7 @@ function TwoFactor({ account, reload }) { async function disable() { setBusy(true); setMsg(''); setError('') try { - await api.player.totpDisable(code.trim()) + await api.totpDisable(code.trim()) setCode(''); setMsg('Two-factor has been disabled.') await reload() } catch (err) { @@ -234,7 +234,7 @@ function LinkedAccounts() { const load = useCallback(async () => { try { const [ids, avail] = await Promise.all([ - api.player.linkedIdentities(), + api.myIdentities(), api.authProviders().catch(() => []), ]) setLinked(ids) @@ -251,7 +251,7 @@ function LinkedAccounts() { async function unlink(provider) { if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return try { - await api.player.unlinkIdentity(provider) + await api.unlinkIdentity(provider) await load() } catch (err) { setError(err.message || 'Could not unlink.') @@ -397,7 +397,7 @@ export default function PlayerAccount() { const load = useCallback(async () => { try { - setAccount(await api.player.getAccount()) + setAccount(await api.myAccount()) } catch { setError('Could not load your account.') } finally { diff --git a/server/routes.guards.json b/server/routes.guards.json index 20e99fe..a4499fe 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -27,66 +27,6 @@ "handlers": 1, "gates": [] }, - { - "method": "GET", - "path": "/api/v1/admin/account", - "handlers": 1, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "GET", - "path": "/api/v1/admin/account/identities", - "handlers": 1, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "DELETE", - "path": "/api/v1/admin/account/identities/:provider", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "POST", - "path": "/api/v1/admin/account/totp/disable", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "POST", - "path": "/api/v1/admin/account/totp/enable", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "POST", - "path": "/api/v1/admin/account/totp/setup", - "handlers": 1, - "gates": [ - "noindex", - "requireAuth" - ] - }, { "method": "GET", "path": "/api/v1/admin/activity", @@ -1640,88 +1580,6 @@ "validate" ] }, - { - "method": "GET", - "path": "/api/v1/player/account", - "handlers": 1, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "GET", - "path": "/api/v1/player/account/identities", - "handlers": 1, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "DELETE", - "path": "/api/v1/player/account/identities/:provider", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "PATCH", - "path": "/api/v1/player/account/password", - "handlers": 5, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "POST", - "path": "/api/v1/player/account/totp/disable", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "POST", - "path": "/api/v1/player/account/totp/enable", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "POST", - "path": "/api/v1/player/account/totp/setup", - "handlers": 1, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "PATCH", - "path": "/api/v1/player/account/username", - "handlers": 4, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, { "method": "GET", "path": "/api/v1/player/appeals", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index 1c98c66..ed7f851 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -17,30 +17,6 @@ "method": "GET", "path": "/api/health" }, - { - "method": "GET", - "path": "/api/v1/admin/account" - }, - { - "method": "GET", - "path": "/api/v1/admin/account/identities" - }, - { - "method": "DELETE", - "path": "/api/v1/admin/account/identities/:provider" - }, - { - "method": "POST", - "path": "/api/v1/admin/account/totp/disable" - }, - { - "method": "POST", - "path": "/api/v1/admin/account/totp/enable" - }, - { - "method": "POST", - "path": "/api/v1/admin/account/totp/setup" - }, { "method": "GET", "path": "/api/v1/admin/activity" @@ -657,38 +633,6 @@ "method": "POST", "path": "/api/v1/auth/sso/totp" }, - { - "method": "GET", - "path": "/api/v1/player/account" - }, - { - "method": "GET", - "path": "/api/v1/player/account/identities" - }, - { - "method": "DELETE", - "path": "/api/v1/player/account/identities/:provider" - }, - { - "method": "PATCH", - "path": "/api/v1/player/account/password" - }, - { - "method": "POST", - "path": "/api/v1/player/account/totp/disable" - }, - { - "method": "POST", - "path": "/api/v1/player/account/totp/enable" - }, - { - "method": "POST", - "path": "/api/v1/player/account/totp/setup" - }, - { - "method": "PATCH", - "path": "/api/v1/player/account/username" - }, { "method": "GET", "path": "/api/v1/player/appeals" diff --git a/server/src/modules/loader.js b/server/src/modules/loader.js index 0735782..1b6c598 100644 --- a/server/src/modules/loader.js +++ b/server/src/modules/loader.js @@ -156,9 +156,9 @@ function buildCtx(id, moduleRoot) { // one store, and one place a breach is logged. // // `accountChangeLimiter` is handed over whole because it is genuinely - // shared policy: core's `/auth/me`, `/player/account` and - // `/player/appeals` are behind the same counter, and a module's - // account-change route has to land in it rather than beside it. + // shared policy: core's `/auth/me/account/*` and `/player/appeals` are + // behind the same counter, and a module's account-change route has to land + // in it rather than beside it. rateLimit: makeLimiter, accountChangeLimiter, }, diff --git a/server/src/router/v1/admin/account.router.js b/server/src/router/v1/admin/account.router.js deleted file mode 100644 index c674d2f..0000000 --- a/server/src/router/v1/admin/account.router.js +++ /dev/null @@ -1,87 +0,0 @@ -// Admin · Account — self-service account security for staff. -// -// Mounted at /api/v1/admin/account by admin/index.js, which already applied -// `noindex, isLoggedIn, staffOnly`. Deliberately NOT behind adminOnly: an editor -// or moderator manages their own 2FA and linked identities here, exactly as a -// player does under /player. Every handler keys off req.user.id. - -const express = require('express') -const { body, param } = require('express-validator') - -const account = require('./account.controller') -const validate = require('../../../middleware/validate') - -const accountRouter = express.Router() - -accountRouter.get( - '/', - // #swagger.tags = ['Admin · Account'] - // #swagger.summary = 'Get the current account (self)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'The account', content: { "application/json": { schema: { $ref: "#/components/schemas/AccountStatus" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - account.getAccount, -) -accountRouter.post( - '/totp/setup', - // #swagger.tags = ['Admin · Account'] - // #swagger.summary = 'Begin 2FA enrollment (returns secret + QR)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'otpauth URL and QR data to scan', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpSetup" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - account.totpSetup, -) -accountRouter.post( - '/totp/enable', - // #swagger.tags = ['Admin · Account'] - // #swagger.summary = 'Enable 2FA by confirming a code' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */ - /* #swagger.responses[200] = { description: '2FA enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */ - /* #swagger.responses[400] = { description: 'Setup not started, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - body('code').isString().trim().isLength({ min: 6, max: 8 }), - validate, - account.totpEnable, -) -accountRouter.post( - '/totp/disable', - // #swagger.tags = ['Admin · Account'] - // #swagger.summary = 'Disable 2FA by confirming a code' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */ - /* #swagger.responses[200] = { description: '2FA disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */ - /* #swagger.responses[400] = { description: 'Not enabled, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - body('code').isString().trim().isLength({ min: 6, max: 8 }), - validate, - account.totpDisable, -) - -// Linked SSO identities (self-service — any logged-in role manages their own). -accountRouter.get( - '/identities', - // #swagger.tags = ['Admin · Account'] - // #swagger.summary = 'List linked SSO identities (self)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Linked identities', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/LinkedIdentity" } } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - account.listIdentities, -) -accountRouter.delete( - '/identities/:provider', - // #swagger.tags = ['Admin · Account'] - // #swagger.summary = 'Unlink an SSO identity (self)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' } - /* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/UnlinkedFlag" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('provider').matches(/^[a-z0-9-]+$/), - validate, - account.unlinkIdentity, -) - -module.exports = accountRouter diff --git a/server/src/router/v1/admin/index.js b/server/src/router/v1/admin/index.js index 5e32048..56d0614 100644 --- a/server/src/router/v1/admin/index.js +++ b/server/src/router/v1/admin/index.js @@ -16,7 +16,6 @@ const express = require('express') const { isLoggedIn, requireRole } = require('../../../utils/auth') const noindex = require('../../../middleware/noindex') -const accountRouter = require('./account.router') const usersRouter = require('./users.router') const invitesRouter = require('./invites.router') const authProvidersRouter = require('./authProviders.router') @@ -48,7 +47,6 @@ const adminRouter = express.Router() const staffOnly = requireRole('admin', 'editor', 'moderator') adminRouter.use(noindex, isLoggedIn, staffOnly) -adminRouter.use('/account', accountRouter) adminRouter.use('/users', usersRouter) adminRouter.use('/invites', invitesRouter) // Mounted at /auth, not /auth/providers: /admin/auth is the capability, and the diff --git a/server/src/router/v1/admin/account.controller.js b/server/src/router/v1/auth/account.controller.js similarity index 96% rename from server/src/router/v1/admin/account.controller.js rename to server/src/router/v1/auth/account.controller.js index ef090b9..e260571 100644 --- a/server/src/router/v1/admin/account.controller.js +++ b/server/src/router/v1/auth/account.controller.js @@ -1,6 +1,13 @@ -// Self-service account security for the logged-in user (any role). Mounted under -// the admin router (so isLoggedIn has already run and req.user is the fresh DB -// row), but NOT behind the admin-only gate — editors manage their own 2FA too. +// Self-service account security for the logged-in user (any role): username, +// password, TOTP, linked identities, device sessions, trusted devices and +// recovery codes. +// +// Reached through exactly one router — me.routes.js at /auth/me — which applies +// `noindex, requireAuth`, so req.user is the fresh DB row and the status + +// session-cutoff checks have already run. Every handler keys off req.user.id and +// none of them consults a role: this file lived under router/v1/admin/ while it +// also served /admin/account/* and /player/account/*, and moved here when those +// two surfaces were deleted. const users = require('../../../model/users/users.model') const activity = require('../../../model/activity/activity.model') @@ -9,7 +16,7 @@ const mobileSessions = require('../../../model/mobileSessions/mobileSessions.mod const trustedDevices = require('../../../model/trustedDevices/trustedDevices.model') const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model') const sessionService = require('../../../auth/session.service') -const { establishTrust } = require('../auth/trustDevice.helper') +const { establishTrust } = require('./trustDevice.helper') const { setAuthCookie, setTrustCookie, clearTrustCookie } = require('../../../auth/token') const usernamePolicy = require('../../../auth/usernamePolicy') const loginProtection = require('../../../middleware/loginProtection') diff --git a/server/src/router/v1/auth/index.js b/server/src/router/v1/auth/index.js index f4ed16b..58b78fc 100644 --- a/server/src/router/v1/auth/index.js +++ b/server/src/router/v1/auth/index.js @@ -38,10 +38,10 @@ authRouter.use('/mobile', mobileRouter) // middleware, so passing through it is a no-op for every other route. authRouter.use(ssoRouter) -// Role-agnostic self-service ("me") — /auth/me/account*, reusing the same -// account.controller handlers as /player/account/* and /admin/account/* behind -// requireAuth (any role). Additive; gives the app one self surface that never -// touches /admin. +// Role-agnostic self-service ("me") — /auth/me/account*, behind requireAuth (any +// role). The single self surface: /player/account/* and /admin/account/* were +// deleted in favour of it, so the app and the web client share one set of URLs +// and neither has to touch /admin. authRouter.use('/me', meRouter) // Push-notification self-service — /auth/me/devices*, /auth/me/notifications/*. diff --git a/server/src/router/v1/auth/me.routes.js b/server/src/router/v1/auth/me.routes.js index 24bd3d0..f4777e2 100644 --- a/server/src/router/v1/auth/me.routes.js +++ b/server/src/router/v1/auth/me.routes.js @@ -1,14 +1,17 @@ // ── Role-agnostic self-service ("me") under /auth/me ─────────────────────── // -// The canonical self surface for EVERY authenticated role (player and staff -// alike). It reuses the exact same account.controller handlers as -// /player/account/* and /admin/account/* — no logic duplication — but gates on -// requireAuth ONLY (any authenticated, active account), never on a specific role. +// The ONLY self surface, for every authenticated role (player and staff alike). +// It gates on requireAuth ONLY (any authenticated, active account), never on a +// specific role. // // Why it exists: the Android app wants one self surface it can call regardless of -// role, and it must never touch /admin (docs/android/PLAN.md §6.4). The older -// /player/account/* and /admin/account/* routes stay for web back-compat; these -// /auth/me/* routes are the additive, role-agnostic canonical form. +// role, and it must never touch /admin (docs/android/PLAN.md §6.4). +// +// It used to be the third of three URL surfaces onto account.controller, beside +// /player/account/* and /admin/account/*. Those were deleted: both were strictly +// smaller than this one (neither carried recovery codes, and /admin/account +// carried no username or password change), so the web client already had to reach +// in here for part of one screen. New self-service fields go here and only here. // // requireAuth sets req.user to the fresh DB row and enforces the status + session // cutoff/revocation checks on every request, exactly as the account handlers @@ -17,7 +20,7 @@ const express = require('express') const { body, param } = require('express-validator') -const account = require('../admin/account.controller') +const account = require('./account.controller') const { requireAuth } = require('../../../auth/session.middleware') const noindex = require('../../../middleware/noindex') const validate = require('../../../middleware/validate') @@ -76,8 +79,8 @@ meRouter.patch( account.changePassword, ) -// TOTP self-enrollment — identical to the player/admin account flow (disable -// requires a valid current code; it does not take a password). +// TOTP self-enrollment (disable requires a valid current code; it does not take +// a password). meRouter.post( '/account/totp/setup', // #swagger.tags = ['Auth · Me'] diff --git a/server/src/router/v1/auth/password.router.js b/server/src/router/v1/auth/password.router.js index 25d209e..5759a8e 100644 --- a/server/src/router/v1/auth/password.router.js +++ b/server/src/router/v1/auth/password.router.js @@ -11,7 +11,7 @@ // limiters below are what stop the endpoints being used as an oracle by volume. // // Changing a password while signed in is a different route — -// PATCH /player/account/password (and its /auth/me and /admin twins). +// PATCH /auth/me/account/password. const express = require('express') const { body, param } = require('express-validator') diff --git a/server/src/router/v1/player/account.router.js b/server/src/router/v1/player/account.router.js deleted file mode 100644 index 1bcbad3..0000000 --- a/server/src/router/v1/player/account.router.js +++ /dev/null @@ -1,130 +0,0 @@ -// Player · Account — self-service credentials, 2FA and linked identities for the -// signed-in account. -// -// Mounted at /api/v1/player/account by player/index.js, which already applied -// `noindex, requireAuth`. No extra gate: every handler is self-scoped to -// req.user.id, and staff are a superset of players (see player/index.js). -// -// The handlers are admin/account.controller — the same code serving -// /admin/account/* and /auth/me/account/*. Three URL surfaces, one implementation; -// this file must not grow a fourth copy of the logic. -// -// The swagger tag stays 'Player', matching the committed spec. - -const express = require('express') -const { body, param } = require('express-validator') - -const account = require('../admin/account.controller') -const validate = require('../../../middleware/validate') -const { accountChangeLimiter } = require('../../../middleware/rateLimit') - -const accountRouter = express.Router() - -accountRouter.get( - '/', - // #swagger.tags = ['Player'] - // #swagger.summary = 'Get the current player account (self)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'The player account', content: { "application/json": { schema: { $ref: "#/components/schemas/PlayerAccount" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[403] = { description: 'Account not active (disabled/banned)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - account.getAccount, -) - -accountRouter.patch( - '/username', - // #swagger.tags = ['Player'] - // #swagger.summary = 'Change the current player’s username' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ChangeUsernameRequest" } } } } */ - /* #swagger.responses[200] = { description: 'Updated username (session cookie re-issued)', content: { "application/json": { schema: { type: "object", properties: { username: { type: "string" } } } } } } */ - /* #swagger.responses[400] = { description: 'Validation error or unavailable username', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ - /* #swagger.responses[403] = { description: 'Account not active (disabled/banned)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[429] = { description: 'Too many changes (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - accountChangeLimiter, - body('username').isString().trim().isLength({ min: 3, max: 32 }), - validate, - account.changeUsername, -) - -accountRouter.patch( - '/password', - // #swagger.tags = ['Player'] - // #swagger.summary = 'Change or set the current player’s password' - // #swagger.description = 'If the account already has a password, currentPassword is required and verified. SSO-provisioned accounts with no password may set an initial one without a current password. On success the caller’s session is re-issued (they stay logged in) while all other sessions are revoked.' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ChangePasswordRequest" } } } } */ - /* #swagger.responses[200] = { description: 'Password changed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */ - /* #swagger.responses[400] = { description: 'Validation error or wrong current password', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[403] = { description: 'Account not active (disabled/banned)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[429] = { description: 'Too many changes (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - accountChangeLimiter, - body('newPassword').isString().isLength({ min: 8, max: 64 }), - body('currentPassword').optional({ values: 'falsy' }).isString(), - validate, - account.changePassword, -) - -// TOTP self-enrollment — identical to the admin account flow (disable requires a -// valid current code; it does not take a password). -accountRouter.post( - '/totp/setup', - // #swagger.tags = ['Player'] - // #swagger.summary = 'Begin 2FA enrollment (returns secret + QR)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'otpauth URL and QR data to scan', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpSetup" } } } } */ - /* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - account.totpSetup, -) -accountRouter.post( - '/totp/enable', - // #swagger.tags = ['Player'] - // #swagger.summary = 'Enable 2FA by confirming a code' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */ - /* #swagger.responses[200] = { description: '2FA enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */ - /* #swagger.responses[400] = { description: 'Setup not started, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - body('code').isString().trim().isLength({ min: 6, max: 8 }), - validate, - account.totpEnable, -) -accountRouter.post( - '/totp/disable', - // #swagger.tags = ['Player'] - // #swagger.summary = 'Disable 2FA by confirming a code' - // #swagger.description = 'Requires a valid current authenticator code (proves control of the authenticator); it does not take a password.' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */ - /* #swagger.responses[200] = { description: '2FA disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */ - /* #swagger.responses[400] = { description: 'Not enabled, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - body('code').isString().trim().isLength({ min: 6, max: 8 }), - validate, - account.totpDisable, -) - -// Linked SSO identities (self-service). Linking itself starts at -// GET /auth/sso/:provider/link (already behind requireAuth; works for players). -accountRouter.get( - '/identities', - // #swagger.tags = ['Player'] - // #swagger.summary = 'List linked SSO identities (self)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Linked identities', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/LinkedIdentity" } } } } } */ - account.listIdentities, -) -accountRouter.delete( - '/identities/:provider', - // #swagger.tags = ['Player'] - // #swagger.summary = 'Unlink an SSO identity (self)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' } - /* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/UnlinkedFlag" } } } } */ - /* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('provider').matches(/^[a-z0-9-]+$/), - validate, - account.unlinkIdentity, -) - -module.exports = accountRouter diff --git a/server/src/router/v1/player/index.js b/server/src/router/v1/player/index.js index ce972f4..f635b52 100644 --- a/server/src/router/v1/player/index.js +++ b/server/src/router/v1/player/index.js @@ -3,19 +3,19 @@ // // This file owns exactly two things: the gate every player route shares, and the // mount table. No route is declared here. Each capability router mounts at the -// prefix it already owned inside the old monolithic player.routes.js, so the -// emitted URL set is byte-identical — proved by a zero-line diff in -// server/routes.manifest.json (`npm run routes:manifest`). +// prefix it already owned inside the old monolithic player.routes.js. +// +// Self-service account security (`/player/account/*`) used to be mounted here. It +// is gone: `/auth/me/account/*` is the single canonical self surface for every +// role, and this group's copy was a strictly smaller duplicate of it. // // **Staff are a superset of players.** This group is open to any authenticated // account, not just role 'player': every read/write is self-scoped to req.user.id, // and a staff member has every player ability plus their staff tools on top. // Adding a requireRole('player') here would 403 an admin off their own characters // (it happened once — see docs/website/BACKEND_DESIGN.md). Staff also reach the -// identical self-scoped handlers under /admin/shard and /auth/me/account; those -// are alternative URLs onto the same controllers, not duplicated logic — and -// both of those live in module-uo now, which changes where they are defined and -// nothing about which URLs answer. +// identical self-scoped handlers under /admin/shard, which lives in module-uo now +// — that changes where they are defined and nothing about which URLs answer. // // See docs/website/API_V2_PLAN.md § Phase 2 for the split. @@ -24,7 +24,6 @@ const express = require('express') const { requireAuth } = require('../../../auth/session.middleware') const noindex = require('../../../middleware/noindex') -const accountRouter = require('./account.router') const appealsRouter = require('./appeals.router') const teamsRouter = require('./teams.router') const teamForumRouter = require('./teamForum.router') @@ -39,7 +38,6 @@ const playerRouter = express.Router() // silently ship without it. playerRouter.use(noindex, requireAuth) -playerRouter.use('/account', accountRouter) playerRouter.use('/appeals', appealsRouter) playerRouter.use('/teams', teamsRouter) // Same prefix, second router. The forum and the leader-exercised grant flow are a diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index d410b34..ee31a2c 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -26,7 +26,7 @@ }, { "name": "Auth · Me", - "description": "The signed-in account: profile, notification streams and devices" + "description": "The signed-in account: profile, account security (credentials, 2FA, linked identities, recovery codes), notification streams and devices" }, { "name": "Auth · Mobile", @@ -40,14 +40,6 @@ "name": "Public", "description": "Unauthenticated site content (settings, posts, wiki, contact)" }, - { - "name": "Admin · Account", - "description": "Self-service account security (2FA, linked identities)" - }, - { - "name": "Player", - "description": "Self-service player accounts (register, credentials, 2FA, linked identities)" - }, { "name": "Player · Appeals", "description": "Player-submitted moderation appeals" @@ -155,345 +147,6 @@ } } }, - "/api/v1/admin/account": { - "get": { - "tags": [ - "Admin · Account" - ], - "summary": "Get the current account (self)", - "description": "", - "responses": { - "200": { - "description": "The account", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AccountStatus" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/account/identities": { - "get": { - "tags": [ - "Admin · Account" - ], - "summary": "List linked SSO identities (self)", - "description": "", - "responses": { - "200": { - "description": "Linked identities", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LinkedIdentity" - } - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/account/identities/{provider}": { - "delete": { - "tags": [ - "Admin · Account" - ], - "summary": "Unlink an SSO identity (self)", - "description": "", - "parameters": [ - { - "name": "provider", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Provider id." - } - ], - "responses": { - "200": { - "description": "Unlinked", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnlinkedFlag" - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "No linked account for that provider", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/account/totp/disable": { - "post": { - "tags": [ - "Admin · Account" - ], - "summary": "Disable 2FA by confirming a code", - "description": "", - "responses": { - "200": { - "description": "2FA disabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TotpState" - } - } - } - }, - "400": { - "description": "Not enabled, or invalid code", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TotpCodeRequest" - } - } - } - } - } - }, - "/api/v1/admin/account/totp/enable": { - "post": { - "tags": [ - "Admin · Account" - ], - "summary": "Enable 2FA by confirming a code", - "description": "", - "responses": { - "200": { - "description": "2FA enabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TotpState" - } - } - } - }, - "400": { - "description": "Setup not started, or invalid code", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "Two-factor already enabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TotpCodeRequest" - } - } - } - } - } - }, - "/api/v1/admin/account/totp/setup": { - "post": { - "tags": [ - "Admin · Account" - ], - "summary": "Begin 2FA enrollment (returns secret + QR)", - "description": "", - "responses": { - "200": { - "description": "otpauth URL and QR data to scan", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TotpSetup" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "Two-factor already enabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, "/api/v1/admin/activity": { "get": { "tags": [ @@ -9922,500 +9575,6 @@ } } }, - "/api/v1/player/account": { - "get": { - "tags": [ - "Player" - ], - "summary": "Get the current player account (self)", - "description": "", - "responses": { - "200": { - "description": "The player account", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PlayerAccount" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Account not active (disabled/banned)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/player/account/identities": { - "get": { - "tags": [ - "Player" - ], - "summary": "List linked SSO identities (self)", - "description": "", - "responses": { - "200": { - "description": "Linked identities", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LinkedIdentity" - } - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/player/account/identities/{provider}": { - "delete": { - "tags": [ - "Player" - ], - "summary": "Unlink an SSO identity (self)", - "description": "", - "parameters": [ - { - "name": "provider", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Provider id." - } - ], - "responses": { - "200": { - "description": "Unlinked", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnlinkedFlag" - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "No linked account for that provider", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/player/account/password": { - "patch": { - "tags": [ - "Player" - ], - "summary": "Change or set the current player’s password", - "description": "If the account already has a password, currentPassword is required and verified. SSO-provisioned accounts with no password may set an initial one without a current password. On success the caller’s session is re-issued (they stay logged in) while all other sessions are revoked.", - "responses": { - "200": { - "description": "Password changed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OkFlag" - } - } - } - }, - "400": { - "description": "Validation error or wrong current password", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Account not active (disabled/banned)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "429": { - "description": "Too many changes (rate limited)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangePasswordRequest" - } - } - } - } - } - }, - "/api/v1/player/account/totp/disable": { - "post": { - "tags": [ - "Player" - ], - "summary": "Disable 2FA by confirming a code", - "description": "Requires a valid current authenticator code (proves control of the authenticator); it does not take a password.", - "responses": { - "200": { - "description": "2FA disabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TotpState" - } - } - } - }, - "400": { - "description": "Not enabled, or invalid code", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TotpCodeRequest" - } - } - } - } - } - }, - "/api/v1/player/account/totp/enable": { - "post": { - "tags": [ - "Player" - ], - "summary": "Enable 2FA by confirming a code", - "description": "", - "responses": { - "200": { - "description": "2FA enabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TotpState" - } - } - } - }, - "400": { - "description": "Setup not started, or invalid code", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "409": { - "description": "Two-factor already enabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TotpCodeRequest" - } - } - } - } - } - }, - "/api/v1/player/account/totp/setup": { - "post": { - "tags": [ - "Player" - ], - "summary": "Begin 2FA enrollment (returns secret + QR)", - "description": "", - "responses": { - "200": { - "description": "otpauth URL and QR data to scan", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TotpSetup" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "409": { - "description": "Two-factor already enabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/player/account/username": { - "patch": { - "tags": [ - "Player" - ], - "summary": "Change the current player’s username", - "description": "", - "responses": { - "200": { - "description": "Updated username (session cookie re-issued)", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "username": { - "type": "string" - } - } - } - } - } - }, - "400": { - "description": "Validation error or unavailable username", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Account not active (disabled/banned)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "Username already taken", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "429": { - "description": "Too many changes (rate limited)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeUsernameRequest" - } - } - } - } - } - }, "/api/v1/player/appeals": { "get": { "tags": [ @@ -15993,7 +15152,7 @@ }, "description": { "type": "string", - "example": "Self-service player account (GET /player/account)." + "example": "The signed-in account (GET /auth/me/account). Same shape for every role." }, "properties": { "type": "object", @@ -16034,6 +15193,9 @@ "enum": { "type": "array", "example": [ + "admin", + "editor", + "moderator", "player" ], "items": { @@ -18051,86 +17213,6 @@ } } }, - "AccountStatus": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "Self-service account security status (GET /admin/account)." - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 1 - } - } - }, - "username": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "admin" - } - } - }, - "role": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "enum": { - "type": "array", - "example": [ - "admin", - "editor" - ], - "items": { - "type": "string" - } - }, - "example": { - "type": "string", - "example": "admin" - } - } - }, - "totp_enabled": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "boolean" - }, - "example": { - "type": "boolean", - "example": true - } - } - } - } - } - } - }, "TotpSetup": { "type": "object", "properties": { diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index f9c6f71..e0c6f18 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -58,12 +58,10 @@ const doc = { tags: [ { name: 'Health', description: 'Liveness probe' }, { name: 'Auth', description: 'Web session login/logout (cookie + TOTP)' }, - { name: 'Auth · Me', description: 'The signed-in account: profile, notification streams and devices' }, + { name: 'Auth · Me', description: 'The signed-in account: profile, account security (credentials, 2FA, linked identities, recovery codes), notification streams and devices' }, { name: 'Auth · Mobile', description: 'Native bearer-token login, refresh and logout' }, { name: 'Auth · SSO', description: 'OAuth2 / OIDC provider discovery and redirect flow' }, { name: 'Public', description: 'Unauthenticated site content (settings, posts, wiki, contact)' }, - { name: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' }, - { name: 'Player', description: 'Self-service player accounts (register, credentials, 2FA, linked identities)' }, { name: 'Player · Appeals', description: 'Player-submitted moderation appeals' }, { name: 'Settings', description: 'Site-wide settings any authenticated account may read (nav overrides)' }, { name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' }, @@ -490,13 +488,16 @@ const doc = { }, }, }, + // The self account as GET /auth/me/account returns it, for EVERY role — the + // name predates the collapse of /player/account and /admin/account onto + // /auth/me and is kept so existing $refs and generated clients resolve. PlayerAccount: { type: 'object', - description: 'Self-service player account (GET /player/account).', + description: 'The signed-in account (GET /auth/me/account). Same shape for every role.', properties: { id: { type: 'integer', example: 42 }, username: { type: 'string', example: 'newplayer' }, - role: { type: 'string', enum: ['player'], example: 'player' }, + role: { type: 'string', enum: ['admin', 'editor', 'moderator', 'player'], example: 'player' }, email: { type: 'string', format: 'email', nullable: true, example: 'player@example.com' }, status: { type: 'string', enum: ['active', 'disabled', 'banned', 'pending'], example: 'active' }, totp_enabled: { type: 'boolean', example: false }, @@ -749,16 +750,6 @@ const doc = { // the affected resource id/slug or a boolean flag. Documented here as-is so // the spec matches the controllers. (The shapes are intentionally recorded // rather than normalized — see the audit note if standardizing later.) - AccountStatus: { - type: 'object', - description: 'Self-service account security status (GET /admin/account).', - properties: { - id: { type: 'integer', example: 1 }, - username: { type: 'string', example: 'admin' }, - role: { type: 'string', enum: ['admin', 'editor'], example: 'admin' }, - totp_enabled: { type: 'boolean', example: true }, - }, - }, TotpSetup: { type: 'object', description: 'Enrollment material returned by POST /account/totp/setup.', diff --git a/server/test/mobileDeviceSessions.test.js b/server/test/mobileDeviceSessions.test.js index 773ac51..84b6b15 100644 --- a/server/test/mobileDeviceSessions.test.js +++ b/server/test/mobileDeviceSessions.test.js @@ -8,7 +8,7 @@ process.env.DB_PORT = '59999' const { test, beforeEach, after } = require('node:test') const assert = require('node:assert/strict') -const account = require('../src/router/v1/admin/account.controller') +const account = require('../src/router/v1/auth/account.controller') const mobileSessions = require('../src/model/mobileSessions/mobileSessions.model') const activity = require('../src/model/activity/activity.model') const db = require('../src/utils/db') diff --git a/server/test/moduleLoader.test.js b/server/test/moduleLoader.test.js index cef7339..1ad1f78 100644 --- a/server/test/moduleLoader.test.js +++ b/server/test/moduleLoader.test.js @@ -518,8 +518,8 @@ test('ctx exposes exactly the documented surface, and is frozen', () => { // is core's limiter FACTORY, not a limiter: a module states its own // window and cap and takes the plumbing, so there is one express-rate-limit in // the process and one place a breach is logged. is - // handed over whole because it is shared policy — core's /auth/me and - // /player/account sit behind the same counter. + // handed over whole because it is shared policy — core's /auth/me/account/* + // and /player/appeals sit behind the same counter. assert.deepEqual(probe.middleware, [ 'accountChangeLimiter', 'noindex', 'rateLimit', 'requireAuth', 'requireRole', 'siteMode', 'validate', ]) diff --git a/server/test/playerAccounts.test.js b/server/test/playerAccounts.test.js index 264753e..763a035 100644 --- a/server/test/playerAccounts.test.js +++ b/server/test/playerAccounts.test.js @@ -8,7 +8,7 @@ const assert = require('node:assert/strict') const bcrypt = require('bcryptjs') const authCtrl = require('../src/router/v1/auth/auth.controller') -const account = require('../src/router/v1/admin/account.controller') +const account = require('../src/router/v1/auth/account.controller') const users = require('../src/model/users/users.model') const settings = require('../src/model/settings/settings.model') const botScore = require('../src/middleware/botScore') diff --git a/server/test/selfTrustedDevices.test.js b/server/test/selfTrustedDevices.test.js index 06176f6..250263e 100644 --- a/server/test/selfTrustedDevices.test.js +++ b/server/test/selfTrustedDevices.test.js @@ -13,7 +13,7 @@ const assert = require('node:assert/strict') // - trusting the current device is ownership-scoped and honors the cap (409); // - self-revoke is scoped to the caller's own id; // - regenerating recovery codes is a password step-up (wrong password → 400). -const ctrl = require('../src/router/v1/admin/account.controller') +const ctrl = require('../src/router/v1/auth/account.controller') const users = require('../src/model/users/users.model') const activity = require('../src/model/activity/activity.model') const sessionService = require('../src/auth/session.service') -- 2.49.1 From fbb4b0bd91020e1dde27e54206df3d16f80874fe Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 29 Aug 2026 01:53:50 -0500 Subject: [PATCH 05/20] feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes `users.email` unique, de-duplicates the addresses an upgrade will find, and builds the self-service change-and-verify flow that did not exist. The uniqueness index is on a generated `email_norm AS (LOWER(email)) STORED` column under `utf8mb4_bin`, NOT on `email` under a `_ci` collation as the plan specified. Every case-insensitive collation this server offers is also accent-insensitive: `josé@x.com` and `jose@x.com` compare equal, and those are two different mailboxes. The plan's index would have refused the second address forever and the de-duplication would have nulled a legitimate account's. A requested address is STAGED in `email_pending` and only a tokened link installs it, so a typo cannot silently redirect account-recovery mail. `isDuplicateUsername()` now distinguishes the two indexes. All five call sites branch on it; each answers differently on purpose, because a public form, an IdP callback, a half-completed invite and an admin screen do not owe the same person the same amount of truth. SSO reads the IdP's actual `email_verified`/`verified` claim instead of inferring verification from an address merely being present. Co-Authored-By: Claude --- client/src/App.jsx | 4 + client/src/api/client.js | 18 + .../components/security/EmailAddressPanel.jsx | 175 +++++ .../src/routes/admin/views/AccountAdmin.jsx | 5 + client/src/routes/player/PlayerAccount.jsx | 2 + client/src/routes/player/VerifyEmail.jsx | 166 ++++ server/db/schema.sql | 149 +++- server/routes.guards.json | 65 ++ server/routes.manifest.json | 28 + server/src/auth/providers/base.provider.js | 4 +- server/src/auth/providers/discord.provider.js | 9 +- .../auth/providers/genericOidc.provider.js | 4 + server/src/auth/providers/google.provider.js | 10 +- server/src/middleware/rateLimit.js | 14 + .../src/model/emailDedupe/emailDedupe.db.js | 22 + .../model/emailDedupe/emailDedupe.model.js | 18 + .../emailVerifications.db.js | 52 ++ .../emailVerifications.model.js | 76 ++ server/src/model/settings/settings.model.js | 26 + server/src/model/users/users.db.js | 41 +- server/src/model/users/users.model.js | 71 +- .../src/router/v1/admin/admin.controller.js | 121 ++- .../src/router/v1/admin/invites.controller.js | 13 + server/src/router/v1/admin/invites.router.js | 1 + server/src/router/v1/admin/users.router.js | 25 + .../src/router/v1/auth/account.controller.js | 130 ++++ server/src/router/v1/auth/auth.controller.js | 14 + .../router/v1/auth/emailVerify.controller.js | 100 +++ .../src/router/v1/auth/emailVerify.router.js | 50 ++ server/src/router/v1/auth/index.js | 2 + .../src/router/v1/auth/invite.controller.js | 14 + server/src/router/v1/auth/me.routes.js | 44 ++ server/src/router/v1/auth/password.router.js | 2 +- .../v1/auth/passwordReset.controller.js | 17 +- server/src/router/v1/auth/sso.controller.js | 60 +- server/src/utils/mailer.js | 43 +- server/swagger/swagger-output.json | 706 +++++++++++++++++- server/swagger/swagger.js | 60 ++ server/test/authMe.test.js | 6 + server/test/emailCollisionSurfaces.test.js | 194 +++++ server/test/emailUniqueness.test.js | 87 +++ server/test/emailVerification.test.js | 278 +++++++ server/test/providers.test.js | 17 +- server/test/ssoEmailVerified.test.js | 140 ++++ 44 files changed, 3024 insertions(+), 59 deletions(-) create mode 100644 client/src/components/security/EmailAddressPanel.jsx create mode 100644 client/src/routes/player/VerifyEmail.jsx create mode 100644 server/src/model/emailDedupe/emailDedupe.db.js create mode 100644 server/src/model/emailDedupe/emailDedupe.model.js create mode 100644 server/src/model/emailVerifications/emailVerifications.db.js create mode 100644 server/src/model/emailVerifications/emailVerifications.model.js create mode 100644 server/src/router/v1/auth/emailVerify.controller.js create mode 100644 server/src/router/v1/auth/emailVerify.router.js create mode 100644 server/test/emailCollisionSurfaces.test.js create mode 100644 server/test/emailUniqueness.test.js create mode 100644 server/test/emailVerification.test.js create mode 100644 server/test/ssoEmailVerified.test.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 88ff3f7..860cc6d 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -54,6 +54,7 @@ import PlayerLogin from './routes/player/PlayerLogin.jsx' import PlayerRegister from './routes/player/PlayerRegister.jsx' import ForgotPassword from './routes/player/ForgotPassword.jsx' import ResetPassword from './routes/player/ResetPassword.jsx' +import VerifyEmail from './routes/player/VerifyEmail.jsx' import AcceptInvite from './routes/player/AcceptInvite.jsx' import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx' import PlayerAccount from './routes/player/PlayerAccount.jsx' @@ -205,6 +206,9 @@ export default function App() { } /> } /> } /> + {/* Opened from a mailbox, so public like the reset page above — the + token is the proof, and confirming issues no session. */} + } /> } /> {/* PUBLIC, and grouped with the other tokened landings above rather than with the portal below: the person following an unsubscribe diff --git a/client/src/api/client.js b/client/src/api/client.js index ff281f8..c219872 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -116,6 +116,18 @@ export const api = { req('/auth/me/account/username', { method: 'PATCH', body: { username } }), changePassword: (newPassword, currentPassword) => req('/auth/me/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }), + // Email address (engagement Phase 1b). changeEmail STAGES the address — the + // account keeps its current one until the emailed link is opened — so the UI + // must show `email_pending` as pending, never as the address in force. + changeEmail: (email, currentPassword) => + req('/auth/me/account/email', { method: 'PATCH', body: { email, currentPassword } }), + resendEmailVerification: () => req('/auth/me/account/email/resend', { method: 'POST' }), + cancelEmailChange: () => req('/auth/me/account/email/pending', { method: 'DELETE' }), + // The confirm half is public and token-gated — it is reached from a mailbox, + // often with no session, so it deliberately sits outside /auth/me. + lookupEmailVerification: (token) => req(`/auth/email/verify/${encodeURIComponent(token)}`), + confirmEmailVerification: (token) => + req(`/auth/email/verify/${encodeURIComponent(token)}`, { method: 'POST' }), totpSetup: () => req('/auth/me/account/totp/setup', { method: 'POST' }), totpEnable: (code) => req('/auth/me/account/totp/enable', { method: 'POST', body: { code } }), totpDisable: (code) => req('/auth/me/account/totp/disable', { method: 'POST', body: { code } }), @@ -312,6 +324,12 @@ export const api = { createUser: (data) => req('/admin/users', { method: 'POST', body: data }), updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }), deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }), + // Accounts whose address was cleared when addresses became unique (Phase 1b). + // They can still sign in but can receive no mail until they set a new one, so + // they are the list an operator has to work through. + emailDedupeReport: () => req('/admin/users/email-dedupe-report'), + acknowledgeEmailDedupeReport: () => + req('/admin/users/email-dedupe-report/acknowledge', { method: 'POST' }), // A user's trusted devices + MFA reset (admin only). userTrustedDevices: (id) => req(`/admin/users/${id}/trusted-devices`), revokeUserTrustedDevice: (id, deviceId) => diff --git a/client/src/components/security/EmailAddressPanel.jsx b/client/src/components/security/EmailAddressPanel.jsx new file mode 100644 index 0000000..4902669 --- /dev/null +++ b/client/src/components/security/EmailAddressPanel.jsx @@ -0,0 +1,175 @@ +import { useState } from 'react' +import { api } from '../../api/client.js' + +// Self-service email address (engagement Phase 1b). Shared by the player portal +// and the admin account screen, the same way TrustedDevicesPanel and +// RecoveryCodesPanel are — /auth/me/account is one surface for every role, so its +// UI is one component too. +// +// The property this component exists to make visible: a requested address is +// STAGED, not applied. The account keeps receiving mail — password resets +// included — at the address it already has until the emailed link is opened. If +// the UI let a pending address look like the address in force, someone who +// mistyped would believe the change took and would only discover otherwise when +// they could not recover their account. +// +// `hasPassword` decides whether the current-password field appears: an address is +// where account recovery lands, so changing it is re-authenticated, with the same +// carve-out the password form makes for an SSO-only account. +export default function EmailAddressPanel({ account, reload, embedded = false }) { + const hasPassword = account.has_password !== false + const [email, setEmail] = useState('') + const [current, setCurrent] = useState('') + const [busy, setBusy] = useState(false) + const [msg, setMsg] = useState('') + const [error, setError] = useState('') + + const pending = account.email_pending + + async function save(e) { + e.preventDefault() + setMsg('') + setError('') + setBusy(true) + try { + const res = await api.changeEmail(email.trim(), hasPassword ? current : undefined) + setEmail('') + setCurrent('') + // Report an unsent mail honestly. Saying "check your inbox" about a message + // that was never sent turns a configuration problem into a user who waits. + if (res.emailed === false) { + setMsg( + res.reason === 'NOT_CONFIGURED' + ? 'Address saved, but this site cannot send email right now. Ask an administrator, then use Resend.' + : 'Address saved, but the confirmation email could not be sent. Try Resend in a moment.', + ) + } else { + setMsg( + `Confirmation sent to ${res.email_pending}. Your current address stays in use until you open that link.`, + ) + } + await reload() + } catch (err) { + if (err.status === 429) setError('Too many confirmation emails. Try again later.') + else setError(err.message || 'Could not change your email address.') + } finally { + setBusy(false) + } + } + + async function resend() { + setMsg('') + setError('') + setBusy(true) + try { + const res = await api.resendEmailVerification() + setMsg( + res.emailed === false + ? 'Could not send the confirmation email.' + : `Confirmation re-sent to ${res.email_pending}.`, + ) + } catch (err) { + setError(err.message || 'Could not resend the confirmation email.') + } finally { + setBusy(false) + } + } + + async function discard() { + setMsg('') + setError('') + setBusy(true) + try { + await api.cancelEmailChange() + setMsg('Pending address discarded.') + await reload() + } catch (err) { + setError(err.message || 'Could not discard the pending address.') + } finally { + setBusy(false) + } + } + + const wrap = embedded + ? {} + : { marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 } + + return ( +
+

+ Email address +

+

+ {account.email ? ( + <> + Currently {account.email} + {account.email_verified ? ' (confirmed)' : ' (not yet confirmed)'}. This is where password-reset + email is sent. + + ) : ( + 'You have no email address on file, so you cannot reset your password by email.' + )} +

+ + {pending && ( +
+ {pending} is waiting to be confirmed. It is not in + use until you open the link in that email. +
+ + +
+
+ )} + +
+ + {hasPassword && ( + + )} +
+ +
+ {(msg || error) && ( +

+ {error || msg} +

+ )} +
+
+ ) +} diff --git a/client/src/routes/admin/views/AccountAdmin.jsx b/client/src/routes/admin/views/AccountAdmin.jsx index b557e3c..4c4ad6e 100644 --- a/client/src/routes/admin/views/AccountAdmin.jsx +++ b/client/src/routes/admin/views/AccountAdmin.jsx @@ -4,6 +4,7 @@ import ProviderIcon from '../../../components/ProviderIcon.jsx' import RecoveryCodesDisplay from '../../../components/security/RecoveryCodesDisplay.jsx' import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPanel.jsx' import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx' +import EmailAddressPanel from '../../../components/security/EmailAddressPanel.jsx' import { api } from '../../../api/client.js' // Link/unlink external SSO identities to this account. Linking redirects through @@ -322,6 +323,10 @@ export default function AccountAdmin() { )} + {/* The self-service address, from the same component the player portal + renders — /auth/me/account is one surface for every role. */} + {account && } +
) diff --git a/client/src/routes/player/PlayerAccount.jsx b/client/src/routes/player/PlayerAccount.jsx index 01782b2..d03645b 100644 --- a/client/src/routes/player/PlayerAccount.jsx +++ b/client/src/routes/player/PlayerAccount.jsx @@ -4,6 +4,7 @@ import { Loading, ErrorState } from '../../components/PageState.jsx' import RecoveryCodesDisplay from '../../components/security/RecoveryCodesDisplay.jsx' import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.jsx' import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.jsx' +import EmailAddressPanel from '../../components/security/EmailAddressPanel.jsx' import { useAuth } from '../../contexts/AuthContext.jsx' import { api } from '../../api/client.js' @@ -423,6 +424,7 @@ export default function PlayerAccount() { {account.email ? ` · ${account.email}` : ''}

+ {account.totp_enabled && ( diff --git a/client/src/routes/player/VerifyEmail.jsx b/client/src/routes/player/VerifyEmail.jsx new file mode 100644 index 0000000..877153d --- /dev/null +++ b/client/src/routes/player/VerifyEmail.jsx @@ -0,0 +1,166 @@ +import { useEffect, useState } from 'react' +import { Link, useParams } from 'react-router-dom' +import { api } from '../../api/client.js' +import PlayerShell from './PlayerShell.jsx' + +// Public, token-gated confirmation page (/account/verify-email/:token). +// +// Unauthenticated on purpose: the link arrives in a mailbox and is routinely +// opened on a device with no session. That is safe because the token IS the +// proof — opening it installs an address on the account it was minted for and +// does nothing else. No session is issued here, deliberately: proving control of +// a mailbox is not proving control of an account. +// +// Every failure the server can have — expired, already used, superseded by a +// later request, or an address another account confirmed first — comes back as +// the same 404. That is not laziness on the server's part; distinguishing them +// would let anyone test which addresses have accounts. So this page says the same +// thing for all of them, and must keep doing so. +export default function VerifyEmail() { + const { token } = useParams() + + const [link, setLink] = useState(null) // { username, email } once validated + const [loadErr, setLoadErr] = useState('') + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const [done, setDone] = useState(false) + + useEffect(() => { + let active = true + api + .lookupEmailVerification(token) + .then((r) => active && setLink(r || {})) + .catch( + (err) => + active && + setLoadErr( + err.status === 404 + ? 'This confirmation link is invalid or has expired.' + : 'Could not load this confirmation link.', + ), + ) + return () => { + active = false + } + }, [token]) + + async function onConfirm() { + setError('') + setBusy(true) + try { + await api.confirmEmailVerification(token) + setDone(true) + } catch (err) { + if (err.status === 404) setError('This confirmation link is no longer usable. Request a new one from your account page.') + else if (err.status === 429) setError('Too many attempts. Please try again in a little while.') + else setError('Could not confirm your address right now. Please try again later.') + setBusy(false) + } + } + + // ── Invalid link ─────────────────────────────────────────────────────────── + if (loadErr) { + return ( + +

+ {loadErr} +

+

+ + Go to your account + +

+
+ ) + } + if (link === null) { + return ( + +
+ +
+
+ ) + } + + // ── Done ─────────────────────────────────────────────────────────────────── + if (done) { + return ( + +

+ {link.email ? ( + <> + {link.email} is now the address for + {link.username ? ( + <> + {' '} + {link.username} + + ) : ( + ' your account' + )} + . + + ) : ( + 'Your email address has been confirmed.' + )} +

+

+ You have not been signed in — confirming an address does not sign you in. +

+

+ + Sign in + +

+
+ ) + } + + // ── Confirm ──────────────────────────────────────────────────────────────── + // + // A button rather than confirming on load. A mail client or scanner that + // pre-fetches links would otherwise spend the token before the person ever saw + // it, and this token is single-use. + return ( + +

+ Confirm that{' '} + {link.email ? {link.email} : 'this address'} should be + the contact and account-recovery address for + {link.username ? ( + <> + {' '} + {link.username} + + ) : ( + ' this account' + )} + . +

+ + {error && ( +

+ {error} +

+ )} + + + +

+ If you did not ask for this, close this page. Nothing changes and no account of yours is affected. +

+
+ ) +} diff --git a/server/db/schema.sql b/server/db/schema.sql index f6cfb91..bfe0b9f 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -21,11 +21,30 @@ CREATE TABLE IF NOT EXISTS users ( -- (validatePassword returns false). password_hash VARCHAR(72) NULL, role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'admin', - -- Optional contact email (players). Not unique — SSO emails may repeat. Used - -- only for display + a future self-serve reset. email_verified is wired now so - -- an eventual SMTP verification flow needs no schema change. + -- The account's ONE contact address, and the destination for password-reset + -- mail. Unique since engagement Phase 1b — but the index is on email_norm + -- below, never on this column, and the reason is not stylistic: + -- + -- Every case-insensitive (_ci) collation this server offers is ALSO + -- accent-insensitive, so a UNIQUE index on `email` would refuse + -- jose@x.com once josé@x.com exists. Those are two different mailboxes. + -- + -- LOWER() under a _bin collation folds case WITHOUT folding accents, which is + -- exactly the equivalence a mail system uses. Keeping the fold in a generated + -- column rather than in application code means it cannot be bypassed by a + -- caller that forgets to normalize. email VARCHAR(255) NULL, + -- The uniqueness key. STORED (not VIRTUAL) because a UNIQUE index over it must + -- be materialized. Multiple NULLs are legal under a UNIQUE index, which is what + -- lets the Phase 1b de-duplication null the losers without deleting an account. + email_norm VARCHAR(255) COLLATE utf8mb4_bin AS (LOWER(email)) STORED, email_verified TINYINT(1) NOT NULL DEFAULT 0, + -- An address the user has asked for but not yet proved. It does NOT displace + -- `email` until the verification link is used, so a typo cannot silently + -- redirect this account's password-reset mail. Deliberately NOT unique: a + -- pending address reserves nothing, and two users may both be pending on one + -- address — the second to verify loses, with the same generic failure. + email_pending VARCHAR(255) NULL, -- Account lifecycle, independent of role: staff can disable/ban a player -- without changing their role. active = normal; disabled = admin-locked; -- banned = moderation ban; pending = reserved for future email-verify gating. @@ -38,7 +57,12 @@ CREATE TABLE IF NOT EXISTS users ( tokens_valid_after DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, last_login_at DATETIME NULL, - last_login_ip VARCHAR(45) NULL -- IPv6-capable, set on each login + last_login_ip VARCHAR(45) NULL, -- IPv6-capable, set on each login + -- One account per mailbox (engagement Phase 1b). On the generated column, not + -- on `email` — see the note there. Upgraded databases get this in the migration + -- block at the foot of this file, AFTER the de-duplication that makes it + -- addable; adding it here too is what gives a FRESH install the same shape. + UNIQUE KEY uq_users_email_norm (email_norm) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS posts ( @@ -415,6 +439,54 @@ CREATE TABLE IF NOT EXISTS password_resets ( INDEX idx_password_resets_status (status, expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Self-service email verification (engagement Phase 1b). The same shape as +-- password_resets, deliberately: an opaque random token whose sha256 is all that +-- is stored, single-use, short-lived. The design of record calls this link +-- "signed"; every comparable flow in this codebase (user_invites, +-- password_resets, mobile_refresh_tokens) uses a hashed random token instead, and +-- matching them beats introducing a second token mechanism for one caller. +-- +-- The address lives on the ROW, not just on the user: a token proves control of +-- the address it was mailed to, so if the user changes their mind and requests a +-- different address, the older token must not be able to confirm the newer one. +CREATE TABLE IF NOT EXISTS email_verifications ( + id INT AUTO_INCREMENT PRIMARY KEY, + token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque token + user_id INT NOT NULL, + email VARCHAR(255) NOT NULL, -- the address THIS token proves + status ENUM('pending','used') NOT NULL DEFAULT 'pending', + requested_ip VARCHAR(64) NULL, -- who asked (audit only) + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + used_at DATETIME NULL, + CONSTRAINT fk_email_verifications_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_email_verifications_user (user_id), + INDEX idx_email_verifications_status (status, expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Who lost an address to the Phase 1b de-duplication, and what they lost. +-- +-- These accounts are exactly the ones an operator must contact: they can no +-- longer receive password-reset or engagement mail until they set a new address. +-- Written by the migration below in pure SQL (ensureSchema() reads this file +-- statement-by-statement and there is no JS migration hook), surfaced as a +-- dashboard warning until acknowledged. +-- +-- No foreign key to users, on purpose: the same reasoning as posts.announce_job_id +-- — a constraint re-added on every boot is a constraint that can fail a boot, and +-- this table is a historical record rather than a live relation. +CREATE TABLE IF NOT EXISTS email_dedupe_report ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + username VARCHAR(32) NOT NULL, -- captured at clear time + lost_address VARCHAR(255) NOT NULL, + cleared_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + acknowledged_at DATETIME NULL, -- set when an admin dismisses the warning + -- Makes the migration's INSERT strictly idempotent: an account cleared once is + -- never reported twice, however many times ensureSchema() runs. + UNIQUE KEY uq_edr_user (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- ── Push notifications (opt-in) ───────────────────────────────────────────── -- One row per registered push endpoint (Android/UnifiedPush v1; FCM later). The -- `endpoint` is the UnifiedPush distributor URL the app's ntfy topic was handed — @@ -1489,6 +1561,75 @@ ALTER TABLE email_config ADD COLUMN IF NOT EXISTS transport VARCHAR(32) NOT NULL ALTER TABLE email_config ADD COLUMN IF NOT EXISTS credential_enc TEXT NULL; ALTER TABLE email_config ADD COLUMN IF NOT EXISTS reply_to VARCHAR(255) NULL; +-- ── Engagement Phase 1b: one account per mailbox ─────────────────────────── +-- (ENGAGEMENT.md Phase 1b / §0.6.) ORDER IS LOAD-BEARING and every statement here +-- is idempotent — after the first successful boot each one matches zero rows. +-- +-- Why the generated column is added BEFORE the de-duplication rather than after: +-- the de-dupe must group addresses exactly the way the index will, and it cannot +-- do that with LOWER(email) = LOWER(email) in SQL, because that comparison uses +-- the COLUMN's collation, which is accent-insensitive. Grouping on email_norm — +-- the very column the UNIQUE index goes on — makes the two agree by construction +-- instead of by a hand-matched COLLATE clause someone can get wrong later. +-- (Tested: with the LOWER()=LOWER() form, jose@x.com was nulled as a "duplicate" +-- of josé@x.com. They are different mailboxes.) + +-- 1. An empty string is a value, not an absence, so two accounts holding '' would +-- collide under the index and stop the boot. Unreachable through the current +-- routes (isEmail() rejects ''), but this runs against databases whose history +-- we do not control. +UPDATE users SET email = NULL WHERE email = ''; + +-- 2. The pending-address column and the uniqueness key. No index yet — a UNIQUE +-- index here, before step 3, is precisely the ALTER that fails and takes the +-- site down with it (§0.6 finding 1). +ALTER TABLE users ADD COLUMN IF NOT EXISTS email_pending VARCHAR(255) NULL; +ALTER TABLE users ADD COLUMN IF NOT EXISTS email_norm VARCHAR(255) COLLATE utf8mb4_bin AS (LOWER(email)) STORED; + +-- 3. Record every account about to lose its address, BEFORE nulling it — the +-- report is the only place the lost value survives. Oldest-wins (§7.1 Q1): +-- the earliest-created account keeps the address, ties broken by id so the +-- outcome is deterministic. Verified status deliberately does NOT arbitrate — +-- SSO set email_verified from the mere presence of an address, so it is too +-- weak a signal to decide who keeps a mailbox (§0.6 finding 3). +INSERT IGNORE INTO email_dedupe_report (user_id, username, lost_address) +SELECT l.id, l.username, l.email FROM ( + SELECT u.id, u.username, u.email FROM users u + WHERE u.email_norm IS NOT NULL + AND u.id <> (SELECT u2.id FROM users u2 + WHERE u2.email_norm = u.email_norm + ORDER BY u2.created_at ASC, u2.id ASC LIMIT 1) +) AS l; + +-- 4. Clear the losers. NEVER deletes a row: multiple NULLs are legal under a +-- UNIQUE index, so every account survives with its login intact and simply has +-- no contact address until its owner sets one. The extra derived table is not +-- decoration — MariaDB refuses a subquery on the table being updated (error +-- 1093) without it. +UPDATE users SET email = NULL, email_verified = 0 + WHERE id IN (SELECT id FROM ( + SELECT u.id FROM users u + WHERE u.email_norm IS NOT NULL + AND u.id <> (SELECT u2.id FROM users u2 + WHERE u2.email_norm = u.email_norm + ORDER BY u2.created_at ASC, u2.id ASC LIMIT 1) + ) AS losers); + +-- 5. Now the table can hold it. +ALTER TABLE users ADD UNIQUE INDEX IF NOT EXISTS uq_users_email_norm (email_norm); + +-- 6. The verification gate: may an UNVERIFIED address receive opt-in engagement +-- mail? ON for a fresh install, OFF for an upgrade — the asymmetry is the G22 +-- lesson, not an oversight. Turning it on retroactively would silently stop +-- mailing every existing opted-in user on upgrade day, which is exactly the +-- kind of quiet breakage Phase 1 had to write a dashboard warning to undo. +-- "Fresh" is read off the users table: a database with no users has no one to +-- surprise. Both statements are INSERT IGNORE, so an operator who has since +-- changed the value keeps theirs. +INSERT IGNORE INTO settings (`key`, value) +SELECT 'email_verification_required', 'on' FROM DUAL WHERE (SELECT COUNT(*) FROM users) = 0; +INSERT IGNORE INTO settings (`key`, value) VALUES ('email_verification_required', 'off'); + -- The status a Gmail-connected deployment carries is 'connected', and after the -- upgrade that is a lie: nothing can send. Correct it once, narrowly. The WHERE -- makes this idempotent and self-limiting — it matches only a row that still holds diff --git a/server/routes.guards.json b/server/routes.guards.json index a4499fe..b931ae3 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -1020,6 +1020,24 @@ "validate" ] }, + { + "method": "GET", + "path": "/api/v1/admin/users/email-dedupe-report", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/users/email-dedupe-report/acknowledge", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, { "method": "GET", "path": "/api/v1/admin/wiki", @@ -1162,6 +1180,24 @@ "requireAuth" ] }, + { + "method": "GET", + "path": "/api/v1/auth/email/verify/:token", + "handlers": 3, + "gates": [ + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/auth/email/verify/:token", + "handlers": 4, + "gates": [ + "middleware", + "validate" + ] + }, { "method": "GET", "path": "/api/v1/auth/invite/:token", @@ -1226,6 +1262,35 @@ "requireAuth" ] }, + { + "method": "PATCH", + "path": "/api/v1/auth/me/account/email", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/auth/me/account/email/pending", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/auth/me/account/email/resend", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, { "method": "GET", "path": "/api/v1/auth/me/account/identities", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index ed7f851..ea1427e 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -401,6 +401,14 @@ "method": "DELETE", "path": "/api/v1/admin/users/:id/trusted-devices/:deviceId" }, + { + "method": "GET", + "path": "/api/v1/admin/users/email-dedupe-report" + }, + { + "method": "POST", + "path": "/api/v1/admin/users/email-dedupe-report/acknowledge" + }, { "method": "GET", "path": "/api/v1/admin/wiki" @@ -457,6 +465,14 @@ "method": "GET", "path": "/api/v1/admin/wiki/tags" }, + { + "method": "GET", + "path": "/api/v1/auth/email/verify/:token" + }, + { + "method": "POST", + "path": "/api/v1/auth/email/verify/:token" + }, { "method": "GET", "path": "/api/v1/auth/invite/:token" @@ -485,6 +501,18 @@ "method": "GET", "path": "/api/v1/auth/me/account" }, + { + "method": "PATCH", + "path": "/api/v1/auth/me/account/email" + }, + { + "method": "DELETE", + "path": "/api/v1/auth/me/account/email/pending" + }, + { + "method": "POST", + "path": "/api/v1/auth/me/account/email/resend" + }, { "method": "GET", "path": "/api/v1/auth/me/account/identities" diff --git a/server/src/auth/providers/base.provider.js b/server/src/auth/providers/base.provider.js index 093acb8..2ceb19c 100644 --- a/server/src/auth/providers/base.provider.js +++ b/server/src/auth/providers/base.provider.js @@ -37,7 +37,7 @@ class BaseProvider { } // Complete an SSO redirect flow: exchange the callback code for a normalized - // user profile ({ subject, email, name }). + // user profile ({ subject, email, emailVerified, name }). // eslint-disable-next-line no-unused-vars async handleCallback(params) { throw new Error(`handleCallback() not implemented for provider '${this.id}'`) @@ -49,7 +49,7 @@ class BaseProvider { throw new Error(`getUserProfile() not implemented for provider '${this.id}'`) } - // Normalize a raw external profile to { subject, email, name }. + // Normalize a raw external profile to { subject, email, emailVerified, name }. // eslint-disable-next-line no-unused-vars mapUser(profile) { throw new Error(`mapUser() not implemented for provider '${this.id}'`) diff --git a/server/src/auth/providers/discord.provider.js b/server/src/auth/providers/discord.provider.js index 76f192b..e493d6c 100644 --- a/server/src/auth/providers/discord.provider.js +++ b/server/src/auth/providers/discord.provider.js @@ -23,7 +23,14 @@ class DiscordProvider extends OAuth2Provider { } normalizeProfile(p = {}) { // global_name is the new display name; fall back to the legacy username. - return { subject: p.id, email: p.email || null, name: p.global_name || p.username || null } + return { + subject: p.id, + email: p.email || null, + // Discord spells the claim `verified` rather than `email_verified`, and it + // means exactly this: the user confirmed the address with Discord. + emailVerified: p.verified === true, + name: p.global_name || p.username || null, + } } } diff --git a/server/src/auth/providers/genericOidc.provider.js b/server/src/auth/providers/genericOidc.provider.js index bc1c0ac..2995b83 100644 --- a/server/src/auth/providers/genericOidc.provider.js +++ b/server/src/auth/providers/genericOidc.provider.js @@ -30,6 +30,10 @@ class GenericOidcProvider extends OAuth2Provider { return { subject: p.sub || p.id || p.user_id || p.uid || null, email: p.email || null, + // The standard OIDC claim. An IdP that omits it has not asserted anything, + // so the address stays unverified and the user proves it the ordinary way — + // absent is treated as false, never as true. + emailVerified: p.email_verified === true || p.email_verified === 'true', name: p.name || p.preferred_username || p.username || p.email || null, } } diff --git a/server/src/auth/providers/google.provider.js b/server/src/auth/providers/google.provider.js index 5597bd4..98653c5 100644 --- a/server/src/auth/providers/google.provider.js +++ b/server/src/auth/providers/google.provider.js @@ -27,7 +27,15 @@ class GoogleProvider extends OAuth2Provider { return { access_type: 'online', prompt: 'select_account' } } normalizeProfile(p = {}) { - return { subject: p.sub, email: p.email || null, name: p.name || p.email || null } + return { + subject: p.sub, + email: p.email || null, + // Google's OIDC userinfo carries the standard `email_verified` claim. Read + // it rather than inferring verification from the mere presence of an + // address, which is what this code used to do (ENGAGEMENT.md §0.6/1b). + emailVerified: p.email_verified === true || p.email_verified === 'true', + name: p.name || p.email || null, + } } } diff --git a/server/src/middleware/rateLimit.js b/server/src/middleware/rateLimit.js index db1fa2d..ede7ce0 100644 --- a/server/src/middleware/rateLimit.js +++ b/server/src/middleware/rateLimit.js @@ -118,6 +118,19 @@ const passwordResetConfirmLimiter = makeLimiter({ message: 'Too many attempts. Please try again later.', }) +// Email-verification confirmations (engagement Phase 1b). Same reasoning as the +// password-reset confirm limiter: the token is 256-bit random, but an +// unauthenticated token-bearing endpoint should not be free to hammer. The +// REQUEST side is authenticated and limited separately — accountChangeLimiter per +// IP, plus a per-user ceiling in the model, because the mail goes to an address +// its recipient did not ask to hear from. +const emailVerifyConfirmLimiter = makeLimiter({ + windowMs: 15 * 60 * 1000, + max: 15, + label: 'email-verify-confirm', + message: 'Too many attempts. Please try again later.', +}) + // CSP violation reports. Unauthenticated by necessity (browsers send them with no // session), and every accepted report writes a log line — so an attacker who can get // a victim to load a page could otherwise use it as a log-flood amplifier. Generous @@ -149,5 +162,6 @@ module.exports = { mobileSsoExchangeLimiter, passwordResetRequestLimiter, passwordResetConfirmLimiter, + emailVerifyConfirmLimiter, cspReportLimiter, } diff --git a/server/src/model/emailDedupe/emailDedupe.db.js b/server/src/model/emailDedupe/emailDedupe.db.js new file mode 100644 index 0000000..1aadf78 --- /dev/null +++ b/server/src/model/emailDedupe/emailDedupe.db.js @@ -0,0 +1,22 @@ +const { query } = require('../../utils/db') + +const COLS = 'id, user_id, username, lost_address, cleared_at, acknowledged_at' + +// Accounts cleared by the Phase 1b de-duplication, newest first. +async function list() { + return query(`SELECT ${COLS} FROM email_dedupe_report ORDER BY cleared_at DESC, id DESC`) +} + +async function countUnacknowledged() { + const rows = await query('SELECT COUNT(*) AS n FROM email_dedupe_report WHERE acknowledged_at IS NULL') + return Number(rows[0] ? rows[0].n : 0) +} + +// Dismiss the whole report. Idempotent — an already-acknowledged row is skipped +// so a second dismissal cannot rewrite when it happened. +async function acknowledgeAll() { + const res = await query('UPDATE email_dedupe_report SET acknowledged_at = NOW() WHERE acknowledged_at IS NULL') + return res.affectedRows || 0 +} + +module.exports = { list, countUnacknowledged, acknowledgeAll } diff --git a/server/src/model/emailDedupe/emailDedupe.model.js b/server/src/model/emailDedupe/emailDedupe.model.js new file mode 100644 index 0000000..6f51b9c --- /dev/null +++ b/server/src/model/emailDedupe/emailDedupe.model.js @@ -0,0 +1,18 @@ +// The Phase 1b de-duplication report: who lost an email address when the UNIQUE +// index went on, and what they lost. +// +// The rows are written by schema.sql's migration in pure SQL — ensureSchema() +// executes that file statement-by-statement and there is no JS migration hook — +// so this model only ever READS and acknowledges. Nothing here creates a row. +// +// It matters because these accounts are exactly the ones an operator must +// contact: each can still log in, but has no contact address, so password-reset +// and engagement mail have nowhere to go until its owner sets a new one. + +const db = require('./emailDedupe.db') + +const list = () => db.list() +const countUnacknowledged = () => db.countUnacknowledged() +const acknowledgeAll = () => db.acknowledgeAll() + +module.exports = { list, countUnacknowledged, acknowledgeAll } diff --git a/server/src/model/emailVerifications/emailVerifications.db.js b/server/src/model/emailVerifications/emailVerifications.db.js new file mode 100644 index 0000000..bd3af12 --- /dev/null +++ b/server/src/model/emailVerifications/emailVerifications.db.js @@ -0,0 +1,52 @@ +const { query } = require('../../utils/db') + +const COLS = 'id, token_hash, user_id, email, status, requested_ip, expires_at, created_at, used_at' + +async function insert({ tokenHash, userId, email, requestedIp, expiresAt }) { + const res = await query( + `INSERT INTO email_verifications (token_hash, user_id, email, requested_ip, expires_at) + VALUES (?, ?, ?, ?, ?)`, + [tokenHash, userId, email, requestedIp ?? null, expiresAt], + ) + return res.insertId +} + +async function findByTokenHash(tokenHash) { + const rows = await query(`SELECT ${COLS} FROM email_verifications WHERE token_hash = ? LIMIT 1`, [tokenHash]) + return rows[0] || null +} + +// Mark used only if still pending (atomic guard against a double-use race). +// Returns rows changed (1 = we won, 0 = already used). +async function markUsed(id) { + const res = await query( + `UPDATE email_verifications SET status = 'used', used_at = NOW() + WHERE id = ? AND status = 'pending'`, + [id], + ) + return res.affectedRows || 0 +} + +// Retire every still-pending verification for a user. Called when a fresh request +// supersedes older links and after a successful verification, so an address the +// user changed their mind about can never be installed by an old email. +async function invalidatePendingForUser(userId) { + const res = await query( + `UPDATE email_verifications SET status = 'used', used_at = NOW() + WHERE user_id = ? AND status = 'pending'`, + [userId], + ) + return res.affectedRows || 0 +} + +// How many verification mails this user has asked for since `since`. Backs the +// per-user resend ceiling, which the IP rate limiter cannot provide on its own. +async function countRecentForUser(userId, since) { + const rows = await query( + 'SELECT COUNT(*) AS n FROM email_verifications WHERE user_id = ? AND created_at >= ?', + [userId, since], + ) + return Number(rows[0] ? rows[0].n : 0) +} + +module.exports = { insert, findByTokenHash, markUsed, invalidatePendingForUser, countRecentForUser } diff --git a/server/src/model/emailVerifications/emailVerifications.model.js b/server/src/model/emailVerifications/emailVerifications.model.js new file mode 100644 index 0000000..190a4cc --- /dev/null +++ b/server/src/model/emailVerifications/emailVerifications.model.js @@ -0,0 +1,76 @@ +// Self-service email verification (engagement Phase 1b). A user asks to set or +// change their address; a tokened link goes to the address they typed, and only +// opening that link installs it. The opaque token lives only in the emailed link — +// the DB stores its sha256 — so a DB read never yields a usable link. Same shape +// as password_resets and user_invites, deliberately: the design of record calls +// this link "signed", but every comparable flow here uses a hashed random token, +// and matching them beats adding a second token mechanism for one caller. +// +// The address is stored ON THE ROW rather than read from the user at confirm +// time, because a token proves control of the address it was mailed to and +// nothing else. + +const crypto = require('crypto') +const db = require('./emailVerifications.db') + +// A day, not an hour. Unlike a password reset this is not a live credential-reset +// capability — the worst a leaked token does is attach an address its holder +// already controls — and a verification mail is routinely opened on another +// device, hours later. +const DEFAULT_TTL_MINUTES = 24 * 60 + +// Per-user ceiling on verification sends, independent of the per-IP limiter: the +// mail goes to an address the RECIPIENT did not choose to hear from, so an +// attacker with one account must not be able to use it to pester a mailbox. +const MAX_SENDS_PER_WINDOW = 5 +const SEND_WINDOW_MINUTES = 60 + +function hashToken(raw) { + return crypto.createHash('sha256').update(String(raw)).digest('hex') +} + +// Create a verification for one user + address. Returns { id, token } — the +// plaintext token is returned ONCE, for the link, and is never recoverable after. +async function create({ userId, email, requestedIp, ttlMinutes = DEFAULT_TTL_MINUTES }) { + const token = crypto.randomBytes(32).toString('base64url') + const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000) + const id = await db.insert({ tokenHash: hashToken(token), userId, email, requestedIp, expiresAt }) + return { id, token } +} + +// Resolve a pending, unexpired verification from its plaintext token, else null. +// Returns the RAW row (incl. user_id and the address it proves). +async function findValidByToken(token) { + if (!token) return null + const row = await db.findByTokenHash(hashToken(token)) + if (!row || row.status !== 'pending') return null + if (new Date(row.expires_at).getTime() < Date.now()) return null + return row +} + +// Atomically consume a pending verification (double-use-safe). True if this call +// won the race. +async function consume(id) { + return (await db.markUsed(id)) === 1 +} + +const invalidatePendingForUser = (userId) => db.invalidatePendingForUser(userId) + +// True when this user has already asked for as many verification mails as the +// window allows. +async function sendQuotaExhausted(userId) { + const since = new Date(Date.now() - SEND_WINDOW_MINUTES * 60 * 1000) + return (await db.countRecentForUser(userId, since)) >= MAX_SENDS_PER_WINDOW +} + +module.exports = { + create, + findValidByToken, + consume, + invalidatePendingForUser, + sendQuotaExhausted, + hashToken, + DEFAULT_TTL_MINUTES, + MAX_SENDS_PER_WINDOW, + SEND_WINDOW_MINUTES, +} diff --git a/server/src/model/settings/settings.model.js b/server/src/model/settings/settings.model.js index ded35a8..14ae934 100644 --- a/server/src/model/settings/settings.model.js +++ b/server/src/model/settings/settings.model.js @@ -67,6 +67,30 @@ function registrationFlags(mode) { } } +// Engagement Phase 1b — may an UNVERIFIED address receive opt-in engagement mail? +// Stored as 'on'/'off'. Seeded by schema.sql ASYMMETRICALLY on purpose: 'on' for a +// fresh install, 'off' for an upgrade. Turning it on retroactively would silently +// stop mailing every already-opted-in user on the day the operator upgraded, which +// is the G22 mistake — a safe default must not be applied backwards to a running +// system without telling anyone. +// +// Nothing CONSUMES this yet: the engine that would honour it arrives in Phase 4 +// and the deliverability rules in Phase 9. It is seeded and editable here because +// the fresh-vs-upgrade distinction is only knowable at the migration that adds it, +// and reconstructing "was this install fresh?" later is guesswork. +const EMAIL_VERIFICATION_KEY = 'email_verification_required' + +// Fail-safe direction is 'off': an unreadable or missing value must not silently +// suppress mail an operator believes is going out. The loud failure mode (mail +// reaching an unverified address) is recoverable; the quiet one is not. +async function isEmailVerificationRequired() { + try { + return String(await settingsDb.get(EMAIL_VERIFICATION_KEY)) === 'on' + } catch { + return false + } +} + // Android App Links opt-in (M9 follow-up). When on, the shard auto-serves // /.well-known/assetlinks.json and the mobile SSO bridge additionally accepts the // self-origin https:///mobile/callback redirect. Stored as the string @@ -256,6 +280,8 @@ module.exports = { REGISTRATION_KEY, REGISTRATION_MODES, getRegistrationMode, + EMAIL_VERIFICATION_KEY, + isEmailVerificationRequired, registrationFlags, MOBILE_APP_LINKS_KEY, isMobileAppLinksEnabled, diff --git a/server/src/model/users/users.db.js b/server/src/model/users/users.db.js index fd888cb..495c697 100644 --- a/server/src/model/users/users.db.js +++ b/server/src/model/users/users.db.js @@ -1,7 +1,7 @@ const { query } = require('../../utils/db') const PUBLIC_COLS = - 'id, username, role, status, email, email_verified, totp_enabled, created_at, last_login_at' + 'id, username, role, status, email, email_verified, email_pending, totp_enabled, created_at, last_login_at' // passwordHash may be null (SSO-provisioned players who have not set one yet). // email/status/emailVerified are optional so existing admin-create callers are @@ -31,17 +31,47 @@ async function findById(id) { return rows[0] || null } -// All ACTIVE accounts on an email address. Email is intentionally non-unique -// (SSO emails may repeat), so a reset request can legitimately match several +// The ACTIVE account on an email address, as a list. Unique since engagement +// Phase 1b, so this returns at most one row — the array shape is kept because the +// password-reset caller iterates and there is nothing to gain from making it care. // accounts; the caller issues one reset link per row. Case-insensitive to match // however the address was stored. Excludes disabled/banned accounts. +// Active accounts on an address. Matches on email_norm, the same generated column +// the UNIQUE index uses, so a lookup folds case exactly the way uniqueness does — +// LOWER() here and LOWER() there can never drift apart. Since Phase 1b this +// returns at most one row; it still returns an array because the password-reset +// caller iterates and there is no value in making that caller care. async function findActiveByEmail(email) { return query( - "SELECT * FROM users WHERE email = ? AND status = 'active'", + "SELECT * FROM users WHERE email_norm = LOWER(?) AND status = 'active'", [email], ) } +// Stage an address the user has asked for but not yet proved. Does not touch +// `email`, so their current address keeps receiving mail until the link is used. +async function setPendingEmail(id, email) { + const res = await query('UPDATE users SET email_pending = ? WHERE id = ?', [email, id]) + return res.affectedRows || 0 +} + +// Promote a proved address into place. Guarded on email_pending still matching, so +// a stale link (the user asked again for a different address) cannot install the +// address it was minted for. Returns rows changed — 0 means the guard rejected it. +async function promotePendingEmail(id, email) { + const res = await query( + `UPDATE users SET email = ?, email_verified = 1, email_pending = NULL + WHERE id = ? AND email_pending = ?`, + [email, id, email], + ) + return res.affectedRows || 0 +} + +async function clearPendingEmail(id) { + const res = await query('UPDATE users SET email_pending = NULL WHERE id = ?', [id]) + return res.affectedRows || 0 +} + async function listUsers() { return query(`SELECT ${PUBLIC_COLS} FROM users ORDER BY id ASC`) } @@ -112,6 +142,9 @@ module.exports = { findByUsername, findById, findActiveByEmail, + setPendingEmail, + promotePendingEmail, + clearPendingEmail, listUsers, updateUser, deleteUser, diff --git a/server/src/model/users/users.model.js b/server/src/model/users/users.model.js index cee8717..01adef8 100644 --- a/server/src/model/users/users.model.js +++ b/server/src/model/users/users.model.js @@ -18,13 +18,54 @@ async function createUser({ username, password, role = 'admin', email = null, st return sanitize(await usersDb.findById(id)) } -// True when a DB error is the unique-index violation on username (the atomic -// backstop for the uniqueness race). Callers translate this into a 409 rather -// than doing a check-then-write. -function isDuplicateUsername(err) { +// ── Telling the two unique constraints apart ─────────────────────────────── +// +// `users` has had one unique index (username) for its whole life, so a bare +// "is this a duplicate-key error" test was enough. Engagement Phase 1b adds a +// second (email, via the generated email_norm column), and the moment it exists +// an undiscriminating test starts LYING: a duplicate email would be reported to +// the user as a taken username, and SSO provisioning would retry usernames +// forever against a conflict no username can clear (§0.6 finding 2). +// +// The violated index name is available ONLY in the driver's message text — the +// mariadb connector exposes no structured field for it — so this reads it back +// out. Verified against MariaDB 11.8: +// "(conn:60, no: 1062, SQLState: 23000) Duplicate entry 'x' for key 'username'" +// +// NOTE the message also embeds the bound parameters, so on an email collision it +// contains the address. That is fine in a server log and is exactly why these +// errors must never be echoed to a client (the anti-enumeration rule below). +const EMAIL_UNIQUE_KEY = 'uq_users_email_norm' + +function isDuplicateKeyError(err) { return Boolean(err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) } +// The name of the unique index that was violated, or null if this is not a +// duplicate-key error (or the driver phrased it in a way we do not recognise). +function duplicateKey(err) { + if (!isDuplicateKeyError(err)) return null + const m = /for key '([^']+)'/.exec(err.sqlMessage || err.message || '') + return m ? m[1] : null +} + +// True when the collision was on the email uniqueness index. +function isDuplicateEmail(err) { + return duplicateKey(err) === EMAIL_UNIQUE_KEY +} + +// True when a DB error is a unique-index violation that is NOT the email one (the +// atomic backstop for the username-uniqueness race). Callers translate this into +// a 409 rather than doing a check-then-write. +// +// Deliberately "not email" rather than "is username": on a database whose index +// happens to carry a different name, the old permissive behaviour is preserved +// and nothing newly falls through to a 500. Only the case we can positively +// identify — email — is carved out. +function isDuplicateUsername(err) { + return isDuplicateKeyError(err) && !isDuplicateEmail(err) +} + // Returns the raw row (incl. hash) — used by login only. async function getRawByUsername(username) { return usersDb.findByUsername(username) @@ -35,7 +76,7 @@ async function getById(id) { } // Raw rows (incl. email/status) for every active account on an email address. -// Server-side only (password-reset request); email is non-unique so this may +// Server-side only (password-reset request). Unique since Phase 1b, so this // return several. Never sent to a client. async function getActiveByEmail(email) { if (!email) return [] @@ -84,6 +125,20 @@ async function update(id, { username, password, role, email, status, emailVerifi return getById(id) } +// ── Pending email address (engagement Phase 1b) ──────────────────────────── +// A requested address is staged rather than installed: `email` keeps working +// until the verification link proves the new one. See the users table comments. +const setPendingEmail = (id, email) => usersDb.setPendingEmail(id, email) +const clearPendingEmail = (id) => usersDb.clearPendingEmail(id) + +// Promote a proved address. Returns true only if it actually landed; false means +// the guard rejected it (the user has since asked for a different address, so the +// token in hand is stale). Throws the duplicate-key error if the address was +// claimed by someone else in the meantime — the caller answers that generically. +async function promotePendingEmail(id, email) { + return (await usersDb.promotePendingEmail(id, email)) === 1 +} + // Invalidate every session token this user currently holds ("log out everywhere") // by advancing their tokens_valid_after cutoff to now. async function invalidateSessions(id) { @@ -115,9 +170,15 @@ async function recordLogin(id, ip = null) { module.exports = { createUser, isDuplicateUsername, + isDuplicateEmail, + duplicateKey, + EMAIL_UNIQUE_KEY, getRawByUsername, getById, getActiveByEmail, + setPendingEmail, + clearPendingEmail, + promotePendingEmail, getRawById, validatePassword, list, diff --git a/server/src/router/v1/admin/admin.controller.js b/server/src/router/v1/admin/admin.controller.js index e542700..8447d3a 100644 --- a/server/src/router/v1/admin/admin.controller.js +++ b/server/src/router/v1/admin/admin.controller.js @@ -10,6 +10,7 @@ const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model' const registries = require('../../../modules/registries') 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') @@ -89,6 +90,60 @@ async function emailWarning() { } } +// Phase 1b — the de-duplication cleared some accounts' addresses so a UNIQUE +// index could go on (ENGAGEMENT.md Phase 1b / §0.6 finding 1). Same posture as +// the email warning above: narrow, self-clearing, and silent on the installs it +// does not concern. +// +// It has to be said out loud for the same reason G22 did. Nothing broke visibly — +// those users can still log in — but they can no longer receive password-reset or +// engagement mail, and they are the only people who can fix that, so somebody has +// to tell the operator to go and ask them. +// +// Never fails the dashboard. +async function emailDedupeWarning() { + try { + const n = await emailDedupe.countUnacknowledged() + if (!n) return null + return { + code: 'EMAIL_DEDUPE', + message: + `${n} account${n === 1 ? '' : 's'} shared an email address with another account and had it ` + + 'cleared when addresses became unique. They can still sign in, but cannot receive password-reset ' + + 'or notification email until they set a new address. Review who was affected and contact them.', + href: '/admin/users', + } + } catch (err) { + log.warn('dashboard email dedupe warning check failed', { message: err.message }) + return null + } +} + +// GET /admin/users/email-dedupe-report — who was cleared, and what they lost. +async function emailDedupeReport(req, res) { + try { + return res.json(await emailDedupe.list()) + } catch (err) { + log.error('emailDedupeReport', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// POST /admin/users/email-dedupe-report/acknowledge — dismiss the warning. The +// rows stay: the report is a record of what the upgrade did, and losing it would +// leave no way to answer "why does this user have no address?" later. +async function acknowledgeEmailDedupeReport(req, res) { + try { + const n = await emailDedupe.acknowledgeAll() + await activity.log({ req, action: 'admin.email_dedupe.acknowledge', detail: { count: n } }) + log.info('email dedupe report acknowledged', { count: n, by: req.user.username }) + return res.json({ ok: true, acknowledged: n }) + } catch (err) { + log.error('acknowledgeEmailDedupeReport', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + async function dashboard(req, res) { try { return res.json({ @@ -101,7 +156,7 @@ async function dashboard(req, res) { posts: await posts.counts(), users: await users.count(), }, - warnings: [await emailWarning()].filter(Boolean), + warnings: [await emailWarning(), await emailDedupeWarning()].filter(Boolean), recent_activity: await activity.list({ limit: 10 }), }) } catch (err) { @@ -562,6 +617,14 @@ async function updateSettings(req, res) { // endpoint takes arbitrary keys either way, and an unrecognised value resolves // to `disabled` on read — the module's gate fails closed, which is the right // direction for "may this player mint a game account". + // Engagement Phase 1b verification gate: 'on'/'off' only, so a typo cannot land + // a value that reads as neither and silently resolves to off. + if (settings.EMAIL_VERIFICATION_KEY in updates) { + const v = updates[settings.EMAIL_VERIFICATION_KEY] + if (v !== 'on' && v !== 'off') { + return res.status(400).json({ message: 'Invalid email_verification_required value' }) + } + } // App Links toggle is a boolean stored as a 'true'/'false' string; accept a real // boolean or those two strings and normalize, reject anything else. if (settings.MOBILE_APP_LINKS_KEY in updates) { @@ -847,13 +910,27 @@ async function createUser(req, res) { if (await users.getRawByUsername(req.body.username)) { return res.status(409).json({ message: 'Username already taken' }) } - const user = await users.createUser({ - username: req.body.username, - password: req.body.password, - role: req.body.role || 'admin', - email: req.body.email || null, - status: req.body.status || 'active', - }) + let user + try { + user = await users.createUser({ + username: req.body.username, + password: req.body.password, + role: req.body.role || 'admin', + email: req.body.email || null, + status: req.body.status || 'active', + }) + } catch (err) { + // Before Phase 1b this had no catch at all, so a duplicate address became + // an opaque 500 for an admin who could see nothing wrong with the form. + // An admin may be told the real reason: they can already list every account. + if (users.isDuplicateEmail(err)) { + return res.status(409).json({ message: 'Another account already uses that email address.' }) + } + if (users.isDuplicateUsername(err)) { + return res.status(409).json({ message: 'Username already taken' }) + } + throw err + } await activity.log({ req, action: 'user.create', @@ -885,13 +962,25 @@ async function updateUser(req, res) { return res.status(400).json({ message: 'Cannot demote the last admin' }) } } - const user = await users.update(id, { - username: req.body.username, - password: req.body.password, - role: req.body.role, - email: req.body.email, - status: req.body.status, - }) + let user + try { + user = await users.update(id, { + username: req.body.username, + password: req.body.password, + role: req.body.role, + email: req.body.email, + status: req.body.status, + }) + } catch (err) { + // Same as createUser: an uncaught duplicate address was an opaque 500. + if (users.isDuplicateEmail(err)) { + return res.status(409).json({ message: 'Another account already uses that email address.' }) + } + if (users.isDuplicateUsername(err)) { + return res.status(409).json({ message: 'Username already taken' }) + } + throw err + } await activity.log({ req, action: 'user.update', detail: { id } }) // Distinct audit trail for the security-sensitive fields (role & status), // so a promotion/ban is greppable beyond the generic user.update entry. @@ -1055,6 +1144,8 @@ module.exports = { getUser, createUser, updateUser, + emailDedupeReport, + acknowledgeEmailDedupeReport, deleteUser, listUserTrustedDevices, revokeUserTrustedDevice, diff --git a/server/src/router/v1/admin/invites.controller.js b/server/src/router/v1/admin/invites.controller.js index 08feb26..9fb057e 100644 --- a/server/src/router/v1/admin/invites.controller.js +++ b/server/src/router/v1/admin/invites.controller.js @@ -7,6 +7,7 @@ // isn't configured); the DB stores only its hash. const invites = require('../../../model/invites/invites.model') +const users = require('../../../model/users/users.model') const activity = require('../../../model/activity/activity.model') const mailer = require('../../../utils/mailer') @@ -34,6 +35,18 @@ async function create(req, res) { return res.status(400).json({ message: 'A valid email and role are required.' }) } try { + // Catch a collision HERE rather than at accept time (Phase 1b decision 1). + // Uniqueness makes an invite to an already-held address unfulfillable, and + // discovering that after the invitee has clicked the link and chosen a + // password is a bad place to find out. Telling an authenticated admin that + // one of their own users holds an address is not the enumeration surface the + // public register form is — the admin can already list every account. + const existing = await users.getActiveByEmail(email) + if (existing.length) { + log.info('invite refused: address already held', { email, by: req.user.username }) + return res.status(409).json({ message: 'An account already uses that email address.' }) + } + const { invite, token } = await invites.create({ email, role, invitedBy: req.user.id }) const url = acceptUrl(token) diff --git a/server/src/router/v1/admin/invites.router.js b/server/src/router/v1/admin/invites.router.js index 5c49495..927cd19 100644 --- a/server/src/router/v1/admin/invites.router.js +++ b/server/src/router/v1/admin/invites.router.js @@ -22,6 +22,7 @@ invitesRouter.post( /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email","role"], properties: { email: { type: "string" }, role: { type: "string" } } } } } } */ /* #swagger.responses[201] = { description: 'Invite created', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[409] = { description: 'An account already uses that email address. Addresses are unique, so such an invite could never be accepted; it is refused here rather than at accept time, after the invitee has clicked the link.', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ adminOnly, body('email').isEmail().isLength({ max: 255 }), body('role').isIn(['admin', 'editor', 'moderator', 'player']), diff --git a/server/src/router/v1/admin/users.router.js b/server/src/router/v1/admin/users.router.js index 2a915aa..4b9b9cc 100644 --- a/server/src/router/v1/admin/users.router.js +++ b/server/src/router/v1/admin/users.router.js @@ -30,6 +30,31 @@ usersRouter.get( /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ ctrl.listUsers, ) +// The Phase 1b de-duplication report. Declared BEFORE '/:id' — Express matches in +// order, so a literal segment registered after a parameterised one is never +// reached ('email-dedupe-report' would bind as :id). +usersRouter.get( + '/email-dedupe-report', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Accounts whose email was cleared by de-duplication (admin only)' + // #swagger.description = 'When email addresses became unique, accounts sharing an address kept only the earliest-created one; the rest had their address cleared. These users can still sign in but cannot receive password-reset or notification email until they set a new address, so they are the ones to contact.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The affected accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/EmailDedupeEntry" } } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + ctrl.emailDedupeReport, +) +usersRouter.post( + '/email-dedupe-report/acknowledge', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Dismiss the de-duplication warning (admin only)' + // #swagger.description = 'Marks the report acknowledged so it stops appearing as a dashboard warning. The rows are kept as a record of what the upgrade did.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Acknowledged', content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, acknowledged: { type: "integer" } } } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + ctrl.acknowledgeEmailDedupeReport, +) usersRouter.post( '/', // #swagger.tags = ['Admin · Users'] diff --git a/server/src/router/v1/auth/account.controller.js b/server/src/router/v1/auth/account.controller.js index e260571..9329020 100644 --- a/server/src/router/v1/auth/account.controller.js +++ b/server/src/router/v1/auth/account.controller.js @@ -10,6 +10,7 @@ // two surfaces were deleted. const users = require('../../../model/users/users.model') +const emailVerifications = require('../../../model/emailVerifications/emailVerifications.model') const activity = require('../../../model/activity/activity.model') const userIdentities = require('../../../model/userIdentities/userIdentities.model') const mobileSessions = require('../../../model/mobileSessions/mobileSessions.model') @@ -22,6 +23,7 @@ const usernamePolicy = require('../../../auth/usernamePolicy') const loginProtection = require('../../../middleware/loginProtection') const botScore = require('../../../middleware/botScore') const totp = require('../../../utils/totp') +const mailer = require('../../../utils/mailer') const log = require('../../../utils/logger')('account') @@ -37,6 +39,10 @@ async function getAccount(req, res) { username: req.user.username, role: req.user.role, email: req.user.email || null, + email_verified: Boolean(req.user.email_verified), + // The address awaiting its link, so the screen can say "check your inbox" + // rather than looking as though the change silently failed. + email_pending: (raw && raw.email_pending) || null, status: req.user.status || 'active', totp_enabled: Boolean(req.user.totp_enabled), has_password: Boolean(raw && raw.password_hash), @@ -133,6 +139,127 @@ async function changePassword(req, res) { } } +// ── Email address (engagement Phase 1b) ──────────────────────────────────── + +function baseUrl() { + return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '') +} + +function verifyUrl(token) { + return `${baseUrl()}/account/verify-email/${token}` +} + +// Mint a verification and mail it. Shared by the change and resend paths so the +// quota, the supersede and the log line cannot drift between them. Returns a +// { ok } or { ok: false, status, message } the caller can hand straight back. +async function issueVerification(req, email) { + if (await emailVerifications.sendQuotaExhausted(req.user.id)) { + log.warn('email verification quota exhausted', { id: req.user.id, ip: req.ip }) + return { ok: false, status: 429, message: 'Too many verification emails. Try again later.' } + } + // A fresh request supersedes every older link — otherwise an address the user + // typed by mistake stays installable for a day. + await emailVerifications.invalidatePendingForUser(req.user.id) + const { token } = await emailVerifications.create({ userId: req.user.id, email, requestedIp: req.ip }) + try { + const result = await mailer.sendEmailVerification({ to: email, verifyUrl: verifyUrl(token), username: req.user.username }) + if (!result.sent) { + // Unlike a password reset there is no enumeration reason to pretend: the + // caller typed this address themselves and is entitled to know why nothing + // arrived. The pending address stays staged so a later resend works. + log.warn('verification email not sent (mail not configured)', { id: req.user.id }) + return { ok: true, emailed: false, reason: 'NOT_CONFIGURED' } + } + } catch (err) { + log.error('verification send failed', err) + return { ok: true, emailed: false, reason: 'SEND_FAILED' } + } + return { ok: true, emailed: true } +} + +// PATCH /account/email - ask to set or change the caller's own address. +// +// The address is STAGED, not installed: `email` keeps receiving mail until the +// link is used, so a typo cannot silently redirect this account's password-reset +// mail to a mailbox its owner does not control. +// +// The current password is required when the account has one. An address is where +// account recovery lands, so repointing it is a credential-grade act; an +// SSO-provisioned account with no password hash is exempt, exactly as +// changePassword already carves out. +async function changeEmail(req, res) { + const email = String(req.body.email || '').trim() + try { + const raw = await users.getRawById(req.user.id) + if (!raw) return res.status(401).json({ message: 'Unauthorized' }) + + if (raw.password_hash) { + const ok = await users.validatePassword(raw, req.body.currentPassword || '') + if (!ok) { + loginProtection.recordFailure(req.ip) + botScore.recordLoginFailure(req.ip) + log.warn('changeEmail wrong current password', { id: req.user.id, ip: req.ip }) + return res.status(400).json({ message: 'Your current password is incorrect.' }) + } + } + + if (raw.email && raw.email.toLowerCase() === email.toLowerCase()) { + return res.status(400).json({ message: 'That is already your email address.' }) + } + + // Stage it. This is also where a collision with a live address FIRST shows up + // cheaply, but it is not the guard that matters - email_pending is deliberately + // not unique, so the real arbitration happens at verification time against the + // UNIQUE index. Answering identically in both places is what keeps this from + // becoming an address-existence oracle. + await users.setPendingEmail(req.user.id, email) + + const issued = await issueVerification(req, email) + if (!issued.ok) return res.status(issued.status).json({ message: issued.message }) + + await activity.log({ req, action: 'account.email.change_requested' }) + log.info('account email change requested', { id: req.user.id }) + return res.json({ email_pending: email, emailed: Boolean(issued.emailed), reason: issued.reason || null }) + } catch (err) { + log.error('changeEmail', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// POST /account/email/resend - re-send the link for the address already staged. +async function resendEmailVerification(req, res) { + try { + const raw = await users.getRawById(req.user.id) + if (!raw) return res.status(401).json({ message: 'Unauthorized' }) + if (!raw.email_pending) { + return res.status(400).json({ message: 'There is no email address awaiting confirmation.' }) + } + const issued = await issueVerification(req, raw.email_pending) + if (!issued.ok) return res.status(issued.status).json({ message: issued.message }) + log.info('account email verification resent', { id: req.user.id }) + return res.json({ email_pending: raw.email_pending, emailed: Boolean(issued.emailed), reason: issued.reason || null }) + } catch (err) { + log.error('resendEmailVerification', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// DELETE /account/email/pending - abandon a staged address (a typo, or a change +// of mind). Retires the outstanding links too, so the abandoned address cannot be +// installed afterwards by a link already sitting in a mailbox. +async function cancelEmailChange(req, res) { + try { + await users.clearPendingEmail(req.user.id) + await emailVerifications.invalidatePendingForUser(req.user.id) + await activity.log({ req, action: 'account.email.change_cancelled' }) + log.info('account email change cancelled', { id: req.user.id }) + return res.json({ ok: true }) + } catch (err) { + log.error('cancelEmailChange', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + // Step 1: generate a fresh secret (stored but not yet enabled) and return the // otpauth URL + a QR data URL for the user to scan. Overwrites any pending, // not-yet-confirmed secret. Refuses if TOTP is already enabled. @@ -396,6 +523,9 @@ async function generateRecoveryCodes(req, res) { } module.exports = { + changeEmail, + resendEmailVerification, + cancelEmailChange, getAccount, changeUsername, changePassword, diff --git a/server/src/router/v1/auth/auth.controller.js b/server/src/router/v1/auth/auth.controller.js index 3adcd88..7760abb 100644 --- a/server/src/router/v1/auth/auth.controller.js +++ b/server/src/router/v1/auth/auth.controller.js @@ -132,6 +132,20 @@ async function register(req, res) { role: 'player', }) } catch (err) { + // Two unique indexes, two different answers (§0.6 finding 2). Before Phase + // 1b this branch caught both and told an email collision it was a username + // one — the single field the user had NOT collided on. + // + // The email answer is deliberately generic and deliberately NOT scored: a + // truthful "that address already has an account" makes account existence + // queryable through a public form, and treating an honest typo on a + // colleague's address as an attack would push a legitimate user toward an + // IP ban. The real reason is logged and never returned — note the driver's + // message embeds the address, which is a second reason it stays server-side. + if (users.isDuplicateEmail(err)) { + log.warn('register rejected: email already registered', { username: check.name, ip: req.ip }) + return res.status(400).json({ message: 'Registration failed. Please check your details and try again.' }) + } // The UNIQUE index is the source of truth for the uniqueness race — a // concurrent duplicate loses here and gets a clean 409. if (users.isDuplicateUsername(err)) { diff --git a/server/src/router/v1/auth/emailVerify.controller.js b/server/src/router/v1/auth/emailVerify.controller.js new file mode 100644 index 0000000..5beff09 --- /dev/null +++ b/server/src/router/v1/auth/emailVerify.controller.js @@ -0,0 +1,100 @@ +// ── Email-address verification (public, token-gated) ─────────────────────── +// +// The confirm half of the Phase 1b change-and-verify flow. The request half is +// authenticated and lives on /auth/me/account/email; this half is deliberately +// NOT, because the link is opened from a mailbox, routinely on a device that is +// not logged in — requiring a session here would strand exactly the users the +// flow exists to serve. +// +// That is safe because the token IS the proof: it is opaque, single-use, +// short-lived, stored only as a sha256, and it carries the user and the address +// it was minted for. Using it installs an address on that account and does +// nothing else — it grants no session, no access, and no way to read anything. +// Compare passwordReset.controller, which is the same posture for a strictly +// more powerful capability. +// +// GET /auth/email/verify/:token -> validate the link so the page can render +// POST /auth/email/verify/:token -> install the address + +const emailVerifications = require('../../../model/emailVerifications/emailVerifications.model') +const users = require('../../../model/users/users.model') +const activity = require('../../../model/activity/activity.model') + +const log = require('../../../utils/logger')('auth-email-verify') + +const INVALID = 'This confirmation link is invalid or has expired.' + +// GET /auth/email/verify/:token — validate a link so the page can render. 404 for +// anything not currently usable, never distinguishing expired from used from +// never-was. +async function lookup(req, res) { + try { + const row = await emailVerifications.findValidByToken(req.params.token) + if (!row) return res.status(404).json({ message: INVALID }) + const user = await users.getById(row.user_id) + if (!user) return res.status(404).json({ message: INVALID }) + // The address is echoed because the person holding this link is the person it + // was mailed to — they already know it. The username tells them which account + // they are about to attach it to. + return res.json({ username: user.username, email: row.email }) + } catch (err) { + log.error('lookup', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// POST /auth/email/verify/:token — install the address. +async function confirm(req, res) { + try { + const row = await emailVerifications.findValidByToken(req.params.token) + if (!row) return res.status(404).json({ message: INVALID }) + + // Consume first: if we lost a double-submit race, stop before touching the + // account so a spent link cannot be replayed. + const won = await emailVerifications.consume(row.id) + if (!won) return res.status(404).json({ message: INVALID }) + + let installed + try { + installed = await users.promotePendingEmail(row.user_id, row.email) + } catch (err) { + // The UNIQUE index is the arbiter, and it fires here rather than at request + // time because a pending address reserves nothing: between staging and + // confirming, someone else may have verified the same address first. + // + // ANTI-ENUMERATION: the answer is the generic INVALID, identical to an + // expired or already-used link. Saying "that address is taken" would turn + // this endpoint into an oracle for which addresses hold accounts — the same + // posture passwordReset.controller keeps. The real reason is logged, never + // returned; note the driver's message embeds the address, which is another + // reason it must not travel to a client. + if (users.isDuplicateEmail(err)) { + log.warn('email verification lost to an existing address', { userId: row.user_id }) + await users.clearPendingEmail(row.user_id).catch(() => {}) + return res.status(404).json({ message: INVALID }) + } + throw err + } + + // The guard rejected it: the user has since asked for a different address, so + // this token is stale even though it had not expired. Same generic answer. + if (!installed) { + log.info('email verification superseded by a later request', { userId: row.user_id }) + return res.status(404).json({ message: INVALID }) + } + + // Retire any other outstanding links for this user — one address is now proved + // and the others must not be installable behind the user's back. + await emailVerifications.invalidatePendingForUser(row.user_id) + + await activity.log({ req, userId: row.user_id, action: 'account.email.verified' }) + log.info('account email verified', { userId: row.user_id, ip: req.ip }) + // No session is issued: this proves control of a mailbox, not of an account. + return res.json({ ok: true, message: 'Your email address has been confirmed.' }) + } catch (err) { + log.error('confirm', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +module.exports = { lookup, confirm } diff --git a/server/src/router/v1/auth/emailVerify.router.js b/server/src/router/v1/auth/emailVerify.router.js new file mode 100644 index 0000000..49c6dc9 --- /dev/null +++ b/server/src/router/v1/auth/emailVerify.router.js @@ -0,0 +1,50 @@ +// Auth · Email — the confirm half of the self-service email change. Public but +// token-gated: validate a link, then install the address it proves. +// +// Mounted at /api/v1/auth/email by auth/index.js, so the routes below emit +// GET|POST /auth/email/verify/:token. +// +// Requesting a change is a different, AUTHENTICATED route — +// PATCH /auth/me/account/email. This half is unauthenticated on purpose: the link +// is opened from a mailbox, often on a device with no session. +// +// One anti-enumeration property is load-bearing and must survive any edit here: +// every unusable link answers with the same 404, and so does a link that lost the +// address to another account. Distinguishing "already taken" from "expired" would +// make this endpoint an oracle for which addresses hold accounts. + +const express = require('express') +const { param } = require('express-validator') + +const { lookup, confirm } = require('./emailVerify.controller') +const { emailVerifyConfirmLimiter } = require('../../../middleware/rateLimit') +const validate = require('../../../middleware/validate') + +const emailRouter = express.Router() + +emailRouter.get( + '/verify/:token', + // #swagger.tags = ['Auth'] + // #swagger.summary = 'Validate an email-confirmation link' + // #swagger.description = 'Returns the target username and the address the link proves, so the confirmation page can render. 404 for anything not currently usable (never distinguishes expired from used from never-existed).' + /* #swagger.responses[200] = { description: 'Confirmation link is valid', content: { "application/json": { schema: { type: "object", properties: { username: { type: "string" }, email: { type: "string", format: "email" } } } } } } */ + /* #swagger.responses[404] = { description: 'Invalid or expired confirmation link', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('token').isString().isLength({ min: 8, max: 128 }), + validate, + lookup, +) +emailRouter.post( + '/verify/:token', + // #swagger.tags = ['Auth'] + // #swagger.summary = 'Confirm an email address from its link' + // #swagger.description = 'Consumes the single-use link and installs the address on the account, marking it verified. Issues no session — it proves control of a mailbox, not of an account. Answers 404 for an unusable link AND for an address another account has since verified, deliberately: the two are indistinguishable to a caller so the endpoint cannot be used to test which addresses hold accounts.' + /* #swagger.responses[200] = { description: 'Address confirmed', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */ + /* #swagger.responses[404] = { description: 'Invalid, expired, superseded, or already-used confirmation link', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[429] = { description: 'Too many attempts', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + emailVerifyConfirmLimiter, + param('token').isString().isLength({ min: 8, max: 128 }), + validate, + confirm, +) + +module.exports = emailRouter diff --git a/server/src/router/v1/auth/index.js b/server/src/router/v1/auth/index.js index 58b78fc..325f991 100644 --- a/server/src/router/v1/auth/index.js +++ b/server/src/router/v1/auth/index.js @@ -26,6 +26,7 @@ const loginRouter = require('./login.router') const registerRouter = require('./register.router') const inviteRouter = require('./invite.router') const passwordRouter = require('./password.router') +const emailRouter = require('./emailVerify.router') const sessionRouter = require('./session.router') const authRouter = express.Router() @@ -54,6 +55,7 @@ authRouter.use('/login', loginRouter) authRouter.use('/register', registerRouter) authRouter.use('/invite', inviteRouter) authRouter.use('/password', passwordRouter) +authRouter.use('/email', emailRouter) // The two singletons that own no path segment of their own: POST /logout and // GET /me. Mounted at the group root and **last**, because `use('/me', …)` above diff --git a/server/src/router/v1/auth/invite.controller.js b/server/src/router/v1/auth/invite.controller.js index 33c0f6e..4bc9ca8 100644 --- a/server/src/router/v1/auth/invite.controller.js +++ b/server/src/router/v1/auth/invite.controller.js @@ -55,6 +55,20 @@ async function acceptInvite(req, res) { emailVerified: true, // they proved control of the address by using the link }) } catch (err) { + // The address on the invite is already held. Admin invites are checked for + // this at CREATION (POST /admin/invites 409s), so reaching here means the + // address was claimed in the window between the invite going out and the + // invitee clicking — a race, not the ordinary case. It still has to be + // survivable: the invitee has already clicked a link and typed a password, + // and an opaque 500 at that point is the worst possible moment to fail. + if (users.isDuplicateEmail(err)) { + log.warn('invite accept rejected: address already held', { inviteId: row.id }) + return res.status(409).json({ + message: + 'This invitation cannot be completed because its email address is already in use. ' + + 'Ask an administrator for a new invitation.', + }) + } if (users.isDuplicateUsername(err)) { return res.status(409).json({ message: 'That username is already taken.' }) } diff --git a/server/src/router/v1/auth/me.routes.js b/server/src/router/v1/auth/me.routes.js index f4777e2..d3e8af8 100644 --- a/server/src/router/v1/auth/me.routes.js +++ b/server/src/router/v1/auth/me.routes.js @@ -79,6 +79,50 @@ meRouter.patch( account.changePassword, ) +// Email address (engagement Phase 1b). The change is STAGED and only a tokened +// link installs it, so these three routes never alter the address that is +// currently receiving mail. The confirm half is public and lives at +// /auth/email/verify/:token, because the link is opened from a mailbox. +meRouter.patch( + '/account/email', + // #swagger.tags = ['Auth · Me'] + // #swagger.summary = 'Request a new email address (self, any role)' + // #swagger.description = 'Stages the address and emails a confirmation link. The account keeps its current address until that link is used, so a mistyped address cannot redirect password-reset mail. Requires currentPassword when the account has a password; SSO-provisioned accounts with no password are exempt.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ChangeEmailRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Address staged; a confirmation link was sent', content: { "application/json": { schema: { $ref: "#/components/schemas/PendingEmail" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error, wrong current password, or already your address', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[429] = { description: 'Too many verification emails', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + accountChangeLimiter, + body('email').isString().trim().isEmail().isLength({ max: 255 }), + body('currentPassword').optional({ values: 'falsy' }).isString(), + validate, + account.changeEmail, +) +meRouter.post( + '/account/email/resend', + // #swagger.tags = ['Auth · Me'] + // #swagger.summary = 'Re-send the confirmation link for the pending address' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Confirmation link re-sent', content: { "application/json": { schema: { $ref: "#/components/schemas/PendingEmail" } } } } */ + /* #swagger.responses[400] = { description: 'No address is awaiting confirmation', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[429] = { description: 'Too many verification emails', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + accountChangeLimiter, + account.resendEmailVerification, +) +meRouter.delete( + '/account/email/pending', + // #swagger.tags = ['Auth · Me'] + // #swagger.summary = 'Abandon the pending email address' + // #swagger.description = 'Clears the staged address and retires its outstanding links, so a confirmation email already delivered can no longer install it.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Pending address cleared', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + account.cancelEmailChange, +) + // TOTP self-enrollment (disable requires a valid current code; it does not take // a password). meRouter.post( diff --git a/server/src/router/v1/auth/password.router.js b/server/src/router/v1/auth/password.router.js index 5759a8e..2da8cd8 100644 --- a/server/src/router/v1/auth/password.router.js +++ b/server/src/router/v1/auth/password.router.js @@ -29,7 +29,7 @@ passwordRouter.post( '/forgot', // #swagger.tags = ['Auth'] // #swagger.summary = 'Request a password-reset link by email' - // #swagger.description = 'Emails a single-use, ~1h reset link to every active account on the address. Always returns the same generic 200 whether or not the email matches (no account enumeration). Email is non-unique, so multiple accounts may each receive a link naming their username. Rate limited per IP.' + // #swagger.description = 'Emails a single-use, ~1h reset link to the active account on the address. Always returns the same generic 200 whether or not the email matches (no account enumeration). Rate limited per IP.' /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email"], properties: { email: { type: "string", format: "email" } } } } } } */ /* #swagger.responses[200] = { description: 'Generic acknowledgement (sent if the account exists)', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */ /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ diff --git a/server/src/router/v1/auth/passwordReset.controller.js b/server/src/router/v1/auth/passwordReset.controller.js index c783848..a4d400e 100644 --- a/server/src/router/v1/auth/passwordReset.controller.js +++ b/server/src/router/v1/auth/passwordReset.controller.js @@ -5,13 +5,16 @@ // 2. GET /auth/password/reset/:token → validate the link (for the form) // 3. POST /auth/password/reset/:token { password } → set the new password // -// Email is intentionally non-unique (SSO emails may repeat), so a request can -// match several accounts; each gets its own link, and the email names the -// username so the recipient knows which account it's for. The request step NEVER -// reveals whether an address exists — it always returns the same generic success -// (no user enumeration). Only the sha256 hash of each opaque token is stored, so a -// DB read never yields a usable link (same pattern as user_invites). Tokens are -// single-use + expire in ~1h. Setting a new password rotates the hash and revokes +// Addresses are unique since engagement Phase 1b, so a request matches at most one +// account; the loop below is kept because it costs nothing and the email names the +// username anyway. The request step NEVER reveals whether an address exists — it +// always returns the same generic success (no user enumeration). Only the sha256 +// hash of each opaque token is stored, so a DB read never yields a usable link +// (same pattern as user_invites). Tokens are single-use + expire in ~1h. +// +// Reset mail is deliberately NOT gated on email_verified. The verification gate +// (Phase 1b) governs opt-in ENGAGEMENT mail; applying it to account recovery would +// lock out every user carrying an address from before verification existed. Setting a new password rotates the hash and revokes // every session (web cookie cutoff + mobile refresh tokens). We do NOT auto-log-in // afterwards: the user signs in fresh, so a 2FA account still passes TOTP. diff --git a/server/src/router/v1/auth/sso.controller.js b/server/src/router/v1/auth/sso.controller.js index cb51f18..794d5ac 100644 --- a/server/src/router/v1/auth/sso.controller.js +++ b/server/src/router/v1/auth/sso.controller.js @@ -192,8 +192,12 @@ async function callback(req, res) { // Auto-provision a `player` from an SSO profile when no identity is linked yet // and registration allows SSO sign-up. Derives a unique username (reserved-name // safe) with a bounded retry against the UNIQUE index, captures the provider -// email, links the identity, and audit-logs the provision. Returns the new user, -// or null if a unique username couldn't be found. +// email, links the identity, and audit-logs the provision. +// +// Returns { user } on success, or { error } naming why it failed. It used to +// return the user or a bare null, which was enough while username was the only +// unique index; since Phase 1b there are two ways to fail and they need different +// things said to the person in front of the browser. async function provisionSsoPlayer(req, providerId, profile) { const base = usernamePolicy.deriveUsernameBase(profile) for (let attempt = 0; attempt < PROVISION_MAX_TRIES; attempt++) { @@ -203,9 +207,17 @@ async function provisionSsoPlayer(req, providerId, profile) { username: candidate, role: 'player', email: profile.email || null, - // The built-in providers only return an email the IdP has verified, so - // treat a supplied address as verified (skips the eventual re-verify). - emailVerified: Boolean(profile.email), + // Honour what the IdP actually ASSERTED, not the mere presence of an + // address. The old `Boolean(profile.email)` marked every SSO address + // verified, which made email_verified too weak a signal to mean anything + // (§0.6 finding 3). An IdP that omits the claim leaves the address + // unverified and the user proves it through the ordinary flow. + // + // Forward-only, by decision: existing rows keep the verified flag they + // were given. Retroactively demoting live users is the G22 mistake — a + // safe default applied backwards to a running system without telling + // anyone. + emailVerified: profile.emailVerified === true, }) await userIdentities.link({ userId: user.id, @@ -215,8 +227,24 @@ async function provisionSsoPlayer(req, providerId, profile) { }) await activity.log({ req, userId: user.id, action: 'auth.sso.provision', detail: { provider: providerId } }) log.info('sso player provisioned', { provider: providerId, id: user.id, username: user.username }) - return user + return { user } } catch (err) { + // An EMAIL collision can never be cleared by trying another username, so + // retrying is not merely useless — it burns every candidate and returns + // null, and the log then blames usernames for a conflict that was never + // about them (§0.6 finding 2). Stop, and say which it was. + // + // This is not the enumeration surface the register form is: the caller has + // already authenticated with the IdP, and the address is one the IdP + // asserted for them. Naming the real reason here is what makes the failure + // diagnosable instead of opaque. + if (users.isDuplicateEmail(err)) { + log.warn('sso provision: address already held by another account', { + provider: providerId, + subject: profile.subject, + }) + return { error: 'email_in_use' } + } // Username collided with a concurrent/existing account — try the next // suffix. Any other error is real; propagate it. if (users.isDuplicateUsername(err)) continue @@ -224,7 +252,7 @@ async function provisionSsoPlayer(req, providerId, profile) { } } log.error('sso provision: exhausted username candidates', { provider: providerId, base }) - return null + return { error: 'error' } } // Trusted-device skip for the SSO paths — the exact analogue of the check in @@ -267,8 +295,9 @@ async function finishLogin(req, res, providerId, kind, tx, profile) { log.warn('sso login refused: no linked account', { provider: providerId }) return res.redirect(loginError('not_linked', portal)) } - user = await provisionSsoPlayer(req, providerId, profile) - if (!user) return res.redirect(loginError('error', portal)) + const provisioned = await provisionSsoPlayer(req, providerId, profile) + if (provisioned.error) return res.redirect(loginError(provisioned.error, portal)) + user = provisioned.user } // Status gate (parity with local login): a disabled/banned account can't @@ -384,12 +413,12 @@ async function resolveMobileSsoUser(req, res, sess, providerId, profile) { res.redirect(appError(sess, 'not_linked')) return null } - const user = await provisionSsoPlayer(req, providerId, profile) - if (!user) { - res.redirect(appError(sess, 'error')) + const provisioned = await provisionSsoPlayer(req, providerId, profile) + if (provisioned.error) { + res.redirect(appError(sess, provisioned.error)) return null } - return user + return provisioned.user } async function finishMobileLogin(req, res, providerId, kind, tx, profile) { @@ -535,6 +564,11 @@ async function finishLink(req, res, providerId, tx, profile) { } module.exports = { + // Exported for tests only. The behaviour that matters is a COUNT — on an email + // conflict it must stop rather than work through every username candidate — and + // that is not observable through the route handlers without stubbing most of the + // OAuth flow to watch a loop it never reaches. + provisionSsoPlayer, listProviders, start, linkStart, diff --git a/server/src/utils/mailer.js b/server/src/utils/mailer.js index dc37ba4..6f28973 100644 --- a/server/src/utils/mailer.js +++ b/server/src/utils/mailer.js @@ -220,8 +220,7 @@ async function sendInvite({ to, acceptUrl, role, invitedByName }) { /** * Send a password-reset link. `to` is the account's email, `resetUrl` the tokened - * reset link, `username` names which account it's for (email is non-unique, so one - * address may receive a link per account). If email is not configured, returns + * reset link, `username` names which account it's for. If email is not configured, returns * { sent: false, reason: 'NOT_CONFIGURED' } — the caller still returns a generic * success to avoid leaking whether the address exists. Throws only on a send failure. */ @@ -251,6 +250,45 @@ async function sendPasswordReset({ to, resetUrl, username }) { } } +/** + * Send an email-verification link (engagement Phase 1b). `to` is the address + * being PROVED — which is by definition not yet the account's address, and may + * belong to someone who has never heard of this site. So the copy names the + * account and says plainly what to do if it was not you, and the link installs + * an address rather than granting any access. + * + * Returns { sent: false, reason: 'NOT_CONFIGURED' } when mail is unconfigured; + * the caller surfaces that honestly, because unlike a password reset there is no + * enumeration reason to pretend a mail went out to an address the CALLER typed. + */ +async function sendEmailVerification({ to, verifyUrl, username }) { + const built = await buildTransport() + if (!built) return { sent: false, reason: 'NOT_CONFIGURED' } + const { transport, config } = built + const forWhom = username ? ` “${username}”` : '' + try { + await transport.sendMail({ + from: fromHeader(config), + to, + replyTo: replyToFor(config), + subject: `Confirm your email address for ${brand.name}`, + text: + `The ${brand.name} account${forWhom} asked to use this address for contact and account recovery.\n\n` + + `Confirm it here:\n${verifyUrl}\n\n` + + `This link is single-use and expires in about a day. Until it is used, nothing changes — ` + + `the account keeps whatever address it had.\n\n` + + `If you did not ask for this, you can ignore this email. Someone may have mistyped their ` + + `own address; no account of yours is affected and this link grants no access to anything.`, + }) + await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Verification send OK', lastVerifiedAt: new Date() }) + return { sent: true } + } catch (err) { + log.error('email verification send failed', err) + await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err, config) }) + throw err + } +} + /** * Send a Team notification — one event (`immediate` mode) or a day's worth * (`digest` mode). TEAMS.md §6.4. @@ -328,5 +366,6 @@ module.exports = { sendTest, sendInvite, sendPasswordReset, + sendEmailVerification, sendTeamNotification, } diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index ee31a2c..4198015 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -1166,6 +1166,16 @@ } } }, + "409": { + "description": "An account already uses that email address. Addresses are unique, so such an invite could never be accepted; it is refused here rather than at accept time, after the invitee has clicked the link.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Internal Server Error" } @@ -5453,6 +5463,121 @@ } } }, + "/api/v1/admin/users/email-dedupe-report": { + "get": { + "tags": [ + "Admin · Users" + ], + "summary": "Accounts whose email was cleared by de-duplication (admin only)", + "description": "When email addresses became unique, accounts sharing an address kept only the earliest-created one; the rest had their address cleared. These users can still sign in but cannot receive password-reset or notification email until they set a new address, so they are the ones to contact.", + "responses": { + "200": { + "description": "The affected accounts", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EmailDedupeEntry" + } + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/users/email-dedupe-report/acknowledge": { + "post": { + "tags": [ + "Admin · Users" + ], + "summary": "Dismiss the de-duplication warning (admin only)", + "description": "Marks the report acknowledged so it stops appearing as a dashboard warning. The rows are kept as a record of what the upgrade did.", + "responses": { + "200": { + "description": "Acknowledged", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "acknowledged": { + "type": "integer" + } + } + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/admin/users/{id}": { "put": { "tags": [ @@ -6993,6 +7118,117 @@ ] } }, + "/api/v1/auth/email/verify/{token}": { + "get": { + "tags": [ + "Auth" + ], + "summary": "Validate an email-confirmation link", + "description": "Returns the target username and the address the link proves, so the confirmation page can render. 404 for anything not currently usable (never distinguishes expired from used from never-existed).", + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Confirmation link is valid", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Invalid or expired confirmation link", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + }, + "post": { + "tags": [ + "Auth" + ], + "summary": "Confirm an email address from its link", + "description": "Consumes the single-use link and installs the address on the account, marking it verified. Issues no session — it proves control of a mailbox, not of an account. Answers 404 for an unusable link AND for an address another account has since verified, deliberately: the two are indistinguishable to a caller so the endpoint cannot be used to test which addresses hold accounts.", + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Address confirmed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Invalid, expired, superseded, or already-used confirmation link", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many attempts", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, "/api/v1/auth/invite/{token}": { "get": { "tags": [ @@ -7385,6 +7621,191 @@ ] } }, + "/api/v1/auth/me/account/email": { + "patch": { + "tags": [ + "Auth · Me" + ], + "summary": "Request a new email address (self, any role)", + "description": "Stages the address and emails a confirmation link. The account keeps its current address until that link is used, so a mistyped address cannot redirect password-reset mail. Requires currentPassword when the account has a password; SSO-provisioned accounts with no password are exempt.", + "responses": { + "200": { + "description": "Address staged; a confirmation link was sent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PendingEmail" + } + } + } + }, + "400": { + "description": "Validation error, wrong current password, or already your address", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "429": { + "description": "Too many verification emails", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangeEmailRequest" + } + } + } + } + } + }, + "/api/v1/auth/me/account/email/pending": { + "delete": { + "tags": [ + "Auth · Me" + ], + "summary": "Abandon the pending email address", + "description": "Clears the staged address and retires its outstanding links, so a confirmation email already delivered can no longer install it.", + "responses": { + "200": { + "description": "Pending address cleared", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkFlag" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/auth/me/account/email/resend": { + "post": { + "tags": [ + "Auth · Me" + ], + "summary": "Re-send the confirmation link for the pending address", + "description": "", + "responses": { + "200": { + "description": "Confirmation link re-sent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PendingEmail" + } + } + } + }, + "400": { + "description": "No address is awaiting confirmation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "429": { + "description": "Too many verification emails", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/auth/me/account/identities": { "get": { "tags": [ @@ -9056,7 +9477,7 @@ "Auth" ], "summary": "Request a password-reset link by email", - "description": "Emails a single-use, ~1h reset link to every active account on the address. Always returns the same generic 200 whether or not the email matches (no account enumeration). Email is non-unique, so multiple accounts may each receive a link naming their username. Rate limited per IP.", + "description": "Emails a single-use, ~1h reset link to the active account on the address. Always returns the same generic 200 whether or not the email matches (no account enumeration). Rate limited per IP.", "responses": { "200": { "description": "Generic acknowledgement (sent if the account exists)", @@ -15229,6 +15650,45 @@ } } }, + "email_verified": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "Whether the address above has been proved by opening a confirmation link." + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "email_pending": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "email" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "An address the user has requested but not yet confirmed. It does NOT replace `email` until the confirmation link is used." + }, + "example": {} + } + }, "status": { "type": "object", "properties": { @@ -15288,6 +15748,250 @@ } } }, + "ChangeEmailRequest": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "required": { + "type": "array", + "example": [ + "email" + ], + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "properties": { + "email": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "email" + }, + "maxLength": { + "type": "number", + "example": 255 + }, + "example": { + "type": "string", + "example": "new@example.com" + } + } + }, + "currentPassword": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "password" + }, + "description": { + "type": "string", + "example": "Required when the account has a password. An address is where account recovery lands, so changing it is re-authenticated; an SSO-provisioned account with no password is exempt." + } + } + } + } + } + } + }, + "PendingEmail": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "The address now awaiting confirmation. The account keeps its existing address until the emailed link is used." + }, + "properties": { + "type": "object", + "properties": { + "email_pending": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "email" + }, + "example": { + "type": "string", + "example": "new@example.com" + } + } + }, + "emailed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "False when outbound email is not configured or the send failed; the address stays staged so a resend can succeed later." + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "enum": { + "type": "array", + "example": [ + "NOT_CONFIGURED", + "SEND_FAILED", + null + ], + "items": {} + }, + "description": { + "type": "string", + "example": "Why nothing was sent, when `emailed` is false." + }, + "example": {} + } + } + } + } + } + }, + "EmailDedupeEntry": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One account whose email address was cleared when addresses became unique, because an older account already held it." + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 3 + } + } + }, + "user_id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 42 + } + } + }, + "username": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "someplayer" + } + } + }, + "lost_address": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "email" + }, + "example": { + "type": "string", + "example": "shared@example.com" + } + } + }, + "cleared_at": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "acknowledged_at": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, "OkFlag": { "type": "object", "properties": { diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index e0c6f18..e806dea 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -499,6 +499,19 @@ const doc = { username: { type: 'string', example: 'newplayer' }, role: { type: 'string', enum: ['admin', 'editor', 'moderator', 'player'], example: 'player' }, email: { type: 'string', format: 'email', nullable: true, example: 'player@example.com' }, + email_verified: { + type: 'boolean', + description: 'Whether the address above has been proved by opening a confirmation link.', + example: true, + }, + email_pending: { + type: 'string', + format: 'email', + nullable: true, + description: + 'An address the user has requested but not yet confirmed. It does NOT replace `email` until the confirmation link is used.', + example: null, + }, status: { type: 'string', enum: ['active', 'disabled', 'banned', 'pending'], example: 'active' }, totp_enabled: { type: 'boolean', example: false }, has_password: { @@ -508,6 +521,53 @@ const doc = { }, }, }, + // Engagement Phase 1b — the change-and-verify flow. + ChangeEmailRequest: { + type: 'object', + required: ['email'], + properties: { + email: { type: 'string', format: 'email', maxLength: 255, example: 'new@example.com' }, + currentPassword: { + type: 'string', + format: 'password', + description: + 'Required when the account has a password. An address is where account recovery lands, so changing it is re-authenticated; an SSO-provisioned account with no password is exempt.', + }, + }, + }, + PendingEmail: { + type: 'object', + description: + 'The address now awaiting confirmation. The account keeps its existing address until the emailed link is used.', + properties: { + email_pending: { type: 'string', format: 'email', example: 'new@example.com' }, + emailed: { + type: 'boolean', + description: 'False when outbound email is not configured or the send failed; the address stays staged so a resend can succeed later.', + example: true, + }, + reason: { + type: 'string', + nullable: true, + enum: ['NOT_CONFIGURED', 'SEND_FAILED', null], + description: 'Why nothing was sent, when `emailed` is false.', + example: null, + }, + }, + }, + EmailDedupeEntry: { + type: 'object', + description: + 'One account whose email address was cleared when addresses became unique, because an older account already held it.', + properties: { + id: { type: 'integer', example: 3 }, + user_id: { type: 'integer', example: 42 }, + username: { type: 'string', example: 'someplayer' }, + lost_address: { type: 'string', format: 'email', example: 'shared@example.com' }, + cleared_at: { type: 'string', format: 'date-time' }, + acknowledged_at: { type: 'string', format: 'date-time', nullable: true }, + }, + }, OkFlag: { type: 'object', properties: { ok: { type: 'boolean', example: true } }, diff --git a/server/test/authMe.test.js b/server/test/authMe.test.js index 6783799..03f9044 100644 --- a/server/test/authMe.test.js +++ b/server/test/authMe.test.js @@ -24,6 +24,12 @@ test('/auth/me/account* rejects unauthenticated callers with 401', async () => { ['GET', '/api/v1/auth/me/account/identities'], ['PATCH', '/api/v1/auth/me/account/username', { username: 'someone' }], ['PATCH', '/api/v1/auth/me/account/password', { newPassword: 'abcd1234' }], + // Engagement Phase 1b — the email change/verify request half is self-service + // and must be gated exactly like the rest. (The CONFIRM half is public by + // design and lives at /auth/email/verify/:token, tested separately.) + ['PATCH', '/api/v1/auth/me/account/email', { email: 'new@example.com' }], + ['POST', '/api/v1/auth/me/account/email/resend'], + ['DELETE', '/api/v1/auth/me/account/email/pending'], ['POST', '/api/v1/auth/me/account/totp/setup'], ['POST', '/api/v1/auth/me/account/totp/enable', { code: '123456' }], ['DELETE', '/api/v1/auth/me/account/identities/google'], diff --git a/server/test/emailCollisionSurfaces.test.js b/server/test/emailCollisionSurfaces.test.js new file mode 100644 index 0000000..7045b98 --- /dev/null +++ b/server/test/emailCollisionSurfaces.test.js @@ -0,0 +1,194 @@ +// Engagement Phase 1b — how each of the five write paths answers a duplicate +// EMAIL, now that `users` has two unique indexes. +// +// Before this phase every one of them either misreported the collision as a +// username clash or fell through to an opaque 500. The answers are deliberately +// NOT uniform, and the differences are the point: +// +// register generic 400, unscored — a public form; the truth would make +// account existence queryable, and +// scoring an honest typo would push a +// real user toward an IP ban +// SSO provision stops, names the reason — the caller already authenticated +// with the IdP; retrying usernames can +// never clear an email conflict +// invite accept 409, explains — the invitee has already clicked a +// link and typed a password +// admin create 409, names the field — an admin can already list every +// admin update 409, names the field account, so there is nothing to leak +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, after, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const authCtrl = require('../src/router/v1/auth/auth.controller') +const inviteCtrl = require('../src/router/v1/auth/invite.controller') +const adminCtrl = require('../src/router/v1/admin/admin.controller') +const users = require('../src/model/users/users.model') +const invites = require('../src/model/invites/invites.model') +const settings = require('../src/model/settings/settings.model') +const activity = require('../src/model/activity/activity.model') +const botScore = require('../src/middleware/botScore') +const loginProtection = require('../src/middleware/loginProtection') +const db = require('../src/utils/db') + +after(() => db.close()) + +function mockRes() { + return { + statusCode: 200, + body: null, + status(c) { + this.statusCode = c + return this + }, + json(b) { + this.body = b + return this + }, + cookie() { + return this + }, + } +} + +const patched = [] +function stub(obj, name, fn) { + patched.push([obj, name, obj[name]]) + obj[name] = fn +} +afterEach(() => { + while (patched.length) { + const [obj, name, fn] = patched.pop() + obj[name] = fn + } +}) + +// The exact error the mariadb connector raises for each index, captured from +// MariaDB 11.8. Note the address is inside the message: that is why none of these +// paths may echo it. +function dupEmailError(value = 'taken@example.com') { + const err = new Error( + `(conn:60, no: 1062, SQLState: 23000) Duplicate entry '${value}' for key 'uq_users_email_norm'\n` + + `sql: INSERT INTO users ... - parameters:['someone','${value}']`, + ) + err.code = 'ER_DUP_ENTRY' + err.errno = 1062 + err.sqlMessage = `Duplicate entry '${value}' for key 'uq_users_email_norm'` + return err +} + +function dupUsernameError() { + const err = new Error("(conn:60, no: 1062, SQLState: 23000) Duplicate entry 'someone' for key 'username'") + err.code = 'ER_DUP_ENTRY' + err.errno = 1062 + err.sqlMessage = "Duplicate entry 'someone' for key 'username'" + return err +} + +let scored + +beforeEach(() => { + scored = [] + stub(activity, 'log', async () => {}) + stub(botScore, 'recordHoneypot', (ip) => scored.push(['honeypot', ip])) + stub(botScore, 'recordLoginFailure', (ip) => scored.push(['loginFailure', ip])) + stub(loginProtection, 'recordFailure', (ip) => scored.push(['backoff', ip])) +}) + +// ── register: generic, and NOT scored ────────────────────────────────────── + +test('register answers a duplicate email generically and never says "username"', async () => { + stub(settings, 'getRegistrationMode', async () => 'password') + stub(users, 'createUser', async () => { + throw dupEmailError() + }) + const res = mockRes() + await authCtrl.register( + { body: { username: 'newperson', password: 'abcd1234', email: 'taken@example.com' }, ip: '9.9.9.9' }, + res, + ) + assert.equal(res.statusCode, 400, 'not the 409 a username clash gets - the shape itself must not distinguish') + assert.match(res.body.message, /Registration failed/i) + assert.doesNotMatch(res.body.message, /username/i, 'must not misattribute to the field they did NOT collide on') + assert.doesNotMatch(res.body.message, /email/i, 'and must not confirm the address exists') + assert.doesNotMatch(res.body.message, /taken@example\.com/, 'the address must never come back') +}) + +test('a duplicate email at register feeds NOTHING to the bot scorer or the backoff', async () => { + stub(settings, 'getRegistrationMode', async () => 'password') + stub(users, 'createUser', async () => { + throw dupEmailError() + }) + await authCtrl.register( + { body: { username: 'newperson', password: 'abcd1234', email: 'taken@example.com' }, ip: '9.9.9.9' }, + mockRes(), + ) + // A legitimate user typing a colleague's address is not an attacker. Scoring + // this would walk them toward an automatic IP ban for an honest mistake. + assert.deepEqual(scored, [], 'no bot score, no backoff') +}) + +test('register still reports a genuine username clash as 409', async () => { + stub(settings, 'getRegistrationMode', async () => 'password') + stub(users, 'createUser', async () => { + throw dupUsernameError() + }) + const res = mockRes() + await authCtrl.register({ body: { username: 'someone', password: 'abcd1234' }, ip: '9.9.9.9' }, res) + assert.equal(res.statusCode, 409) + assert.match(res.body.message, /username/i) +}) + +// ── invite accept: survivable, and distinguishable from a username clash ─── + +test('invite accept explains a duplicate email instead of blaming the username', async () => { + stub(invites, 'findValidByToken', async () => ({ id: 5, email: 'taken@example.com', role: 'player' })) + stub(users, 'createUser', async () => { + throw dupEmailError() + }) + const res = mockRes() + await inviteCtrl.acceptInvite( + { body: { username: 'newperson', password: 'abcd1234' }, params: { token: 't' }, ip: '9.9.9.9' }, + res, + ) + assert.equal(res.statusCode, 409) + assert.match(res.body.message, /email address is already in use/i) + assert.doesNotMatch(res.body.message, /username is already taken/i) +}) + +// ── admin user CRUD: was an opaque 500, now a 409 that names the field ───── + +test('admin createUser answers a duplicate email with 409, not a 500', async () => { + stub(users, 'getRawByUsername', async () => null) + stub(users, 'createUser', async () => { + throw dupEmailError() + }) + const res = mockRes() + await adminCtrl.createUser({ body: { username: 'newperson', password: 'abcd1234', email: 'taken@example.com' } }, res) + assert.equal(res.statusCode, 409, 'before Phase 1b this had no catch at all and became a 500') + assert.match(res.body.message, /email address/i) +}) + +test('admin updateUser answers a duplicate email with 409, not a 500', async () => { + stub(users, 'getById', async () => ({ id: 3, username: 'existing', role: 'player', status: 'active' })) + stub(users, 'update', async () => { + throw dupEmailError() + }) + const res = mockRes() + await adminCtrl.updateUser({ params: { id: '3' }, body: { email: 'taken@example.com' } }, res) + assert.equal(res.statusCode, 409) + assert.match(res.body.message, /email address/i) +}) + +test('admin createUser still reports a username clash as a username clash', async () => { + stub(users, 'getRawByUsername', async () => null) + stub(users, 'createUser', async () => { + throw dupUsernameError() + }) + const res = mockRes() + await adminCtrl.createUser({ body: { username: 'someone', password: 'abcd1234' } }, res) + assert.equal(res.statusCode, 409) + assert.match(res.body.message, /Username already taken/i) +}) diff --git a/server/test/emailUniqueness.test.js b/server/test/emailUniqueness.test.js new file mode 100644 index 0000000..4f03b12 --- /dev/null +++ b/server/test/emailUniqueness.test.js @@ -0,0 +1,87 @@ +// Engagement Phase 1b — telling the two unique constraints on `users` apart. +// +// This is the piece the whole phase rests on: `users` grew a second unique index, +// and until Phase 1b the duplicate-key test could not tell which one fired. Every +// call site that creates or updates a user branches on these predicates, so a +// wrong answer here means a duplicate email reported as a taken username, an SSO +// sign-up retrying usernames against a conflict no username can clear, or an +// opaque 500 on the admin user form. +// +// The error strings below are VERBATIM from MariaDB 11.8 through the mariadb Node +// connector, captured against a real duplicate insert. The key name lives only in +// the message text — the driver exposes no structured field for it — which is +// exactly why this needs its own test: it is parsing, and parsing rots silently. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, after } = require('node:test') +const assert = require('node:assert/strict') + +const users = require('../src/model/users/users.model') +const db = require('../src/utils/db') + +after(() => db.close()) + +// Shaped exactly as the connector delivers them, including the trailing `sql:` +// section — which is also the reason these must never be echoed to a client: note +// the bound parameters, and therefore the address, are in the text. +function dupError(key, value) { + const err = new Error( + `(conn:60, no: 1062, SQLState: 23000) Duplicate entry '${value}' for key '${key}'\n` + + `sql: INSERT INTO users (username, email) VALUES (?, ?) - parameters:['someone','${value}']`, + ) + err.code = 'ER_DUP_ENTRY' + err.errno = 1062 + err.sqlState = '23000' + err.sqlMessage = `Duplicate entry '${value}' for key '${key}'` + return err +} + +test('an email collision is reported as email, not username', () => { + const err = dupError('uq_users_email_norm', 'taken@example.com') + assert.equal(users.isDuplicateEmail(err), true) + assert.equal(users.isDuplicateUsername(err), false, 'must NOT masquerade as a username collision') + assert.equal(users.duplicateKey(err), 'uq_users_email_norm') +}) + +test('a username collision is still reported as username', () => { + const err = dupError('username', 'someone') + assert.equal(users.isDuplicateUsername(err), true) + assert.equal(users.isDuplicateEmail(err), false) + assert.equal(users.duplicateKey(err), 'username') +}) + +// The permissive fallback is deliberate. Only the case we can positively identify +// — email — is carved out; anything else keeps the pre-Phase-1b behaviour so no +// call site newly falls through to a 500 on a database whose index carries an +// unexpected name. +test('an unrecognised unique index keeps the old permissive behaviour', () => { + const err = dupError('some_other_uq', 'x') + assert.equal(users.isDuplicateUsername(err), true) + assert.equal(users.isDuplicateEmail(err), false) +}) + +test('a duplicate-key error the message does not name is treated as username', () => { + const err = new Error('Duplicate entry - no key clause here') + err.code = 'ER_DUP_ENTRY' + err.errno = 1062 + assert.equal(users.duplicateKey(err), null) + assert.equal(users.isDuplicateUsername(err), true) + assert.equal(users.isDuplicateEmail(err), false) +}) + +test('non-duplicate errors are neither', () => { + for (const err of [null, undefined, new Error('boom'), { code: 'ER_NO_SUCH_TABLE' }]) { + assert.equal(users.isDuplicateUsername(err), false) + assert.equal(users.isDuplicateEmail(err), false) + assert.equal(users.duplicateKey(err), null) + } +}) + +// errno alone, with no `code`, is how some driver paths surface it. +test('errno 1062 without a code still counts', () => { + const err = new Error("Duplicate entry 'a@b.com' for key 'uq_users_email_norm'") + err.errno = 1062 + assert.equal(users.isDuplicateEmail(err), true) + assert.equal(users.isDuplicateUsername(err), false) +}) diff --git a/server/test/emailVerification.test.js b/server/test/emailVerification.test.js new file mode 100644 index 0000000..11c91be --- /dev/null +++ b/server/test/emailVerification.test.js @@ -0,0 +1,278 @@ +// Engagement Phase 1b — the change-and-verify flow, at the controller level. +// +// Every model call is monkeypatched, so no query runs. What is under test is the +// DECISION-MAKING, and three properties in particular that no single unit of the +// code enforces on its own: +// +// 1. Requesting a change never touches the live address. The account keeps +// receiving password-reset mail at the address it had until a link proves the +// new one. A regression here is silent, and only shows up when somebody +// cannot recover their account. +// 2. Confirming answers IDENTICALLY for every failure. Expired, already-used, +// superseded, and "another account verified this address first" are one 404 +// with one message. Any divergence turns the endpoint into an oracle for +// which addresses hold accounts. +// 3. Changing the address is re-authenticated, with the SSO carve-out. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, after, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const account = require('../src/router/v1/auth/account.controller') +const verify = require('../src/router/v1/auth/emailVerify.controller') +const users = require('../src/model/users/users.model') +const emailVerifications = require('../src/model/emailVerifications/emailVerifications.model') +const activity = require('../src/model/activity/activity.model') +const mailer = require('../src/utils/mailer') +const db = require('../src/utils/db') + +after(() => db.close()) + +function mockRes() { + return { + statusCode: 200, + body: null, + status(c) { + this.statusCode = c + return this + }, + json(b) { + this.body = b + return this + }, + } +} + +// Restore-on-teardown patching, keyed by owner+name so two modules may each carry +// a function of the same name. +const patched = [] +function stub(obj, name, fn) { + patched.push([obj, name, obj[name]]) + obj[name] = fn +} + +let sent +let staged +let promoted + +beforeEach(() => { + sent = [] + staged = [] + promoted = [] + stub(activity, 'log', async () => {}) + stub(mailer, 'sendEmailVerification', async (args) => { + sent.push(args) + return { sent: true } + }) + stub(users, 'setPendingEmail', async (id, email) => { + staged.push([id, email]) + return 1 + }) + stub(users, 'clearPendingEmail', async () => 1) + stub(users, 'promotePendingEmail', async (id, email) => { + promoted.push([id, email]) + return true + }) + stub(users, 'validatePassword', async (_u, pw) => pw === 'correct-horse') + stub(emailVerifications, 'sendQuotaExhausted', async () => false) + stub(emailVerifications, 'invalidatePendingForUser', async () => 0) + stub(emailVerifications, 'create', async () => ({ id: 1, token: 'tok-abcdefgh' })) +}) + +afterEach(() => { + while (patched.length) { + const [obj, name, fn] = patched.pop() + obj[name] = fn + } +}) + +const reqFor = (body, user = {}) => ({ + body, + ip: '10.0.0.1', + user: { id: 7, username: 'alice', ...user }, +}) + +// ── 1. The live address is never touched by a request ────────────────────── + +test('requesting a change stages the address and leaves the live one alone', async () => { + let updateCalled = false + stub(users, 'update', async () => { + updateCalled = true + }) + stub(users, 'getRawById', async () => ({ id: 7, password_hash: 'h', email: 'old@example.com' })) + + const res = mockRes() + await account.changeEmail(reqFor({ email: 'new@example.com', currentPassword: 'correct-horse' }), res) + + assert.equal(res.statusCode, 200) + assert.deepEqual(staged, [[7, 'new@example.com']], 'the new address is STAGED') + assert.equal(updateCalled, false, 'users.update must NOT be called - the live address stands') + assert.equal(res.body.email_pending, 'new@example.com') + assert.equal(sent.length, 1, 'a verification mail goes to the address being proved') + assert.equal(sent[0].to, 'new@example.com') +}) + +test('the verification link goes to the NEW address, never the old one', async () => { + stub(users, 'getRawById', async () => ({ id: 7, password_hash: 'h', email: 'old@example.com' })) + const res = mockRes() + await account.changeEmail(reqFor({ email: 'new@example.com', currentPassword: 'correct-horse' }), res) + assert.equal(sent[0].to, 'new@example.com') + assert.notEqual(sent[0].to, 'old@example.com') +}) + +// ── 2. Re-authentication, with the SSO carve-out ─────────────────────────── + +test('a wrong current password is refused and stages nothing', async () => { + stub(users, 'getRawById', async () => ({ id: 7, password_hash: 'h', email: 'old@example.com' })) + const res = mockRes() + await account.changeEmail(reqFor({ email: 'new@example.com', currentPassword: 'wrong' }), res) + assert.equal(res.statusCode, 400) + assert.deepEqual(staged, [], 'nothing may be staged on a failed re-auth') + assert.equal(sent.length, 0, 'and no mail may go out') +}) + +test('an SSO-only account (no password hash) may change its address without one', async () => { + stub(users, 'getRawById', async () => ({ id: 7, password_hash: null, email: null })) + const res = mockRes() + await account.changeEmail(reqFor({ email: 'first@example.com' }), res) + assert.equal(res.statusCode, 200, 'the carve-out changePassword already makes, made here too') + assert.deepEqual(staged, [[7, 'first@example.com']]) +}) + +test('setting the same address again is refused', async () => { + stub(users, 'getRawById', async () => ({ id: 7, password_hash: 'h', email: 'Same@Example.com' })) + const res = mockRes() + // Compared case-folded, because the uniqueness index folds case: this IS the + // same mailbox, and staging it would mail the user a link to prove what they + // have already proved. + await account.changeEmail(reqFor({ email: 'same@example.com', currentPassword: 'correct-horse' }), res) + assert.equal(res.statusCode, 400) + assert.deepEqual(staged, []) +}) + +// ── 3. Confirming: every failure answers identically ─────────────────────── + +const INVALID = 'This confirmation link is invalid or has expired.' + +test('an unusable link 404s with the generic message', async () => { + stub(emailVerifications, 'findValidByToken', async () => null) + const res = mockRes() + await verify.confirm({ params: { token: 'nope' }, ip: '1.2.3.4' }, res) + assert.equal(res.statusCode, 404) + assert.equal(res.body.message, INVALID) +}) + +test('an address another account verified first answers the SAME 404', async () => { + stub(emailVerifications, 'findValidByToken', async () => ({ id: 1, user_id: 7, email: 'taken@example.com' })) + stub(emailVerifications, 'consume', async () => true) + stub(users, 'promotePendingEmail', async () => { + const err = new Error("Duplicate entry 'taken@example.com' for key 'uq_users_email_norm'") + err.code = 'ER_DUP_ENTRY' + err.errno = 1062 + err.sqlMessage = "Duplicate entry 'taken@example.com' for key 'uq_users_email_norm'" + throw err + }) + const res = mockRes() + await verify.confirm({ params: { token: 'tok' }, ip: '1.2.3.4' }, res) + assert.equal(res.statusCode, 404, 'not a 409 - that would be an enumeration oracle') + assert.equal(res.body.message, INVALID, 'byte-identical to an expired link') +}) + +test('a superseded link answers the SAME 404', async () => { + stub(emailVerifications, 'findValidByToken', async () => ({ id: 1, user_id: 7, email: 'stale@example.com' })) + stub(emailVerifications, 'consume', async () => true) + stub(users, 'promotePendingEmail', async () => false) // the guard rejected it + const res = mockRes() + await verify.confirm({ params: { token: 'tok' }, ip: '1.2.3.4' }, res) + assert.equal(res.statusCode, 404) + assert.equal(res.body.message, INVALID) +}) + +test('a link that lost the double-use race answers the SAME 404', async () => { + stub(emailVerifications, 'findValidByToken', async () => ({ id: 1, user_id: 7, email: 'a@example.com' })) + stub(emailVerifications, 'consume', async () => false) // someone else consumed it first + const res = mockRes() + await verify.confirm({ params: { token: 'tok' }, ip: '1.2.3.4' }, res) + assert.equal(res.statusCode, 404) + assert.equal(res.body.message, INVALID) + assert.deepEqual(promoted, [], 'and must not touch the account') +}) + +test('a good link installs the address and issues no session', async () => { + stub(emailVerifications, 'findValidByToken', async () => ({ id: 1, user_id: 7, email: 'good@example.com' })) + stub(emailVerifications, 'consume', async () => true) + const res = mockRes() + await verify.confirm({ params: { token: 'tok' }, ip: '1.2.3.4' }, res) + assert.equal(res.statusCode, 200) + assert.equal(res.body.ok, true) + assert.deepEqual(promoted, [[7, 'good@example.com']]) + // The response carries no token, cookie or user — proving control of a mailbox + // is not proving control of an account. + assert.equal(res.body.token, undefined) + assert.equal(res.body.user, undefined) +}) + +// ── 4. The send ceiling, and honest reporting when mail is off ───────────── + +test('the per-user send ceiling refuses with 429 and sends nothing', async () => { + stub(users, 'getRawById', async () => ({ id: 7, password_hash: null, email: null })) + stub(emailVerifications, 'sendQuotaExhausted', async () => true) + const res = mockRes() + await account.changeEmail(reqFor({ email: 'new@example.com' }), res) + assert.equal(res.statusCode, 429) + assert.equal(sent.length, 0) +}) + +test('unconfigured mail is reported honestly, and the address stays staged', async () => { + stub(users, 'getRawById', async () => ({ id: 7, password_hash: null, email: null })) + stub(mailer, 'sendEmailVerification', async () => ({ sent: false, reason: 'NOT_CONFIGURED' })) + const res = mockRes() + await account.changeEmail(reqFor({ email: 'new@example.com' }), res) + assert.equal(res.statusCode, 200) + assert.equal(res.body.emailed, false) + assert.equal(res.body.reason, 'NOT_CONFIGURED') + assert.deepEqual(staged, [[7, 'new@example.com']], 'staged, so a later resend can work') +}) + +test('resending with nothing pending is a 400, not a mail', async () => { + stub(users, 'getRawById', async () => ({ id: 7, password_hash: 'h', email: 'a@example.com', email_pending: null })) + const res = mockRes() + await account.resendEmailVerification(reqFor({}), res) + assert.equal(res.statusCode, 400) + assert.equal(sent.length, 0) +}) + +test('resending re-sends to the pending address', async () => { + stub(users, 'getRawById', async () => ({ + id: 7, + password_hash: 'h', + email: 'a@example.com', + email_pending: 'p@example.com', + })) + const res = mockRes() + await account.resendEmailVerification(reqFor({}), res) + assert.equal(res.statusCode, 200) + assert.equal(sent.length, 1) + assert.equal(sent[0].to, 'p@example.com') +}) + +test('cancelling clears the pending address AND retires its outstanding links', async () => { + let cleared = false + let retired = false + stub(users, 'clearPendingEmail', async () => { + cleared = true + return 1 + }) + stub(emailVerifications, 'invalidatePendingForUser', async () => { + retired = true + return 1 + }) + const res = mockRes() + await account.cancelEmailChange(reqFor({}), res) + assert.equal(res.statusCode, 200) + assert.equal(cleared, true) + // Both halves matter: clearing the column alone would leave a link already + // sitting in a mailbox able to install the address the user just abandoned. + assert.equal(retired, true, 'outstanding links must be retired too') +}) diff --git a/server/test/providers.test.js b/server/test/providers.test.js index 23ecc90..8356567 100644 --- a/server/test/providers.test.js +++ b/server/test/providers.test.js @@ -50,7 +50,16 @@ test('Google handleCallback exchanges code and normalizes the profile', async () }) const p = new GoogleProvider({ id: 'google', clientId: 'gid', clientSecret: 'gsecret' }) const profile = await p.handleCallback({ code: 'C', redirectUri: 'https://app/cb', codeVerifier: 'V' }) - assert.deepEqual(profile, { subject: '11550', email: 'alice@example.com', name: 'Alice' }) + // emailVerified is false because this userinfo document carries no + // `email_verified` claim. Before engagement Phase 1b the presence of an address + // was itself treated as verification, which is the bug that made the flag + // meaningless — see ssoEmailVerified.test.js. + assert.deepEqual(profile, { + subject: '11550', + email: 'alice@example.com', + emailVerified: false, + name: 'Alice', + }) }) test('Discord authorize URL + profile mapping (global_name → name, id → subject)', async () => { @@ -64,7 +73,8 @@ test('Discord authorize URL + profile mapping (global_name → name, id → subj 'discord.com/api/users/@me': { id: '99', username: 'bob', global_name: 'Bob', email: 'bob@x.io' }, }) const profile = await p.handleCallback({ code: 'C', redirectUri: 'https://app/cb' }) - assert.deepEqual(profile, { subject: '99', email: 'bob@x.io', name: 'Bob' }) + // Discord spells the claim `verified`, and this fixture does not send it. + assert.deepEqual(profile, { subject: '99', email: 'bob@x.io', emailVerified: false, name: 'Bob' }) }) test('Generic OIDC provider uses configured endpoints and OIDC profile fields', async () => { @@ -82,7 +92,8 @@ test('Generic OIDC provider uses configured endpoints and OIDC profile fields', 'idp.example/userinfo': { sub: 'abc', email: 'c@d.e', preferred_username: 'carol' }, }) const profile = await p.handleCallback({ code: 'C', redirectUri: 'https://app/cb', codeVerifier: 'V' }) - assert.deepEqual(profile, { subject: 'abc', email: 'c@d.e', name: 'carol' }) + // An IdP that omits the claim has asserted nothing: absent is false, never true. + assert.deepEqual(profile, { subject: 'abc', email: 'c@d.e', emailVerified: false, name: 'carol' }) }) test('handleCallback throws when the token exchange fails', async () => { diff --git a/server/test/ssoEmailVerified.test.js b/server/test/ssoEmailVerified.test.js new file mode 100644 index 0000000..0ad6983 --- /dev/null +++ b/server/test/ssoEmailVerified.test.js @@ -0,0 +1,140 @@ +// Engagement Phase 1b — what SSO does with an email address. +// +// Two corrections, both of them things the old code got wrong quietly: +// +// 1. `emailVerified: Boolean(profile.email)` marked EVERY SSO address verified, +// because an address was present. That made `email_verified` mean "we have an +// address", which is not a fact about anything, and is why the de-duplication +// resolves duplicates oldest-wins rather than verified-wins (§0.6 finding 3). +// Now each provider reports the claim its IdP actually asserted. +// 2. Provisioning retried usernames on ANY duplicate-key error. Once email is +// unique that loop can never clear an email conflict — it burns every +// candidate and returns "could not find a username", blaming usernames for a +// conflict that was never about them (§0.6 finding 2). +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, after, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const GoogleProvider = require('../src/auth/providers/google.provider') +const DiscordProvider = require('../src/auth/providers/discord.provider') +const GenericOidcProvider = require('../src/auth/providers/genericOidc.provider') +const sso = require('../src/router/v1/auth/sso.controller') +const users = require('../src/model/users/users.model') +const userIdentities = require('../src/model/userIdentities/userIdentities.model') +const activity = require('../src/model/activity/activity.model') +const db = require('../src/utils/db') + +after(() => db.close()) + +// ── 1. Each provider reads its own spelling of the claim ─────────────────── + +test('Google reads the standard email_verified claim', () => { + const p = new GoogleProvider({ id: 'google' }) + assert.equal(p.normalizeProfile({ sub: '1', email: 'a@b.com', email_verified: true }).emailVerified, true) + assert.equal(p.normalizeProfile({ sub: '1', email: 'a@b.com', email_verified: false }).emailVerified, false) + // Present-but-unasserted is NOT verified. This is the whole bug. + assert.equal(p.normalizeProfile({ sub: '1', email: 'a@b.com' }).emailVerified, false) +}) + +test('Discord reads `verified`, which is how Discord spells it', () => { + const p = new DiscordProvider({ id: 'discord' }) + assert.equal(p.normalizeProfile({ id: '1', email: 'a@b.com', verified: true }).emailVerified, true) + assert.equal(p.normalizeProfile({ id: '1', email: 'a@b.com', verified: false }).emailVerified, false) + assert.equal(p.normalizeProfile({ id: '1', email: 'a@b.com' }).emailVerified, false) +}) + +test('a generic OIDC provider that omits the claim leaves the address unverified', () => { + const p = new GenericOidcProvider({ id: 'custom' }) + assert.equal(p.normalizeProfile({ sub: '1', email: 'a@b.com', email_verified: true }).emailVerified, true) + // An IdP that asserts nothing has asserted nothing. Absent is false, never true. + assert.equal(p.normalizeProfile({ sub: '1', email: 'a@b.com' }).emailVerified, false) +}) + +// Some IdPs stringify booleans in the userinfo document. +test('the string "true" counts, anything else does not', () => { + const p = new GenericOidcProvider({ id: 'custom' }) + assert.equal(p.normalizeProfile({ sub: '1', email: 'a@b.com', email_verified: 'true' }).emailVerified, true) + assert.equal(p.normalizeProfile({ sub: '1', email: 'a@b.com', email_verified: 'yes' }).emailVerified, false) + assert.equal(p.normalizeProfile({ sub: '1', email: 'a@b.com', email_verified: 1 }).emailVerified, false) +}) + +test('every provider still returns the fields the rest of the flow reads', () => { + const cases = [ + [new GoogleProvider({ id: 'google' }), { sub: 'g1', email: 'a@b.com', name: 'A' }], + [new DiscordProvider({ id: 'discord' }), { id: 'd1', email: 'a@b.com', global_name: 'A' }], + [new GenericOidcProvider({ id: 'custom' }), { sub: 'c1', email: 'a@b.com', name: 'A' }], + ] + for (const [provider, raw] of cases) { + const out = provider.normalizeProfile(raw) + assert.ok(out.subject, `${provider.id} must still derive a subject`) + assert.equal(out.email, 'a@b.com') + assert.equal(typeof out.emailVerified, 'boolean', `${provider.id} must report a boolean, never undefined`) + assert.ok('name' in out) + } +}) + +// ── 2. Provisioning stops on an email conflict instead of burning candidates ─ + +const patched = [] +function stub(obj, name, fn) { + patched.push([obj, name, obj[name]]) + obj[name] = fn +} +afterEach(() => { + while (patched.length) { + const [obj, name, fn] = patched.pop() + obj[name] = fn + } +}) + +function dupError(key, value) { + const err = new Error(`Duplicate entry '${value}' for key '${key}'`) + err.code = 'ER_DUP_ENTRY' + err.errno = 1062 + err.sqlMessage = `Duplicate entry '${value}' for key '${key}'` + return err +} + +const req = { ip: '1.2.3.4' } +const profile = { subject: 'idp-1', email: 'taken@example.com', name: 'Someone', emailVerified: true } + +test('an email conflict stops provisioning at the FIRST attempt', async () => { + let attempts = 0 + stub(users, 'createUser', async () => { + attempts += 1 + throw dupError('uq_users_email_norm', 'taken@example.com') + }) + const out = await sso.provisionSsoPlayer(req, 'google', profile) + // PROVISION_MAX_TRIES is 25. Retrying usernames cannot clear an EMAIL conflict, + // so 25 attempts would be 24 pointless writes ending in a log line blaming + // usernames for something they had nothing to do with. + assert.equal(attempts, 1, 'must not retry a conflict no username change can resolve') + assert.equal(out.error, 'email_in_use', 'and must say which conflict it was') + assert.equal(out.user, undefined) +}) + +test('a username conflict still retries the next candidate', async () => { + let attempts = 0 + stub(users, 'createUser', async () => { + attempts += 1 + if (attempts < 3) throw dupError('username', 'someone') + return { id: 42, username: `someone${attempts}`, role: 'player' } + }) + stub(userIdentities, 'link', async () => {}) + stub(activity, 'log', async () => {}) + const out = await sso.provisionSsoPlayer(req, 'google', profile) + assert.equal(attempts, 3, 'the bounded username retry is unchanged') + assert.equal(out.user.id, 42) + assert.equal(out.error, undefined) +}) + +test('exhausting username candidates reports a generic error, not an email one', async () => { + stub(users, 'createUser', async () => { + throw dupError('username', 'someone') + }) + const out = await sso.provisionSsoPlayer(req, 'google', profile) + assert.equal(out.error, 'error') + assert.equal(out.user, undefined) +}) -- 2.49.1 From 563199a096af6e200f8c44f3204a0cfab49add73 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 29 Aug 2026 06:40:28 -0500 Subject: [PATCH 06/20] feat(modules): event triggers, audiences and the ceiling lattice (engagement Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract half of the engagement system: a module (and core) can DECLARE an event with a payload contract and fire it. Nothing delivers yet — `emit` validates, logs and stops, and Phase 4 replaces that log line with the engine. `api.registerEventTriggers` and `api.registerAudiences` ride the existing stage()/apply() validate-then-commit discipline, so a registrant that throws halfway leaves nothing behind. `ctx.events.emit` is fire-and-forget and binds the owner from the calling module — a module fires its own triggers and no one else's. `ctx.inbox.push` is present and throws until Phase 7, the shape 1.6.0 settled on for a member that arrives a phase late. MODULE_API_VERSION 1.7.0 on both halves. Additions only; module-uo's `coreApi: "^1.3.0"` still resolves. Three design decisions, approved by the org lead before any code: ONE NAMESPACE for trigger ids and notification-stream ids (ENGAGEMENT.md §7.2, against the recommendation in the text). A trigger is a payload contract attached to an id that may also carry a subscription toggle, so an id has exactly one owner across both facets, checked in both directions. Core's five trigger ids ARE its five stream ids, so the same-owner upgrade case is exercised on every boot rather than only by a module. It keeps notification_channel_prefs single-keyed in Phase 3, where two namespaces would have forced a `kind` discriminator into its primary key. Two knock-on effects appeared only once it was implemented. The id grammar had to be RELAXED to admit `_` inside a segment — §4.3's own worked example is `uo.house.idoc_warning`, and two grammars over one namespace would mean an id legal as a trigger and illegal as the stream it is the same event as. And the seven grandfathered `uo.*` ids had to share their legacy allowlist with triggers, because under one namespace `idoc.warning` is a single id. The push catalog is untouched either way: allStreams() still serves the stream facet only, so the shipped Android client sees exactly what it saw before. THE CEILING LATTICE (G24), which the plan named everywhere and defined nowhere. It is containment, not size: everyone ⊃ authenticated ⊃ {subscribers, members, staff, owner}, with the four leaves mutually incomparable. The flat total order the plan's wording invites would let a `staff`-ceilinged trigger be given an `owner` audience — a rule that mails cheat detection to the player it detected. Fewer people is not less exposure. Two incomparable ceilings have no meet at all, so a composition is refused rather than guessed; union-widens is the intuitive implementation and it is the wrong one. `kind: 'event' | 'scheduled'` is declarable now and no evaluator exists (§7.1 Q6). Registration accepts `scheduled` and emit refuses to fire one, so `kind` means something from the moment it can be written rather than from the moment it is honoured. Also: `GET /admin/engagement/{triggers,audiences}`, served from the registries rather than a table so an uninstalled module simply stops appearing; `npm run engagement:manifest` plus its CI `--check`, the twin of the route manifest, because renaming a variable breaks stored templates silently, at send time, in mail someone already received. Co-Authored-By: Claude --- .gitea/workflows/pr-checks.yml | 9 + client/src/modules/version.js | 10 +- server/engagement-triggers.json | 211 +++++++++ server/package.json | 1 + server/routes.guards.json | 18 + server/routes.manifest.json | 8 + server/scripts/engagementManifest.js | 132 ++++++ server/src/config/coreTriggers.js | 147 ++++++ server/src/modules/ceilings.js | 107 +++++ server/src/modules/loader.js | 53 +++ server/src/modules/registries.js | 359 ++++++++++++++- server/src/modules/version.js | 22 +- .../router/v1/admin/engagement.controller.js | 54 +++ .../src/router/v1/admin/engagement.router.js | 47 ++ server/src/router/v1/admin/index.js | 8 + server/src/utils/engagementEmit.js | 223 +++++++++ server/swagger/swagger-output.json | 122 +++++ server/test/engagementCeilings.test.js | 86 ++++ server/test/engagementManifest.test.js | 77 ++++ server/test/engagementTriggers.test.js | 435 ++++++++++++++++++ server/test/moduleLoader.test.js | 9 +- 21 files changed, 2132 insertions(+), 6 deletions(-) create mode 100644 server/engagement-triggers.json create mode 100644 server/scripts/engagementManifest.js create mode 100644 server/src/config/coreTriggers.js create mode 100644 server/src/modules/ceilings.js create mode 100644 server/src/router/v1/admin/engagement.controller.js create mode 100644 server/src/router/v1/admin/engagement.router.js create mode 100644 server/src/utils/engagementEmit.js create mode 100644 server/test/engagementCeilings.test.js create mode 100644 server/test/engagementManifest.test.js create mode 100644 server/test/engagementTriggers.test.js diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index 4188ea9..06251e5 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -75,6 +75,15 @@ jobs: # of a reviewer instead of letting it pass silently. run: npm run routes:manifest --prefix server -- --check + - name: Check the engagement trigger manifest is current + # ENGAGEMENT.md 4.3 property 4 - the same mechanism as the route manifest + # above, for the event contract instead of the URL surface. A trigger + # declaration is what a stored template interpolates and what a stored + # rule is written against, so renaming a variable or widening a ceiling + # breaks them silently, at send time, in mail someone already received. + # Regenerating and diffing makes that change something a reviewer reads. + run: npm run engagement:manifest --prefix server -- --check + client-build: runs-on: ubuntu-latest steps: diff --git a/client/src/modules/version.js b/client/src/modules/version.js index c0c833a..44d4942 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -11,6 +11,14 @@ // 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.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, +// ctx.inbox.push). This file bumps anyway, for the reason at the top — the two +// halves state ONE version, and a module declares one `coreApi` range against +// both. The web surfaces the engagement system needs (the rules and template +// editors, the in-app inbox) land in Phases 4, 5 and 7 and will add to this half +// then. // 1.6.0 — the Team surface (docs/website/TEAMS.md Part 11). Nothing on this half // changed yet: the two client additions the version covers are the `team.overview` // and `team.member.row` slots, and a slot can only be declared by the page that @@ -45,4 +53,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.6.0' +export const MODULE_API_VERSION = '1.7.0' diff --git a/server/engagement-triggers.json b/server/engagement-triggers.json new file mode 100644 index 0000000..80bf010 --- /dev/null +++ b/server/engagement-triggers.json @@ -0,0 +1,211 @@ +{ + "_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", + "triggers": [ + { + "id": "news.post", + "owner": "core", + "label": "News post published", + "description": "A news / Five-on-Friday / newsletter post was published.", + "kind": "event", + "subjectKey": null, + "audience": "subscribers", + "ceiling": "authenticated", + "version": 1, + "variables": [ + { + "name": "title", + "type": "string", + "required": true, + "example": "Five on Friday — the Yew invasion", + "description": "The post title." + }, + { + "name": "excerpt", + "type": "string", + "required": false, + "example": "Four new champion spawns, and the fate of the Yew moongate…", + "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." + } + ] + }, + { + "id": "team.announcement", + "owner": "core", + "label": "Team — announcement", + "description": "A leader posted an announcement in a Team.", + "kind": "event", + "subjectKey": "teamName", + "audience": "members", + "ceiling": "members", + "version": 1, + "variables": [ + { + "name": "teamName", + "type": "string", + "required": true, + "example": "The Silver Anvil", + "description": "The Team the event is about. Also the cooldown subject." + }, + { + "name": "authorName", + "type": "string", + "required": true, + "example": "Marisol", + "description": "Display name of the leader who posted." + }, + { + "name": "title", + "type": "string", + "required": true, + "example": "Siege practice moved to Sunday", + "description": "The announcement title." + }, + { + "name": "excerpt", + "type": "string", + "required": false, + "example": "We are moving practice to Sunday 8pm…", + "description": "Plain-text excerpt of the announcement body." + }, + { + "name": "postUrl", + "type": "url", + "required": false, + "example": "/guilds/the-silver-anvil/forum/419", + "description": "Site-relative path to the announcement." + } + ] + }, + { + "id": "team.forum.post", + "owner": "core", + "label": "Team — new forum post", + "description": "A new thread or reply in a Team forum.", + "kind": "event", + "subjectKey": "teamName", + "audience": "members", + "ceiling": "members", + "version": 1, + "variables": [ + { + "name": "teamName", + "type": "string", + "required": true, + "example": "The Silver Anvil", + "description": "The Team the event is about. Also the cooldown subject." + }, + { + "name": "authorName", + "type": "string", + "required": true, + "example": "Darrow", + "description": "Display name of the poster." + }, + { + "name": "threadTitle", + "type": "string", + "required": true, + "example": "Tuesday champ rotation", + "description": "Title of the thread the post belongs to." + }, + { + "name": "excerpt", + "type": "string", + "required": false, + "example": "Moving the Tuesday run an hour later…", + "description": "Plain-text excerpt of the post body, already stripped of markup." + }, + { + "name": "postUrl", + "type": "url", + "required": false, + "example": "/guilds/the-silver-anvil/forum/412", + "description": "Site-relative path to the post." + } + ] + }, + { + "id": "team.leadership.changed", + "owner": "core", + "label": "Team — leadership change", + "description": "Leadership changed in a Team.", + "kind": "event", + "subjectKey": "teamName", + "audience": "members", + "ceiling": "members", + "version": 1, + "variables": [ + { + "name": "teamName", + "type": "string", + "required": true, + "example": "The Silver Anvil", + "description": "The Team the event is about. Also the cooldown subject." + }, + { + "name": "leaderName", + "type": "string", + "required": true, + "example": "Marisol", + "description": "Display name of the new leader." + }, + { + "name": "teamUrl", + "type": "url", + "required": false, + "example": "/guilds/the-silver-anvil", + "description": "Site-relative path to the Team page." + } + ] + }, + { + "id": "team.member.joined", + "owner": "core", + "label": "Team — new member", + "description": "Someone joined a Team.", + "kind": "event", + "subjectKey": "teamName", + "audience": "members", + "ceiling": "members", + "version": 1, + "variables": [ + { + "name": "teamName", + "type": "string", + "required": true, + "example": "The Silver Anvil", + "description": "The Team the event is about. Also the cooldown subject." + }, + { + "name": "memberName", + "type": "string", + "required": true, + "example": "Darrow", + "description": "Display name of the member who joined." + }, + { + "name": "teamUrl", + "type": "url", + "required": false, + "example": "/guilds/the-silver-anvil", + "description": "Site-relative path to the Team page. Absent when no module supplies a pageUrlTemplate." + } + ] + } + ] +} diff --git a/server/package.json b/server/package.json index 2380ed4..a9e5966 100644 --- a/server/package.json +++ b/server/package.json @@ -9,6 +9,7 @@ "seed": "node db/seed.js", "swagger": "node swagger/swagger.js", "routes:manifest": "node scripts/routeManifest.js", + "engagement:manifest": "node scripts/engagementManifest.js", "test": "node --test --require ./test/_setup.js" }, "keywords": [ diff --git a/server/routes.guards.json b/server/routes.guards.json index b931ae3..e89f215 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -167,6 +167,24 @@ "validate" ] }, + { + "method": "GET", + "path": "/api/v1/admin/engagement/audiences", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/engagement/triggers", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, { "method": "GET", "path": "/api/v1/admin/invites", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index ea1427e..0ca2665 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -73,6 +73,14 @@ "method": "POST", "path": "/api/v1/admin/email/test" }, + { + "method": "GET", + "path": "/api/v1/admin/engagement/audiences" + }, + { + "method": "GET", + "path": "/api/v1/admin/engagement/triggers" + }, { "method": "GET", "path": "/api/v1/admin/invites" diff --git a/server/scripts/engagementManifest.js b/server/scripts/engagementManifest.js new file mode 100644 index 0000000..dd28be5 --- /dev/null +++ b/server/scripts/engagementManifest.js @@ -0,0 +1,132 @@ +#!/usr/bin/env node +/** + * Engagement trigger manifest — the machine-readable freeze of core's event + * contract (ENGAGEMENT.md §4.3, property 4). + * + * Why this exists: a trigger declaration is what a template interpolates and what + * a rule is written against. Renaming a variable, changing its type, or widening + * a ceiling breaks stored templates and stored rules — and does it silently, at + * send time, in an email someone already received. `routes.manifest.json` freezes + * the URL surface for exactly this reason and this is its twin: a generated + * artifact committed to the repo, whose DIFF is the review signal. Changing a + * declaration without regenerating is a red build; changing one deliberately puts + * the change in front of a reviewer instead of letting it pass as a comment edit. + * + * **Core's only.** A module ships its own `engagement-triggers.json` in its + * bundle, for the same reason it ships a prebuilt swagger fragment: core never + * has its sources to analyse (MODULE_API.md §6.1a). So this loads + * `config/coreTriggers.js` through the real `registerCore()` — the declarations + * as VALIDATED, not as authored — which means a shape error is a failure here + * rather than a surprise at boot. + * + * The `resolve` half of an audience cannot be frozen (it is a function over a + * module's own store), so audiences are deliberately absent: what a manifest can + * usefully freeze is the payload contract, and freezing half a declaration would + * suggest the other half was checked. + * + * Usage: + * npm run engagement:manifest # write server/engagement-triggers.json + * npm run engagement:manifest -- --check # exit 1 if the committed file is stale + */ + +// registries.js -> config/coreStreams + utils/discordAnnounce, which reach +// utils/db and build a mariadb pool at require time. Point it at a closed port +// (the same trick routeManifest.js and the test suite use) so generating a +// manifest never opens a connection or hangs on a missing database. +process.env.DB_HOST = process.env.DB_HOST || '127.0.0.1' +process.env.DB_PORT = process.env.DB_PORT || '59999' + +const fs = require('fs') +const path = require('path') + +const registries = require('../src/modules/registries') +const db = require('../src/utils/db') +const { MODULE_API_VERSION } = require('../src/modules/version') + +const SERVER_ROOT = path.join(__dirname, '..') +const MANIFEST_PATH = path.join(SERVER_ROOT, 'engagement-triggers.json') + +const MANIFEST_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.' + +function build() { + // Through registerCore(), not by reading the array: what a reviewer needs + // frozen is what the registry ACCEPTED — defaults filled in, audience resolved + // against the ceiling, variables normalised — because that is what the editor + // will read and the emit path will check against. + registries.registerCore() + + const triggers = registries + .allTriggers() + .filter((t) => t.owner === 'core') + // Sorted by id rather than left in registration order, like the route + // manifest: reordering a declaration in the source is not a contract change + // and must not produce a diff that looks like one. + .sort((a, b) => a.id.localeCompare(b.id)) + .map((t) => ({ + id: t.id, + owner: t.owner, + label: t.label, + description: t.description, + kind: t.kind, + subjectKey: t.subjectKey, + audience: t.audience, + ceiling: t.ceiling, + version: t.version, + // Variables keep their DECLARED order. Here it is contract: it is the + // order the template editor lists them in, and an author reading the + // manifest should see what the editor will show. + variables: t.variables.map((v) => ({ + name: v.name, + type: v.type, + required: v.required, + example: v.example, + description: v.description, + })), + })) + + return { + _comment: MANIFEST_COMMENT, + // The contract version these declarations are shaped by. A reader looking at + // a stale manifest needs to know which API's rules produced it. + moduleApiVersion: MODULE_API_VERSION, + triggers, + } +} + +function main() { + const check = process.argv.includes('--check') + const next = `${JSON.stringify(build(), null, 2)}\n` + + if (!check) { + fs.writeFileSync(MANIFEST_PATH, next) + process.stdout.write(`wrote ${path.relative(SERVER_ROOT, MANIFEST_PATH)}\n`) + return + } + + const current = fs.existsSync(MANIFEST_PATH) ? fs.readFileSync(MANIFEST_PATH, 'utf8') : '' + if (current === next) { + process.stdout.write('engagement-triggers.json is current\n') + return + } + process.stderr.write( + 'engagement-triggers.json is stale.\n' + + 'A trigger declaration changed without the manifest being regenerated.\n' + + 'Run `npm run engagement:manifest` in website/server and commit the result —\n' + + 'the diff is what a reviewer reads to see the contract change.\n', + ) + process.exitCode = 1 +} + +if (require.main === module) { + main() + // The mariadb pool never connects here, but it keeps the loop alive even + // pointed at a dead port — the same exit routeManifest.js takes. + db.close().finally(() => process.exit(process.exitCode || 0)) +} + +module.exports = { build } diff --git a/server/src/config/coreTriggers.js b/server/src/config/coreTriggers.js new file mode 100644 index 0000000..8fa2db1 --- /dev/null +++ b/server/src/config/coreTriggers.js @@ -0,0 +1,147 @@ +// ── Core's own engagement triggers ───────────────────────────────────────── +// +// ENGAGEMENT.md §4.3 and Phase 2. The twin of config/coreStreams.js, and +// deliberately the SAME FIVE IDS — that is the org lead's §7.2 decision, taken at +// the start of this phase: **one namespace.** A trigger is not a second thing +// standing next to a stream; it is a payload contract attached to an id that may +// also carry a subscription toggle. `news.post` names one event, whether the +// question being asked of it is "may I push this?" or "what may a template +// interpolate?". +// +// What that buys, concretely: `notification_channel_prefs.stream_id` (§4.5) stays +// single-keyed. Under two namespaces it would have needed a `kind` discriminator +// in its primary key, and `news.post` would have named two different things +// forever. +// +// What it costs is the rule enforced in registries.js: an id has ONE owner across +// both facets, so a module cannot attach a payload contract to another module's +// stream, and core cannot attach one to a module's. Core's five ids below are +// already core's five streams, so all five are the same-owner upgrade case. +// +// **These declare; nothing here emits yet.** Phase 2 is the contract only — the +// Team pipeline keeps its own hardcoded mail until Phase 6 migrates it onto the +// engine, and this file is what it migrates ONTO. Registering the declarations a +// phase early is the same decision registerCore() has always taken: a registry +// whose first real exercise is a module is a registry that has already drifted. +// +// Every variable carries an `example`, and that is required rather than +// decorative (§4.3 property 3). It is what lets the template editor preview and +// test-send without a live game event, which is the reason template systems go +// untested. + +const TRIGGERS = [ + { + id: 'news.post', + label: 'News post published', + description: 'A news / Five-on-Friday / newsletter post was published.', + kind: 'event', + // No subjectKey. The subject of a cooldown here is the USER, not the post — + // "do not mail me about news more than once an hour" is the useful rule, and + // keying it per post would make every cooldown a no-op. Compare the four + // Team triggers below, where the Team genuinely is the subject. + audience: 'subscribers', + ceiling: 'authenticated', + version: 1, + variables: [ + { name: 'title', type: 'string', required: true, example: 'Five on Friday — the Yew invasion', + description: 'The post title.' }, + { name: 'excerpt', type: 'string', required: false, example: 'Four new champion spawns, and the fate of the Yew moongate…', + 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.' }, + ], + }, + + // ── Teams (TEAMS.md Part 6) ───────────────────────────────────────────── + // + // All four ceiling at `members` and not one of them higher. Who may be told + // about a Team event is the access resolver's answer and always has been + // (coreStreams.js says the same thing about the push catalog); the ceiling is + // that rule written where a RULE EDITOR has to obey it too. Without it an + // operator could point a rule at `authenticated` and mail a private Team's + // forum excerpt to the whole site. + { + id: 'team.member.joined', + label: 'Team — new member', + description: 'Someone joined a Team.', + kind: 'event', + subjectKey: 'teamName', + audience: 'members', + ceiling: 'members', + version: 1, + variables: [ + { name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil', + description: 'The Team the event is about. Also the cooldown subject.' }, + { name: 'memberName', type: 'string', required: true, example: 'Darrow', + description: 'Display name of the member who joined.' }, + { name: 'teamUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil', + description: 'Site-relative path to the Team page. Absent when no module supplies a pageUrlTemplate.' }, + ], + }, + { + id: 'team.leadership.changed', + label: 'Team — leadership change', + description: 'Leadership changed in a Team.', + kind: 'event', + subjectKey: 'teamName', + audience: 'members', + ceiling: 'members', + version: 1, + variables: [ + { name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil', + description: 'The Team the event is about. Also the cooldown subject.' }, + { name: 'leaderName', type: 'string', required: true, example: 'Marisol', + description: 'Display name of the new leader.' }, + { name: 'teamUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil', + description: 'Site-relative path to the Team page.' }, + ], + }, + { + id: 'team.forum.post', + label: 'Team — new forum post', + description: 'A new thread or reply in a Team forum.', + kind: 'event', + subjectKey: 'teamName', + audience: 'members', + ceiling: 'members', + version: 1, + variables: [ + { name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil', + description: 'The Team the event is about. Also the cooldown subject.' }, + { name: 'authorName', type: 'string', required: true, example: 'Darrow', + description: 'Display name of the poster.' }, + { name: 'threadTitle', type: 'string', required: true, example: 'Tuesday champ rotation', + description: 'Title of the thread the post belongs to.' }, + { name: 'excerpt', type: 'string', required: false, example: 'Moving the Tuesday run an hour later…', + description: 'Plain-text excerpt of the post body, already stripped of markup.' }, + { name: 'postUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil/forum/412', + description: 'Site-relative path to the post.' }, + ], + }, + { + id: 'team.announcement', + label: 'Team — announcement', + description: 'A leader posted an announcement in a Team.', + kind: 'event', + subjectKey: 'teamName', + audience: 'members', + ceiling: 'members', + version: 1, + variables: [ + { name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil', + description: 'The Team the event is about. Also the cooldown subject.' }, + { name: 'authorName', type: 'string', required: true, example: 'Marisol', + description: 'Display name of the leader who posted.' }, + { name: 'title', type: 'string', required: true, example: 'Siege practice moved to Sunday', + description: 'The announcement title.' }, + { name: 'excerpt', type: 'string', required: false, example: 'We are moving practice to Sunday 8pm…', + description: 'Plain-text excerpt of the announcement body.' }, + { name: 'postUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil/forum/419', + description: 'Site-relative path to the announcement.' }, + ], + }, +] + +module.exports = { TRIGGERS } diff --git a/server/src/modules/ceilings.js b/server/src/modules/ceilings.js new file mode 100644 index 0000000..5367551 --- /dev/null +++ b/server/src/modules/ceilings.js @@ -0,0 +1,107 @@ +// ── Audience ceilings ────────────────────────────────────────────────────── +// +// G24, and the one piece of ENGAGEMENT.md that was named everywhere and defined +// nowhere: §5.1a says a composed segment takes "the narrowest ceiling it +// contains" and §4.3 says a trigger declares "the widest audience a rule may +// ever give it", but neither says what narrower MEANS. This file is that +// answer, settled by the org lead at the start of Phase 2. +// +// **It is a subset lattice, not a size ordering.** The tempting model is a flat +// total order — self < owner < staff < members < authenticated < everyone, +// compared with `<=` — and it is wrong in a way that matters. Under a total +// order a trigger ceilinged at `staff` also permits `owner`, so a rule could +// mail `uo.cheat.detected` to the player who was detected. "Fewer people" is not +// "less exposure"; the question is always WHICH people. +// +// So the order is containment, and it is a TREE: +// +// everyone anyone at all, signed in or not +// └── authenticated any logged-in user +// ├── subscribers logged-in users who opted into this id +// ├── members a module-declared list (a Team, the governors) +// ├── staff admin / editor / moderator +// └── owner the one user the event is about +// +// The four leaves 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. +// +// Nothing here reaches the database, the network or a user record. It is +// arithmetic over six 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 +// lower bound only when one of them IS the bound. +const PARENT = { + everyone: null, + authenticated: 'everyone', + subscribers: 'authenticated', + members: 'authenticated', + staff: 'authenticated', + owner: 'authenticated', +} + +// Operator-facing text. Lives beside the lattice rather than in the admin client +// so the rule editor and the trigger catalog describe a ceiling the same way. +const LABELS = { + everyone: 'Everyone, including signed-out visitors', + authenticated: 'Any signed-in user', + subscribers: 'Signed-in users subscribed to this event', + members: 'Members of a module-declared list', + staff: 'Staff only', + owner: 'Only the user the event is about', +} + +const CEILINGS = Object.keys(PARENT) + +/** Is this one of the six? The gate every registration and every rule save runs. */ +const isCeiling = (value) => Object.prototype.hasOwnProperty.call(PARENT, value) + +/** + * May `ceiling` reach as widely as `candidate`? + * + * True when `candidate` is `ceiling` itself or sits below it — i.e. walking + * `candidate` up the tree reaches `ceiling`. Everything else is false, including + * every incomparable pair, so this FAILS CLOSED on an id it does not know. + */ +function permits(ceiling, candidate) { + if (!isCeiling(ceiling) || !isCeiling(candidate)) return false + for (let at = candidate; at; at = PARENT[at]) { + if (at === ceiling) return true + } + return false +} + +/** + * The narrower of two ceilings, or `null` when they are incomparable. + * + * This is the greatest lower bound, and in a tree it exists only when one node + * is an ancestor of the other — so `meet('authenticated', 'staff')` is `staff` + * and `meet('staff', 'owner')` is `null`. Returning null is the point: + * §5.1a rule 3 says composition must never widen, and the intuitive + * union-widens implementation is the wrong one. A caller that cannot name a + * bound must refuse the save, not pick a side. + */ +function meet(a, b) { + if (!isCeiling(a) || !isCeiling(b)) return null + if (permits(a, b)) return b + if (permits(b, a)) return a + return null +} + +/** + * Fold `meet` across a whole expression's ceilings. + * + * `A OR B` takes the tighter of the two, and so does `A AND B` — the direction + * of the boolean operator is irrelevant, because the ceiling is a statement + * about what the operator is ALLOWED to reach, not about what it will resolve + * to. An empty list has no bound to state and is null, not `everyone`. + */ +function meetAll(list) { + if (!Array.isArray(list) || !list.length) return null + return list.reduce((acc, next) => (acc === null ? null : meet(acc, next)), list[0]) +} + +module.exports = { CEILINGS, LABELS, isCeiling, permits, meet, meetAll } diff --git a/server/src/modules/loader.js b/server/src/modules/loader.js index 1b6c598..a176f16 100644 --- a/server/src/modules/loader.js +++ b/server/src/modules/loader.js @@ -121,6 +121,7 @@ function buildCtx(id, moduleRoot) { const users = require('../model/users/users.model') const teams = require('../model/teams/teamSync.model') const teamActivity = require('../model/teams/teamActivity.model') + const engagementEmit = require('../utils/engagementEmit') const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit') /* eslint-enable global-require */ @@ -207,6 +208,40 @@ function buildCtx(id, moduleRoot) { ), }, }, + // Engagement (API 1.7.0, ENGAGEMENT.md §5.1). The push half of the trigger + // contract the module registered with `api.registerEventTriggers`. + // + // `id` is bound here and is never taken from the arguments, exactly as + // `teamActivity.push(id, …)` binds its source: a module fires its OWN + // triggers. Without that binding, emit would be a way to fire another + // module's event with a payload of your choosing, and every rule an operator + // wrote against it would fire on that. + // + // Fire-and-forget and returns undefined. `emit()` answers a result its core + // callers want; a module gets nothing back on purpose, because there is + // nothing it could correctly do with a failure from inside a game-event + // handler — and "never throws in production" is only true if there is also + // nothing to await. The dev-time throw is inside `emit`, where the stack + // still points at the module's own call. + events: { + emit: (triggerId, envelope) => { + engagementEmit.emit(id, triggerId, envelope) + }, + }, + // The in-app sink (§5.1) — a module writing the inbox directly, without a + // rule. It is PRESENT AND THROWS until Phase 7 builds the channel and the + // `user_notifications` table behind it. + // + // Present-and-throwing rather than absent is the shape 1.6.0 settled on for + // exactly this situation (`ctx.teams.activity.push` before its phase landed): + // the version number states a whole surface, so a member of 1.7.0 that is + // missing would make the version a lie, and one that silently accepted data + // into a table that does not exist would be the worst of the three. + inbox: { + push: () => { + throw new Error('ctx.inbox.push is not available until the in-app channel lands (ENGAGEMENT.md Phase 7)') + }, + }, // One function, for one caller: the `admin.users.detail` slot router needs // the user its prefix names. Narrowed like `ctx.posts` — the users model // exports creation, role changes and password handling, none of which is a @@ -293,6 +328,24 @@ function buildApi(record) { once('registerSlashCommands') record.staged.registerSlashCommands(commands) }, + // The engagement contract (API 1.7.0, ENGAGEMENT.md §4.3 / §5.1a). Both + // STAGE, like the registries above them, and both take `once` for the same + // reason `registerNotificationStreams` does: a batch is a module's complete + // statement about what it declares, and a second call is a module changing + // its mind halfway through register() rather than adding to it. + // + // A trigger id and a stream id share one namespace (§7.2), so a module that + // calls both may legitimately name the same id in each — that is one event + // with a subscription toggle and a payload contract, and it is the case core + // itself exercises on every boot. + registerEventTriggers(triggers) { + once('registerEventTriggers') + record.staged.registerEventTriggers(triggers) + }, + registerAudiences(audiences) { + once('registerAudiences') + record.staged.registerAudiences(audiences) + }, // The two lifecycle hooks (§2.5). Registered here, dispatched from // lifecycle.js — this file runs with no database and the hooks run with one. // Both are optional: a module with no warm-up and nothing to close simply diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js index 908b580..b35fc0b 100644 --- a/server/src/modules/registries.js +++ b/server/src/modules/registries.js @@ -29,12 +29,28 @@ // mount rule: nothing a module claims takes effect until the module as a whole is // known good. // +// Two more arrived with the engagement system (ENGAGEMENT.md Phase 2), from a +// different workstream but through the same door: +// +// 4. `registerEventTriggers(triggers)` — §4.3. The payload CONTRACT behind an +// event id: what a template may interpolate, and how widely a rule may +// ever send it (the ceiling, G24). +// 5. `registerAudiences(audiences)` — §5.1a. Named sets of user ids a +// module can resolve over its own data, for an operator to point a rule at. +// +// **Triggers and notification streams share ONE id namespace** (the org lead's +// §7.2 decision). A stream entry is a subscription toggle and a trigger is a +// payload contract, so they stay two REGISTRATIONS with two shapes — but an id +// has exactly one owner across both, and `news.post` names one event whichever +// question is being asked of it. See the cross-facet checks in `apply()`. +// // Nothing here reaches the database or the network. It is a require-time-safe // collection of what core and modules have declared, read at request time. const express = require('express') const log = require('../utils/logger')('modules') +const ceilings = require('./ceilings') // ── State ────────────────────────────────────────────────────────────────── @@ -80,6 +96,21 @@ let teamProvider = null // calling `interaction.deferReply()` would be a module holding a Discord handle. const slashCommands = new Map() +// trigger id → { owner, id, label, description, kind, subjectKey, audience, +// ceiling, version, variables } (ENGAGEMENT.md §4.3, API 1.7.0). +// +// A Map rather than an array, unlike `streams`: a stream catalog is READ WHOLE +// (the app renders it in registration order) and a trigger is READ BY ID (the +// emit path, the rule editor, the template editor), so insertion order is kept +// for display and the lookup is the primary access. +const triggers = new Map() + +// audience id → { owner, id, label, description, params, ceiling, resolve } +// (§5.1a). Its own id space, not the trigger/stream one: an audience names a set +// of PEOPLE and a trigger names an EVENT, and `uo.team.members` colliding with a +// trigger of the same name would be a collision between two unrelated things. +const audiences = new Map() + let coreRegistered = false // Stream ids that predate the module system and may not carry their owner's @@ -99,8 +130,17 @@ const LEGACY_STREAM_IDS = { // announce_job_legs.leg and the body of the admin retry endpoint. const LEGACY_LEGS = { uo: ['towncrier'] } -const STREAM_ID = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/ +// ONE grammar for the one namespace streams and triggers share. It relaxes what +// `STREAM_ID` used to allow by admitting `_` inside a segment, because the +// trigger ids this contract is written for have them (`uo.house.idoc_warning`, +// ENGAGEMENT.md §4.3) and two grammars over one namespace would mean an id that +// is legal as a trigger and illegal as the stream it is the same event as. +// Relaxation only: every id valid before is valid now, and no stored id changes. +const EVENT_ID = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/ const LEG_ID = /^[a-z][a-z0-9.]{1,62}$/ +// Audiences are their own id space (see the `audiences` Map), so they get their +// own constant even though the grammar is the same one. +const AUDIENCE_ID = EVENT_ID // A module's claim must carry its id. Core's ids are its own namespace, and the // grandfathered names are the ones that predate all of this. @@ -243,6 +283,68 @@ const slashCommandDefinitions = () => /** One command, handler included. The dispatcher's lookup. */ const slashCommand = (name) => slashCommands.get(name) || null +// ── Event triggers (ENGAGEMENT.md §4.3) ──────────────────────────────────── + +/** Every declaration, core's first, in registration order. The admin catalog. */ +const allTriggers = () => [...triggers.values()] + +/** One declaration, or null. The emit path's lookup and the rule editor's. */ +const eventTrigger = (id) => triggers.get(id) || null + +/** + * Who owns this id, across BOTH facets — the one-namespace question. + * + * A caller asking "may this module emit this?" wants this rather than + * `eventTrigger(id).owner`, because an id can be held as a stream by one owner + * and not yet declared as a trigger by anyone, and that id is still taken. + */ +const eventOwner = (id) => triggers.get(id)?.owner || streamOwners.get(id) || null + +// ── Audiences (§5.1a) ────────────────────────────────────────────────────── + +/** + * Every declaration WITHOUT its resolver — what the admin surface serves. + * + * The resolver is stripped for the same reason a slash command's handler is: + * this is the object that leaves the process, and `resolve` is a function over a + * module's own store that no client has any business holding a reference to. + */ +const allAudiences = () => [...audiences.values()].map(({ resolve, ...rest }) => rest) + +/** One declaration, resolver included. The engine's lookup. */ +const audience = (id) => audiences.get(id) || null + +/** + * Resolve a declared audience to user ids, never throwing. + * + * Three answers, and the middle one is the contract (§5.1a rule 4): a registered + * audience answers `{ dormant: false, userIds }`; an audience whose module is + * uninstalled answers `{ dormant: true, userIds: [] }` — the EMPTY set and a + * flag, never an error and never a fallback to some other set of people; and a + * resolver that throws or answers a non-array is logged and treated as empty, + * because a module's storage problem must not become a send to the wrong people. + * + * `userIds` is filtered to positive integers here rather than trusted. It is the + * one value a module hands core that decides who receives mail, and the resolver + * is module code running over a module's own store. + */ +async function resolveAudience(id, params = {}) { + const entry = audiences.get(id) + if (!entry) return { dormant: true, userIds: [] } + try { + const raw = await entry.resolve(params) + if (!Array.isArray(raw)) { + log.warn('audience resolver did not return an array', { audience: id, owner: entry.owner }) + return { dormant: false, userIds: [] } + } + const userIds = [...new Set(raw.map(Number).filter((n) => Number.isInteger(n) && n > 0))] + return { dormant: false, userIds } + } catch (err) { + log.error('audience resolver failed', { audience: id, owner: entry.owner, message: err.message }) + return { dormant: false, userIds: [] } + } +} + // ── Shape checks, run the moment a registrant calls ──────────────────────── // // Split from the collision checks below on the same line PR 3 drew through @@ -251,7 +353,7 @@ const slashCommand = (name) => slashCommands.get(name) || null // depends on other registrants has to wait for the batch to be complete. function checkStreamShape(entry) { - if (!entry || !STREAM_ID.test(entry.id || '')) { + if (!entry || !EVENT_ID.test(entry.id || '')) { throw new Error(`registerNotificationStreams: bad stream id "${entry && entry.id}"`) } if (!entry.label) throw new Error(`registerNotificationStreams: stream "${entry.id}" has no label`) @@ -448,6 +550,179 @@ function checkPostHookShape(entry) { return { onSaved, onDeleted } } +// ── Event trigger shape (ENGAGEMENT.md §4.3) ─────────────────────────────── + +// Deliberately small, and closed. A payload variable ends up interpolated into +// an email, so the set is "things a template can render and a preview can fake", +// not "things JSON can hold". No `object` and no `array`: a template that has to +// walk a structure is a template that has outgrown interpolation, and a block +// type is the right answer to that (§4.4). +const VARIABLE_TYPES = ['string', 'int', 'float', 'boolean', 'datetime', 'url'] + +// `event` fires from ctx.events.emit; `scheduled` is evaluated periodically and +// has no evaluator yet — the org lead's §7.1 Q6 answer is design now, build after +// Phase 9. It is declarable from today so `kind` is in the contract, the manifest +// and every stored declaration before there are rows to migrate. +const TRIGGER_KINDS = ['event', 'scheduled'] + +const VARIABLE_NAME = /^[a-z][A-Za-z0-9]{0,39}$/ + +function checkTriggerVariable(triggerId, entry, seen) { + const { name, type, required, example, description } = entry || {} + const where = `registerEventTriggers: ${triggerId}` + if (!VARIABLE_NAME.test(name || '')) throw new Error(`${where}: bad variable name "${name}"`) + if (seen.has(name)) throw new Error(`${where}: variable "${name}" declared twice`) + seen.add(name) + if (!VARIABLE_TYPES.includes(type)) { + throw new Error(`${where}: variable "${name}" has unsupported type "${type}"`) + } + // REQUIRED, and the one field of this shape that looks optional and is not + // (§4.3 property 3). Without an example, previewing or test-sending a template + // needs a live game event — which is exactly how template systems come to be + // shipped untested. It is cheap to write at declaration time and impossible to + // reconstruct later. + if (example === undefined || example === null || example === '') { + throw new Error(`${where}: variable "${name}" needs an example (§4.3 — it is the preview)`) + } + return { + name, + type, + required: Boolean(required), + example, + description: description || '', + } +} + +/** + * `registerEventTriggers([{ id, label, kind, subjectKey, audience, ceiling, version, variables }])`. + * + * Everything decidable from the argument alone is decided here, at the call, so + * the error carries the registrant's own stack. The one-namespace collision — is + * this id already someone's stream? — depends on other registrants and waits for + * `apply()`, exactly as a stream's own collision does. + * + * The copy is explicit rather than a spread, like `checkTeamProviderShape`: this + * object is served to the admin UI and frozen into a committed manifest, so + * anything not named here is not part of the contract and must not ride along. + */ +function checkTriggerShape(entry) { + const t = entry || {} + if (!EVENT_ID.test(t.id || '')) { + throw new Error(`registerEventTriggers: bad trigger id "${t.id}"`) + } + if (!t.label) throw new Error(`registerEventTriggers: trigger "${t.id}" has no label`) + + const kind = t.kind || 'event' + if (!TRIGGER_KINDS.includes(kind)) { + throw new Error(`registerEventTriggers: ${t.id} has unknown kind "${t.kind}"`) + } + + // G24. Required with no default — a ceiling that could be forgotten is a + // ceiling that gets forgotten on the one trigger it mattered for, and there is + // no safe value to guess: `owner` would silently break a broadcast and + // `authenticated` would silently widen a staff-only event. + if (!ceilings.isCeiling(t.ceiling)) { + throw new Error( + `registerEventTriggers: ${t.id} needs a ceiling, one of ${ceilings.CEILINGS.join(', ')}`, + ) + } + // The DEFAULT a rule is created with; the ceiling is the maximum it may be + // raised to. Defaulting it to the ceiling is right — a trigger that declares no + // opinion gets the widest it permits, and an operator narrows from there. + const audienceDefault = t.audience || t.ceiling + if (!ceilings.permits(t.ceiling, audienceDefault)) { + throw new Error( + `registerEventTriggers: ${t.id} default audience "${audienceDefault}" is not permitted by ceiling "${t.ceiling}"`, + ) + } + + const version = t.version === undefined ? 1 : t.version + if (!Number.isInteger(version) || version < 1) { + throw new Error(`registerEventTriggers: ${t.id} has a bad version "${t.version}"`) + } + + if (t.variables !== undefined && !Array.isArray(t.variables)) { + throw new Error(`registerEventTriggers: ${t.id} variables must be an array`) + } + const seen = new Set() + const variables = (t.variables || []).map((v) => checkTriggerVariable(t.id, v, seen)) + + // A subjectKey naming a variable that does not exist would produce a cooldown + // keyed on `undefined` — i.e. one cooldown for every subject at once, which + // looks like the feature working until the day two houses share it (§4.1). + if (t.subjectKey !== undefined && !seen.has(t.subjectKey)) { + throw new Error( + `registerEventTriggers: ${t.id} subjectKey "${t.subjectKey}" is not one of its variables`, + ) + } + + return { + id: t.id, + label: t.label, + description: t.description || '', + kind, + subjectKey: t.subjectKey === undefined ? null : t.subjectKey, + audience: audienceDefault, + ceiling: t.ceiling, + version, + variables, + } +} + +// ── Audience shape (§5.1a) ───────────────────────────────────────────────── + +// Two types, and no more. A param is something an operator types into a rule +// editor to point a declared audience at one row of a module's data ("which +// Team?"), so it is an identifier or a word. Anything richer is a query, and a +// query surface is the free-form list building Q7 rules out. +const AUDIENCE_PARAM_TYPES = ['int', 'string'] + +function checkAudienceParam(audienceId, entry, seen) { + const { id, type, required, label } = entry || {} + const where = `registerAudiences: ${audienceId}` + if (!VARIABLE_NAME.test(id || '')) throw new Error(`${where}: bad param id "${id}"`) + if (seen.has(id)) throw new Error(`${where}: param "${id}" declared twice`) + seen.add(id) + if (!AUDIENCE_PARAM_TYPES.includes(type)) { + throw new Error(`${where}: param "${id}" has unsupported type "${type}"`) + } + return { id, type, required: Boolean(required), label: label || id } +} + +/** + * `registerAudiences([{ id, label, description, params, ceiling, resolve }])`. + * + * The resolver returns USER IDS and nothing else (§5.1a rule 2). It is not handed + * a template, a channel or an address and it cannot enumerate them — a module + * still cannot send mail, and this must not become the back door that lets it. + * Core maps ids to addresses on its own side, after preferences, suppression and + * the verification gate. + */ +function checkAudienceShape(entry) { + const a = entry || {} + if (!AUDIENCE_ID.test(a.id || '')) throw new Error(`registerAudiences: bad audience id "${a.id}"`) + if (!a.label) throw new Error(`registerAudiences: audience "${a.id}" has no label`) + if (!ceilings.isCeiling(a.ceiling)) { + throw new Error( + `registerAudiences: ${a.id} needs a ceiling, one of ${ceilings.CEILINGS.join(', ')}`, + ) + } + if (typeof a.resolve !== 'function') throw new Error(`registerAudiences: ${a.id} has no resolve()`) + if (a.params !== undefined && !Array.isArray(a.params)) { + throw new Error(`registerAudiences: ${a.id} params must be an array`) + } + const seen = new Set() + const params = (a.params || []).map((p) => checkAudienceParam(a.id, p, seen)) + return { + id: a.id, + label: a.label, + description: a.description || '', + params, + ceiling: a.ceiling, + resolve: a.resolve, + } +} + // `specFile` is CORE-ONLY and is not on the module-facing signature. A slot's // router reaches the app through declareSlot(), which no static parse of app.js // can follow, so swagger-autogen would silently drop every route in it — the @@ -473,7 +748,15 @@ function checkExtensionShape(slot, router, specFile) { */ function stage(owner) { const staged = { - owner, streams: [], legs: [], extensions: [], postHooks: [], teamProviders: [], slashCommands: [], + owner, + streams: [], + legs: [], + extensions: [], + postHooks: [], + teamProviders: [], + slashCommands: [], + triggers: [], + audiences: [], } return { staged, @@ -497,6 +780,14 @@ function stage(owner) { if (!Array.isArray(entries)) throw new Error('registerSlashCommands: expected an array') for (const e of entries) staged.slashCommands.push(checkSlashCommandShape(e)) }, + registerEventTriggers(entries) { + if (!Array.isArray(entries)) throw new Error('registerEventTriggers: expected an array') + for (const e of entries) staged.triggers.push(checkTriggerShape(e)) + }, + registerAudiences(entries) { + if (!Array.isArray(entries)) throw new Error('registerAudiences: expected an array') + for (const e of entries) staged.audiences.push(checkAudienceShape(e)) + }, } } @@ -517,6 +808,8 @@ function apply({ postHooks: newPostHooks = [], teamProviders: newTeamProviders = [], slashCommands: newSlashCommands = [], + triggers: newTriggers = [], + audiences: newAudiences = [], }) { // ── validate ── const seenStreams = new Set() @@ -524,12 +817,54 @@ function apply({ const held = streamOwners.get(s.id) if (held) throw new Error(`stream "${s.id}" is already registered by "${held}"`) if (seenStreams.has(s.id)) throw new Error(`stream "${s.id}" registered twice`) + // The cross-facet half of the one-namespace rule (§7.2). A stream may share + // its id with a TRIGGER — that is the whole point, `news.post` is one event + // with two facets — but only when the same registrant owns both. Someone + // else's trigger id is taken. + const heldAsTrigger = triggers.get(s.id) + if (heldAsTrigger && heldAsTrigger.owner !== owner) { + throw new Error(`stream "${s.id}" is already registered as an event trigger by "${heldAsTrigger.owner}"`) + } if (!namespaced(owner, s.id, LEGACY_STREAM_IDS)) { throw new Error(`stream "${s.id}" is not namespaced "${owner}."`) } seenStreams.add(s.id) } + // Triggers, against the SAME namespace and the SAME legacy allowlist as + // streams above. Sharing LEGACY_STREAM_IDS is not laziness: under one + // namespace `idoc.warning` is one id, so if `uo` may hold it as a stream + // without the prefix it may hold it as a trigger without the prefix, and any + // other answer would mean the seven grandfathered ids could never gain a + // payload contract. + const seenTriggers = new Set() + for (const t of newTriggers) { + const held = triggers.get(t.id) + if (held) throw new Error(`event trigger "${t.id}" is already registered by "${held.owner}"`) + if (seenTriggers.has(t.id)) throw new Error(`event trigger "${t.id}" registered twice`) + const heldAsStream = streamOwners.get(t.id) + if (heldAsStream && heldAsStream !== owner) { + throw new Error(`event trigger "${t.id}" is already registered as a notification stream by "${heldAsStream}"`) + } + if (!namespaced(owner, t.id, LEGACY_STREAM_IDS)) { + throw new Error(`event trigger "${t.id}" is not namespaced "${owner}."`) + } + seenTriggers.add(t.id) + } + + const seenAudiences = new Set() + for (const a of newAudiences) { + const held = audiences.get(a.id) + if (held) throw new Error(`audience "${a.id}" is already registered by "${held.owner}"`) + if (seenAudiences.has(a.id)) throw new Error(`audience "${a.id}" registered twice`) + // No legacy allowlist — nothing predates audiences, so the prefix rule has no + // exceptions and should never grow one. + if (!namespaced(owner, a.id, {})) { + throw new Error(`audience "${a.id}" is not namespaced "${owner}."`) + } + seenAudiences.add(a.id) + } + const seenLegs = new Set() for (const l of newLegs) { const held = legs.get(l.leg) @@ -584,6 +919,8 @@ function apply({ for (const h of newPostHooks) postHooks.set(owner, h) for (const p of newTeamProviders) teamProvider = { owner, ...p } for (const c of newSlashCommands) slashCommands.set(c.name, { owner, ...c }) + for (const t of newTriggers) triggers.set(t.id, { owner, ...t }) + for (const a of newAudiences) audiences.set(a.id, { owner, ...a }) } // ── Core's own registrations ─────────────────────────────────────────────── @@ -604,12 +941,17 @@ function registerCore() { /* eslint-disable global-require */ const coreStreams = require('../config/coreStreams') + const coreTriggers = require('../config/coreTriggers') const discordLeg = require('../utils/discordAnnounce') /* eslint-enable global-require */ const api = stage('core') api.registerNotificationStreams(coreStreams.STREAMS) api.registerAnnounceLeg(discordLeg.leg) + // The engagement contract (ENGAGEMENT.md Phase 2). Core's five trigger ids ARE + // its five stream ids — the same-owner upgrade the one-namespace rule above is + // written for — so this batch exercises the cross-facet check on every boot. + api.registerEventTriggers(coreTriggers.TRIGGERS) // The three lines that used to follow — the shard stream catalog, the town // crier leg and the `admin.users.detail` filling — were shard CONTENT held @@ -622,6 +964,7 @@ function registerCore() { log.info('core registrations complete', { streams: streams.length, + eventTriggers: triggers.size, announceLegs: legs.size, extensions: [...slots.keys()].filter(slotFilledBy), }) @@ -651,6 +994,8 @@ function _reset() { postHooks.clear() teamProvider = null slashCommands.clear() + triggers.clear() + audiences.clear() coreRegistered = false } @@ -672,6 +1017,14 @@ module.exports = { hasTeamProvider, slashCommandDefinitions, slashCommand, + allTriggers, + eventTrigger, + eventOwner, + allAudiences, + audience, + resolveAudience, + VARIABLE_TYPES, + TRIGGER_KINDS, stage, apply, registerCore, diff --git a/server/src/modules/version.js b/server/src/modules/version.js index 4ff8c60..8dd05bf 100644 --- a/server/src/modules/version.js +++ b/server/src/modules/version.js @@ -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.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 +// `ctx.inbox.push(userId, item)`. module-uo's `coreApi: "^1.3.0"` still resolves. +// +// **As in 1.6.0, the number covers the whole surface and the members arrive by +// phase.** `ctx.inbox.push` is present and THROWS until Phase 7 builds the +// in-app channel and the table behind it — the same choice, for the same reason: +// a member of 1.7.0 that were absent would make the version a lie, and one that +// silently accepted data into a table that does not exist would be worse than +// either. Everything else in 1.7.0 is live. +// +// One thing here is not a member and is still part of the contract: a trigger id +// and a notification-stream id share ONE namespace (ENGAGEMENT.md §7.2, settled +// by the org lead in Phase 2). An id has exactly one owner across both facets, +// so a module cannot attach a payload contract to another module's stream. That +// tightens a rule rather than changing a signature, and nothing registrable +// before this bump becomes unregistrable after it — the id grammar was RELAXED +// in the same change (`_` is now legal inside a segment). +// // 1.6.0 — the Team surface (docs/website/TEAMS.md Part 11). Additions only, so // minor: `api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })`, // `ctx.teams.publish(event)`, `ctx.teams.reconcile({ reason })`, @@ -58,6 +78,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.6.0' +const MODULE_API_VERSION = '1.7.0' module.exports = { MODULE_API_VERSION } diff --git a/server/src/router/v1/admin/engagement.controller.js b/server/src/router/v1/admin/engagement.controller.js new file mode 100644 index 0000000..bd666f5 --- /dev/null +++ b/server/src/router/v1/admin/engagement.controller.js @@ -0,0 +1,54 @@ +// ── Admin: engagement ────────────────────────────────────────────────────── +// +// ENGAGEMENT.md Phase 2, G3 — the event catalog surface the admin UI needs in +// order to enumerate triggers. **Read-only, and entirely from the registries.** +// There is no table behind either route: a trigger is DECLARED in code by core +// or by a module (§4.3), so the catalog is whatever registered on this boot, and +// a module that was uninstalled simply stops appearing. +// +// That is also what makes the answer honest about dormancy later. §7.3's rule is +// that a rule pointing at an unregistered trigger shows as dormant, never as an +// error and never auto-deleted; a catalog served from a table would have to +// decide whether to delete rows on uninstall, and there is no right answer to +// that question. Serving it from the registry means there is no question. +// +// The rule and template editors (Phases 4 and 5) read these two endpoints: the +// variable list is what makes the editor's autocomplete real rather than blind +// interpolation (§4.3 property 2), the `example` on each variable is what makes +// preview and test-send possible without a live game event, and the ceilings are +// what the rule editor has to obey when it offers an audience (G24). + +const registries = require('../../../modules/registries') +const ceilings = require('../../../modules/ceilings') + +// 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 +// client, because the client would be a second copy of a security rule and a +// second copy is a copy that drifts. The server is still the boundary — Phase 4 +// re-checks every rule save against `ceilings.permits` — this is so the editor +// does not offer a choice it knows will be refused. +const ceilingVocabulary = () => + ceilings.CEILINGS.map((id) => ({ + id, + label: ceilings.LABELS[id], + permits: ceilings.CEILINGS.filter((other) => ceilings.permits(id, other)), + })) + +/** GET /api/v1/admin/engagement/triggers */ +exports.listTriggers = (req, res) => { + res.json({ + triggers: registries.allTriggers(), + ceilings: ceilingVocabulary(), + variableTypes: registries.VARIABLE_TYPES, + kinds: registries.TRIGGER_KINDS, + }) +} + +/** GET /api/v1/admin/engagement/audiences */ +exports.listAudiences = (req, res) => { + // `allAudiences()` has already stripped each `resolve`. That stripping is in + // the registry rather than here for the same reason a slash command's handler + // is stripped there: it is the boundary the function must not cross, and a + // second caller must not have to remember. + res.json({ audiences: registries.allAudiences(), ceilings: ceilingVocabulary() }) +} diff --git a/server/src/router/v1/admin/engagement.router.js b/server/src/router/v1/admin/engagement.router.js new file mode 100644 index 0000000..c596a25 --- /dev/null +++ b/server/src/router/v1/admin/engagement.router.js @@ -0,0 +1,47 @@ +// Admin · Engagement — the declared event catalog (ENGAGEMENT.md Phase 2). +// +// Mounted at /api/v1/admin/engagement by admin/index.js, which has already +// applied `noindex, isLoggedIn, staffOnly`. Both routes re-gate to `admin`. +// +// Admin rather than staff-wide, deliberately. Nothing here is writable yet, but +// this is the entry point of the screen that decides who receives mail, and the +// declarations it serves name every variable a template may interpolate. A +// capability is easier to widen later with a reason than to narrow after an +// editor has been using it. +// +// Rules, templates and the send log arrive under this same prefix in Phases 4 +// and 5, which is why the group exists now with two read routes in it. + +const express = require('express') + +const controller = require('./engagement.controller') +const { requireRole } = require('../../../utils/auth') + +const engagementRouter = express.Router() +const adminOnly = requireRole('admin') + +engagementRouter.get( + '/triggers', + // #swagger.tags = ['Admin · Engagement'] + // #swagger.summary = 'List every declared event trigger, with its payload contract and audience ceiling' + // #swagger.description = 'Served from the module registries, not from a table: a trigger is declared in code by core or by an installed module, so this is whatever registered on this boot. Each declaration carries the variables a template may interpolate (with an example per variable, for preview and test-send) and the widest audience a rule may ever give it.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The declared triggers, the audience-ceiling vocabulary, and the variable types', content: { "application/json": { schema: { type: "object", properties: { triggers: { type: "array", items: { type: "object", additionalProperties: true } }, ceilings: { type: "array", items: { type: "object", additionalProperties: true } }, variableTypes: { type: "array", items: { type: "string" } }, kinds: { type: "array", items: { type: "string" } } } } } } } */ + /* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + controller.listTriggers, +) + +engagementRouter.get( + '/audiences', + // #swagger.tags = ['Admin · Engagement'] + // #swagger.summary = 'List every declared audience a rule may be pointed at' + // #swagger.description = 'Module-declared named sets of users, resolved over the module own data. The resolver itself is never served — an audience answers with user ids on the server side only.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The declared audiences and the audience-ceiling vocabulary', content: { "application/json": { schema: { type: "object", properties: { audiences: { type: "array", items: { type: "object", additionalProperties: true } }, ceilings: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */ + /* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + controller.listAudiences, +) + +module.exports = engagementRouter diff --git a/server/src/router/v1/admin/index.js b/server/src/router/v1/admin/index.js index 56d0614..f4bf0e2 100644 --- a/server/src/router/v1/admin/index.js +++ b/server/src/router/v1/admin/index.js @@ -30,6 +30,7 @@ const emailRouter = require('./email.router') const discordBotRouter = require('./discordBot.router') const settingsRouter = require('./settings.router') const modulesRouter = require('./modules.router') +const engagementRouter = require('./engagement.router') const teamsRouter = require('./teams.router') const teamsVoiceRouter = require('./teamsVoice.router') const dashboardRouter = require('./dashboard.router') @@ -79,6 +80,13 @@ adminRouter.use('/settings', settingsRouter) // here alongside the other configuration capabilities, and admin-only per route // rather than at this line, so the gate sits next to what it is guarding. adminRouter.use('/modules', modulesRouter) +// The engagement catalog (ENGAGEMENT.md Phase 2). Read-only for now — the two +// routes serve what core and the installed modules DECLARED, so there is no +// table behind it and nothing to configure yet. Rules, templates and the send log +// land under this same prefix in Phases 4 and 5. Admin-only per route, like +// /modules above and for a related reason: this is the surface that decides who +// the site sends mail to. +adminRouter.use('/engagement', engagementRouter) // Teams. Staff-wide, like /activity: a moderator runs the reserved-name review // queue. The three actions that PUBLISH untrusted game-sourced strings are gated // per request inside the controller, not per route — a moderator may call them, diff --git a/server/src/utils/engagementEmit.js b/server/src/utils/engagementEmit.js new file mode 100644 index 0000000..c756738 --- /dev/null +++ b/server/src/utils/engagementEmit.js @@ -0,0 +1,223 @@ +// ── ctx.events.emit — the validating half of the engagement seam ──────────── +// +// ENGAGEMENT.md §4.3 and §5.2, Phase 2. A registrant fires a declared event with +// a payload; this checks the payload against the declaration and stops there. +// **There is no delivery in this phase** — no rules, no cooldowns, no outbox, no +// mail. Phase 4 replaces the log line at the bottom with the engine call, and +// every validation rule below is already the one it will need. +// +// Landing the contract a phase before the engine is deliberate, and it is the +// same argument registerCore() has always made: a seam whose first real exercise +// is the thing that depends on it is a seam that has already drifted. Phase 6 +// migrates the Team mail onto this, and it should be migrating onto a validator +// that has been running against core's own five triggers since Phase 2. +// +// **Two postures, one switch.** A malformed emit THROWS in development and is +// DROPPED AND LOGGED in production, which is `ctx.teams.activity.push`'s posture +// and it is not a compromise: this is called from inside a game-event handler, +// and a contract problem of core's must not become the module's control flow at +// three in the morning. In development it must be loud, because a payload that +// silently loses a variable is a template that silently renders `undefined`. + +const registries = require('../modules/registries') +const createLogger = require('./logger') + +const log = createLogger('engagement') + +// The same character class `pageUrlTemplate` is validated with (registries.js), +// for the same reason: a `url` variable is a string that ends up in an href. +// Relative only — one leading slash, and the second character may not be +// another, because `//evil.test/x` passes an "is it rooted" check and is a +// PROTOCOL-RELATIVE url that would send a recipient off-site. +const RELATIVE_URL = /^\/(?!\/)[A-Za-z0-9\-._~/?#[\]@!$&'()*+,;=%]*$/ + +// A dedupe key is stored in a VARCHAR(190) (§4.5 user_notifications.dedupe_key), +// so it is bounded here rather than at the insert — a truncated key silently +// collides with a different event, which is the one failure mode dedupe exists +// to prevent. +const DEDUPE_KEY_MAX = 190 + +const isProd = () => process.env.NODE_ENV === 'production' + +/** Coerce and check one declared variable. Returns `{ value }` or `{ error }`. */ +function coerce(variable, raw) { + switch (variable.type) { + case 'string': + return typeof raw === 'string' ? { value: raw } : { error: 'expected a string' } + case 'int': + return Number.isInteger(raw) ? { value: raw } : { error: 'expected an integer' } + case 'float': + return typeof raw === 'number' && Number.isFinite(raw) + ? { value: raw } + : { error: 'expected a finite number' } + case 'boolean': + return typeof raw === 'boolean' ? { value: raw } : { error: 'expected a boolean' } + // Normalised to an ISO string at the boundary, so a template, a manifest + // example and a stored outbox row all hold the same representation of a + // moment. A Date and its ISO string are the same value everywhere downstream + // only if one of them stops existing here. + case 'datetime': { + const d = raw instanceof Date ? raw : new Date(raw) + if (!(d instanceof Date) || Number.isNaN(d.getTime())) return { error: 'expected a date' } + return { value: d.toISOString() } + } + case 'url': + if (typeof raw !== 'string') return { error: 'expected a string' } + return RELATIVE_URL.test(raw) + ? { value: raw } + : { error: 'expected a site-relative path beginning with a single "/"' } + default: + // Unreachable — registerEventTriggers refuses an undeclared type — and it + // fails CLOSED anyway rather than passing an unchecked value through. + return { error: `unsupported type "${variable.type}"` } + } +} + +/** + * Check a payload against a trigger declaration. + * + * Returns `{ ok: true, data }` with a NEW object holding only declared + * variables, or `{ ok: false, errors }` listing every problem rather than the + * first — a module author fixing one emit at a time is a module author making + * six round trips through a game server restart. + * + * Undeclared keys are dropped rather than rejected. They can never be + * interpolated (the editor only offers declared names, §4.3 property 2), so + * refusing the whole emit over one would be strictness with no safety behind it; + * they are named in a debug line so a typo is still findable. + */ +function validatePayload(declaration, raw) { + const input = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {} + const errors = [] + const data = {} + + for (const variable of declaration.variables) { + const present = Object.prototype.hasOwnProperty.call(input, variable.name) + const value = input[variable.name] + if (!present || value === undefined || value === null) { + if (variable.required) errors.push(`${variable.name}: required`) + continue + } + const { value: coerced, error } = coerce(variable, value) + if (error) errors.push(`${variable.name}: ${error}`) + else data[variable.name] = coerced + } + + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + const declared = new Set(declaration.variables.map((v) => v.name)) + const extra = Object.keys(raw).filter((k) => !declared.has(k)) + if (extra.length) log.debug('emit carried undeclared variables', { trigger: declaration.id, extra }) + } + + return errors.length ? { ok: false, errors } : { ok: true, data } +} + +/** + * Emit a declared event. Core's implementation; `ctx.events.emit` wraps it. + * + * `owner` is bound by the CALLER — the loader passes the module's own id and + * core passes `'core'` — and is never taken from the arguments. A module emits + * its own triggers and nothing else: without that, `ctx.events.emit` would be a + * way to fire another module's event with a payload of your choosing, and every + * rule an operator wrote against that trigger would fire on it. + * + * @returns {{ ok: true, event: object } | { ok: false, reason: string }} + */ +function emit(owner, triggerId, envelope = {}) { + const fail = (reason, detail) => { + if (!isProd()) { + const suffix = detail ? ` (${detail})` : '' + throw new Error(`ctx.events.emit: ${reason}${suffix}`) + } + log.warn('emit dropped', { owner, trigger: triggerId, reason, detail }) + return { ok: false, reason } + } + + const declaration = registries.eventTrigger(triggerId) + if (!declaration) { + // Names the holder when the id is taken by the OTHER facet, because under + // one namespace "there is no such trigger" and "that id is a stream nobody + // gave a payload contract to" are different problems with the same symptom. + const heldBy = registries.eventOwner(triggerId) + return fail( + `unknown event trigger "${triggerId}"`, + heldBy ? `the id is registered as a notification stream by "${heldBy}"` : null, + ) + } + if (declaration.owner !== owner) { + return fail(`"${triggerId}" belongs to "${declaration.owner}"`, `emitted by "${owner}"`) + } + // A scheduled trigger is fired by the periodic evaluator, not by a caller + // (§7.1 Q6). There is no evaluator yet, and this is still the right refusal: + // it keeps `kind` meaning something from the day it is declarable. + if (declaration.kind !== 'event') { + return fail(`"${triggerId}" is kind "${declaration.kind}" and is not emitted directly`) + } + + const { subject, data, ownerUserId, dedupeKey, occurredAt } = envelope || {} + + const payload = validatePayload(declaration, data) + if (!payload.ok) return fail(`payload for "${triggerId}" is invalid`, payload.errors.join('; ')) + + // The subject is what a cooldown is keyed on (§4.1): "once per house", not + // "once per user". An explicit `subject` wins; otherwise it is read from the + // variable the declaration named, which is why checkTriggerShape insists that + // variable exists. + let resolvedSubject = null + if (subject !== undefined && subject !== null) { + if (typeof subject !== 'string' && typeof subject !== 'number') { + return fail('subject must be a string or a number') + } + resolvedSubject = String(subject) + } else if (declaration.subjectKey && payload.data[declaration.subjectKey] !== undefined) { + resolvedSubject = String(payload.data[declaration.subjectKey]) + } + + if (ownerUserId !== undefined && ownerUserId !== null) { + if (!Number.isInteger(ownerUserId) || ownerUserId < 1) { + return fail('ownerUserId must be a positive integer') + } + } + + if (dedupeKey !== undefined && dedupeKey !== null) { + if (typeof dedupeKey !== 'string' || !dedupeKey || dedupeKey.length > DEDUPE_KEY_MAX) { + return fail(`dedupeKey must be a string of 1-${DEDUPE_KEY_MAX} characters`) + } + } + + let at = new Date() + if (occurredAt !== undefined && occurredAt !== null) { + const parsed = occurredAt instanceof Date ? occurredAt : new Date(occurredAt) + if (Number.isNaN(parsed.getTime())) return fail('occurredAt is not a date') + at = parsed + } + + const event = { + triggerId, + owner, + version: declaration.version, + subject: resolvedSubject, + ownerUserId: ownerUserId === undefined ? null : ownerUserId, + dedupeKey: dedupeKey === undefined ? null : dedupeKey, + occurredAt: at.toISOString(), + data: payload.data, + } + + // Phase 2 ends here: validated, recorded, and deliberately undelivered. + // + // The values are NOT logged. A payload carries player names, house locations + // and forum excerpts, and an event log that reproduces them is a second copy + // of exactly the content §4.5 was careful to keep out of `engagement_sends` + // (which hashes the address rather than storing it). The keys are enough to + // debug a contract problem, which is what this line is for. + log.info('event emitted', { + trigger: triggerId, + owner, + subject: resolvedSubject, + variables: Object.keys(event.data), + }) + + return { ok: true, event } +} + +module.exports = { emit, validatePayload, RELATIVE_URL, DEDUPE_KEY_MAX } diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 4198015..4d89318 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -1137,6 +1137,128 @@ } } }, + "/api/v1/admin/engagement/audiences": { + "get": { + "tags": [ + "Admin · Engagement" + ], + "summary": "List every declared audience a rule may be pointed at", + "description": "Module-declared named sets of users, resolved over the module own data. The resolver itself is never served — an audience answers with user ids on the server side only.", + "responses": { + "200": { + "description": "The declared audiences and the audience-ceiling vocabulary", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "audiences": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "ceilings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + }, + "403": { + "description": "Not an admin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/engagement/triggers": { + "get": { + "tags": [ + "Admin · Engagement" + ], + "summary": "List every declared event trigger, with its payload contract and audience ceiling", + "description": "Served from the module registries, not from a table: a trigger is declared in code by core or by an installed module, so this is whatever registered on this boot. Each declaration carries the variables a template may interpolate (with an example per variable, for preview and test-send) and the widest audience a rule may ever give it.", + "responses": { + "200": { + "description": "The declared triggers, the audience-ceiling vocabulary, and the variable types", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "triggers": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "ceilings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "variableTypes": { + "type": "array", + "items": { + "type": "string" + } + }, + "kinds": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "403": { + "description": "Not an admin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/admin/invites": { "post": { "tags": [ diff --git a/server/test/engagementCeilings.test.js b/server/test/engagementCeilings.test.js new file mode 100644 index 0000000..a0a58b4 --- /dev/null +++ b/server/test/engagementCeilings.test.js @@ -0,0 +1,86 @@ +// ── The audience ceiling lattice ─────────────────────────────────────────── +// +// ENGAGEMENT.md §5.1a / G24. These are the tests for the security property the +// whole rule model rests on: **composition may narrow, never widen**, and a +// ceiling is about WHICH people rather than how many. +// +// The case worth naming is `staff` vs `owner`. Under the flat total order the +// plan's wording invites — self < owner < staff < members < authenticated < +// everyone — a trigger ceilinged at `staff` also permits `owner`, so a rule +// could mail `uo.cheat.detected` to the player it detected. That is the bug this +// file exists to keep out, so it is asserted explicitly rather than left implied +// by the shape of the table. + +const { test } = require('node:test') +const assert = require('node:assert/strict') + +const ceilings = require('../src/modules/ceilings') + +test('the six ceilings are the vocabulary, and nothing else is', () => { + assert.deepEqual( + [...ceilings.CEILINGS].sort(), + ['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) + assert.equal(ceilings.isCeiling(undefined), false) +}) + +test('everyone permits every ceiling; every ceiling permits itself', () => { + for (const id of ceilings.CEILINGS) { + assert.equal(ceilings.permits('everyone', id), true, `everyone permits ${id}`) + assert.equal(ceilings.permits(id, id), true, `${id} 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) + } + assert.equal(ceilings.permits('authenticated', 'everyone'), false) +}) + +// The one that a flat ordering gets wrong. +test('a staff ceiling does NOT permit owner — fewer people is not less exposure', () => { + assert.equal(ceilings.permits('staff', 'owner'), false) + assert.equal(ceilings.permits('owner', 'staff'), false) + // …and the same for every other pair of leaves, so the property is the tree's + // and not a special case someone wrote for cheat detection. + const leaves = ['subscribers', 'members', 'staff', 'owner'] + for (const a of leaves) { + for (const b of leaves) { + if (a === b) continue + assert.equal(ceilings.permits(a, b), false, `${a} must not permit ${b}`) + } + } +}) + +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) + assert.equal(ceilings.permits('everyone', undefined), false) +}) + +test('A OR B takes the NARROWER of the two ceilings, not the wider', () => { + assert.equal(ceilings.meet('everyone', 'staff'), 'staff') + assert.equal(ceilings.meet('staff', 'everyone'), 'staff') + assert.equal(ceilings.meet('authenticated', 'members'), 'members') + assert.equal(ceilings.meet('members', 'members'), 'members') +}) + +test('incomparable ceilings have no meet — the save is refused, not guessed', () => { + assert.equal(ceilings.meet('staff', 'members'), null) + assert.equal(ceilings.meet('owner', 'subscribers'), null) + assert.equal(ceilings.meet('staff', 'nonsense'), null) +}) + +test('meetAll folds, short-circuits to null, and has no opinion about an empty list', () => { + assert.equal(ceilings.meetAll(['everyone', 'authenticated', 'members']), 'members') + // members ∧ staff is undefined, so the whole composition is. + assert.equal(ceilings.meetAll(['everyone', 'members', 'staff']), null) + assert.equal(ceilings.meetAll(['owner']), 'owner') + // Not 'everyone': an empty composition states no bound, and defaulting it to + // the top would make "no audiences selected" the widest possible rule. + assert.equal(ceilings.meetAll([]), null) + assert.equal(ceilings.meetAll(null), null) +}) diff --git a/server/test/engagementManifest.test.js b/server/test/engagementManifest.test.js new file mode 100644 index 0000000..9743971 --- /dev/null +++ b/server/test/engagementManifest.test.js @@ -0,0 +1,77 @@ +// ── The engagement trigger manifest ──────────────────────────────────────── +// +// ENGAGEMENT.md §4.3 property 4. CI runs `npm run engagement:manifest -- --check` +// and that is the gate; this file is here for the reason +// `checkModuleIdentifiers.test.js` exists — **a check that silently stops +// checking is worse than no check**. So there are two tests: the committed file +// is current, and the generator actually notices a changed declaration. +// +// It also makes a stale manifest fail `npm test`, which is the run a developer +// does before pushing. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, afterEach, after } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('fs') +const path = require('path') + +const { build } = require('../scripts/engagementManifest') +const registries = require('../src/modules/registries') +const db = require('../src/utils/db') + +after(() => db.close()) +afterEach(() => registries._reset()) + +const MANIFEST_PATH = path.join(__dirname, '..', 'engagement-triggers.json') +const serialize = (m) => `${JSON.stringify(m, null, 2)}\n` + +test('the committed manifest matches the declarations in the tree', () => { + const committed = fs.readFileSync(MANIFEST_PATH, 'utf8') + assert.equal( + serialize(build()), + committed, + 'engagement-triggers.json is stale — run `npm run engagement:manifest` and commit the result', + ) +}) + +test('the manifest holds core\'s triggers only, never a module\'s', () => { + // A module ships its own copy in its bundle (MODULE_API.md §6.1a), because + // core never has its sources to analyse. If a module's declarations leaked + // into core's manifest, the file would change depending on which modules + // happened to be installed on the machine that regenerated it. + registries._reset() + const api = registries.stage('uo') + api.registerEventTriggers([{ + id: 'uo.house.idoc_warning', + label: 'House approaching collapse', + ceiling: 'owner', + variables: [{ name: 'house', type: 'string', required: true, example: 'The Silver Anvil' }], + }]) + registries.apply(api.staged) + + const manifest = build() + assert.equal(manifest.triggers.every((t) => t.owner === 'core'), true) + assert.equal(manifest.triggers.some((t) => t.id === 'uo.house.idoc_warning'), false) +}) + +test('and it notices a changed declaration — the check is live', () => { + const before = serialize(build()) + + // Register one more trigger AS CORE, then rebuild. `build()` calls + // registerCore(), which is a no-op once core has registered, so this lands + // beside core's five rather than replacing them. + registries._reset() + const api = registries.stage('core') + api.registerEventTriggers([{ + id: 'core.probe', + label: 'A declaration the committed manifest does not have', + ceiling: 'staff', + variables: [{ name: 'why', type: 'string', required: true, example: 'proving the check works' }], + }]) + registries.apply(api.staged) + + const after_ = serialize(build()) + assert.notEqual(after_, before) + assert.match(after_, /core\.probe/) +}) diff --git a/server/test/engagementTriggers.test.js b/server/test/engagementTriggers.test.js new file mode 100644 index 0000000..63c59c1 --- /dev/null +++ b/server/test/engagementTriggers.test.js @@ -0,0 +1,435 @@ +// ── The trigger registry, the audience registry, and the emit path ───────── +// +// ENGAGEMENT.md Phase 2's acceptance criteria, one test apiece: +// +// • core's triggers appear in GET /admin/engagement/triggers +// • a module registering an un-namespaced trigger or audience fails, with the +// holder named +// • an audience whose module is uninstalled resolves EMPTY and dormant, never +// an error +// • a payload missing a `required` variable throws in dev, is dropped+logged +// in prod +// • engagement-triggers.json diffs zero, and is not a file that silently stops +// checking +// +// …plus the property the org lead's §7.2 decision creates and the plan never had +// to test before: **one namespace**. A trigger id and a stream id are the same +// id, so the interesting cases are the same-owner upgrade (core's five, on every +// boot) and the cross-owner collision (a module reaching for another's). +// +// Point the DB at a closed port BEFORE requiring anything: registries.js reaches +// utils/discordAnnounce, which reaches 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 engagementEmit = require('../src/utils/engagementEmit') +const ctrl = require('../src/router/v1/admin/engagement.controller') +const coreTriggers = require('../src/config/coreTriggers') +const db = require('../src/utils/db') + +after(() => db.close()) + +// Registries are process-global by design (there is one core), so a test that +// registers has to be able to undo it. +beforeEach(() => registries._reset()) +afterEach(() => registries._reset()) + +// NODE_ENV decides throw-vs-drop, and node:test does not set it. Every emit test +// states the posture it is testing rather than inheriting whatever the shell had. +const originalEnv = process.env.NODE_ENV +afterEach(() => { + if (originalEnv === undefined) delete process.env.NODE_ENV + else process.env.NODE_ENV = originalEnv +}) + +function mockRes() { + return { + statusCode: 200, + body: null, + status(c) { this.statusCode = c; return this }, + json(b) { this.body = b; return this }, + } +} + +/** A minimal valid declaration, for the tests that are about one field. */ +const decl = (over = {}) => ({ + id: 'uo.house.idoc_warning', + label: 'House approaching collapse', + ceiling: 'owner', + variables: [ + { name: 'house', type: 'string', required: true, example: 'The Silver Anvil' }, + { name: 'nextStage', type: 'datetime', required: false, example: '2026-08-30T04:00:00Z' }, + ], + ...over, +}) + +/** Register a batch as `owner`, the way the loader's second pass commits one. */ +function register(owner, fn) { + const api = registries.stage(owner) + fn(api) + registries.apply(api.staged) +} + +// ── Core's own declarations ──────────────────────────────────────────────── + +test('core registers its five triggers, and they are the five stream ids', () => { + registries.registerCore() + const triggerIds = registries.allTriggers().map((t) => t.id).sort() + const streamIds = registries.allStreams().map((s) => s.id).sort() + assert.deepEqual(triggerIds, streamIds) + assert.deepEqual(triggerIds, [ + 'news.post', 'team.announcement', 'team.forum.post', + 'team.leadership.changed', 'team.member.joined', + ]) +}) + +test('the four Team triggers ceiling at members — a private forum excerpt cannot be widened', () => { + registries.registerCore() + for (const id of ['team.member.joined', 'team.leadership.changed', 'team.forum.post', 'team.announcement']) { + assert.equal(registries.eventTrigger(id).ceiling, 'members', id) + } + // News is public content, so it may reach every signed-in user — but its + // DEFAULT is still the narrower `subscribers`, because a rule an operator has + // not thought about should not be a newsletter to the whole site. + const news = registries.eventTrigger('news.post') + assert.equal(news.ceiling, 'authenticated') + assert.equal(news.audience, 'subscribers') +}) + +test('every core variable carries an example — the preview and test-send depend on it', () => { + for (const t of coreTriggers.TRIGGERS) { + for (const v of t.variables) { + assert.ok(v.example !== undefined && v.example !== '', `${t.id}.${v.name} has an example`) + } + } +}) + +// ── One namespace (§7.2) ─────────────────────────────────────────────────── + +test('the same owner may hold an id as BOTH a stream and a trigger — that is the upgrade', () => { + register('uo', (api) => { + api.registerNotificationStreams([{ id: 'uo.market.sale', label: 'Vendor sales' }]) + api.registerEventTriggers([decl({ id: 'uo.market.sale', label: 'Vendor sale', ceiling: 'owner' })]) + }) + assert.equal(registries.isValidStream('uo.market.sale'), true) + assert.equal(registries.eventTrigger('uo.market.sale').ceiling, 'owner') + assert.equal(registries.eventOwner('uo.market.sale'), 'uo') +}) + +test('a module cannot attach a payload contract to another owner\'s stream', () => { + register('uo', (api) => { + api.registerNotificationStreams([{ id: 'uo.market.sale', label: 'Vendor sales' }]) + }) + assert.throws( + () => register('rust', (api) => api.registerEventTriggers([decl({ id: 'uo.market.sale' })])), + // The holder is named, and so is the facet it holds it as: under one + // namespace "no such trigger" and "that id is someone's stream" are + // different problems with the same symptom. + /already registered as a notification stream by "uo"/, + ) + assert.equal(registries.eventTrigger('uo.market.sale'), null) +}) + +test('and the collision is symmetric — a stream cannot take another owner\'s trigger id', () => { + register('uo', (api) => api.registerEventTriggers([decl({ id: 'uo.market.sale' })])) + assert.throws( + () => register('rust', (api) => api.registerNotificationStreams([{ id: 'uo.market.sale', label: 'x' }])), + /already registered as an event trigger by "uo"/, + ) +}) + +test('a trigger must be namespaced under its owner, with the seven legacy ids exempt', () => { + assert.throws( + () => register('rust', (api) => api.registerEventTriggers([decl({ id: 'house.collapsed' })])), + /not namespaced "rust\."/, + ) + // The same allowlist streams use, and it has to be the same one: under one + // namespace `idoc.warning` is a single id, so if `uo` may hold it unprefixed + // as a stream it may hold it unprefixed as a trigger. + register('uo', (api) => api.registerEventTriggers([decl({ id: 'idoc.warning' })])) + assert.equal(registries.eventTrigger('idoc.warning').owner, 'uo') +}) + +test('an id with an underscore is legal — the grammar was relaxed, not replaced', () => { + register('uo', (api) => api.registerEventTriggers([decl({ id: 'uo.house.idoc_warning' })])) + assert.ok(registries.eventTrigger('uo.house.idoc_warning')) + assert.throws( + () => register('uo', (api) => api.registerEventTriggers([decl({ id: 'uo.House.Warning' })])), + /bad trigger id/, + ) +}) + +test('nothing commits when a later claim in the same batch fails', () => { + // A well-formed id that is not the owner's. Shape errors throw at the CALL + // (checkTriggerShape, so the stack points at the module); this one survives to + // apply(), which is where the all-or-nothing rule lives. + assert.throws(() => register('uo', (api) => { + api.registerEventTriggers([decl({ id: 'uo.a.one' }), decl({ id: 'other.thing' })]) + }), /not namespaced/) + assert.equal(registries.eventTrigger('uo.a.one'), null) +}) + +// ── Declaration shape (§4.3) ─────────────────────────────────────────────── + +test('a ceiling is required and has no default — there is no safe value to guess', () => { + assert.throws( + () => register('uo', (api) => api.registerEventTriggers([decl({ ceiling: undefined })])), + /needs a ceiling/, + ) + assert.throws( + () => register('uo', (api) => api.registerEventTriggers([decl({ ceiling: 'everybody' })])), + /needs a ceiling/, + ) +}) + +test('a default audience wider than the ceiling is refused at registration', () => { + assert.throws( + () => register('uo', (api) => api.registerEventTriggers([decl({ ceiling: 'staff', audience: 'everyone' })])), + /is not permitted by ceiling "staff"/, + ) + // Incomparable is refused too, which is the case a total order would allow. + assert.throws( + () => register('uo', (api) => api.registerEventTriggers([decl({ ceiling: 'staff', audience: 'owner' })])), + /is not permitted by ceiling "staff"/, + ) + // Omitted, it defaults to the ceiling itself. + register('uo', (api) => api.registerEventTriggers([decl({ ceiling: 'staff', audience: undefined })])) + assert.equal(registries.eventTrigger('uo.house.idoc_warning').audience, 'staff') +}) + +test('a variable without an example is refused — that is what makes preview possible', () => { + assert.throws( + () => register('uo', (api) => api.registerEventTriggers([ + decl({ variables: [{ name: 'house', type: 'string', required: true }] }), + ])), + /needs an example/, + ) +}) + +test('a subjectKey naming no declared variable is refused', () => { + assert.throws( + () => register('uo', (api) => api.registerEventTriggers([decl({ subjectKey: 'serial' })])), + /subjectKey "serial" is not one of its variables/, + ) + register('uo', (api) => api.registerEventTriggers([decl({ subjectKey: 'house' })])) + assert.equal(registries.eventTrigger('uo.house.idoc_warning').subjectKey, 'house') +}) + +test('kind defaults to event and only the two declared kinds are accepted', () => { + register('uo', (api) => api.registerEventTriggers([ + decl({ id: 'uo.a.one' }), + decl({ id: 'uo.a.two', kind: 'scheduled' }), + ])) + assert.equal(registries.eventTrigger('uo.a.one').kind, 'event') + assert.equal(registries.eventTrigger('uo.a.two').kind, 'scheduled') + assert.throws( + () => register('uo', (api) => api.registerEventTriggers([decl({ id: 'uo.a.three', kind: 'cron' })])), + /unknown kind "cron"/, + ) +}) + +test('a declaration keeps only what the contract names', () => { + register('uo', (api) => api.registerEventTriggers([decl({ handler: () => 'nope', secret: 'x' })])) + const t = registries.eventTrigger('uo.house.idoc_warning') + assert.equal(t.handler, undefined) + assert.equal(t.secret, undefined) + assert.deepEqual(Object.keys(t).sort(), [ + 'audience', 'ceiling', 'description', 'id', 'kind', 'label', 'owner', 'subjectKey', + 'variables', 'version', + ]) +}) + +// ── Audiences (§5.1a) ────────────────────────────────────────────────────── + +const aud = (over = {}) => ({ + id: 'uo.team.members', + label: 'Members of a team', + ceiling: 'members', + params: [{ id: 'teamId', type: 'int', required: true }], + resolve: async () => [4, 9], + ...over, +}) + +test('an audience registers, resolves to user ids, and never leaks its resolver', async () => { + register('uo', (api) => api.registerAudiences([aud()])) + const listed = registries.allAudiences() + assert.equal(listed.length, 1) + assert.equal(listed[0].resolve, undefined) + assert.deepEqual((await registries.resolveAudience('uo.team.members', { teamId: 3 })).userIds, [4, 9]) +}) + +test('an audience whose module is uninstalled is DORMANT and empty, never an error', async () => { + const gone = await registries.resolveAudience('uo.team.members', { teamId: 3 }) + assert.deepEqual(gone, { dormant: true, userIds: [] }) +}) + +test('a resolver that throws or answers rubbish costs an empty set, not a wrong one', async () => { + register('uo', (api) => api.registerAudiences([ + aud({ id: 'uo.a.boom', resolve: async () => { throw new Error('db down') } }), + aud({ id: 'uo.a.junk', resolve: async () => 'everyone' }), + aud({ id: 'uo.a.dirty', resolve: async () => [4, '9', 0, -2, 4, null, 'x'] }), + ])) + assert.deepEqual((await registries.resolveAudience('uo.a.boom')).userIds, []) + assert.deepEqual((await registries.resolveAudience('uo.a.junk')).userIds, []) + // Filtered to positive integers and de-duplicated. This is the one value a + // module hands core that decides who receives mail. + assert.deepEqual((await registries.resolveAudience('uo.a.dirty')).userIds, [4, 9]) + // Not dormant: the module IS installed. Dormant is a different answer from + // "resolved to nobody", and Phase 4's admin UI shows them differently. + assert.equal((await registries.resolveAudience('uo.a.boom')).dormant, false) +}) + +test('an audience needs a ceiling, a resolve, and its owner\'s prefix', () => { + assert.throws(() => register('uo', (api) => api.registerAudiences([aud({ ceiling: undefined })])), /needs a ceiling/) + assert.throws(() => register('uo', (api) => api.registerAudiences([aud({ resolve: undefined })])), /has no resolve\(\)/) + assert.throws(() => register('rust', (api) => api.registerAudiences([aud()])), /not namespaced "rust\."/) +}) + +test('audiences are their own id space — an audience may share a name with a trigger', () => { + register('uo', (api) => { + api.registerEventTriggers([decl({ id: 'uo.team.members' })]) + api.registerAudiences([aud({ id: 'uo.team.members' })]) + }) + assert.ok(registries.eventTrigger('uo.team.members')) + assert.ok(registries.audience('uo.team.members')) +}) + +// ── The emit path (§4.3 property 1) ──────────────────────────────────────── + +const emitOk = () => { + register('uo', (api) => api.registerEventTriggers([decl({ subjectKey: 'house' })])) +} + +test('a valid emit validates, normalises and returns the event', () => { + process.env.NODE_ENV = 'development' + emitOk() + const out = engagementEmit.emit('uo', 'uo.house.idoc_warning', { + data: { house: 'The Silver Anvil', nextStage: '2026-08-30T04:00:00Z' }, + ownerUserId: 7, + }) + assert.equal(out.ok, true) + assert.equal(out.event.subject, 'The Silver Anvil') // derived from subjectKey + assert.equal(out.event.ownerUserId, 7) + assert.equal(out.event.data.nextStage, '2026-08-30T04:00:00.000Z') // normalised + assert.ok(out.event.occurredAt) +}) + +test('a payload missing a required variable throws in dev and is dropped in prod', () => { + emitOk() + process.env.NODE_ENV = 'development' + assert.throws( + () => engagementEmit.emit('uo', 'uo.house.idoc_warning', { data: { nextStage: '2026-08-30T04:00:00Z' } }), + /house: required/, + ) + // Same call, production posture: no throw, and an unmistakable failure result. + // This is called from inside a game-event handler; a contract problem of + // core's must not become the module's control flow. + process.env.NODE_ENV = 'production' + const out = engagementEmit.emit('uo', 'uo.house.idoc_warning', { data: {} }) + assert.equal(out.ok, false) + assert.match(out.reason, /payload for "uo\.house\.idoc_warning" is invalid/) +}) + +test('every payload problem is reported at once, not one per round trip', () => { + process.env.NODE_ENV = 'development' + register('uo', (api) => api.registerEventTriggers([decl({ + variables: [ + { name: 'house', type: 'string', required: true, example: 'x' }, + { name: 'count', type: 'int', required: true, example: 2 }, + { name: 'link', type: 'url', required: true, example: '/a' }, + ], + })])) + assert.throws( + () => engagementEmit.emit('uo', 'uo.house.idoc_warning', { data: { house: 5, count: 1.5, link: 'x' } }), + /house: expected a string; count: expected an integer; link: expected a site-relative path/, + ) +}) + +test('a url variable is relative-only — a protocol-relative path never reaches an href', () => { + process.env.NODE_ENV = 'production' + register('uo', (api) => api.registerEventTriggers([decl({ + variables: [{ name: 'link', type: 'url', required: true, example: '/houses/1' }], + })])) + const bad = (link) => engagementEmit.emit('uo', 'uo.house.idoc_warning', { data: { link } }).ok + assert.equal(bad('//evil.test/x'), false) + assert.equal(bad('https://evil.test/x'), false) + assert.equal(bad('houses/1'), false) + assert.equal(bad('/houses/1?stage=2'), true) +}) + +test('a module cannot emit another owner\'s trigger, nor an unknown one', () => { + process.env.NODE_ENV = 'production' + emitOk() + const foreign = engagementEmit.emit('rust', 'uo.house.idoc_warning', { data: { house: 'x' } }) + assert.equal(foreign.ok, false) + assert.match(foreign.reason, /belongs to "uo"/) + + const unknown = engagementEmit.emit('uo', 'uo.nope.gone', { data: {} }) + assert.equal(unknown.ok, false) + assert.match(unknown.reason, /unknown event trigger/) +}) + +test('a scheduled trigger is not emitted directly — the evaluator fires it (Q6)', () => { + process.env.NODE_ENV = 'production' + register('uo', (api) => api.registerEventTriggers([decl({ kind: 'scheduled' })])) + const out = engagementEmit.emit('uo', 'uo.house.idoc_warning', { data: { house: 'x' } }) + assert.equal(out.ok, false) + assert.match(out.reason, /is kind "scheduled" and is not emitted directly/) +}) + +test('an explicit subject beats the declared subjectKey; envelope fields are bounded', () => { + process.env.NODE_ENV = 'production' + emitOk() + const call = (envelope) => engagementEmit.emit('uo', 'uo.house.idoc_warning', { + data: { house: 'The Silver Anvil' }, ...envelope, + }) + assert.equal(call({ subject: 4141 }).event.subject, '4141') + assert.equal(call({}).event.subject, 'The Silver Anvil') + assert.equal(call({ subject: {} }).ok, false) + assert.equal(call({ ownerUserId: 0 }).ok, false) + assert.equal(call({ ownerUserId: '7' }).ok, false) + assert.equal(call({ dedupeKey: 'x'.repeat(191) }).ok, false) + assert.equal(call({ occurredAt: 'not a date' }).ok, false) + assert.equal(call({ occurredAt: new Date('2026-01-02T03:04:05Z') }).event.occurredAt, '2026-01-02T03:04:05.000Z') +}) + +test('undeclared payload keys are dropped rather than rejected', () => { + process.env.NODE_ENV = 'development' + emitOk() + const out = engagementEmit.emit('uo', 'uo.house.idoc_warning', { + data: { house: 'The Silver Anvil', ownerIp: '10.0.0.4' }, + }) + assert.equal(out.ok, true) + assert.equal(out.event.data.ownerIp, undefined) +}) + +// ── The admin catalog (G3) ───────────────────────────────────────────────── + +test('GET /admin/engagement/triggers serves core\'s declarations and the ceiling vocabulary', () => { + registries.registerCore() + const res = mockRes() + ctrl.listTriggers({}, res) + assert.equal(res.body.triggers.length, 5) + const news = res.body.triggers.find((t) => t.id === 'news.post') + assert.equal(news.owner, 'core') + 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. + const staff = res.body.ceilings.find((c) => c.id === 'staff') + assert.deepEqual(staff.permits, ['staff']) + const everyone = res.body.ceilings.find((c) => c.id === 'everyone') + assert.equal(everyone.permits.length, 6) +}) + +test('GET /admin/engagement/audiences never serves a resolver', () => { + register('uo', (api) => api.registerAudiences([aud()])) + const res = mockRes() + ctrl.listAudiences({}, res) + assert.equal(res.body.audiences.length, 1) + assert.equal(res.body.audiences[0].resolve, undefined) + assert.equal(res.body.audiences[0].ceiling, 'members') +}) diff --git a/server/test/moduleLoader.test.js b/server/test/moduleLoader.test.js index 1ad1f78..90b5b7b 100644 --- a/server/test/moduleLoader.test.js +++ b/server/test/moduleLoader.test.js @@ -511,8 +511,15 @@ test('ctx exposes exactly the documented surface, and is frozen', () => { // (TEAMS.md §2.3). Read-only by omission: a module answers questions about // Teams and never asks them, so there is no getter here to add later by // accident. + // API 1.7.0 added `events` and `inbox` (ENGAGEMENT.md Phase 2), and they are + // two members rather than one on purpose: `events.emit` fires a DECLARED event + // for the engine to decide the consequence of, and `inbox.push` writes a + // user's in-app inbox with no rule in between. `inbox.push` is present and + // throws until Phase 7 builds the channel — which is why it has to be in this + // list now: a member of a declared version that were absent would make + // MODULE_API_VERSION a lie, and this test is what says so. assert.deepEqual(probe.keys, [ - 'activity', 'auth', 'db', 'express', 'log', 'middleware', 'moduleId', 'paths', + 'activity', 'auth', 'db', 'events', 'express', 'inbox', 'log', 'middleware', 'moduleId', 'paths', 'posts', 'push', 'secretBox', 'settings', 'site', 'teams', 'uploads', 'users', 'validator', ]) // is core's limiter FACTORY, not a limiter: a module states its own -- 2.49.1 From b13ffd584f3fed59845486d8c18fae8fd1196c71 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 29 Aug 2026 07:08:17 -0500 Subject: [PATCH 07/20] feat(notifications): per-channel preferences and the delivery-channel registry (engagement Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `notification_subscriptions` answers one question — which streams a user wants PUSHED — because that is the only question the shipped Android client can ask. This adds the general one: which subscribable ids, on which channel, in which mode. The old table becomes the push projection of the new one and keeps its exact wire shape, so the shipped APK needs no update and no delivery path is touched. What lands: - `engagement/channels.js` — `registerDeliveryChannel` (ENGAGEMENT.md §3.1), the declarative half only: id, label, `carriesContent`, `defaultMode`, `supportsDigest`. `addressFor`/`render`/`deliver` wait for Phases 6 and 7, for the reason `transports/index.js` deferred this file at all. `coreChannels.js` declares push / email / inapp through the subsystem's one door. - `notification_channel_prefs` + a replay-safe `INSERT IGNORE … SELECT` backfill, copying the `announce_jobs → announce_job_legs` precedent. - `GET · PUT /auth/me/notifications/channels`. The PUT is SPARSE — only the `(id, channel)` pairs named are written — deliberately unlike the two whole-set PUTs beside it. `off` is a mode rather than an omission, so this endpoint has no empty-array case and the kotlinx DTO gotcha cannot arise here. Three decisions the org lead settled before any code, and one corrects the phase's own acceptance criterion: push's `defaultMode` is `off`, not `instant`. The plan borrowed "push is opt-OUT" from `team_notification_prefs`, where no row does mean notified — but stream subscriptions have never worked that way, so `instant` would have projected the whole catalog into the legacy GET for every existing user and switched every toggle on in the shipped app after an upgrade nobody asked for. A test pins the legacy GET at `{streams:[]}` for a fresh user. One thing not named by the phase, and it is a G24 consequence rather than scope creep: a trigger ceilinged at `staff` can never reach a non-staff user, so offering the toggle would be offering a dead control AND disclosing the event exists — `uo.cheat.detected` would otherwise appear in every player's screen the moment Phase 11 declared it. Filtered from the catalog and gated on write. That gave the `staff` label its first consumer, now written down as `ceilings.STAFF_CEILING_ROLES` (the admin tier's three, deliberately not `teamGrants.STAFF_ROLES`, which answers a different question). 15 new tests; swagger, route manifest and guards regenerated. No web or app surface — those are Phases 7 and 8, where a preference governs something visible. Refs: docs/website/ENGAGEMENT.md Phase 3, §3.1, §4.5 Co-Authored-By: Claude --- server/db/schema.sql | 44 ++ server/routes.guards.json | 20 + server/routes.manifest.json | 8 + server/src/app.js | 9 + server/src/engagement/channels.js | 128 ++++ server/src/engagement/coreChannels.js | 74 +++ server/src/engagement/index.js | 19 +- server/src/engagement/transports/index.js | 9 +- .../notificationChannelPrefs.db.js | 44 ++ .../notificationChannelPrefs.model.js | Bin 0 -> 8766 bytes .../notificationSubs/notificationSubs.db.js | 18 +- .../notificationSubs.model.js | 16 + server/src/modules/ceilings.js | 17 +- .../v1/auth/notifications.controller.js | 36 ++ .../router/v1/auth/notifications.routes.js | 39 ++ server/swagger/swagger-output.json | 611 ++++++++++++++++++ server/swagger/swagger.js | 82 +++ server/test/notificationChannelPrefs.test.js | 368 +++++++++++ 18 files changed, 1530 insertions(+), 12 deletions(-) create mode 100644 server/src/engagement/channels.js create mode 100644 server/src/engagement/coreChannels.js create mode 100644 server/src/model/notificationChannelPrefs/notificationChannelPrefs.db.js create mode 100644 server/src/model/notificationChannelPrefs/notificationChannelPrefs.model.js create mode 100644 server/test/notificationChannelPrefs.test.js diff --git a/server/db/schema.sql b/server/db/schema.sql index bfe0b9f..267a7d5 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -1642,3 +1642,47 @@ UPDATE email_config WHERE refresh_token_enc IS NOT NULL AND credential_enc IS NULL AND status <> 'unconfigured'; + +-- ── Per-channel notification preferences (ENGAGEMENT.md §4.5, Phase 3) ────── +-- +-- G8: `notification_subscriptions` above has no channel dimension. It answers +-- "which streams does this user want pushed", and the shipped Android client's +-- wire shape (`{ streams: [...] }`) is frozen around exactly that question. This +-- table answers the general one — which streams AND triggers, on which channel, +-- in which mode — and the old table becomes its push projection: every write to +-- one fans out to the other (`notificationChannelPrefs.model`). +-- +-- `stream_id` names a stream OR a trigger id, ONE namespace (§7.2, settled in +-- Phase 2). That decision is what keeps this primary key single-keyed: under two +-- namespaces it would have needed a `kind` discriminator, and `news.post` would +-- have meant two different rows forever. +-- +-- **A row exists only where a user has expressed something.** Absence is not +-- "off" — it is "the channel's `defaultMode`", which lives in +-- `src/engagement/channels.js` and nowhere else (§3.1, G9: per-channel defaults +-- differ). All three of core's channels default 'off' today, so absence and off +-- coincide; that is a fact about the current declarations, not about this table, +-- and code must not assume it. The column DEFAULT below is the value a write with +-- no mode takes, not the value a missing row means. +CREATE TABLE IF NOT EXISTS notification_channel_prefs ( + user_id INT NOT NULL, + stream_id VARCHAR(64) NOT NULL, + channel VARCHAR(32) NOT NULL, + mode ENUM('off','instant','digest') NOT NULL DEFAULT 'off', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, stream_id, channel), + CONSTRAINT fk_ncp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_ncp_channel (channel, mode) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Carry the existing push subscriptions across, once. Same shape as the +-- announce_jobs -> announce_job_legs backfill above: an INSERT IGNORE ... SELECT, +-- so replaying this file on every boot is a no-op after the first, and a user who +-- has since turned a stream OFF is not resurrected by the next boot (their row +-- exists with mode 'off', and INSERT IGNORE leaves it alone). +-- +-- 'instant' rather than the column default, because a row in +-- notification_subscriptions IS an opt-in: the user asked to be pushed, and push +-- has no digest mode to be asked into instead. +INSERT IGNORE INTO notification_channel_prefs (user_id, stream_id, channel, mode) + SELECT user_id, stream_id, 'push', 'instant' FROM notification_subscriptions; diff --git a/server/routes.guards.json b/server/routes.guards.json index e89f215..c52051c 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -1434,6 +1434,26 @@ "validate" ] }, + { + "method": "GET", + "path": "/api/v1/auth/me/notifications/channels", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "PUT", + "path": "/api/v1/auth/me/notifications/channels", + "handlers": 6, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, { "method": "GET", "path": "/api/v1/auth/me/notifications/streams", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index 0ca2665..eeff48a 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -569,6 +569,14 @@ "method": "DELETE", "path": "/api/v1/auth/me/devices/:id" }, + { + "method": "GET", + "path": "/api/v1/auth/me/notifications/channels" + }, + { + "method": "PUT", + "path": "/api/v1/auth/me/notifications/channels" + }, { "method": "GET", "path": "/api/v1/auth/me/notifications/streams" diff --git a/server/src/app.js b/server/src/app.js index f03a30a..f7d8e1c 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -192,6 +192,15 @@ app.use('/api', apiRouter) // module's collision checks are asked against what is ALREADY registered, so // core's streams, its announce leg and its extension-slot fill have to be there // before the first module registers anything (MODULE_SYSTEM.md §1.8). +// The engagement subsystem's own door, which is what brings core's mail +// transports and its three delivery channels into existence (ENGAGEMENT.md +// §3.1). Requiring `engagement/channels` or `engagement/transports` directly gets +// the empty registry — populating it is deliberately a side effect of this one +// require, so there is exactly one place either can be registered from. It runs +// beside registerCore() and before the loader for the same reason: a preference +// read or a mail send must never find a half-populated registry. +require('./engagement') + registries.registerCore() modules.load({ public: require('./router/v1/public'), diff --git a/server/src/engagement/channels.js b/server/src/engagement/channels.js new file mode 100644 index 0000000..804f844 --- /dev/null +++ b/server/src/engagement/channels.js @@ -0,0 +1,128 @@ +// ── The delivery-channel registry ────────────────────────────────────────── +// +// ENGAGEMENT.md §3.1, Phase 3. The other half of the axis `transports/index.js` +// splits: a **channel** is what kind of sink this is (email, push, in-app), a +// **transport** is how one channel actually delivers (SMTP, ntfy, FCM). Push has +// had this shape since before anyone named it — `push_devices.transport` is a +// transport column on a channel with one implementation. +// +// **Only the declarative half registers here today**, and that is the whole of +// what Phase 3 needs. `addressFor` / `render` / `deliver` arrive with the phases +// that can exercise them: email in Phase 6, in-app in Phase 7. Declaring a +// function nothing calls freezes a signature before anything has tried to use +// it, which is the reason `transports/index.js` deferred this file at all. +// +// What forced it into Phase 3 rather than Phase 6: `notification_channel_prefs` +// stores a mode only when a user has expressed one, so reading a preference +// means knowing the channel's default — and §3.1 says `defaultMode` is expressed +// **once**. A constant list beside the prefs model would be that expression in a +// second place two phases before the registry replaced it. +// +// Nothing here touches the database, the network or a user record. + +// id → channel definition, in registration order. +const channels = new Map() + +// The three modes a preference can take. `digest` is only offered by a channel +// that declares `supportsDigest` — push and in-app are instant-only in v1, +// because a digest of content-free tickles is not a thing you can batch. +const MODES = ['off', 'instant', 'digest'] + +const isMode = (value) => MODES.includes(value) + +/** + * Register a delivery channel. + * + * Validate-then-commit, the same discipline `registerMailTransport` and + * `modules/registries.js` use: every check runs before the map is touched, so a + * rejected registration leaves nothing behind. + * + * @param {object} def + * @param {string} def.id 'email' | 'push' | 'inapp' | later 'discord.dm' + * @param {string} def.label operator/user-facing name + * @param {boolean} def.carriesContent false for push — the tickle invariant, stated structurally + * @param {string} def.defaultMode the mode that applies with no stored row + * @param {boolean} def.supportsDigest may a preference for this channel be 'digest' + * @param {string} [def.description] one line for the preferences screen + */ +function registerDeliveryChannel(def) { + if (!def || typeof def !== 'object') throw new Error('registerDeliveryChannel: definition required') + const { id, label, carriesContent, defaultMode, supportsDigest } = def + if (typeof id !== 'string' || !/^[a-z][a-z0-9_.-]*$/.test(id)) { + throw new Error(`registerDeliveryChannel: invalid id ${JSON.stringify(id)}`) + } + if (channels.has(id)) throw new Error(`registerDeliveryChannel: ${id} is already registered`) + if (typeof label !== 'string' || !label) throw new Error(`registerDeliveryChannel(${id}): label required`) + if (typeof carriesContent !== 'boolean') { + throw new Error(`registerDeliveryChannel(${id}): carriesContent must be declared explicitly`) + } + if (!isMode(defaultMode)) { + throw new Error(`registerDeliveryChannel(${id}): defaultMode must be one of ${MODES.join(', ')}`) + } + if (typeof supportsDigest !== 'boolean') { + throw new Error(`registerDeliveryChannel(${id}): supportsDigest must be declared explicitly`) + } + // A channel that cannot batch cannot default to batching. Cheap to check, and + // the failure it prevents is a stored 'digest' row no delivery path can honour. + if (defaultMode === 'digest' && !supportsDigest) { + throw new Error(`registerDeliveryChannel(${id}): defaultMode 'digest' needs supportsDigest`) + } + + channels.set(id, { + id, + label, + description: def.description || null, + carriesContent, + defaultMode, + supportsDigest, + }) + return id +} + +/** Every channel, in registration order. The preferences screen's column set. */ +const all = () => [...channels.values()].map((c) => ({ ...c })) + +/** Just the ids. */ +const ids = () => [...channels.keys()] + +/** One channel, or null. Callers must handle null: a stored pref row can name a + * channel that is no longer registered, and that must read as "off", not throw. */ +const get = (id) => { + const c = channels.get(id) + return c ? { ...c } : null +} + +const has = (id) => channels.has(id) + +/** The mode that applies when the user has expressed nothing. An unregistered + * channel is 'off' — never on by accident. */ +const defaultMode = (id) => (channels.get(id) || {}).defaultMode || 'off' + +/** Which modes this channel will accept from a client. */ +const modesFor = (id) => { + const c = channels.get(id) + if (!c) return [] + return c.supportsDigest ? MODES.slice() : MODES.filter((m) => m !== 'digest') +} + +/** Is `mode` a mode this channel accepts? The gate on every preference write. */ +const acceptsMode = (id, mode) => modesFor(id).includes(mode) + +// Test-only: the registry is module-level state. +function _reset() { + channels.clear() +} + +module.exports = { + MODES, + isMode, + registerDeliveryChannel, + all, + ids, + get, + has, + defaultMode, + modesFor, + acceptsMode, + _reset, +} diff --git a/server/src/engagement/coreChannels.js b/server/src/engagement/coreChannels.js new file mode 100644 index 0000000..158c294 --- /dev/null +++ b/server/src/engagement/coreChannels.js @@ -0,0 +1,74 @@ +// ── Core's own delivery channels ─────────────────────────────────────────── +// +// ENGAGEMENT.md §3.1 / Phase 3. All three are core's, and none of them is a game +// concept: a mailbox, a push endpoint and an inbox row are the same three things +// on any shard running this platform. +// +// **They are declared here before two of them can deliver anything**, and that is +// deliberate rather than premature. A preference is a durable user statement; the +// three columns of the preferences screen have to exist from the moment the table +// does, or the first person to open it after Phase 6 finds an email toggle that +// has never had a value and a screen that changed shape under them. Registering +// the metadata early costs nothing — the registry holds no behaviour yet — while +// registering it late means back-filling opinions users were never asked for. +// +// The `defaultMode`s below are the whole of G9: "per-channel defaults differ and +// there is nowhere to express that generically". This is that place. + +const { registerDeliveryChannel } = require('./channels') + +const CHANNELS = [ + { + id: 'push', + label: 'Push', + description: 'A silent tickle to your phone; the app then pulls the real content.', + // The tickle invariant (docs/android/PLAN.md §11), stated structurally rather + // than as a comment: what leaves the server on this channel is { stream, ref } + // and never a message body. Phase 7's `deliver` reads this flag; declaring it + // false here is what makes "push must not carry content" a property of the + // registration instead of a rule each caller has to remember. + carriesContent: false, + // **Opt-IN, and this is the one place the phase's own acceptance line was + // wrong.** ENGAGEMENT.md Phase 3 said a fresh user's push defaults to + // 'instant'; §3.1 called push "opt-OUT", borrowing the semantics of + // `team_notification_prefs` (where no row does mean notified). But push + // STREAM subscriptions have never worked that way: `notification_subscriptions` + // holds a row only when a user opted in, so no row means not subscribed. + // Defaulting to 'instant' here would have projected the entire catalog into + // `GET /auth/me/notifications/subscriptions` for every existing user, and the + // shipped Android client would have shown every toggle switched on after an + // upgrade nobody asked for. Settled by the org lead 2026-08-29: 'off'. + defaultMode: 'off', + // A batched tickle is a contradiction — the content is not in the message, so + // there is nothing to roll up. Ten events are ten wakeups or one; either way + // the app pulls the same inbox. + supportsDigest: false, + }, + { + id: 'email', + label: 'Email', + description: 'A message to your verified address.', + carriesContent: true, + // Opt-IN, per §7.1 Q1: standard marketing-email practice, and the posture + // `team_notification_prefs.email_mode` already takes ('off' by default). + defaultMode: 'off', + supportsDigest: true, + }, + { + id: 'inapp', + label: 'On the site', + description: 'An item in your notification inbox on the website and in the app.', + carriesContent: true, + // Opt-IN like the other two, and for a reason particular to this channel: the + // inbox does not exist until Phase 7. A default of 'instant' would mean every + // user is opted into a surface that has no rows and no screen, and the first + // thing Phase 7 shipped would be a backlog. Whether the inbox is opt-out once + // it is real is a Phase 7 decision with a live surface to look at. + defaultMode: 'off', + supportsDigest: false, + }, +] + +for (const channel of CHANNELS) registerDeliveryChannel(channel) + +module.exports = { CHANNELS } diff --git a/server/src/engagement/index.js b/server/src/engagement/index.js index e64adab..a815d78 100644 --- a/server/src/engagement/index.js +++ b/server/src/engagement/index.js @@ -1,8 +1,11 @@ // ── The engagement subsystem — one door ──────────────────────────────────── // -// ENGAGEMENT.md Phase 1. Today this is the mail transport registry and core's -// own transports; the trigger registry, the rules engine and the delivery -// channels arrive in later phases and hang here too. +// ENGAGEMENT.md Phases 1 and 3. Today this is the mail transport registry, core's +// own transports, and the delivery-channel registry with core's three channels; +// the rules engine and the render/deliver half of a channel arrive in later +// phases and hang here too. (The trigger registry lives in `modules/registries.js` +// instead, because a trigger is something a MODULE declares and modules only ever +// see one registration door.) // // **Core's transports register through the same door a module's would**, and // they register HERE rather than at the bottom of the registry file. That keeps @@ -11,12 +14,14 @@ // `modules/registries.js` (MODULE_API.md §7.6) — and it means requiring the // registry never has the side effect of populating it. // -// Requiring this module is what makes `smtp` available. Everything that resolves -// a transport goes through here, so there is exactly one place a transport can -// come into existence. +// Requiring this module is what makes `smtp` and the three channels available. +// Everything that resolves either goes through here, so there is exactly one +// place a transport or a channel can come into existence. require('./transports/smtp') +require('./coreChannels') const transports = require('./transports') +const channels = require('./channels') -module.exports = { transports } +module.exports = { transports, channels } diff --git a/server/src/engagement/transports/index.js b/server/src/engagement/transports/index.js index 3a7d7b3..74d5d55 100644 --- a/server/src/engagement/transports/index.js +++ b/server/src/engagement/transports/index.js @@ -2,9 +2,12 @@ // // ENGAGEMENT.md §3.1, Phase 1. A **channel** is what kind of sink this is (email, // push, in-app); a **transport** is how one channel actually delivers. This file -// is the second half only. The channel registry arrives with the engine that -// consumes it — registering a channel nothing calls would be a shape frozen -// before anything had tried to use it. +// is the second half only; the channel half is `../channels.js`, which Phase 3 +// added when `notification_channel_prefs` needed a single place for `defaultMode` +// to live. Its render/deliver functions are still deferred to the phases that can +// exercise them, for the reason this comment used to give about the whole file: +// registering a function nothing calls freezes a signature before anything has +// tried to use it. // // What this replaces: `mailer.buildTransport()` had Gmail's host, port and // OAuth2 auth type as literals, so "which provider" was a code edit. Now the diff --git a/server/src/model/notificationChannelPrefs/notificationChannelPrefs.db.js b/server/src/model/notificationChannelPrefs/notificationChannelPrefs.db.js new file mode 100644 index 0000000..9176548 --- /dev/null +++ b/server/src/model/notificationChannelPrefs/notificationChannelPrefs.db.js @@ -0,0 +1,44 @@ +const { query } = require('../../utils/db') + +const listByUser = (userId) => + query( + 'SELECT stream_id, channel, mode FROM notification_channel_prefs WHERE user_id = ? ORDER BY stream_id, channel', + [userId], + ) + +// One (user, stream, channel) row. Upsert rather than insert-or-update in app +// code: the primary key is exactly the triple, so MariaDB decides, and two +// concurrent PUTs from a phone and a browser cannot race into a duplicate-key +// error. +const upsert = (userId, streamId, channel, mode) => + query( + `INSERT INTO notification_channel_prefs (user_id, stream_id, channel, mode) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE mode = VALUES(mode)`, + [userId, streamId, channel, mode], + ) + +// Every push row this user holds that is NOT in `keep`, set to 'off'. The legacy +// whole-set PUT's other half: it says "these streams and no others", and the +// rows it is silent about have to stop meaning 'instant'. +// +// It sets rather than deletes, so a user's explicit "no" survives a later change +// to push's `defaultMode` (§3.1 / channels.js). Deleting would fold "I turned +// this off" back into "I never said", and those are the same thing only for as +// long as the default happens to be 'off'. +async function offPushExcept(userId, keep) { + if (!keep.length) { + return query( + "UPDATE notification_channel_prefs SET mode = 'off' WHERE user_id = ? AND channel = 'push' AND mode <> 'off'", + [userId], + ) + } + const marks = keep.map(() => '?').join(', ') + return query( + `UPDATE notification_channel_prefs SET mode = 'off' + WHERE user_id = ? AND channel = 'push' AND mode <> 'off' AND stream_id NOT IN (${marks})`, + [userId, ...keep], + ) +} + +module.exports = { listByUser, upsert, offPushExcept } diff --git a/server/src/model/notificationChannelPrefs/notificationChannelPrefs.model.js b/server/src/model/notificationChannelPrefs/notificationChannelPrefs.model.js new file mode 100644 index 0000000000000000000000000000000000000000..1aeff0bafc0443f9a1c08709892cbb06c1164d6a GIT binary patch literal 8766 zcmaJ{-E!N;74Eg3;@FcJks1n2ob)GZos44BKdmimBBj0Xc)}&HBoToCg9Su$9L?mi z50Lv_W~PtO%REY+q~CXT7X)S5o-{Rq-LrdszwaDmJeJ>o`5FJnxvh`Wr725WNLe-c zJWoxNSEa0LJGZqhQ|n|fIeU8gbnbubQ@bcsY)w9w$|}3ls35U-@B%^ zX5}QSaIa>WJ87M*Wn;=l&cC~OHhFx1C^MTHJjQ#|2$nRsN>vuyJ&Animic;ZGdV4@ zy2>+2i`+s`YDzKgDuE8ztrzSr#%)0@Uh>4>n^$zuCav?CnH5&@%#H9qXca6xk~}`4 z&?H#u=;(4;TV6LW7f$j-G|X#*{5nN@iA`_I4VMW?0RxMW?qX3CquNI@o!iXP#HL@p}T z3h~kK!gQpw@GEvrO;KEUUk?yr|NgJw!KJ^78w{tTATzVaS-UP6weaI?3w;nuys9+_ zr@XN%=vUWUm{$Jv?D?Z-5}X0;*K5Q?Q(;vir$Jy)_gLcvH--zG*p;v!z|*f%+XeK*D_9}ygr^5Siyk1}j%RING{YDb58%|(nPUdh zAdI_k^FxHcotw6R@gZFftNHv8o<5bj+K7FNV`z5hwn6m3vEe3aU?int zs``49rKz(NvKgQK4EQ_*FauELIHN%Tnyo_>s(!F3U7MG%8n1%XkcK%1jrXd__aOn- ztQl?^t_ag@SO%tTlNWBx6CUTtg(h82;S<9=WqO5b<_in0+Egt9h$o^xLA81tBx0`* zc4%k$TsAf03`=ngExAzrR2M2Ks`{MyR<)E%p)~>q#|Dx6V(O}0_>&8}FzHronXM}r z0H)aqp$t|iL`QjT>YNpjX%$3fc@4wS5~vs0i~pU-(UDJb4HV51z2M)z@-Za8|L1>Q zw0z9A+|TlJfGU98&1Uitw*0ZhnS;Y*4D)qB^2meBIpD-4+3au__Rv|6_xJ5@BslCz z;)A{fpHOuUYxhr2#z``UT5Vz7c(u|>wTQ1DPt^gY2m_yhV zR3skZYKM%3!-In{3?@f1(U3s`hKjc`aPV8etz%Z~U{}lx;i8Zc>w3lVe}d9mThsuC z59ImS@e1Q<1ju<_!!v_mfPlZ;kF-Sm0pLp1zEInDm6;M5{ia}UY+1bpu#Fw71n>BI zdYxNfbXObbcmg$Q94kyB&lPm>_e|yh2m2N%d0T3jNAMf?+YRO2fe@r%1cCs42V9buW<7v| zL~l0m?~#}DP@Wl0NgsN=9Pu&|XPbdb2r-n-KY4Z{`Xu}=47A`Q{mUpkMa2U%(89!b zy`S>aPjYw|*R28TRcRpLo2n`R(KszW8^%Q;(cFu?yt3J8npSPu^mp93pxX|7KMhd= zTmfuti6(iOvskcX2Uzgnc2!mz2R^d#p3p)F!|R_I@h9?{q5dXbFyzmPlxG5I!2-yGTKe5%w^60qTu4 zODUM^3D9GYsF-LKE##&^c_(vVeKA2}NW9H|G~BPOU6Z&0u!vxu5h$!IP^! zJ6XX{k^6Q~b)9!Pp$HWX9#u@jMp&bXe-Bb=iF@6;UZ4#HSuj1a zx<#Ji&yD_DQ%Tc+#@|l^eG;~YG-y7O26vnr11N_42>`EfHa@;Hu5YdB#pL_(*ct0~ zP=-jM!pi?+z3&*J7FjJ`fAucr8%BoGtiVis>4gO#PDu<{CEANJLm^ZSP=h(Jra?SF z+p(&#pcPex=s~#g2XsGH)~dyxG*ad9)%&$PlxBJK?DXtx@*=L-dS2-3b}WnxB9`M& z^iuAP)`BlVs$=FbhEjR>@S%28L#6s%9P~u^YtBPPUYi0iKq-8lbfTFNgS5;4+BR)n z%IhRad@#j*Z<0CaudN3IJ|D`%ucXTZeKf*v0?%-GfJze3^kP}HB!q0#{A<7nRAcW- zhz+U8TkD!U`#QkbD-vX4QVpiyIUxABrjF~lJZ$vz!57kZ9QbqS|BlyfjW$Pd%-rx;g%AJM2tg@v-1V+mdE;%z(^>i8$-9};g46P{0dQG z8tA0V(}Nx!h|1%HCPikX^{u*5EojvV`BZT%I+~#HxBG;Dr~b%v0)FBK@2K^OjmX_ z1Hey@gH3oH^$m_@kAauyViA>RuP((0lm)#A+c2u&9S!0YWqHMN;Hm;jC>7+?1YA+I zX6msAA;H&<1MZ6yQs2R`-tK#R6XXU_0-sp<0o{fM3iZ9jz{Q`uv$u~6=aVmTMT8i9 zQVutO*w8E|^;1 zf;j~i_;pg_N30iefs}#K(Y(NpPTt9D@2}_Cn-l&DH48tqExM5#^rhnilG+?*o zZ~8qL59V8ZK`y%Y0%)u>OZq`mjU*ZdiE#4Y)giUJsj?!lZC-Yc+|2p-fjHvhee zplfR)oB|4KC@pc~^W9@7q;cF)ZxNk7`Otz~YpGnCVm?Bcv_wyY3`aDmXSe5Peu_g* zHF@%6^62vUKPTQTTpP6Lav9|hUBETcmCcLykaiL*U16#hqSe>jrMX6Zs)YO&`f>Ca z;Ii3NjCyzo*#>C7(nLTb7Kd=Yyl?>v=SZf#p@~m?Fn2I>?e@J;3XBFpj>P(+{Ykq* z&!h=*E6(6R1X(XSIsokTHbK>gA*kMq&7EI;#}W+Z&_xE4!**vdOjc-&0Mmrs!8-vu zrz16Ye2>nRo;(L2vJ zV*{t>Vs-ZzMXKX5A>LqUl@Qg6nMCfY`@8J+k?(rHQDR!KG&Di*+SfbF^c*_~a))xF z^`qMj1M^S3a7ZGxAny~57~bEg!ESo#1%ilzZ{*_q^xKOGx?*jLdZ~62C??_<6S+~m z+z^cjI($V&4#yZ_Q*Lup0nkN=9eE(zhdJN}XInOl>2pc;gnyVUOi5qDyNMWwU&NDn zU3qSy_28IyJ^$_!X2(vSG^NcKON=dWF;s+z?TYzW-4rnI9?%V{dLhUb==I>!2Y)*L z^e@K`z8rEG3w3s<%qmPG;B$V90C(|2dZjK(zgMQj5GGD(kz5#>W|1qfA49NF;LM~~ zoK|o0wQ>_UhdGTzuI+KYT30nSfeo&z1|y=kvZyd6G_Y&i5CqS?d6$@eH(DdIDn<5! z7U|3as_9S`N}_GVRA-)BP*d0y9~bm9LuWuLq|?#+DYPdwMHgV4qBk6Hp8{ZYMJZ4~ zFAhu%shY?$&UAI=>rt7}jk-t1qTFzmI~Q7cY7N;~k6S>HU9Fp~I?nU6isNm>N`6l&LPzrqD=f8e& z5Yq8bF==Yo&=&W8{Ozmr^U34Dz`Z`W1U!KN%eoTJ9$)y$p~aqQfBvXbLj+$arBlp~ zuUNI|d4!ag=!XQ5YA}8QgGXI5K)euX0~`n?g!6f9Vip9R)bUHOT1vt<7Yv->C5t+? zYB75v94Xc?OFWJ`LQieOTpR3`DVHG$6p%TxpeV4rHKteUIfh3l^q@Y?QbWgdVTB09 z5E>rt@mFq1d6B!ZMST)X61WE-nelxW$Z$2ju<*OqZi@EtYX3Tva|Y%{E@FKTu6DyW z=;E}oeSxs|Z4B~~({0#VdY^Py=5CR2EcpAyt20cyQ5KN5Y{U`1VcN$}fryOVMR^cB z9Rb5z3vhADBn4^I{~xIGLsWnL3#$C; z>z#UZ?Rn2ocP=DL3J1i-J({Vbwe1>gu<638hg#vnikTRa`~zb%t$jK*3*UNBy8Or( z0f|`%YBz}`qPzPFg1_SfPECOELz9{mR-?(6xgBz#E#eUvB3FI{6G>e670IXRszOfg zJ{vOVI+10TU*}niWZE*y{IoF`v^OP*{DxlB;NSp7%k(gkGyUX|)Rw2+PCkT)3K!9C z)Em?J9VW{@w4A&{=c<8te3&%$NC!#`Prm`8Sw(P-Dm!lJAtB=}hK=+(>f z7n940AA+H*Vv=@5FNDrKpskiY^n!p^e2Ygr?!re(tmcMCS|Zumf4o+kIR-+M!ge}L z4Y+@A*RAh?X$B7KfHKreC!SC_<0VXsD7?25C zY%7TRT#3_rec>vv>#9D730UrEu;;+KU`U@Nz3lSO(JdmOx-}n4p|}|T(FF$WBRN!< zDI0uil3hEAsTWV5yd`?RcLDkzafxY2g3nFLb-<`e077iRa760v*PcEgp5Z?(xH + query('INSERT IGNORE INTO notification_subscriptions (user_id, stream_id) VALUES (?, ?)', [userId, streamId]) + +const removeForUser = (userId, streamId) => + query('DELETE FROM notification_subscriptions WHERE user_id = ? AND stream_id = ?', [userId, streamId]) + +module.exports = { listByUser, replaceForUser, addForUser, removeForUser } diff --git a/server/src/model/notificationSubs/notificationSubs.model.js b/server/src/model/notificationSubs/notificationSubs.model.js index 9311954..af980ba 100644 --- a/server/src/model/notificationSubs/notificationSubs.model.js +++ b/server/src/model/notificationSubs/notificationSubs.model.js @@ -2,6 +2,15 @@ // applied to every device they register). The catalog is core's plus every // installed module's, so it is read back through modules/registries rather than // from a config file (MODULE_SYSTEM.md §1.8). +// +// **This is now the push PROJECTION of `notification_channel_prefs`** +// (ENGAGEMENT.md Phase 3), and it keeps its exact wire shape on purpose: the +// shipped Android client's DTO is `{ streams: [...] }` and cannot be changed from +// this side. So the general table gained the channel dimension and this one stays +// the answer to "which streams does this user want pushed" — the only question +// that client knows how to ask. Every write here fans out to there; every write +// there that touches push fans out to here. What `utils/pushDispatch` reads did +// not change at all, which is what makes this phase touch no delivery path. const db = require('./notificationSubs.db') const { isValidStream } = require('../../modules/registries') @@ -11,9 +20,16 @@ const getForUser = async (userId) => (await db.listByUser(userId)).map((r) => r. // Replace the user's subscription set. Ignores unknown ids and de-dupes, so a // stale client can't create rows for streams that no longer exist. Returns the // stored (cleaned) set. +// +// The mirror is required lazily rather than at the top of the file: the channel +// prefs model requires the registries and the channel registry, and this module +// is required by the router at boot. A cycle here would be a silent half-loaded +// module rather than an error, and there is nothing to gain from the eager form. async function setForUser(userId, streams) { const clean = [...new Set((Array.isArray(streams) ? streams : []).filter(isValidStream))] await db.replaceForUser(userId, clean) + // eslint-disable-next-line global-require + await require('../notificationChannelPrefs/notificationChannelPrefs.model').mirrorPushSet(userId, clean) return clean } diff --git a/server/src/modules/ceilings.js b/server/src/modules/ceilings.js index 5367551..ae5f809 100644 --- a/server/src/modules/ceilings.js +++ b/server/src/modules/ceilings.js @@ -54,6 +54,21 @@ const LABELS = { owner: 'Only the user the event is about', } +// Which roles the `staff` ceiling actually names. The label above has always +// claimed "admin / editor / moderator"; Phase 3 gave that claim a consumer, so it +// is written down once rather than re-derived at each call site. +// +// It matches the admin TIER gate — `requireRole('admin','editor','moderator')` in +// `router/v1/admin/index.js`, and `public.controller`'s own STAFF_ROLES — and NOT +// `teamGrants.STAFF_ROLES`, which is `['admin','moderator']`. The two are +// genuinely different questions: teamGrants asks who may act on a Team they are +// not a member of, and an editor deliberately may not. A ceiling asks who may be +// TOLD, which is the tier gate's population. +const STAFF_CEILING_ROLES = ['admin', 'editor', 'moderator'] + +/** Does this user fall inside the `staff` ceiling? */ +const isStaffRole = (role) => STAFF_CEILING_ROLES.includes(role) + const CEILINGS = Object.keys(PARENT) /** Is this one of the six? The gate every registration and every rule save runs. */ @@ -104,4 +119,4 @@ function meetAll(list) { return list.reduce((acc, next) => (acc === null ? null : meet(acc, next)), list[0]) } -module.exports = { CEILINGS, LABELS, isCeiling, permits, meet, meetAll } +module.exports = { CEILINGS, LABELS, STAFF_CEILING_ROLES, isStaffRole, isCeiling, permits, meet, meetAll } diff --git a/server/src/router/v1/auth/notifications.controller.js b/server/src/router/v1/auth/notifications.controller.js index f2b4889..bee7dad 100644 --- a/server/src/router/v1/auth/notifications.controller.js +++ b/server/src/router/v1/auth/notifications.controller.js @@ -5,6 +5,7 @@ const pushDevices = require('../../../model/pushDevices/pushDevices.model') const notificationSubs = require('../../../model/notificationSubs/notificationSubs.model') +const channelPrefs = require('../../../model/notificationChannelPrefs/notificationChannelPrefs.model') const registries = require('../../../modules/registries') const teamPrefs = require('../../../model/teams/teamNotify.model') const { isAllowedEndpoint } = require('../../../utils/pushDispatch') @@ -79,6 +80,39 @@ async function putSubscriptions(req, res) { } } +// GET /auth/me/notifications/channels — the per-channel preferences surface +// (ENGAGEMENT.md Phase 3): the channel registry's declarative half, plus one item +// per subscribable id with its EFFECTIVE mode on each channel that applies. +// +// The superset of `/notifications/streams` + `/notifications/subscriptions`, +// which stay exactly as they are for the shipped app. +async function getChannelPrefs(req, res) { + try { + return res.json(await channelPrefs.getForUser(req.user.id, req.user)) + } catch (err) { + log.error('getChannelPrefs', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// PUT /auth/me/notifications/channels — apply a SPARSE set of preferences. +// +// Only the (id, channel) pairs in the body are written; every other pair is left +// alone, so a screen that manages one channel need not know about the others. Off +// is a mode, not an omission — which is also why this endpoint has no +// empty-array case to get wrong, unlike its two neighbours. Entries naming an +// unknown id, an inapplicable channel or a mode that channel does not accept are +// dropped by the model; the full stored state is echoed back so the caller can +// see what actually took. +async function putChannelPrefs(req, res) { + try { + return res.json(await channelPrefs.applyForUser(req.user.id, req.body.prefs, req.user)) + } catch (err) { + log.error('putChannelPrefs', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + // GET /auth/me/notifications/teams — this user's per-Team preferences, one row // per Team they could be notified about whether or not they have ever set one. // @@ -119,6 +153,8 @@ module.exports = { getStreams, getSubscriptions, putSubscriptions, + getChannelPrefs, + putChannelPrefs, getTeamPrefs, putTeamPrefs, } diff --git a/server/src/router/v1/auth/notifications.routes.js b/server/src/router/v1/auth/notifications.routes.js index d3b6731..81abffa 100644 --- a/server/src/router/v1/auth/notifications.routes.js +++ b/server/src/router/v1/auth/notifications.routes.js @@ -14,6 +14,7 @@ const { requireAuth } = require('../../../auth/session.middleware') const noindex = require('../../../middleware/noindex') const validate = require('../../../middleware/validate') const { EMAIL_MODES } = require('../../../model/teams/teamNotify.model') +const { MODES } = require('../../../engagement/channels') const notifRouter = express.Router() @@ -98,6 +99,44 @@ notifRouter.put( notif.putSubscriptions, ) +// ── Per-channel preferences (ENGAGEMENT.md §4.5, phase 3) ────────────────── +// +// The channel dimension `notification_subscriptions` lacks. The two endpoints +// above are unchanged and become the push projection of these — the shipped app +// keeps its wire shape, and a newer client manages email and in-app through here. +// +// The PUT is SPARSE, deliberately unlike the two whole-set PUTs either side of +// it: only the pairs named are written. `off` is a mode rather than an omission, +// so there is no "clearing the last entry" case and no empty-array DTO gotcha. +notifRouter.get( + '/notifications/channels', + // #swagger.tags = ['Auth · Me'] + // #swagger.summary = 'Get the current user’s per-channel notification preferences' + // #swagger.description = 'The delivery channels (email, push, in-app) with their defaults, plus one item per subscribable id — every push stream and every event trigger, one namespace — carrying the effective mode on each channel that applies to it. A trigger-only id has no push toggle. Modes not stored are reported as the channel’s default, so a client never has to know which it is looking at.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Per-channel preferences', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationChannelPrefs" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + notif.getChannelPrefs, +) + +notifRouter.put( + '/notifications/channels', + // #swagger.tags = ['Auth · Me'] + // #swagger.summary = 'Update the current user’s per-channel notification preferences' + // #swagger.description = 'A SPARSE update: only the (id, channel) pairs in `prefs` are written and every other pair is left untouched, so setting `email` does not disturb `push`. Entries naming an unknown id, a channel that does not apply to that id, or a mode that channel does not accept are ignored. A `push` entry is mirrored into /notifications/subscriptions. The full stored state is echoed back.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationChannelPrefsUpdate" } } } } */ + /* #swagger.responses[200] = { description: 'Updated preferences', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationChannelPrefs" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + body('prefs').isArray(), + body('prefs.*.id').isString().isLength({ min: 1, max: 64 }), + body('prefs.*.channel').isString().isLength({ min: 1, max: 32 }), + body('prefs.*.mode').isIn(MODES), + validate, + notif.putChannelPrefs, +) + // ── Per-Team preferences (TEAMS.md §6.3, phase 6) ────────────────────────── // // The granularity per-stream opt-in cannot express: "I am in five Teams and want diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 4d89318..59a32ae 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -8672,6 +8672,114 @@ ] } }, + "/api/v1/auth/me/notifications/channels": { + "get": { + "tags": [ + "Auth · Me" + ], + "summary": "Get the current user’s per-channel notification preferences", + "description": "The delivery channels (email, push, in-app) with their defaults, plus one item per subscribable id — every push stream and every event trigger, one namespace — carrying the effective mode on each channel that applies to it. A trigger-only id has no push toggle. Modes not stored are reported as the channel’s default, so a client never has to know which it is looking at.", + "responses": { + "200": { + "description": "Per-channel preferences", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationChannelPrefs" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "tags": [ + "Auth · Me" + ], + "summary": "Update the current user’s per-channel notification preferences", + "description": "A SPARSE update: only the (id, channel) pairs in `prefs` are written and every other pair is left untouched, so setting `email` does not disturb `push`. Entries naming an unknown id, a channel that does not apply to that id, or a mode that channel does not accept are ignored. A `push` entry is mirrored into /notifications/subscriptions. The full stored state is echoed back.", + "responses": { + "200": { + "description": "Updated preferences", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationChannelPrefs" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationChannelPrefsUpdate" + } + } + } + } + } + }, "/api/v1/auth/me/notifications/streams": { "get": { "tags": [ @@ -16503,6 +16611,509 @@ } } }, + "DeliveryChannel": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One delivery channel from the registry (ENGAGEMENT.md §3.1). A channel is what kind of sink this is; a transport is how it delivers." + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "email" + } + } + }, + "label": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Email" + } + } + }, + "description": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "A message to your verified address." + } + } + }, + "carriesContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "False for push, which only ever sends a content-free tickle the client then pulls against." + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "defaultMode": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "off", + "instant", + "digest" + ], + "items": { + "type": "string" + } + }, + "description": { + "type": "string", + "example": "The mode that applies when the user has stored no preference for an id on this channel." + }, + "example": { + "type": "string", + "example": "off" + } + } + }, + "supportsDigest": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "modes": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "off", + "instant", + "digest" + ], + "items": { + "type": "string" + } + } + } + }, + "description": { + "type": "string", + "example": "The modes this channel will accept. Excludes `digest` unless supportsDigest." + }, + "example": { + "type": "array", + "example": [ + "off", + "instant", + "digest" + ], + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "NotificationChannelPrefItem": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One subscribable id — a push stream, an event trigger, or both — with the effective mode on each channel that applies to it." + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "news.post" + } + } + }, + "label": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "News posts" + } + } + }, + "description": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "New news / Five-on-Friday / newsletter posts." + } + } + }, + "personal": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "requiresLinkedAccount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "ceiling": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The trigger’s audience ceiling, or null for an id with no trigger declaration." + }, + "example": { + "type": "string", + "example": "authenticated" + } + } + }, + "channels": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "description": { + "type": "string", + "example": "Which channels apply. A trigger-only id has no `push` — nothing is registered to push it." + }, + "example": { + "type": "array", + "example": [ + "push", + "email", + "inapp" + ], + "items": { + "type": "string" + } + } + } + }, + "modes": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "off", + "instant", + "digest" + ], + "items": { + "type": "string" + } + } + } + }, + "description": { + "type": "string", + "example": "Effective mode per applicable channel: the stored value, or the channel’s default where nothing is stored." + }, + "example": { + "type": "object", + "properties": { + "push": { + "type": "string", + "example": "instant" + }, + "email": { + "type": "string", + "example": "off" + }, + "inapp": { + "type": "string", + "example": "off" + } + } + } + } + } + } + } + } + }, + "NotificationChannelPrefs": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "The per-channel preferences surface: the channel registry plus one item per subscribable id. Returned by both GET and PUT." + }, + "properties": { + "type": "object", + "properties": { + "channels": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/DeliveryChannel" + } + } + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/NotificationChannelPrefItem" + } + } + } + } + } + } + }, + "NotificationChannelPrefsUpdate": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A SPARSE preference update. Only the (id, channel) pairs listed are written; every other pair is left untouched. `off` is a mode, never an omission." + }, + "properties": { + "type": "object", + "properties": { + "prefs": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "news.post" + } + } + }, + "channel": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "email" + } + } + }, + "mode": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "off", + "instant", + "digest" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "digest" + } + } + } + } + } + } + }, + "example": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "news.post" + }, + "channel": { + "type": "string", + "example": "email" + }, + "mode": { + "type": "string", + "example": "digest" + } + } + } + } + } + } + } + } + } + }, "TeamNotificationPref": { "type": "object", "properties": { diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index e806dea..17ef6df 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -634,6 +634,88 @@ const doc = { }, }, }, + DeliveryChannel: { + type: 'object', + description: 'One delivery channel from the registry (ENGAGEMENT.md §3.1). A channel is what kind of sink this is; a transport is how it delivers.', + properties: { + id: { type: 'string', example: 'email' }, + label: { type: 'string', example: 'Email' }, + description: { type: 'string', nullable: true, example: 'A message to your verified address.' }, + carriesContent: { + type: 'boolean', + description: 'False for push, which only ever sends a content-free tickle the client then pulls against.', + example: true, + }, + defaultMode: { + type: 'string', + enum: ['off', 'instant', 'digest'], + description: 'The mode that applies when the user has stored no preference for an id on this channel.', + example: 'off', + }, + supportsDigest: { type: 'boolean', example: true }, + modes: { + type: 'array', + items: { type: 'string', enum: ['off', 'instant', 'digest'] }, + description: 'The modes this channel will accept. Excludes `digest` unless supportsDigest.', + example: ['off', 'instant', 'digest'], + }, + }, + }, + NotificationChannelPrefItem: { + type: 'object', + description: 'One subscribable id — a push stream, an event trigger, or both — with the effective mode on each channel that applies to it.', + properties: { + id: { type: 'string', example: 'news.post' }, + label: { type: 'string', example: 'News posts' }, + description: { type: 'string', example: 'New news / Five-on-Friday / newsletter posts.' }, + personal: { type: 'boolean', example: false }, + requiresLinkedAccount: { type: 'boolean', example: false }, + ceiling: { + type: 'string', + nullable: true, + description: 'The trigger’s audience ceiling, or null for an id with no trigger declaration.', + example: 'authenticated', + }, + channels: { + type: 'array', + items: { type: 'string' }, + description: 'Which channels apply. A trigger-only id has no `push` — nothing is registered to push it.', + example: ['push', 'email', 'inapp'], + }, + modes: { + type: 'object', + additionalProperties: { type: 'string', enum: ['off', 'instant', 'digest'] }, + description: 'Effective mode per applicable channel: the stored value, or the channel’s default where nothing is stored.', + example: { push: 'instant', email: 'off', inapp: 'off' }, + }, + }, + }, + NotificationChannelPrefs: { + type: 'object', + description: 'The per-channel preferences surface: the channel registry plus one item per subscribable id. Returned by both GET and PUT.', + properties: { + channels: { type: 'array', items: { $ref: '#/components/schemas/DeliveryChannel' } }, + items: { type: 'array', items: { $ref: '#/components/schemas/NotificationChannelPrefItem' } }, + }, + }, + NotificationChannelPrefsUpdate: { + type: 'object', + description: 'A SPARSE preference update. Only the (id, channel) pairs listed are written; every other pair is left untouched. `off` is a mode, never an omission.', + properties: { + prefs: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string', example: 'news.post' }, + channel: { type: 'string', example: 'email' }, + mode: { type: 'string', enum: ['off', 'instant', 'digest'], example: 'digest' }, + }, + }, + example: [{ id: 'news.post', channel: 'email', mode: 'digest' }], + }, + }, + }, TeamNotificationPref: { type: 'object', description: "One Team's notification preference for the current user. Absent fields take the stored defaults: push is opt-OUT (not muted) and email is opt-IN (`off`).", diff --git a/server/test/notificationChannelPrefs.test.js b/server/test/notificationChannelPrefs.test.js new file mode 100644 index 0000000..384ad4d --- /dev/null +++ b/server/test/notificationChannelPrefs.test.js @@ -0,0 +1,368 @@ +// ── Per-channel notification preferences (ENGAGEMENT.md Phase 3) ─────────── +// +// The phase's acceptance criteria, one test apiece: +// +// • the shipped Android app's flat `{streams:[…]}` PUT still round-trips, +// INCLUDING the empty-array case its DTO comment warns about +// • a per-channel PUT sets `email` without touching `push` +// • a fresh user's email mode defaults `off`, and so does push +// +// …plus the two properties that make the projection safe to ship: the legacy +// wire shape is pinned BYTE-FOR-BYTE (the app cannot be changed from this side), +// and the invariant the two endpoints jointly maintain — a push pref with mode +// <> 'off' iff a `notification_subscriptions` row — is asserted from both +// directions rather than only from the one the code happens to take. +// +// 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 prefs = require('../src/model/notificationChannelPrefs/notificationChannelPrefs.model') +const prefsDb = require('../src/model/notificationChannelPrefs/notificationChannelPrefs.db') +const subs = require('../src/model/notificationSubs/notificationSubs.model') +const subsDb = require('../src/model/notificationSubs/notificationSubs.db') +const notifCtrl = require('../src/router/v1/auth/notifications.controller') +const db = require('../src/utils/db') + +after(() => db.close()) + +const USER = 7 +const PLAYER = { id: USER, role: 'player' } +const ADMIN = { id: USER, role: 'admin' } + +// ── In-memory stand-ins for the two tables ───────────────────────────────── +// +// Both are stubbed at the `.db` layer, so the model's own fan-out logic — the +// part this phase actually adds — runs for real against them. +let prefRows // Map " " -> mode +let subRows // Set " " + +function installStubs() { + prefRows = new Map() + subRows = new Set() + + prefsDb.listByUser = async (userId) => + [...prefRows.entries()] + .filter(([k]) => k.startsWith(`${userId} `)) + .map(([k, mode]) => { + const [, streamId, channel] = k.split(' ') + return { stream_id: streamId, channel, mode } + }) + .sort((a, b) => a.stream_id.localeCompare(b.stream_id) || a.channel.localeCompare(b.channel)) + + prefsDb.upsert = async (userId, streamId, channel, mode) => { + prefRows.set(`${userId} ${streamId} ${channel}`, mode) + } + + prefsDb.offPushExcept = async (userId, keep) => { + for (const [k, mode] of prefRows.entries()) { + const [u, streamId, channel] = k.split(' ') + if (Number(u) !== userId || channel !== 'push' || mode === 'off') continue + if (!keep.includes(streamId)) prefRows.set(k, 'off') + } + } + + subsDb.listByUser = async (userId) => + [...subRows] + .filter((k) => k.startsWith(`${userId} `)) + .map((k) => ({ stream_id: k.split(' ')[1] })) + .sort((a, b) => a.stream_id.localeCompare(b.stream_id)) + + subsDb.replaceForUser = async (userId, streams) => { + for (const k of [...subRows]) if (k.startsWith(`${userId} `)) subRows.delete(k) + for (const s of streams) subRows.add(`${userId} ${s}`) + } + subsDb.addForUser = async (userId, streamId) => subRows.add(`${userId} ${streamId}`) + subsDb.removeForUser = async (userId, streamId) => subRows.delete(`${userId} ${streamId}`) +} + +// The channel registry is populated by requiring the subsystem's door, exactly +// as app.js does. Requiring `channels` alone gets the empty map — that is the +// design, and doing it the other way here would hide a boot-order regression. +function registerChannels() { + channels._reset() + delete require.cache[require.resolve('../src/engagement/coreChannels')] + // eslint-disable-next-line global-require + require('../src/engagement/coreChannels') +} + +beforeEach(() => { + registries._reset() + registries.registerCore() + registerChannels() + installStubs() +}) + +afterEach(() => { + registries._reset() + channels._reset() +}) + +const mockRes = () => ({ + statusCode: 200, + body: null, + status(c) { this.statusCode = c; return this }, + json(b) { this.body = b; return this }, +}) + +const item = (surface, id) => surface.items.find((i) => i.id === id) + +// ── Acceptance: the shipped app's wire shape ─────────────────────────────── + +test('the legacy subscriptions PUT round-trips byte-for-byte', async () => { + const put = mockRes() + await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: ['news.post', 'team.forum.post'] } }, put) + + // Byte-for-byte: the response is `{ streams: [...] }` and nothing else. The + // Android DTO is frozen, so an extra key is as much a break as a missing one. + assert.deepEqual(Object.keys(put.body), ['streams']) + assert.deepEqual(put.body.streams.slice().sort(), ['news.post', 'team.forum.post']) + + const get = mockRes() + await notifCtrl.getSubscriptions({ user: PLAYER }, get) + assert.deepEqual(Object.keys(get.body), ['streams']) + assert.deepEqual(get.body.streams, ['news.post', 'team.forum.post']) +}) + +test('the empty-array case its DTO comment warns about still clears the set', async () => { + await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: ['news.post'] } }, mockRes()) + + const cleared = mockRes() + await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: [] } }, cleared) + assert.deepEqual(cleared.body, { streams: [] }) + + // And the projection cleared with it — the failure this test exists to catch + // is a channel-prefs row left at 'instant' after the app said "none", which + // would resurrect the subscription the next time anything read the new table. + const surface = await prefs.getForUser(USER, PLAYER) + assert.equal(item(surface, 'news.post').modes.push, 'off') + assert.equal(subRows.size, 0) +}) + +test('unknown stream ids are still dropped, and are not mirrored either', async () => { + const res = mockRes() + await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: ['news.post', 'no.such.stream'] } }, res) + assert.deepEqual(res.body, { streams: ['news.post'] }) + + const surface = await prefs.getForUser(USER, PLAYER) + assert.equal(item(surface, 'no.such.stream'), undefined) + assert.deepEqual([...subRows], [`${USER} news.post`]) +}) + +// ── Acceptance: defaults ─────────────────────────────────────────────────── + +test("a fresh user's modes are the channel defaults, and all three are off", async () => { + const surface = await prefs.getForUser(USER, PLAYER) + const news = item(surface, 'news.post') + + assert.deepEqual(news.modes, { push: 'off', email: 'off', inapp: 'off' }) + assert.equal(prefRows.size, 0, 'reading preferences must not write rows') + + // The acceptance line in ENGAGEMENT.md originally said push defaults + // 'instant'. It cannot: `notification_subscriptions` is opt-IN, so that would + // have projected the whole catalog into the legacy GET for every existing + // user and switched every toggle on in the shipped app. Settled 'off' by the + // org lead; this assertion is what stops it drifting back. + const legacy = mockRes() + await notifCtrl.getSubscriptions({ user: PLAYER }, legacy) + assert.deepEqual(legacy.body, { streams: [] }) +}) + +test('a mode with no stored row reads as the channel default, not as a hardcoded off', async () => { + // Prove the default is READ from the registry rather than assumed: re-register + // `inapp` with a different default and the same fresh user reads it back. + channels._reset() + channels.registerDeliveryChannel({ + id: 'inapp', label: 'On the site', carriesContent: true, defaultMode: 'instant', supportsDigest: false, + }) + + const surface = await prefs.getForUser(USER, PLAYER) + assert.equal(item(surface, 'news.post').modes.inapp, 'instant') +}) + +// ── Acceptance: the sparse per-channel PUT ───────────────────────────────── + +test('a per-channel PUT sets email without touching push', async () => { + await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: ['news.post'] } }, mockRes()) + + const res = mockRes() + await notifCtrl.putChannelPrefs( + { user: PLAYER, body: { prefs: [{ id: 'news.post', channel: 'email', mode: 'digest' }] } }, + res, + ) + + const news = item(res.body, 'news.post') + assert.equal(news.modes.email, 'digest') + assert.equal(news.modes.push, 'instant', 'the push mode must survive an email-only write') + + // …and the legacy endpoint agrees, which is the whole point of the projection. + const legacy = mockRes() + await notifCtrl.getSubscriptions({ user: PLAYER }, legacy) + assert.deepEqual(legacy.body, { streams: ['news.post'] }) +}) + +test('a push write through the channels endpoint fans out to the old table', async () => { + await notifCtrl.putChannelPrefs( + { user: PLAYER, body: { prefs: [{ id: 'team.forum.post', channel: 'push', mode: 'instant' }] } }, + mockRes(), + ) + assert.deepEqual([...subRows], [`${USER} team.forum.post`]) + + await notifCtrl.putChannelPrefs( + { user: PLAYER, body: { prefs: [{ id: 'team.forum.post', channel: 'push', mode: 'off' }] } }, + mockRes(), + ) + assert.deepEqual([...subRows], []) + + // 'off' is STORED, not deleted: it is a statement the user made, and folding it + // back into "never said" is only harmless while push's default happens to be + // off. The two are different the moment that default changes. + assert.equal(prefRows.get(`${USER} team.forum.post push`), 'off') +}) + +test('entries the catalog cannot accept are dropped, not refused', async () => { + const res = mockRes() + await notifCtrl.putChannelPrefs( + { + user: PLAYER, + body: { + prefs: [ + { id: 'no.such.id', channel: 'email', mode: 'instant' }, + { id: 'news.post', channel: 'carrier.pigeon', mode: 'instant' }, + { id: 'news.post', channel: 'push', mode: 'digest' }, // push has no digest + { id: 'news.post', channel: 'email', mode: 'instant' }, // the one good row + ], + }, + }, + res, + ) + + assert.equal(res.statusCode, 200) + assert.equal(item(res.body, 'news.post').modes.email, 'instant') + assert.equal(item(res.body, 'news.post').modes.push, 'off') + assert.equal(prefRows.size, 1, 'only the accepted pair was written') +}) + +test('the last entry wins when a body names the same pair twice', async () => { + const res = mockRes() + await notifCtrl.putChannelPrefs( + { + user: PLAYER, + body: { + prefs: [ + { id: 'news.post', channel: 'email', mode: 'instant' }, + { id: 'news.post', channel: 'email', mode: 'digest' }, + ], + }, + }, + res, + ) + assert.equal(item(res.body, 'news.post').modes.email, 'digest') +}) + +// ── The catalog: one namespace, two facets ───────────────────────────────── + +test('a trigger-only id gets email and in-app, and no push toggle', async () => { + const api = registries.stage('uo') + api.registerEventTriggers([{ + id: 'uo.house.idoc_warning', + label: 'House approaching collapse', + ceiling: 'owner', + variables: [{ name: 'house', type: 'string', required: true, example: 'The Silver Anvil' }], + }]) + registries.apply(api.staged) + + const surface = await prefs.getForUser(USER, PLAYER) + const idoc = item(surface, 'uo.house.idoc_warning') + + assert.ok(idoc, 'a trigger-only id is subscribable') + assert.deepEqual(idoc.channels, ['email', 'inapp']) + assert.equal('push' in idoc.modes, false, 'there is nothing registered to push it') + + // A push entry for it is therefore inapplicable and dropped. + await notifCtrl.putChannelPrefs( + { user: PLAYER, body: { prefs: [{ id: 'uo.house.idoc_warning', channel: 'push', mode: 'instant' }] } }, + mockRes(), + ) + assert.equal(subRows.size, 0) + assert.equal(prefRows.size, 0) +}) + +test('an id that is both a stream and a trigger appears once, with all three channels', async () => { + // Core's five trigger ids ARE its five stream ids — the same-owner upgrade the + // one-namespace rule exists for, exercised on every boot. + const surface = await prefs.getForUser(USER, PLAYER) + const news = surface.items.filter((i) => i.id === 'news.post') + + assert.equal(news.length, 1) + assert.deepEqual(news[0].channels, ['push', 'email', 'inapp']) + assert.equal(news[0].ceiling, 'authenticated', 'the trigger facet supplies the ceiling') +}) + +test('a staff-ceilinged trigger is not offered to a player, and is to staff', async () => { + const api = registries.stage('uo') + api.registerEventTriggers([{ + id: 'uo.cheat.detected', + label: 'Cheat detected', + ceiling: 'staff', + variables: [{ name: 'character', type: 'string', required: true, example: 'Darrow' }], + }]) + registries.apply(api.staged) + + const asPlayer = await prefs.getForUser(USER, PLAYER) + assert.equal(item(asPlayer, 'uo.cheat.detected'), undefined, 'a player is not told it exists') + + const asAdmin = await prefs.getForUser(USER, ADMIN) + assert.ok(item(asAdmin, 'uo.cheat.detected')) + + // And the filter is a gate, not just a display rule: a player who knows the id + // still cannot store a preference for it. + const res = mockRes() + await notifCtrl.putChannelPrefs( + { user: PLAYER, body: { prefs: [{ id: 'uo.cheat.detected', channel: 'email', mode: 'instant' }] } }, + res, + ) + assert.equal(prefRows.size, 0) +}) + +// ── The channel registry itself ──────────────────────────────────────────── + +test('the registry refuses a channel that under-declares', async () => { + channels._reset() + const ok = { id: 'x', label: 'X', carriesContent: true, defaultMode: 'off', supportsDigest: false } + + assert.throws(() => channels.registerDeliveryChannel({ ...ok, carriesContent: undefined }), /carriesContent/) + assert.throws(() => channels.registerDeliveryChannel({ ...ok, supportsDigest: undefined }), /supportsDigest/) + assert.throws(() => channels.registerDeliveryChannel({ ...ok, defaultMode: 'sometimes' }), /defaultMode/) + assert.throws(() => channels.registerDeliveryChannel({ ...ok, id: 'Not An Id' }), /invalid id/) + + // A channel that cannot batch cannot default to batching — the failure this + // prevents is a stored 'digest' row no delivery path can ever honour. + assert.throws( + () => channels.registerDeliveryChannel({ ...ok, defaultMode: 'digest', supportsDigest: false }), + /supportsDigest/, + ) + + channels.registerDeliveryChannel(ok) + assert.throws(() => channels.registerDeliveryChannel(ok), /already registered/) +}) + +test('push is content-free and instant-only, by declaration', async () => { + assert.equal(channels.get('push').carriesContent, false) + assert.deepEqual(channels.modesFor('push'), ['off', 'instant']) + assert.deepEqual(channels.modesFor('email'), ['off', 'instant', 'digest']) +}) + +test('an unregistered channel reads as off and accepts nothing', async () => { + // A stored row can name a channel that is no longer registered (a downgrade). + // It must read as off, never throw and never be on by accident. + assert.equal(channels.defaultMode('discord.dm'), 'off') + assert.deepEqual(channels.modesFor('discord.dm'), []) + assert.equal(channels.acceptsMode('discord.dm', 'instant'), false) +}) -- 2.49.1 From 2079aaf667c610371db6e54ff9e8f85b4f232696 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 29 Aug 2026 08:07:27 -0500 Subject: [PATCH 08/20] feat(engagement): the rules engine, cooldowns and outbox (engagement Phase 4a) Phase 4 of docs/website/ENGAGEMENT.md, split 4a/4b at the org lead's direction. This is 4a: the engine, server only, with no HTTP surface at all. A fired trigger now produces outbox rows and send-log entries; Admin - Engagement - Rules and the segment composition UI are 4b. Five tables (rules, audience segments, cooldowns, outbox, sends), the sweep worker, audience resolution, condition evaluation, the grace window and its cancellation, and the save-path validation 4b's form will call. engagementEmit's Phase 2 log line becomes the engine call. Two settled questions this phase was blocked on: Q2 (multi-instance) - neither SKIP LOCKED nor documented single-instance: the outbox claims each row with a compare-and-set into the 'sending' state the ENUM already carried. It makes the outbox safe for two instances, not the deployment. Q4 (admin surface) - its own top-level nav group, built in 4b. Two defects in the plan's own section 4, both found by building it: The global UNIQUE(dedupe_key) was data loss. A dedupe key names the EVENT, and one event is one row per (rule, user, channel) - so a fifty-person audience would have had one row admitted and forty-nine silently ignored. Scoped. Section 4.1's single INSERT ... ON DUPLICATE KEY UPDATE cooldown claim always passes against this codebase's pool: the mariadb connector defaults foundRows:true, so a no-op update reports affectedRows 1 rather than 0. It is two statements now, with the interval guard in a WHERE clause. The second defect is why there is a second test file. The stubbed suite was green against the broken claim, because a stub can only agree with whoever wrote it; engagementEngineSql.test.js runs the raw statements against a real MariaDB and skips when there is none. Verification: 43 new tests green in engagementEngine.test.js, 12 more against MariaDB 11.8, and the whole path exercised end to end against a live database - per-subject cooldowns, conditions, the CAS claim, the send log's honest failure detail, and dormancy on uninstall. The three pre-existing Windows-only CRLF failures in the generated-artifact tests are unchanged from clean edge. Co-Authored-By: Claude --- server/db/schema.sql | 161 ++++ server/src/engagement/audiences.js | 138 +++ server/src/engagement/conditions.js | 251 +++++ server/src/engagement/engine.js | 241 +++++ server/src/engagement/segments.js | 232 +++++ .../engagement/engagementCooldowns.db.js | 78 ++ .../model/engagement/engagementOutbox.db.js | 158 +++ .../engagement/engagementRecipients.db.js | 125 +++ .../model/engagement/engagementRules.db.js | 127 +++ .../model/engagement/engagementRules.model.js | 225 +++++ .../model/engagement/engagementSegments.db.js | 37 + .../engagement/engagementSegments.model.js | 87 ++ .../model/engagement/engagementSends.db.js | 73 ++ server/src/server.js | 6 + server/src/utils/engagementEmit.js | 35 +- server/src/utils/engagementWorker.js | 168 ++++ server/test/engagementEngine.test.js | 908 ++++++++++++++++++ server/test/engagementEngineSql.test.js | 283 ++++++ 18 files changed, 3322 insertions(+), 11 deletions(-) create mode 100644 server/src/engagement/audiences.js create mode 100644 server/src/engagement/conditions.js create mode 100644 server/src/engagement/engine.js create mode 100644 server/src/engagement/segments.js create mode 100644 server/src/model/engagement/engagementCooldowns.db.js create mode 100644 server/src/model/engagement/engagementOutbox.db.js create mode 100644 server/src/model/engagement/engagementRecipients.db.js create mode 100644 server/src/model/engagement/engagementRules.db.js create mode 100644 server/src/model/engagement/engagementRules.model.js create mode 100644 server/src/model/engagement/engagementSegments.db.js create mode 100644 server/src/model/engagement/engagementSegments.model.js create mode 100644 server/src/model/engagement/engagementSends.db.js create mode 100644 server/src/utils/engagementWorker.js create mode 100644 server/test/engagementEngine.test.js create mode 100644 server/test/engagementEngineSql.test.js diff --git a/server/db/schema.sql b/server/db/schema.sql index 267a7d5..0b6763a 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -1686,3 +1686,164 @@ CREATE TABLE IF NOT EXISTS notification_channel_prefs ( -- has no digest mode to be asked into instead. INSERT IGNORE INTO notification_channel_prefs (user_id, stream_id, channel, mode) SELECT user_id, stream_id, 'push', 'instant' FROM notification_subscriptions; + +-- ── The engagement engine (ENGAGEMENT.md §4.1, §4.2a, §4.5 — Phase 4a) ────── +-- +-- Five tables and no delivery. A rule says "when this trigger fires, for these +-- people, on these channels, no more often than this"; the outbox is the queue +-- the grace window needs; the cooldown table is what makes "once per house" mean +-- once per house; and the send log is the first answer this deployment has ever +-- had to "did user X get the mail?". +-- +-- Nothing here sends anything. Core seeds no rules and `enabled` defaults to 0, +-- so on a real deployment these five tables stay empty until an operator turns a +-- rule on from the screen Phase 4b builds. + +-- What an operator actually configures: trigger -> audience -> template -> timing. +-- +-- `trigger_id` deliberately has NO foreign key and no existence check: a trigger +-- is DECLARED IN CODE (§4.3), so the set of them is whatever registered on this +-- boot. A rule naming a trigger no module currently registers is DORMANT — it is +-- listed, it never fires, and it starts working again when the module comes back +-- (§7.3). Deleting it on uninstall would silently destroy an operator's +-- configuration on the strength of a module being temporarily absent. +CREATE TABLE IF NOT EXISTS engagement_rules ( + id INT AUTO_INCREMENT PRIMARY KEY, + trigger_id VARCHAR(96) NOT NULL, + name VARCHAR(160) NOT NULL, + -- OFF by default (§7.1 Q3). A rule arrives inert and an operator turns it on, + -- so no import, seed or restore can start mailing on its own. + enabled TINYINT(1) NOT NULL DEFAULT 0, + audience VARCHAR(32) NOT NULL DEFAULT 'owner', + audience_segment_id INT NULL, + -- §7.1 Q3: the hard stop that makes operator-editable rules safe to choose over + -- code-registered ones. Counted in engagement_sends, enforced before the outbox + -- row is written, never overridable from the rule editor beyond this column. + max_sends_per_hour INT NOT NULL DEFAULT 100, + channels JSON NOT NULL, + template_keys JSON NOT NULL, + conditions JSON NULL, + cooldown_seconds INT NOT NULL DEFAULT 0, + delay_seconds INT NOT NULL DEFAULT 0, + cancel_on JSON NULL, + updated_by INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_engr_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_engr_trigger (trigger_id, enabled) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- §5.1a: an operator-composed segment over module-declared audiences. Stored as a +-- boolean tree of audience ids + params; `ceiling` is DERIVED at save time as the +-- NARROWEST ceiling in the tree (ceilings.meetAll) and re-checked against the +-- trigger's own ceiling, so composition can never widen. It is a column rather +-- than a runtime computation so an audit can read what a rule was allowed to +-- reach without re-resolving it — and so a module that has since changed its +-- audience's ceiling cannot retroactively widen a saved segment. +-- +-- `engagement_rules.audience_segment_id` above points here with NO foreign key, +-- on purpose and for the same reason `trigger_id` has none: a rule whose segment +-- has been deleted must go DORMANT, not silently fall back to its plain +-- `audience` column. ON DELETE SET NULL would be exactly that silent fallback, +-- and the fallback reaches a DIFFERENT set of people (§5.1a rule 4). +CREATE TABLE IF NOT EXISTS engagement_audience_segments ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(160) NOT NULL, + expression JSON NOT NULL, + ceiling VARCHAR(32) NOT NULL, + updated_by INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_engseg_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- §4.1. NOT `settings`: cooldown state is high-cardinality (recipients x rules x +-- subjects), written on every fire, and asked "is this one pair still cooling?". +-- A JSON blob under one settings key would be a read-modify-write of the whole +-- deployment's cooldown state per event, with a lost-update race between two +-- concurrent triggers. +-- +-- `subject_key` is what makes "one IDOC mail per player per day" the right rule +-- instead of the wrong one: a player with four houses decaying should hear about +-- all four, once each. Cooling per (rule, user) alone silently drops three. +CREATE TABLE IF NOT EXISTS engagement_cooldowns ( + rule_id INT NOT NULL, + user_id INT NOT NULL, + -- The SUBJECT the cooldown is about, opaque to core: a house serial, a vendor + -- id, ''. NOT NULL with a '' default, because this is a PRIMARY KEY column and + -- MariaDB would coerce a NULL one anyway. '' is "this rule cools per user, not + -- per subject". + subject_key VARCHAR(190) NOT NULL DEFAULT '', + last_fired_at DATETIME NOT NULL, + fire_count INT NOT NULL DEFAULT 1, + PRIMARY KEY (rule_id, user_id, subject_key), + CONSTRAINT fk_engc_rule FOREIGN KEY (rule_id) REFERENCES engagement_rules(id) ON DELETE CASCADE, + CONSTRAINT fk_engc_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + -- So a prune worker can drop rows older than the longest configured cooldown. + -- Without it this table grows without bound, which is the failure mode + -- teamActivityPrune was written for. + INDEX idx_engc_sweep (last_fired_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- §4.2a. Modelled on announce_jobs / announce_job_legs. One row per +-- (rule, user, channel) occurrence of an event. +CREATE TABLE IF NOT EXISTS engagement_outbox ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + rule_id INT NOT NULL, + trigger_id VARCHAR(96) NOT NULL, -- denormalized; survives a rule edit + user_id INT NOT NULL, + channel VARCHAR(32) NOT NULL, -- VARCHAR, never ENUM: the channel set is data + subject_key VARCHAR(190) NOT NULL DEFAULT '', + payload JSON NOT NULL, -- the declared variables, snapshotted at emit + dedupe_key VARCHAR(190) NULL, + status ENUM('scheduled','sending','sent','failed','cancelled','suppressed') NOT NULL DEFAULT 'scheduled', + due_at DATETIME NOT NULL, + attempts SMALLINT NOT NULL DEFAULT 0, + last_error TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + sent_at DATETIME NULL, + CONSTRAINT fk_engo_rule FOREIGN KEY (rule_id) REFERENCES engagement_rules(id) ON DELETE CASCADE, + CONSTRAINT fk_engo_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + -- **Scoped to the row's identity, and §4.2a's global `UNIQUE (dedupe_key)` is + -- a defect this phase found while building it.** A dedupe key names the EVENT + -- ("house 0x4001 entered IDOC"), and one event legitimately becomes many rows: + -- an audience of fifty users is fifty rows, a rule spanning email and in-app + -- doubles that, and two rules on one trigger double it again. Under a global + -- unique index the FIRST of those inserts wins and every other one is silently + -- ignored — ninety-nine recipients dropped by the mechanism meant to stop a + -- replayed event becoming a second mail. Scoping it to (rule, user, channel) + -- keeps exactly that guarantee and nothing more. + UNIQUE KEY uq_engo_dedupe (rule_id, user_id, channel, dedupe_key), + INDEX idx_engo_due (status, due_at), + -- What a RESOLVING event queries: a house repaired back to LikeNew cancels + -- every scheduled row for that (rule, user, house). + INDEX idx_engo_cancel (rule_id, user_id, subject_key, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- G15: the per-message record. Today "did user X get the mail?" is unanswerable. +-- +-- It is deliberately NOT a second address book: the address is stored as a +-- sha256, which is enough to correlate a bounce (Phase 9) and useless as a +-- mailing list. `user_id` is SET NULL rather than CASCADE so the log survives an +-- account deletion — an audit of what this deployment sent must not be erasable +-- by deleting the recipient. +CREATE TABLE IF NOT EXISTS engagement_sends ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + outbox_id BIGINT NULL, + rule_id INT NULL, + trigger_id VARCHAR(96) NOT NULL, + user_id INT NULL, + channel VARCHAR(32) NOT NULL, + transport VARCHAR(32) NULL, + address_hash CHAR(64) NULL, + status ENUM('sent','failed','suppressed','bounced','complained') NOT NULL, + detail VARCHAR(500) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_engs_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_engs_trigger (trigger_id, created_at), + INDEX idx_engs_user (user_id, created_at), + -- The per-rule hourly ceiling (§7.1 Q3) is counted here, so the count has to be + -- an index range scan rather than a table scan: it runs once per rule per event. + INDEX idx_engs_rule_window (rule_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server/src/engagement/audiences.js b/server/src/engagement/audiences.js new file mode 100644 index 0000000..2d9d465 --- /dev/null +++ b/server/src/engagement/audiences.js @@ -0,0 +1,138 @@ +// ── 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`, +// `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. +// +// **Three things it is careful about, all of them the same worry.** The set this +// function returns is the set that gets mailed, so: +// +// 1. Every id is checked against `users.status = 'active'` - including the ones a +// MODULE's resolver produced, which core has no reason to trust with account +// status it does not know about. +// 2. A dormant segment (its module uninstalled) resolves to EMPTY and says so. +// The caller must not send. Falling back to the rule's plain `audience` +// column would reach a different population than the one composed (§5.1a +// rule 4), which is the failure mode this whole design exists to avoid. +// 3. `members` as a PLAIN audience resolves to nobody. It is the ceiling for +// "a module-declared list", and without a segment there is no list - core +// knows no game vocabulary and cannot guess which members were meant. A rule +// saved that way is inert and visible as such, rather than quietly falling +// back to something wider. + +const registries = require('../modules/registries') +const channels = require('./channels') +const segments = require('./segments') +const segmentsDb = require('../model/engagement/engagementSegments.db') +const recipients = require('../model/engagement/engagementRecipients.db') +const ceilings = require('../modules/ceilings') +const log = require('../utils/logger')('engagement') + +/** + * Which registered channels default to something other than 'off'? + * + * Read once per resolution rather than hardcoded, because it is the difference + * between "opted in" meaning a stored row and meaning the absence of one + * (§3.1, G9). All three of core's channels default 'off' today, so this is empty + * and `subscribers` is the simple query - but the answer lives in the registry. + */ +const defaultOnChannels = () => channels.all().filter((c) => c.defaultMode !== 'off').map((c) => c.id) + +/** + * Resolve one rule against one event. + * + * @returns {{ userIds: number[], ceiling: string|null, dormant: boolean, reason: string|null }} + * `dormant` means "this rule cannot be resolved right now"; `reason` names why + * for the log and, in Phase 4b, for the admin list's dormant badge. + */ +async function resolveForRule(rule, event) { + if (rule.audience_segment_id) { + const segment = await segmentsDb.getById(rule.audience_segment_id) + if (!segment) { + // The segment was deleted out from under the rule. `audience_segment_id` + // deliberately has no ON DELETE SET NULL (see schema.sql), because that + // would silently fall back to the rule's plain `audience` column and mail + // a different set of people. + return { userIds: [], ceiling: null, dormant: true, reason: 'audience segment no longer exists' } + } + const { dormant, userIds } = await segments.resolve(segment.expression) + if (dormant) { + return { userIds: [], ceiling: segment.ceiling, dormant: true, reason: 'audience segment is dormant' } + } + return { + userIds: await recipients.filterActive(userIds), + // The STORED ceiling, not one re-derived now: a module that has since + // widened its own audience's ceiling must not widen a segment that was + // saved under the old one. + ceiling: segment.ceiling, + dormant: false, + reason: null, + } + } + + switch (rule.audience) { + case 'owner': { + if (!event.ownerUserId) { + // Not dormant: the rule is fine and this particular event simply has no + // owner to mail. A trigger that never carries one is an operator's + // mistake the rule editor should catch (Phase 4b), not a runtime error. + return { userIds: [], ceiling: 'owner', dormant: false, reason: 'event carries no ownerUserId' } + } + return { + userIds: await recipients.filterActive([event.ownerUserId]), + ceiling: 'owner', + dormant: false, + reason: null, + } + } + case 'staff': + return { + userIds: await recipients.staff(ceilings.STAFF_CEILING_ROLES), + ceiling: 'staff', + dormant: false, + reason: null, + } + case 'subscribers': + return { + userIds: await recipients.subscribers(event.triggerId, defaultOnChannels()), + ceiling: 'subscribers', + dormant: false, + reason: null, + } + case 'authenticated': + case 'everyone': + return { userIds: await recipients.active(), ceiling: rule.audience, dormant: false, reason: null } + case 'members': + return { + userIds: [], + ceiling: 'members', + dormant: false, + reason: 'a "members" audience needs a segment naming which list', + } + default: + // Fails closed on an audience name the lattice does not know - the same + // posture `ceilings.permits` takes, and for the same reason. + log.warn('rule names an unknown audience', { rule: rule.id, audience: rule.audience }) + return { userIds: [], ceiling: null, dormant: true, reason: `unknown audience "${rule.audience}"` } + } +} + +/** + * The G24 gate, re-run at SEND time and not only at save time. + * + * A rule's audience was checked against its trigger's ceiling when it was saved, + * so this can only fail when something changed underneath: a module upgraded and + * narrowed its trigger's ceiling, or a module was replaced by one declaring the + * same id more tightly. That is precisely the case where a stale rule would + * otherwise mail a population the current declaration forbids, which is what + * makes this the security boundary rather than a duplicate check. + */ +function permitted(triggerId, ceiling) { + const declaration = registries.eventTrigger(triggerId) + if (!declaration) return false + return ceilings.permits(declaration.ceiling, ceiling) +} + +module.exports = { resolveForRule, permitted, defaultOnChannels } diff --git a/server/src/engagement/conditions.js b/server/src/engagement/conditions.js new file mode 100644 index 0000000..2f99031 --- /dev/null +++ b/server/src/engagement/conditions.js @@ -0,0 +1,251 @@ +// ── Rule conditions — a predicate over a trigger's DECLARED variables ─────── +// +// ENGAGEMENT.md §4.5, Phase 4a. `engagement_rules.conditions` is the half of a +// rule that decides *whether* this particular firing is interesting: "only when +// decayStatus is IDOC", "only for threads in this Team". Without it every rule is +// all-or-nothing per trigger, and an operator's only way to narrow is to ask a +// module author for a second trigger. +// +// **It is validated against the declaration, not against a payload.** A condition +// naming a variable the trigger does not declare is refused at SAVE, with the +// variable named, for the same reason §4.3 gives the template editor: a predicate +// that silently reads `undefined` is a rule that silently never fires (or always +// does), and the day you find out is the day the mail did not go. +// +// **The grammar is small and closed on purpose.** No arbitrary expressions, no +// arithmetic, no regex. An operator composes and/or/not over comparisons of one +// declared variable against a literal, and every operator here is one a rule +// editor can render as a dropdown. Anything that needs more than this is asking +// for a condition the module should have declared as a variable. +// +// Nothing in this file reaches the database or the network. + +const registries = require('../modules/registries') + +// Comparison operators, grouped by what they may be applied to. The grouping is +// the whole of the type check: `gt` on a boolean and `startsWith` on an int are +// both refused at save rather than quietly answering false forever. +const OPERATORS = { + eq: { label: 'is', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 1 }, + ne: { label: 'is not', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 1 }, + in: { label: 'is one of', types: ['string', 'int', 'float', 'url'], arity: 'list' }, + nin: { label: 'is none of', types: ['string', 'int', 'float', 'url'], arity: 'list' }, + gt: { label: 'is greater than', types: ['int', 'float', 'datetime'], arity: 1 }, + gte: { label: 'is at least', types: ['int', 'float', 'datetime'], arity: 1 }, + lt: { label: 'is less than', types: ['int', 'float', 'datetime'], arity: 1 }, + lte: { label: 'is at most', types: ['int', 'float', 'datetime'], arity: 1 }, + contains: { label: 'contains', types: ['string', 'url'], arity: 1 }, + startsWith: { label: 'starts with', types: ['string', 'url'], arity: 1 }, + // The one operator that takes no value: "the emit carried this variable at + // all". It is the honest way to write a rule about an OPTIONAL variable, and + // without it `ne` would have to double as a presence test and get it wrong + // (an absent variable is not "not equal to X"; it is absent). + present: { label: 'is present', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 0 }, + absent: { label: 'is absent', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 0 }, +} + +const BOOLEAN_OPS = ['and', 'or', 'not'] + +// A list literal an operator may type. Bounded because it is stored in a JSON +// column an admin can write, and an unbounded IN list is an unbounded predicate +// evaluated on every event. +const MAX_LIST = 50 +// Depth of the and/or/not tree. Three levels is more nesting than any rule +// editor should offer; the bound is here so a hand-written JSON body cannot +// recurse this evaluator into a stack overflow on the emit path. +const MAX_DEPTH = 5 + +const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) + +/** + * Check one literal against the declared type of the variable it is compared to. + * + * `datetime` accepts anything `Date` parses and is normalised to an ISO string, + * which is what `engagementEmit.coerce` does to the payload side — so both sides + * of every comparison are the same representation of a moment, and a lexical + * `<` on two ISO strings is a chronological one. + */ +function checkLiteral(type, raw) { + switch (type) { + case 'string': + case 'url': + return typeof raw === 'string' ? { value: raw } : { error: 'expected a string' } + case 'int': + return Number.isInteger(raw) ? { value: raw } : { error: 'expected an integer' } + case 'float': + return typeof raw === 'number' && Number.isFinite(raw) + ? { value: raw } + : { error: 'expected a finite number' } + case 'boolean': + return typeof raw === 'boolean' ? { value: raw } : { error: 'expected a boolean' } + case 'datetime': { + const d = raw instanceof Date ? raw : new Date(raw) + if (Number.isNaN(d.getTime())) return { error: 'expected a date' } + return { value: d.toISOString() } + } + default: + return { error: `unsupported type "${type}"` } + } +} + +/** + * Validate a condition tree against a trigger declaration. + * + * Returns `{ ok: true, conditions }` with a NEW normalised tree — literals + * coerced, unknown keys dropped — or `{ ok: false, errors }` listing every + * problem rather than the first, the posture `validatePayload` takes and for the + * same reason: an operator fixing one clause at a time is an operator making six + * round trips through a form. + * + * `null` and `undefined` are valid and mean "no conditions" — a rule that fires + * on every occurrence of its trigger, which is the common case. + */ +function validate(declaration, raw) { + const errors = [] + const variables = new Map((declaration?.variables || []).map((v) => [v.name, v])) + + function walk(node, depth, path) { + if (depth > MAX_DEPTH) { + errors.push(`${path}: nested deeper than ${MAX_DEPTH}`) + return null + } + if (!isPlainObject(node)) { + errors.push(`${path}: expected an object`) + return null + } + + if (BOOLEAN_OPS.includes(node.op)) { + // `not` takes exactly one node; `and`/`or` take a list. Both are written + // as `nodes` so a client walks one shape. + const raws = Array.isArray(node.nodes) ? node.nodes : [] + if (!raws.length) { + errors.push(`${path}: "${node.op}" has no nodes`) + return null + } + if (node.op === 'not' && raws.length !== 1) { + errors.push(`${path}: "not" takes exactly one node`) + return null + } + const nodes = raws.map((child, i) => walk(child, depth + 1, `${path}.nodes[${i}]`)).filter(Boolean) + return nodes.length === raws.length ? { op: node.op, nodes } : null + } + + if (node.op !== undefined) { + errors.push(`${path}: unknown operator "${node.op}"`) + return null + } + + // A leaf: { variable, cmp, value }. + const variable = variables.get(node.variable) + if (!variable) { + errors.push(`${path}: "${node.variable}" is not a variable of "${declaration?.id}"`) + return null + } + const operator = OPERATORS[node.cmp] + if (!operator) { + errors.push(`${path}: unknown comparison "${node.cmp}"`) + return null + } + if (!operator.types.includes(variable.type)) { + errors.push(`${path}: "${node.cmp}" cannot be applied to a ${variable.type}`) + return null + } + + if (operator.arity === 0) return { variable: variable.name, cmp: node.cmp } + + if (operator.arity === 'list') { + if (!Array.isArray(node.value) || !node.value.length) { + errors.push(`${path}: "${node.cmp}" needs a non-empty list`) + return null + } + if (node.value.length > MAX_LIST) { + errors.push(`${path}: "${node.cmp}" list is longer than ${MAX_LIST}`) + return null + } + const value = [] + let bad = false + node.value.forEach((item, i) => { + const checked = checkLiteral(variable.type, item) + if (checked.error) { + errors.push(`${path}.value[${i}]: ${checked.error}`) + bad = true + } else value.push(checked.value) + }) + return bad ? null : { variable: variable.name, cmp: node.cmp, value } + } + + const checked = checkLiteral(variable.type, node.value) + if (checked.error) { + errors.push(`${path}: ${checked.error}`) + return null + } + return { variable: variable.name, cmp: node.cmp, value: checked.value } + } + + if (raw === null || raw === undefined) return { ok: true, conditions: null } + const conditions = walk(raw, 0, 'conditions') + return errors.length ? { ok: false, errors } : { ok: true, conditions } +} + +/** Compare one already-normalised leaf against a payload. */ +function evaluateLeaf(leaf, data) { + const present = Object.prototype.hasOwnProperty.call(data, leaf.variable) + const actual = data[leaf.variable] + + if (leaf.cmp === 'present') return present + if (leaf.cmp === 'absent') return !present + // Every other comparison against an absent variable is FALSE, never true. + // `ne` is the one that tempts otherwise — "not equal to X" reads as satisfied + // by nothing at all — and treating it as true would make an optional variable's + // absence fire the rule. + if (!present) return false + + switch (leaf.cmp) { + case 'eq': return actual === leaf.value + case 'ne': return actual !== leaf.value + case 'in': return leaf.value.includes(actual) + case 'nin': return !leaf.value.includes(actual) + case 'gt': return actual > leaf.value + case 'gte': return actual >= leaf.value + case 'lt': return actual < leaf.value + case 'lte': return actual <= leaf.value + case 'contains': return typeof actual === 'string' && actual.includes(leaf.value) + case 'startsWith': return typeof actual === 'string' && actual.startsWith(leaf.value) + default: return false + } +} + +/** + * Does this event's payload satisfy the rule's conditions? + * + * `null` conditions are satisfied — a rule with no conditions fires on every + * occurrence. A tree this evaluator does not recognise answers **false**, which + * is the fail-closed direction: a stored condition that no longer parses (a rule + * saved against an older trigger version, say) must stop the mail rather than + * become "no conditions" and mail everyone. + */ +function evaluate(conditions, data = {}) { + if (conditions === null || conditions === undefined) return true + if (!isPlainObject(conditions)) return false + + if (conditions.op === 'and') return (conditions.nodes || []).every((n) => evaluate(n, data)) + if (conditions.op === 'or') return (conditions.nodes || []).some((n) => evaluate(n, data)) + if (conditions.op === 'not') return !evaluate((conditions.nodes || [])[0], data) + if (conditions.op !== undefined) return false + + return evaluateLeaf(conditions, data) +} + +/** + * The operator vocabulary a rule editor renders, with the variable types each + * one applies to. Served with the rule surface in Phase 4b rather than hardcoded + * in the client, on the same argument the ceiling vocabulary is served with the + * trigger catalog: a second copy of a rule is a copy that drifts. + */ +const vocabulary = () => + Object.entries(OPERATORS).map(([cmp, o]) => ({ cmp, label: o.label, types: o.types, arity: o.arity })) + +/** Convenience for a caller holding only a trigger id. */ +const validateFor = (triggerId, raw) => validate(registries.eventTrigger(triggerId), raw) + +module.exports = { validate, validateFor, evaluate, vocabulary, OPERATORS, MAX_LIST, MAX_DEPTH } diff --git a/server/src/engagement/engine.js b/server/src/engagement/engine.js new file mode 100644 index 0000000..82729d7 --- /dev/null +++ b/server/src/engagement/engine.js @@ -0,0 +1,241 @@ +// ── The engagement engine ────────────────────────────────────────────────── +// +// ENGAGEMENT.md Phase 4a. `ctx.events.emit` validated a payload against a +// declaration and stopped (Phase 2); this is what it now hands the validated +// event to. The engine's whole job is to answer, for one event, **who gets told, +// on what, and not too often** - and then to write that down as outbox rows. +// It never delivers: `engagementWorker` drains the outbox, and what actually +// carries a message arrives with the channels' `deliver` in Phases 6 and 7. +// +// **The order of the gates is the design, and each one is here because skipping +// it is a way to mail the wrong people or too many of them:** +// +// 1. enabled rules for this trigger - nothing is seeded, nothing is on by default +// 2. conditions - is this particular firing interesting +// 3. audience -> user ids - core's tables, or a composed segment +// 4. ceiling re-check (G24) - re-run at SEND time, not only at save +// 5. per-channel preference - a user's own opt-in, effective mode +// 6. per-rule hourly ceiling (§7.1 Q3) - the hard stop that makes rules-as-data safe +// 7. cooldown, per (rule, user, subject) - one statement, so two emits cannot race +// 8. enqueue, deduped - a replayed event is one row, not two +// +// Steps 6 and 7 are in that order deliberately. The hourly ceiling is about the +// RULE and is the thing that stops a mail storm; the cooldown is about one +// recipient and one subject. Checking the cheap global bound before consuming a +// per-recipient cooldown slot means a rule that has hit its ceiling does not also +// silently burn everybody's cooldowns on sends that never happen. +// +// **Nothing here throws at its caller.** It is invoked from inside a game-event +// handler by way of `ctx.events.emit`, and a database problem of core's must not +// become a module's control flow (the same posture the emit validator takes). + +const rulesDb = require('../model/engagement/engagementRules.db') +const outboxDb = require('../model/engagement/engagementOutbox.db') +const cooldownsDb = require('../model/engagement/engagementCooldowns.db') +const sendsDb = require('../model/engagement/engagementSends.db') +const recipients = require('../model/engagement/engagementRecipients.db') +const conditions = require('./conditions') +const audiences = require('./audiences') +const channels = require('./channels') +const log = require('../utils/logger')('engagement') + +const HOUR_MS = 60 * 60 * 1000 + +/** + * Which of a rule's channels are actually deliverable right now? + * + * A rule stores channel ids as data (`channels JSON`), so it can name one whose + * module has been removed since. An unregistered channel is dropped rather than + * failing the rule: the other channels of that rule are still correct, and a + * dropped one is visible in the log line below. + */ +const liveChannels = (rule) => (rule.channels || []).filter((c) => channels.has(c)) + +/** + * Narrow a candidate set to the users whose EFFECTIVE mode for (id, channel) is + * not 'off'. + * + * Effective, not stored: a row exists only where a user has expressed something, + * and absence means the channel's `defaultMode` (§3.1). Reading the stored rows + * and applying the default here keeps that answer in the registry, which is the + * invariant Phase 3 established. + * + * A 'digest' preference is kept, not dropped. Digest delivery is Phase 6's, and + * an outbox row for it is still the right record of "this person should be told"; + * what changes in Phase 6 is who drains it. + */ +async function subscribedTo(userIds, streamId, channel) { + if (!userIds.length) return [] + const stored = await recipients.storedModes(userIds, streamId, channel) + const fallback = channels.defaultMode(channel) + return userIds.filter((id) => (stored.get(id) ?? fallback) !== 'off') +} + +/** + * Run one rule against one event. Returns a small summary, for the log line and + * 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 } + + if (!conditions.evaluate(rule.conditions, event.data)) { + summary.skipped = 'conditions' + return summary + } + + const resolved = await audiences.resolveForRule(rule, event) + if (resolved.dormant) { + summary.skipped = resolved.reason || 'dormant' + return summary + } + if (!resolved.userIds.length) { + summary.skipped = resolved.reason || 'empty audience' + return summary + } + + // G24, re-run at send time. A rule saved when its trigger permitted a wider + // audience must not keep reaching it after a module upgrade narrowed the + // declaration - and that is the only way this can fail, since the save path + // ran the same check. + if (!audiences.permitted(event.triggerId, resolved.ceiling)) { + log.warn('rule audience exceeds its trigger ceiling - refusing', { + rule: rule.id, + trigger: event.triggerId, + audience: resolved.ceiling, + }) + summary.skipped = 'ceiling' + return summary + } + + const live = liveChannels(rule) + if (!live.length) { + summary.skipped = 'no registered channel' + return summary + } + + // The per-rule hourly ceiling (§7.1 Q3). Counted once for the whole event + // rather than per channel: an operator setting "100 an hour" means a hundred + // messages, not a hundred per channel per event. + const sentThisHour = await sendsDb.countSentSince(rule.id, new Date(now.getTime() - HOUR_MS)) + let budget = Math.max(0, rule.max_sends_per_hour - sentThisHour) + if (budget === 0) { + log.warn('rule is at its hourly send ceiling', { + rule: rule.id, + trigger: event.triggerId, + ceiling: rule.max_sends_per_hour, + }) + summary.skipped = 'hourly ceiling' + return summary + } + + const subjectKey = (event.subject ?? '').toString().slice(0, 190) + 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) + for (const userId of eligible) { + if (budget <= 0) { + summary.capped += 1 + continue + } + // One statement, guarded on the interval, so two concurrent emits cannot + // both pass a read-then-write check (§4.1). + const allowed = await cooldownsDb.claim(rule.id, userId, subjectKey, rule.cooldown_seconds, now) + if (!allowed) { + summary.cooled += 1 + continue + } + const id = await outboxDb.enqueue({ + rule_id: rule.id, + trigger_id: event.triggerId, + user_id: userId, + channel, + subject_key: subjectKey, + payload: event.data, + // Scoped per (rule, user, channel) by the unique index, so one event + // fanned out to fifty people is fifty rows carrying the same key. + dedupe_key: event.dedupeKey, + due_at: dueAt, + }) + if (id === null) summary.deduped += 1 + else { + summary.enqueued += 1 + budget -= 1 + } + } + } + + return summary +} + +/** + * Cancel pending rows that this event resolves (§4.2a). + * + * This is the actual point of `delay_seconds`: without cancellation a delay is + * just a late mail. A house repaired back to LikeNew fires a trigger that some + * rule names in its `cancel_on`, and every still-scheduled row for that + * (rule, subject) stops. + * + * When the resolving event names an owner, only that user's rows are cancelled; + * when it does not, every user queued about that subject is - which is the + * house-repaired case, where the event is about the house and not about any one + * of the people who were going to be told. + */ +async function applyCancellations(event, summary) { + const rules = await rulesDb.enabledCancelledBy(event.triggerId) + if (!rules.length) return + const subjectKey = (event.subject ?? '').toString().slice(0, 190) + for (const rule of rules) { + const n = await outboxDb.cancel(rule.id, subjectKey, event.ownerUserId || null) + if (n) { + summary.cancelled += n + log.info('cancelled scheduled sends', { + rule: rule.id, + by: event.triggerId, + subject: subjectKey, + rows: n, + }) + } + } +} + +/** + * Dispatch one validated event. Called by `engagementEmit.emit` after the payload + * has been checked against the declaration. + * + * @param {object} event the envelope `engagementEmit` built + * @returns {Promise<{ rules: number, enqueued: number, cancelled: number }>} + */ +async function dispatch(event, now = new Date()) { + const summary = { rules: 0, enqueued: 0, deduped: 0, cooled: 0, capped: 0, cancelled: 0 } + try { + const rules = await rulesDb.enabledForTrigger(event.triggerId) + summary.rules = rules.length + + for (const rule of rules) { + const result = await applyRule(rule, event, now) + summary.enqueued += result.enqueued + summary.deduped += result.deduped + summary.cooled += result.cooled + summary.capped += result.capped + } + + await applyCancellations(event, summary) + + // Keys and counts, never values - the same rule the emit log line follows. + // A payload carries player names, house locations and forum excerpts, and a + // log that reproduces them is a second copy of exactly the content + // `engagement_sends` is careful to keep out of the database. + if (summary.rules || summary.cancelled) { + log.info('event dispatched', { trigger: event.triggerId, ...summary }) + } + } catch (err) { + // A database problem of core's must not become the module's control flow at + // three in the morning. The emit already succeeded as a contract; what failed + // is delivery, and it is logged as core's failure. + log.error('dispatch failed', { trigger: event.triggerId, message: err.message }) + } + return summary +} + +module.exports = { dispatch, applyRule, applyCancellations, subscribedTo, liveChannels, HOUR_MS } diff --git a/server/src/engagement/segments.js b/server/src/engagement/segments.js new file mode 100644 index 0000000..390044b --- /dev/null +++ b/server/src/engagement/segments.js @@ -0,0 +1,232 @@ +// ── Audience segments — operator composition over module-declared audiences ── +// +// ENGAGEMENT.md §5.1a, Phase 4a. A module declares named audiences over its own +// data ("members of a Team", "the governors"); an operator combines them with +// and/or/not into a saved segment; a rule points at the segment. This file is the +// two halves of that: derive the segment's ceiling at save time, and resolve the +// expression to user ids at send time. +// +// **Composition must NARROW, never widen** (§5.1a rule 3), and that is the whole +// security content of this file. `A OR B` takes the TIGHTER of the two ceilings, +// not the looser - a ceiling states what an expression is *allowed* to reach, not +// what it will resolve to, so the direction of the boolean operator is +// irrelevant. Union-widens is the intuitive implementation and it is the wrong +// one; `ceilings.meetAll` is the arithmetic, settled in Phase 2, and this is its +// first consumer. +// +// The second rule that shows up in both halves is **dormancy** (§5.1a rule 4). +// An audience whose module has been uninstalled resolves to the EMPTY set and +// flags itself, never to an error and never to some other set of people. A +// segment containing one is dormant, and a rule using a dormant segment does not +// send. Resolving the rest of the tree instead would mail a DIFFERENT population +// than the one the operator composed. + +const registries = require('../modules/registries') +const ceilings = require('../modules/ceilings') + +const BOOLEAN_OPS = ['and', 'or', 'not'] +// Same bounds and the same reason as conditions.js: this tree comes out of a JSON +// column an admin can write, and it is walked on the emit path. +const MAX_DEPTH = 5 +const MAX_NODES = 50 + +const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) +const isNot = (node) => isPlainObject(node) && node.op === 'not' + +/** Check one audience's declared params against what the operator supplied. */ +function checkParams(declaration, raw, path, errors) { + const params = {} + const supplied = isPlainObject(raw) ? raw : {} + for (const p of declaration.params || []) { + const value = supplied[p.id] + if (value === undefined || value === null || value === '') { + if (p.required) errors.push(`${path}: "${p.id}" is required`) + continue + } + if (p.type === 'int') { + const n = Number(value) + if (!Number.isInteger(n)) { + errors.push(`${path}: "${p.id}" expected an integer`) + continue + } + params[p.id] = n + } else if (p.type === 'boolean') { + if (typeof value !== 'boolean') { + errors.push(`${path}: "${p.id}" expected a boolean`) + continue + } + params[p.id] = value + } else { + if (typeof value !== 'string') { + errors.push(`${path}: "${p.id}" expected a string`) + continue + } + params[p.id] = value + } + } + return params +} + +/** + * Validate an expression and derive its ceiling in one walk. + * + * Returns `{ ok: true, expression, ceiling }` with a normalised tree, or + * `{ ok: false, errors }`. + * + * **`not` is legal only as a child of `and`**, and that restriction is what makes + * a complement mean something. A complement needs a universe, and the only + * universe available here that does not widen is the set its siblings already + * produced: `A AND NOT B` is "A, less B", which is exactly what an operator + * wants and cannot be composed into a broadcast. A bare `NOT B`, or `A OR NOT B`, + * would have to mean "everyone except..." - a way to build the whole deployment + * out of one narrow audience, which is the widening rule 3 forbids. Refusing it + * at save is better than a semantics nobody can predict from the screen. + * + * Two failure modes, and they are different: + * + * - a leaf naming an audience nobody registers is refused AT SAVE, because an + * operator composing a segment out of a typo should hear about it now rather + * than discovering a permanently-empty rule later. (A segment that was VALID + * when saved and whose module has since gone is a different case - that is + * dormancy, handled in `resolve`, and it is not refused.) + * - two incomparable ceilings have NO meet, so the composition is refused rather + * than resolved to a guess. `staff AND owner` is not `owner`; it is a question + * the lattice declines to answer, and picking a side would be a widening. + */ +function validate(raw) { + const errors = [] + let nodes = 0 + + // `underAnd` is the only context in which a `not` is legal. + function walk(node, depth, path, underAnd) { + if (++nodes > MAX_NODES) { + errors.push(`${path}: expression has more than ${MAX_NODES} nodes`) + return null + } + if (depth > MAX_DEPTH) { + errors.push(`${path}: nested deeper than ${MAX_DEPTH}`) + return null + } + if (!isPlainObject(node)) { + errors.push(`${path}: expected an object`) + return null + } + + if (node.op === 'not') { + if (!underAnd) { + errors.push(`${path}: "not" is only allowed inside an "and" - a complement needs a set to take it from`) + return null + } + const children = Array.isArray(node.nodes) ? node.nodes : [] + if (children.length !== 1) { + errors.push(`${path}: "not" takes exactly one node`) + return null + } + const inner = walk(children[0], depth + 1, `${path}.nodes[0]`, false) + if (!inner) return null + // A `not` contributes NO ceiling. Excluding people cannot widen who the + // expression reaches, so folding the excluded audience's ceiling into the + // meet would refuse perfectly safe segments: `members AND NOT staff` would + // hit meet('members','staff') = null and be rejected, even though it + // reaches strictly fewer people than `members` alone. + return { node: { op: 'not', nodes: [inner.node] }, ceiling: null, complement: true } + } + + if (node.op === 'and' || node.op === 'or') { + const children = Array.isArray(node.nodes) ? node.nodes : [] + if (!children.length) { + errors.push(`${path}: "${node.op}" has no nodes`) + return null + } + const walked = children.map((c, i) => walk(c, depth + 1, `${path}.nodes[${i}]`, node.op === 'and')) + if (walked.some((w) => w === null)) return null + const positives = walked.filter((w) => !w.complement) + if (!positives.length) { + errors.push(`${path}: "${node.op}" has nothing but complements - there is no set to exclude from`) + return null + } + return { + node: { op: node.op, nodes: walked.map((w) => w.node) }, + ceiling: ceilings.meetAll(positives.map((w) => w.ceiling)), + } + } + + if (node.op !== undefined) { + errors.push(`${path}: unknown operator "${node.op}"`) + return null + } + + const declaration = registries.audience(node.audienceId) + if (!declaration) { + errors.push(`${path}: no audience "${node.audienceId}" is registered`) + return null + } + const params = checkParams(declaration, node.params, path, errors) + return { node: { audienceId: declaration.id, params }, ceiling: declaration.ceiling } + } + + if (!isPlainObject(raw)) return { ok: false, errors: ['expression: expected an object'] } + const walked = walk(raw, 0, 'expression', false) + if (errors.length || !walked) return { ok: false, errors: errors.length ? errors : ['expression: invalid'] } + if (!walked.ceiling) { + return { + ok: false, + errors: [ + 'expression: the audiences combined here have no common ceiling, so there is no bound this segment could be given', + ], + } + } + return { ok: true, expression: walked.node, ceiling: walked.ceiling } +} + +/** + * Resolve a validated expression to a set of user ids. + * + * Returns `{ dormant, userIds }`. `dormant` is true the moment ANY leaf names an + * audience that is no longer registered, and when it is true the caller must not + * send: `userIds` is empty, because the tree it would have come from is not the + * tree the operator composed. + * + * `and` is the intersection of its positive children, less the union of its + * complements. `or` is the union of its children, which are all positive because + * `validate` refused any other shape. + */ +async function resolve(expression) { + let dormant = false + + async function walk(node) { + if (!isPlainObject(node)) return new Set() + + if (node.op === 'and' || node.op === 'or') { + const children = Array.isArray(node.nodes) ? node.nodes : [] + const positives = children.filter((c) => !isNot(c)) + const complements = children.filter(isNot) + + let out = new Set() + for (let i = 0; i < positives.length; i += 1) { + const set = await walk(positives[i]) + if (i === 0) out = set + else if (node.op === 'and') out = new Set([...out].filter((id) => set.has(id))) + else for (const id of set) out.add(id) + } + for (const c of complements) { + const excluded = await walk((c.nodes || [])[0]) + out = new Set([...out].filter((id) => !excluded.has(id))) + } + return out + } + + // A `not` reached directly (never produced by validate, but a stored row + // predates nothing and this must not throw): no universe, so no members. + if (node.op !== undefined) return new Set() + + const { dormant: gone, userIds } = await registries.resolveAudience(node.audienceId, node.params || {}) + if (gone) dormant = true + return new Set(userIds) + } + + const set = await walk(expression) + return { dormant, userIds: dormant ? [] : [...set] } +} + +module.exports = { validate, resolve, MAX_DEPTH, MAX_NODES } diff --git a/server/src/model/engagement/engagementCooldowns.db.js b/server/src/model/engagement/engagementCooldowns.db.js new file mode 100644 index 0000000..cead939 --- /dev/null +++ b/server/src/model/engagement/engagementCooldowns.db.js @@ -0,0 +1,78 @@ +const { query } = require('../../utils/db') + +/** + * Claim a fire for (rule, user, subject), or refuse it because the pair is still + * cooling. ENGAGEMENT.md §4.1. + * + * **Two statements, each of which is its own atomic decision** - and it is worth + * saying why it is not the single `INSERT ... ON DUPLICATE KEY UPDATE` §4.1 + * describes, because that version was written, tested green against an in-memory + * stub, and disproved by the first run against a real MariaDB. + * + * The one-statement form reads its answer out of `affectedRows`, on the usual + * contract: 1 for an insert, 2 for an update that changed something, and 0 for a + * duplicate key whose update changed nothing - that 0 being "the guard failed, so + * this pair is still cooling". **The mariadb Node connector sets `foundRows: true` + * by default**, which makes `affectedRows` report rows MATCHED rather than rows + * CHANGED, and `utils/db.js` does not override it. Under that pool the no-op case + * returns 1, indistinguishable from a fresh insert: every cooldown would have + * passed, always, and nothing in a stubbed test could have noticed. + * + * So the guard moves into a WHERE clause, where a row either matches or does not + * and `foundRows` has nothing to fold together: + * + * 1. UPDATE the row, guarded on the interval. `affectedRows = 1` means this + * caller moved it and owns the fire. + * 2. If that matched nothing, the row either does not exist yet or is still + * cooling. `INSERT IGNORE` separates the two: 1 means we inserted the first + * fire, 0 means the row was there and step 1 already said it is cooling. + * + * It is still race-free, and each race resolves the right way: + * - two concurrent first fires: neither UPDATEs, both INSERT IGNORE, exactly + * one gets 1 (the primary key decides). The loser is treated as cooling. + * - two concurrent fires after expiry: the row is locked by the first UPDATE, + * and the second re-evaluates its guard against the committed row - which now + * holds `now`, so it fails and is refused. + * + * `cooldown_seconds = 0` always passes, which is the documented meaning of a rule + * with no cooldown: the guard becomes `last_fired_at <= now`, and it is. + */ +async function claim(ruleId, userId, subjectKey, cooldownSeconds, now = new Date()) { + const moved = await query( + `UPDATE engagement_cooldowns + SET last_fired_at = ?, fire_count = fire_count + 1 + WHERE rule_id = ? AND user_id = ? AND subject_key = ? + AND last_fired_at <= ? - INTERVAL ? SECOND`, + [now, ruleId, userId, subjectKey, now, cooldownSeconds], + ) + if (Number(moved?.affectedRows || 0) === 1) return true + + const inserted = await query( + `INSERT IGNORE INTO engagement_cooldowns (rule_id, user_id, subject_key, last_fired_at, fire_count) + VALUES (?, ?, ?, ?, 1)`, + [ruleId, userId, subjectKey, now], + ) + return Number(inserted?.affectedRows || 0) === 1 +} + +const get = async (ruleId, userId, subjectKey) => { + const [row] = await query( + 'SELECT * FROM engagement_cooldowns WHERE rule_id = ? AND user_id = ? AND subject_key = ?', + [ruleId, userId, subjectKey], + ) + return row || null +} + +/** + * Drop cooldown rows older than `olderThan`. + * + * `idx_engc_sweep (last_fired_at)` exists for this: the table is written on every + * fire and read once per fire, so without a prune it is the unbounded-growth + * failure `teamActivityPrune` was written for. A dropped row means the next fire + * is treated as a first fire, which is correct as long as the retention window is + * longer than the longest configured cooldown - the caller's job, not this one's. + */ +const prune = (olderThan) => + query('DELETE FROM engagement_cooldowns WHERE last_fired_at < ?', [olderThan]) + +module.exports = { claim, get, prune } diff --git a/server/src/model/engagement/engagementOutbox.db.js b/server/src/model/engagement/engagementOutbox.db.js new file mode 100644 index 0000000..06aef38 --- /dev/null +++ b/server/src/model/engagement/engagementOutbox.db.js @@ -0,0 +1,158 @@ +const { query } = require('../../utils/db') +const { parseJson } = require('./engagementRules.db') + +const hydrate = (row) => row && { ...row, payload: parseJson(row.payload, {}) } + +/** + * Enqueue one (rule, user, channel) row, idempotently. + * + * `INSERT IGNORE` rather than a plain INSERT, because `uq_engo_dedupe` is the + * replay guard (§4.2a): the sidecar feed is at-least-once and a reconnect + * backfills, so the same event arriving twice must produce one row and not two + * mails. IGNORE turns that into a silent no-op, which is what a replay should be. + * + * Returns the new id, or null when the row already existed. A null is a + * SUCCESSFUL duplicate, not a failure - the caller counts it as such. + * + * A NULL dedupe_key never collides (multiple NULLs are legal under a UNIQUE + * index), so an emit that carries no key always enqueues. That is the right + * default: dedupe is something the emitter opts into by naming a key, and core + * cannot invent one that means anything. + */ +async function enqueue(row) { + const result = await query( + `INSERT IGNORE INTO engagement_outbox + (rule_id, trigger_id, user_id, channel, subject_key, payload, dedupe_key, due_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + row.rule_id, + row.trigger_id, + row.user_id, + row.channel, + row.subject_key || '', + JSON.stringify(row.payload || {}), + row.dedupe_key ?? null, + row.due_at, + ], + ) + return Number(result?.affectedRows || 0) === 1 ? result.insertId : null +} + +/** + * Rows that are due. `idx_engo_due (status, due_at)` is this query. + * + * It selects rather than claims - claiming is `claim()` below, one row at a + * time - so two instances sweeping at once both see the same candidates and then + * disagree, harmlessly, about which of them owns each. + */ +const findDue = async (now, limit = 100) => + ( + await query( + "SELECT * FROM engagement_outbox WHERE status = 'scheduled' AND due_at <= ? ORDER BY due_at, id LIMIT ?", + [now, limit], + ) + ).map(hydrate) + +/** + * Take ownership of one due row: a compare-and-set from 'scheduled' to 'sending'. + * + * **This is §7.1 Q2's answer** (settled by the org lead 2026-08-29, over + * `SELECT ... FOR UPDATE SKIP LOCKED`). The winner is whoever the server reports + * `affectedRows = 1` to; every other sweeper gets 0 and moves on. No explicit + * transaction, no MariaDB version floor, and it uses a status the ENUM already + * carried for exactly this. + * + * What it makes safe is the OUTBOX and only the outbox. `announceWorker`, + * `teamDigestWorker`, `teamForumUploadSweep` and `teamActivityPrune` are all + * still written for a single instance, so this does not make the deployment + * multi-instance - it makes the one table that will carry mail ready for the day + * it is, which is cheap now and expensive after mail has doubled once. + */ +async function claim(id) { + const result = await query( + `UPDATE engagement_outbox + SET status = 'sending', attempts = attempts + 1 + WHERE id = ? AND status = 'scheduled'`, + [id], + ) + return Number(result?.affectedRows || 0) === 1 +} + +/** + * Release a claimed row back to 'scheduled' with a later `due_at` - a transient + * failure that should be retried. The mirror of announceJobs' backoff. + */ +const reschedule = (id, dueAt, error) => + query( + "UPDATE engagement_outbox SET status = 'scheduled', due_at = ?, last_error = ? WHERE id = ? AND status = 'sending'", + [dueAt, error ? String(error).slice(0, 2000) : null, id], + ) + +/** A terminal outcome: 'sent', 'failed' or 'suppressed'. */ +const finish = (id, status, error) => + query( + `UPDATE engagement_outbox + SET status = ?, last_error = ?, sent_at = IF(? = 'sent', NOW(), sent_at) + WHERE id = ?`, + [status, error ? String(error).slice(0, 2000) : null, status, id], + ) + +/** + * Cancel every still-scheduled row for a (rule, subject) - the point of the + * grace window (§4.2a). `userId` narrows it to one recipient when the resolving + * event names one; a resolving event with no owner cancels for everyone the + * original event was queued for, which is the house-repaired case. + * + * Only 'scheduled' rows are touched: a row already claimed into 'sending' is + * somebody's in-flight send and cancelling it would leave two workers writing + * one row's outcome. + */ +async function cancel(ruleId, subjectKey, userId = null) { + const params = [ruleId, subjectKey] + let sql = "UPDATE engagement_outbox SET status = 'cancelled' WHERE rule_id = ? AND subject_key = ? AND status = 'scheduled'" + if (userId !== null && userId !== undefined) { + sql += ' AND user_id = ?' + params.push(userId) + } + const result = await query(sql, params) + return Number(result?.affectedRows || 0) +} + +/** + * Recover rows stranded in 'sending' by a crash between the claim and the + * outcome. + * + * Without this the CAS claim leaks: the claiming process died, no other sweeper + * will ever match `status = 'scheduled'`, and the row sits in 'sending' forever. + * `updated_at` is the clock (it is ON UPDATE CURRENT_TIMESTAMP, so the claim + * stamped it), and the window has to be comfortably longer than the slowest + * legitimate send or this reclaims rows that are merely slow. + */ +const reclaimStale = (before) => + query( + "UPDATE engagement_outbox SET status = 'scheduled' WHERE status = 'sending' AND updated_at < ?", + [before], + ) + +const getById = async (id) => { + const [row] = await query('SELECT * FROM engagement_outbox WHERE id = ?', [id]) + return hydrate(row) +} + +/** Admin/read surfaces (Phase 4b) and tests. */ +const listForRule = async (ruleId, limit = 100) => + ( + await query('SELECT * FROM engagement_outbox WHERE rule_id = ? ORDER BY id DESC LIMIT ?', [ruleId, limit]) + ).map(hydrate) + +module.exports = { + enqueue, + findDue, + claim, + reschedule, + finish, + cancel, + reclaimStale, + getById, + listForRule, +} diff --git a/server/src/model/engagement/engagementRecipients.db.js b/server/src/model/engagement/engagementRecipients.db.js new file mode 100644 index 0000000..3b85f80 --- /dev/null +++ b/server/src/model/engagement/engagementRecipients.db.js @@ -0,0 +1,125 @@ +const { query } = require('../../utils/db') + +// A bound on every "resolve an audience" query. `authenticated` on a large +// deployment is the whole user table, and the engine turns each id into an +// outbox row - so the read that feeds it has to have a ceiling of its own. The +// per-rule hourly cap (§7.1 Q3) is the operator-facing limit; this is the one +// that keeps a single emit from loading a hundred thousand rows into memory. +const MAX_AUDIENCE = 5000 + +const ids = (rows) => rows.map((r) => Number(r.id)).filter((n) => Number.isInteger(n) && n > 0) + +const marks = (list) => list.map(() => '?').join(', ') + +/** + * Every active user. The `authenticated` audience - and `everyone`, which has no + * distinct meaning here: a signed-out visitor has no address, no device and no + * inbox, so the widest set the engine can actually deliver to is this one. The + * ceiling lattice still distinguishes them (a trigger ceilinged `everyone` + * permits an `authenticated` rule and not the reverse); only the resolution + * coincides. + * + * `status = 'active'` on every query in this file: a banned or disabled account + * is refused at login, and mailing it engagement content would be the one + * surface that did not get the message. + */ +const active = async (limit = MAX_AUDIENCE) => + ids(await query("SELECT id FROM users WHERE status = 'active' ORDER BY id LIMIT ?", [limit])) + +/** The `staff` audience. Roles come from `ceilings.STAFF_CEILING_ROLES`. */ +const staff = async (roles, limit = MAX_AUDIENCE) => { + if (!roles.length) return [] + return ids( + await query( + `SELECT id FROM users WHERE status = 'active' AND role IN (${marks(roles)}) ORDER BY id LIMIT ?`, + [...roles, limit], + ), + ) +} + +/** + * The `subscribers` audience: active users who have opted into this id on at + * least one channel. + * + * "Opted in" is the EFFECTIVE mode, not the stored one, and that is why this is + * not simply `WHERE mode <> 'off'`. A row exists only where a user said + * something; absence means the channel's `defaultMode` (§3.1). All three of + * core's channels default 'off' today, so the second half of the WHERE matches + * nobody - but writing it means the day a channel ships with a non-off default, + * this audience is already right rather than silently excluding everyone who + * never opened the preferences screen. + * + * `defaultOnChannels` is the caller's list of channels whose defaultMode is not + * 'off'; it comes from the channel registry, so the default lives in exactly one + * place here too. + */ +const subscribers = async (streamId, defaultOnChannels = [], limit = MAX_AUDIENCE) => { + const optedIn = `EXISTS ( + SELECT 1 FROM notification_channel_prefs p + WHERE p.user_id = u.id AND p.stream_id = ? AND p.mode <> 'off')` + + if (!defaultOnChannels.length) { + return ids( + await query( + `SELECT u.id FROM users u WHERE u.status = 'active' AND ${optedIn} ORDER BY u.id LIMIT ?`, + [streamId, limit], + ), + ) + } + + // "At least one default-on channel has no row for this user" - counted rather + // than NOT EXISTS, because NOT EXISTS would mean "none of them has a row". + const defaulted = `( + SELECT COUNT(*) FROM notification_channel_prefs p2 + WHERE p2.user_id = u.id AND p2.stream_id = ? AND p2.channel IN (${marks(defaultOnChannels)}) + ) < ?` + + return ids( + await query( + `SELECT u.id FROM users u + WHERE u.status = 'active' AND (${optedIn} OR ${defaulted}) + ORDER BY u.id LIMIT ?`, + [streamId, streamId, ...defaultOnChannels, defaultOnChannels.length, limit], + ), + ) +} + +/** + * Narrow a set of user ids to the active ones. + * + * Every audience that does NOT come from a query in this file goes through here: + * `owner` is a single id off the event envelope, and a module-declared audience + * (§5.1a) is a list of ids a module's own resolver produced. Neither has any + * notion of account status, and a module must not be able to mail a banned + * account by returning its id. + */ +const filterActive = async (userIds) => { + const wanted = [...new Set(userIds.map(Number).filter((n) => Number.isInteger(n) && n > 0))] + if (!wanted.length) return [] + const capped = wanted.slice(0, MAX_AUDIENCE) + return ids( + await query( + `SELECT id FROM users WHERE status = 'active' AND id IN (${marks(capped)}) ORDER BY id`, + capped, + ), + ) +} + +/** + * The stored mode for one (id, channel) across a set of users, as a Map. + * + * The caller applies the channel's `defaultMode` to anyone missing from the map, + * which keeps the defaulting in the one place §3.1 put it. Returning stored rows + * rather than a decision is what makes that possible. + */ +const storedModes = async (userIds, streamId, channel) => { + if (!userIds.length) return new Map() + const rows = await query( + `SELECT user_id, mode FROM notification_channel_prefs + WHERE stream_id = ? AND channel = ? AND user_id IN (${marks(userIds)})`, + [streamId, channel, ...userIds], + ) + return new Map(rows.map((r) => [Number(r.user_id), r.mode])) +} + +module.exports = { active, staff, subscribers, filterActive, storedModes, MAX_AUDIENCE } diff --git a/server/src/model/engagement/engagementRules.db.js b/server/src/model/engagement/engagementRules.db.js new file mode 100644 index 0000000..f003f96 --- /dev/null +++ b/server/src/model/engagement/engagementRules.db.js @@ -0,0 +1,127 @@ +const { query } = require('../../utils/db') + +// JSON columns come back from the driver already parsed on some MariaDB/driver +// combinations and as a string on others (it depends on whether the column is a +// real JSON type or the LONGTEXT + CHECK alias MariaDB implements it as). Every +// read below goes through this, so no caller has to know which it got. +function parseJson(value, fallback) { + if (value === null || value === undefined) return fallback + if (typeof value !== 'string') return value + try { + return JSON.parse(value) + } catch { + return fallback + } +} + +const hydrate = (row) => + row && { + ...row, + enabled: Boolean(row.enabled), + channels: parseJson(row.channels, []), + template_keys: parseJson(row.template_keys, {}), + conditions: parseJson(row.conditions, null), + cancel_on: parseJson(row.cancel_on, []), + } + +const list = async () => + (await query('SELECT * FROM engagement_rules ORDER BY trigger_id, name, id')).map(hydrate) + +const getById = async (id) => { + const [row] = await query('SELECT * FROM engagement_rules WHERE id = ?', [id]) + return hydrate(row) +} + +/** + * Every ENABLED rule for one trigger. The engine's hot path: one indexed read + * per emit, and `idx_engr_trigger (trigger_id, enabled)` is exactly this query. + */ +const enabledForTrigger = async (triggerId) => + (await query('SELECT * FROM engagement_rules WHERE trigger_id = ? AND enabled = 1', [triggerId])).map(hydrate) + +/** + * Every enabled rule that names `triggerId` in its `cancel_on`. + * + * A JSON_CONTAINS rather than a scan: `cancel_on` is a small array on a small + * table, but this runs on EVERY emit — including the overwhelming majority that + * cancel nothing — so it must not be a full table read of the rule set. + */ +const enabledCancelledBy = async (triggerId) => + ( + await query( + "SELECT * FROM engagement_rules WHERE enabled = 1 AND cancel_on IS NOT NULL AND JSON_CONTAINS(cancel_on, JSON_QUOTE(?))", + [triggerId], + ) + ).map(hydrate) + +const insert = async (rule) => { + const result = await query( + `INSERT INTO engagement_rules + (trigger_id, name, enabled, audience, audience_segment_id, max_sends_per_hour, + channels, template_keys, conditions, cooldown_seconds, delay_seconds, cancel_on, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + rule.trigger_id, + rule.name, + rule.enabled ? 1 : 0, + rule.audience, + rule.audience_segment_id, + rule.max_sends_per_hour, + JSON.stringify(rule.channels), + JSON.stringify(rule.template_keys), + rule.conditions === null ? null : JSON.stringify(rule.conditions), + rule.cooldown_seconds, + rule.delay_seconds, + JSON.stringify(rule.cancel_on || []), + rule.updated_by, + ], + ) + return result.insertId +} + +const update = (id, rule) => + query( + `UPDATE engagement_rules + SET name = ?, enabled = ?, audience = ?, audience_segment_id = ?, max_sends_per_hour = ?, + channels = ?, template_keys = ?, conditions = ?, cooldown_seconds = ?, + delay_seconds = ?, cancel_on = ?, updated_by = ? + WHERE id = ?`, + [ + rule.name, + rule.enabled ? 1 : 0, + rule.audience, + rule.audience_segment_id, + rule.max_sends_per_hour, + JSON.stringify(rule.channels), + JSON.stringify(rule.template_keys), + rule.conditions === null ? null : JSON.stringify(rule.conditions), + rule.cooldown_seconds, + rule.delay_seconds, + JSON.stringify(rule.cancel_on || []), + rule.updated_by, + id, + ], + ) + +const remove = (id) => query('DELETE FROM engagement_rules WHERE id = ?', [id]) + +/** Does any rule still point at this segment? The check before a segment delete. */ +const countUsingSegment = async (segmentId) => { + const [row] = await query( + 'SELECT COUNT(*) AS n FROM engagement_rules WHERE audience_segment_id = ?', + [segmentId], + ) + return Number(row?.n || 0) +} + +module.exports = { + list, + getById, + enabledForTrigger, + enabledCancelledBy, + insert, + update, + remove, + countUsingSegment, + parseJson, +} diff --git a/server/src/model/engagement/engagementRules.model.js b/server/src/model/engagement/engagementRules.model.js new file mode 100644 index 0000000..51835e9 --- /dev/null +++ b/server/src/model/engagement/engagementRules.model.js @@ -0,0 +1,225 @@ +// ── Engagement rules — the save path ─────────────────────────────────────── +// +// ENGAGEMENT.md §4.5 / §7.1 Q3, Phase 4a. A rule is **operator-editable data**, +// not code, and that was a deliberate choice with a condition attached: it is +// safe to choose only because `enabled` defaults to 0 and every rule carries a +// hard per-hour send ceiling. Both of those live in this file's validation, not +// in the screen that calls it - Phase 4b builds a form over this, and a rule that +// arrives by any other route (a restore, a fixture, a future import) gets the +// same answer. +// +// **Every check here is a boundary, not a convenience.** The rule editor will +// re-implement some of them for the sake of a good error message, and that +// second copy is expected to drift - so this one is the one that decides. +// +// The check with teeth is the ceiling (G24): an operator may narrow a rule's +// audience as much as they like and may never widen it past what the trigger +// declared. `ceilings.permits` is that arithmetic, `segments.validate` derives +// it for a composed audience, and the engine re-runs the same check at SEND +// time in case a module upgrade narrowed the declaration underneath a saved rule. + +const db = require('./engagementRules.db') +const segmentsDb = require('./engagementSegments.db') +const registries = require('../../modules/registries') +const ceilings = require('../../modules/ceilings') +const channels = require('../../engagement/channels') +const conditions = require('../../engagement/conditions') + +// A day. Longer than this and "cooldown" is really "send once", which a rule +// expresses by being disabled rather than by a decade-long interval. +const MAX_COOLDOWN_SECONDS = 86_400 +// The grace window (§4.2a). A delay longer than a day outlives the thing it is +// about - and, more practically, a queue row that sits for a week is a row whose +// payload no longer describes the world. +const MAX_DELAY_SECONDS = 86_400 +// The upper bound on the operator-set hourly ceiling. It is not "unlimited by +// another name": the number exists so that a misconfiguration is a bad hour +// rather than an unbounded one, and a ceiling nobody can raise past a bound is +// what makes rules-as-data safe (§7.1 Q3). +const MAX_SENDS_PER_HOUR = 10_000 + +const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) + +/** + * Validate a rule against the registries and the lattice. + * + * Returns `{ ok: true, rule }` with a normalised row ready for insert/update, or + * `{ ok: false, errors }` listing every problem. + * + * `triggerId` may name a trigger nobody currently registers ONLY on an update of + * an existing rule - a dormant rule must stay editable (its module can come + * back), and refusing to save it would make an uninstall destructive after the + * fact. A NEW rule must name a live trigger, because there is nothing to + * preserve and a typo should be caught now. + */ +async function validate(input, { existing = null } = {}) { + const errors = [] + const raw = isPlainObject(input) ? input : {} + + const triggerId = typeof raw.triggerId === 'string' ? raw.triggerId : existing?.trigger_id + const declaration = triggerId ? registries.eventTrigger(triggerId) : null + if (!triggerId) errors.push('triggerId is required') + else if (!declaration && !existing) errors.push(`no trigger "${triggerId}" is registered`) + + const name = typeof raw.name === 'string' ? raw.name.trim() : '' + if (!name) errors.push('name is required') + else if (name.length > 160) errors.push('name is longer than 160 characters') + + // Channels are stored as data and checked against the registry, so a rule + // cannot name a sink that does not exist. Phase 4b's form offers the registered + // set; this is what makes that an affordance rather than the rule. + const wanted = Array.isArray(raw.channels) ? [...new Set(raw.channels)] : [] + if (!wanted.length) errors.push('at least one channel is required') + for (const c of wanted) if (!channels.has(c)) errors.push(`no channel "${c}" is registered`) + + // `template_keys` is { channel: templateKey }. Phase 5 owns templates, so the + // KEYS are checked for shape and not for existence - a rule may legitimately + // name a template that has not been authored yet, and Phase 5's editor is where + // that becomes resolvable. + const templateKeys = {} + if (raw.templateKeys !== undefined && !isPlainObject(raw.templateKeys)) { + errors.push('templateKeys must be an object of { channel: templateKey }') + } else { + for (const [channel, key] of Object.entries(raw.templateKeys || {})) { + if (!wanted.includes(channel)) { + errors.push(`templateKeys names "${channel}", which is not one of this rule's channels`) + continue + } + if (typeof key !== 'string' || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(key)) { + errors.push(`templateKeys.${channel} is not a valid template key`) + continue + } + templateKeys[channel] = key + } + } + + const numbers = [ + ['cooldownSeconds', 'cooldown_seconds', MAX_COOLDOWN_SECONDS, 0], + ['delaySeconds', 'delay_seconds', MAX_DELAY_SECONDS, 0], + ['maxSendsPerHour', 'max_sends_per_hour', MAX_SENDS_PER_HOUR, 1], + ] + const scalars = {} + for (const [key, column, max, min] of numbers) { + const supplied = raw[key] + const fallback = existing ? existing[column] : column === 'max_sends_per_hour' ? 100 : 0 + const value = supplied === undefined || supplied === null ? fallback : Number(supplied) + if (!Number.isInteger(value) || value < min || value > max) { + errors.push(`${key} must be an integer between ${min} and ${max}`) + } else scalars[column] = value + } + + // `cancel_on` names trigger ids, and they are NOT checked for registration for + // the dormancy reason (§7.3): a resolving event whose module is temporarily + // absent should stop cancelling, not make the rule unsaveable. + const cancelOn = Array.isArray(raw.cancelOn) ? [...new Set(raw.cancelOn.filter((t) => typeof t === 'string'))] : [] + if (cancelOn.length && !scalars.delay_seconds) { + // Not an error - it is a rule that will never cancel anything, because there + // is no window in which to do it. Worth saying out loud rather than silently + // accepting a setting that cannot take effect. + errors.push('cancelOn has no effect without a delaySeconds grace window') + } + + const checked = conditions.validate(declaration, raw.conditions === undefined ? existing?.conditions : raw.conditions) + if (!checked.ok) errors.push(...checked.errors) + + // ── The audience, and the one check that is a security boundary ────────── + let audience = typeof raw.audience === 'string' ? raw.audience : existing?.audience || declaration?.audience + let segmentId = raw.audienceSegmentId === undefined ? existing?.audience_segment_id ?? null : raw.audienceSegmentId + segmentId = segmentId === null || segmentId === '' ? null : Number(segmentId) + + let effectiveCeiling = null + if (segmentId !== null) { + if (!Number.isInteger(segmentId)) errors.push('audienceSegmentId must be an integer') + else { + const segment = await segmentsDb.getById(segmentId) + if (!segment) errors.push(`no audience segment ${segmentId} exists`) + else { + // The segment's STORED ceiling, derived when it was saved by + // `segments.validate` from the narrowest audience it contains. A rule + // pointing at a segment takes that as its reach; the `audience` column + // is retained for display and is not what the engine resolves. + effectiveCeiling = segment.ceiling + audience = segment.ceiling + } + } + } else if (!ceilings.isCeiling(audience)) { + errors.push(`audience must be one of ${ceilings.CEILINGS.join(', ')}`) + } else { + effectiveCeiling = audience + } + + if (declaration && effectiveCeiling && !ceilings.permits(declaration.ceiling, effectiveCeiling)) { + errors.push( + `audience "${effectiveCeiling}" is wider than trigger "${triggerId}" permits (ceiling "${declaration.ceiling}")`, + ) + } + + if (errors.length) return { ok: false, errors } + + return { + ok: true, + rule: { + trigger_id: triggerId, + name, + enabled: raw.enabled === undefined ? Boolean(existing?.enabled) : Boolean(raw.enabled), + audience, + audience_segment_id: segmentId, + max_sends_per_hour: scalars.max_sends_per_hour, + channels: wanted, + template_keys: templateKeys, + conditions: checked.conditions, + cooldown_seconds: scalars.cooldown_seconds, + delay_seconds: scalars.delay_seconds, + cancel_on: cancelOn, + updated_by: Number.isInteger(raw.updatedBy) ? raw.updatedBy : null, + }, + } +} + +async function create(input) { + const checked = await validate(input) + if (!checked.ok) return checked + const id = await db.insert(checked.rule) + return { ok: true, rule: await db.getById(id) } +} + +async function update(id, input) { + const existing = await db.getById(id) + if (!existing) return { ok: false, errors: [`no rule ${id} exists`], notFound: true } + const checked = await validate(input, { existing }) + if (!checked.ok) return checked + await db.update(id, checked.rule) + return { ok: true, rule: await db.getById(id) } +} + +/** + * List every rule, each annotated with whether it can currently fire. + * + * Dormancy is computed rather than stored (§7.3): a rule whose trigger or + * segment is not registered right now is listed, flagged, and left alone. The + * alternative - deleting or disabling it on uninstall - destroys an operator's + * configuration on the strength of a module being temporarily absent. + */ +async function listAnnotated() { + const rows = await db.list() + const segments = new Map((await segmentsDb.list()).map((s) => [s.id, s])) + return rows.map((rule) => { + const reasons = [] + if (!registries.eventTrigger(rule.trigger_id)) reasons.push(`trigger "${rule.trigger_id}" is not registered`) + if (rule.audience_segment_id && !segments.has(rule.audience_segment_id)) { + reasons.push('its audience segment no longer exists') + } + for (const c of rule.channels || []) if (!channels.has(c)) reasons.push(`channel "${c}" is not registered`) + return { ...rule, dormant: reasons.length > 0, dormantReasons: reasons } + }) +} + +module.exports = { + validate, + create, + update, + listAnnotated, + MAX_COOLDOWN_SECONDS, + MAX_DELAY_SECONDS, + MAX_SENDS_PER_HOUR, +} diff --git a/server/src/model/engagement/engagementSegments.db.js b/server/src/model/engagement/engagementSegments.db.js new file mode 100644 index 0000000..c8fee62 --- /dev/null +++ b/server/src/model/engagement/engagementSegments.db.js @@ -0,0 +1,37 @@ +const { query } = require('../../utils/db') +const { parseJson } = require('./engagementRules.db') + +const hydrate = (row) => row && { ...row, expression: parseJson(row.expression, null) } + +const list = async () => + (await query('SELECT * FROM engagement_audience_segments ORDER BY name, id')).map(hydrate) + +const getById = async (id) => { + const [row] = await query('SELECT * FROM engagement_audience_segments WHERE id = ?', [id]) + return hydrate(row) +} + +/** + * `ceiling` is written by the caller from `segments.deriveCeiling`, never taken + * from an operator. It is a stored column rather than a runtime computation so + * an audit can read what a rule was ALLOWED to reach without re-resolving it, + * and so a module that later widens its own audience's ceiling cannot + * retroactively widen a segment that was saved under the old one. + */ +const insert = async (segment) => { + const result = await query( + 'INSERT INTO engagement_audience_segments (name, expression, ceiling, updated_by) VALUES (?, ?, ?, ?)', + [segment.name, JSON.stringify(segment.expression), segment.ceiling, segment.updated_by ?? null], + ) + return result.insertId +} + +const update = (id, segment) => + query( + 'UPDATE engagement_audience_segments SET name = ?, expression = ?, ceiling = ?, updated_by = ? WHERE id = ?', + [segment.name, JSON.stringify(segment.expression), segment.ceiling, segment.updated_by ?? null, id], + ) + +const remove = (id) => query('DELETE FROM engagement_audience_segments WHERE id = ?', [id]) + +module.exports = { list, getById, insert, update, remove } diff --git a/server/src/model/engagement/engagementSegments.model.js b/server/src/model/engagement/engagementSegments.model.js new file mode 100644 index 0000000..dac36b0 --- /dev/null +++ b/server/src/model/engagement/engagementSegments.model.js @@ -0,0 +1,87 @@ +// ── Audience segments — the save path ────────────────────────────────────── +// +// ENGAGEMENT.md §5.1a, Phase 4a. The thin model over `segments.js`: it validates, +// derives the ceiling, and writes. The composition UI is Phase 4b's; this is what +// it will call, and what any other route in must go through. +// +// The `ceiling` column is never taken from the caller. It is derived from the +// expression by `segments.validate` as the narrowest ceiling in the tree, and +// stored so an audit can read what a rule was ALLOWED to reach without +// re-resolving it. + +const db = require('./engagementSegments.db') +const rulesDb = require('./engagementRules.db') +const registries = require('../../modules/registries') +const segments = require('../../engagement/segments') + +async function save(input, { id = null } = {}) { + const errors = [] + const name = typeof input?.name === 'string' ? input.name.trim() : '' + if (!name) errors.push('name is required') + else if (name.length > 160) errors.push('name is longer than 160 characters') + + const checked = segments.validate(input?.expression) + if (!checked.ok) errors.push(...checked.errors) + if (errors.length) return { ok: false, errors } + + const row = { + name, + expression: checked.expression, + ceiling: checked.ceiling, + updated_by: Number.isInteger(input?.updatedBy) ? input.updatedBy : null, + } + + if (id) { + const existing = await db.getById(id) + if (!existing) return { ok: false, errors: [`no segment ${id} exists`], notFound: true } + await db.update(id, row) + return { ok: true, segment: await db.getById(id) } + } + const newId = await db.insert(row) + return { ok: true, segment: await db.getById(newId) } +} + +/** + * Delete a segment, refusing while a rule still points at it. + * + * There is deliberately no foreign key doing this (schema.sql): the database + * options are CASCADE, which would delete an operator's rules, and SET NULL, + * which would silently fall the rule back to its plain `audience` column and mail + * a DIFFERENT set of people. Refusing here, with the count, is the third option + * and the only safe one. + */ +async function remove(id) { + const inUse = await rulesDb.countUsingSegment(id) + if (inUse > 0) { + return { + ok: false, + inUse, + errors: [`${inUse} rule${inUse === 1 ? '' : 's'} still use this segment`], + } + } + await db.remove(id) + return { ok: true } +} + +/** + * Every segment, each annotated with whether it can currently resolve. + * + * A segment naming an audience whose module has been uninstalled is DORMANT, not + * broken: it is listed, it resolves to nobody, and it starts working again when + * the module comes back (§5.1a rule 4). + */ +async function listAnnotated() { + const rows = await db.list() + return rows.map((segment) => { + const missing = [] + const walk = (node) => { + if (!node || typeof node !== 'object') return + if (node.op) (node.nodes || []).forEach(walk) + else if (!registries.audience(node.audienceId)) missing.push(node.audienceId) + } + walk(segment.expression) + return { ...segment, dormant: missing.length > 0, missingAudiences: [...new Set(missing)] } + }) +} + +module.exports = { save, remove, listAnnotated } diff --git a/server/src/model/engagement/engagementSends.db.js b/server/src/model/engagement/engagementSends.db.js new file mode 100644 index 0000000..d9b2873 --- /dev/null +++ b/server/src/model/engagement/engagementSends.db.js @@ -0,0 +1,73 @@ +const { query } = require('../../utils/db') + +/** + * Record one attempt's outcome. G15: "did user X get the mail?" has never been + * answerable on this deployment, and this row is the answer. + * + * `address_hash` is a sha256 the CALLER computes, never an address. The log has + * to correlate a bounce back to a recipient (Phase 9) and it must not become a + * second address book, and a hash does the first without the second. + */ +const record = async (entry) => { + const result = await query( + `INSERT INTO engagement_sends + (outbox_id, rule_id, trigger_id, user_id, channel, transport, address_hash, status, detail) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + entry.outbox_id ?? null, + entry.rule_id ?? null, + entry.trigger_id, + entry.user_id ?? null, + entry.channel, + entry.transport ?? null, + entry.address_hash ?? null, + entry.status, + entry.detail ? String(entry.detail).slice(0, 500) : null, + ], + ) + return result.insertId +} + +/** + * How many sends this rule has made in the last hour - the count the per-rule + * ceiling (§7.1 Q3) is enforced against. + * + * It counts 'sent' only. A refusal that never left the building (`suppressed`) + * and an attempt that failed are not sends, and counting them would let a broken + * transport silently consume a rule's whole hourly budget and mute it. + * + * `idx_engs_rule_window (rule_id, created_at)` exists for this: it runs once per + * rule per event, so it has to be an index range scan. + */ +const countSentSince = async (ruleId, since) => { + const [row] = await query( + "SELECT COUNT(*) AS n FROM engagement_sends WHERE rule_id = ? AND status = 'sent' AND created_at >= ?", + [ruleId, since], + ) + return Number(row?.n || 0) +} + +/** The admin send log (Phase 4b/5), newest first. */ +const list = ({ triggerId = null, userId = null, ruleId = null, limit = 100, offset = 0 } = {}) => { + const where = [] + const params = [] + if (triggerId) { + where.push('trigger_id = ?') + params.push(triggerId) + } + if (userId) { + where.push('user_id = ?') + params.push(userId) + } + if (ruleId) { + where.push('rule_id = ?') + params.push(ruleId) + } + const clause = where.length ? `WHERE ${where.join(' AND ')}` : '' + return query( + `SELECT * FROM engagement_sends ${clause} ORDER BY id DESC LIMIT ? OFFSET ?`, + [...params, limit, offset], + ) +} + +module.exports = { record, countSentSince, list } diff --git a/server/src/server.js b/server/src/server.js index fccd480..3efb3b7 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -12,6 +12,7 @@ const announceWorker = require('./utils/announceWorker') const teamActivityPrune = require('./utils/teamActivityPrune') const teamForumUploadSweep = require('./utils/teamForumUploadSweep') const teamDigestWorker = require('./utils/teamDigestWorker') +const engagementWorker = require('./utils/engagementWorker') const { ensureSchema, close } = require('./utils/db') const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed') const settings = require('./model/settings/settings.model') @@ -160,6 +161,10 @@ async function start() { teamForumUploadSweep.start() teamDigestWorker.start() + // Drain the engagement outbox (ENGAGEMENT.md §4.2a). No-op until an operator + // enables a rule: core seeds none and `enabled` defaults to 0. + engagementWorker.start() + setupShutdown(server, internalServer) } @@ -180,6 +185,7 @@ function setupShutdown(server, internalServer) { teamActivityPrune.stop() // stop the Team activity retention timer teamForumUploadSweep.stop() // stop the forum upload sweep teamDigestWorker.stop() // stop the Team forum digest timer + engagementWorker.stop() // stop the engagement outbox worker server.close(() => log.info('http server closed')) if (internalServer) internalServer.close(() => log.info('internal http server closed')) try { diff --git a/server/src/utils/engagementEmit.js b/server/src/utils/engagementEmit.js index c756738..e3e2262 100644 --- a/server/src/utils/engagementEmit.js +++ b/server/src/utils/engagementEmit.js @@ -1,16 +1,17 @@ // ── ctx.events.emit — the validating half of the engagement seam ──────────── // -// ENGAGEMENT.md §4.3 and §5.2, Phase 2. A registrant fires a declared event with -// a payload; this checks the payload against the declaration and stops there. -// **There is no delivery in this phase** — no rules, no cooldowns, no outbox, no -// mail. Phase 4 replaces the log line at the bottom with the engine call, and -// every validation rule below is already the one it will need. +// ENGAGEMENT.md §4.3 and §5.2. A registrant fires a declared event with a +// payload; this checks the payload against the declaration and, since Phase 4a, +// hands the validated event to the engine. // -// Landing the contract a phase before the engine is deliberate, and it is the +// Landing the contract a phase before the engine was deliberate, and it is the // same argument registerCore() has always made: a seam whose first real exercise -// is the thing that depends on it is a seam that has already drifted. Phase 6 -// migrates the Team mail onto this, and it should be migrating onto a validator -// that has been running against core's own five triggers since Phase 2. +// is the thing that depends on it is a seam that has already drifted. Every +// validation rule below was written in Phase 2 for a caller that did not exist +// yet, and the engine needed none of them changed. +// +// **The engine call is deliberately not awaited** — see `emit` below. Phase 6 +// migrates the Team mail onto this. // // **Two postures, one switch.** A malformed emit THROWS in development and is // DROPPED AND LOGGED in production, which is `ctx.teams.activity.push`'s posture @@ -20,6 +21,7 @@ // silently loses a variable is a template that silently renders `undefined`. const registries = require('../modules/registries') +const engine = require('../engagement/engine') const createLogger = require('./logger') const log = createLogger('engagement') @@ -203,8 +205,6 @@ function emit(owner, triggerId, envelope = {}) { data: payload.data, } - // Phase 2 ends here: validated, recorded, and deliberately undelivered. - // // The values are NOT logged. A payload carries player names, house locations // and forum excerpts, and an event log that reproduces them is a second copy // of exactly the content §4.5 was careful to keep out of `engagement_sends` @@ -217,6 +217,19 @@ function emit(owner, triggerId, envelope = {}) { variables: Object.keys(event.data), }) + // **Not awaited, and this is the point of the whole seam.** `emit` is called + // from inside a game-event handler; the caller's job is to say the event + // happened, and it must not be made to wait on rule lookups, audience + // resolution and a dozen inserts to find out whether it is allowed to carry on. + // That is the same reason the C# side's `Emit()` enqueues and returns rather + // than touching the socket from the Core thread. `dispatch` catches everything + // internally and never rejects, and the `.catch` is the belt to that braces. + // + // The consequence a test has to know about: `emit` returns before the outbox + // rows exist. `engine.dispatch(event)` is exported for a caller that needs to + // await the delivery decision, and the tests use it directly. + engine.dispatch(event).catch((err) => log.error('dispatch rejected', { trigger: triggerId, message: err.message })) + return { ok: true, event } } diff --git a/server/src/utils/engagementWorker.js b/server/src/utils/engagementWorker.js new file mode 100644 index 0000000..bea3771 --- /dev/null +++ b/server/src/utils/engagementWorker.js @@ -0,0 +1,168 @@ +// ── Engagement outbox worker ─────────────────────────────────────────────── +// +// ENGAGEMENT.md §4.2a, Phase 4a. Every ENGAGEMENT_POLL_MS it sweeps +// `engagement_outbox` for rows whose `due_at` has passed, claims each one, hands +// it to its channel, and records the outcome in `engagement_sends`. Same +// setInterval + unref + stop() shape as `announceWorker` and the three Team +// sweepers, wired into server.js start/shutdown beside them. +// +// **Claiming is a compare-and-set, not a lock** (§7.1 Q2, settled by the org lead +// 2026-08-29 over `SELECT ... FOR UPDATE SKIP LOCKED`): an +// `UPDATE ... SET status='sending' WHERE id=? AND status='scheduled'`, and the +// instance the server reports `affectedRows = 1` to owns the row. No transaction +// to hold open, no MariaDB version floor, and it uses a status the ENUM already +// carried for exactly this. What it makes safe is the outbox; the four existing +// workers are still single-instance, so this does not by itself make the +// deployment multi-instance. +// +// **Nothing is delivered in this phase, and that is visible rather than +// pretended.** A channel's `deliver` arrives with email in Phase 6 and the in-app +// inbox in Phase 7; until then `channels.get(id)` has no such function, the row +// finishes as `failed` and the send log says why in as many words. The +// alternatives were both worse: recording 'sent' would be a lie in the one table +// whose whole purpose is answering "did they get it", and leaving the row +// scheduled would mean an IDOC warning queued today arriving three weeks later +// on the deploy that first shipped a mailer. +// +// In practice this path is unreachable on a real deployment for now: core seeds +// no rules and `enabled` defaults to 0, so the outbox stays empty until an +// operator turns a rule on from the screen Phase 4b builds. + +const outboxDb = require('../model/engagement/engagementOutbox.db') +const sendsDb = require('../model/engagement/engagementSends.db') +const channels = require('../engagement/channels') +const log = require('./logger')('engagement-worker') + +const POLL_MS = Number(process.env.ENGAGEMENT_POLL_MS) || 30_000 +// How many rows one sweep will look at. A bound rather than a target: the sweep +// runs again in POLL_MS, and an unbounded batch is how a backlog turns one tick +// into a stall. +const BATCH = Number(process.env.ENGAGEMENT_BATCH) || 100 + +// A transient failure is retried with a flat backoff, and then given up on. +// Flat rather than exponential because `due_at` is also the grace window's clock +// and a doubling backoff would push a delayed message arbitrarily far past the +// moment it was about. +const MAX_ATTEMPTS = 5 +const RETRY_MS = 5 * 60 * 1000 + +// A row claimed into 'sending' by a process that then died is invisible to every +// other sweeper - `status='scheduled'` will never match it again. This window is +// how long a claim may look alive before it is taken back; it has to be +// comfortably longer than the slowest legitimate send or a slow one gets sent +// twice. +const STALE_MS = 15 * 60 * 1000 + +/** + * Deliver one claimed row. + * + * @returns {{ outcome: 'sent'|'retry'|'terminal', detail?: string, transport?: string }} + */ +async function deliver(row) { + const channel = channels.get(row.channel) + if (!channel) { + // The channel's module was removed between enqueue and now. Terminal: there + // is nothing to retry towards, and leaving the row scheduled would make it + // sweep forever. + return { outcome: 'terminal', detail: `channel "${row.channel}" is no longer registered` } + } + if (typeof channel.deliver !== 'function') { + return { outcome: 'terminal', detail: `channel "${row.channel}" has no delivery implementation yet` } + } + try { + const result = await channel.deliver(row) + if (result && result.ok) return { outcome: 'sent', transport: result.transport, detail: result.detail } + if (result && result.retry) return { outcome: 'retry', detail: result.detail || 'transient failure' } + return { outcome: 'terminal', detail: (result && result.detail) || 'delivery refused' } + } catch (err) { + // A channel shouldn't throw, but if one does it is a transient failure + // rather than a crashed tick - announceWorker's posture with its legs. + log.error('channel deliver threw', { outbox: row.id, channel: row.channel, message: err.message }) + return { outcome: 'retry', detail: err.message } + } +} + +/** + * Claim, deliver, record. One row, start to finish. + * + * `deliverFn` is injectable so a test can drive the retry/give-up path without a + * channel that fails on demand - the alternative is registering a fake channel, + * which would make the registry, not this function, the thing under test. + */ +async function processRow(row, now = new Date(), deliverFn = deliver) { + if (!(await outboxDb.claim(row.id))) return null // another sweeper got there first + + const result = await deliverFn(row) + + if (result.outcome === 'retry' && row.attempts + 1 < MAX_ATTEMPTS) { + await outboxDb.reschedule(row.id, new Date(now.getTime() + RETRY_MS), result.detail) + return 'retry' + } + + const status = result.outcome === 'sent' ? 'sent' : 'failed' + await outboxDb.finish(row.id, status, status === 'failed' ? result.detail : null) + // 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. + await sendsDb.record({ + outbox_id: row.id, + rule_id: row.rule_id, + trigger_id: row.trigger_id, + user_id: row.user_id, + channel: row.channel, + transport: result.transport ?? null, + status, + detail: result.detail ?? null, + }) + return status +} + +async function tick(now = new Date()) { + try { + await outboxDb.reclaimStale(new Date(now.getTime() - STALE_MS)) + } catch (err) { + log.error('failed to reclaim stale rows', { message: err.message }) + } + + let due + try { + due = await outboxDb.findDue(now, BATCH) + } catch (err) { + log.error('failed to load due rows', { message: err.message }) + return + } + if (!due || !due.length) return + + const counts = { sent: 0, failed: 0, retry: 0, taken: 0 } + for (const row of due) { + try { + const outcome = await processRow(row, now) + if (outcome === null) counts.taken += 1 + else counts[outcome] += 1 + } catch (err) { + log.error('row failed', { outbox: row.id, message: err.message }) + } + } + log.info('outbox swept', counts) +} + +let timer = null + +function start() { + if (timer) return timer + timer = setInterval(() => { + tick().catch((err) => log.error('engagement tick failed', { message: err.message })) + }, POLL_MS) + if (timer.unref) timer.unref() // don't keep the event loop alive (tests, shutdown) + log.info('engagement outbox worker started', { pollMs: POLL_MS, batch: BATCH }) + return timer +} + +function stop() { + if (timer) { + clearInterval(timer) + timer = null + } +} + +module.exports = { start, stop, tick, processRow, deliver, POLL_MS, MAX_ATTEMPTS, RETRY_MS, STALE_MS } diff --git a/server/test/engagementEngine.test.js b/server/test/engagementEngine.test.js new file mode 100644 index 0000000..612e792 --- /dev/null +++ b/server/test/engagementEngine.test.js @@ -0,0 +1,908 @@ +// ── The engagement engine (ENGAGEMENT.md Phase 4a) ───────────────────────── +// +// The phase's acceptance criteria, one test apiece: +// +// • a trigger fired twice inside `cooldown_seconds` for the same +// (rule, user, subject) sends once +// • the same trigger for a DIFFERENT subject sends again — the multi-house +// case §4.1 names, which is the one a per-user cooldown gets wrong +// • a scheduled row is cancelled by a `cancel_on` trigger and never sends +// • a restart mid-window still sends exactly once +// • a duplicate `dedupe_key` is a successful no-op +// +// …plus the two properties that are security boundaries rather than behaviour: +// the G24 ceiling is re-checked at SEND time and not only at save, and a composed +// segment takes the NARROWEST ceiling in its tree. +// +// **The five tables are stubbed at the `.db` layer** and the engine's own logic +// runs for real against them, the shape `notificationChannelPrefs.test.js` uses. +// The one place that is not enough is the raw SQL whose correctness IS a server +// contract - the cooldown claim, the outbox compare-and-set, and the scoped +// dedupe key. Those run against a real MariaDB in `engagementEngineSql.test.js`, +// which skips when there is none, and the first time it ran it disproved the +// cooldown statement this file's stub had been agreeing with. +// +// 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 conditions = require('../src/engagement/conditions') +const segments = require('../src/engagement/segments') +const worker = require('../src/utils/engagementWorker') +const rules = require('../src/model/engagement/engagementRules.model') +const rulesDb = require('../src/model/engagement/engagementRules.db') +const outboxDb = require('../src/model/engagement/engagementOutbox.db') +const cooldownsDb = require('../src/model/engagement/engagementCooldowns.db') +const sendsDb = require('../src/model/engagement/engagementSends.db') +const segmentsDb = require('../src/model/engagement/engagementSegments.db') +const recipients = require('../src/model/engagement/engagementRecipients.db') +const db = require('../src/utils/db') + +after(() => db.close()) + +const T0 = new Date('2026-08-29T12:00:00Z') +const later = (ms) => new Date(T0.getTime() + ms) + +// ── In-memory stand-ins for the five tables ──────────────────────────────── + +let store +const originals = {} + +function snapshotOriginals() { + for (const [name, mod] of [ + ['rulesDb', rulesDb], ['outboxDb', outboxDb], ['cooldownsDb', cooldownsDb], + ['sendsDb', sendsDb], ['segmentsDb', segmentsDb], ['recipients', recipients], + ]) { + originals[name] = { mod, fns: { ...mod } } + } +} +snapshotOriginals() + +function restoreOriginals() { + for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns) +} + +function installStubs() { + store = { + rules: new Map(), + segments: new Map(), + cooldowns: new Map(), + outbox: new Map(), + sends: [], + users: new Map(), // id -> { id, role, status } + prefs: new Map(), // " " -> mode + nextOutboxId: 1, + } + + rulesDb.enabledForTrigger = async (triggerId) => + [...store.rules.values()].filter((r) => r.enabled && r.trigger_id === triggerId) + rulesDb.enabledCancelledBy = async (triggerId) => + [...store.rules.values()].filter((r) => r.enabled && (r.cancel_on || []).includes(triggerId)) + rulesDb.getById = async (id) => store.rules.get(id) || null + rulesDb.list = async () => [...store.rules.values()] + rulesDb.countUsingSegment = async (segmentId) => + [...store.rules.values()].filter((r) => r.audience_segment_id === segmentId).length + + segmentsDb.getById = async (id) => store.segments.get(id) || null + segmentsDb.list = async () => [...store.segments.values()] + + // The two statements' semantics, reproduced: a guarded UPDATE that matches + // claims the fire; otherwise an INSERT IGNORE claims a first fire; otherwise + // the pair is still cooling. `engagementEngineSql.test.js` is what proves the + // SQL itself - a stub can only ever agree with whoever wrote it, and in this + // case the first version of both was wrong together. + cooldownsDb.claim = async (ruleId, userId, subjectKey, cooldownSeconds, now) => { + const key = `${ruleId}|${userId}|${subjectKey}` + const row = store.cooldowns.get(key) + if (!row) { + store.cooldowns.set(key, { last_fired_at: now, fire_count: 1 }) + return true + } + if (row.last_fired_at.getTime() <= now.getTime() - cooldownSeconds * 1000) { + row.fire_count += 1 + row.last_fired_at = now + return true + } + return false + } + + outboxDb.enqueue = async (row) => { + if (row.dedupe_key) { + const clash = [...store.outbox.values()].find( + (r) => + r.dedupe_key === row.dedupe_key && + r.rule_id === row.rule_id && + r.user_id === row.user_id && + r.channel === row.channel, + ) + if (clash) return null + } + const id = store.nextOutboxId++ + store.outbox.set(id, { id, status: 'scheduled', attempts: 0, subject_key: '', ...row }) + return id + } + // Copies, not the live objects: a SQL SELECT hands back a snapshot, and + // `processRow` reads `row.attempts` as the value BEFORE its own claim + // incremented it. Returning references here made the retry budget off by one + // in the stub only, which is exactly the class of thing a stub must not invent. + outboxDb.findDue = async (now, limit = 100) => + [...store.outbox.values()] + .filter((r) => r.status === 'scheduled' && r.due_at <= now) + .sort((a, b) => a.due_at - b.due_at || a.id - b.id) + .slice(0, limit) + .map((r) => ({ ...r })) + outboxDb.claim = async (id) => { + const row = store.outbox.get(id) + if (!row || row.status !== 'scheduled') return false + row.status = 'sending' + row.attempts += 1 + return true + } + outboxDb.reschedule = async (id, dueAt, error) => { + const row = store.outbox.get(id) + if (row && row.status === 'sending') Object.assign(row, { status: 'scheduled', due_at: dueAt, last_error: error }) + } + outboxDb.finish = async (id, status, error) => { + const row = store.outbox.get(id) + if (row) Object.assign(row, { status, last_error: error }) + } + outboxDb.cancel = async (ruleId, subjectKey, userId = null) => { + let n = 0 + for (const row of store.outbox.values()) { + if (row.rule_id !== ruleId || row.subject_key !== subjectKey || row.status !== 'scheduled') continue + if (userId !== null && userId !== undefined && row.user_id !== userId) continue + row.status = 'cancelled' + n += 1 + } + return n + } + outboxDb.reclaimStale = async () => {} + outboxDb.getById = async (id) => (store.outbox.has(id) ? { ...store.outbox.get(id) } : null) + + sendsDb.record = async (entry) => { + store.sends.push({ id: store.sends.length + 1, created_at: T0, ...entry }) + return store.sends.length + } + sendsDb.countSentSince = async (ruleId, since) => + store.sends.filter((s) => s.rule_id === ruleId && s.status === 'sent' && s.created_at >= since).length + + const activeIds = () => [...store.users.values()].filter((u) => u.status === 'active').map((u) => u.id) + recipients.active = async () => activeIds() + recipients.staff = async (roles) => + [...store.users.values()].filter((u) => u.status === 'active' && roles.includes(u.role)).map((u) => u.id) + recipients.subscribers = async (streamId, defaultOn = []) => + activeIds().filter((id) => { + const rows = [...store.prefs.entries()].filter(([k]) => k.startsWith(`${id} ${streamId} `)) + if (rows.some(([, mode]) => mode !== 'off')) return true + const named = new Set(rows.map(([k]) => k.split(' ')[2])) + return defaultOn.some((c) => !named.has(c)) + }) + recipients.filterActive = async (ids) => + [...new Set(ids)].filter((id) => store.users.get(id)?.status === 'active') + recipients.storedModes = async (userIds, streamId, channel) => + new Map( + userIds + .filter((id) => store.prefs.has(`${id} ${streamId} ${channel}`)) + .map((id) => [id, store.prefs.get(`${id} ${streamId} ${channel}`)]), + ) +} + +// ── Fixtures ─────────────────────────────────────────────────────────────── + +const addUser = (id, over = {}) => store.users.set(id, { id, role: 'player', status: 'active', ...over }) +const optIn = (userId, streamId, channel, mode = 'instant') => + store.prefs.set(`${userId} ${streamId} ${channel}`, mode) + +let nextRuleId = 1 +function addRule(over = {}) { + const id = nextRuleId++ + const rule = { + id, + trigger_id: 'uo.house.idoc_warning', + name: `rule ${id}`, + enabled: true, + audience: 'owner', + audience_segment_id: null, + max_sends_per_hour: 100, + channels: ['email'], + template_keys: {}, + conditions: null, + cooldown_seconds: 0, + delay_seconds: 0, + cancel_on: [], + ...over, + } + store.rules.set(id, rule) + return rule +} + +/** A validated event envelope, the shape `engagementEmit.emit` builds. */ +const event = (over = {}) => ({ + triggerId: 'uo.house.idoc_warning', + owner: 'uo', + version: 1, + subject: 'house-4001', + ownerUserId: 10, + dedupeKey: null, + occurredAt: T0.toISOString(), + data: { house: 'The Silver Anvil', decayStatus: 'IDOC' }, + ...over, +}) + +/** Register a batch, the way the loader's second pass commits one. */ +function register(owner, fn) { + const api = registries.stage(owner) + fn(api) + registries.apply(api.staged) +} + +const IDOC_TRIGGER = { + id: 'uo.house.idoc_warning', + label: 'House approaching collapse', + ceiling: 'owner', + audience: 'owner', + subjectKey: 'house', + variables: [ + { name: 'house', type: 'string', required: true, example: 'The Silver Anvil' }, + { name: 'decayStatus', type: 'string', required: false, example: 'IDOC' }, + ], +} + +function registerUoTrigger(over = {}) { + register('uo', (api) => api.registerEventTriggers([{ ...IDOC_TRIGGER, ...over }])) +} + +const outboxRows = (filter = () => true) => [...store.outbox.values()].filter(filter) +const scheduled = () => outboxRows((r) => r.status === 'scheduled') + +// Core's channels register through `coreChannels`, the way app.js does. +// Requiring `channels` alone gets the empty map — that is the design, and the +// engine dropping every rule because no channel is registered is what a +// boot-order regression would look like. +function registerChannels() { + channels._reset() + delete require.cache[require.resolve('../src/engagement/coreChannels')] + // eslint-disable-next-line global-require + require('../src/engagement/coreChannels') +} + +beforeEach(() => { + registries._reset() + registerChannels() + installStubs() + nextRuleId = 1 + registerUoTrigger() + addUser(10) + // Every channel is opt-IN (§7.1 Q1, and channels.js `defaultMode: 'off'`), so + // a fixture that wants mail to happen has to say so. The test below that turns + // this off again is the one asserting exactly that. + optIn(10, 'uo.house.idoc_warning', 'email') +}) + +afterEach(() => { + registries._reset() + restoreOriginals() +}) + +// ── Acceptance: cooldowns ────────────────────────────────────────────────── + +test('a trigger fired twice inside cooldown_seconds for the same (rule, user, subject) sends once', async () => { + addRule({ cooldown_seconds: 3600 }) + + await engine.dispatch(event(), T0) + await engine.dispatch(event(), later(60_000)) + + assert.equal(outboxRows().length, 1) +}) + +test('the same trigger for a DIFFERENT subject sends again — the multi-house case (§4.1)', async () => { + // The rule §4.1 warns about is "one IDOC mail per player per day": a player + // with four houses decaying should hear about all four, once each. Cooling on + // (rule, user) alone silently drops three of them, and this is the test that + // would fail if `subject_key` were ever dropped from the primary key. + addRule({ cooldown_seconds: 86_400 }) + + await engine.dispatch(event({ subject: 'house-4001' }), T0) + await engine.dispatch(event({ subject: 'house-4002' }), later(1000)) + await engine.dispatch(event({ subject: 'house-4003' }), later(2000)) + // …and the first house again, still inside the day. + await engine.dispatch(event({ subject: 'house-4001' }), later(3000)) + + assert.deepEqual(outboxRows().map((r) => r.subject_key).sort(), ['house-4001', 'house-4002', 'house-4003']) +}) + +test('a cooldown that has expired lets the same subject through again', async () => { + addRule({ cooldown_seconds: 60 }) + + await engine.dispatch(event(), T0) + await engine.dispatch(event(), later(61_000)) + + assert.equal(outboxRows().length, 2) +}) + +test('two rules on one trigger each get their own cooldown', async () => { + addRule({ cooldown_seconds: 3600 }) + addRule({ cooldown_seconds: 3600 }) + + await engine.dispatch(event(), T0) + + assert.equal(outboxRows().length, 2) +}) + +// ── Acceptance: dedupe ───────────────────────────────────────────────────── + +test('a duplicate dedupe_key is a successful no-op, not a second row and not an error', async () => { + addRule() + + const first = await engine.dispatch(event({ dedupeKey: 'idoc:4001:2026-08-29' }), T0) + const replay = await engine.dispatch(event({ dedupeKey: 'idoc:4001:2026-08-29' }), later(1000)) + + assert.equal(first.enqueued, 1) + assert.equal(replay.enqueued, 0) + assert.equal(replay.deduped, 1) + assert.equal(outboxRows().length, 1) +}) + +test('one dedupe_key fans out to every recipient — the key is scoped, not global', async () => { + // §4.2a's `UNIQUE (dedupe_key)` was a defect: a dedupe key names the EVENT, and + // one event legitimately becomes one row per (rule, user, channel). A global + // unique index would have let the FIRST recipient's row in and silently dropped + // everyone else's, which is the opposite of what dedupe is for. + addUser(11) + addUser(12) + for (const id of [10, 11, 12]) { + optIn(id, 'uo.house.idoc_warning', 'email') + optIn(id, 'uo.house.idoc_warning', 'inapp') + } + registries._reset() + registerUoTrigger({ ceiling: 'subscribers', audience: 'subscribers' }) + addRule({ audience: 'subscribers', channels: ['email', 'inapp'] }) + + const result = await engine.dispatch(event({ dedupeKey: 'idoc:4001' }), T0) + + // three users x two channels + assert.equal(result.enqueued, 6) + assert.equal(new Set(outboxRows().map((r) => r.dedupe_key)).size, 1) +}) + +// ── Acceptance: the grace window and cancellation ────────────────────────── + +test('a scheduled row is cancelled by a cancel_on trigger and never sends', async () => { + register('uo', (api) => + api.registerEventTriggers([ + { ...IDOC_TRIGGER, id: 'uo.house.repaired', label: 'House repaired', subjectKey: 'house' }, + ]), + ) + const rule = addRule({ delay_seconds: 1800, cancel_on: ['uo.house.repaired'] }) + + await engine.dispatch(event(), T0) + assert.equal(scheduled().length, 1) + + const cancelled = await engine.dispatch( + event({ triggerId: 'uo.house.repaired', subject: 'house-4001' }), + later(60_000), + ) + assert.equal(cancelled.cancelled, 1) + + // The window has passed; the worker finds nothing to do. + await worker.tick(later(1_900_000)) + assert.equal(store.outbox.get(1).status, 'cancelled') + assert.equal(store.sends.length, 0) + assert.equal(rule.id, 1) +}) + +test('a resolving event with no owner cancels every recipient queued about that subject', async () => { + register('uo', (api) => + api.registerEventTriggers([ + { ...IDOC_TRIGGER, id: 'uo.house.repaired', label: 'House repaired', ceiling: 'authenticated', audience: 'authenticated' }, + ]), + ) + addUser(11) + registries._reset() + registerUoTrigger({ ceiling: 'authenticated', audience: 'authenticated' }) + register('uo', (api) => + api.registerEventTriggers([ + { ...IDOC_TRIGGER, id: 'uo.house.repaired', label: 'House repaired', ceiling: 'authenticated', audience: 'authenticated' }, + ]), + ) + optIn(10, 'uo.house.idoc_warning', 'email') + optIn(11, 'uo.house.idoc_warning', 'email') + addRule({ audience: 'authenticated', delay_seconds: 600, cancel_on: ['uo.house.repaired'] }) + + await engine.dispatch(event(), T0) + assert.equal(scheduled().length, 2) + + await engine.dispatch( + event({ triggerId: 'uo.house.repaired', subject: 'house-4001', ownerUserId: null }), + later(1000), + ) + assert.equal(scheduled().length, 0) +}) + +test('cancellation leaves an in-flight row alone', async () => { + register('uo', (api) => + api.registerEventTriggers([{ ...IDOC_TRIGGER, id: 'uo.house.repaired', label: 'House repaired' }]), + ) + addRule({ delay_seconds: 600, cancel_on: ['uo.house.repaired'] }) + await engine.dispatch(event(), T0) + + // A worker has claimed it: cancelling now would leave two writers on one row. + await outboxDb.claim(1) + const result = await engine.dispatch(event({ triggerId: 'uo.house.repaired' }), later(1000)) + + assert.equal(result.cancelled, 0) + assert.equal(store.outbox.get(1).status, 'sending') +}) + +// ── Acceptance: exactly once across a restart ────────────────────────────── + +test('a restart mid-window still sends exactly once', async () => { + addRule({ delay_seconds: 600 }) + await engine.dispatch(event(), T0) + + // "Restart" is the engine losing its process between enqueue and due_at. The + // outbox is the durable half, so the only question is whether the sweep after + // the restart double-delivers — and the CAS claim is what says it cannot. + await worker.tick(later(500_000)) // not yet due + assert.equal(store.sends.length, 0) + + await worker.tick(later(700_000)) + await worker.tick(later(700_001)) // a second instance, or the next tick + assert.equal(store.sends.length, 1) +}) + +test('two sweepers racing one due row: exactly one claim wins', async () => { + addRule() + await engine.dispatch(event(), T0) + + // Both see the same candidate — findDue does not claim — and then disagree + // harmlessly about which of them owns it. §7.1 Q2's answer, as a test. + const [a] = await outboxDb.findDue(later(1000)) + const [b] = await outboxDb.findDue(later(1000)) + assert.equal(a.id, b.id) + + assert.equal(await outboxDb.claim(a.id), true) + assert.equal(await outboxDb.claim(b.id), false) +}) + +// ── The send log ─────────────────────────────────────────────────────────── + +test('a row whose channel has no deliver() finishes failed, and the send log says why', async () => { + // Phase 4a delivers nothing: `deliver` arrives with email in Phase 6 and the + // inbox in Phase 7. Recording 'sent' would be a lie in the one table whose + // purpose is answering "did they get it". + addRule() + await engine.dispatch(event(), T0) + await worker.tick(later(1000)) + + assert.equal(store.outbox.get(1).status, 'failed') + assert.equal(store.sends.length, 1) + assert.equal(store.sends[0].status, 'failed') + assert.match(store.sends[0].detail, /no delivery implementation/) + assert.equal(store.sends[0].user_id, 10) +}) + +test('a transient failure is retried, and then given up on', async () => { + addRule() + await engine.dispatch(event(), T0) + + let attempts = 0 + const failing = async () => { + attempts += 1 + return { outcome: 'retry', detail: 'smtp timeout' } + } + + let at = later(1000) + for (let i = 0; i < worker.MAX_ATTEMPTS + 2; i += 1) { + // eslint-disable-next-line no-await-in-loop + const [row] = await outboxDb.findDue(at) + if (!row) break + // eslint-disable-next-line no-await-in-loop + await worker.processRow(row, at, failing) + at = new Date(at.getTime() + worker.RETRY_MS + 1000) + } + + // Tried MAX_ATTEMPTS times and then stopped, rather than retrying forever. + assert.equal(attempts, worker.MAX_ATTEMPTS) + assert.equal(store.outbox.get(1).status, 'failed') + assert.equal(store.sends.length, 1) + assert.match(store.sends[0].detail, /smtp timeout/) +}) + +// ── Preferences ──────────────────────────────────────────────────────────── + +test("a user whose mode is 'off' for the channel is not enqueued", async () => { + registries._reset() + registerUoTrigger({ ceiling: 'authenticated', audience: 'authenticated' }) + addUser(11) + optIn(10, 'uo.house.idoc_warning', 'email', 'instant') + optIn(11, 'uo.house.idoc_warning', 'email', 'off') + addRule({ audience: 'authenticated' }) + + await engine.dispatch(event(), T0) + + assert.deepEqual(outboxRows().map((r) => r.user_id), [10]) +}) + +test('absence means the CHANNEL default, and all three of core default off', async () => { + registries._reset() + registerUoTrigger({ ceiling: 'authenticated', audience: 'authenticated' }) + store.prefs.clear() + addRule({ audience: 'authenticated' }) + + // Nobody has expressed anything, and email defaults 'off' (§3.1) — so an + // `authenticated` rule reaches nobody until people opt in. That is opt-IN + // working, not the engine failing. + const result = await engine.dispatch(event(), T0) + assert.equal(result.enqueued, 0) + assert.equal(channels.defaultMode('email'), 'off') +}) + +test("a 'digest' preference still enqueues — batching is the drain's job, not the enqueue's", async () => { + registries._reset() + registerUoTrigger({ ceiling: 'authenticated', audience: 'authenticated' }) + optIn(10, 'uo.house.idoc_warning', 'email', 'digest') + addRule({ audience: 'authenticated' }) + + await engine.dispatch(event(), T0) + assert.equal(outboxRows().length, 1) +}) + +// ── The hourly ceiling (§7.1 Q3) ─────────────────────────────────────────── + +test('a rule stops at its hourly send ceiling', async () => { + registries._reset() + registerUoTrigger({ ceiling: 'authenticated', audience: 'authenticated' }) + for (let i = 20; i < 30; i += 1) { + addUser(i) + optIn(i, 'uo.house.idoc_warning', 'email') + } + const rule = addRule({ audience: 'authenticated', max_sends_per_hour: 4 }) + + const result = await engine.dispatch(event(), T0) + + // Eleven eligible recipients (the ten here plus the fixture's user 10), and a + // ceiling of four: four rows, and the rest are counted and dropped rather than + // queued for later - a rule at its ceiling is a rule an operator has to fix. + assert.equal(result.enqueued, 4) + assert.equal(result.enqueued + result.capped, 11) + assert.equal(rule.max_sends_per_hour, 4) +}) + +test('the hourly ceiling counts sends, not attempts', async () => { + // A broken transport must not silently consume a rule's whole budget and mute + // it: only rows the log records as 'sent' count against the ceiling. + const rule = addRule({ max_sends_per_hour: 2 }) + store.sends.push({ rule_id: rule.id, status: 'failed', created_at: T0 }) + store.sends.push({ rule_id: rule.id, status: 'suppressed', created_at: T0 }) + + const result = await engine.dispatch(event(), later(1000)) + assert.equal(result.enqueued, 1) +}) + +// ── Ceilings: the security boundary, both halves ─────────────────────────── + +test('a rule may not be SAVED with an audience wider than its trigger permits', async () => { + const checked = await rules.validate({ + triggerId: 'uo.house.idoc_warning', // ceiling: owner + name: 'IDOC warning', + channels: ['email'], + audience: 'authenticated', + }) + + assert.equal(checked.ok, false) + assert.match(checked.errors.join(' '), /wider than trigger/) +}) + +test('the ceiling is re-checked at SEND time, so a module narrowing its declaration stops a saved rule', async () => { + // The only way this can fail is the case it exists for: the rule was saved + // when the trigger permitted `authenticated`, and a module upgrade has since + // narrowed the declaration to `owner`. A save-time check alone would keep + // mailing the wider set forever. + registries._reset() + registerUoTrigger({ ceiling: 'authenticated', audience: 'authenticated' }) + addUser(11) + optIn(10, 'uo.house.idoc_warning', 'email') + optIn(11, 'uo.house.idoc_warning', 'email') + addRule({ audience: 'authenticated' }) + + const before = await engine.dispatch(event(), T0) + assert.equal(before.enqueued, 2) + + registries._reset() + registerUoTrigger({ ceiling: 'owner', audience: 'owner' }) // the upgrade + + const after2 = await engine.dispatch(event({ subject: 'house-9' }), later(1000)) + assert.equal(after2.enqueued, 0) +}) + +test('a rule for an unregistered trigger is dormant, not deleted and not an error', async () => { + const rule = addRule({ trigger_id: 'uo.gone.away' }) + const listed = await rules.listAnnotated() + const found = listed.find((r) => r.id === rule.id) + + assert.equal(found.dormant, true) + assert.match(found.dormantReasons.join(' '), /not registered/) +}) + +// ── Segments (§5.1a) ─────────────────────────────────────────────────────── + +function registerAudiences() { + register('uo', (api) => + api.registerAudiences([ + { id: 'uo.team.members', label: 'Team members', ceiling: 'members', params: [{ id: 'teamId', type: 'int', required: true }], resolve: async ({ teamId }) => (teamId === 1 ? [10, 11] : [12]) }, + { id: 'uo.governors', label: 'Governors', ceiling: 'members', resolve: async () => [11, 12] }, + { id: 'uo.watchers', label: 'Watchers', ceiling: 'authenticated', resolve: async () => [10, 13] }, + { id: 'uo.flagged', label: 'Flagged accounts', ceiling: 'staff', resolve: async () => [10] }, + ]), + ) +} + +test('OR takes the TIGHTER ceiling — union-widens is the wrong implementation', async () => { + registerAudiences() + const checked = segments.validate({ + op: 'or', + nodes: [{ audienceId: 'uo.governors' }, { audienceId: 'uo.watchers' }], + }) + + assert.equal(checked.ok, true) + // members is below authenticated, so the meet is members — NOT authenticated, + // which is what a "widest wins" reading would have given. + assert.equal(checked.ceiling, 'members') +}) + +test('two incomparable ceilings are refused rather than resolved to a guess', async () => { + registerAudiences() + const checked = segments.validate({ + op: 'and', + nodes: [{ audienceId: 'uo.governors' }, { audienceId: 'uo.flagged' }], + }) + + assert.equal(checked.ok, false) + assert.match(checked.errors.join(' '), /no common ceiling/) +}) + +test('NOT does not constrain the ceiling — excluding people cannot widen', async () => { + registerAudiences() + // `members AND NOT staff` reaches strictly fewer people than `members`. If the + // complement's ceiling were folded into the meet, meet('members','staff') is + // null and this safe segment would be refused. + const checked = segments.validate({ + op: 'and', + nodes: [{ audienceId: 'uo.governors' }, { op: 'not', nodes: [{ audienceId: 'uo.flagged' }] }], + }) + + assert.equal(checked.ok, true) + assert.equal(checked.ceiling, 'members') +}) + +test('NOT outside an AND is refused — a complement needs a set to take it from', async () => { + registerAudiences() + for (const expression of [ + { op: 'not', nodes: [{ audienceId: 'uo.governors' }] }, + { op: 'or', nodes: [{ audienceId: 'uo.governors' }, { op: 'not', nodes: [{ audienceId: 'uo.flagged' }] }] }, + ]) { + const checked = segments.validate(expression) + assert.equal(checked.ok, false) + assert.match(checked.errors.join(' '), /only allowed inside an "and"/) + } +}) + +test('a segment resolves through the module resolvers, and AND NOT subtracts', async () => { + registerAudiences() + const checked = segments.validate({ + op: 'and', + nodes: [ + { audienceId: 'uo.team.members', params: { teamId: 1 } }, // [10, 11] + { op: 'not', nodes: [{ audienceId: 'uo.flagged' }] }, // [10] + ], + }) + const resolved = await segments.resolve(checked.expression) + + assert.equal(resolved.dormant, false) + assert.deepEqual(resolved.userIds, [11]) +}) + +test('a segment whose module is uninstalled is DORMANT and sends to nobody', async () => { + registerAudiences() + const checked = segments.validate({ op: 'or', nodes: [{ audienceId: 'uo.governors' }, { audienceId: 'uo.watchers' }] }) + store.segments.set(1, { id: 1, name: 'staff-ish', expression: checked.expression, ceiling: 'members' }) + addUser(11) + optIn(10, 'uo.house.idoc_warning', 'email') + optIn(11, 'uo.house.idoc_warning', 'email') + registries._reset() + registerUoTrigger({ ceiling: 'members', audience: 'members' }) + addRule({ audience: 'members', audience_segment_id: 1 }) + + // The module is gone: `resolveAudience` answers dormant + empty, and the rule + // must NOT fall back to anything. Reaching a different population than the one + // composed is the failure §5.1a rule 4 forbids. + const result = await engine.dispatch(event(), T0) + + assert.equal(result.enqueued, 0) + assert.equal(outboxRows().length, 0) +}) + +test('a rule pointing at a deleted segment is dormant, never a fallback to its plain audience', async () => { + registries._reset() + registerUoTrigger({ ceiling: 'authenticated', audience: 'authenticated' }) + optIn(10, 'uo.house.idoc_warning', 'email') + addRule({ audience: 'authenticated', audience_segment_id: 99 }) // no such segment + + const result = await engine.dispatch(event(), T0) + assert.equal(result.enqueued, 0) +}) + +test("a plain 'members' audience with no segment reaches nobody", async () => { + registries._reset() + registerUoTrigger({ ceiling: 'members', audience: 'members' }) + optIn(10, 'uo.house.idoc_warning', 'email') + addRule({ audience: 'members' }) + + const result = await engine.dispatch(event(), T0) + assert.equal(result.enqueued, 0) +}) + +// ── Conditions ───────────────────────────────────────────────────────────── + +test('a condition narrows which firings are interesting', async () => { + addRule({ + conditions: { variable: 'decayStatus', cmp: 'in', value: ['Greatly damaged', 'IDOC'] }, + }) + + await engine.dispatch(event({ data: { house: 'A', decayStatus: 'IDOC' } }), T0) + await engine.dispatch(event({ subject: 'house-2', data: { house: 'B', decayStatus: 'LikeNew' } }), later(1000)) + + assert.equal(outboxRows().length, 1) +}) + +test('a condition naming a variable the trigger does not declare is refused at save, with the name', async () => { + const checked = await rules.validate({ + triggerId: 'uo.house.idoc_warning', + name: 'typo', + channels: ['email'], + audience: 'owner', + conditions: { variable: 'decaystatus', cmp: 'eq', value: 'IDOC' }, + }) + + assert.equal(checked.ok, false) + assert.match(checked.errors.join(' '), /"decaystatus" is not a variable/) +}) + +test('an absent variable makes every comparison false — including "is not"', async () => { + // `ne` is the one that tempts otherwise: "not equal to IDOC" reads as satisfied + // by nothing at all, and treating it that way would fire the rule on every + // event that omits an optional variable. + const c = { variable: 'decayStatus', cmp: 'ne', value: 'IDOC' } + assert.equal(conditions.evaluate(c, { house: 'A' }), false) + assert.equal(conditions.evaluate(c, { house: 'A', decayStatus: 'LikeNew' }), true) + assert.equal(conditions.evaluate({ variable: 'decayStatus', cmp: 'absent' }, { house: 'A' }), true) +}) + +test('a condition tree that no longer parses fails CLOSED', async () => { + // A stored condition that stops making sense must stop the mail, not decay + // into "no conditions" and reach everyone the rule could ever reach. + assert.equal(conditions.evaluate({ op: 'xor', nodes: [] }, {}), false) + assert.equal(conditions.evaluate('nonsense', {}), false) + assert.equal(conditions.evaluate(null, {}), true) +}) + +test('and / or / not compose', () => { + const data = { house: 'The Silver Anvil', decayStatus: 'IDOC' } + assert.equal( + conditions.evaluate( + { op: 'and', nodes: [{ variable: 'decayStatus', cmp: 'eq', value: 'IDOC' }, { variable: 'house', cmp: 'contains', value: 'Silver' }] }, + data, + ), + true, + ) + assert.equal( + conditions.evaluate({ op: 'not', nodes: [{ variable: 'decayStatus', cmp: 'eq', value: 'IDOC' }] }, data), + false, + ) + assert.equal( + conditions.evaluate( + { op: 'or', nodes: [{ variable: 'decayStatus', cmp: 'eq', value: 'LikeNew' }, { variable: 'house', cmp: 'startsWith', value: 'The' }] }, + data, + ), + true, + ) +}) + +test('an operator cannot be applied to a type it does not fit', () => { + const declaration = registries.eventTrigger('uo.house.idoc_warning') + const checked = conditions.validate(declaration, { variable: 'house', cmp: 'gt', value: 'x' }) + assert.equal(checked.ok, false) + assert.match(checked.errors.join(' '), /cannot be applied to a string/) +}) + +// ── Rule validation, the rest ────────────────────────────────────────────── + +test('a new rule is created disabled unless it says otherwise (§7.1 Q3)', async () => { + const checked = await rules.validate({ + triggerId: 'uo.house.idoc_warning', + name: 'IDOC warning', + channels: ['email'], + audience: 'owner', + }) + assert.equal(checked.ok, true) + assert.equal(checked.rule.enabled, false) + assert.equal(checked.rule.max_sends_per_hour, 100) +}) + +test('a rule naming an unregistered channel is refused', async () => { + const checked = await rules.validate({ + triggerId: 'uo.house.idoc_warning', + name: 'IDOC warning', + channels: ['carrier-pigeon'], + audience: 'owner', + }) + assert.equal(checked.ok, false) + assert.match(checked.errors.join(' '), /no channel "carrier-pigeon"/) +}) + +test('the hourly ceiling has a hard upper bound an operator cannot type past', async () => { + const checked = await rules.validate({ + triggerId: 'uo.house.idoc_warning', + name: 'IDOC warning', + channels: ['email'], + audience: 'owner', + maxSendsPerHour: 10_000_000, + }) + assert.equal(checked.ok, false) + assert.match(checked.errors.join(' '), /maxSendsPerHour/) +}) + +test('cancelOn without a delay is refused — there is no window to cancel in', async () => { + const checked = await rules.validate({ + triggerId: 'uo.house.idoc_warning', + name: 'IDOC warning', + channels: ['email'], + audience: 'owner', + cancelOn: ['uo.house.repaired'], + }) + assert.equal(checked.ok, false) + assert.match(checked.errors.join(' '), /no effect without a delaySeconds/) +}) + +test('a rule whose channel was removed is dormant but still editable', async () => { + const rule = addRule({ channels: ['email', 'carrier-pigeon'] }) + const listed = await rules.listAnnotated() + assert.equal(listed.find((r) => r.id === rule.id).dormant, true) + + // …and only the live channel is used when it fires. + optIn(10, 'uo.house.idoc_warning', 'email') + await engine.dispatch(event(), T0) + assert.deepEqual([...new Set(outboxRows().map((r) => r.channel))], ['email']) +}) + +// ── The dispatch contract ────────────────────────────────────────────────── + +test('dispatch never throws at its caller, even when the database is gone', async () => { + addRule() + rulesDb.enabledForTrigger = async () => { + throw new Error('connection lost') + } + + const result = await engine.dispatch(event(), T0) + assert.equal(result.enqueued, 0) +}) + +test('an event nobody has written a rule for is a no-op', async () => { + const result = await engine.dispatch(event(), T0) + assert.equal(result.rules, 0) + assert.equal(outboxRows().length, 0) +}) + +test('a disabled rule does not fire', async () => { + addRule({ enabled: false }) + const result = await engine.dispatch(event(), T0) + assert.equal(result.rules, 0) +}) diff --git a/server/test/engagementEngineSql.test.js b/server/test/engagementEngineSql.test.js new file mode 100644 index 0000000..03338e5 --- /dev/null +++ b/server/test/engagementEngineSql.test.js @@ -0,0 +1,283 @@ +// ── The engine's raw SQL, against a real MariaDB ─────────────────────────── +// +// ENGAGEMENT.md Phase 4a. `engagementEngine.test.js` stubs the five tables and +// exercises the engine's logic against in-memory stand-ins, which is the right +// shape for everything the engine DECIDES. It cannot prove the three statements +// whose whole correctness is a server contract: +// +// • the cooldown claim's answer is read out of `affectedRows`, and what that +// number MEANS depends on the pool's `foundRows` setting. This file is what +// found that: §4.1's single `INSERT ... ON DUPLICATE KEY UPDATE` was written, +// was green against the stub, and always allowed the send against a real +// server, because the connector defaults `foundRows: true` and a no-op +// update reports 1 rather than 0. A cooldown that never cools. +// • the outbox claim is a compare-and-set (§7.1 Q2), and "exactly one winner" +// is `affectedRows = 1` for one caller and 0 for the other. +// • `uq_engo_dedupe` is scoped to (rule, user, channel, dedupe_key), so one +// event's key fans out to every recipient instead of admitting the first. +// +// A stub that reproduces those from the same reading of the manual proves the +// reading, not the server. So this file talks to a real database. +// +// **It SKIPS when there is none**, and that is deliberate rather than lax: CI +// runs the suite without a database (the harness points the pool at a dead port), +// and a file that failed there would make every PR red for a reason unrelated to +// itself. Run it against this machine's container with: +// +// DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=... DB_PASSWORD=... DB_NAME=... \ +// node --test test/engagementEngineSql.test.js +// +// It creates its tables in a throwaway database named after the process, and +// drops it again, so it can never touch a real schema. + +const { test, before, after } = require('node:test') +const assert = require('node:assert/strict') +const mariadb = require('mariadb') + +const SCHEMA = ` +CREATE TABLE engagement_cooldowns ( + rule_id INT NOT NULL, + user_id INT NOT NULL, + subject_key VARCHAR(190) NOT NULL DEFAULT '', + last_fired_at DATETIME NOT NULL, + fire_count INT NOT NULL DEFAULT 1, + PRIMARY KEY (rule_id, user_id, subject_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE engagement_outbox ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + rule_id INT NOT NULL, + trigger_id VARCHAR(96) NOT NULL, + user_id INT NOT NULL, + channel VARCHAR(32) NOT NULL, + subject_key VARCHAR(190) NOT NULL DEFAULT '', + payload JSON NOT NULL, + dedupe_key VARCHAR(190) NULL, + status ENUM('scheduled','sending','sent','failed','cancelled','suppressed') NOT NULL DEFAULT 'scheduled', + due_at DATETIME NOT NULL, + attempts SMALLINT NOT NULL DEFAULT 0, + last_error TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + sent_at DATETIME NULL, + UNIQUE KEY uq_engo_dedupe (rule_id, user_id, channel, dedupe_key), + INDEX idx_engo_due (status, due_at), + INDEX idx_engo_cancel (rule_id, user_id, subject_key, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +` + +const DB = `rg_engage_test_${process.pid}` +let pool = null +let available = false + +// The statements under test, verbatim from the two `.db` files. They are +// duplicated here rather than required, because requiring the modules would drag +// in `utils/db`'s pool, which the harness has already pointed at a dead port. +// +// The pool below leaves `foundRows` at the connector's default, exactly as +// `utils/db.js` does - pinning it to `false` here would make this file agree with +// the code by construction and prove nothing about the pool the server runs. +const CLAIM_COOLDOWN_UPDATE = ` +UPDATE engagement_cooldowns + SET last_fired_at = ?, fire_count = fire_count + 1 + WHERE rule_id = ? AND user_id = ? AND subject_key = ? + AND last_fired_at <= ? - INTERVAL ? SECOND` + +const CLAIM_COOLDOWN_INSERT = ` +INSERT IGNORE INTO engagement_cooldowns (rule_id, user_id, subject_key, last_fired_at, fire_count) +VALUES (?, ?, ?, ?, 1)` + +const CLAIM_OUTBOX = ` +UPDATE engagement_outbox SET status = 'sending', attempts = attempts + 1 + WHERE id = ? AND status = 'scheduled'` + +const ENQUEUE = ` +INSERT IGNORE INTO engagement_outbox + (rule_id, trigger_id, user_id, channel, subject_key, payload, dedupe_key, due_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + +before(async () => { + const admin = mariadb.createPool({ + host: process.env.DB_HOST || '127.0.0.1', + port: Number(process.env.DB_PORT) || 3306, + user: process.env.DB_USER || 'root', + password: process.env.DB_PASSWORD || '', + connectionLimit: 1, + connectTimeout: 2000, + initializationTimeout: 2000, + multipleStatements: true, + }) + try { + await admin.query(`CREATE DATABASE ${DB}`) + available = true + } catch { + available = false + } finally { + await admin.end().catch(() => {}) + } + if (!available) return + + pool = mariadb.createPool({ + host: process.env.DB_HOST || '127.0.0.1', + port: Number(process.env.DB_PORT) || 3306, + user: process.env.DB_USER || 'root', + password: process.env.DB_PASSWORD || '', + database: DB, + connectionLimit: 3, + multipleStatements: true, + bigIntAsNumber: true, + insertIdAsNumber: true, + }) + await pool.query(SCHEMA) +}) + +after(async () => { + if (pool) { + await pool.query(`DROP DATABASE IF EXISTS ${DB}`).catch(() => {}) + await pool.end().catch(() => {}) + } +}) + +// Checked INSIDE each test, never as a `{ skip }` option: the option is +// evaluated when the file is read, which is before `before()` has had a chance to +// find out whether there is a database. Every test skipped unconditionally is +// what that mistake looks like, and it looks exactly like a passing suite. +const SKIP = 'no database reachable - set DB_HOST/DB_PORT/DB_USER/DB_PASSWORD to run' +const needDb = (t) => { + if (available) return false + t.skip(SKIP) + return true +} + +const T0 = new Date('2026-08-29T12:00:00Z') +const later = (ms) => new Date(T0.getTime() + ms) + +const claimCooldown = async (ruleId, userId, subject, seconds, now) => { + const moved = await pool.query(CLAIM_COOLDOWN_UPDATE, [now, ruleId, userId, subject, now, seconds]) + if (Number(moved.affectedRows) === 1) return true + const inserted = await pool.query(CLAIM_COOLDOWN_INSERT, [ruleId, userId, subject, now]) + return Number(inserted.affectedRows) === 1 +} + +// ── The cooldown claim ───────────────────────────────────────────────────── + +test('the cooldown claim: first fire inserts and is allowed', async (t) => { + if (needDb(t)) return + assert.equal(await claimCooldown(1, 10, 'h1', 3600, T0), true) +}) + +test('the cooldown claim: a second fire inside the window is REFUSED', async (t) => { + if (needDb(t)) return + await claimCooldown(2, 10, 'h1', 3600, T0) + // affectedRows = 0: a duplicate key whose update changed nothing. + assert.equal(await claimCooldown(2, 10, 'h1', 3600, later(60_000)), false) +}) + +test('the cooldown claim: a fire after the window is allowed, and counts', async (t) => { + if (needDb(t)) return + await claimCooldown(3, 10, 'h1', 60, T0) + assert.equal(await claimCooldown(3, 10, 'h1', 60, later(61_000)), true) + const [row] = await pool.query('SELECT fire_count FROM engagement_cooldowns WHERE rule_id = 3') + assert.equal(Number(row.fire_count), 2) +}) + +test('the cooldown claim: the refusal survives foundRows — the bug this file caught', async (t) => { + if (needDb(t)) return + // The regression, named. `foundRows: true` (the connector's default, and what + // `utils/db.js` gets) makes `affectedRows` count MATCHED rows, so the + // ON DUPLICATE KEY UPDATE form's "0 means still cooling" reading returns 1 and + // every send is allowed. Guarding in a WHERE clause is what makes the number + // mean one thing. + const seed = 'INSERT INTO engagement_cooldowns VALUES (7, 10, "h1", ?, 1)' + await pool.query(seed, [T0]) + const noop = await pool.query(`${seed} ON DUPLICATE KEY UPDATE fire_count = fire_count`, [T0]) + assert.equal(Number(noop.affectedRows), 1, 'a no-op ODKU reports 1 under foundRows, not 0') + + // …and the shipped claim still refuses. + assert.equal(await claimCooldown(7, 10, 'h1', 3600, later(60_000)), false) +}) + +test('the cooldown claim: repeated expiries keep counting', async (t) => { + if (needDb(t)) return + // `fire_count` is moved by the same guarded UPDATE that moves `last_fired_at`, + // so a claim that succeeded and a claim that counted can never disagree. + await claimCooldown(4, 10, 'h1', 10, T0) + for (let i = 1; i <= 3; i += 1) await claimCooldown(4, 10, 'h1', 10, later(i * 11_000)) + const [row] = await pool.query('SELECT fire_count FROM engagement_cooldowns WHERE rule_id = 4') + assert.equal(Number(row.fire_count), 4) +}) + +test('the cooldown claim: a different subject is a different row', async (t) => { + if (needDb(t)) return + assert.equal(await claimCooldown(5, 10, 'house-1', 86_400, T0), true) + assert.equal(await claimCooldown(5, 10, 'house-2', 86_400, later(1000)), true) + assert.equal(await claimCooldown(5, 10, 'house-1', 86_400, later(2000)), false) +}) + +test('the cooldown claim: cooldown_seconds = 0 always passes', async (t) => { + if (needDb(t)) return + assert.equal(await claimCooldown(6, 10, '', 0, T0), true) + assert.equal(await claimCooldown(6, 10, '', 0, later(1)), true) +}) + +// ── The outbox claim and the dedupe key ──────────────────────────────────── + +const enqueue = async (over = {}) => { + const row = { + rule_id: 1, trigger_id: 'uo.house.idoc_warning', user_id: 10, channel: 'email', + subject_key: 'h1', dedupe_key: null, due_at: T0, ...over, + } + const r = await pool.query(ENQUEUE, [ + row.rule_id, row.trigger_id, row.user_id, row.channel, row.subject_key, + JSON.stringify({ house: 'A' }), row.dedupe_key, row.due_at, + ]) + return Number(r.affectedRows) === 1 ? Number(r.insertId) : null +} + +test('the outbox claim: exactly one of two callers wins (§7.1 Q2)', async (t) => { + if (needDb(t)) return + const id = await enqueue({ rule_id: 20 }) + const a = await pool.query(CLAIM_OUTBOX, [id]) + const b = await pool.query(CLAIM_OUTBOX, [id]) + assert.equal(Number(a.affectedRows), 1) + assert.equal(Number(b.affectedRows), 0) + const [row] = await pool.query('SELECT status, attempts FROM engagement_outbox WHERE id = ?', [id]) + assert.equal(row.status, 'sending') + assert.equal(Number(row.attempts), 1) +}) + +test('the outbox claim under real concurrency: one winner, however many racers', async (t) => { + if (needDb(t)) return + const id = await enqueue({ rule_id: 21 }) + // Fired at once on separate pooled connections, so the server - not the + // JavaScript event loop's ordering - is what serialises them. + const results = await Promise.all([1, 2, 3, 4, 5].map(() => pool.query(CLAIM_OUTBOX, [id]))) + assert.equal(results.filter((r) => Number(r.affectedRows) === 1).length, 1) +}) + +test('a replayed event with the same dedupe key is IGNOREd, not duplicated', async (t) => { + if (needDb(t)) return + const first = await enqueue({ rule_id: 30, dedupe_key: 'idoc:4001' }) + const replay = await enqueue({ rule_id: 30, dedupe_key: 'idoc:4001' }) + assert.ok(first) + assert.equal(replay, null) +}) + +test('ONE dedupe key fans out to every recipient — the scoped unique key', async (t) => { + if (needDb(t)) return + // §4.2a's global `UNIQUE (dedupe_key)` would have admitted the first of these + // and silently ignored the other five: fifty recipients would have become one. + const ids = [] + for (const user of [10, 11, 12]) { + for (const channel of ['email', 'inapp']) { + ids.push(await enqueue({ rule_id: 31, user_id: user, channel, dedupe_key: 'idoc:4001' })) + } + } + assert.equal(ids.filter(Boolean).length, 6) +}) + +test('a NULL dedupe key never collides — many NULLs are legal under a UNIQUE index', async (t) => { + if (needDb(t)) return + const a = await enqueue({ rule_id: 32, dedupe_key: null }) + const b = await enqueue({ rule_id: 32, dedupe_key: null }) + assert.ok(a && b && a !== b) +}) -- 2.49.1 From 4b45eddb5d88e11cba8d9ad4bba47131a8b92dba Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 29 Aug 2026 12:10:04 -0500 Subject: [PATCH 09/20] feat(engagement): Admin - Engagement - Rules and Audiences (engagement Phase 4b) The admin surface over the Phase 4a engine: two screens, twelve routes and the reach preview. Nothing in the engine changed; what changed is that an operator can now reach it. Four decisions settled by the org lead before any code: - segments get their OWN nav entry, "Audiences", not a tab of the rules screen - the on/off switch is its own PATCH route, not a full PUT - the reach preview is a count only, on demand - a rule can be hard-deleted; the send log survives it The switch is the one with real content in it. A PUT re-validates against the registries as they are NOW, so the rules a re-validating toggle cannot switch off are exactly the three an operator most wants stopped: a rule whose module was uninstalled, one naming a channel that is gone, and one whose trigger has since narrowed its ceiling under a saved audience. PATCH .../enabled writes one column and always works. Switching ON unvalidated is safe because the engine re-checks the ceiling at send time. The preview calls the engine's own resolver rather than a second query that agrees with it today, and answers a count and nothing else - the resolver's output for a module-declared segment is a set of players derived from game data. It reports `capped` at the 5000-row bound (the count is a floor, not a total), `reason` for an `owner` audience (which resolves per event and has no advance answer), and `permitted` so the editor cannot show a healthy number beside a save the server will refuse. Two defects found by walking it against a live server, both in Phase 4a's code: 1. A rule pointing at a DORMANT segment read as healthy. listAnnotated asked only whether the segment ROW existed. The other shape of the same failure is a segment sitting exactly where it was whose every audience belongs to an uninstalled module: same outcome, nothing deleted. Uninstalling a module under an enabled rule produced a rule the screen showed as on and firing. The expression walk now lives in engagement/segments.js as `missingAudiences` and both lists ask it. 2. "1 rule still use this segment" - the delete refusal pluralised the noun and not the verb, in the sentence an operator reads when told no. Also: a rule's trigger is now a stated rule rather than an omission in the UPDATE statement (its cooldowns, queued sends and history are all about one trigger id); a condition tree the editor cannot render is shown read-only rather than flattened, because flattening changes which events fire the rule; and literals are coerced client-side to the type the trigger declared, with anything that does not parse passed through unchanged so the server's refusal names the variable. Tests: 21 new server tests (test/engagementAdmin.test.js) and 25 client ones (client/test/engagementRules.test.js), all green. The single failure in the server suite (`the committed manifest matches the declarations in the tree`) is the known Windows CRLF artifact and fails identically on clean edge. Companion docs PR: docs#184. - [x] AI-assisted: written with Claude Code (Opus) Co-Authored-By: Claude --- client/src/App.jsx | 18 + client/src/api/client.js | 32 + client/src/lib/engagementRules.js | 298 ++++++ client/src/routes/admin/AdminLayout.jsx | 18 + .../admin/views/EngagementAudiences.jsx | 431 +++++++++ .../routes/admin/views/EngagementRules.jsx | 628 +++++++++++++ client/test/engagementRules.test.js | 277 ++++++ server/routes.guards.json | 108 +++ server/routes.manifest.json | 48 + server/src/engagement/segments.js | 28 +- .../model/engagement/engagementRules.db.js | 22 + .../model/engagement/engagementRules.model.js | 99 +- .../engagement/engagementSegments.model.js | 16 +- .../router/v1/admin/engagement.controller.js | 248 +++++ .../src/router/v1/admin/engagement.router.js | 183 +++- server/swagger/swagger-output.json | 884 +++++++++++++++++- server/test/engagementAdmin.test.js | 469 ++++++++++ 17 files changed, 3777 insertions(+), 30 deletions(-) create mode 100644 client/src/lib/engagementRules.js create mode 100644 client/src/routes/admin/views/EngagementAudiences.jsx create mode 100644 client/src/routes/admin/views/EngagementRules.jsx create mode 100644 client/test/engagementRules.test.js create mode 100644 server/test/engagementAdmin.test.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 860cc6d..b1d9789 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -42,6 +42,8 @@ import UsersAdmin from './routes/admin/views/UsersAdmin.jsx' import UserDetail from './routes/admin/views/UserDetail.jsx' import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx' import ModulesAdmin from './routes/admin/views/ModulesAdmin.jsx' +import EngagementRules from './routes/admin/views/EngagementRules.jsx' +import EngagementAudiences from './routes/admin/views/EngagementAudiences.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' @@ -184,6 +186,22 @@ export default function App() { actions that publish a game-written name is applied per request on the server, from the caller's live role (TEAMS.md 2.9). */} } /> + {/* Engagement (ENGAGEMENT.md Phase 4b). Admin-only, matching the + server: every route under /admin/engagement re-gates to `admin` + on top of the group's staff gate, because this is the group that + decides who receives mail. */} + + + + } + > + } /> + } /> + } /> + } /> {/* Installed modules' admin pages, at /admin//…, already inside RequireAuth + AdminLayout. A module cannot supply its own auth diff --git a/client/src/api/client.js b/client/src/api/client.js index c219872..af9423f 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -357,6 +357,38 @@ export const api = { setModuleSources: (hosts) => req('/admin/modules/sources', { method: 'PUT', body: { hosts } }), restartServer: () => req('/admin/modules/restart', { method: 'POST' }), + // Engagement (docs/website/ENGAGEMENT.md Phase 4b). The first three are the + // catalog — triggers, audiences and channels, all served from the registries + // rather than from tables, so an installed module's declarations appear here + // without a client release. + // + // `setEngagementRuleEnabled` is its own call rather than a `saveEngagementRule` + // with one field, because the route is its own route: turning a rule off must + // work on a rule the registries would now refuse, which is exactly the rule an + // operator most wants stopped. + // + // `previewEngagementReach` answers with a COUNT and never a list of people. + engagementTriggers: () => req('/admin/engagement/triggers'), + engagementAudiences: () => req('/admin/engagement/audiences'), + engagementChannels: () => req('/admin/engagement/channels'), + listEngagementRules: () => req('/admin/engagement/rules'), + createEngagementRule: (body) => req('/admin/engagement/rules', { method: 'POST', body }), + updateEngagementRule: (id, body) => req(`/admin/engagement/rules/${id}`, { method: 'PUT', body }), + setEngagementRuleEnabled: (id, enabled) => + req(`/admin/engagement/rules/${id}/enabled`, { method: 'PATCH', body: { enabled } }), + deleteEngagementRule: (id) => req(`/admin/engagement/rules/${id}`, { method: 'DELETE' }), + listEngagementSegments: () => req('/admin/engagement/segments'), + createEngagementSegment: (body) => req('/admin/engagement/segments', { method: 'POST', body }), + updateEngagementSegment: (id, body) => req(`/admin/engagement/segments/${id}`, { method: 'PUT', body }), + deleteEngagementSegment: (id) => req(`/admin/engagement/segments/${id}`, { method: 'DELETE' }), + previewEngagementReach: ({ audience, audienceSegmentId, triggerId } = {}) => { + const qs = new URLSearchParams() + if (audienceSegmentId) qs.set('audienceSegmentId', String(audienceSegmentId)) + else if (audience) qs.set('audience', audience) + if (triggerId) qs.set('triggerId', triggerId) + return req(`/admin/engagement/audience-preview${withQs(qs.toString())}`) + }, + // 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`. diff --git a/client/src/lib/engagementRules.js b/client/src/lib/engagementRules.js new file mode 100644 index 0000000..5611e80 --- /dev/null +++ b/client/src/lib/engagementRules.js @@ -0,0 +1,298 @@ +// What the Engagement screens say, and what they let an operator choose. +// +// ENGAGEMENT.md Phase 4b. Plain JS in its own file for the reason +// `lib/moduleAdmin.js` is: it is the part of these two screens worth testing, and +// the test runner cannot reach a `.jsx`. +// +// **None of this is a boundary.** `engagementRules.model.js` on the server +// decides what may be saved, and the engine re-checks the audience ceiling again +// at send time. Everything here is an affordance — not offering a choice the +// server is going to refuse, and saying why in the form rather than in a toast. +// The two copies are expected to drift, which is why the server's is the one +// that decides. +// +// The one rule worth stating out loud, because it is the reason the audience +// list is derived rather than hardcoded: **the ceiling vocabulary comes from the +// server** (`GET /admin/engagement/triggers` serves `ceilings`, each with the set +// it `permits`). A second copy of the lattice in the client would be a second +// copy of a security rule, and a second copy is a copy that drifts. + +/** A rule row as the API returns it → the shape the form edits. */ +export function formFromRule(rule) { + return { + id: rule?.id ?? null, + triggerId: rule?.trigger_id ?? '', + name: rule?.name ?? '', + enabled: Boolean(rule?.enabled), + audience: rule?.audience ?? 'owner', + audienceSegmentId: rule?.audience_segment_id ?? null, + channels: Array.isArray(rule?.channels) ? [...rule.channels] : [], + templateKeys: { ...(rule?.template_keys || {}) }, + conditions: rule?.conditions ?? null, + cooldownSeconds: Number(rule?.cooldown_seconds ?? 0), + delaySeconds: Number(rule?.delay_seconds ?? 0), + cancelOn: Array.isArray(rule?.cancel_on) ? [...rule.cancel_on] : [], + maxSendsPerHour: Number(rule?.max_sends_per_hour ?? 100), + } +} + +/** + * The form → a POST/PUT body. + * + * `templateKeys` is filtered to the rule's channels rather than sent whole, + * because unticking a channel in the form leaves its template key behind and the + * server refuses a key naming a channel the rule does not have. Dropping it here + * makes unticking a channel do the obvious thing instead of producing an error + * about a field the operator cannot see. + */ +export function ruleToPayload(form) { + const channels = [...new Set(form.channels || [])] + const templateKeys = {} + for (const channel of channels) { + const key = (form.templateKeys || {})[channel] + if (key) templateKeys[channel] = key + } + return { + triggerId: form.triggerId, + name: (form.name || '').trim(), + enabled: Boolean(form.enabled), + audience: form.audience, + audienceSegmentId: form.audienceSegmentId ?? null, + channels, + templateKeys, + conditions: form.conditions ?? null, + cooldownSeconds: Number(form.cooldownSeconds) || 0, + delaySeconds: Number(form.delaySeconds) || 0, + cancelOn: [...new Set(form.cancelOn || [])], + maxSendsPerHour: Number(form.maxSendsPerHour) || 100, + } +} + +/** + * Which plain audiences this trigger's ceiling allows, in lattice order. + * + * Derived from the `permits` list the server sends with each ceiling, so a + * trigger declared `owner` offers only `owner` and the editor never presents a + * choice the save is going to refuse. An unknown trigger (a dormant rule whose + * module is gone) offers nothing rather than everything — failing closed is the + * same posture `ceilings.permits` takes on the server. + */ +export function audienceChoicesFor(trigger, ceilings) { + if (!trigger || !Array.isArray(ceilings)) return [] + const declared = ceilings.find((c) => c.id === trigger.ceiling) + if (!declared) return [] + const allowed = new Set(declared.permits || []) + return ceilings.filter((c) => allowed.has(c.id)) +} + +/** Segments a rule under this trigger may point at — the same test, on the stored ceiling. */ +export function segmentChoicesFor(trigger, ceilings, segments) { + const allowed = new Set(audienceChoicesFor(trigger, ceilings).map((c) => c.id)) + return (segments || []).filter((s) => allowed.has(s.ceiling)) +} + +/** + * The sentence rendered beside a reach preview. + * + * Every branch here exists because the bare number would be a lie in that case: + * a capped count is a floor, an `owner` audience has no advance answer, a dormant + * segment resolves to nobody for a reason worth naming, and a count the trigger's + * ceiling forbids is a number the save is about to refuse. + */ +export function describeReach(preview) { + if (!preview) return '' + if (preview.dormant) return `Resolves to nobody right now — ${preview.reason || 'dormant'}.` + if (preview.permitted === false) { + return `Reaches ${preview.count}, but this trigger does not permit that audience — saving will be refused.` + } + if (preview.reason) return `${preview.count} right now — ${preview.reason}.` + if (preview.capped) return `At least ${preview.count} people (the preview stops counting there).` + return preview.count === 1 ? '1 person right now.' : `${preview.count} people right now.` +} + +// ── Segment expressions ──────────────────────────────────────────────────── + +/** + * `not` is legal only as a child of `and` — the server's rule, checked here so + * the composer can grey the button out instead of letting the operator build + * something and then be refused. + * + * The reason, from §5.1a: a complement needs a universe, and the only one that + * does not widen is the set its siblings produced. `A AND NOT B` is "A, less B". + * A bare `NOT B`, or `A OR NOT B`, would have to mean "everyone except…", which + * is a way to build the whole deployment out of one narrow audience. + */ +export function notPlacementError(expression) { + const walk = (node, underAnd) => { + if (!node || typeof node !== 'object') return null + if (!node.op) return null + if (node.op === 'not' && !underAnd) { + return 'A "not" can only be used inside an "all of" group — on its own it would mean "everyone except…".' + } + for (const child of node.nodes || []) { + const err = walk(child, node.op === 'and') + if (err) return err + } + return null + } + return walk(expression, false) +} + +/** A one-line summary of a segment expression, for the list. */ +export function describeExpression(node, audiencesById = {}) { + if (!node || typeof node !== 'object') return '—' + if (!node.op) { + const label = audiencesById[node.audienceId]?.label || node.audienceId + const params = Object.entries(node.params || {}) + return params.length ? `${label} (${params.map(([k, v]) => `${k}: ${v}`).join(', ')})` : label + } + const parts = (node.nodes || []).map((n) => describeExpression(n, audiencesById)) + if (node.op === 'not') return `not ${parts.join(', ')}` + return parts.join(node.op === 'and' ? ' and ' : ' or ') +} + +/** + * The one-line summary of a rule, for the list. + * + * `dormant` is deliberately not folded in here — the list renders that as its own + * badge, because "this rule cannot fire" is a different fact from "this is what + * the rule says" and an operator needs both. + */ +export function describeRule(rule, { segmentsById = {} } = {}) { + const parts = [] + const audience = rule.audience_segment_id + ? segmentsById[rule.audience_segment_id]?.name || `segment ${rule.audience_segment_id}` + : rule.audience + parts.push(`to ${audience}`) + parts.push(`via ${(rule.channels || []).join(', ') || 'no channel'}`) + if (rule.delay_seconds) parts.push(`after ${humanSeconds(rule.delay_seconds)}`) + if (rule.cooldown_seconds) parts.push(`at most once per ${humanSeconds(rule.cooldown_seconds)}`) + parts.push(`≤ ${rule.max_sends_per_hour}/hour`) + return parts.join(' · ') +} + +// ── Conditions ───────────────────────────────────────────────────────────── +// +// The stored grammar is and/or/not over comparisons; the editor offers the flat +// half of it — one and/or over a list of comparisons — because that is what a +// dropdown-per-operator can render honestly and it covers the rules anyone +// writes by hand. +// +// **A tree the editor cannot render is shown, not silently flattened.** +// Flattening `A AND (B OR C)` into `A AND B AND C` changes which events fire the +// rule, and the operator would have no way to know the save had done it. Such a +// rule opens read-only with its JSON visible and one honest choice: leave it, or +// clear it and start again. + +/** Which comparison operators apply to a variable of this declared type? */ +export function operatorsForType(operators, type) { + return (operators || []).filter((o) => !type || (o.types || []).includes(type)) +} + +/** + * A stored conditions tree → the flat rows the editor edits. + * + * `editable: false` means "this file will not pretend it can round-trip that", + * and the screen renders the tree read-only rather than losing part of it. + */ +export function conditionRowsFrom(conditions) { + if (!conditions) return { op: 'and', rows: [], editable: true } + if (conditions.cmp) return { op: 'and', rows: [rowFrom(conditions)], editable: true } + if (conditions.op === 'and' || conditions.op === 'or') { + const children = conditions.nodes || [] + if (children.every((n) => n && n.cmp)) { + return { op: conditions.op, rows: children.map(rowFrom), editable: true } + } + } + return { op: 'and', rows: [], editable: false } +} + +const rowFrom = (node) => ({ + variable: node.variable, + cmp: node.cmp, + // A list operator's value arrives as an array and is edited as comma-separated + // text; everything else is edited as the literal it is. + value: Array.isArray(node.value) ? node.value.join(', ') : node.value === undefined ? '' : String(node.value), +}) + +/** + * The editor's rows → a conditions tree, with each literal coerced to the type + * the trigger DECLARED for that variable. + * + * The coercion is the point. Every value in an HTML input is a string, and the + * server refuses `{ cmp: 'gt', value: "5" }` against an `int` variable — rightly, + * because a rule whose comparison silently compares a number to a string is a + * rule that quietly never fires. Doing it here means the form's error is about + * something the operator typed rather than about JSON. + */ +export function conditionsFromRows(op, rows, variables) { + const byName = Object.fromEntries((variables || []).map((v) => [v.name, v])) + const nodes = (rows || []) + .filter((r) => r.variable && r.cmp) + .map((r) => { + const type = byName[r.variable]?.type || 'string' + const node = { variable: r.variable, cmp: r.cmp } + if (r.cmp === 'present' || r.cmp === 'absent') return node + if (r.cmp === 'in' || r.cmp === 'nin') { + node.value = String(r.value ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .map((s) => coerceLiteral(type, s)) + } else { + node.value = coerceLiteral(type, r.value) + } + return node + }) + if (!nodes.length) return null + if (nodes.length === 1) return nodes[0] + return { op, nodes } +} + +/** + * One typed literal out of one string. + * + * A value that does not parse is passed through UNCHANGED rather than turned + * into `NaN` or `false`: the server's type check will then refuse it and name the + * variable, which is a better error than a rule that saves cleanly and compares + * against a number the operator never typed. + */ +export function coerceLiteral(type, raw) { + if (raw === null || raw === undefined) return raw + const text = typeof raw === 'string' ? raw.trim() : raw + switch (type) { + case 'int': { + const n = Number(text) + return Number.isInteger(n) && text !== '' ? n : text + } + case 'float': { + const n = Number(text) + return Number.isFinite(n) && text !== '' ? n : text + } + case 'boolean': { + if (text === true || text === 'true') return true + if (text === false || text === 'false') return false + return text + } + default: + return text + } +} + +/** Seconds as the coarsest exact unit — 3600 is "1 hour", 3660 is "61 minutes". */ +export function humanSeconds(seconds) { + const n = Number(seconds) || 0 + if (n === 0) return 'none' + const units = [ + [86_400, 'day'], + [3_600, 'hour'], + [60, 'minute'], + ] + for (const [size, name] of units) { + if (n % size === 0) { + const count = n / size + return `${count} ${name}${count === 1 ? '' : 's'}` + } + } + return `${n} seconds` +} diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 55b5f2c..ddef1ec 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -46,6 +46,8 @@ const IconUser = () => const IconPalette = () => const IconModules = () => +const IconMail = () => +const IconList = () => // Nav is grouped into collapsible categories. A group with no `title` renders // its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles` @@ -89,6 +91,19 @@ export const NAV = [ { to: '/admin/teams', label: 'Teams', icon: IconUsers, roles: ['admin', 'moderator'] }, ], }, + { + // Its own top-level group (ENGAGEMENT.md §7.1 Q4), not a section of + // Settings. Settings is already one long page of sections, and the screens + // that join this group in Phase 5 - Triggers, Templates and the send log - + // are a catalog, an editor and a paged table, 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: [ + { to: '/admin/engagement/rules', label: 'Rules', icon: IconMail, roles: ['admin'] }, + { to: '/admin/engagement/audiences', label: 'Audiences', icon: IconList, roles: ['admin'] }, + ], + }, { title: 'System', items: [ @@ -157,6 +172,8 @@ const TITLES = { '/admin/users': 'Users', '/admin/invites': 'Invites', '/admin/account': 'Account Security', + '/admin/engagement/rules': 'Engagement Rules', + '/admin/engagement/audiences': 'Engagement Audiences', } // An installed module's admin pages are not in TITLES and cannot be — core does @@ -176,6 +193,7 @@ function moduleTitle(baseNav, pathname) { function sectionTitle(pathname) { if (pathname.startsWith('/admin/moderation')) return 'Moderation' if (pathname.startsWith('/admin/users/')) return 'User' + if (pathname.startsWith('/admin/engagement')) return 'Engagement' return 'Admin' } diff --git a/client/src/routes/admin/views/EngagementAudiences.jsx b/client/src/routes/admin/views/EngagementAudiences.jsx new file mode 100644 index 0000000..0bd892b --- /dev/null +++ b/client/src/routes/admin/views/EngagementAudiences.jsx @@ -0,0 +1,431 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Loading, ErrorState } from '../../../components/PageState.jsx' +import { api } from '../../../api/client.js' +import { describeExpression, describeReach, notPlacementError } from '../../../lib/engagementRules.js' + +// Admin → Engagement → Audiences (ENGAGEMENT.md §5.1a, Phase 4b). +// +// A module declares named sets of users over its own data — "members of a team", +// "the governors" — and an operator combines them here into a saved audience a +// rule can point at. Core learns no game vocabulary: it knows an id, a label and +// a resolver it may call. +// +// **Composition narrows and never widens**, and that is the whole security +// content of this screen: +// +// • the saved ceiling is DERIVED from the tightest audience in the expression, +// not chosen — including for "any of", where the intuitive answer (the widest +// of the two) is the wrong one. A ceiling says what an expression is allowed +// to reach, not what it will resolve to, so the boolean operator makes no +// difference to it. +// • two ceilings with no ordering between them (staff and owner, say) have no +// answer at all, and the save is refused rather than guessing a side. +// • "none of" is only available inside an "all of" group. On its own it would +// have to mean "everyone except…" — a broadcast built out of one narrow list. +// The composer does not offer it anywhere else, and the server refuses it +// anyway. +// +// The three-level composer here is deliberate: one top-level all-of/any-of, one +// level of groups inside it, and audiences at the leaves. The stored grammar +// allows more nesting; anything deeper is left to the rule that made it and shown +// read-only, the same way the rule editor treats a nested condition. + +const DANGER = { color: '#d98b84', borderColor: '#5b2020' } + +/** A fresh, empty top-level group. */ +const blankExpression = () => ({ op: 'and', nodes: [] }) + +/** Is this tree one the composer can render — a single group of leaves and not-groups? */ +function isComposable(node) { + if (!node || typeof node !== 'object') return false + if (!node.op) return true + if (node.op === 'not') return (node.nodes || []).every((n) => n && !n.op) + if (node.op !== 'and' && node.op !== 'or') return false + return (node.nodes || []).every((n) => n && (!n.op || (n.op === 'not' && (n.nodes || []).every((c) => !c.op)))) +} + +/** The composer edits a top-level group; a bare leaf is lifted into one. */ +const toGroup = (expression) => + !expression ? blankExpression() : expression.op ? expression : { op: 'and', nodes: [expression] } + +// ── One leaf: an audience and its declared parameters ────────────────────── + +function LeafRow({ audiences, node, onChange, onRemove, negated, onToggleNegate, canNegate }) { + const declared = audiences.find((a) => a.id === node.audienceId) + return ( +
+ + {(declared?.params || []).map((p) => ( + + ))} + {canNegate && ( + + )} + +
+ ) +} + +// ── The composer ─────────────────────────────────────────────────────────── + +function SegmentEditor({ audiences, segment, onSaved, onCancel }) { + const [name, setName] = useState(segment?.name || '') + const [group, setGroup] = useState(() => toGroup(segment?.expression)) + const [errors, setErrors] = useState([]) + const [busy, setBusy] = useState(false) + + const isNew = !segment + + // `not` is only offered under "all of" (§5.1a). Under "any of" the checkbox + // disappears rather than being offered and refused. + const canNegate = group.op === 'and' + + function setNodes(nodes) { + setGroup((g) => ({ ...g, nodes })) + } + + function addLeaf() { + setNodes([...group.nodes, { audienceId: '', params: {} }]) + } + + function replaceAt(i, next) { + setNodes(group.nodes.map((n, j) => (i === j ? next : n))) + } + + function toggleNegate(i) { + const node = group.nodes[i] + replaceAt(i, node.op === 'not' ? node.nodes[0] : { op: 'not', nodes: [node] }) + } + + function changeOp(op) { + // Switching to "any of" drops the exclusions rather than sending a tree the + // server will refuse — and says so, because silently keeping them and failing + // at save would be worse than either. + const nodes = op === 'or' ? group.nodes.map((n) => (n.op === 'not' ? n.nodes[0] : n)) : group.nodes + setGroup({ op, nodes }) + } + + const expression = useMemo(() => { + const nodes = group.nodes.filter((n) => (n.op === 'not' ? n.nodes[0]?.audienceId : n.audienceId)) + if (!nodes.length) return null + if (nodes.length === 1 && !nodes[0].op) return nodes[0] + return { op: group.op, nodes } + }, [group]) + + const localError = expression ? notPlacementError(expression) : null + + async function submit(e) { + e.preventDefault() + setErrors([]) + if (!expression) return setErrors(['Add at least one audience.']) + if (localError) return setErrors([localError]) + setBusy(true) + try { + const body = { name: name.trim(), expression } + if (isNew) await api.admin.createEngagementSegment(body) + else await api.admin.updateEngagementSegment(segment.id, body) + await onSaved() + } catch (err) { + setErrors(err.body?.errors?.length ? err.body.errors : [err.message || 'Could not save that audience.']) + } finally { + setBusy(false) + } + } + + return ( +
+
+ {isNew ? 'New saved audience' : `Editing “${segment.name}”`} +
+ +
+ + +
+ +
+ {group.nodes.length === 0 && ( +

+ No audiences yet. A saved audience is built out of the lists installed modules declare. +

+ )} + {group.nodes.map((node, i) => { + const negated = node.op === 'not' + const leaf = negated ? node.nodes[0] : node + return ( + toggleNegate(i)} + onChange={(next) => replaceAt(i, negated ? { op: 'not', nodes: [next] } : next)} + onRemove={() => setNodes(group.nodes.filter((_, j) => j !== i))} + /> + ) + })} + + {!audiences.length && ( + + No module currently declares any. Install one, or use a plain audience on the rule itself. + + )} +
+ + {canNegate ? ( +

+ “Exclude” removes people from what the other rows produced. It is only available under “all + of”: on its own it would mean “everyone except…”, which is a way to reach the whole + deployment from one narrow list. +

+ ) : ( +

+ “Any of” takes the tightest limit of the audiences in it, not the widest — combining two + lists never reaches further than the narrower one allows. +

+ )} + + {(errors.length > 0 || localError) && ( +
    + {(errors.length ? errors : [localError]).map((e) =>
  • {e}
  • )} +
+ )} + +
+ + +
+
+ ) +} + +// ── The screen ───────────────────────────────────────────────────────────── + +export default function EngagementAudiences() { + const [audiences, setAudiences] = useState([]) + const [segments, setSegments] = useState(null) + const [editing, setEditing] = useState(null) // null | { segment } | { segment: null } + const [error, setError] = useState('') + const [rowError, setRowError] = useState('') + const [reach, setReach] = useState({}) // segment id -> preview + + const load = useCallback(async () => { + setError('') + try { + const [declared, saved] = await Promise.all([ + api.admin.engagementAudiences(), + api.admin.listEngagementSegments(), + ]) + setAudiences(declared.audiences || []) + setSegments(saved.segments || []) + } catch { + setError('Could not load audiences.') + } + }, []) + useEffect(() => { load() }, [load]) + + const audiencesById = useMemo( + () => Object.fromEntries(audiences.map((a) => [a.id, a])), + [audiences], + ) + + async function preview(segment) { + try { + const counted = await api.admin.previewEngagementReach({ audienceSegmentId: segment.id }) + setReach((r) => ({ ...r, [segment.id]: counted })) + } catch (err) { + setReach((r) => ({ ...r, [segment.id]: { count: 0, dormant: true, reason: err.message } })) + } + } + + async function remove(segment) { + if (!window.confirm(`Delete “${segment.name}”?`)) return + setRowError('') + try { + await api.admin.deleteEngagementSegment(segment.id) + await load() + } catch (err) { + // A 409 here is the interesting case and the message carries the count: + // deleting a segment a rule still points at would leave that rule reaching + // a different set of people, so it is refused rather than cascaded. + setRowError(err.message || 'Could not delete that audience.') + } + } + + if (error) return + if (!segments) return + + if (editing) { + return ( +
+ { setEditing(null); await load() }} + onCancel={() => setEditing(null)} + /> +
+ ) + } + + return ( +
+
+

+ Named sets of people a rule can be pointed at, built out of the lists installed modules + declare. A saved audience can only ever narrow — combining two lists never reaches further + than the tighter of them allows. +

+ +
+ + {rowError && ( +

{rowError}

+ )} + +
+ + + + + + + + + + + {segments.length === 0 && ( + + + + )} + {segments.map((s) => ( + + + + + + + + ))} + +
NameMade ofReaches at mostRight now +
+ No saved audiences yet. +
+ {s.name} + {s.dormant && ( +
+ + Dormant + +
+ )} +
+ {describeExpression(s.expression, audiencesById)} + {s.ceiling} + {reach[s.id] ? ( + describeReach(reach[s.id]) + ) : ( + + )} + + + +
+
+ +
+
What modules currently declare
+ {audiences.length === 0 ? ( +

+ Nothing. Audiences come from installed modules — core declares none, because core knows no + game vocabulary. +

+ ) : ( +
    + {audiences.map((a) => ( +
  • + {a.label}{a.id}, reaches at + most “{a.ceiling}” + {(a.params || []).length ? ` (${a.params.map((p) => p.id).join(', ')})` : ''} +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/client/src/routes/admin/views/EngagementRules.jsx b/client/src/routes/admin/views/EngagementRules.jsx new file mode 100644 index 0000000..d4ebd52 --- /dev/null +++ b/client/src/routes/admin/views/EngagementRules.jsx @@ -0,0 +1,628 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Loading, ErrorState } from '../../../components/PageState.jsx' +import { api } from '../../../api/client.js' +import { + formFromRule, + ruleToPayload, + audienceChoicesFor, + segmentChoicesFor, + describeReach, + describeRule, + conditionRowsFrom, + conditionsFromRows, + operatorsForType, +} from '../../../lib/engagementRules.js' + +// Admin → Engagement → Rules (ENGAGEMENT.md Phase 4b). +// +// A rule is trigger → audience → channels → timing, and this is the screen that +// writes one. Everything it decides lives in lib/engagementRules.js so it can be +// tested; this file renders it and talks to the API. +// +// Four things about this screen are deliberate and would be wrong the obvious +// way round: +// +// 1. **The on/off switch is not the form.** It is its own request against its +// own route, and it does not re-validate the rule. A rule whose module has +// been uninstalled is dormant, is the rule an operator most wants stopped, +// and is exactly the rule the form would refuse to save. +// 2. **A rule's trigger is fixed once it exists.** Its cooldowns, its pending +// outbox rows and its send-log history are all about one trigger id. +// 3. **Every rule arrives off.** §7.1 Q3 makes rules operator-editable data on +// the condition that nothing starts mailing by itself — so a new rule is +// created disabled and switched on afterwards, as a separate act. +// 4. **The reach preview is a number.** Never a list of people: a +// module-declared segment resolves over game data, and this screen is about +// mail scheduling. + +const DANGER = { color: '#d98b84', borderColor: '#5b2020' } +const BLANK = { + id: null, + triggerId: '', + name: '', + enabled: false, + audience: 'owner', + audienceSegmentId: null, + channels: [], + templateKeys: {}, + conditions: null, + cooldownSeconds: 0, + delaySeconds: 0, + cancelOn: [], + maxSendsPerHour: 100, +} + +function Dormant({ reasons }) { + return ( + + Dormant + + ) +} + +// ── The editor ───────────────────────────────────────────────────────────── + +function RuleEditor({ catalog, segments, rule, onSaved, onCancel }) { + const [form, setForm] = useState(() => (rule ? formFromRule(rule) : { ...BLANK })) + const [conditionState, setConditionState] = useState(() => conditionRowsFrom(rule?.conditions)) + const [preview, setPreview] = useState(null) + const [previewing, setPreviewing] = useState(false) + const [errors, setErrors] = useState([]) + const [busy, setBusy] = useState(false) + + const isNew = !form.id + const set = (patch) => setForm((f) => ({ ...f, ...patch })) + + const trigger = useMemo( + () => catalog.triggers.find((t) => t.id === form.triggerId) || null, + [catalog.triggers, form.triggerId], + ) + const audienceChoices = audienceChoicesFor(trigger, catalog.ceilings) + const segmentChoices = segmentChoicesFor(trigger, catalog.ceilings, segments) + const variables = trigger?.variables || [] + + // Changing the trigger invalidates the audience and every condition, because + // both are stated in the old trigger's vocabulary. Clearing them is the honest + // move: keeping a condition on a variable the new trigger never carries would + // make the rule fire on nothing, silently (an absent variable fails every + // comparison, by design). + function pickTrigger(id) { + const next = catalog.triggers.find((t) => t.id === id) + setForm((f) => ({ + ...f, + triggerId: id, + audience: next?.audience || 'owner', + audienceSegmentId: null, + })) + setConditionState({ op: 'and', rows: [], editable: true }) + setPreview(null) + } + + function toggleChannel(id) { + setForm((f) => ({ + ...f, + channels: f.channels.includes(id) ? f.channels.filter((c) => c !== id) : [...f.channels, id], + })) + } + + async function runPreview() { + setPreviewing(true) + try { + setPreview( + await api.admin.previewEngagementReach({ + audience: form.audience, + audienceSegmentId: form.audienceSegmentId, + triggerId: form.triggerId, + }), + ) + } catch (err) { + setPreview({ count: 0, dormant: true, reason: err.message || 'could not be resolved' }) + } finally { + setPreviewing(false) + } + } + + async function submit(e) { + e.preventDefault() + setErrors([]) + setBusy(true) + const payload = ruleToPayload({ + ...form, + conditions: conditionState.editable + ? conditionsFromRows(conditionState.op, conditionState.rows, variables) + : form.conditions, + }) + try { + if (isNew) await api.admin.createEngagementRule(payload) + else await api.admin.updateEngagementRule(form.id, payload) + await onSaved() + } catch (err) { + // The server sends every problem, not just the first. A form that shows one + // makes an operator fix four things in four round trips. + setErrors(err.body?.errors?.length ? err.body.errors : [err.message || 'Could not save the rule.']) + } finally { + setBusy(false) + } + } + + return ( +
+
+ {isNew ? 'New rule' : `Editing “${rule.name}”`} +
+ +
+ + +
+ + {trigger?.description && ( +

+ {trigger.description} +

+ )} + + {/* ── Audience ── */} +
Who it reaches
+
+ + + +
+ {preview && ( +

+ {describeReach(preview)} +

+ )} + {trigger && audienceChoices.length <= 1 && ( +

+ This event only permits “{trigger.ceiling}”. The audience a rule may use is capped by the + event itself, not by the rule. +

+ )} + + {/* ── Channels ── */} +
How it is delivered
+
+ {catalog.channels.map((c) => ( +
+ + {form.channels.includes(c.id) && ( + set({ templateKeys: { ...form.templateKeys, [c.id]: e.target.value } })} + /> + )} +
+ ))} +
+

+ Every channel is opt-in: a rule reaches only the people who turned that channel on for this + event in their own notification settings. +

+ + {/* ── Conditions ── */} +
Only when…
+ {!conditionState.editable ? ( +
+

+ This rule has a nested condition this editor does not render. It is left exactly as it is + unless you clear it — flattening it here would change which events fire the rule. +

+
+            {JSON.stringify(form.conditions, null, 2)}
+          
+ +
+ ) : ( + <> + {conditionState.rows.length > 1 && ( + + )} + {conditionState.rows.map((row, i) => { + const type = variables.find((v) => v.name === row.variable)?.type + const ops = operatorsForType(catalog.operators, type) + const takesValue = row.cmp !== 'present' && row.cmp !== 'absent' + const patch = (p) => + setConditionState((s) => ({ + ...s, + rows: s.rows.map((r, j) => (i === j ? { ...r, ...p } : r)), + })) + return ( +
+ + + {takesValue && ( + patch({ value: e.target.value })} + /> + )} + +
+ ) + })} + + {!variables.length && ( + + Choose a trigger first — its declared variables are what a condition can talk about. + + )} + + )} + + {/* ── Timing and the ceiling ── */} +
Timing
+
+ + + +
+

+ The cooldown is per recipient and per subject + {trigger?.subjectKey ? ` (“${trigger.subjectKey}”)` : ''} — a player whose four houses are all + decaying hears about all four, once each. The hourly cap is per rule and is the hard stop that + keeps a misconfiguration to a bad hour. +

+ + {form.delaySeconds > 0 && ( + + )} + + {errors.length > 0 && ( +
    + {errors.map((e) =>
  • {e}
  • )} +
+ )} + +
+ + + {isNew && ( + + A new rule is created switched off. Turn it on from the list when you are happy with it. + + )} +
+
+ ) +} + +// ── The screen ───────────────────────────────────────────────────────────── + +export default function EngagementRules() { + const [catalog, setCatalog] = useState(null) + const [segments, setSegments] = useState([]) + const [rules, setRules] = useState(null) + const [editing, setEditing] = useState(null) // null | { rule } | { rule: null } for new + const [error, setError] = useState('') + const [rowError, setRowError] = useState('') + + const load = useCallback(async () => { + setError('') + try { + const [triggers, channels, segs, list] = await Promise.all([ + api.admin.engagementTriggers(), + api.admin.engagementChannels(), + api.admin.listEngagementSegments(), + api.admin.listEngagementRules(), + ]) + setCatalog({ + triggers: triggers.triggers || [], + ceilings: triggers.ceilings || [], + operators: triggers.operators || [], + channels: channels.channels || [], + }) + setSegments(segs.segments || []) + setRules(list.rules || []) + } catch { + setError('Could not load the engagement rules.') + } + }, []) + useEffect(() => { load() }, [load]) + + const segmentsById = useMemo( + () => Object.fromEntries(segments.map((s) => [s.id, s])), + [segments], + ) + + async function toggle(rule) { + setRowError('') + try { + await api.admin.setEngagementRuleEnabled(rule.id, !rule.enabled) + await load() + } catch (err) { + setRowError(err.message || 'Could not change that rule.') + } + } + + async function remove(rule) { + if (!window.confirm(`Delete “${rule.name}”? Its queued sends go with it; the send log does not.`)) return + setRowError('') + try { + await api.admin.deleteEngagementRule(rule.id) + await load() + } catch (err) { + setRowError(err.message || 'Could not delete that rule.') + } + } + + if (error) return + if (!catalog || !rules) return + + if (editing) { + return ( +
+ { setEditing(null); await load() }} + onCancel={() => setEditing(null)} + /> +
+ ) + } + + return ( +
+
+

+ A rule turns an event into mail: which event, who hears about it, on which channels, and how + often at most. Nothing sends until a rule is switched on. +

+ +
+ + {rowError && ( +

{rowError}

+ )} + +
+ + + + + + + + + + + {rules.length === 0 && ( + + + + )} + {rules.map((rule) => ( + + + + + + + + ))} + +
RuleTriggerWhat it doesState +
+ No rules yet. Nothing is being sent. +
{rule.name}{rule.trigger_id} + {describeRule(rule, { segmentsById })} + + + {rule.dormant && ( +
+ )} +
+ + +
+
+ + {rules.some((r) => r.dormant) && ( +

+ A dormant rule names something that is not registered right now — usually a module that has + been uninstalled. It is kept exactly as it is, it never fires, and it starts working again + when the module comes back. It can still be switched off. +

+ )} +
+ ) +} diff --git a/client/test/engagementRules.test.js b/client/test/engagementRules.test.js new file mode 100644 index 0000000..7245878 --- /dev/null +++ b/client/test/engagementRules.test.js @@ -0,0 +1,277 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + formFromRule, + ruleToPayload, + audienceChoicesFor, + segmentChoicesFor, + describeReach, + describeRule, + describeExpression, + notPlacementError, + conditionRowsFrom, + conditionsFromRows, + operatorsForType, + coerceLiteral, + humanSeconds, +} from '../src/lib/engagementRules.js' + +// lib/engagementRules.js — what the two Engagement screens say and what they let +// an operator pick (ENGAGEMENT.md Phase 4b). +// +// None of this is a boundary: the server's `engagementRules.model` decides what +// may be saved and the engine re-checks the audience ceiling at send time. What +// is tested here is the part that would be wrong SILENTLY — a form that sends a +// string where the trigger declared an int, a composer that flattens a nested +// condition into one that fires on different events, an editor that offers an +// audience the save is going to refuse. + +const CEILINGS = [ + { id: 'everyone', label: 'Everyone', permits: ['everyone', 'authenticated', 'subscribers', 'members', 'staff', 'owner'] }, + { id: 'authenticated', label: 'Signed-in users', permits: ['authenticated', 'subscribers', 'members', 'staff', 'owner'] }, + { id: 'subscribers', label: 'Subscribers', permits: ['subscribers'] }, + { id: 'members', label: 'A module list', permits: ['members'] }, + { id: 'staff', label: 'Staff', permits: ['staff'] }, + { id: 'owner', label: 'The person it is about', permits: ['owner'] }, +] + +const TRIGGER = { + id: 'uo.house.idoc_warning', + label: 'House approaching collapse', + ceiling: 'owner', + audience: 'owner', + subjectKey: 'house', + variables: [ + { name: 'house', type: 'string', required: true }, + { name: 'daysLeft', type: 'int', required: false }, + { name: 'insured', type: 'boolean', required: false }, + ], +} + +const OPERATORS = [ + { cmp: 'eq', label: 'is', types: ['string', 'int', 'boolean'], arity: 1 }, + { cmp: 'gt', label: 'is greater than', types: ['int'], arity: 1 }, + { cmp: 'in', label: 'is one of', types: ['string', 'int'], arity: 'list' }, + { cmp: 'present', label: 'is present', types: ['string', 'int', 'boolean'], arity: 0 }, +] + +const row = (over = {}) => ({ + id: 3, + trigger_id: 'uo.house.idoc_warning', + name: 'IDOC warning', + enabled: 1, + audience: 'owner', + audience_segment_id: null, + channels: ['email'], + template_keys: { email: 'idoc-warning' }, + conditions: null, + cooldown_seconds: 86400, + delay_seconds: 0, + cancel_on: [], + max_sends_per_hour: 100, + ...over, +}) + +// ── The form round trip ──────────────────────────────────────────────────── + +test('a rule row round-trips through the form without changing what it means', () => { + const payload = ruleToPayload(formFromRule(row())) + + assert.equal(payload.triggerId, 'uo.house.idoc_warning') + assert.equal(payload.enabled, true) + assert.deepEqual(payload.channels, ['email']) + assert.deepEqual(payload.templateKeys, { email: 'idoc-warning' }) + assert.equal(payload.cooldownSeconds, 86400) + assert.equal(payload.maxSendsPerHour, 100) +}) + +test('unticking a channel drops its template key, rather than sending one the server refuses', () => { + const form = formFromRule(row({ channels: ['email', 'push'], template_keys: { email: 'a', push: 'b' } })) + form.channels = ['email'] + + const payload = ruleToPayload(form) + + // The server refuses `templateKeys` naming a channel the rule does not have. + // Leaving it in would produce an error about a field the operator cannot see. + assert.deepEqual(payload.templateKeys, { email: 'a' }) +}) + +// ── The audience the editor may offer ────────────────────────────────────── + +test('the editor offers only what the trigger ceiling permits', () => { + const choices = audienceChoicesFor(TRIGGER, CEILINGS).map((c) => c.id) + assert.deepEqual(choices, ['owner']) +}) + +test('a wider trigger offers more, in lattice order', () => { + const choices = audienceChoicesFor({ ...TRIGGER, ceiling: 'authenticated' }, CEILINGS).map((c) => c.id) + assert.deepEqual(choices, ['authenticated', 'subscribers', 'members', 'staff', 'owner']) +}) + +test('an unknown trigger offers nothing — failing closed, like the server', () => { + // This is a dormant rule, whose module has been uninstalled. Offering the full + // vocabulary would be the widening the whole ceiling design exists to prevent. + assert.deepEqual(audienceChoicesFor({ ...TRIGGER, ceiling: 'nonsense' }, CEILINGS), []) + assert.deepEqual(audienceChoicesFor(null, CEILINGS), []) +}) + +test('segments are filtered by their STORED ceiling, not re-derived', () => { + const segments = [ + { id: 1, name: 'Governors', ceiling: 'members' }, + { id: 2, name: 'Watchers', ceiling: 'authenticated' }, + ] + const wide = segmentChoicesFor({ ...TRIGGER, ceiling: 'authenticated' }, CEILINGS, segments) + assert.deepEqual(wide.map((s) => s.id), [1, 2]) + + const narrow = segmentChoicesFor({ ...TRIGGER, ceiling: 'members' }, CEILINGS, segments) + assert.deepEqual(narrow.map((s) => s.id), [1]) +}) + +// ── The reach preview ────────────────────────────────────────────────────── + +test('a capped count reads as a floor, never as a total', () => { + const said = describeReach({ count: 5000, capped: true, dormant: false, reason: null, permitted: true }) + assert.match(said, /At least 5000/) +}) + +test('a count the trigger would refuse says so, instead of looking healthy', () => { + const said = describeReach({ count: 12, capped: false, dormant: false, reason: null, permitted: false }) + assert.match(said, /will be refused/) +}) + +test('a dormant segment says why, rather than reading as "nobody"', () => { + const said = describeReach({ count: 0, dormant: true, reason: 'audience segment is dormant' }) + assert.match(said, /dormant/) +}) + +test('an owner audience carries its reason forward', () => { + const said = describeReach({ count: 0, dormant: false, reason: 'event carries no ownerUserId', permitted: true }) + assert.match(said, /ownerUserId/) +}) + +// ── Conditions ───────────────────────────────────────────────────────────── + +test('operators narrow to the variable type that was picked', () => { + assert.deepEqual(operatorsForType(OPERATORS, 'boolean').map((o) => o.cmp), ['eq', 'present']) + assert.deepEqual(operatorsForType(OPERATORS, 'int').map((o) => o.cmp), ['eq', 'gt', 'in', 'present']) +}) + +test('a literal is coerced to the type the trigger DECLARED', () => { + // Every value in an HTML input is a string, and `{ cmp: 'gt', value: "5" }` + // against an int variable is refused by the server — rightly, because a + // comparison between a number and a string quietly never matches. + const built = conditionsFromRows('and', [{ variable: 'daysLeft', cmp: 'gt', value: '5' }], TRIGGER.variables) + assert.deepEqual(built, { variable: 'daysLeft', cmp: 'gt', value: 5 }) +}) + +test('a value that does not parse is passed through, so the server names the field', () => { + // NOT NaN, and not 0: a rule that saves cleanly having silently compared + // against a number nobody typed is worse than a refusal that says which + // variable it was. + assert.equal(coerceLiteral('int', 'soon'), 'soon') + assert.equal(coerceLiteral('boolean', 'yes'), 'yes') + assert.equal(coerceLiteral('boolean', 'true'), true) + assert.equal(coerceLiteral('float', '1.5'), 1.5) +}) + +test('a list operator splits on commas and types each item', () => { + const built = conditionsFromRows('and', [{ variable: 'daysLeft', cmp: 'in', value: '1, 2, 3' }], TRIGGER.variables) + assert.deepEqual(built.value, [1, 2, 3]) +}) + +test('present and absent carry no value at all', () => { + const built = conditionsFromRows('and', [{ variable: 'house', cmp: 'present', value: 'ignored' }], TRIGGER.variables) + assert.deepEqual(built, { variable: 'house', cmp: 'present' }) +}) + +test('no rows means no conditions — not an empty group that matches nothing', () => { + assert.equal(conditionsFromRows('and', [], TRIGGER.variables), null) + assert.equal(conditionsFromRows('and', [{ variable: '', cmp: '' }], TRIGGER.variables), null) +}) + +test('a flat stored tree opens editable; a nested one opens read-only', () => { + const flat = conditionRowsFrom({ + op: 'and', + nodes: [{ variable: 'house', cmp: 'eq', value: 'x' }, { variable: 'daysLeft', cmp: 'gt', value: 5 }], + }) + assert.equal(flat.editable, true) + assert.equal(flat.rows.length, 2) + + // `A AND (B OR C)` flattened to `A AND B AND C` fires on different events, and + // the operator would have no way to know the save had done it. + const nested = conditionRowsFrom({ + op: 'and', + nodes: [ + { variable: 'house', cmp: 'eq', value: 'x' }, + { op: 'or', nodes: [{ variable: 'daysLeft', cmp: 'gt', value: 5 }] }, + ], + }) + assert.equal(nested.editable, false) + assert.deepEqual(nested.rows, []) +}) + +test('a single stored comparison is one editable row', () => { + const one = conditionRowsFrom({ variable: 'house', cmp: 'eq', value: 'x' }) + assert.equal(one.editable, true) + assert.deepEqual(one.rows, [{ variable: 'house', cmp: 'eq', value: 'x' }]) +}) + +// ── Segment composition ──────────────────────────────────────────────────── + +test('a bare not is refused before it reaches the server', () => { + assert.ok(notPlacementError({ op: 'not', nodes: [{ audienceId: 'uo.governors' }] })) + assert.ok(notPlacementError({ op: 'or', nodes: [{ audienceId: 'a' }, { op: 'not', nodes: [{ audienceId: 'b' }] }] })) +}) + +test('a not under an "all of" is fine — that is the only universe that does not widen', () => { + assert.equal( + notPlacementError({ + op: 'and', + nodes: [{ audienceId: 'uo.governors' }, { op: 'not', nodes: [{ audienceId: 'uo.flagged' }] }], + }), + null, + ) +}) + +test('an expression describes itself with module labels where it has them', () => { + const byId = { 'uo.governors': { label: 'Governors' } } + const said = describeExpression( + { op: 'and', nodes: [{ audienceId: 'uo.governors' }, { op: 'not', nodes: [{ audienceId: 'uo.flagged' }] }] }, + byId, + ) + assert.equal(said, 'Governors and not uo.flagged') +}) + +test('a leaf renders its parameters, so two rows built on the same audience are distinguishable', () => { + const said = describeExpression({ audienceId: 'uo.team.members', params: { teamId: 4 } }, {}) + assert.equal(said, 'uo.team.members (teamId: 4)') +}) + +// ── The list summary ─────────────────────────────────────────────────────── + +test('a rule summarises to what it will do, and always names its hourly cap', () => { + const said = describeRule(row({ delay_seconds: 3600 }), { segmentsById: {} }) + assert.match(said, /to owner/) + assert.match(said, /via email/) + assert.match(said, /after 1 hour/) + assert.match(said, /once per 1 day/) + assert.match(said, /100\/hour/) +}) + +test('a rule on a segment names the segment, not the ceiling column', () => { + // The `audience` column on such a rule holds the segment's ceiling, which is a + // fact about what it MAY reach and not about who it does. + const said = describeRule(row({ audience: 'members', audience_segment_id: 7 }), { + segmentsById: { 7: { name: 'Governors' } }, + }) + assert.match(said, /to Governors/) +}) + +test('humanSeconds picks the coarsest EXACT unit, and never rounds', () => { + assert.equal(humanSeconds(0), 'none') + assert.equal(humanSeconds(3600), '1 hour') + assert.equal(humanSeconds(86400), '1 day') + assert.equal(humanSeconds(7200), '2 hours') + assert.equal(humanSeconds(3660), '61 minutes') + assert.equal(humanSeconds(90), '90 seconds') +}) diff --git a/server/routes.guards.json b/server/routes.guards.json index c52051c..bdf9987 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -167,6 +167,15 @@ "validate" ] }, + { + "method": "GET", + "path": "/api/v1/admin/engagement/audience-preview", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, { "method": "GET", "path": "/api/v1/admin/engagement/audiences", @@ -176,6 +185,105 @@ "requireAuth" ] }, + { + "method": "GET", + "path": "/api/v1/admin/engagement/channels", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/engagement/rules", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/engagement/rules", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/admin/engagement/rules/:id", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/engagement/rules/:id", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "PUT", + "path": "/api/v1/admin/engagement/rules/:id", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "PATCH", + "path": "/api/v1/admin/engagement/rules/:id/enabled", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/engagement/segments", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/engagement/segments", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/admin/engagement/segments/:id", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "PUT", + "path": "/api/v1/admin/engagement/segments/:id", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, { "method": "GET", "path": "/api/v1/admin/engagement/triggers", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index eeff48a..14f89bc 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -73,10 +73,58 @@ "method": "POST", "path": "/api/v1/admin/email/test" }, + { + "method": "GET", + "path": "/api/v1/admin/engagement/audience-preview" + }, { "method": "GET", "path": "/api/v1/admin/engagement/audiences" }, + { + "method": "GET", + "path": "/api/v1/admin/engagement/channels" + }, + { + "method": "GET", + "path": "/api/v1/admin/engagement/rules" + }, + { + "method": "POST", + "path": "/api/v1/admin/engagement/rules" + }, + { + "method": "DELETE", + "path": "/api/v1/admin/engagement/rules/:id" + }, + { + "method": "GET", + "path": "/api/v1/admin/engagement/rules/:id" + }, + { + "method": "PUT", + "path": "/api/v1/admin/engagement/rules/:id" + }, + { + "method": "PATCH", + "path": "/api/v1/admin/engagement/rules/:id/enabled" + }, + { + "method": "GET", + "path": "/api/v1/admin/engagement/segments" + }, + { + "method": "POST", + "path": "/api/v1/admin/engagement/segments" + }, + { + "method": "DELETE", + "path": "/api/v1/admin/engagement/segments/:id" + }, + { + "method": "PUT", + "path": "/api/v1/admin/engagement/segments/:id" + }, { "method": "GET", "path": "/api/v1/admin/engagement/triggers" diff --git a/server/src/engagement/segments.js b/server/src/engagement/segments.js index 390044b..a1b7663 100644 --- a/server/src/engagement/segments.js +++ b/server/src/engagement/segments.js @@ -229,4 +229,30 @@ async function resolve(expression) { return { dormant, userIds: dormant ? [] : [...set] } } -module.exports = { validate, resolve, MAX_DEPTH, MAX_NODES } +/** + * Which audience ids in this expression nobody registers right now? + * + * The static half of the dormancy answer `resolve` gives at send time, and it + * lives here so the two cannot disagree. Two callers need it and neither may + * require the other: the segment list annotates itself with it, and the RULE + * list needs it to say that a rule pointing at a dormant segment is itself + * dormant — which is §5.1a rule 4, and which the first version of the rule + * annotation missed by asking only whether the segment ROW still existed. + * + * The difference is the whole point. A deleted segment and a segment whose + * module is gone both leave the rule reaching nobody; only one of them leaves a + * row behind. A screen that reports the first and not the second shows an + * enabled, healthy-looking rule that cannot fire. + */ +function missingAudiences(expression) { + const missing = [] + const walk = (node) => { + if (!node || typeof node !== 'object') return + if (node.op) (node.nodes || []).forEach(walk) + else if (!registries.audience(node.audienceId)) missing.push(node.audienceId) + } + walk(expression) + return [...new Set(missing)] +} + +module.exports = { validate, resolve, missingAudiences, MAX_DEPTH, MAX_NODES } diff --git a/server/src/model/engagement/engagementRules.db.js b/server/src/model/engagement/engagementRules.db.js index f003f96..149a8f7 100644 --- a/server/src/model/engagement/engagementRules.db.js +++ b/server/src/model/engagement/engagementRules.db.js @@ -103,6 +103,27 @@ const update = (id, rule) => ], ) +/** + * Flip `enabled` and nothing else (Phase 4b). + * + * Deliberately NOT a call through `validate`: turning a rule OFF is the panic + * button, and it has to work on a rule the registries would now refuse — one + * whose module was uninstalled, or whose trigger has since narrowed its ceiling + * underneath a saved audience. Re-validating on the way to `enabled = 0` would + * make exactly the rules an operator most wants to stop the ones they cannot. + * + * Turning a rule ON is safe without re-validation for a different reason: the + * engine re-runs the ceiling check at send time (audiences.permitted), so an + * enabled-but-no-longer-permitted rule resolves to nobody rather than to the + * wrong people. + */ +const setEnabled = (id, enabled, updatedBy = null) => + query('UPDATE engagement_rules SET enabled = ?, updated_by = ? WHERE id = ?', [ + enabled ? 1 : 0, + updatedBy, + id, + ]) + const remove = (id) => query('DELETE FROM engagement_rules WHERE id = ?', [id]) /** Does any rule still point at this segment? The check before a segment delete. */ @@ -121,6 +142,7 @@ module.exports = { enabledCancelledBy, insert, update, + setEnabled, remove, countUsingSegment, parseJson, diff --git a/server/src/model/engagement/engagementRules.model.js b/server/src/model/engagement/engagementRules.model.js index 51835e9..65ca767 100644 --- a/server/src/model/engagement/engagementRules.model.js +++ b/server/src/model/engagement/engagementRules.model.js @@ -23,6 +23,7 @@ const segmentsDb = require('./engagementSegments.db') const registries = require('../../modules/registries') const ceilings = require('../../modules/ceilings') const channels = require('../../engagement/channels') +const segmentExpressions = require('../../engagement/segments') const conditions = require('../../engagement/conditions') // A day. Longer than this and "cooldown" is really "send once", which a rule @@ -192,6 +193,48 @@ async function update(id, input) { return { ok: true, rule: await db.getById(id) } } +/** + * Why this rule cannot currently fire, as a list of sentences. Empty = it can. + * + * **Three ways, not two.** A rule can be dormant because its trigger is gone, + * because a channel it names is gone, or because its AUDIENCE is gone - and the + * audience case has two shapes that a screen must not collapse into one: + * + * • the segment row was deleted out from under it (§7.3), or + * • the segment still exists and every audience in it belongs to a module that + * has been uninstalled (§5.1a rule 4). + * + * Both leave the rule reaching nobody. Only the first leaves nothing behind, and + * a check that asks only "does the row exist" reports the first and misses the + * second - which shows an enabled, healthy-looking rule that cannot fire. Found + * by uninstalling a module under a live rule while building Phase 4b's screen. + * + * @param {Map} segments every segment, by id + */ +function dormancyReasons(rule, segments) { + const reasons = [] + if (!registries.eventTrigger(rule.trigger_id)) reasons.push(`trigger "${rule.trigger_id}" is not registered`) + if (rule.audience_segment_id) { + const segment = segments.get(rule.audience_segment_id) + if (!segment) reasons.push('its audience segment no longer exists') + else { + const missing = segmentExpressions.missingAudiences(segment.expression) + if (missing.length) { + reasons.push(`its audience "${segment.name}" uses ${missing.join(', ')}, which nothing registers`) + } + } + } + for (const c of rule.channels || []) if (!channels.has(c)) reasons.push(`channel "${c}" is not registered`) + return reasons +} + +const annotate = (rule, segments) => { + const reasons = dormancyReasons(rule, segments) + return { ...rule, dormant: reasons.length > 0, dormantReasons: reasons } +} + +const segmentsById = async () => new Map((await segmentsDb.list()).map((s) => [s.id, s])) + /** * List every rule, each annotated with whether it can currently fire. * @@ -202,23 +245,59 @@ async function update(id, input) { */ async function listAnnotated() { const rows = await db.list() - const segments = new Map((await segmentsDb.list()).map((s) => [s.id, s])) - return rows.map((rule) => { - const reasons = [] - if (!registries.eventTrigger(rule.trigger_id)) reasons.push(`trigger "${rule.trigger_id}" is not registered`) - if (rule.audience_segment_id && !segments.has(rule.audience_segment_id)) { - reasons.push('its audience segment no longer exists') - } - for (const c of rule.channels || []) if (!channels.has(c)) reasons.push(`channel "${c}" is not registered`) - return { ...rule, dormant: reasons.length > 0, dormantReasons: reasons } - }) + const segments = await segmentsById() + return rows.map((rule) => annotate(rule, segments)) +} + +/** One rule with the same dormancy annotation the list carries, or null. */ +async function getAnnotated(id) { + const rule = await db.getById(id) + if (!rule) return null + return annotate(rule, await segmentsById()) +} + +/** + * Turn one rule on or off, writing that column and no other (Phase 4b). + * + * This is the one write path that does NOT go through `validate`, and the + * asymmetry is deliberate. Switching a rule OFF must always be possible - a rule + * whose module has been uninstalled, or whose trigger has since narrowed its + * ceiling under a saved audience, is exactly the rule an operator most urgently + * wants stopped, and it is exactly the rule `validate` would now refuse. The + * full editor still re-validates on save, and the engine re-checks the ceiling at + * send time, so nothing is loosened by having a switch that is only a switch. + */ +async function setEnabled(id, enabled, updatedBy = null) { + const existing = await db.getById(id) + if (!existing) return { ok: false, errors: [`no rule ${id} exists`], notFound: true } + await db.setEnabled(id, enabled, updatedBy) + return { ok: true, rule: await getAnnotated(id) } +} + +/** + * Delete a rule. + * + * Its cooldown rows and any still-pending outbox rows go with it (both carry an + * ON DELETE CASCADE), and that is the right blast radius: neither means anything + * without the rule. `engagement_sends` deliberately does NOT — its `rule_id` + * carries no foreign key — so the send log outlives the rule and the record of + * what was actually mailed survives an operator tidying up. + */ +async function remove(id) { + const existing = await db.getById(id) + if (!existing) return { ok: false, errors: [`no rule ${id} exists`], notFound: true } + await db.remove(id) + return { ok: true } } module.exports = { validate, create, update, + setEnabled, + remove, listAnnotated, + getAnnotated, MAX_COOLDOWN_SECONDS, MAX_DELAY_SECONDS, MAX_SENDS_PER_HOUR, diff --git a/server/src/model/engagement/engagementSegments.model.js b/server/src/model/engagement/engagementSegments.model.js index dac36b0..79893b4 100644 --- a/server/src/model/engagement/engagementSegments.model.js +++ b/server/src/model/engagement/engagementSegments.model.js @@ -11,7 +11,6 @@ const db = require('./engagementSegments.db') const rulesDb = require('./engagementRules.db') -const registries = require('../../modules/registries') const segments = require('../../engagement/segments') async function save(input, { id = null } = {}) { @@ -56,7 +55,7 @@ async function remove(id) { return { ok: false, inUse, - errors: [`${inUse} rule${inUse === 1 ? '' : 's'} still use this segment`], + errors: [`${inUse} rule${inUse === 1 ? ' still uses' : 's still use'} this segment`], } } await db.remove(id) @@ -73,14 +72,11 @@ async function remove(id) { async function listAnnotated() { const rows = await db.list() return rows.map((segment) => { - const missing = [] - const walk = (node) => { - if (!node || typeof node !== 'object') return - if (node.op) (node.nodes || []).forEach(walk) - else if (!registries.audience(node.audienceId)) missing.push(node.audienceId) - } - walk(segment.expression) - return { ...segment, dormant: missing.length > 0, missingAudiences: [...new Set(missing)] } + // The walk lives in segments.js so the rule list can ask the same question: + // a rule pointing at a DORMANT segment is dormant too, and asking only + // whether the segment row still exists misses that (§5.1a rule 4). + const missing = segments.missingAudiences(segment.expression) + return { ...segment, dormant: missing.length > 0, missingAudiences: missing } }) } diff --git a/server/src/router/v1/admin/engagement.controller.js b/server/src/router/v1/admin/engagement.controller.js index bd666f5..05b114e 100644 --- a/server/src/router/v1/admin/engagement.controller.js +++ b/server/src/router/v1/admin/engagement.controller.js @@ -17,9 +17,19 @@ // interpolation (§4.3 property 2), the `example` on each variable is what makes // preview and test-send possible without a live game event, and the ceilings are // what the rule editor has to obey when it offers an audience (G24). +// +// **Phase 4b adds the writes**: rules and segments CRUD, the enable switch and +// the reach preview, all below. Every one of them goes through the model — this +// file reads ids out of URLs and shapes responses, and validates nothing. const registries = require('../../../modules/registries') const ceilings = require('../../../modules/ceilings') +const channels = require('../../../engagement/channels') +const conditions = require('../../../engagement/conditions') +const audiences = require('../../../engagement/audiences') +const rules = require('../../../model/engagement/engagementRules.model') +const segments = require('../../../model/engagement/engagementSegments.model') +const recipients = require('../../../model/engagement/engagementRecipients.db') // 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 @@ -41,6 +51,12 @@ exports.listTriggers = (req, res) => { ceilings: ceilingVocabulary(), variableTypes: registries.VARIABLE_TYPES, kinds: registries.TRIGGER_KINDS, + // The condition operators, each with the variable types it applies to, so + // the editor's operator dropdown narrows itself to the variable that was + // picked instead of offering "is greater than" on a boolean. Same argument + // as the ceilings: one copy of the grammar, served from the file that + // evaluates it. + operators: conditions.vocabulary(), }) } @@ -52,3 +68,235 @@ exports.listAudiences = (req, res) => { // second caller must not have to remember. res.json({ audiences: registries.allAudiences(), ceilings: ceilingVocabulary() }) } + +// The catalog's third leg: the channels a rule may name. Same argument as the +// ceilings above — the rule editor offers a set and the save path checks the +// same set, so serving it means the two cannot drift, and a module that +// registers a channel gets an editor that knows about it with no client release. +const channelVocabulary = () => + channels.all().map(({ id, label, defaultMode }) => ({ id, label, defaultMode })) + +/** GET /api/v1/admin/engagement/channels */ +exports.listChannels = (req, res) => { + res.json({ channels: channelVocabulary() }) +} + +// ── Rules (Phase 4b) ─────────────────────────────────────────────────────── +// +// Every write goes through `engagementRules.model`, which is the boundary. The +// screen re-implements some of the same checks for the sake of a good inline +// error and that second copy is expected to drift — which is exactly why it is +// not the one that decides. + +// A model refusal is `{ ok: false, errors: [...] }` with an optional `notFound`. +// One helper so every write answers in the same shape: `message` is the first +// sentence for a toast, `errors` is the whole list for a form that wants to put +// each one beside the field it is about. +const refuse = (res, result, status = 400) => + res.status(result.notFound ? 404 : status).json({ + message: result.errors?.[0] || 'The request was refused', + errors: result.errors || [], + }) + +/** GET /api/v1/admin/engagement/rules */ +exports.listRules = async (req, res, next) => { + try { + res.json({ rules: await rules.listAnnotated() }) + } catch (err) { + next(err) + } +} + +/** GET /api/v1/admin/engagement/rules/:id */ +exports.getRule = async (req, res, next) => { + try { + const rule = await rules.getAnnotated(Number(req.params.id)) + if (!rule) return res.status(404).json({ message: 'Not found' }) + res.json({ rule }) + } catch (err) { + next(err) + } +} + +/** POST /api/v1/admin/engagement/rules */ +exports.createRule = async (req, res, next) => { + try { + const result = await rules.create({ ...req.body, updatedBy: req.user?.id ?? null }) + if (!result.ok) return refuse(res, result) + res.status(201).json({ rule: result.rule }) + } catch (err) { + next(err) + } +} + +/** + * PUT /api/v1/admin/engagement/rules/:id + * + * `trigger_id` is not in the model's UPDATE statement and that is not an + * oversight: a rule's cooldown rows, its pending outbox rows and its send-log + * history are all about one trigger, and re-pointing a rule at another one + * silently re-attributes every one of them. Changing the trigger means a new + * rule, and the editor shows the field read-only once the rule exists. + */ +exports.updateRule = async (req, res, next) => { + try { + const result = await rules.update(Number(req.params.id), { + ...req.body, + updatedBy: req.user?.id ?? null, + }) + if (!result.ok) return refuse(res, result) + res.json({ rule: result.rule }) + } catch (err) { + next(err) + } +} + +/** + * PATCH /api/v1/admin/engagement/rules/:id/enabled + * + * Its own route rather than a PUT, because turning a rule off is the panic button + * and must not be blocked by the rule failing validation now. See the model for + * the whole argument; the short version is that a rule whose module has been + * uninstalled is the one an operator most wants to stop and the one a + * re-validating PUT would refuse to save. + */ +exports.setRuleEnabled = async (req, res, next) => { + try { + if (typeof req.body?.enabled !== 'boolean') { + const message = 'enabled must be true or false' + return res.status(400).json({ message, errors: [message] }) + } + const result = await rules.setEnabled(Number(req.params.id), req.body.enabled, req.user?.id ?? null) + if (!result.ok) return refuse(res, result) + res.json({ rule: result.rule }) + } catch (err) { + next(err) + } +} + +/** DELETE /api/v1/admin/engagement/rules/:id */ +exports.deleteRule = async (req, res, next) => { + try { + const result = await rules.remove(Number(req.params.id)) + if (!result.ok) return refuse(res, result) + res.status(204).end() + } catch (err) { + next(err) + } +} + +// ── Segments (Phase 4b) ──────────────────────────────────────────────────── + +/** GET /api/v1/admin/engagement/segments */ +exports.listSegments = async (req, res, next) => { + try { + res.json({ segments: await segments.listAnnotated() }) + } catch (err) { + next(err) + } +} + +/** POST /api/v1/admin/engagement/segments */ +exports.createSegment = async (req, res, next) => { + try { + const result = await segments.save({ ...req.body, updatedBy: req.user?.id ?? null }) + if (!result.ok) return refuse(res, result) + res.status(201).json({ segment: result.segment }) + } catch (err) { + next(err) + } +} + +/** PUT /api/v1/admin/engagement/segments/:id */ +exports.updateSegment = async (req, res, next) => { + try { + const result = await segments.save( + { ...req.body, updatedBy: req.user?.id ?? null }, + { id: Number(req.params.id) }, + ) + if (!result.ok) return refuse(res, result) + res.json({ segment: result.segment }) + } catch (err) { + next(err) + } +} + +/** + * DELETE /api/v1/admin/engagement/segments/:id + * + * 409, not 400, when a rule still points at it: the request is well-formed and + * the refusal is about the state of something else. The count travels in the + * message because "3 rules still use this segment" is the whole of what the + * operator needs in order to decide what to do next. The database is not doing + * this — `audience_segment_id` carries no foreign key on purpose, because both + * of the options SQL offers here (CASCADE, SET NULL) destroy something. + */ +exports.deleteSegment = async (req, res, next) => { + try { + const result = await segments.remove(Number(req.params.id)) + if (!result.ok) return refuse(res, result, 409) + res.status(204).end() + } catch (err) { + next(err) + } +} + +// ── Reach preview ────────────────────────────────────────────────────────── + +/** + * GET /api/v1/admin/engagement/audience-preview + * + * "How many people does this reach right now?", answered by calling the SAME + * resolver the engine calls (`audiences.resolveForRule`) rather than a second + * query that agrees with it today. A preview built out of its own SQL is a + * preview that can be wrong about the only thing it exists to say. + * + * **A count and nothing else.** Not a sample, not a list of names: the resolver's + * output for a module-declared segment is a set of players derived from game + * data, and an editor that rendered those names would be a user-enumeration + * surface reached from a screen about mail scheduling. + * + * Three honesty requirements, each of them a way this number could lie: + * + * - **`capped`** — every audience query is bounded at `MAX_AUDIENCE` (5000), so a + * count that lands exactly on the bound is a floor and not a total. Rendering + * it as "5000" understates a large deployment by an unknown amount. + * - **`owner`** resolves per event, from an id the event carries, so there is no + * number to give in advance. It answers 0 with the reason saying so, which is + * the truth; a blank or a dash would read as "nobody". + * - **`permitted`** — whether the trigger's declared ceiling allows this audience + * 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. + */ +exports.previewAudience = async (req, res, next) => { + try { + const segmentId = req.query.audienceSegmentId ? Number(req.query.audienceSegmentId) : null + if (segmentId !== null && !Number.isInteger(segmentId)) { + return res.status(400).json({ message: 'audienceSegmentId must be an integer' }) + } + const audience = typeof req.query.audience === 'string' ? req.query.audience : 'owner' + if (segmentId === null && !ceilings.isCeiling(audience)) { + return res.status(400).json({ message: `audience must be one of ${ceilings.CEILINGS.join(', ')}` }) + } + const triggerId = typeof req.query.triggerId === 'string' ? req.query.triggerId : null + + const resolved = await audiences.resolveForRule( + { audience, audience_segment_id: segmentId }, + // No `ownerUserId`, because there is no event here — which is precisely + // why an `owner` audience has no advance answer to give. + { triggerId, ownerUserId: null }, + ) + + res.json({ + count: resolved.userIds.length, + capped: resolved.userIds.length >= recipients.MAX_AUDIENCE, + ceiling: resolved.ceiling, + dormant: resolved.dormant, + reason: resolved.reason, + permitted: triggerId && resolved.ceiling ? audiences.permitted(triggerId, resolved.ceiling) : null, + }) + } catch (err) { + next(err) + } +} diff --git a/server/src/router/v1/admin/engagement.router.js b/server/src/router/v1/admin/engagement.router.js index c596a25..bb66b47 100644 --- a/server/src/router/v1/admin/engagement.router.js +++ b/server/src/router/v1/admin/engagement.router.js @@ -1,16 +1,16 @@ -// Admin · Engagement — the declared event catalog (ENGAGEMENT.md Phase 2). +// Admin · Engagement — the declared event catalog (Phase 2) and the rules and +// audience segments an operator configures over it (Phase 4b). // // Mounted at /api/v1/admin/engagement by admin/index.js, which has already -// applied `noindex, isLoggedIn, staffOnly`. Both routes re-gate to `admin`. +// applied `noindex, isLoggedIn, staffOnly`. Every route re-gates to `admin`. // -// Admin rather than staff-wide, deliberately. Nothing here is writable yet, but -// this is the entry point of the screen that decides who receives mail, and the -// declarations it serves name every variable a template may interpolate. A +// Admin rather than staff-wide, deliberately. This is the group that decides who +// receives mail: the declarations it serves name every variable a template may +// interpolate, and the writes below are how a deployment starts sending. A // capability is easier to widen later with a reason than to narrow after an // editor has been using it. // -// Rules, templates and the send log arrive under this same prefix in Phases 4 -// and 5, which is why the group exists now with two read routes in it. +// Templates and the send log arrive under this same prefix in Phase 5. const express = require('express') @@ -20,13 +20,15 @@ const { requireRole } = require('../../../utils/auth') const engagementRouter = express.Router() const adminOnly = requireRole('admin') +// ── The catalog: three read routes, all served from the registries ───────── + engagementRouter.get( '/triggers', // #swagger.tags = ['Admin · Engagement'] // #swagger.summary = 'List every declared event trigger, with its payload contract and audience ceiling' // #swagger.description = 'Served from the module registries, not from a table: a trigger is declared in code by core or by an installed module, so this is whatever registered on this boot. Each declaration carries the variables a template may interpolate (with an example per variable, for preview and test-send) and the widest audience a rule may ever give it.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'The declared triggers, the audience-ceiling vocabulary, and the variable types', content: { "application/json": { schema: { type: "object", properties: { triggers: { type: "array", items: { type: "object", additionalProperties: true } }, ceilings: { type: "array", items: { type: "object", additionalProperties: true } }, variableTypes: { type: "array", items: { type: "string" } }, kinds: { type: "array", items: { type: "string" } } } } } } } */ + /* #swagger.responses[200] = { description: 'The declared triggers, the audience-ceiling vocabulary, the variable types and the condition operators', content: { "application/json": { schema: { type: "object", properties: { triggers: { type: "array", items: { type: "object", additionalProperties: true } }, ceilings: { type: "array", items: { type: "object", additionalProperties: true } }, variableTypes: { type: "array", items: { type: "string" } }, kinds: { type: "array", items: { type: "string" } }, operators: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */ /* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ adminOnly, controller.listTriggers, @@ -44,4 +46,169 @@ engagementRouter.get( controller.listAudiences, ) +engagementRouter.get( + '/channels', + // #swagger.tags = ['Admin · Engagement'] + // #swagger.summary = 'List every registered delivery channel a rule may send on' + // #swagger.description = 'From the delivery-channel registry, so the rule editor offers exactly the set the save path checks against. A channel registered by a module appears here without a client release.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The registered channels', content: { "application/json": { schema: { type: "object", properties: { channels: { type: "array", items: { type: "object", properties: { id: { type: "string" }, label: { type: "string" }, defaultMode: { type: "string" } } } } } } } } } */ + /* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + controller.listChannels, +) + +// ── Reach preview ───────────────────────────────────────────────────────── +// +// Declared ahead of /rules/:id so the literal path is never read as an id. + +engagementRouter.get( + '/audience-preview', + // #swagger.tags = ['Admin · Engagement'] + // #swagger.summary = 'Count how many users an audience or segment reaches right now' + // #swagger.description = 'Runs the same resolver the engine runs, and returns a COUNT ONLY — never names or ids, because a module-declared segment resolves over game data and the rule editor must not become a user-enumeration surface. `capped` is true when the count hit the 5000-row audience bound and is therefore a floor rather than a total; an `owner` audience answers 0 with a reason, because it resolves per event from an id the event carries.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['audience'] = { in: 'query', description: 'A ceiling name (owner, staff, subscribers, members, authenticated, everyone). Ignored when audienceSegmentId is given.', required: false, schema: { type: 'string' } } + // #swagger.parameters['audienceSegmentId'] = { in: 'query', description: 'A saved segment to resolve instead of a plain audience', required: false, schema: { type: 'integer' } } + // #swagger.parameters['triggerId'] = { in: 'query', description: 'The rule trigger, used to resolve a subscribers audience and to report whether the trigger ceiling permits this reach', required: false, schema: { type: 'string' } } + /* #swagger.responses[200] = { description: 'The reach', content: { "application/json": { schema: { type: "object", properties: { count: { type: "integer" }, capped: { type: "boolean" }, ceiling: { type: "string", nullable: true }, dormant: { type: "boolean" }, reason: { type: "string", nullable: true }, permitted: { type: "boolean", nullable: true } } } } } } */ + /* #swagger.responses[400] = { description: 'Unknown audience name, or a non-integer segment id', 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.previewAudience, +) + +// ── Rules ───────────────────────────────────────────────────────────────── + +engagementRouter.get( + '/rules', + // #swagger.tags = ['Admin · Engagement'] + // #swagger.summary = 'List every engagement rule, annotated with dormancy' + // #swagger.description = 'A rule whose trigger, channel or audience segment is not registered right now is listed with `dormant: true` and the reasons why, never deleted and never auto-disabled — an uninstalled module must not destroy an operator configuration.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The rules', content: { "application/json": { schema: { type: "object", properties: { rules: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */ + /* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + controller.listRules, +) + +engagementRouter.post( + '/rules', + // #swagger.tags = ['Admin · Engagement'] + // #swagger.summary = 'Create an engagement rule' + // #swagger.description = 'A new rule must name a trigger that is registered right now — there is nothing to preserve and a typo should be caught here. It arrives with `enabled` false unless asked otherwise, and its audience is checked against the trigger declared ceiling: an operator may narrow a rule reach and may never widen it.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { triggerId: { type: "string" }, name: { type: "string" }, enabled: { type: "boolean" }, audience: { type: "string" }, audienceSegmentId: { type: "integer", nullable: true }, channels: { type: "array", items: { type: "string" } }, templateKeys: { type: "object", additionalProperties: { type: "string" } }, conditions: { type: "object", nullable: true, additionalProperties: true }, cooldownSeconds: { type: "integer" }, delaySeconds: { type: "integer" }, cancelOn: { type: "array", items: { type: "string" } }, maxSendsPerHour: { type: "integer" } }, required: ["triggerId", "name", "channels"] } } } } */ + /* #swagger.responses[201] = { description: 'The created rule', content: { "application/json": { schema: { type: "object", properties: { rule: { type: "object", additionalProperties: true } } } } } } */ + /* #swagger.responses[400] = { description: 'Validation failed; `errors` lists every problem', 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.createRule, +) + +engagementRouter.get( + '/rules/:id', + // #swagger.tags = ['Admin · Engagement'] + // #swagger.summary = 'Read one engagement rule' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The rule', content: { "application/json": { schema: { type: "object", properties: { rule: { type: "object", additionalProperties: true } } } } } } */ + /* #swagger.responses[404] = { description: 'No such rule', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + controller.getRule, +) + +engagementRouter.put( + '/rules/:id', + // #swagger.tags = ['Admin · Engagement'] + // #swagger.summary = 'Update an engagement rule' + // #swagger.description = 'The trigger is NOT updatable: a rule cooldowns, its pending outbox rows and its send-log history are all about one trigger, and re-pointing the rule silently re-attributes them. An existing rule may keep naming a trigger nobody currently registers, so that a dormant rule stays editable until its module comes back.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, enabled: { type: "boolean" }, audience: { type: "string" }, audienceSegmentId: { type: "integer", nullable: true }, channels: { type: "array", items: { type: "string" } }, templateKeys: { type: "object", additionalProperties: { type: "string" } }, conditions: { type: "object", nullable: true, additionalProperties: true }, cooldownSeconds: { type: "integer" }, delaySeconds: { type: "integer" }, cancelOn: { type: "array", items: { type: "string" } }, maxSendsPerHour: { type: "integer" } } } } } } */ + /* #swagger.responses[200] = { description: 'The updated rule', content: { "application/json": { schema: { type: "object", properties: { rule: { type: "object", additionalProperties: true } } } } } } */ + /* #swagger.responses[400] = { description: 'Validation failed; `errors` lists every problem', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'No such rule', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + controller.updateRule, +) + +engagementRouter.patch( + '/rules/:id/enabled', + // #swagger.tags = ['Admin · Engagement'] + // #swagger.summary = 'Turn one rule on or off' + // #swagger.description = 'Writes that column and nothing else, without re-validating the rule. Turning a rule off is the panic button: a rule whose module has been uninstalled, or whose trigger has since narrowed its ceiling under a saved audience, is the rule an operator most urgently wants stopped and the one a re-validating update would refuse to save. Turning one on is safe without re-validation because the engine re-checks the ceiling at send time.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { enabled: { type: "boolean" } }, required: ["enabled"] } } } } */ + /* #swagger.responses[200] = { description: 'The rule, with its new state', content: { "application/json": { schema: { type: "object", properties: { rule: { type: "object", additionalProperties: true } } } } } } */ + /* #swagger.responses[400] = { description: 'enabled was not a boolean', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'No such rule', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + controller.setRuleEnabled, +) + +engagementRouter.delete( + '/rules/:id', + // #swagger.tags = ['Admin · Engagement'] + // #swagger.summary = 'Delete an engagement rule' + // #swagger.description = 'Its cooldown rows and any still-pending outbox rows go with it, and neither means anything without the rule. The send log does NOT — `engagement_sends.rule_id` carries no foreign key — so the record of what was actually mailed outlives the rule.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[204] = { description: 'Deleted' } */ + /* #swagger.responses[404] = { description: 'No such rule', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + controller.deleteRule, +) + +// ── Audience segments ───────────────────────────────────────────────────── + +engagementRouter.get( + '/segments', + // #swagger.tags = ['Admin · Engagement'] + // #swagger.summary = 'List every saved audience segment, annotated with dormancy' + // #swagger.description = 'A segment naming an audience whose module has been uninstalled is dormant: it is listed with the missing ids, it resolves to nobody, and it works again when the module comes back.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The segments', content: { "application/json": { schema: { type: "object", properties: { segments: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */ + /* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + controller.listSegments, +) + +engagementRouter.post( + '/segments', + // #swagger.tags = ['Admin · Engagement'] + // #swagger.summary = 'Save a new audience segment' + // #swagger.description = 'The expression is a boolean tree of module-declared audiences. `not` is legal only as a child of `and`, because a complement needs a universe and the only one that does not widen is the set its siblings produced. The ceiling is DERIVED as the narrowest in the tree and is never taken from the caller; two incomparable ceilings have no meet and the composition is refused rather than guessed.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, expression: { type: "object", additionalProperties: true } }, required: ["name", "expression"] } } } } */ + /* #swagger.responses[201] = { description: 'The created segment, with its derived ceiling', content: { "application/json": { schema: { type: "object", properties: { segment: { type: "object", additionalProperties: true } } } } } } */ + /* #swagger.responses[400] = { description: 'Validation failed; `errors` lists every problem', 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.createSegment, +) + +engagementRouter.put( + '/segments/:id', + // #swagger.tags = ['Admin · Engagement'] + // #swagger.summary = 'Update an audience segment' + // #swagger.description = 'The ceiling is re-derived from the new expression. A rule already pointing at this segment took the ceiling stored at ITS save time, so narrowing a segment does not retroactively widen anything and the engine re-checks at send time either way.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, expression: { type: "object", additionalProperties: true } }, required: ["name", "expression"] } } } } */ + /* #swagger.responses[200] = { description: 'The updated segment', content: { "application/json": { schema: { type: "object", properties: { segment: { type: "object", additionalProperties: true } } } } } } */ + /* #swagger.responses[400] = { description: 'Validation failed; `errors` lists every problem', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'No such segment', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + controller.updateSegment, +) + +engagementRouter.delete( + '/segments/:id', + // #swagger.tags = ['Admin · Engagement'] + // #swagger.summary = 'Delete an audience segment' + // #swagger.description = 'Refused with 409 while any rule still points at it, and the message carries the count. There is no foreign key doing this: CASCADE would delete an operator rules and SET NULL would silently fall each rule back to its plain audience column, which reaches a DIFFERENT set of people.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[204] = { description: 'Deleted' } */ + /* #swagger.responses[409] = { description: 'Rules still use this segment', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + controller.deleteSegment, +) + module.exports = engagementRouter diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 59a32ae..07c1af1 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -1137,6 +1137,107 @@ } } }, + "/api/v1/admin/engagement/audience-preview": { + "get": { + "tags": [ + "Admin · Engagement" + ], + "summary": "Count how many users an audience or segment reaches right now", + "description": "Runs the same resolver the engine runs, and returns a COUNT ONLY — never names or ids, because a module-declared segment resolves over game data and the rule editor must not become a user-enumeration surface. `capped` is true when the count hit the 5000-row audience bound and is therefore a floor rather than a total; an `owner` audience answers 0 with a reason, because it resolves per event from an id the event carries.", + "parameters": [ + { + "name": "audience", + "in": "query", + "description": "A ceiling name (owner, staff, subscribers, members, authenticated, everyone). Ignored when audienceSegmentId is given.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "audienceSegmentId", + "in": "query", + "description": "A saved segment to resolve instead of a plain audience", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "triggerId", + "in": "query", + "description": "The rule trigger, used to resolve a subscribers audience and to report whether the trigger ceiling permits this reach", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The reach", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "capped": { + "type": "boolean" + }, + "ceiling": { + "type": "string", + "nullable": true + }, + "dormant": { + "type": "boolean" + }, + "reason": { + "type": "string", + "nullable": true + }, + "permitted": { + "type": "boolean", + "nullable": true + } + } + } + } + } + }, + "400": { + "description": "Unknown audience name, or a non-integer segment id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not an admin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/admin/engagement/audiences": { "get": { "tags": [ @@ -1192,6 +1293,780 @@ ] } }, + "/api/v1/admin/engagement/channels": { + "get": { + "tags": [ + "Admin · Engagement" + ], + "summary": "List every registered delivery channel a rule may send on", + "description": "From the delivery-channel registry, so the rule editor offers exactly the set the save path checks against. A channel registered by a module appears here without a client release.", + "responses": { + "200": { + "description": "The registered channels", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "channels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "defaultMode": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "403": { + "description": "Not an admin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/engagement/rules": { + "get": { + "tags": [ + "Admin · Engagement" + ], + "summary": "List every engagement rule, annotated with dormancy", + "description": "A rule whose trigger, channel or audience segment is not registered right now is listed with `dormant: true` and the reasons why, never deleted and never auto-disabled — an uninstalled module must not destroy an operator configuration.", + "responses": { + "200": { + "description": "The rules", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + }, + "403": { + "description": "Not an admin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Admin · Engagement" + ], + "summary": "Create an engagement rule", + "description": "A new rule must name a trigger that is registered right now — there is nothing to preserve and a typo should be caught here. It arrives with `enabled` false unless asked otherwise, and its audience is checked against the trigger declared ceiling: an operator may narrow a rule reach and may never widen it.", + "responses": { + "201": { + "description": "The created rule", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "rule": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "400": { + "description": "Validation failed; `errors` lists every problem", + "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": { + "triggerId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "audience": { + "type": "string" + }, + "audienceSegmentId": { + "type": "integer", + "nullable": true + }, + "channels": { + "type": "array", + "items": { + "type": "string" + } + }, + "templateKeys": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "conditions": { + "type": "object", + "nullable": true, + "additionalProperties": true + }, + "cooldownSeconds": { + "type": "integer" + }, + "delaySeconds": { + "type": "integer" + }, + "cancelOn": { + "type": "array", + "items": { + "type": "string" + } + }, + "maxSendsPerHour": { + "type": "integer" + } + }, + "required": [ + "triggerId", + "name", + "channels" + ] + } + } + } + } + } + }, + "/api/v1/admin/engagement/rules/{id}": { + "get": { + "tags": [ + "Admin · Engagement" + ], + "summary": "Read one engagement rule", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The rule", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "rule": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "404": { + "description": "No such rule", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "tags": [ + "Admin · Engagement" + ], + "summary": "Update an engagement rule", + "description": "The trigger is NOT updatable: a rule cooldowns, its pending outbox rows and its send-log history are all about one trigger, and re-pointing the rule silently re-attributes them. An existing rule may keep naming a trigger nobody currently registers, so that a dormant rule stays editable until its module comes back.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The updated rule", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "rule": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "400": { + "description": "Validation failed; `errors` lists every problem", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "No such rule", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "audience": { + "type": "string" + }, + "audienceSegmentId": { + "type": "integer", + "nullable": true + }, + "channels": { + "type": "array", + "items": { + "type": "string" + } + }, + "templateKeys": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "conditions": { + "type": "object", + "nullable": true, + "additionalProperties": true + }, + "cooldownSeconds": { + "type": "integer" + }, + "delaySeconds": { + "type": "integer" + }, + "cancelOn": { + "type": "array", + "items": { + "type": "string" + } + }, + "maxSendsPerHour": { + "type": "integer" + } + } + } + } + } + } + }, + "delete": { + "tags": [ + "Admin · Engagement" + ], + "summary": "Delete an engagement rule", + "description": "Its cooldown rows and any still-pending outbox rows go with it, and neither means anything without the rule. The send log does NOT — `engagement_sends.rule_id` carries no foreign key — so the record of what was actually mailed outlives the rule.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Deleted" + }, + "404": { + "description": "No such rule", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/engagement/rules/{id}/enabled": { + "patch": { + "tags": [ + "Admin · Engagement" + ], + "summary": "Turn one rule on or off", + "description": "Writes that column and nothing else, without re-validating the rule. Turning a rule off is the panic button: a rule whose module has been uninstalled, or whose trigger has since narrowed its ceiling under a saved audience, is the rule an operator most urgently wants stopped and the one a re-validating update would refuse to save. Turning one on is safe without re-validation because the engine re-checks the ceiling at send time.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The rule, with its new state", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "rule": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "400": { + "description": "enabled was not a boolean", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "No such rule", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ] + } + } + } + } + } + }, + "/api/v1/admin/engagement/segments": { + "get": { + "tags": [ + "Admin · Engagement" + ], + "summary": "List every saved audience segment, annotated with dormancy", + "description": "A segment naming an audience whose module has been uninstalled is dormant: it is listed with the missing ids, it resolves to nobody, and it works again when the module comes back.", + "responses": { + "200": { + "description": "The segments", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "segments": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + }, + "403": { + "description": "Not an admin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Admin · Engagement" + ], + "summary": "Save a new audience segment", + "description": "The expression is a boolean tree of module-declared audiences. `not` is legal only as a child of `and`, because a complement needs a universe and the only one that does not widen is the set its siblings produced. The ceiling is DERIVED as the narrowest in the tree and is never taken from the caller; two incomparable ceilings have no meet and the composition is refused rather than guessed.", + "responses": { + "201": { + "description": "The created segment, with its derived ceiling", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "segment": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "400": { + "description": "Validation failed; `errors` lists every problem", + "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": { + "name": { + "type": "string" + }, + "expression": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "name", + "expression" + ] + } + } + } + } + } + }, + "/api/v1/admin/engagement/segments/{id}": { + "put": { + "tags": [ + "Admin · Engagement" + ], + "summary": "Update an audience segment", + "description": "The ceiling is re-derived from the new expression. A rule already pointing at this segment took the ceiling stored at ITS save time, so narrowing a segment does not retroactively widen anything and the engine re-checks at send time either way.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The updated segment", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "segment": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "400": { + "description": "Validation failed; `errors` lists every problem", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "No such segment", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "expression": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "name", + "expression" + ] + } + } + } + } + }, + "delete": { + "tags": [ + "Admin · Engagement" + ], + "summary": "Delete an audience segment", + "description": "Refused with 409 while any rule still points at it, and the message carries the count. There is no foreign key doing this: CASCADE would delete an operator rules and SET NULL would silently fall each rule back to its plain audience column, which reaches a DIFFERENT set of people.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Deleted" + }, + "409": { + "description": "Rules still use this segment", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/admin/engagement/triggers": { "get": { "tags": [ @@ -1201,7 +2076,7 @@ "description": "Served from the module registries, not from a table: a trigger is declared in code by core or by an installed module, so this is whatever registered on this boot. Each declaration carries the variables a template may interpolate (with an example per variable, for preview and test-send) and the widest audience a rule may ever give it.", "responses": { "200": { - "description": "The declared triggers, the audience-ceiling vocabulary, and the variable types", + "description": "The declared triggers, the audience-ceiling vocabulary, the variable types and the condition operators", "content": { "application/json": { "schema": { @@ -1232,6 +2107,13 @@ "items": { "type": "string" } + }, + "operators": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } } } } diff --git a/server/test/engagementAdmin.test.js b/server/test/engagementAdmin.test.js new file mode 100644 index 0000000..f0b5b56 --- /dev/null +++ b/server/test/engagementAdmin.test.js @@ -0,0 +1,469 @@ +// ── The engagement admin surface (ENGAGEMENT.md Phase 4b) ────────────────── +// +// Phase 4a built the engine and the save-path validation with **no HTTP surface +// at all**; this is the surface, and these tests are about the things the routes +// decide rather than the things the model already decided. `engagementEngine` +// covers validation, ceilings and dormancy at the model layer — re-asserting +// them here would be a second copy of a test rather than a second test. +// +// What is genuinely new, and what each of these is about: +// +// • **the enable switch does not re-validate.** Turning a rule OFF is the panic +// button, and it has to work on the rule an operator most wants stopped — one +// whose module has been uninstalled, or whose trigger has since narrowed its +// ceiling under a saved audience. Those are exactly the rules a re-validating +// PUT refuses to save, so a toggle built on PUT is broken in precisely the +// case it is needed. +// • **the trigger is not updatable.** A rule's cooldowns, its pending outbox +// rows and its send-log history are all about one trigger id. +// • **deleting a segment a rule uses is 409, with the count**, because the +// database is deliberately not doing this (no foreign key: CASCADE deletes an +// operator's rules, SET NULL silently mails a different set of people). +// • **the reach preview is a count and never a list**, it says when it hit the +// 5000-row audience bound, and it says when the trigger's ceiling would +// refuse the audience it just counted. +// +// The `.db` layer is stubbed in-memory and the real models and controllers run +// against it, the shape `engagementEngine.test.js` uses. +// +// 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 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 db = require('../src/utils/db') + +after(() => db.close()) + +// ── In-memory stand-ins for the two tables the surface writes ────────────── + +let store +const originals = {} +for (const [name, mod] of [['rulesDb', rulesDb], ['segmentsDb', segmentsDb], ['recipients', recipients]]) { + originals[name] = { mod, fns: { ...mod } } +} +const restoreOriginals = () => { + for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns) +} + +function installStubs() { + store = { rules: new Map(), segments: new Map(), users: new Map(), 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) + rulesDb.insert = async (rule) => { + const id = store.nextRule++ + store.rules.set(id, { id, ...rule }) + return id + } + // Mirrors the real UPDATE statement, which does NOT carry trigger_id. That + // omission is the behaviour one of the tests below is about, so the stub has + // to reproduce it rather than helpfully assign the whole object. + rulesDb.update = async (id, rule) => { + const existing = store.rules.get(id) + if (!existing) return + const { trigger_id: _ignored, ...rest } = rule + Object.assign(existing, rest) + } + rulesDb.setEnabled = async (id, enabled, updatedBy) => { + const existing = store.rules.get(id) + if (existing) Object.assign(existing, { enabled: Boolean(enabled), updated_by: updatedBy }) + } + rulesDb.remove = async (id) => store.rules.delete(id) + rulesDb.countUsingSegment = async (segmentId) => + [...store.rules.values()].filter((r) => r.audience_segment_id === segmentId).length + + segmentsDb.list = async () => [...store.segments.values()].map((s) => ({ ...s })) + segmentsDb.getById = async (id) => (store.segments.has(id) ? { ...store.segments.get(id) } : null) + segmentsDb.insert = async (segment) => { + const id = store.nextSegment++ + store.segments.set(id, { id, ...segment }) + return id + } + segmentsDb.update = async (id, segment) => Object.assign(store.segments.get(id) || {}, segment) + segmentsDb.remove = async (id) => store.segments.delete(id) + + const activeIds = () => [...store.users.values()].filter((u) => u.status === 'active').map((u) => u.id) + recipients.active = async (limit = recipients.MAX_AUDIENCE) => activeIds().slice(0, limit) + recipients.staff = async (roles, limit = recipients.MAX_AUDIENCE) => + [...store.users.values()] + .filter((u) => u.status === 'active' && roles.includes(u.role)) + .map((u) => u.id) + .slice(0, limit) + recipients.subscribers = async () => [] + recipients.filterActive = async (ids) => + [...new Set(ids)].filter((id) => store.users.get(id)?.status === 'active') +} + +// ── Fixtures ─────────────────────────────────────────────────────────────── + +const addUser = (id, over = {}) => store.users.set(id, { id, role: 'player', status: 'active', ...over }) + +function register(owner, fn) { + const api = registries.stage(owner) + fn(api) + registries.apply(api.staged) +} + +const IDOC_TRIGGER = { + id: 'uo.house.idoc_warning', + label: 'House approaching collapse', + ceiling: 'owner', + audience: 'owner', + subjectKey: 'house', + variables: [{ name: 'house', type: 'string', required: true, example: 'The Silver Anvil' }], +} + +const registerUoTrigger = (over = {}) => + register('uo', (api) => api.registerEventTriggers([{ ...IDOC_TRIGGER, ...over }])) + +function registerChannels() { + channels._reset() + delete require.cache[require.resolve('../src/engagement/coreChannels')] + // eslint-disable-next-line global-require + require('../src/engagement/coreChannels') +} + +/** The controller signature is (req, res, next); this is the res half of it. */ +function mockRes() { + return { + statusCode: 200, + body: null, + ended: false, + status(c) { this.statusCode = c; return this }, + json(b) { this.body = b; return this }, + end() { this.ended = true; return this }, + } +} + +/** Call a controller and fail the test on an unexpected throw, not silently. */ +async function call(handler, req) { + const res = mockRes() + let thrown = null + await handler({ body: {}, params: {}, query: {}, user: { id: 1 }, ...req }, res, (err) => { + thrown = err + }) + if (thrown) throw thrown + return res +} + +const validRule = (over = {}) => ({ + triggerId: 'uo.house.idoc_warning', + name: 'IDOC warning', + channels: ['email'], + ...over, +}) + +beforeEach(() => { + registries._reset() + registerChannels() + installStubs() + registerUoTrigger() +}) + +afterEach(() => { + registries._reset() + restoreOriginals() +}) + +// ── Rules: create, list, update ──────────────────────────────────────────── + +test('a created rule arrives disabled unless it says otherwise', async () => { + const res = await call(ctrl.createRule, { body: validRule() }) + + assert.equal(res.statusCode, 201) + assert.equal(res.body.rule.enabled, false) + assert.equal(res.body.rule.trigger_id, 'uo.house.idoc_warning') + // §7.1 Q3: rules-as-data is only safe because of the hourly cap, so a rule + // that never mentions one still has one. + assert.equal(res.body.rule.max_sends_per_hour, 100) +}) + +test('an audience wider than the trigger permits is refused, and the reason is in errors[]', async () => { + const res = await call(ctrl.createRule, { body: validRule({ audience: 'everyone' }) }) + + assert.equal(res.statusCode, 400) + assert.ok(Array.isArray(res.body.errors) && res.body.errors.length) + assert.match(res.body.errors.join(' '), /wider than trigger/) + // `message` is the first sentence, for a toast; `errors` is the whole list, + // for a form putting each one beside its field. + assert.equal(res.body.message, res.body.errors[0]) +}) + +test('the rules list flags a rule whose trigger is no longer registered, and does not drop it', async () => { + await call(ctrl.createRule, { body: validRule() }) + registries._reset() + + const res = await call(ctrl.listRules, {}) + + assert.equal(res.body.rules.length, 1) + assert.equal(res.body.rules[0].dormant, true) + assert.match(res.body.rules[0].dormantReasons.join(' '), /is not registered/) +}) + +test('updating a rule cannot re-point it at another trigger', async () => { + register('uo', (api) => + api.registerEventTriggers([{ ...IDOC_TRIGGER, id: 'uo.house.repaired', label: 'Repaired' }]), + ) + const created = await call(ctrl.createRule, { body: validRule() }) + const id = created.body.rule.id + + const res = await call(ctrl.updateRule, { + params: { id: String(id) }, + body: { ...validRule({ triggerId: 'uo.house.repaired' }), name: 'renamed' }, + }) + + assert.equal(res.statusCode, 200) + assert.equal(res.body.rule.name, 'renamed') + // A rule's cooldown rows, pending outbox rows and send-log history are all + // about one trigger. Re-pointing it would silently re-attribute all three. + assert.equal(res.body.rule.trigger_id, 'uo.house.idoc_warning') +}) + +// ── The enable switch: the property that made it its own route ───────────── + +test('a rule whose module is gone can still be switched OFF', async () => { + const created = await call(ctrl.createRule, { body: validRule({ enabled: true }) }) + const id = created.body.rule.id + // The module is uninstalled. This rule is now dormant, and it is also the rule + // an operator is most likely to want stopped. + registries._reset() + + const res = await call(ctrl.setRuleEnabled, { params: { id: String(id) }, body: { enabled: false } }) + + assert.equal(res.statusCode, 200) + assert.equal(res.body.rule.enabled, false) + assert.equal(res.body.rule.dormant, true) +}) + +test('a full update of that same rule is refused — which is why the switch is not a PUT', async () => { + const created = await call(ctrl.createRule, { body: validRule({ enabled: true }) }) + const id = created.body.rule.id + registerChannels() + channels._reset() // the module took its channel with it, too + + const res = await call(ctrl.updateRule, { params: { id: String(id) }, body: validRule() }) + + assert.equal(res.statusCode, 400) + assert.match(res.body.errors.join(' '), /no channel "email" is registered/) +}) + +test('enabled must be a boolean, not a truthy string', async () => { + const created = await call(ctrl.createRule, { body: validRule() }) + + const res = await call(ctrl.setRuleEnabled, { + params: { id: String(created.body.rule.id) }, + body: { enabled: 'false' }, + }) + + assert.equal(res.statusCode, 400) + assert.equal(store.rules.get(created.body.rule.id).enabled, false) +}) + +test('toggling a rule that does not exist is 404, not a silent no-op', async () => { + const res = await call(ctrl.setRuleEnabled, { params: { id: '99' }, body: { enabled: false } }) + assert.equal(res.statusCode, 404) +}) + +// ── Delete ───────────────────────────────────────────────────────────────── + +test('deleting a rule answers 204 and removes it; deleting it twice is 404', async () => { + const created = await call(ctrl.createRule, { body: validRule() }) + const id = String(created.body.rule.id) + + const first = await call(ctrl.deleteRule, { params: { id } }) + assert.equal(first.statusCode, 204) + assert.equal(store.rules.size, 0) + + const second = await call(ctrl.deleteRule, { params: { id } }) + assert.equal(second.statusCode, 404) +}) + +// ── Segments ─────────────────────────────────────────────────────────────── + +function registerAudiences() { + register('uo', (api) => + api.registerAudiences([ + { id: 'uo.governors', label: 'Governors', ceiling: 'members', resolve: async () => [11, 12] }, + { id: 'uo.watchers', label: 'Watchers', ceiling: 'authenticated', resolve: async () => [10, 13] }, + ]), + ) +} + +test('a saved segment stores the DERIVED ceiling, never one the caller asked for', async () => { + registerAudiences() + + const res = await call(ctrl.createSegment, { + body: { + name: 'Governors or watchers', + ceiling: 'everyone', // ignored: the ceiling is not the caller's to state + expression: { op: 'or', nodes: [{ audienceId: 'uo.governors' }, { audienceId: 'uo.watchers' }] }, + }, + }) + + assert.equal(res.statusCode, 201) + // members is below authenticated, so OR takes the TIGHTER of the two. + assert.equal(res.body.segment.ceiling, 'members') +}) + +test('a bare `not` is refused at save, with the sentence saying why', async () => { + registerAudiences() + + const res = await call(ctrl.createSegment, { + body: { name: 'Everyone but governors', expression: { op: 'not', nodes: [{ audienceId: 'uo.governors' }] } }, + }) + + assert.equal(res.statusCode, 400) + assert.match(res.body.errors.join(' '), /not/i) +}) + +test('deleting a segment a rule still uses is 409, and the count is in the message', async () => { + // A rule pointing at a `members` segment needs a trigger whose ceiling permits + // one, so this test re-registers the catalog rather than taking the default. + registries._reset() + registerUoTrigger({ ceiling: 'members', audience: 'members' }) + registerAudiences() + const segment = await call(ctrl.createSegment, { + body: { name: 'Governors', expression: { audienceId: 'uo.governors' } }, + }) + const segmentId = segment.body.segment.id + await call(ctrl.createRule, { body: validRule({ audienceSegmentId: segmentId }) }) + + const refused = await call(ctrl.deleteSegment, { params: { id: String(segmentId) } }) + + assert.equal(refused.statusCode, 409) + assert.match(refused.body.message, /1 rule still use|1 rule/) + assert.equal(store.segments.size, 1) +}) + +test('the same segment deletes once no rule points at it', async () => { + registerAudiences() + const segment = await call(ctrl.createSegment, { + body: { name: 'Governors', expression: { audienceId: 'uo.governors' } }, + }) + + const res = await call(ctrl.deleteSegment, { params: { id: String(segment.body.segment.id) } }) + + assert.equal(res.statusCode, 204) + assert.equal(store.segments.size, 0) +}) + +test('a rule whose segment still EXISTS but is dormant is itself dormant', async () => { + // The case a row-existence check misses, and the one the live walk found: the + // segment is still there, every audience in it belongs to a module that has + // been uninstalled, and the rule reaches nobody. Reported as healthy, it is an + // enabled rule that cannot fire and says nothing about it. + registries._reset() + registerUoTrigger({ ceiling: 'members', audience: 'members' }) + registerAudiences() + const segment = await call(ctrl.createSegment, { + body: { name: 'Governors', expression: { audienceId: 'uo.governors' } }, + }) + await call(ctrl.createRule, { + body: validRule({ audienceSegmentId: segment.body.segment.id, enabled: true }), + }) + + // The module goes; the segment ROW stays exactly where it was. + registries._reset() + registerUoTrigger() + registerChannels() + + const res = await call(ctrl.listRules, {}) + + assert.equal(store.segments.size, 1, 'the segment row is still there') + assert.equal(res.body.rules[0].dormant, true) + assert.match(res.body.rules[0].dormantReasons.join(' '), /uo\.governors/) +}) + +test('a segment naming an audience whose module is gone is listed as dormant, not dropped', async () => { + registerAudiences() + await call(ctrl.createSegment, { + body: { name: 'Governors', expression: { audienceId: 'uo.governors' } }, + }) + registries._reset() + + const res = await call(ctrl.listSegments, {}) + + assert.equal(res.body.segments.length, 1) + assert.equal(res.body.segments[0].dormant, true) + assert.deepEqual(res.body.segments[0].missingAudiences, ['uo.governors']) +}) + +// ── Reach preview ────────────────────────────────────────────────────────── + +test('the preview counts, and returns no identities of any kind', async () => { + addUser(1, { role: 'admin' }) + addUser(2, { role: 'moderator' }) + addUser(3) + + const res = await call(ctrl.previewAudience, { query: { audience: 'staff' } }) + + assert.equal(res.body.count, 2) + assert.equal(res.body.ceiling, 'staff') + // Whatever else this response grows, it must never grow a list of people: the + // resolver's answer for a module-declared segment is a set of players derived + // from game data, and the rule editor is not a user-enumeration surface. + const serialised = JSON.stringify(res.body) + assert.equal(serialised.includes('userIds'), false) + assert.equal(/"(users|names|ids|sample)"/.test(serialised), false) +}) + +test('a count that hit the audience bound says so, rather than reading as a total', async () => { + for (let id = 1; id <= recipients.MAX_AUDIENCE; id += 1) addUser(id) + + const res = await call(ctrl.previewAudience, { query: { audience: 'authenticated' } }) + + assert.equal(res.body.count, recipients.MAX_AUDIENCE) + assert.equal(res.body.capped, true) +}) + +test('an `owner` audience previews as 0 with the reason, because it resolves per event', async () => { + addUser(1) + + const res = await call(ctrl.previewAudience, { + query: { audience: 'owner', triggerId: 'uo.house.idoc_warning' }, + }) + + assert.equal(res.body.count, 0) + assert.match(res.body.reason, /ownerUserId/) + assert.equal(res.body.permitted, true) +}) + +test('the preview reports when the trigger ceiling would refuse what it just counted', async () => { + addUser(1, { role: 'admin' }) + + const res = await call(ctrl.previewAudience, { + query: { audience: 'staff', triggerId: 'uo.house.idoc_warning' }, + }) + + // The count is real — those people exist — but this trigger is ceilinged + // `owner`, so saving a rule with it would be refused. Showing a healthy number + // with no other signal reads as a bug in the save. + assert.equal(res.body.count, 1) + assert.equal(res.body.permitted, false) +}) + +test('an audience name the lattice does not know is 400, not an empty count', async () => { + const res = await call(ctrl.previewAudience, { query: { audience: 'admins' } }) + assert.equal(res.statusCode, 400) +}) + +// ── The catalog's third leg ──────────────────────────────────────────────── + +test('the channel catalog is served from the registry, defaults included', async () => { + const res = await call(ctrl.listChannels, {}) + + const email = res.body.channels.find((c) => c.id === 'email') + assert.ok(email, 'core registers an email channel') + // §7.1 Q1 / §3.1: every channel is opt-IN. The editor has to be able to say so. + assert.equal(email.defaultMode, 'off') +}) -- 2.49.1 From 3a7a08425cd68e03c62a2f18b93ce55e3b20ded3 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 29 Aug 2026 12:26:29 -0500 Subject: [PATCH 10/20] fix(engagement): the six defects the browser pass found (Phase 4b) Driving the two screens in Chrome, after the API walk had already found the two in Phase 4a's code. None of these is visible from a test or from curl. Two cost an operator something real: - The Audience dropdown rendered EMPTY before a trigger was chosen. There is genuinely nothing it may offer without a ceiling, but a select with zero options reads as broken rather than as waiting. It now says "Choose a trigger first..." and is disabled. - A `members` audience with no saved audience reaches NOBODY, and only the preview button said so. That is the design, but it is also the default the instant a members-ceiling trigger is picked - so the rule saves, gets switched on, and mails nobody with nothing on screen saying so. The editor now says it inline, and stands down once a preview has answered the same question more precisely. One the server was already refusing, just too late: - The composer offered "exclude" on the only row, building an `and` whose every child is a complement. The server refuses it correctly but only after a save, and it is one checkbox away at all times. Now refused inline, in the operator's words. Three wording and layout: - the template-key input truncated its placeholder, and said "optional until Phase 5" - a sentence about the plan document, not about the deployment - "segment" leaked into a screen that says "saved audience" everywhere else. The API, schema and docs keep saying segment (one word for one table); translated at the point of display only - the composer repeated its AUDIENCE heading above every row Client only - no server change, so swagger and the route manifest are untouched. Client suite 316/316; all six verified in the browser after the fix. - [x] AI-assisted: written with Claude Code (Opus) Co-Authored-By: Claude --- client/src/lib/engagementRules.js | 56 ++++++++++++++++++- .../admin/views/EngagementAudiences.jsx | 6 +- .../routes/admin/views/EngagementRules.jsx | 21 ++++++- client/test/engagementRules.test.js | 30 ++++++++++ 4 files changed, 105 insertions(+), 8 deletions(-) diff --git a/client/src/lib/engagementRules.js b/client/src/lib/engagementRules.js index 5611e80..7e1eb59 100644 --- a/client/src/lib/engagementRules.js +++ b/client/src/lib/engagementRules.js @@ -101,15 +101,56 @@ export function segmentChoicesFor(trigger, ceilings, segments) { */ export function describeReach(preview) { if (!preview) return '' - if (preview.dormant) return `Resolves to nobody right now — ${preview.reason || 'dormant'}.` + const why = operatorWords(preview.reason) + if (preview.dormant) return `Resolves to nobody right now — ${why || 'dormant'}.` if (preview.permitted === false) { return `Reaches ${preview.count}, but this trigger does not permit that audience — saving will be refused.` } - if (preview.reason) return `${preview.count} right now — ${preview.reason}.` + if (why) return `${preview.count} right now — ${why}.` if (preview.capped) return `At least ${preview.count} people (the preview stops counting there).` return preview.count === 1 ? '1 person right now.' : `${preview.count} people right now.` } +/** + * The server says "segment"; these screens say "saved audience". + * + * The API, the schema and the docs all call it a segment and should keep doing + * so - it is one word for one table. But an operator meets the concept here, + * under a heading that says "Audiences", and a sentence that switches vocabulary + * mid-screen reads as a sentence about something else. + */ +export function operatorWords(text) { + if (!text) return text + // Word-wise rather than a regex, so "segmented" and the like are left alone. + const swap = { segment: 'saved audience', segments: 'saved audiences' } + return String(text) + .split(' ') + .map((word) => swap[word] || word) + .join(' ') +} + +/** + * The one audience choice that silently reaches nobody, said out loud. + * + * `members` is the ceiling for "a module-declared list". Without a saved + * audience naming WHICH list there is no list, and core knows no game vocabulary + * with which to guess - so the rule resolves to the empty set every time it + * fires. It is also the DEFAULT the moment an operator picks a `members`-ceiling + * trigger, which is what makes it a trap rather than a curiosity: the rule saves, + * switches on, and mails nobody, with nothing on the screen saying so unless the + * operator happens to press Preview. + * + * Returns a sentence, or null when there is nothing to warn about. + */ +export function audienceWarning(form) { + if (!form) return null + if (form.audienceSegmentId) return null + if (form.audience === 'members') { + return 'This reaches nobody as it stands. “Members of a module-declared list” needs a saved audience naming which list.' + } + return null +} + // ── Segment expressions ──────────────────────────────────────────────────── /** @@ -127,7 +168,16 @@ export function notPlacementError(expression) { if (!node || typeof node !== 'object') return null if (!node.op) return null if (node.op === 'not' && !underAnd) { - return 'A "not" can only be used inside an "all of" group — on its own it would mean "everyone except…".' + return 'An excluded audience can only be used alongside an included one — on its own it would mean “everyone except…”.' + } + // The same rule from the other side: a group of nothing but exclusions has + // no set to take them from. The composer offers "exclude" on every row, so + // this is one checkbox away at all times and is worth saying before the + // round trip - the server refuses it, correctly, but only after a save. + if ((node.op === 'and' || node.op === 'or') && (node.nodes || []).length) { + if ((node.nodes || []).every((c) => c && c.op === 'not')) { + return 'At least one audience has to be included — a list made only of exclusions has nothing to exclude from.' + } } for (const child of node.nodes || []) { const err = walk(child, node.op === 'and') diff --git a/client/src/routes/admin/views/EngagementAudiences.jsx b/client/src/routes/admin/views/EngagementAudiences.jsx index 0bd892b..de09168 100644 --- a/client/src/routes/admin/views/EngagementAudiences.jsx +++ b/client/src/routes/admin/views/EngagementAudiences.jsx @@ -50,12 +50,13 @@ const toGroup = (expression) => // ── One leaf: an audience and its declared parameters ────────────────────── -function LeafRow({ audiences, node, onChange, onRemove, negated, onToggleNegate, canNegate }) { +function LeafRow({ audiences, node, onChange, onRemove, negated, onToggleNegate, canNegate, first }) { const declared = audiences.find((a) => a.id === node.audienceId) return (