@adcp/sdk 6.7.0 → 6.8.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 (166) hide show
  1. package/bin/adcp.js +15 -3
  2. package/dist/lib/adapters/derived-account-store.d.ts +152 -0
  3. package/dist/lib/adapters/derived-account-store.d.ts.map +1 -0
  4. package/dist/lib/adapters/derived-account-store.js +135 -0
  5. package/dist/lib/adapters/derived-account-store.js.map +1 -0
  6. package/dist/lib/adapters/implicit-account-store.d.ts +3 -1
  7. package/dist/lib/adapters/implicit-account-store.d.ts.map +1 -1
  8. package/dist/lib/adapters/implicit-account-store.js +3 -1
  9. package/dist/lib/adapters/implicit-account-store.js.map +1 -1
  10. package/dist/lib/adapters/index.d.ts +1 -0
  11. package/dist/lib/adapters/index.d.ts.map +1 -1
  12. package/dist/lib/adapters/index.js +7 -1
  13. package/dist/lib/adapters/index.js.map +1 -1
  14. package/dist/lib/adapters/oauth-passthrough-resolver.d.ts +3 -1
  15. package/dist/lib/adapters/oauth-passthrough-resolver.d.ts.map +1 -1
  16. package/dist/lib/adapters/oauth-passthrough-resolver.js +3 -1
  17. package/dist/lib/adapters/oauth-passthrough-resolver.js.map +1 -1
  18. package/dist/lib/adapters/roster-account-store.d.ts +85 -24
  19. package/dist/lib/adapters/roster-account-store.d.ts.map +1 -1
  20. package/dist/lib/adapters/roster-account-store.js +52 -30
  21. package/dist/lib/adapters/roster-account-store.js.map +1 -1
  22. package/dist/lib/index.d.ts +3 -3
  23. package/dist/lib/index.d.ts.map +1 -1
  24. package/dist/lib/index.js +13 -8
  25. package/dist/lib/index.js.map +1 -1
  26. package/dist/lib/mock-server/creative-ad-server/seed-data.d.ts +81 -0
  27. package/dist/lib/mock-server/creative-ad-server/seed-data.d.ts.map +1 -0
  28. package/dist/lib/mock-server/creative-ad-server/seed-data.js +200 -0
  29. package/dist/lib/mock-server/creative-ad-server/seed-data.js.map +1 -0
  30. package/dist/lib/mock-server/creative-ad-server/server.d.ts +39 -0
  31. package/dist/lib/mock-server/creative-ad-server/server.d.ts.map +1 -0
  32. package/dist/lib/mock-server/creative-ad-server/server.js +618 -0
  33. package/dist/lib/mock-server/creative-ad-server/server.js.map +1 -0
  34. package/dist/lib/mock-server/index.d.ts.map +1 -1
  35. package/dist/lib/mock-server/index.js +180 -24
  36. package/dist/lib/mock-server/index.js.map +1 -1
  37. package/dist/lib/mock-server/sales-non-guaranteed/seed-data.d.ts +66 -0
  38. package/dist/lib/mock-server/sales-non-guaranteed/seed-data.d.ts.map +1 -0
  39. package/dist/lib/mock-server/sales-non-guaranteed/seed-data.js +193 -0
  40. package/dist/lib/mock-server/sales-non-guaranteed/seed-data.js.map +1 -0
  41. package/dist/lib/mock-server/sales-non-guaranteed/server.d.ts +33 -0
  42. package/dist/lib/mock-server/sales-non-guaranteed/server.d.ts.map +1 -0
  43. package/dist/lib/mock-server/sales-non-guaranteed/server.js +782 -0
  44. package/dist/lib/mock-server/sales-non-guaranteed/server.js.map +1 -0
  45. package/dist/lib/mock-server/sponsored-intelligence/seed-data.d.ts +50 -0
  46. package/dist/lib/mock-server/sponsored-intelligence/seed-data.d.ts.map +1 -0
  47. package/dist/lib/mock-server/sponsored-intelligence/seed-data.js +133 -0
  48. package/dist/lib/mock-server/sponsored-intelligence/seed-data.js.map +1 -0
  49. package/dist/lib/mock-server/sponsored-intelligence/server.d.ts +13 -0
  50. package/dist/lib/mock-server/sponsored-intelligence/server.d.ts.map +1 -0
  51. package/dist/lib/mock-server/sponsored-intelligence/server.js +609 -0
  52. package/dist/lib/mock-server/sponsored-intelligence/server.js.map +1 -0
  53. package/dist/lib/protocols/mcp.d.ts.map +1 -1
  54. package/dist/lib/protocols/mcp.js +1 -41
  55. package/dist/lib/protocols/mcp.js.map +1 -1
  56. package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
  57. package/dist/lib/server/account-mode.d.ts +113 -0
  58. package/dist/lib/server/account-mode.d.ts.map +1 -0
  59. package/dist/lib/server/account-mode.js +125 -0
  60. package/dist/lib/server/account-mode.js.map +1 -0
  61. package/dist/lib/server/adcp-server.js +41 -0
  62. package/dist/lib/server/adcp-server.js.map +1 -1
  63. package/dist/lib/server/auth.d.ts +35 -0
  64. package/dist/lib/server/auth.d.ts.map +1 -1
  65. package/dist/lib/server/auth.js.map +1 -1
  66. package/dist/lib/server/create-adcp-server.d.ts +26 -9
  67. package/dist/lib/server/create-adcp-server.d.ts.map +1 -1
  68. package/dist/lib/server/create-adcp-server.js +46 -20
  69. package/dist/lib/server/create-adcp-server.js.map +1 -1
  70. package/dist/lib/server/ctx-metadata/store.d.ts +1 -1
  71. package/dist/lib/server/ctx-metadata/store.d.ts.map +1 -1
  72. package/dist/lib/server/ctx-metadata/store.js +1 -0
  73. package/dist/lib/server/ctx-metadata/store.js.map +1 -1
  74. package/dist/lib/server/decisioning/account.d.ts +5 -0
  75. package/dist/lib/server/decisioning/account.d.ts.map +1 -1
  76. package/dist/lib/server/decisioning/account.js.map +1 -1
  77. package/dist/lib/server/decisioning/buyer-agent.d.ts +37 -4
  78. package/dist/lib/server/decisioning/buyer-agent.d.ts.map +1 -1
  79. package/dist/lib/server/decisioning/buyer-agent.js +12 -2
  80. package/dist/lib/server/decisioning/buyer-agent.js.map +1 -1
  81. package/dist/lib/server/decisioning/compose.d.ts +33 -2
  82. package/dist/lib/server/decisioning/compose.d.ts.map +1 -1
  83. package/dist/lib/server/decisioning/compose.js +13 -46
  84. package/dist/lib/server/decisioning/compose.js.map +1 -1
  85. package/dist/lib/server/decisioning/index.d.ts +2 -1
  86. package/dist/lib/server/decisioning/index.d.ts.map +1 -1
  87. package/dist/lib/server/decisioning/index.js +2 -1
  88. package/dist/lib/server/decisioning/index.js.map +1 -1
  89. package/dist/lib/server/decisioning/platform-helpers.d.ts +18 -0
  90. package/dist/lib/server/decisioning/platform-helpers.d.ts.map +1 -1
  91. package/dist/lib/server/decisioning/platform-helpers.js +20 -0
  92. package/dist/lib/server/decisioning/platform-helpers.js.map +1 -1
  93. package/dist/lib/server/decisioning/platform.d.ts +19 -21
  94. package/dist/lib/server/decisioning/platform.d.ts.map +1 -1
  95. package/dist/lib/server/decisioning/platform.js.map +1 -1
  96. package/dist/lib/server/decisioning/runtime/from-platform.d.ts.map +1 -1
  97. package/dist/lib/server/decisioning/runtime/from-platform.js +334 -44
  98. package/dist/lib/server/decisioning/runtime/from-platform.js.map +1 -1
  99. package/dist/lib/server/decisioning/runtime/observed-modes.d.ts +40 -0
  100. package/dist/lib/server/decisioning/runtime/observed-modes.d.ts.map +1 -0
  101. package/dist/lib/server/decisioning/runtime/observed-modes.js +82 -0
  102. package/dist/lib/server/decisioning/runtime/observed-modes.js.map +1 -0
  103. package/dist/lib/server/decisioning/runtime/protocol-for-tool.js +2 -2
  104. package/dist/lib/server/decisioning/runtime/protocol-for-tool.js.map +1 -1
  105. package/dist/lib/server/decisioning/runtime/validate-platform.d.ts.map +1 -1
  106. package/dist/lib/server/decisioning/runtime/validate-platform.js +9 -1
  107. package/dist/lib/server/decisioning/runtime/validate-platform.js.map +1 -1
  108. package/dist/lib/server/decisioning/specialisms/sponsored-intelligence.d.ts +125 -0
  109. package/dist/lib/server/decisioning/specialisms/sponsored-intelligence.d.ts.map +1 -0
  110. package/dist/lib/server/decisioning/specialisms/sponsored-intelligence.js +52 -0
  111. package/dist/lib/server/decisioning/specialisms/sponsored-intelligence.js.map +1 -0
  112. package/dist/lib/server/decisioning/tenant-registry.d.ts +16 -0
  113. package/dist/lib/server/decisioning/tenant-registry.d.ts.map +1 -1
  114. package/dist/lib/server/decisioning/tenant-registry.js.map +1 -1
  115. package/dist/lib/server/index.d.ts +4 -1
  116. package/dist/lib/server/index.d.ts.map +1 -1
  117. package/dist/lib/server/index.js +9 -3
  118. package/dist/lib/server/index.js.map +1 -1
  119. package/dist/lib/server/serve.js +20 -2
  120. package/dist/lib/server/serve.js.map +1 -1
  121. package/dist/lib/server/test-controller.d.ts +3 -0
  122. package/dist/lib/server/test-controller.d.ts.map +1 -1
  123. package/dist/lib/server/test-controller.js +23 -20
  124. package/dist/lib/server/test-controller.js.map +1 -1
  125. package/dist/lib/testing/comply-controller.d.ts +23 -2
  126. package/dist/lib/testing/comply-controller.d.ts.map +1 -1
  127. package/dist/lib/testing/comply-controller.js +19 -2
  128. package/dist/lib/testing/comply-controller.js.map +1 -1
  129. package/dist/lib/testing/index.d.ts +1 -1
  130. package/dist/lib/testing/index.d.ts.map +1 -1
  131. package/dist/lib/testing/index.js.map +1 -1
  132. package/dist/lib/testing/storyboard/validations.d.ts.map +1 -1
  133. package/dist/lib/testing/storyboard/validations.js +36 -54
  134. package/dist/lib/testing/storyboard/validations.js.map +1 -1
  135. package/dist/lib/testing/test-controller.d.ts +10 -4
  136. package/dist/lib/testing/test-controller.d.ts.map +1 -1
  137. package/dist/lib/testing/test-controller.js +9 -3
  138. package/dist/lib/testing/test-controller.js.map +1 -1
  139. package/dist/lib/types/index.d.ts +3 -2
  140. package/dist/lib/types/index.d.ts.map +1 -1
  141. package/dist/lib/types/index.js +3 -0
  142. package/dist/lib/types/index.js.map +1 -1
  143. package/dist/lib/utils/glob.d.ts +4 -2
  144. package/dist/lib/utils/glob.d.ts.map +1 -1
  145. package/dist/lib/utils/glob.js +4 -2
  146. package/dist/lib/utils/glob.js.map +1 -1
  147. package/dist/lib/version.d.ts +3 -3
  148. package/dist/lib/version.js +3 -3
  149. package/docs/llms.txt +2 -2
  150. package/examples/README.md +29 -13
  151. package/examples/hello-cluster.ts +62 -23
  152. package/examples/hello_creative_adapter_ad_server.ts +790 -0
  153. package/examples/hello_seller_adapter_guaranteed.ts +80 -22
  154. package/examples/hello_seller_adapter_non_guaranteed.ts +1020 -0
  155. package/examples/hello_si_adapter_brand.ts +572 -0
  156. package/package.json +3 -2
  157. package/skills/build-creative-agent/SKILL.md +103 -183
  158. package/skills/build-generative-seller-agent/SKILL.md +15 -9
  159. package/skills/build-governance-agent/SKILL.md +20 -11
  160. package/skills/build-retail-media-agent/SKILL.md +10 -8
  161. package/skills/build-seller-agent/SKILL.md +15 -13
  162. package/skills/build-seller-agent/specialisms/sales-non-guaranteed.md +9 -31
  163. package/skills/build-si-agent/SKILL.md +251 -196
  164. package/skills/build-signals-agent/SKILL.md +2 -0
  165. package/skills/call-adcp-agent/SKILL.md +7 -1
  166. package/skills/call-adcp-agent.previous/SKILL.md +0 -261
