mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 04:15:04 +03:00
## Problem While working on the phased PostHog Attribution rollout (GROWTH-625 / GROWTH-668) — specifically the initial referrer handoff across the app boundary — we discovered that `parseFirstReferrerCookie` was returning `null` even when the cookie was visibly present in the browser. The root cause: `serializeFirstReferrerCookie` calls `encodeURIComponent(JSON.stringify(data))`, but Next.js `cookies.set()` encodes the value again, producing a double-encoded cookie. On the read side, `parseFirstReferrerCookie` only decodes once, so `JSON.parse` receives a still-encoded string and fails. The `try/catch` swallows the error and returns `null`. Confirmed in production: the raw cookie value starts with `%257B%2522` (double-encoded `%` characters). A single `decodeURIComponent` produces `%7B%22referrer%22...` — still URL-encoded, not valid JSON. This was blocking the cross-app attribution handoff that GROWTH-625 depends on. ## Changes - **Serializer**: Removed `encodeURIComponent` from `serializeFirstReferrerCookie` — Next.js `cookies.set()` already handles encoding - **Parser**: Made `parseFirstReferrerCookie` resilient to both encodings — tries single decode first, falls back to double decode for existing cookies in the wild (365-day TTL means legacy cookies persist up to a year) - **Test**: Added unit test for the double-encoded legacy cookie format ## Testing - 35/35 common unit tests pass, 12/12 www middleware tests pass - Verified locally: Google → www (localhost:3000) → Studio (localhost:8082). Console logs confirmed `parseFirstReferrerCookie` returned the full data object instead of null - Confirmed in production that existing cookies are double-encoded and the fix correctly handles them Ref: GROWTH-625 / GROWTH-668
365 lines
11 KiB
TypeScript
365 lines
11 KiB
TypeScript
/**
|
|
* 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<string, string>
|
|
/** Ad-network click IDs parsed from the landing URL */
|
|
click_ids: Record<string, string>
|
|
/** 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
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<string, string> {
|
|
const result: Record<string, string> = {}
|
|
for (const key of keys) {
|
|
const value = searchParams.get(key)
|
|
if (value) {
|
|
result[key] = value
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
function toStringRecord(value: unknown): Record<string, string> {
|
|
if (!value || typeof value !== 'object') return {}
|
|
|
|
return Object.fromEntries(
|
|
Object.entries(value as Record<string, unknown>).filter(
|
|
([key, v]) => typeof key === 'string' && typeof v === 'string'
|
|
)
|
|
) as Record<string, string>
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<string, string> = {}
|
|
let click_ids: Record<string, string> = {}
|
|
|
|
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)
|
|
* - Cookie exists + paid signals in URL → stamp (paid traffic refresh)
|
|
* - Otherwise → skip
|
|
*/
|
|
export function shouldRefreshCookie(
|
|
existingCookie: boolean,
|
|
request: { referrer: string; url: string }
|
|
): { stamp: boolean } {
|
|
if (!existingCookie) {
|
|
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<string, unknown>
|
|
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
|
|
}
|
|
}
|