@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.
- 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/contact-sales-action-source.ts +75 -0
- 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 +2 -0
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +43 -16
- 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",
|
|
@@ -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
|
-
//
|
|
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";
|
|
@@ -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
|
|
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,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
|
|
53
|
-
* fail-closed same-origin default.
|
|
54
|
-
*
|
|
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
|
|
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
|
|
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
|
|
163
|
-
`
|
|
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({
|
|
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,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
|
+
}
|