@tokenoftrust/storefront-runner 2.0.30 → 2.0.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/storefront/astro.config.mjs +2 -0
- package/apps/storefront/env.d.ts +0 -2
- package/apps/storefront/integrations/google-measurement-sandbox-asset.mjs +97 -0
- package/apps/storefront/public/platform/google-measurement/v2/entry.mjs +13 -0
- 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/package.json +2 -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/csp-compiler.ts +3 -6
- package/packages/public-runtime/src/integration/events.ts +5 -5
- package/packages/public-runtime/src/integration/index.ts +1 -0
- package/packages/public-runtime/src/integration/interaction-tracking-gate.ts +1 -2
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +28 -14
- 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/managed-analytics-value-safety.ts +9 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +2 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +3 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +207 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +168 -0
- package/packages/public-runtime/src/integration/native-ga4.ts +111 -215
- package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +18 -9
- package/packages/public-runtime/src/integration/platform-adapters.ts +20 -9
- 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/public/platform/google-measurement/v1/entry.mjs +0 -19
|
@@ -92,13 +92,16 @@ export interface ConsentDestination {
|
|
|
92
92
|
|
|
93
93
|
/**
|
|
94
94
|
* The versioned, browser-safe consent snapshot — the single value client and server
|
|
95
|
-
* destinations filter against. `eligiblePurposes`
|
|
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
|
+
}
|
|
@@ -63,12 +63,9 @@ export const INTEGRATION_CSP_PROFILES: Readonly<Record<string, CspSourceSet>> =
|
|
|
63
63
|
"connect-src": ["https://cdn.usefathom.com"],
|
|
64
64
|
},
|
|
65
65
|
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
"google-measurement": {
|
|
70
|
-
"connect-src": ["https://www.google-analytics.com"],
|
|
71
|
-
},
|
|
66
|
+
// Google code and network authority live only in the null-origin sandbox. The
|
|
67
|
+
// parent needs no addition: base frame-src 'self' admits the versioned host.
|
|
68
|
+
"google-measurement": {},
|
|
72
69
|
|
|
73
70
|
// Meta pixel (fbevents.js). The loader is served from connect.facebook.net; the
|
|
74
71
|
// pixel's collect endpoint is the facebook.com tracking origin. The tenant
|
|
@@ -56,7 +56,10 @@ import {
|
|
|
56
56
|
import { INTEGRATION_ENVIRONMENTS, INTEGRATION_SITE_SURFACES } from "./environment.js";
|
|
57
57
|
import type { IntegrationEnvironment, IntegrationSiteSurface } from "./environment.js";
|
|
58
58
|
import { findForbiddenFields } from "./secrets.js";
|
|
59
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
resemblesManagedAnalyticsPii,
|
|
61
|
+
resemblesManagedAnalyticsProtectedValue,
|
|
62
|
+
} from "./managed-analytics-value-safety.js";
|
|
60
63
|
|
|
61
64
|
/**
|
|
62
65
|
* The reserved commerce event taxonomy. Every accepted type is here; an unknown
|
|
@@ -348,9 +351,6 @@ function validateLineItems(parent: Record<string, unknown>, v: Violations, path:
|
|
|
348
351
|
|
|
349
352
|
const UTM_FIELDS = ["source", "medium", "campaign", "term", "content"] as const;
|
|
350
353
|
const SAFE_UTM_VALUE = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/;
|
|
351
|
-
const PROTECTED_ATTRIBUTION_VALUE =
|
|
352
|
-
/(?:@|\d{7,}|(?:^|[._~-])(?:bearer|password|secret|token|authorization|preview)(?:$|[._~-]))/i;
|
|
353
|
-
|
|
354
354
|
function validatePageAttribution(parent: Record<string, unknown>, v: Violations): void {
|
|
355
355
|
const attribution = childObject(parent, "attribution", v, "data");
|
|
356
356
|
if (!attribution) return;
|
|
@@ -403,7 +403,7 @@ function validatePageAttribution(parent: Record<string, unknown>, v: Violations)
|
|
|
403
403
|
`UTM ${key} must be a 1-128 character opaque campaign token.`,
|
|
404
404
|
);
|
|
405
405
|
}
|
|
406
|
-
if (value !== undefined &&
|
|
406
|
+
if (value !== undefined && resemblesManagedAnalyticsProtectedValue(value)) {
|
|
407
407
|
v.add(
|
|
408
408
|
`data.attribution.utm.${key}`,
|
|
409
409
|
"protected_value",
|
|
@@ -58,6 +58,7 @@ export * from "./resolve.js";
|
|
|
58
58
|
export * from "./platform-adapters.js";
|
|
59
59
|
export * from "./native-amplitude.js";
|
|
60
60
|
export * from "./native-ga4.js";
|
|
61
|
+
export * from "./native-ga4-sandbox.js";
|
|
61
62
|
export * from "./platform-adapter-runtime.js";
|
|
62
63
|
export * from "./render-phases.js";
|
|
63
64
|
export * from "./interim-bridge.js";
|
|
@@ -13,8 +13,7 @@
|
|
|
13
13
|
* ({@link isPurposeEligible}, `consent-broker.ts`). A missing or tampered
|
|
14
14
|
* `window.__TOT_INTEGRATION_CONSENT__` global fails closed to the
|
|
15
15
|
* default-deny snapshot, same as every other consumer of that global
|
|
16
|
-
* (`lifecycle-boot.ts
|
|
17
|
-
* `Layout.astro`).
|
|
16
|
+
* (`lifecycle-boot.ts` and the managed analytics adapters).
|
|
18
17
|
* - ENVIRONMENT: the renderer-published four-stage environment
|
|
19
18
|
* (`resolveBrowserEnvironment`, `environment.ts`) must resolve AND must not
|
|
20
19
|
* be `candidate`. A per-change candidate render is a preview — it is never
|
|
@@ -16,11 +16,10 @@
|
|
|
16
16
|
* constructs bundled platform code with host-owned capabilities. Every other entry goes
|
|
17
17
|
* through the authority-tier loader: it loads ONLY an `elevated-parent` entry (whose
|
|
18
18
|
* honest, review-only contract IS arbitrary parent-document JavaScript) and REFUSES every
|
|
19
|
-
* other tier. A
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* authority its label does not grant. Loading is also consent-gated: the binding reads
|
|
19
|
+
* other tier. A reviewed registry entry may construct its own null-origin iframe and
|
|
20
|
+
* private typed MessagePort; arbitrary `sandbox`-tier entry modules remain refused by
|
|
21
|
+
* the parent-realm loader, so their isolation is never silently downgraded. Loading is
|
|
22
|
+
* also consent-gated: the binding reads
|
|
24
23
|
* the renderer-inlined default-deny consent snapshot and holds a non-essential entry
|
|
25
24
|
* back until its declared purposes are eligible. With an environment-only plan there
|
|
26
25
|
* are no integrations, so the runtime boots inert and ready.
|
|
@@ -43,6 +42,11 @@ import type { NavigationCompletedEvent, NavigationKind } from "./events.js";
|
|
|
43
42
|
import { CommerceEventBroker } from "./commerce-broker.js";
|
|
44
43
|
import { validateBrowserEnvironment, BROWSER_ENVIRONMENT_GLOBAL, type BrowserEnvironment } from "./environment.js";
|
|
45
44
|
import { makePlatformAdapterRuntimeLoader } from "./platform-adapter-runtime.js";
|
|
45
|
+
import { NATIVE_GA4_SANDBOX_URL, type NativeGa4Transport } from "./native-ga4.js";
|
|
46
|
+
import {
|
|
47
|
+
createBrowserNativeGa4SandboxHost,
|
|
48
|
+
createNativeGa4SandboxTransport,
|
|
49
|
+
} from "./native-ga4-sandbox.js";
|
|
46
50
|
import { ManagedAnalyticsEventBrokerBridge } from "./managed-analytics-bridge.js";
|
|
47
51
|
import { minimizePageAttribution, type ManagedPageViewInput } from "./managed-analytics.js";
|
|
48
52
|
import {
|
|
@@ -53,10 +57,9 @@ import {
|
|
|
53
57
|
/** The window property holding the single per-document lifecycle (idempotent boot guard). */
|
|
54
58
|
export const LIFECYCLE_SINGLETON_GLOBAL = "__TOT_INTEGRATION_LIFECYCLE__";
|
|
55
59
|
/**
|
|
56
|
-
* An optional window-published loader the entry-asset layer may install to
|
|
57
|
-
* fail-closed same-origin default.
|
|
58
|
-
*
|
|
59
|
-
* that host ships the seam is unused and the default loader refuses the sandbox tier.
|
|
60
|
+
* An optional window-published loader the entry-asset layer may install to override the
|
|
61
|
+
* fail-closed same-origin default. The default loader still refuses sandbox-tier modules;
|
|
62
|
+
* reviewed platform adapters construct any required isolation through their registry.
|
|
60
63
|
*/
|
|
61
64
|
export const LIFECYCLE_MODULE_LOADER_GLOBAL = "__TOT_INTEGRATION_MODULE_LOADER__";
|
|
62
65
|
/** An optional window-published sink for the completed-navigation event. */
|
|
@@ -114,6 +117,8 @@ export interface BootLifecycleOptions {
|
|
|
114
117
|
fetchCapability?: typeof fetch;
|
|
115
118
|
/** Entry import capability; injectable for tests and non-browser hosts. */
|
|
116
119
|
importModule?: (url: string) => Promise<unknown>;
|
|
120
|
+
/** Reviewed null-origin GA4 transport; injectable for DOM-free tests. */
|
|
121
|
+
nativeGa4Transport?: NativeGa4Transport;
|
|
117
122
|
}
|
|
118
123
|
|
|
119
124
|
/**
|
|
@@ -146,11 +151,10 @@ const PARENT_REALM_TIER = "elevated-parent" as const;
|
|
|
146
151
|
* a hardcoded vendor path. `importModule` is injected so the loader is testable without
|
|
147
152
|
* a live network; it defaults to a native dynamic `import()`.
|
|
148
153
|
*
|
|
149
|
-
* FAIL-CLOSED BY TIER
|
|
154
|
+
* FAIL-CLOSED BY TIER: a parent-realm import gives the entry
|
|
150
155
|
* full parent-document authority, so the loader refuses any tier but `elevated-parent`
|
|
151
156
|
* BEFORE importing — a `sandbox`-tier entry throws without ever being fetched or
|
|
152
|
-
* evaluated, so code labeled "sandboxed" cannot run in the parent
|
|
153
|
-
* iframe host is still the tracked follow-up. The throw is caught by the runtime's
|
|
157
|
+
* evaluated, so code labeled "sandboxed" cannot run in the parent. The throw is caught by the runtime's
|
|
154
158
|
* per-integration load isolation: the entry never becomes active, the page is unaffected,
|
|
155
159
|
* and every other integration still boots.
|
|
156
160
|
*/
|
|
@@ -163,8 +167,8 @@ export function makeSameOriginEntryLoader(
|
|
|
163
167
|
`Refusing to load "${integration.appId}@${integration.version}" (tier ` +
|
|
164
168
|
`"${integration.executionTier}") into the parent document: the same-origin loader ` +
|
|
165
169
|
`only runs the "${PARENT_REALM_TIER}" tier. A "sandbox"-tier entry must run inside a ` +
|
|
166
|
-
`null-origin iframe reachable only through
|
|
167
|
-
`
|
|
170
|
+
`null-origin iframe reachable only through a typed MessagePort broker, so the entry ` +
|
|
171
|
+
`is held fail-closed rather than run ` +
|
|
168
172
|
`with parent-document authority its tier does not grant.`,
|
|
169
173
|
);
|
|
170
174
|
}
|
|
@@ -314,6 +318,16 @@ export function bootIntegrationLifecycle(
|
|
|
314
318
|
fetchCapability: hostFetch,
|
|
315
319
|
importModule: options.importModule,
|
|
316
320
|
fallback: ordinaryLoader,
|
|
321
|
+
nativeGa4Transport: () => {
|
|
322
|
+
if (options.nativeGa4Transport) return options.nativeGa4Transport;
|
|
323
|
+
if (typeof document === "undefined" || typeof MessageChannel === "undefined") {
|
|
324
|
+
throw new Error("platform_adapter_ga4_sandbox_unavailable");
|
|
325
|
+
}
|
|
326
|
+
return createNativeGa4SandboxTransport(
|
|
327
|
+
createBrowserNativeGa4SandboxHost(document, MessageChannel),
|
|
328
|
+
NATIVE_GA4_SANDBOX_URL,
|
|
329
|
+
);
|
|
330
|
+
},
|
|
317
331
|
});
|
|
318
332
|
|
|
319
333
|
const isEligible = options.isEligible ?? defaultConsentGate(win);
|
|
@@ -12,5 +12,7 @@
|
|
|
12
12
|
* self-invoking module there would boot the lifecycle on any server-side import.
|
|
13
13
|
*/
|
|
14
14
|
import { bootIntegrationLifecycleFromDocument } from "./lifecycle-boot.js";
|
|
15
|
+
import { bootViewerAnalyticsConsentFromDocument } from "./consent-broker.js";
|
|
15
16
|
|
|
17
|
+
bootViewerAnalyticsConsentFromDocument();
|
|
16
18
|
bootIntegrationLifecycleFromDocument();
|