@adcp/sdk 6.11.0 → 6.12.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 (95) hide show
  1. package/bin/adcp-config.js +7 -1
  2. package/bin/adcp.js +191 -4
  3. package/dist/lib/adapters/index.d.ts +0 -2
  4. package/dist/lib/adapters/index.d.ts.map +1 -1
  5. package/dist/lib/adapters/index.js +1 -8
  6. package/dist/lib/adapters/index.js.map +1 -1
  7. package/dist/lib/index.d.ts +1 -1
  8. package/dist/lib/index.d.ts.map +1 -1
  9. package/dist/lib/index.js +2 -7
  10. package/dist/lib/index.js.map +1 -1
  11. package/dist/lib/mock-server/index.d.ts +2 -0
  12. package/dist/lib/mock-server/index.d.ts.map +1 -1
  13. package/dist/lib/mock-server/index.js +17 -0
  14. package/dist/lib/mock-server/index.js.map +1 -1
  15. package/dist/lib/mock-server/sales-guaranteed/recipe.d.ts +155 -0
  16. package/dist/lib/mock-server/sales-guaranteed/recipe.d.ts.map +1 -0
  17. package/dist/lib/mock-server/sales-guaranteed/recipe.js +107 -0
  18. package/dist/lib/mock-server/sales-guaranteed/recipe.js.map +1 -0
  19. package/dist/lib/mock-server/sales-guaranteed/server.d.ts.map +1 -1
  20. package/dist/lib/mock-server/sales-guaranteed/server.js +212 -0
  21. package/dist/lib/mock-server/sales-guaranteed/server.js.map +1 -1
  22. package/dist/lib/mock-server/sales-non-guaranteed/recipe.d.ts +123 -0
  23. package/dist/lib/mock-server/sales-non-guaranteed/recipe.d.ts.map +1 -0
  24. package/dist/lib/mock-server/sales-non-guaranteed/recipe.js +81 -0
  25. package/dist/lib/mock-server/sales-non-guaranteed/recipe.js.map +1 -0
  26. package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
  27. package/dist/lib/server/ctx-metadata/index.d.ts +1 -1
  28. package/dist/lib/server/ctx-metadata/index.d.ts.map +1 -1
  29. package/dist/lib/server/ctx-metadata/index.js +3 -1
  30. package/dist/lib/server/ctx-metadata/index.js.map +1 -1
  31. package/dist/lib/server/ctx-metadata/wire-shape.d.ts +21 -0
  32. package/dist/lib/server/ctx-metadata/wire-shape.d.ts.map +1 -1
  33. package/dist/lib/server/ctx-metadata/wire-shape.js +111 -0
  34. package/dist/lib/server/ctx-metadata/wire-shape.js.map +1 -1
  35. package/dist/lib/server/decisioning/context.d.ts +19 -0
  36. package/dist/lib/server/decisioning/context.d.ts.map +1 -1
  37. package/dist/lib/server/decisioning/index.d.ts +3 -0
  38. package/dist/lib/server/decisioning/index.d.ts.map +1 -1
  39. package/dist/lib/server/decisioning/index.js +16 -1
  40. package/dist/lib/server/decisioning/index.js.map +1 -1
  41. package/dist/lib/server/decisioning/platform.d.ts +17 -0
  42. package/dist/lib/server/decisioning/platform.d.ts.map +1 -1
  43. package/dist/lib/server/decisioning/platform.js.map +1 -1
  44. package/dist/lib/server/decisioning/proposal/dispatch.d.ts +203 -0
  45. package/dist/lib/server/decisioning/proposal/dispatch.d.ts.map +1 -0
  46. package/dist/lib/server/decisioning/proposal/dispatch.js +395 -0
  47. package/dist/lib/server/decisioning/proposal/dispatch.js.map +1 -0
  48. package/dist/lib/server/decisioning/proposal/index.d.ts +21 -0
  49. package/dist/lib/server/decisioning/proposal/index.d.ts.map +1 -0
  50. package/dist/lib/server/decisioning/proposal/index.js +37 -0
  51. package/dist/lib/server/decisioning/proposal/index.js.map +1 -0
  52. package/dist/lib/server/decisioning/proposal/lifecycle.d.ts +195 -0
  53. package/dist/lib/server/decisioning/proposal/lifecycle.d.ts.map +1 -0
  54. package/dist/lib/server/decisioning/proposal/lifecycle.js +366 -0
  55. package/dist/lib/server/decisioning/proposal/lifecycle.js.map +1 -0
  56. package/dist/lib/server/decisioning/proposal/mock-manager.d.ts +93 -0
  57. package/dist/lib/server/decisioning/proposal/mock-manager.d.ts.map +1 -0
  58. package/dist/lib/server/decisioning/proposal/mock-manager.js +109 -0
  59. package/dist/lib/server/decisioning/proposal/mock-manager.js.map +1 -0
  60. package/dist/lib/server/decisioning/proposal/store.d.ts +279 -0
  61. package/dist/lib/server/decisioning/proposal/store.d.ts.map +1 -0
  62. package/dist/lib/server/decisioning/proposal/store.js +291 -0
  63. package/dist/lib/server/decisioning/proposal/store.js.map +1 -0
  64. package/dist/lib/server/decisioning/proposal/types.d.ts +394 -0
  65. package/dist/lib/server/decisioning/proposal/types.d.ts.map +1 -0
  66. package/dist/lib/server/decisioning/proposal/types.js +58 -0
  67. package/dist/lib/server/decisioning/proposal/types.js.map +1 -0
  68. package/dist/lib/server/decisioning/runtime/from-platform.d.ts +25 -0
  69. package/dist/lib/server/decisioning/runtime/from-platform.d.ts.map +1 -1
  70. package/dist/lib/server/decisioning/runtime/from-platform.js +198 -15
  71. package/dist/lib/server/decisioning/runtime/from-platform.js.map +1 -1
  72. package/dist/lib/server/index.d.ts +1 -1
  73. package/dist/lib/server/index.d.ts.map +1 -1
  74. package/dist/lib/server/index.js +3 -1
  75. package/dist/lib/server/index.js.map +1 -1
  76. package/dist/lib/testing/client.d.ts.map +1 -1
  77. package/dist/lib/testing/client.js +7 -1
  78. package/dist/lib/testing/client.js.map +1 -1
  79. package/dist/lib/testing/storyboard/task-map.d.ts.map +1 -1
  80. package/dist/lib/testing/storyboard/task-map.js +1 -0
  81. package/dist/lib/testing/storyboard/task-map.js.map +1 -1
  82. package/dist/lib/testing/storyboard/test-kit.d.ts.map +1 -1
  83. package/dist/lib/testing/storyboard/test-kit.js +4 -0
  84. package/dist/lib/testing/storyboard/test-kit.js.map +1 -1
  85. package/dist/lib/testing/types.d.ts +10 -0
  86. package/dist/lib/testing/types.d.ts.map +1 -1
  87. package/dist/lib/version.d.ts +3 -3
  88. package/dist/lib/version.js +3 -3
  89. package/examples/hello_seller_adapter_guaranteed.ts +29 -2
  90. package/examples/hello_seller_adapter_proposal_mode.ts +575 -0
  91. package/package.json +1 -1
  92. package/dist/lib/adapters/proposal-manager.d.ts +0 -142
  93. package/dist/lib/adapters/proposal-manager.d.ts.map +0 -1
  94. package/dist/lib/adapters/proposal-manager.js +0 -184
  95. package/dist/lib/adapters/proposal-manager.js.map +0 -1
