* fix(banner): bridge blocker state between loader and bundle
``consent-loader.js`` and ``consent-bundle.js`` are built as two
separate rollup IIFEs, so each inlines a private copy of
``blocker.ts``. The loader's copy is the one that actually matters
— it installs the ``document.cookie`` / ``Storage.prototype.setItem``
/ ``MutationObserver`` proxies, and those proxies close over the
loader's private ``acceptedCategories`` set and its
``blockedScripts`` queue.
The bundle used to ``import { updateAcceptedCategories } from './blocker'``
and call it from ``handleConsent``. That updated the **bundle's**
dead-end copy of the blocker module — a different ``Set`` object in
a different scope from the one the proxies read — so after the user
granted consent the loader's proxies stayed stuck on
``Set(['necessary'])``, any non-necessary cookie write kept being
silently dropped, and the loader's queue of blocked scripts was
never released.
Fix by exposing the loader's live ``updateAcceptedCategories`` on
``window.__consentos._updateBlocker`` right after
``installBlocker()``, and replacing the bundle's dead-end import
with a helper that calls through the bridge. The bundle no longer
imports from ``./blocker`` at all; rollup tree-shakes the bundle's
copy out, so ``consent-bundle.js`` gets slightly smaller.
Tests (``__tests__/blocker-bridge.test.ts``) cover:
* Bridge is called with the exact accepted list.
* Bridge is forwarded verbatim (no slug filtering).
* Missing bridge logs a warning and doesn't throw.
* Missing ``window.__consentos`` logs a warning and doesn't throw.
``vi.hoisted`` seeds ``window.__consentos`` before banner.ts's
module-level ``init()`` runs so the import-time IIFE doesn't throw
on the empty global.
* fix(banner): sweep disallowed cookies + storage on consent update
When the loader runs it now proactively deletes any pre-existing
cookies (and localStorage / sessionStorage keys) that classify into
a category the visitor hasn't consented to. Fixes the common case
of an ``_ga`` that slipped through before the loader was installed
— e.g. on a host page that loads the loader with ``async`` or
places another tracker above it in ``<head>`` — sitting there
forever because the blocker's only defence was a setter proxy on
future writes.
Sweep semantics:
- Runs on every ``updateAcceptedCategories`` call (consent narrowed
→ the just-revoked categories' cookies are wiped) plus an
explicit call from the loader's "no consent yet" branch (initial
visit with pre-existing trackers from elsewhere).
- Only deletes cookies / keys whose classifier hits a known
pattern (``_ga``, ``_fbp``, ``intercom-*`` etc. — same lists as
the proxied setters). Unknown / unclassified cookies are left
alone: we can't attribute them and won't risk clobbering
first-party session state.
- ``_consentos_*`` is always preserved.
- For cookies, we don't know the original ``domain`` / ``path``
(``document.cookie`` doesn't expose them), so we fire deletes
against every plausible domain variant — bare hostname, leading
``.``, and every parent domain walked up from the left. Covers
the GA "set on ``.example.com`` from a subdomain page" case
without over-deleting.
- Deletions bypass the proxied setter and go directly through the
cached ``originalCookieDescriptor`` captured before the proxy was
installed, so the blocker doesn't eat its own expiry writes.
- Storage access is wrapped in ``safeStorage`` — sandboxed /
cross-origin iframes that throw on ``window.localStorage`` are
skipped rather than crashing the loader.
Tests in ``__tests__/blocker.test.ts`` cover: non-consented analytics
cookies are deleted, consented ones are preserved, unknown cookies
survive, ``_consentos_*`` is untouched, revoking a category after
seeding new cookies triggers a follow-up sweep, and localStorage
cleanup follows the same rules. 6 new cases, 373 passing total in
the banner suite.
236 lines
8.3 KiB
TypeScript
236 lines
8.3 KiB
TypeScript
/**
|
|
* consent-loader.js — Lightweight synchronous bootstrap (~2KB gzipped).
|
|
*
|
|
* Runs before any other scripts on the page. Responsibilities:
|
|
* 1. Read existing consent cookie — if valid, apply consent state immediately
|
|
* 2. Set Google Consent Mode defaults (all denied except security_storage)
|
|
* 3. If no consent: async-load the full banner bundle
|
|
* 4. Fetch site config from CDN/API
|
|
*/
|
|
|
|
import {
|
|
installBlocker,
|
|
sweepDisallowedState,
|
|
updateAcceptedCategories,
|
|
} from './blocker';
|
|
import { hasConsent, readConsent } from './consent';
|
|
import { buildDeniedDefaults, buildGcmStateFromCategories, setGcmDefaults, updateGcm } from './gcm';
|
|
import { isGpcEnabled } from './gpc';
|
|
import type { GppApiCallback, GppApiFunction, GppQueueEntry } from './gpp-api';
|
|
import type { CategorySlug } from './types';
|
|
|
|
declare global {
|
|
interface Window {
|
|
__consentos: {
|
|
siteId: string;
|
|
apiBase: string;
|
|
cdnBase: string;
|
|
loaded: boolean;
|
|
/** Visitor region from GeoIP (e.g. 'US-CA'), set by loader. */
|
|
visitorRegion?: string;
|
|
/** Whether GPC signal was detected by the loader. */
|
|
gpcDetected?: boolean;
|
|
/**
|
|
* Internal: drives the blocker installed by the loader. The
|
|
* banner bundle is a separate IIFE with its own module scope,
|
|
* so it can't share ``acceptedCategories`` via a direct import
|
|
* — it has to call back through this bridge. See
|
|
* ``apps/banner/src/blocker.ts`` for the state it mutates.
|
|
* Consumers outside the banner bundle should not call this.
|
|
*/
|
|
_updateBlocker?: (accepted: CategorySlug[]) => void;
|
|
};
|
|
/** Public ConsentOS API for site integration. */
|
|
ConsentOS: {
|
|
/**
|
|
* Identify a user by providing their third-party JWT.
|
|
* Syncs consent with the server-side profile.
|
|
* Returns categories that still need consent (empty if fully resolved).
|
|
*/
|
|
identifyUser: (jwt: string) => Promise<string[]>;
|
|
/** Clear the identified user session (revert to anonymous). */
|
|
clearIdentity: () => void;
|
|
/**
|
|
* Re-open the banner so the visitor can review, change, or
|
|
* withdraw their consent. Pre-fills category toggles from the
|
|
* current stored consent state. Required by GDPR Art. 7(3)
|
|
* ("it shall be as easy to withdraw as to give consent").
|
|
*/
|
|
showPreferences: () => void;
|
|
};
|
|
}
|
|
}
|
|
|
|
(function consentosLoader() {
|
|
// Read data attributes from the script tag, falling back to
|
|
// window.__consentos if attributes are absent (e.g. GTM injectScript).
|
|
const scriptEl = document.currentScript as HTMLScriptElement | null;
|
|
const gtmConfig = (window as any).__consentos;
|
|
const siteId = scriptEl?.getAttribute('data-site-id') ?? gtmConfig?.siteId ?? '';
|
|
const apiBase = scriptEl?.getAttribute('data-api-base') ?? gtmConfig?.apiBase ?? '';
|
|
|
|
// Derive cdnBase: explicit attribute > apiBase > same origin as this script
|
|
const scriptSrc = scriptEl?.getAttribute('src') ?? '';
|
|
let scriptOrigin = '';
|
|
try {
|
|
if (scriptSrc) {
|
|
scriptOrigin = new URL(scriptSrc, window.location.href).origin;
|
|
}
|
|
} catch {
|
|
// Invalid URL — fall through to empty string
|
|
}
|
|
const cdnBase = scriptEl?.getAttribute('data-cdn-base') ?? (apiBase || scriptOrigin);
|
|
|
|
// Expose global CMP context
|
|
window.__consentos = {
|
|
siteId,
|
|
apiBase,
|
|
cdnBase,
|
|
loaded: false,
|
|
};
|
|
|
|
// Expose public CMP API — methods are stubs until the full bundle loads
|
|
// and replaces them with real implementations.
|
|
window.ConsentOS = {
|
|
identifyUser: async () => {
|
|
console.warn('[ConsentOS] identifyUser called before bundle loaded — queuing');
|
|
return [];
|
|
},
|
|
clearIdentity: () => {
|
|
console.warn('[ConsentOS] clearIdentity called before bundle loaded');
|
|
},
|
|
showPreferences: () => {
|
|
console.warn('[ConsentOS] showPreferences called before bundle loaded');
|
|
},
|
|
};
|
|
|
|
// Warn if essential attributes are missing
|
|
if (!siteId) {
|
|
console.warn('[ConsentOS] Missing data-site-id attribute on the consent-loader script tag');
|
|
}
|
|
if (!apiBase) {
|
|
console.warn('[ConsentOS] Missing data-api-base attribute — consent recording will not work');
|
|
}
|
|
|
|
// 1. Install script/cookie blocker immediately (before any third-party scripts)
|
|
installBlocker();
|
|
|
|
// 1a. Bridge the blocker to the full banner bundle. ``consent-bundle.js``
|
|
// is built as a separate rollup IIFE with its own module scope, so it
|
|
// gets its own dead-end copy of ``blocker.ts``. Expose the loader's
|
|
// live ``updateAcceptedCategories`` on ``window.__consentos`` so
|
|
// ``handleConsent`` in the bundle can drive the loader's proxies
|
|
// directly. Without this, consent updates from the bundle would only
|
|
// mutate the bundle's copy and the cookie/storage proxies running in
|
|
// the loader's scope would stay stuck on ``Set(['necessary'])``.
|
|
window.__consentos._updateBlocker = updateAcceptedCategories;
|
|
|
|
// 1b. Install __gpp stub — queues calls until the full bundle loads
|
|
installGppStub();
|
|
|
|
// 2. Set GCM defaults immediately (must happen before gtag tags fire)
|
|
setGcmDefaults(buildDeniedDefaults());
|
|
|
|
// 2b. Detect GPC signal early and store on __cmp for the banner bundle
|
|
window.__consentos.gpcDetected = isGpcEnabled();
|
|
|
|
// 3. Check for existing consent
|
|
const existingConsent = readConsent();
|
|
|
|
if (existingConsent) {
|
|
// Consent already given — update blocker (which also sweeps any
|
|
// cookies / storage in non-accepted categories), update GCM, and
|
|
// we're done. ``updateAcceptedCategories`` runs the sweep
|
|
// internally so historical trackers from a previously-wider
|
|
// consent set get cleaned up.
|
|
updateAcceptedCategories(existingConsent.accepted as import('./types').CategorySlug[]);
|
|
const gcmState = buildGcmStateFromCategories(existingConsent.accepted);
|
|
updateGcm(gcmState);
|
|
|
|
// Fire consent-change event so GTM/other scripts know
|
|
dispatchConsentEvent(existingConsent.accepted);
|
|
return;
|
|
}
|
|
|
|
// 4. No consent. Sweep any pre-existing classified trackers
|
|
// (typically ``_ga``, ``_fbp`` and friends that slipped in before
|
|
// the blocker was installed — e.g. from a script-ordering bug on
|
|
// the host page) so the visitor starts from a clean slate. Runs
|
|
// against the default ``Set(['necessary'])`` so every non-necessary
|
|
// known tracker is deleted.
|
|
sweepDisallowedState();
|
|
|
|
// 5. Async-load the full banner bundle
|
|
loadBannerBundle(cdnBase);
|
|
})();
|
|
|
|
/** Async-load the full consent banner bundle. */
|
|
function loadBannerBundle(cdnBase: string): void {
|
|
const script = document.createElement('script');
|
|
// Mark as allowed so the blocker's MutationObserver doesn't intercept it
|
|
script.setAttribute('data-consentos-allowed', 'true');
|
|
script.src = `${cdnBase}/consent-bundle.js`;
|
|
script.async = true;
|
|
script.onload = () => {
|
|
window.__consentos.loaded = true;
|
|
};
|
|
script.onerror = () => {
|
|
console.error(`[ConsentOS] Failed to load consent bundle from ${cdnBase}/consent-bundle.js`);
|
|
};
|
|
document.head.appendChild(script);
|
|
}
|
|
|
|
/**
|
|
* Install a lightweight __gpp() stub that queues calls until the full
|
|
* banner bundle loads and replaces it with the real implementation.
|
|
*/
|
|
function installGppStub(): void {
|
|
if (typeof window === 'undefined') return;
|
|
if (window.__gpp) return; // Already installed
|
|
|
|
const queue: GppQueueEntry[] = [];
|
|
window.__gppQueue = queue;
|
|
|
|
const stub: GppApiFunction = function gppStub(
|
|
command: string,
|
|
callback: GppApiCallback,
|
|
parameter?: unknown,
|
|
) {
|
|
if (command === 'ping') {
|
|
callback(
|
|
{
|
|
gppVersion: '1.1',
|
|
cmpStatus: 'stub',
|
|
cmpDisplayStatus: 'hidden',
|
|
signalStatus: 'not ready',
|
|
supportedAPIs: [],
|
|
cmpId: 0,
|
|
gppString: '',
|
|
applicableSections: [],
|
|
},
|
|
true,
|
|
);
|
|
return;
|
|
}
|
|
queue.push([command, callback, parameter]);
|
|
};
|
|
|
|
window.__gpp = stub;
|
|
}
|
|
|
|
/** Dispatch a custom event with accepted categories. */
|
|
function dispatchConsentEvent(accepted: string[]): void {
|
|
const event = new CustomEvent('consentos:consent-change', {
|
|
detail: { accepted },
|
|
});
|
|
document.dispatchEvent(event);
|
|
|
|
// Also push to dataLayer for GTM
|
|
if (typeof window.dataLayer !== 'undefined') {
|
|
window.dataLayer.push({
|
|
event: 'consentos_consent_change',
|
|
cmp_accepted_categories: accepted,
|
|
});
|
|
}
|
|
}
|