@tokenoftrust/storefront-runner 2.0.30 → 2.0.32

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.
Files changed (37) hide show
  1. package/apps/storefront/astro.config.mjs +2 -0
  2. package/apps/storefront/env.d.ts +0 -2
  3. package/apps/storefront/integrations/google-measurement-sandbox-asset.mjs +97 -0
  4. package/apps/storefront/public/platform/google-measurement/v2/entry.mjs +13 -0
  5. package/apps/storefront/src/layouts/Layout.astro +3 -48
  6. package/apps/storefront/src/lib/analytics/budgets.json +0 -1
  7. package/apps/storefront/src/lib/analytics/index.ts +1 -4
  8. package/apps/storefront/src/lib/analytics/webVitals.ts +42 -4
  9. package/apps/storefront/src/lib/integration/tenantDeclarations.ts +1 -1
  10. package/apps/storefront/src/lib/monitoring/manifest.ts +1 -2
  11. package/package.json +1 -1
  12. package/packages/public-runtime/package.json +2 -1
  13. package/packages/public-runtime/src/csp.ts +2 -2
  14. package/packages/public-runtime/src/integration/consent-broker.ts +370 -13
  15. package/packages/public-runtime/src/integration/csp-compiler.ts +3 -6
  16. package/packages/public-runtime/src/integration/events.ts +5 -5
  17. package/packages/public-runtime/src/integration/index.ts +1 -0
  18. package/packages/public-runtime/src/integration/interaction-tracking-gate.ts +1 -2
  19. package/packages/public-runtime/src/integration/lifecycle-boot.ts +28 -14
  20. package/packages/public-runtime/src/integration/lifecycle-host-entry.ts +2 -0
  21. package/packages/public-runtime/src/integration/lifecycle-runtime.ts +256 -22
  22. package/packages/public-runtime/src/integration/managed-analytics-value-safety.ts +9 -0
  23. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +2 -0
  24. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +3 -0
  25. package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +207 -0
  26. package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +168 -0
  27. package/packages/public-runtime/src/integration/native-ga4.ts +111 -215
  28. package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +18 -9
  29. package/packages/public-runtime/src/integration/platform-adapters.ts +20 -9
  30. package/packages/public-runtime/src/integration/render-phases.ts +28 -2
  31. package/packages/public-runtime/src/integration/version.ts +2 -2
  32. package/packages/public-runtime/tests/fixtures/integration/commerce-event.example.json +1 -1
  33. package/packages/public-runtime/tests/fixtures/integration/commerce-order-event.example.json +1 -1
  34. package/packages/public-runtime/tests/fixtures/integration/integration-manifest-v2.example.json +1 -1
  35. package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/manifest.json +1 -1
  36. package/scripts/tenant/lint-embeds.mjs +0 -8
  37. package/apps/storefront/public/platform/google-measurement/v1/entry.mjs +0 -19
@@ -160,6 +160,13 @@ type LifecycleState = "idle" | "initializing" | "initialized" | "disposing" | "d
160
160
  interface ActiveIntegration {
161
161
  integration: BootDescriptorIntegration;
162
162
  module: IntegrationLifecycleModule;
163
+ eligible: boolean;
164
+ }
165
+
166
+ /** A module whose init hook has started but has not yet settled. */
167
+ interface StartingIntegration extends ActiveIntegration {
168
+ cancelled: boolean;
169
+ disposed: boolean;
163
170
  }
164
171
 
