@tokenoftrust/storefront-runner 2.0.31 → 2.0.33

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 (24) hide show
  1. package/apps/storefront/astro.config.mjs +8 -0
  2. package/apps/storefront/env.d.ts +0 -2
  3. package/apps/storefront/src/layouts/Layout.astro +3 -48
  4. package/apps/storefront/src/lib/analytics/budgets.json +0 -1
  5. package/apps/storefront/src/lib/analytics/index.ts +1 -4
  6. package/apps/storefront/src/lib/analytics/webVitals.ts +42 -4
  7. package/apps/storefront/src/lib/integration/tenantDeclarations.ts +1 -1
  8. package/apps/storefront/src/lib/monitoring/manifest.ts +1 -2
  9. package/package.json +1 -1
  10. package/packages/public-runtime/src/csp.ts +2 -2
  11. package/packages/public-runtime/src/integration/consent-broker.ts +370 -13
  12. package/packages/public-runtime/src/integration/contact-sales-action-source.ts +4 -2
  13. package/packages/public-runtime/src/integration/interaction-tracking-gate.ts +1 -2
  14. package/packages/public-runtime/src/integration/lifecycle-boot.ts +2 -1
  15. package/packages/public-runtime/src/integration/lifecycle-host-entry.ts +2 -0
  16. package/packages/public-runtime/src/integration/lifecycle-runtime.ts +256 -22
  17. package/packages/public-runtime/src/integration/managed-analytics-value-safety.ts +18 -0
  18. package/packages/public-runtime/src/integration/render-phases.ts +28 -2
  19. package/packages/public-runtime/src/integration/version.ts +2 -2
  20. package/packages/public-runtime/tests/fixtures/integration/commerce-event.example.json +1 -1
  21. package/packages/public-runtime/tests/fixtures/integration/commerce-order-event.example.json +1 -1
  22. package/packages/public-runtime/tests/fixtures/integration/integration-manifest-v2.example.json +1 -1
  23. package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/manifest.json +1 -1
  24. package/scripts/tenant/lint-embeds.mjs +0 -8
@@ -133,6 +133,14 @@ export default defineConfig({
133
133
  "@astrojs/cloudflare/entrypoints/preview.js",
134
134
  ],
135
135
  },
136
+ // The Cloudflare dev worker keeps its SSR module graph across Vite optimizer
137
+ // turns. Prebundle Preact's renderer before that graph is loaded so it never
138
+ // retains a URL for a superseded .vite/deps_ssr chunk.
139
+ ssr: {
140
+ optimizeDeps: {
141
+ include: ["@astrojs/preact/server.js"],
142
+ },
143
+ },
136
144
  // Dev-only: let the dev server accept tenant Host headers so host-based
137
145
  // tenant routing can be tested locally (e.g. curl -H 'Host: sample-store.example').
138
146
  // Prod runs on Cloudflare Workers, which has no such allowlist.
