Files
website/server/src/engagement/transports/index.js
wtclaude b13ffd584f
All checks were successful
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / bot-tests (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 10m29s
feat(notifications): per-channel preferences and the delivery-channel registry (engagement Phase 3)
`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 <noreply@anthropic.com>
2026-08-29 07:08:17 -05:00

199 lines
7.8 KiB
JavaScript

// ── 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 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
// 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,
}