@@ -0,0 +1,1020 @@
1
+ /**
2
+ * hello_seller_adapter_non_guaranteed — worked starting point for an
3
+ * AdCP non-guaranteed sales agent (specialism `sales-non-guaranteed`)
4
+ * that wraps an upstream programmatic-auction platform with sync
5
+ * confirmation over static Bearer.
6
+ *
7
+ * Closes #1458 (sub-issue of #1381 umbrella). Closest neighbor in the
8
+ * worked-reference family is `hello_seller_adapter_guaranteed.ts` —
9
+ * this adapter is the deletion-fork (rip out HITL approval, sync
10
+ * confirmation throughout). The auction shape applies to DSP-side
11
+ * sellers, retail-media remnant, header-bidding inventory, and any
12
+ * non-walled-garden seller.
13
+ *
14
+ * Headline behavior: `create_media_buy` returns `media_buy_id` synchronously
15
+ * on `success` — auction is immediate, no IO-review task. Floor-priced
16
+ * products (`pricing_options[].fixed_price = product.min_cpm`); pacing
17
+ * propagated to upstream order; spend-only forecast surfaced inline.
18
+ *
19
+ * Fork this. Replace `upstream` with calls to your real backend. The
20
+ * AdCP-facing platform methods stay the same.
21
+ *
22
+ * Auction mode is the deletion-fork of the guaranteed sibling: `createMediaBuy`
23
+ * returns sync, no `ctx.handoffToTask`, no IO poll loop, no task envelope.
24
+ * If your backend has HITL approval, fork the guaranteed example instead.
25
+ *
26
+ * FORK CHECKLIST
27
+ * 1. Replace every `// SWAP:` marker with calls to your backend.
28
+ * 2. Replace `KNOWN_PUBLISHERS` with your tenant directory.
29
+ * 3. Replace `projectProduct()` defaults — `publisher_properties` selector,
30
+ * `pricing_options[]`, `reporting_capabilities` — with values your
31
+ * seller actually commits to.
32
+ * 4. Replace `advertiserId = networkCode` collapse with a real lookup
33
+ * (production splits network and advertiser ids).
34
+ * 5. Validate: `node --test test/examples/hello-seller-adapter-non-guaranteed.test.js`
35
+ * 6. **DELETE the `// TEST-ONLY` blocks** before deploying:
36
+ * - sandbox-arm in `accounts.resolve` (resolves storyboard runner's
37
+ * synthetic `{brand, sandbox: true}` refs to a known network and
38
+ * stamps `mode: 'sandbox'` on the returned Account so the framework
39
+ * gate admits the comply controller)
40
+ * - `complyTest:` config block on `createAdcpServerFromPlatform`
41
+ * - in-memory `seededMediaBuys` / `simulatedDelivery` / `adapterStatusOverrides`
42
+ * These exist so the conformance harness can drive cascade scenarios
43
+ * deterministically. Production sellers ship without them.
44
+ *
45
+ * Demo:
46
+ * npx @adcp/sdk@latest mock-server sales-non-guaranteed --port 4451
47
+ * UPSTREAM_URL=http://127.0.0.1:4451 \
48
+ * npx tsx examples/hello_seller_adapter_non_guaranteed.ts
49
+ * adcp storyboard run http://127.0.0.1:3007/mcp sales_non_guaranteed \
50
+ * --auth sk_harness_do_not_use_in_prod
51
+ * curl http://127.0.0.1:4451/_debug/traffic
52
+ */
53
+
54
+ import {
55
+ createAdcpServerFromPlatform,
56
+ serve,
57
+ verifyApiKey,
58
+ createIdempotencyStore,
59
+ createUpstreamHttpClient,
60
+ memoryBackend,
61
+ AdcpError,
62
+ createMediaBuyStore,
63
+ InMemoryStateStore,
64
+ type DecisioningPlatform,
65
+ type SalesCorePlatform,
66
+ type SalesIngestionPlatform,
67
+ type AccountStore,
68
+ type Account,
69
+ type SyncCreativesRow,
70
+ type SyncAccountsResultRow,
71
+ } from '@adcp/sdk/server';
72
+ import type {
73
+ GetProductsRequest,
74
+ GetProductsResponse,
75
+ CreateMediaBuyRequest,
76
+ CreateMediaBuySuccess,
77
+ UpdateMediaBuyRequest,
78
+ UpdateMediaBuySuccess,
79
+ GetMediaBuysRequest,
80
+ GetMediaBuysResponse,
81
+ GetMediaBuyDeliveryRequest,
82
+ GetMediaBuyDeliveryResponse,
83
+ ListCreativeFormatsResponse,
84
+ } from '@adcp/sdk/types';
85
+
86
+ // `Product` isn't re-exported from `@adcp/sdk/types`; derive from response.
87
+ type Product = GetProductsResponse['products'][number];
88
+
89
+ const UPSTREAM_URL = process.env['UPSTREAM_URL'] ?? 'http://127.0.0.1:4451';
90
+ const UPSTREAM_API_KEY = process.env['UPSTREAM_API_KEY'] ?? 'mock_sales_non_guaranteed_key_do_not_use_in_prod';
91
+ const PORT = Number(process.env['PORT'] ?? 3007);
92
+ const ADCP_AUTH_TOKEN = process.env['ADCP_AUTH_TOKEN'] ?? 'sk_harness_do_not_use_in_prod';
93
+ const PUBLIC_AGENT_URL = process.env['PUBLIC_AGENT_URL'] ?? `http://127.0.0.1:${PORT}`;
94
+
95
+ const KNOWN_PUBLISHERS = ['remnant-network.example', 'acmeoutdoor.example', 'pinnacle-agency.example'];
96
+
97
+ // TEST-ONLY: id-prefix used by the sandbox arm in `accounts.resolve` so
98
+ // production sellers don't need this; remove the sandbox arm + this
99
+ // constant before deploying. See FORK CHECKLIST item 6.
100
+ const SANDBOX_ID_PREFIX = 'sandbox_';
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Upstream client — SWAP for production.
104
+ // ---------------------------------------------------------------------------
105
+
106
+ interface UpstreamNetwork {
107
+ network_code: string;
108
+ display_name: string;
109
+ adcp_publisher: string;
110
+ }
111
+
112
+ interface UpstreamProductPricing {
113
+ min_cpm: number;
114
+ target_cpm?: number;
115
+ currency: string;
116
+ min_spend?: number;
117
+ }
118
+
119
+ interface UpstreamForecastPoint {
120
+ budget?: number;
121
+ metrics: {
122
+ impressions?: { low: number; mid: number; high: number };
123
+ clicks?: { low: number; mid: number; high: number };
124
+ spend?: { low: number; mid: number; high: number };
125
+ };
126
+ }
127
+
128
+ interface UpstreamForecast {
129
+ product_id: string;
130
+ forecast_range_unit: 'spend';
131
+ method: 'modeled';
132
+ currency: string;
133
+ points: UpstreamForecastPoint[];
134
+ min_budget_warning?: { required: number; reason: string };
135
+ }
136
+
137
+ interface UpstreamProduct {
138
+ product_id: string;
139
+ name: string;
140
+ network_code: string;
141
+ delivery_type: 'non_guaranteed';
142
+ channel: 'video' | 'ctv' | 'display' | 'audio';
143
+ format_ids: string[];
144
+ ad_unit_ids: string[];
145
+ pricing: UpstreamProductPricing;
146
+ forecast?: UpstreamForecast;
147
+ }
148
+
149
+ interface UpstreamLineItem {
150
+ line_item_id: string;
151
+ order_id: string;
152
+ product_id: string;
153
+ status: 'ready' | 'paused' | 'delivering' | 'completed';
154
+ budget: number;
155
+ ad_unit_targeting: string[];
156
+ creative_ids: string[];
157
+ }
158
+
159
+ interface UpstreamOrder {
160
+ order_id: string;
161
+ network_code: string;
162
+ name: string;
163
+ status: 'confirmed' | 'delivering' | 'completed' | 'canceled' | 'rejected';
164
+ advertiser_id: string;
165
+ currency: string;
166
+ budget: number;
167
+ pacing: 'even' | 'asap' | 'front_loaded';
168
+ flight_start?: string;
169
+ flight_end?: string;
170
+ rejection_reason?: string;
171
+ line_items?: UpstreamLineItem[];
172
+ created_at?: string;
173
+ updated_at?: string;
174
+ replayed?: boolean;
175
+ }
176
+
177
+ interface UpstreamCreative {
178
+ creative_id: string;
179
+ network_code: string;
180
+ name: string;
181
+ format_id: string;
182
+ advertiser_id: string;
183
+ status: 'active' | 'paused' | 'archived';
184
+ }
185
+
186
+ interface UpstreamDelivery {
187
+ order_id: string;
188
+ currency: string;
189
+ pacing: 'even' | 'asap' | 'front_loaded';
190
+ reporting_period: { start?: string; end?: string };
191
+ totals: { impressions: number; clicks: number; spend: number; budget_remaining: number };
192
+ line_items: Array<{
193
+ line_item_id: string;
194
+ product_id: string;
195
+ impressions: number;
196
+ clicks: number;
197
+ spend: number;
198
+ currency: string;
199
+ effective_cpm: number;
200
+ pacing_pct: number;
201
+ }>;
202
+ }
203
+
204
+ const http = createUpstreamHttpClient({
205
+ baseUrl: UPSTREAM_URL,
206
+ auth: { kind: 'static_bearer', token: UPSTREAM_API_KEY },
207
+ });
208
+
209
+ const networkHeader = (networkCode: string): Record<string, string> => ({ 'X-Network-Code': networkCode });
210
+
211
+ const upstream = {
212
+ // SWAP: tenant lookup. Mock exposes /_lookup; production typically a
213
+ // network registry / service-account scope endpoint.
214
+ async lookupNetwork(publisherDomain: string): Promise<UpstreamNetwork | null> {
215
+ const { body } = await http.get<UpstreamNetwork>('/_lookup/network', { adcp_publisher: publisherDomain });
216
+ return body;
217
+ },
218
+
219
+ // SWAP: product catalog. Mock filters by network_code via header + optional
220
+ // ?channel. When `flight_start`/`flight_end`/`budget` are passed, the mock
221
+ // returns per-product `forecast` inline — single round-trip instead of N
222
+ // follow-up `/v1/forecast` calls.
223
+ async listProducts(
224
+ networkCode: string,
225
+ opts?: {
226
+ channel?: 'video' | 'ctv' | 'display' | 'audio';
227
+ flightStart?: string;
228
+ flightEnd?: string;
229
+ budget?: number;
230
+ }
231
+ ): Promise<UpstreamProduct[]> {
232
+ const params: Record<string, string> = {};
233
+ if (opts?.channel) params['channel'] = opts.channel;
234
+ if (opts?.flightStart) params['flight_start'] = opts.flightStart;
235
+ if (opts?.flightEnd) params['flight_end'] = opts.flightEnd;
236
+ if (opts?.budget !== undefined) params['budget'] = String(opts.budget);
237
+ const { body } = await http.get<{ products: UpstreamProduct[] }>(
238
+ '/v1/products',
239
+ params,
240
+ networkHeader(networkCode)
241
+ );
242
+ return body?.products ?? [];
243
+ },
244
+
245
+ // SWAP: per-product forecast. Use this when your backend separates the
246
+ // catalog and forecast surfaces. For the worked-mock case we fold forecast
247
+ // into `listProducts` above; this method shows the discrete shape.
248
+ async getForecast(
249
+ networkCode: string,
250
+ body: {
251
+ product_id: string;
252
+ targeting?: Record<string, unknown>;
253
+ flight_dates?: { start?: string; end?: string };
254
+ budget?: number;
255
+ }
256
+ ): Promise<UpstreamForecast | null> {
257
+ const r = await http.post<UpstreamForecast>('/v1/forecast', body, networkHeader(networkCode));
258
+ return r.body;
259
+ },
260
+
261
+ // SWAP: list orders. Returns { orders: [...] }.
262
+ async listOrders(networkCode: string): Promise<UpstreamOrder[]> {
263
+ const { body } = await http.get<{ orders: UpstreamOrder[] }>('/v1/orders', undefined, networkHeader(networkCode));
264
+ return body?.orders ?? [];
265
+ },
266
+
267
+ // SWAP: sync create. Mock returns 201 with status='confirmed'. No HITL
268
+ // task — auction-cleared programmatic. `client_request_id` round-trips
269
+ // to upstream for at-most-once execution; replay returns same order_id
270
+ // with `replayed: true`.
271
+ async createOrder(
272
+ networkCode: string,
273
+ body: {
274
+ name: string;
275
+ advertiser_id: string;
276
+ currency: string;
277
+ budget: number;
278
+ pacing?: 'even' | 'asap' | 'front_loaded';
279
+ flight_start?: string;
280
+ flight_end?: string;
281
+ line_items?: Array<{ product_id: string; budget: number }>;
282
+ client_request_id?: string;
283
+ }
284
+ ): Promise<UpstreamOrder> {
285
+ const r = await http.post<UpstreamOrder>('/v1/orders', body, networkHeader(networkCode));
286
+ if (r.body === null) {
287
+ throw new AdcpError('INVALID_REQUEST', { message: 'order creation rejected by upstream' });
288
+ }
289
+ return r.body;
290
+ },
291
+
292
+ async getOrder(networkCode: string, orderId: string): Promise<UpstreamOrder | null> {
293
+ const { body } = await http.get<UpstreamOrder>(
294
+ `/v1/orders/${encodeURIComponent(orderId)}`,
295
+ undefined,
296
+ networkHeader(networkCode)
297
+ );
298
+ return body;
299
+ },
300
+
301
+ async listLineItems(networkCode: string, orderId: string): Promise<UpstreamLineItem[]> {
302
+ const { body } = await http.get<{ line_items: UpstreamLineItem[] }>(
303
+ `/v1/orders/${encodeURIComponent(orderId)}/lineitems`,
304
+ undefined,
305
+ networkHeader(networkCode)
306
+ );
307
+ return body?.line_items ?? [];
308
+ },
309
+
310
+ async createLineItem(
311
+ networkCode: string,
312
+ orderId: string,
313
+ body: { product_id: string; budget: number; ad_unit_ids?: string[]; client_request_id?: string }
314
+ ): Promise<{ line_item_id: string }> {
315
+ const r = await http.post<{ line_item_id: string }>(
316
+ `/v1/orders/${encodeURIComponent(orderId)}/lineitems`,
317
+ body,
318
+ networkHeader(networkCode)
319
+ );
320
+ if (r.body === null) {
321
+ throw new AdcpError('INVALID_REQUEST', { message: 'line item creation rejected by upstream' });
322
+ }
323
+ return r.body;
324
+ },
325
+
326
+ async createCreative(
327
+ networkCode: string,
328
+ body: { name: string; format_id: string; advertiser_id: string; client_request_id?: string }
329
+ ): Promise<UpstreamCreative> {
330
+ const r = await http.post<UpstreamCreative>('/v1/creatives', body, networkHeader(networkCode));
331
+ if (r.body === null) {
332
+ throw new AdcpError('INVALID_REQUEST', { message: 'creative creation rejected by upstream' });
333
+ }
334
+ return r.body;
335
+ },
336
+
337
+ async getDelivery(networkCode: string, orderId: string): Promise<UpstreamDelivery | null> {
338
+ const { body } = await http.get<UpstreamDelivery>(
339
+ `/v1/orders/${encodeURIComponent(orderId)}/delivery`,
340
+ undefined,
341
+ networkHeader(networkCode)
342
+ );
343
+ return body;
344
+ },
345
+ };
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // AdCP-side adapter — typed against SalesCorePlatform & SalesIngestionPlatform.
349
+ // ---------------------------------------------------------------------------
350
+
351
+ interface NetworkMeta {
352
+ network_code: string;
353
+ publisher_domain: string;
354
+ [key: string]: unknown;
355
+ }
356
+
357
+ const FORMAT_AGENT_URL = PUBLIC_AGENT_URL;
358
+
359
+ /** Project upstream product onto AdCP `Product`. Auction-cleared inventory
360
+ * surfaces `min_cpm` as the `pricing_options[].fixed_price` (floor); buyers
361
+ * bid at or above. Production sellers can layer `auction` pricing models or
362
+ * deal-id-keyed alternative pricing options on top. */
363
+ function projectProduct(p: UpstreamProduct, publisherDomain: string): Product {
364
+ return {
365
+ product_id: p.product_id,
366
+ name: p.name,
367
+ description: `${p.name} — programmatic remnant ${p.channel}`,
368
+ publisher_properties: [{ publisher_domain: publisherDomain, selection_type: 'all' }],
369
+ channels: [
370
+ p.channel === 'video'
371
+ ? 'olv'
372
+ : p.channel === 'ctv'
373
+ ? 'ctv'
374
+ : p.channel === 'audio'
375
+ ? 'streaming_audio'
376
+ : 'display',
377
+ ],
378
+ format_ids: p.format_ids.map(id => ({ agent_url: FORMAT_AGENT_URL, id })),
379
+ delivery_type: 'non_guaranteed',
380
+ pricing_options: [
381
+ {
382
+ pricing_option_id: 'cpm_floor',
383
+ pricing_model: 'cpm',
384
+ currency: p.pricing.currency,
385
+ // Floor pricing — sellers accept any bid ≥ this. Auction-cleared
386
+ // effective CPM lands somewhere between `min_cpm` and `target_cpm`
387
+ // depending on bid pressure; we surface the floor as the firm
388
+ // commitment.
389
+ fixed_price: p.pricing.min_cpm,
390
+ ...(p.pricing.min_spend !== undefined && { min_spend: p.pricing.min_spend }),
391
+ },
392
+ ],
393
+ reporting_capabilities: {
394
+ available_reporting_frequencies: ['daily'],
395
+ expected_delay_minutes: 60,
396
+ timezone: 'UTC',
397
+ supports_webhooks: false,
398
+ available_metrics: ['impressions', 'clicks', 'spend'],
399
+ date_range_support: 'date_range',
400
+ },
401
+ // Pass through per-query forecast verbatim — mock returns AdCP-shape
402
+ // already (`points`, `metrics.{impressions,clicks,spend}.{low,mid,high}`,
403
+ // `forecast_range_unit: 'spend'`, `method: 'modeled'`). Real auction-
404
+ // backed sellers (FreeWheel, Magnite SSP) need adapter-side translation.
405
+ ...(p.forecast && { forecast: p.forecast }),
406
+ };
407
+ }
408
+
409
+ function mapMediaBuyStatus(
410
+ orderStatus: UpstreamOrder['status']
411
+ ): 'pending_creatives' | 'pending_start' | 'active' | 'paused' | 'completed' | 'canceled' {
412
+ switch (orderStatus) {
413
+ case 'delivering':
414
+ return 'active';
415
+ case 'completed':
416
+ return 'completed';
417
+ case 'canceled':
418
+ case 'rejected':
419
+ return 'canceled';
420
+ case 'confirmed':
421
+ default:
422
+ // Auction-cleared but no creatives attached yet → pending_creatives.
423
+ // Buyer transitions to `active` after sync_creatives lands and the
424
+ // first impression delivers. Framework surfaces the status change
425
+ // via publishStatusChange on resource_type: 'media_buy'.
426
+ return 'pending_creatives';
427
+ }
428
+ }
429
+
430
+ // Status overrides persisted at the adapter level. Real backends mutate the
431
+ // upstream Order on creative-attach (the line-item status flips, the order
432
+ // status flips with it). The mock doesn't model that transition; we track
433
+ // the override here so `get_media_buys` returns the advanced status the
434
+ // storyboard validates. SWAP: drop this map and read upstream state — your
435
+ // backend already persists what we're tracking here.
436
+ //
437
+ // Keyed by `<network_code>::<order_id>` so a forking adopter who keeps the
438
+ // map for in-flight scenarios doesn't accidentally surface tenant A's
439
+ // override on tenant B's `media_buy_id` collision (mock IDs are 32 bits).
440
+ const adapterStatusOverrides = new Map<string, 'pending_start' | 'active'>();
441
+ const overrideKey = (networkCode: string, orderId: string): string => `${networkCode}::${orderId}`;
442
+
443
+ class SalesNonGuaranteedAdapter implements DecisioningPlatform<Record<string, never>, NetworkMeta> {
444
+ capabilities = {
445
+ specialisms: ['sales-non-guaranteed'] as const,
446
+ channels: ['olv', 'ctv', 'display', 'streaming_audio'] as const,
447
+ pricingModels: ['cpm'] as const,
448
+ config: {},
449
+ compliance_testing: {
450
+ scenarios: ['force_media_buy_status', 'simulate_delivery'] as const,
451
+ },
452
+ };
453
+
454
+ accounts: AccountStore<NetworkMeta> = {
455
+ resolve: async ref => {
456
+ if (!ref) return null;
457
+ // SWAP: persist `account_id → network_code` during sync_accounts and
458
+ // serve account_id lookups from there.
459
+ if ('account_id' in ref) return null;
460
+
461
+ // ─── TEST-ONLY: cascade-scenario sandbox-arm ─────────────────────
462
+ // DELETE THIS BLOCK BEFORE DEPLOYING. The compliance runner injects
463
+ // synthetic refs like `{brand: {domain: 'test.example'}, sandbox: true}`
464
+ // for `media_buy_seller/*` cascade scenarios. Routes those refs to a
465
+ // fixed seeded network so the rest of the adapter has something
466
+ // concrete to operate on. Production sellers either reject sandbox
467
+ // refs entirely or route them to a dedicated sandbox tenant.
468
+ //
469
+ // Gate is `ref.sandbox === true` from the wire `AccountReference`.
470
+ // Stamping `mode: 'sandbox'` on the returned `Account` is what admits
471
+ // the framework's `comply_test_controller` gate — see #1435 phase 2/3.
472
+ // No env var is consulted; production traffic that doesn't set
473
+ // `sandbox: true` on the wire never hits this branch.
474
+ if (ref.sandbox === true) {
475
+ const sandboxDomain = 'acmeoutdoor.example';
476
+ const network = await upstream.lookupNetwork(sandboxDomain);
477
+ if (!network) return null;
478
+ return {
479
+ id: `${SANDBOX_ID_PREFIX}${network.network_code}`,
480
+ name: `Sandbox: ${network.display_name}`,
481
+ status: 'active',
482
+ mode: 'sandbox',
483
+ ...(ref.operator !== undefined && { operator: ref.operator }),
484
+ brand: { domain: ref.brand.domain ?? sandboxDomain },
485
+ ctx_metadata: { network_code: network.network_code, publisher_domain: network.adcp_publisher },
486
+ };
487
+ }
488
+ // ─── /TEST-ONLY ──────────────────────────────────────────────────
489
+
490
+ const publisherDomain = ref.brand.domain;
491
+ if (!publisherDomain) return null;
492
+ const network = await upstream.lookupNetwork(publisherDomain);
493
+ if (!network) return null;
494
+ const operator = ref.operator;
495
+ return {
496
+ id: network.network_code,
497
+ name: network.display_name,
498
+ status: 'active',
499
+ ...(operator !== undefined && { operator }),
500
+ brand: { domain: network.adcp_publisher },
501
+ ctx_metadata: { network_code: network.network_code, publisher_domain: network.adcp_publisher },
502
+ };
503
+ },
504
+
505
+ upsert: async refs => {
506
+ const out: SyncAccountsResultRow[] = [];
507
+ for (const ref of refs) {
508
+ if ('account_id' in ref) {
509
+ out.push({
510
+ brand: { domain: '' },
511
+ operator: '',
512
+ action: 'failed',
513
+ status: 'rejected',
514
+ errors: [{ code: 'INVALID_REQUEST', message: 'sync_accounts requires brand+operator, not account_id' }],
515
+ });
516
+ continue;
517
+ }
518
+ const domain = ref.brand.domain;
519
+ const operator = ref.operator;
520
+ const network = domain ? await upstream.lookupNetwork(domain) : null;
521
+ if (!network) {
522
+ out.push({
523
+ brand: { domain },
524
+ operator,
525
+ action: 'failed',
526
+ status: 'rejected',
527
+ errors: [{ code: 'ACCOUNT_NOT_FOUND', message: `No publisher network registered for ${domain}` }],
528
+ });
529
+ continue;
530
+ }
531
+ out.push({
532
+ account_id: network.network_code,
533
+ name: network.display_name,
534
+ brand: { domain: network.adcp_publisher },
535
+ operator,
536
+ action: 'unchanged',
537
+ status: 'active',
538
+ });
539
+ }
540
+ return out;
541
+ },
542
+
543
+ list: async () => {
544
+ const items: Array<Account<NetworkMeta>> = [];
545
+ for (const domain of KNOWN_PUBLISHERS) {
546
+ const n = await upstream.lookupNetwork(domain);
547
+ if (!n) continue;
548
+ items.push({
549
+ id: n.network_code,
550
+ name: n.display_name,
551
+ status: 'active',
552
+ brand: { domain: n.adcp_publisher },
553
+ ctx_metadata: { network_code: n.network_code, publisher_domain: n.adcp_publisher },
554
+ });
555
+ }
556
+ return { items, has_more: false };
557
+ },
558
+ };
559
+
560
+ // Required: explicit `SalesCorePlatform<Meta> & SalesIngestionPlatform<Meta>`
561
+ // annotation per migration recipe #11 — `defineSalesPlatform` widens to
562
+ // all-optional and `RequiredPlatformsFor<'sales-non-guaranteed'>` requires
563
+ // the closed shape on the way out.
564
+ sales: SalesCorePlatform<NetworkMeta> & SalesIngestionPlatform<NetworkMeta> = {
565
+ getProducts: async (req: GetProductsRequest, ctx): Promise<GetProductsResponse> => {
566
+ const networkCode = ctx.account.ctx_metadata.network_code;
567
+ const publisherDomain = ctx.account.ctx_metadata.publisher_domain;
568
+ // When the buyer provides structured filters (flight dates, budget),
569
+ // forward them so each product comes back with a per-query forecast.
570
+ // Single upstream round-trip surfaces both the catalog and the
571
+ // forecast curve.
572
+ const briefBudget = (req.filters?.budget_range as { max?: number } | undefined)?.max;
573
+ const products = await upstream.listProducts(networkCode, {
574
+ ...(req.filters?.start_date && { flightStart: req.filters.start_date }),
575
+ ...(req.filters?.end_date && { flightEnd: req.filters.end_date }),
576
+ ...(briefBudget !== undefined && { budget: briefBudget }),
577
+ });
578
+ return { products: products.map(p => projectProduct(p, publisherDomain)) };
579
+ },
580
+
581
+ /**
582
+ * Sync `create_media_buy`. Returns `media_buy_id` on the sync-success
583
+ * arm immediately — auction-cleared inventory has no IO-review step.
584
+ * `idempotency_key` round-trips to upstream `client_request_id`.
585
+ */
586
+ createMediaBuy: async (req: CreateMediaBuyRequest, ctx) => {
587
+ const networkCode = ctx.account.ctx_metadata.network_code;
588
+ // SWAP: production splits network_code (publisher tenant) and
589
+ // advertiser_id (brand seat). Mock collapses both — real DSPs/SSPs
590
+ // resolve advertiser_id from `req.brand` against the publisher's
591
+ // advertiser directory.
592
+ const advertiserId = networkCode;
593
+
594
+ const totalBudget =
595
+ req.total_budget?.amount ??
596
+ (req.packages ?? []).reduce((s, p) => s + ((p as { budget?: number }).budget ?? 0), 0);
597
+ const currency = req.total_budget?.currency ?? 'USD';
598
+
599
+ // Budget-floor enforcement happens upstream — the mock returns
600
+ // budget_too_low if any line item is below product.min_spend.
601
+ // Adopters who want to enforce client-side (skip the upstream call
602
+ // for an obvious reject) can iterate `req.packages` and check
603
+ // against a cached product catalog before calling the upstream.
604
+
605
+ const packagesRequest = (req.packages ?? []) as Array<{
606
+ product_id?: string;
607
+ budget?: number;
608
+ }>;
609
+
610
+ // Build the line_items array up front so the upstream POST is one
611
+ // round-trip. Production splits this when line-item validation needs
612
+ // to happen per-LI server-side; the mock accepts the array inline.
613
+ const lineItemsBody: Array<{ product_id: string; budget: number }> = [];
614
+ for (let i = 0; i < packagesRequest.length; i++) {
615
+ const pkg = packagesRequest[i];
616
+ if (!pkg) continue;
617
+ if (!pkg.product_id) {
618
+ throw new AdcpError('INVALID_REQUEST', {
619
+ message: `package[${i}]: product_id required`,
620
+ field: `packages[${i}].product_id`,
621
+ });
622
+ }
623
+ lineItemsBody.push({ product_id: pkg.product_id, budget: pkg.budget ?? 0 });
624
+ }
625
+
626
+ // SWAP: pacing extraction — production reads from req.packages[i].pacing
627
+ // or req.delivery_settings, varying by your contract surface. AdCP 3.0.5
628
+ // doesn't carry an order-level `pacing` on the wire — the mock accepts
629
+ // it because real platforms (Meta, TTD, etc.) all expose pacing on
630
+ // their own non-guaranteed shape. Default 'even' if unspecified;
631
+ // reject typos rather than silently passing them through.
632
+ const PACING_VALUES = ['even', 'asap', 'front_loaded'] as const;
633
+ const rawPacing = (req as { pacing?: unknown }).pacing;
634
+ let pacing: (typeof PACING_VALUES)[number] = 'even';
635
+ if (typeof rawPacing === 'string') {
636
+ if (!(PACING_VALUES as readonly string[]).includes(rawPacing)) {
637
+ throw new AdcpError('INVALID_REQUEST', {
638
+ message: `pacing must be one of ${PACING_VALUES.join(', ')} (got: ${rawPacing})`,
639
+ field: 'pacing',
640
+ recovery: 'correctable',
641
+ });
642
+ }
643
+ pacing = rawPacing as (typeof PACING_VALUES)[number];
644
+ }
645
+
646
+ let order: UpstreamOrder;
647
+ try {
648
+ order = await upstream.createOrder(networkCode, {
649
+ name: `MediaBuy from ${req.brand?.domain ?? 'unknown'}`,
650
+ advertiser_id: advertiserId,
651
+ currency,
652
+ budget: totalBudget,
653
+ pacing,
654
+ ...(req.start_time && { flight_start: req.start_time }),
655
+ ...(req.end_time && { flight_end: req.end_time }),
656
+ line_items: lineItemsBody,
657
+ client_request_id: req.idempotency_key,
658
+ });
659
+ } catch (e) {
660
+ // Surface upstream-typed error bodies as AdcpError with the
661
+ // appropriate code. Mock returns `code: 'budget_too_low'` /
662
+ // `code: 'product_not_found'` / etc.; we map a few of these
663
+ // explicitly so adopters see typed errors at the buyer boundary.
664
+ if (e instanceof AdcpError) throw e;
665
+ throw new AdcpError('SERVICE_UNAVAILABLE', {
666
+ message: (e as Error).message ?? 'upstream order creation failed',
667
+ recovery: 'transient',
668
+ });
669
+ }
670
+
671
+ // Project the response. The upstream order carries `line_items[]`
672
+ // already (since we created them inline); each maps to a wire
673
+ // `package`.
674
+ const packagesOut: CreateMediaBuySuccess['packages'] = (order.line_items ?? []).map((li, i) => ({
675
+ package_id: li.line_item_id,
676
+ product_id: li.product_id,
677
+ budget: li.budget,
678
+ // Re-thread the buyer's package_id if supplied — adopters who
679
+ // care about preserving buyer-side ids should round-trip them
680
+ // here. SWAP: persist the mapping.
681
+ ...(packagesRequest[i] !== undefined &&
682
+ (packagesRequest[i] as { buyer_ref?: string }).buyer_ref !== undefined && {
683
+ buyer_ref: (packagesRequest[i] as { buyer_ref?: string }).buyer_ref,
684
+ }),
685
+ }));
686
+
687
+ return {
688
+ media_buy_id: order.order_id,
689
+ // pending_creatives — buy is auction-confirmed but no creatives
690
+ // attached yet. Buyer transitions to `active` after sync_creatives.
691
+ status: 'pending_creatives',
692
+ confirmed_at: order.created_at ?? new Date().toISOString(),
693
+ packages: packagesOut,
694
+ };
695
+ },
696
+
697
+ updateMediaBuy: async (id: string, patch: UpdateMediaBuyRequest, ctx): Promise<UpdateMediaBuySuccess> => {
698
+ const networkCode = ctx.account.ctx_metadata.network_code;
699
+ // Validate existence first — production sellers PATCH the upstream
700
+ // order to apply pacing / budget / status changes; the worked
701
+ // example echoes the current state for clarity. SWAP: wire your
702
+ // backend's order-mutation endpoint here.
703
+ const existing = await upstream.getOrder(networkCode, id);
704
+ if (!existing) {
705
+ throw new AdcpError('MEDIA_BUY_NOT_FOUND', {
706
+ message: `media_buy ${id} not found in this seller's network`,
707
+ recovery: 'terminal',
708
+ });
709
+ }
710
+ // Validate any patch.packages reference real line items. Storyboard
711
+ // exercises bogus package_id and asserts PACKAGE_NOT_FOUND on the wire.
712
+ const patchPackages = (
713
+ patch as {
714
+ packages?: Array<{ package_id?: string; creative_assignments?: unknown[] }>;
715
+ }
716
+ ).packages;
717
+ let hasCreativeAssignment = false;
718
+ if (patchPackages?.length) {
719
+ const lineItems = await upstream.listLineItems(networkCode, id);
720
+ const knownPackageIds = new Set(lineItems.map(li => li.line_item_id));
721
+ for (const p of patchPackages) {
722
+ if (p.package_id && !knownPackageIds.has(p.package_id)) {
723
+ throw new AdcpError('PACKAGE_NOT_FOUND', {
724
+ message: `Package ${p.package_id} not found in media buy ${id}`,
725
+ field: 'packages.package_id',
726
+ recovery: 'terminal',
727
+ });
728
+ }
729
+ if (Array.isArray(p.creative_assignments) && p.creative_assignments.length > 0) {
730
+ hasCreativeAssignment = true;
731
+ }
732
+ }
733
+ }
734
+ // Status advances when the buyer attaches creatives — pending_creatives
735
+ // → pending_start (or active if the flight already started). Production
736
+ // backends would also persist the assignment to the upstream line item;
737
+ // the worked example just advances the response state.
738
+ const baseStatus = mapMediaBuyStatus(existing.status);
739
+ let nextStatus: ReturnType<typeof mapMediaBuyStatus> =
740
+ adapterStatusOverrides.get(overrideKey(networkCode, id)) ?? baseStatus;
741
+ if (hasCreativeAssignment && nextStatus === 'pending_creatives') {
742
+ const flightStarted = existing.flight_start !== undefined && Date.parse(existing.flight_start) <= Date.now();
743
+ nextStatus = flightStarted ? 'active' : 'pending_start';
744
+ adapterStatusOverrides.set(overrideKey(networkCode, id), nextStatus);
745
+ }
746
+ return {
747
+ media_buy_id: existing.order_id,
748
+ status: nextStatus,
749
+ };
750
+ },
751
+
752
+ getMediaBuyDelivery: async (req: GetMediaBuyDeliveryRequest, ctx): Promise<GetMediaBuyDeliveryResponse> => {
753
+ const networkCode = ctx.account.ctx_metadata.network_code;
754
+ const requestedIds = req.media_buy_ids ?? [];
755
+ // Multi-id pass-through per #1342 contract — fan out per id; framework
756
+ // dev-mode warn (post-#1410) fires automatically if we accidentally
757
+ // truncate to ids[0]. Filter undefined results so an unknown id
758
+ // produces no row rather than an error (matches upstream semantics).
759
+ const deliveries = await Promise.all(
760
+ requestedIds.map(async id => {
761
+ const d = await upstream.getDelivery(networkCode, id);
762
+ if (!d) return null;
763
+ return {
764
+ media_buy_id: d.order_id,
765
+ currency: d.currency,
766
+ reporting_period: d.reporting_period,
767
+ totals: d.totals,
768
+ packages: d.line_items.map(li => ({
769
+ package_id: li.line_item_id,
770
+ product_id: li.product_id,
771
+ impressions: li.impressions,
772
+ clicks: li.clicks,
773
+ spend: li.spend,
774
+ currency: li.currency,
775
+ })),
776
+ };
777
+ })
778
+ );
779
+ const filtered = deliveries.filter((d): d is NonNullable<typeof d> => d !== null);
780
+ // Surface a debugging-friendly trace when buyers ask about ids the
781
+ // upstream doesn't know — silently dropping rows looks like delivery
782
+ // simply hasn't started yet, which buries the actual error.
783
+ const missing = requestedIds.filter((_, i) => deliveries[i] === null);
784
+ if (missing.length > 0) {
785
+ // eslint-disable-next-line no-console
786
+ console.warn(
787
+ `[sales-non-guaranteed] get_media_buy_delivery: ${missing.length} unknown media_buy_id(s) returned no delivery rows: ${missing.join(', ')}`
788
+ );
789
+ }
790
+ const response: GetMediaBuyDeliveryResponse = {
791
+ currency: filtered[0]?.currency ?? 'USD',
792
+ reporting_period: {
793
+ start: filtered[0]?.reporting_period.start ?? new Date().toISOString(),
794
+ end: filtered[0]?.reporting_period.end ?? new Date().toISOString(),
795
+ },
796
+ aggregated_totals: {
797
+ impressions: filtered.reduce((s, d) => s + d.totals.impressions, 0),
798
+ spend: filtered.reduce((s, d) => s + d.totals.spend, 0),
799
+ clicks: filtered.reduce((s, d) => s + d.totals.clicks, 0),
800
+ media_buy_count: filtered.length,
801
+ },
802
+ media_buy_deliveries: filtered.map(d => ({
803
+ media_buy_id: d.media_buy_id,
804
+ // Required field on media_buy_deliveries[i]. Auction inventory in
805
+ // active delivery defaults to 'active'; production sellers map
806
+ // upstream order state through `mapMediaBuyStatus` like elsewhere.
807
+ status: 'active' as const,
808
+ totals: d.totals,
809
+ by_package: d.packages.map(p => ({
810
+ package_id: p.package_id,
811
+ impressions: p.impressions,
812
+ spend: p.spend,
813
+ })),
814
+ })),
815
+ };
816
+ return response;
817
+ },
818
+
819
+ getMediaBuys: async (req: GetMediaBuysRequest, ctx): Promise<GetMediaBuysResponse> => {
820
+ const networkCode = ctx.account.ctx_metadata.network_code;
821
+ const requestedIds = req.media_buy_ids ?? [];
822
+ let orders: UpstreamOrder[];
823
+ if (requestedIds.length > 0) {
824
+ orders = (await Promise.all(requestedIds.map(id => upstream.getOrder(networkCode, id)))).filter(
825
+ (o): o is UpstreamOrder => o !== null
826
+ );
827
+ } else {
828
+ orders = await upstream.listOrders(networkCode);
829
+ }
830
+ // Fetch per-order line items. Mock's GET /v1/orders/{id} omits them;
831
+ // GAM-style backends similarly split Order vs LineItem services. SWAP:
832
+ // batch via `?include=lineitems` if your platform supports it.
833
+ const media_buys = await Promise.all(
834
+ orders.map(async o => {
835
+ const lineItems = await upstream.listLineItems(networkCode, o.order_id);
836
+ const baseStatus = mapMediaBuyStatus(o.status);
837
+ const status = adapterStatusOverrides.get(overrideKey(networkCode, o.order_id)) ?? baseStatus;
838
+ return {
839
+ media_buy_id: o.order_id,
840
+ status,
841
+ currency: o.currency,
842
+ ...(o.budget !== undefined && { total_budget: o.budget }),
843
+ ...(o.updated_at !== undefined && { confirmed_at: o.updated_at }),
844
+ ...(o.created_at !== undefined && { created_at: o.created_at }),
845
+ ...(o.updated_at !== undefined && { updated_at: o.updated_at }),
846
+ ...(o.flight_start && { start_time: o.flight_start }),
847
+ ...(o.flight_end && { end_time: o.flight_end }),
848
+ packages: lineItems.map(li => ({
849
+ package_id: li.line_item_id,
850
+ product_id: li.product_id,
851
+ budget: li.budget,
852
+ currency: o.currency,
853
+ })),
854
+ };
855
+ })
856
+ );
857
+ const response: GetMediaBuysResponse = { media_buys };
858
+ return response;
859
+ },
860
+
861
+ syncCreatives: async (creatives, ctx) => {
862
+ const networkCode = ctx.account.ctx_metadata.network_code;
863
+ const advertiserId = networkCode; // SWAP: same collapse caveat as createMediaBuy.
864
+ const out: SyncCreativesRow[] = [];
865
+ for (const c of creatives) {
866
+ const formatRef = (c as { format_id?: { id?: string } | string }).format_id;
867
+ const formatId = typeof formatRef === 'string' ? formatRef : formatRef?.id;
868
+ if (!formatId) {
869
+ out.push({
870
+ creative_id: (c as { creative_id?: string }).creative_id ?? 'unknown',
871
+ action: 'failed',
872
+ status: 'rejected',
873
+ errors: [{ code: 'CREATIVE_REJECTED', message: 'format_id is required' }],
874
+ });
875
+ continue;
876
+ }
877
+ try {
878
+ const creativeIdHint = (c as { creative_id?: string }).creative_id;
879
+ const created = await upstream.createCreative(networkCode, {
880
+ name: (c as { name?: string }).name ?? 'Untitled',
881
+ format_id: formatId,
882
+ advertiser_id: advertiserId,
883
+ ...(creativeIdHint !== undefined && { client_request_id: creativeIdHint }),
884
+ });
885
+ out.push({
886
+ creative_id: created.creative_id,
887
+ action: 'created',
888
+ status: 'approved',
889
+ });
890
+ } catch (e) {
891
+ out.push({
892
+ creative_id: (c as { creative_id?: string }).creative_id ?? 'unknown',
893
+ action: 'failed',
894
+ status: 'rejected',
895
+ errors: [
896
+ { code: 'CREATIVE_REJECTED', message: (e as Error).message ?? 'upstream creative creation failed' },
897
+ ],
898
+ });
899
+ }
900
+ }
901
+ return out;
902
+ },
903
+
904
+ listCreativeFormats: async (_req, _ctx): Promise<ListCreativeFormatsResponse> => {
905
+ // Publisher-owned format catalog. The mock doesn't have a discrete
906
+ // formats endpoint (formats live inline on Product); production sellers
907
+ // typically expose `/v1/formats` separately. SWAP: replace with your
908
+ // backend's format catalog.
909
+ return {
910
+ formats: [
911
+ {
912
+ format_id: { agent_url: FORMAT_AGENT_URL, id: 'display_300x250' },
913
+ name: 'Display 300x250 (medrec)',
914
+ renders: [{ role: 'main', dimensions: { width: 300, height: 250, unit: 'px' } }],
915
+ },
916
+ {
917
+ format_id: { agent_url: FORMAT_AGENT_URL, id: 'display_728x90' },
918
+ name: 'Display 728x90 (leaderboard)',
919
+ renders: [{ role: 'main', dimensions: { width: 728, height: 90, unit: 'px' } }],
920
+ },
921
+ {
922
+ format_id: { agent_url: FORMAT_AGENT_URL, id: 'video_30s' },
923
+ name: 'Video 30s outstream / instream',
924
+ renders: [{ role: 'main', dimensions: { width: 1920, height: 1080, unit: 'px' } }],
925
+ },
926
+ {
927
+ format_id: { agent_url: FORMAT_AGENT_URL, id: 'video_15s' },
928
+ name: 'Video 15s',
929
+ renders: [{ role: 'main', dimensions: { width: 1920, height: 1080, unit: 'px' } }],
930
+ },
931
+ ],
932
+ };
933
+ },
934
+ };
935
+ }
936
+
937
+ // ---------------------------------------------------------------------------
938
+ // Bootstrap.
939
+ // ---------------------------------------------------------------------------
940
+
941
+ const platform = new SalesNonGuaranteedAdapter();
942
+ const idempotencyStore = createIdempotencyStore({ backend: memoryBackend(), ttlSeconds: 86_400 });
943
+
944
+ // Persist `packages[].targeting_overlay` from create_media_buy and echo it
945
+ // on get_media_buys. Required for any seller claiming property-lists /
946
+ // collection-lists. SWAP `InMemoryStateStore` for `PostgresStateStore` in
947
+ // production — in-memory loss after restart silently strips the echo.
948
+ const stateStore = new InMemoryStateStore();
949
+ const mediaBuyStore = createMediaBuyStore({ store: stateStore });
950
+
951
+ // ─── TEST-ONLY: comply-controller in-memory state ───────────────────────
952
+ // DELETE BEFORE DEPLOYING. Module-scope maps shared across accounts; only
953
+ // the conformance harness reaches them via the gate below.
954
+ const seededMediaBuys = new Map<string, { status: string; revision: number }>();
955
+ const simulatedDelivery = new Map<
956
+ string,
957
+ { impressions: number; clicks: number; reported_spend: { amount: number; currency: string } }
958
+ >();
959
+ // ─── /TEST-ONLY ──────────────────────────────────────────────────────────
960
+
961
+ serve(
962
+ ({ taskStore }) =>
963
+ createAdcpServerFromPlatform(platform, {
964
+ name: 'hello-seller-adapter-non-guaranteed',
965
+ version: '1.0.0',
966
+ taskStore,
967
+ idempotency: idempotencyStore,
968
+ mediaBuyStore,
969
+ resolveSessionKey: ctx => {
970
+ const acct = ctx.account as Account<NetworkMeta> | undefined;
971
+ return acct?.id ?? 'anonymous';
972
+ },
973
+ // ─── TEST-ONLY: comply_test_controller wiring ──────────────────────
974
+ // DELETE BEFORE DEPLOYING. The framework auto-gates on the resolved
975
+ // account's `mode === 'sandbox'` (see `accounts.resolve` synthesis arm
976
+ // above) — adapters no longer carry their own gate callback. #1435 phase 3.
977
+ complyTest: {
978
+ seed: {
979
+ media_buy: ({ media_buy_id, fixture }) => {
980
+ const existing = seededMediaBuys.get(media_buy_id);
981
+ const status = typeof fixture['status'] === 'string' ? (fixture['status'] as string) : 'pending_creatives';
982
+ seededMediaBuys.set(media_buy_id, { status, revision: existing?.revision ?? 0 });
983
+ },
984
+ },
985
+ force: {
986
+ media_buy_status: ({ media_buy_id, status, rejection_reason }) => {
987
+ const buy = seededMediaBuys.get(media_buy_id);
988
+ const previous = buy?.status ?? 'pending_creatives';
989
+ seededMediaBuys.set(media_buy_id, { status, revision: (buy?.revision ?? 0) + 1 });
990
+ void rejection_reason;
991
+ return { success: true, previous_state: previous, current_state: status };
992
+ },
993
+ },
994
+ simulate: {
995
+ delivery: ({ media_buy_id, impressions, clicks, reported_spend }) => {
996
+ const prev = simulatedDelivery.get(media_buy_id) ?? {
997
+ impressions: 0,
998
+ clicks: 0,
999
+ reported_spend: { amount: 0, currency: 'USD' },
1000
+ };
1001
+ simulatedDelivery.set(media_buy_id, {
1002
+ impressions: prev.impressions + (impressions ?? 0),
1003
+ clicks: prev.clicks + (clicks ?? 0),
1004
+ reported_spend: reported_spend ?? prev.reported_spend,
1005
+ });
1006
+ return { success: true, simulated: { media_buy_id, impressions, clicks, reported_spend } };
1007
+ },
1008
+ },
1009
+ },
1010
+ // ─── /TEST-ONLY ──────────────────────────────────────────────────
1011
+ }),
1012
+ {
1013
+ port: PORT,
1014
+ authenticate: verifyApiKey({
1015
+ keys: { [ADCP_AUTH_TOKEN]: { principal: 'compliance-runner' } },
1016
+ }),
1017
+ }
1018
+ );
1019
+
1020
+ console.log(`sales-non-guaranteed adapter on http://127.0.0.1:${PORT}/mcp · upstream: ${UPSTREAM_URL}`);