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 | |
| 21 | interface MiddlewareRequest { |
| 22 | headers: { get(name: string): string | null } |
| 23 | cookies: { has(name: string): boolean } |
| 24 | url: string |
| 25 | nextUrl: { hostname: string } |
| 26 | } |
| 27 | |
| 28 | interface 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 | |
| 48 | export 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 | */ |
| 55 | export const MW_DIAG_COOKIE_NAME = '_sb_mw_diag' |
| 56 | |
| 57 | /** 365 days in seconds */ |
| 58 | export const FIRST_REFERRER_COOKIE_MAX_AGE = 365 * 24 * 60 * 60 |
| 59 | |
| 60 | // --------------------------------------------------------------------------- |
| 61 | // Types |
| 62 | // --------------------------------------------------------------------------- |
| 63 | |
| 64 | export 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 | */ |
| 85 | export 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 | */ |
| 107 | export 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 | |
| 136 | const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const |
| 137 | |
| 138 | const 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 | |
| 150 | function 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 | |
| 164 | function 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 | */ |
| 182 | export 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 | |
| 213 | export function serializeFirstReferrerCookie(data: FirstReferrerData): string { |
| 214 | return JSON.stringify(data) |
| 215 | } |
| 216 | |
| 217 | // --------------------------------------------------------------------------- |
| 218 | // Paid-signal detection |
| 219 | // --------------------------------------------------------------------------- |
| 220 | |
| 221 | const 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 | */ |
| 235 | export 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 | */ |
| 253 | export 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 | */ |
| 284 | export 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 | |
| 317 | export 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 | */ |
| 327 | export 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 | |
| 353 | export 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 | } |