@tokenoftrust/storefront-runner 2.0.41 → 2.0.43

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 (148) hide show
  1. package/apps/storefront/integrations/lifecycle-host-asset.mjs +20 -6
  2. package/apps/storefront/package.json +1 -0
  3. package/apps/storefront/platform-reconcile-tenants.json +3 -0
  4. package/apps/storefront/public/favicon.ico +0 -0
  5. package/apps/storefront/public/shared/commerce-chrome.css +147 -16
  6. package/apps/storefront/src/components/AsyncStylesheet.astro +26 -0
  7. package/apps/storefront/src/components/Breadcrumbs.astro +8 -1
  8. package/apps/storefront/src/components/Img.astro +6 -5
  9. package/apps/storefront/src/components/ProductCard.astro +6 -4
  10. package/apps/storefront/src/components/Seo.astro +2 -1
  11. package/apps/storefront/src/components/chrome/SiteFooter.astro +45 -11
  12. package/apps/storefront/src/components/chrome/SiteHeader.astro +83 -13
  13. package/apps/storefront/src/components/commerce/RatingStars.astro +1 -1
  14. package/apps/storefront/src/components/compliance/ComplianceNotice.astro +2 -2
  15. package/apps/storefront/src/components/content/PromoTiles.astro +107 -0
  16. package/apps/storefront/src/components/content/TrustStrip.astro +30 -14
  17. package/apps/storefront/src/components/home/Hero.astro +156 -4
  18. package/apps/storefront/src/components/islands/AgeGate.tsx +135 -26
  19. package/apps/storefront/src/components/islands/ImageGallery.tsx +6 -9
  20. package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +194 -46
  21. package/apps/storefront/src/components/islands/SearchTypeahead.tsx +10 -4
  22. package/apps/storefront/src/components/marketing/Faq.astro +9 -2
  23. package/apps/storefront/src/config/devTenantSeed.ts +1 -1
  24. package/apps/storefront/src/config/kvTenantResolver.ts +7 -1
  25. package/apps/storefront/src/config/publicationSpaceConfig.ts +12 -1
  26. package/apps/storefront/src/config/publicationSpaceRegistry.ts +5 -0
  27. package/apps/storefront/src/config/tenants.ts +22 -0
  28. package/apps/storefront/src/layouts/Layout.astro +69 -44
  29. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +24 -8
  30. package/apps/storefront/src/lib/activity/recordActivity.ts +15 -0
  31. package/apps/storefront/src/lib/asyncCss.ts +23 -0
  32. package/apps/storefront/src/lib/auth/loginGate.ts +22 -11
  33. package/apps/storefront/src/lib/basePath.ts +70 -9
  34. package/apps/storefront/src/lib/chrome/managedCart.ts +50 -0
  35. package/apps/storefront/src/lib/chrome/model.ts +4 -0
  36. package/apps/storefront/src/lib/chrome/newsletterAgeAttestation.ts +9 -0
  37. package/apps/storefront/src/lib/content/trustStrip.ts +38 -3
  38. package/apps/storefront/src/lib/cspStaticHashes.ts +4 -1
  39. package/apps/storefront/src/lib/diag/capture.ts +161 -0
  40. package/apps/storefront/src/lib/diag/consoleLine.ts +76 -0
  41. package/apps/storefront/src/lib/diag/debugConsole.ts +118 -0
  42. package/apps/storefront/src/lib/diag/debugMode.ts +163 -0
  43. package/apps/storefront/src/lib/diag/debugWindow.ts +111 -0
  44. package/apps/storefront/src/lib/diag/reference.ts +148 -0
  45. package/apps/storefront/src/lib/diag/referenceStore.ts +113 -0
  46. package/apps/storefront/src/lib/diag/report.ts +109 -0
  47. package/apps/storefront/src/lib/diag/session.ts +93 -0
  48. package/apps/storefront/src/lib/featuredProductsPresentation.ts +5 -0
  49. package/apps/storefront/src/lib/heroSlideshow.ts +41 -0
  50. package/apps/storefront/src/lib/homeVisualParity.ts +107 -0
  51. package/apps/storefront/src/lib/imageAttrs.ts +9 -6
  52. package/apps/storefront/src/lib/integration/nonceStamp.ts +23 -1
  53. package/apps/storefront/src/lib/integration/renderIntegrationState.ts +36 -0
  54. package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +17 -12
  55. package/apps/storefront/src/lib/jsonScript.ts +13 -0
  56. package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +31 -0
  57. package/apps/storefront/src/lib/previewReload.ts +53 -9
  58. package/apps/storefront/src/lib/rawChrome.ts +148 -19
  59. package/apps/storefront/src/lib/rawMarketingHtml.ts +120 -7
  60. package/apps/storefront/src/lib/storyblok/content-model.ts +46 -3
  61. package/apps/storefront/src/lib/styleGuideThemes.ts +107 -0
  62. package/apps/storefront/src/lib/tenantRouting.ts +15 -4
  63. package/apps/storefront/src/lib/tot/syncState.ts +21 -0
  64. package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +29 -3
  65. package/apps/storefront/src/middleware/controlPlane.ts +9 -5
  66. package/apps/storefront/src/middleware/index.ts +13 -1
  67. package/apps/storefront/src/middleware/renderChain.ts +54 -11
  68. package/apps/storefront/src/middleware/routeBranch.ts +58 -12
  69. package/apps/storefront/src/middleware/staticBundle.ts +37 -3
  70. package/apps/storefront/src/pages/[...slug].astro +6 -2
  71. package/apps/storefront/src/pages/api/request-access.ts +5 -4
  72. package/apps/storefront/src/pages/blog/[slug].astro +11 -1
  73. package/apps/storefront/src/pages/cart.ts +23 -0
  74. package/apps/storefront/src/pages/index.astro +64 -85
  75. package/apps/storefront/src/pages/products/[handle].astro +8 -6
  76. package/apps/storefront/src/pages/sitemap.xml.ts +8 -2
  77. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +14 -25
  78. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +10 -17
  79. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +9 -16
  80. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +9 -15
  81. package/apps/storefront/src/pages/the-build/[slug].astro +7 -1
  82. package/apps/storefront/src/styles/fonts.css +7 -0
  83. package/apps/storefront/src/styles/global.css +397 -3
  84. package/package.json +4 -1
  85. package/packages/cli/src/visual-parity.mjs +321 -0
  86. package/packages/public-runtime/package.json +7 -0
  87. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
  88. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
  89. package/packages/public-runtime/src/chrome.ts +44 -7
  90. package/packages/public-runtime/src/csp.ts +12 -1
  91. package/packages/public-runtime/src/extension-contract-values.d.mts +19 -0
  92. package/packages/public-runtime/src/extension-contract-values.mjs +62 -0
  93. package/packages/public-runtime/src/extension-contract.ts +20 -6
  94. package/packages/public-runtime/src/image-dependencies.ts +1438 -0
  95. package/packages/public-runtime/src/index.ts +8 -0
  96. package/packages/public-runtime/src/integration/consent-broker.ts +10 -7
  97. package/packages/public-runtime/src/integration/consent-config.ts +9 -0
  98. package/packages/public-runtime/src/integration/contact-sales-action-source.ts +56 -10
  99. package/packages/public-runtime/src/integration/governed-event-properties.ts +62 -0
  100. package/packages/public-runtime/src/integration/index.ts +1 -0
  101. package/packages/public-runtime/src/integration/lifecycle-boot.ts +30 -2
  102. package/packages/public-runtime/src/integration/lifecycle-host-asset.d.mts +2 -0
  103. package/packages/public-runtime/src/integration/lifecycle-host-asset.mjs +17 -3
  104. package/packages/public-runtime/src/integration/native-amplitude.ts +12 -3
  105. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +9 -0
  106. package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +27 -3
  107. package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +13 -0
  108. package/packages/public-runtime/src/integration/native-ga4.ts +11 -0
  109. package/packages/public-runtime/src/integration/platform-adapters.ts +5 -1
  110. package/packages/public-runtime/src/integration/render-phases.ts +28 -2
  111. package/packages/public-runtime/src/publication-redirects.d.mts +45 -0
  112. package/packages/public-runtime/src/publication-redirects.mjs +379 -0
  113. package/packages/public-runtime/src/publication-space.ts +24 -3
  114. package/packages/public-runtime/src/static-bundle.ts +145 -26
  115. package/packages/public-runtime/src/tenant-embed-defaults.d.mts +3 -0
  116. package/packages/public-runtime/src/tenant-embed-defaults.mjs +40 -0
  117. package/packages/public-runtime/src/tenant.ts +59 -1
  118. package/pnpm-lock.runner.yaml +19 -0
  119. package/pnpm-workspace.yaml +4 -0
  120. package/scripts/dev/git-status.mjs +1 -1
  121. package/scripts/dev/publish.mjs +6 -0
  122. package/scripts/lib/tenant-asset-copy.mjs +4 -2
  123. package/scripts/tenant/materialize-tenant-content.mjs +1 -0
  124. package/scripts/tenant/validate.mjs +171 -8
  125. package/apps/storefront/src/lib/cfImage.ts +0 -55
  126. package/apps/storefront/src/workers/pruneRollupWorker.ts +0 -120
  127. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  128. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  129. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  130. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  131. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  132. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  133. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  134. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  135. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  136. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  137. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  138. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  139. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  140. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  141. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  142. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  143. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  144. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  145. package/tenants/home/public/js/storefront.js +0 -129
  146. package/tenants/home/public/logo-wordmark.png +0 -0
  147. package/tenants/home/public/pages/storefront.css +0 -235
  148. package/tenants/home/theme.json +0 -6
