/** * 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, ForbiddenError } 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'; import { extractAuth } from './auth.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)!; } /** * `FLEET_TENANT_ENFORCEMENT` env gate — default OFF. When OFF, tenant scoping is * advisory only (the dashboard shows you your projects, but the API does not * reject cross-tenant access) — byte-for-byte the current behavior. When ON, the * API enforces that a caller may only act on products they own (admins exempt). */ export function isTenantEnforcementEnabled(): boolean { const v = (process.env.FLEET_TENANT_ENFORCEMENT ?? '').trim().toLowerCase(); return v === '1' || v === 'true' || v === 'on' || v === 'yes'; } /** * Resolve the request's productId AND authorize the caller for it (multi-tenant * guard, §tenancy). Resolution + status gating is unchanged (`getRequestProductId`); * the access check is additive and flag-gated: * * - enforcement OFF ⇒ returns the resolved id (no behavior change). * - admins / super_admins ⇒ always allowed (operators see everything). * - otherwise ⇒ allowed only when the product is owned by the caller, OR is * owner-less/legacy (grace for products created before ownership tracking; * migrate them to lock down fully). A product owned by SOMEONE ELSE ⇒ 403. * * Async because it needs the verified auth identity. Use this on tenant-scoped * surfaces (e.g. the fleet control plane) in place of `getRequestProductId`. */ export async function requireProductAccess(req: FastifyRequest): Promise { const id = getRequestProductId(req); if (!isTenantEnforcementEnabled()) return id; const auth = await extractAuth(req); if (auth.role === 'admin' || auth.role === 'super_admin') return id; const product = getProduct(id); if (product?.ownerId && product.ownerId !== auth.sub) { throw new ForbiddenError(`Not authorized for product '${id}'`); } return id; }