// ── 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:' }`: // 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 `:` (`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 `:` — 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 }