@tokenoftrust/storefront-runner 2.0.39 → 2.0.41

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.
@@ -1,4 +1,4 @@
1
- /** Reviewed parent-side marker for the null-origin GA4 platform adapter. */
1
+ /** Reviewed parent-side marker for the isolated-origin GA4 platform adapter. */
2
2
  export const platformAdapterEntry = Object.freeze({
3
3
  schemaVersion: 1,
4
4
  origin: "platform-adapter",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.0.39",
3
+ "version": "2.0.41",
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",
@@ -63,9 +63,12 @@ export const INTEGRATION_CSP_PROFILES: Readonly<Record<string, CspSourceSet>> =
63
63
  "connect-src": ["https://cdn.usefathom.com"],
64
64
  },
65
65
 
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": {},
66
+ // Google code and network authority live only in the isolated-origin sandbox. The
67
+ // hosted preview uses the dedicated tokenoftrust.store sibling. Custom-domain
68
+ // publication aliases are added from the validated canonical host at declaration time.
69
+ "google-measurement": {
70
+ "frame-src": ["https://analytics-frame.tokenoftrust.store"],
71
+ },
69
72
 
70
73
  // Meta pixel (fbevents.js). The loader is served from connect.facebook.net; the
71
74
  // pixel's collect endpoint is the facebook.com tracking origin. The tenant
@@ -16,7 +16,7 @@
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 reviewed registry entry may construct its own null-origin iframe and
19
+ * other tier. A reviewed registry entry may construct its own isolated-origin iframe and
20
20
  * private typed MessagePort; arbitrary `sandbox`-tier entry modules remain refused by
21
21
  * the parent-realm loader, so their isolation is never silently downgraded. Loading is
22
22
  * also consent-gated: the binding reads
@@ -42,7 +42,7 @@ import type { NavigationCompletedEvent, NavigationKind } from "./events.js";
42
42
  import { CommerceEventBroker } from "./commerce-broker.js";
43
43
  import { validateBrowserEnvironment, BROWSER_ENVIRONMENT_GLOBAL, type BrowserEnvironment } from "./environment.js";
44
44
  import { makePlatformAdapterRuntimeLoader } from "./platform-adapter-runtime.js";
45
- import { NATIVE_GA4_SANDBOX_URL, type NativeGa4Transport } from "./native-ga4.js";
45
+ import { resolveNativeGa4SandboxUrl, type NativeGa4Transport } from "./native-ga4.js";
46
46
  import {
47
47
  createBrowserNativeGa4SandboxHost,
48
48
  createNativeGa4SandboxTransport,
@@ -118,7 +118,7 @@ export interface BootLifecycleOptions {
118
118
  fetchCapability?: typeof fetch;
119
119
  /** Entry import capability; injectable for tests and non-browser hosts. */
120
120
  importModule?: (url: string) => Promise<unknown>;
121
- /** Reviewed null-origin GA4 transport; injectable for DOM-free tests. */
121
+ /** Reviewed isolated-origin GA4 transport; injectable for DOM-free tests. */
122
122
  nativeGa4Transport?: NativeGa4Transport;
123
123
  }
124
124
 
@@ -326,7 +326,7 @@ export function bootIntegrationLifecycle(
326
326
  }
327
327
  return createNativeGa4SandboxTransport(
328
328
  createBrowserNativeGa4SandboxHost(document, MessageChannel),
329
- NATIVE_GA4_SANDBOX_URL,
329
+ resolveNativeGa4SandboxUrl(win.location.href),
330
330
  );
331
331
  },
332
332
  });
@@ -1,2 +1,4 @@
1
1
  export const NATIVE_GA4_SANDBOX_ASSET_PATH: "platform/google-measurement/v2/sandbox.html";
2
2
  export const NATIVE_GA4_SANDBOX_ASSET_URL: "/platform/google-measurement/v2/sandbox.html";
