/** * Shared utilities for the cross-app first-referrer handoff cookie. * * The `_sb_first_referrer` cookie is written by edge middleware on `apps/www`, * `apps/docs`, and `apps/studio` when a user arrives from an * external source. Studio reads it on the first telemetry pageview to recover * external attribution context that would otherwise be lost at the app boundary. * * The cookie is normally write-once (365-day TTL, domain=supabase.com), but is * refreshed when a returning visitor arrives with paid traffic signals (click IDs * or paid UTM medium values) to ensure paid attribution overrides stale organic data. */ // --------------------------------------------------------------------------- // Structural types for Next.js middleware request/response // --------------------------------------------------------------------------- // Using structural interfaces instead of importing NextRequest/NextResponse // avoids version conflicts when different apps pin different Next.js versions // (e.g. studio on Next 15, docs/www on Next 16). interface MiddlewareRequest { headers: { get(name: string): string | null } cookies: { has(name: string): boolean } url: string nextUrl: { hostname: string } } interface MiddlewareResponse { cookies: { set( name: string, value: string, options?: { path?: string sameSite?: 'lax' | 'strict' | 'none' secure?: boolean domain?: string maxAge?: number } ): void } } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- export const FIRST_REFERRER_COOKIE_NAME = '_sb_first_referrer' /** * Short-lived (60s) diagnostic cookie written by www middleware on /dashboard and /docs paths. * Encodes: hit=1&would_stamp={0|1}&has_cookie={0|1} * Read by Studio telemetry to report middleware reach and attribution signals to PostHog. */ export const MW_DIAG_COOKIE_NAME = '_sb_mw_diag' /** 365 days in seconds */ export const FIRST_REFERRER_COOKIE_MAX_AGE = 365 * 24 * 60 * 60 // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface FirstReferrerData { /** The external referrer URL (e.g. https://www.google.com/) */ referrer: string /** The landing URL on our site when the external referrer was captured */ landing_url: string /** UTM params parsed from the landing URL (e.g. utm_source, utm_medium) */ utms: Record /** Ad-network click IDs parsed from the landing URL */ click_ids: Record /** Unix timestamp (ms) when the cookie was written */ ts: number } // --------------------------------------------------------------------------- // Referrer classification // --------------------------------------------------------------------------- /** * Returns true if the referrer URL points to an external (non-Supabase) domain. * Handles malformed URLs gracefully by returning false. */ export function isExternalReferrer(referrer: string): boolean { if (!referrer) return false try { const hostname = new URL(referrer).hostname return hostname !== 'supabase.com' && !hostname.endsWith('.supabase.com') } catch { return false } } /** * Returns true if the referrer URL is an OAuth/SSO redirect that should NOT * be treated as a genuine traffic source. * * A referrer should reflect how someone discovered Supabase, not how they * authenticated. This function identifies auth provider redirects: * - accounts.google.com — blocked entirely (dedicated SSO subdomain) * - github.com with no path (bare domain) — blocked (OAuth strips the path * via origin-when-cross-origin Referrer-Policy) * - github.com/login/oauth/* — blocked (explicit OAuth path, rare) * - github.com with a specific path — allowed (genuine repo/README referrals) */ export function isOAuthRedirectReferrer(referrer: string): boolean { if (!referrer) return false try { const url = new URL(referrer) const hostname = url.hostname // Google SSO — entire subdomain is auth traffic if (hostname === 'accounts.google.com') return true // GitHub — bare domain (no meaningful path) is OAuth redirect noise if (hostname === 'github.com') { const path = url.pathname if (path === '/') return true // Explicit OAuth path (rare — GitHub usually strips this) if (path.startsWith('/login/oauth')) return true // Any other path = genuine referral (README, repo, discussion, etc.) return false } return false } catch { return false } } // --------------------------------------------------------------------------- // UTM + click-ID extraction // --------------------------------------------------------------------------- const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const const CLICK_ID_KEYS = [ 'gclid', // Google Ads 'gbraid', // Google Ads (iOS) 'wbraid', // Google Ads (iOS) 'msclkid', // Microsoft Ads (Bing) 'fbclid', // Meta (Facebook/Instagram) 'rdt_cid', // Reddit Ads 'ttclid', // TikTok Ads 'twclid', // X Ads (Twitter) 'li_fat_id', // LinkedIn Ads ] as const function pickParams( searchParams: URLSearchParams, keys: readonly string[] ): Record { const result: Record = {} for (const key of keys) { const value = searchParams.get(key) if (value) { result[key] = value } } return result } function toStringRecord(value: unknown): Record { if (!value || typeof value !== 'object') return {} return Object.fromEntries( Object.entries(value as Record).filter( ([key, v]) => typeof key === 'string' && typeof v === 'string' ) ) as Record } // --------------------------------------------------------------------------- // Build cookie payload from a request (edge-compatible) // --------------------------------------------------------------------------- /** * Build a `FirstReferrerData` payload from raw request values. * Intended for use in Next.js middleware where `document` is not available. */ export function buildFirstReferrerData({ referrer, landingUrl, }: { referrer: string landingUrl: string }): FirstReferrerData { let utms: Record = {} let click_ids: Record = {} try { const url = new URL(landingUrl) utms = pickParams(url.searchParams, UTM_KEYS) click_ids = pickParams(url.searchParams, CLICK_ID_KEYS) } catch { // If landing URL is malformed, just skip param extraction } return { referrer, landing_url: landingUrl, utms, click_ids, ts: Date.now(), } } // --------------------------------------------------------------------------- // Serialize / parse // --------------------------------------------------------------------------- export function serializeFirstReferrerCookie(data: FirstReferrerData): string { return JSON.stringify(data) } // --------------------------------------------------------------------------- // Paid-signal detection // --------------------------------------------------------------------------- const PAID_UTM_MEDIUMS = new Set([ 'cpc', 'ppc', 'paid_search', 'paidsocial', 'paid_social', 'display', ]) /** * Returns true if the URL contains ad-network click IDs or paid UTM medium values. * These indicate the user arrived via a paid campaign, which should override * stale organic attribution. */ export function hasPaidSignals(url: URL): boolean { for (const key of CLICK_ID_KEYS) { if (url.searchParams.has(key)) return true } const medium = url.searchParams.get('utm_medium')?.toLowerCase() return medium !== undefined && PAID_UTM_MEDIUMS.has(medium) } /** * Decides whether the first-referrer cookie should be (re-)stamped. * * - No cookie + external referrer → stamp (first visit attribution) * - No cookie + OAuth/SSO redirect referrer → skip (auth ≠ discovery) * - Cookie exists + paid signals in URL → stamp (paid traffic refresh) * Note: OAuth check intentionally skipped for paid refresh — the paid * signal comes from the URL (gclid, utm_medium=cpc), not the referrer. * - Otherwise → skip */ export function shouldRefreshCookie( existingCookie: boolean, request: { referrer: string; url: string } ): { stamp: boolean } { if (!existingCookie) { if (isOAuthRedirectReferrer(request.referrer)) return { stamp: false } return { stamp: isExternalReferrer(request.referrer) } } try { const url = new URL(request.url) return { stamp: hasPaidSignals(url) } } catch { return { stamp: false } } } // --------------------------------------------------------------------------- // Middleware helper — shared across apps/www, apps/docs, and apps/studio // --------------------------------------------------------------------------- /** * Stamp the first-referrer cookie on a Next.js middleware response if the * request warrants it. This is the single entry point for all app middleware * files — call it with the incoming request and outgoing response. * * On *.supabase.com the cookie is set with `domain=supabase.com` so it's * readable across all subdomains (www, docs, studio). On other hosts * (localhost, preview deploys) the domain is left unset so the browser * stores a host-only cookie instead of rejecting an invalid domain. */ export function stampFirstReferrerCookie( request: MiddlewareRequest, response: MiddlewareResponse ): void { const referrer = request.headers.get('referer') ?? '' const { stamp } = shouldRefreshCookie(request.cookies.has(FIRST_REFERRER_COOKIE_NAME), { referrer, url: request.url, }) if (!stamp) return const data = buildFirstReferrerData({ referrer, landingUrl: request.url, }) response.cookies.set(FIRST_REFERRER_COOKIE_NAME, serializeFirstReferrerCookie(data), { path: '/', sameSite: 'lax', ...(request.nextUrl.hostname === 'supabase.com' || request.nextUrl.hostname.endsWith('.supabase.com') ? { domain: 'supabase.com', secure: true } : {}), maxAge: FIRST_REFERRER_COOKIE_MAX_AGE, }) } // --------------------------------------------------------------------------- // Middleware diagnostic cookie — parse (client-side) // --------------------------------------------------------------------------- export interface MwDiagData { hit: boolean would_stamp: boolean has_existing_cookie: boolean } /** * Parse the short-lived middleware diagnostic cookie written by www middleware * on /dashboard and /docs paths. Returns null if the cookie is absent or malformed. */ export function parseMwDiagCookie(cookieHeader: string): MwDiagData | null { try { const cookies = cookieHeader.split(';') const match = cookies.map((c) => c.trim()).find((c) => c.startsWith(`${MW_DIAG_COOKIE_NAME}=`)) if (!match) return null const rawValue = match.slice(`${MW_DIAG_COOKIE_NAME}=`.length) const params = new URLSearchParams(decodeURIComponent(rawValue)) if (params.get('hit') !== '1') return null return { hit: true, would_stamp: params.get('would_stamp') === '1', has_existing_cookie: params.get('has_cookie') === '1', } } catch { return null } } // --------------------------------------------------------------------------- // Parse cookie from document.cookie header (client-side) // --------------------------------------------------------------------------- export function parseFirstReferrerCookie(cookieHeader: string): FirstReferrerData | null { try { const cookies = cookieHeader.split(';') const match = cookies .map((c) => c.trim()) .find((c) => c.startsWith(`${FIRST_REFERRER_COOKIE_NAME}=`)) if (!match) return null const value = match.slice(`${FIRST_REFERRER_COOKIE_NAME}=`.length) const decoded = decodeURIComponent(value) // Handle double-encoded cookies from before the serializer fix. // Next.js cookies.set() encodes automatically, but serializeFirstReferrerCookie // previously called encodeURIComponent too, producing double-encoded values. let jsonString: string try { JSON.parse(decoded) jsonString = decoded } catch { jsonString = decodeURIComponent(decoded) } const parsed = JSON.parse(jsonString) as unknown if (!parsed || typeof parsed !== 'object') return null const parsedRecord = parsed as Record const referrer = parsedRecord.referrer const landingUrl = parsedRecord.landing_url if (typeof referrer !== 'string' || typeof landingUrl !== 'string') { return null } const utmsRaw = parsedRecord.utms const clickIdsRaw = parsedRecord.click_ids const tsRaw = parsedRecord.ts const utms = toStringRecord(utmsRaw) const click_ids = toStringRecord(clickIdsRaw) const ts = typeof tsRaw === 'number' && Number.isFinite(tsRaw) ? tsRaw : Date.now() return { referrer, landing_url: landingUrl, utms, click_ids, ts, } } catch { return null } }