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:
parent
b9b4822cad
commit
c7480661eb
@ -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})`);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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(','));
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user