Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/cookie-banner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<!-- Script tag -->
<script src="..." data-banner-id="..." data-base-url="..." data-gpc-record="false"></script>

<!-- Themed or headless components -->
<probo-cookie-banner banner-id="..." base-url="..." gpc-record="false"></probo-cookie-banner>
```

**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.
Expand Down
51 changes: 51 additions & 0 deletions packages/cookie-banner/src/attributes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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");
});
});
41 changes: 41 additions & 0 deletions packages/cookie-banner/src/attributes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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;
}
10 changes: 9 additions & 1 deletion packages/cookie-banner/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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) {

@cubic-dev-ai cubic-dev-ai Bot Aug 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new tests only cover resolveBooleanAttribute parsing, not the actual behavior this PR introduces. There is no client-level test asserting that gpcRecord: false skips the consent cookie, visitor-id creation, and the {bannerId}/consents POST, or that the default true still records. Add a client test for the GPC branch so the privacy-relevant toggle can't silently regress.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cookie-banner/src/client.ts, line 201:

<comment>The new tests only cover `resolveBooleanAttribute` parsing, not the actual behavior this PR introduces. There is no client-level test asserting that `gpcRecord: false` skips the consent cookie, visitor-id creation, and the `{bannerId}/consents` POST, or that the default `true` still records. Add a client test for the GPC branch so the privacy-relevant toggle can't silently regress.</comment>

<file context>
@@ -194,7 +196,13 @@ export class CookieBannerClient {
-      this.gpc();
+      // GPC is honoured either way; gpcRecord only controls whether the
+      // decision is persisted and sent.
+      if (this.gpcRecord) {
+        this.gpc();
+      } else {
</file context>
Fix with cubic

this.gpc();
} else {
this.activate(gpcData);

@cubic-dev-ai cubic-dev-ai Bot Aug 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When gpc-record="false" is used with the themed or headless banner, this branch leaves CookieBannerClient.hasConsent false, so the banner opens for a GPC visitor and can accept non-necessary categories after the opt-out was applied. Preserve the pre-change hidden/GPC-applied UI state through a non-persisting consent state instead of leaving the client indistinguishable from an unresolved visitor.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cookie-banner/src/client.ts, line 204:

<comment>When `gpc-record="false"` is used with the themed or headless banner, this branch leaves `CookieBannerClient.hasConsent` false, so the banner opens for a GPC visitor and can accept non-necessary categories after the opt-out was applied. Preserve the pre-change hidden/GPC-applied UI state through a non-persisting consent state instead of leaving the client indistinguishable from an unresolved visitor.</comment>

<file context>
@@ -194,7 +196,13 @@ export class CookieBannerClient {
+      if (this.gpcRecord) {
+        this.gpc();
+      } else {
+        this.activate(gpcData);
+      }
       this._gpcApplied = true;
</file context>
Fix with cubic

@cubic-dev-ai cubic-dev-ai Bot Aug 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: With gpc-record="false" in a deployment that renders visible banner UI (themed/IIFE always render), a GPC opt-out visitor now sees the consent banner. The new else branch calls this.activate(gpcData) instead of this.gpc(), so this.consent is never set and hasConsent stays false; cookie-banner-root.initClient() then falls through to setState(initial_state) instead of setState("hidden"). The default path hides the banner for GPC visitors (consent recorded), so the flag changes banner visibility for GPC visitors rather than only suppressing the record. If a GPC visitor should still have the banner suppressed, base the hide decision on gpcApplied as well as hasConsent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cookie-banner/src/client.ts, line 204:

<comment>With `gpc-record="false"` in a deployment that renders visible banner UI (themed/IIFE always render), a GPC opt-out visitor now sees the consent banner. The new `else` branch calls `this.activate(gpcData)` instead of `this.gpc()`, so `this.consent` is never set and `hasConsent` stays `false`; `cookie-banner-root.initClient()` then falls through to `setState(initial_state)` instead of `setState("hidden")`. The default path hides the banner for GPC visitors (consent recorded), so the flag changes banner visibility for GPC visitors rather than only suppressing the record. If a GPC visitor should still have the banner suppressed, base the hide decision on `gpcApplied` as well as `hasConsent`.</comment>

<file context>
@@ -194,7 +196,13 @@ export class CookieBannerClient {
+      if (this.gpcRecord) {
+        this.gpc();
+      } else {
+        this.activate(gpcData);
+      }
       this._gpcApplied = true;
</file context>
Fix with cubic

}
this._gpcApplied = true;
} else if (!this.consent) {
const defaults = this.buildDefaultConsentData();
Expand Down
3 changes: 3 additions & 0 deletions packages/cookie-banner/src/components/cookie-banner-root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 2 additions & 16 deletions packages/cookie-banner/src/integrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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");
}
5 changes: 5 additions & 0 deletions packages/cookie-banner/src/themed-banner/iife.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};

Expand Down
5 changes: 4 additions & 1 deletion packages/cookie-banner/src/themed-banner/themed-banner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<style>${THEMED_STYLES}</style>
<probo-cookie-banner-root banner-id="${esc(bannerId)}" base-url="${esc(baseUrl)}"${langAttr}${gcmAttr}></probo-cookie-banner-root>
<probo-cookie-banner-root banner-id="${esc(bannerId)}" base-url="${esc(baseUrl)}"${langAttr}${gcmAttr}${gpcAttr}></probo-cookie-banner-root>
`;

const root = this.shadow.querySelector("probo-cookie-banner-root") as ProboCookieBannerRoot;
Expand Down
1 change: 1 addition & 0 deletions packages/cookie-banner/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,5 @@ export interface CookieBannerClientOptions {
baseUrl: string;
lang?: string;
integrations?: IntegrationConfig[];
gpcRecord?: boolean;
}