Files
supabase/packages/common/consent-state.ts
T
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

342 lines
13 KiB
TypeScript

import type Usercentrics from '@usercentrics/cmp-browser-sdk'
import type { BaseCategory, UserDecision } from '@usercentrics/cmp-browser-sdk'
import { proxy, ref, snapshot, useSnapshot } from 'valtio'
import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from './constants'
export type PriorConsentDecision =
| null
| { kind: 'uniform-accept' }
| { kind: 'decisions'; decisions: UserDecision[] }
type UcDataServiceEntry = [string, { consent: boolean }]
const isValidUcDataServiceEntry = (entry: [string, unknown]): entry is UcDataServiceEntry => {
const value = entry[1]
return (
typeof value === 'object' &&
value !== null &&
typeof (value as { consent: unknown }).consent === 'boolean'
)
}
type UcSettingsService = { id: string; status: boolean }
const isValidUcSettingsService = (service: unknown): service is UcSettingsService =>
typeof service === 'object' &&
service !== null &&
typeof (service as { id: unknown }).id === 'string' &&
typeof (service as { status: unknown }).status === 'boolean'
/**
* Check whether the user previously made a consent decision by reading
* localStorage state that was written before UC.init() overwrites it.
*
* Returns enough information for the caller to restore the user's exact
* prior state via UC.updateServices, or to fast-path via UC.acceptAllServices
* when we can safely identify a uniform accept. Returns null when nothing
* trustworthy can be detected — in that case the caller should show the
* banner rather than fabricate a decision.
*
* Handles two scenarios (FE-2648 and GROWTH-790):
*
* 1. Slow navigation: GTM's Usercentrics integration replaced uc_settings with
* compressed ucString/ucData after the user's decision. On the next page
* load, UC.init() can't read that format and treats the user as new. We
* read ucData.consent.services directly and return every per-service
* decision so the caller can restore the user's exact state — including
* mixed states (essentials + functional accepted, tracking denied) that
* users produce via the Privacy Settings modal or the Opt out button.
*
* 2. Fast navigation: User decided on app A and navigated to app B before GTM
* finished writing ucData (or before GTM loaded at all, which happens on
* deny since TelemetryTagManager is gated behind hasAccepted). App B's
* UC.init() overwrites uc_settings with a fresh controllerId and resets
* uc_user_interaction to false. We check uc_user_interaction: "true" as
* a gate confirming the user actually interacted, then parse uc_settings
* to extract per-service decisions. uc_user_interaction alone is not
* enough — without uc_settings we cannot tell accept from deny and must
* not fabricate a direction.
*
* Both scenarios fail closed: any schema mismatch or partial corruption
* returns null rather than proceeding with a subset of valid entries.
* Over-consenting from malformed storage is the worst-direction bias in
* this domain.
*
* Must be called BEFORE UC.init() since init overwrites these keys.
*/
export function detectPriorConsent(): PriorConsentDecision {
try {
const ucData = localStorage?.getItem('ucData')
if (ucData) {
const data = JSON.parse(ucData)
const services = data?.consent?.services
if (services && typeof services === 'object') {
const rawEntries = Object.entries(services) as Array<[string, unknown]>
if (rawEntries.length > 0) {
if (!rawEntries.every(isValidUcDataServiceEntry)) {
// Partial corruption: don't cherry-pick the valid subset. Fall
// through to scenario 2 — uc_settings may still be intact.
} else {
const entries = rawEntries as UcDataServiceEntry[]
if (entries.every(([, s]) => s.consent === true)) {
return { kind: 'uniform-accept' }
}
return {
kind: 'decisions',
decisions: entries.map(([serviceId, s]) => ({
serviceId,
status: s.consent,
})),
}
}
}
}
}
// uc_user_interaction gates trust in uc_settings — the SDK sets it on any
// user interaction, which confirms uc_settings holds real decisions rather
// than ruleset defaults.
if (localStorage?.getItem('uc_user_interaction') === 'true') {
const ucSettings = localStorage?.getItem('uc_settings')
if (ucSettings) {
const parsed = JSON.parse(ucSettings)
const services = parsed?.services
if (
Array.isArray(services) &&
services.length > 0 &&
services.every(isValidUcSettingsService)
) {
const decisions: UserDecision[] = services.map((s) => ({
serviceId: s.id,
status: s.status,
}))
if (decisions.every((d) => d.status === true)) {
return { kind: 'uniform-accept' }
}
return { kind: 'decisions', decisions }
}
}
// Flag says interacted but uc_settings is missing or malformed.
// Don't fabricate direction — show the banner on the next init.
}
return null
} catch {
return null
}
}
export const consentState = proxy({
UC: null as Usercentrics | null,
categories: null as BaseCategory[] | null,
/**
* Whether the consent decision has settled, either from the SDK or from a
* failure to load it. Consumers that must act on a decision need to tell
* "not decided yet" apart from "decided against", which `hasConsented`
* alone cannot express.
*/
isResolved: false,
showConsentToast: false,
hasConsented: false,
acceptAll: () => {
if (!consentState.UC) return
const previousConsentValue = consentState.hasConsented
consentState.hasConsented = true
consentState.showConsentToast = false
consentState.UC.acceptAllServices()
.then(() => {
consentState.categories = consentState.UC?.getCategoriesBaseInfo() ?? null
})
.catch(() => {
consentState.hasConsented = previousConsentValue
consentState.showConsentToast = true
})
},
denyAll: () => {
if (!consentState.UC) return
const previousConsentValue = consentState.hasConsented
consentState.hasConsented = false
consentState.showConsentToast = false
consentState.UC.denyAllServices()
.then(() => {
consentState.categories = consentState.UC?.getCategoriesBaseInfo() ?? null
})
.catch(() => {
consentState.showConsentToast = previousConsentValue
})
},
updateServices: (decisions: UserDecision[]) => {
if (!consentState.UC) return
consentState.showConsentToast = false
consentState.UC.updateServices(decisions)
.then(() => {
consentState.hasConsented = consentState.UC?.areAllConsentsAccepted() ?? false
consentState.categories = consentState.UC?.getCategoriesBaseInfo() ?? null
})
.catch(() => {
consentState.showConsentToast = true
})
},
})
/**
* Apply a prior consent decision (or lack of one) to the freshly-initialized
* Usercentrics SDK and the module's consentState proxy. Extracted from
* initUserCentrics to make the orchestration unit-testable without mocking
* the dynamic SDK import. Must be called after UC.init(). Mutates
* consentState synchronously and may call UC methods asynchronously.
*/
export function applyPriorDecisionToSDK(
UC: Usercentrics,
initialUIValues: { initialLayer: number },
priorDecision: PriorConsentDecision
): void | Promise<void> {
// ref() prevents valtio from recursively proxying the SDK instance's internals in place,
// which would corrupt them the same way it once corrupted the AI Assistant's message array
consentState.UC = ref(UC)
const hasConsented = UC.areAllConsentsAccepted()
// If the SDK wants to show the banner but the user previously made a
// decision (detected via ucData or uc_settings before init overwrote
// them), silently re-apply that decision instead of re-prompting
// (FE-2648, GROWTH-790).
if (initialUIValues.initialLayer === 0 && !hasConsented && priorDecision) {
consentState.categories = UC.getCategoriesBaseInfo()
consentState.showConsentToast = false
localStorage?.removeItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT)
if (priorDecision.kind === 'uniform-accept') {
// Uniform accept covers any currently-active service by definition,
// including any added to the ruleset since the user's decision was
// stored — acceptAllServices applies to all current services.
consentState.hasConsented = true
return UC.acceptAllServices()
.then(() => {
consentState.categories = UC.getCategoriesBaseInfo()
})
.catch(() => {
consentState.hasConsented = false
consentState.showConsentToast = true
})
}
// priorDecision.kind === 'decisions'. Only suppress the banner if the
// stored decisions cover every non-essential service the SDK currently
// knows about. If the ruleset has grown since the user's ucData/
// uc_settings was written, force a re-prompt rather than silently
// defaulting the new service. Essentials are skipped because the SDK
// forces them on regardless of user decision.
const currentNonEssentialIds = UC.getServicesBaseInfo()
.filter((s) => !s.isEssential)
.map((s) => s.id)
const coveredIds = new Set(priorDecision.decisions.map((d) => d.serviceId))
const allCovered = currentNonEssentialIds.every((id) => coveredIds.has(id))
if (!allCovered) {
// Fall through to the banner path below. Reset the early writes
// so the default-branch state assignments take effect correctly.
consentState.showConsentToast = initialUIValues.initialLayer === 0
consentState.hasConsented = hasConsented
return
}
// Restore the user's exact per-service state — handles deny and any
// partial/category-level decision made via Privacy Settings. hasConsented
// is computed from SDK state after the restore resolves (will be false
// unless every service was accepted).
return UC.updateServices(priorDecision.decisions)
.then(() => {
consentState.hasConsented = UC.areAllConsentsAccepted()
consentState.categories = UC.getCategoriesBaseInfo()
})
.catch(() => {
// Falling back to the banner is safer than silently flipping to a
// uniform state the user didn't choose.
consentState.showConsentToast = true
})
}
// 0 = first layer, aka show consent toast
consentState.showConsentToast = initialUIValues.initialLayer === 0
consentState.hasConsented = hasConsented
consentState.categories = UC.getCategoriesBaseInfo()
// If the user has previously consented (before usercentrics), accept all services
if (!hasConsented && localStorage?.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) === 'true') {
consentState.acceptAll()
localStorage.removeItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT)
}
}
async function initUserCentrics() {
if (process.env.NODE_ENV === 'test' || !IS_PLATFORM) return
// [Alaister] For local development and staging, we accept all consent by default.
// If you need to test usercentrics in these environments, comment out this
// NEXT_PUBLIC_ENVIRONMENT check and add an ngrok domain to usercentrics
if (
process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' ||
process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging'
) {
consentState.hasConsented = true
consentState.isResolved = true
return
}
// Check for prior consent BEFORE UC.init(), which can't read the compressed
// ucData format written by the GTM/Usercentrics integration (FE-2648).
const priorDecision = detectPriorConsent()
try {
const { default: Usercentrics } = await import('@usercentrics/cmp-browser-sdk')
const UC = new Usercentrics(process.env.NEXT_PUBLIC_USERCENTRICS_RULESET_ID!, {
rulesetId: process.env.NEXT_PUBLIC_USERCENTRICS_RULESET_ID,
useRulesetId: true,
})
const initialUIValues = await UC.init()
await applyPriorDecisionToSDK(UC, initialUIValues, priorDecision)
} catch (error) {
console.error('Failed to initialize Usercentrics:', error)
// If SDK fails but user previously accepted uniformly, honor that.
// For explicit per-service decisions we can't restore without the SDK,
// and showing the banner when the SDK is broken would fail anyway.
if (priorDecision?.kind === 'uniform-accept') {
consentState.hasConsented = true
}
} finally {
consentState.isResolved = true
}
}
// Usercentrics is not available on the server
if (typeof window !== 'undefined') {
initUserCentrics()
}
export function hasConsented() {
return snapshot(consentState).hasConsented
}
export function useConsentState() {
const snap = useSnapshot(consentState)
return {
hasAccepted: snap.hasConsented,
categories: snap.categories as BaseCategory[] | null,
acceptAll: snap.acceptAll,
denyAll: snap.denyAll,
updateServices: snap.updateServices,
}
}