Files
supabase/packages/common/consented-url-cookie.ts
Aleksi ImmonenandSean Oliver 66e6cd1639 feat: capture Freebuff ad click ids (#50181)
## 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>
2026-09-11 12:14:08 -07:00

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()
}
}