3
+ export const NATIVE_GA4_SANDBOX_ORIGIN: "https://analytics-frame.tokenoftrust.store";
4
+ export function resolveNativeGa4SandboxUrl(parentHref: string): string;
@@ -1,3 +1,28 @@
1
- /** Stable provider-neutral artifact path for the reviewed GA4 null-origin sandbox. */
1
+ /** Stable provider-neutral artifact path for the reviewed GA4 transport frame. */
2
2
  export const NATIVE_GA4_SANDBOX_ASSET_PATH = "platform/google-measurement/v2/sandbox.html";
3
3
  export const NATIVE_GA4_SANDBOX_ASSET_URL = `/${NATIVE_GA4_SANDBOX_ASSET_PATH}`;
4
+ export const NATIVE_GA4_SANDBOX_ORIGIN = "https://analytics-frame.tokenoftrust.store";
5
+
6
+ /**
7
+ * Keep Google tag execution on a real, isolated origin. An opaque sandbox origin makes
8
+ * gtag.js load successfully but silently suppresses every collection request. Production
9
+ * uses a dedicated no-auth hostname for hosted surfaces and the tenant's paired apex/www
10
+ * publication alias for custom domains. Local development uses an analytics.localhost
11
+ * sibling while retaining the current dev-server port.
12
+ */
13
+ export function resolveNativeGa4SandboxUrl(parentHref) {
14
+ const parent = new URL(parentHref);
15
+ if (parent.hostname === "localhost" || parent.hostname.endsWith(".localhost")) {
16
+ const sandbox = new URL(NATIVE_GA4_SANDBOX_ASSET_URL, parent);
17
+ sandbox.hostname = "analytics.localhost";
18
+ return sandbox.href;
19
+ }
20
+ if (parent.hostname === "tokenoftrust.store" || parent.hostname.endsWith(".tokenoftrust.store")) {
21
+ return new URL(NATIVE_GA4_SANDBOX_ASSET_URL, NATIVE_GA4_SANDBOX_ORIGIN).href;
22
+ }
23
+ const sandbox = new URL(NATIVE_GA4_SANDBOX_ASSET_URL, parent);
24
+ sandbox.hostname = parent.hostname.startsWith("www.")
25
+ ? parent.hostname.slice("www.".length)
26
+ : `www.${parent.hostname}`;
27
+ return sandbox.href;
28
+ }
@@ -64,11 +64,11 @@ export function startNativeGa4Sandbox(win: NativeGa4SandboxRuntimeWindow, doc: D
64
64
  const bind = (event: MessageEvent): void => {
65
65
  try {
66
66
  const data = plainRecord(event.data);
67
- const ownOrigin = new URL(win.location.href).origin;
68
67
  if (
69
- bound || event.source !== win.parent || event.origin !== ownOrigin || event.ports.length !== 1 ||
70
- !data || !exactKeys(data, ["protocol", "type"]) ||
68
+ bound || event.source !== win.parent || event.ports.length !== 1 ||
69
+ !data || !exactKeys(data, ["protocol", "type", "parentOrigin"]) ||
71
70
  data["protocol"] !== NATIVE_GA4_SANDBOX_PROTOCOL || data["type"] !== "bind"
71
+ || !validOrigin(data["parentOrigin"]) || event.origin !== data["parentOrigin"]
72
72
  ) return;
73
73
  bound = true;
74
74
  win.removeEventListener("message", bind);
@@ -85,9 +85,8 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
85
85
  let active = false;
86
86
  let disposed = false;
87
87
  let canonicalOrigin = "";
88
- let currentGroup = "";
88
+ let currentDestinations: string[] = [];
89
89
  let lastPageLocation = "";
90
- let generation = 0;
91
90
  let tagLoaded = false;
92
91
 
93
92
  const dataLayer: unknown[] = [];
@@ -99,13 +98,14 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
99
98
 
100
99
  const configure = (message: Record<string, unknown>): void => {
101
100
  if (
102
- !exactKeys(message, ["schemaVersion", "type", "destinationIds", "canonicalOrigin"]) ||
101
+ !exactKeys(message, ["schemaVersion", "type", "destinationIds", "canonicalOrigin", "analyticsConsent"]) ||
103
102
  message["schemaVersion"] !== 1 || message["type"] !== "configure" ||
103
+ message["analyticsConsent"] !== "granted" ||
104
104
  !validIds(message["destinationIds"]) || !validOrigin(message["canonicalOrigin"])
105
105
  ) return;
106
106
  const ids = message["destinationIds"];
107
107
  canonicalOrigin = message["canonicalOrigin"];
108
- currentGroup = `tot_storefront_${++generation}`;
108
+ currentDestinations = [...ids];
109
109
  lastPageLocation = "";
110
110
  active = true;
111
111
 
@@ -119,9 +119,13 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
119
119
  });
120
120
  gtag("js", new Date());
121
121
  }
122
+ // This sandbox is created and configured only after the parent lifecycle's
123
+ // platform consent gate grants analytics. Mirror that granted state into Google's
124
+ // consent mode; leaving its default denied suppresses the explicit page_view that
125
+ // follows even though the platform correctly admitted it.
126
+ gtag("consent", "update", { analytics_storage: message["analyticsConsent"] });
122
127
  for (const id of ids) {
123
128
  gtag("config", id, {
124
- groups: currentGroup,
125
129
  send_page_view: false,
126
130
  allow_google_signals: false,
127
131
  allow_ad_personalization_signals: false,
@@ -148,7 +152,6 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
148
152
  const pageLocation = `${canonicalOrigin}${message["path"]}`;
149
153
  lastPageLocation = pageLocation;
150
154
  const params: Record<string, unknown> = {
151
- send_to: currentGroup,
152
155
  page_location: pageLocation,
153
156
  page_path: message["path"],
154
157
  };
@@ -160,7 +163,9 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
160
163
  if (campaign[key] !== undefined) params[`campaign_${ga4Key}`] = campaign[key];
161
164
  }
162
165
  }
163
- gtag("event", "page_view", params);
166
+ for (const id of currentDestinations) {
167
+ gtag("event", "page_view", { ...params, send_to: id });
168
+ }
164
169
  return;
165
170
  }
166
171
  if (message["type"] === "action") {
@@ -169,7 +174,9 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
169
174
  typeof message["name"] !== "string" || !APPROVED_ACTIONS.has(message["name"]) ||
170
175
  message["pageLocation"] !== lastPageLocation || !lastPageLocation
171
176
  ) return;
172
- gtag("event", message["name"], { send_to: currentGroup, page_location: lastPageLocation });
177
+ for (const id of currentDestinations) {
178
+ gtag("event", message["name"], { send_to: id, page_location: lastPageLocation });
179
+ }
173
180
  }
