@tokenoftrust/storefront-runner 4.9.3 → 4.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/agency-kit/tools/lib/analytics-declaration.mjs +299 -0
  2. package/apps/storefront/src/lib/access/shared/cspStaticHashes.ts +1 -1
  3. package/apps/storefront/src/lib/compliance/shared/visitorRegionCookie.ts +1 -1
  4. package/apps/storefront/src/lib/integrations/integration/tenantAnalyticsDeclarations.ts +46 -206
  5. package/apps/storefront/src/lib/measurement/admin/analyticsRegionPolicy.ts +151 -0
  6. package/apps/storefront/src/lib/measurement/admin/analyticsRegionsClient.ts +84 -0
  7. package/apps/storefront/test/pipeline/publish-kinds.ts +5 -1
  8. package/apps/storefront/test/pipeline-faults/matrix-lib.ts +16 -2
  9. package/package.json +1 -1
  10. package/packages/public-runtime/src/integration/consent-broker.ts +117 -17
  11. package/packages/public-runtime/src/integration/events.ts +15 -19
  12. package/packages/public-runtime/src/integration/index.ts +1 -0
  13. package/packages/public-runtime/src/integration/lifecycle-boot.ts +41 -7
  14. package/packages/public-runtime/src/integration/managed-analytics-value-safety.ts +84 -1
  15. package/packages/public-runtime/src/integration/managed-analytics.ts +39 -26
  16. package/packages/public-runtime/src/integration/native-amplitude.ts +11 -10
  17. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +1 -0
  18. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +20 -0
  19. package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +24 -13
  20. package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +8 -11
  21. package/packages/public-runtime/src/integration/native-ga4.ts +21 -8
  22. package/packages/public-runtime/src/integration/plan.ts +6 -4
  23. package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +9 -2
  24. package/packages/public-runtime/src/integration/render-phases.ts +10 -18
  25. package/packages/public-runtime/src/integration/resolve.ts +3 -3
  26. package/packages/public-runtime/src/integration/tenant-analytics.ts +250 -0
  27. package/packages/public-runtime/src/integration/visitor-region.mjs +1 -1
  28. package/packages/public-runtime/src/tenant.ts +3 -3
  29. package/packages/public-runtime/tests/fixtures/integration/campaign-token-corpus.ts +75 -0
  30. package/scripts/dev/ga4-gs2-canary.mjs +36 -10
  31. 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: the edge classed the
353
- * visitor as EU/EEA/UK/CH or could not class them, and the tenant has not taken `full`
354
- * responsibility. A missing state global applies it (fail closed).
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 !state || (state.region !== "other" && state.euUkVisitors !== "full");
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 { MANAGED_ANALYTICS_PII_VALUE_PATTERN } from "./managed-analytics-api-v1-safety.mjs";
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, unsafe free-form
148
- * values, credentials, paths, and fragments are discarded. No browser global is read.
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 utm: NonNullable<ManagedPageAttribution["utm"]> = {};
172
- for (const [queryName, field] of UTM_QUERY_FIELDS) {
173
- const values = route.searchParams.getAll(queryName);
174
- if (values.length !== 1) continue;
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
- if (Object.keys(utm).length > 0) result.utm = utm;
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 { resemblesManagedAnalyticsPii } from "./managed-analytics-value-safety.js";
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`. Re-screened for PII here
346
- * rather than trusted from upstream validation alone, matching `isSafePagePath`'s
347
- * own double-check: a field that resembles PII is dropped individually rather
348
- * than failing the whole page-view event.
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 of ["source", "medium", "campaign", "term", "content"] as const) {
358
- const value = utm[key];
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 { resemblesManagedAnalyticsProtectedValue } from "./managed-analytics-value-safety.js";
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 the frame's own
159
- * document: its URL, its title, and a `scroll` at 90% depth that a short hidden
160
- * document reaches on load (measured 2026-10-06 on a live stream). A frame taller than
161
- * any viewport never reaches that depth, so the fabricated scroll is never sent.
162
- * Setting height through the CSSOM is outside the frame's style CSP, which governs markup.
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
- if (!record || !exactKeys(record, ["source", "medium", "campaign", "term", "content"], [])) return false;
172
- return Object.values(record).every((item) =>
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: pageLocation,
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 ["source", "medium", "campaign", "term", "content"] as const) {
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 { resolveNativeGa4SandboxUrl } from "./native-ga4-sandbox-asset.mjs";
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
- source?: string;
45
- medium?: string;
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
- hidden: boolean;
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 hidden same-site, cross-origin iframe and transfer exactly one MessagePort
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.hidden = true;
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 { resemblesManagedAnalyticsProtectedValue } from "./managed-analytics-value-safety.js";
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 campaign: NonNullable<typeof command.campaign> = {};
224
- for (const key of ["source", "medium", "campaign", "term", "content"] as const) {
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(command);
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
- * - `count-only` (default): page views are delivered, nothing is stored on the device.
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
- * `count-only` default. It participates in `planRevision`.
107
+ * `ask` default. It participates in `planRevision`.
106
108
  */
107
- euUkVisitors?: Exclude<EuUkVisitorPolicy, "count-only">;
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
  }