From c7480661eb16d39a0e58780ea50c0047a6ef92d2 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Mon, 16 Feb 2026 23:28:32 -0800 Subject: [PATCH] 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 --- .../src/lib/request-context.ts | 36 ++++++++--------- .../src/modules/products/routes.ts | 6 +-- .../src/modules/waitlist/routes.ts | 39 +++++++++++++++---- 3 files changed, 50 insertions(+), 31 deletions(-) diff --git a/services/platform-service/src/lib/request-context.ts b/services/platform-service/src/lib/request-context.ts index cdf66542..d235cdd6 100644 --- a/services/platform-service/src/lib/request-context.ts +++ b/services/platform-service/src/lib/request-context.ts @@ -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})`); } diff --git a/services/platform-service/src/modules/products/routes.ts b/services/platform-service/src/modules/products/routes.ts index 8922e7fb..1654d0bb 100644 --- a/services/platform-service/src/modules/products/routes.ts +++ b/services/platform-service/src/modules/products/routes.ts @@ -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); diff --git a/services/platform-service/src/modules/waitlist/routes.ts b/services/platform-service/src/modules/waitlist/routes.ts index 204b1d75..d187fc7d 100644 --- a/services/platform-service/src/modules/waitlist/routes.ts +++ b/services/platform-service/src/modules/waitlist/routes.ts @@ -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)[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(',')); }