diff --git a/packages/cookie-banner/README.md b/packages/cookie-banner/README.md index ffc379ec18..5f8754e021 100644 --- a/packages/cookie-banner/README.md +++ b/packages/cookie-banner/README.md @@ -140,6 +140,22 @@ If your site manages Consent Mode itself (for example through an existing GTM se The attribute accepts `"true"`/`"false"`; any other value logs a warning and keeps the integration enabled. Programmatic use of `CookieBannerClient` takes the same switch as an option: `integrations: [{ name: "gcm", enabled: false }]`. Disabling covers all of it — the eager deny-all default, the discovery-mode grant-all, and per-consent updates. +## Global Privacy Control + +When a visitor's browser sends [Global Privacy Control](https://globalprivacycontrol.org/), the SDK applies an opt-out and records it as a consent decision: a visitor id in `localStorage`, the consent cookie, and a consent record sent to Probo. That happens even when no banner UI is mounted, which is surprising for a detection-only embed, and it means a second consent record exists for sites that already have their own. + +`gpc-record="false"` stops the SDK persisting and transmitting that decision: + +```html + + + + + +``` + +**GPC is still honoured.** The opt-out is applied in the page exactly as before — non-necessary categories are denied, blocked resources stay blocked, and integrations are updated — so the flag cannot be used to ignore a GPC signal. What it removes is the record of it: no visitor id, no cookie, no consent record. Programmatic equivalent: `gpcRecord: false`. + ## Key Features - **Multi-regulation compliance** — Supports opt-in (GDPR, ePrivacy) and opt-out (CCPA/CPRA) consent modes, Global Privacy Control (GPC) detection, and per-category cookie controls. Under CCPA the banner starts closed; the settings link (“Your Privacy Choices” + official icon) opens the Privacy Choices panel with sale/sharing opt-out and an SPI rights statement. diff --git a/packages/cookie-banner/src/attributes.test.ts b/packages/cookie-banner/src/attributes.test.ts new file mode 100644 index 0000000000..a7bc718397 --- /dev/null +++ b/packages/cookie-banner/src/attributes.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { resolveBooleanAttribute } from "./attributes"; + +describe("resolveBooleanAttribute", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("defaults to true when the attribute is absent", () => { + expect(resolveBooleanAttribute(null, "gpc-record")).toBe(true); + }); + + it("parses \"true\" and \"false\"", () => { + expect(resolveBooleanAttribute("true", "gpc-record")).toBe(true); + expect(resolveBooleanAttribute("false", "gpc-record")).toBe(false); + }); + + it("normalizes case and whitespace", () => { + expect(resolveBooleanAttribute("FALSE", "gpc-record")).toBe(false); + expect(resolveBooleanAttribute(" false ", "gpc-record")).toBe(false); + expect(resolveBooleanAttribute("True", "gpc-record")).toBe(true); + }); + + it("warns with the attribute name and falls back to true", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(resolveBooleanAttribute("off", "gpc-record")).toBe(true); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain("gpc-record"); + }); +}); diff --git a/packages/cookie-banner/src/attributes.ts b/packages/cookie-banner/src/attributes.ts new file mode 100644 index 0000000000..b094048e1f --- /dev/null +++ b/packages/cookie-banner/src/attributes.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +export function resolveBooleanAttribute( + value: string | null, + attribute: string, +): boolean { + if (value == null) { + return true; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === "true") { + return true; + } + if (normalized === "false") { + return false; + } + + console.warn( + `[probo] invalid ${attribute} value "${value}": expected "true" or "false", falling back to enabled`, + ); + return true; +} diff --git a/packages/cookie-banner/src/client.ts b/packages/cookie-banner/src/client.ts index 0d9a18beae..e47e1e2655 100644 --- a/packages/cookie-banner/src/client.ts +++ b/packages/cookie-banner/src/client.ts @@ -79,6 +79,7 @@ export class CookieBannerClient { private readonly lang: string; private readonly integrations: ConsentIntegration[]; + private readonly gpcRecord: boolean; private bannerConfig: BannerConfig | null = null; private consent: VisitorConsent | null = null; @@ -97,6 +98,7 @@ export class CookieBannerClient { this.visitorId = getVisitorId(config.bannerId); this.lang = detectLanguage(config.lang); this.integrations = createDefaultIntegrations(config.integrations); + this.gpcRecord = config.gpcRecord !== false; } get loaded(): boolean { @@ -194,7 +196,13 @@ export class CookieBannerClient { gpcData[cat.slug] = cat.kind === "NECESSARY"; } getConsent()._setReady(gpcData, false); - this.gpc(); + // GPC is honoured either way; gpcRecord only controls whether the + // decision is persisted and sent. + if (this.gpcRecord) { + this.gpc(); + } else { + this.activate(gpcData); + } this._gpcApplied = true; } else if (!this.consent) { const defaults = this.buildDefaultConsentData(); diff --git a/packages/cookie-banner/src/components/cookie-banner-root.ts b/packages/cookie-banner/src/components/cookie-banner-root.ts index 5af666c795..bd21c83acb 100644 --- a/packages/cookie-banner/src/components/cookie-banner-root.ts +++ b/packages/cookie-banner/src/components/cookie-banner-root.ts @@ -18,6 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +import { resolveBooleanAttribute } from "../attributes"; import { CookieBannerClient } from "../client"; import { resolveGcmEnabled } from "../integrations"; import { resolveLayout } from "../layout"; @@ -146,12 +147,14 @@ export class ProboCookieBannerRoot extends ProboElement implements ProboRootElem const lang = this.getAttribute("lang") ?? undefined; const gcmEnabled = resolveGcmEnabled(this.getAttribute("gcm-enabled")); + const gpcRecord = resolveBooleanAttribute(this.getAttribute("gpc-record"), "gpc-record"); this._client = new CookieBannerClient({ bannerId, baseUrl, lang, integrations: [{ name: "gcm", enabled: gcmEnabled }], + gpcRecord, }); try { @@ -179,7 +182,9 @@ export class ProboCookieBannerRoot extends ProboElement implements ProboRootElem this.scheduleValidation(() => this.validateSettingsLink()); - if (this._client.hasConsent) { + // gpcApplied covers the opt-out being in effect without a stored consent, + // which is the gpcRecord: false case. + if (this._client.hasConsent || this._client.gpcApplied) { this.setState("hidden"); } else { this.setState(resolveLayout(this._config).initial_state); diff --git a/packages/cookie-banner/src/integrations/index.ts b/packages/cookie-banner/src/integrations/index.ts index 05eb5b2cd4..5d3028b7e0 100644 --- a/packages/cookie-banner/src/integrations/index.ts +++ b/packages/cookie-banner/src/integrations/index.ts @@ -21,6 +21,7 @@ export type { ConsentIntegration } from "./integration"; export { GoogleConsentModeIntegration } from "./gcm"; +import { resolveBooleanAttribute } from "../attributes"; import type { IntegrationConfig } from "../types"; import type { ConsentIntegration } from "./integration"; import { GoogleConsentModeIntegration } from "./gcm"; @@ -39,20 +40,5 @@ export function createDefaultIntegrations( } export function resolveGcmEnabled(value: string | null): boolean { - if (value == null) { - return true; - } - - const normalized = value.trim().toLowerCase(); - if (normalized === "true") { - return true; - } - if (normalized === "false") { - return false; - } - - console.warn( - `[probo] invalid gcm-enabled value "${value}": expected "true" or "false", falling back to enabled`, - ); - return true; + return resolveBooleanAttribute(value, "gcm-enabled"); } diff --git a/packages/cookie-banner/src/themed-banner/iife.ts b/packages/cookie-banner/src/themed-banner/iife.ts index 22a12178be..f7a5d98edf 100644 --- a/packages/cookie-banner/src/themed-banner/iife.ts +++ b/packages/cookie-banner/src/themed-banner/iife.ts @@ -56,6 +56,11 @@ if (script) { el.setAttribute("gcm-enabled", gcm); } + const gpc = script.getAttribute("data-gpc-record"); + if (gpc) { + el.setAttribute("gpc-record", gpc); + } + document.body.appendChild(el); }; diff --git a/packages/cookie-banner/src/themed-banner/themed-banner.ts b/packages/cookie-banner/src/themed-banner/themed-banner.ts index ac7ea47f62..8b86756ae1 100644 --- a/packages/cookie-banner/src/themed-banner/themed-banner.ts +++ b/packages/cookie-banner/src/themed-banner/themed-banner.ts @@ -64,9 +64,12 @@ export class ProboThemedBanner extends HTMLElement { const gcm = this.getAttribute("gcm-enabled"); const gcmAttr = gcm ? ` gcm-enabled="${esc(gcm)}"` : ""; + const gpc = this.getAttribute("gpc-record"); + const gpcAttr = gpc ? ` gpc-record="${esc(gpc)}"` : ""; + this.shadow.innerHTML = ` - + `; const root = this.shadow.querySelector("probo-cookie-banner-root") as ProboCookieBannerRoot; @@ -117,7 +120,9 @@ export class ProboThemedBanner extends HTMLElement { this.shadow.querySelector("[data-action=back]")?.addEventListener("click", () => { root.setState( - root.client.hasConsent ? "hidden" : (root.layout?.initial_state ?? "banner"), + root.client.hasConsent || root.client.gpcApplied + ? "hidden" + : (root.layout?.initial_state ?? "banner"), ); }); diff --git a/packages/cookie-banner/src/types.ts b/packages/cookie-banner/src/types.ts index 8ab60dfbbc..9b564db233 100644 --- a/packages/cookie-banner/src/types.ts +++ b/packages/cookie-banner/src/types.ts @@ -127,4 +127,5 @@ export interface CookieBannerClientOptions { baseUrl: string; lang?: string; integrations?: IntegrationConfig[]; + gpcRecord?: boolean; }