165
172
  function messageOf(error: unknown): string {
@@ -195,8 +202,16 @@ export function sanitizeNavigationUrl(raw: string, keepHash: boolean): string {
195
202
  export class IntegrationLifecycle {
196
203
  private state: LifecycleState = "idle";
197
204
  private readonly active: ActiveIntegration[] = [];
205
+ /** Modules visible to an interrupting consent withdrawal while init is unresolved. */
206
+ private readonly starting: StartingIntegration[] = [];
198
207
  /** Integrations held back by the consent gate; re-evaluated on each `update`. */
199
208
  private deferred: BootDescriptorIntegration[] = [];
209
+ /** Updates arriving during async initialization, replayed before bootstrap emission. */
210
+ private readonly pendingUpdates: LifecycleUpdateReason[] = [];
211
+ /** An atomic withdrawal/grant seen during init needs only deferred-load reconciliation. */
212
+ private pendingGrantReconciliation = false;
213
+ /** Serializes ordinary post-init updates; consent withdrawal interrupts an unresolved grant. */
214
+ private updateQueue: Promise<void> = Promise.resolve();
200
215
  private eventCounter = 0;
201
216
  private readonly opts: IntegrationLifecycleOptions;
202
217
 
@@ -220,16 +235,26 @@ export class IntegrationLifecycle {
220
235
  if (this.state !== "idle") return;
221
236
  this.state = "initializing";
222
237
  for (const integration of this.opts.descriptor.integrations) {
238
+ if (this.state !== "initializing") break;
223
239
  if (!this.eligible(integration)) {
224
240
  // Consent precedes the loader: hold it back, do not load, and record why.
225
241
  this.deferred.push(integration);
226
242
  this.observe({ stage: "gate", integration, ok: false });
227
243
  continue;
228
244
  }
229
- await this.initOne(integration);
245
+ const remainDeferred = await this.initOne(integration);
246
+ if (this.state !== "initializing") break;
247
+ if (remainDeferred) this.deferred.push(integration);
230
248
  }
231
249
  // A dispose that raced in during an await leaves us terminal; otherwise we are live.
232
250
  if (this.state === "initializing") this.state = "initialized";
251
+ if (this.state === "initialized" && this.pendingGrantReconciliation) {
252
+ this.pendingGrantReconciliation = false;
253
+ await this.loadNewlyEligible();
254
+ }
255
+ if (this.state === "initialized") {
256
+ for (const reason of this.pendingUpdates.splice(0)) await this.update(reason);
257
+ }
233
258
  // Last, so every adapter that just subscribed is registered when this emits: the
234
259
  // broker has no replay, so a page view emitted before their `init` reaches nobody.
235
260
  if (this.state === "initialized") this.emitBootstrapPageView();
@@ -250,6 +275,7 @@ export class IntegrationLifecycle {
250
275
  * throwing integration.
251
276
  */
252
277
  private emitBootstrapPageView(): void {
278
+ if (this.state !== "initialized") return;
253
279
  const managed = this.opts.managedAnalytics;
254
280
  if (!managed || managed.bridge.isInitialized || managed.bridge.isDisposed) return;
255
281
  if (this.opts.commerceBroker?.isConsentEligible("analytics") === false) return;
@@ -265,6 +291,11 @@ export class IntegrationLifecycle {
265
291
  return this.opts.isEligible ? this.opts.isEligible(integration) : true;
266
292
  }
267
293
 
294
+ /** Read terminal state across async hook boundaries without relying on stale narrowing. */
295
+ private isTerminal(): boolean {
296
+ return this.state === "disposing" || this.state === "disposed";
297
+ }
298
+
268
299
  /**
269
300
  * Load any consent-deferred integration that has since become eligible, in the
270
301
  * plan's resolved order. Called from `update` so a consent grant loads the
@@ -273,31 +304,179 @@ export class IntegrationLifecycle {
273
304
  private async loadNewlyEligible(): Promise<void> {
274
305
  if (this.deferred.length === 0) return;
275
306
  const stillDeferred: BootDescriptorIntegration[] = [];
307
+ const candidates: BootDescriptorIntegration[] = [];
276
308
  for (const integration of this.deferred) {
277
- if (this.eligible(integration)) await this.initOne(integration);
278
- else stillDeferred.push(integration);
309
+ (this.eligible(integration) ? candidates : stillDeferred).push(integration);
279
310
  }
311
+ // Reserve eligible entries before the first await so an interrupting consent
312
+ // transition cannot start a duplicate load of the same adapter.
280
313
  this.deferred = stillDeferred;
314
+ for (const integration of candidates) {
315
+ if (this.isTerminal()) break;
316
+ let remainDeferred = await this.initOne(integration);
317
+ // A rapid withdrawal can cancel this reserved init, then a regrant can arrive
318
+ // before it settles. Retry with a fresh module as soon as the cancelled attempt
319
+ // returns; otherwise that regrant has no later update left to wake it.
320
+ while (
321
+ remainDeferred
322
+ && this.state !== "disposing"
323
+ && this.state !== "disposed"
324
+ && this.eligible(integration)
325
+ ) {
326
+ remainDeferred = await this.initOne(integration);
327
+ }
328
+ if (remainDeferred && this.state !== "disposing" && this.state !== "disposed") {
329
+ this.deferred.push(integration);
330
+ }
331
+ }
281
332
  }
282
333
 
283
- private async initOne(integration: BootDescriptorIntegration): Promise<void> {
334
+ /** Returns true when consent changed during loading and the entry remains deferred. */
335
+ private async initOne(integration: BootDescriptorIntegration): Promise<boolean> {
336
+ if (this.isTerminal()) return false;
284
337
  let module: IntegrationLifecycleModule;
285
338
  try {
286
339
  module = await this.opts.loadModule(integration);
287
340
  } catch (error) {
288
341
  this.observe({ stage: "load", integration, ok: false, error: messageOf(error) });
289
- return;
342
+ return false;
343
+ }
344
+ if (this.state === "disposing" || this.state === "disposed") return false; // torn down mid-load: do not init.
345
+ // Consent may be withdrawn while the asynchronous loader is in flight. Recheck
346
+ // before calling tenant/vendor code; an entry must never initialize after denial.
347
+ if (!this.eligible(integration)) {
348
+ this.observe({ stage: "gate", integration, ok: false });
349
+ return true;
290
350
  }
291
- if (this.state === "disposing" || this.state === "disposed") return; // torn down mid-load: do not init.
351
+ const starting: StartingIntegration = {
352
+ integration,
353
+ module,
354
+ eligible: true,
355
+ cancelled: false,
356
+ disposed: false,
357
+ };
358
+ this.starting.push(starting);
359
+ let remainDeferred = false;
292
360
  try {
293
361
  await module.init(this.buildContext(integration));
294
- this.active.push({ integration, module });
295
- this.observe({ stage: "init", integration, ok: true });
362
+ // A consent withdrawal may arrive while vendor/sandbox init is unresolved. Its
363
+ // interrupt marks the module cancelled and invokes teardown immediately; never
364
+ // publish that late init as active, and load a fresh module on a later grant.
365
+ remainDeferred = starting.cancelled
366
+ || this.isTerminal()
367
+ || !this.eligible(integration);
368
+ if (!remainDeferred) {
369
+ this.active.push({ integration, module, eligible: true });
370
+ this.observe({ stage: "init", integration, ok: true });
371
+ }
296
372
  } catch (error) {
373
+ remainDeferred = starting.cancelled
374
+ || this.isTerminal()
375
+ || !this.eligible(integration);
297
376
  this.observe({ stage: "init", integration, ok: false, error: messageOf(error) });
377
+ } finally {
378
+ const index = this.starting.indexOf(starting);
379
+ if (index >= 0) this.starting.splice(index, 1);
380
+ }
381
+ return remainDeferred;
382
+ }
383
+
384
+ /**
385
+ * Interrupt a consent grant whose module init has not settled. Calling update and
386
+ * dispose synchronously gives the module/sandbox its immediate stop signal; returned
387
+ * promises are observed in the background so a hostile or stuck hook cannot delay the
388
+ * viewer's withdrawal. The late init is kept out of `active` by `cancelled` above.
389
+ */
390
+ private interruptWithdrawnStarts(reason: LifecycleUpdateReason): boolean {
391
+ const withdrawn = this.starting.filter(({ integration }) => !this.eligible(integration));
392
+ if (withdrawn.length === 0) return false;
393
+ for (const entry of withdrawn) {
394
+ if (entry.cancelled) continue;
395
+ entry.cancelled = true;
396
+ if (entry.module.update) {
397
+ try {
398
+ const result = entry.module.update(reason, this.buildContext(entry.integration));
399
+ void Promise.resolve(result).then(
400
+ () => this.observe({ stage: "update", integration: entry.integration, ok: true, reason }),
401
+ (error) => this.observe({
402
+ stage: "update", integration: entry.integration, ok: false, reason, error: messageOf(error),
403
+ }),
404
+ );
405
+ } catch (error) {
406
+ this.observe({
407
+ stage: "update", integration: entry.integration, ok: false, reason, error: messageOf(error),
408
+ });
409
+ }
410
+ }
411
+ if (entry.module.dispose && !entry.disposed) {
412
+ entry.disposed = true;
413
+ try {
414
+ const result = entry.module.dispose();
415
+ void Promise.resolve(result).then(
416
+ () => this.observe({ stage: "dispose", integration: entry.integration, ok: true }),
417
+ (error) => this.observe({
418
+ stage: "dispose", integration: entry.integration, ok: false, error: messageOf(error),
419
+ }),
420
+ );
421
+ } catch (error) {
422
+ this.observe({ stage: "dispose", integration: entry.integration, ok: false, error: messageOf(error) });
423
+ }
424
+ }
425
+ }
426
+ return true;
427
+ }
428
+
429
+ /** Deliver an update to a fixed set of active modules without loading deferred work. */
430
+ private async updateActive(
431
+ reason: LifecycleUpdateReason,
432
+ entries: readonly ActiveIntegration[],
433
+ ): Promise<void> {
434
+ for (const { integration, module } of entries) {
435
+ if (this.state !== "initialized") return;
436
+ if (!module.update) continue;
437
+ try {
438
+ await module.update(reason, this.buildContext(integration));
439
+ this.observe({ stage: "update", integration, ok: true, reason });
440
+ } catch (error) {
441
+ this.observe({ stage: "update", integration, ok: false, reason, error: messageOf(error) });
442
+ }
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Signal every active module synchronously during a withdrawal. Returned promises
448
+ * are observed in the background so one stuck adapter cannot delay the stop signal
449
+ * for another adapter or keep the viewer's withdrawal request pending.
450
+ */
451
+ private interruptActiveUpdates(
452
+ reason: LifecycleUpdateReason,
453
+ entries: readonly ActiveIntegration[],
454
+ ): void {
455
+ for (const { integration, module } of entries) {
456
+ if (!module.update) continue;
457
+ try {
458
+ const result = module.update(reason, this.buildContext(integration));
459
+ void Promise.resolve(result).then(
460
+ () => this.observe({ stage: "update", integration, ok: true, reason }),
461
+ (error) => this.observe({ stage: "update", integration, ok: false, reason, error: messageOf(error) }),
462
+ );
463
+ } catch (error) {
464
+ this.observe({ stage: "update", integration, ok: false, reason, error: messageOf(error) });
465
+ }
298
466
  }
299
467
  }
300
468
 
469
+ /** Record current eligibility and report whether this consent change withdrew an active entry. */
470
+ private updateActiveEligibility(): boolean {
471
+ let withdrew = false;
472
+ for (const entry of this.active) {
473
+ const eligible = this.eligible(entry.integration);
474
+ if (entry.eligible && !eligible) withdrew = true;
475
+ entry.eligible = eligible;
476
+ }
477
+ return withdrew;
478
+ }
479
+
301
480
  /**
302
481
  * Build the typed context handed to `init`/`update`: this integration's own
303
482
  * resolved public config (never another integration's), the document's
@@ -349,26 +528,59 @@ export class IntegrationLifecycle {
349
528
 
350
529
  /**
351
530
  * Deliver an in-place `update(reason)` to every initialized integration that implements
352
- * it. Used for a consent change, a config change, or a rollback — never a re-init. A no-op
353
- * before init or after dispose. One integration's throwing `update` is isolated.
531
+ * it. Used for a consent change, a config change, or a rollback — never a re-init. An
532
+ * update during async initialization is replayed before bootstrap emission; calls before
533
+ * initialization starts or after dispose are no-ops. One throwing `update` is isolated.
354
534
  */
355
535
  async update(reason: LifecycleUpdateReason): Promise<void> {
356
536
  if (!LIFECYCLE_UPDATE_REASONS.includes(reason)) return;
537
+ if (this.state !== "initializing" && this.state !== "initialized") return;
538
+ // Withdrawal is a safety interrupt, including while the document's initial
539
+ // lifecycle boot is still awaiting module.init().
540
+ let interruptedWithdrawal = false;
541
+ if (reason === "consent-changed") {
542
+ interruptedWithdrawal = this.interruptWithdrawnStarts(reason);
543
+ interruptedWithdrawal = this.updateActiveEligibility() || interruptedWithdrawal;
544
+ }
545
+ if (interruptedWithdrawal) {
546
+ // A mixed initial state can have earlier entries active while a later analytics
547
+ // entry is still starting. Stop both groups now; returning before this delivery
548
+ // would leave an active sibling subscribed under a withdrawn snapshot.
549
+ this.interruptActiveUpdates(reason, [...this.active]);
550
+ // One atomic consent snapshot may withdraw one purpose and grant another.
551
+ // Once initial traversal is complete, start those grants without making
552
+ // withdrawal wait for loader/vendor code. During traversal, queue the grant
553
+ // reconciliation so it cannot duplicate an entry the init loop has not reached.
554
+ if (this.state === "initializing") {
555
+ this.pendingGrantReconciliation = true;
556
+ } else {
557
+ void this.loadNewlyEligible().then(
558
+ () => this.emitBootstrapPageView(),
559
+ () => {},
560
+ );
561
+ }
562
+ return;
563
+ }
564
+ if (this.state === "initializing") {
565
+ this.pendingUpdates.push(reason);
566
+ return;
567
+ }
568
+ if (this.state !== "initialized") return;
569
+ const run = this.updateQueue.then(() => this.applyUpdate(reason));
570
+ this.updateQueue = run.catch(() => {});
571
+ await run;
572
+ }
573
+
574
+ private async applyUpdate(reason: LifecycleUpdateReason): Promise<void> {
357
575
  if (this.state !== "initialized") return;
358
576
  // A consent/config change may make a gated integration eligible: load it now
359
577
  // (init), then deliver the update only to integrations that were already live —
360
578
  // a just-loaded entry gets `init`, not an immediate redundant `update`.
361
579
  const alreadyActive = [...this.active];
362
580
  await this.loadNewlyEligible();
363
- for (const { integration, module } of alreadyActive) {
364
- if (!module.update) continue;
365
- try {
366
- await module.update(reason, this.buildContext(integration));
367
- this.observe({ stage: "update", integration, ok: true, reason });
368
- } catch (error) {
369
- this.observe({ stage: "update", integration, ok: false, reason, error: messageOf(error) });
370
- }
371
- }
581
+ if (this.state !== "initialized") return;
582
+ await this.updateActive(reason, alreadyActive);
583
+ if (this.state !== "initialized") return;
372
584
  // A consent grant is the case that matters: it both loads a previously-gated adapter
373
585
  // (above) and makes the held-back bootstrap page view emittable, so the document the
374
586
  // visitor consented ON is still counted rather than only its successors.
@@ -378,16 +590,38 @@ export class IntegrationLifecycle {
378
590
  /**
379
591
  * Dispose every initialized integration on teardown. Idempotent — a second dispose is a
380
592
  * no-op — and terminal: after it, `update` and a late `init` do nothing. One integration's
381
- * throwing `dispose` is isolated; every other integration is still disposed.
593
+ * throwing or unresolved `dispose` is isolated; every other integration is still signalled.
382
594
  */
383
595
  async dispose(): Promise<void> {
384
596
  if (this.state === "disposed" || this.state === "disposing") return;
385
597
  this.state = "disposing";
598
+ // Starting modules are already executing tenant/vendor code but are not yet in
599
+ // `active`. Cancel and signal teardown synchronously so a late init settlement
600
+ // cannot publish after this terminal transition.
601
+ for (const entry of this.starting) {
602
+ entry.cancelled = true;
603
+ if (!entry.module.dispose || entry.disposed) continue;
604
+ entry.disposed = true;
605
+ try {
606
+ const result = entry.module.dispose();
607
+ void Promise.resolve(result).then(
608
+ () => this.observe({ stage: "dispose", integration: entry.integration, ok: true }),
609
+ (error) => this.observe({
610
+ stage: "dispose", integration: entry.integration, ok: false, error: messageOf(error),
611
+ }),
612
+ );
613
+ } catch (error) {
614
+ this.observe({ stage: "dispose", integration: entry.integration, ok: false, error: messageOf(error) });
615
+ }
616
+ }
386
617
  for (const { integration, module } of this.active) {
387
618
  if (!module.dispose) continue;
388
619
  try {
389
- await module.dispose();
390
- this.observe({ stage: "dispose", integration, ok: true });
620
+ const result = module.dispose();
621
+ void Promise.resolve(result).then(
622
+ () => this.observe({ stage: "dispose", integration, ok: true }),
623
+ (error) => this.observe({ stage: "dispose", integration, ok: false, error: messageOf(error) }),
624
+ );
391
625
  } catch (error) {
392
626
  this.observe({ stage: "dispose", integration, ok: false, error: messageOf(error) });
393
627
  }
@@ -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
+ }