/** * Request-level product context helpers. * * Extract and validate productId from every request: * 1. JWT token payload (authenticated requests) * 2. X-Product-Id header (unauthenticated requests) * 3. PRODUCT_ID env var fallback (backward compat during migration) * * Validates against the products registry — rejects unknown or disabled products. */ import type { FastifyRequest } from 'fastify'; import { BadRequestError } from './errors.js'; import { isValidProduct, getProduct } from '../modules/products/cache.js'; import type { ProductDoc } from '../modules/products/types.js'; import { autoRegisterProduct } from './auto-register.js'; /** JWT payload shape attached to req by the onRequest hook (see Commit 3). */ export interface JwtPayload { sub: string; email?: string; role?: string; productId?: string; type?: string; } // Augment Fastify request to include parsed JWT payload declare module 'fastify' { interface FastifyRequest { jwtPayload?: JwtPayload; } } /** * Extract raw productId from request without status validation. * Priority: JWT token > X-Product-Id header > env fallback (dev only). * Validates the product exists in the registry but does NOT check status. */ export async function extractProductIdAsync(req: FastifyRequest): Promise { // 1. From JWT (set during login/register, attached by onRequest hook) let id = req.jwtPayload?.productId; // 2. From header (unauthenticated requests like license activation) if (!id) { const header = req.headers['x-product-id']; if (typeof header === 'string' && header.length > 0) id = header; } // 3. Fallback to env var (backward compat during migration, dev only) if (!id) { const envFallback = process.env.PRODUCT_ID; if (envFallback) id = envFallback; } if (!id) throw new BadRequestError('productId is required (via JWT or X-Product-Id header)'); // Auto-register unknown products when requester has a valid JWT if (!isValidProduct(id) && req.jwtPayload?.sub) { const result = await autoRegisterProduct(id, req.jwtPayload.sub); if ( !result.registered && result.reason !== 'Already exists' && result.reason !== 'Concurrent registration' ) { throw new BadRequestError( `Unknown product: ${id} (auto-registration failed: ${result.reason})` ); } } else if (!isValidProduct(id)) { throw new BadRequestError(`Unknown product: ${id}`); } return id; } /** Synchronous wrapper kept for backward compatibility on hot paths. */ function extractProductId(req: FastifyRequest): string { // 1. From JWT (set during login/register, attached by onRequest hook) let id = req.jwtPayload?.productId; // 2. From header (unauthenticated requests like license activation) if (!id) { const header = req.headers['x-product-id']; if (typeof header === 'string' && header.length > 0) id = header; } // 3. Fallback to env var (backward compat during migration, dev only) if (!id) { const envFallback = process.env.PRODUCT_ID; if (envFallback) id = envFallback; } if (!id) throw new BadRequestError('productId is required (via JWT or X-Product-Id header)'); if (!isValidProduct(id)) throw new BadRequestError(`Unknown product: ${id}`); return id; } /** * Extract and validate productId from the request. * Blocks: `draft`, `sunset`, `disabled` (only `pre_launch`, `beta`, `active` pass). */ export function getRequestProductId(req: FastifyRequest): string { const id = extractProductId(req); const product = getProduct(id)!; const blockedStatuses = ['draft', 'sunset', 'disabled'] as const; if ((blockedStatuses as readonly string[]).includes(product.status)) { throw new BadRequestError(`Product ${id} is not available (status: ${product.status})`); } return id; } /** * Extract productId with relaxed status gating — permits `pre_launch`. * Used by public waitlist routes where the product isn't fully operational yet. * Blocks: `draft`, `sunset`, `disabled`. */ export function getRequestProductIdForPublic(req: FastifyRequest): string { const id = extractProductId(req); const product = getProduct(id)!; if (product.status === 'draft' || product.status === 'sunset' || product.status === 'disabled') { throw new BadRequestError(`Product ${id} is not available (status: ${product.status})`); } return id; } /** * Get the full product config for the current request's productId. * Useful when modules need product-specific values (trial days, device limits, etc.). */ export function getRequestProductConfig(req: FastifyRequest): ProductDoc { const id = getRequestProductId(req); return getProduct(id)!; }