@adcp/sdk 7.3.0 → 7.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/lib/core/AgentClient.d.ts.map +1 -1
  2. package/dist/lib/core/SingleAgentClient.d.ts +58 -14
  3. package/dist/lib/core/SingleAgentClient.d.ts.map +1 -1
  4. package/dist/lib/core/SingleAgentClient.js +68 -26
  5. package/dist/lib/core/SingleAgentClient.js.map +1 -1
  6. package/dist/lib/errors/index.d.ts +8 -3
  7. package/dist/lib/errors/index.d.ts.map +1 -1
  8. package/dist/lib/errors/index.js +1 -1
  9. package/dist/lib/errors/index.js.map +1 -1
  10. package/dist/lib/index.d.ts +2 -2
  11. package/dist/lib/index.d.ts.map +1 -1
  12. package/dist/lib/index.js.map +1 -1
  13. package/dist/lib/protocols/index.d.ts +16 -6
  14. package/dist/lib/protocols/index.d.ts.map +1 -1
  15. package/dist/lib/protocols/index.js.map +1 -1
  16. package/dist/lib/protocols/responseSizeLimit.js +7 -0
  17. package/dist/lib/protocols/responseSizeLimit.js.map +1 -1
  18. package/dist/lib/registry/index.d.ts +36 -3
  19. package/dist/lib/registry/index.d.ts.map +1 -1
  20. package/dist/lib/registry/index.js +41 -5
  21. package/dist/lib/registry/index.js.map +1 -1
  22. package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
  23. package/dist/lib/server/create-adcp-server.d.ts +95 -0
  24. package/dist/lib/server/create-adcp-server.d.ts.map +1 -1
  25. package/dist/lib/server/create-adcp-server.js +543 -35
  26. package/dist/lib/server/create-adcp-server.js.map +1 -1
  27. package/dist/lib/server/example-tld-guard.d.ts +9 -0
  28. package/dist/lib/server/example-tld-guard.d.ts.map +1 -0
  29. package/dist/lib/server/example-tld-guard.js +25 -0
  30. package/dist/lib/server/example-tld-guard.js.map +1 -0
  31. package/dist/lib/server/index.d.ts +5 -3
  32. package/dist/lib/server/index.d.ts.map +1 -1
  33. package/dist/lib/server/index.js +17 -4
  34. package/dist/lib/server/index.js.map +1 -1
  35. package/dist/lib/server/test-controller-bridge.d.ts +885 -1
  36. package/dist/lib/server/test-controller-bridge.d.ts.map +1 -1
  37. package/dist/lib/server/test-controller-bridge.js +1502 -2
  38. package/dist/lib/server/test-controller-bridge.js.map +1 -1
  39. package/dist/lib/testing/compliance/comply.d.ts.map +1 -1
  40. package/dist/lib/testing/compliance/comply.js +130 -6
  41. package/dist/lib/testing/compliance/comply.js.map +1 -1
  42. package/dist/lib/testing/compliance/index.d.ts +1 -1
  43. package/dist/lib/testing/compliance/index.d.ts.map +1 -1
  44. package/dist/lib/testing/compliance/types.d.ts +83 -0
  45. package/dist/lib/testing/compliance/types.d.ts.map +1 -1
  46. package/dist/lib/testing/index.d.ts +3 -3
  47. package/dist/lib/testing/index.d.ts.map +1 -1
  48. package/dist/lib/testing/index.js +17 -3
  49. package/dist/lib/testing/index.js.map +1 -1
  50. package/dist/lib/types/core.generated.d.ts +1212 -20
  51. package/dist/lib/types/core.generated.d.ts.map +1 -1
  52. package/dist/lib/types/core.generated.js +1 -1
  53. package/dist/lib/types/inline-enums.generated.d.ts +6 -2
  54. package/dist/lib/types/inline-enums.generated.d.ts.map +1 -1
  55. package/dist/lib/types/inline-enums.generated.js +11 -5
  56. package/dist/lib/types/inline-enums.generated.js.map +1 -1
  57. package/dist/lib/types/schemas.generated.d.ts +26202 -26253
  58. package/dist/lib/types/schemas.generated.d.ts.map +1 -1
  59. package/dist/lib/types/schemas.generated.js +1353 -1300
  60. package/dist/lib/types/schemas.generated.js.map +1 -1
  61. package/dist/lib/types/tools.generated.d.ts +1082 -21
  62. package/dist/lib/types/tools.generated.d.ts.map +1 -1
  63. package/dist/lib/types/wellknown-schemas.generated.d.ts +2 -2
  64. package/dist/lib/validation/sync-creatives.d.ts +6 -6
  65. package/dist/lib/version.d.ts +3 -3
  66. package/dist/lib/version.js +3 -3
  67. package/examples/hello_creative_adapter_ad_server.ts +5 -0
  68. package/examples/hello_creative_adapter_template.ts +5 -0
  69. package/examples/hello_seller_adapter_guaranteed.ts +5 -0
  70. package/examples/hello_seller_adapter_non_guaranteed.ts +5 -0
  71. package/examples/hello_seller_adapter_social.ts +5 -0
  72. package/examples/hello_si_adapter_brand.ts +5 -0
  73. package/package.json +2 -2
  74. package/skills/call-adcp-agent/SKILL.md +1 -0
@@ -18,11 +18,90 @@
18
18
  * Sellers provide a `getSeededProducts` callback that returns the list the
19
19
  * SDK should merge — which lets the same wiring work whether the backing
20
20
  * store is in-memory, Postgres, Redis, or a mock.
21
+ *
22
+ * ## Scope of verification (storyboard pass through this bridge)
23
+ *
24
+ * A storyboard run that succeeds because seeded fixtures were merged into
25
+ * the response verifies **protocol conformance against fixture data**:
26
+ * wire shape, error envelopes, idempotency, signed-request handling,
27
+ * sandbox stamping. It does **not** verify that the seller's adapter
28
+ * against the real upstream (e.g., social / search / programmatic
29
+ * inventory APIs) is working — the upstream response is shadowed by the
30
+ * post-handler merge.
31
+ *
32
+ * Treat this bridge as the conformance equivalent of a recorded-fixtures
33
+ * unit test, not an end-to-end integration test. Sellers should still
34
+ * exercise their adapters against a real (or sandbox) upstream OAuth tier
35
+ * separately; the typical pattern is a CLI runner pointed at a deployed
36
+ * sandbox URL with live credentials. The two together — storyboard-via-
37
+ * bridge plus live-OAuth runner — give wire conformance and adapter
38
+ * health respectively. See adcp-client#1775 for the cross-repo
39
+ * coordination on making bridge participation visible in storyboard
40
+ * run records.
41
+ *
42
+ * ## Adopter responsibilities
43
+ *
44
+ * **`resolveAccount` is the trust boundary.** The dispatcher's sandbox
45
+ * gate is "request carries a sandbox marker AND (resolved account is
46
+ * sandbox OR no account was resolved)." If you deploy a server with this
47
+ * bridge registered but no `resolveAccount` configured, a buyer can stamp
48
+ * `context.sandbox: true` on a request and trigger the merge. That's the
49
+ * intended behavior for storyboard runners with no account scoping, but
50
+ * means **production bindings must always configure `resolveAccount`** —
51
+ * otherwise the buyer-supplied sandbox marker is the only gate.
52
+ *
53
+ * **Multi-tenant isolation is the adopter's job.** Callbacks receive
54
+ * `ctx.account` and must key their fixture store on it. The SDK does no
55
+ * defensive cross-check between the account on the response entries and
56
+ * the `ctx.account` that asked for them. A sloppy session-store keying
57
+ * can return tenant A's fixtures to tenant B; nothing in this module
58
+ * will notice. Treat fixture stores like any other multi-tenant data
59
+ * layer.
21
60
  */
22
61
  Object.defineProperty(exports, "__esModule", { value: true });
23
62
  exports.isSandboxRequest = isSandboxRequest;
24
63
  exports.mergeSeededProductsIntoResponse = mergeSeededProductsIntoResponse;
25
64
  exports.filterValidSeededProducts = filterValidSeededProducts;
65
+ exports.filterValidSeededCreatives = filterValidSeededCreatives;
66
+ exports.filterValidSeededMediaBuys = filterValidSeededMediaBuys;
67
+ exports.filterValidSeededMediaBuyDeliveries = filterValidSeededMediaBuyDeliveries;
68
+ exports.filterValidSeededAccounts = filterValidSeededAccounts;
69
+ exports.filterValidSeededAccountFinancials = filterValidSeededAccountFinancials;
70
+ exports.filterValidSeededCreativeFormats = filterValidSeededCreativeFormats;
71
+ exports.mergeSeededCreativesIntoResponse = mergeSeededCreativesIntoResponse;
72
+ exports.mergeSeededMediaBuysIntoResponse = mergeSeededMediaBuysIntoResponse;
73
+ exports.recomputeAggregatedTotals = recomputeAggregatedTotals;
74
+ exports.mergeSeededMediaBuyDeliveryIntoResponse = mergeSeededMediaBuyDeliveryIntoResponse;
75
+ exports.mergeSeededAccountsIntoResponse = mergeSeededAccountsIntoResponse;
76
+ exports.pickSeededAccountFinancialsForRequest = pickSeededAccountFinancialsForRequest;
77
+ exports.replaceAccountFinancialsIfSeeded = replaceAccountFinancialsIfSeeded;
78
+ exports.mergeSeededCreativeFormatsIntoResponse = mergeSeededCreativeFormatsIntoResponse;
79
+ exports.filterValidSeededPropertyLists = filterValidSeededPropertyLists;
80
+ exports.filterValidSeededCollectionLists = filterValidSeededCollectionLists;
81
+ exports.filterValidSeededContentStandards = filterValidSeededContentStandards;
82
+ exports.mergeSeededPropertyListsIntoResponse = mergeSeededPropertyListsIntoResponse;
83
+ exports.mergeSeededCollectionListsIntoResponse = mergeSeededCollectionListsIntoResponse;
84
+ exports.mergeSeededContentStandardsIntoResponse = mergeSeededContentStandardsIntoResponse;
85
+ exports.pickSeededPropertyListForRequest = pickSeededPropertyListForRequest;
86
+ exports.replacePropertyListIfSeeded = replacePropertyListIfSeeded;
87
+ exports.pickSeededCollectionListForRequest = pickSeededCollectionListForRequest;
88
+ exports.replaceCollectionListIfSeeded = replaceCollectionListIfSeeded;
89
+ exports.pickSeededContentStandardsForRequest = pickSeededContentStandardsForRequest;
90
+ exports.replaceContentStandardsIfSeeded = replaceContentStandardsIfSeeded;
91
+ exports.filterValidSeededSignals = filterValidSeededSignals;
92
+ exports.filterValidSeededCreativeDelivery = filterValidSeededCreativeDelivery;
93
+ exports.filterValidSeededCreativeFeatures = filterValidSeededCreativeFeatures;
94
+ exports.mergeSeededSignalsIntoResponse = mergeSeededSignalsIntoResponse;
95
+ exports.mergeSeededCreativeDeliveryIntoResponse = mergeSeededCreativeDeliveryIntoResponse;
96
+ exports.mergeSeededCreativeFeaturesIntoResponse = mergeSeededCreativeFeaturesIntoResponse;
97
+ exports.filterValidSeededBrandIdentity = filterValidSeededBrandIdentity;
98
+ exports.filterValidSeededRights = filterValidSeededRights;
99
+ exports.filterValidSeededSiOffering = filterValidSeededSiOffering;
100
+ exports.mergeSeededRightsIntoResponse = mergeSeededRightsIntoResponse;
101
+ exports.pickSeededBrandIdentityForRequest = pickSeededBrandIdentityForRequest;
102
+ exports.replaceBrandIdentityIfSeeded = replaceBrandIdentityIfSeeded;
103
+ exports.pickSeededSiOfferingForRequest = pickSeededSiOfferingForRequest;
104
+ exports.replaceSiOfferingIfSeeded = replaceSiOfferingIfSeeded;
26
105
  exports.bridgeFromTestControllerStore = bridgeFromTestControllerStore;
