@tokenoftrust/storefront-runner 2.0.26 → 2.0.28

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 (18) hide show
  1. package/apps/storefront/src/lib/integration/managedInstallDeclarations.ts +5 -3
  2. package/apps/storefront/src/lib/integration/stageDestinationIds.ts +89 -14
  3. package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +1 -1
  4. package/package.json +1 -1
  5. package/packages/public-runtime/schemas/managed-analytics/v1/adapter-definition.schema.json +6 -1
  6. package/packages/public-runtime/schemas/managed-analytics/v1/create-integration-install-request.schema.json +81 -66
  7. package/packages/public-runtime/schemas/managed-analytics/v1/create-migration-draft-request.schema.json +81 -66
  8. package/packages/public-runtime/schemas/managed-analytics/v1/integration-revision.schema.json +81 -66
  9. package/packages/public-runtime/schemas/managed-analytics/v1/migration-draft.schema.json +81 -66
  10. package/packages/public-runtime/schemas/managed-analytics/v1/revise-integration-install-request.schema.json +81 -66
  11. package/packages/public-runtime/schemas/managed-analytics/v1/update-migration-draft-request.schema.json +81 -66
  12. package/packages/public-runtime/src/gtm-migration/draft-generator.ts +57 -18
  13. package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +19 -6
  14. package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +49 -15
  15. package/packages/public-runtime/src/integration/native-amplitude.ts +22 -4
  16. package/packages/public-runtime/src/integration/native-ga4.ts +69 -38
  17. package/packages/public-runtime/src/integration/platform-adapters.ts +157 -54
  18. 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 { destinationIdConfigKey, GOOGLE_MEASUREMENT_ADAPTER } from "./platform-adapters.js";
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
- destinationId: string;
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
- const liveId = ctx.config[destinationIdConfigKey("live")];
88
- if (typeof liveId !== "string" || !GOOGLE_MEASUREMENT_ADAPTER.validateId(liveId)) return null;
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
- const value = ctx.config[destinationIdConfigKey(environment)];
91
- if (value === undefined) continue;
92
- if (typeof value !== "string" || !GOOGLE_MEASUREMENT_ADAPTER.validateId(value)) return null;
93
- if (environment !== "live" && value === liveId) return null;
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 destinationId = ctx.config[destinationIdConfigKey(ctx.environment.environment)];
97
- if (
98
- typeof destinationId !== "string" ||
99
- !GOOGLE_MEASUREMENT_ADAPTER.validateId(destinationId)
100
- ) {
101
- return null;
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
- destinationId,
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
- function mapEvent(config: ActiveGa4Config, input: unknown): NativeGa4CollectRequest | null {
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 null;
161
+ if (!validated.ok) return [];
140
162
  const event = validated.value;
141
- if (!NATIVE_GA4_EVENT_SET.has(event.type)) return null;
142
- if (event.storefront.emitter !== "browser") return null;
143
- if (event.storefront.environment !== config.environment) return null;
144
- if (event.storefront.siteSurface !== config.siteSurface) return null;
145
- if (!event.consent.eligiblePurposes.includes("analytics")) return null;
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 null;
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 null;
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 null;
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 null;
203
+ if (typeof name !== "string" || !APPROVED_ACTION_SET.has(name)) return [];
181
204
  fields["en"] = name;
182
205
  }
183
206
 
184
- return Object.freeze({ endpoint: GA4_COLLECTION_ENDPOINT, fields: Object.freeze(fields) });
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 request = mapEvent(current, event);
241
- if (!request) {
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
- try {
252
- const result = options.transport(request);
253
- if (result && typeof result.then === "function") {
254
- void result.catch(() => observeSafely(options.observe, "transport_failed"));
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 resolveAdapterDestinationId} returns
28
- * the id for the CURRENT stage only, and {@link buildPlatformAdapterDeclaration}
29
- * REFUSES a config where a non-live stage reuses the live id — so candidate/staged
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 id that fires for a specific stage from resolved public
250
- * config — the current stage's id ONLY. A stage with no configured id returns null,
251
- * so nothing fires there; because {@link buildPlatformAdapterDeclaration} refuses a
252
- * non-live stage that reuses the live id, this can never return the live id off a
253
- * non-live stage. This is the first non-live guard.
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 resolveAdapterDestinationId(
307
+ export function resolveAdapterDestinationIds(
308
+ adapter: PlatformAdapterDefinition,
256
309
  publicConfig: Record<string, unknown>,
257
310
  environment: IntegrationEnvironment,
258
- ): string | null {
311
+ ): readonly string[] {
259
312
  const value = publicConfig[destinationIdConfigKey(environment)];
260
- return typeof value === "string" && value.length > 0 ? value : null;
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 a destination id is configured for this stage. */
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
- configured: typeof ids[environment] === "string" && (ids[environment] as string).length > 0,
332
- firesRealConversions: isLiveEnvironment(environment) && typeof ids[environment] === "string",
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 id = ids[environment];
418
- if (id === undefined) continue;
484
+ const stageIds = ids[environment];
485
+ if (stageIds === undefined || stageIds.length === 0) continue;
419
486
  const key = destinationIdConfigKey(environment);
420
- publicConfig[key] = id;
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; and NO non-live stage may reuse the live id — that is the
496
- * config-time guard that keeps candidate/staged traffic off the live property.
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 (typeof raw !== "string" || raw.length === 0) {
515
- v.add(`ids.${key}`, "type", "A destination id must be a non-empty string.");
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
- if (!def.validateId(raw)) {
519
- v.add(`ids.${key}`, "format", `"${raw}" is not a valid ${def.idLabel}.`);
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] = raw;
621
+ out[key as IntegrationEnvironment] = ids;
523
622
  }
524
623
 
525
- const liveId = out["live"];
526
- if (liveId === undefined) {
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
- if (out[environment] === liveId) {
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 the live destination id; non-live traffic must never reach the live property. Use a separate test id or leave it unset.`,
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 id (never the live id off a non-live stage), and drops conversion-class
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 = mapCommerceEventToVendorCall(adapter, publicConfig, environment, event);
608
- if (call) dispatch(call);
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 a vendor call, applying both non-live guards.
616
- * Returns null (no vendor call) when: the current stage has no destination id; the
617
- * event type is not in the adapter's map; or the mapping is a conversion on a non-live
618
- * stage. Pure — exported so the mapping + non-live gating are directly testable.
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 mapCommerceEventToVendorCall(
723
+ export function mapCommerceEventToVendorCalls(
621
724
  adapter: PlatformAdapterDefinition,
622
725
  publicConfig: Record<string, unknown>,
623
726
  environment: BrowserEnvironment,
624
727
  event: CommerceEvent,
625
- ): VendorCall | null {
626
- const destinationId = resolveAdapterDestinationId(publicConfig, environment.environment);
627
- if (!destinationId) return null;
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 null;
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 null;
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 null;
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": ["storefront.page.viewed.v1"],
30
- "consentPurposes": ["analytics"],
31
- "collectionOrigins": ["https://collector.invalid"]
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
  }