Files
website/server/src/utils/unsubscribeToken.js
wtclaude 065bec7ad8
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 11m9s
feat(engagement): the email channel on the engine, and the Teams migration (engagement Phase 6)
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>
2026-08-29 20:11:54 -05:00

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 }