@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
@@ -59,6 +59,7 @@ import tailwindcss from "@tailwindcss/vite";
59
59
  import { tenantHotReload } from "./dev-plugins/tenant-hot-reload.mjs";
60
60
  import { materializeGuard } from "./integrations/materialize-guard.mjs";
61
61
  import { lifecycleHostAsset } from "./integrations/lifecycle-host-asset.mjs";
62
+ import { googleMeasurementSandboxAsset } from "./integrations/google-measurement-sandbox-asset.mjs";
62
63
 
63
64
  // SSR on Cloudflare Workers. `output: 'server'` because every response is
64
65
  // tenant-resolved at the edge from the Host header (see src/middleware).
@@ -99,6 +100,7 @@ export default defineConfig({
99
100
  // stable same-origin URL so BOTH document mounts load the same boot — the raw
100
101
  // marketing mount has no bundler to hoist a module import through.
101
102
  lifecycleHostAsset(),
103
+ googleMeasurementSandboxAsset(),
102
104
  preact({ compat: true }),
103
105
  // Astro's file-based router ignores ANY path segment starting with "_" (see
104
106
  // `astro/dist/core/routing/create-manifest.js`), so a page literally named
@@ -61,7 +61,6 @@ interface CloudflareEnv {
61
61
  TOT_API_KEY?: string;
62
62
  TOT_SECRET_KEY?: string;
63
63
  TOT_ACCESS_TOKEN?: string;
64
- PUBLIC_CF_ANALYTICS_TOKEN?: string;
65
64
  /** HMAC secret for signing/verifying customization preview tokens. */
66
65
  CUSTOMIZATION_PREVIEW_SECRET?: string;
67
66
  /**
@@ -260,7 +259,6 @@ interface ImportMetaEnv {
260
259
  readonly TOT_API_KEY?: string;
261
260
  readonly TOT_SECRET_KEY?: string;
262
261
  readonly TOT_ACCESS_TOKEN?: string;
263
- readonly PUBLIC_CF_ANALYTICS_TOKEN?: string;
264
262
  readonly CUSTOMIZATION_PREVIEW_SECRET?: string;
265
263
  }
266
264
  interface ImportMeta {
@@ -0,0 +1,97 @@
1
+ /** Emit the reviewed GA4 runtime as one import-free, CSP-hashed static document. */
2
+ import { createHash } from "node:crypto";
3
+ import { mkdir, writeFile } from "node:fs/promises";
4
+ import { createRequire } from "node:module";
5
+ import { dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import {
8
+ NATIVE_GA4_SANDBOX_ASSET_PATH,
9
+ } from "@tot/public-runtime/integration/native-ga4-sandbox-asset";
10
+
11
+ const SANDBOX_ENTRY = fileURLToPath(new URL(
12
+ "../../../packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts",
13
+ import.meta.url,
14
+ ));
15
+
16
+ export async function bundleGoogleMeasurementSandboxScript() {
17
+ const require = createRequire(import.meta.url);
18
+ const esbuild = createRequire(require.resolve("astro/package.json"))("esbuild");
19
+ const result = await esbuild.build({
20
+ entryPoints: [SANDBOX_ENTRY],
21
+ bundle: true,
22
+ write: false,
23
+ metafile: true,
24
+ format: "iife",
25
+ platform: "browser",
26
+ target: "es2022",
27
+ minify: true,
28
+ treeShaking: true,
29
+ legalComments: "none",
30
+ sourcemap: false,
31
+ logLevel: "silent",
32
+ });
33
+ if (result.outputFiles.length !== 1) {
34
+ throw new Error(`GA4 sandbox emitted ${result.outputFiles.length} files; expected one.`);
35
+ }
36
+ for (const output of Object.values(result.metafile.outputs)) {
37
+ if ((output.imports ?? []).length > 0) throw new Error("GA4 sandbox must be import-free.");
38
+ }
39
+ const code = result.outputFiles[0].text.trim();
40
+ if (/<\/script/i.test(code)) throw new Error("GA4 sandbox bundle contains a closing script token.");
41
+ return code;
42
+ }
43
+
44
+ export function googleMeasurementSandboxCsp(code) {
45
+ const hash = createHash("sha256").update(code).digest("base64");
46
+ const script = `'sha256-${hash}' https://www.googletagmanager.com`;
47
+ return [
48
+ "default-src 'none'",
49
+ "base-uri 'none'",
50
+ "object-src 'none'",
51
+ "form-action 'none'",
52
+ "frame-src 'none'",
53
+ "worker-src 'none'",
54
+ `script-src ${script}`,
55
+ `script-src-elem ${script}`,
56
+ "img-src https://*.google-analytics.com https://www.googletagmanager.com",
57
+ "connect-src https://*.google-analytics.com https://*.analytics.google.com https://www.googletagmanager.com",
58
+ ].join("; ");
59
+ }
60
+
61
+ export async function buildGoogleMeasurementSandboxHtml() {
62
+ const code = await bundleGoogleMeasurementSandboxScript();
63
+ const csp = googleMeasurementSandboxCsp(code);
64
+ return `<!doctype html>\n<meta charset="utf-8">\n<meta http-equiv="Content-Security-Policy" content="${csp}">\n<title>Analytics transport</title>\n<script>${code}</script>\n`;
65
+ }
66
+
67
+ export function googleMeasurementSandboxAsset() {
68
+ let clientDir = null;
69
+ return {
70
+ name: "tot-google-measurement-sandbox-asset",
71
+ hooks: {
72
+ "astro:config:done": ({ config }) => { clientDir = config.build.client; },
73
+ "astro:server:setup": ({ server }) => {
74
+ server.middlewares.use((req, res, next) => {
75
+ const path = (req.url ?? "").split("?")[0];
76
+ if (path !== `/${NATIVE_GA4_SANDBOX_ASSET_PATH}`) return next();
77
+ buildGoogleMeasurementSandboxHtml().then(
78
+ (html) => {
79
+ res.setHeader("content-type", "text/html; charset=utf-8");
80
+ res.setHeader("cache-control", "no-store");
81
+ res.end(html);
82
+ },
83
+ (error) => next(error),
84
+ );
85
+ });
86
+ },
87
+ "astro:build:done": async ({ logger }) => {
88
+ if (!clientDir) throw new Error("the GA4 sandbox has no resolved client output dir");
89
+ const html = await buildGoogleMeasurementSandboxHtml();
90
+ const target = fileURLToPath(new URL(NATIVE_GA4_SANDBOX_ASSET_PATH, clientDir));
91
+ await mkdir(dirname(target), { recursive: true });
92
+ await writeFile(target, html, "utf8");
93
+ logger.info(`GA4 sandbox → /${NATIVE_GA4_SANDBOX_ASSET_PATH} (${html.length} bytes)`);
94
+ },
95
+ },
96
+ };
97
+ }
@@ -0,0 +1,13 @@
1
+ /** Reviewed parent-side marker for the null-origin GA4 platform adapter. */
2
+ export const platformAdapterEntry = Object.freeze({
3
+ schemaVersion: 1,
4
+ origin: "platform-adapter",
5
+ executionTier: "platform-adapter",
6
+ appId: "platform.google-measurement",
7
+ version: "1.0.0",
8
+ entry: Object.freeze({
9
+ url: "/platform/google-measurement/v2/entry.mjs",
10
+ format: "module",
11
+ }),
12
+ runtime: "native-ga4-v2",
13
+ });
@@ -222,10 +222,6 @@ const { gridColumns: footerColumns, legalLinks: bottomLinks } = splitFooterLegal
222
222
  const legalBase = chrome.footer.bottom?.copyright ?? `© ${new Date().getFullYear()} ${tenant.displayName}`;
223
223
  const legalLine = `${legalBase}${chrome.footer.bottom?.note ? ` · ${chrome.footer.bottom.note}` : ""}`;
224
224
 
225
- const analyticsToken = import.meta.env.PUBLIC_CF_ANALYTICS_TOKEN;
226
- const analyticsEnabled =
227
- analyticsToken && !/REPLACE|PLACEHOLDER/i.test(analyticsToken);
228
-
229
225
  // Private-app widgets — global.footer slot. Resolved once here so it
230
226
  // renders for BOTH footer paths below (the tenant-owned chrome.html splice
231
227
  // and the generic <Footer>) instead of being owned by either. No dark-mode
@@ -293,46 +289,6 @@ const footerWidgets = await renderWidgetSlot(
293
289
  alternates={alternates}
294
290
  />
295
291
 
296
- {
297
- /* Cloudflare Web Analytics is a non-essential measurement destination —
298
- gate it on the SAME neutral consent broker every vendor loader reads
299
- (ext-kernel-04 `consent.defaults` phase, published above as
300
- `window.__TOT_INTEGRATION_CONSENT__`). The token rides a <meta> tag
301
- (any script attribute, incl. define:vars, forces is:inline and drops
302
- module processing — this script needs a real import) rather than a
303
- page global; the script fail-closes like every other consumer of that
304
- global: an absent/tampered snapshot denies, so an unconfigured CMP
305
- means this never loads rather than loading ungated. */
306
- analyticsEnabled && <meta name="cf-beacon-token" content={analyticsToken} />
307
- }
308
- {
309
- analyticsEnabled && (
310
- <script>
311
- import {
312
- parseConsentSnapshot,
313
- isPurposeEligible,
314
- defaultConsentSnapshot,
315
- INTEGRATION_CONSENT_GLOBAL,
316
- } from "@tot/public-runtime";
317
-
318
- const snapshot =
319
- parseConsentSnapshot((window as unknown as Record<string, unknown>)[INTEGRATION_CONSENT_GLOBAL]) ??
320
- defaultConsentSnapshot();
321
-
322
- const token = document
323
- .querySelector('meta[name="cf-beacon-token"]')
324
- ?.getAttribute("content");
325
-
326
- if (token && isPurposeEligible(snapshot, "analytics")) {
327
- const beacon = document.createElement("script");
328
- beacon.defer = true;
329
- beacon.src = "https://static.cloudflareinsights.com/beacon.min.js";
330
- beacon.setAttribute("data-cf-beacon", JSON.stringify({ token }));
331
- document.head.appendChild(beacon);
332
- }
333
- </script>
334
- )
335
- }
336
292
  </head>
337
293
  <body>
338
294
  {/* body.start fallback boundary (ext-kernel-04) — the renderer-owned mount
@@ -582,11 +538,10 @@ const footerWidgets = await renderWidgetSlot(
582
538
  {/* Field Web Vitals (RUM) — measures LCP/INP/CLS for real visitors and
583
539
  beacons per-route samples to the RUM sink. ~1KB, no framework; no-ops
584
540
  where PerformanceObserver is absent. This is the per-route-template
585
- signal the `core-web-vitals` monitor consumes (the CF Web Analytics
586
- beacon above only gives aggregate field CWV). */}
541
+ signal the `core-web-vitals` monitor consumes. */}
587
542
  <script>
588
- import { startWebVitals } from "@/lib/analytics/webVitals";
589
- startWebVitals();
543
+ import { startConsentGatedWebVitals } from "@/lib/analytics/webVitals";
544
+ startConsentGatedWebVitals();
590
545
  </script>
591
546
 
592
547
  {/* Protected-surface tamper sentinel (ext-policy-09, D-R7) — a DETECTIVE
@@ -15,7 +15,6 @@
15
15
  "totalKib": 1300
16
16
  },
17
17
  "thirdPartyScriptAllowlist": [
18
- "static.cloudflareinsights.com",
19
18
  "cdn.foxycart.com"
20
19
  ],
21
20
  "routes": [
@@ -1,10 +1,7 @@
1
1
  /**
2
2
  * Privacy-friendly analytics (brief §5 Analytics).
3
3
  *
4
- * Phase 1 ships TWO layers:
5
- * 1. Pageviews — Cloudflare Web Analytics, a single beacon script injected in
6
- * Layout.astro when PUBLIC_CF_ANALYTICS_TOKEN is set (no cookies, no PII).
7
- * 2. Commerce events — product impression/click/PDP-view instrumented NOW to a
4
+ * Phase 1 ships commerce events — product impression/click/PDP-view instrumented NOW to a
8
5
  * NO-OP sink, so Phase 2 conversion work inherits the call sites for free.
9
6
  *
10
7
  * The event API is intentionally tiny and synchronous-looking; swap the sink
@@ -24,12 +24,16 @@
24
24
  * + bucketing in node; `startWebVitals` is the thin browser wiring, guarded so
25
25
  * importing this module in a non-browser context (SSR, tests) is a no-op.
26
26
  *
27
- * Field CWV is emitted TWICE, deliberately: Cloudflare Web Analytics
28
- * (PUBLIC_CF_ANALYTICS_TOKEN, Layout.astro) already gives aggregate field CWV;
29
- * this beacon adds the PER-ROUTE-TEMPLATE breakdown the budget gate + the
30
- * regression alert (`rumAlert.ts`) reason over, which CF's aggregate cannot.
27
+ * Field CWV is emitted through the consent-gated per-route-template beacon that the
28
+ * budget gate and regression alert (`rumAlert.ts`) reason over.
31
29
  */
32
30
  import { classifyPath, PERF_BUDGETS, type PerfBudgetManifest, type RouteClass } from "./perfBudgets.js";
31
+ import {
32
+ INTEGRATION_CONSENT_GLOBAL,
33
+ VIEWER_ANALYTICS_CONSENT_CHANGE_EVENT,
34
+ isPurposeEligible,
35
+ parseConsentSnapshot,
36
+ } from "@tot/public-runtime";
33
37
 
34
38
  /** The three field metrics we collect. INP is field-only (no lab equivalent). */
35
39
  export type WebVitalName = "lcp" | "inp" | "cls";
@@ -267,3 +271,37 @@ export function startWebVitals(
267
271
  inpObs.disconnect();
268
272
  };
269
273
  }
274
+
275
+ /**
276
+ * Keep field telemetry behind the same authenticated viewer-consent snapshot as
277
+ * every managed analytics adapter. Withdrawal tears down observers before they
278
+ * can flush; a later grant starts a fresh collector for the current document.
279
+ */
280
+ export function startConsentGatedWebVitals(
281
+ start: () => () => void = () => startWebVitals(),
282
+ ): () => void {
283
+ if (typeof window === "undefined") return () => {};
284
+ const win = window as unknown as Record<string, unknown>;
285
+ let stopCollector: (() => void) | null = null;
286
+ let disposed = false;
287
+ const sync = () => {
288
+ if (disposed) return;
289
+ const snapshot = parseConsentSnapshot(win[INTEGRATION_CONSENT_GLOBAL]);
290
+ const eligible = snapshot !== null && isPurposeEligible(snapshot, "analytics");
291
+ if (eligible && !stopCollector) {
292
+ stopCollector = start();
293
+ } else if (!eligible && stopCollector) {
294
+ const stop = stopCollector;
295
+ stopCollector = null;
296
+ stop();
297
+ }
298
+ };
299
+ window.addEventListener(VIEWER_ANALYTICS_CONSENT_CHANGE_EVENT, sync);
300
+ sync();
301
+ return () => {
302
+ disposed = true;
303
+ window.removeEventListener(VIEWER_ANALYTICS_CONSENT_CHANGE_EVENT, sync);
304
+ stopCollector?.();
305
+ stopCollector = null;
306
+ };
307
+ }
@@ -69,7 +69,7 @@ const DEMO_ANALYTICS_MANIFEST: IntegrationManifest = {
69
69
  digest: `sha256:${DEMO_ANALYTICS_APP_ID}-${DEMO_ANALYTICS_VERSION}`,
70
70
  publisher: "Token of Trust",
71
71
  },
72
- compatibility: { storefrontBrowserContract: ">=2.0.0 <3.0.0", configurationSchemaVersion: 1 },
72
+ compatibility: { storefrontBrowserContract: ">=3.0.0 <4.0.0", configurationSchemaVersion: 1 },
73
73
  browser: {
74
74
  executionTier: "sandbox",
75
75
  entry: { asset: DEMO_ANALYTICS_ENTRY_URL, format: "module", phase: "document.deferred" },
@@ -258,8 +258,7 @@ export const MONITORS: readonly Monitor[] = [
258
258
  runbook: "bad-content-policy",
259
259
  wiring: "needs-external",
260
260
  notes:
261
- "Reference F14's RUM signal by name; do NOT edit F14's analytics files. Cloudflare Web Analytics " +
262
- "(PUBLIC_CF_ANALYTICS_TOKEN, injected in Layout.astro) gives field CWV today; F14's beacon gives per-template detail.",
261
+ "Reference F14's consent-gated RUM signal by name; do not duplicate its analytics implementation.",
263
262
  },
264
263
  ] as const;
265
264
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.0.30",
3
+ "version": "2.0.32",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -10,7 +10,8 @@
10
10
  "exports": {
11
11
  ".": "./src/index.ts",
12
12
  "./blog-visibility": "./src/blog-visibility.mjs",
13
- "./integration/lifecycle-host-asset": "./src/integration/lifecycle-host-asset.mjs"
13
+ "./integration/lifecycle-host-asset": "./src/integration/lifecycle-host-asset.mjs",
14
+ "./integration/native-ga4-sandbox-asset": "./src/integration/native-ga4-sandbox-asset.mjs"
14
15
  },
15
16
  "scripts": {
16
17
  "typecheck": "tsc --noEmit",
@@ -78,7 +78,7 @@ export const CSP_REPORT_GROUP = "csp-endpoint";
78
78
  * origin) per request.
79
79
  */
80
80
  export const STOREFRONT_CSP_ALLOWLIST = {
81
- scriptSrc: ["https://static.cloudflareinsights.com"],
81
+ scriptSrc: [],
82
82
  // Web fonts are self-hosted (@fontsource, bundled same-origin) — no
83
83
  // fonts.googleapis.com stylesheet or fonts.gstatic.com woff2 to allowlist;
84
84
  // 'self' + `data:` (both added by buildCsp) cover style-src + font-src,
@@ -87,7 +87,7 @@ export const STOREFRONT_CSP_ALLOWLIST = {
87
87
  fontSrc: [],
88
88
  // Product imagery is rehosted to R2 or served from remote CDNs.
89
89
  imgSrc: ["https:"],
90
- connectSrc: ["https://cloudflareinsights.com"],
90
+ connectSrc: [],
91
91
  } as const;
92
92
 
93
93
  /**