From 61ab2d9f59ee9c713f3be204e75d3e986729a27b Mon Sep 17 00:00:00 2001 From: GreenFlux <24459976+GreenFlux@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:42:57 -0400 Subject: [PATCH 1/2] feat(runner/authoring): load HubSpot + Reo behind Cookiebot consent Links from DevRel posts land on demos.handsontable.com, but the runner loaded no marketing tag, so a visit from a campaign link left no pageview, campaign attribution or account match in HubSpot or Reo. The authoring SPA now loads the Cookiebot banner (the account handsontable.com uses) and, only once marketing consent is recorded, the HubSpot tracking code and the Reo tag. The decision lives in the import-free marketingGate.ts: production host only, never under webdriver, never for a signed-in staff session. /d, /embed and Tier-2 previews are user code and never carry the tags. ADR-0044 records the decision; AGENTS.md scopes the "no cookies" rule to the runner's own telemetry. Co-Authored-By: Claude Opus 5.5 --- runner/AGENTS.md | 2 +- runner/apps/authoring/src/main.tsx | 4 ++ runner/apps/authoring/src/marketingGate.ts | 56 +++++++++++++++ runner/apps/authoring/src/marketingTags.ts | 70 +++++++++++++++++++ .../adr/0044-consent-gated-marketing-tags.md | 56 +++++++++++++++ runner/docs/adr/README.md | 1 + runner/pipeline/marketing-gate.test.mjs | 48 +++++++++++++ 7 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 runner/apps/authoring/src/marketingGate.ts create mode 100644 runner/apps/authoring/src/marketingTags.ts create mode 100644 runner/docs/adr/0044-consent-gated-marketing-tags.md create mode 100644 runner/pipeline/marketing-gate.test.mjs diff --git a/runner/AGENTS.md b/runner/AGENTS.md index d5f70f6116..b76fd8176e 100644 --- a/runner/AGENTS.md +++ b/runner/AGENTS.md @@ -235,6 +235,6 @@ violation as a finding. - **Observability** (ADR-0041, **deployed to production**): a self-hosted, sleeping Loki + Grafana box (`workers/o11y/`, `containers/o11y/`) fed over OTLP, with metrics in Workers Analytics Engine, plus Sentry for uncaught errors (errors escaping a handler, ADR-0041 §E.1) and spend alerts (plus a few explicit captures for failures with no other channel — contract §11). Handled diagnostics (`reportError`, `reportDiagnostic`, the demo-runtime relay) go to the o11y stack only, with no switch to send them to Sentry too (contract §11; the old `SENTRY_SCOPE`/`VITE_SENTRY_SCOPE` flag was removed in DEV-3099). New telemetry follows the ADR's `hot.*` attribute set and its "never a label" rule for demo id, session id, cf-ray and the user pseudonym, checked by `pipeline/telemetry-contract.test.mjs`. ADR-0042 (example analytics) ships with it. ADR-0043 (`/admin` cutover) follows after launch, once ADR-0041 itself is Accepted (see its own status line — two items are pending: a real-Workers-isolate symbolication measurement, and an R2 retention-lifecycle clock still running). Shared names, Analytics Engine slots and payload shapes are frozen in [`docs/observability-contract.md`](docs/observability-contract.md); the deploy runbook, one-time setup and post-deploy checklist are in [`docs/run-and-deploy.md`](docs/run-and-deploy.md)'s Observability and Launch plan sections. - **Ask AI** (DEV-2047, `docs/example-chat.md`): chat panel scoped to the open example; docs chunks are retrieved **in the browser** (Cloudflare blocks Worker→workers.dev, error 1042), the Worker adds Algolia page links and calls LiteLLM. Model edits are proposed, never auto-applied, and every answer is metered into the cost ledger. - **Style panel** (DEV-2047, `docs/style-panel.md`): Theme Builder's controls applied to the open example via Handsontable's **JS theme API** (`registerTheme`/`params`), written into the demo as a real module and wired into the grid; `POST /api/theme` does natural-language styling. Controls show the demo's own Handsontable version's preset defaults — fetched at runtime, override state stays empty until you edit — and the panel is gated off below core 17, which has no theme API (DEV-2560). -- **Analytics are anonymous by construction** — no cookies, no IPs, no user agents, no query strings, no per-request rows; unique visitors use a daily-rotating salted hash. Keep it that way when touching `workers/api/src/analytics.ts`. +- **Analytics are anonymous by construction** — no cookies, no IPs, no user agents, no query strings, no per-request rows; unique visitors use a daily-rotating salted hash. Keep it that way when touching `workers/api/src/analytics.ts`. The one exception is outside the runner's own telemetry: the authoring SPA loads the company's HubSpot + Reo marketing tags behind the Cookiebot consent banner, for signed-out visitors on the production host only, and never into `/d`, `/embed` or previews (ADR-0044, `apps/authoring/src/marketingTags.ts`). - **No manual Handsontable CSS imports in `examples/` on master** (DEV-2200). Master feeds the 17+/next buckets, where core CSS auto-injects (since 17.0.0) and theming uses the JS theme object (`theme: mainTheme` from `handsontable/themes`, also 17.0.0+) — never the string `themeName`, whose stylesheet nothing injects. The `prod-examples/15|16` branches are the exception: below 17 there is no auto-injection and no `handsontable/themes`, so their sources keep `handsontable/styles/*.css` imports + string `themeName` — do not "clean up" CSS imports on those branches. - **A starter states its colour scheme; the shell drives the ones that don't** (DEV-2561, `docs/adr/0035-*`). `mainTheme` carries no `colorScheme`, so a bare `theme: mainTheme` renders whatever the *visitor's OS* prefers — which is why every starter passes `{ ...mainTheme, colorScheme: 'light' }`. A demo with no declaration of its own follows the shell's toggle over the `hot-runner-scheme` bridge (`packages/runtime/src/scheme.ts`), injected into the preview document on both tiers and never into the authored file map. Whether the shell may decide is settled in `App.tsx`, not in the receiver: a docs example or a Style-panel-wired demo owns its own scheme and is sent `auto`. diff --git a/runner/apps/authoring/src/main.tsx b/runner/apps/authoring/src/main.tsx index 5f4ff8e51d..055fab0975 100644 --- a/runner/apps/authoring/src/main.tsx +++ b/runner/apps/authoring/src/main.tsx @@ -18,6 +18,10 @@ import { safeInit } from "./bootGuard.js"; // best-effort side channel, so a construction failure degrades to // `noopTelemetry` instead — see `safeInit`'s own doc comment. safeInit(initTelemetry, (err) => Sentry.captureException(err, { tags: { surface: "telemetry-init" } })); +// The consent banner and, once marketing consent is given, HubSpot + Reo +// (ADR-0044). Guarded like telemetry: a marketing tag must never blank the app. +import { initMarketingTags } from "./marketingTags.js"; +safeInit(initMarketingTags, (err) => Sentry.captureException(err, { tags: { surface: "marketing-init" } })); // DEMOS-1D (DEV-2859): attach the bounded editor trail to every event this // client sends, plus the tags derived from its most recent entry. Registered // unconditionally — `Sentry.addEventProcessor` is a no-op when reporting is diff --git a/runner/apps/authoring/src/marketingGate.ts b/runner/apps/authoring/src/marketingGate.ts new file mode 100644 index 0000000000..130d2086a5 --- /dev/null +++ b/runner/apps/authoring/src/marketingGate.ts @@ -0,0 +1,56 @@ +// Whether the authoring app loads the marketing tags — the HubSpot tracking code +// and the Reo.dev tag — and whether a Cookiebot consent state lets them (ADR-0044). +// +// Split out of `marketingTags.ts` for the same reason as `reportingGate.ts:1-9`: +// that module touches the DOM and imports sibling `./x.js` modules, which +// `pipeline/marketing-gate.test.mjs` cannot resolve under +// `--experimental-strip-types`. Do not let this one grow imports. + +export interface MarketingInputs { + /** `window.location.hostname`. */ + hostname?: string; + /** The one host the tags may run on — `PRODUCTION_HOST` at the call site. */ + productionHost: string; + /** `navigator.webdriver` — `true` under every automation harness. */ + webdriver?: boolean; + /** A Handsontable staff session (see `looksSignedIn`). */ + signedIn: boolean; +} + +/** + * Three conjuncts, the first two the same as `resolveReporting`'s: + * + * - `hostname` — strict equality, so local runs, `vite preview` in CI and any + * lookalike host never load the banner or the tags. + * - `webdriver` — a Playwright run pointed at production is not a visitor. + * Compared against `true`, not `!webdriver`: a browser that does not expose the + * property reports `undefined` and is a real visitor. + * - `signedIn` — the editor's own users are staff (ADR-0007). Their sessions would + * land in HubSpot and Reo as prospect activity. + */ +export function offerMarketingTags({ hostname, productionHost, webdriver, signedIn }: MarketingInputs): boolean { + return hostname === productionHost && webdriver !== true && !signedIn; +} + +/** The part of `window.Cookiebot` this gate reads. */ +export interface CookiebotShape { + consent?: { marketing?: boolean }; +} + +/** Marketing consent, as Cookiebot records it — given explicitly, or implied + * where Cookiebot's own rules imply it for the visitor's region. Anything else + * (no Cookiebot yet, no answer, declined) is no. */ +export function marketingConsented(cookiebot: CookiebotShape | undefined): boolean { + return cookiebot?.consent?.marketing === true; +} + +/** + * A staff session, read synchronously: the broker token in sessionStorage, or a + * `#token=` fragment the broker has just redirected back with and `auth.ts` has not + * consumed yet. Deliberately not `currentUser()`, which round-trips the broker + * (see the `seedAnonymousContext` comment in `main.tsx`). A staff member in a + * fresh, signed-out tab is counted as a visitor; that is accepted. + */ +export function looksSignedIn(token: string | null, hash: string): boolean { + return Boolean(token) || new URLSearchParams(hash.replace(/^#/, "")).has("token"); +} diff --git a/runner/apps/authoring/src/marketingTags.ts b/runner/apps/authoring/src/marketingTags.ts new file mode 100644 index 0000000000..270cd7c639 --- /dev/null +++ b/runner/apps/authoring/src/marketingTags.ts @@ -0,0 +1,70 @@ +// The marketing tags — the HubSpot tracking code and the Reo.dev tag — behind the +// Cookiebot consent banner handsontable.com uses (ADR-0044). +// +// Only the SPA document runs this. `/d/:id` and `/embed/:id` are the user's own +// demo, served by the API worker, and never carry it; neither do Tier-2 previews. +// The decision itself is `marketingGate.ts`, where it can be tested. + +import { getToken } from "./auth.js"; +import { looksSignedIn, marketingConsented, offerMarketingTags, type CookiebotShape } from "./marketingGate.js"; +import { PRODUCTION_HOST } from "./reportingGate.js"; + +/* Public identifiers, the same ones handsontable.com ships: the Cookiebot account + (handsontable.com-v3 `src/content/data/cookiebot.ts`), and the HubSpot portal and + Reo client its GTM container loads. */ +const COOKIEBOT_ID = "ef171f1d-a288-433f-b680-3cdbdebd5646"; +const HUBSPOT_SRC = "https://js-eu1.hs-scripts.com/147178780.js"; +const REO_CLIENT_ID = "84883421d90cd49"; + +type MarketingWindow = Window & { + Cookiebot?: CookiebotShape; + Reo?: { init(options: { clientID: string }): void }; + _hsq?: unknown[]; +}; + +function addScript(src: string): HTMLScriptElement { + const script = document.createElement("script"); + script.src = src; + script.async = true; + document.head.appendChild(script); + return script; +} + +export function initMarketingTags(): void { + let token: string | null = null; + try { + token = getToken(); + } catch { + /* storage blocked: no staff session can exist in it either */ + } + const offer = offerMarketingTags({ + hostname: window.location.hostname, + productionHost: PRODUCTION_HOST, + webdriver: navigator.webdriver, + signedIn: looksSignedIn(token, window.location.hash), + }); + if (!offer) return; + + const w = window as MarketingWindow; + let loaded = false; + const loadIfConsented = () => { + if (loaded || !marketingConsented(w.Cookiebot)) return; + loaded = true; + addScript(HUBSPOT_SRC).id = "hs-script-loader"; + addScript(`https://static.reo.dev/${REO_CLIENT_ID}/reo.js`).onload = () => + w.Reo?.init({ clientID: REO_CLIENT_ID }); + }; + // `CookiebotOnConsentReady` fires once a stored or implied answer is known, + // `CookiebotOnAccept` when the visitor accepts in the banner. + window.addEventListener("CookiebotOnConsentReady", loadIfConsented); + window.addEventListener("CookiebotOnAccept", loadIfConsented); + // A withdrawal mid-visit: HubSpot drops its cookies; the scripts themselves go + // with the next page load, which no longer loads them. + window.addEventListener("CookiebotOnDecline", () => { + if (loaded) (w._hsq = w._hsq || []).push(["revokeCookieConsent"]); + }); + + const banner = addScript("https://consent.cookiebot.com/uc.js"); + banner.id = "Cookiebot"; + banner.dataset.cbid = COOKIEBOT_ID; +} diff --git a/runner/docs/adr/0044-consent-gated-marketing-tags.md b/runner/docs/adr/0044-consent-gated-marketing-tags.md new file mode 100644 index 0000000000..41e4174bc9 --- /dev/null +++ b/runner/docs/adr/0044-consent-gated-marketing-tags.md @@ -0,0 +1,56 @@ +# ADR-0044: Consent-gated marketing tags on the authoring SPA + +**Status:** Proposed (2026-10-28) + +## Context + +Posts from the company's DevRel channels (LinkedIn, X, the blog) link to +demos.handsontable.com, and the runner is where those readers actually try the +product. None of that reaches the company's marketing systems. handsontable.com +loads the HubSpot tracking code and the Reo.dev tag (through its GTM container), +but the runner loads neither. A visit that starts with a `utm_campaign` link and +ends in a demo leaves no pageview, no campaign attribution and no account match. + +The runner's own telemetry is anonymous by construction (ADR-0040, ADR-0041, +ADR-0042): no cookies, no client-side identifier beyond the per-tab `hot_sid`. +That stays true. What is new is a third-party tag, owned by marketing, that sets +cookies. + +## Decision + +1. **The SPA only.** `apps/authoring/src/marketingTags.ts`, started from `main.tsx` + under `safeInit`, is the only place the tags load. Every SPA route is the same + static `index.html`, so this covers `/`, `/share/:id`, the guide and the editor. + `/d/:id` and `/embed/:id` are the user's own demo, served by the API worker and + framed by the SPA and by the documentation site. They never carry the tags, and + neither do Tier-2 previews. +2. **Consent first.** The page loads the Cookiebot banner with the account + handsontable.com uses. HubSpot (`js-eu1.hs-scripts.com/147178780.js`) and Reo + load only once `Cookiebot.consent.marketing` is true: on + `CookiebotOnConsentReady` for a stored or implied answer, or on + `CookiebotOnAccept`. This is an explicit gate in code, not left to tag-manager + settings, so a decline is honoured in every region. +3. **Visitors only.** The gate (`marketingGate.ts`) requires the production host + (strict equality, as in `reportingGate.ts`), `navigator.webdriver !== true`, and + no staff session. A staff session is detected synchronously from the broker + token in sessionStorage or an unconsumed `#token=` fragment. `currentUser()` is + not used: it would add a broker round-trip to every route. +4. **No GTM.** The runner loads the two tags directly rather than the whole + handsontable.com container, whose ad pixels and conversion triggers are + specific to that site. + +## Consequences + +- Visitors on demos.handsontable.com see the Cookiebot banner. Consent given on + handsontable.com does not carry over, because the `CookieConsent` cookie is + host-only. `demos.handsontable.com` must be in the Cookiebot domain group. +- HubSpot cookies are set on `.handsontable.com`, so a visitor is one HubSpot + contact across the two sites. +- `/d/:id` links (the MCP API's `url` field) stay untracked by marketing; their + views are still counted anonymously by the lite beacon. Campaign links should + point at `/share/:id`. +- Errors thrown by the third-party scripts are already dropped by Sentry's + cross-origin gate (`isForeignUnhandled`, `eventGate.ts`). +- None of this is visible to CI: the gate is closed on localhost and under + webdriver. `pipeline/marketing-gate.test.mjs` pins the decision. Production + behaviour is verified by hand after deploy (accept, decline, signed in). diff --git a/runner/docs/adr/README.md b/runner/docs/adr/README.md index 94e1dab111..f2af023a5d 100644 --- a/runner/docs/adr/README.md +++ b/runner/docs/adr/README.md @@ -48,3 +48,4 @@ once Accepted. | [0041](0041-observability-stack.md) | Observability on Cloudflare — a sleeping Loki + Grafana box, OTLP inward, Sentry for uncaught errors | Accepted 2026-10-21 (rev. 3; 14/15 exit criteria pass, criteria 5, 9, 11, 13 verified in production; criterion 8 Mixed at ≈0.36–0.85M/month; §L "Results"; supersedes part of 0040; amends 0022, 0038) | | [0042](0042-example-analytics.md) | Count which examples people open, by docs guide and starter | Accepted 2026-10-21 (rev. 2; ships with 0041) | | [0043](0043-admin-cutover-to-grafana.md) | `/admin` reads move to Grafana; the writes stay on `/admin/controls` | Proposed, design approved (rev. 2; after 0041's launch; amends 0022) | +| [0044](0044-consent-gated-marketing-tags.md) | Consent-gated marketing tags (HubSpot + Reo) on the authoring SPA | Proposed | diff --git a/runner/pipeline/marketing-gate.test.mjs b/runner/pipeline/marketing-gate.test.mjs new file mode 100644 index 0000000000..f8b3d55376 --- /dev/null +++ b/runner/pipeline/marketing-gate.test.mjs @@ -0,0 +1,48 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { PRODUCTION_HOST } from "../apps/authoring/src/reportingGate.ts"; +import { looksSignedIn, marketingConsented, offerMarketingTags } from "../apps/authoring/src/marketingGate.ts"; + +// ADR-0044. The marketing tags (HubSpot + Reo) and their consent banner load only +// for a signed-out visitor on the production host, outside automation, and the +// tags themselves only once Cookiebot records marketing consent. + +const visitor = { hostname: PRODUCTION_HOST, productionHost: PRODUCTION_HOST, webdriver: false, signedIn: false }; + +test("a signed-out visitor on the production host is offered the tags", () => { + assert.equal(offerMarketingTags(visitor), true); +}); + +test("a browser that does not expose navigator.webdriver is still a visitor", () => { + assert.equal(offerMarketingTags({ ...visitor, webdriver: undefined }), true); +}); + +test("no tags off the production host, including lookalikes and CI previews", () => { + for (const hostname of ["localhost", "127.0.0.1", `${PRODUCTION_HOST}.evil.test`, `x.${PRODUCTION_HOST}`, undefined]) { + assert.equal(offerMarketingTags({ ...visitor, hostname }), false, String(hostname)); + } +}); + +test("no tags under an automation harness", () => { + assert.equal(offerMarketingTags({ ...visitor, webdriver: true }), false); +}); + +test("no tags for a signed-in staff session", () => { + assert.equal(offerMarketingTags({ ...visitor, signedIn: true }), false); +}); + +test("only an explicit or implied marketing consent lets the tags load", () => { + assert.equal(marketingConsented({ consent: { marketing: true } }), true); + assert.equal(marketingConsented({ consent: { marketing: false } }), false); + assert.equal(marketingConsented({ consent: {} }), false); + assert.equal(marketingConsented({}), false); + assert.equal(marketingConsented(undefined), false); +}); + +test("a staff session is the stored token or a #token= the broker just returned", () => { + assert.equal(looksSignedIn("abc", ""), true); + assert.equal(looksSignedIn(null, "#token=abc"), true); + assert.equal(looksSignedIn(null, "#error=denied"), false); + assert.equal(looksSignedIn(null, ""), false); + assert.equal(looksSignedIn("", "#section"), false); +}); From 4a9756435c43255624c749b1962a6405e9828738 Mon Sep 17 00:00:00 2001 From: GreenFlux <24459976+GreenFlux@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:04:56 -0400 Subject: [PATCH 2/2] fix(runner/authoring): honour a marketing-only consent withdrawal Unticking only marketing in the Cookiebot banner fires CookiebotOnAccept, not CookiebotOnDecline, so the loader never revoked anything, and on a full decline only HubSpot was told while Reo kept running. Every Cookiebot event now re-reads the consent and compares it with what is loaded (consentAction, tested). A withdrawal clears both tags' cookies, tells HubSpot, and reloads the page without the scripts: Reo has no stop call. Only signed-out pages carry the tags, and their edits are never persisted, so the reload loses nothing. Co-Authored-By: Claude Opus 5.5 --- runner/apps/authoring/src/marketingGate.ts | 12 ++++ runner/apps/authoring/src/marketingTags.ts | 70 ++++++++++++++----- .../adr/0044-consent-gated-marketing-tags.md | 13 ++-- runner/pipeline/marketing-gate.test.mjs | 17 ++++- 4 files changed, 91 insertions(+), 21 deletions(-) diff --git a/runner/apps/authoring/src/marketingGate.ts b/runner/apps/authoring/src/marketingGate.ts index 130d2086a5..9046ce4cda 100644 --- a/runner/apps/authoring/src/marketingGate.ts +++ b/runner/apps/authoring/src/marketingGate.ts @@ -44,6 +44,18 @@ export function marketingConsented(cookiebot: CookiebotShape | undefined): boole return cookiebot?.consent?.marketing === true; } +/** + * What a consent change asks of the page, given whether the tags are loaded. + * Every Cookiebot event goes through this one rule, because the event name does + * not say which way marketing went: unticking only marketing in the banner fires + * `CookiebotOnAccept`, not `CookiebotOnDecline`. + */ +export function consentAction(consented: boolean, loaded: boolean): "load" | "withdraw" | "none" { + if (consented && !loaded) return "load"; + if (!consented && loaded) return "withdraw"; + return "none"; +} + /** * A staff session, read synchronously: the broker token in sessionStorage, or a * `#token=` fragment the broker has just redirected back with and `auth.ts` has not diff --git a/runner/apps/authoring/src/marketingTags.ts b/runner/apps/authoring/src/marketingTags.ts index 270cd7c639..43f22ead93 100644 --- a/runner/apps/authoring/src/marketingTags.ts +++ b/runner/apps/authoring/src/marketingTags.ts @@ -6,7 +6,13 @@ // The decision itself is `marketingGate.ts`, where it can be tested. import { getToken } from "./auth.js"; -import { looksSignedIn, marketingConsented, offerMarketingTags, type CookiebotShape } from "./marketingGate.js"; +import { + consentAction, + looksSignedIn, + marketingConsented, + offerMarketingTags, + type CookiebotShape, +} from "./marketingGate.js"; import { PRODUCTION_HOST } from "./reportingGate.js"; /* Public identifiers, the same ones handsontable.com ships: the Cookiebot account @@ -30,6 +36,30 @@ function addScript(src: string): HTMLScriptElement { return script; } +/* The cookies the two tags set: Reo's six first-party cookies (docs.reo.dev, + "Cookies & Consent Guide") and HubSpot's tracking cookies. Cleared on this + host and on the parent domain, either of which the scripts may have used. */ +const MARKETING_COOKIES = [ + "__sec__cid", + "__sec__fid", + "__sec__ghost", + "__sec__token", + "__sec__crid", + "__sec__tid", + "hubspotutk", + "__hstc", + "__hssc", + "__hssrc", +]; + +function clearMarketingCookies(): void { + const expired = "expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/"; + for (const name of MARKETING_COOKIES) { + document.cookie = `${name}=; ${expired}`; + document.cookie = `${name}=; ${expired}; domain=.handsontable.com`; + } +} + export function initMarketingTags(): void { let token: string | null = null; try { @@ -47,22 +77,30 @@ export function initMarketingTags(): void { const w = window as MarketingWindow; let loaded = false; - const loadIfConsented = () => { - if (loaded || !marketingConsented(w.Cookiebot)) return; - loaded = true; - addScript(HUBSPOT_SRC).id = "hs-script-loader"; - addScript(`https://static.reo.dev/${REO_CLIENT_ID}/reo.js`).onload = () => - w.Reo?.init({ clientID: REO_CLIENT_ID }); + const syncConsent = () => { + const action = consentAction(marketingConsented(w.Cookiebot), loaded); + if (action === "load") { + loaded = true; + addScript(HUBSPOT_SRC).id = "hs-script-loader"; + addScript(`https://static.reo.dev/${REO_CLIENT_ID}/reo.js`).onload = () => + w.Reo?.init({ clientID: REO_CLIENT_ID }); + } else if (action === "withdraw") { + // Marketing consent withdrawn mid-visit. Reo has no stop call (its docs + // only say not to load it without consent), so both tags' cookies go and + // the page reloads without either script. HubSpot is also told directly, + // in case it writes again before the reload. Only signed-out pages get + // here, and their edits are never persisted — the edit page's + // unsaved-work guard is staff-only, as are its tags. + (w._hsq = w._hsq || []).push(["revokeCookieConsent"]); + clearMarketingCookies(); + window.location.reload(); + } }; - // `CookiebotOnConsentReady` fires once a stored or implied answer is known, - // `CookiebotOnAccept` when the visitor accepts in the banner. - window.addEventListener("CookiebotOnConsentReady", loadIfConsented); - window.addEventListener("CookiebotOnAccept", loadIfConsented); - // A withdrawal mid-visit: HubSpot drops its cookies; the scripts themselves go - // with the next page load, which no longer loads them. - window.addEventListener("CookiebotOnDecline", () => { - if (loaded) (w._hsq = w._hsq || []).push(["revokeCookieConsent"]); - }); + // The event name does not say which way marketing went (unticking only + // marketing fires `CookiebotOnAccept`), so every one re-reads the consent. + for (const event of ["CookiebotOnConsentReady", "CookiebotOnAccept", "CookiebotOnDecline"]) { + window.addEventListener(event, syncConsent); + } const banner = addScript("https://consent.cookiebot.com/uc.js"); banner.id = "Cookiebot"; diff --git a/runner/docs/adr/0044-consent-gated-marketing-tags.md b/runner/docs/adr/0044-consent-gated-marketing-tags.md index 41e4174bc9..4249409be9 100644 --- a/runner/docs/adr/0044-consent-gated-marketing-tags.md +++ b/runner/docs/adr/0044-consent-gated-marketing-tags.md @@ -26,10 +26,15 @@ cookies. neither do Tier-2 previews. 2. **Consent first.** The page loads the Cookiebot banner with the account handsontable.com uses. HubSpot (`js-eu1.hs-scripts.com/147178780.js`) and Reo - load only once `Cookiebot.consent.marketing` is true: on - `CookiebotOnConsentReady` for a stored or implied answer, or on - `CookiebotOnAccept`. This is an explicit gate in code, not left to tag-manager - settings, so a decline is honoured in every region. + load only once `Cookiebot.consent.marketing` is true. Every Cookiebot event + (`CookiebotOnConsentReady`, `CookiebotOnAccept`, `CookiebotOnDecline`) re-reads + the consent and compares it with what is loaded (`consentAction`), because the + event name does not say which way marketing went: unticking only marketing in + the banner fires `CookiebotOnAccept`. A withdrawal after the tags loaded clears + their cookies and reloads the page. Reo has no stop call, and only signed-out + pages, whose edits are never persisted, carry the tags. This is an explicit + gate in code, not left to tag-manager settings, so a decline is honoured in + every region. 3. **Visitors only.** The gate (`marketingGate.ts`) requires the production host (strict equality, as in `reportingGate.ts`), `navigator.webdriver !== true`, and no staff session. A staff session is detected synchronously from the broker diff --git a/runner/pipeline/marketing-gate.test.mjs b/runner/pipeline/marketing-gate.test.mjs index f8b3d55376..e046369f1d 100644 --- a/runner/pipeline/marketing-gate.test.mjs +++ b/runner/pipeline/marketing-gate.test.mjs @@ -1,7 +1,12 @@ import test from "node:test"; import assert from "node:assert/strict"; import { PRODUCTION_HOST } from "../apps/authoring/src/reportingGate.ts"; -import { looksSignedIn, marketingConsented, offerMarketingTags } from "../apps/authoring/src/marketingGate.ts"; +import { + consentAction, + looksSignedIn, + marketingConsented, + offerMarketingTags, +} from "../apps/authoring/src/marketingGate.ts"; // ADR-0044. The marketing tags (HubSpot + Reo) and their consent banner load only // for a signed-out visitor on the production host, outside automation, and the @@ -39,6 +44,16 @@ test("only an explicit or implied marketing consent lets the tags load", () => { assert.equal(marketingConsented(undefined), false); }); +test("withdrawing marketing consent after the tags loaded is a withdrawal, whatever event carried it", () => { + // PR #476 review: unticking only marketing fires CookiebotOnAccept, so the old + // "load on accept, revoke on decline" wiring never revoked. The decision now + // depends only on consent vs. what is loaded. + assert.equal(consentAction(false, true), "withdraw"); + assert.equal(consentAction(true, false), "load"); + assert.equal(consentAction(true, true), "none"); + assert.equal(consentAction(false, false), "none"); +}); + test("a staff session is the stored token or a #token= the broker just returned", () => { assert.equal(looksSignedIn("abc", ""), true); assert.equal(looksSignedIn(null, "#token=abc"), true);