@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
@@ -92,13 +92,16 @@ export interface ConsentDestination {
92
92
 
93
93
  /**
94
94
  * The versioned, browser-safe consent snapshot — the single value client and server
95
- * destinations filter against. `eligiblePurposes` is in canonical purpose order and
96
- * always contains `essential`. `unknownState` is always `"block"` (fail closed).
95
+ * destinations filter against. `eligiblePurposes` and `approvedPurposes` are in
96
+ * canonical purpose order and always contain `essential`; approval is deliberately
97
+ * separate from current viewer eligibility. `unknownState` is always `"block"`.
97
98
  */
98
99
  export interface ConsentSnapshot {
99
100
  schemaVersion: typeof CONSENT_SNAPSHOT_SCHEMA_VERSION;
100
101
  snapshotId: string;
101
102
  eligiblePurposes: ConsentPurpose[];
103
+ /** Purposes the tenant approved to exist, whether or not this viewer granted them. */
104
+ approvedPurposes: ConsentPurpose[];
102
105
  unknownState: "block";
103
106
  }
104
107
 
@@ -113,20 +116,32 @@ function fnv1a32Hex(input: string): string {
113
116
  return (h >>> 0).toString(16).padStart(8, "0");
114
117
  }
115
118
 
116
- /** Content-addressed snapshot id: identical eligibility → identical id on client and server. */
117
- function computeSnapshotId(eligible: readonly ConsentPurpose[]): string {
118
- return `cs1-${fnv1a32Hex(`${CONSENT_SNAPSHOT_SCHEMA_VERSION}:${eligible.join(",")}`)}`;
119
+ /** Content-addressed snapshot id: identical eligibility + approval → identical id on client and server. */
120
+ function computeSnapshotId(
121
+ eligible: readonly ConsentPurpose[],
122
+ approved: readonly ConsentPurpose[],
123
+ ): string {
124
+ return `cs${CONSENT_SNAPSHOT_SCHEMA_VERSION}-${fnv1a32Hex(
125
+ `${CONSENT_SNAPSHOT_SCHEMA_VERSION}:${eligible.join(",")}:${approved.join(",")}`,
126
+ )}`;
119
127
  }
120
128
 
121
129
  /** Build a snapshot from an eligible set, forcing `essential` on and canonicalizing order. */
122
- function makeSnapshot(eligible: Iterable<ConsentPurpose>): ConsentSnapshot {
130
+ function makeSnapshot(
131
+ eligible: Iterable<ConsentPurpose>,
132
+ approved: Iterable<ConsentPurpose> = eligible,
133
+ ): ConsentSnapshot {
123
134
  const set = new Set<ConsentPurpose>(eligible);
124
135
  set.add(ESSENTIAL);
136
+ const approvedSet = new Set<ConsentPurpose>(approved);
137
+ approvedSet.add(ESSENTIAL);
125
138
  const eligiblePurposes = CONSENT_PURPOSES.filter((p) => set.has(p));
139
+ const approvedPurposes = CONSENT_PURPOSES.filter((p) => approvedSet.has(p));
126
140
  return {
127
141
  schemaVersion: CONSENT_SNAPSHOT_SCHEMA_VERSION,
128
- snapshotId: computeSnapshotId(eligiblePurposes),
142
+ snapshotId: computeSnapshotId(eligiblePurposes, approvedPurposes),
129
143
  eligiblePurposes,
144
+ approvedPurposes,
130
145
  unknownState: "block",
131
146
  };
132
147
  }
@@ -137,7 +152,7 @@ function makeSnapshot(eligible: Iterable<ConsentPurpose>): ConsentSnapshot {
137
152
  * granted. This is what precedes every vendor loader.
138
153
  */
139
154
  export function defaultConsentSnapshot(): ConsentSnapshot {
140
- return makeSnapshot([ESSENTIAL]);
155
+ return makeSnapshot([ESSENTIAL], [ESSENTIAL]);
141
156
  }
142
157
 
143
158
  /**
@@ -204,9 +219,8 @@ export function serializeConsentSnapshot(snapshot: ConsentSnapshot): string {
204
219
 
205
220
  /**
206
221
  * Parse a serialized snapshot fail-closed: an unknown schema version, a non-block
207
- * unknown state, an unknown purpose, a missing `essential`, or an id that does not
208
- * match the eligible set yields `null` — a caller cannot smuggle in a forged or
209
- * tampered snapshot.
222
+ * unknown state, an unknown purpose, a missing `essential`, eligibility outside the
223
+ * tenant-approved set, or an id that does not match both sets yields `null`.
210
224
  */
211
225
  export function parseConsentSnapshot(raw: unknown): ConsentSnapshot | null {
212
226
  let obj: unknown = raw;
@@ -230,7 +244,14 @@ export function parseConsentSnapshot(raw: unknown): ConsentSnapshot | null {
230
244
  }
231
245
  const eligible = CONSENT_PURPOSES.filter((p) => (eligibleRaw as string[]).includes(p));
232
246
  if (!eligible.includes(ESSENTIAL)) return null;
233
- const canonical = makeSnapshot(eligible);
247
+ const approvedRaw = s["approvedPurposes"];
248
+ if (!Array.isArray(approvedRaw)) return null;
249
+ for (const p of approvedRaw) {
250
+ if (typeof p !== "string" || !known.has(p)) return null;
251
+ }
252
+ const approved = CONSENT_PURPOSES.filter((p) => (approvedRaw as string[]).includes(p));
253
+ if (!approved.includes(ESSENTIAL) || eligible.some((p) => !approved.includes(p))) return null;
254
+ const canonical = makeSnapshot(eligible, approved);
234
255
  if (canonical.snapshotId !== s["snapshotId"]) return null;
235
256
  return canonical;
236
257
  }
@@ -423,7 +444,7 @@ export class ConsentBroker {
423
444
 
424
445
  private compute(): ConsentSnapshot {
425
446
  const eligible = CONSENT_PURPOSES.filter((p) => this.effective(p) === "granted");
426
- return makeSnapshot(eligible);
447
+ return makeSnapshot(eligible, [ESSENTIAL, ...this.approved.keys()]);
427
448
  }
428
449
 
429
450
  private update(): ConsentSnapshot {
@@ -450,3 +471,339 @@ export class ConsentBroker {
450
471
  }
451
472
  }
452
473
  }
474
+
475
+ /**
476
+ * Viewer-owned analytics consent for the public storefront document.
477
+ *
478
+ * The tenant configuration says which purposes may exist; it never speaks for the
479
+ * viewer. This controller therefore removes a baked analytics grant until the viewer
480
+ * has made an explicit, unexpired choice. The same synchronous early boot runs in both
481
+ * document mounts before any integration or measurement loader can execute.
482
+ */
483
+
484
+ export const VIEWER_ANALYTICS_CONSENT_STORAGE_KEY = "tot.viewer-analytics-consent.v1";
485
+ export const VIEWER_ANALYTICS_CONSENT_STATE_GLOBAL = "__TOT_VIEWER_ANALYTICS_CONSENT__";
486
+ export const VIEWER_ANALYTICS_CONSENT_CHANGE_EVENT = "tot:viewer-analytics-consent-changed";
487
+ export const VIEWER_ANALYTICS_CONSENT_RETENTION_DAYS = 180;
488
+ export const VIEWER_ANALYTICS_REGION_POLICY = "universal-opt-in" as const;
489
+
490
+ export type ViewerAnalyticsConsentDecision = "granted" | "denied";
491
+
492
+ export interface ViewerAnalyticsConsentState {
493
+ schemaVersion: 1;
494
+ decision: ViewerAnalyticsConsentDecision;
495
+ source: "default-deny" | "stored" | "global-privacy-control";
496
+ canGrant: boolean;
497
+ expiresAt: number | null;
498
+ retentionDays: typeof VIEWER_ANALYTICS_CONSENT_RETENTION_DAYS;
499
+ regionPolicy: typeof VIEWER_ANALYTICS_REGION_POLICY;
500
+ tenantPublicId: string;
501
+ environment: string;
502
+ siteSurface: string;
503
+ }
504
+
505
+ interface ViewerConsentWindow {
506
+ navigator?: { globalPrivacyControl?: boolean };
507
+ localStorage?: {
508
+ getItem(key: string): string | null;
509
+ setItem(key: string, value: string): void;
510
+ };
511
+ document?: Document;
512
+ addEventListener?: (type: string, listener: (event: { key?: string | null }) => void) => void;
513
+ dispatchEvent?: (event: Event) => boolean;
514
+ CustomEvent?: typeof CustomEvent;
515
+ [key: string]: unknown;
516
+ }
517
+
518
+ /**
519
+ * Apply the effective viewer decision before any analytics loader runs.
520
+ *
521
+ * This function is deliberately closure-free: its runtime source is also serialized
522
+ * into the renderer's early-head script, keeping the tested function and emitted boot
523
+ * byte-for-byte one implementation rather than two drifting copies.
524
+ */
525
+ export function applyEarlyViewerAnalyticsConsent(
526
+ win: ViewerConsentWindow,
527
+ now: number = Date.now(),
528
+ override?: ViewerAnalyticsConsentDecision,
529
+ ): ViewerAnalyticsConsentState {
530
+ const consentGlobal = "__TOT_INTEGRATION_CONSENT__";
531
+ const planGlobal = "__TOT_INTEGRATION_PLAN__";
532
+ const stateGlobal = "__TOT_VIEWER_ANALYTICS_CONSENT__";
533
+ const storageKey = "tot.viewer-analytics-consent.v1";
534
+ const retentionDays = 180;
535
+ const retentionMs = retentionDays * 24 * 60 * 60 * 1000;
536
+ const canonicalPurposes = ["essential", "analytics", "marketing", "personalization"];
537
+
538
+ const current = win[consentGlobal] as {
539
+ schemaVersion?: unknown;
540
+ snapshotId?: unknown;
541
+ eligiblePurposes?: unknown;
542
+ approvedPurposes?: unknown;
543
+ unknownState?: unknown;
544
+ } | undefined;
545
+ const plan = win[planGlobal] as {
546
+ tenantPublicId?: unknown;
547
+ environment?: unknown;
548
+ siteSurface?: unknown;
549
+ } | undefined;
550
+ const candidateEligible = Array.isArray(current?.eligiblePurposes)
551
+ ? current.eligiblePurposes.filter((purpose): purpose is string =>
552
+ typeof purpose === "string" && canonicalPurposes.includes(purpose))
553
+ : [];
554
+ const candidateApproved = Array.isArray(current?.approvedPurposes)
555
+ ? current.approvedPurposes.filter((purpose): purpose is string =>
556
+ typeof purpose === "string" && canonicalPurposes.includes(purpose))
557
+ : [];
558
+ const snapshotIdFor = (eligible: readonly string[], approved: readonly string[]) => {
559
+ let hash = 0x811c9dc5;
560
+ const digestInput = `2:${eligible.join(",")}:${approved.join(",")}`;
561
+ for (let index = 0; index < digestInput.length; index += 1) {
562
+ const code = digestInput.charCodeAt(index);
563
+ hash = Math.imul(hash ^ (code & 0xff), 0x01000193);
564
+ hash = Math.imul(hash ^ ((code >> 8) & 0xff), 0x01000193);
565
+ }
566
+ return `cs2-${(hash >>> 0).toString(16).padStart(8, "0")}`;
567
+ };
568
+ const snapshotValid =
569
+ current?.schemaVersion === 2 &&
570
+ current.unknownState === "block" &&
571
+ Array.isArray(current.eligiblePurposes) &&
572
+ candidateEligible.length === current.eligiblePurposes.length &&
573
+ Array.isArray(current.approvedPurposes) &&
574
+ candidateApproved.length === current.approvedPurposes.length &&
575
+ candidateEligible.join(",") ===
576
+ canonicalPurposes.filter((purpose) => candidateEligible.includes(purpose)).join(",") &&
577
+ candidateApproved.join(",") ===
578
+ canonicalPurposes.filter((purpose) => candidateApproved.includes(purpose)).join(",") &&
579
+ candidateEligible.includes("essential") &&
580
+ candidateApproved.includes("essential") &&
581
+ candidateEligible.every((purpose) => candidateApproved.includes(purpose)) &&
582
+ current.snapshotId === snapshotIdFor(candidateEligible, candidateApproved);
583
+ const rawEligible = snapshotValid ? candidateEligible : ["essential"];
584
+ const rawApproved = snapshotValid ? candidateApproved : ["essential"];
585
+ const tenantPublicId = typeof plan?.tenantPublicId === "string" ? plan.tenantPublicId : "";
586
+ const environment = typeof plan?.environment === "string" ? plan.environment : "";
587
+ const siteSurface = typeof plan?.siteSurface === "string" ? plan.siteSurface : "";
588
+ const contextKey = `${tenantPublicId}:${environment}:${siteSurface}`;
589
+ const scopedStorageKey = `${storageKey}:${encodeURIComponent(contextKey)}`;
590
+ // A manifest's consentNeeds and mutable viewer state are requests, never tenant
591
+ // approval. Only the current authenticated server-rendered snapshot may grant.
592
+ const contextComplete = tenantPublicId.length > 0 && environment.length > 0 && siteSurface.length > 0;
593
+ const tenantApprovedAnalytics = contextComplete && rawApproved.includes("analytics");
594
+ const globalPrivacyControl = win.navigator?.globalPrivacyControl === true;
595
+
596
+ let stored: { decision: ViewerAnalyticsConsentDecision; expiresAt: number } | null = null;
597
+ if (override) {
598
+ stored = { decision: override, expiresAt: now + retentionMs };
599
+ } else {
600
+ try {
601
+ const raw = win.localStorage?.getItem(scopedStorageKey);
602
+ if (raw) {
603
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
604
+ const keys = Object.keys(parsed).sort();
605
+ const decidedAt = parsed["decidedAt"];
606
+ const expiresAt = parsed["expiresAt"];
607
+ const decision = parsed["decision"];
608
+ if (
609
+ keys.join(",") === "decidedAt,decision,environment,expiresAt,schemaVersion,siteSurface,tenantPublicId" &&
610
+ parsed["schemaVersion"] === 1 &&
611
+ parsed["tenantPublicId"] === tenantPublicId &&
612
+ parsed["environment"] === environment &&
613
+ parsed["siteSurface"] === siteSurface &&
614
+ (decision === "granted" || decision === "denied") &&
615
+ typeof decidedAt === "number" &&
616
+ Number.isFinite(decidedAt) &&
617
+ typeof expiresAt === "number" &&
618
+ Number.isFinite(expiresAt) &&
619
+ decidedAt <= now &&
620
+ expiresAt > now &&
621
+ expiresAt - decidedAt === retentionMs
622
+ ) {
623
+ stored = { decision, expiresAt };
624
+ }
625
+ }
626
+ } catch {
627
+ // Storage denial, malformed data, and hostile access all fail closed.
628
+ }
629
+ }
630
+
631
+ const decision: ViewerAnalyticsConsentDecision =
632
+ globalPrivacyControl || !tenantApprovedAnalytics
633
+ ? "denied"
634
+ : stored?.decision ?? "denied";
635
+ const source: ViewerAnalyticsConsentState["source"] = globalPrivacyControl
636
+ ? "global-privacy-control"
637
+ : stored
638
+ ? "stored"
639
+ : "default-deny";
640
+
641
+ const eligible = canonicalPurposes.filter(
642
+ (purpose) =>
643
+ purpose === "essential" ||
644
+ (purpose === "analytics"
645
+ ? decision === "granted" && tenantApprovedAnalytics
646
+ : rawEligible.includes(purpose)),
647
+ );
648
+ const approved = canonicalPurposes.filter((purpose) => rawApproved.includes(purpose));
649
+ win[consentGlobal] = Object.freeze({
650
+ schemaVersion: 2,
651
+ snapshotId: snapshotIdFor(eligible, approved),
652
+ eligiblePurposes: Object.freeze(eligible),
653
+ approvedPurposes: Object.freeze(approved),
654
+ unknownState: "block",
655
+ });
656
+
657
+ const state: ViewerAnalyticsConsentState = Object.freeze({
658
+ schemaVersion: 1,
659
+ decision,
660
+ source,
661
+ canGrant: tenantApprovedAnalytics && !globalPrivacyControl,
662
+ expiresAt: stored?.expiresAt ?? null,
663
+ retentionDays,
664
+ regionPolicy: "universal-opt-in",
665
+ tenantPublicId,
666
+ environment,
667
+ siteSurface,
668
+ });
669
+ win[stateGlobal] = state;
670
+ return state;
671
+ }
672
+
673
+ /** The exact synchronous script rendered after the plan + consent globals in `<head>`. */
674
+ export const VIEWER_ANALYTICS_CONSENT_EARLY_BOOT_JS =
675
+ `(${applyEarlyViewerAnalyticsConsent.toString()})(window);`;
676
+
677
+ function writeDecision(
678
+ win: ViewerConsentWindow,
679
+ decision: ViewerAnalyticsConsentDecision,
680
+ now: number,
681
+ ): void {
682
+ const retentionMs = VIEWER_ANALYTICS_CONSENT_RETENTION_DAYS * 24 * 60 * 60 * 1000;
683
+ try {
684
+ const plan = win["__TOT_INTEGRATION_PLAN__"] as {
685
+ tenantPublicId?: unknown;
686
+ environment?: unknown;
687
+ siteSurface?: unknown;
688
+ } | undefined;
689
+ const tenantPublicId = typeof plan?.tenantPublicId === "string" ? plan.tenantPublicId : "";
690
+ const environment = typeof plan?.environment === "string" ? plan.environment : "";
691
+ const siteSurface = typeof plan?.siteSurface === "string" ? plan.siteSurface : "";
692
+ const contextKey = `${tenantPublicId}:${environment}:${siteSurface}`;
693
+ win.localStorage?.setItem(
694
+ `${VIEWER_ANALYTICS_CONSENT_STORAGE_KEY}:${encodeURIComponent(contextKey)}`,
695
+ JSON.stringify({
696
+ schemaVersion: 1,
697
+ decision,
698
+ decidedAt: now,
699
+ expiresAt: now + retentionMs,
700
+ tenantPublicId,
701
+ environment,
702
+ siteSurface,
703
+ }),
704
+ );
705
+ } catch {
706
+ // The in-memory decision still applies to this document; the next load fails closed.
707
+ }
708
+ }
709
+
710
+ function notifyViewerAnalyticsConsentChanged(
711
+ win: ViewerConsentWindow,
712
+ state: ViewerAnalyticsConsentState,
713
+ ): void {
714
+ const EventCtor = win.CustomEvent ?? (typeof CustomEvent === "function" ? CustomEvent : undefined);
715
+ if (EventCtor && win.dispatchEvent) {
716
+ win.dispatchEvent(new EventCtor(VIEWER_ANALYTICS_CONSENT_CHANGE_EVENT, {
717
+ detail: Object.freeze({ decision: state.decision }),
718
+ }));
719
+ }
720
+
721
+ const lifecycle = win["__TOT_INTEGRATION_LIFECYCLE__"] as
722
+ | { update?: (reason: "consent-changed") => unknown }
723
+ | undefined;
724
+ try {
725
+ const updated = lifecycle?.update?.("consent-changed");
726
+ if (updated && typeof (updated as Promise<unknown>).catch === "function") {
727
+ void (updated as Promise<unknown>).catch(() => {});
728
+ }
729
+ } catch {
730
+ // Consent state is already deny/grant; an integration cannot make the control fail.
731
+ }
732
+ }
733
+
734
+ /** Apply a viewer action, update every broker reader, and notify the live lifecycle. */
735
+ export function setViewerAnalyticsConsent(
736
+ win: ViewerConsentWindow,
737
+ decision: ViewerAnalyticsConsentDecision,
738
+ now: number = Date.now(),
739
+ ): ViewerAnalyticsConsentState {
740
+ if (!(decision === "granted" && win.navigator?.globalPrivacyControl === true)) {
741
+ writeDecision(win, decision, now);
742
+ }
743
+ const state = applyEarlyViewerAnalyticsConsent(win, now, decision);
744
+ notifyViewerAnalyticsConsentChanged(win, state);
745
+ return state;
746
+ }
747
+
748
+ function renderControlState(root: HTMLElement, state: ViewerAnalyticsConsentState): void {
749
+ const status = root.querySelector<HTMLElement>("[data-tot-analytics-consent-status]");
750
+ const grant = root.querySelector<HTMLButtonElement>('[data-tot-analytics-consent-decision="granted"]');
751
+ const deny = root.querySelector<HTMLButtonElement>('[data-tot-analytics-consent-decision="denied"]');
752
+ if (status) {
753
+ status.textContent = state.source === "global-privacy-control"
754
+ ? "Analytics off — Global Privacy Control"
755
+ : state.decision === "granted"
756
+ ? "Analytics on"
757
+ : "Analytics off";
758
+ }
759
+ if (grant) {
760
+ grant.disabled = !state.canGrant || state.decision === "granted";
761
+ grant.setAttribute("aria-pressed", String(state.decision === "granted"));
762
+ }
763
+ if (deny) {
764
+ deny.disabled = state.decision === "denied";
765
+ deny.setAttribute("aria-pressed", String(state.decision === "denied"));
766
+ }
767
+ }
768
+
769
+ /** Bind the renderer-owned control once; safe when a document contains no analytics plan. */
770
+ export function bootViewerAnalyticsConsentControl(
771
+ win: ViewerConsentWindow,
772
+ now: () => number = Date.now,
773
+ ): void {
774
+ const roots = win.document?.querySelectorAll<HTMLElement>("[data-tot-analytics-consent-control]");
775
+ const current = (win[VIEWER_ANALYTICS_CONSENT_STATE_GLOBAL] as ViewerAnalyticsConsentState | undefined) ??
776
+ applyEarlyViewerAnalyticsConsent(win, now());
777
+ roots?.forEach((root) => {
778
+ renderControlState(root, current);
779
+ if (root.dataset["totAnalyticsConsentBound"] === "true") return;
780
+ root.dataset["totAnalyticsConsentBound"] = "true";
781
+ root.addEventListener("click", (event) => {
782
+ const target = event.target;
783
+ if (!(target instanceof Element)) return;
784
+ const button = target.closest<HTMLButtonElement>("[data-tot-analytics-consent-decision]");
785
+ const decision = button?.dataset["totAnalyticsConsentDecision"];
786
+ if (decision !== "granted" && decision !== "denied") return;
787
+ renderControlState(root, setViewerAnalyticsConsent(win, decision, now()));
788
+ });
789
+ });
790
+
791
+ const storageBoundGlobal = "__TOT_VIEWER_ANALYTICS_CONSENT_STORAGE_BOUND__";
792
+ if (win.addEventListener && win[storageBoundGlobal] !== true) {
793
+ win[storageBoundGlobal] = true;
794
+ win.addEventListener("storage", (event) => {
795
+ // `key === null` is the browser signal for localStorage.clear(). It clears
796
+ // this context too, so recompute to default-deny and notify immediately.
797
+ if (event.key !== null && !event.key?.startsWith(`${VIEWER_ANALYTICS_CONSENT_STORAGE_KEY}:`)) return;
798
+ const next = applyEarlyViewerAnalyticsConsent(win, now());
799
+ roots?.forEach((root) => renderControlState(root, next));
800
+ notifyViewerAnalyticsConsentChanged(win, next);
801
+ });
802
+ }
803
+ }
804
+
805
+ /** Browser entry used by the shared lifecycle host asset. */
806
+ export function bootViewerAnalyticsConsentFromDocument(): void {
807
+ if (typeof window === "undefined") return;
808
+ bootViewerAnalyticsConsentControl(window as unknown as ViewerConsentWindow);
809
+ }
@@ -63,12 +63,9 @@ export const INTEGRATION_CSP_PROFILES: Readonly<Record<string, CspSourceSet>> =
63
63
  "connect-src": ["https://cdn.usefathom.com"],
64
64
  },
65
65
 
66
- // Native Google Analytics / Ads measurement. The adapter sends reviewed event
67
- // payloads only to Google's measurement endpoints; it does not load a remote
68
- // browser container. Every origin is exact and platform-owned.
69
- "google-measurement": {
70
- "connect-src": ["https://www.google-analytics.com"],
71
- },
66
+ // Google code and network authority live only in the null-origin sandbox. The
67
+ // parent needs no addition: base frame-src 'self' admits the versioned host.
68
+ "google-measurement": {},
72
69
 
73
70
  // Meta pixel (fbevents.js). The loader is served from connect.facebook.net; the
74
71
  // pixel's collect endpoint is the facebook.com tracking origin. The tenant
@@ -56,7 +56,10 @@ import {
56
56
  import { INTEGRATION_ENVIRONMENTS, INTEGRATION_SITE_SURFACES } from "./environment.js";
57
57
  import type { IntegrationEnvironment, IntegrationSiteSurface } from "./environment.js";
58
58
  import { findForbiddenFields } from "./secrets.js";
59
- import { resemblesManagedAnalyticsPii } from "./managed-analytics-value-safety.js";
59
+ import {
60
+ resemblesManagedAnalyticsPii,
61
+ resemblesManagedAnalyticsProtectedValue,
62
+ } from "./managed-analytics-value-safety.js";
60
63
 
61
64
  /**
62
65
  * The reserved commerce event taxonomy. Every accepted type is here; an unknown
@@ -348,9 +351,6 @@ function validateLineItems(parent: Record<string, unknown>, v: Violations, path:
348
351
 
349
352
  const UTM_FIELDS = ["source", "medium", "campaign", "term", "content"] as const;
350
353
  const SAFE_UTM_VALUE = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/;
351
- const PROTECTED_ATTRIBUTION_VALUE =
352
- /(?:@|\d{7,}|(?:^|[._~-])(?:bearer|password|secret|token|authorization|preview)(?:$|[._~-]))/i;
353
-
354
354
  function validatePageAttribution(parent: Record<string, unknown>, v: Violations): void {
355
355
  const attribution = childObject(parent, "attribution", v, "data");
356
356
  if (!attribution) return;
@@ -403,7 +403,7 @@ function validatePageAttribution(parent: Record<string, unknown>, v: Violations)
403
403
  `UTM ${key} must be a 1-128 character opaque campaign token.`,
404
404
  );
405
405
  }
406
- if (value !== undefined && PROTECTED_ATTRIBUTION_VALUE.test(value)) {
406
+ if (value !== undefined && resemblesManagedAnalyticsProtectedValue(value)) {
407
407
  v.add(
408
408
  `data.attribution.utm.${key}`,
409
409
  "protected_value",
@@ -58,6 +58,7 @@ export * from "./resolve.js";
58
58
  export * from "./platform-adapters.js";
59
59
  export * from "./native-amplitude.js";
60
60
  export * from "./native-ga4.js";
61
+ export * from "./native-ga4-sandbox.js";
61
62
  export * from "./platform-adapter-runtime.js";
62
63
  export * from "./render-phases.js";
63
64
  export * from "./interim-bridge.js";
@@ -13,8 +13,7 @@
13
13
  * ({@link isPurposeEligible}, `consent-broker.ts`). A missing or tampered
14
14
  * `window.__TOT_INTEGRATION_CONSENT__` global fails closed to the
15
15
  * default-deny snapshot, same as every other consumer of that global
16
- * (`lifecycle-boot.ts`, the Cloudflare Web Analytics loader in
17
- * `Layout.astro`).
16
+ * (`lifecycle-boot.ts` and the managed analytics adapters).
18
17
  * - ENVIRONMENT: the renderer-published four-stage environment
19
18
  * (`resolveBrowserEnvironment`, `environment.ts`) must resolve AND must not
20
19
  * be `candidate`. A per-change candidate render is a preview — it is never
@@ -16,11 +16,10 @@
16
16
  * constructs bundled platform code with host-owned capabilities. Every other entry goes
17
17
  * through the authority-tier loader: it loads ONLY an `elevated-parent` entry (whose
18
18
  * honest, review-only contract IS arbitrary parent-document JavaScript) and REFUSES every
19
- * other tier. A `sandbox`-tier entry —
20
- * whose contract is a null-origin iframe reachable only through the typed postMessage
21
- * broker — is refused here and held back until the iframe host that actually isolates
22
- * it ships, so third-party code labeled "sandboxed" is never executed with the parent
23
- * authority its label does not grant. Loading is also consent-gated: the binding reads
19
+ * other tier. A reviewed registry entry may construct its own null-origin iframe and
20
+ * private typed MessagePort; arbitrary `sandbox`-tier entry modules remain refused by
21
+ * the parent-realm loader, so their isolation is never silently downgraded. Loading is
22
+ * also consent-gated: the binding reads
24
23
  * the renderer-inlined default-deny consent snapshot and holds a non-essential entry
25
24
  * back until its declared purposes are eligible. With an environment-only plan there
26
25
  * are no integrations, so the runtime boots inert and ready.
@@ -43,6 +42,11 @@ import type { NavigationCompletedEvent, NavigationKind } from "./events.js";
43
42
  import { CommerceEventBroker } from "./commerce-broker.js";
44
43
  import { validateBrowserEnvironment, BROWSER_ENVIRONMENT_GLOBAL, type BrowserEnvironment } from "./environment.js";
45
44
  import { makePlatformAdapterRuntimeLoader } from "./platform-adapter-runtime.js";
45
+ import { NATIVE_GA4_SANDBOX_URL, type NativeGa4Transport } from "./native-ga4.js";
46
+ import {
47
+ createBrowserNativeGa4SandboxHost,
48
+ createNativeGa4SandboxTransport,
49
+ } from "./native-ga4-sandbox.js";
46
50
  import { ManagedAnalyticsEventBrokerBridge } from "./managed-analytics-bridge.js";
47
51
  import { minimizePageAttribution, type ManagedPageViewInput } from "./managed-analytics.js";
48
52
  import {
@@ -53,10 +57,9 @@ import {
53
57
  /** The window property holding the single per-document lifecycle (idempotent boot guard). */
54
58
  export const LIFECYCLE_SINGLETON_GLOBAL = "__TOT_INTEGRATION_LIFECYCLE__";
55
59
  /**
56
- * An optional window-published loader the entry-asset layer may install to OVERRIDE the
57
- * fail-closed same-origin default. It is the seam the null-origin sandboxed-iframe host
58
- * (the tracked F3 follow-up) installs to actually isolate a `sandbox`-tier entry; until
59
- * that host ships the seam is unused and the default loader refuses the sandbox tier.
60
+ * An optional window-published loader the entry-asset layer may install to override the
61
+ * fail-closed same-origin default. The default loader still refuses sandbox-tier modules;
62
+ * reviewed platform adapters construct any required isolation through their registry.
60
63
  */
61
64
  export const LIFECYCLE_MODULE_LOADER_GLOBAL = "__TOT_INTEGRATION_MODULE_LOADER__";
62
65
  /** An optional window-published sink for the completed-navigation event. */
@@ -114,6 +117,8 @@ export interface BootLifecycleOptions {
114
117
  fetchCapability?: typeof fetch;
115
118
  /** Entry import capability; injectable for tests and non-browser hosts. */
116
119
  importModule?: (url: string) => Promise<unknown>;
120
+ /** Reviewed null-origin GA4 transport; injectable for DOM-free tests. */
121
+ nativeGa4Transport?: NativeGa4Transport;
117
122
  }
118
123
 
119
124
  /**
@@ -146,11 +151,10 @@ const PARENT_REALM_TIER = "elevated-parent" as const;
146
151
  * a hardcoded vendor path. `importModule` is injected so the loader is testable without
147
152
  * a live network; it defaults to a native dynamic `import()`.
148
153
  *
149
- * FAIL-CLOSED BY TIER (design-partner finding F3): a parent-realm import gives the entry
154
+ * FAIL-CLOSED BY TIER: a parent-realm import gives the entry
150
155
  * full parent-document authority, so the loader refuses any tier but `elevated-parent`
151
156
  * BEFORE importing — a `sandbox`-tier entry throws without ever being fetched or
152
- * evaluated, so code labeled "sandboxed" cannot run in the parent while its isolating
153
- * iframe host is still the tracked follow-up. The throw is caught by the runtime's
157
+ * evaluated, so code labeled "sandboxed" cannot run in the parent. The throw is caught by the runtime's
154
158
  * per-integration load isolation: the entry never becomes active, the page is unaffected,
155
159
  * and every other integration still boots.
156
160
  */
@@ -163,8 +167,8 @@ export function makeSameOriginEntryLoader(
163
167
  `Refusing to load "${integration.appId}@${integration.version}" (tier ` +
164
168
  `"${integration.executionTier}") into the parent document: the same-origin loader ` +
165
169
  `only runs the "${PARENT_REALM_TIER}" tier. A "sandbox"-tier entry must run inside a ` +
166
- `null-origin iframe reachable only through the typed postMessage broker; that ` +
167
- `isolating host is not yet wired, so the entry is held fail-closed rather than run ` +
170
+ `null-origin iframe reachable only through a typed MessagePort broker, so the entry ` +
171
+ `is held fail-closed rather than run ` +
168
172
  `with parent-document authority its tier does not grant.`,
169
173
  );
170
174
  }
@@ -314,6 +318,16 @@ export function bootIntegrationLifecycle(
314
318
  fetchCapability: hostFetch,
315
319
  importModule: options.importModule,
316
320
  fallback: ordinaryLoader,
321
+ nativeGa4Transport: () => {
322
+ if (options.nativeGa4Transport) return options.nativeGa4Transport;
323
+ if (typeof document === "undefined" || typeof MessageChannel === "undefined") {
324
+ throw new Error("platform_adapter_ga4_sandbox_unavailable");
325
+ }
326
+ return createNativeGa4SandboxTransport(
327
+ createBrowserNativeGa4SandboxHost(document, MessageChannel),
328
+ NATIVE_GA4_SANDBOX_URL,
329
+ );
330
+ },
317
331
  });
318
332
 
319
333
  const isEligible = options.isEligible ?? defaultConsentGate(win);
@@ -12,5 +12,7 @@
12
12
  * self-invoking module there would boot the lifecycle on any server-side import.
13
13
  */
14
14
  import { bootIntegrationLifecycleFromDocument } from "./lifecycle-boot.js";
15
+ import { bootViewerAnalyticsConsentFromDocument } from "./consent-broker.js";
15
16
 
17
+ bootViewerAnalyticsConsentFromDocument();
16
18
  bootIntegrationLifecycleFromDocument();