Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion runner/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
4 changes: 4 additions & 0 deletions runner/apps/authoring/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions runner/apps/authoring/src/marketingGate.ts
Original file line number Diff line number Diff line change
@@ -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");
}
108 changes: 108 additions & 0 deletions runner/apps/authoring/src/marketingTags.ts
Original file line number Diff line number Diff line change
@@ -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;
}
61 changes: 61 additions & 0 deletions runner/docs/adr/0044-consent-gated-marketing-tags.md
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions runner/docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
63 changes: 63 additions & 0 deletions runner/pipeline/marketing-gate.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading