fix(banner): bridge blocker state loader↔bundle and sweep stale cookies on consent change (#4)
* 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.
This commit is contained in:
116
apps/banner/src/__tests__/blocker-bridge.test.ts
Normal file
116
apps/banner/src/__tests__/blocker-bridge.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Tests for the loader ↔ bundle blocker bridge.
|
||||
*
|
||||
* ``consent-loader.js`` and ``consent-bundle.js`` are compiled as
|
||||
* separate rollup IIFEs, so each one inlines its own copy of
|
||||
* ``blocker.ts`` with private module state. The bundle therefore
|
||||
* can't reach the loader's ``acceptedCategories`` set via a direct
|
||||
* import — it has to call through ``window.__consentos._updateBlocker``,
|
||||
* which the loader sets after ``installBlocker()``.
|
||||
*
|
||||
* We mock the imports the banner module pulls in so importing
|
||||
* ``banner.ts`` here doesn't try to hit real network / timers.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Seed ``window.__consentos`` before banner.ts's init() IIFE runs at
|
||||
// import time. Without this, destructuring ``window.__consentos`` at
|
||||
// the top of init() throws and fills the test output with noise.
|
||||
vi.hoisted(() => {
|
||||
(globalThis as any).window = (globalThis as any).window || globalThis;
|
||||
(globalThis as any).window.__consentos = {
|
||||
siteId: '',
|
||||
apiBase: '',
|
||||
cdnBase: '',
|
||||
loaded: false,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../consent', () => ({
|
||||
buildConsentState: vi.fn(() => ({ accepted: [], rejected: [] })),
|
||||
readConsent: vi.fn(() => null),
|
||||
writeConsent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../gcm', () => ({
|
||||
buildGcmStateFromCategories: vi.fn(() => ({})),
|
||||
updateGcm: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../i18n', () => ({
|
||||
DEFAULT_TRANSLATIONS: {},
|
||||
detectLocale: vi.fn(() => 'en'),
|
||||
interpolate: vi.fn((s: string) => s),
|
||||
loadTranslations: vi.fn(async () => ({})),
|
||||
renderLinks: vi.fn((s: string) => s),
|
||||
}));
|
||||
|
||||
vi.mock('../a11y', () => ({
|
||||
announce: vi.fn(),
|
||||
createLiveRegion: vi.fn(),
|
||||
focusFirst: vi.fn(),
|
||||
onEscape: vi.fn(() => () => {}),
|
||||
prefersReducedMotion: vi.fn(() => false),
|
||||
trapFocus: vi.fn(() => () => {}),
|
||||
}));
|
||||
|
||||
// Prevent banner.ts's init() IIFE from running against real globals.
|
||||
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('mocked'))));
|
||||
|
||||
import { updateAcceptedCategories } from '../banner';
|
||||
|
||||
describe('loader ↔ bundle blocker bridge', () => {
|
||||
beforeEach(() => {
|
||||
window.__consentos = {
|
||||
siteId: 'test',
|
||||
apiBase: 'https://api.example.com',
|
||||
cdnBase: 'https://cdn.example.com',
|
||||
loaded: false,
|
||||
};
|
||||
});
|
||||
|
||||
it('calls window.__consentos._updateBlocker when the bridge is present', () => {
|
||||
const bridge = vi.fn();
|
||||
window.__consentos._updateBlocker = bridge;
|
||||
|
||||
updateAcceptedCategories(['necessary', 'analytics']);
|
||||
|
||||
expect(bridge).toHaveBeenCalledTimes(1);
|
||||
expect(bridge).toHaveBeenCalledWith(['necessary', 'analytics']);
|
||||
});
|
||||
|
||||
it('forwards the exact array reference so the loader sees every slug', () => {
|
||||
const bridge = vi.fn();
|
||||
window.__consentos._updateBlocker = bridge;
|
||||
|
||||
const accepted = ['necessary', 'functional', 'marketing'] as const;
|
||||
updateAcceptedCategories([...accepted]);
|
||||
|
||||
const args = bridge.mock.calls[0][0];
|
||||
expect(args).toEqual(['necessary', 'functional', 'marketing']);
|
||||
});
|
||||
|
||||
it('warns and returns cleanly when the bridge is missing', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
delete window.__consentos._updateBlocker;
|
||||
|
||||
expect(() => updateAcceptedCategories(['necessary'])).not.toThrow();
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('blocker bridge missing'),
|
||||
);
|
||||
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('warns when window.__consentos is missing entirely', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
// @ts-expect-error — simulating a pre-init state
|
||||
window.__consentos = undefined;
|
||||
|
||||
expect(() => updateAcceptedCategories(['necessary'])).not.toThrow();
|
||||
expect(warn).toHaveBeenCalled();
|
||||
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
isCategoryAllowed,
|
||||
addScriptPatterns,
|
||||
loadInitiatorMappings,
|
||||
sweepDisallowedState,
|
||||
} from '../blocker';
|
||||
|
||||
describe('blocker', () => {
|
||||
@@ -330,4 +331,109 @@ describe('blocker', () => {
|
||||
expect(isCategoryAllowed('necessary')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sweepDisallowedState', () => {
|
||||
/**
|
||||
* Reset the cookie jar by expiring any test cookies we know
|
||||
* about. ``document.cookie =`` runs through the proxy so
|
||||
* ``_consentos_*`` passes the shortcut and everything else gets
|
||||
* eaten by the blocker's classifier. Expiring the well-known
|
||||
* analytics cookies here gives the individual tests a clean
|
||||
* starting point without relying on the jsdom harness to wipe
|
||||
* between tests.
|
||||
*/
|
||||
function resetCookies(names: string[]) {
|
||||
for (const name of names) {
|
||||
document.cookie = `_consentos_reset_marker=${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Directly seed cookies via the native descriptor so the
|
||||
// proxied setter doesn't eat them as "tracker writes with
|
||||
// no consent".
|
||||
const nativeSet = Object.getOwnPropertyDescriptor(
|
||||
Document.prototype,
|
||||
'cookie',
|
||||
)?.set;
|
||||
if (!nativeSet) throw new Error('cannot locate native cookie setter');
|
||||
nativeSet.call(
|
||||
document,
|
||||
'_ga=GA1.2.seed; path=/',
|
||||
);
|
||||
nativeSet.call(
|
||||
document,
|
||||
'_fbp=fb.2.seed; path=/',
|
||||
);
|
||||
nativeSet.call(
|
||||
document,
|
||||
'unknown_cookie=opaque; path=/',
|
||||
);
|
||||
nativeSet.call(
|
||||
document,
|
||||
'_consentos_consent=%7B%7D; path=/',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetCookies(['_ga', '_fbp', 'unknown_cookie', '_consentos_consent']);
|
||||
});
|
||||
|
||||
it('deletes non-consented analytics cookies', () => {
|
||||
updateAcceptedCategories(['necessary']);
|
||||
// ^^ updateAcceptedCategories calls sweep internally; the
|
||||
// assertions below verify the post-sweep cookie jar.
|
||||
expect(document.cookie).not.toContain('_ga=');
|
||||
expect(document.cookie).not.toContain('_fbp=');
|
||||
});
|
||||
|
||||
it('leaves consented cookies alone', () => {
|
||||
updateAcceptedCategories(['necessary', 'analytics', 'marketing']);
|
||||
expect(document.cookie).toContain('_ga=');
|
||||
expect(document.cookie).toContain('_fbp=');
|
||||
});
|
||||
|
||||
it('leaves unknown cookies alone even without consent', () => {
|
||||
updateAcceptedCategories(['necessary']);
|
||||
expect(document.cookie).toContain('unknown_cookie=');
|
||||
});
|
||||
|
||||
it('never touches _consentos_* cookies', () => {
|
||||
updateAcceptedCategories(['necessary']);
|
||||
expect(document.cookie).toContain('_consentos_consent=');
|
||||
});
|
||||
|
||||
it('standalone sweepDisallowedState respects the current set', () => {
|
||||
updateAcceptedCategories(['necessary', 'analytics']);
|
||||
// Re-seed _ga after the first sweep would have left it (analytics consented).
|
||||
const nativeSet = Object.getOwnPropertyDescriptor(
|
||||
Document.prototype,
|
||||
'cookie',
|
||||
)?.set;
|
||||
nativeSet?.call(document, '_fbp=fb.2.reseed; path=/');
|
||||
|
||||
// Revoke marketing, sweep again.
|
||||
updateAcceptedCategories(['necessary', 'analytics']);
|
||||
sweepDisallowedState();
|
||||
expect(document.cookie).toContain('_ga=');
|
||||
expect(document.cookie).not.toContain('_fbp=');
|
||||
});
|
||||
|
||||
it('cleans non-consented localStorage keys', () => {
|
||||
localStorage.setItem('_consentos_keep', 'yes');
|
||||
// Seed via a direct setItem — the proxied setter would block
|
||||
// non-necessary writes, but we want a pre-existing key.
|
||||
const nativeSetItem = Object.getPrototypeOf(localStorage).setItem;
|
||||
nativeSetItem.call(localStorage, '_ga_stuff', 'tracker');
|
||||
nativeSetItem.call(localStorage, 'opaque_key', 'leave-alone');
|
||||
|
||||
updateAcceptedCategories(['necessary']);
|
||||
|
||||
expect(localStorage.getItem('_consentos_keep')).toBe('yes');
|
||||
expect(localStorage.getItem('_ga_stuff')).toBeNull();
|
||||
expect(localStorage.getItem('opaque_key')).toBe('leave-alone');
|
||||
|
||||
localStorage.clear();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,12 +10,39 @@
|
||||
*/
|
||||
|
||||
import { announce, createLiveRegion, focusFirst, onEscape, prefersReducedMotion, trapFocus } from './a11y';
|
||||
import { updateAcceptedCategories } from './blocker';
|
||||
// NB: intentionally NOT importing from './blocker'. The loader already
|
||||
// installed the blocker proxies in its own IIFE module scope, and
|
||||
// the bundle can't share that state via a direct import — rollup
|
||||
// builds ``consent-loader.js`` and ``consent-bundle.js`` as separate
|
||||
// IIFEs so each one inlines its own private copy of every module.
|
||||
// The loader exposes ``_updateBlocker`` on ``window.__consentos``
|
||||
// for us to drive its proxies — see ``updateAcceptedCategories``
|
||||
// below and ``apps/banner/src/loader.ts``.
|
||||
import { buildConsentState, readConsent, writeConsent } from './consent';
|
||||
import { buildGcmStateFromCategories, updateGcm } from './gcm';
|
||||
import { type TranslationStrings, DEFAULT_TRANSLATIONS, detectLocale, interpolate, loadTranslations, renderLinks } from './i18n';
|
||||
import type { BannerConfig, ButtonConfig, CategorySlug, SiteConfig } from './types';
|
||||
|
||||
/**
|
||||
* Drive the loader's blocker proxies with a new accepted-categories
|
||||
* set. Falls back to a ``console.warn`` if the bridge is missing,
|
||||
* which would mean the loader hasn't finished ``installBlocker`` yet
|
||||
* (shouldn't happen — the bundle only loads after the loader's
|
||||
* synchronous init phase). Exported for unit testing only.
|
||||
*/
|
||||
export function updateAcceptedCategories(accepted: CategorySlug[]): void {
|
||||
const bridge = window.__consentos?._updateBlocker;
|
||||
if (typeof bridge === 'function') {
|
||||
bridge(accepted);
|
||||
} else if (typeof console !== 'undefined') {
|
||||
console.warn(
|
||||
'[ConsentOS] blocker bridge missing — consent granted but ' +
|
||||
'cookie/script blocker state was not updated. The loader ' +
|
||||
'may not have initialised correctly.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Preference-centre closure captured during init() ---------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -122,12 +122,29 @@ export function loadInitiatorMappings(mappings: InitiatorMapping[]): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the set of accepted categories and release any blocked scripts
|
||||
* that now have consent.
|
||||
* Update the set of accepted categories, release any blocked scripts
|
||||
* that now have consent, and sweep any existing cookies / storage
|
||||
* items that belong to a category the visitor has **not** consented
|
||||
* to. Consented categories are left untouched — those cookies are
|
||||
* presumed to be in use by the site.
|
||||
*/
|
||||
export function updateAcceptedCategories(categories: CategorySlug[]): void {
|
||||
acceptedCategories = new Set(categories);
|
||||
releaseBlockedScripts();
|
||||
sweepDisallowedState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete any existing cookies and storage items whose classified
|
||||
* category isn't currently consented. Runs on install and every
|
||||
* consent-state update so historical trackers from pre-consent, a
|
||||
* previous session, or a narrowed consent decision get removed.
|
||||
* Unknown / unclassified cookies are left alone since we can't
|
||||
* attribute them to a category.
|
||||
*/
|
||||
export function sweepDisallowedState(): void {
|
||||
sweepDisallowedCookies();
|
||||
sweepDisallowedStorage();
|
||||
}
|
||||
|
||||
/** Get the current blocked script count (useful for debugging/reporting). */
|
||||
@@ -492,6 +509,114 @@ function allNonEssentialConsented(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sweep existing state ──────────────────────────────────────────────
|
||||
|
||||
/** Delete classified cookies that aren't in a consented category. */
|
||||
function sweepDisallowedCookies(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (!originalCookieDescriptor?.set) return;
|
||||
|
||||
const cookieHeader = document.cookie || '';
|
||||
if (!cookieHeader) return;
|
||||
|
||||
const nativeSet = originalCookieDescriptor.set.bind(document);
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const entry of cookieHeader.split(';')) {
|
||||
const name = parseCookieName(entry);
|
||||
if (!name || seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
|
||||
// Never touch ConsentOS's own cookies.
|
||||
if (name.startsWith('_consentos_')) continue;
|
||||
|
||||
const category = classifyCookie(name);
|
||||
// Unknown / unclassified cookies get left alone — we can't
|
||||
// attribute them so we can't safely delete them.
|
||||
if (!category || category === 'necessary') continue;
|
||||
if (acceptedCategories.has(category)) continue;
|
||||
|
||||
// Expire the cookie. We don't know the domain / path the cookie
|
||||
// was set on, so we fire deletes for every plausible combination:
|
||||
// the current hostname bare, the leading-dot form, and every
|
||||
// parent domain walked up from the left. This catches the common
|
||||
// case of analytics cookies set on ``.example.com`` from a
|
||||
// subdomain page without over-deleting.
|
||||
const expired = 'expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
|
||||
try {
|
||||
nativeSet(`${name}=; ${expired}`);
|
||||
for (const domain of domainVariants()) {
|
||||
nativeSet(`${name}=; ${expired}; domain=${domain}`);
|
||||
}
|
||||
} catch {
|
||||
// Writing a cookie can throw in exotic sandboxed contexts;
|
||||
// best-effort, don't crash the loader.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Derived list of plausible cookie domains for the current hostname. */
|
||||
function domainVariants(): string[] {
|
||||
if (typeof location === 'undefined' || !location.hostname) return [];
|
||||
|
||||
const hostname = location.hostname;
|
||||
// IP addresses and ``localhost`` have no "parent domain" concept.
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname === 'localhost') {
|
||||
return [hostname];
|
||||
}
|
||||
|
||||
const parts = hostname.split('.');
|
||||
const variants: string[] = [];
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const parent = parts.slice(i).join('.');
|
||||
if (parent) {
|
||||
variants.push(parent, `.${parent}`);
|
||||
}
|
||||
}
|
||||
return Array.from(new Set(variants));
|
||||
}
|
||||
|
||||
/** Delete classified localStorage / sessionStorage keys that aren't consented. */
|
||||
function sweepDisallowedStorage(): void {
|
||||
if (typeof Storage === 'undefined') return;
|
||||
|
||||
for (const storage of [safeStorage('local'), safeStorage('session')]) {
|
||||
if (!storage) continue;
|
||||
|
||||
const toRemove: string[] = [];
|
||||
try {
|
||||
for (let i = 0; i < storage.length; i++) {
|
||||
const key = storage.key(i);
|
||||
if (!key || key.startsWith('_consentos_')) continue;
|
||||
const category = classifyStorageKey(key);
|
||||
if (!category || category === 'necessary') continue;
|
||||
if (acceptedCategories.has(category)) continue;
|
||||
toRemove.push(key);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const key of toRemove) {
|
||||
try {
|
||||
storage.removeItem(key);
|
||||
} catch {
|
||||
// Ignore quota / security errors — best-effort.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the requested Storage instance, or null if inaccessible. */
|
||||
function safeStorage(kind: 'local' | 'session'): Storage | null {
|
||||
try {
|
||||
return kind === 'local' ? window.localStorage : window.sessionStorage;
|
||||
} catch {
|
||||
// Access can throw on cross-origin / sandboxed iframes.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Teardown (for testing) ───
|
||||
|
||||
/** Remove all interception hooks. Used in tests. */
|
||||
|
||||
@@ -8,11 +8,16 @@
|
||||
* 4. Fetch site config from CDN/API
|
||||
*/
|
||||
|
||||
import { installBlocker, updateAcceptedCategories } from './blocker';
|
||||
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 {
|
||||
@@ -25,6 +30,15 @@ declare global {
|
||||
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: {
|
||||
@@ -101,6 +115,16 @@ declare global {
|
||||
// 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();
|
||||
|
||||
@@ -114,7 +138,11 @@ declare global {
|
||||
const existingConsent = readConsent();
|
||||
|
||||
if (existingConsent) {
|
||||
// Consent already given — update blocker, GCM, and we're done
|
||||
// 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);
|
||||
@@ -124,7 +152,15 @@ declare global {
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. No consent — async-load the full banner bundle
|
||||
// 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);
|
||||
})();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user