@tokenoftrust/storefront-runner 4.9.3 → 4.10.1
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/agency-kit/tools/lib/analytics-declaration.mjs +299 -0
- package/apps/storefront/package.json +1 -1
- package/apps/storefront/src/lib/access/shared/cspStaticHashes.ts +1 -1
- package/apps/storefront/src/lib/compliance/shared/visitorRegionCookie.ts +1 -1
- package/apps/storefront/src/lib/integrations/integration/tenantAnalyticsDeclarations.ts +46 -206
- package/apps/storefront/src/lib/measurement/admin/analyticsRegionPolicy.ts +151 -0
- package/apps/storefront/src/lib/measurement/admin/analyticsRegionsClient.ts +84 -0
- package/apps/storefront/test/pipeline/publish-kinds.ts +5 -1
- package/apps/storefront/test/pipeline-faults/matrix-lib.ts +16 -2
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -1
- package/packages/public-runtime/src/integration/consent-broker.ts +117 -17
- package/packages/public-runtime/src/integration/events.ts +15 -19
- package/packages/public-runtime/src/integration/index.ts +1 -0
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +41 -7
- package/packages/public-runtime/src/integration/managed-analytics-value-safety.ts +84 -1
- package/packages/public-runtime/src/integration/managed-analytics.ts +39 -26
- package/packages/public-runtime/src/integration/native-amplitude.ts +11 -10
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +1 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +20 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +24 -13
- package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +8 -11
- package/packages/public-runtime/src/integration/native-ga4.ts +21 -8
- package/packages/public-runtime/src/integration/plan.ts +6 -4
- package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +9 -2
- package/packages/public-runtime/src/integration/render-phases.ts +10 -18
- package/packages/public-runtime/src/integration/resolve.ts +3 -3
- package/packages/public-runtime/src/integration/tenant-analytics.ts +250 -0
- package/packages/public-runtime/src/integration/visitor-region.mjs +1 -1
- package/packages/public-runtime/src/tenant.ts +3 -3
- package/packages/public-runtime/tests/fixtures/integration/campaign-token-corpus.ts +75 -0
- package/scripts/dev/ga4-gs2-canary.mjs +36 -10
- package/scripts/tenant/validate.mjs +9 -0
|
@@ -59,8 +59,8 @@ import {
|
|
|
59
59
|
import { schedulePostPaint, type PostPaintWindow } from "./post-paint.js";
|
|
60
60
|
import { scheduleGa4Sandbox, type Ga4SandboxScheduleWindow } from "./ga4-sandbox-schedule.js";
|
|
61
61
|
import { ManagedAnalyticsEventBrokerBridge } from "./managed-analytics-bridge.js";
|
|
62
|
-
import { minimizePageAttribution, type ManagedPageViewInput } from "./managed-analytics.js";
|
|
63
|
-
import { managedAnalyticsOpaqueRevision } from "./managed-analytics-value-safety.js";
|
|
62
|
+
import { minimizePageAttribution, readGoogleAdsClickIds, type ManagedPageViewInput } from "./managed-analytics.js";
|
|
63
|
+
import { managedAnalyticsOpaqueRevision, type GoogleAdsClickIds } from "./managed-analytics-value-safety.js";
|
|
64
64
|
import {
|
|
65
65
|
CONTACT_SALES_ACTION_REGISTRY,
|
|
66
66
|
createContactSalesActionSource,
|
|
@@ -349,13 +349,13 @@ function defaultConsentGate(
|
|
|
349
349
|
}
|
|
350
350
|
|
|
351
351
|
/**
|
|
352
|
-
* Whether the tenant's EU/UK visitor policy governs this document
|
|
353
|
-
*
|
|
354
|
-
*
|
|
352
|
+
* Whether the tenant's EU/UK visitor policy governs this document, as the early consent
|
|
353
|
+
* boot decided it (`regionRestricted`). Only an explicit `false` lifts it: a missing
|
|
354
|
+
* state global or a missing or non-boolean flag applies it (fail closed).
|
|
355
355
|
*/
|
|
356
356
|
function euUkPolicyApplies(win: LifecycleWindow): boolean {
|
|
357
|
-
const state = win[VIEWER_ANALYTICS_CONSENT_STATE_GLOBAL] as ViewerAnalyticsConsentState | undefined;
|
|
358
|
-
return
|
|
357
|
+
const state = win[VIEWER_ANALYTICS_CONSENT_STATE_GLOBAL] as Partial<ViewerAnalyticsConsentState> | undefined;
|
|
358
|
+
return state?.regionRestricted !== false;
|
|
359
359
|
}
|
|
360
360
|
|
|
361
361
|
/**
|
|
@@ -367,6 +367,39 @@ function persistentVisitorStorage(win: LifecycleWindow): boolean {
|
|
|
367
367
|
return state?.visitorStorage === "persistent";
|
|
368
368
|
}
|
|
369
369
|
|
|
370
|
+
/**
|
|
371
|
+
* Whether landing click identifiers may reach GA4: the visitor is known to be outside
|
|
372
|
+
* the EU/UK and analytics may keep them on this device. GPC, a saved denial, `count-only`,
|
|
373
|
+
* an unknown region, and an EU/UK visitor under any tenant policy all forward nothing.
|
|
374
|
+
* A missing state global allows nothing (fail closed).
|
|
375
|
+
*/
|
|
376
|
+
function landingClickIdsAllowed(win: LifecycleWindow): boolean {
|
|
377
|
+
const state = win[VIEWER_ANALYTICS_CONSENT_STATE_GLOBAL] as ViewerAnalyticsConsentState | undefined;
|
|
378
|
+
return state?.visitorStorage === "persistent" && state.region === "other";
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Windows whose landing click identifiers already went out; a back-forward restore never resends them. */
|
|
382
|
+
const landingClickIdsTaken = new WeakSet<object>();
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The GA4 host capability over this document's landing URL: identifiers are read once,
|
|
386
|
+
* when the lifecycle is built, and released once, to the page view of the landing path,
|
|
387
|
+
* only while {@link landingClickIdsAllowed} holds at that moment.
|
|
388
|
+
*/
|
|
389
|
+
function landingClickIdSource(
|
|
390
|
+
win: LifecycleWindow,
|
|
391
|
+
tenantPublicId: string,
|
|
392
|
+
): ((path: string) => GoogleAdsClickIds | undefined) | undefined {
|
|
393
|
+
const clickIds = readGoogleAdsClickIds(win.location.href);
|
|
394
|
+
if (!clickIds) return undefined;
|
|
395
|
+
const landingPath = canonicalDocumentPath(win.location.pathname, tenantPublicId);
|
|
396
|
+
return (path) => {
|
|
397
|
+
if (path !== landingPath || landingClickIdsTaken.has(win) || !landingClickIdsAllowed(win)) return undefined;
|
|
398
|
+
landingClickIdsTaken.add(win);
|
|
399
|
+
return clickIds;
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
370
403
|
/**
|
|
371
404
|
* Under the EU/UK policy only a reviewed platform adapter may run: its
|
|
372
405
|
* visitor storage goes through this host's continuity seam, which then keeps nothing on
|
|
@@ -500,6 +533,7 @@ function createDocumentLifecycle(
|
|
|
500
533
|
? win["localStorage"] as VisitorContinuityStorage | undefined
|
|
501
534
|
: undefined),
|
|
502
535
|
}),
|
|
536
|
+
takeLandingClickIds: landingClickIdSource(win, descriptor.tenantPublicId),
|
|
503
537
|
nativeGa4Transport: () => {
|
|
504
538
|
if (options.nativeGa4Transport) return options.nativeGa4Transport;
|
|
505
539
|
if (typeof document === "undefined" || typeof MessageChannel === "undefined") {
|
|
@@ -1,12 +1,95 @@
|
|
|
1
1
|
/** Shared PII-value canary for managed browser events and their diagnostics. */
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
MANAGED_ANALYTICS_PII_VALUE_PATTERN,
|
|
4
|
+
MANAGED_ANALYTICS_SECRET_VALUE_PATTERN,
|
|
5
|
+
} from "./managed-analytics-api-v1-safety.mjs";
|
|
3
6
|
import { fnv1a128Hex } from "./digest.js";
|
|
4
7
|
|
|
5
8
|
const MANAGED_ANALYTICS_PII_VALUE = new RegExp(MANAGED_ANALYTICS_PII_VALUE_PATTERN);
|
|
9
|
+
const MANAGED_ANALYTICS_SECRET_VALUE = new RegExp(MANAGED_ANALYTICS_SECRET_VALUE_PATTERN);
|
|
6
10
|
const MANAGED_ANALYTICS_PROTECTED_VALUE =
|
|
7
11
|
/(?:@|\d{7,}|(?:^|[./_~-])(?:bearer|password|secret|token|authorization|preview)(?:$|[./_~-]))/i;
|
|
8
12
|
const OPAQUE_HEX_ALPHABET = "abcdefghijklmnop";
|
|
9
13
|
|
|
14
|
+
/** The five standard UTM fields, without their `utm_` prefix. */
|
|
15
|
+
export const MANAGED_ANALYTICS_CAMPAIGN_FIELDS = ["source", "medium", "campaign", "term", "content"] as const;
|
|
16
|
+
export type ManagedAnalyticsCampaignField = (typeof MANAGED_ANALYTICS_CAMPAIGN_FIELDS)[number];
|
|
17
|
+
export type ManagedAnalyticsCampaign = Partial<Record<ManagedAnalyticsCampaignField, string>>;
|
|
18
|
+
|
|
19
|
+
/** A dropped campaign field. It names the field and the reason, never the value. */
|
|
20
|
+
export interface ManagedAnalyticsCampaignRejection {
|
|
21
|
+
field: ManagedAnalyticsCampaignField;
|
|
22
|
+
reason: "format" | "protected_value";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ManagedAnalyticsCampaignScreen {
|
|
26
|
+
kept: ManagedAnalyticsCampaign;
|
|
27
|
+
rejected: ManagedAnalyticsCampaignRejection[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const CAMPAIGN_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/;
|
|
31
|
+
/** Credential words as a whole token segment. `token` and `preview` are ordinary campaign words. */
|
|
32
|
+
const CAMPAIGN_CREDENTIAL_WORD = /(?:^|[._~-])(?:bearer|password|secret|authorization|credential)(?:$|[._~-])/i;
|
|
33
|
+
/** A labelled token may carry a 7-8 digit stamp (`_20261027`); a longer run could be a dialable number. */
|
|
34
|
+
const CAMPAIGN_LONG_DIGIT_RUN = /\d{9,}/;
|
|
35
|
+
/**
|
|
36
|
+
* A bare number is never a campaign token. A Google Ads ValueTrack `{campaignid}` has the
|
|
37
|
+
* same shape as an international phone number written without `+`, and neither `utm_source`
|
|
38
|
+
* nor length separates them, so it is rejected; GA4 still attributes Ads traffic by click id.
|
|
39
|
+
*/
|
|
40
|
+
const CAMPAIGN_BARE_DIGITS = /^\d{7,}$/;
|
|
41
|
+
|
|
42
|
+
function campaignTokenRejection(value: unknown): ManagedAnalyticsCampaignRejection["reason"] | undefined {
|
|
43
|
+
if (typeof value !== "string" || !CAMPAIGN_TOKEN.test(value)) return "format";
|
|
44
|
+
if (
|
|
45
|
+
resemblesManagedAnalyticsPii(value) || MANAGED_ANALYTICS_SECRET_VALUE.test(value) ||
|
|
46
|
+
CAMPAIGN_CREDENTIAL_WORD.test(value)
|
|
47
|
+
) return "protected_value";
|
|
48
|
+
return CAMPAIGN_BARE_DIGITS.test(value) || CAMPAIGN_LONG_DIGIT_RUN.test(value) ? "protected_value" : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The one campaign-token policy for every layer that carries UTM values: minimization,
|
|
53
|
+
* event validation, the GA4 parent and sandbox, and Amplitude. Each present field is kept
|
|
54
|
+
* or rejected on its own, so one unsafe value never costs the page view its other fields.
|
|
55
|
+
*/
|
|
56
|
+
export function screenManagedAnalyticsCampaign(utm: Readonly<Record<string, unknown>>): ManagedAnalyticsCampaignScreen {
|
|
57
|
+
const kept: ManagedAnalyticsCampaign = {};
|
|
58
|
+
const rejected: ManagedAnalyticsCampaignRejection[] = [];
|
|
59
|
+
for (const field of MANAGED_ANALYTICS_CAMPAIGN_FIELDS) {
|
|
60
|
+
if (!Object.hasOwn(utm, field)) continue;
|
|
61
|
+
const value = utm[field];
|
|
62
|
+
const reason = campaignTokenRejection(value);
|
|
63
|
+
if (reason) rejected.push({ field, reason });
|
|
64
|
+
else kept[field] = value as string;
|
|
65
|
+
}
|
|
66
|
+
return { kept, rejected };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The Google Ads click identifiers a landing URL may carry to GA4. */
|
|
70
|
+
export const GOOGLE_ADS_CLICK_ID_KEYS = ["gclid", "gbraid", "wbraid"] as const;
|
|
71
|
+
export type GoogleAdsClickIdKey = (typeof GOOGLE_ADS_CLICK_ID_KEYS)[number];
|
|
72
|
+
export type GoogleAdsClickIds = Partial<Record<GoogleAdsClickIdKey, string>>;
|
|
73
|
+
|
|
74
|
+
/** Auto-tagging writes URL-safe base64; the length bound only caps hostile input. */
|
|
75
|
+
const GOOGLE_ADS_CLICK_ID = /^[A-Za-z0-9_-]{1,512}$/;
|
|
76
|
+
|
|
77
|
+
/** A click identifier safe to place on a GA4 `page_location`: URL-safe, bounded, and not PII- or secret-shaped. */
|
|
78
|
+
export function isGoogleAdsClickIdValue(value: unknown): value is string {
|
|
79
|
+
return typeof value === "string" && GOOGLE_ADS_CLICK_ID.test(value) &&
|
|
80
|
+
!resemblesManagedAnalyticsPii(value) && !MANAGED_ANALYTICS_SECRET_VALUE.test(value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A non-empty record of known click-identifier keys, every value valid. */
|
|
84
|
+
export function isGoogleAdsClickIds(value: unknown): value is GoogleAdsClickIds {
|
|
85
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
86
|
+
const proto = Object.getPrototypeOf(value);
|
|
87
|
+
if (proto !== Object.prototype && proto !== null) return false;
|
|
88
|
+
const entries = Object.entries(value);
|
|
89
|
+
return entries.length > 0 && entries.every(([key, id]) =>
|
|
90
|
+
(GOOGLE_ADS_CLICK_ID_KEYS as readonly string[]).includes(key) && isGoogleAdsClickIdValue(id));
|
|
91
|
+
}
|
|
92
|
+
|
|
10
93
|
/** True when a public string resembles an email, SSN, payment card, or phone number. */
|
|
11
94
|
export function resemblesManagedAnalyticsPii(value: unknown): boolean {
|
|
12
95
|
return typeof value === "string" && MANAGED_ANALYTICS_PII_VALUE.test(value);
|
|
@@ -16,6 +16,13 @@ import {
|
|
|
16
16
|
type ContractResult,
|
|
17
17
|
} from "./result.js";
|
|
18
18
|
import { findForbiddenFields } from "./secrets.js";
|
|
19
|
+
import {
|
|
20
|
+
GOOGLE_ADS_CLICK_ID_KEYS,
|
|
21
|
+
isGoogleAdsClickIdValue,
|
|
22
|
+
MANAGED_ANALYTICS_CAMPAIGN_FIELDS,
|
|
23
|
+
screenManagedAnalyticsCampaign,
|
|
24
|
+
type GoogleAdsClickIds,
|
|
25
|
+
} from "./managed-analytics-value-safety.js";
|
|
19
26
|
import type { CommerceEmitResult, CommerceEventBroker } from "./commerce-broker.js";
|
|
20
27
|
import {
|
|
21
28
|
APPROVED_MARKETING_ACTION_NAMES,
|
|
@@ -125,27 +132,11 @@ export interface AttributionSourceInput {
|
|
|
125
132
|
routeUrl?: string | null;
|
|
126
133
|
}
|
|
127
134
|
|
|
128
|
-
const UTM_QUERY_FIELDS = [
|
|
129
|
-
["utm_source", "source"],
|
|
130
|
-
["utm_medium", "medium"],
|
|
131
|
-
["utm_campaign", "campaign"],
|
|
132
|
-
["utm_term", "term"],
|
|
133
|
-
["utm_content", "content"],
|
|
134
|
-
] as const;
|
|
135
|
-
const SAFE_CAMPAIGN_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/;
|
|
136
|
-
const PROTECTED_CAMPAIGN_TOKEN =
|
|
137
|
-
/(?:@|\d{7,}|(?:^|[._~-])(?:bearer|password|secret|token|authorization|preview)(?:$|[._~-]))/i;
|
|
138
|
-
|
|
139
|
-
function safeCampaignToken(value: string): string | undefined {
|
|
140
|
-
return SAFE_CAMPAIGN_TOKEN.test(value) && !PROTECTED_CAMPAIGN_TOKEN.test(value)
|
|
141
|
-
? value
|
|
142
|
-
: undefined;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
135
|
/**
|
|
146
136
|
* Reduce explicit URL inputs to an origin-only referrer and the five standard UTM
|
|
147
|
-
* tokens. Query keys outside the UTM allowlist, duplicate keys,
|
|
148
|
-
*
|
|
137
|
+
* tokens. Query keys outside the UTM allowlist, duplicate keys, values the shared
|
|
138
|
+
* campaign policy rejects (`screenManagedAnalyticsCampaign`), credentials, paths, and
|
|
139
|
+
* fragments are discarded. No browser global is read.
|
|
149
140
|
*/
|
|
150
141
|
export function minimizePageAttribution(input: AttributionSourceInput): ManagedPageAttribution | undefined {
|
|
151
142
|
const result: ManagedPageAttribution = {};
|
|
@@ -168,14 +159,13 @@ export function minimizePageAttribution(input: AttributionSourceInput): ManagedP
|
|
|
168
159
|
if (input.routeUrl) {
|
|
169
160
|
try {
|
|
170
161
|
const route = new URL(input.routeUrl, "https://route.invalid");
|
|
171
|
-
const
|
|
172
|
-
for (const
|
|
173
|
-
const values = route.searchParams.getAll(
|
|
174
|
-
if (values.length
|
|
175
|
-
const value = safeCampaignToken(values[0] ?? "");
|
|
176
|
-
if (value !== undefined) utm[field] = value;
|
|
162
|
+
const query: Record<string, string> = {};
|
|
163
|
+
for (const field of MANAGED_ANALYTICS_CAMPAIGN_FIELDS) {
|
|
164
|
+
const values = route.searchParams.getAll(`utm_${field}`);
|
|
165
|
+
if (values.length === 1) query[field] = values[0]!;
|
|
177
166
|
}
|
|
178
|
-
|
|
167
|
+
const { kept } = screenManagedAnalyticsCampaign(query);
|
|
168
|
+
if (Object.keys(kept).length > 0) result.utm = kept;
|
|
179
169
|
} catch {
|
|
180
170
|
// Invalid route input contributes no attribution.
|
|
181
171
|
}
|
|
@@ -184,6 +174,29 @@ export function minimizePageAttribution(input: AttributionSourceInput): ManagedP
|
|
|
184
174
|
return result.referrer || result.utm ? result : undefined;
|
|
185
175
|
}
|
|
186
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Read Google Ads click identifiers (`gclid`, `gbraid`, `wbraid`) from a landing URL.
|
|
179
|
+
* Each key counts only when it appears exactly once with a valid value; anything else
|
|
180
|
+
* is dropped silently, so no rejected value reaches a diagnostic. These never enter
|
|
181
|
+
* the analytics event contract: only the GA4 host capability carries them.
|
|
182
|
+
*/
|
|
183
|
+
export function readGoogleAdsClickIds(routeUrl: string | null | undefined): GoogleAdsClickIds | undefined {
|
|
184
|
+
if (!routeUrl) return undefined;
|
|
185
|
+
let route: URL;
|
|
186
|
+
try {
|
|
187
|
+
route = new URL(routeUrl, "https://route.invalid");
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (error instanceof TypeError) return undefined;
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
const ids: GoogleAdsClickIds = {};
|
|
193
|
+
for (const key of GOOGLE_ADS_CLICK_ID_KEYS) {
|
|
194
|
+
const values = route.searchParams.getAll(key);
|
|
195
|
+
if (values.length === 1 && isGoogleAdsClickIdValue(values[0])) ids[key] = values[0];
|
|
196
|
+
}
|
|
197
|
+
return Object.keys(ids).length > 0 ? ids : undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
187
200
|
export type ManagedAnalyticsEmitResult =
|
|
188
201
|
| CommerceEmitResult
|
|
189
202
|
| {
|
|
@@ -41,7 +41,11 @@ import {
|
|
|
41
41
|
type BrowserEnvironment,
|
|
42
42
|
type IntegrationEnvironment,
|
|
43
43
|
} from "./environment.js";
|
|
44
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
resemblesManagedAnalyticsPii,
|
|
46
|
+
screenManagedAnalyticsCampaign,
|
|
47
|
+
type ManagedAnalyticsCampaignField,
|
|
48
|
+
} from "./managed-analytics-value-safety.js";
|
|
45
49
|
import {
|
|
46
50
|
buildGovernedEventProperties,
|
|
47
51
|
type GovernedEventProperties,
|
|
@@ -342,10 +346,10 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
|
342
346
|
/**
|
|
343
347
|
* Flatten the already-validated `data.attribution` object (`events.ts`
|
|
344
348
|
* `validatePageAttribution` — a minimized referrer origin plus up to five UTM
|
|
345
|
-
* tokens) into Amplitude's flat `event_properties`.
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
* than failing the whole page
|
|
349
|
+
* tokens) into Amplitude's flat `event_properties`. UTM values are re-screened with
|
|
350
|
+
* the same campaign policy GA4 applies (`screenManagedAnalyticsCampaign`) rather than
|
|
351
|
+
* trusted from upstream validation alone, so both destinations carry the same fields;
|
|
352
|
+
* a rejected field is dropped individually rather than failing the whole page view.
|
|
349
353
|
*/
|
|
350
354
|
function extractAttributionProperties(data: Record<string, unknown>): AmplitudeAttributionProperties {
|
|
351
355
|
const attribution = data["attribution"];
|
|
@@ -354,11 +358,8 @@ function extractAttributionProperties(data: Record<string, unknown>): AmplitudeA
|
|
|
354
358
|
|
|
355
359
|
const utm = attribution["utm"];
|
|
356
360
|
if (isPlainObject(utm)) {
|
|
357
|
-
for (const key
|
|
358
|
-
|
|
359
|
-
if (typeof value === "string" && value.length > 0 && !resemblesManagedAnalyticsPii(value)) {
|
|
360
|
-
out[`utm_${key}`] = value;
|
|
361
|
-
}
|
|
361
|
+
for (const [key, value] of Object.entries(screenManagedAnalyticsCampaign(utm).kept)) {
|
|
362
|
+
out[`utm_${key as ManagedAnalyticsCampaignField}`] = value;
|
|
362
363
|
}
|
|
363
364
|
}
|
|
364
365
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export const NATIVE_GA4_SANDBOX_ASSET_PATH: "platform/google-measurement/v2/sandbox.html";
|
|
2
2
|
export const NATIVE_GA4_SANDBOX_ASSET_URL: "/platform/google-measurement/v2/sandbox.html";
|
|
3
3
|
export const NATIVE_GA4_SANDBOX_ORIGIN: "https://analytics-frame.tokenoftrust.store";
|
|
4
|
+
export const NATIVE_GA4_SANDBOX_FRAME_STYLE: Readonly<Record<string, string>>;
|
|
4
5
|
export function resolveNativeGa4SandboxUrl(parentHref: string): string;
|
|
@@ -3,6 +3,26 @@ export const NATIVE_GA4_SANDBOX_ASSET_PATH = "platform/google-measurement/v2/san
|
|
|
3
3
|
export const NATIVE_GA4_SANDBOX_ASSET_URL = `/${NATIVE_GA4_SANDBOX_ASSET_PATH}`;
|
|
4
4
|
export const NATIVE_GA4_SANDBOX_ORIGIN = "https://analytics-frame.tokenoftrust.store";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* The frame's box: laid out, but invisible and inert. gtag's enhanced measurement
|
|
8
|
+
* reports a 90% `scroll` as soon as it loads in a frame with no layout (`display:none`
|
|
9
|
+
* or a 0x0 box), because the frame document's height guard then has no viewport to
|
|
10
|
+
* measure against. A 1px box gives it one (measured 2026-10-28 with the live gtag.js,
|
|
11
|
+
* collection null-routed: hidden and 0x0 frames each sent `scroll` at 90%, the 1px box
|
|
12
|
+
* sent only the page views). The parent sets it through the CSSOM, which its CSP does
|
|
13
|
+
* not govern.
|
|
14
|
+
*/
|
|
15
|
+
export const NATIVE_GA4_SANDBOX_FRAME_STYLE = Object.freeze({
|
|
16
|
+
position: "fixed",
|
|
17
|
+
top: "0",
|
|
18
|
+
left: "0",
|
|
19
|
+
width: "1px",
|
|
20
|
+
height: "1px",
|
|
21
|
+
border: "0",
|
|
22
|
+
opacity: "0",
|
|
23
|
+
"pointer-events": "none",
|
|
24
|
+
});
|
|
25
|
+
|
|
6
26
|
/**
|
|
7
27
|
* Keep Google tag execution on a real, isolated origin. An opaque sandbox origin makes
|
|
8
28
|
* gtag.js load successfully but silently suppresses every collection request. Production
|
|
@@ -5,10 +5,15 @@ import {
|
|
|
5
5
|
NATIVE_GA4_SANDBOX_PROTOCOL,
|
|
6
6
|
type NativeGa4SandboxPortMessage,
|
|
7
7
|
} from "./native-ga4-sandbox.js";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
isGoogleAdsClickIds,
|
|
10
|
+
MANAGED_ANALYTICS_CAMPAIGN_FIELDS,
|
|
11
|
+
resemblesManagedAnalyticsProtectedValue,
|
|
12
|
+
screenManagedAnalyticsCampaign,
|
|
13
|
+
type GoogleAdsClickIds,
|
|
14
|
+
} from "./managed-analytics-value-safety.js";
|
|
9
15
|
|
|
10
16
|
const MEASUREMENT_ID = /^G-[A-Z0-9]{4,20}$/;
|
|
11
|
-
const CAMPAIGN_VALUE = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/;
|
|
12
17
|
const GOVERNED_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
13
18
|
const CLIENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
14
19
|
const SESSION_STATES: ReadonlySet<unknown> = new Set(["first_visit", "new_session", "continuing"]);
|
|
@@ -155,11 +160,12 @@ function seedSessionCookies(jar: Map<string, string>, ids: readonly string[], vi
|
|
|
155
160
|
}
|
|
156
161
|
|
|
157
162
|
/**
|
|
158
|
-
* gtag's enhanced measurement runs inside this frame and reports
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
* Setting height through the CSSOM is outside the frame's style CSP,
|
|
163
|
+
* gtag's enhanced measurement runs inside this frame and reports a `scroll` at 90% depth
|
|
164
|
+
* once the frame document fits its viewport. A document taller than any viewport never
|
|
165
|
+
* reaches that depth, so the fabricated scroll is never sent. The guard needs the frame
|
|
166
|
+
* to be laid out (`NATIVE_GA4_SANDBOX_FRAME_STYLE`); a frame with no layout reports the
|
|
167
|
+
* scroll regardless. Setting height through the CSSOM is outside the frame's style CSP,
|
|
168
|
+
* which governs markup.
|
|
163
169
|
*/
|
|
164
170
|
function suppressFrameScroll(doc: Document): void {
|
|
165
171
|
const root = doc.documentElement as HTMLElement | null | undefined;
|
|
@@ -168,9 +174,8 @@ function suppressFrameScroll(doc: Document): void {
|
|
|
168
174
|
|
|
169
175
|
function validCampaign(value: unknown): value is Record<string, string> {
|
|
170
176
|
const record = plainRecord(value);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
typeof item === "string" && CAMPAIGN_VALUE.test(item) && !resemblesManagedAnalyticsProtectedValue(item));
|
|
177
|
+
return record !== null && exactKeys(record, MANAGED_ANALYTICS_CAMPAIGN_FIELDS, []) &&
|
|
178
|
+
screenManagedAnalyticsCampaign(record).rejected.length === 0;
|
|
174
179
|
}
|
|
175
180
|
|
|
176
181
|
export interface NativeGa4SandboxRuntimeWindow extends Pick<Window, "parent" | "location"> {
|
|
@@ -281,25 +286,31 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
|
|
|
281
286
|
const deliver = (message: Record<string, unknown>): void => {
|
|
282
287
|
if (!active || disposed || message["schemaVersion"] !== 1) return;
|
|
283
288
|
if (message["type"] === "page_view") {
|
|
284
|
-
if (!exactKeys(message, ["schemaVersion", "type", "path", "referrerOrigin", "campaign"], ["schemaVersion", "type", "path"])) return;
|
|
289
|
+
if (!exactKeys(message, ["schemaVersion", "type", "path", "referrerOrigin", "campaign", "clickIds"], ["schemaVersion", "type", "path"])) return;
|
|
285
290
|
if (!validPath(message["path"])) return;
|
|
286
291
|
if (message["referrerOrigin"] !== undefined && !validOrigin(message["referrerOrigin"])) return;
|
|
287
292
|
if (message["campaign"] !== undefined && !validCampaign(message["campaign"])) return;
|
|
293
|
+
if (message["clickIds"] !== undefined && !isGoogleAdsClickIds(message["clickIds"])) return;
|
|
288
294
|
const pageLocation = `${canonicalOrigin}${message["path"]}`;
|
|
289
295
|
lastPageLocation = pageLocation;
|
|
290
296
|
// Events gtag collects on its own (engagement, enhanced measurement) take the page
|
|
291
297
|
// from these defaults, not from the frame. The title crosses the boundary as the path:
|
|
292
298
|
// the frame's own title would label every page "Analytics transport".
|
|
293
299
|
gtag("set", { page_location: pageLocation, page_title: message["path"] });
|
|
300
|
+
// Click identifiers ride only this event's page_location, where GA4 reads ad-click
|
|
301
|
+
// information; the page default and the action anchor stay query-free.
|
|
302
|
+
const clickIds = message["clickIds"] as GoogleAdsClickIds | undefined;
|
|
294
303
|
const params: Record<string, unknown> = {
|
|
295
304
|
...governedProperties,
|
|
296
|
-
page_location:
|
|
305
|
+
page_location: clickIds
|
|
306
|
+
? `${pageLocation}?${new URLSearchParams(clickIds as Record<string, string>).toString()}`
|
|
307
|
+
: pageLocation,
|
|
297
308
|
page_path: message["path"],
|
|
298
309
|
};
|
|
299
310
|
if (message["referrerOrigin"] !== undefined) params["page_referrer"] = message["referrerOrigin"];
|
|
300
311
|
if (message["campaign"] !== undefined) {
|
|
301
312
|
const campaign = message["campaign"] as Record<string, string>;
|
|
302
|
-
for (const key of
|
|
313
|
+
for (const key of MANAGED_ANALYTICS_CAMPAIGN_FIELDS) {
|
|
303
314
|
const ga4Key = key === "campaign" ? "name" : key;
|
|
304
315
|
if (campaign[key] !== undefined) params[`campaign_${ga4Key}`] = campaign[key];
|
|
305
316
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Closed parent/iframe protocol for the reviewed GA4 isolated-origin sandbox. */
|
|
2
2
|
import type { GovernedEventProperties } from "./governed-event-properties.js";
|
|
3
|
-
import {
|
|
3
|
+
import type { GoogleAdsClickIds, ManagedAnalyticsCampaign } from "./managed-analytics-value-safety.js";
|
|
4
|
+
import { NATIVE_GA4_SANDBOX_FRAME_STYLE, resolveNativeGa4SandboxUrl } from "./native-ga4-sandbox-asset.mjs";
|
|
4
5
|
|
|
5
6
|
export const NATIVE_GA4_SANDBOX_PROTOCOL = "tot.ga4-sandbox/1" as const;
|
|
6
7
|
export const NATIVE_GA4_SANDBOX_MAX_DESTINATIONS = 4 as const;
|
|
@@ -40,13 +41,9 @@ export interface NativeGa4PageViewCommand {
|
|
|
40
41
|
type: "page_view";
|
|
41
42
|
path: string;
|
|
42
43
|
referrerOrigin?: string;
|
|
43
|
-
campaign?:
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
campaign?: string;
|
|
47
|
-
term?: string;
|
|
48
|
-
content?: string;
|
|
49
|
-
};
|
|
44
|
+
campaign?: ManagedAnalyticsCampaign;
|
|
45
|
+
/** Landing-page Google Ads click identifiers; the frame adds them to this event's `page_location` only. */
|
|
46
|
+
clickIds?: GoogleAdsClickIds;
|
|
50
47
|
}
|
|
51
48
|
|
|
52
49
|
export interface NativeGa4ActionCommand {
|
|
@@ -75,7 +72,7 @@ export interface NativeGa4SandboxFrame {
|
|
|
75
72
|
addEventListener(type: "load" | "error", listener: () => void, options?: { once?: boolean }): void;
|
|
76
73
|
remove(): void;
|
|
77
74
|
src: string;
|
|
78
|
-
|
|
75
|
+
style: { setProperty(name: string, value: string): void };
|
|
79
76
|
}
|
|
80
77
|
|
|
81
78
|
export interface NativeGa4SandboxHost {
|
|
@@ -122,7 +119,7 @@ const IMMEDIATE_SCHEDULER: NativeGa4SandboxScheduler = (start) => {
|
|
|
122
119
|
};
|
|
123
120
|
|
|
124
121
|
/**
|
|
125
|
-
* Mount one
|
|
122
|
+
* Mount one invisible same-site, cross-origin iframe and transfer exactly one MessagePort
|
|
126
123
|
* after load. gtag suppresses collection in an opaque third-party frame, so the receiver
|
|
127
124
|
* needs a real same-site origin. The exact origin, iframe window, and one-shot transfer
|
|
128
125
|
* are the authority boundary. No general parent `message` listener is installed.
|
|
@@ -170,7 +167,7 @@ export function createNativeGa4SandboxTransport(
|
|
|
170
167
|
mounted.setAttribute("aria-hidden", "true");
|
|
171
168
|
mounted.setAttribute("tabindex", "-1");
|
|
172
169
|
mounted.setAttribute("title", "Analytics transport");
|
|
173
|
-
mounted.
|
|
170
|
+
for (const [name, value] of Object.entries(NATIVE_GA4_SANDBOX_FRAME_STYLE)) mounted.style.setProperty(name, value);
|
|
174
171
|
mounted.src = sandboxUrl;
|
|
175
172
|
|
|
176
173
|
mounted.addEventListener("load", () => {
|
|
@@ -24,7 +24,12 @@ import {
|
|
|
24
24
|
resolveAdapterDestinationIds,
|
|
25
25
|
} from "./platform-adapters.js";
|
|
26
26
|
import { INTEGRATION_ENVIRONMENTS, type IntegrationEnvironment } from "./environment.js";
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
resemblesManagedAnalyticsProtectedValue,
|
|
29
|
+
screenManagedAnalyticsCampaign,
|
|
30
|
+
isGoogleAdsClickIds,
|
|
31
|
+
type GoogleAdsClickIds,
|
|
32
|
+
} from "./managed-analytics-value-safety.js";
|
|
28
33
|
import {
|
|
29
34
|
createVisitorContinuity,
|
|
30
35
|
NO_SESSION_ID,
|
|
@@ -88,6 +93,11 @@ export interface NativeGa4ModuleOptions {
|
|
|
88
93
|
continuity?: VisitorContinuity;
|
|
89
94
|
/** Epoch-millisecond clock for session activity; defaults to the wall clock. */
|
|
90
95
|
now?: () => number;
|
|
96
|
+
/**
|
|
97
|
+
* The host's one-shot landing click identifiers for a page view at `path`. The host
|
|
98
|
+
* owns the consent and region decision and returns them at most once per document.
|
|
99
|
+
*/
|
|
100
|
+
takeLandingClickIds?: (path: string) => GoogleAdsClickIds | undefined;
|
|
91
101
|
}
|
|
92
102
|
|
|
93
103
|
interface ActiveGa4Config {
|
|
@@ -220,12 +230,8 @@ function mapEvent(
|
|
|
220
230
|
}
|
|
221
231
|
const utm = record["utm"];
|
|
222
232
|
if (typeof utm === "object" && utm !== null && !Array.isArray(utm)) {
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
const value = (utm as Record<string, unknown>)[key];
|
|
226
|
-
if (typeof value === "string" && !resemblesManagedAnalyticsProtectedValue(value)) campaign[key] = value;
|
|
227
|
-
}
|
|
228
|
-
if (Object.keys(campaign).length > 0) command.campaign = campaign;
|
|
233
|
+
const { kept } = screenManagedAnalyticsCampaign(utm as Record<string, unknown>);
|
|
234
|
+
if (Object.keys(kept).length > 0) command.campaign = kept;
|
|
229
235
|
}
|
|
230
236
|
}
|
|
231
237
|
return Object.freeze(command);
|
|
@@ -332,8 +338,15 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
|
|
|
332
338
|
const oldest = deliveredOrder.shift();
|
|
333
339
|
if (oldest !== undefined) delivered.delete(oldest);
|
|
334
340
|
}
|
|
341
|
+
let outgoing = command;
|
|
342
|
+
if (command.type === "page_view" && options.takeLandingClickIds) {
|
|
343
|
+
try {
|
|
344
|
+
const clickIds = options.takeLandingClickIds(command.path);
|
|
345
|
+
if (isGoogleAdsClickIds(clickIds)) outgoing = Object.freeze({ ...command, clickIds: { ...clickIds } });
|
|
346
|
+
} catch { /* a page view never waits on attribution */ }
|
|
347
|
+
}
|
|
335
348
|
try {
|
|
336
|
-
const result = send(
|
|
349
|
+
const result = send(outgoing);
|
|
337
350
|
if (result && typeof result.then === "function") {
|
|
338
351
|
void result.catch(() => observeSafely(options.observe, "transport_failed"));
|
|
339
352
|
}
|
|
@@ -83,11 +83,13 @@ export interface NormalizedContribution {
|
|
|
83
83
|
/**
|
|
84
84
|
* The tenant's analytics policy for visitors in the EU/EEA, the UK and Switzerland, and
|
|
85
85
|
* for visitors whose region is unknown:
|
|
86
|
-
* - `
|
|
86
|
+
* - `ask` (default): page views are counted without storing anything, and the visitor is
|
|
87
|
+
* prompted; an Allow applies the default behavior for that visitor.
|
|
88
|
+
* - `count-only`: page views are delivered, nothing is stored on the device, no prompt.
|
|
87
89
|
* - `full`: the tenant takes responsibility for EU/UK consent; the default behavior applies.
|
|
88
90
|
* - `none`: no analytics or marketing destination runs for those visitors.
|
|
89
91
|
*/
|
|
90
|
-
export const EU_UK_VISITOR_POLICIES = ["count-only", "full", "none"] as const;
|
|
92
|
+
export const EU_UK_VISITOR_POLICIES = ["ask", "count-only", "full", "none"] as const;
|
|
91
93
|
export type EuUkVisitorPolicy = (typeof EU_UK_VISITOR_POLICIES)[number];
|
|
92
94
|
|
|
93
95
|
export function isEuUkVisitorPolicy(value: unknown): value is EuUkVisitorPolicy {
|
|
@@ -102,9 +104,9 @@ export interface EffectivePlan {
|
|
|
102
104
|
tenantPublicId: string;
|
|
103
105
|
/**
|
|
104
106
|
* The tenant's {@link EuUkVisitorPolicy}, present only when it is not the
|
|
105
|
-
* `
|
|
107
|
+
* `ask` default. It participates in `planRevision`.
|
|
106
108
|
*/
|
|
107
|
-
euUkVisitors?: Exclude<EuUkVisitorPolicy, "
|
|
109
|
+
euUkVisitors?: Exclude<EuUkVisitorPolicy, "ask">;
|
|
108
110
|
integrations: PlanIntegration[];
|
|
109
111
|
contributions: NormalizedContribution[];
|
|
110
112
|
/** Resolved execution order over contribution ids. */
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
NATIVE_GA4_RUNTIME_ID,
|
|
25
25
|
type NativeGa4Transport,
|
|
26
26
|
} from "./native-ga4.js";
|
|
27
|
+
import type { GoogleAdsClickIds } from "./managed-analytics-value-safety.js";
|
|
27
28
|
import { AMPLITUDE_HTTP_ADAPTER, GOOGLE_MEASUREMENT_ADAPTER } from "./platform-adapters.js";
|
|
28
29
|
|
|
29
30
|
export const PLATFORM_ADAPTER_ENTRY_SCHEMA_VERSION = 1 as const;
|
|
@@ -43,6 +44,8 @@ export interface PlatformAdapterRuntimeHost {
|
|
|
43
44
|
nativeGa4Transport?: NativeGa4Transport;
|
|
44
45
|
/** The host's persistent visitor store, shared by GA4 and Amplitude; absent keeps visitors memory-only. */
|
|
45
46
|
visitorContinuity?: VisitorContinuity;
|
|
47
|
+
/** GA4 only: the host's consent- and region-gated one-shot landing click identifiers. */
|
|
48
|
+
takeLandingClickIds?: (path: string) => GoogleAdsClickIds | undefined;
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
export interface PlatformAdapterRuntimeRegistration {
|
|
@@ -74,9 +77,9 @@ const AMPLITUDE_MARKER: PlatformAdapterEntryMarker = Object.freeze({
|
|
|
74
77
|
export const PLATFORM_ADAPTER_RUNTIME_REGISTRY: readonly PlatformAdapterRuntimeRegistration[] = Object.freeze([
|
|
75
78
|
Object.freeze({
|
|
76
79
|
marker: GA4_MARKER,
|
|
77
|
-
create: ({ nativeGa4Transport, visitorContinuity }: PlatformAdapterRuntimeHost) => {
|
|
80
|
+
create: ({ nativeGa4Transport, visitorContinuity, takeLandingClickIds }: PlatformAdapterRuntimeHost) => {
|
|
78
81
|
if (!nativeGa4Transport) throw new Error("platform_adapter_ga4_sandbox_unavailable");
|
|
79
|
-
return createNativeGa4Module({ transport: nativeGa4Transport, continuity: visitorContinuity });
|
|
82
|
+
return createNativeGa4Module({ transport: nativeGa4Transport, continuity: visitorContinuity, takeLandingClickIds });
|
|
80
83
|
},
|
|
81
84
|
}),
|
|
82
85
|
Object.freeze({
|
|
@@ -92,6 +95,7 @@ export interface PlatformAdapterRuntimeLoaderOptions {
|
|
|
92
95
|
fetchCapability?: typeof fetch;
|
|
93
96
|
nativeGa4Transport?: () => NativeGa4Transport;
|
|
94
97
|
visitorContinuity?: VisitorContinuity;
|
|
98
|
+
takeLandingClickIds?: (path: string) => GoogleAdsClickIds | undefined;
|
|
95
99
|
importModule?: (url: string) => Promise<unknown>;
|
|
96
100
|
fallback: IntegrationModuleLoader;
|
|
97
101
|
}
|
|
@@ -156,6 +160,9 @@ export function makePlatformAdapterRuntimeLoader(
|
|
|
156
160
|
: undefined,
|
|
157
161
|
// One store for both destinations, so they agree on visitors and sessions.
|
|
158
162
|
visitorContinuity: options.visitorContinuity,
|
|
163
|
+
takeLandingClickIds: registration.marker.appId === GOOGLE_MEASUREMENT_ADAPTER.adapterId
|
|
164
|
+
? options.takeLandingClickIds
|
|
165
|
+
: undefined,
|
|
159
166
|
});
|
|
160
167
|
};
|
|
161
168
|
}
|