From a717d9e69431207c74d230cb916f50ecf0d6fd86 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Wed, 2 Sep 2026 19:07:21 -0300 Subject: [PATCH 1/9] record fake click for demo workspace --- apps/web/.env.example | 2 + apps/web/app/(ee)/api/demo/click/route.ts | 107 +++++++++++++++++++++ apps/web/lib/tinybird/record-fake-click.ts | 43 ++++++++- 3 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 apps/web/app/(ee)/api/demo/click/route.ts diff --git a/apps/web/.env.example b/apps/web/.env.example index 4ea84d0dc49..e7883eb7b3b 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -8,6 +8,8 @@ NEXTAUTH_URL=http://localhost:8888 # (only needed for localhost) # Secret for Vercel cron jobs + sync-embeddings CRON_SECRET= +# Shared with the LoopWork demo cron. Mints geo-accurate clicks via POST /api/demo/click +DEMO_CLICK_SECRET= # Encryption key (AES-256-GCM) for encrypting sensitive data in the database ENCRYPTION_KEY= # Email unsubscribe token secret (optional, falls back to NEXTAUTH_SECRET) diff --git a/apps/web/app/(ee)/api/demo/click/route.ts b/apps/web/app/(ee)/api/demo/click/route.ts new file mode 100644 index 00000000000..d35f90f1678 --- /dev/null +++ b/apps/web/app/(ee)/api/demo/click/route.ts @@ -0,0 +1,107 @@ +import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { parseRequestBody } from "@/lib/api/utils"; +import { prefixWorkspaceId } from "@/lib/api/workspaces/workspace-id"; +import { withAxiom } from "@/lib/axiom/server"; +import { getLinkWithPartner } from "@/lib/planetscale/get-link-with-partner"; +import { recordFakeClick } from "@/lib/tinybird/record-fake-click"; +import { + COUNTRY_CODES, + DEMO_PROGRAM_ID, + DEMO_WORKSPACE_ID, + getDomainWithoutWWW, +} from "@dub/utils"; +import { NextResponse } from "next/server"; +import * as z from "zod/v4"; + +const demoClickSchema = z.object({ + domain: z.preprocess( + (val) => getDomainWithoutWWW(val as string), + z.string({ error: "domain is required." }), + ), + key: z.string({ error: "key is required." }), + country: z.enum(COUNTRY_CODES), + region: z.string().nullish(), + city: z.string().nullish(), + continent: z.string().nullish(), + referrer: z.string().nullish(), + userAgent: z.string().nullish(), +}); + +function verifyDemoClickSecret(req: Request) { + const secret = process.env.DEMO_CLICK_SECRET; + const authorization = req.headers.get("authorization"); + + if (!secret || authorization !== `Bearer ${secret}`) { + throw new DubApiError({ + code: "unauthorized", + message: "Invalid or missing DEMO_CLICK_SECRET.", + }); + } +} + +// POST /api/demo/click – mint a geo-accurate click for the LoopWork demo workspace only +export const POST = withAxiom(async (req) => { + try { + verifyDemoClickSecret(req); + + const { + domain, + key, + country, + region, + city, + continent, + referrer, + userAgent, + } = demoClickSchema.parse(await parseRequestBody(req)); + + const link = await getLinkWithPartner({ domain, key }); + + if (!link) { + throw new DubApiError({ + code: "not_found", + message: `Link not found for domain: ${domain} and key: ${key}.`, + }); + } + + if ( + prefixWorkspaceId(link.projectId) !== prefixWorkspaceId(DEMO_WORKSPACE_ID) + ) { + throw new DubApiError({ + code: "forbidden", + message: "This endpoint can only record clicks for the demo workspace.", + }); + } + + if (link.programId && link.programId !== DEMO_PROGRAM_ID) { + throw new DubApiError({ + code: "forbidden", + message: "This endpoint can only record clicks for the demo program.", + }); + } + + const clickEvent = await recordFakeClick({ + link: { + id: link.id, + url: link.url, + domain: link.domain, + key: link.key, + projectId: link.projectId, + programId: link.programId, + partnerId: link.partnerId, + }, + customer: { + country, + region, + city, + continent, + }, + referrer, + userAgent, + }); + + return NextResponse.json({ clickId: clickEvent.click_id }); + } catch (error) { + return handleAndReturnErrorResponse(error); + } +}); diff --git a/apps/web/lib/tinybird/record-fake-click.ts b/apps/web/lib/tinybird/record-fake-click.ts index 76464382dd6..9c4a75bebee 100644 --- a/apps/web/lib/tinybird/record-fake-click.ts +++ b/apps/web/lib/tinybird/record-fake-click.ts @@ -1,4 +1,4 @@ -import { nanoid } from "@dub/utils"; +import { COUNTRIES_TO_CONTINENTS, nanoid } from "@dub/utils"; import { Link } from "@prisma/client"; import { clickEventSchemaTB } from "../zod/schemas/clicks"; import { recordClick } from "./record-click"; @@ -19,28 +19,58 @@ function toSafeHeaderValue(value: string | null | undefined) { return value; } +const DEFAULT_USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"; + // TODO: // Use this in other places where we need to record a fake click event (Eg: import-customers) export async function recordFakeClick({ link, customer, timestamp, + referrer, + userAgent, }: { - link: Pick; + link: Pick & { + programId?: string | null; + partnerId?: string | null; + }; customer?: { country?: string | null; region?: string | null; continent?: string | null; + city?: string | null; + latitude?: string | null; + longitude?: string | null; }; timestamp?: string | number; + referrer?: string | null; + userAgent?: string | null; }) { + const country = toSafeHeaderValue(customer?.country) || "US"; + const continent = + toSafeHeaderValue(customer?.continent) || + COUNTRIES_TO_CONTINENTS[country] || + "NA"; + const dummyRequest = new Request(link.url, { headers: new Headers({ - "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + "user-agent": userAgent || DEFAULT_USER_AGENT, "x-forwarded-for": "127.0.0.1", - "x-vercel-ip-country": toSafeHeaderValue(customer?.country) || "US", + "x-vercel-ip-country": country, "x-vercel-ip-country-region": toSafeHeaderValue(customer?.region) || "CA", - "x-vercel-ip-continent": toSafeHeaderValue(customer?.continent) || "NA", + "x-vercel-ip-continent": continent, + ...(customer?.city && { + "x-vercel-ip-city": toSafeHeaderValue(customer.city) || "Unknown", + }), + ...(customer?.latitude && { + "x-vercel-ip-latitude": + toSafeHeaderValue(customer.latitude) || "Unknown", + }), + ...(customer?.longitude && { + "x-vercel-ip-longitude": + toSafeHeaderValue(customer.longitude) || "Unknown", + }), }), }); @@ -52,8 +82,11 @@ export async function recordFakeClick({ domain: link.domain, key: link.key, url: link.url, + programId: link.programId ?? undefined, + partnerId: link.partnerId ?? undefined, skipRatelimit: true, shouldCacheClickId: true, + ...(referrer && { referrer }), ...(timestamp && { timestamp: new Date(timestamp).toISOString() }), }); From 6255a55a9c6aed7a1259041cd621e36f202cf178 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Wed, 2 Sep 2026 19:09:45 -0300 Subject: [PATCH 2/9] update record-fake-click --- apps/web/lib/tinybird/record-fake-click.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/web/lib/tinybird/record-fake-click.ts b/apps/web/lib/tinybird/record-fake-click.ts index 9c4a75bebee..7461969f92b 100644 --- a/apps/web/lib/tinybird/record-fake-click.ts +++ b/apps/web/lib/tinybird/record-fake-click.ts @@ -19,9 +19,6 @@ function toSafeHeaderValue(value: string | null | undefined) { return value; } -const DEFAULT_USER_AGENT = - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"; - // TODO: // Use this in other places where we need to record a fake click event (Eg: import-customers) export async function recordFakeClick({ @@ -55,7 +52,8 @@ export async function recordFakeClick({ const dummyRequest = new Request(link.url, { headers: new Headers({ - "user-agent": userAgent || DEFAULT_USER_AGENT, + "user-agent": + userAgent || "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", "x-forwarded-for": "127.0.0.1", "x-vercel-ip-country": country, "x-vercel-ip-country-region": toSafeHeaderValue(customer?.region) || "CA", From 4b23d564c76ddc2fe0ab0b5cd0b49895733ed2fa Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Wed, 2 Sep 2026 20:25:09 -0300 Subject: [PATCH 3/9] add workspace_id, domain, key and country --- apps/web/lib/api/commissions/create-manual-commissions.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/lib/api/commissions/create-manual-commissions.ts b/apps/web/lib/api/commissions/create-manual-commissions.ts index 6998a52aa90..faccb29afab 100644 --- a/apps/web/lib/api/commissions/create-manual-commissions.ts +++ b/apps/web/lib/api/commissions/create-manual-commissions.ts @@ -447,9 +447,13 @@ async function recordEvents(args: RecordEventsArgs) { timestamp: clickedAt.toISOString(), identity_hash: targetCustomer.externalId || targetCustomer.id, click_id: clickId, + workspace_id: workspace.id, link_id: targetLink.id, + domain: targetLink.domain, + key: targetLink.key, url: targetLink.url, ip: "127.0.0.1", + country: targetCustomer.country || "Unknown", continent: targetCustomer.country ? COUNTRIES_TO_CONTINENTS[targetCustomer.country.toUpperCase()] || "" : "", From 353ec92d9ad659023e2983a94409de5fd38ffa4d Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Thu, 3 Sep 2026 11:20:10 -0300 Subject: [PATCH 4/9] demo commission --- apps/web/.env.example | 3 +- apps/web/app/(ee)/api/demo/click/route.ts | 43 +-- .../web/app/(ee)/api/demo/commission/route.ts | 82 +++++ .../lib/api/demo/create-demo-commission.ts | 301 ++++++++++++++++++ apps/web/lib/api/demo/guard.ts | 43 +++ apps/web/lib/tinybird/record-fake-click.ts | 3 +- 6 files changed, 438 insertions(+), 37 deletions(-) create mode 100644 apps/web/app/(ee)/api/demo/commission/route.ts create mode 100644 apps/web/lib/api/demo/create-demo-commission.ts create mode 100644 apps/web/lib/api/demo/guard.ts diff --git a/apps/web/.env.example b/apps/web/.env.example index e7883eb7b3b..c8b378c39e3 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -8,7 +8,8 @@ NEXTAUTH_URL=http://localhost:8888 # (only needed for localhost) # Secret for Vercel cron jobs + sync-embeddings CRON_SECRET= -# Shared with the LoopWork demo cron. Mints geo-accurate clicks via POST /api/demo/click +# Shared with the LoopWork demo. Mints geo-accurate clicks via POST /api/demo/click +# and backdated commissions via POST /api/demo/commission DEMO_CLICK_SECRET= # Encryption key (AES-256-GCM) for encrypting sensitive data in the database ENCRYPTION_KEY= diff --git a/apps/web/app/(ee)/api/demo/click/route.ts b/apps/web/app/(ee)/api/demo/click/route.ts index d35f90f1678..63cdfbac2f3 100644 --- a/apps/web/app/(ee)/api/demo/click/route.ts +++ b/apps/web/app/(ee)/api/demo/click/route.ts @@ -1,15 +1,11 @@ +import { assertDemoLink, verifyDemoSecret } from "@/lib/api/demo/guard"; import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; import { parseRequestBody } from "@/lib/api/utils"; -import { prefixWorkspaceId } from "@/lib/api/workspaces/workspace-id"; import { withAxiom } from "@/lib/axiom/server"; import { getLinkWithPartner } from "@/lib/planetscale/get-link-with-partner"; import { recordFakeClick } from "@/lib/tinybird/record-fake-click"; -import { - COUNTRY_CODES, - DEMO_PROGRAM_ID, - DEMO_WORKSPACE_ID, - getDomainWithoutWWW, -} from "@dub/utils"; +import { parseDateSchema } from "@/lib/zod/schemas/utils"; +import { COUNTRY_CODES, getDomainWithoutWWW } from "@dub/utils"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; @@ -25,24 +21,13 @@ const demoClickSchema = z.object({ continent: z.string().nullish(), referrer: z.string().nullish(), userAgent: z.string().nullish(), + timestamp: parseDateSchema.nullish(), }); -function verifyDemoClickSecret(req: Request) { - const secret = process.env.DEMO_CLICK_SECRET; - const authorization = req.headers.get("authorization"); - - if (!secret || authorization !== `Bearer ${secret}`) { - throw new DubApiError({ - code: "unauthorized", - message: "Invalid or missing DEMO_CLICK_SECRET.", - }); - } -} - // POST /api/demo/click – mint a geo-accurate click for the LoopWork demo workspace only export const POST = withAxiom(async (req) => { try { - verifyDemoClickSecret(req); + verifyDemoSecret(req); const { domain, @@ -53,6 +38,7 @@ export const POST = withAxiom(async (req) => { continent, referrer, userAgent, + timestamp, } = demoClickSchema.parse(await parseRequestBody(req)); const link = await getLinkWithPartner({ domain, key }); @@ -64,21 +50,7 @@ export const POST = withAxiom(async (req) => { }); } - if ( - prefixWorkspaceId(link.projectId) !== prefixWorkspaceId(DEMO_WORKSPACE_ID) - ) { - throw new DubApiError({ - code: "forbidden", - message: "This endpoint can only record clicks for the demo workspace.", - }); - } - - if (link.programId && link.programId !== DEMO_PROGRAM_ID) { - throw new DubApiError({ - code: "forbidden", - message: "This endpoint can only record clicks for the demo program.", - }); - } + assertDemoLink(link); const clickEvent = await recordFakeClick({ link: { @@ -98,6 +70,7 @@ export const POST = withAxiom(async (req) => { }, referrer, userAgent, + ...(timestamp && { timestamp: timestamp.toISOString() }), }); return NextResponse.json({ clickId: clickEvent.click_id }); diff --git a/apps/web/app/(ee)/api/demo/commission/route.ts b/apps/web/app/(ee)/api/demo/commission/route.ts new file mode 100644 index 00000000000..62c37bfe5ba --- /dev/null +++ b/apps/web/app/(ee)/api/demo/commission/route.ts @@ -0,0 +1,82 @@ +import { createDemoCommission } from "@/lib/api/demo/create-demo-commission"; +import { assertDemoLink, verifyDemoSecret } from "@/lib/api/demo/guard"; +import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { parseRequestBody } from "@/lib/api/utils"; +import { withAxiom } from "@/lib/axiom/server"; +import { getLinkWithPartner } from "@/lib/planetscale/get-link-with-partner"; +import { centsSchema, parseDateSchema } from "@/lib/zod/schemas/utils"; +import { COUNTRY_CODES, getDomainWithoutWWW } from "@dub/utils"; +import { NextResponse } from "next/server"; +import * as z from "zod/v4"; + +const demoCommissionSchema = z + .object({ + domain: z.preprocess( + (val) => getDomainWithoutWWW(val as string), + z.string({ error: "domain is required." }), + ), + key: z.string({ error: "key is required." }), + date: parseDateSchema, + type: z.enum(["lead", "sale"]), + country: z.enum(COUNTRY_CODES), + region: z.string().nullish(), + city: z.string().nullish(), + continent: z.string().nullish(), + referrer: z.string().nullish(), + userAgent: z.string().nullish(), + customer: z.object({ + name: z.string().nullish(), + email: z.string().nullish(), + externalId: z.string(), + country: z.enum(COUNTRY_CODES), + }), + sale: z + .object({ + amount: centsSchema.pipe(z.number().int().min(0)), + invoiceId: z.string().nullish(), + eventName: z.string().nullish(), + }) + .nullish(), + }) + .refine((data) => data.type !== "sale" || data.sale?.amount != null, { + message: "sale.amount is required when type is sale.", + path: ["sale", "amount"], + }); + +// POST /api/demo/commission – backdated click + lead/sale + commission for the LoopWork demo only +export const POST = withAxiom(async (req) => { + try { + verifyDemoSecret(req); + + const body = demoCommissionSchema.parse(await parseRequestBody(req)); + const { domain, key, date, ...rest } = body; + + const link = await getLinkWithPartner({ domain, key }); + + if (!link) { + throw new DubApiError({ + code: "not_found", + message: `Link not found for domain: ${domain} and key: ${key}.`, + }); + } + + assertDemoLink(link); + + if (!date) { + throw new DubApiError({ + code: "bad_request", + message: "Invalid date.", + }); + } + + const result = await createDemoCommission({ + link, + date, + ...rest, + }); + + return NextResponse.json(result); + } catch (error) { + return handleAndReturnErrorResponse(error); + } +}); diff --git a/apps/web/lib/api/demo/create-demo-commission.ts b/apps/web/lib/api/demo/create-demo-commission.ts new file mode 100644 index 00000000000..44ad1fa88a7 --- /dev/null +++ b/apps/web/lib/api/demo/create-demo-commission.ts @@ -0,0 +1,301 @@ +import { isFirstConversion } from "@/lib/analytics/is-first-conversion"; +import { createId } from "@/lib/api/create-id"; +import { DubApiError } from "@/lib/api/errors"; +import { updateLinkStatsForImporter } from "@/lib/api/links/update-link-stats-for-importer"; +import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-stats"; +import { generateRandomName } from "@/lib/names"; +import { queuePartnerCommissionCreation } from "@/lib/partners/queue-partner-commission-creation"; +import { EdgeLinkProps } from "@/lib/planetscale/types"; +import { prisma } from "@/lib/prisma"; +import { recordLeadWithTimestamp } from "@/lib/tinybird/record-lead"; +import { recordFakeClick } from "@/lib/tinybird/record-fake-click"; +import { recordSaleWithTimestamp } from "@/lib/tinybird/record-sale"; +import { leadEventSchemaTB } from "@/lib/zod/schemas/leads"; +import { saleEventSchemaTB } from "@/lib/zod/schemas/sales"; +import { DEMO_PROGRAM_ID, nanoid } from "@dub/utils"; +import { CommissionType } from "@prisma/client"; +import * as z from "zod/v4"; + +const leadEventSchemaTBWithTimestamp = leadEventSchemaTB.extend({ + timestamp: z.string(), +}); + +const saleEventSchemaTBWithTimestamp = saleEventSchemaTB.extend({ + timestamp: z.string(), +}); + +export async function createDemoCommission({ + link, + type, + date, + country, + region, + city, + continent, + referrer, + userAgent, + customer, + sale, +}: { + link: Pick< + EdgeLinkProps, + "id" | "url" | "domain" | "key" | "projectId" | "programId" | "partnerId" + >; + type: "lead" | "sale"; + date: Date; + country: string; + region?: string | null; + city?: string | null; + continent?: string | null; + referrer?: string | null; + userAgent?: string | null; + customer: { + name?: string | null; + email?: string | null; + externalId: string; + country: string; + }; + sale?: { + amount: number; + invoiceId?: string | null; + eventName?: string | null; + } | null; +}) { + if (!link.partnerId) { + throw new DubApiError({ + code: "bad_request", + message: "Demo commissions require a partner link.", + }); + } + + if (type === "sale" && sale?.amount == null) { + throw new DubApiError({ + code: "bad_request", + message: "sale.amount is required when type is sale.", + }); + } + + const workspace = await prisma.project.findUnique({ + where: { + id: link.projectId, + }, + select: { + id: true, + stripeConnectId: true, + }, + }); + + if (!workspace) { + throw new DubApiError({ + code: "not_found", + message: "Demo workspace not found.", + }); + } + + const targetLink = await prisma.link.findUnique({ + where: { id: link.id }, + }); + + if (!targetLink) { + throw new DubApiError({ + code: "not_found", + message: `Link ${link.id} not found.`, + }); + } + + const invoiceId = sale?.invoiceId ?? null; + + if (type === "sale" && invoiceId) { + const existing = await prisma.commission.findUnique({ + where: { + invoiceId_programId: { + invoiceId, + programId: DEMO_PROGRAM_ID, + }, + }, + select: { id: true }, + }); + + if (existing) { + throw new DubApiError({ + code: "conflict", + message: `There is already a commission for the invoice ${invoiceId}.`, + }); + } + } + + const customerId = createId({ prefix: "cus_" }); + const targetCustomer = await prisma.customer.upsert({ + where: { + projectId_externalId: { + projectId: workspace.id, + externalId: customer.externalId, + }, + }, + create: { + id: customerId, + name: customer.name || customer.email || generateRandomName(), + email: customer.email, + externalId: customer.externalId, + country: customer.country, + linkId: targetLink.id, + projectId: workspace.id, + projectConnectId: workspace.stripeConnectId, + createdAt: date, + }, + update: { + name: customer.name || customer.email || generateRandomName(), + email: customer.email, + country: customer.country, + }, + }); + + const firstConversion = isFirstConversion({ + customer: targetCustomer, + linkId: targetLink.id, + }); + + const clickedAt = new Date(date.getTime() - 5 * 60 * 1000); + + const clickEvent = await recordFakeClick({ + link: { + id: targetLink.id, + url: targetLink.url, + domain: targetLink.domain, + key: targetLink.key, + projectId: targetLink.projectId, + programId: targetLink.programId, + partnerId: targetLink.partnerId, + }, + customer: { + country, + region, + city, + continent, + }, + referrer, + userAgent, + timestamp: clickedAt.toISOString(), + }); + + const eventTimestamp = date.toISOString(); + + const leadEvent = leadEventSchemaTBWithTimestamp.parse({ + ...clickEvent, + event_id: nanoid(16), + event_name: "Sign up", + customer_id: targetCustomer.id, + timestamp: eventTimestamp, + metadata: "", + }); + + const saleEvent = + type === "sale" && sale + ? saleEventSchemaTBWithTimestamp.parse({ + ...clickEvent, + event_id: nanoid(16), + event_name: sale.eventName ?? "Purchase", + customer_id: targetCustomer.id, + payment_processor: "stripe", + amount: sale.amount, + invoice_id: invoiceId ?? "", + currency: "usd", + timestamp: eventTimestamp, + metadata: "", + }) + : null; + + await Promise.all([ + recordLeadWithTimestamp(leadEvent), + saleEvent ? recordSaleWithTimestamp(saleEvent) : undefined, + ]); + + await queuePartnerCommissionCreation({ + event: + type === "sale" ? CommissionType.sale : CommissionType.lead, + programId: DEMO_PROGRAM_ID, + partnerId: link.partnerId, + linkId: targetLink.id, + customerId: targetCustomer.id, + eventId: saleEvent?.event_id ?? leadEvent.event_id, + quantity: 1, + createdAt: date, + ...(saleEvent && { + amount: saleEvent.amount, + currency: saleEvent.currency, + invoiceId: saleEvent.invoice_id || undefined, + isFirstConversion: firstConversion, + }), + context: { + customer: { + country: targetCustomer.country, + ...(type === "sale" && { signupDate: targetCustomer.createdAt }), + }, + ...(type === "sale" && + saleEvent && { + sale: { + amount: saleEvent.amount, + }, + }), + }, + triggerAggregateDueCommissions: true, + }); + + const totalSales = saleEvent ? 1 : 0; + const totalSaleAmount = saleEvent?.amount ?? 0; + const lastLeadAt = updateLinkStatsForImporter({ + currentTimestamp: targetLink.lastLeadAt, + newTimestamp: date, + }); + const lastConversionAt = saleEvent + ? updateLinkStatsForImporter({ + currentTimestamp: targetLink.lastConversionAt, + newTimestamp: date, + }) + : undefined; + + await prisma.$transaction([ + prisma.link.update({ + where: { id: targetLink.id }, + data: { + clicks: { increment: 1 }, + leads: { increment: 1 }, + lastLeadAt, + ...(firstConversion && + saleEvent && { + conversions: { increment: 1 }, + lastConversionAt, + }), + sales: { increment: totalSales }, + saleAmount: { increment: totalSaleAmount }, + }, + }), + prisma.customer.update({ + where: { id: targetCustomer.id }, + data: { + linkId: targetLink.id, + programId: targetLink.programId, + partnerId: targetLink.partnerId, + clickId: clickEvent.click_id, + clickedAt, + sales: { increment: totalSales }, + saleAmount: { increment: totalSaleAmount }, + ...(type === "sale" && + !targetCustomer.firstSaleAt && { + firstSaleAt: date, + }), + }, + }), + ]); + + await syncPartnerLinksStats({ + partnerId: link.partnerId, + programId: DEMO_PROGRAM_ID, + eventType: type, + }); + + return { + clickId: clickEvent.click_id, + customerId: targetCustomer.id, + }; +} diff --git a/apps/web/lib/api/demo/guard.ts b/apps/web/lib/api/demo/guard.ts new file mode 100644 index 00000000000..43ae0bf60ab --- /dev/null +++ b/apps/web/lib/api/demo/guard.ts @@ -0,0 +1,43 @@ +import { DubApiError } from "@/lib/api/errors"; +import { prefixWorkspaceId } from "@/lib/api/workspaces/workspace-id"; +import { EdgeLinkProps } from "@/lib/planetscale/types"; +import { DEMO_PROGRAM_ID, DEMO_WORKSPACE_ID } from "@dub/utils"; + +export function verifyDemoSecret(req: Request) { + const secret = process.env.DEMO_CLICK_SECRET; + const authorization = req.headers.get("authorization"); + + if (!secret || authorization !== `Bearer ${secret}`) { + throw new DubApiError({ + code: "unauthorized", + message: "Invalid or missing DEMO_CLICK_SECRET.", + }); + } +} + +export function assertDemoLink( + link: Pick | null, +): asserts link is NonNullable { + if (!link) { + throw new DubApiError({ + code: "not_found", + message: "Link not found.", + }); + } + + if ( + prefixWorkspaceId(link.projectId) !== prefixWorkspaceId(DEMO_WORKSPACE_ID) + ) { + throw new DubApiError({ + code: "forbidden", + message: "This endpoint can only record events for the demo workspace.", + }); + } + + if (link.programId && link.programId !== DEMO_PROGRAM_ID) { + throw new DubApiError({ + code: "forbidden", + message: "This endpoint can only record events for the demo program.", + }); + } +} diff --git a/apps/web/lib/tinybird/record-fake-click.ts b/apps/web/lib/tinybird/record-fake-click.ts index 7461969f92b..5a56d2be640 100644 --- a/apps/web/lib/tinybird/record-fake-click.ts +++ b/apps/web/lib/tinybird/record-fake-click.ts @@ -53,7 +53,8 @@ export async function recordFakeClick({ const dummyRequest = new Request(link.url, { headers: new Headers({ "user-agent": - userAgent || "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + toSafeHeaderValue(userAgent) || + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", "x-forwarded-for": "127.0.0.1", "x-vercel-ip-country": country, "x-vercel-ip-country-region": toSafeHeaderValue(customer?.region) || "CA", From 0b250923d504596470d195cc82720439dbeeca5c Mon Sep 17 00:00:00 2001 From: Pedro Ladeira <57876830+pepeladeira@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:07:11 -0300 Subject: [PATCH 5/9] Fix marketplace application form (#4448) Co-authored-by: Steven Tey --- .../[programId]/default-group/route.ts | 32 +++++++++++++++ .../groups/[groupIdOrSlug]/route.ts | 40 ------------------- .../ui/partners/program-application-sheet.tsx | 22 ++-------- 3 files changed, 36 insertions(+), 58 deletions(-) create mode 100644 apps/web/app/(ee)/api/partner-profile/programs/[programId]/default-group/route.ts delete mode 100644 apps/web/app/(ee)/api/partner-profile/programs/[programId]/groups/[groupIdOrSlug]/route.ts diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/default-group/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/default-group/route.ts new file mode 100644 index 00000000000..1e202f8c4be --- /dev/null +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/default-group/route.ts @@ -0,0 +1,32 @@ +import { DubApiError } from "@/lib/api/errors"; +import { withPartnerProfile } from "@/lib/auth/partner"; +import { prisma } from "@/lib/prisma"; +import { + DEFAULT_PARTNER_GROUP, + PartnerProgramGroupSchema, +} from "@/lib/zod/schemas/groups"; +import { NextResponse } from "next/server"; + +// GET /api/partner-profile/programs/[programId]/default-group - get information about a program's default group +export const GET = withPartnerProfile(async ({ params, partner }) => { + const { programId } = params; + + const group = await prisma.partnerGroup.findUnique({ + where: { + programId_slug: { + programId, + slug: DEFAULT_PARTNER_GROUP.slug, + }, + }, + }); + + // should never happen, but just in case + if (!group) { + throw new DubApiError({ + code: "not_found", + message: `Program "${programId}" does not have a default group.`, + }); + } + + return NextResponse.json(PartnerProgramGroupSchema.parse(group)); +}); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/groups/[groupIdOrSlug]/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/groups/[groupIdOrSlug]/route.ts deleted file mode 100644 index 49f516ffc4a..00000000000 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/groups/[groupIdOrSlug]/route.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { DubApiError } from "@/lib/api/errors"; -import { withPartnerProfile } from "@/lib/auth/partner"; -import { prisma } from "@/lib/prisma"; -import { PartnerProgramGroupSchema } from "@/lib/zod/schemas/groups"; -import { NextResponse } from "next/server"; - -// GET /api/partner-profile/programs/[programId]/groups/[groupIdOrSlug] - get information about a program's group -export const GET = withPartnerProfile(async ({ params, partner }) => { - const { programId, groupIdOrSlug } = params; - - const group = await prisma.partnerGroup.findUnique({ - where: { - ...(groupIdOrSlug.startsWith("grp_") - ? { - id: groupIdOrSlug, - } - : { - programId_slug: { - programId, - slug: groupIdOrSlug, - }, - }), - programId, - partners: { - some: { - partnerId: partner.id, - }, - }, - }, - }); - - if (!group) { - throw new DubApiError({ - code: "not_found", - message: `Group "${groupIdOrSlug}" not found.`, - }); - } - - return NextResponse.json(PartnerProgramGroupSchema.parse(group)); -}); diff --git a/apps/web/ui/partners/program-application-sheet.tsx b/apps/web/ui/partners/program-application-sheet.tsx index dd1434fcb08..6ca06cd861a 100644 --- a/apps/web/ui/partners/program-application-sheet.tsx +++ b/apps/web/ui/partners/program-application-sheet.tsx @@ -4,10 +4,7 @@ import { parseActionError } from "@/lib/actions/parse-action-errors"; import { createProgramApplicationAction } from "@/lib/actions/partners/create-program-application"; import usePartnerProfile from "@/lib/swr/use-partner-profile"; import { ProgramEnrollmentProps, ProgramProps } from "@/lib/types"; -import { - DEFAULT_PARTNER_GROUP, - PartnerProgramGroupSchema, -} from "@/lib/zod/schemas/groups"; +import { PartnerProgramGroupSchema } from "@/lib/zod/schemas/groups"; import { createProgramApplicationSchema } from "@/lib/zod/schemas/programs"; import { X } from "@/ui/shared/icons"; import { @@ -55,22 +52,11 @@ function ProgramApplicationSheetContent({ programEnrollment, ...rest }: ProgramApplicationSheetProps) { - const groupIdOrSlug = - programEnrollment?.groupId || - program?.defaultGroupId || - DEFAULT_PARTNER_GROUP.slug; - const { data: group, error: groupError } = useSWR< z.infer - >( - groupIdOrSlug - ? `/api/partner-profile/programs/${program.id}/groups/${groupIdOrSlug}` - : null, - fetcher, - { - keepPreviousData: true, - }, - ); + >(`/api/partner-profile/programs/${program.id}/default-group`, fetcher, { + keepPreviousData: true, + }); return group ? ( Date: Thu, 3 Sep 2026 23:29:00 +0530 Subject: [PATCH 6/9] Avoid ECR Public rate limits when starting QStash in Playwright CI. (#4450) --- .github/workflows/playwright.yaml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/playwright.yaml b/.github/workflows/playwright.yaml index 8b9d91f7174..fcdc6be200e 100644 --- a/.github/workflows/playwright.yaml +++ b/.github/workflows/playwright.yaml @@ -130,10 +130,7 @@ jobs: - name: Start QStash dev server run: | - docker run -d --name qstash-dev \ - --network host \ - public.ecr.aws/upstash/qstash:latest \ - qstash dev + nohup npx --yes @upstash/qstash-cli@2.37.18 dev > /tmp/qstash.log 2>&1 & for i in $(seq 1 30); do if curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080 | grep -qE '^[0-9]{3}$'; then echo "QStash is up" @@ -141,7 +138,8 @@ jobs: fi sleep 1 done - docker logs qstash-dev + echo "QStash failed to start" + cat /tmp/qstash.log exit 1 - name: Configure Tinybird Local From d24567479925b72d7df73136b42f3dfc32987c4a Mon Sep 17 00:00:00 2001 From: Pedro Ladeira <57876830+pepeladeira@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:32:48 -0300 Subject: [PATCH 7/9] Add Axiom tracking to critical webhooks and payout crons (#4325) Co-authored-by: Kiran K Co-authored-by: Steven Tey --- .../api/cron/invoices/retry-failed/route.ts | 148 ++++++++---------- .../cron/payouts/balance-available/route.ts | 12 +- .../cron/payouts/charge-succeeded/route.ts | 12 +- .../api/cron/payouts/payout-failed/route.ts | 12 +- .../api/cron/payouts/payout-paid/route.ts | 12 +- .../(ee)/api/cron/payouts/process/route.ts | 13 +- .../api/cron/payouts/process/updates/route.ts | 16 +- .../cron/payouts/send-stripe-payout/route.ts | 16 +- apps/web/app/(ee)/api/paypal/webhook/route.ts | 5 +- .../api/shopify/integration/webhook/route.ts | 5 +- apps/web/app/(ee)/api/stripe/webhook/route.ts | 9 +- apps/web/app/api/dub/webhook/route.ts | 5 +- apps/web/app/api/resend/webhook/route.ts | 5 +- apps/web/app/api/veriff/webhook/route.ts | 5 +- 14 files changed, 117 insertions(+), 158 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/invoices/retry-failed/route.ts b/apps/web/app/(ee)/api/cron/invoices/retry-failed/route.ts index 1c2320ec4d0..746876cbe67 100644 --- a/apps/web/app/(ee)/api/cron/invoices/retry-failed/route.ts +++ b/apps/web/app/(ee)/api/cron/invoices/retry-failed/route.ts @@ -1,5 +1,4 @@ -import { handleAndReturnErrorResponse } from "@/lib/api/errors"; -import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { withCron } from "@/lib/cron/with-cron"; import { prisma } from "@/lib/prisma"; import { createPaymentIntent } from "@/lib/stripe/create-payment-intent"; import { ACME_WORKSPACE_ID, DUB_WORKSPACE_ID } from "@dub/utils"; @@ -12,93 +11,82 @@ const schema = z.object({ }); // POST /api/cron/invoices/retry-failed -export async function POST(req: Request) { - try { - const rawBody = await req.text(); - - await verifyQstashSignature({ - req, - rawBody, - }); - - const { invoiceId } = schema.parse(JSON.parse(rawBody)); - - const invoice = await prisma.invoice.findUnique({ - where: { - id: invoiceId, - }, - select: { - id: true, - type: true, - status: true, - total: true, - failedAttempts: true, - workspace: { - select: { - id: true, - stripeId: true, - }, +export const POST = withCron(async ({ rawBody }) => { + const { invoiceId } = schema.parse(JSON.parse(rawBody)); + + const invoice = await prisma.invoice.findUnique({ + where: { + id: invoiceId, + }, + select: { + id: true, + type: true, + status: true, + total: true, + failedAttempts: true, + workspace: { + select: { + id: true, + stripeId: true, }, }, - }); + }, + }); - if (!invoice) { - console.log(`Invoice ${invoiceId} not found.`); - return new Response(`Invoice ${invoiceId} not found.`); - } + if (!invoice) { + console.log(`Invoice ${invoiceId} not found.`); + return new Response(`Invoice ${invoiceId} not found.`); + } - if (invoice.status !== "failed") { - console.log(`Invoice ${invoiceId} is not failed.`); - return new Response(`Invoice ${invoiceId} is not failed.`); - } + if (invoice.status !== "failed") { + console.log(`Invoice ${invoiceId} is not failed.`); + return new Response(`Invoice ${invoiceId} is not failed.`); + } - if (invoice.failedAttempts >= 3) { - console.log(`Invoice ${invoiceId} has reached max failed attempts of 3.`); - return new Response( - `Invoice ${invoiceId} has reached max failed attempts of 3.`, - ); - } + if (invoice.failedAttempts >= 3) { + console.log(`Invoice ${invoiceId} has reached max failed attempts of 3.`); + return new Response( + `Invoice ${invoiceId} has reached max failed attempts of 3.`, + ); + } - if (invoice.type !== "domainRenewal") { - console.log(`Only domain renewals can be retried at this time.`); - return new Response(`Only domain renewals can be retried at this time.`); - } + if (invoice.type !== "domainRenewal") { + console.log(`Only domain renewals can be retried at this time.`); + return new Response(`Only domain renewals can be retried at this time.`); + } - let { workspace } = invoice; + let { workspace } = invoice; - // If Acme workspace, use Dub workspace stripeId - if (workspace.id === ACME_WORKSPACE_ID) { - const dubWorkspace = await prisma.project.findUniqueOrThrow({ - where: { - id: DUB_WORKSPACE_ID, - }, - select: { - stripeId: true, - }, - }); + // If Acme workspace, use Dub workspace stripeId + if (workspace.id === ACME_WORKSPACE_ID) { + const dubWorkspace = await prisma.project.findUniqueOrThrow({ + where: { + id: DUB_WORKSPACE_ID, + }, + select: { + stripeId: true, + }, + }); - workspace = { - ...workspace, - stripeId: dubWorkspace.stripeId, - }; - } + workspace = { + ...workspace, + stripeId: dubWorkspace.stripeId, + }; + } - if (!workspace.stripeId) { - console.log(`Workspace ${workspace.id} has no stripeId.`); - return new Response(`Workspace ${workspace.id} has no stripeId.`); - } + if (!workspace.stripeId) { + console.log(`Workspace ${workspace.id} has no stripeId.`); + return new Response(`Workspace ${workspace.id} has no stripeId.`); + } - await createPaymentIntent({ - stripeId: workspace.stripeId, - amount: invoice.total, - invoiceId: invoice.id, - statementDescriptor: "DUB.CO DOMAIN RENEWAL", - description: `Domain renewal invoice (${invoice.id})`, - idempotencyKey: `${invoice.id}-${invoice.failedAttempts}`, - }); + await createPaymentIntent({ + stripeId: workspace.stripeId, + amount: invoice.total, + invoiceId: invoice.id, + statementDescriptor: "DUB.CO DOMAIN RENEWAL", + description: `Domain renewal invoice (${invoice.id})`, + idempotencyKey: `${invoice.id}-${invoice.failedAttempts}`, + }); - return new Response(`Retrying invoice charge ${invoice.id}...`); - } catch (error) { - return handleAndReturnErrorResponse(error); - } -} + return new Response(`Retrying invoice charge ${invoice.id}...`); +}); diff --git a/apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts b/apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts index 4b5cff4bdeb..274af4aad09 100644 --- a/apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts +++ b/apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts @@ -1,7 +1,6 @@ -import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { BANK_ACCOUNT_STATUS_DESCRIPTIONS } from "@/lib/constants/payouts"; import { qstash } from "@/lib/cron"; -import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { withCron } from "@/lib/cron/with-cron"; import { getPartnerBankAccount } from "@/lib/partners/get-partner-bank-account"; import { prisma } from "@/lib/prisma"; import { stripe } from "@/lib/stripe"; @@ -24,11 +23,8 @@ const payloadSchema = z.object({ }); // POST /api/cron/payouts/balance-available -export async function POST(req: Request) { +export const POST = withCron(async ({ rawBody }) => { try { - const rawBody = await req.text(); - await verifyQstashSignature({ req, rawBody }); - const { stripeAccount } = payloadSchema.parse(JSON.parse(rawBody)); const partner = await prisma.partner.findUnique({ @@ -222,6 +218,6 @@ export async function POST(req: Request) { type: "errors", }); - return handleAndReturnErrorResponse(error); + throw error; } -} +}); diff --git a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts index 3498b1b2538..37b5e0af77b 100644 --- a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts +++ b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts @@ -1,5 +1,4 @@ -import { handleAndReturnErrorResponse } from "@/lib/api/errors"; -import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { withCron } from "@/lib/cron/with-cron"; import { prisma } from "@/lib/prisma"; import { log } from "@dub/utils"; import { PartnerPayoutMethod } from "@prisma/client"; @@ -21,11 +20,8 @@ const payloadSchema = z.object({ // POST /api/cron/payouts/charge-succeeded // This route is used to process the charge-succeeded event from Stripe. // We're intentionally offloading this to a cron job so we can return a 200 to Stripe immediately. -export async function POST(req: Request) { +export const POST = withCron(async ({ rawBody }) => { try { - const rawBody = await req.text(); - await verifyQstashSignature({ req, rawBody }); - const { invoiceId } = payloadSchema.parse(JSON.parse(rawBody)); const invoice = await prisma.invoice.findUnique({ @@ -131,6 +127,6 @@ export async function POST(req: Request) { type: "cron", }); - return handleAndReturnErrorResponse(error); + throw error; } -} +}); diff --git a/apps/web/app/(ee)/api/cron/payouts/payout-failed/route.ts b/apps/web/app/(ee)/api/cron/payouts/payout-failed/route.ts index f7a0a144f51..5c2c2957c89 100644 --- a/apps/web/app/(ee)/api/cron/payouts/payout-failed/route.ts +++ b/apps/web/app/(ee)/api/cron/payouts/payout-failed/route.ts @@ -1,5 +1,4 @@ -import { handleAndReturnErrorResponse } from "@/lib/api/errors"; -import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { withCron } from "@/lib/cron/with-cron"; import { getPartnerBankAccount } from "@/lib/partners/get-partner-bank-account"; import { prisma } from "@/lib/prisma"; import { sendEmail } from "@dub/email"; @@ -19,11 +18,8 @@ const payloadSchema = z.object({ }); // POST /api/cron/payouts/payout-failed -export async function POST(req: Request) { +export const POST = withCron(async ({ rawBody }) => { try { - const rawBody = await req.text(); - await verifyQstashSignature({ req, rawBody }); - const { stripeAccount, stripePayout } = payloadSchema.parse( JSON.parse(rawBody), ); @@ -86,6 +82,6 @@ export async function POST(req: Request) { type: "errors", }); - return handleAndReturnErrorResponse(error); + throw error; } -} +}); diff --git a/apps/web/app/(ee)/api/cron/payouts/payout-paid/route.ts b/apps/web/app/(ee)/api/cron/payouts/payout-paid/route.ts index e9ed1d10a48..9c97d263eed 100644 --- a/apps/web/app/(ee)/api/cron/payouts/payout-paid/route.ts +++ b/apps/web/app/(ee)/api/cron/payouts/payout-paid/route.ts @@ -1,5 +1,4 @@ -import { handleAndReturnErrorResponse } from "@/lib/api/errors"; -import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { withCron } from "@/lib/cron/with-cron"; import { prisma } from "@/lib/prisma"; import { sendEmail } from "@dub/email"; import PartnerPayoutWithdrawalCompleted from "@dub/email/templates/partner-payout-withdrawal-completed"; @@ -19,11 +18,8 @@ const payloadSchema = z.object({ }); // POST /api/cron/payouts/payout-paid -export async function POST(req: Request) { +export const POST = withCron(async ({ rawBody }) => { try { - const rawBody = await req.text(); - await verifyQstashSignature({ req, rawBody }); - const { stripeAccount, stripePayout } = payloadSchema.parse( JSON.parse(rawBody), ); @@ -83,6 +79,6 @@ export async function POST(req: Request) { type: "errors", }); - return handleAndReturnErrorResponse(error); + throw error; } -} +}); diff --git a/apps/web/app/(ee)/api/cron/payouts/process/route.ts b/apps/web/app/(ee)/api/cron/payouts/process/route.ts index ba3385a3119..4a877aea41d 100644 --- a/apps/web/app/(ee)/api/cron/payouts/process/route.ts +++ b/apps/web/app/(ee)/api/cron/payouts/process/route.ts @@ -1,5 +1,4 @@ -import { handleAndReturnErrorResponse } from "@/lib/api/errors"; -import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { withCron } from "@/lib/cron/with-cron"; import { CUTOFF_PERIOD_ENUM } from "@/lib/partners/cutoff-period"; import { prisma } from "@/lib/prisma"; import { log } from "@dub/utils"; @@ -24,12 +23,8 @@ const processPayoutsCronSchema = z.object({ // POST /api/cron/payouts/process // This route is used to process payouts for a given invoice // we're intentionally offloading this to a cron job to avoid blocking the main thread -export async function POST(req: Request) { +export const POST = withCron(async ({ rawBody }) => { try { - const rawBody = await req.text(); - - await verifyQstashSignature({ req, rawBody }); - const { workspaceId, userId, @@ -107,6 +102,6 @@ export async function POST(req: Request) { mention: true, }); - return handleAndReturnErrorResponse(error); + throw error; } -} +}); diff --git a/apps/web/app/(ee)/api/cron/payouts/process/updates/route.ts b/apps/web/app/(ee)/api/cron/payouts/process/updates/route.ts index a7628e0162e..493ee08354e 100644 --- a/apps/web/app/(ee)/api/cron/payouts/process/updates/route.ts +++ b/apps/web/app/(ee)/api/cron/payouts/process/updates/route.ts @@ -1,7 +1,6 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; -import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { qstash } from "@/lib/cron"; -import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { withCron } from "@/lib/cron/with-cron"; import { prisma } from "@/lib/prisma"; import { sendBatchEmail } from "@dub/email"; import PartnerPayoutConfirmed from "@dub/email/templates/partner-payout-confirmed"; @@ -20,15 +19,8 @@ const BATCH_SIZE = 100; // POST /api/cron/payouts/process/updates // Recursive cron job to handle side effects of the `cron/payouts/process` job (recordAuditLog, sendBatchEmails) -export async function POST(req: Request) { +export const POST = withCron(async ({ rawBody }) => { try { - const rawBody = await req.text(); - - await verifyQstashSignature({ - req, - rawBody, - }); - const { invoiceId, startingAfter } = payloadSchema.parse( JSON.parse(rawBody), ); @@ -149,6 +141,6 @@ export async function POST(req: Request) { mention: true, }); - return handleAndReturnErrorResponse(error); + throw error; } -} +}); diff --git a/apps/web/app/(ee)/api/cron/payouts/send-stripe-payout/route.ts b/apps/web/app/(ee)/api/cron/payouts/send-stripe-payout/route.ts index d84321d36fd..5520cab723c 100644 --- a/apps/web/app/(ee)/api/cron/payouts/send-stripe-payout/route.ts +++ b/apps/web/app/(ee)/api/cron/payouts/send-stripe-payout/route.ts @@ -1,5 +1,4 @@ -import { handleAndReturnErrorResponse } from "@/lib/api/errors"; -import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { withCron } from "@/lib/cron/with-cron"; import { createStablecoinPayout } from "@/lib/partners/create-stablecoin-payout"; import { createStripeTransfer } from "@/lib/partners/create-stripe-transfer"; import { prisma } from "@/lib/prisma"; @@ -16,15 +15,8 @@ const payloadSchema = z.object({ }); // POST /api/cron/payouts/send-stripe-payout -export async function POST(req: Request) { +export const POST = withCron(async ({ rawBody }) => { try { - const rawBody = await req.text(); - - await verifyQstashSignature({ - req, - rawBody, - }); - const { partnerId, invoiceId, chargeId } = payloadSchema.parse( JSON.parse(rawBody), ); @@ -76,6 +68,6 @@ export async function POST(req: Request) { mention: true, }); - return handleAndReturnErrorResponse(error); + throw error; } -} +}); diff --git a/apps/web/app/(ee)/api/paypal/webhook/route.ts b/apps/web/app/(ee)/api/paypal/webhook/route.ts index 3d7a0a25d7b..12ab8d0ddc6 100644 --- a/apps/web/app/(ee)/api/paypal/webhook/route.ts +++ b/apps/web/app/(ee)/api/paypal/webhook/route.ts @@ -1,3 +1,4 @@ +import { withAxiom } from "@/lib/axiom/server"; import { log } from "@dub/utils"; import { payoutsItemFailed } from "./payouts-item-failed"; import { payoutsItemSucceeded } from "./payouts-item-succeeded"; @@ -17,7 +18,7 @@ const relevantEvents = new Set([ ]); // POST /api/paypal/webhook – Listen to Paypal webhook events -export const POST = async (req: Request) => { +export const POST = withAxiom(async (req: Request) => { const rawBody = await req.text(); const headers = req.headers; @@ -69,4 +70,4 @@ export const POST = async (req: Request) => { } return new Response("OK"); -}; +}); diff --git a/apps/web/app/(ee)/api/shopify/integration/webhook/route.ts b/apps/web/app/(ee)/api/shopify/integration/webhook/route.ts index f51475c3199..41813984ed9 100644 --- a/apps/web/app/(ee)/api/shopify/integration/webhook/route.ts +++ b/apps/web/app/(ee)/api/shopify/integration/webhook/route.ts @@ -1,5 +1,6 @@ import { captureWebhookLog } from "@/lib/api-logs/capture-webhook-log"; import { isLocalDev } from "@/lib/api/environment"; +import { withAxiom } from "@/lib/axiom/server"; import { prisma } from "@/lib/prisma"; import { waitUntil } from "@vercel/functions"; import { logAndRespond } from "app/(ee)/api/cron/utils"; @@ -21,7 +22,7 @@ const relevantTopics = new Set([ ]); // POST /api/shopify/integration/webhook – Listen to Shopify webhook events -export const POST = async (req: Request) => { +export const POST = withAxiom(async (req: Request) => { const startTime = Date.now(); const data = await req.text(); const headers = req.headers; @@ -150,4 +151,4 @@ export const POST = async (req: Request) => { } return new Response(response); -}; +}); diff --git a/apps/web/app/(ee)/api/stripe/webhook/route.ts b/apps/web/app/(ee)/api/stripe/webhook/route.ts index 6ca0c1561bf..153ceca9ad7 100644 --- a/apps/web/app/(ee)/api/stripe/webhook/route.ts +++ b/apps/web/app/(ee)/api/stripe/webhook/route.ts @@ -1,3 +1,4 @@ +import { withAxiom } from "@/lib/axiom/server"; import { stripe } from "@/lib/stripe"; import { log } from "@dub/utils"; import Stripe from "stripe"; @@ -29,13 +30,15 @@ const relevantEvents = new Set([ ]); // POST /api/stripe/webhook – listen to Stripe webhooks -export const POST = async (req: Request) => { +export const POST = withAxiom(async (req: Request) => { const buf = await req.text(); const sig = req.headers.get("Stripe-Signature") as string; const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; let event: Stripe.Event; try { - if (!sig || !webhookSecret) return; + if (!sig || !webhookSecret) { + return logAndRespond("Invalid request", { status: 400 }); + } event = stripe.webhooks.constructEvent(buf, sig, webhookSecret); } catch (err: any) { console.log(`❌ Error message: ${err.message}`); @@ -99,4 +102,4 @@ export const POST = async (req: Request) => { } return logAndRespond(`[${event.type}]: ${response}`); -}; +}); diff --git a/apps/web/app/api/dub/webhook/route.ts b/apps/web/app/api/dub/webhook/route.ts index 630365f3fc9..9b2cfc974c1 100644 --- a/apps/web/app/api/dub/webhook/route.ts +++ b/apps/web/app/api/dub/webhook/route.ts @@ -1,10 +1,11 @@ +import { withAxiom } from "@/lib/axiom/server"; import { webhookPayloadSchema } from "@/lib/webhook/schemas"; import crypto from "crypto"; import { leadCreated } from "./lead-created"; import { saleCreated } from "./sale-created"; // POST /api/dub/webhook - receive webhooks for Dub -export const POST = async (req: Request) => { +export const POST = withAxiom(async (req: Request) => { const body = await req.json(); const { event, data } = webhookPayloadSchema.parse(body); @@ -35,4 +36,4 @@ export const POST = async (req: Request) => { } return new Response(response); -}; +}); diff --git a/apps/web/app/api/resend/webhook/route.ts b/apps/web/app/api/resend/webhook/route.ts index ef479c51372..db8ffb5b864 100644 --- a/apps/web/app/api/resend/webhook/route.ts +++ b/apps/web/app/api/resend/webhook/route.ts @@ -1,3 +1,4 @@ +import { withAxiom } from "@/lib/axiom/server"; import { NextResponse } from "next/server"; import { Webhook } from "svix"; import { emailBounced } from "./email-bounced"; @@ -7,7 +8,7 @@ import { emailOpened } from "./email-opened"; const webhookSecret = process.env.RESEND_WEBHOOK_SECRET!; // POST /api/resend/webhook – listen to Resend webhooks -export const POST = async (req: Request) => { +export const POST = withAxiom(async (req: Request) => { const rawBody = await req.text(); const webhook = new Webhook(webhookSecret); @@ -33,4 +34,4 @@ export const POST = async (req: Request) => { } return NextResponse.json({ message: "Webhook processed." }); -}; +}); diff --git a/apps/web/app/api/veriff/webhook/route.ts b/apps/web/app/api/veriff/webhook/route.ts index 0dd3c215261..b560aeb05d1 100644 --- a/apps/web/app/api/veriff/webhook/route.ts +++ b/apps/web/app/api/veriff/webhook/route.ts @@ -1,10 +1,11 @@ +import { withAxiom } from "@/lib/axiom/server"; import { logAndRespond } from "app/(ee)/api/cron/utils"; import crypto from "crypto"; import { handleDecisionEvent } from "./handle-decision-event"; import { handleSessionEvent } from "./handle-session-event"; // POST /api/veriff/webhook -export const POST = async (req: Request) => { +export const POST = withAxiom(async (req: Request) => { const rawBody = await req.text(); const signature = req.headers.get("x-hmac-signature"); @@ -62,4 +63,4 @@ export const POST = async (req: Request) => { } else { return await handleSessionEvent(body); } -}; +}); From 7de765e40d5b51cd9e7c12b5bf704ae336752bf0 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira <57876830+pepeladeira@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:33:24 -0300 Subject: [PATCH 8/9] Format AI payout amounts as USD instead of cents (#4384) Co-authored-by: Steven Tey --- apps/web/lib/ai/build-system-prompt.ts | 3 ++- apps/web/lib/ai/create-support-ticket.ts | 5 +++- apps/web/lib/ai/get-program-performance.ts | 30 +++++++++++++++++----- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/apps/web/lib/ai/build-system-prompt.ts b/apps/web/lib/ai/build-system-prompt.ts index 3aa53f73b28..e5a9d47efbb 100644 --- a/apps/web/lib/ai/build-system-prompt.ts +++ b/apps/web/lib/ai/build-system-prompt.ts @@ -18,6 +18,7 @@ const CONTEXT_SYSTEM_PROMPTS: Record = { partners: `You are a helpful Dub Partners support assistant helping affiliate partners with their programs. Focus on: payouts, referral tracking, commission structure, partner links, bank account setup, payout countries, program enrollment, and affiliate performance. When the user asks about their specific program data — such as earnings, commissions, payouts, minimum payout amount, holding period, or payout history — call getProgramPerformance with the program's ID before answering. Use this real data in your response instead of guessing or citing generic documentation. + Money amounts from getProgramPerformance are already formatted USD strings (e.g. $10). Never treat them as cents or multiply/divide. When a user has a payout dispute, tax compliance issue, or a problem that can't be resolved through documentation, first call requestSupportTicket (to show them an upload form), then after the user confirms, call createSupportTicket. Always try to provide the program's support email for program-specific issues.`, }; @@ -39,7 +40,7 @@ const BASE_SYSTEM_PROMPT = ` `.trim(); const PARTNERS_PAYOUT_PROMPT = ` - For any partner payout question — pending, timing, schedule, or a failed/retry/resend request — always call getProgramPerformance first to get real data (payout status, holding period, minimum payout threshold). Then branch by status: + For any partner payout question — pending, timing, schedule, or a failed/retry/resend request — always call getProgramPerformance first to get real data (payout status, holding period, minimum payout threshold). Money amounts from getProgramPerformance are already formatted USD (e.g. $10) — never treat them as cents or multiply/divide. Then branch by status: Status is pending, processing, processed, sent, or completed (i.e. NOT failed): - Explain using the real data from getProgramPerformance. Call findRelevantDocs too if helpful for general context. diff --git a/apps/web/lib/ai/create-support-ticket.ts b/apps/web/lib/ai/create-support-ticket.ts index 419b6d72003..643725eeec2 100644 --- a/apps/web/lib/ai/create-support-ticket.ts +++ b/apps/web/lib/ai/create-support-ticket.ts @@ -224,7 +224,10 @@ async function getPriorityAndMetadata( ? { "Program Support Email": program.supportEmail } : {}), "Program Holding Period Days": holdingPeriodDays.toString(), - "Program Min Payout Amount": program.minPayoutAmount.toString(), + "Program Min Payout Amount": currencyFormatter( + program.minPayoutAmount, + { trailingZeroDisplay: "stripIfInteger" }, + ), "Partner Lifetime Payouts": currencyFormatter(partnerLifetimePayouts), }); } diff --git a/apps/web/lib/ai/get-program-performance.ts b/apps/web/lib/ai/get-program-performance.ts index 20f3a638ea3..505ac9530a9 100644 --- a/apps/web/lib/ai/get-program-performance.ts +++ b/apps/web/lib/ai/get-program-performance.ts @@ -1,9 +1,13 @@ import { prisma } from "@/lib/prisma"; +import { currencyFormatter } from "@dub/utils"; import { CommissionStatus, PayoutStatus } from "@prisma/client"; import { tool } from "ai"; import { z } from "zod"; import { getSession } from "../auth/utils"; +const formatUsd = (valueInCents: number) => + currencyFormatter(valueInCents, { trailingZeroDisplay: "stripIfInteger" }); + const programPerformanceSchema = z.object({ program: z.object({ name: z.string().describe("The name of the program."), @@ -13,9 +17,9 @@ const programPerformanceSchema = z.object({ "The support email of the program. Useful for letting the user know how to contact the program's support team.", ), minPayoutAmount: z - .number() + .string() .describe( - "The minimum payout amount for the program in USD cents. If the partner's earnings are less than this amount, it will not be eligible for payout.", + 'The minimum payout amount for the program, already formatted as USD (e.g. "$10"). If the partner\'s earnings are less than this amount, it will not be eligible for payout.', ), }), commissions: z.array( @@ -23,12 +27,18 @@ const programPerformanceSchema = z.object({ status: z .enum(CommissionStatus) .describe("The status of the commission."), - earningsInCents: z.number().describe("Total earnings in USD cents"), + earnings: z + .string() + .describe('Total earnings, already formatted as USD (e.g. "$10").'), }), ), payouts: z.array( z.object({ - amount: z.number().describe("The amount of the payout in USD cents."), + amount: z + .string() + .describe( + 'The amount of the payout, already formatted as USD (e.g. "$10").', + ), status: z.enum(PayoutStatus).describe("The status of the payout."), stripePayoutTraceId: z .string() @@ -118,16 +128,22 @@ export const getProgramPerformanceTool = tool({ ]); const data = programPerformanceSchema.parse({ - program: programEnrollment.program, + program: { + ...programEnrollment.program, + minPayoutAmount: formatUsd(programEnrollment.program.minPayoutAmount), + }, holdingPeriodDays: programEnrollment.partnerGroup?.holdingPeriodDays ?? programEnrollment.program.groups[0]?.holdingPeriodDays ?? 0, commissions: commissions.map((commission) => ({ status: commission.status, - earningsInCents: commission._sum.earnings, + earnings: formatUsd(commission._sum.earnings ?? 0), + })), + payouts: payouts.map((payout) => ({ + ...payout, + amount: formatUsd(payout.amount), })), - payouts, }); return data; From 6884316f53104c0533936c81a05281feac43c23d Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 4 Sep 2026 05:27:57 +0530 Subject: [PATCH 9/9] fix: use constant-time comparison for Shopify and HubSpot webhook signatures (#4449) Co-authored-by: Cursor Agent Co-authored-by: Steven Tey --- .../web/app/(ee)/api/hubspot/webhook/route.ts | 5 ++- .../webhook/verify-webhook-signature.ts | 18 +-------- .../api/shopify/integration/webhook/route.ts | 3 +- apps/web/app/api/dub/webhook/route.ts | 3 +- .../oauth/token/exchange-code-for-token.ts | 4 +- .../api/oauth/token/refresh-access-token.ts | 4 +- apps/web/app/api/veriff/webhook/route.ts | 24 ++--------- apps/web/lib/email/unsubscribe-token.ts | 10 ++--- .../lib/integrations/slack/verify-request.ts | 3 +- apps/web/lib/webhook/timing-safe-compare.ts | 40 +++++++++++++++++++ 10 files changed, 63 insertions(+), 51 deletions(-) create mode 100644 apps/web/lib/webhook/timing-safe-compare.ts diff --git a/apps/web/app/(ee)/api/hubspot/webhook/route.ts b/apps/web/app/(ee)/api/hubspot/webhook/route.ts index 7e2b4f49b77..1f1f814d93b 100644 --- a/apps/web/app/(ee)/api/hubspot/webhook/route.ts +++ b/apps/web/app/(ee)/api/hubspot/webhook/route.ts @@ -1,6 +1,7 @@ import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; import { withAxiom } from "@/lib/axiom/server"; import { enqueueBatchJobs } from "@/lib/cron/enqueue-batch-jobs"; +import { timingSafeCompare } from "@/lib/webhook/timing-safe-compare"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import crypto from "crypto"; import { logAndRespond } from "../../cron/utils"; @@ -35,8 +36,8 @@ export const POST = withAxiom(async (req) => { .update(sourceString) .digest("hex"); - // Compare with provided signature - if (signature !== expectedHash) { + // Compare with provided signature using constant-time comparison + if (!timingSafeCompare(signature, expectedHash)) { throw new DubApiError({ code: "unauthorized", message: "Invalid webhook signature.", diff --git a/apps/web/app/(ee)/api/intercom/webhook/verify-webhook-signature.ts b/apps/web/app/(ee)/api/intercom/webhook/verify-webhook-signature.ts index eea03538519..9152b0136c8 100644 --- a/apps/web/app/(ee)/api/intercom/webhook/verify-webhook-signature.ts +++ b/apps/web/app/(ee)/api/intercom/webhook/verify-webhook-signature.ts @@ -1,4 +1,5 @@ import { DubApiError } from "@/lib/api/errors"; +import { timingSafeCompare } from "@/lib/webhook/timing-safe-compare"; import crypto from "crypto"; const INTERCOM_CLIENT_SECRET = process.env.INTERCOM_CLIENT_SECRET || ""; @@ -51,22 +52,7 @@ export async function verifyIntercomWebhookSignature( }); } - const providedBuffer = Buffer.from(providedSignature, "utf8"); - const expectedBuffer = Buffer.from(expectedSignature, "utf8"); - - if (providedBuffer.length !== expectedBuffer.length) { - throw new DubApiError({ - code: "unauthorized", - message: "Invalid webhook signature.", - }); - } - - const isSignatureValid = crypto.timingSafeEqual( - Uint8Array.from(providedBuffer), - Uint8Array.from(expectedBuffer), - ); - - if (!isSignatureValid) { + if (!timingSafeCompare(providedSignature, expectedSignature)) { throw new DubApiError({ code: "unauthorized", message: "Invalid webhook signature.", diff --git a/apps/web/app/(ee)/api/shopify/integration/webhook/route.ts b/apps/web/app/(ee)/api/shopify/integration/webhook/route.ts index 41813984ed9..7a41d1def66 100644 --- a/apps/web/app/(ee)/api/shopify/integration/webhook/route.ts +++ b/apps/web/app/(ee)/api/shopify/integration/webhook/route.ts @@ -2,6 +2,7 @@ import { captureWebhookLog } from "@/lib/api-logs/capture-webhook-log"; import { isLocalDev } from "@/lib/api/environment"; import { withAxiom } from "@/lib/axiom/server"; import { prisma } from "@/lib/prisma"; +import { timingSafeCompare } from "@/lib/webhook/timing-safe-compare"; import { waitUntil } from "@vercel/functions"; import { logAndRespond } from "app/(ee)/api/cron/utils"; import crypto from "crypto"; @@ -42,7 +43,7 @@ export const POST = withAxiom(async (req: Request) => { .update(data, "utf8") .digest("base64"); - if (generatedSignature !== signature) { + if (!timingSafeCompare(signature, generatedSignature)) { return logAndRespond( "Shopify webhook signature verification failed. Skipping...", { diff --git a/apps/web/app/api/dub/webhook/route.ts b/apps/web/app/api/dub/webhook/route.ts index 9b2cfc974c1..7dc78bf3be5 100644 --- a/apps/web/app/api/dub/webhook/route.ts +++ b/apps/web/app/api/dub/webhook/route.ts @@ -1,5 +1,6 @@ import { withAxiom } from "@/lib/axiom/server"; import { webhookPayloadSchema } from "@/lib/webhook/schemas"; +import { timingSafeCompare } from "@/lib/webhook/timing-safe-compare"; import crypto from "crypto"; import { leadCreated } from "./lead-created"; import { saleCreated } from "./sale-created"; @@ -20,7 +21,7 @@ export const POST = withAxiom(async (req: Request) => { .update(JSON.stringify(body)) .digest("hex"); - if (webhookSignature !== computedSignature) { + if (!timingSafeCompare(webhookSignature, computedSignature)) { return new Response("Invalid signature", { status: 400 }); } diff --git a/apps/web/app/api/oauth/token/exchange-code-for-token.ts b/apps/web/app/api/oauth/token/exchange-code-for-token.ts index 97d38373f84..ea3d92aca75 100644 --- a/apps/web/app/api/oauth/token/exchange-code-for-token.ts +++ b/apps/web/app/api/oauth/token/exchange-code-for-token.ts @@ -5,6 +5,7 @@ import { hashToken } from "@/lib/auth"; import { installIntegration } from "@/lib/integrations/install"; import { generateRandomName } from "@/lib/names"; import { prisma } from "@/lib/prisma"; +import { timingSafeCompare } from "@/lib/webhook/timing-safe-compare"; import { authCodeExchangeSchema } from "@/lib/zod/schemas/oauth"; import { waitUntil } from "@vercel/functions"; import { NextRequest } from "next/server"; @@ -100,7 +101,8 @@ export const exchangeAuthCodeForToken = async ( }); } - if (app.hashedClientSecret !== (await hashToken(clientSecret))) { + const hashedClientSecret = await hashToken(clientSecret); + if (!timingSafeCompare(hashedClientSecret, app.hashedClientSecret)) { throw new DubApiError({ code: "unauthorized", message: "Invalid client_secret", diff --git a/apps/web/app/api/oauth/token/refresh-access-token.ts b/apps/web/app/api/oauth/token/refresh-access-token.ts index 9a98ce21cad..c19b029a9b8 100644 --- a/apps/web/app/api/oauth/token/refresh-access-token.ts +++ b/apps/web/app/api/oauth/token/refresh-access-token.ts @@ -4,6 +4,7 @@ import { createToken } from "@/lib/api/oauth/utils"; import { hashToken } from "@/lib/auth"; import { generateRandomName } from "@/lib/names"; import { prisma } from "@/lib/prisma"; +import { timingSafeCompare } from "@/lib/webhook/timing-safe-compare"; import { refreshTokenSchema } from "@/lib/zod/schemas/oauth"; import { NextRequest } from "next/server"; import * as z from "zod/v4"; @@ -68,7 +69,8 @@ export const refreshAccessToken = async ( }); } - if (oAuthApp.hashedClientSecret !== (await hashToken(clientSecret))) { + const hashedClientSecret = await hashToken(clientSecret); + if (!timingSafeCompare(hashedClientSecret, oAuthApp.hashedClientSecret)) { throw new DubApiError({ code: "unauthorized", message: "Invalid client_secret", diff --git a/apps/web/app/api/veriff/webhook/route.ts b/apps/web/app/api/veriff/webhook/route.ts index b560aeb05d1..54f71df5e31 100644 --- a/apps/web/app/api/veriff/webhook/route.ts +++ b/apps/web/app/api/veriff/webhook/route.ts @@ -1,4 +1,5 @@ import { withAxiom } from "@/lib/axiom/server"; +import { timingSafeCompare } from "@/lib/webhook/timing-safe-compare"; import { logAndRespond } from "app/(ee)/api/cron/utils"; import crypto from "crypto"; import { handleDecisionEvent } from "./handle-decision-event"; @@ -18,17 +19,7 @@ export const POST = withAxiom(async (req: Request) => { const expectedApiKey = process.env.VERIFF_API_KEY; - if (!expectedApiKey || !authClient) { - return logAndRespond("Invalid auth client.", { status: 401 }); - } - - const authClientBuffer = Uint8Array.from(Buffer.from(authClient)); - const expectedApiKeyBuffer = Uint8Array.from(Buffer.from(expectedApiKey)); - - if ( - authClientBuffer.length !== expectedApiKeyBuffer.length || - !crypto.timingSafeEqual(authClientBuffer, expectedApiKeyBuffer) - ) { + if (!expectedApiKey || !timingSafeCompare(authClient, expectedApiKey)) { return logAndRespond("Invalid auth client.", { status: 401 }); } @@ -43,16 +34,7 @@ export const POST = withAxiom(async (req: Request) => { .update(rawBody) .digest("hex"); - const computedSignatureBuffer = Uint8Array.from( - Buffer.from(computedSignature), - ); - const signatureBuffer = Uint8Array.from(Buffer.from(signature)); - - const isSignatureValid = - computedSignatureBuffer.length === signatureBuffer.length && - crypto.timingSafeEqual(computedSignatureBuffer, signatureBuffer); - - if (!isSignatureValid) { + if (!timingSafeCompare(signature, computedSignature)) { return logAndRespond("Invalid signature.", { status: 400 }); } diff --git a/apps/web/lib/email/unsubscribe-token.ts b/apps/web/lib/email/unsubscribe-token.ts index ce597e7b9e8..c2ca8dd9d94 100644 --- a/apps/web/lib/email/unsubscribe-token.ts +++ b/apps/web/lib/email/unsubscribe-token.ts @@ -1,4 +1,5 @@ -import { createHmac, timingSafeEqual } from "crypto"; +import { timingSafeCompare } from "@/lib/webhook/timing-safe-compare"; +import { createHmac } from "crypto"; const TOKEN_SECRET = process.env.UNSUBSCRIBE_TOKEN_SECRET || process.env.NEXTAUTH_SECRET; @@ -41,12 +42,7 @@ export function verifyUnsubscribeToken(token: string): string | null { .digest("hex") .slice(0, 24); - if ( - !timingSafeEqual( - Uint8Array.from(Buffer.from(signature)), - Uint8Array.from(Buffer.from(expectedSignature)), - ) - ) { + if (!timingSafeCompare(signature, expectedSignature)) { return null; } diff --git a/apps/web/lib/integrations/slack/verify-request.ts b/apps/web/lib/integrations/slack/verify-request.ts index bbe2c342f87..bf51a60cf7d 100644 --- a/apps/web/lib/integrations/slack/verify-request.ts +++ b/apps/web/lib/integrations/slack/verify-request.ts @@ -1,3 +1,4 @@ +import { timingSafeCompare } from "@/lib/webhook/timing-safe-compare"; import { createHmac } from "crypto"; interface SlackRequestVerificationOptions { @@ -63,7 +64,7 @@ export const verifySlackSignature = async (req: Request, body: string) => { hmac.update(`${signatureVersion}:${requestTimestampSec}:${options.body}`); const expectedSignature = hmac.digest("hex"); - if (!signatureHash || signatureHash !== expectedSignature) { + if (!timingSafeCompare(signatureHash, expectedSignature)) { throw new Error(`${verifyErrorPrefix}: signature mismatch`); } }; diff --git a/apps/web/lib/webhook/timing-safe-compare.ts b/apps/web/lib/webhook/timing-safe-compare.ts new file mode 100644 index 00000000000..fc30d65d45b --- /dev/null +++ b/apps/web/lib/webhook/timing-safe-compare.ts @@ -0,0 +1,40 @@ +import crypto from "crypto"; + +/** + * Performs constant-time comparison of two strings to prevent timing attacks. + * + * This function compares two strings in a way that takes the same amount of time + * regardless of where the strings differ, mitigating timing side-channel attacks + * (CWE-208). + * + * Use this for comparing security-sensitive values like: + * - Webhook signatures + * - HMAC digests + * - API keys + * - Authentication tokens + * + * @param provided - The value provided by the client/request + * @param expected - The expected/computed value + * @returns true if the strings match, false otherwise + */ +export function timingSafeCompare( + provided: string | null | undefined, + expected: string, +): boolean { + if (!provided) { + return false; + } + + const providedBuffer = Buffer.from(provided, "utf8"); + const expectedBuffer = Buffer.from(expected, "utf8"); + + // Length check before constant-time comparison + if (providedBuffer.length !== expectedBuffer.length) { + return false; + } + + return crypto.timingSafeEqual( + Uint8Array.from(providedBuffer), + Uint8Array.from(expectedBuffer), + ); +}