@tokenoftrust/storefront-runner 2.0.31 → 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.
- package/apps/storefront/env.d.ts +0 -2
- package/apps/storefront/src/layouts/Layout.astro +3 -48
- package/apps/storefront/src/lib/analytics/budgets.json +0 -1
- package/apps/storefront/src/lib/analytics/index.ts +1 -4
- package/apps/storefront/src/lib/analytics/webVitals.ts +42 -4
- package/apps/storefront/src/lib/integration/tenantDeclarations.ts +1 -1
- package/apps/storefront/src/lib/monitoring/manifest.ts +1 -2
- package/package.json +1 -1
- package/packages/public-runtime/src/csp.ts +2 -2
- package/packages/public-runtime/src/integration/consent-broker.ts +370 -13
- package/packages/public-runtime/src/integration/interaction-tracking-gate.ts +1 -2
- package/packages/public-runtime/src/integration/lifecycle-host-entry.ts +2 -0
- package/packages/public-runtime/src/integration/lifecycle-runtime.ts +256 -22
- package/packages/public-runtime/src/integration/render-phases.ts +28 -2
- package/packages/public-runtime/src/integration/version.ts +2 -2
- package/packages/public-runtime/tests/fixtures/integration/commerce-event.example.json +1 -1
- package/packages/public-runtime/tests/fixtures/integration/commerce-order-event.example.json +1 -1
- package/packages/public-runtime/tests/fixtures/integration/integration-manifest-v2.example.json +1 -1
- package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/manifest.json +1 -1
- package/scripts/tenant/lint-embeds.mjs +0 -8
package/apps/storefront/env.d.ts
CHANGED
|
@@ -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
|
|
586
|
-
beacon above only gives aggregate field CWV). */}
|
|
541
|
+
signal the `core-web-vitals` monitor consumes. */}
|
|
587
542
|
<script>
|
|
588
|
-
import {
|
|
589
|
-
|
|
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
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Privacy-friendly analytics (brief §5 Analytics).
|
|
3
3
|
*
|
|
4
|
-
* Phase 1 ships
|
|
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
|
|
28
|
-
*
|
|
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: ">=
|
|
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
|
|
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.
|
|
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",
|
|
@@ -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: [
|
|
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: [
|
|
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`
|
|
96
|
-
* always
|
|
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(
|
|
118
|
-
|
|
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(
|
|
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`,
|
|
208
|
-
*
|
|
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
|
|
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
|
+
}
|
|
@@ -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
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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.
|
|
353
|
-
*
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
|
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
|
-
|
|
390
|
-
|
|
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
|
}
|
|
@@ -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") +
|
|
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 = "
|
|
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 =
|
|
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;
|
|
@@ -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.
|