@tokenoftrust/storefront-runner 4.9.2 → 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 (57) hide show
  1. package/agency-kit/tools/lib/analytics-declaration.mjs +299 -0
  2. package/apps/storefront/migrations-apps/0021_contact_vault.sql +162 -0
  3. package/apps/storefront/src/lib/access/auth/contactReaderAuthority.ts +64 -0
  4. package/apps/storefront/src/lib/access/shared/cspStaticHashes.ts +1 -1
  5. package/apps/storefront/src/lib/access/shared/viewerGrant.ts +30 -0
  6. package/apps/storefront/src/lib/compliance/shared/visitorRegionCookie.ts +1 -1
  7. package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +80 -0
  8. package/apps/storefront/src/lib/customers/contactVault/envelope.ts +166 -0
  9. package/apps/storefront/src/lib/customers/contactVault/retention.ts +44 -0
  10. package/apps/storefront/src/lib/engagement/contact/contactMessages.ts +242 -65
  11. package/apps/storefront/src/lib/engagement/contact/contract.ts +2 -0
  12. package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +0 -1
  13. package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +3 -78
  14. package/apps/storefront/src/lib/integrations/integration/tenantAnalyticsDeclarations.ts +46 -206
  15. package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +4 -0
  16. package/apps/storefront/src/lib/measurement/admin/analyticsRegionPolicy.ts +151 -0
  17. package/apps/storefront/src/lib/measurement/admin/analyticsRegionsClient.ts +84 -0
  18. package/apps/storefront/src/lib/orders/admin/orderActions.ts +229 -19
  19. package/apps/storefront/src/lib/orders/data/commerceCallerConfig.ts +37 -0
  20. package/apps/storefront/src/lib/orders/data/orderForwardReceiver.ts +10 -3
  21. package/apps/storefront/src/lib/orders/data/orderMapper.ts +9 -0
  22. package/apps/storefront/src/lib/orders/data/sourceAuthorityContract.ts +2 -1
  23. package/apps/storefront/src/lib/orders/vault/contactIntake.ts +61 -0
  24. package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +99 -0
  25. package/apps/storefront/src/lib/orders/vault/keyringEnv.ts +7 -0
  26. package/apps/storefront/src/lib/orders/vault/orderContact.ts +96 -0
  27. package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +369 -0
  28. package/apps/storefront/src/lib/publication/d1/schema-apps.ts +22 -11
  29. package/apps/storefront/src/lib/publication/shared/routedApiParam.ts +3 -0
  30. package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id]/address.ts +26 -23
  31. package/apps/storefront/src/pages/api/contact.ts +16 -4
  32. package/apps/storefront/src/scheduled/contactVaultSweep.ts +80 -0
  33. package/apps/storefront/test/pipeline/publish-kinds.ts +5 -1
  34. package/apps/storefront/test/pipeline-faults/matrix-lib.ts +16 -2
  35. package/package.json +1 -1
  36. package/packages/public-runtime/src/integration/consent-broker.ts +117 -17
  37. package/packages/public-runtime/src/integration/events.ts +15 -19
  38. package/packages/public-runtime/src/integration/index.ts +1 -0
  39. package/packages/public-runtime/src/integration/lifecycle-boot.ts +41 -7
  40. package/packages/public-runtime/src/integration/managed-analytics-value-safety.ts +84 -1
  41. package/packages/public-runtime/src/integration/managed-analytics.ts +39 -26
  42. package/packages/public-runtime/src/integration/native-amplitude.ts +11 -10
  43. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +1 -0
  44. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +20 -0
  45. package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +24 -13
  46. package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +8 -11
  47. package/packages/public-runtime/src/integration/native-ga4.ts +21 -8
  48. package/packages/public-runtime/src/integration/plan.ts +6 -4
  49. package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +9 -2
  50. package/packages/public-runtime/src/integration/render-phases.ts +10 -18
  51. package/packages/public-runtime/src/integration/resolve.ts +3 -3
  52. package/packages/public-runtime/src/integration/tenant-analytics.ts +250 -0
  53. package/packages/public-runtime/src/integration/visitor-region.mjs +1 -1
  54. package/packages/public-runtime/src/tenant.ts +3 -3
  55. package/packages/public-runtime/tests/fixtures/integration/campaign-token-corpus.ts +75 -0
  56. package/scripts/dev/ga4-gs2-canary.mjs +36 -10
  57. package/scripts/tenant/validate.mjs +9 -0
