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..9046ce4cda --- /dev/null +++ b/runner/apps/authoring/src/marketingGate.ts @@ -0,0 +1,68 @@ +// 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; +} + +/** + * 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 + * 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..43f22ead93 --- /dev/null +++ b/runner/apps/authoring/src/marketingTags.ts @@ -0,0 +1,108 @@ +// 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 { + 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 + (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; +} + +/* 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 { + 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 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(); + } + }; + // 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"; + 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..4249409be9 --- /dev/null +++ b/runner/docs/adr/0044-consent-gated-marketing-tags.md @@ -0,0 +1,61 @@ +# 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. 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 + 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..e046369f1d --- /dev/null +++ b/runner/pipeline/marketing-gate.test.mjs @@ -0,0 +1,63 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { PRODUCTION_HOST } from "../apps/authoring/src/reportingGate.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 +// 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("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); + assert.equal(looksSignedIn(null, "#error=denied"), false); + assert.equal(looksSignedIn(null, ""), false); + assert.equal(looksSignedIn("", "#section"), false); +});