27
106
  exports.bridgeFromSessionStore = bridgeFromSessionStore;
28
107
  const seed_merge_1 = require("../testing/seed-merge");
@@ -50,6 +129,25 @@ function isSandboxRequest(input) {
50
129
  }
51
130
  return false;
52
131
  }
132
+ /**
133
+ * Detect the Submitted-arm shape (`{ status: 'submitted', task_id }`) on a
134
+ * read-tool response payload. `get_products` formally permits this arm per
135
+ * `schemas/cache/3.0.11/media-buy/get-products-async-response-submitted.json`
136
+ * (queued custom/bespoke product curation); the dispatcher's
137
+ * `isSubmittedEnvelope` routes the wrap, but the bridge merge runs after
138
+ * wrap and would spread `products: [...]` into the Submitted envelope
139
+ * without this guard.
140
+ *
141
+ * Mirrors the `isSubmittedEnvelope` predicate at
142
+ * `src/lib/server/create-adcp-server.ts` — kept local rather than imported
143
+ * because that helper lives inside the dispatcher closure.
144
+ */
145
+ function isSubmittedArm(value) {
146
+ if (value == null || typeof value !== 'object')
147
+ return false;
148
+ const obj = value;
149
+ return obj.status === 'submitted' && typeof obj.task_id === 'string';
150
+ }
53
151
  /**
54
152
  * Merge seeded products into a `get_products` response payload.
55
153
  *
@@ -62,10 +160,29 @@ function isSandboxRequest(input) {
62
160
  * handler explicitly declared `sandbox: false` (which stays authoritative
63
161
  * — a handler that has already decided the request is non-sandbox
64
162
  * shouldn't be overridden by the bridge).
163
+ *
164
+ * **Submitted-arm short-circuit.** `get_products` formally permits an
165
+ * async Submitted arm per `schemas/cache/3.0.11/media-buy/get-products-async-response-submitted.json`
166
+ * (queued custom/bespoke curation). The dispatcher routes
167
+ * `{ status: 'submitted', task_id }` handler returns through
168
+ * `wrapSubmittedEnvelope`, but the bridge merge then receives the
169
+ * unwrapped Submitted body from `formatted.structuredContent`. Without
170
+ * this guard, the merge would spread `products: [...]` into that body
171
+ * and produce a `{ status: 'submitted', task_id, products: [...], sandbox: true }`
172
+ * hybrid that violates the wire schema. Detect the Submitted shape and
173
+ * return the handler response reference-equal so the dispatcher's
174
+ * skip-on-reference-equality wrap-avoidance kicks in.
175
+ *
176
+ * None of the other 12 bridged read tools have a formal Submitted arm
177
+ * in 3.0.11 per `schemas/cache/3.0.11/core/async-response-data.json`;
178
+ * this guard is `get_products`-specific defense rather than a uniform
179
+ * pattern across helpers.
65
180
  */
66
181
  function mergeSeededProductsIntoResponse(response, seeded) {
67
182
  if (!seeded.length)
68
183
  return response;
184
+ if (isSubmittedArm(response))
185
+ return response;
69
186
  const seededIds = new Set();
70
187
  for (const p of seeded)
71
188
  seededIds.add(p.product_id);
@@ -116,6 +233,1277 @@ function filterValidSeededProducts(raw, logger) {
116
233
  });
117
234
  return valid;
118
235
  }
