Repository navigation
feat(consent): CMP adapters for Cookiebot, OneTrust, and Complianz #41
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import type { ConsentHub } from '../listener.js'; | ||
| import { createEmitter, noop, resolveTarget, toRecord } from './shared.js'; | ||
| import type { CmpAdapterOptions, Disposer } from './shared.js'; | ||
|
|
||
| type HasConsent = (category: string) => boolean; | ||
|
|
||
| // Complianz 6+ fires cmplz_fire_categories with event.detail.categories (the | ||
| // accepted categories) and cmplz_status_change on later changes. | ||
| const EVENTS = ['cmplz_fire_categories', 'cmplz_status_change']; | ||
|
|
||
| /** | ||
| * Forward Complianz decisions to the hub. Reads the documented | ||
| * `cmplz_has_consent(category)` API; falls back to the event's accepted | ||
| * categories when the function is unavailable. | ||
| */ | ||
| export function connectComplianz(hub: ConsentHub, options?: CmpAdapterOptions): Disposer { | ||
| const target = resolveTarget(options); | ||
| if (!target) return noop; | ||
| const now = options?.now ?? (() => new Date()); | ||
| const emit = createEmitter(hub); | ||
| const doc = (target.document as typeof target | undefined) ?? target; | ||
|
|
||
| const read = (event?: unknown) => { | ||
| const hasConsent = target.cmplz_has_consent as HasConsent | undefined; | ||
| const categories = (event as { detail?: { categories?: unknown } } | undefined)?.detail?.categories; | ||
| let marketing: boolean; | ||
| let analytics: boolean; | ||
| if (typeof hasConsent === 'function') { | ||
| marketing = !!hasConsent('marketing'); | ||
| analytics = !!hasConsent('statistics'); | ||
| } else if (Array.isArray(categories)) { | ||
| marketing = categories.includes('marketing'); | ||
| analytics = categories.includes('statistics'); | ||
| } else { | ||
| return; // no decision available: pending | ||
| } | ||
| emit(toRecord({ marketing, analytics }, 'complianz', now)); | ||
| }; | ||
|
|
||
| EVENTS.forEach((type) => doc.addEventListener(type, read)); | ||
| // A stored decision marks the banner 'dismissed'; before that the visitor is pending. | ||
| const status = typeof target.cmplz_get_banner_status === 'function' | ||
| ? String((target.cmplz_get_banner_status as () => unknown)()) | ||
| : ''; | ||
| if (status === 'dismissed') read(); | ||
|
|
||
| return () => EVENTS.forEach((type) => doc.removeEventListener(type, read)); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import type { ConsentHub } from '../listener.js'; | ||
| import { createEmitter, noop, resolveTarget, toRecord } from './shared.js'; | ||
| import type { CmpAdapterOptions, Disposer } from './shared.js'; | ||
|
|
||
| interface CookiebotGlobal { | ||
| hasResponse?: boolean; | ||
| consent?: { marketing?: boolean; statistics?: boolean }; | ||
| } | ||
|
|
||
| // Cookiebot may wipe unclassified storage before these fire, so capture must | ||
| // run on the emitted grant (reading the click ID from the current URL). | ||
| const EVENTS = ['CookiebotOnConsentReady', 'CookiebotOnAccept', 'CookiebotOnDecline']; | ||
|
|
||
| /** | ||
| * Forward Cookiebot decisions to the hub. Stays subscribed so in-page changes | ||
| * and withdrawals (the renew dialog) are delivered, not only the first answer. | ||
| */ | ||
| export function connectCookiebot(hub: ConsentHub, options?: CmpAdapterOptions): Disposer { | ||
| const target = resolveTarget(options); | ||
| if (!target) return noop; | ||
| const now = options?.now ?? (() => new Date()); | ||
| const emit = createEmitter(hub); | ||
|
|
||
| const read = () => { | ||
| const cookiebot = target.Cookiebot as CookiebotGlobal | undefined; | ||
| if (!cookiebot?.hasResponse) return; // pending: never emit | ||
| emit( | ||
| toRecord( | ||
| { marketing: !!cookiebot.consent?.marketing, analytics: !!cookiebot.consent?.statistics }, | ||
| 'cookiebot', | ||
| now, | ||
| ), | ||
| ); | ||
| }; | ||
|
|
||
| EVENTS.forEach((type) => target.addEventListener(type, read)); | ||
| read(); | ||
| return () => EVENTS.forEach((type) => target.removeEventListener(type, read)); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| export { connectCookiebot } from './cookiebot.js'; | ||
| export { connectOneTrust } from './onetrust.js'; | ||
| export type { OneTrustOptions } from './onetrust.js'; | ||
| export { connectComplianz } from './complianz.js'; | ||
| export type { CmpAdapterOptions, CmpTarget, Disposer } from './shared.js'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import type { ConsentHub } from '../listener.js'; | ||
| import { createEmitter, noop, resolveTarget, toRecord } from './shared.js'; | ||
| import type { CmpAdapterOptions, Disposer } from './shared.js'; | ||
|
|
||
| export interface OneTrustOptions extends CmpAdapterOptions { | ||
| /** OneTrust group for targeting/marketing cookies (default 'C0004'). */ | ||
| marketingGroup?: string; | ||
| /** OneTrust group for performance/analytics cookies (default 'C0002'). */ | ||
| analyticsGroup?: string; | ||
| } | ||
|
|
||
| /** | ||
| * Forward OneTrust decisions to the hub. Listens to `OneTrustGroupsUpdated` and | ||
| * also wraps OptanonWrapper (the host's wrapper still runs), so a later | ||
| * reassignment of OptanonWrapper by the install snippet cannot silence it. | ||
| */ | ||
| export function connectOneTrust(hub: ConsentHub, options?: OneTrustOptions): Disposer { | ||
| const target = resolveTarget(options); | ||
| if (!target) return noop; | ||
| const now = options?.now ?? (() => new Date()); | ||
| const marketingGroup = options?.marketingGroup ?? 'C0004'; | ||
| const analyticsGroup = options?.analyticsGroup ?? 'C0002'; | ||
| const emit = createEmitter(hub); | ||
| let disposed = false; | ||
|
|
||
| const read = () => { | ||
| if (disposed) return; // our wrapper may still sit inside another wrapper chain | ||
| const raw = target.OnetrustActiveGroups; | ||
| if (typeof raw !== 'string' || raw === '') return; // SDK not loaded yet | ||
| // Exact group match; ",C0004," style lists must not match "C00040". | ||
| const groups = new Set(raw.split(',').map((group) => group.trim()).filter(Boolean)); | ||
| emit( | ||
| toRecord( | ||
| { marketing: groups.has(marketingGroup), analytics: groups.has(analyticsGroup) }, | ||
| 'onetrust', | ||
| now, | ||
| ), | ||
| ); | ||
| }; | ||
|
|
||
| const hostWrapper = target.OptanonWrapper; | ||
| const wrapper = function (this: unknown, ...args: unknown[]) { | ||
| if (typeof hostWrapper === 'function') hostWrapper.apply(this, args); | ||
| read(); | ||
| }; | ||
| target.OptanonWrapper = wrapper; | ||
| target.addEventListener('OneTrustGroupsUpdated', read); | ||
| read(); | ||
|
|
||
| return () => { | ||
| disposed = true; | ||
| target.removeEventListener('OneTrustGroupsUpdated', read); | ||
| if (target.OptanonWrapper === wrapper) target.OptanonWrapper = hostWrapper; | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| /** | ||
| * Shared seams for CMP adapters. Nothing here touches the DOM at import time; | ||
| * adapters receive the window-like target when they are connected. | ||
| */ | ||
| import type { ConsentHub } from '../listener.js'; | ||
| import type { ConsentRecord } from '../types.js'; | ||
|
|
||
| /** Minimal window/document surface the adapters need (injectable for tests). */ | ||
| export interface CmpTarget { | ||
| addEventListener(type: string, listener: (event: unknown) => void): void; | ||
| removeEventListener(type: string, listener: (event: unknown) => void): void; | ||
| [key: string]: unknown; | ||
| } | ||
|
|
||
| export interface CmpAdapterOptions { | ||
| /** Window-like object; defaults to globalThis.window when present. */ | ||
| target?: CmpTarget; | ||
| /** Clock seam for the record's `at` field. */ | ||
| now?: () => Date; | ||
| } | ||
|
|
||
| export type Disposer = () => void; | ||
|
|
||
| export const noop: Disposer = () => {}; | ||
|
|
||
| export function resolveTarget(options: CmpAdapterOptions | undefined): CmpTarget | null { | ||
| if (options?.target) return options.target; | ||
| const win = (globalThis as { window?: unknown }).window; | ||
| return win && typeof (win as CmpTarget).addEventListener === 'function' ? (win as CmpTarget) : null; | ||
| } | ||
|
|
||
| /** | ||
| * Build a record. `state` follows marketing consent: storageAllowed() only | ||
| * checks `state`, so analytics-only consent must not unlock attribution storage | ||
| * (same rule as the ClickTrail WordPress consent bridge). | ||
| */ | ||
| export function toRecord( | ||
| purposes: { marketing: boolean; analytics: boolean }, | ||
| source: string, | ||
| now: () => Date, | ||
| ): ConsentRecord { | ||
| return { | ||
| state: purposes.marketing ? 'granted' : 'denied', | ||
| marketing: purposes.marketing, | ||
| advertising: purposes.marketing, | ||
| analytics: purposes.analytics, | ||
| source, | ||
| at: now().toISOString(), | ||
| }; | ||
| } | ||
|
|
||
| /** Notify only when the decision actually changed (CMPs fire several events per click). */ | ||
| export function createEmitter(hub: ConsentHub): (record: ConsentRecord) => void { | ||
| let last = ''; | ||
| return (record) => { | ||
| const key = `${record.state}|${record.marketing}|${record.analytics}`; | ||
| if (key === last) return; | ||
| last = key; | ||
| hub.notify(record); | ||
| }; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
cmplz_has_consentis unavailable—the explicitly supported fallback case—Complianz's latercmplz_status_changeevents provide the changeddetail.categoryanddetail.value, rather than adetail.categoriesarray. After an initialcmplz_fire_categoriesgrant, a subsequent marketing withdrawal therefore skips this branch and returns without notifying the hub, leaving stored attribution uncleared. Preserve the fallback category state and apply these category/value updates.Useful? React with 👍 / 👎.