Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/leads/leadToken.server.ts
T

172 lines
6.0 KiB
TypeScript

/**
* 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=<id>&token=<token>&plan=<planId>`. 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.<base64url(payload)>.<base64url(hmac-sha256)>`.
* `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.<payload>.<sig>` 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<string, unknown>;
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,
};
}