236
+ // ---------------------------------------------------------------------------
237
+ // Per-tool validation + merge helpers
238
+ //
239
+ // Each helper pair (`filterValidSeededXxx` + `mergeSeededXxxIntoResponse`)
240
+ // mirrors the `filterValidSeededProducts` + `mergeSeededProductsIntoResponse`
241
+ // shape. The semantics are identical: validate-and-drop on the input side,
242
+ // dedupe-and-stamp-sandbox on the merge side. Symmetry is the point — adopters
243
+ // who understand the products seam should be able to read the others at a
244
+ // glance.
245
+ // ---------------------------------------------------------------------------
246
+ /**
247
+ * Validate seeded creatives. Drops entries that are not plain objects or are
248
+ * missing a non-empty string `creative_id` — matches the products contract
249
+ * (a missing identifier collides on `undefined === undefined` when deduping).
250
+ */
251
+ function filterValidSeededCreatives(raw, logger) {
252
+ return filterValidById(raw, 'creative_id', 'getSeededCreatives', logger);
253
+ }
254
+ /**
255
+ * Validate seeded media buys. Drops entries missing a non-empty string
256
+ * `media_buy_id`.
257
+ */
258
+ function filterValidSeededMediaBuys(raw, logger) {
259
+ return filterValidById(raw, 'media_buy_id', 'getSeededMediaBuys', logger);
260
+ }
261
+ /**
262
+ * Validate seeded media-buy-delivery snapshots. Drops entries missing a
263
+ * non-empty string `media_buy_id` — the dedup key against the handler set.
264
+ */
265
+ function filterValidSeededMediaBuyDeliveries(raw, logger) {
266
+ return filterValidById(raw, 'media_buy_id', 'getSeededMediaBuyDelivery', logger);
267
+ }
268
+ /**
269
+ * Validate seeded accounts. Drops entries missing a non-empty string `account_id`.
270
+ */
271
+ function filterValidSeededAccounts(raw, logger) {
272
+ return filterValidById(raw, 'account_id', 'getSeededAccounts', logger);
273
+ }
274
+ /**
275
+ * Validate seeded account-financials records. Drops entries whose `account`
276
+ * field is not a `{ account_id: string }`-shaped object (`AccountReference`
277
+ * carries `account_id` on the operator-resolved variant; the bridge keys on
278
+ * that field to match against the request's account).
279
+ *
280
+ * Also drops duplicate entries by `account.account_id` (first occurrence
281
+ * wins, matching the array-collection helpers' on-collision-seeded-wins
282
+ * contract — the "first" seeded entry in iteration order is authoritative).
283
+ * A fixture array with two entries for the same `account_id` is almost
284
+ * always a seed-store bug; warn-and-drop surfaces it instead of silently
285
+ * picking whichever happened to come first in iteration order.
286
+ */
287
+ function filterValidSeededAccountFinancials(raw, logger) {
288
+ if (!Array.isArray(raw)) {
289
+ logger?.warn('testController.getSeededAccountFinancials did not return an array; skipping bridge', {
290
+ received: typeof raw,
291
+ });
292
+ return [];
293
+ }
294
+ const valid = [];
295
+ const seenAccountIds = new Set();
296
+ raw.forEach((entry, index) => {
297
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
298
+ logger?.warn('testController.getSeededAccountFinancials entry is not an object; dropping', { index });
299
+ return;
300
+ }
301
+ const account = entry.account;
302
+ const accountId = account && typeof account === 'object' && !Array.isArray(account)
303
+ ? account.account_id
304
+ : undefined;
305
+ if (typeof accountId !== 'string' || accountId.length === 0) {
306
+ logger?.warn('testController.getSeededAccountFinancials entry missing account.account_id; dropping', { index });
307
+ return;
308
+ }
309
+ if (seenAccountIds.has(accountId)) {
310
+ logger?.warn('testController.getSeededAccountFinancials duplicate account.account_id; dropping (first occurrence wins)', { index, account_id: accountId });
311
+ return;
312
+ }
313
+ seenAccountIds.add(accountId);
314
+ valid.push(entry);
315
+ });
316
+ return valid;
317
+ }
318
+ /**
319
+ * Validate seeded creative formats. Drops entries whose `format_id` is not a
320
+ * `{ agent_url: string, id: string }`-shaped object — both fields are
321
+ * required to dedupe (and to canonicalize per the URL canonicalization rules
322
+ * in the AdCP spec).
323
+ */
324
+ function filterValidSeededCreativeFormats(raw, logger) {
325
+ if (!Array.isArray(raw)) {
326
+ logger?.warn('testController.getSeededCreativeFormats did not return an array; skipping bridge', {
327
+ received: typeof raw,
328
+ });
329
+ return [];
330
+ }
331
+ const valid = [];
332
+ raw.forEach((entry, index) => {
333
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
334
+ logger?.warn('testController.getSeededCreativeFormats entry is not an object; dropping', { index });
335
+ return;
336
+ }
337
+ const formatId = entry.format_id;
338
+ if (!formatId || typeof formatId !== 'object' || Array.isArray(formatId)) {
339
+ logger?.warn('testController.getSeededCreativeFormats entry missing format_id; dropping', { index });
340
+ return;
341
+ }
342
+ const agentUrl = formatId.agent_url;
343
+ const id = formatId.id;
344
+ if (typeof agentUrl !== 'string' || agentUrl.length === 0 || typeof id !== 'string' || id.length === 0) {
345
+ logger?.warn('testController.getSeededCreativeFormats entry has incomplete format_id (agent_url and id required); dropping', { index });
346
+ return;
347
+ }
348
+ valid.push(entry);
349
+ });
350
+ return valid;
351
+ }
352
+ /**
353
+ * Shared by-`id` validator for collections whose entries dedupe on a single
354
+ * top-level string field (creatives → `creative_id`, media buys →
355
+ * `media_buy_id`, accounts → `account_id`). Mirrors
356
+ * {@link filterValidSeededProducts} exactly; factored only because the three
357
+ * shapes share the same single-string-key contract.
358
+ */
359
+ function filterValidById(raw, idField, callbackName, logger) {
360
+ if (!Array.isArray(raw)) {
361
+ logger?.warn(`testController.${callbackName} did not return an array; skipping bridge`, {
362
+ received: typeof raw,
363
+ });
364
+ return [];
365
+ }
366
+ const valid = [];
367
+ raw.forEach((entry, index) => {
368
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
369
+ logger?.warn(`testController.${callbackName} entry is not an object; dropping`, { index });
370
+ return;
371
+ }
372
+ const id = entry[idField];
373
+ if (typeof id !== 'string' || id.length === 0) {
374
+ logger?.warn(`testController.${callbackName} entry missing ${idField}; dropping`, { index });
375
+ return;
376
+ }
377
+ valid.push(entry);
378
+ });
379
+ return valid;
380
+ }
381
+ /**
382
+ * Merge seeded creatives into a `list_creatives` response. Existing creatives
383
+ * come first; seeded entries append after deduping by `creative_id`. On
384
+ * collision the seeded entry wins. Stamps `sandbox: true` unless the handler
385
+ * explicitly declared `sandbox: false`. Returns a NEW response object.
386
+ *
387
+ * Also updates `query_summary.returned` to match the final array length and
388
+ * `query_summary.total_matching` to `handler.total_matching + (seeded entries
389
+ * that did NOT collide with the handler set)`. Storyboards that assert on
390
+ * counts see the merged totals, not the handler's pre-merge counts. Mirror
391
+ * field on `pagination.total_count` is updated by the same delta when the
392
+ * handler set it.
393
+ */
394
+ function mergeSeededCreativesIntoResponse(response, seeded) {
395
+ if (!seeded.length)
396
+ return response;
397
+ const seededIds = new Set();
398
+ for (const c of seeded)
399
+ seededIds.add(c.creative_id);
400
+ const existing = Array.isArray(response.creatives) ? response.creatives : [];
401
+ const existingIds = new Set();
402
+ for (const c of existing) {
403
+ if (c && typeof c.creative_id === 'string')
404
+ existingIds.add(c.creative_id);
405
+ }
406
+ const retained = existing.filter(c => !seededIds.has(c?.creative_id));
407
+ const finalCreatives = [...retained, ...seeded];
408
+ // New entries = seeded that did NOT collide with the handler's set. Collisions
409
+ // replace in-place; the merged total_matching shouldn't grow on those.
410
+ let newCount = 0;
411
+ for (const c of seeded)
412
+ if (!existingIds.has(c.creative_id))
413
+ newCount += 1;
414
+ const merged = {
415
+ ...response,
416
+ creatives: finalCreatives,
417
+ };
418
+ if (response.sandbox !== false) {
419
+ merged.sandbox = true;
420
+ }
421
+ // query_summary is required on list_creatives per AdCP 3.0.11. Update the
422
+ // counts if the handler provided them; leave the rest of the block
423
+ // (filters_applied, etc.) untouched.
424
+ const qs = response.query_summary;
425
+ if (qs && typeof qs === 'object') {
426
+ const baseTotal = typeof qs.total_matching === 'number' ? qs.total_matching : existing.length;
427
+ merged.query_summary = {
428
+ ...qs,
429
+ total_matching: baseTotal + newCount,
430
+ returned: finalCreatives.length,
431
+ };
432
+ }
433
+ // pagination.total_count is optional; only update when the handler provided it.
434
+ const pagination = response.pagination;
435
+ if (pagination && typeof pagination === 'object' && typeof pagination.total_count === 'number') {
436
+ merged.pagination = {
437
+ ...pagination,
438
+ total_count: pagination.total_count + newCount,
439
+ };
440
+ }
441
+ return merged;
442
+ }
443
+ /**
444
+ * Merge seeded media buys into a `get_media_buys` response. Existing media
445
+ * buys come first; seeded entries append after deduping by `media_buy_id`.
446
+ * On collision the seeded entry wins. Returns a NEW response object.
447
+ *
448
+ * `get_media_buys` does NOT carry a `query_summary` block (per AdCP 3.0.11);
449
+ * it exposes its count via `pagination.total_count` (optional). When the
450
+ * handler set `pagination.total_count`, it's incremented by the count of
451
+ * new (non-colliding) seeded entries so the merged response stays
452
+ * internally consistent. Handlers that left `total_count` off pass through
453
+ * unchanged.
454
+ */
455
+ function mergeSeededMediaBuysIntoResponse(response, seeded) {
456
+ if (!seeded.length)
457
+ return response;
458
+ const seededIds = new Set();
459
+ for (const mb of seeded)
460
+ seededIds.add(mb.media_buy_id);
461
+ const existing = Array.isArray(response.media_buys) ? response.media_buys : [];
462
+ const existingIds = new Set();
463
+ for (const mb of existing) {
464
+ if (mb && typeof mb.media_buy_id === 'string')
465
+ existingIds.add(mb.media_buy_id);
466
+ }
467
+ const retained = existing.filter(mb => !seededIds.has(mb?.media_buy_id));
468
+ let newCount = 0;
469
+ for (const mb of seeded)
470
+ if (!existingIds.has(mb.media_buy_id))
471
+ newCount += 1;
472
+ const merged = {
473
+ ...response,
474
+ media_buys: [...retained, ...seeded],
475
+ };
476
+ if (response.sandbox !== false) {
477
+ merged.sandbox = true;
478
+ }
479
+ const pagination = response.pagination;
480
+ if (pagination && typeof pagination === 'object' && typeof pagination.total_count === 'number') {
481
+ merged.pagination = {
482
+ ...pagination,
483
+ total_count: pagination.total_count + newCount,
484
+ };
485
+ }
486
+ return merged;
487
+ }
488
+ /**
489
+ * Recompute `aggregated_totals` from a merged `media_buy_deliveries` array.
490
+ *
491
+ * The wire schema makes `aggregated_totals.impressions` / `spend` /
492
+ * `media_buy_count` REQUIRED, so once the bridge changes the delivery list it
493
+ * MUST rewrite the totals — otherwise `media_buy_count` is stale and the
494
+ * impressions/spend sums no longer reflect the merged set.
495
+ *
496
+ * Policy (see VALIDATE-YOUR-AGENT.md "Platform-proxy sellers"):
497
+ * - Sum-derived (required): `impressions`, `spend`, `media_buy_count`.
498
+ * - Sum-derived (optional): `clicks`, `completed_views`, `views`,
499
+ * `conversions`, `conversion_value` — recomputed ONLY when EVERY merged
500
+ * delivery populates the field on its `totals`. Otherwise fall back to
501
+ * the handler's value (or omit if the handler omitted).
502
+ * - Derived ratios: `roas` (`conversion_value / spend`),
503
+ * `completion_rate` (`completed_views / impressions`),
504
+ * `cost_per_acquisition` (`spend / conversions`). Recomputed ONLY when
505
+ * both input fields recomputed AND the divisor is non-zero. Otherwise
506
+ * fall back to the handler's value (or omit).
507
+ * - Pass-through (not derivable from per-delivery `totals`): `reach`,
508
+ * `reach_unit`, `frequency`, `new_to_brand_rate`. The handler's values
509
+ * survive verbatim.
510
+ *
511
+ * Empty merged array → `{ impressions: 0, spend: 0, media_buy_count: 0 }` +
512
+ * any pass-through the handler set. Divide-by-zero guards keep ratios omitted
513
+ * rather than producing `Infinity` / `NaN` values that would fail validation.
514
+ *
515
+ * Pure helper — testable in isolation, no I/O.
516
+ */
517
+ function recomputeAggregatedTotals(deliveries, handlerAggregated) {
518
+ const totalsList = deliveries.map(d => d.totals);
519
+ const sumNumber = (key) => {
520
+ let acc = 0;
521
+ for (const t of totalsList) {
522
+ const v = t[key];
523
+ if (typeof v === 'number')
524
+ acc += v;
525
+ }
526
+ return acc;
527
+ };
528
+ const everyHasNumber = (key) => {
529
+ if (totalsList.length === 0)
530
+ return false;
531
+ for (const t of totalsList) {
532
+ const v = t[key];
533
+ if (typeof v !== 'number')
534
+ return false;
535
+ }
536
+ return true;
537
+ };
538
+ // Required sums: per spec, these fields are REQUIRED on aggregated_totals
539
+ // when the block exists. Empty merged set → zeros (still wire-correct).
540
+ const recomputed = {
541
+ impressions: everyHasNumber('impressions') ? sumNumber('impressions') : 0,
542
+ spend: everyHasNumber('spend') ? sumNumber('spend') : 0,
543
+ media_buy_count: deliveries.length,
544
+ };
545
+ // Optional sums — only recompute when every delivery populates the field.
546
+ // Otherwise fall back to the handler's value to avoid wire-incorrect
547
+ // partial sums.
548
+ const recomputedFields = new Set();
549
+ const optionalSumFields = ['clicks', 'completed_views', 'views', 'conversions', 'conversion_value'];
550
+ for (const field of optionalSumFields) {
551
+ if (everyHasNumber(field)) {
552
+ recomputed[field] = sumNumber(field);
553
+ recomputedFields.add(field);
554
+ }
555
+ else {
556
+ const handlerValue = handlerAggregated ? handlerAggregated[field] : undefined;
557
+ if (handlerValue !== undefined)
558
+ recomputed[field] = handlerValue;
559
+ }
560
+ }
561
+ // Also track recomputed status for the required sum fields — needed for the
562
+ // derived-ratio guards below. `impressions` / `spend` are recomputed when
563
+ // every delivery populates them; empty merged set counts as "recomputed to 0".
564
+ if (everyHasNumber('impressions') || deliveries.length === 0)
565
+ recomputedFields.add('impressions');
566
+ if (everyHasNumber('spend') || deliveries.length === 0)
567
+ recomputedFields.add('spend');
568
+ // Derived ratios — only recompute when BOTH inputs were recomputed AND the
569
+ // divisor is non-zero. Otherwise fall back to the handler's value (or omit).
570
+ const tryRatio = (outKey, numerator, denominator) => {
571
+ if (recomputedFields.has(numerator) && recomputedFields.has(denominator)) {
572
+ const denom = recomputed[denominator];
573
+ const num = recomputed[numerator];
574
+ if (typeof denom === 'number' && denom > 0 && typeof num === 'number') {
575
+ recomputed[outKey] = num / denom;
576
+ return;
577
+ }
578
+ // Divide-by-zero — omit the ratio (don't fall back; the recomputed
579
+ // inputs say the ratio is undefined for this set).
580
+ return;
581
+ }
582
+ const handlerValue = handlerAggregated ? handlerAggregated[outKey] : undefined;
583
+ if (handlerValue !== undefined)
584
+ recomputed[outKey] = handlerValue;
585
+ };
586
+ tryRatio('roas', 'conversion_value', 'spend');
587
+ tryRatio('completion_rate', 'completed_views', 'impressions');
588
+ tryRatio('cost_per_acquisition', 'spend', 'conversions');
589
+ // Pass-through fields — not derivable from per-delivery `totals` (reach
590
+ // needs de-dup info we don't have; frequency depends on reach; NTB rate
591
+ // needs an NTB conversion count that isn't carried on per-delivery totals).
592
+ // Preserve handler's values verbatim.
593
+ const passThroughFields = ['reach', 'reach_unit', 'frequency', 'new_to_brand_rate'];
594
+ for (const field of passThroughFields) {
595
+ const handlerValue = handlerAggregated ? handlerAggregated[field] : undefined;
596
+ if (handlerValue !== undefined)
597
+ recomputed[field] = handlerValue;
598
+ }
599
+ return recomputed;
600
+ }
601
+ /**
602
+ * Merge seeded media-buy-delivery entries into a `get_media_buy_delivery`
603
+ * response. Existing handler entries come first; seeded entries append after
604
+ * deduping by `media_buy_id`. On collision the SEEDED entry wins, matching the
605
+ * precedent set by `mergeSeededMediaBuys` / `mergeSeededCreatives` /
606
+ * `mergeSeededAccounts` — storyboards seed deliberately, so a seeded fixture
607
+ * for an existing `media_buy_id` is an explicit author override.
608
+ *
609
+ * After merging, `aggregated_totals` is recomputed via
610
+ * {@link recomputeAggregatedTotals} so `media_buy_count` / `impressions` /
611
+ * `spend` reflect the merged set instead of the handler's pre-merge values.
612
+ *
613
+ * Returns a NEW response object — the handler's singleton envelope fields
614
+ * (`reporting_period`, `currency`, `attribution_window`, `errors`, `sandbox`,
615
+ * `context`, `ext`, plus webhook-only `notification_type` / `partial_data` /
616
+ * `sequence_number` / etc.) pass through verbatim. Stamps `sandbox: true`
617
+ * unless the handler explicitly declared `sandbox: false`.
618
+ */
619
+ function mergeSeededMediaBuyDeliveryIntoResponse(response, seeded) {
620
+ if (!seeded.length)
621
+ return response;
622
+ const handlerDeliveries = Array.isArray(response.media_buy_deliveries) ? response.media_buy_deliveries : [];
623
+ const seededIds = new Set();
624
+ for (const d of seeded)
625
+ seededIds.add(d.media_buy_id);
626
+ // Seeded wins on collision: drop handler entries whose `media_buy_id` is in
627
+ // the seeded set, then append seeded entries. Order mirrors
628
+ // `mergeSeededMediaBuysIntoResponse` — retained handler entries first,
629
+ // seeded entries (including overrides) last.
630
+ const retained = handlerDeliveries.filter(d => !seededIds.has(d?.media_buy_id));
631
+ const final = [...retained, ...seeded];
632
+ const merged = {
633
+ ...response,
634
+ media_buy_deliveries: final,
635
+ aggregated_totals: recomputeAggregatedTotals(final, response.aggregated_totals),
636
+ };
637
+ if (response.sandbox !== false) {
638
+ merged.sandbox = true;
639
+ }
640
+ return merged;
641
+ }
642
+ /**
643
+ * Merge seeded accounts into a `list_accounts` response. Existing accounts
644
+ * come first; seeded entries append after deduping by `account_id`. On
645
+ * collision the seeded entry wins. Returns a NEW response object.
646
+ *
647
+ * `list_accounts` exposes its count via `pagination.total_count` (optional,
648
+ * same as `get_media_buys`). When the handler set it, it's incremented by
649
+ * the count of new (non-colliding) seeded entries.
650
+ */
651
+ function mergeSeededAccountsIntoResponse(response, seeded) {
652
+ if (!seeded.length)
653
+ return response;
654
+ const seededIds = new Set();
655
+ for (const a of seeded)
656
+ seededIds.add(a.account_id);
657
+ const existing = Array.isArray(response.accounts) ? response.accounts : [];
658
+ const existingIds = new Set();
659
+ for (const a of existing) {
660
+ if (a && typeof a.account_id === 'string')
661
+ existingIds.add(a.account_id);
662
+ }
663
+ const retained = existing.filter(a => !seededIds.has(a?.account_id));
664
+ let newCount = 0;
665
+ for (const a of seeded)
666
+ if (!existingIds.has(a.account_id))
667
+ newCount += 1;
668
+ const merged = {
669
+ ...response,
670
+ accounts: [...retained, ...seeded],
671
+ };
672
+ if (response.sandbox !== false) {
673
+ merged.sandbox = true;
674
+ }
675
+ const pagination = response.pagination;
676
+ if (pagination && typeof pagination === 'object' && typeof pagination.total_count === 'number') {
677
+ merged.pagination = {
678
+ ...pagination,
679
+ total_count: pagination.total_count + newCount,
680
+ };
681
+ }
682
+ return merged;
683
+ }
684
+ /**
685
+ * Extract `account_id` from a `get_account_financials` request's `account`
686
+ * reference. `AccountReference` is a discriminated union — the
687
+ * operator-resolved variant carries `account_id` directly, brand+operator
688
+ * variants do not. Returns `undefined` for variants that don't carry an
689
+ * `account_id` (those requests can't match a seeded fixture by id and pass
690
+ * through to the handler).
691
+ */
692
+ function readRequestAccountId(req) {
693
+ const account = req?.account;
694
+ if (!account || typeof account !== 'object' || Array.isArray(account))
695
+ return undefined;
696
+ const id = account.account_id;
697
+ return typeof id === 'string' && id.length > 0 ? id : undefined;
698
+ }
699
+ /**
700
+ * Pick a seeded `get_account_financials` fixture matching the request's
701
+ * account. Unlike the array-collection helpers, this returns the SINGLE
702
+ * matched envelope or `undefined` — `get_account_financials` is a singleton
703
+ * response and "merge" reduces to "replace if the request's account matches
704
+ * a seeded fixture".
705
+ *
706
+ * Matching honors both the raw request and the resolved account from
707
+ * `resolveAccount`. `AccountReference` is a discriminated union — the
708
+ * operator-resolved variant carries `account_id` on the request, but the
709
+ * brand+operator variants do not. When the framework has already resolved
710
+ * the request to an account, prefer that resolved id so seeded fixtures
711
+ * find their match regardless of which `AccountReference` variant the
712
+ * buyer sent.
713
+ *
714
+ * @param resolvedAccountId - The `account_id` from `ctx.account` after
715
+ * `resolveAccount` ran. Pass `undefined` when no account was resolved
716
+ * (singleton-tenant adopters) — the function falls back to the request's
717
+ * `account.account_id` field, which preserves the original semantics for
718
+ * adopters who don't wire `resolveAccount`.
719
+ */
720
+ function pickSeededAccountFinancialsForRequest(request, seeded, resolvedAccountId) {
721
+ if (!seeded.length)
722
+ return undefined;
723
+ const requestedId = resolvedAccountId ?? readRequestAccountId(request);
724
+ if (requestedId == null)
725
+ return undefined;
726
+ for (const entry of seeded) {
727
+ const id = entry.account?.account_id;
728
+ if (typeof id === 'string' && id === requestedId)
729
+ return entry;
730
+ }
731
+ return undefined;
732
+ }
733
+ /**
734
+ * Replace a `get_account_financials` response when a seeded fixture matches
735
+ * the request's account. The seeded fixture is authoritative on the
736
+ * financials payload (spend, period, account, currency, ...). The handler's
737
+ * `context` and `ext` are framework-managed (`context` echoes
738
+ * `adcp_version` / `request_id`; `ext` carries adopter passthrough) and
739
+ * MUST be preserved across the replace — wire-correct context echo is the
740
+ * framework's responsibility, not the seed fixture's.
741
+ *
742
+ * When no fixture matches, returns the handler response unchanged.
743
+ *
744
+ * @param resolvedAccountId - Optional resolved `account_id` from
745
+ * `ctx.account`; passed through to {@link pickSeededAccountFinancialsForRequest}.
746
+ */
747
+ function replaceAccountFinancialsIfSeeded(request, response, seeded, resolvedAccountId) {
748
+ const picked = pickSeededAccountFinancialsForRequest(request, seeded, resolvedAccountId);
749
+ if (!picked)
750
+ return response;
751
+ // Preserve framework-managed envelope fields from the handler response;
752
+ // seeded fixture owns the financials body. Pull `context` / `ext` off the
753
+ // handler response (when present) and re-stamp them on top of the seeded
754
+ // payload — the fixture's own `context` / `ext` (if any) lose to the
755
+ // handler's, which is correct: a seeded snapshot can't know the current
756
+ // request's `request_id` / `adcp_version` echo.
757
+ const handlerContext = response.context;
758
+ const handlerExt = response.ext;
759
+ const merged = { ...picked };
760
+ if (handlerContext !== undefined)
761
+ merged.context = handlerContext;
762
+ if (handlerExt !== undefined)
763
+ merged.ext = handlerExt;
764
+ return merged;
765
+ }
766
+ /**
767
+ * Canonical dedup key for a `Format` — `${agent_url}|${id}`. Matches the AdCP
768
+ * format-id contract: two formats from the same agent with the same id are
769
+ * the same format, regardless of parameterized dimension / duration. Fixtures
770
+ * that seed multiple parameterized variants of the same template should use
771
+ * distinct `id` values per variant.
772
+ */
773
+ function formatDedupKey(f) {
774
+ const fid = f.format_id;
775
+ if (!fid || typeof fid !== 'object')
776
+ return undefined;
777
+ const agentUrl = fid.agent_url;
778
+ const id = fid.id;
779
+ if (typeof agentUrl !== 'string' || typeof id !== 'string')
780
+ return undefined;
781
+ return `${agentUrl}|${id}`;
782
+ }
783
+ /**
784
+ * Merge seeded creative formats into a `list_creative_formats` response.
785
+ * Existing formats come first; seeded entries append after deduping by
786
+ * canonical `${agent_url}|${id}`. On collision the seeded entry wins.
787
+ * Returns a NEW response object.
788
+ */
789
+ function mergeSeededCreativeFormatsIntoResponse(response, seeded) {
790
+ if (!seeded.length)
791
+ return response;
792
+ const seededKeys = new Set();
793
+ for (const f of seeded) {
794
+ const key = formatDedupKey(f);
795
+ if (key != null)
796
+ seededKeys.add(key);
797
+ }
798
+ const existing = Array.isArray(response.formats) ? response.formats : [];
799
+ const retained = existing.filter(f => {
800
+ const key = formatDedupKey(f);
801
+ return key == null || !seededKeys.has(key);
802
+ });
803
+ const merged = {
804
+ ...response,
805
+ formats: [...retained, ...seeded],
806
+ };
807
+ if (response.sandbox !== false) {
808
+ merged.sandbox = true;
809
+ }
810
+ return merged;
811
+ }
812
+ // ---------------------------------------------------------------------------
813
+ // Property lists / collection lists / content standards
814
+ //
815
+ // Each entity has a "list" tool (returns `T[]` under a top-level key) AND a
816
+ // "get" tool (returns a singleton). The same seeded fixture array feeds both
817
+ // tools — the list tool merges with seeded-wins on collision; the get tool
818
+ // picks by id and replaces. PropertyList and CollectionList wrap the picked
819
+ // entity into the response's `list` field; ContentStandards' success arm IS
820
+ // the entity directly.
821
+ //
822
+ // All three follow the same dedup contract:
823
+ // PropertyList → top-level `list_id` (string, required)
824
+ // CollectionList → top-level `list_id` (string, required)
825
+ // ContentStandards → top-level `standards_id` (string, required)
826
+ // ---------------------------------------------------------------------------
827
+ /**
828
+ * Validate seeded property-list entries. Drops entries missing a non-empty
829
+ * string `list_id`.
830
+ */
831
+ function filterValidSeededPropertyLists(raw, logger) {
832
+ return filterValidById(raw, 'list_id', 'getSeededPropertyLists', logger);
833
+ }
834
+ /**
835
+ * Validate seeded collection-list entries. Drops entries missing a non-empty
836
+ * string `list_id`.
837
+ */
838
+ function filterValidSeededCollectionLists(raw, logger) {
839
+ return filterValidById(raw, 'list_id', 'getSeededCollectionLists', logger);
840
+ }
841
+ /**
842
+ * Validate seeded content-standards entries. Drops entries missing a
843
+ * non-empty string `standards_id`.
844
+ */
845
+ function filterValidSeededContentStandards(raw, logger) {
846
+ return filterValidById(raw, 'standards_id', 'getSeededContentStandards', logger);
847
+ }
848
+ /**
849
+ * Merge seeded property lists into a `list_property_lists` response.
850
+ * Existing entries come first; seeded entries append after deduping by
851
+ * `list_id`. On collision the seeded entry wins. Returns a NEW response
852
+ * object. When the handler set `pagination.total_count`, it's incremented
853
+ * by the count of new (non-colliding) seeded entries.
854
+ *
855
+ * `list_property_lists` does not carry a `query_summary` block (per AdCP
856
+ * 3.0.11) — pagination.total_count is the only count field to update.
857
+ */
858
+ function mergeSeededPropertyListsIntoResponse(response, seeded) {
859
+ if (!seeded.length)
860
+ return response;
861
+ const seededIds = new Set();
862
+ for (const l of seeded)
863
+ seededIds.add(l.list_id);
864
+ const existing = Array.isArray(response.lists) ? response.lists : [];
865
+ const existingIds = new Set();
866
+ for (const l of existing) {
867
+ if (l && typeof l.list_id === 'string')
868
+ existingIds.add(l.list_id);
869
+ }
870
+ const retained = existing.filter(l => !seededIds.has(l?.list_id));
871
+ let newCount = 0;
872
+ for (const l of seeded)
873
+ if (!existingIds.has(l.list_id))
874
+ newCount += 1;
875
+ const merged = {
876
+ ...response,
877
+ lists: [...retained, ...seeded],
878
+ };
879
+ const pagination = response.pagination;
880
+ if (pagination && typeof pagination === 'object' && typeof pagination.total_count === 'number') {
881
+ merged.pagination = {
882
+ ...pagination,
883
+ total_count: pagination.total_count + newCount,
884
+ };
885
+ }
886
+ return merged;
887
+ }
888
+ /**
889
+ * Merge seeded collection lists into a `list_collection_lists` response.
890
+ * Symmetric with {@link mergeSeededPropertyListsIntoResponse} — same dedup
891
+ * key (`list_id`), same pagination update policy.
892
+ */
893
+ function mergeSeededCollectionListsIntoResponse(response, seeded) {
894
+ if (!seeded.length)
895
+ return response;
896
+ const seededIds = new Set();
897
+ for (const l of seeded)
898
+ seededIds.add(l.list_id);
899
+ const existing = Array.isArray(response.lists) ? response.lists : [];
900
+ const existingIds = new Set();
901
+ for (const l of existing) {
902
+ if (l && typeof l.list_id === 'string')
903
+ existingIds.add(l.list_id);
904
+ }
905
+ const retained = existing.filter(l => !seededIds.has(l?.list_id));
906
+ let newCount = 0;
907
+ for (const l of seeded)
908
+ if (!existingIds.has(l.list_id))
909
+ newCount += 1;
910
+ const merged = {
911
+ ...response,
912
+ lists: [...retained, ...seeded],
913
+ };
914
+ const pagination = response.pagination;
915
+ if (pagination && typeof pagination === 'object' && typeof pagination.total_count === 'number') {
916
+ merged.pagination = {
917
+ ...pagination,
918
+ total_count: pagination.total_count + newCount,
919
+ };
920
+ }
921
+ return merged;
922
+ }
923
+ /**
924
+ * Merge seeded content-standards into a `list_content_standards` response
925
+ * (success arm). Drops to a no-op if the response is the error arm (no
926
+ * `standards` array). On `standards_id` collision the seeded entry wins.
927
+ * Updates `pagination.total_count` when the handler set it. Returns a NEW
928
+ * response object.
929
+ */
930
+ function mergeSeededContentStandardsIntoResponse(response, seeded) {
931
+ if (!seeded.length)
932
+ return response;
933
+ // Skip the error arm — the dispatcher already gates on !isErrorResponse,
934
+ // but the type is a union so we re-narrow defensively.
935
+ const successArm = response;
936
+ if (!Array.isArray(successArm.standards))
937
+ return response;
938
+ const seededIds = new Set();
939
+ for (const s of seeded)
940
+ seededIds.add(s.standards_id);
941
+ const existing = successArm.standards;
942
+ const existingIds = new Set();
943
+ for (const s of existing) {
944
+ if (s && typeof s.standards_id === 'string')
945
+ existingIds.add(s.standards_id);
946
+ }
947
+ const retained = existing.filter(s => !seededIds.has(s?.standards_id));
948
+ let newCount = 0;
949
+ for (const s of seeded)
950
+ if (!existingIds.has(s.standards_id))
951
+ newCount += 1;
952
+ const merged = {
953
+ ...successArm,
954
+ standards: [...retained, ...seeded],
955
+ };
956
+ const pagination = successArm.pagination;
957
+ if (pagination && typeof pagination === 'object' && typeof pagination.total_count === 'number') {
958
+ merged.pagination = {
959
+ ...pagination,
960
+ total_count: pagination.total_count + newCount,
961
+ };
962
+ }
963
+ return merged;
964
+ }
965
+ /**
966
+ * Read the `list_id` from a `get_property_list` request. Required field per
967
+ * spec; this helper is defensive about runtime input.
968
+ */
969
+ function readRequestListId(req) {
970
+ const id = req?.list_id;
971
+ return typeof id === 'string' && id.length > 0 ? id : undefined;
972
+ }
973
+ /**
974
+ * Read the `standards_id` from a `get_content_standards` request.
975
+ */
976
+ function readRequestStandardsId(req) {
977
+ const id = req?.standards_id;
978
+ return typeof id === 'string' && id.length > 0 ? id : undefined;
979
+ }
980
+ /**
981
+ * Pick the seeded property list whose `list_id` matches the request.
982
+ * Returns `undefined` when nothing matches.
983
+ */
984
+ function pickSeededPropertyListForRequest(request, seeded) {
985
+ if (!seeded.length)
986
+ return undefined;
987
+ const requestedId = readRequestListId(request);
988
+ if (requestedId == null)
989
+ return undefined;
990
+ for (const entry of seeded) {
991
+ if (entry.list_id === requestedId)
992
+ return entry;
993
+ }
994
+ return undefined;
995
+ }
996
+ /**
997
+ * Replace a `get_property_list` response's `list` field with a seeded fixture
998
+ * when one matches. The handler's auxiliary fields (`identifiers`,
999
+ * `pagination`, `resolved_at`, `cache_valid_until`, `coverage_gaps`,
1000
+ * `context`, `ext`) pass through verbatim — those depend on request-time
1001
+ * pagination / resolve params that a static fixture can't know. Only `list`
1002
+ * is replaced. When no fixture matches, returns the handler response
1003
+ * unchanged.
1004
+ */
1005
+ function replacePropertyListIfSeeded(request, response, seeded) {
1006
+ const picked = pickSeededPropertyListForRequest(request, seeded);
1007
+ if (!picked)
1008
+ return response;
1009
+ return { ...response, list: picked };
1010
+ }
1011
+ /**
1012
+ * Pick the seeded collection list whose `list_id` matches the request.
1013
+ * Returns `undefined` when nothing matches.
1014
+ */
1015
+ function pickSeededCollectionListForRequest(request, seeded) {
1016
+ if (!seeded.length)
1017
+ return undefined;
1018
+ const requestedId = readRequestListId(request);
1019
+ if (requestedId == null)
1020
+ return undefined;
1021
+ for (const entry of seeded) {
1022
+ if (entry.list_id === requestedId)
1023
+ return entry;
1024
+ }
1025
+ return undefined;
1026
+ }
1027
+ /**
1028
+ * Replace a `get_collection_list` response's `list` field with a seeded
1029
+ * fixture when one matches. Same envelope-preservation policy as
1030
+ * {@link replacePropertyListIfSeeded}.
1031
+ */
1032
+ function replaceCollectionListIfSeeded(request, response, seeded) {
1033
+ const picked = pickSeededCollectionListForRequest(request, seeded);
1034
+ if (!picked)
1035
+ return response;
1036
+ return { ...response, list: picked };
1037
+ }
1038
+ /**
1039
+ * Pick the seeded content-standards entry whose `standards_id` matches the
1040
+ * request. Returns `undefined` when nothing matches.
1041
+ */
1042
+ function pickSeededContentStandardsForRequest(request, seeded) {
1043
+ if (!seeded.length)
1044
+ return undefined;
1045
+ const requestedId = readRequestStandardsId(request);
1046
+ if (requestedId == null)
1047
+ return undefined;
1048
+ for (const entry of seeded) {
1049
+ if (entry.standards_id === requestedId)
1050
+ return entry;
1051
+ }
1052
+ return undefined;
1053
+ }
1054
+ /**
1055
+ * Replace a `get_content_standards` response with a seeded fixture when one
1056
+ * matches. Seeded fixture is authoritative on the `ContentStandards` body.
1057
+ * Framework-managed envelope fields (`context`, `ext`) round-trip from the
1058
+ * handler — matches the precedent set by {@link replaceAccountFinancialsIfSeeded}.
1059
+ *
1060
+ * When no fixture matches, returns the handler response unchanged. The
1061
+ * caller is responsible for skipping the error arm (the dispatcher gates on
1062
+ * `!isErrorResponse`).
1063
+ */
1064
+ function replaceContentStandardsIfSeeded(request, response, seeded) {
1065
+ const picked = pickSeededContentStandardsForRequest(request, seeded);
1066
+ if (!picked)
1067
+ return response;
1068
+ // Both context and ext are framework-managed envelope fields.
1069
+ // Seeded fixture is authoritative on the ContentStandards body only.
1070
+ const handlerContext = response.context;
1071
+ const handlerExt = response.ext;
1072
+ const replaced = { ...picked };
1073
+ if (handlerContext !== undefined)
1074
+ replaced.context = handlerContext;
1075
+ if (handlerExt !== undefined)
1076
+ replaced.ext = handlerExt;
1077
+ return replaced;
1078
+ }
1079
+ // ---------------------------------------------------------------------------
1080
+ // Signals / creative delivery / creative features
1081
+ //
1082
+ // `get_signals` (signal-marketplace + signal-owned) → list-merge by signal_id
1083
+ // `get_creative_delivery` (creative-* delivery) → list-merge by creative_id, pagination.total update
1084
+ // `get_creative_features` (creative-* governance) → list-merge into success-arm `results` by feature_id
1085
+ //
1086
+ // All three follow the validate-and-drop / dedupe-and-stamp-sandbox
1087
+ // precedent set by the earlier bridges. The features bridge gates on the
1088
+ // success arm — error envelopes pass through unchanged.
1089
+ // ---------------------------------------------------------------------------
1090
+ /**
1091
+ * Canonical dedup key for a `SignalID`. `SignalID` is a discriminated union
1092
+ * (`{source:'catalog', data_provider_domain, id}` vs `{source:'agent',
1093
+ * agent_url, id}`) — two signals with the same `id` from different sources
1094
+ * are distinct, so dedup keys on the full source+origin+id tuple.
1095
+ */
1096
+ function signalIdDedupKey(signal) {
1097
+ const sid = signal.signal_id;
1098
+ if (!sid || typeof sid !== 'object' || Array.isArray(sid))
1099
+ return undefined;
1100
+ const source = sid.source;
1101
+ const id = sid.id;
1102
+ if (typeof source !== 'string' || typeof id !== 'string' || id.length === 0)
1103
+ return undefined;
1104
+ if (source === 'catalog') {
1105
+ const origin = sid.data_provider_domain;
1106
+ if (typeof origin !== 'string' || origin.length === 0)
1107
+ return undefined;
1108
+ return `catalog|${origin}|${id}`;
1109
+ }
1110
+ if (source === 'agent') {
1111
+ const origin = sid.agent_url;
1112
+ if (typeof origin !== 'string' || origin.length === 0)
1113
+ return undefined;
1114
+ return `agent|${origin}|${id}`;
1115
+ }
1116
+ return undefined;
1117
+ }
1118
+ /**
1119
+ * Validate seeded signals. Drops entries whose `signal_id` is not a valid
1120
+ * `SignalID` discriminated-union shape (`{source:'catalog',
1121
+ * data_provider_domain, id}` or `{source:'agent', agent_url, id}`). A missing
1122
+ * or malformed `signal_id` collides on `undefined === undefined` when
1123
+ * deduping, so we drop early.
1124
+ */
1125
+ function filterValidSeededSignals(raw, logger) {
1126
+ if (!Array.isArray(raw)) {
1127
+ logger?.warn('testController.getSeededSignals did not return an array; skipping bridge', {
1128
+ received: typeof raw,
1129
+ });
1130
+ return [];
1131
+ }
1132
+ const valid = [];
1133
+ raw.forEach((entry, index) => {
1134
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
1135
+ logger?.warn('testController.getSeededSignals entry is not an object; dropping', { index });
1136
+ return;
1137
+ }
1138
+ const key = signalIdDedupKey(entry);
1139
+ if (!key) {
1140
+ logger?.warn('testController.getSeededSignals entry has invalid signal_id (expected {source:catalog,data_provider_domain,id} or {source:agent,agent_url,id}); dropping', { index });
1141
+ return;
1142
+ }
1143
+ valid.push(entry);
1144
+ });
1145
+ return valid;
1146
+ }
1147
+ /**
1148
+ * Validate seeded creative-delivery entries. Drops entries missing a non-empty
1149
+ * string `creative_id`.
1150
+ */
1151
+ function filterValidSeededCreativeDelivery(raw, logger) {
1152
+ return filterValidById(raw, 'creative_id', 'getSeededCreativeDelivery', logger);
1153
+ }
1154
+ /**
1155
+ * Validate seeded creative-feature results. Drops entries missing a non-empty
1156
+ * string `feature_id` (the dedup key against the handler's `results` array).
1157
+ */
1158
+ function filterValidSeededCreativeFeatures(raw, logger) {
1159
+ return filterValidById(raw, 'feature_id', 'getSeededCreativeFeatures', logger);
1160
+ }
1161
+ /**
1162
+ * Merge seeded signals into a `get_signals` response. Existing handler signals
1163
+ * come first; seeded entries append after deduping by `signal_id`. On
1164
+ * collision the seeded entry wins. Stamps `sandbox: true` unless the handler
1165
+ * explicitly declared `sandbox: false`.
1166
+ *
1167
+ * `get_signals` carries `pagination: PaginationResponse` and no `query_summary`.
1168
+ * Pagination is not recomputed on the merge. `PaginationResponse.total_count`
1169
+ * is optional; recomputing it on partial pages would mis-represent the
1170
+ * cross-page total (the handler may have served page 1 of N, and the seeded
1171
+ * fixture sits outside that pagination context). Storyboards asserting on
1172
+ * totals should seed the handler's response, not the post-merge envelope.
1173
+ */
1174
+ function mergeSeededSignalsIntoResponse(response, seeded) {
1175
+ if (!seeded.length)
1176
+ return response;
1177
+ const seededKeys = new Set();
1178
+ for (const s of seeded) {
1179
+ const key = signalIdDedupKey(s);
1180
+ if (key)
1181
+ seededKeys.add(key);
1182
+ }
1183
+ const existing = Array.isArray(response.signals) ? response.signals : [];
1184
+ const retained = existing.filter(s => {
1185
+ const key = s ? signalIdDedupKey(s) : undefined;
1186
+ return key == null || !seededKeys.has(key);
1187
+ });
1188
+ const merged = {
1189
+ ...response,
1190
+ signals: [...retained, ...seeded],
1191
+ };
1192
+ if (response.sandbox !== false) {
1193
+ merged.sandbox = true;
1194
+ }
1195
+ return merged;
1196
+ }
1197
+ /**
1198
+ * Merge seeded creative-delivery entries into a `get_creative_delivery`
1199
+ * response. Existing handler creatives come first; seeded entries append after
1200
+ * deduping by `creative_id`. On collision the seeded entry wins (matches the
1201
+ * precedent set by `mergeSeededMediaBuyDelivery` — storyboards seed
1202
+ * deliberately, so a seeded fixture for an existing id is an explicit author
1203
+ * override).
1204
+ *
1205
+ * `pagination.total` (optional per the AdCP 3.0.11 schema) is incremented by
1206
+ * the count of new non-colliding seeded entries. There is no top-level
1207
+ * aggregated-totals envelope on this response (unlike `get_media_buy_delivery`),
1208
+ * so no further recomputation is performed. Stamps `sandbox: true` unless the
1209
+ * handler explicitly declared `sandbox: false`. Returns a NEW response object.
1210
+ */
1211
+ function mergeSeededCreativeDeliveryIntoResponse(response, seeded) {
1212
+ if (!seeded.length)
1213
+ return response;
1214
+ const seededIds = new Set();
1215
+ for (const c of seeded)
1216
+ seededIds.add(c.creative_id);
1217
+ const existing = Array.isArray(response.creatives) ? response.creatives : [];
1218
+ const existingIds = new Set();
1219
+ for (const c of existing) {
1220
+ if (c && typeof c.creative_id === 'string')
1221
+ existingIds.add(c.creative_id);
1222
+ }
1223
+ const retained = existing.filter(c => !seededIds.has(c?.creative_id));
1224
+ let newCount = 0;
1225
+ for (const c of seeded)
1226
+ if (!existingIds.has(c.creative_id))
1227
+ newCount += 1;
1228
+ const merged = {
1229
+ ...response,
1230
+ creatives: [...retained, ...seeded],
1231
+ };
1232
+ if (response.sandbox !== false) {
1233
+ merged.sandbox = true;
1234
+ }
1235
+ // `pagination.total` is the schema-correct field name on
1236
+ // GetCreativeDeliveryResponse (distinct from `pagination.total_count` used
1237
+ // by list_creatives / get_media_buys / list_accounts).
1238
+ const pagination = response.pagination;
1239
+ if (pagination && typeof pagination === 'object' && typeof pagination.total === 'number') {
1240
+ merged.pagination = {
1241
+ ...pagination,
1242
+ total: pagination.total + newCount,
1243
+ };
1244
+ }
1245
+ return merged;
1246
+ }
1247
+ /**
1248
+ * Merge seeded creative-feature results into a `get_creative_features`
1249
+ * response. The response is a `oneOf` envelope — success arm carries
1250
+ * `results: CreativeFeatureResult[]`, error arm carries `errors: Error[]`.
1251
+ * When the handler returned the error arm, this helper is a no-op; the error
1252
+ * envelope passes through unchanged. When the handler returned the success
1253
+ * arm, seeded results merge into the `results` array (dedup by `feature_id`,
1254
+ * seeded wins on collision).
1255
+ *
1256
+ * Framework-managed envelope fields (`context`, `ext`, `detail_url`,
1257
+ * `pricing_option_id`, `vendor_cost`, `currency`, `consumption`) round-trip
1258
+ * from the handler verbatim — the bridge only augments the per-feature
1259
+ * results array.
1260
+ *
1261
+ * Returns a NEW response object.
1262
+ */
1263
+ function mergeSeededCreativeFeaturesIntoResponse(response, seeded) {
1264
+ if (!seeded.length)
1265
+ return response;
1266
+ // Discriminate the success vs error arms. The error arm carries `errors`
1267
+ // and no `results`; the success arm carries `results` (required per spec).
1268
+ const successArm = response;
1269
+ if (!Array.isArray(successArm.results))
1270
+ return response;
1271
+ const seededIds = new Set();
1272
+ for (const r of seeded)
1273
+ seededIds.add(r.feature_id);
1274
+ const existing = successArm.results;
1275
+ const retained = existing.filter(r => !seededIds.has(r?.feature_id));
1276
+ const merged = {
1277
+ ...successArm,
1278
+ results: [...retained, ...seeded],
1279
+ };
1280
+ return merged;
1281
+ }
1282
+ // ---------------------------------------------------------------------------
1283
+ // Brand rights / sponsored intelligence
1284
+ //
1285
+ // `get_brand_identity` and `si_get_offering` are stateless singleton lookups
1286
+ // keyed by a top-level request id; the bridge picks the matching seeded
1287
+ // fixture and REPLACES the handler response body, preserving framework-managed
1288
+ // `context` / `ext`. `get_rights` is a discovery / search tool with an array
1289
+ // response; the bridge appends seeded entries with dedup by `rights_id`,
1290
+ // matching the `getSeededProducts` precedent.
1291
+ //
1292
+ // Mutating siblings (`acquire_rights`, `update_rights`, `si_initiate_session`,
1293
+ // `si_send_message`, `si_terminate_session`) are intentionally NOT bridged —
1294
+ // seeded read paths only.
1295
+ // ---------------------------------------------------------------------------
1296
+ /**
1297
+ * Validate seeded brand-identity entries. Drops entries missing a non-empty
1298
+ * string `brand_id`, and warn-drops duplicates (first occurrence wins) since
1299
+ * a fixture array with two entries for the same `brand_id` is almost always
1300
+ * a seed-store bug.
1301
+ */
1302
+ function filterValidSeededBrandIdentity(raw, logger) {
1303
+ if (!Array.isArray(raw)) {
1304
+ logger?.warn('testController.getSeededBrandIdentity did not return an array; skipping bridge', {
1305
+ received: typeof raw,
1306
+ });
1307
+ return [];
1308
+ }
1309
+ const valid = [];
1310
+ const seen = new Set();
1311
+ raw.forEach((entry, index) => {
1312
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
1313
+ logger?.warn('testController.getSeededBrandIdentity entry is not an object; dropping', { index });
1314
+ return;
1315
+ }
1316
+ const brandId = entry.brand_id;
1317
+ if (typeof brandId !== 'string' || brandId.length === 0) {
1318
+ logger?.warn('testController.getSeededBrandIdentity entry missing brand_id; dropping', { index });
1319
+ return;
1320
+ }
1321
+ if (seen.has(brandId)) {
1322
+ logger?.warn('testController.getSeededBrandIdentity duplicate brand_id; dropping (first occurrence wins)', {
1323
+ index,
1324
+ brand_id: brandId,
1325
+ });
1326
+ return;
1327
+ }
1328
+ seen.add(brandId);
1329
+ valid.push(entry);
1330
+ });
1331
+ return valid;
1332
+ }
1333
+ /**
1334
+ * Validate seeded rights entries. Drops entries missing a non-empty string
1335
+ * `rights_id` (the dedup key against the handler set).
1336
+ */
1337
+ function filterValidSeededRights(raw, logger) {
1338
+ return filterValidById(raw, 'rights_id', 'getSeededRights', logger);
1339
+ }
1340
+ /**
1341
+ * Validate seeded SI offering entries. Each entry must carry a non-empty
1342
+ * string at `offering.offering_id` — that's the field the bridge matches
1343
+ * against `request.offering_id`. Warn-drops duplicates by that key.
1344
+ *
1345
+ * An entry without `offering` (e.g. an `available: false` fixture from a
1346
+ * different storyboard) can't be matched by the bridge and is dropped — a
1347
+ * seeded fixture that can't address a request would be a dead write.
1348
+ */
1349
+ function filterValidSeededSiOffering(raw, logger) {
1350
+ if (!Array.isArray(raw)) {
1351
+ logger?.warn('testController.getSeededSiOffering did not return an array; skipping bridge', {
1352
+ received: typeof raw,
1353
+ });
1354
+ return [];
1355
+ }
1356
+ const valid = [];
1357
+ const seen = new Set();
1358
+ raw.forEach((entry, index) => {
1359
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
1360
+ logger?.warn('testController.getSeededSiOffering entry is not an object; dropping', { index });
1361
+ return;
1362
+ }
1363
+ const offering = entry.offering;
1364
+ const offeringId = offering && typeof offering === 'object' && !Array.isArray(offering)
1365
+ ? offering.offering_id
1366
+ : undefined;
1367
+ if (typeof offeringId !== 'string' || offeringId.length === 0) {
1368
+ logger?.warn('testController.getSeededSiOffering entry missing offering.offering_id; dropping', { index });
1369
+ return;
1370
+ }
1371
+ if (seen.has(offeringId)) {
1372
+ logger?.warn('testController.getSeededSiOffering duplicate offering.offering_id; dropping (first occurrence wins)', { index, offering_id: offeringId });
1373
+ return;
1374
+ }
1375
+ seen.add(offeringId);
1376
+ valid.push(entry);
1377
+ });
1378
+ return valid;
1379
+ }
1380
+ /**
1381
+ * Merge seeded rights entries into a `get_rights` response (success arm).
1382
+ * Existing entries come first; seeded entries append after deduping by
1383
+ * `rights_id`. On collision the seeded entry wins. Returns a NEW response
1384
+ * object. `get_rights` does not carry `pagination` / `query_summary` blocks
1385
+ * per AdCP 3.0.11 — no count fields to update.
1386
+ *
1387
+ * Drops to a no-op if the response is the error arm (no `rights` array). The
1388
+ * dispatcher gates on `!isErrorResponse` upstream; we re-narrow defensively.
1389
+ */
1390
+ function mergeSeededRightsIntoResponse(response, seeded) {
1391
+ if (!seeded.length)
1392
+ return response;
1393
+ const successArm = response;
1394
+ if (!Array.isArray(successArm.rights))
1395
+ return response;
1396
+ const seededIds = new Set();
1397
+ for (const r of seeded)
1398
+ seededIds.add(r.rights_id);
1399
+ const existing = successArm.rights;
1400
+ const retained = existing.filter(r => !seededIds.has(r?.rights_id));
1401
+ return { ...response, rights: [...retained, ...seeded] };
1402
+ }
1403
+ /**
1404
+ * Read the `brand_id` from a `get_brand_identity` request. Required field per
1405
+ * spec; defensive about runtime input.
1406
+ */
1407
+ function readRequestBrandId(req) {
1408
+ const id = req?.brand_id;
1409
+ return typeof id === 'string' && id.length > 0 ? id : undefined;
1410
+ }
1411
+ /**
1412
+ * Pick the seeded brand-identity entry whose `brand_id` matches the request.
1413
+ * Returns `undefined` when nothing matches.
1414
+ */
1415
+ function pickSeededBrandIdentityForRequest(request, seeded) {
1416
+ if (!seeded.length)
1417
+ return undefined;
1418
+ const requestedId = readRequestBrandId(request);
1419
+ if (requestedId == null)
1420
+ return undefined;
1421
+ for (const entry of seeded) {
1422
+ if (entry.brand_id === requestedId)
1423
+ return entry;
1424
+ }
1425
+ return undefined;
1426
+ }
1427
+ /**
1428
+ * Replace a `get_brand_identity` response with a seeded fixture when one
1429
+ * matches the request's `brand_id`. Seeded fixture is authoritative on the
1430
+ * `GetBrandIdentitySuccess` body. Framework-managed envelope fields
1431
+ * (`context`, `ext`) round-trip from the handler — matches the precedent set
1432
+ * by {@link replaceContentStandardsIfSeeded}.
1433
+ *
1434
+ * When no fixture matches, returns the handler response unchanged. Caller
1435
+ * is responsible for skipping the error arm (the dispatcher gates on
1436
+ * `!isErrorResponse`).
1437
+ */
1438
+ function replaceBrandIdentityIfSeeded(request, response, seeded) {
1439
+ const picked = pickSeededBrandIdentityForRequest(request, seeded);
1440
+ if (!picked)
1441
+ return response;
1442
+ const handlerContext = response.context;
1443
+ const handlerExt = response.ext;
1444
+ const replaced = { ...picked };
1445
+ if (handlerContext !== undefined)
1446
+ replaced.context = handlerContext;
1447
+ if (handlerExt !== undefined)
1448
+ replaced.ext = handlerExt;
1449
+ return replaced;
1450
+ }
1451
+ /**
1452
+ * Read the `offering_id` from an `si_get_offering` request. Required field
1453
+ * per spec; defensive about runtime input.
1454
+ */
1455
+ function readRequestOfferingId(req) {
1456
+ const id = req?.offering_id;
1457
+ return typeof id === 'string' && id.length > 0 ? id : undefined;
1458
+ }
1459
+ /**
1460
+ * Pick the seeded SI offering entry whose `offering.offering_id` matches the
1461
+ * request's `offering_id`. Returns `undefined` when nothing matches.
1462
+ */
1463
+ function pickSeededSiOfferingForRequest(request, seeded) {
1464
+ if (!seeded.length)
1465
+ return undefined;
1466
+ const requestedId = readRequestOfferingId(request);
1467
+ if (requestedId == null)
1468
+ return undefined;
1469
+ for (const entry of seeded) {
1470
+ const id = entry.offering?.offering_id;
1471
+ if (typeof id === 'string' && id === requestedId)
1472
+ return entry;
1473
+ }
1474
+ return undefined;
1475
+ }
1476
+ /**
1477
+ * Replace an `si_get_offering` response with a seeded fixture when one
1478
+ * matches the request's `offering_id`. Seeded fixture is authoritative on
1479
+ * the offering body (`available`, `offering_token`, `offering`, ...);
1480
+ * handler's `context` and `ext` round-trip — same envelope-preservation
1481
+ * policy as {@link replaceBrandIdentityIfSeeded}.
1482
+ *
1483
+ * When no fixture matches, returns the handler response unchanged.
1484
+ *
1485
+ * Note: seeded fixture is authoritative on the entire offering body,
1486
+ * including `offering_token`. The token is intentionally NOT preserved
1487
+ * from the handler — storyboards seed `offering_token` to drive
1488
+ * deterministic session continuation in downstream steps. When/if a
1489
+ * stateful SI session bridge ships (phase 5+; see
1490
+ * adcontextprotocol/adcp-client#1755), the storyboard contract becomes
1491
+ * coupled: seeded `offering_token` here must match the seeded session's
1492
+ * `offering_token`, or `si_initiate_session` will reject as stale-token.
1493
+ */
1494
+ function replaceSiOfferingIfSeeded(request, response, seeded) {
1495
+ const picked = pickSeededSiOfferingForRequest(request, seeded);
1496
+ if (!picked)
1497
+ return response;
1498
+ const handlerContext = response.context;
1499
+ const handlerExt = response.ext;
1500
+ const replaced = { ...picked };
1501
+ if (handlerContext !== undefined)
1502
+ replaced.context = handlerContext;
1503
+ if (handlerExt !== undefined)
1504
+ replaced.ext = handlerExt;
1505
+ return replaced;
1506
+ }
119
1507
  /**
120
1508
  * Bridge the default test-controller store (a `Map<string, unknown>` that
121
1509
  * holds seeded fixtures by `product_id`, populated by `seed_product` scenarios)
@@ -185,8 +1573,14 @@ function bridgeFromTestControllerStore(store, productDefaults = {}) {
185
1573
  * ```
186
1574
  */
