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:
James Cottrill
2026-04-14 17:30:02 +01:00
committed by GitHub
parent 0fbe2717f2
commit bd465008e5
5 changed files with 416 additions and 6 deletions

View File

@@ -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);
})();