first-referrer-cookie.ts 13 KB

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