@@ -17,6 +17,14 @@ export * from "./product.js";
17
17
  export * from "./tenant.js";
18
18
  export * from "./publication-space.js";
19
19
  export * from "./blog-visibility.mjs";
20
+ // Per-tenant build-time embed defaults (tenant-embed-defaults.mjs) are deliberately
21
+ // NOT re-exported here: this barrel is inside the v84 clean-clone ATTESTED source
22
+ // graph, so an `export *` widens that graph's membership and takes a human-review
23
+ // gate (`repin-v84-clean-clone.mjs` refuses a membership change, correctly). The
24
+ // module is reached by its own subpath export instead —
25
+ // `@tot/public-runtime/tenant-embed-defaults` — which keeps the attestation surface
26
+ // unchanged. Adding a convenience re-export here would cost an attestation review
27
+ // for nothing.
20
28
  export * from "./publication-routing.js";
21
29
  export * from "./publication-version.js";
22
30
  export * from "./publication-artifact.js";
@@ -475,10 +475,10 @@ export class ConsentBroker {
475
475
  /**
476
476
  * Viewer-owned analytics consent for the public storefront document.
477
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.
478
+ * The tenant configuration supplies the analytics default and a stored viewer choice
479
+ * overrides it. Global Privacy Control always denies analytics. The same synchronous
480
+ * early boot runs in both document mounts before any integration or measurement loader
481
+ * can execute.
482
482
  */
483
483
 
484
484
  export const VIEWER_ANALYTICS_CONSENT_STORAGE_KEY = "tot.viewer-analytics-consent.v1";
@@ -492,7 +492,7 @@ export type ViewerAnalyticsConsentDecision = "granted" | "denied";
492
492
  export interface ViewerAnalyticsConsentState {
493
493
  schemaVersion: 1;
494
494
  decision: ViewerAnalyticsConsentDecision;
495
- source: "default-deny" | "stored" | "global-privacy-control";
495
+ source: "tenant-default" | "default-deny" | "stored" | "global-privacy-control";
496
496
  canGrant: boolean;
497
497
  expiresAt: number | null;
498
498
  retentionDays: typeof VIEWER_ANALYTICS_CONSENT_RETENTION_DAYS;
@@ -591,6 +591,7 @@ export function applyEarlyViewerAnalyticsConsent(
591
591
  // approval. Only the current authenticated server-rendered snapshot may grant.
592
592
  const contextComplete = tenantPublicId.length > 0 && environment.length > 0 && siteSurface.length > 0;
593
593
  const tenantApprovedAnalytics = contextComplete && rawApproved.includes("analytics");
594
+ const tenantDefaultAnalytics = tenantApprovedAnalytics && rawEligible.includes("analytics");
594
595
  const globalPrivacyControl = win.navigator?.globalPrivacyControl === true;
595
596
 
596
597
  let stored: { decision: ViewerAnalyticsConsentDecision; expiresAt: number } | null = null;
@@ -631,12 +632,14 @@ export function applyEarlyViewerAnalyticsConsent(
631
632
  const decision: ViewerAnalyticsConsentDecision =
632
633
  globalPrivacyControl || !tenantApprovedAnalytics
633
634
  ? "denied"
634
- : stored?.decision ?? "denied";
635
+ : stored?.decision ?? (tenantDefaultAnalytics ? "granted" : "denied");
635
636
  const source: ViewerAnalyticsConsentState["source"] = globalPrivacyControl
636
637
  ? "global-privacy-control"
637
638
  : stored
638
639
  ? "stored"
639
- : "default-deny";
640
+ : tenantDefaultAnalytics
641
+ ? "tenant-default"
642
+ : "default-deny";
640
643
 
641
644
  const eligible = canonicalPurposes.filter(
642
645
  (purpose) =>
@@ -258,3 +258,12 @@ export function unmetConsentDependencies(
258
258
  export function emptyConsentConfig(): TenantConsentConfig {
259
259
  return { schemaVersion: CONSENT_CONFIG_SCHEMA_VERSION, purposes: [], cmp: { mode: "none" } };
260
260
  }
261
+
262
+ /** Platform default when a tenant configured analytics and saved no consent override. */
263
+ export function defaultAnalyticsConsentConfig(): TenantConsentConfig {
264
+ return {
265
+ schemaVersion: CONSENT_CONFIG_SCHEMA_VERSION,
266
+ purposes: [{ purpose: "analytics", legalBasis: "legitimate-interest", defaultState: "granted" }],
267
+ cmp: { mode: "none" },
268
+ };
269
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The platform-owned browser binding for the two reviewed contact-sales CTAs.
2
+ * The platform-owned browser binding for reviewed contact-sales CTA identities.
3
3
  *
4
4
  * This is deliberately not a general interaction adapter. Both DOM identifiers and
5
5
  * the emitted semantic name are fixed below; the source reads no labels, URLs,
@@ -10,19 +10,41 @@ import { managedAnalyticsOpaqueRevision } from "./managed-analytics-value-safety
10
10
 
11
11
  /** The only reviewed CTA identities that may raise this marketing action. */
12
12
  export const CONTACT_SALES_ACTION_SOURCES = [
13
- { ctaId: "cta-book-demo", logicalId: "contact-sales-book-demo" },
14
- { ctaId: "contact-sales", logicalId: "contact-sales-lower" },
13
+ { selector: '[data-cta-id="cta-book-demo"]', logicalId: "contact-sales-book-demo" },
14
+ { selector: '[data-cta-id="book-demo"]', logicalId: "contact-sales-book-demo" },
15
+ { selector: '[data-cta-id="contact-sales"]', logicalId: "contact-sales-lower" },
16
+ { selector: '[data-tot-action="contact-sales"]', logicalId: "contact-sales-action" },
17
+ { selector: '[data-tot-action="nav-contact-sales"]', logicalId: "contact-sales-nav" },
15
18
  ] as const;
16
19
 
17
- const CONTACT_SALES_ACTION_NAME = "user_clicked_contact_sales_mktg" as const;
20
+ export const CONTACT_SALES_ACTION_NAME = "user_clicked_contact_sales_mktg" as const;
21
+ /** The reviewed action allowlist paired with this fixed platform source. */
22
+ export const CONTACT_SALES_ACTION_REGISTRY = {
23
+ schemaVersion: 1,
24
+ actions: [{ name: CONTACT_SALES_ACTION_NAME }],
25
+ } as const;
18
26
  const CORRELATION_TTL_MS = 60 * 60 * 1000;
27
+ export const CONTACT_SALES_NAVIGATION_DEFER_MS = 100;
28
+
29
+ export interface ContactSalesClickEvent {
30
+ altKey?: boolean;
31
+ button?: number;
32
+ ctrlKey?: boolean;
33
+ defaultPrevented?: boolean;
34
+ metaKey?: boolean;
35
+ preventDefault?(): void;
36
+ shiftKey?: boolean;
37
+ }
19
38
 
20
39
  export interface ContactSalesActionElement {
21
- addEventListener(type: "click", listener: () => void): void;
22
- removeEventListener(type: "click", listener: () => void): void;
40
+ download?: string;
41
+ href?: string;
42
+ target?: string;
43
+ addEventListener(type: "click", listener: (event: ContactSalesClickEvent) => void): void;
44
+ removeEventListener(type: "click", listener: (event: ContactSalesClickEvent) => void): void;
23
45
  }
24
46
 
25
- /** The narrow DOM seam used only to find the two fixed platform CTA identities. */
47
+ /** The narrow DOM seam used only to find the fixed platform CTA identities. */
26
48
  export interface ContactSalesActionDocument {
27
49
  querySelectorAll(selector: string): ArrayLike<ContactSalesActionElement>;
28
50
  }
@@ -31,10 +53,23 @@ export interface ContactSalesActionSourceOptions {
31
53
  document?: ContactSalesActionDocument;
32
54
  planRevision: string;
33
55
  now?: () => number;
56
+ navigate?: (href: string) => void;
57
+ schedule?: (callback: () => void, delayMs: number) => void;
58
+ }
59
+
60
+ function deferredNavigationHref(node: ContactSalesActionElement, event: ContactSalesClickEvent): string | undefined {
61
+ if (
62
+ event.defaultPrevented || (event.button !== undefined && event.button !== 0) ||
63
+ event.altKey || event.ctrlKey || event.metaKey || event.shiftKey ||
64
+ typeof event.preventDefault !== "function" || typeof node.href !== "string" || !node.href ||
65
+ (typeof node.target === "string" && node.target !== "" && node.target !== "_self") ||
66
+ (typeof node.download === "string" && node.download !== "")
67
+ ) return undefined;
68
+ return node.href;
34
69
  }
35
70
 
36
71
  /**
37
- * Bind literal `data-cta-id` values to a typed source for the managed-analytics
72
+ * Bind the fixed reviewed DOM identities to a typed source for the managed-analytics
38
73
  * bridge. A keyboard activation of an anchor/button produces its native `click`, so
39
74
  * it follows this same semantic path without a separate key listener.
40
75
  */
@@ -43,6 +78,7 @@ export function createContactSalesActionSource(
43
78
  ): PlatformManagedActionSource {
44
79
  const document = options.document;
45
80
  const now = options.now ?? Date.now;
81
+ const schedule = options.schedule ?? ((callback, delayMs) => { setTimeout(callback, delayMs); });
46
82
  const revisionId = managedAnalyticsOpaqueRevision(options.planRevision);
47
83
  let sequence = 0;
48
84
 
@@ -50,10 +86,15 @@ export function createContactSalesActionSource(
50
86
  subscribe(listener) {
51
87
  if (!document || typeof document.querySelectorAll !== "function") return () => {};
52
88
  const removals: Array<() => void> = [];
89
+ const bound = new Set<ContactSalesActionElement>();
53
90
  for (const source of CONTACT_SALES_ACTION_SOURCES) {
54
- const nodes = document.querySelectorAll(`[data-cta-id="${source.ctaId}"]`);
91
+ const nodes = document.querySelectorAll(source.selector);
55
92
  for (const node of Array.from(nodes)) {
56
- const emit = (): void => {
93
+ if (bound.has(node)) continue;
94
+ bound.add(node);
95
+ const emit = (event: ContactSalesClickEvent = {}): void => {
96
+ const navigationHref = options.navigate ? deferredNavigationHref(node, event) : undefined;
97
+ if (navigationHref) event.preventDefault?.();
57
98
  sequence += 1;
58
99
  const at = now();
59
100
  const activation: PlatformManagedActionActivation = {
@@ -66,6 +107,11 @@ export function createContactSalesActionSource(
66
107
  sequence,
67
108
  };
68
109
  listener(activation);
110
+ // A full-document navigation destroys the isolated GA4 iframe. Give its
111
+ // MessagePort one bounded turn to receive the reviewed action first.
112
+ if (navigationHref) {
113
+ schedule(() => options.navigate?.(navigationHref), CONTACT_SALES_NAVIGATION_DEFER_MS);
114
+ }
69
115
  };
70
116
  node.addEventListener("click", emit);
71
117
  removals.push(() => node.removeEventListener("click", emit));
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The governed event-property set every reviewed analytics adapter attaches to
3
+ * every event it sends.
4
+ *
5
+ * These three values answer "whose traffic, on which surface, from which stage"
6
+ * — the question a merchant has to answer before any storefront number is worth
7
+ * reading. They are DERIVED from the renderer's own trusted context
8
+ * (`BrowserEnvironment` + the server-stamped tenant identity), never declared by
9
+ * a tenant and never read from the browser. That is the whole point: a tenant
10
+ * that could type its own `tenant` or `environment` could mislabel its traffic,
11
+ * so the adapter configuration contract bans those keys outright
12
+ * (`managed-analytics-api-v1.schemas.ts` FORBIDDEN_KEY_PATTERN, `secrets.ts`
13
+ * findForbiddenFields) and this module supplies them instead.
14
+ *
15
+ * They carry no person, device, account, or network signal, so they add nothing
16
+ * to the anonymous-collection posture the adapters already hold.
17
+ */
18
+ import {
19
+ validateBrowserEnvironment,
20
+ type BrowserEnvironment,
21
+ type IntegrationEnvironment,
22
+ type IntegrationSiteSurface,
23
+ } from "./environment.js";
24
+
25
+ /** The exact, closed key set. Adding a key here is a reviewed platform change. */
26
+ export const GOVERNED_EVENT_PROPERTY_KEYS = Object.freeze([
27
+ "tenant",
28
+ "environment",
29
+ "site_surface",
30
+ ] as const);
31
+
32
+ export interface GovernedEventProperties {
33
+ /** The tenant's public id, e.g. `tokenoftrust.com`. Server-stamped. */
34
+ tenant: string;
35
+ /** The four-stage publication stage: `local` | `candidate` | `staged` | `live`. */
36
+ environment: IntegrationEnvironment;
37
+ /** The document surface: `storefront` | `checkout` | `admin` | `marketing`. */
38
+ site_surface: IntegrationSiteSurface;
39
+ }
40
+
41
+ /**
42
+ * Build the governed property set, or null when the context cannot be trusted.
43
+ *
44
+ * Fail-closed is deliberate and matches every other adapter gate: an adapter
45
+ * that cannot prove which tenant and stage an event belongs to must not label
46
+ * it, and a caller that gets null drops the event rather than sending it
47
+ * unlabelled. Mislabelled traffic is worse than absent traffic — it silently
48
+ * corrupts every report built on it.
49
+ */
50
+ export function buildGovernedEventProperties(
51
+ environment: BrowserEnvironment,
52
+ tenantPublicId: string,
53
+ ): GovernedEventProperties | null {
54
+ const validated = validateBrowserEnvironment(environment);
55
+ if (!validated.ok) return null;
56
+ if (typeof tenantPublicId !== "string" || tenantPublicId.length === 0) return null;
57
+ return Object.freeze({
58
+ tenant: tenantPublicId,
59
+ environment: validated.value.environment,
60
+ site_surface: validated.value.siteSurface,
61
+ });
62
+ }
@@ -59,6 +59,7 @@ export * from "./platform-adapters.js";
59
59
  export * from "./native-amplitude.js";
60
60
  export * from "./native-ga4.js";
61
61
  export * from "./native-ga4-sandbox.js";
62
+ export * from "./native-ga4-sandbox-asset.mjs";
62
63
  export * from "./platform-adapter-runtime.js";
63
64
  export * from "./render-phases.js";
64
65
  export * from "./interim-bridge.js";
@@ -51,6 +51,7 @@ import { ManagedAnalyticsEventBrokerBridge } from "./managed-analytics-bridge.js
51
51
  import { minimizePageAttribution, type ManagedPageViewInput } from "./managed-analytics.js";
52
52
  import { managedAnalyticsOpaqueRevision } from "./managed-analytics-value-safety.js";
53
53
  import {
54
+ CONTACT_SALES_ACTION_REGISTRY,
54
55
  createContactSalesActionSource,
55
56
  type ContactSalesActionDocument,
56
57
  } from "./contact-sales-action-source.js";
@@ -68,7 +69,7 @@ export const LIFECYCLE_NAVIGATION_SINK_GLOBAL = "__TOT_INTEGRATION_NAVIGATION__"
68
69
 
69
70
  /** The narrow slice of `window` the binding depends on — structural, for edge-safe typing. */
70
71
  export interface LifecycleWindow {
71
- location: { pathname: string; search: string; hash: string; href: string };
72
+ location: { pathname: string; search: string; hash: string; href: string; assign?: (href: string) => void };
72
73
  addEventListener(type: string, listener: (event: unknown) => void): void;
73
74
  fetch?: typeof fetch;
74
75
  /**
@@ -207,6 +208,7 @@ function makeDocumentCommerceBroker(
207
208
  },
208
209
  source: `//${descriptor.tenantPublicId}`,
209
210
  consentSnapshot: getConsentSnapshot,
211
+ tenantActionRegistry: CONTACT_SALES_ACTION_REGISTRY,
210
212
  });
211
213
  }
212
214
 
@@ -242,7 +244,7 @@ function documentPageView(
242
244
  const id = `pageview-${managedAnalyticsOpaqueRevision(descriptor.planRevision)}`;
243
245
  return {
244
246
  logicalId: id,
245
- path: win.location.pathname,
247
+ path: canonicalDocumentPath(win.location.pathname, descriptor.tenantPublicId),
246
248
  correlation: {
247
249
  id,
248
250
  expiresAt: new Date(now() + PAGE_VIEW_CORRELATION_TTL_MS).toISOString(),
@@ -251,6 +253,28 @@ function documentPageView(
251
253
  };
252
254
  }
253
255
 
256
+ /**
257
+ * Project immutable preview wrapper routes back to the tenant path they render.
258
+ * The wrapper contains a commit SHA, which is both non-canonical and deliberately
259
+ * rejected by the analytics value-safety boundary. Public/live routes pass through.
260
+ */
261
+ export function canonicalDocumentPath(pathname: string, tenantPublicId: string): string {
262
+ const segments = pathname.split("/");
263
+ const previewIndex = segments[1] === tenantPublicId && segments[2] === "preview"
264
+ ? 2
265
+ : segments[1] === "preview"
266
+ ? 1
267
+ : -1;
268
+ if (
269
+ previewIndex !== -1 &&
270
+ (segments[previewIndex + 1] === "staged" || segments[previewIndex + 1] === "rev") &&
271
+ /^[0-9a-f]{40,64}$/i.test(segments[previewIndex + 2] ?? "")
272
+ ) {
273
+ return `/${segments.slice(previewIndex + 3).join("/")}`;
274
+ }
275
+ return pathname;
276
+ }
277
+
254
278
  function readDescriptor(win: LifecycleWindow): IntegrationBootDescriptor | null {
255
279
  const descriptor = win[INTEGRATION_PLAN_GLOBAL];
256
280
  if (!descriptor || typeof descriptor !== "object") return null;
@@ -364,6 +388,10 @@ export function bootIntegrationLifecycle(
364
388
  : undefined,
365
389
  planRevision: descriptor.planRevision,
366
390
  now: options.now ?? Date.now,
391
+ navigate: (href) => {
392
+ if (typeof win.location.assign === "function") win.location.assign(href);
393
+ else win.location.href = href;
394
+ },
367
395
  }),
368
396
  }),
369
397
  pageView: () => documentPageView(win, descriptor, options.now ?? Date.now),
@@ -1,3 +1,5 @@
1
1
  /** Types for the dual-consumer (`.ts` engine + `.mjs` build plugin) host-asset path. */
2
2
  export declare const LIFECYCLE_HOST_ASSET_PATH: string;
3
3
  export declare const LIFECYCLE_HOST_ASSET_URL: string;
4
+ export declare const LIFECYCLE_HOST_COMPATIBILITY_ASSET_PATHS: readonly string[];
5
+ export declare const LIFECYCLE_HOST_COMPATIBILITY_ASSET_URLS: readonly string[];
@@ -3,15 +3,29 @@
3
3
  *
4
4
  * It lives in a `.mjs` module because it has TWO consumers that cannot share a `.ts`
5
5
  * one: the render engine (`render-phases.ts`) that emits the `<script src>`, and the
6
- * build plugin (`apps/storefront/build-plugins/lifecycle-host-asset.mjs`) that produces
6
+ * build integration (`apps/storefront/integrations/lifecycle-host-asset.mjs`) that produces
7
7
  * the file at that exact path. One source of truth, so the tag and the artifact cannot
8
8
  * disagree — a mismatch would leave every static tenant page with plan data and no host,
9
9
  * which fails silently in exactly the way it is not allowed to.
10
10
  *
11
- * The path is version-pinned (`/v1/`) like a platform adapter entry, so a future host
11
+ * The path is version-pinned (`/v3/`) like a platform adapter entry, so a future host
12
12
  * with a different boot contract ships alongside rather than replacing this one.
13
13
  */
14
- export const LIFECYCLE_HOST_ASSET_PATH = "platform/lifecycle/v1/boot.mjs";
14
+ export const LIFECYCLE_HOST_ASSET_PATH = "platform/lifecycle/v3/boot.mjs";
15
+
16
+ /**
17
+ * Earlier immutable URLs that must keep serving the current host bytes during a
18
+ * version transition. Cached HTML may still reference one of these paths after a
19
+ * pointer flip, so dropping it would turn a cache refresh into a lifecycle 404.
20
+ */
21
+ export const LIFECYCLE_HOST_COMPATIBILITY_ASSET_PATHS = Object.freeze([
22
+ "platform/lifecycle/v2/boot.mjs",
23
+ ]);
15
24
 
16
25
  /** The root-absolute, same-origin URL the served markup references. */
17
26
  export const LIFECYCLE_HOST_ASSET_URL = `/${LIFECYCLE_HOST_ASSET_PATH}`;
27
+
28
+ /** Root-absolute compatibility URLs emitted alongside the current URL. */
29
+ export const LIFECYCLE_HOST_COMPATIBILITY_ASSET_URLS = Object.freeze(
30
+ LIFECYCLE_HOST_COMPATIBILITY_ASSET_PATHS.map((path) => `/${path}`),
31
+ );
@@ -20,6 +20,10 @@ import {
20
20
  type IntegrationEnvironment,
21
21
  } from "./environment.js";
22
22
  import { resemblesManagedAnalyticsPii } from "./managed-analytics-value-safety.js";
23
+ import {
24
+ buildGovernedEventProperties,
25
+ type GovernedEventProperties,
26
+ } from "./governed-event-properties.js";
23
27
  import type {
24
28
  IntegrationEventSubscribe,
25
29
  IntegrationLifecycleContext,
@@ -47,7 +51,7 @@ export interface AmplitudeHttpEvent {
47
51
  event_type: "page_view" | (typeof APPROVED_MARKETING_ACTION_NAMES)[number];
48
52
  device_id: typeof ANONYMOUS_DEVICE_ID;
49
53
  insert_id: string;
50
- event_properties: { page_path: string } | Record<string, never>;
54
+ event_properties: GovernedEventProperties & { page_path?: string };
51
55
  }
52
56
 
53
57
  export const AMPLITUDE_DEDUPE_CAPACITY = 512 as const;
@@ -127,6 +131,11 @@ export function mapCommerceEventToAmplitude(
127
131
  ) return null;
128
132
  if (!event.consent.eligiblePurposes.includes("analytics")) return null;
129
133
 
134
+ // Derived from the same trusted context the equality check above just proved
135
+ // this event agrees with, so labelling can never disagree with admission.
136
+ const governed = buildGovernedEventProperties(environmentResult.value, tenantPublicId);
137
+ if (!governed) return null;
138
+
130
139
  const base = {
131
140
  device_id: ANONYMOUS_DEVICE_ID,
132
141
  insert_id: eventDedupeKey(event),
@@ -137,12 +146,12 @@ export function mapCommerceEventToAmplitude(
137
146
  return {
138
147
  ...base,
139
148
  event_type: name as (typeof APPROVED_MARKETING_ACTION_NAMES)[number],
140
- event_properties: {},
149
+ event_properties: { ...governed },
141
150
  };
142
151
  }
143
152
  const path = event.data["path"];
144
153
  if (!isSafePagePath(path)) return null;
145
- return { ...base, event_type: "page_view", event_properties: { page_path: path } };
154
+ return { ...base, event_type: "page_view", event_properties: { ...governed, page_path: path } };
146
155
  }
147
156
 
148
157
  export interface RegisterAmplitudeHttpDestinationInput {
@@ -20,6 +20,15 @@ export function resolveNativeGa4SandboxUrl(parentHref) {
20
20
  if (parent.hostname === "tokenoftrust.store" || parent.hostname.endsWith(".tokenoftrust.store")) {
21
21
  return new URL(NATIVE_GA4_SANDBOX_ASSET_URL, NATIVE_GA4_SANDBOX_ORIGIN).href;
22
22
  }
23
+ // A static publication verification alias is deliberately not the tenant's
24
+ // canonical apex/www pair. Its frame must still share the tenant's site: gtag.js
25
+ // loads but suppresses collection from a third-party frame. The corresponding
26
+ // analytics-frame hostname is provisioned as a Cloudflare-for-SaaS custom host.
27
+ if (parent.hostname.startsWith("published.")) {
28
+ const sandbox = new URL(NATIVE_GA4_SANDBOX_ASSET_URL, parent);
29
+ sandbox.hostname = `analytics-frame.${parent.hostname.slice("published.".length)}`;
30
+ return sandbox.href;
31
+ }
23
32
  const sandbox = new URL(NATIVE_GA4_SANDBOX_ASSET_URL, parent);
24
33
  sandbox.hostname = parent.hostname.startsWith("www.")
25
34
  ? parent.hostname.slice("www.".length)
@@ -9,6 +9,7 @@ import { resemblesManagedAnalyticsProtectedValue } from "./managed-analytics-val
9
9
 
10
10
  const MEASUREMENT_ID = /^G-[A-Z0-9]{4,20}$/;
11
11
  const CAMPAIGN_VALUE = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/;
12
+ const GOVERNED_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
12
13
  const APPROVED_ACTIONS: ReadonlySet<string> = new Set(APPROVED_MARKETING_ACTION_NAMES);
13
14
 
14
15
  function plainRecord(value: unknown): Record<string, unknown> | null {
@@ -44,6 +45,20 @@ function validIds(value: unknown): value is string[] {
44
45
  value.every((id) => typeof id === "string" && MEASUREMENT_ID.test(id)) && new Set(value).size === value.length;
45
46
  }
46
47
 
48
+ /**
49
+ * Governed labelling arrives across a hostile boundary, so it is re-validated
50
+ * here rather than trusted: the exact three keys, each a bare token. `tenant`
51
+ * is a public host id and the other two are closed enums the parent derives, so
52
+ * nothing here can smuggle a value gtag would treat as anything but a label.
53
+ */
54
+ function validGovernedProperties(value: unknown): value is Record<string, string> {
55
+ const record = plainRecord(value);
56
+ if (!record || !exactKeys(record, ["tenant", "environment", "site_surface"])) return false;
57
+ return Object.values(record).every((item) =>
58
+ typeof item === "string" && GOVERNED_VALUE.test(item) &&
59
+ !resemblesManagedAnalyticsProtectedValue(item));
60
+ }
61
+
47
62
  function validCampaign(value: unknown): value is Record<string, string> {
48
63
  const record = plainRecord(value);
49
64
  if (!record || !exactKeys(record, ["source", "medium", "campaign", "term", "content"], [])) return false;
@@ -86,6 +101,7 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
86
101
  let disposed = false;
87
102
  let canonicalOrigin = "";
88
103
  let currentDestinations: string[] = [];
104
+ let governedProperties: Record<string, string> = {};
89
105
  let lastPageLocation = "";
90
106
  let tagLoaded = false;
91
107
 
@@ -98,13 +114,15 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
98
114
 
99
115
  const configure = (message: Record<string, unknown>): void => {
100
116
  if (
101
- !exactKeys(message, ["schemaVersion", "type", "destinationIds", "canonicalOrigin", "analyticsConsent"]) ||
117
+ !exactKeys(message, ["schemaVersion", "type", "destinationIds", "canonicalOrigin", "analyticsConsent", "governedProperties"]) ||
102
118
  message["schemaVersion"] !== 1 || message["type"] !== "configure" ||
103
119
  message["analyticsConsent"] !== "granted" ||
104
- !validIds(message["destinationIds"]) || !validOrigin(message["canonicalOrigin"])
120
+ !validIds(message["destinationIds"]) || !validOrigin(message["canonicalOrigin"]) ||
121
+ !validGovernedProperties(message["governedProperties"])
105
122
  ) return;
106
123
  const ids = message["destinationIds"];
107
124
  canonicalOrigin = message["canonicalOrigin"];
125
+ governedProperties = { ...message["governedProperties"] };
108
126
  currentDestinations = [...ids];
109
127
  lastPageLocation = "";
110
128
  active = true;
@@ -152,6 +170,7 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
152
170
  const pageLocation = `${canonicalOrigin}${message["path"]}`;
153
171
  lastPageLocation = pageLocation;
154
172
  const params: Record<string, unknown> = {
173
+ ...governedProperties,
155
174
  page_location: pageLocation,
156
175
  page_path: message["path"],
157
176
  };
@@ -175,7 +194,12 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
175
194
  message["pageLocation"] !== lastPageLocation || !lastPageLocation
176
195
  ) return;
177
196
  for (const id of currentDestinations) {
178
- gtag("event", message["name"], { send_to: id, page_location: lastPageLocation });
197
+ gtag("event", message["name"], {
198
+ ...governedProperties,
199
+ send_to: id,
200
+ page_location: lastPageLocation,
201
+ transport_type: "beacon",
202
+ });
179
203
  }
180
204
  }
181
205
  };
@@ -1,4 +1,7 @@
1
1
  /** Closed parent/iframe protocol for the reviewed GA4 isolated-origin sandbox. */
2
+ import type { GovernedEventProperties } from "./governed-event-properties.js";
3
+ import { resolveNativeGa4SandboxUrl } from "./native-ga4-sandbox-asset.mjs";
4
+
2
5
  export const NATIVE_GA4_SANDBOX_PROTOCOL = "tot.ga4-sandbox/1" as const;
3
6
  export const NATIVE_GA4_SANDBOX_MAX_DESTINATIONS = 4 as const;
4
7
 
@@ -8,6 +11,12 @@ export interface NativeGa4SandboxConfig {
8
11
  canonicalOrigin: string;
9
12
  /** The parent may configure GA4 only after the platform consent gate grants analytics. */
10
13
  analyticsConsent: "granted";
14
+ /**
15
+ * Governed labelling attached to every event this sandbox sends. Configured
16
+ * once rather than per command: all three are constant for the document's
17
+ * life, so a per-event copy would only widen the hostile boundary.
18
+ */
19
+ governedProperties: GovernedEventProperties;
11
20
  }
12
21
 
13
22
  export interface NativeGa4PageViewCommand {
@@ -97,6 +106,10 @@ export function createNativeGa4SandboxTransport(
97
106
  if (!host.parentOrigin || sandboxOrigin === host.parentOrigin) {
98
107
  throw new Error("ga4_sandbox_origin_not_isolated");
99
108
  }
109
+ const expectedSandboxOrigin = new URL(resolveNativeGa4SandboxUrl(host.parentOrigin)).origin;
110
+ if (sandboxOrigin !== expectedSandboxOrigin) {
111
+ throw new Error("ga4_sandbox_origin_not_same_site");
112
+ }
100
113
  const frame = host.createFrame();
101
114
  frame.setAttribute("sandbox", "allow-scripts allow-same-origin");
102
115
  frame.setAttribute("referrerpolicy", "no-referrer");
@@ -17,6 +17,10 @@ import {
17
17
  } from "./platform-adapters.js";
18
18
  import { INTEGRATION_ENVIRONMENTS, type IntegrationEnvironment } from "./environment.js";
19
19
  import { resemblesManagedAnalyticsProtectedValue } from "./managed-analytics-value-safety.js";
20
+ import {
21
+ buildGovernedEventProperties,
22
+ type GovernedEventProperties,
23
+ } from "./governed-event-properties.js";
20
24
  import type {
21
25
  NativeGa4SandboxCommand,
22
26
  NativeGa4SandboxConfig,
@@ -72,6 +76,7 @@ interface ActiveGa4Config {
72
76
  environment: IntegrationEnvironment;
73
77
  siteSurface: IntegrationLifecycleContext["environment"]["siteSurface"];
74
78
  canonicalOrigin: string;
79
+ governedProperties: GovernedEventProperties;
75
80
  }
76
81
 
77
82
  function observeSafely(observer: NativeGa4Observer | undefined, code: NativeGa4DiagnosticCode): void {
@@ -101,11 +106,16 @@ function configuredFor(ctx: IntegrationLifecycleContext): ActiveGa4Config | null
101
106
  destinationIds.length === 0 || typeof canonicalHost !== "string" || canonicalHost.length === 0 ||
102
107
  canonicalHost.includes("/") || canonicalHost.includes("?") || canonicalHost.includes("#")
103
108
  ) return null;
109
+ // Unlabellable traffic is not collected: without a provable tenant and stage
110
+ // every number downstream is unattributable, so this joins the other gates.
111
+ const governedProperties = buildGovernedEventProperties(ctx.environment, ctx.tenantPublicId);
112
+ if (!governedProperties) return null;
104
113
  return {
105
114
  destinationIds,
106
115
  environment: ctx.environment.environment,
107
116
  siteSurface: ctx.environment.siteSurface,
108
117
  canonicalOrigin: `https://${canonicalHost}`,
118
+ governedProperties,
109
119
  };
110
120
  }
111
121
 
@@ -228,6 +238,7 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
228
238
  destinationIds: [...next.destinationIds],
229
239
  canonicalOrigin: next.canonicalOrigin,
230
240
  analyticsConsent: "granted" as const,
241
+ governedProperties: { ...next.governedProperties },
231
242
  };
232
243
  runTransportEffect(transport?.configure ? () => transport.configure!(configuration) : undefined);
233
244
  } catch {
@@ -490,7 +490,11 @@ export function buildPlatformAdapterDeclaration(
490
490
  if (def.adapterId === GOOGLE_MEASUREMENT_ADAPTER.adapterId) {
491
491
  const canonicalHost = envResult.value.canonicalHost.toLowerCase();
492
492
  const apexHost = canonicalHost.startsWith("www.") ? canonicalHost.slice(4) : canonicalHost;
493
- cspSources["frame-src"] = [`https://${apexHost}`, `https://www.${apexHost}`];
493
+ cspSources["frame-src"] = [
494
+ `https://${apexHost}`,
495
+ `https://analytics-frame.${apexHost}`,
496
+ `https://www.${apexHost}`,
497
+ ];
494
498
  }
495
499
 
496
500
  // Public config: one flat, primitive, PUBLIC id per configured stage. These are