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 diff --git a/apps/web/.env.example b/apps/web/.env.example index 4ea84d0dc49..c8b378c39e3 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -8,6 +8,9 @@ NEXTAUTH_URL=http://localhost:8888 # (only needed for localhost) # Secret for Vercel cron jobs + sync-embeddings CRON_SECRET= +# 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= # Email unsubscribe token secret (optional, falls back to NEXTAUTH_SECRET) 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/demo/click/route.ts b/apps/web/app/(ee)/api/demo/click/route.ts new file mode 100644 index 00000000000..63cdfbac2f3 --- /dev/null +++ b/apps/web/app/(ee)/api/demo/click/route.ts @@ -0,0 +1,80 @@ +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 { recordFakeClick } from "@/lib/tinybird/record-fake-click"; +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"; + +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(), + timestamp: parseDateSchema.nullish(), +}); + +// POST /api/demo/click – mint a geo-accurate click for the LoopWork demo workspace only +export const POST = withAxiom(async (req) => { + try { + verifyDemoSecret(req); + + const { + domain, + key, + country, + region, + city, + continent, + referrer, + userAgent, + timestamp, + } = 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}.`, + }); + } + + assertDemoLink(link); + + 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, + ...(timestamp && { timestamp: timestamp.toISOString() }), + }); + + return NextResponse.json({ clickId: clickEvent.click_id }); + } catch (error) { + return handleAndReturnErrorResponse(error); + } +}); 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/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/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/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..7a41d1def66 100644 --- a/apps/web/app/(ee)/api/shopify/integration/webhook/route.ts +++ b/apps/web/app/(ee)/api/shopify/integration/webhook/route.ts @@ -1,6 +1,8 @@ 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"; @@ -21,7 +23,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; @@ -41,7 +43,7 @@ export const POST = async (req: Request) => { .update(data, "utf8") .digest("base64"); - if (generatedSignature !== signature) { + if (!timingSafeCompare(signature, generatedSignature)) { return logAndRespond( "Shopify webhook signature verification failed. Skipping...", { @@ -150,4 +152,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..7dc78bf3be5 100644 --- a/apps/web/app/api/dub/webhook/route.ts +++ b/apps/web/app/api/dub/webhook/route.ts @@ -1,10 +1,12 @@ +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"; // 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); @@ -19,7 +21,7 @@ export const POST = async (req: Request) => { .update(JSON.stringify(body)) .digest("hex"); - if (webhookSignature !== computedSignature) { + if (!timingSafeCompare(webhookSignature, computedSignature)) { return new Response("Invalid signature", { status: 400 }); } @@ -35,4 +37,4 @@ export const POST = async (req: Request) => { } return new Response(response); -}; +}); 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/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..54f71df5e31 100644 --- a/apps/web/app/api/veriff/webhook/route.ts +++ b/apps/web/app/api/veriff/webhook/route.ts @@ -1,10 +1,12 @@ +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"; 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"); @@ -17,17 +19,7 @@ export const POST = 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 }); } @@ -42,16 +34,7 @@ export const POST = 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 }); } @@ -62,4 +45,4 @@ export const POST = async (req: Request) => { } else { return await handleSessionEvent(body); } -}; +}); 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; 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/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/tinybird/record-fake-click.ts b/apps/web/lib/tinybird/record-fake-click.ts index 76464382dd6..5a56d2be640 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"; @@ -25,22 +25,51 @@ 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": + toSafeHeaderValue(userAgent) || + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", "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 +81,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() }), }); 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), + ); +} 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 ? (