/** * Signed-Lead-Token helper for the Marketing-Funnel Phase 2 self-service * conversion flow (Phase 05 #79). * * The operator (or an automated nurture email) sends a Lead a deep-link * such as `/signup?leadId=&token=&plan=`. The signup * loader verifies the token before pre-filling the registration form so: * * - Random visitors can't enumerate `?leadId=…` URLs to extract PII * from the prefilled form fields. * - Tokens expire after 7 days by default — captured leads have a * reasonable conversion window without leaving long-lived bearer * tokens floating around. * - Lead-row identity is bound to the token: tampering with the * leadId / plan / expiry payload invalidates the signature. * * The token format is `v1..`. * `LEAD_CONVERSION_SECRET` (env, min-32-byte hex) is the HMAC key. When * the env is missing every call fails-closed (`verifyLeadToken` returns * `null`, `signLeadToken` throws) so misconfiguration cannot silently * accept unsigned URLs. */ import { createHmac, timingSafeEqual } from "node:crypto"; export interface LeadTokenPayload { /** Lead.id this token is bound to. */ leadId: string; /** Optional pre-selected SubscriptionProduct.id surfaced on the signup form. */ planId?: string | null; /** Issued-at (unix seconds). */ iat: number; /** Expires-at (unix seconds). */ exp: number; } export interface SignLeadTokenInput { leadId: string; planId?: string | null; /** Token lifetime in days. Capped at 30 to bound exposure. Default 7. */ expiresInDays?: number; /** Override system clock (test seam). */ now?: () => Date; /** Override env lookup (test seam). */ secret?: string; } export interface VerifyLeadTokenOptions { now?: () => Date; secret?: string; } export class LeadTokenSecretMissingError extends Error { override readonly name = "LeadTokenSecretMissingError"; constructor() { super("LEAD_CONVERSION_SECRET env var is not set; cannot sign or verify lead tokens."); } } const TOKEN_VERSION = "v1"; const MAX_TTL_DAYS = 30; const DEFAULT_TTL_DAYS = 7; const MIN_SECRET_BYTES = 32; function getSecret(override?: string): Buffer { const raw = override ?? process.env.LEAD_CONVERSION_SECRET; if (!raw || raw.length === 0) { throw new LeadTokenSecretMissingError(); } // Accept hex-encoded or raw secrets. We don't enforce hex specifically // (operators may roll arbitrary base64) but we *do* enforce a minimum // length so a weak secret rejects fail-fast. if (raw.length < MIN_SECRET_BYTES) { throw new LeadTokenSecretMissingError(); } return Buffer.from(raw, "utf8"); } function base64UrlEncode(input: Buffer | string): string { const buf = Buffer.isBuffer(input) ? input : Buffer.from(input, "utf8"); return buf.toString("base64url"); } function base64UrlDecode(input: string): Buffer | null { // base64url accepts only [A-Za-z0-9_-] if (!/^[A-Za-z0-9_-]+$/u.test(input)) return null; try { return Buffer.from(input, "base64url"); } catch { return null; } } function nowSec(now?: () => Date): number { return Math.floor((now ? now() : new Date()).getTime() / 1000); } /** * Sign a lead-token. Returns the `v1..` string. Throws * {@link LeadTokenSecretMissingError} when `LEAD_CONVERSION_SECRET` is * unset or shorter than 32 bytes — callers MUST handle that case so the * conversion-disabled rollback path stays usable. */ export function signLeadToken(input: SignLeadTokenInput): string { if (!input.leadId || typeof input.leadId !== "string") { throw new TypeError("signLeadToken: leadId required"); } const secret = getSecret(input.secret); const ttlDays = clampTtl(input.expiresInDays); const iat = nowSec(input.now); const exp = iat + ttlDays * 24 * 60 * 60; const payload: LeadTokenPayload = { leadId: input.leadId, planId: input.planId ?? null, iat, exp, }; const payloadStr = JSON.stringify(payload); const payloadB64 = base64UrlEncode(payloadStr); const sig = createHmac("sha256", secret).update(`${TOKEN_VERSION}.${payloadB64}`).digest(); return `${TOKEN_VERSION}.${payloadB64}.${base64UrlEncode(sig)}`; } function clampTtl(days: number | undefined): number { if (!days || !Number.isFinite(days) || days <= 0) return DEFAULT_TTL_DAYS; return Math.min(Math.floor(days), MAX_TTL_DAYS); } /** * Verify a lead-token. Returns the decoded payload when the signature * matches AND the token has not expired. Returns `null` otherwise. * NEVER throws on bad input — fail-closed silently so route loaders can * fall back to the plain-signup form without leaking which check failed. */ export function verifyLeadToken(token: string | null | undefined, options: VerifyLeadTokenOptions = {}): LeadTokenPayload | null { if (!token || typeof token !== "string") return null; const parts = token.split("."); if (parts.length !== 3) return null; const [version, payloadB64, sigB64] = parts; if (version !== TOKEN_VERSION) return null; let secret: Buffer; try { secret = getSecret(options.secret); } catch { return null; } const expectedSig = createHmac("sha256", secret).update(`${version}.${payloadB64}`).digest(); const actualSig = base64UrlDecode(sigB64); if (!actualSig || actualSig.length !== expectedSig.length) return null; if (!timingSafeEqual(actualSig, expectedSig)) return null; const payloadBuf = base64UrlDecode(payloadB64); if (!payloadBuf) return null; let parsed: unknown; try { parsed = JSON.parse(payloadBuf.toString("utf8")); } catch { return null; } if (!parsed || typeof parsed !== "object") return null; const rec = parsed as Record; if (typeof rec.leadId !== "string" || rec.leadId.length === 0) return null; if (typeof rec.iat !== "number" || typeof rec.exp !== "number") return null; if (rec.exp <= nowSec(options.now)) return null; return { leadId: rec.leadId, planId: typeof rec.planId === "string" ? rec.planId : null, iat: rec.iat, exp: rec.exp, }; }