mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +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>
138 lines
4.0 KiB
TypeScript
138 lines
4.0 KiB
TypeScript
import { subscribe } from 'valtio'
|
|
|
|
import { consentState } from './consent-state'
|
|
import { getTelemetryCookieOptions } from './telemetry-utils'
|
|
|
|
const COOKIE_NAME = 'bfcid'
|
|
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30
|
|
// Not held in memory, because it has to survive the page load the visitor
|
|
// landed on. Session-scoped, so it never outlives the tab.
|
|
const PENDING_STORAGE_KEY = 'sb-bfcid-pending'
|
|
|
|
// Mirrors the validator Freebuff publishes, so a value stored here is never
|
|
// one they reject.
|
|
const CLICK_ID_SHAPE = /^bfc_[A-Za-z0-9._-]{1,508}$/
|
|
|
|
function readPendingValue(): string | null {
|
|
try {
|
|
return sessionStorage.getItem(PENDING_STORAGE_KEY)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function writePendingValue(value: string): void {
|
|
try {
|
|
sessionStorage.setItem(PENDING_STORAGE_KEY, value)
|
|
} catch {
|
|
// Storage restrictions must not prevent the page from rendering.
|
|
}
|
|
}
|
|
|
|
function clearPendingValue(): void {
|
|
try {
|
|
sessionStorage.removeItem(PENDING_STORAGE_KEY)
|
|
} catch {
|
|
// Storage restrictions must not prevent a consent update.
|
|
}
|
|
}
|
|
|
|
function getParameterValue(url: string): string | null {
|
|
try {
|
|
const values = new URL(url).searchParams.getAll(COOKIE_NAME)
|
|
if (values.length !== 1) return null
|
|
|
|
const value = values[0]
|
|
return CLICK_ID_SHAPE.test(value) ? value : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function getCookieOptions(): string {
|
|
return `${getTelemetryCookieOptions()}${window.location.protocol === 'https:' ? '; Secure' : ''}`
|
|
}
|
|
|
|
/** Remove the cookie, leaving any value still awaiting a consent decision. */
|
|
export function clearConsentedUrlCookie(): void {
|
|
if (typeof document === 'undefined') return
|
|
|
|
try {
|
|
document.cookie = `${COOKIE_NAME}=; Max-Age=0; ${getCookieOptions()}`
|
|
// Outside production the write is host-only, so clear that variant too.
|
|
document.cookie = `${COOKIE_NAME}=; Max-Age=0; Path=/; SameSite=Lax`
|
|
} catch {
|
|
// Cookie restrictions must not break the page.
|
|
}
|
|
}
|
|
|
|
/** Remove the cookie and any value awaiting a consent decision. */
|
|
export function discardConsentedUrlValue(): void {
|
|
clearPendingValue()
|
|
clearConsentedUrlCookie()
|
|
}
|
|
|
|
/** True until the visitor has either accepted or declined. */
|
|
function isDecisionPending(): boolean {
|
|
return !consentState.isResolved || consentState.showConsentToast
|
|
}
|
|
|
|
/**
|
|
* The single rule tying the stored click id to the consent decision.
|
|
*
|
|
* Undecided and declined both mean there is no consent to point at, so the
|
|
* cookie goes either way. They differ in the retained value: an undecided
|
|
* visitor may still accept, so it is kept until they decide.
|
|
*/
|
|
function enforceConsentDecision(): void {
|
|
if (!consentState.isResolved || consentState.hasConsented) return
|
|
|
|
if (consentState.showConsentToast) {
|
|
clearConsentedUrlCookie()
|
|
return
|
|
}
|
|
|
|
discardConsentedUrlValue()
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
subscribe(consentState, enforceConsentDecision)
|
|
}
|
|
|
|
/**
|
|
* Retain the click id until consent permits writing a cookie.
|
|
*
|
|
* The cookie is scoped to `domain=supabase.com` so the management API receives
|
|
* it, and is written only after consent, which is how the API knows consent
|
|
* was granted.
|
|
*/
|
|
export function createConsentedUrlCookieSync() {
|
|
return (hasAccepted: boolean): void => {
|
|
if (typeof window === 'undefined') return
|
|
|
|
const valueFromUrl = getParameterValue(window.location.href)
|
|
if (valueFromUrl && !hasAccepted && isDecisionPending()) {
|
|
writePendingValue(valueFromUrl)
|
|
}
|
|
|
|
if (!hasAccepted) return
|
|
|
|
const value = valueFromUrl ?? readPendingValue()
|
|
if (!value) return
|
|
|
|
try {
|
|
const cookie = `${COOKIE_NAME}=${value}`
|
|
const hasSameValue = document.cookie.split(';').some((part) => part.trim() === cookie)
|
|
|
|
// Ordinary navigation must not extend the original cookie lifetime.
|
|
if (!hasSameValue) {
|
|
document.cookie = `${cookie}; Max-Age=${COOKIE_MAX_AGE}; ${getCookieOptions()}`
|
|
}
|
|
} catch {
|
|
// Cookie restrictions must not break the page.
|
|
}
|
|
|
|
clearPendingValue()
|
|
}
|
|
}
|