learning_ai_common_plat/services/platform-service/src/lib/request-context.ts
saravanakumardb1 1fe1e75999 feat(platform-service): auto-registration on first request (Phase 4.1) — 8 tests
Zero-touch product provisioning: when a request arrives with an unknown
productId and a valid JWT, auto-create a minimal ProductDoc instead of
rejecting. Enables new products to use platform-service immediately.

- auto-register.ts: auto-create ProductDoc with sensible defaults
- Rate limited: max 10 auto-registrations per minute
- Requires valid JWT (unauthenticated requests still rejected)
- Audit logged as product.auto_registered
- request-context.ts: exported extractProductIdAsync with auto-register
- 8 tests: register, duplicate, format validation, rate limit
2026-03-19 22:00:57 -07:00

139 lines
4.6 KiB
TypeScript

/**
* 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<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)');
// 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)!;
}