@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.
- package/agency-kit/tools/lib/analytics-declaration.mjs +299 -0
- package/apps/storefront/migrations-apps/0021_contact_vault.sql +162 -0
- package/apps/storefront/src/lib/access/auth/contactReaderAuthority.ts +64 -0
- package/apps/storefront/src/lib/access/shared/cspStaticHashes.ts +1 -1
- package/apps/storefront/src/lib/access/shared/viewerGrant.ts +30 -0
- package/apps/storefront/src/lib/compliance/shared/visitorRegionCookie.ts +1 -1
- package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +80 -0
- package/apps/storefront/src/lib/customers/contactVault/envelope.ts +166 -0
- package/apps/storefront/src/lib/customers/contactVault/retention.ts +44 -0
- package/apps/storefront/src/lib/engagement/contact/contactMessages.ts +242 -65
- package/apps/storefront/src/lib/engagement/contact/contract.ts +2 -0
- package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +0 -1
- package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +3 -78
- package/apps/storefront/src/lib/integrations/integration/tenantAnalyticsDeclarations.ts +46 -206
- package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +4 -0
- 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/src/lib/orders/admin/orderActions.ts +229 -19
- package/apps/storefront/src/lib/orders/data/commerceCallerConfig.ts +37 -0
- package/apps/storefront/src/lib/orders/data/orderForwardReceiver.ts +10 -3
- package/apps/storefront/src/lib/orders/data/orderMapper.ts +9 -0
- package/apps/storefront/src/lib/orders/data/sourceAuthorityContract.ts +2 -1
- package/apps/storefront/src/lib/orders/vault/contactIntake.ts +61 -0
- package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +99 -0
- package/apps/storefront/src/lib/orders/vault/keyringEnv.ts +7 -0
- package/apps/storefront/src/lib/orders/vault/orderContact.ts +96 -0
- package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +369 -0
- package/apps/storefront/src/lib/publication/d1/schema-apps.ts +22 -11
- package/apps/storefront/src/lib/publication/shared/routedApiParam.ts +3 -0
- package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id]/address.ts +26 -23
- package/apps/storefront/src/pages/api/contact.ts +16 -4
- package/apps/storefront/src/scheduled/contactVaultSweep.ts +80 -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/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
|
@@ -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
|
}
|
|
@@ -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 `
|
|
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}
|
|
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
|
-
/**
|
|
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 `
|
|
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
|
|
221
|
+
...(isEuUkVisitorPolicy(input.euUkVisitors) && input.euUkVisitors !== "ask"
|
|
222
222
|
? { euUkVisitors: input.euUkVisitors }
|
|
223
223
|
: {}),
|
|
224
224
|
integrations,
|