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>
342 lines
13 KiB
TypeScript
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,
|
|
}
|
|
}
|