Email becomes a DeliveryChannel driven by rules, and the Team pipeline stops being
its own thing. `teamNotify.forumPost` now emits an event; a rule decides who is
mailed, through which template, and how often at most. One walk goes forum write
-> events.emit -> rule -> outbox -> worker -> email channel -> template -> SMTP.
Seven decisions settled by the org lead before any code:
- email only moves; the push tickle and the Discord bridge stay direct calls
- the EVENT carries its access-checked audience, and `members` resolves to it
- the four Team rules are seeded DISABLED, with an admin banner and a note
- team_notification_prefs stays, read by the engine as a scoped preference
- the payload wins and a structural projection fills the gaps
- the digest keeps computing at send time; only its state generalizes
- an unsubscribe token turns off the channel it names, and nothing else
Three defects found while building it:
- `email.button` never absolutized its href, while image and itemList both
did. Every rule-driven CTA would have been a dead relative link, because a
trigger's url variables are validated site-relative by construction.
- Phase 4a enqueued digest-mode recipients for a drain that Phase 6 decided
not to build. An outbox row snapshots the payload and so has none of the
three properties the digest design exists for, including the security one.
- the digest's send-log row carried no address_hash while the instant row
beside it did, which would have made half the mail uncorrelatable in Phase 9.
Also: engagement_digest_state + a replay-safe backfill, engagement_outbox.scope_key,
a v2 unsubscribe token that still verifies v1 forever, and the canonical
/public/engagement/unsubscribe pair with the old /public/teams path kept
permanently — mail is not editable once sent.
Verified with 1464 server tests, 324 client tests, and a live rig (MariaDB +
Mailpit + a real Team) covering the instant mail, the digest, the generic
template, a pre-migration unsubscribe link and the backfill's replay-safety.
Docs: RunicGateway/docs#TBD
Co-Authored-By: Claude <noreply@anthropic.com>
164 lines
7.9 KiB
JavaScript
164 lines
7.9 KiB
JavaScript
// ── One-click unsubscribe tokens (TEAMS.md §6.4; generalized in ENGAGEMENT.md Phase 6) ──
|
|
//
|
|
// A stateless HMAC over the thing being unsubscribed from, not a row in a table.
|
|
//
|
|
// **Why stateless.** The alternative is a `password_resets`-shaped token table,
|
|
// and it is the wrong shape for this: an unsubscribe link sits in a mailbox for
|
|
// months and must still work, so it has no useful expiry; it is not single-use,
|
|
// because clicking it twice must mean the same thing as clicking it once; and a
|
|
// table would need pruning for a capability that never expires. Every property
|
|
// that makes a reset token a row is absent here.
|
|
//
|
|
// **v1 was `(userId, teamId)`; v2 is `(userId, channel, scopeKey)`, and BOTH
|
|
// verify — permanently.** Phase 6 generalized the token because the thing being
|
|
// unsubscribed from is no longer always a Team, but v1 tokens are already in
|
|
// people's mailboxes and a link that stops working is a person who cannot
|
|
// unsubscribe. A v1 token reads as `{ channel: 'email', scopeKey: 'team:<id>' }`:
|
|
// it can only ever have arrived in an email, so naming that channel is a reading
|
|
// of what it always meant rather than a guess.
|
|
//
|
|
// **What the capability actually is.** Holding a token lets the holder turn ONE
|
|
// channel off for ONE scope for one account. It cannot read anything, cannot turn
|
|
// anything back on, and names no other scope. So the honest threat model is:
|
|
// someone who intercepts the mail can silence one Team's email for that account,
|
|
// visibly and reversibly on the account screen. That is a smaller capability than
|
|
// the mail itself already carries (it contains the content).
|
|
//
|
|
// **The narrowing from v1 is deliberate and is a live behaviour change.** A v1
|
|
// token set `muted = 1`, which silenced that Team's push as well as its email —
|
|
// a link labelled "stop these emails" quietly stopped notifications on somebody's
|
|
// phone. From this phase a token turns off the channel it names and nothing else,
|
|
// which is both what the link says and what RFC 8058 means by it. Settled by the
|
|
// org lead 2026-08-29.
|
|
//
|
|
// **`v` is the version prefix, and it is what makes rotation possible at all.** A
|
|
// stateless token cannot be revoked individually; retiring a version invalidates
|
|
// every outstanding link of it at once, which is the only revocation a design
|
|
// with no server-side state can offer, and it needs to exist before it is needed.
|
|
//
|
|
// The key is SECRET_ENC_KEY, derived through the same dev fallback as
|
|
// utils/secretBox — a separate label so an unsubscribe token can never be
|
|
// confused with, or replayed as, anything else keyed by the same secret.
|
|
|
|
const crypto = require('crypto')
|
|
require('dotenv').config()
|
|
|
|
const log = require('./logger')('unsub-token')
|
|
|
|
// The version this deployment SIGNS with. Both are verified; see the header.
|
|
const VERSION = 2
|
|
const LEGACY_VERSION = 1
|
|
|
|
// `.` is the field separator, so neither field may contain one. The scope
|
|
// vocabulary is `<kind>:<id>` (`team:12`) or '' for deployment-wide, and the
|
|
// channel ids the registry accepts are `[a-z][a-z0-9_.-]*` — which DOES admit a
|
|
// dot (`discord.dm` is the example §3.1 gives). So the channel is checked against
|
|
// a dot-free subset here rather than against the registry's own pattern, and a
|
|
// channel id containing a dot would need a signing format with a real escape
|
|
// before it could carry an unsubscribe link. Refused loudly rather than signed
|
|
// into a token that verifies as some other channel.
|
|
const CHANNEL_RE = /^[a-z][a-z0-9_-]*$/
|
|
const SCOPE_RE = /^[a-z0-9][a-z0-9:_-]*$/
|
|
|
|
function resolveKey() {
|
|
const explicit = process.env.SECRET_ENC_KEY
|
|
if (explicit) return crypto.createHash('sha256').update(`unsubscribe:${explicit}`).digest()
|
|
if (process.env.NODE_ENV === 'production') {
|
|
throw new Error('SECRET_ENC_KEY must be set in production')
|
|
}
|
|
const jwt = process.env.JWT_SECRET || 'dev-insecure-jwt-secret-do-not-use-in-production'
|
|
log.warn('SECRET_ENC_KEY is not set — deriving an insecure unsubscribe key from JWT_SECRET for development.')
|
|
return crypto.createHash('sha256').update(`unsubscribe:${jwt}`).digest()
|
|
}
|
|
|
|
let cachedKey = null
|
|
const key = () => {
|
|
// Lazily, not at require time. utils/secretBox resolves its key on import and
|
|
// that is fine for a module every boot loads anyway; this one is reached from a
|
|
// mail template, and a test that never sends mail should not have to set an env
|
|
// var to require the module that sends it.
|
|
if (!cachedKey) cachedKey = resolveKey()
|
|
return cachedKey
|
|
}
|
|
|
|
// base64url so the token survives being a path segment, a query value and a mail
|
|
// client's own re-wrapping of a long URL without any of the three escaping it.
|
|
const b64u = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
|
|
// Truncated to 16 bytes (128 bits). Full-length would double the URL for no
|
|
// reachable gain: forging this buys one unsubscribe, and 128 bits is far past the
|
|
// point where that is worth anyone's compute.
|
|
const mac = (body) => b64u(crypto.createHmac('sha256', key()).update(body).digest().subarray(0, 16))
|
|
|
|
const legacyBody = (userId, teamId) => `${LEGACY_VERSION}.${Number(userId)}.${Number(teamId)}`
|
|
|
|
/**
|
|
* Sign a v2 token: turn `channel` off for `scopeKey` for this user.
|
|
*
|
|
* @param {number} userId
|
|
* @param {string} channel a registered delivery-channel id, dot-free (see CHANNEL_RE)
|
|
* @param {string} scopeKey '' for deployment-wide, or `<kind>:<id>` — a stable
|
|
* IDENTIFIER, never a display name. A Team renamed
|
|
* between the mail and the click must not orphan the
|
|
* link in it, which is why this is not `subject_key`.
|
|
*/
|
|
function sign(userId, channel, scopeKey = '') {
|
|
const id = Number(userId)
|
|
if (!Number.isInteger(id) || id < 1) throw new Error('unsubscribeToken.sign: userId must be a positive integer')
|
|
if (!CHANNEL_RE.test(String(channel || ''))) {
|
|
throw new Error(`unsubscribeToken.sign: channel "${channel}" cannot be carried in a token`)
|
|
}
|
|
const scope = String(scopeKey || '')
|
|
if (scope && !SCOPE_RE.test(scope)) {
|
|
throw new Error(`unsubscribeToken.sign: scope "${scope}" cannot be carried in a token`)
|
|
}
|
|
const body = `${VERSION}.${id}.${channel}.${scope}`
|
|
return `${body}.${mac(body)}`
|
|
}
|
|
|
|
/** Sign a v1 token. Kept only so a test can produce one; nothing else calls it. */
|
|
const signLegacy = (userId, teamId) => `${legacyBody(userId, teamId)}.${mac(legacyBody(userId, teamId))}`
|
|
|
|
/**
|
|
* Verify a token of either version.
|
|
*
|
|
* Returns `{ userId, channel, scopeKey, version }` or null — null for every
|
|
* failure mode, deliberately, so a caller cannot accidentally report which part
|
|
* was wrong.
|
|
*/
|
|
function verify(token) {
|
|
const raw = String(token || '')
|
|
const parts = raw.split('.')
|
|
if (parts.length < 4) return null
|
|
|
|
if (Number(parts[0]) === LEGACY_VERSION) {
|
|
if (parts.length !== 4) return null
|
|
const userId = Number(parts[1])
|
|
const teamId = Number(parts[2])
|
|
if (!Number.isInteger(userId) || !Number.isInteger(teamId)) return null
|
|
if (!equal(signLegacy(userId, teamId), raw)) return null
|
|
// A v1 link can only ever have arrived in an email. See the header.
|
|
return { userId, channel: 'email', scopeKey: `team:${teamId}`, version: LEGACY_VERSION }
|
|
}
|
|
|
|
if (Number(parts[0]) !== VERSION || parts.length !== 5) return null
|
|
const userId = Number(parts[1])
|
|
const channel = parts[2]
|
|
const scopeKey = parts[3]
|
|
if (!Number.isInteger(userId) || userId < 1) return null
|
|
if (!CHANNEL_RE.test(channel)) return null
|
|
if (scopeKey && !SCOPE_RE.test(scopeKey)) return null
|
|
if (!equal(sign(userId, channel, scopeKey), raw)) return null
|
|
return { userId, channel, scopeKey, version: VERSION }
|
|
}
|
|
|
|
function equal(expected, actual) {
|
|
const a = Buffer.from(expected)
|
|
const b = Buffer.from(actual)
|
|
// Length-check first: timingSafeEqual throws on a length mismatch, and the
|
|
// length of a token is not a secret.
|
|
return a.length === b.length && crypto.timingSafeEqual(a, b)
|
|
}
|
|
|
|
module.exports = { sign, signLegacy, verify, VERSION, LEGACY_VERSION, CHANNEL_RE, SCOPE_RE }
|