| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405 |
- /**
- * 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-Briven) 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 Briven, 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<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)
- * - 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<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
- }
- }
|