174
181
  };
175
182
 
@@ -181,6 +188,7 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
181
188
  if (message["type"] === "disable") {
182
189
  if (exactKeys(message, ["schemaVersion", "type"]) && message["schemaVersion"] === 1) {
183
190
  active = false;
191
+ currentDestinations = [];
184
192
  lastPageLocation = "";
185
193
  }
186
194
  return;
@@ -189,6 +197,7 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
189
197
  if (exactKeys(message, ["schemaVersion", "type"]) && message["schemaVersion"] === 1) {
190
198
  active = false;
191
199
  disposed = true;
200
+ currentDestinations = [];
192
201
  lastPageLocation = "";
193
202
  port.close();
194
203
  }
@@ -1,4 +1,4 @@
1
- /** Closed parent/iframe protocol for the reviewed GA4 null-origin sandbox. */
1
+ /** Closed parent/iframe protocol for the reviewed GA4 isolated-origin sandbox. */
2
2
  export const NATIVE_GA4_SANDBOX_PROTOCOL = "tot.ga4-sandbox/1" as const;
3
3
  export const NATIVE_GA4_SANDBOX_MAX_DESTINATIONS = 4 as const;
4
4
 
@@ -6,6 +6,8 @@ export interface NativeGa4SandboxConfig {
6
6
  schemaVersion: 1;
7
7
  destinationIds: string[];
8
8
  canonicalOrigin: string;
9
+ /** The parent may configure GA4 only after the platform consent gate grants analytics. */
10
+ analyticsConsent: "granted";
9
11
  }
10
12
 
11
13
  export interface NativeGa4PageViewCommand {
@@ -52,6 +54,7 @@ export interface NativeGa4SandboxFrame {
52
54
  }
53
55
 
54
56
  export interface NativeGa4SandboxHost {
57
+ parentOrigin: string;
55
58
  createFrame(): NativeGa4SandboxFrame;
56
59
  appendFrame(frame: NativeGa4SandboxFrame): void;
57
60
  createChannel(): { port1: NativeGa4MessagePort; port2: Transferable };
@@ -71,6 +74,7 @@ export function createBrowserNativeGa4SandboxHost(
71
74
  messageChannel: typeof MessageChannel,
72
75
  ): NativeGa4SandboxHost {
73
76
  return {
77
+ parentOrigin: doc.location.origin,
74
78
  createFrame: () => doc.createElement("iframe"),
75
79
  appendFrame: (frame) => (doc.body ?? doc.documentElement).append(frame as HTMLIFrameElement),
76
80
  createChannel: () => new messageChannel(),
@@ -80,17 +84,21 @@ export function createBrowserNativeGa4SandboxHost(
80
84
  }
81
85
 
82
86
  /**
83
- * Mount one hidden same-origin iframe and transfer exactly one MessagePort after load.
84
- * The wildcard target is required because `allow-scripts` without `allow-same-origin`
85
- * gives the receiver an opaque origin; the exact iframe window and one-shot transfer
87
+ * Mount one hidden same-site, cross-origin iframe and transfer exactly one MessagePort
88
+ * after load. gtag suppresses collection in an opaque third-party frame, so the receiver
89
+ * needs a real same-site origin. The exact origin, iframe window, and one-shot transfer
86
90
  * are the authority boundary. No general parent `message` listener is installed.
87
91
  */
88
92
  export function createNativeGa4SandboxTransport(
89
93
  host: NativeGa4SandboxHost,
90
94
  sandboxUrl: string,
91
95
  ): NativeGa4SandboxTransport {
96
+ const sandboxOrigin = new URL(sandboxUrl, host.parentOrigin).origin;
97
+ if (!host.parentOrigin || sandboxOrigin === host.parentOrigin) {
98
+ throw new Error("ga4_sandbox_origin_not_isolated");
99
+ }
92
100
  const frame = host.createFrame();
93
- frame.setAttribute("sandbox", "allow-scripts");
101
+ frame.setAttribute("sandbox", "allow-scripts allow-same-origin");
94
102
  frame.setAttribute("referrerpolicy", "no-referrer");
95
103
  frame.setAttribute("aria-hidden", "true");
96
104
  frame.setAttribute("tabindex", "-1");
@@ -119,8 +127,8 @@ export function createNativeGa4SandboxTransport(
119
127
  port = channel.port1;
120
128
  try {
121
129
  target.postMessage(
122
- Object.freeze({ protocol: NATIVE_GA4_SANDBOX_PROTOCOL, type: "bind" }),
123
- "*",
130
+ Object.freeze({ protocol: NATIVE_GA4_SANDBOX_PROTOCOL, type: "bind", parentOrigin: host.parentOrigin }),
131
+ sandboxOrigin,
124
132
  [channel.port2],
125
133
  );
126
134
  } catch {
@@ -4,7 +4,7 @@
4
4
  * The adapter consumes only manually-authored page/action events from the scoped
5
5
  * lifecycle subscription. It never loads Google code into the regulated parent.
6
6
  * Instead, a closed command crosses one private MessagePort into the reviewed,
7
- * null-origin GA4 sandbox after consent and logical-event dedupe have passed.
7
+ * isolated-origin GA4 sandbox after consent and logical-event dedupe have passed.
8
8
  */
9
9
  import type { IntegrationLifecycleContext, IntegrationLifecycleModule, LifecycleUpdateReason } from "./lifecycle.js";
10
10
  import type { CommerceEventType } from "./events.js";
@@ -21,10 +21,14 @@ import type {
21
21
  NativeGa4SandboxCommand,
22
22
  NativeGa4SandboxConfig,
23
23
  } from "./native-ga4-sandbox.js";
24
- import { NATIVE_GA4_SANDBOX_ASSET_URL } from "./native-ga4-sandbox-asset.mjs";
24
+ import {
25
+ NATIVE_GA4_SANDBOX_ASSET_URL,
26
+ resolveNativeGa4SandboxUrl,
27
+ } from "./native-ga4-sandbox-asset.mjs";
25
28
 
26
29
  export const NATIVE_GA4_ENTRY_URL = "/platform/google-measurement/v2/entry.mjs" as const;
27
30
  export const NATIVE_GA4_SANDBOX_URL = NATIVE_GA4_SANDBOX_ASSET_URL;
31
+ export { resolveNativeGa4SandboxUrl };
28
32
  export const NATIVE_GA4_ADAPTER_VERSION = "1.0.0" as const;
29
33
  export const NATIVE_GA4_RUNTIME_ID = "native-ga4-v2" as const;
30
34
 
@@ -223,6 +227,7 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
223
227
  schemaVersion: 1 as const,
224
228
  destinationIds: [...next.destinationIds],
225
229
  canonicalOrigin: next.canonicalOrigin,
230
+ analyticsConsent: "granted" as const,
226
231
  };
227
232
  runTransportEffect(transport?.configure ? () => transport.configure!(configuration) : undefined);
228
233
  } catch {
@@ -75,7 +75,7 @@ import {
75
75
  } from "./manifest.js";
76
76
  import type { IntegrationCspProfile } from "./csp-compiler.js";
77
77
  import { INTEGRATION_CSP_PROFILES } from "./csp-compiler.js";
78
- import { INTEGRATION_CSP_DIRECTIVES } from "./csp.js";
78
+ import { INTEGRATION_CSP_DIRECTIVES, type CspSourceSet } from "./csp.js";
79
79
  import { digestValue } from "./digest.js";
80
80
  import type { EffectivePlan } from "./plan.js";
81
81
  import { resolveEffectivePlan, type IntegrationDeclarationInput } from "./resolve.js";
@@ -140,7 +140,7 @@ const AMPLITUDE_PROJECT_KEY = /^(?=[a-f0-9]{32}$)(?=.*[a-f])[a-f0-9]+$/i;
140
140
 
141
141
  /**
142
142
  * Native Google Analytics 4 measurement. Reviewed parent code sends a closed event
143
- * protocol to supported gtag.js inside a null-origin iframe. Google code and network
143
+ * protocol to supported gtag.js inside a same-site, cross-origin iframe. Google code and network
144
144
  * authority stay out of the parent document. It does not run a merchant-owned browser
145
145
  * container. Depends on analytics consent (ADR 0023 Q3).
146
146
  */
@@ -486,6 +486,13 @@ export function buildPlatformAdapterDeclaration(
486
486
  return blocked("validation-blocked", v.items);
487
487
  }
488
488
 
489
+ const cspSources: CspSourceSet = {};
490
+ if (def.adapterId === GOOGLE_MEASUREMENT_ADAPTER.adapterId) {
491
+ const canonicalHost = envResult.value.canonicalHost.toLowerCase();
492
+ const apexHost = canonicalHost.startsWith("www.") ? canonicalHost.slice(4) : canonicalHost;
493
+ cspSources["frame-src"] = [`https://${apexHost}`, `https://www.${apexHost}`];
494
+ }
495
+
489
496
  // Public config: one flat, primitive, PUBLIC id per configured stage. These are
490
497
  // public measurement ids (they ship in page HTML), never secret material.
491
498
  const publicConfig: Record<string, unknown> = {};
@@ -530,7 +537,7 @@ export function buildPlatformAdapterDeclaration(
530
537
  loadWhen: "all-declared-purposes-eligible",
531
538
  unknownState: "block",
532
539
  },
533
- csp: { profiles: [def.cspProfile], sources: {} },
540
+ csp: { profiles: [def.cspProfile], sources: cspSources },
534
541
  permissions: { events: adapterEventTypes(def), data: [], actions: [], domIntent: "none" },
535
542
  performanceBudget: def.performanceBudget,
536
543
  },