@@ -18,6 +18,7 @@ import { eq } from 'drizzle-orm'
1818import { nanoid } from 'nanoid'
1919import { cookies } from 'next/headers'
2020import { NextResponse } from 'next/server'
21+ import { z } from 'zod'
2122import { getSession } from '@/lib/auth'
2223import { applyBonusCredits } from '@/lib/billing/credits/bonus'
2324
@@ -26,11 +27,21 @@ const logger = createLogger('AttributionAPI')
2627const COOKIE_NAME = 'sim_utm'
2728const CLOCK_DRIFT_TOLERANCE_MS = 60 * 1000
2829
30+ const UtmCookieSchema = z . object ( {
31+ utm_source : z . string ( ) . optional ( ) ,
32+ utm_medium : z . string ( ) . optional ( ) ,
33+ utm_campaign : z . string ( ) . optional ( ) ,
34+ utm_content : z . string ( ) . optional ( ) ,
35+ referrer_url : z . string ( ) . optional ( ) ,
36+ landing_page : z . string ( ) . optional ( ) ,
37+ created_at : z . string ( ) . min ( 1 ) ,
38+ } )
39+
2940/**
3041 * Finds the most specific active campaign matching the given UTM params.
3142 * Null fields on a campaign act as wildcards. Ties broken by newest campaign.
3243 */
33- async function findMatchingCampaign ( utmData : Record < string , string > ) {
44+ async function findMatchingCampaign ( utmData : z . infer < typeof UtmCookieSchema > ) {
3445 const campaigns = await db
3546 . select ( )
3647 . from ( referralCampaigns )
@@ -89,25 +100,24 @@ export async function POST() {
89100 return NextResponse . json ( { attributed : false , reason : 'no_utm_cookie' } )
90101 }
91102
92- let utmData : Record < string , string >
103+ let utmData : z . infer < typeof UtmCookieSchema >
93104 try {
94- // Decode first, falling back to raw value if UTM params contain bare %
95105 let decoded : string
96106 try {
97107 decoded = decodeURIComponent ( utmCookie . value )
98108 } catch {
99109 decoded = utmCookie . value
100110 }
101- utmData = JSON . parse ( decoded )
111+ utmData = UtmCookieSchema . parse ( JSON . parse ( decoded ) )
102112 } catch {
103113 logger . warn ( 'Failed to parse UTM cookie' , { userId : session . user . id } )
104114 cookieStore . delete ( COOKIE_NAME )
105115 return NextResponse . json ( { attributed : false , reason : 'invalid_cookie' } )
106116 }
107117
108118 const cookieCreatedAt = Number ( utmData . created_at )
109- if ( ! cookieCreatedAt || ! Number . isFinite ( cookieCreatedAt ) ) {
110- logger . warn ( 'UTM cookie missing created_at timestamp' , { userId : session . user . id } )
119+ if ( ! Number . isFinite ( cookieCreatedAt ) ) {
120+ logger . warn ( 'UTM cookie has invalid created_at timestamp' , { userId : session . user . id } )
111121 cookieStore . delete ( COOKIE_NAME )
112122 return NextResponse . json ( { attributed : false , reason : 'invalid_cookie' } )
113123 }
0 commit comments