@tokenoftrust/storefront-runner 2.0.30 → 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.30",
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",
@@ -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";
@@ -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,6 +42,11 @@ 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";
48
52
  import {
@@ -53,10 +57,9 @@ import {
53
57
  /** The window property holding the single per-document lifecycle (idempotent boot guard). */
54
58
  export const LIFECYCLE_SINGLETON_GLOBAL = "__TOT_INTEGRATION_LIFECYCLE__";
55
59
  /**
56
- * An optional window-published loader the entry-asset layer may install to OVERRIDE the
57
- * fail-closed same-origin default. It is the seam the null-origin sandboxed-iframe host
58
- * (the tracked F3 follow-up) installs to actually isolate a `sandbox`-tier entry; until
59
- * 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.
60
63
  */
61
64
  export const LIFECYCLE_MODULE_LOADER_GLOBAL = "__TOT_INTEGRATION_MODULE_LOADER__";
62
65
  /** An optional window-published sink for the completed-navigation event. */
@@ -114,6 +117,8 @@ export interface BootLifecycleOptions {
114
117
  fetchCapability?: typeof fetch;
115
118
  /** Entry import capability; injectable for tests and non-browser hosts. */
116
119
  importModule?: (url: string) => Promise<unknown>;
120
+ /** Reviewed null-origin GA4 transport; injectable for DOM-free tests. */
121
+ nativeGa4Transport?: NativeGa4Transport;
117
122
  }
118
123
 
119
124
  /**
@@ -146,11 +151,10 @@ const PARENT_REALM_TIER = "elevated-parent" as const;
146
151
  * a hardcoded vendor path. `importModule` is injected so the loader is testable without
147
152
  * a live network; it defaults to a native dynamic `import()`.
148
153
  *
149
- * 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
150
155
  * full parent-document authority, so the loader refuses any tier but `elevated-parent`
151
156
  * BEFORE importing — a `sandbox`-tier entry throws without ever being fetched or
152
- * evaluated, so code labeled "sandboxed" cannot run in the parent while its isolating
153
- * 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
154
158
  * per-integration load isolation: the entry never becomes active, the page is unaffected,
155
159
  * and every other integration still boots.
156
160
  */
@@ -163,8 +167,8 @@ export function makeSameOriginEntryLoader(
163
167
  `Refusing to load "${integration.appId}@${integration.version}" (tier ` +
164
168
  `"${integration.executionTier}") into the parent document: the same-origin loader ` +
165
169
  `only runs the "${PARENT_REALM_TIER}" tier. A "sandbox"-tier entry must run inside a ` +
166
- `null-origin iframe reachable only through the typed postMessage broker; that ` +
167
- `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 ` +
168
172
  `with parent-document authority its tier does not grant.`,
169
173
  );
170
174
  }
@@ -314,6 +318,16 @@ export function bootIntegrationLifecycle(
314
318
  fetchCapability: hostFetch,
315
319
  importModule: options.importModule,
316
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
+ },
317
331
  });
318
332
 
319
333
  const isEligible = options.isEligible ?? defaultConsentGate(win);
@@ -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
+ }