feat(teams): email as the third sink, with a digest that keeps no queue
A web-only user on a deployment running neither the Android app nor Discord gets no notification that someone replied to their own thread — which is most users on most deployments, and a forum where replies are invisible is a forum nobody returns to. Email is a third consumer of the recipient set the previous commit builds, not a fourth pipeline. Unlike a push tickle, an email carries content: a mailbox is a destination the recipient chose, not an untrusted relay reached by an unguessable topic. It carries a title and an excerpt, never a full post. The digest COMPUTES AT SEND TIME and keeps no pending-items queue. The only state is `last_digest_at`. Three properties fall out, and the third is why it was chosen: a deployment down for two days sends one correct digest rather than replaying a backlog; a post a moderator hid after it was written is simply not in the query; and a user who lost forum access between the post and the send is no longer in the recipient set, so they are not emailed content they can no longer read. `last_digest_at` is stamped only on a SUCCESSFUL send — stamping first would quietly eat a day of somebody's notifications every time the mail provider had a bad minute. One-click unsubscribe is a stateless HMAC rather than a token table. Every property that makes a password-reset token a row is absent: the link sits in a mailbox for months so it has no useful expiry, and clicking it twice must mean what clicking it once meant. Its whole capability is setting `muted` for one (user, Team) pair. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
91
server/src/utils/unsubscribeToken.js
Normal file
91
server/src/utils/unsubscribeToken.js
Normal file
@@ -0,0 +1,91 @@
|
||||
// ── One-click unsubscribe tokens (TEAMS.md §6.4) ───────────────────────────
|
||||
//
|
||||
// A stateless HMAC over (userId, teamId, version), 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.
|
||||
//
|
||||
// **What the capability actually is.** Holding a token lets the holder set
|
||||
// `muted = 1` for ONE (user, Team) pair. It cannot read anything, cannot unmute,
|
||||
// cannot touch email mode, and names no other Team. So the honest threat model is:
|
||||
// someone who intercepts the mail can silence one Team's notifications 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).
|
||||
//
|
||||
// **`v` is the version prefix, and it is what makes rotation possible at all.** A
|
||||
// stateless token cannot be revoked individually; bumping VERSION invalidates
|
||||
// every outstanding link 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')
|
||||
|
||||
const VERSION = 1
|
||||
|
||||
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(/=+$/, '')
|
||||
|
||||
function sign(userId, teamId) {
|
||||
const body = `${VERSION}.${Number(userId)}.${Number(teamId)}`
|
||||
const mac = crypto.createHmac('sha256', key()).update(body).digest()
|
||||
// Truncated to 16 bytes (128 bits). Full-length would double the URL for no
|
||||
// reachable gain: forging this buys one mute, and 128 bits is far past the
|
||||
// point where that is worth anyone's compute.
|
||||
return `${body}.${b64u(mac.subarray(0, 16))}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a token. Returns { userId, teamId } or null — null for every failure
|
||||
* mode, deliberately, so a caller cannot accidentally report which part was wrong.
|
||||
*/
|
||||
function verify(token) {
|
||||
const parts = String(token || '').split('.')
|
||||
if (parts.length !== 4) return null
|
||||
const [v, uid, tid] = parts
|
||||
if (Number(v) !== VERSION) return null
|
||||
const userId = Number(uid)
|
||||
const teamId = Number(tid)
|
||||
if (!Number.isInteger(userId) || !Number.isInteger(teamId)) return null
|
||||
|
||||
const expected = sign(userId, teamId)
|
||||
const a = Buffer.from(expected)
|
||||
const b = Buffer.from(String(token))
|
||||
// Length-check first: timingSafeEqual throws on a length mismatch, and the
|
||||
// length of a token is not a secret.
|
||||
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null
|
||||
return { userId, teamId }
|
||||
}
|
||||
|
||||
module.exports = { sign, verify, VERSION }
|
||||
Reference in New Issue
Block a user