@@ -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
  }
@@ -108,7 +108,7 @@ export interface IntegrationBootDescriptor {
108
108
  siteSurface: EffectivePlan["environment"]["siteSurface"];
109
109
  tenantPublicId: string;
110
110
  /**
111
- * The tenant's EU/UK visitor policy when it is not the `count-only` default. The
111
+ * The tenant's EU/UK visitor policy when it is not the `ask` default. The
112
112
  * early consent boot reads it together with the edge's region class.
113
113
  */
114
114
  euUkVisitors?: EffectivePlan["euUkVisitors"];
@@ -182,7 +182,6 @@ export function renderIntegrationPhases(
182
182
  const bodyDeferred =
183
183
  phaseBoundary("document.deferred") +
184
184
  `<div data-tot-integration-deferred hidden></div>` +
185
- viewerAnalyticsChoicesLink(plan) +
186
185
  lifecycleHostScript();
187
186
 
188
187
  return { head, bodyStart, bodyDeferred };
@@ -240,17 +239,23 @@ function inlineScript(source: string, nonce: string): string {
240
239
 
241
240
  /** Renderer-owned, provider-neutral viewer control: identical in both document mounts. */
242
241
  function viewerAnalyticsConsentStyle(nonce: string): string {
243
- const style = `.tot-analytics-consent{position:fixed;z-index:900;right:1rem;bottom:1rem;display:flex;max-width:min(30rem,calc(100vw - 2rem));align-items:center;gap:.65rem;padding:.7rem .8rem;border:1px solid var(--color-border,#cbd5e1);border-radius:.75rem;background:var(--color-bg,#fff);color:var(--color-text,#111827);box-shadow:0 8px 30px rgba(15,23,42,.18);font:500 .75rem/1.25 system-ui,sans-serif}.tot-analytics-consent__copy{display:grid;gap:.15rem;min-width:8rem}.tot-analytics-consent__detail{color:var(--color-muted,#475569);font-size:.68rem}.tot-analytics-consent__actions{display:flex;gap:.4rem}.tot-analytics-consent button{border:1px solid currentColor;border-radius:999px;background:transparent;color:inherit;padding:.4rem .65rem;font:inherit;cursor:pointer}.tot-analytics-consent button:disabled{cursor:default;opacity:.45}@media(max-width:34rem){.tot-analytics-consent{left:1rem;right:1rem;align-items:flex-start;flex-direction:column}.tot-analytics-consent__actions{width:100%}.tot-analytics-consent button{flex:1}}.tot-analytics-consent[hidden]{display:none}.tot-analytics-choices{display:flex;justify-content:center;padding:.75rem 1rem;background:var(--color-bg,#fff)}.tot-analytics-choices__toggle{border:0;background:none;padding:.25rem;color:var(--color-muted,#475569);font:500 .75rem/1.25 system-ui,sans-serif;text-decoration:underline;cursor:pointer}`;
242
+ const style = `.tot-analytics-consent{position:fixed;z-index:900;right:1rem;bottom:1rem;display:flex;max-width:min(30rem,calc(100vw - 2rem));align-items:center;gap:.65rem;padding:.7rem .8rem;border:1px solid var(--color-border,#cbd5e1);border-radius:.75rem;background:var(--color-bg,#fff);color:var(--color-text,#111827);box-shadow:0 8px 30px rgba(15,23,42,.18);font:500 .75rem/1.25 system-ui,sans-serif}.tot-analytics-consent__copy{display:grid;gap:.15rem;min-width:8rem}.tot-analytics-consent__detail{color:var(--color-muted,#475569);font-size:.68rem}.tot-analytics-consent__actions{display:flex;gap:.4rem}.tot-analytics-consent button{border:1px solid currentColor;border-radius:999px;background:transparent;color:inherit;padding:.4rem .65rem;font:inherit;cursor:pointer}.tot-analytics-consent button:disabled{cursor:default;opacity:.45}@media(max-width:34rem){.tot-analytics-consent{left:1rem;right:1rem;align-items:flex-start;flex-direction:column}.tot-analytics-consent__actions{width:100%}.tot-analytics-consent button{flex:1}}.tot-analytics-consent[hidden]{display:none}`;
244
243
  return `<style${nonce ? ` nonce="${nonce}"` : ""}>${style}</style>`;
245
244
  }
246
245
 
247
- /** The id the persistent "Analytics choices" toggle names in `aria-controls`. */
246
+ /**
247
+ * The id a tenant's Privacy Policy "Analytics choices" button names in `aria-controls`.
248
+ * The engine renders no visible route to the control: the tenant places that button in
249
+ * its own Privacy Policy, and the lifecycle host binds it
250
+ * (`bootViewerAnalyticsConsentControl`). Markup contract:
251
+ * `docs/architecture/analytics-activation.md`.
252
+ */
248
253
  const VIEWER_ANALYTICS_CONSENT_CONTROL_ID = "tot-analytics-consent";
249
254
 
250
255
  function viewerAnalyticsConsentControl(): string {
251
256
  return `<aside id="${VIEWER_ANALYTICS_CONSENT_CONTROL_ID}" class="tot-analytics-consent" data-tot-analytics-consent-control aria-label="Analytics privacy settings" hidden>` +
252
257
  `<span class="tot-analytics-consent__copy"><strong data-tot-analytics-consent-status aria-live="polite">Analytics off</strong>` +
253
- `<span class="tot-analytics-consent__detail">Choose whether analytics may run on this site.</span></span>` +
258
+ `<span class="tot-analytics-consent__detail" data-tot-analytics-consent-detail>Choose whether analytics may run on this site.</span></span>` +
254
259
  `<span class="tot-analytics-consent__actions">` +
255
260
  `<button type="button" data-tot-analytics-consent-decision="granted" aria-pressed="false" disabled>Allow</button>` +
256
261
  `<button type="button" data-tot-analytics-consent-decision="denied" aria-pressed="true" disabled>Decline</button>` +
@@ -258,19 +263,6 @@ function viewerAnalyticsConsentControl(): string {
258
263
  `</span></aside>`;
259
264
  }
260
265
 
261
- /**
262
- * The persistent, platform-owned way to reach the control: a disclosure button placed
263
- * after the tenant's own footer, on every page whose plan asks for analytics consent.
264
- * The lifecycle host binds it (`bootViewerAnalyticsConsentControl`); no inline handler.
265
- */
266
- function viewerAnalyticsChoicesLink(plan: EffectivePlan): string {
267
- if (!plan.consentNeeds.includes("analytics")) return "";
268
- return `<div class="tot-analytics-choices">` +
269
- `<button type="button" class="tot-analytics-choices__toggle" data-tot-analytics-choices-toggle ` +
270
- `aria-controls="${VIEWER_ANALYTICS_CONSENT_CONTROL_ID}" aria-expanded="false">Analytics choices</button>` +
271
- `</div>`;
272
- }
273
-
274
266
  /**
275
267
  * Replace every {@link INTEGRATION_NONCE_PLACEHOLDER} in `text` (an HTML body or a
276
268
  * CSP header value) with one fresh per-response `nonce`. Idempotent on text with no
@@ -68,7 +68,7 @@ export interface ResolvePlanInput {
68
68
  tenantPublicId: string;
69
69
  environment: BrowserEnvironment;
70
70
  declarations: readonly IntegrationDeclarationInput[];
71
- /** The tenant's EU/UK visitor policy; absent means `count-only`. */
71
+ /** The tenant's EU/UK visitor policy; absent means `ask`. */
72
72
  euUkVisitors?: EuUkVisitorPolicy;
73
73
  }
74
74
 
@@ -98,7 +98,7 @@ export function resolveEffectivePlan(
98
98
  }
99
99
 
100
100
  if (input.euUkVisitors !== undefined && !isEuUkVisitorPolicy(input.euUkVisitors)) {
101
- v.add("euUkVisitors", "unknown_enum", 'euUkVisitors must be "count-only", "full", or "none".');
101
+ v.add("euUkVisitors", "unknown_enum", 'euUkVisitors must be "ask", "count-only", "full", or "none".');
102
102
  }
103
103
 
104
104
  if (!Array.isArray(input.declarations)) {
@@ -218,7 +218,7 @@ export function resolveEffectivePlan(
218
218
  schemaVersion: EFFECTIVE_PLAN_SCHEMA_VERSION,
219
219
  environment: envResult.ok ? envResult.value : input.environment,
220
220
  tenantPublicId: input.tenantPublicId,
221
- ...(input.euUkVisitors === "full" || input.euUkVisitors === "none"
221
+ ...(isEuUkVisitorPolicy(input.euUkVisitors) && input.euUkVisitors !== "ask"
222
222
  ? { euUkVisitors: input.euUkVisitors }
223
223
  : {}),
224
224
  integrations,