@tokenoftrust/storefront-runner 2.0.25 → 2.0.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/storefront/src/lib/integration/stageDestinationIds.ts +67 -14
- package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +1 -1
- package/package.json +1 -1
- package/packages/public-runtime/schemas/managed-analytics/v1/adapter-definition.schema.json +6 -1
- package/packages/public-runtime/schemas/managed-analytics/v1/create-integration-install-request.schema.json +81 -66
- package/packages/public-runtime/schemas/managed-analytics/v1/create-migration-draft-request.schema.json +81 -66
- package/packages/public-runtime/schemas/managed-analytics/v1/integration-revision.schema.json +81 -66
- package/packages/public-runtime/schemas/managed-analytics/v1/migration-draft.schema.json +81 -66
- package/packages/public-runtime/schemas/managed-analytics/v1/revise-integration-install-request.schema.json +81 -66
- package/packages/public-runtime/schemas/managed-analytics/v1/update-migration-draft-request.schema.json +81 -66
- package/packages/public-runtime/src/customization-preview.ts +30 -18
- package/packages/public-runtime/src/gtm-migration/draft-generator.ts +1 -1
- package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +19 -6
- package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +49 -15
- package/packages/public-runtime/src/integration/native-amplitude.ts +22 -4
- package/packages/public-runtime/src/integration/native-ga4.ts +69 -38
- package/packages/public-runtime/src/integration/platform-adapters.ts +157 -54
- package/packages/public-runtime/tests/fixtures/integration/managed-analytics-v1/fake-adapter-catalog.json +13 -5
|
@@ -6,12 +6,21 @@
|
|
|
6
6
|
* or persist browser identity. A reviewed direct POST sends a closed, anonymous
|
|
7
7
|
* payload to one exact collection origin after the shared broker has applied consent
|
|
8
8
|
* and logical-event dedupe.
|
|
9
|
+
*
|
|
10
|
+
* A stage may configure SEVERAL measurement ids (a primary plus a rollup). GA4 has no
|
|
11
|
+
* multi-property hit — `tid` names exactly one measurement id — so one logical event
|
|
12
|
+
* becomes one POST per id, identical but for `tid`. The fan-out sits below the broker's
|
|
13
|
+
* dedupe, so a logical action is still measured exactly once per property.
|
|
9
14
|
*/
|
|
10
15
|
import type { IntegrationLifecycleContext, IntegrationLifecycleModule, LifecycleUpdateReason } from "./lifecycle.js";
|
|
11
16
|
import type { CommerceEvent, CommerceEventType } from "./events.js";
|
|
12
17
|
import { APPROVED_MARKETING_ACTION_NAMES, validateCommerceEvent } from "./events.js";
|
|
13
18
|
import { isPurposeEligible, parseConsentSnapshot } from "./consent-broker.js";
|
|
14
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
destinationIdConfigKey,
|
|
21
|
+
GOOGLE_MEASUREMENT_ADAPTER,
|
|
22
|
+
resolveAdapterDestinationIds,
|
|
23
|
+
} from "./platform-adapters.js";
|
|
15
24
|
import { INTEGRATION_ENVIRONMENTS, type IntegrationEnvironment } from "./environment.js";
|
|
16
25
|
import { resemblesManagedAnalyticsPii } from "./managed-analytics-value-safety.js";
|
|
17
26
|
|
|
@@ -63,7 +72,8 @@ export interface NativeGa4ModuleOptions {
|
|
|
63
72
|
}
|
|
64
73
|
|
|
65
74
|
interface ActiveGa4Config {
|
|
66
|
-
|
|
75
|
+
/** Every measurement id this stage collects to, in configured order. */
|
|
76
|
+
destinationIds: readonly string[];
|
|
67
77
|
environment: IntegrationEnvironment;
|
|
68
78
|
siteSurface: IntegrationLifecycleContext["environment"]["siteSurface"];
|
|
69
79
|
}
|
|
@@ -84,24 +94,28 @@ function configuredFor(ctx: IntegrationLifecycleContext): ActiveGa4Config | null
|
|
|
84
94
|
if (!CONFIG_KEYS.has(key)) return null;
|
|
85
95
|
}
|
|
86
96
|
|
|
87
|
-
|
|
88
|
-
|
|
97
|
+
// Every stage present must decode to a valid, in-cap set, and no non-live stage may
|
|
98
|
+
// reuse ANY live id — checked across the whole config, not just the active stage, so a
|
|
99
|
+
// misconfigured neighbour resolves the entire integration to nothing rather than
|
|
100
|
+
// collecting from a page whose other stages would leak.
|
|
101
|
+
const liveIds = resolveAdapterDestinationIds(GOOGLE_MEASUREMENT_ADAPTER, ctx.config, "live");
|
|
102
|
+
if (liveIds.length === 0) return null;
|
|
103
|
+
const live = new Set(liveIds);
|
|
89
104
|
for (const environment of INTEGRATION_ENVIRONMENTS) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (
|
|
93
|
-
if (environment !== "live" &&
|
|
105
|
+
if (ctx.config[destinationIdConfigKey(environment)] === undefined) continue;
|
|
106
|
+
const ids = resolveAdapterDestinationIds(GOOGLE_MEASUREMENT_ADAPTER, ctx.config, environment);
|
|
107
|
+
if (ids.length === 0) return null;
|
|
108
|
+
if (environment !== "live" && ids.some((id) => live.has(id))) return null;
|
|
94
109
|
}
|
|
95
110
|
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
}
|
|
111
|
+
const destinationIds = resolveAdapterDestinationIds(
|
|
112
|
+
GOOGLE_MEASUREMENT_ADAPTER,
|
|
113
|
+
ctx.config,
|
|
114
|
+
ctx.environment.environment,
|
|
115
|
+
);
|
|
116
|
+
if (destinationIds.length === 0) return null;
|
|
103
117
|
return {
|
|
104
|
-
|
|
118
|
+
destinationIds,
|
|
105
119
|
environment: ctx.environment.environment,
|
|
106
120
|
siteSurface: ctx.environment.siteSurface,
|
|
107
121
|
};
|
|
@@ -134,28 +148,37 @@ function exactKeys(value: Record<string, unknown>, allowed: readonly string[]):
|
|
|
134
148
|
return actual.length <= allowed.length && actual.every((key) => allowed.includes(key));
|
|
135
149
|
}
|
|
136
150
|
|
|
137
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Build the collect requests one event becomes — ONE per configured measurement id.
|
|
153
|
+
*
|
|
154
|
+
* GA4 has no multi-property hit: `tid` names exactly one measurement id, so reaching a
|
|
155
|
+
* primary and a rollup means repeating the same closed payload with a different `tid`.
|
|
156
|
+
* Returns an empty list when the event does not map, so a caller cannot mistake "not
|
|
157
|
+
* measured" for "measured once".
|
|
158
|
+
*/
|
|
159
|
+
function mapEvent(config: ActiveGa4Config, input: unknown): NativeGa4CollectRequest[] {
|
|
138
160
|
const validated = validateCommerceEvent(input);
|
|
139
|
-
if (!validated.ok) return
|
|
161
|
+
if (!validated.ok) return [];
|
|
140
162
|
const event = validated.value;
|
|
141
|
-
if (!NATIVE_GA4_EVENT_SET.has(event.type)) return
|
|
142
|
-
if (event.storefront.emitter !== "browser") return
|
|
143
|
-
if (event.storefront.environment !== config.environment) return
|
|
144
|
-
if (event.storefront.siteSurface !== config.siteSurface) return
|
|
145
|
-
if (!event.consent.eligiblePurposes.includes("analytics")) return
|
|
146
|
-
|
|
163
|
+
if (!NATIVE_GA4_EVENT_SET.has(event.type)) return [];
|
|
164
|
+
if (event.storefront.emitter !== "browser") return [];
|
|
165
|
+
if (event.storefront.environment !== config.environment) return [];
|
|
166
|
+
if (event.storefront.siteSurface !== config.siteSurface) return [];
|
|
167
|
+
if (!event.consent.eligiblePurposes.includes("analytics")) return [];
|
|
168
|
+
|
|
169
|
+
// `tid` is the one field that differs between the fan-out requests; it is added per
|
|
170
|
+
// destination below.
|
|
147
171
|
const fields: Record<string, string> = {
|
|
148
172
|
v: "2",
|
|
149
|
-
tid: config.destinationId,
|
|
150
173
|
cid: anonymousClientId(event),
|
|
151
174
|
};
|
|
152
175
|
|
|
153
176
|
if (event.type === "storefront.page.viewed.v1") {
|
|
154
|
-
if (!exactKeys(event.data, ["path"]) || typeof event.data["path"] !== "string") return
|
|
177
|
+
if (!exactKeys(event.data, ["path"]) || typeof event.data["path"] !== "string") return [];
|
|
155
178
|
fields["en"] = "page_view";
|
|
156
179
|
fields["ep.page_path"] = event.data["path"];
|
|
157
180
|
} else if (event.type === "storefront.page.viewed.v2") {
|
|
158
|
-
if (!exactKeys(event.data, ["path", "attribution"]) || typeof event.data["path"] !== "string") return
|
|
181
|
+
if (!exactKeys(event.data, ["path", "attribution"]) || typeof event.data["path"] !== "string") return [];
|
|
159
182
|
fields["en"] = "page_view";
|
|
160
183
|
fields["ep.page_path"] = event.data["path"];
|
|
161
184
|
const attribution = event.data["attribution"];
|
|
@@ -175,13 +198,16 @@ function mapEvent(config: ActiveGa4Config, input: unknown): NativeGa4CollectRequ
|
|
|
175
198
|
}
|
|
176
199
|
}
|
|
177
200
|
} else {
|
|
178
|
-
if (!exactKeys(event.data, ["name"])) return
|
|
201
|
+
if (!exactKeys(event.data, ["name"])) return [];
|
|
179
202
|
const name = event.data["name"];
|
|
180
|
-
if (typeof name !== "string" || !APPROVED_ACTION_SET.has(name)) return
|
|
203
|
+
if (typeof name !== "string" || !APPROVED_ACTION_SET.has(name)) return [];
|
|
181
204
|
fields["en"] = name;
|
|
182
205
|
}
|
|
183
206
|
|
|
184
|
-
return
|
|
207
|
+
return config.destinationIds.map((tid) => Object.freeze({
|
|
208
|
+
endpoint: GA4_COLLECTION_ENDPOINT,
|
|
209
|
+
fields: Object.freeze({ ...fields, tid }),
|
|
210
|
+
}));
|
|
185
211
|
}
|
|
186
212
|
|
|
187
213
|
/**
|
|
@@ -237,8 +263,8 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
|
|
|
237
263
|
observeSafely(options.observe, "duplicate_suppressed");
|
|
238
264
|
return;
|
|
239
265
|
}
|
|
240
|
-
const
|
|
241
|
-
if (
|
|
266
|
+
const requests = mapEvent(current, event);
|
|
267
|
+
if (requests.length === 0) {
|
|
242
268
|
observeSafely(options.observe, "event_rejected");
|
|
243
269
|
return;
|
|
244
270
|
}
|
|
@@ -248,13 +274,18 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
|
|
|
248
274
|
const oldest = deliveredOrder.shift();
|
|
249
275
|
if (oldest !== undefined) delivered.delete(oldest);
|
|
250
276
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
277
|
+
// One logical event, one send per configured measurement id. Each send is
|
|
278
|
+
// independent: a destination whose transport fails is observed on its own and
|
|
279
|
+
// never suppresses the others.
|
|
280
|
+
for (const request of requests) {
|
|
281
|
+
try {
|
|
282
|
+
const result = options.transport(request);
|
|
283
|
+
if (result && typeof result.then === "function") {
|
|
284
|
+
void result.catch(() => observeSafely(options.observe, "transport_failed"));
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
observeSafely(options.observe, "transport_failed");
|
|
255
288
|
}
|
|
256
|
-
} catch {
|
|
257
|
-
observeSafely(options.observe, "transport_failed");
|
|
258
289
|
}
|
|
259
290
|
},
|
|
260
291
|
},
|
|
@@ -23,10 +23,18 @@
|
|
|
23
23
|
* Meta each measure a logical page/cart/checkout action exactly once — never a
|
|
24
24
|
* duplicate, even with multiple adapters live.
|
|
25
25
|
*
|
|
26
|
+
* A stage may name SEVERAL destinations, up to the adapter's own
|
|
27
|
+
* {@link PlatformAdapterDefinition.maxDestinationsPerStage}: a vendor that natively
|
|
28
|
+
* receives one event at several properties (a GA4 primary plus a rollup) fans one logical
|
|
29
|
+
* action out to each, while a single-valued adapter declares 1 and refuses a second id at
|
|
30
|
+
* config time. Fan-out happens strictly BELOW the broker, so dedupe is unaffected — the
|
|
31
|
+
* broker still delivers a logical action once, and the adapter turns that one delivery
|
|
32
|
+
* into one call per destination.
|
|
33
|
+
*
|
|
26
34
|
* NON-LIVE SAFETY — by construction, two independent guards:
|
|
27
|
-
* 1. Destination ids are per-ENVIRONMENT. {@link
|
|
28
|
-
* the
|
|
29
|
-
* REFUSES a config where a non-live stage reuses
|
|
35
|
+
* 1. Destination ids are per-ENVIRONMENT. {@link resolveAdapterDestinationIds} returns
|
|
36
|
+
* the ids for the CURRENT stage only, and {@link buildPlatformAdapterDeclaration}
|
|
37
|
+
* REFUSES a config where a non-live stage reuses ANY live id — so candidate/staged
|
|
30
38
|
* traffic can never reach the live property. A stage with no configured id fires
|
|
31
39
|
* nothing.
|
|
32
40
|
* 2. Real conversions fire only on `live`. A conversion-class mapping
|
|
@@ -109,6 +117,15 @@ export interface PlatformAdapterDefinition {
|
|
|
109
117
|
performanceBudget: PerformanceBudget;
|
|
110
118
|
/** Human hint for the public destination-id field (e.g. "GA4 measurement id"). */
|
|
111
119
|
idLabel: string;
|
|
120
|
+
/**
|
|
121
|
+
* How many destination ids ONE stage of this adapter may hold. `1` means
|
|
122
|
+
* single-valued: the adapter's runtime sends each event to exactly one destination and
|
|
123
|
+
* a second id is refused at config time. Above 1 the runtime fans one event out to
|
|
124
|
+
* every configured id for the active stage, which costs one vendor request each — so
|
|
125
|
+
* the cap must stay within {@link PlatformAdapterDefinition.performanceBudget}'s
|
|
126
|
+
* `maxAdditionalRequests`, and a test holds it there.
|
|
127
|
+
*/
|
|
128
|
+
maxDestinationsPerStage: number;
|
|
112
129
|
/** Validates the shape of a public destination id for this vendor. */
|
|
113
130
|
validateId(id: string): boolean;
|
|
114
131
|
/** The semantic commerce events this adapter measures, and their vendor mapping. */
|
|
@@ -142,6 +159,10 @@ export const GOOGLE_MEASUREMENT_ADAPTER: PlatformAdapterDefinition = Object.free
|
|
|
142
159
|
mustBeAsyncOrDeferred: true,
|
|
143
160
|
}),
|
|
144
161
|
idLabel: "GA4 measurement id (e.g. G-XXXXXXX)",
|
|
162
|
+
// GA4 natively receives one event at several properties (a primary plus a rollup); the
|
|
163
|
+
// wire shape is one `/g/collect` request per measurement id, so the ceiling is the
|
|
164
|
+
// budget's own `maxAdditionalRequests`.
|
|
165
|
+
maxDestinationsPerStage: 4,
|
|
145
166
|
validateId: (id: string) => GOOGLE_MEASUREMENT_ID.test(id),
|
|
146
167
|
eventMap: Object.freeze({
|
|
147
168
|
"storefront.page.viewed.v1": { vendorEvent: "page_view", isConversion: false },
|
|
@@ -172,6 +193,9 @@ export const META_PIXEL_ADAPTER: PlatformAdapterDefinition = Object.freeze({
|
|
|
172
193
|
mustBeAsyncOrDeferred: true,
|
|
173
194
|
}),
|
|
174
195
|
idLabel: "Meta pixel id (15–16 digits)",
|
|
196
|
+
// Single-valued: this adapter's runtime initializes and sends to one pixel. Multi-pixel
|
|
197
|
+
// fbq is a separate reviewed change, not a config the merchant can reach today.
|
|
198
|
+
maxDestinationsPerStage: 1,
|
|
175
199
|
validateId: (id: string) => META_PIXEL_ID.test(id),
|
|
176
200
|
eventMap: Object.freeze({
|
|
177
201
|
"storefront.page.viewed.v1": { vendorEvent: "PageView", isConversion: false },
|
|
@@ -203,6 +227,9 @@ export const AMPLITUDE_HTTP_ADAPTER: PlatformAdapterDefinition = Object.freeze({
|
|
|
203
227
|
mustBeAsyncOrDeferred: true,
|
|
204
228
|
}),
|
|
205
229
|
idLabel: "Amplitude project API key (32 hexadecimal characters)",
|
|
230
|
+
// Single-valued: the reviewed HTTP transport carries one `api_key` per request and the
|
|
231
|
+
// budget allows one additional request, so a second project key has nowhere to go.
|
|
232
|
+
maxDestinationsPerStage: 1,
|
|
206
233
|
validateId: (id: string) => AMPLITUDE_PROJECT_KEY.test(id),
|
|
207
234
|
eventMap: Object.freeze({
|
|
208
235
|
"storefront.page.viewed.v1": { vendorEvent: "page_view", isConversion: false },
|
|
@@ -242,22 +269,53 @@ export function destinationIdConfigKey(environment: IntegrationEnvironment): str
|
|
|
242
269
|
return `${DESTINATION_ID_CONFIG_PREFIX}_${environment}`;
|
|
243
270
|
}
|
|
244
271
|
|
|
272
|
+
/**
|
|
273
|
+
* The separator that packs one stage's destination ids into ONE primitive public-config
|
|
274
|
+
* value.
|
|
275
|
+
*
|
|
276
|
+
* Browser public config is deliberately closed to primitives — `publicConfigSchema` in
|
|
277
|
+
* the shared manifest contract admits only string/number/integer/boolean, so no tenant
|
|
278
|
+
* can put structure into browser config. A multi-id stage therefore travels as a joined
|
|
279
|
+
* string rather than by widening that contract for every integration in the system.
|
|
280
|
+
* The encoding is unambiguous because no adapter's `validateId` accepts a value
|
|
281
|
+
* containing this character, and {@link resolveAdapterDestinationIds} re-validates every
|
|
282
|
+
* decoded element — so a hostile or malformed value fails closed exactly as one id does.
|
|
283
|
+
*/
|
|
284
|
+
export const DESTINATION_ID_SEPARATOR = "," as const;
|
|
285
|
+
|
|
245
286
|
/** A merchant's per-environment public destination ids. `live` is required to publish. */
|
|
246
|
-
export type DestinationIdsByEnvironment = Partial<Record<IntegrationEnvironment, string>>;
|
|
287
|
+
export type DestinationIdsByEnvironment = Partial<Record<IntegrationEnvironment, readonly string[]>>;
|
|
288
|
+
|
|
289
|
+
/** Pack one stage's ids into the single primitive public-config value they travel as. */
|
|
290
|
+
export function encodeDestinationIds(ids: readonly string[]): string {
|
|
291
|
+
return ids.join(DESTINATION_ID_SEPARATOR);
|
|
292
|
+
}
|
|
247
293
|
|
|
248
294
|
/**
|
|
249
|
-
* Resolve the destination
|
|
250
|
-
* config — the current stage's
|
|
251
|
-
* so nothing fires there; because
|
|
252
|
-
* non-live stage that reuses
|
|
253
|
-
* non-live stage. This is the first non-live
|
|
295
|
+
* Resolve the destination ids that fire for a specific stage from resolved public
|
|
296
|
+
* config — the current stage's ids ONLY, in configured order (index 0 is the primary).
|
|
297
|
+
* A stage with no configured ids returns an empty list, so nothing fires there; because
|
|
298
|
+
* {@link buildPlatformAdapterDeclaration} refuses a non-live stage that reuses any live
|
|
299
|
+
* id, this can never return a live id off a non-live stage. This is the first non-live
|
|
300
|
+
* guard.
|
|
301
|
+
*
|
|
302
|
+
* Fails CLOSED as a unit: if any decoded element is not a valid id for this adapter, or
|
|
303
|
+
* there are more than the adapter's declared cap, the whole stage resolves to nothing
|
|
304
|
+
* rather than to the subset that happened to parse — a partial fan-out would under-report
|
|
305
|
+
* while looking configured.
|
|
254
306
|
*/
|
|
255
|
-
export function
|
|
307
|
+
export function resolveAdapterDestinationIds(
|
|
308
|
+
adapter: PlatformAdapterDefinition,
|
|
256
309
|
publicConfig: Record<string, unknown>,
|
|
257
310
|
environment: IntegrationEnvironment,
|
|
258
|
-
): string
|
|
311
|
+
): readonly string[] {
|
|
259
312
|
const value = publicConfig[destinationIdConfigKey(environment)];
|
|
260
|
-
|
|
313
|
+
if (typeof value !== "string" || value.length === 0) return [];
|
|
314
|
+
const ids = value.split(DESTINATION_ID_SEPARATOR);
|
|
315
|
+
if (ids.length > adapter.maxDestinationsPerStage) return [];
|
|
316
|
+
if (new Set(ids).size !== ids.length) return [];
|
|
317
|
+
if (!ids.every((id) => adapter.validateId(id))) return [];
|
|
318
|
+
return Object.freeze(ids);
|
|
261
319
|
}
|
|
262
320
|
|
|
263
321
|
// ── Declaration builder ───────────────────────────────────────────────────────
|
|
@@ -265,7 +323,7 @@ export function resolveAdapterDestinationId(
|
|
|
265
323
|
/** What the admin surface supplies to configure one first-class adapter. */
|
|
266
324
|
export interface PlatformAdapterConfigInput {
|
|
267
325
|
adapterId: string;
|
|
268
|
-
/** Public destination ids per environment. `live` is required; non-live stages take SEPARATE test ids. */
|
|
326
|
+
/** Public destination ids per environment. `live` is required; non-live stages take SEPARATE test ids. One stage may name several, up to the adapter's cap. */
|
|
269
327
|
ids: DestinationIdsByEnvironment;
|
|
270
328
|
/** The tenant this adapter is configured for (server-resolved, never browser-set). */
|
|
271
329
|
tenantPublicId: string;
|
|
@@ -281,6 +339,8 @@ export interface PlatformAdapterCard {
|
|
|
281
339
|
publisher: string;
|
|
282
340
|
supportUrl: string;
|
|
283
341
|
idLabel: string;
|
|
342
|
+
/** How many destinations one stage may name — 1 for a single-valued adapter. */
|
|
343
|
+
maxDestinationsPerStage: number;
|
|
284
344
|
/** Consent purposes the destination depends on — visible before publish. */
|
|
285
345
|
consentPurposes: ConsentPurpose[];
|
|
286
346
|
/** The performance budget the adapter must fit — visible before publish. */
|
|
@@ -296,8 +356,10 @@ export interface PlatformAdapterCard {
|
|
|
296
356
|
/** One stage's measurement disposition — makes the non-live safety visible in the read model. */
|
|
297
357
|
export interface AdapterStageBehavior {
|
|
298
358
|
environment: IntegrationEnvironment;
|
|
299
|
-
/** True when
|
|
359
|
+
/** True when at least one destination id is configured for this stage. */
|
|
300
360
|
configured: boolean;
|
|
361
|
+
/** How many destinations one event fans out to on this stage. */
|
|
362
|
+
destinationCount: number;
|
|
301
363
|
/** True only on `live`: the stage where real conversions fire. */
|
|
302
364
|
firesRealConversions: boolean;
|
|
303
365
|
}
|
|
@@ -326,11 +388,15 @@ function profileOrigins(profile: IntegrationCspProfile): string[] {
|
|
|
326
388
|
|
|
327
389
|
/** The per-stage destination behavior, in canonical environment order. */
|
|
328
390
|
function stageBehavior(ids: DestinationIdsByEnvironment): AdapterStageBehavior[] {
|
|
329
|
-
return INTEGRATION_ENVIRONMENTS.map((environment) =>
|
|
330
|
-
environment
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
391
|
+
return INTEGRATION_ENVIRONMENTS.map((environment) => {
|
|
392
|
+
const count = ids[environment]?.length ?? 0;
|
|
393
|
+
return {
|
|
394
|
+
environment,
|
|
395
|
+
configured: count > 0,
|
|
396
|
+
destinationCount: count,
|
|
397
|
+
firesRealConversions: isLiveEnvironment(environment) && count > 0,
|
|
398
|
+
};
|
|
399
|
+
});
|
|
334
400
|
}
|
|
335
401
|
|
|
336
402
|
/** Build the read-model card for an adapter definition + a merchant's configured ids. */
|
|
@@ -345,6 +411,7 @@ export function buildPlatformAdapterCard(
|
|
|
345
411
|
publisher: def.publisher,
|
|
346
412
|
supportUrl: def.supportUrl,
|
|
347
413
|
idLabel: def.idLabel,
|
|
414
|
+
maxDestinationsPerStage: def.maxDestinationsPerStage,
|
|
348
415
|
consentPurposes: [def.consentPurpose],
|
|
349
416
|
performanceBudget: def.performanceBudget,
|
|
350
417
|
cspOrigins: profileOrigins(def.cspProfile),
|
|
@@ -414,10 +481,10 @@ export function buildPlatformAdapterDeclaration(
|
|
|
414
481
|
const schemaProps: Record<string, { type: string }> = {};
|
|
415
482
|
const requiredKeys: string[] = [];
|
|
416
483
|
for (const environment of INTEGRATION_ENVIRONMENTS) {
|
|
417
|
-
const
|
|
418
|
-
if (
|
|
484
|
+
const stageIds = ids[environment];
|
|
485
|
+
if (stageIds === undefined || stageIds.length === 0) continue;
|
|
419
486
|
const key = destinationIdConfigKey(environment);
|
|
420
|
-
publicConfig[key] =
|
|
487
|
+
publicConfig[key] = encodeDestinationIds(stageIds);
|
|
421
488
|
schemaProps[key] = { type: "string" };
|
|
422
489
|
requiredKeys.push(key);
|
|
423
490
|
}
|
|
@@ -492,8 +559,12 @@ export function buildPlatformAdapterDeclaration(
|
|
|
492
559
|
|
|
493
560
|
/**
|
|
494
561
|
* Validate the per-environment public ids. `live` is required; every provided id must
|
|
495
|
-
* match the adapter's shape;
|
|
496
|
-
*
|
|
562
|
+
* match the adapter's shape; a stage may hold no more ids than the adapter declares; and
|
|
563
|
+
* NO non-live stage may reuse ANY live id — that is the config-time guard that keeps
|
|
564
|
+
* candidate/staged traffic off the live property.
|
|
565
|
+
*
|
|
566
|
+
* A failing id is named by its exact index, so a merchant with four ids learns which one
|
|
567
|
+
* is wrong rather than that the stage is wrong.
|
|
497
568
|
*/
|
|
498
569
|
function validateDestinationIds(
|
|
499
570
|
value: unknown,
|
|
@@ -501,7 +572,7 @@ function validateDestinationIds(
|
|
|
501
572
|
v: Violations,
|
|
502
573
|
): DestinationIdsByEnvironment | undefined {
|
|
503
574
|
if (!isPlainObject(value)) {
|
|
504
|
-
v.add("ids", "type", "ids must be an object of { environment: destinationId }.");
|
|
575
|
+
v.add("ids", "type", "ids must be an object of { environment: destinationId[] }.");
|
|
505
576
|
return undefined;
|
|
506
577
|
}
|
|
507
578
|
|
|
@@ -511,30 +582,60 @@ function validateDestinationIds(
|
|
|
511
582
|
v.add(`ids.${key}`, "unknown_enum", `Unknown environment "${key}"; one of: ${INTEGRATION_ENVIRONMENTS.join(", ")}.`);
|
|
512
583
|
continue;
|
|
513
584
|
}
|
|
514
|
-
if (
|
|
515
|
-
v.add(`ids.${key}`, "type", "A destination
|
|
585
|
+
if (!Array.isArray(raw)) {
|
|
586
|
+
v.add(`ids.${key}`, "type", "A stage's destination ids must be an array.");
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (raw.length === 0) {
|
|
590
|
+
v.add(`ids.${key}`, "empty", "A configured stage must name at least one destination id.");
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
if (raw.length > def.maxDestinationsPerStage) {
|
|
594
|
+
v.add(
|
|
595
|
+
`ids.${key}`,
|
|
596
|
+
"too_many_destinations",
|
|
597
|
+
def.maxDestinationsPerStage === 1
|
|
598
|
+
? `${def.name} sends to one destination per stage; "${key}" names ${raw.length}.`
|
|
599
|
+
: `The "${key}" stage names ${raw.length} destinations; ${def.name} accepts at most ${def.maxDestinationsPerStage} per stage.`,
|
|
600
|
+
);
|
|
516
601
|
continue;
|
|
517
602
|
}
|
|
518
|
-
|
|
519
|
-
|
|
603
|
+
let stageOk = true;
|
|
604
|
+
raw.forEach((id, index) => {
|
|
605
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
606
|
+
v.add(`ids.${key}[${index}]`, "type", "A destination id must be a non-empty string.");
|
|
607
|
+
stageOk = false;
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (!def.validateId(id)) {
|
|
611
|
+
v.add(`ids.${key}[${index}]`, "format", `"${id}" is not a valid ${def.idLabel}.`);
|
|
612
|
+
stageOk = false;
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
if (!stageOk) continue;
|
|
616
|
+
const ids = raw as string[];
|
|
617
|
+
if (new Set(ids).size !== ids.length) {
|
|
618
|
+
v.add(`ids.${key}`, "duplicate", `The "${key}" stage names the same destination id twice; each event would be counted once per duplicate.`);
|
|
520
619
|
continue;
|
|
521
620
|
}
|
|
522
|
-
out[key as IntegrationEnvironment] =
|
|
621
|
+
out[key as IntegrationEnvironment] = ids;
|
|
523
622
|
}
|
|
524
623
|
|
|
525
|
-
const
|
|
526
|
-
if (
|
|
624
|
+
const liveIds = out["live"];
|
|
625
|
+
if (liveIds === undefined) {
|
|
527
626
|
v.add("ids.live", "required", "A live destination id is required to publish this adapter.");
|
|
528
627
|
} else {
|
|
628
|
+
const live = new Set(liveIds);
|
|
529
629
|
for (const environment of INTEGRATION_ENVIRONMENTS) {
|
|
530
630
|
if (environment === "live") continue;
|
|
531
|
-
|
|
631
|
+
(out[environment] ?? []).forEach((id, index) => {
|
|
632
|
+
if (!live.has(id)) return;
|
|
532
633
|
v.add(
|
|
533
|
-
`ids.${environment}`,
|
|
634
|
+
`ids.${environment}[${index}]`,
|
|
534
635
|
"non_live_reuses_live_id",
|
|
535
|
-
`The "${environment}" stage reuses
|
|
636
|
+
`The "${environment}" stage reuses live destination id "${id}"; non-live traffic must never reach the live property. Use a separate test id or leave it unset.`,
|
|
536
637
|
);
|
|
537
|
-
}
|
|
638
|
+
});
|
|
538
639
|
}
|
|
539
640
|
}
|
|
540
641
|
|
|
@@ -553,7 +654,7 @@ function blocked(
|
|
|
553
654
|
/** One mapped vendor call the adapter's entry module makes. */
|
|
554
655
|
export interface VendorCall {
|
|
555
656
|
adapterId: string;
|
|
556
|
-
/** The public destination id for the CURRENT stage this call targets. */
|
|
657
|
+
/** The ONE public destination id, for the CURRENT stage, this call targets. */
|
|
557
658
|
destinationId: string;
|
|
558
659
|
/** The vendor's own event name. */
|
|
559
660
|
vendorEvent: string;
|
|
@@ -589,9 +690,9 @@ export interface CreateAdapterDestinationInput {
|
|
|
589
690
|
* broker's, shared with every other destination.
|
|
590
691
|
*
|
|
591
692
|
* The handler is the non-live enforcement point: it fires only to the CURRENT stage's
|
|
592
|
-
* destination
|
|
693
|
+
* destination ids (never a live id off a non-live stage), and drops conversion-class
|
|
593
694
|
* events on every non-live stage — so a candidate/staged page never fires a real
|
|
594
|
-
* conversion.
|
|
695
|
+
* conversion. One delivered event becomes one dispatch per configured destination.
|
|
595
696
|
*/
|
|
596
697
|
export function createAdapterDestination(
|
|
597
698
|
input: CreateAdapterDestinationInput,
|
|
@@ -604,30 +705,32 @@ export function createAdapterDestination(
|
|
|
604
705
|
sink: {
|
|
605
706
|
kind: "callback",
|
|
606
707
|
handler: (event: CommerceEvent) => {
|
|
607
|
-
const call
|
|
608
|
-
|
|
708
|
+
for (const call of mapCommerceEventToVendorCalls(adapter, publicConfig, environment, event)) {
|
|
709
|
+
dispatch(call);
|
|
710
|
+
}
|
|
609
711
|
},
|
|
610
712
|
},
|
|
611
713
|
};
|
|
612
714
|
}
|
|
613
715
|
|
|
614
716
|
/**
|
|
615
|
-
* Map one delivered commerce event to
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
717
|
+
* Map one delivered commerce event to the vendor calls it becomes — ONE per destination
|
|
718
|
+
* id configured for the current stage, in configured order — applying both non-live
|
|
719
|
+
* guards. Returns an empty list when: the current stage has no destination ids; the event
|
|
720
|
+
* type is not in the adapter's map; or the mapping is a conversion on a non-live stage.
|
|
721
|
+
* Pure — exported so the mapping, fan-out, and non-live gating are directly testable.
|
|
619
722
|
*/
|
|
620
|
-
export function
|
|
723
|
+
export function mapCommerceEventToVendorCalls(
|
|
621
724
|
adapter: PlatformAdapterDefinition,
|
|
622
725
|
publicConfig: Record<string, unknown>,
|
|
623
726
|
environment: BrowserEnvironment,
|
|
624
727
|
event: CommerceEvent,
|
|
625
|
-
): VendorCall
|
|
626
|
-
const
|
|
627
|
-
if (
|
|
728
|
+
): VendorCall[] {
|
|
729
|
+
const destinationIds = resolveAdapterDestinationIds(adapter, publicConfig, environment.environment);
|
|
730
|
+
if (destinationIds.length === 0) return [];
|
|
628
731
|
|
|
629
732
|
const mapping = adapter.eventMap[event.type];
|
|
630
|
-
if (!mapping) return
|
|
733
|
+
if (!mapping) return [];
|
|
631
734
|
|
|
632
735
|
let vendorEvent = mapping.vendorEvent;
|
|
633
736
|
if (
|
|
@@ -638,15 +741,15 @@ export function mapCommerceEventToVendorCall(
|
|
|
638
741
|
if (
|
|
639
742
|
typeof name !== "string" ||
|
|
640
743
|
!(APPROVED_MARKETING_ACTION_NAMES as readonly string[]).includes(name)
|
|
641
|
-
) return
|
|
744
|
+
) return [];
|
|
642
745
|
vendorEvent = name;
|
|
643
746
|
}
|
|
644
747
|
|
|
645
748
|
// Guard 2: real conversions fire only on live. A non-live stage never fires a real
|
|
646
749
|
// conversion, even to a configured test id.
|
|
647
|
-
if (mapping.isConversion && !isLiveEnvironment(environment.environment)) return
|
|
750
|
+
if (mapping.isConversion && !isLiveEnvironment(environment.environment)) return [];
|
|
648
751
|
|
|
649
|
-
return {
|
|
752
|
+
return destinationIds.map((destinationId) => ({
|
|
650
753
|
adapterId: adapter.adapterId,
|
|
651
754
|
destinationId,
|
|
652
755
|
vendorEvent,
|
|
@@ -654,7 +757,7 @@ export function mapCommerceEventToVendorCall(
|
|
|
654
757
|
logicalId: event.id,
|
|
655
758
|
data: event.data,
|
|
656
759
|
isConversion: mapping.isConversion,
|
|
657
|
-
};
|
|
760
|
+
}));
|
|
658
761
|
}
|
|
659
762
|
|
|
660
763
|
/**
|
|
@@ -16,19 +16,27 @@
|
|
|
16
16
|
"label": "Dataset id",
|
|
17
17
|
"description": "Public identifier for the fake dataset.",
|
|
18
18
|
"kind": "string",
|
|
19
|
-
"required": true
|
|
19
|
+
"required": true,
|
|
20
|
+
"maxEntries": 1
|
|
20
21
|
},
|
|
21
22
|
{
|
|
22
23
|
"key": "sampleRate",
|
|
23
24
|
"label": "Sample rate",
|
|
24
25
|
"description": "Integer sampling percentage.",
|
|
25
26
|
"kind": "integer",
|
|
26
|
-
"required": false
|
|
27
|
+
"required": false,
|
|
28
|
+
"maxEntries": 1
|
|
27
29
|
}
|
|
28
30
|
],
|
|
29
|
-
"eventTypes": [
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
"eventTypes": [
|
|
32
|
+
"storefront.page.viewed.v1"
|
|
33
|
+
],
|
|
34
|
+
"consentPurposes": [
|
|
35
|
+
"analytics"
|
|
36
|
+
],
|
|
37
|
+
"collectionOrigins": [
|
|
38
|
+
"https://collector.invalid"
|
|
39
|
+
]
|
|
32
40
|
}
|
|
33
41
|
]
|
|
34
42
|
}
|