@@ -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 {
@@ -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.31",
3
+ "version": "2.0.33",
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",
@@ -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
  /**
@@ -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
+ }
@@ -6,6 +6,7 @@
6
6
  * selectors supplied by a caller, event detail, or arbitrary DOM data.
7
7
  */
8
8
  import type { PlatformManagedActionActivation, PlatformManagedActionSource } from "./managed-analytics-bridge.js";
9
+ import { managedAnalyticsOpaqueRevision } from "./managed-analytics-value-safety.js";
9
10
 
10
11
  /** The only reviewed CTA identities that may raise this marketing action. */
11
12
  export const CONTACT_SALES_ACTION_SOURCES = [
@@ -42,6 +43,7 @@ export function createContactSalesActionSource(
42
43
  ): PlatformManagedActionSource {
43
44
  const document = options.document;
44
45
  const now = options.now ?? Date.now;
46
+ const revisionId = managedAnalyticsOpaqueRevision(options.planRevision);
45
47
  let sequence = 0;
46
48
 
47
49
  return {
@@ -55,10 +57,10 @@ export function createContactSalesActionSource(
55
57
  sequence += 1;
56
58
  const at = now();
57
59
  const activation: PlatformManagedActionActivation = {
58
- logicalId: `${source.logicalId}-${options.planRevision}-${sequence}`,
60
+ logicalId: `${source.logicalId}-${revisionId}-${sequence}`,
59
61
  name: CONTACT_SALES_ACTION_NAME,
60
62
  correlation: {
61
- id: `contact-sales-${options.planRevision}`,
63
+ id: `contact-sales-${revisionId}`,
62
64
  expiresAt: new Date(at + CORRELATION_TTL_MS).toISOString(),
63
65
  },
64
66
  sequence,
@@ -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
@@ -49,6 +49,7 @@ import {
49
49
  } from "./native-ga4-sandbox.js";
50
50
  import { ManagedAnalyticsEventBrokerBridge } from "./managed-analytics-bridge.js";
51
51
  import { minimizePageAttribution, type ManagedPageViewInput } from "./managed-analytics.js";
52
+ import { managedAnalyticsOpaqueRevision } from "./managed-analytics-value-safety.js";
52
53
  import {
53
54
  createContactSalesActionSource,
54
55
  type ContactSalesActionDocument,
@@ -238,7 +239,7 @@ function documentPageView(
238
239
  referrerUrl: win.document?.referrer ?? null,
239
240
  routeUrl: win.location.href,
240
241
  });
241
- const id = `pageview-${descriptor.planRevision}`;
242
+ const id = `pageview-${managedAnalyticsOpaqueRevision(descriptor.planRevision)}`;
242
243
  return {
243
244
  logicalId: id,
244
245
  path: win.location.pathname,
@@ -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();
@@ -160,6 +160,13 @@ type LifecycleState = "idle" | "initializing" | "initialized" | "disposing" | "d
160
160
  interface ActiveIntegration {
161
161
  integration: BootDescriptorIntegration;
162
162
  module: IntegrationLifecycleModule;
163
+ eligible: boolean;
164
+ }
165
+
166
+ /** A module whose init hook has started but has not yet settled. */
167
+ interface StartingIntegration extends ActiveIntegration {
168
+ cancelled: boolean;
169
+ disposed: boolean;
163
170
  }
164
171
 
165
172
  function messageOf(error: unknown): string {
@@ -195,8 +202,16 @@ export function sanitizeNavigationUrl(raw: string, keepHash: boolean): string {
195
202
  export class IntegrationLifecycle {
196
203
  private state: LifecycleState = "idle";
197
204
  private readonly active: ActiveIntegration[] = [];
205
+ /** Modules visible to an interrupting consent withdrawal while init is unresolved. */
206
+ private readonly starting: StartingIntegration[] = [];
198
207
  /** Integrations held back by the consent gate; re-evaluated on each `update`. */
199
208
  private deferred: BootDescriptorIntegration[] = [];
209
+ /** Updates arriving during async initialization, replayed before bootstrap emission. */
210
+ private readonly pendingUpdates: LifecycleUpdateReason[] = [];
211
+ /** An atomic withdrawal/grant seen during init needs only deferred-load reconciliation. */
212
+ private pendingGrantReconciliation = false;
213
+ /** Serializes ordinary post-init updates; consent withdrawal interrupts an unresolved grant. */
214
+ private updateQueue: Promise<void> = Promise.resolve();
200
215
  private eventCounter = 0;
201
216
  private readonly opts: IntegrationLifecycleOptions;
202
217
 
@@ -220,16 +235,26 @@ export class IntegrationLifecycle {
220
235
  if (this.state !== "idle") return;
221
236
  this.state = "initializing";
222
237
  for (const integration of this.opts.descriptor.integrations) {
238
+ if (this.state !== "initializing") break;
223
239
  if (!this.eligible(integration)) {
224
240
  // Consent precedes the loader: hold it back, do not load, and record why.
225
241
  this.deferred.push(integration);
226
242
  this.observe({ stage: "gate", integration, ok: false });
227
243
  continue;
228
244
  }
229
- await this.initOne(integration);
245
+ const remainDeferred = await this.initOne(integration);
246
+ if (this.state !== "initializing") break;
247
+ if (remainDeferred) this.deferred.push(integration);
230
248
  }
231
249
  // A dispose that raced in during an await leaves us terminal; otherwise we are live.
232
250
  if (this.state === "initializing") this.state = "initialized";
251
+ if (this.state === "initialized" && this.pendingGrantReconciliation) {
252
+ this.pendingGrantReconciliation = false;
253
+ await this.loadNewlyEligible();
254
+ }
255
+ if (this.state === "initialized") {
256
+ for (const reason of this.pendingUpdates.splice(0)) await this.update(reason);
257
+ }
233
258
  // Last, so every adapter that just subscribed is registered when this emits: the
234
259
  // broker has no replay, so a page view emitted before their `init` reaches nobody.
235
260
  if (this.state === "initialized") this.emitBootstrapPageView();
@@ -250,6 +275,7 @@ export class IntegrationLifecycle {
250
275
  * throwing integration.
251
276
  */
252
277
  private emitBootstrapPageView(): void {
278
+ if (this.state !== "initialized") return;
253
279
  const managed = this.opts.managedAnalytics;
254
280
  if (!managed || managed.bridge.isInitialized || managed.bridge.isDisposed) return;
255
281
  if (this.opts.commerceBroker?.isConsentEligible("analytics") === false) return;
@@ -265,6 +291,11 @@ export class IntegrationLifecycle {
265
291
  return this.opts.isEligible ? this.opts.isEligible(integration) : true;
266
292
  }
267
293
 
294
+ /** Read terminal state across async hook boundaries without relying on stale narrowing. */
295
+ private isTerminal(): boolean {
296
+ return this.state === "disposing" || this.state === "disposed";
297
+ }
298
+
268
299
  /**
269
300
  * Load any consent-deferred integration that has since become eligible, in the
270
301
  * plan's resolved order. Called from `update` so a consent grant loads the
@@ -273,31 +304,179 @@ export class IntegrationLifecycle {
273
304
  private async loadNewlyEligible(): Promise<void> {
274
305
  if (this.deferred.length === 0) return;
275
306
  const stillDeferred: BootDescriptorIntegration[] = [];
307
+ const candidates: BootDescriptorIntegration[] = [];
276
308
  for (const integration of this.deferred) {
277
- if (this.eligible(integration)) await this.initOne(integration);
278
- else stillDeferred.push(integration);
309
+ (this.eligible(integration) ? candidates : stillDeferred).push(integration);
279
310
  }
311
+ // Reserve eligible entries before the first await so an interrupting consent
312
+ // transition cannot start a duplicate load of the same adapter.
280
313
  this.deferred = stillDeferred;
314
+ for (const integration of candidates) {
315
+ if (this.isTerminal()) break;
316
+ let remainDeferred = await this.initOne(integration);
317
+ // A rapid withdrawal can cancel this reserved init, then a regrant can arrive
318
+ // before it settles. Retry with a fresh module as soon as the cancelled attempt
319
+ // returns; otherwise that regrant has no later update left to wake it.
320
+ while (
321
+ remainDeferred
322
+ && this.state !== "disposing"
323
+ && this.state !== "disposed"
324
+ && this.eligible(integration)
325
+ ) {
326
+ remainDeferred = await this.initOne(integration);
327
+ }
328
+ if (remainDeferred && this.state !== "disposing" && this.state !== "disposed") {
329
+ this.deferred.push(integration);
330
+ }
331
+ }
281
332
  }
282
333
 
283
- private async initOne(integration: BootDescriptorIntegration): Promise<void> {
334
+ /** Returns true when consent changed during loading and the entry remains deferred. */
335
+ private async initOne(integration: BootDescriptorIntegration): Promise<boolean> {
336
+ if (this.isTerminal()) return false;
284
337
  let module: IntegrationLifecycleModule;
285
338
  try {
286
339
  module = await this.opts.loadModule(integration);
287
340
  } catch (error) {
288
341
  this.observe({ stage: "load", integration, ok: false, error: messageOf(error) });
289
- return;
342
+ return false;
343
+ }
344
+ if (this.state === "disposing" || this.state === "disposed") return false; // torn down mid-load: do not init.
345
+ // Consent may be withdrawn while the asynchronous loader is in flight. Recheck
346
+ // before calling tenant/vendor code; an entry must never initialize after denial.
347
+ if (!this.eligible(integration)) {
348
+ this.observe({ stage: "gate", integration, ok: false });
349
+ return true;
290
350
  }
291
- if (this.state === "disposing" || this.state === "disposed") return; // torn down mid-load: do not init.
351
+ const starting: StartingIntegration = {
352
+ integration,
353
+ module,
354
+ eligible: true,
355
+ cancelled: false,
356
+ disposed: false,
357
+ };
358
+ this.starting.push(starting);
359
+ let remainDeferred = false;
292
360
  try {
293
361
  await module.init(this.buildContext(integration));
294
- this.active.push({ integration, module });
295
- this.observe({ stage: "init", integration, ok: true });
362
+ // A consent withdrawal may arrive while vendor/sandbox init is unresolved. Its
363
+ // interrupt marks the module cancelled and invokes teardown immediately; never
364
+ // publish that late init as active, and load a fresh module on a later grant.
365
+ remainDeferred = starting.cancelled
366
+ || this.isTerminal()
367
+ || !this.eligible(integration);
368
+ if (!remainDeferred) {
369
+ this.active.push({ integration, module, eligible: true });
370
+ this.observe({ stage: "init", integration, ok: true });
371
+ }
296
372
  } catch (error) {
373
+ remainDeferred = starting.cancelled
374
+ || this.isTerminal()
375
+ || !this.eligible(integration);
297
376
  this.observe({ stage: "init", integration, ok: false, error: messageOf(error) });
377
+ } finally {
378
+ const index = this.starting.indexOf(starting);
379
+ if (index >= 0) this.starting.splice(index, 1);
380
+ }
381
+ return remainDeferred;
382
+ }
383
+
384
+ /**
385
+ * Interrupt a consent grant whose module init has not settled. Calling update and
386
+ * dispose synchronously gives the module/sandbox its immediate stop signal; returned
387
+ * promises are observed in the background so a hostile or stuck hook cannot delay the
388
+ * viewer's withdrawal. The late init is kept out of `active` by `cancelled` above.
389
+ */
390
+ private interruptWithdrawnStarts(reason: LifecycleUpdateReason): boolean {
391
+ const withdrawn = this.starting.filter(({ integration }) => !this.eligible(integration));
392
+ if (withdrawn.length === 0) return false;
393
+ for (const entry of withdrawn) {
394
+ if (entry.cancelled) continue;
395
+ entry.cancelled = true;
396
+ if (entry.module.update) {
397
+ try {
398
+ const result = entry.module.update(reason, this.buildContext(entry.integration));
399
+ void Promise.resolve(result).then(
400
+ () => this.observe({ stage: "update", integration: entry.integration, ok: true, reason }),
401
+ (error) => this.observe({
402
+ stage: "update", integration: entry.integration, ok: false, reason, error: messageOf(error),
403
+ }),
404
+ );
405
+ } catch (error) {
406
+ this.observe({
407
+ stage: "update", integration: entry.integration, ok: false, reason, error: messageOf(error),
408
+ });
409
+ }
410
+ }
411
+ if (entry.module.dispose && !entry.disposed) {
412
+ entry.disposed = true;
413
+ try {
414
+ const result = entry.module.dispose();
415
+ void Promise.resolve(result).then(
416
+ () => this.observe({ stage: "dispose", integration: entry.integration, ok: true }),
417
+ (error) => this.observe({
418
+ stage: "dispose", integration: entry.integration, ok: false, error: messageOf(error),
419
+ }),
420
+ );
421
+ } catch (error) {
422
+ this.observe({ stage: "dispose", integration: entry.integration, ok: false, error: messageOf(error) });
423
+ }
424
+ }
425
+ }
426
+ return true;
427
+ }
428
+
429
+ /** Deliver an update to a fixed set of active modules without loading deferred work. */
430
+ private async updateActive(
431
+ reason: LifecycleUpdateReason,
432
+ entries: readonly ActiveIntegration[],
433
+ ): Promise<void> {
434
+ for (const { integration, module } of entries) {
435
+ if (this.state !== "initialized") return;
436
+ if (!module.update) continue;
437
+ try {
438
+ await module.update(reason, this.buildContext(integration));
439
+ this.observe({ stage: "update", integration, ok: true, reason });
440
+ } catch (error) {
441
+ this.observe({ stage: "update", integration, ok: false, reason, error: messageOf(error) });
442
+ }
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Signal every active module synchronously during a withdrawal. Returned promises
448
+ * are observed in the background so one stuck adapter cannot delay the stop signal
449
+ * for another adapter or keep the viewer's withdrawal request pending.
450
+ */
451
+ private interruptActiveUpdates(
452
+ reason: LifecycleUpdateReason,
453
+ entries: readonly ActiveIntegration[],
454
+ ): void {
455
+ for (const { integration, module } of entries) {
456
+ if (!module.update) continue;
457
+ try {
458
+ const result = module.update(reason, this.buildContext(integration));
459
+ void Promise.resolve(result).then(
460
+ () => this.observe({ stage: "update", integration, ok: true, reason }),
461
+ (error) => this.observe({ stage: "update", integration, ok: false, reason, error: messageOf(error) }),
462
+ );
463
+ } catch (error) {
464
+ this.observe({ stage: "update", integration, ok: false, reason, error: messageOf(error) });
465
+ }
298
466
  }
299
467
  }
300
468
 
469
+ /** Record current eligibility and report whether this consent change withdrew an active entry. */
470
+ private updateActiveEligibility(): boolean {
471
+ let withdrew = false;
472
+ for (const entry of this.active) {
473
+ const eligible = this.eligible(entry.integration);
474
+ if (entry.eligible && !eligible) withdrew = true;
475
+ entry.eligible = eligible;
476
+ }
477
+ return withdrew;
478
+ }
479
+
301
480
  /**
302
481
  * Build the typed context handed to `init`/`update`: this integration's own
303
482
  * resolved public config (never another integration's), the document's
@@ -349,26 +528,59 @@ export class IntegrationLifecycle {
349
528
 
350
529
  /**
351
530
  * Deliver an in-place `update(reason)` to every initialized integration that implements
352
- * it. Used for a consent change, a config change, or a rollback — never a re-init. A no-op
353
- * before init or after dispose. One integration's throwing `update` is isolated.
531
+ * it. Used for a consent change, a config change, or a rollback — never a re-init. An
532
+ * update during async initialization is replayed before bootstrap emission; calls before
533
+ * initialization starts or after dispose are no-ops. One throwing `update` is isolated.
354
534
  */
355
535
  async update(reason: LifecycleUpdateReason): Promise<void> {
356
536
  if (!LIFECYCLE_UPDATE_REASONS.includes(reason)) return;
537
+ if (this.state !== "initializing" && this.state !== "initialized") return;
538
+ // Withdrawal is a safety interrupt, including while the document's initial
539
+ // lifecycle boot is still awaiting module.init().
540
+ let interruptedWithdrawal = false;
541
+ if (reason === "consent-changed") {
542
+ interruptedWithdrawal = this.interruptWithdrawnStarts(reason);
543
+ interruptedWithdrawal = this.updateActiveEligibility() || interruptedWithdrawal;
544
+ }
545
+ if (interruptedWithdrawal) {
546
+ // A mixed initial state can have earlier entries active while a later analytics
547
+ // entry is still starting. Stop both groups now; returning before this delivery
548
+ // would leave an active sibling subscribed under a withdrawn snapshot.
549
+ this.interruptActiveUpdates(reason, [...this.active]);
550
+ // One atomic consent snapshot may withdraw one purpose and grant another.
551
+ // Once initial traversal is complete, start those grants without making
552
+ // withdrawal wait for loader/vendor code. During traversal, queue the grant
553
+ // reconciliation so it cannot duplicate an entry the init loop has not reached.
554
+ if (this.state === "initializing") {
555
+ this.pendingGrantReconciliation = true;
556
+ } else {
557
+ void this.loadNewlyEligible().then(
558
+ () => this.emitBootstrapPageView(),
559
+ () => {},
560
+ );
561
+ }
562
+ return;
563
+ }
564
+ if (this.state === "initializing") {
565
+ this.pendingUpdates.push(reason);
566
+ return;
567
+ }
568
+ if (this.state !== "initialized") return;
569
+ const run = this.updateQueue.then(() => this.applyUpdate(reason));
570
+ this.updateQueue = run.catch(() => {});
571
+ await run;
572
+ }
573
+
574
+ private async applyUpdate(reason: LifecycleUpdateReason): Promise<void> {
357
575
  if (this.state !== "initialized") return;
358
576
  // A consent/config change may make a gated integration eligible: load it now
359
577
  // (init), then deliver the update only to integrations that were already live —
360
578
  // a just-loaded entry gets `init`, not an immediate redundant `update`.
361
579
  const alreadyActive = [...this.active];
362
580
  await this.loadNewlyEligible();
363
- for (const { integration, module } of alreadyActive) {
364
- if (!module.update) continue;
365
- try {
366
- await module.update(reason, this.buildContext(integration));
367
- this.observe({ stage: "update", integration, ok: true, reason });
368
- } catch (error) {
369
- this.observe({ stage: "update", integration, ok: false, reason, error: messageOf(error) });
370
- }
371
- }
581
+ if (this.state !== "initialized") return;
582
+ await this.updateActive(reason, alreadyActive);
583
+ if (this.state !== "initialized") return;
372
584
  // A consent grant is the case that matters: it both loads a previously-gated adapter
373
585
  // (above) and makes the held-back bootstrap page view emittable, so the document the
374
586
  // visitor consented ON is still counted rather than only its successors.
@@ -378,16 +590,38 @@ export class IntegrationLifecycle {
378
590
  /**
379
591
  * Dispose every initialized integration on teardown. Idempotent — a second dispose is a
380
592
  * no-op — and terminal: after it, `update` and a late `init` do nothing. One integration's
381
- * throwing `dispose` is isolated; every other integration is still disposed.
593
+ * throwing or unresolved `dispose` is isolated; every other integration is still signalled.
382
594
  */
383
595
  async dispose(): Promise<void> {
384
596
  if (this.state === "disposed" || this.state === "disposing") return;
385
597
  this.state = "disposing";
598
+ // Starting modules are already executing tenant/vendor code but are not yet in
599
+ // `active`. Cancel and signal teardown synchronously so a late init settlement
600
+ // cannot publish after this terminal transition.
601
+ for (const entry of this.starting) {
602
+ entry.cancelled = true;
603
+ if (!entry.module.dispose || entry.disposed) continue;
604
+ entry.disposed = true;
605
+ try {
606
+ const result = entry.module.dispose();
607
+ void Promise.resolve(result).then(
608
+ () => this.observe({ stage: "dispose", integration: entry.integration, ok: true }),
609
+ (error) => this.observe({
610
+ stage: "dispose", integration: entry.integration, ok: false, error: messageOf(error),
611
+ }),
612
+ );
613
+ } catch (error) {
614
+ this.observe({ stage: "dispose", integration: entry.integration, ok: false, error: messageOf(error) });
615
+ }
616
+ }
386
617
  for (const { integration, module } of this.active) {
387
618
  if (!module.dispose) continue;
388
619
  try {
389
- await module.dispose();
390
- this.observe({ stage: "dispose", integration, ok: true });
620
+ const result = module.dispose();
621
+ void Promise.resolve(result).then(
622
+ () => this.observe({ stage: "dispose", integration, ok: true }),
623
+ (error) => this.observe({ stage: "dispose", integration, ok: false, error: messageOf(error) }),
624
+ );
391
625
  } catch (error) {
392
626
  this.observe({ stage: "dispose", integration, ok: false, error: messageOf(error) });
393
627
  }
@@ -1,9 +1,11 @@
1
1
  /** Shared PII-value canary for managed browser events and their diagnostics. */
2
2
  import { MANAGED_ANALYTICS_PII_VALUE_PATTERN } from "./managed-analytics-api-v1-safety.mjs";
3
+ import { fnv1a128Hex } from "./digest.js";
3
4
 
4
5
  const MANAGED_ANALYTICS_PII_VALUE = new RegExp(MANAGED_ANALYTICS_PII_VALUE_PATTERN);
5
6
  const MANAGED_ANALYTICS_PROTECTED_VALUE =
6
7
  /(?:@|\d{7,}|(?:^|[./_~-])(?:bearer|password|secret|token|authorization|preview)(?:$|[./_~-]))/i;
8
+ const OPAQUE_HEX_ALPHABET = "abcdefghijklmnop";
7
9
 
8
10
  /** True when a public string resembles an email, SSN, payment card, or phone number. */
9
11
  export function resemblesManagedAnalyticsPii(value: unknown): boolean {
@@ -17,6 +19,22 @@ export function resemblesManagedAnalyticsProtectedValue(value: unknown): boolean
17
19
  );
18
20
  }
19
21
 
22
+ /**
23
+ * Derive a stable letters-only token for managed-event ids from a public revision.
24
+ *
25
+ * Revisions are hexadecimal content identities and can therefore contain a run of
26
+ * seven digits by chance. Copying one directly into an event id trips the deliberate
27
+ * phone/card canary and silently rejects an otherwise safe event. Hashing once more
28
+ * and encoding each nibble as `a`-`p` retains deterministic revision scoping while
29
+ * making a numeric/credential-looking result impossible by construction.
30
+ */
31
+ export function managedAnalyticsOpaqueRevision(value: string): string {
32
+ return Array.from(
33
+ fnv1a128Hex(value),
34
+ (nibble) => OPAQUE_HEX_ALPHABET[Number.parseInt(nibble, 16)]!,
35
+ ).join("");
36
+ }
37
+
20
38
  /** Defense-in-depth for identifiers copied into structured broker observations. */
21
39
  export function safeManagedAnalyticsDiagnosticId(value: string): string {
22
40
  return resemblesManagedAnalyticsPii(value) ? "[redacted]" : value;
@@ -40,6 +40,7 @@ import {
40
40
  } from "./environment.js";
41
41
  import { defaultConsentSnapshot, type ConsentSnapshot } from "./consent-broker.js";
42
42
  import { LIFECYCLE_HOST_ASSET_URL } from "./lifecycle-host-asset.mjs";
43
+ import { VIEWER_ANALYTICS_CONSENT_EARLY_BOOT_JS } from "./consent-broker.js";
43
44
 
44
45
  /** Re-exported so consumers and tests read the host URL from the engine that emits it. */
45
46
  export { LIFECYCLE_HOST_ASSET_PATH, LIFECYCLE_HOST_ASSET_URL } from "./lifecycle-host-asset.mjs";
@@ -163,10 +164,14 @@ export function renderIntegrationPhases(
163
164
  phaseBoundary("consent.defaults") +
164
165
  inlineFrozenGlobal(INTEGRATION_CONSENT_GLOBAL, consentSnapshot, nonce) +
165
166
  phaseBoundary("head.early") +
166
- inlineFrozenGlobal(INTEGRATION_PLAN_GLOBAL, bootDescriptor(plan), nonce);
167
+ inlineFrozenGlobal(INTEGRATION_PLAN_GLOBAL, bootDescriptor(plan), nonce) +
168
+ inlineScript(VIEWER_ANALYTICS_CONSENT_EARLY_BOOT_JS, nonce) +
169
+ viewerAnalyticsConsentStyle(nonce);
167
170
 
168
171
  const bodyStart =
169
- phaseBoundary("body.start") + `<div data-tot-integration-root hidden></div>`;
172
+ phaseBoundary("body.start") +
173
+ `<div data-tot-integration-root hidden></div>` +
174
+ viewerAnalyticsConsentControl();
170
175
 
171
176
  const bodyDeferred =
172
177
  phaseBoundary("document.deferred") +
@@ -196,6 +201,27 @@ function lifecycleHostScript(): string {
196
201
  return `<script type="module" src="${LIFECYCLE_HOST_ASSET_URL}"></script>`;
197
202
  }
198
203
 
204
+ function inlineScript(source: string, nonce: string): string {
205
+ const nonceAttr = nonce ? ` nonce="${nonce}"` : "";
206
+ return `<script${nonceAttr}>${source}</script>`;
207
+ }
208
+
209
+ /** Renderer-owned, provider-neutral viewer control: identical in both document mounts. */
210
+ function viewerAnalyticsConsentStyle(nonce: string): string {
211
+ const style = `.tot-analytics-consent{position:fixed;z-index:900;right:1rem;bottom:1rem;display:flex;max-width:min(30rem,calc(100vw - 2rem));align-items:center;gap:.65rem;padding:.7rem .8rem;border:1px solid var(--color-border,#cbd5e1);border-radius:.75rem;background:var(--color-bg,#fff);color:var(--color-text,#111827);box-shadow:0 8px 30px rgba(15,23,42,.18);font:500 .75rem/1.25 system-ui,sans-serif}.tot-analytics-consent__copy{display:grid;gap:.15rem;min-width:8rem}.tot-analytics-consent__detail{color:var(--color-muted,#475569);font-size:.68rem}.tot-analytics-consent__actions{display:flex;gap:.4rem}.tot-analytics-consent button{border:1px solid currentColor;border-radius:999px;background:transparent;color:inherit;padding:.4rem .65rem;font:inherit;cursor:pointer}.tot-analytics-consent button:disabled{cursor:default;opacity:.45}@media(max-width:34rem){.tot-analytics-consent{left:1rem;right:1rem;align-items:flex-start;flex-direction:column}.tot-analytics-consent__actions{width:100%}.tot-analytics-consent button{flex:1}}`;
212
+ return `<style${nonce ? ` nonce="${nonce}"` : ""}>${style}</style>`;
213
+ }
214
+
215
+ function viewerAnalyticsConsentControl(): string {
216
+ return `<aside class="tot-analytics-consent" data-tot-analytics-consent-control aria-label="Analytics privacy settings">` +
217
+ `<span class="tot-analytics-consent__copy"><strong data-tot-analytics-consent-status aria-live="polite">Analytics off</strong>` +
218
+ `<span class="tot-analytics-consent__detail">Choose whether anonymous analytics may run.</span></span>` +
219
+ `<span class="tot-analytics-consent__actions">` +
220
+ `<button type="button" data-tot-analytics-consent-decision="granted" aria-pressed="false" disabled>Allow</button>` +
221
+ `<button type="button" data-tot-analytics-consent-decision="denied" aria-pressed="true" disabled>Decline</button>` +
222
+ `</span></aside>`;
223
+ }
224
+
199
225
  /**
200
226
  * Replace every {@link INTEGRATION_NONCE_PLACEHOLDER} in `text` (an HTML body or a
201
227
  * CSP header value) with one fresh per-response `nonce`. Idempotent on text with no
@@ -11,13 +11,13 @@
11
11
  * schema versions fail closed.
12
12
  */
13
13
 
14
- export const BROWSER_INTEGRATION_CONTRACT_VERSION = "2.0.0" as const;
14
+ export const BROWSER_INTEGRATION_CONTRACT_VERSION = "3.0.0" as const;
15
15
 
16
16
  export const ENVIRONMENT_SCHEMA_VERSION = 1 as const;
17
17
  export const INTEGRATION_MANIFEST_SCHEMA_VERSION = 2 as const;
18
18
  export const EFFECTIVE_PLAN_SCHEMA_VERSION = 1 as const;
19
19
  export const COMMERCE_EVENT_STOREFRONT_SCHEMA_VERSION = 1 as const;
20
- export const CONSENT_SNAPSHOT_SCHEMA_VERSION = 1 as const;
20
+ export const CONSENT_SNAPSHOT_SCHEMA_VERSION = 2 as const;
21
21
  export const RECEIPT_MEASUREMENT_GRANT_SCHEMA_VERSION = 1 as const;
22
22
  export const CONSENT_CONFIG_SCHEMA_VERSION = 1 as const;
23
23
  export const ASSURANCE_RECORD_SCHEMA_VERSION = 1 as const;
@@ -21,7 +21,7 @@
21
21
  "expiresAt": "2026-08-28T21:15:30.000Z"
22
22
  },
23
23
  "consent": {
24
- "schemaVersion": 1,
24
+ "schemaVersion": 2,
25
25
  "snapshotId": "consent_public_01J6EXAMPLE",
26
26
  "eligiblePurposes": [
27
27
  "essential",
@@ -20,7 +20,7 @@
20
20
  "expiresAt": "2026-08-28T21:15:30.000Z"
21
21
  },
22
22
  "consent": {
23
- "schemaVersion": 1,
23
+ "schemaVersion": 2,
24
24
  "snapshotId": "consent_public_01J6EXAMPLE",
25
25
  "eligiblePurposes": [
26
26
  "essential",
@@ -11,7 +11,7 @@
11
11
  "supportUrl": "https://support.example.invalid/storefront"
12
12
  },
13
13
  "compatibility": {
14
- "storefrontBrowserContract": ">=2.0.0 <3.0.0",
14
+ "storefrontBrowserContract": ">=3.0.0 <4.0.0",
15
15
  "configurationSchemaVersion": 3
16
16
  },
17
17
  "browser": {
@@ -10,7 +10,7 @@
10
10
  "supportUrl": "https://acme.example/support"
11
11
  },
12
12
  "compatibility": {
13
- "storefrontBrowserContract": ">=2.0.0 <3.0.0",
13
+ "storefrontBrowserContract": ">=3.0.0 <4.0.0",
14
14
  "configurationSchemaVersion": 2
15
15
  },
16
16
  "browser": {
@@ -41,12 +41,6 @@ export const KNOWN_EMBEDS = Object.entries(EMBED_PROVIDERS).map(([slug, p]) => (
41
41
  origins: [...new Set([...(p.scriptSrc ?? []), ...(p.connectSrc ?? []), ...(p.frameSrc ?? [])])],
42
42
  }));
43
43
 
44
- /** Hosts the platform always allows (self is implicit); embeds to these are fine. */
45
- const ALWAYS_ALLOWED_HOSTS = new Set([
46
- "static.cloudflareinsights.com",
47
- "cloudflareinsights.com",
48
- ]);
49
-
50
44
  // Only <script src> and <iframe src> load executable/frameable content the CSP
51
45
  // gates (script-src / frame-src). We deliberately DON'T scan generic URLs
52
46
  // (canonical links, JSON-LD @context, <a href>, og:url) — those aren't embeds
@@ -79,8 +73,6 @@ export function scanHtmlForEmbeds(html) {
79
73
  while ((m = SRC_RE.exec(html))) {
80
74
  const origin = originOf(m[1]);
81
75
  if (!origin) continue;
82
- const host = new URL(origin).hostname;
83
- if (ALWAYS_ALLOWED_HOSTS.has(host)) continue;
84
76
  external.add(origin);
85
77
  }
86
78
  // An external origin is "unknown" if no catalog provider claims it.