fix(waitlist): harden public endpoints and unsubscribe validation

- Block sunset products on public waitlist join/count/config endpoints
- Verify unsubscribe email matches unsubscribeToken before status change
- Keep idempotent join behavior explicit for existing entries (200 path)
- Escape CSV newline/carriage-return values to prevent malformed exports
- Refactor request productId extraction to shared helper in request-context
- Guard prelaunchConfig merge with safe default object in products update route
This commit is contained in:
saravanakumardb1 2026-02-16 23:28:32 -08:00
parent b9b4822cad
commit c7480661eb
3 changed files with 50 additions and 31 deletions

View File

@ -31,11 +31,11 @@ declare module 'fastify' {
}
/**
* Extract and validate productId from the request.
* Extract raw productId from request without status validation.
* Priority: JWT token > X-Product-Id header > env fallback (dev only).
* Rejects unknown or disabled products.
* Validates the product exists in the registry but does NOT check status.
*/
export function getRequestProductId(req: FastifyRequest): string {
function extractProductId(req: FastifyRequest): string {
// 1. From JWT (set during login/register, attached by onRequest hook)
let id = req.jwtPayload?.productId;
@ -52,12 +52,19 @@ export function getRequestProductId(req: FastifyRequest): string {
}
if (!id) throw new BadRequestError('productId is required (via JWT or X-Product-Id header)');
// Validate against product registry
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)!;
// Block products that are not operational
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})`);
@ -67,25 +74,14 @@ export function getRequestProductId(req: FastifyRequest): string {
}
/**
* Extract productId with relaxed status gating permits `pre_launch` status.
* Extract productId with relaxed status gating permits `pre_launch` and `sunset`.
* Used by public waitlist routes where the product isn't fully operational yet.
* Blocks only: `draft`, `disabled`.
*/
export function getRequestProductIdForPublic(req: FastifyRequest): string {
// Reuse extraction logic without calling getRequestProductId (which blocks pre_launch)
let id = req.jwtPayload?.productId;
if (!id) {
const header = req.headers['x-product-id'];
if (typeof header === 'string' && header.length > 0) id = header;
}
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}`);
const id = extractProductId(req);
const product = getProduct(id)!;
if (product.status === 'draft' || product.status === 'disabled') {
throw new BadRequestError(`Product ${id} is not available (status: ${product.status})`);
}

View File

@ -87,10 +87,8 @@ export async function productRoutes(app: FastifyInstance) {
}
// Merge prelaunchConfig if partial update provided
if (prelaunchConfig) {
updates.prelaunchConfig = {
...existing.prelaunchConfig,
...prelaunchConfig,
} as ProductDoc['prelaunchConfig'];
const base = existing.prelaunchConfig ?? {};
updates.prelaunchConfig = { ...base, ...prelaunchConfig } as ProductDoc['prelaunchConfig'];
}
const updated = await repo.update(id, updates);

View File

@ -100,7 +100,11 @@ export async function waitlistRoutes(app: FastifyInstance) {
// Validate product exists and is in pre_launch (or active/beta for flexibility)
const product = getProduct(productId);
if (!product) throw new NotFoundError('Product not found');
if (product.status === 'draft' || product.status === 'disabled') {
if (
product.status === 'draft' ||
product.status === 'sunset' ||
product.status === 'disabled'
) {
throw new BadRequestError(`Product ${productId} is not accepting signups`);
}
@ -127,11 +131,10 @@ export async function waitlistRoutes(app: FastifyInstance) {
if (err) throw new BadRequestError(err);
}
// Dedupe check
// Dedupe check (returns 200, not 201, for existing entries)
const emailNormalized = repo.normalizeEmail(input.email);
const existing = await repo.getByEmail(emailNormalized, productId);
if (existing) {
// Idempotent: return existing entry position
return {
id: existing.id,
position: existing.position,
@ -242,6 +245,13 @@ export async function waitlistRoutes(app: FastifyInstance) {
const { productId } = req.params as { productId: string };
const product = getProduct(productId);
if (!product) throw new NotFoundError('Product not found');
if (
product.status === 'draft' ||
product.status === 'sunset' ||
product.status === 'disabled'
) {
throw new BadRequestError(`Product ${productId} is not available`);
}
const total = await repo.count(productId);
return { count: total };
@ -252,6 +262,13 @@ export async function waitlistRoutes(app: FastifyInstance) {
const { productId } = req.params as { productId: string };
const product = getProduct(productId);
if (!product) throw new NotFoundError('Product not found');
if (
product.status === 'draft' ||
product.status === 'sunset' ||
product.status === 'disabled'
) {
throw new BadRequestError(`Product ${productId} is not available`);
}
const plConfig = product.prelaunchConfig;
if (!plConfig) {
@ -284,9 +301,14 @@ export async function waitlistRoutes(app: FastifyInstance) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const entry = await repo.unsubscribe(parsed.data.unsubscribeToken);
if (!entry) {
throw new NotFoundError('Entry not found or already unsubscribed');
// Verify email matches the token (prevents blind token guessing)
const entry = await repo.getByUnsubscribeToken(parsed.data.unsubscribeToken);
if (!entry || repo.normalizeEmail(parsed.data.email) !== entry.emailNormalized) {
throw new NotFoundError('Entry not found or token mismatch');
}
if (entry.status !== 'unsubscribed') {
await repo.update(entry.id, entry.email, { status: 'unsubscribed' });
}
return { status: 'unsubscribed' };
@ -454,7 +476,10 @@ export async function waitlistRoutes(app: FastifyInstance) {
const val = (item as unknown as Record<string, unknown>)[h];
if (val === undefined || val === null) return '';
const str = String(val);
return str.includes(',') || str.includes('"') ? `"${str.replace(/"/g, '""')}"` : str;
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
});
csvLines.push(row.join(','));
}