@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.
- package/apps/storefront/astro.config.mjs +2 -0
- package/apps/storefront/integrations/google-measurement-sandbox-asset.mjs +97 -0
- package/apps/storefront/public/platform/google-measurement/v2/entry.mjs +13 -0
- package/package.json +1 -1
- package/packages/public-runtime/package.json +2 -1
- package/packages/public-runtime/src/integration/csp-compiler.ts +3 -6
- package/packages/public-runtime/src/integration/events.ts +5 -5
- package/packages/public-runtime/src/integration/index.ts +1 -0
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +28 -14
- package/packages/public-runtime/src/integration/managed-analytics-value-safety.ts +9 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +2 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +3 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +207 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +168 -0
- package/packages/public-runtime/src/integration/native-ga4.ts +111 -215
- package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +18 -9
- package/packages/public-runtime/src/integration/platform-adapters.ts +20 -9
- package/apps/storefront/public/platform/google-measurement/v1/entry.mjs +0 -19
|
@@ -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.
|
|
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
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
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 {
|
|
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 &&
|
|
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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
57
|
-
* fail-closed same-origin default.
|
|
58
|
-
*
|
|
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
|
|
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
|
|
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
|
|
167
|
-
`
|
|
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,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
|
+
}
|