187
1575
  function bridgeFromSessionStore(opts) {
188
- const { loadSession, selectSeededProducts, productDefaults = {} } = opts;
189
- return {
1576
+ const { loadSession, selectSeededProducts, productDefaults = {}, selectSeededCreatives, selectSeededMediaBuys, selectSeededMediaBuyDelivery, selectSeededAccounts, selectSeededAccountFinancials, selectSeededCreativeFormats, selectSeededPropertyLists, selectSeededContentStandards, selectSeededCollectionLists, selectSeededSignals, selectSeededCreativeDelivery, selectSeededCreativeFeatures, selectSeededBrandIdentity, selectSeededRights, selectSeededSiOffering, } = opts;
1577
+ // Each per-tool callback resolves the session per-request (no memoisation —
1578
+ // same contract as the original `getSeededProducts` path) and awaits the
1579
+ // selector so callers can lazy-load seed collections on the selector path.
1580
+ // Selectors are wired only when the adopter provided them; absent selectors
1581
+ // leave the bridge callback omitted (opt-in by presence on the bridge
1582
+ // interface, same shape as `getSeededProducts`).
1583
+ const bridge = {
190
1584
  getSeededProducts: async (ctx) => {
191
1585
  const session = await loadSession(ctx.input);
192
1586
  const entries = await selectSeededProducts(session);
@@ -203,5 +1597,111 @@ function bridgeFromSessionStore(opts) {
203
1597
  return out;
204
1598
  },
205
1599
  };
1600
+ if (selectSeededCreatives) {
1601
+ bridge.getSeededCreatives = async (ctx) => {
1602
+ const session = await loadSession(ctx.input);
1603
+ const entries = await selectSeededCreatives(session);
1604
+ return entries ? Array.from(entries) : [];
1605
+ };
1606
+ }
1607
+ if (selectSeededMediaBuys) {
1608
+ bridge.getSeededMediaBuys = async (ctx) => {
1609
+ const session = await loadSession(ctx.input);
1610
+ const entries = await selectSeededMediaBuys(session);
1611
+ return entries ? Array.from(entries) : [];
1612
+ };
1613
+ }
1614
+ if (selectSeededMediaBuyDelivery) {
1615
+ bridge.getSeededMediaBuyDelivery = async (ctx) => {
1616
+ const session = await loadSession(ctx.input);
1617
+ const entries = await selectSeededMediaBuyDelivery(session);
1618
+ return entries ? Array.from(entries) : [];
1619
+ };
1620
+ }
1621
+ if (selectSeededAccounts) {
1622
+ bridge.getSeededAccounts = async (ctx) => {
1623
+ const session = await loadSession(ctx.input);
1624
+ const entries = await selectSeededAccounts(session);
1625
+ return entries ? Array.from(entries) : [];
1626
+ };
1627
+ }
1628
+ if (selectSeededAccountFinancials) {
1629
+ bridge.getSeededAccountFinancials = async (ctx) => {
1630
+ const session = await loadSession(ctx.input);
1631
+ const entries = await selectSeededAccountFinancials(session);
1632
+ return entries ? Array.from(entries) : [];
1633
+ };
1634
+ }
1635
+ if (selectSeededCreativeFormats) {
1636
+ bridge.getSeededCreativeFormats = async (ctx) => {
1637
+ const session = await loadSession(ctx.input);
1638
+ const entries = await selectSeededCreativeFormats(session);
1639
+ return entries ? Array.from(entries) : [];
1640
+ };
1641
+ }
1642
+ if (selectSeededPropertyLists) {
1643
+ bridge.getSeededPropertyLists = async (ctx) => {
1644
+ const session = await loadSession(ctx.input);
1645
+ const entries = await selectSeededPropertyLists(session);
1646
+ return entries ? Array.from(entries) : [];
1647
+ };
1648
+ }
1649
+ if (selectSeededContentStandards) {
1650
+ bridge.getSeededContentStandards = async (ctx) => {
1651
+ const session = await loadSession(ctx.input);
1652
+ const entries = await selectSeededContentStandards(session);
1653
+ return entries ? Array.from(entries) : [];
1654
+ };
1655
+ }
1656
+ if (selectSeededCollectionLists) {
1657
+ bridge.getSeededCollectionLists = async (ctx) => {
1658
+ const session = await loadSession(ctx.input);
1659
+ const entries = await selectSeededCollectionLists(session);
1660
+ return entries ? Array.from(entries) : [];
1661
+ };
1662
+ }
1663
+ if (selectSeededSignals) {
1664
+ bridge.getSeededSignals = async (ctx) => {
1665
+ const session = await loadSession(ctx.input);
1666
+ const entries = await selectSeededSignals(session);
1667
+ return entries ? Array.from(entries) : [];
1668
+ };
1669
+ }
1670
+ if (selectSeededCreativeDelivery) {
1671
+ bridge.getSeededCreativeDelivery = async (ctx) => {
1672
+ const session = await loadSession(ctx.input);
1673
+ const entries = await selectSeededCreativeDelivery(session);
1674
+ return entries ? Array.from(entries) : [];
1675
+ };
1676
+ }
1677
+ if (selectSeededCreativeFeatures) {
1678
+ bridge.getSeededCreativeFeatures = async (ctx) => {
1679
+ const session = await loadSession(ctx.input);
1680
+ const entries = await selectSeededCreativeFeatures(session);
1681
+ return entries ? Array.from(entries) : [];
1682
+ };
1683
+ }
1684
+ if (selectSeededBrandIdentity) {
1685
+ bridge.getSeededBrandIdentity = async (ctx) => {
1686
+ const session = await loadSession(ctx.input);
1687
+ const entries = await selectSeededBrandIdentity(session);
1688
+ return entries ? Array.from(entries) : [];
1689
+ };
1690
+ }
1691
+ if (selectSeededRights) {
1692
+ bridge.getSeededRights = async (ctx) => {
1693
+ const session = await loadSession(ctx.input);
1694
+ const entries = await selectSeededRights(session);
1695
+ return entries ? Array.from(entries) : [];
1696
+ };
1697
+ }
1698
+ if (selectSeededSiOffering) {
1699
+ bridge.getSeededSiOffering = async (ctx) => {
1700
+ const session = await loadSession(ctx.input);
1701
+ const entries = await selectSeededSiOffering(session);
1702
+ return entries ? Array.from(entries) : [];
1703
+ };
1704
+ }
1705
+ return bridge;
206
1706
  }
207
1707
  //# sourceMappingURL=test-controller-bridge.js.map