@tokenoftrust/storefront-runner 2.0.29 → 2.0.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -59,6 +59,7 @@ import tailwindcss from "@tailwindcss/vite";
59
59
  import { tenantHotReload } from "./dev-plugins/tenant-hot-reload.mjs";
60
60
  import { materializeGuard } from "./integrations/materialize-guard.mjs";
61
61
  import { lifecycleHostAsset } from "./integrations/lifecycle-host-asset.mjs";
62
+ import { googleMeasurementSandboxAsset } from "./integrations/google-measurement-sandbox-asset.mjs";
62
63
 
63
64
  // SSR on Cloudflare Workers. `output: 'server'` because every response is
64
65
  // tenant-resolved at the edge from the Host header (see src/middleware).
@@ -99,6 +100,7 @@ export default defineConfig({
99
100
  // stable same-origin URL so BOTH document mounts load the same boot — the raw
100
101
  // marketing mount has no bundler to hoist a module import through.
101
102
  lifecycleHostAsset(),
103
+ googleMeasurementSandboxAsset(),
102
104
  preact({ compat: true }),
103
105
  // Astro's file-based router ignores ANY path segment starting with "_" (see
104
106
  // `astro/dist/core/routing/create-manifest.js`), so a page literally named
@@ -0,0 +1,97 @@
1
+ /** Emit the reviewed GA4 runtime as one import-free, CSP-hashed static document. */
2
+ import { createHash } from "node:crypto";
3
+ import { mkdir, writeFile } from "node:fs/promises";
4
+ import { createRequire } from "node:module";
5
+ import { dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import {
8
+ NATIVE_GA4_SANDBOX_ASSET_PATH,
9
+ } from "@tot/public-runtime/integration/native-ga4-sandbox-asset";
10
+
11
+ const SANDBOX_ENTRY = fileURLToPath(new URL(
12
+ "../../../packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts",
13
+ import.meta.url,
14
+ ));
15
+
16
+ export async function bundleGoogleMeasurementSandboxScript() {
17
+ const require = createRequire(import.meta.url);
18
+ const esbuild = createRequire(require.resolve("astro/package.json"))("esbuild");
19
+ const result = await esbuild.build({
20
+ entryPoints: [SANDBOX_ENTRY],
21
+ bundle: true,
22
+ write: false,
23
+ metafile: true,
24
+ format: "iife",
25
+ platform: "browser",
26
+ target: "es2022",
27
+ minify: true,
28
+ treeShaking: true,
29
+ legalComments: "none",
30
+ sourcemap: false,
31
+ logLevel: "silent",
32
+ });
33
+ if (result.outputFiles.length !== 1) {
34
+ throw new Error(`GA4 sandbox emitted ${result.outputFiles.length} files; expected one.`);
35
+ }
36
+ for (const output of Object.values(result.metafile.outputs)) {
37
+ if ((output.imports ?? []).length > 0) throw new Error("GA4 sandbox must be import-free.");
38
+ }
39
+ const code = result.outputFiles[0].text.trim();
40
+ if (/<\/script/i.test(code)) throw new Error("GA4 sandbox bundle contains a closing script token.");
41
+ return code;
42
+ }
43
+
44
+ export function googleMeasurementSandboxCsp(code) {
45
+ const hash = createHash("sha256").update(code).digest("base64");
46
+ const script = `'sha256-${hash}' https://www.googletagmanager.com`;
47
+ return [
48
+ "default-src 'none'",
49
+ "base-uri 'none'",
50
+ "object-src 'none'",
51
+ "form-action 'none'",
52
+ "frame-src 'none'",
53
+ "worker-src 'none'",
54
+ `script-src ${script}`,
55
+ `script-src-elem ${script}`,
56
+ "img-src https://*.google-analytics.com https://www.googletagmanager.com",
57
+ "connect-src https://*.google-analytics.com https://*.analytics.google.com https://www.googletagmanager.com",
58
+ ].join("; ");
59
+ }
60
+
61
+ export async function buildGoogleMeasurementSandboxHtml() {
62
+ const code = await bundleGoogleMeasurementSandboxScript();
63
+ const csp = googleMeasurementSandboxCsp(code);
64
+ return `<!doctype html>\n<meta charset="utf-8">\n<meta http-equiv="Content-Security-Policy" content="${csp}">\n<title>Analytics transport</title>\n<script>${code}</script>\n`;
65
+ }
66
+
67
+ export function googleMeasurementSandboxAsset() {
68
+ let clientDir = null;
69
+ return {
70
+ name: "tot-google-measurement-sandbox-asset",
71
+ hooks: {
72
+ "astro:config:done": ({ config }) => { clientDir = config.build.client; },
73
+ "astro:server:setup": ({ server }) => {
74
+ server.middlewares.use((req, res, next) => {
75
+ const path = (req.url ?? "").split("?")[0];
76
+ if (path !== `/${NATIVE_GA4_SANDBOX_ASSET_PATH}`) return next();
77
+ buildGoogleMeasurementSandboxHtml().then(
78
+ (html) => {
79
+ res.setHeader("content-type", "text/html; charset=utf-8");
80
+ res.setHeader("cache-control", "no-store");
81
+ res.end(html);
82
+ },
83
+ (error) => next(error),
84
+ );
85
+ });
86
+ },
87
+ "astro:build:done": async ({ logger }) => {
88
+ if (!clientDir) throw new Error("the GA4 sandbox has no resolved client output dir");
89
+ const html = await buildGoogleMeasurementSandboxHtml();
90
+ const target = fileURLToPath(new URL(NATIVE_GA4_SANDBOX_ASSET_PATH, clientDir));
91
+ await mkdir(dirname(target), { recursive: true });
92
+ await writeFile(target, html, "utf8");
93
+ logger.info(`GA4 sandbox → /${NATIVE_GA4_SANDBOX_ASSET_PATH} (${html.length} bytes)`);
94
+ },
95
+ },
96
+ };
97
+ }
@@ -0,0 +1,13 @@
1
+ /** Reviewed parent-side marker for the null-origin GA4 platform adapter. */
2
+ export const platformAdapterEntry = Object.freeze({
3
+ schemaVersion: 1,
4
+ origin: "platform-adapter",
5
+ executionTier: "platform-adapter",
6
+ appId: "platform.google-measurement",
7
+ version: "1.0.0",
8
+ entry: Object.freeze({
9
+ url: "/platform/google-measurement/v2/entry.mjs",
10
+ format: "module",
11
+ }),
12
+ runtime: "native-ga4-v2",
13
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.0.29",
3
+ "version": "2.0.31",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -10,7 +10,8 @@
10
10
  "exports": {
11
11
  ".": "./src/index.ts",
12
12
  "./blog-visibility": "./src/blog-visibility.mjs",
13
- "./integration/lifecycle-host-asset": "./src/integration/lifecycle-host-asset.mjs"
13
+ "./integration/lifecycle-host-asset": "./src/integration/lifecycle-host-asset.mjs",
14
+ "./integration/native-ga4-sandbox-asset": "./src/integration/native-ga4-sandbox-asset.mjs"
14
15
  },
15
16
  "scripts": {
16
17
  "typecheck": "tsc --noEmit",
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The platform-owned browser binding for the two reviewed contact-sales CTAs.
3
+ *
4
+ * This is deliberately not a general interaction adapter. Both DOM identifiers and
5
+ * the emitted semantic name are fixed below; the source reads no labels, URLs,
6
+ * selectors supplied by a caller, event detail, or arbitrary DOM data.
7
+ */
8
+ import type { PlatformManagedActionActivation, PlatformManagedActionSource } from "./managed-analytics-bridge.js";
9
+
10
+ /** The only reviewed CTA identities that may raise this marketing action. */
11
+ export const CONTACT_SALES_ACTION_SOURCES = [
12
+ { ctaId: "cta-book-demo", logicalId: "contact-sales-book-demo" },
13
+ { ctaId: "contact-sales", logicalId: "contact-sales-lower" },
14
+ ] as const;
15
+
16
+ const CONTACT_SALES_ACTION_NAME = "user_clicked_contact_sales_mktg" as const;
17
+ const CORRELATION_TTL_MS = 60 * 60 * 1000;
18
+
19
+ export interface ContactSalesActionElement {
20
+ addEventListener(type: "click", listener: () => void): void;
21
+ removeEventListener(type: "click", listener: () => void): void;
22
+ }
23
+
24
+ /** The narrow DOM seam used only to find the two fixed platform CTA identities. */
25
+ export interface ContactSalesActionDocument {
26
+ querySelectorAll(selector: string): ArrayLike<ContactSalesActionElement>;
27
+ }
28
+
29
+ export interface ContactSalesActionSourceOptions {
30
+ document?: ContactSalesActionDocument;
31
+ planRevision: string;
32
+ now?: () => number;
33
+ }
34
+
35
+ /**
36
+ * Bind literal `data-cta-id` values to a typed source for the managed-analytics
37
+ * bridge. A keyboard activation of an anchor/button produces its native `click`, so
38
+ * it follows this same semantic path without a separate key listener.
39
+ */
40
+ export function createContactSalesActionSource(
41
+ options: ContactSalesActionSourceOptions,
42
+ ): PlatformManagedActionSource {
43
+ const document = options.document;
44
+ const now = options.now ?? Date.now;
45
+ let sequence = 0;
46
+
47
+ return {
48
+ subscribe(listener) {
49
+ if (!document || typeof document.querySelectorAll !== "function") return () => {};
50
+ const removals: Array<() => void> = [];
51
+ for (const source of CONTACT_SALES_ACTION_SOURCES) {
52
+ const nodes = document.querySelectorAll(`[data-cta-id="${source.ctaId}"]`);
53
+ for (const node of Array.from(nodes)) {
54
+ const emit = (): void => {
55
+ sequence += 1;
56
+ const at = now();
57
+ const activation: PlatformManagedActionActivation = {
58
+ logicalId: `${source.logicalId}-${options.planRevision}-${sequence}`,
59
+ name: CONTACT_SALES_ACTION_NAME,
60
+ correlation: {
61
+ id: `contact-sales-${options.planRevision}`,
62
+ expiresAt: new Date(at + CORRELATION_TTL_MS).toISOString(),
63
+ },
64
+ sequence,
65
+ };
66
+ listener(activation);
67
+ };
68
+ node.addEventListener("click", emit);
69
+ removals.push(() => node.removeEventListener("click", emit));
70
+ }
71
+ }
72
+ return () => removals.splice(0).forEach((remove) => remove());
73
+ },
74
+ };
75
+ }
@@ -63,12 +63,9 @@ export const INTEGRATION_CSP_PROFILES: Readonly<Record<string, CspSourceSet>> =
63
63
  "connect-src": ["https://cdn.usefathom.com"],
64
64
  },
65
65
 
66
- // Native Google Analytics / Ads measurement. The adapter sends reviewed event
67
- // payloads only to Google's measurement endpoints; it does not load a remote
68
- // browser container. Every origin is exact and platform-owned.
69
- "google-measurement": {
70
- "connect-src": ["https://www.google-analytics.com"],
71
- },
66
+ // Google code and network authority live only in the null-origin sandbox. The
67
+ // parent needs no addition: base frame-src 'self' admits the versioned host.
68
+ "google-measurement": {},
72
69
 
73
70
  // Meta pixel (fbevents.js). The loader is served from connect.facebook.net; the
74
71
  // pixel's collect endpoint is the facebook.com tracking origin. The tenant
@@ -56,7 +56,10 @@ import {
56
56
  import { INTEGRATION_ENVIRONMENTS, INTEGRATION_SITE_SURFACES } from "./environment.js";
57
57
  import type { IntegrationEnvironment, IntegrationSiteSurface } from "./environment.js";
58
58
  import { findForbiddenFields } from "./secrets.js";
59
- import { resemblesManagedAnalyticsPii } from "./managed-analytics-value-safety.js";
59
+ import {
60
+ resemblesManagedAnalyticsPii,
61
+ resemblesManagedAnalyticsProtectedValue,
62
+ } from "./managed-analytics-value-safety.js";
60
63
 
61
64
  /**
62
65
  * The reserved commerce event taxonomy. Every accepted type is here; an unknown
@@ -348,9 +351,6 @@ function validateLineItems(parent: Record<string, unknown>, v: Violations, path:
348
351
 
349
352
  const UTM_FIELDS = ["source", "medium", "campaign", "term", "content"] as const;
350
353
  const SAFE_UTM_VALUE = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/;
351
- const PROTECTED_ATTRIBUTION_VALUE =
352
- /(?:@|\d{7,}|(?:^|[._~-])(?:bearer|password|secret|token|authorization|preview)(?:$|[._~-]))/i;
353
-
354
354
  function validatePageAttribution(parent: Record<string, unknown>, v: Violations): void {
355
355
  const attribution = childObject(parent, "attribution", v, "data");
356
356
  if (!attribution) return;
@@ -403,7 +403,7 @@ function validatePageAttribution(parent: Record<string, unknown>, v: Violations)
403
403
  `UTM ${key} must be a 1-128 character opaque campaign token.`,
404
404
  );
405
405
  }
406
- if (value !== undefined && PROTECTED_ATTRIBUTION_VALUE.test(value)) {
406
+ if (value !== undefined && resemblesManagedAnalyticsProtectedValue(value)) {
407
407
  v.add(
408
408
  `data.attribution.utm.${key}`,
409
409
  "protected_value",
@@ -58,6 +58,7 @@ export * from "./resolve.js";
58
58
  export * from "./platform-adapters.js";
59
59
  export * from "./native-amplitude.js";
60
60
  export * from "./native-ga4.js";
61
+ export * from "./native-ga4-sandbox.js";
61
62
  export * from "./platform-adapter-runtime.js";
62
63
  export * from "./render-phases.js";
63
64
  export * from "./interim-bridge.js";
@@ -76,6 +77,7 @@ export * from "./events-catalog.js";
76
77
  export * from "./commerce-broker.js";
77
78
  export * from "./managed-analytics.js";
78
79
  export * from "./managed-analytics-bridge.js";
80
+ export * from "./contact-sales-action-source.js";
79
81
  export * from "./g2-action-boundary.js";
80
82
  export * from "./correlation-token.js";
81
83
  export * from "./order-conversion.js";
@@ -16,11 +16,10 @@
16
16
  * constructs bundled platform code with host-owned capabilities. Every other entry goes
17
17
  * through the authority-tier loader: it loads ONLY an `elevated-parent` entry (whose
18
18
  * honest, review-only contract IS arbitrary parent-document JavaScript) and REFUSES every
19
- * other tier. A `sandbox`-tier entry —
20
- * whose contract is a null-origin iframe reachable only through the typed postMessage
21
- * broker — is refused here and held back until the iframe host that actually isolates
22
- * it ships, so third-party code labeled "sandboxed" is never executed with the parent
23
- * authority its label does not grant. Loading is also consent-gated: the binding reads
19
+ * other tier. A reviewed registry entry may construct its own null-origin iframe and
20
+ * private typed MessagePort; arbitrary `sandbox`-tier entry modules remain refused by
21
+ * the parent-realm loader, so their isolation is never silently downgraded. Loading is
22
+ * also consent-gated: the binding reads
24
23
  * the renderer-inlined default-deny consent snapshot and holds a non-essential entry
25
24
  * back until its declared purposes are eligible. With an environment-only plan there
26
25
  * are no integrations, so the runtime boots inert and ready.
@@ -43,16 +42,24 @@ import type { NavigationCompletedEvent, NavigationKind } from "./events.js";
43
42
  import { CommerceEventBroker } from "./commerce-broker.js";
44
43
  import { validateBrowserEnvironment, BROWSER_ENVIRONMENT_GLOBAL, type BrowserEnvironment } from "./environment.js";
45
44
  import { makePlatformAdapterRuntimeLoader } from "./platform-adapter-runtime.js";
45
+ import { NATIVE_GA4_SANDBOX_URL, type NativeGa4Transport } from "./native-ga4.js";
46
+ import {
47
+ createBrowserNativeGa4SandboxHost,
48
+ createNativeGa4SandboxTransport,
49
+ } from "./native-ga4-sandbox.js";
46
50
  import { ManagedAnalyticsEventBrokerBridge } from "./managed-analytics-bridge.js";
47
51
  import { minimizePageAttribution, type ManagedPageViewInput } from "./managed-analytics.js";
52
+ import {
53
+ createContactSalesActionSource,
54
+ type ContactSalesActionDocument,
55
+ } from "./contact-sales-action-source.js";
48
56
 
49
57
  /** The window property holding the single per-document lifecycle (idempotent boot guard). */
50
58
  export const LIFECYCLE_SINGLETON_GLOBAL = "__TOT_INTEGRATION_LIFECYCLE__";
51
59
  /**
52
- * An optional window-published loader the entry-asset layer may install to OVERRIDE the
53
- * fail-closed same-origin default. It is the seam the null-origin sandboxed-iframe host
54
- * (the tracked F3 follow-up) installs to actually isolate a `sandbox`-tier entry; until
55
- * that host ships the seam is unused and the default loader refuses the sandbox tier.
60
+ * An optional window-published loader the entry-asset layer may install to override the
61
+ * fail-closed same-origin default. The default loader still refuses sandbox-tier modules;
62
+ * reviewed platform adapters construct any required isolation through their registry.
56
63
  */
57
64
  export const LIFECYCLE_MODULE_LOADER_GLOBAL = "__TOT_INTEGRATION_MODULE_LOADER__";
58
65
  /** An optional window-published sink for the completed-navigation event. */
@@ -68,7 +75,7 @@ export interface LifecycleWindow {
68
75
  * analytics data allowlist accepts no other document state — no title, no DOM, no
69
76
  * globals — so this is deliberately the narrowest slice that supports attribution.
70
77
  */
71
- document?: { referrer?: string };
78
+ document?: { referrer?: string; querySelectorAll?: ContactSalesActionDocument["querySelectorAll"] };
72
79
  [key: string]: unknown;
73
80
  }
74
81
 
@@ -110,6 +117,8 @@ export interface BootLifecycleOptions {
110
117
  fetchCapability?: typeof fetch;
111
118
  /** Entry import capability; injectable for tests and non-browser hosts. */
112
119
  importModule?: (url: string) => Promise<unknown>;
120
+ /** Reviewed null-origin GA4 transport; injectable for DOM-free tests. */
121
+ nativeGa4Transport?: NativeGa4Transport;
113
122
  }
114
123
 
115
124
  /**
@@ -142,11 +151,10 @@ const PARENT_REALM_TIER = "elevated-parent" as const;
142
151
  * a hardcoded vendor path. `importModule` is injected so the loader is testable without
143
152
  * a live network; it defaults to a native dynamic `import()`.
144
153
  *
145
- * FAIL-CLOSED BY TIER (design-partner finding F3): a parent-realm import gives the entry
154
+ * FAIL-CLOSED BY TIER: a parent-realm import gives the entry
146
155
  * full parent-document authority, so the loader refuses any tier but `elevated-parent`
147
156
  * BEFORE importing — a `sandbox`-tier entry throws without ever being fetched or
148
- * evaluated, so code labeled "sandboxed" cannot run in the parent while its isolating
149
- * iframe host is still the tracked follow-up. The throw is caught by the runtime's
157
+ * evaluated, so code labeled "sandboxed" cannot run in the parent. The throw is caught by the runtime's
150
158
  * per-integration load isolation: the entry never becomes active, the page is unaffected,
151
159
  * and every other integration still boots.
152
160
  */
@@ -159,8 +167,8 @@ export function makeSameOriginEntryLoader(
159
167
  `Refusing to load "${integration.appId}@${integration.version}" (tier ` +
160
168
  `"${integration.executionTier}") into the parent document: the same-origin loader ` +
161
169
  `only runs the "${PARENT_REALM_TIER}" tier. A "sandbox"-tier entry must run inside a ` +
162
- `null-origin iframe reachable only through the typed postMessage broker; that ` +
163
- `isolating host is not yet wired, so the entry is held fail-closed rather than run ` +
170
+ `null-origin iframe reachable only through a typed MessagePort broker, so the entry ` +
171
+ `is held fail-closed rather than run ` +
164
172
  `with parent-document authority its tier does not grant.`,
165
173
  );
166
174
  }
@@ -310,6 +318,16 @@ export function bootIntegrationLifecycle(
310
318
  fetchCapability: hostFetch,
311
319
  importModule: options.importModule,
312
320
  fallback: ordinaryLoader,
321
+ nativeGa4Transport: () => {
322
+ if (options.nativeGa4Transport) return options.nativeGa4Transport;
323
+ if (typeof document === "undefined" || typeof MessageChannel === "undefined") {
324
+ throw new Error("platform_adapter_ga4_sandbox_unavailable");
325
+ }
326
+ return createNativeGa4SandboxTransport(
327
+ createBrowserNativeGa4SandboxHost(document, MessageChannel),
328
+ NATIVE_GA4_SANDBOX_URL,
329
+ );
330
+ },
313
331
  });
314
332
 
315
333
  const isEligible = options.isEligible ?? defaultConsentGate(win);
@@ -337,7 +355,16 @@ export function bootIntegrationLifecycle(
337
355
  commerceBroker,
338
356
  managedAnalytics: {
339
357
  bridge: options.managedAnalyticsBridge ??
340
- new ManagedAnalyticsEventBrokerBridge({ broker: commerceBroker }),
358
+ new ManagedAnalyticsEventBrokerBridge({
359
+ broker: commerceBroker,
360
+ actionSource: createContactSalesActionSource({
361
+ document: typeof win.document?.querySelectorAll === "function"
362
+ ? win.document as ContactSalesActionDocument
363
+ : undefined,
364
+ planRevision: descriptor.planRevision,
365
+ now: options.now ?? Date.now,
366
+ }),
367
+ }),
341
368
  pageView: () => documentPageView(win, descriptor, options.now ?? Date.now),
342
369
  },
343
370
  });
@@ -2,12 +2,21 @@
2
2
  import { MANAGED_ANALYTICS_PII_VALUE_PATTERN } from "./managed-analytics-api-v1-safety.mjs";
3
3
 
4
4
  const MANAGED_ANALYTICS_PII_VALUE = new RegExp(MANAGED_ANALYTICS_PII_VALUE_PATTERN);
5
+ const MANAGED_ANALYTICS_PROTECTED_VALUE =
6
+ /(?:@|\d{7,}|(?:^|[./_~-])(?:bearer|password|secret|token|authorization|preview)(?:$|[./_~-]))/i;
5
7
 
6
8
  /** True when a public string resembles an email, SSN, payment card, or phone number. */
7
9
  export function resemblesManagedAnalyticsPii(value: unknown): boolean {
8
10
  return typeof value === "string" && MANAGED_ANALYTICS_PII_VALUE.test(value);
9
11
  }
10
12
 
13
+ /** True when a public value resembles PII or an authentication/protected token. */
14
+ export function resemblesManagedAnalyticsProtectedValue(value: unknown): boolean {
15
+ return typeof value === "string" && (
16
+ resemblesManagedAnalyticsPii(value) || MANAGED_ANALYTICS_PROTECTED_VALUE.test(value)
17
+ );
18
+ }
19
+
11
20
  /** Defense-in-depth for identifiers copied into structured broker observations. */
12
21
  export function safeManagedAnalyticsDiagnosticId(value: string): string {
13
22
  return resemblesManagedAnalyticsPii(value) ? "[redacted]" : value;
@@ -0,0 +1,2 @@
1
+ export const NATIVE_GA4_SANDBOX_ASSET_PATH: "platform/google-measurement/v2/sandbox.html";
2
+ export const NATIVE_GA4_SANDBOX_ASSET_URL: "/platform/google-measurement/v2/sandbox.html";
@@ -0,0 +1,3 @@
1
+ /** Stable provider-neutral artifact path for the reviewed GA4 null-origin sandbox. */
2
+ export const NATIVE_GA4_SANDBOX_ASSET_PATH = "platform/google-measurement/v2/sandbox.html";
3
+ export const NATIVE_GA4_SANDBOX_ASSET_URL = `/${NATIVE_GA4_SANDBOX_ASSET_PATH}`;
@@ -0,0 +1,207 @@
1
+ /** Runtime bundled inline into the versioned GA4 sandbox HTML. */
2
+ import { APPROVED_MARKETING_ACTION_NAMES } from "./events.js";
3
+ import {
4
+ NATIVE_GA4_SANDBOX_MAX_DESTINATIONS,
5
+ NATIVE_GA4_SANDBOX_PROTOCOL,
6
+ type NativeGa4SandboxPortMessage,
7
+ } from "./native-ga4-sandbox.js";
8
+ import { resemblesManagedAnalyticsProtectedValue } from "./managed-analytics-value-safety.js";
9
+
10
+ const MEASUREMENT_ID = /^G-[A-Z0-9]{4,20}$/;
11
+ const CAMPAIGN_VALUE = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/;
12
+ const APPROVED_ACTIONS: ReadonlySet<string> = new Set(APPROVED_MARKETING_ACTION_NAMES);
13
+
14
+ function plainRecord(value: unknown): Record<string, unknown> | null {
15
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
16
+ const proto = Object.getPrototypeOf(value);
17
+ return proto === Object.prototype || proto === null ? value as Record<string, unknown> : null;
18
+ }
19
+
20
+ function exactKeys(value: Record<string, unknown>, allowed: readonly string[], required = allowed): boolean {
21
+ const keys = Object.keys(value);
22
+ return required.every((key) => keys.includes(key)) && keys.every((key) => allowed.includes(key));
23
+ }
24
+
25
+ function validOrigin(value: unknown): value is string {
26
+ if (typeof value !== "string") return false;
27
+ try {
28
+ const parsed = new URL(value);
29
+ return parsed.origin === value && (
30
+ parsed.protocol === "https:" ||
31
+ (parsed.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname))
32
+ );
33
+ } catch { return false; }
34
+ }
35
+
36
+ function validPath(value: unknown): value is string {
37
+ return typeof value === "string" && value.startsWith("/") && !value.startsWith("//") &&
38
+ !value.includes("\\") && !value.includes("?") && !value.includes("#") &&
39
+ !resemblesManagedAnalyticsProtectedValue(value);
40
+ }
41
+
42
+ function validIds(value: unknown): value is string[] {
43
+ return Array.isArray(value) && value.length > 0 && value.length <= NATIVE_GA4_SANDBOX_MAX_DESTINATIONS &&
44
+ value.every((id) => typeof id === "string" && MEASUREMENT_ID.test(id)) && new Set(value).size === value.length;
45
+ }
46
+
47
+ function validCampaign(value: unknown): value is Record<string, string> {
48
+ const record = plainRecord(value);
49
+ if (!record || !exactKeys(record, ["source", "medium", "campaign", "term", "content"], [])) return false;
50
+ return Object.values(record).every((item) =>
51
+ typeof item === "string" && CAMPAIGN_VALUE.test(item) && !resemblesManagedAnalyticsProtectedValue(item));
52
+ }
53
+
54
+ export interface NativeGa4SandboxRuntimeWindow extends Pick<Window, "parent" | "location"> {
55
+ addEventListener(type: "message", listener: (event: MessageEvent) => void): void;
56
+ removeEventListener(type: "message", listener: (event: MessageEvent) => void): void;
57
+ dataLayer?: unknown[];
58
+ gtag?: (...args: unknown[]) => void;
59
+ }
60
+
61
+ export function startNativeGa4Sandbox(win: NativeGa4SandboxRuntimeWindow, doc: Document): void {
62
+ let bound = false;
63
+
64
+ const bind = (event: MessageEvent): void => {
65
+ try {
66
+ const data = plainRecord(event.data);
67
+ const ownOrigin = new URL(win.location.href).origin;
68
+ if (
69
+ bound || event.source !== win.parent || event.origin !== ownOrigin || event.ports.length !== 1 ||
70
+ !data || !exactKeys(data, ["protocol", "type"]) ||
71
+ data["protocol"] !== NATIVE_GA4_SANDBOX_PROTOCOL || data["type"] !== "bind"
72
+ ) return;
73
+ bound = true;
74
+ win.removeEventListener("message", bind);
75
+ runPort(event.ports[0]!, win, doc);
76
+ } catch {
77
+ // Hostile window messages fail closed without exposing an oracle.
78
+ }
79
+ };
80
+
81
+ win.addEventListener("message", bind);
82
+ }
83
+
84
+ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Document): void {
85
+ let active = false;
86
+ let disposed = false;
87
+ let canonicalOrigin = "";
88
+ let currentGroup = "";
89
+ let lastPageLocation = "";
90
+ let generation = 0;
91
+ let tagLoaded = false;
92
+
93
+ const dataLayer: unknown[] = [];
94
+ const gtag = function(..._args: unknown[]): void {
95
+ dataLayer.push(arguments);
96
+ };
97
+ win.dataLayer = dataLayer;
98
+ win.gtag = gtag;
99
+
100
+ const configure = (message: Record<string, unknown>): void => {
101
+ if (
102
+ !exactKeys(message, ["schemaVersion", "type", "destinationIds", "canonicalOrigin"]) ||
103
+ message["schemaVersion"] !== 1 || message["type"] !== "configure" ||
104
+ !validIds(message["destinationIds"]) || !validOrigin(message["canonicalOrigin"])
105
+ ) return;
106
+ const ids = message["destinationIds"];
107
+ canonicalOrigin = message["canonicalOrigin"];
108
+ currentGroup = `tot_storefront_${++generation}`;
109
+ lastPageLocation = "";
110
+ active = true;
111
+
112
+ if (!tagLoaded) {
113
+ tagLoaded = true;
114
+ gtag("consent", "default", {
115
+ analytics_storage: "denied",
116
+ ad_storage: "denied",
117
+ ad_user_data: "denied",
118
+ ad_personalization: "denied",
119
+ });
120
+ gtag("js", new Date());
121
+ }
122
+ for (const id of ids) {
123
+ gtag("config", id, {
124
+ groups: currentGroup,
125
+ send_page_view: false,
126
+ allow_google_signals: false,
127
+ allow_ad_personalization_signals: false,
128
+ restricted_data_processing: true,
129
+ ignore_referrer: true,
130
+ });
131
+ }
132
+ if (doc.querySelector('script[data-tot-ga4="1"]') === null) {
133
+ const script = doc.createElement("script");
134
+ script.async = true;
135
+ script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(ids[0]!)}`;
136
+ script.setAttribute("data-tot-ga4", "1");
137
+ doc.head.append(script);
138
+ }
139
+ };
140
+
141
+ const deliver = (message: Record<string, unknown>): void => {
142
+ if (!active || disposed || message["schemaVersion"] !== 1) return;
143
+ if (message["type"] === "page_view") {
144
+ if (!exactKeys(message, ["schemaVersion", "type", "path", "referrerOrigin", "campaign"], ["schemaVersion", "type", "path"])) return;
145
+ if (!validPath(message["path"])) return;
146
+ if (message["referrerOrigin"] !== undefined && !validOrigin(message["referrerOrigin"])) return;
147
+ if (message["campaign"] !== undefined && !validCampaign(message["campaign"])) return;
148
+ const pageLocation = `${canonicalOrigin}${message["path"]}`;
149
+ lastPageLocation = pageLocation;
150
+ const params: Record<string, unknown> = {
151
+ send_to: currentGroup,
152
+ page_location: pageLocation,
153
+ page_path: message["path"],
154
+ };
155
+ if (message["referrerOrigin"] !== undefined) params["page_referrer"] = message["referrerOrigin"];
156
+ if (message["campaign"] !== undefined) {
157
+ const campaign = message["campaign"] as Record<string, string>;
158
+ for (const key of ["source", "medium", "campaign", "term", "content"] as const) {
159
+ const ga4Key = key === "campaign" ? "name" : key;
160
+ if (campaign[key] !== undefined) params[`campaign_${ga4Key}`] = campaign[key];
161
+ }
162
+ }
163
+ gtag("event", "page_view", params);
164
+ return;
165
+ }
166
+ if (message["type"] === "action") {
167
+ if (!exactKeys(message, ["schemaVersion", "type", "name", "pageLocation"])) return;
168
+ if (
169
+ typeof message["name"] !== "string" || !APPROVED_ACTIONS.has(message["name"]) ||
170
+ message["pageLocation"] !== lastPageLocation || !lastPageLocation
171
+ ) return;
172
+ gtag("event", message["name"], { send_to: currentGroup, page_location: lastPageLocation });
173
+ }
174
+ };
175
+
176
+ port.onmessage = (event: MessageEvent<NativeGa4SandboxPortMessage>) => {
177
+ try {
178
+ const message = plainRecord(event.data);
179
+ if (!message || disposed) return;
180
+ if (message["type"] === "configure") return configure(message);
181
+ if (message["type"] === "disable") {
182
+ if (exactKeys(message, ["schemaVersion", "type"]) && message["schemaVersion"] === 1) {
183
+ active = false;
184
+ lastPageLocation = "";
185
+ }
186
+ return;
187
+ }
188
+ if (message["type"] === "dispose") {
189
+ if (exactKeys(message, ["schemaVersion", "type"]) && message["schemaVersion"] === 1) {
190
+ active = false;
191
+ disposed = true;
192
+ lastPageLocation = "";
193
+ port.close();
194
+ }
195
+ return;
196
+ }
197
+ deliver(message);
198
+ } catch {
199
+ // Every malformed port message fails closed without exposing an oracle.
200
+ }
201
+ };
202
+ port.start();
203
+ }
204
+
205
+ if (typeof window !== "undefined" && typeof document !== "undefined") {
206
+ startNativeGa4Sandbox(window, document);
207
+ }