mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Feature: ad attribution capture. ## What is the current behavior? Freebuff ad clicks arrive on supabase.com with a signed click id in `?bfcid=`. Nothing captures it, so those signups are unattributed. ## What is the new behavior? This PR captures `bfcid` and writes it to a cookie that, in production, is scoped so the management API receives it. The conversion is reported server-side on profile creation, in a separate change tracked in GROWTH-1217. Start with `enforceConsentDecision` in `packages/common/consented-url-cookie.ts`. It is the rule everything else hangs off, and `consented-url-cookie.test.ts` covers the state matrix. Capture: - `bfcid` is read on landing and held in `sessionStorage` until the consent decision resolves. Memory alone loses it when someone navigates before answering the banner. - Once consent is granted it goes into a cookie. In production on `*.supabase.com` that cookie is scoped to `domain=supabase.com`, and it is host-only elsewhere. It is written only after consent, which is the signal GROWTH-1217 relies on. - Values are validated with `/^bfc_[A-Za-z0-9._-]{1,508}$/`, the validator Freebuff publishes in their tag, so we never store a value their tag would reject. - `bfcid` is added to the first-touch attribution props, which feed pageview telemetry and are already consent-gated. Consent: - `enforceConsentDecision` reduces the decision to two states. Undecided and declined both clear the cookie, since neither has consent to point at. They differ in the retained value: an undecided visitor may still accept, so it waits for them. - `clearConsentedUrlCookie` drops the cookie. `discardConsentedUrlValue` also drops the retained value. - A module-level valtio subscription registers on import, guarded on `window` so it is inert during SSR. `packages/common/consent-state.ts` gains a generic `isResolved` flag and no vendor knowledge. A consumer acting on a decision needs to tell "not decided yet" from "decided against", which `hasConsented` cannot express alone. `applyPriorDecisionToSDK` now returns its promise chains, so its signature becomes `void | Promise<void>` and initialization awaits settlement before marking the decision resolved. Worth checking the call sites. ## Additional context 160 tests pass in `packages/common`. Typecheck and Prettier are clean locally on the changed files. CI is still running on the latest commit. Unverified: the clearing paths are covered by unit tests only. The consent SDK is short-circuited in local and preview builds, so they cannot be exercised outside production. An end-to-end conversion recorded by Freebuff is also unverified, since it needs the server-side change deployed. GROWTH-1216 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added consent-aware handling for Freebuff Ads click identifiers, retaining valid URL values until consent is resolved and storing them in a cookie after approval. - Added automatic cleanup when consent is denied or withdrawn, while preserving unrelated cookies. - Added support for capturing the click identifier in first-touch attribution data. - **Bug Fixes** - Improved consent initialization tracking so completion is reported after successful or failed resolution. - Added safeguards for restricted browser storage, cookies, and server-rendered environments. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Sean Oliver <882952+seanoliver@users.noreply.github.com>
301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
// @vitest-environment jsdom
|
||
/// <reference types="vitest/jsdom" />
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||
|
||
import { consentState } from './consent-state'
|
||
import {
|
||
clearConsentedUrlCookie,
|
||
createConsentedUrlCookieSync,
|
||
discardConsentedUrlValue,
|
||
} from './consented-url-cookie'
|
||
|
||
vi.hoisted(() => {
|
||
vi.stubEnv('NEXT_PUBLIC_VERCEL_ENV', 'production')
|
||
// The shared browser helpers also read this API during module initialization.
|
||
window.matchMedia = vi.fn().mockReturnValue({ matches: false })
|
||
})
|
||
|
||
const COOKIE_VALUE = 'bfc_test_1.Opaque_Value.signature'
|
||
const SECOND_COOKIE_VALUE = 'bfc_test_1.Second_Value.signature'
|
||
|
||
function openPage(url = `https://supabase.com/?bfcid=${COOKIE_VALUE}`) {
|
||
jsdom.reconfigure({ url })
|
||
return jsdom
|
||
}
|
||
|
||
beforeEach(() => {
|
||
// Undecided: the sync writes the cookie on an explicit `true` regardless of
|
||
// proxy state, so this default lets both paths be exercised.
|
||
consentState.isResolved = false
|
||
consentState.showConsentToast = false
|
||
consentState.hasConsented = false
|
||
})
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals()
|
||
vi.restoreAllMocks()
|
||
jsdom.cookieJar.removeAllCookiesSync()
|
||
sessionStorage.clear()
|
||
})
|
||
|
||
describe('consented URL cookie', () => {
|
||
it('keeps the URL parameter in memory until consent, including after SPA navigation', () => {
|
||
const page = openPage()
|
||
const sync = createConsentedUrlCookieSync()
|
||
|
||
sync(false)
|
||
expect(document.cookie).toBe('')
|
||
window.history.replaceState(null, '', '/next-page')
|
||
sync(false)
|
||
expect(document.cookie).toBe('')
|
||
|
||
sync(true)
|
||
expect(page.cookieJar.getCookieStringSync('https://subdomain.supabase.com/endpoint')).toBe(
|
||
`bfcid=${COOKIE_VALUE}`
|
||
)
|
||
})
|
||
|
||
it('recovers the parameter after a page load that happened before consent', () => {
|
||
openPage()
|
||
createConsentedUrlCookieSync()(false)
|
||
expect(document.cookie).toBe('')
|
||
|
||
// A new page load builds a fresh closure and the URL no longer carries the parameter.
|
||
jsdom.reconfigure({ url: 'https://supabase.com/pricing' })
|
||
const afterNavigation = createConsentedUrlCookieSync()
|
||
afterNavigation(false)
|
||
afterNavigation(true)
|
||
|
||
expect(document.cookie).toBe(`bfcid=${COOKIE_VALUE}`)
|
||
})
|
||
|
||
it('drops the retained parameter once consent is denied', () => {
|
||
openPage()
|
||
createConsentedUrlCookieSync()(false)
|
||
|
||
discardConsentedUrlValue()
|
||
|
||
jsdom.reconfigure({ url: 'https://supabase.com/pricing' })
|
||
createConsentedUrlCookieSync()(true)
|
||
expect(document.cookie).toBe('')
|
||
})
|
||
|
||
it('does not throw when session storage is blocked', () => {
|
||
openPage()
|
||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||
throw new Error('Storage access blocked')
|
||
})
|
||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||
throw new Error('Storage access blocked')
|
||
})
|
||
|
||
const sync = createConsentedUrlCookieSync()
|
||
expect(() => sync(false)).not.toThrow()
|
||
expect(() => sync(true)).not.toThrow()
|
||
})
|
||
|
||
it('makes the unchanged cookie value available across subdomains and paths', () => {
|
||
const page = openPage()
|
||
createConsentedUrlCookieSync()(true)
|
||
|
||
const cookies = page.cookieJar.getCookiesSync('https://subdomain.supabase.com/endpoint')
|
||
expect(cookies).toHaveLength(1)
|
||
expect(cookies[0]).toMatchObject({
|
||
key: 'bfcid',
|
||
value: COOKIE_VALUE,
|
||
domain: 'supabase.com',
|
||
path: '/',
|
||
secure: true,
|
||
sameSite: 'lax',
|
||
maxAge: 2_592_000,
|
||
})
|
||
expect(page.cookieJar.getCookieStringSync('https://supabase.com/next-page')).toBe(
|
||
`bfcid=${COOKIE_VALUE}`
|
||
)
|
||
expect(page.cookieJar.getCookieStringSync('https://example.com/')).toBe('')
|
||
expect(page.cookieJar.getCookieStringSync('http://subdomain.supabase.com/endpoint')).toBe('')
|
||
})
|
||
|
||
it('preserves the cookie on later visits without refreshing its lifetime', () => {
|
||
const page = openPage()
|
||
createConsentedUrlCookieSync()(true)
|
||
const writeCookie = vi.spyOn(document, 'cookie', 'set')
|
||
|
||
createConsentedUrlCookieSync()(true)
|
||
window.history.replaceState(null, '', '/next-page')
|
||
createConsentedUrlCookieSync()(true)
|
||
|
||
expect(writeCookie).not.toHaveBeenCalled()
|
||
expect(page.cookieJar.getCookieStringSync('https://subdomain.supabase.com/')).toBe(
|
||
`bfcid=${COOKIE_VALUE}`
|
||
)
|
||
})
|
||
|
||
it('replaces the cookie when a new consented URL parameter arrives', () => {
|
||
openPage()
|
||
createConsentedUrlCookieSync()(true)
|
||
window.history.replaceState(null, '', `/?bfcid=${SECOND_COOKIE_VALUE}`)
|
||
createConsentedUrlCookieSync()(true)
|
||
expect(document.cookie).toBe(`bfcid=${SECOND_COOKIE_VALUE}`)
|
||
})
|
||
|
||
it('does not erase a returning visitor’s cookie while consent initializes', () => {
|
||
openPage()
|
||
createConsentedUrlCookieSync()(true)
|
||
window.history.replaceState(null, '', '/next-page')
|
||
const sync = createConsentedUrlCookieSync()
|
||
|
||
sync(false)
|
||
sync(true)
|
||
expect(document.cookie).toBe(`bfcid=${COOKIE_VALUE}`)
|
||
})
|
||
|
||
it('keeps the retained value while the banner is still showing', async () => {
|
||
openPage()
|
||
createConsentedUrlCookieSync()(false)
|
||
expect(sessionStorage.getItem('sb-bfcid-pending')).not.toBeNull()
|
||
|
||
consentState.isResolved = true
|
||
consentState.showConsentToast = true
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||
// An undecided visitor may still accept, so the value waits for them.
|
||
expect(sessionStorage.getItem('sb-bfcid-pending')).not.toBeNull()
|
||
})
|
||
|
||
it('clears an existing cookie when the decision resolves to no consent', async () => {
|
||
openPage()
|
||
createConsentedUrlCookieSync()(true)
|
||
document.cookie = 'unrelated=keep; Path=/'
|
||
|
||
consentState.isResolved = true
|
||
consentState.showConsentToast = true
|
||
|
||
await vi.waitFor(() => expect(document.cookie).toBe('unrelated=keep'))
|
||
})
|
||
|
||
// acceptAll sets hasConsented before the Usercentrics promise settles, so
|
||
// the cookie is written optimistically. Its failure path flips consent back
|
||
// and re-shows the banner, which has to take the cookie with it.
|
||
it('clears the cookie when an optimistic acceptance is rolled back', async () => {
|
||
openPage()
|
||
createConsentedUrlCookieSync()(true)
|
||
expect(document.cookie).toContain('bfcid=')
|
||
|
||
consentState.isResolved = true
|
||
consentState.hasConsented = false
|
||
consentState.showConsentToast = true
|
||
|
||
await vi.waitFor(() => expect(document.cookie).not.toContain('bfcid='))
|
||
})
|
||
|
||
it('discards the retained value once the visitor declines', async () => {
|
||
openPage()
|
||
createConsentedUrlCookieSync()(false)
|
||
expect(sessionStorage.getItem('sb-bfcid-pending')).not.toBeNull()
|
||
|
||
consentState.isResolved = true
|
||
|
||
await vi.waitFor(() => expect(sessionStorage.getItem('sb-bfcid-pending')).toBeNull())
|
||
expect(document.cookie).not.toContain('bfcid=')
|
||
})
|
||
|
||
it('does not clear anything before the consent decision resolves', async () => {
|
||
openPage()
|
||
createConsentedUrlCookieSync()(true)
|
||
|
||
consentState.isResolved = false
|
||
consentState.hasConsented = false
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||
expect(document.cookie).toContain('bfcid=')
|
||
})
|
||
|
||
it('clears host-only and shared cookies for an explicit denial', () => {
|
||
const page = openPage()
|
||
createConsentedUrlCookieSync()(true)
|
||
document.cookie = `bfcid=${SECOND_COOKIE_VALUE}; Path=/`
|
||
|
||
clearConsentedUrlCookie()
|
||
expect(document.cookie).toBe('')
|
||
expect(page.cookieJar.getCookieStringSync('https://subdomain.supabase.com/')).toBe('')
|
||
})
|
||
|
||
it.each([
|
||
'',
|
||
'?bfcid=',
|
||
'?bfcid=bfc_',
|
||
'?bfcid=invalid-value',
|
||
'?bfcid=bfc_unsafe%3Bvalue',
|
||
'?bfcid=bfc_unsafe%0A',
|
||
'?bfcid=bfc_unsafe%0D',
|
||
'?bfcid=bfc_unsafe+value',
|
||
`?bfcid=bfc_${'a'.repeat(509)}`,
|
||
`?bfcid=${COOKIE_VALUE}&bfcid=${SECOND_COOKIE_VALUE}`,
|
||
])('does not store missing, ambiguous or unsafe values: %s', (query) => {
|
||
openPage(`https://supabase.com/${query}`)
|
||
createConsentedUrlCookieSync()(true)
|
||
expect(document.cookie).toBe('')
|
||
})
|
||
|
||
it('preserves the full 512-character identifier without truncation', () => {
|
||
// Freebuff validates with /^bfc_[A-Za-z0-9._-]{1,508}$/, so anything
|
||
// longer would be stored here and then rejected by them.
|
||
const cookieValue = `bfc_${'a'.repeat(508)}`
|
||
openPage(`https://supabase.com/?bfcid=${cookieValue}`)
|
||
createConsentedUrlCookieSync()(true)
|
||
expect(document.cookie).toBe(`bfcid=${cookieValue}`)
|
||
})
|
||
|
||
it.each(['http://localhost:3000', 'https://preview.example.com', 'https://supabase.com.example'])(
|
||
'uses host-only storage on a development or preview origin: %s',
|
||
(origin) => {
|
||
const page = openPage(`${origin}/?bfcid=${COOKIE_VALUE}`)
|
||
createConsentedUrlCookieSync()(true)
|
||
expect(document.cookie).toBe(`bfcid=${COOKIE_VALUE}`)
|
||
expect(page.cookieJar.getCookiesSync(origin)[0].hostOnly).toBe(true)
|
||
expect(page.cookieJar.getCookieStringSync('https://subdomain.supabase.com/')).toBe('')
|
||
}
|
||
)
|
||
|
||
it('follows the shared host-only policy for preview deployments on a Supabase subdomain', async () => {
|
||
const page = openPage(`https://preview.supabase.com/?bfcid=${COOKIE_VALUE}`)
|
||
vi.stubEnv('NEXT_PUBLIC_VERCEL_ENV', 'preview')
|
||
vi.resetModules()
|
||
|
||
try {
|
||
const { createConsentedUrlCookieSync: createPreviewCookieSync } =
|
||
await import('./consented-url-cookie')
|
||
createPreviewCookieSync()(true)
|
||
|
||
expect(document.cookie).toBe(`bfcid=${COOKIE_VALUE}`)
|
||
expect(page.cookieJar.getCookiesSync('https://preview.supabase.com/')[0].hostOnly).toBe(true)
|
||
expect(page.cookieJar.getCookieStringSync('https://subdomain.supabase.com/')).toBe('')
|
||
} finally {
|
||
vi.stubEnv('NEXT_PUBLIC_VERCEL_ENV', 'production')
|
||
}
|
||
})
|
||
|
||
it('does not throw during cookie setup or consent changes when storage is blocked', () => {
|
||
openPage()
|
||
vi.spyOn(document, 'cookie', 'get').mockImplementation(() => {
|
||
throw new Error('Cookie access blocked')
|
||
})
|
||
vi.spyOn(document, 'cookie', 'set').mockImplementation(() => {
|
||
throw new Error('Cookie access blocked')
|
||
})
|
||
|
||
const sync = createConsentedUrlCookieSync()
|
||
expect(() => sync(true)).not.toThrow()
|
||
expect(() => sync(false)).not.toThrow()
|
||
expect(() => clearConsentedUrlCookie()).not.toThrow()
|
||
})
|
||
|
||
it('does not access browser globals during server rendering', () => {
|
||
vi.stubGlobal('window', undefined)
|
||
vi.stubGlobal('document', undefined)
|
||
expect(() => createConsentedUrlCookieSync()(true)).not.toThrow()
|
||
expect(() => clearConsentedUrlCookie()).not.toThrow()
|
||
})
|
||
})
|