first-referrer-cookie.ts405 lines · main
1/**
2 * Shared utilities for the cross-app first-referrer handoff cookie.
3 *
4 * The `_sb_first_referrer` cookie is written by edge middleware on `apps/www`,
5 * `apps/docs`, and `apps/studio` when a user arrives from an
6 * external source. Studio reads it on the first telemetry pageview to recover
7 * external attribution context that would otherwise be lost at the app boundary.
8 *
9 * The cookie is normally write-once (365-day TTL, domain=supabase.com), but is
10 * refreshed when a returning visitor arrives with paid traffic signals (click IDs
11 * or paid UTM medium values) to ensure paid attribution overrides stale organic data.
12 */
13
14// ---------------------------------------------------------------------------
15// Structural types for Next.js middleware request/response
16// ---------------------------------------------------------------------------
17// Using structural interfaces instead of importing NextRequest/NextResponse
18// avoids version conflicts when different apps pin different Next.js versions
19// (e.g. studio on Next 15, docs/www on Next 16).
20
21interface MiddlewareRequest {
22 headers: { get(name: string): string | null }
23 cookies: { has(name: string): boolean }
24 url: string
25 nextUrl: { hostname: string }
26}
27
28interface MiddlewareResponse {
29 cookies: {
30 set(
31 name: string,
32 value: string,
33 options?: {
34 path?: string
35 sameSite?: 'lax' | 'strict' | 'none'
36 secure?: boolean
37 domain?: string
38 maxAge?: number
39 }
40 ): void
41 }
42}
43
44// ---------------------------------------------------------------------------
45// Constants
46// ---------------------------------------------------------------------------
47
48export const FIRST_REFERRER_COOKIE_NAME = '_sb_first_referrer'
49
50/**
51 * Short-lived (60s) diagnostic cookie written by www middleware on /dashboard and /docs paths.
52 * Encodes: hit=1&would_stamp={0|1}&has_cookie={0|1}
53 * Read by Studio telemetry to report middleware reach and attribution signals to PostHog.
54 */
55export const MW_DIAG_COOKIE_NAME = '_sb_mw_diag'
56
57/** 365 days in seconds */
58export const FIRST_REFERRER_COOKIE_MAX_AGE = 365 * 24 * 60 * 60
59
60// ---------------------------------------------------------------------------
61// Types
62// ---------------------------------------------------------------------------
63
64export interface FirstReferrerData {
65 /** The external referrer URL (e.g. https://www.google.com/) */
66 referrer: string
67 /** The landing URL on our site when the external referrer was captured */
68 landing_url: string
69 /** UTM params parsed from the landing URL (e.g. utm_source, utm_medium) */
70 utms: Record<string, string>
71 /** Ad-network click IDs parsed from the landing URL */
72 click_ids: Record<string, string>
73 /** Unix timestamp (ms) when the cookie was written */
74 ts: number
75}
76
77// ---------------------------------------------------------------------------
78// Referrer classification
79// ---------------------------------------------------------------------------
80
81/**
82 * Returns true if the referrer URL points to an external (non-Briven) domain.
83 * Handles malformed URLs gracefully by returning false.
84 */
85export function isExternalReferrer(referrer: string): boolean {
86 if (!referrer) return false
87 try {
88 const hostname = new URL(referrer).hostname
89 return hostname !== 'supabase.com' && !hostname.endsWith('.supabase.com')
90 } catch {
91 return false
92 }
93}
94
95/**
96 * Returns true if the referrer URL is an OAuth/SSO redirect that should NOT
97 * be treated as a genuine traffic source.
98 *
99 * A referrer should reflect how someone discovered Briven, not how they
100 * authenticated. This function identifies auth provider redirects:
101 * - accounts.google.com — blocked entirely (dedicated SSO subdomain)
102 * - github.com with no path (bare domain) — blocked (OAuth strips the path
103 * via origin-when-cross-origin Referrer-Policy)
104 * - github.com/login/oauth/* — blocked (explicit OAuth path, rare)
105 * - github.com with a specific path — allowed (genuine repo/README referrals)
106 */
107export function isOAuthRedirectReferrer(referrer: string): boolean {
108 if (!referrer) return false
109 try {
110 const url = new URL(referrer)
111 const hostname = url.hostname
112
113 // Google SSO — entire subdomain is auth traffic
114 if (hostname === 'accounts.google.com') return true
115
116 // GitHub — bare domain (no meaningful path) is OAuth redirect noise
117 if (hostname === 'github.com') {
118 const path = url.pathname
119 if (path === '/') return true
120 // Explicit OAuth path (rare — GitHub usually strips this)
121 if (path.startsWith('/login/oauth')) return true
122 // Any other path = genuine referral (README, repo, discussion, etc.)
123 return false
124 }
125
126 return false
127 } catch {
128 return false
129 }
130}
131
132// ---------------------------------------------------------------------------
133// UTM + click-ID extraction
134// ---------------------------------------------------------------------------
135
136const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const
137
138const CLICK_ID_KEYS = [
139 'gclid', // Google Ads
140 'gbraid', // Google Ads (iOS)
141 'wbraid', // Google Ads (iOS)
142 'msclkid', // Microsoft Ads (Bing)
143 'fbclid', // Meta (Facebook/Instagram)
144 'rdt_cid', // Reddit Ads
145 'ttclid', // TikTok Ads
146 'twclid', // X Ads (Twitter)
147 'li_fat_id', // LinkedIn Ads
148] as const
149
150function pickParams(
151 searchParams: URLSearchParams,
152 keys: readonly string[]
153): Record<string, string> {
154 const result: Record<string, string> = {}
155 for (const key of keys) {
156 const value = searchParams.get(key)
157 if (value) {
158 result[key] = value
159 }
160 }
161 return result
162}
163
164function toStringRecord(value: unknown): Record<string, string> {
165 if (!value || typeof value !== 'object') return {}
166
167 return Object.fromEntries(
168 Object.entries(value as Record<string, unknown>).filter(
169 ([key, v]) => typeof key === 'string' && typeof v === 'string'
170 )
171 ) as Record<string, string>
172}
173
174// ---------------------------------------------------------------------------
175// Build cookie payload from a request (edge-compatible)
176// ---------------------------------------------------------------------------
177
178/**
179 * Build a `FirstReferrerData` payload from raw request values.
180 * Intended for use in Next.js middleware where `document` is not available.
181 */
182export function buildFirstReferrerData({
183 referrer,
184 landingUrl,
185}: {
186 referrer: string
187 landingUrl: string
188}): FirstReferrerData {
189 let utms: Record<string, string> = {}
190 let click_ids: Record<string, string> = {}
191
192 try {
193 const url = new URL(landingUrl)
194 utms = pickParams(url.searchParams, UTM_KEYS)
195 click_ids = pickParams(url.searchParams, CLICK_ID_KEYS)
196 } catch {
197 // If landing URL is malformed, just skip param extraction
198 }
199
200 return {
201 referrer,
202 landing_url: landingUrl,
203 utms,
204 click_ids,
205 ts: Date.now(),
206 }
207}
208
209// ---------------------------------------------------------------------------
210// Serialize / parse
211// ---------------------------------------------------------------------------
212
213export function serializeFirstReferrerCookie(data: FirstReferrerData): string {
214 return JSON.stringify(data)
215}
216
217// ---------------------------------------------------------------------------
218// Paid-signal detection
219// ---------------------------------------------------------------------------
220
221const PAID_UTM_MEDIUMS = new Set([
222 'cpc',
223 'ppc',
224 'paid_search',
225 'paidsocial',
226 'paid_social',
227 'display',
228])
229
230/**
231 * Returns true if the URL contains ad-network click IDs or paid UTM medium values.
232 * These indicate the user arrived via a paid campaign, which should override
233 * stale organic attribution.
234 */
235export function hasPaidSignals(url: URL): boolean {
236 for (const key of CLICK_ID_KEYS) {
237 if (url.searchParams.has(key)) return true
238 }
239 const medium = url.searchParams.get('utm_medium')?.toLowerCase()
240 return medium !== undefined && PAID_UTM_MEDIUMS.has(medium)
241}
242
243/**
244 * Decides whether the first-referrer cookie should be (re-)stamped.
245 *
246 * - No cookie + external referrer → stamp (first visit attribution)
247 * - No cookie + OAuth/SSO redirect referrer → skip (auth ≠ discovery)
248 * - Cookie exists + paid signals in URL → stamp (paid traffic refresh)
249 * Note: OAuth check intentionally skipped for paid refresh — the paid
250 * signal comes from the URL (gclid, utm_medium=cpc), not the referrer.
251 * - Otherwise → skip
252 */
253export function shouldRefreshCookie(
254 existingCookie: boolean,
255 request: { referrer: string; url: string }
256): { stamp: boolean } {
257 if (!existingCookie) {
258 if (isOAuthRedirectReferrer(request.referrer)) return { stamp: false }
259 return { stamp: isExternalReferrer(request.referrer) }
260 }
261
262 try {
263 const url = new URL(request.url)
264 return { stamp: hasPaidSignals(url) }
265 } catch {
266 return { stamp: false }
267 }
268}
269
270// ---------------------------------------------------------------------------
271// Middleware helper — shared across apps/www, apps/docs, and apps/studio
272// ---------------------------------------------------------------------------
273
274/**
275 * Stamp the first-referrer cookie on a Next.js middleware response if the
276 * request warrants it. This is the single entry point for all app middleware
277 * files — call it with the incoming request and outgoing response.
278 *
279 * On *.supabase.com the cookie is set with `domain=supabase.com` so it's
280 * readable across all subdomains (www, docs, studio). On other hosts
281 * (localhost, preview deploys) the domain is left unset so the browser
282 * stores a host-only cookie instead of rejecting an invalid domain.
283 */
284export function stampFirstReferrerCookie(
285 request: MiddlewareRequest,
286 response: MiddlewareResponse
287): void {
288 const referrer = request.headers.get('referer') ?? ''
289
290 const { stamp } = shouldRefreshCookie(request.cookies.has(FIRST_REFERRER_COOKIE_NAME), {
291 referrer,
292 url: request.url,
293 })
294
295 if (!stamp) return
296
297 const data = buildFirstReferrerData({
298 referrer,
299 landingUrl: request.url,
300 })
301
302 response.cookies.set(FIRST_REFERRER_COOKIE_NAME, serializeFirstReferrerCookie(data), {
303 path: '/',
304 sameSite: 'lax',
305 ...(request.nextUrl.hostname === 'supabase.com' ||
306 request.nextUrl.hostname.endsWith('.supabase.com')
307 ? { domain: 'supabase.com', secure: true }
308 : {}),
309 maxAge: FIRST_REFERRER_COOKIE_MAX_AGE,
310 })
311}
312
313// ---------------------------------------------------------------------------
314// Middleware diagnostic cookie — parse (client-side)
315// ---------------------------------------------------------------------------
316
317export interface MwDiagData {
318 hit: boolean
319 would_stamp: boolean
320 has_existing_cookie: boolean
321}
322
323/**
324 * Parse the short-lived middleware diagnostic cookie written by www middleware
325 * on /dashboard and /docs paths. Returns null if the cookie is absent or malformed.
326 */
327export function parseMwDiagCookie(cookieHeader: string): MwDiagData | null {
328 try {
329 const cookies = cookieHeader.split(';')
330 const match = cookies.map((c) => c.trim()).find((c) => c.startsWith(`${MW_DIAG_COOKIE_NAME}=`))
331
332 if (!match) return null
333
334 const rawValue = match.slice(`${MW_DIAG_COOKIE_NAME}=`.length)
335 const params = new URLSearchParams(decodeURIComponent(rawValue))
336
337 if (params.get('hit') !== '1') return null
338
339 return {
340 hit: true,
341 would_stamp: params.get('would_stamp') === '1',
342 has_existing_cookie: params.get('has_cookie') === '1',
343 }
344 } catch {
345 return null
346 }
347}
348
349// ---------------------------------------------------------------------------
350// Parse cookie from document.cookie header (client-side)
351// ---------------------------------------------------------------------------
352
353export function parseFirstReferrerCookie(cookieHeader: string): FirstReferrerData | null {
354 try {
355 const cookies = cookieHeader.split(';')
356 const match = cookies
357 .map((c) => c.trim())
358 .find((c) => c.startsWith(`${FIRST_REFERRER_COOKIE_NAME}=`))
359
360 if (!match) return null
361
362 const value = match.slice(`${FIRST_REFERRER_COOKIE_NAME}=`.length)
363 const decoded = decodeURIComponent(value)
364 // Handle double-encoded cookies from before the serializer fix.
365 // Next.js cookies.set() encodes automatically, but serializeFirstReferrerCookie
366 // previously called encodeURIComponent too, producing double-encoded values.
367 let jsonString: string
368 try {
369 JSON.parse(decoded)
370 jsonString = decoded
371 } catch {
372 jsonString = decodeURIComponent(decoded)
373 }
374 const parsed = JSON.parse(jsonString) as unknown
375
376 if (!parsed || typeof parsed !== 'object') return null
377
378 const parsedRecord = parsed as Record<string, unknown>
379 const referrer = parsedRecord.referrer
380 const landingUrl = parsedRecord.landing_url
381
382 if (typeof referrer !== 'string' || typeof landingUrl !== 'string') {
383 return null
384 }
385
386 const utmsRaw = parsedRecord.utms
387 const clickIdsRaw = parsedRecord.click_ids
388 const tsRaw = parsedRecord.ts
389
390 const utms = toStringRecord(utmsRaw)
391 const click_ids = toStringRecord(clickIdsRaw)
392
393 const ts = typeof tsRaw === 'number' && Number.isFinite(tsRaw) ? tsRaw : Date.now()
394
395 return {
396 referrer,
397 landing_url: landingUrl,
398 utms,
399 click_ids,
400 ts,
401 }
402 } catch {
403 return null
404 }
405}