@@ -0,0 +1,575 @@
1
+ /**
2
+ * hello_seller_adapter_proposal_mode — canonical reference for the v1.5
3
+ * `ProposalManager` + `DecisioningPlatform` two-platform composition.
4
+ *
5
+ * The seller curates a media plan from a buyer's brief, the buyer
6
+ * refines and finalizes the proposal, and accepts it via a single
7
+ * `create_media_buy(proposal_id=...)` call. Mirrors Python's
8
+ * `examples/sales_proposal_mode_seller/` (PR #550).
9
+ *
10
+ * **What's interesting about this agent:**
11
+ *
12
+ * - All proposal-lifecycle work lives behind `ProposalManager` —
13
+ * `getProducts` curates draft proposals, `refineProducts` applies
14
+ * iteration, `finalizeProposal` locks pricing.
15
+ * - The adapter never persists proposal state itself. The framework's
16
+ * {@link InMemoryProposalStore} carries `draft → committed → consumed`
17
+ * transitions; the adapter just calls the upstream and returns the
18
+ * wire shape.
19
+ * - `sales.createMediaBuy(proposal_id)` reads `ctx.recipes` (populated
20
+ * by the framework from the committed proposal) and uses
21
+ * `recipe.upstream_ids.line_item_template_id` to drive the order
22
+ * creation. There's no second round-trip to the upstream's proposal
23
+ * store — the recipe IS the contract.
24
+ *
25
+ * Demo:
26
+ * npx @adcp/sdk@latest mock-server sales-guaranteed --port 4450
27
+ * UPSTREAM_URL=http://127.0.0.1:4450 \
28
+ * npx tsx examples/hello_seller_adapter_proposal_mode.ts
29
+ * adcp storyboard run http://127.0.0.1:3007/mcp media_buy_seller/proposal_finalize \
30
+ * --auth sk_harness_do_not_use_in_prod
31
+ */
32
+
33
+ import {
34
+ AdcpError,
35
+ createAdcpServerFromPlatform,
36
+ createIdempotencyStore,
37
+ createInMemoryTaskRegistry,
38
+ createMediaBuyStore,
39
+ createUpstreamHttpClient,
40
+ InMemoryProposalStore,
41
+ InMemoryStateStore,
42
+ memoryBackend,
43
+ serve,
44
+ verifyApiKey,
45
+ type Account,
46
+ type AccountStore,
47
+ type DecisioningPlatform,
48
+ type FinalizeProposalRequest,
49
+ type FinalizeProposalSuccess,
50
+ type ProposalManager,
51
+ type SalesCorePlatform,
52
+ } from '@adcp/sdk/server';
53
+ import { buildGAMLikeRecipe, GAM_LIKE_OVERLAP, type GAMLikeRecipe } from '@adcp/sdk/mock-server';
54
+ import type {
55
+ AccountReference,
56
+ CreateMediaBuyRequest,
57
+ CreateMediaBuySuccess,
58
+ GetMediaBuyDeliveryRequest,
59
+ GetMediaBuyDeliveryResponse,
60
+ GetMediaBuysRequest,
61
+ GetMediaBuysResponse,
62
+ GetProductsRequest,
63
+ GetProductsResponse,
64
+ UpdateMediaBuyRequest,
65
+ UpdateMediaBuySuccess,
66
+ } from '@adcp/sdk/types';
67
+
68
+ // Wire `Product` and `Proposal` aren't directly re-exported from
69
+ // `@adcp/sdk/types` — derive from the response array (same pattern as
70
+ // `hello_seller_adapter_guaranteed.ts`).
71
+ type Product = GetProductsResponse['products'][number];
72
+ type Proposal = NonNullable<GetProductsResponse['proposals']>[number];
73
+ type Package = CreateMediaBuySuccess['packages'][number];
74
+
75
+ const UPSTREAM_URL = process.env['UPSTREAM_URL'] ?? 'http://127.0.0.1:4450';
76
+ const UPSTREAM_API_KEY = process.env['UPSTREAM_API_KEY'] ?? 'mock_sales_guaranteed_key_do_not_use_in_prod';
77
+ const PORT = Number(process.env['PORT'] ?? 3007);
78
+ const ADCP_AUTH_TOKEN = process.env['ADCP_AUTH_TOKEN'] ?? 'sk_harness_do_not_use_in_prod';
79
+ const PUBLIC_AGENT_URL = process.env['PUBLIC_AGENT_URL'] ?? `http://127.0.0.1:${PORT}`;
80
+
81
+ const KNOWN_PUBLISHERS = ['premium-sports.example', 'acmeoutdoor.example', 'pinnacle-agency.example'];
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Upstream client
85
+ // ---------------------------------------------------------------------------
86
+
87
+ interface UpstreamProduct {
88
+ product_id: string;
89
+ name: string;
90
+ network_code: string;
91
+ delivery_type: 'guaranteed' | 'non_guaranteed';
92
+ channel: 'video' | 'ctv' | 'display' | 'audio';
93
+ format_ids: string[];
94
+ ad_unit_ids: string[];
95
+ pricing: { model: 'cpm' | 'cpv'; cpm: number; currency: string; min_spend?: number };
96
+ availability?: { start_date?: string; end_date?: string; available_impressions?: number };
97
+ }
98
+
99
+ interface UpstreamProposal {
100
+ proposal_id: string;
101
+ network_code: string;
102
+ status: 'draft' | 'committed' | 'expired' | 'rejected';
103
+ brief?: string;
104
+ allocations: Array<{
105
+ product_id: string;
106
+ allocation_percentage: number;
107
+ indicative_cpm: number;
108
+ locked_cpm?: number;
109
+ upstream_line_item_template_id?: string;
110
+ }>;
111
+ total_budget?: { amount: number; currency: string };
112
+ expires_at?: string;
113
+ }
114
+
115
+ const http = createUpstreamHttpClient({
116
+ baseUrl: UPSTREAM_URL,
117
+ auth: { kind: 'static_bearer', token: UPSTREAM_API_KEY },
118
+ });
119
+
120
+ const headers = (networkCode: string) => ({ 'X-Network-Code': networkCode });
121
+
122
+ const upstream = {
123
+ async lookupNetwork(publisherDomain: string) {
124
+ const { body } = await http.get<{ network_code: string; display_name: string; adcp_publisher: string }>(
125
+ '/_lookup/network',
126
+ { adcp_publisher: publisherDomain }
127
+ );
128
+ return body;
129
+ },
130
+ async listProducts(networkCode: string): Promise<UpstreamProduct[]> {
131
+ const { body } = await http.get<{ products: UpstreamProduct[] }>(
132
+ '/v1/products',
133
+ { delivery_type: 'guaranteed' },
134
+ headers(networkCode)
135
+ );
136
+ return body?.products ?? [];
137
+ },
138
+ async createProposal(
139
+ networkCode: string,
140
+ body: { brief?: string; total_budget?: { amount: number; currency: string }; product_ids?: string[] }
141
+ ): Promise<UpstreamProposal> {
142
+ const r = await http.post<UpstreamProposal>('/v1/proposals', body, headers(networkCode));
143
+ if (!r.body) throw new AdcpError('SERVICE_UNAVAILABLE', { message: 'upstream proposal creation failed' });
144
+ return r.body;
145
+ },
146
+ async refineProposal(
147
+ networkCode: string,
148
+ proposalId: string,
149
+ body: { ask?: string; allocation_overrides?: Array<{ product_id: string; allocation_percentage: number }> }
150
+ ): Promise<UpstreamProposal> {
151
+ const r = await http.post<UpstreamProposal>(
152
+ `/v1/proposals/${encodeURIComponent(proposalId)}/refine`,
153
+ body,
154
+ headers(networkCode)
155
+ );
156
+ if (!r.body) throw new AdcpError('SERVICE_UNAVAILABLE', { message: 'upstream refine failed' });
157
+ return r.body;
158
+ },
159
+ async finalizeProposal(networkCode: string, proposalId: string): Promise<UpstreamProposal> {
160
+ const r = await http.post<UpstreamProposal>(
161
+ `/v1/proposals/${encodeURIComponent(proposalId)}/finalize`,
162
+ {},
163
+ headers(networkCode)
164
+ );
165
+ if (!r.body) throw new AdcpError('SERVICE_UNAVAILABLE', { message: 'upstream finalize failed' });
166
+ return r.body;
167
+ },
168
+ async createOrder(
169
+ networkCode: string,
170
+ body: { name: string; advertiser_id: string; currency: string; budget: number; client_request_id?: string }
171
+ ) {
172
+ const r = await http.post<{ order_id: string; status: string; approval_task_id?: string }>(
173
+ '/v1/orders',
174
+ body,
175
+ headers(networkCode)
176
+ );
177
+ if (!r.body) throw new AdcpError('INVALID_REQUEST', { message: 'order creation rejected' });
178
+ return r.body;
179
+ },
180
+ async createLineItem(
181
+ networkCode: string,
182
+ orderId: string,
183
+ body: { product_id: string; budget: number; ad_unit_targeting?: string[]; client_request_id?: string }
184
+ ) {
185
+ const r = await http.post<{ line_item_id: string }>(
186
+ `/v1/orders/${encodeURIComponent(orderId)}/lineitems`,
187
+ body,
188
+ headers(networkCode)
189
+ );
190
+ if (!r.body) throw new AdcpError('INVALID_REQUEST', { message: 'line item creation rejected' });
191
+ return r.body;
192
+ },
193
+ async getDelivery(networkCode: string, orderId: string) {
194
+ const { body } = await http.get<{
195
+ currency: string;
196
+ reporting_period: { start: string; end: string };
197
+ totals: { impressions: number; clicks: number; spend: number };
198
+ }>(`/v1/orders/${encodeURIComponent(orderId)}/delivery`, undefined, headers(networkCode));
199
+ return body;
200
+ },
201
+ };
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Account resolution
205
+ // ---------------------------------------------------------------------------
206
+
207
+ interface NetworkMeta {
208
+ network_code: string;
209
+ publisher_domain: string;
210
+ [key: string]: unknown;
211
+ }
212
+
213
+ // AccountReference is a discriminated union — narrow before reading.
214
+ function publisherDomainFromRef(ref: AccountReference | undefined): string | undefined {
215
+ if (!ref) return undefined;
216
+ if ('brand' in ref) return ref.brand.domain;
217
+ // The {account_id} arm — adopters who store accounts pre-resolved at
218
+ // sync_accounts time would look up by id; this demo only accepts
219
+ // `pub_<domain>`-shaped ids as a self-rehydration trick.
220
+ if (ref.account_id.startsWith('pub_')) return ref.account_id.slice(4);
221
+ return undefined;
222
+ }
223
+
224
+ const accounts: AccountStore<NetworkMeta> = {
225
+ resolution: 'explicit',
226
+ async resolve(ref) {
227
+ const publisherDomain = publisherDomainFromRef(ref);
228
+ if (!publisherDomain || !KNOWN_PUBLISHERS.includes(publisherDomain)) return null;
229
+ const network = await upstream.lookupNetwork(publisherDomain);
230
+ if (!network) return null;
231
+ return {
232
+ id: `pub_${publisherDomain}`,
233
+ name: network.display_name,
234
+ status: 'active',
235
+ brand: { domain: publisherDomain },
236
+ ctx_metadata: { network_code: network.network_code, publisher_domain: publisherDomain },
237
+ };
238
+ },
239
+ };
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // ProposalManager — owns getProducts / refine / finalize.
243
+ // The framework persists drafts, intercepts finalize, and hydrates
244
+ // recipes onto ctx.recipes for sales.createMediaBuy.
245
+ // ---------------------------------------------------------------------------
246
+
247
+ const FORMAT_AGENT_URL = PUBLIC_AGENT_URL;
248
+
249
+ function projectProduct(p: UpstreamProduct, publisherDomain: string, recipe: GAMLikeRecipe): Product {
250
+ // The wire `Product` shape doesn't enumerate `implementation_config`
251
+ // — adapters attach it on the wire and the framework reads it back
252
+ // via cast in the dispatch helpers. We do the same on the way out.
253
+ const product: Product = {
254
+ product_id: p.product_id,
255
+ name: p.name,
256
+ description: `${p.name} — ${p.delivery_type} ${p.channel}`,
257
+ publisher_properties: [{ publisher_domain: publisherDomain, selection_type: 'all' }],
258
+ channels: [
259
+ p.channel === 'video'
260
+ ? 'olv'
261
+ : p.channel === 'ctv'
262
+ ? 'ctv'
263
+ : p.channel === 'audio'
264
+ ? 'streaming_audio'
265
+ : 'display',
266
+ ],
267
+ format_ids: p.format_ids.map(id => ({ agent_url: FORMAT_AGENT_URL, id })),
268
+ delivery_type: p.delivery_type,
269
+ pricing_options: [
270
+ {
271
+ pricing_option_id: 'cpm_guaranteed_fixed',
272
+ pricing_model: 'cpm',
273
+ currency: p.pricing.currency,
274
+ fixed_price: p.pricing.cpm,
275
+ ...(p.pricing.min_spend !== undefined && { min_spend: p.pricing.min_spend }),
276
+ },
277
+ ],
278
+ reporting_capabilities: {
279
+ available_reporting_frequencies: ['daily'],
280
+ expected_delay_minutes: 240,
281
+ timezone: 'UTC',
282
+ supports_webhooks: false,
283
+ available_metrics: ['impressions', 'clicks', 'spend'],
284
+ date_range_support: 'date_range',
285
+ },
286
+ };
287
+ // Attach the recipe via cast so strict TS doesn't reject the field
288
+ // that's not in the generated `Product` interface.
289
+ (product as { implementation_config?: GAMLikeRecipe }).implementation_config = recipe;
290
+ return product;
291
+ }
292
+
293
+ function projectProposal(up: UpstreamProposal, total_budget?: { amount: number; currency: string }): Proposal {
294
+ return {
295
+ proposal_id: up.proposal_id,
296
+ name: up.brief ? `Plan: ${up.brief.slice(0, 60)}` : `Plan ${up.proposal_id}`,
297
+ description: 'Curated media plan generated from the buyer brief.',
298
+ proposal_status: up.status === 'committed' ? 'committed' : 'draft',
299
+ allocations: up.allocations.map(a => ({
300
+ product_id: a.product_id,
301
+ allocation_percentage: a.allocation_percentage,
302
+ rationale: a.locked_cpm
303
+ ? `Locked at ${a.locked_cpm} ${total_budget?.currency ?? 'USD'} CPM.`
304
+ : `Indicative pricing ${a.indicative_cpm} CPM.`,
305
+ })),
306
+ ...(up.expires_at !== undefined && { expires_at: up.expires_at }),
307
+ ...(total_budget && { total_budget }),
308
+ };
309
+ }
310
+
311
+ const proposalManager: ProposalManager<GAMLikeRecipe, NetworkMeta> = {
312
+ capabilities: {
313
+ salesSpecialism: 'sales-guaranteed',
314
+ refine: true,
315
+ finalize: true,
316
+ expiresAtGraceSeconds: 60, // tolerate 1 minute of clock skew
317
+ rateCardPricing: true,
318
+ availabilityReservations: true,
319
+ },
320
+
321
+ async getProducts(req: GetProductsRequest, ctx): Promise<GetProductsResponse> {
322
+ const networkCode = ctx.account.ctx_metadata.network_code;
323
+ const publisherDomain = ctx.account.ctx_metadata.publisher_domain;
324
+ const products = await upstream.listProducts(networkCode);
325
+ if (products.length === 0) return { products: [] };
326
+
327
+ // brief + total_budget signals → curated proposal. Without a brief
328
+ // the buyer is browsing the catalog; skip proposal generation.
329
+ const brief = typeof (req as { brief?: unknown }).brief === 'string' ? (req as { brief: string }).brief : undefined;
330
+ const totalBudget = (req as { total_budget?: { amount: number; currency: string } }).total_budget;
331
+ if (!brief) {
332
+ // Catalog mode — return products with recipes, no proposals.
333
+ const productsOut = products.map(p => projectProduct(p, publisherDomain, buildGAMLikeRecipe(p)));
334
+ return { products: productsOut };
335
+ }
336
+
337
+ const draft = await upstream.createProposal(networkCode, {
338
+ brief,
339
+ ...(totalBudget && { total_budget: totalBudget }),
340
+ });
341
+ const referencedIds = new Set(draft.allocations.map(a => a.product_id));
342
+ const productsOut = products
343
+ .filter(p => referencedIds.has(p.product_id))
344
+ .map(p => projectProduct(p, publisherDomain, buildGAMLikeRecipe(p)));
345
+ return {
346
+ products: productsOut,
347
+ proposals: [projectProposal(draft, totalBudget)],
348
+ };
349
+ },
350
+
351
+ async refineProducts(req: GetProductsRequest, ctx): Promise<GetProductsResponse> {
352
+ const networkCode = ctx.account.ctx_metadata.network_code;
353
+ const publisherDomain = ctx.account.ctx_metadata.publisher_domain;
354
+ const refine =
355
+ (req as { refine?: ReadonlyArray<{ scope?: string; proposal_id?: string; ask?: string }> }).refine ?? [];
356
+ const proposalEntry = refine.find(r => r.scope === 'proposal' && typeof r.proposal_id === 'string');
357
+ if (!proposalEntry?.proposal_id) {
358
+ throw new AdcpError('INVALID_REQUEST', {
359
+ message: 'refine_products requires at least one proposal-scoped refine entry with proposal_id.',
360
+ field: 'refine',
361
+ });
362
+ }
363
+ const refined = await upstream.refineProposal(networkCode, proposalEntry.proposal_id, {
364
+ ...(proposalEntry.ask !== undefined && { ask: proposalEntry.ask }),
365
+ });
366
+ const products = await upstream.listProducts(networkCode);
367
+ const referencedIds = new Set(refined.allocations.map(a => a.product_id));
368
+ const productsOut = products
369
+ .filter(p => referencedIds.has(p.product_id))
370
+ .map(p => projectProduct(p, publisherDomain, buildGAMLikeRecipe(p)));
371
+ return {
372
+ products: productsOut,
373
+ proposals: [projectProposal(refined)],
374
+ refinement_applied: refine.map(r => ({
375
+ scope: r.scope ?? 'request',
376
+ ...(r.proposal_id !== undefined && { proposal_id: r.proposal_id }),
377
+ status: 'applied',
378
+ })) as never,
379
+ };
380
+ },
381
+
382
+ async finalizeProposal(
383
+ req: FinalizeProposalRequest<GAMLikeRecipe>,
384
+ ctx
385
+ ): Promise<FinalizeProposalSuccess<GAMLikeRecipe>> {
386
+ const networkCode = ctx.account.ctx_metadata.network_code;
387
+ const committed = await upstream.finalizeProposal(networkCode, req.proposalId);
388
+ // Refresh recipes with locked pricing + line-item template ids the
389
+ // upstream allocated. The framework writes these to the store on
390
+ // commit; sales.createMediaBuy reads them via ctx.recipes.
391
+ const products = await upstream.listProducts(networkCode);
392
+ const recipes = new Map<string, GAMLikeRecipe>();
393
+ for (const allocation of committed.allocations) {
394
+ const product = products.find(p => p.product_id === allocation.product_id);
395
+ if (!product) continue;
396
+ const baseRecipe = buildGAMLikeRecipe(product, {
397
+ upstream_ids: {
398
+ proposal_id: committed.proposal_id,
399
+ ...(allocation.upstream_line_item_template_id && {
400
+ line_item_template_id: allocation.upstream_line_item_template_id,
401
+ }),
402
+ },
403
+ });
404
+ // Override pricing with locked rate.
405
+ if (allocation.locked_cpm !== undefined) {
406
+ baseRecipe.pricing = { ...baseRecipe.pricing, rate: allocation.locked_cpm };
407
+ }
408
+ recipes.set(allocation.product_id, baseRecipe);
409
+ }
410
+ if (!committed.expires_at) {
411
+ throw new AdcpError('SERVICE_UNAVAILABLE', { message: 'upstream finalize did not return expires_at' });
412
+ }
413
+ return {
414
+ proposal: projectProposal(committed) as unknown as Record<string, unknown>,
415
+ expiresAt: new Date(committed.expires_at),
416
+ recipes,
417
+ };
418
+ },
419
+ };
420
+
421
+ // ---------------------------------------------------------------------------
422
+ // SalesPlatform — owns createMediaBuy / lifecycle. Reads ctx.recipes
423
+ // (hydrated by the framework from the committed proposal) instead of
424
+ // re-fetching from upstream.
425
+ // ---------------------------------------------------------------------------
426
+
427
+ const sales: SalesCorePlatform<NetworkMeta> = {
428
+ // getProducts is owned by proposalManager when wired; the framework
429
+ // routes there. We keep this empty at the type level — the framework
430
+ // never reaches it.
431
+ getProducts: async () => ({ products: [] }),
432
+
433
+ async createMediaBuy(req: CreateMediaBuyRequest, ctx): Promise<CreateMediaBuySuccess> {
434
+ const networkCode = ctx.account.ctx_metadata.network_code;
435
+ const recipes = ctx.recipes as ReadonlyMap<string, GAMLikeRecipe> | undefined;
436
+ if (!recipes || recipes.size === 0) {
437
+ throw new AdcpError('INVALID_REQUEST', {
438
+ message:
439
+ 'create_media_buy requires a committed proposal_id (this seller does not accept ' +
440
+ 'manual packages). Call get_products(buying_mode=brief), refine if needed, ' +
441
+ 'and finalize before create_media_buy.',
442
+ field: 'proposal_id',
443
+ });
444
+ }
445
+ const totalBudget = req.total_budget?.amount ?? 0;
446
+ const currency = req.total_budget?.currency ?? 'USD';
447
+ const order = await upstream.createOrder(networkCode, {
448
+ name: `prop_buy_${Date.now()}`,
449
+ advertiser_id: networkCode,
450
+ currency,
451
+ budget: totalBudget,
452
+ client_request_id: req.idempotency_key,
453
+ });
454
+
455
+ // Equal-split allocation across the recipes. Production adapters
456
+ // would honor the proposal's stored allocation percentages — those
457
+ // live in `ctx.recipes`'s carrier object on the framework side; the
458
+ // simpler split keeps the demo tight.
459
+ const perPackageBudget = totalBudget / recipes.size;
460
+ const packages: Package[] = [];
461
+ for (const [productId, recipe] of recipes) {
462
+ const lineItem = await upstream.createLineItem(networkCode, order.order_id, {
463
+ product_id: productId,
464
+ budget: perPackageBudget,
465
+ ad_unit_targeting: [...recipe.ad_unit_ids],
466
+ client_request_id: `${req.idempotency_key}_${productId}`,
467
+ });
468
+ packages.push({
469
+ package_id: lineItem.line_item_id,
470
+ product_id: productId,
471
+ budget: perPackageBudget,
472
+ });
473
+ }
474
+ return {
475
+ media_buy_id: order.order_id,
476
+ status: 'pending_creatives',
477
+ packages,
478
+ };
479
+ },
480
+
481
+ async updateMediaBuy(buyId: string, _patch: UpdateMediaBuyRequest, _ctx): Promise<UpdateMediaBuySuccess> {
482
+ // Pass-through; the proposal-mode demo doesn't drive update logic.
483
+ // Production adapters branch on the patch shape and PATCH the
484
+ // upstream order, returning `affected_packages[]` for the modified
485
+ // package set.
486
+ return {
487
+ media_buy_id: buyId,
488
+ status: 'active',
489
+ };
490
+ },
491
+
492
+ async getMediaBuyDelivery(req: GetMediaBuyDeliveryRequest, ctx): Promise<GetMediaBuyDeliveryResponse> {
493
+ const networkCode = ctx.account.ctx_metadata.network_code;
494
+ const ids = req.media_buy_ids ?? [];
495
+ const deliveries: GetMediaBuyDeliveryResponse['media_buy_deliveries'] = [];
496
+ for (const id of ids) {
497
+ const delivery = await upstream.getDelivery(networkCode, id);
498
+ if (!delivery) continue;
499
+ deliveries.push({
500
+ media_buy_id: id,
501
+ status: 'active',
502
+ totals: {
503
+ impressions: delivery.totals.impressions,
504
+ clicks: delivery.totals.clicks,
505
+ spend: delivery.totals.spend,
506
+ },
507
+ by_package: [],
508
+ });
509
+ }
510
+ return {
511
+ reporting_period: { start: '2026-04-01T00:00:00Z', end: '2026-06-30T23:59:59Z' },
512
+ currency: 'USD',
513
+ media_buy_deliveries: deliveries,
514
+ };
515
+ },
516
+
517
+ async getMediaBuys(_req: GetMediaBuysRequest): Promise<GetMediaBuysResponse> {
518
+ return { media_buys: [] };
519
+ },
520
+ };
521
+
522
+ // ---------------------------------------------------------------------------
523
+ // Platform composition + boot
524
+ // ---------------------------------------------------------------------------
525
+
526
+ const platform: DecisioningPlatform<unknown, NetworkMeta> = {
527
+ capabilities: {
528
+ specialisms: ['sales-proposal-mode'],
529
+ channels: ['olv', 'ctv', 'display'],
530
+ pricingModels: ['cpm'],
531
+ config: undefined,
532
+ },
533
+ accounts,
534
+ proposalManager,
535
+ sales,
536
+ };
537
+
538
+ const idempotencyStore = createIdempotencyStore({ backend: memoryBackend(), ttlSeconds: 86_400 });
539
+ const taskRegistry = createInMemoryTaskRegistry();
540
+ const proposalStore = new InMemoryProposalStore<GAMLikeRecipe>();
541
+ // Single-tenant agent: explicit InMemoryStateStore + MediaBuyStore so the
542
+ // framework's "no implicit cross-tenant in-memory" guard accepts the wiring.
543
+ // Production adopters swap for `PostgresStateStore({ pool })`.
544
+ const stateStore = new InMemoryStateStore();
545
+ const mediaBuyStore = createMediaBuyStore({ store: stateStore });
546
+
547
+ serve(
548
+ ({ taskStore }) =>
549
+ createAdcpServerFromPlatform(platform, {
550
+ name: 'hello-seller-adapter-proposal-mode',
551
+ version: '1.0.0',
552
+ taskStore,
553
+ taskRegistry,
554
+ idempotency: idempotencyStore,
555
+ stateStore,
556
+ mediaBuyStore,
557
+ proposalStore,
558
+ resolveSessionKey: ctx => {
559
+ const acct = ctx.account as Account<NetworkMeta> | undefined;
560
+ return acct?.id ?? 'anonymous';
561
+ },
562
+ }),
563
+ {
564
+ port: PORT,
565
+ authenticate: verifyApiKey({
566
+ keys: { [ADCP_AUTH_TOKEN]: { principal: 'compliance-runner' } },
567
+ }),
568
+ }
569
+ );
570
+
571
+ /* eslint-disable no-console */
572
+ console.log(`hello-seller-adapter-proposal-mode on http://127.0.0.1:${PORT}/mcp · upstream: ${UPSTREAM_URL}`);
573
+ /* eslint-enable no-console */
574
+
575
+ void GAM_LIKE_OVERLAP; // imported for type re-export discoverability
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adcp/sdk",
3
- "version": "6.11.0",
3
+ "version": "6.12.0",
4
4
  "description": "AdCP SDK — client, server, and compliance harnesses for the AdContext Protocol (MCP + A2A)",
5
5
  "workspaces": [
6
6
  ".",