@adcp/sdk 6.11.0 → 6.13.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.
- package/bin/adcp-config.js +7 -1
- package/bin/adcp.js +191 -4
- package/dist/lib/adapters/index.d.ts +0 -2
- package/dist/lib/adapters/index.d.ts.map +1 -1
- package/dist/lib/adapters/index.js +1 -8
- package/dist/lib/adapters/index.js.map +1 -1
- package/dist/lib/auth/oauth/index.d.ts +1 -0
- package/dist/lib/auth/oauth/index.d.ts.map +1 -1
- package/dist/lib/auth/oauth/index.js +17 -1
- package/dist/lib/auth/oauth/index.js.map +1 -1
- package/dist/lib/auth/oauth/web-flow.d.ts +270 -0
- package/dist/lib/auth/oauth/web-flow.d.ts.map +1 -0
- package/dist/lib/auth/oauth/web-flow.js +413 -0
- package/dist/lib/auth/oauth/web-flow.js.map +1 -0
- package/dist/lib/index.d.ts +1 -1
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +2 -7
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/mock-server/index.d.ts +2 -0
- package/dist/lib/mock-server/index.d.ts.map +1 -1
- package/dist/lib/mock-server/index.js +17 -0
- package/dist/lib/mock-server/index.js.map +1 -1
- package/dist/lib/mock-server/sales-guaranteed/recipe.d.ts +155 -0
- package/dist/lib/mock-server/sales-guaranteed/recipe.d.ts.map +1 -0
- package/dist/lib/mock-server/sales-guaranteed/recipe.js +107 -0
- package/dist/lib/mock-server/sales-guaranteed/recipe.js.map +1 -0
- package/dist/lib/mock-server/sales-guaranteed/server.d.ts.map +1 -1
- package/dist/lib/mock-server/sales-guaranteed/server.js +212 -0
- package/dist/lib/mock-server/sales-guaranteed/server.js.map +1 -1
- package/dist/lib/mock-server/sales-non-guaranteed/recipe.d.ts +123 -0
- package/dist/lib/mock-server/sales-non-guaranteed/recipe.d.ts.map +1 -0
- package/dist/lib/mock-server/sales-non-guaranteed/recipe.js +81 -0
- package/dist/lib/mock-server/sales-non-guaranteed/recipe.js.map +1 -0
- package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
- package/dist/lib/server/ctx-metadata/index.d.ts +1 -1
- package/dist/lib/server/ctx-metadata/index.d.ts.map +1 -1
- package/dist/lib/server/ctx-metadata/index.js +3 -1
- package/dist/lib/server/ctx-metadata/index.js.map +1 -1
- package/dist/lib/server/ctx-metadata/wire-shape.d.ts +21 -0
- package/dist/lib/server/ctx-metadata/wire-shape.d.ts.map +1 -1
- package/dist/lib/server/ctx-metadata/wire-shape.js +111 -0
- package/dist/lib/server/ctx-metadata/wire-shape.js.map +1 -1
- package/dist/lib/server/decisioning/context.d.ts +19 -0
- package/dist/lib/server/decisioning/context.d.ts.map +1 -1
- package/dist/lib/server/decisioning/index.d.ts +3 -0
- package/dist/lib/server/decisioning/index.d.ts.map +1 -1
- package/dist/lib/server/decisioning/index.js +16 -1
- package/dist/lib/server/decisioning/index.js.map +1 -1
- package/dist/lib/server/decisioning/platform.d.ts +17 -0
- package/dist/lib/server/decisioning/platform.d.ts.map +1 -1
- package/dist/lib/server/decisioning/platform.js.map +1 -1
- package/dist/lib/server/decisioning/proposal/dispatch.d.ts +203 -0
- package/dist/lib/server/decisioning/proposal/dispatch.d.ts.map +1 -0
- package/dist/lib/server/decisioning/proposal/dispatch.js +395 -0
- package/dist/lib/server/decisioning/proposal/dispatch.js.map +1 -0
- package/dist/lib/server/decisioning/proposal/index.d.ts +21 -0
- package/dist/lib/server/decisioning/proposal/index.d.ts.map +1 -0
- package/dist/lib/server/decisioning/proposal/index.js +37 -0
- package/dist/lib/server/decisioning/proposal/index.js.map +1 -0
- package/dist/lib/server/decisioning/proposal/lifecycle.d.ts +195 -0
- package/dist/lib/server/decisioning/proposal/lifecycle.d.ts.map +1 -0
- package/dist/lib/server/decisioning/proposal/lifecycle.js +366 -0
- package/dist/lib/server/decisioning/proposal/lifecycle.js.map +1 -0
- package/dist/lib/server/decisioning/proposal/mock-manager.d.ts +93 -0
- package/dist/lib/server/decisioning/proposal/mock-manager.d.ts.map +1 -0
- package/dist/lib/server/decisioning/proposal/mock-manager.js +109 -0
- package/dist/lib/server/decisioning/proposal/mock-manager.js.map +1 -0
- package/dist/lib/server/decisioning/proposal/store.d.ts +279 -0
- package/dist/lib/server/decisioning/proposal/store.d.ts.map +1 -0
- package/dist/lib/server/decisioning/proposal/store.js +291 -0
- package/dist/lib/server/decisioning/proposal/store.js.map +1 -0
- package/dist/lib/server/decisioning/proposal/types.d.ts +394 -0
- package/dist/lib/server/decisioning/proposal/types.d.ts.map +1 -0
- package/dist/lib/server/decisioning/proposal/types.js +58 -0
- package/dist/lib/server/decisioning/proposal/types.js.map +1 -0
- package/dist/lib/server/decisioning/runtime/from-platform.d.ts +25 -0
- package/dist/lib/server/decisioning/runtime/from-platform.d.ts.map +1 -1
- package/dist/lib/server/decisioning/runtime/from-platform.js +198 -15
- package/dist/lib/server/decisioning/runtime/from-platform.js.map +1 -1
- package/dist/lib/server/index.d.ts +1 -1
- package/dist/lib/server/index.d.ts.map +1 -1
- package/dist/lib/server/index.js +3 -1
- package/dist/lib/server/index.js.map +1 -1
- package/dist/lib/testing/client.d.ts.map +1 -1
- package/dist/lib/testing/client.js +7 -1
- package/dist/lib/testing/client.js.map +1 -1
- package/dist/lib/testing/storyboard/task-map.d.ts.map +1 -1
- package/dist/lib/testing/storyboard/task-map.js +1 -0
- package/dist/lib/testing/storyboard/task-map.js.map +1 -1
- package/dist/lib/testing/storyboard/test-kit.d.ts.map +1 -1
- package/dist/lib/testing/storyboard/test-kit.js +4 -0
- package/dist/lib/testing/storyboard/test-kit.js.map +1 -1
- package/dist/lib/testing/types.d.ts +10 -0
- package/dist/lib/testing/types.d.ts.map +1 -1
- package/dist/lib/version.d.ts +3 -3
- package/dist/lib/version.js +3 -3
- package/examples/hello_seller_adapter_guaranteed.ts +29 -2
- package/examples/hello_seller_adapter_proposal_mode.ts +575 -0
- package/package.json +1 -1
- package/dist/lib/adapters/proposal-manager.d.ts +0 -142
- package/dist/lib/adapters/proposal-manager.d.ts.map +0 -1
- package/dist/lib/adapters/proposal-manager.js +0 -184
- 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
|