@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.
- package/bin/adcp.js +15 -3
- package/dist/lib/adapters/derived-account-store.d.ts +152 -0
- package/dist/lib/adapters/derived-account-store.d.ts.map +1 -0
- package/dist/lib/adapters/derived-account-store.js +135 -0
- package/dist/lib/adapters/derived-account-store.js.map +1 -0
- package/dist/lib/adapters/implicit-account-store.d.ts +3 -1
- package/dist/lib/adapters/implicit-account-store.d.ts.map +1 -1
- package/dist/lib/adapters/implicit-account-store.js +3 -1
- package/dist/lib/adapters/implicit-account-store.js.map +1 -1
- package/dist/lib/adapters/index.d.ts +1 -0
- package/dist/lib/adapters/index.d.ts.map +1 -1
- package/dist/lib/adapters/index.js +7 -1
- package/dist/lib/adapters/index.js.map +1 -1
- package/dist/lib/adapters/oauth-passthrough-resolver.d.ts +3 -1
- package/dist/lib/adapters/oauth-passthrough-resolver.d.ts.map +1 -1
- package/dist/lib/adapters/oauth-passthrough-resolver.js +3 -1
- package/dist/lib/adapters/oauth-passthrough-resolver.js.map +1 -1
- package/dist/lib/adapters/roster-account-store.d.ts +85 -24
- package/dist/lib/adapters/roster-account-store.d.ts.map +1 -1
- package/dist/lib/adapters/roster-account-store.js +52 -30
- package/dist/lib/adapters/roster-account-store.js.map +1 -1
- package/dist/lib/index.d.ts +3 -3
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +13 -8
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/mock-server/creative-ad-server/seed-data.d.ts +81 -0
- package/dist/lib/mock-server/creative-ad-server/seed-data.d.ts.map +1 -0
- package/dist/lib/mock-server/creative-ad-server/seed-data.js +200 -0
- package/dist/lib/mock-server/creative-ad-server/seed-data.js.map +1 -0
- package/dist/lib/mock-server/creative-ad-server/server.d.ts +39 -0
- package/dist/lib/mock-server/creative-ad-server/server.d.ts.map +1 -0
- package/dist/lib/mock-server/creative-ad-server/server.js +618 -0
- package/dist/lib/mock-server/creative-ad-server/server.js.map +1 -0
- package/dist/lib/mock-server/index.d.ts.map +1 -1
- package/dist/lib/mock-server/index.js +180 -24
- package/dist/lib/mock-server/index.js.map +1 -1
- package/dist/lib/mock-server/sales-non-guaranteed/seed-data.d.ts +66 -0
- package/dist/lib/mock-server/sales-non-guaranteed/seed-data.d.ts.map +1 -0
- package/dist/lib/mock-server/sales-non-guaranteed/seed-data.js +193 -0
- package/dist/lib/mock-server/sales-non-guaranteed/seed-data.js.map +1 -0
- package/dist/lib/mock-server/sales-non-guaranteed/server.d.ts +33 -0
- package/dist/lib/mock-server/sales-non-guaranteed/server.d.ts.map +1 -0
- package/dist/lib/mock-server/sales-non-guaranteed/server.js +782 -0
- package/dist/lib/mock-server/sales-non-guaranteed/server.js.map +1 -0
- package/dist/lib/mock-server/sponsored-intelligence/seed-data.d.ts +50 -0
- package/dist/lib/mock-server/sponsored-intelligence/seed-data.d.ts.map +1 -0
- package/dist/lib/mock-server/sponsored-intelligence/seed-data.js +133 -0
- package/dist/lib/mock-server/sponsored-intelligence/seed-data.js.map +1 -0
- package/dist/lib/mock-server/sponsored-intelligence/server.d.ts +13 -0
- package/dist/lib/mock-server/sponsored-intelligence/server.d.ts.map +1 -0
- package/dist/lib/mock-server/sponsored-intelligence/server.js +609 -0
- package/dist/lib/mock-server/sponsored-intelligence/server.js.map +1 -0
- package/dist/lib/protocols/mcp.d.ts.map +1 -1
- package/dist/lib/protocols/mcp.js +1 -41
- package/dist/lib/protocols/mcp.js.map +1 -1
- package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
- package/dist/lib/server/account-mode.d.ts +113 -0
- package/dist/lib/server/account-mode.d.ts.map +1 -0
- package/dist/lib/server/account-mode.js +125 -0
- package/dist/lib/server/account-mode.js.map +1 -0
- package/dist/lib/server/adcp-server.js +41 -0
- package/dist/lib/server/adcp-server.js.map +1 -1
- package/dist/lib/server/auth.d.ts +35 -0
- package/dist/lib/server/auth.d.ts.map +1 -1
- package/dist/lib/server/auth.js.map +1 -1
- package/dist/lib/server/create-adcp-server.d.ts +26 -9
- package/dist/lib/server/create-adcp-server.d.ts.map +1 -1
- package/dist/lib/server/create-adcp-server.js +46 -20
- package/dist/lib/server/create-adcp-server.js.map +1 -1
- package/dist/lib/server/ctx-metadata/store.d.ts +1 -1
- package/dist/lib/server/ctx-metadata/store.d.ts.map +1 -1
- package/dist/lib/server/ctx-metadata/store.js +1 -0
- package/dist/lib/server/ctx-metadata/store.js.map +1 -1
- package/dist/lib/server/decisioning/account.d.ts +5 -0
- package/dist/lib/server/decisioning/account.d.ts.map +1 -1
- package/dist/lib/server/decisioning/account.js.map +1 -1
- package/dist/lib/server/decisioning/buyer-agent.d.ts +37 -4
- package/dist/lib/server/decisioning/buyer-agent.d.ts.map +1 -1
- package/dist/lib/server/decisioning/buyer-agent.js +12 -2
- package/dist/lib/server/decisioning/buyer-agent.js.map +1 -1
- package/dist/lib/server/decisioning/compose.d.ts +33 -2
- package/dist/lib/server/decisioning/compose.d.ts.map +1 -1
- package/dist/lib/server/decisioning/compose.js +13 -46
- package/dist/lib/server/decisioning/compose.js.map +1 -1
- package/dist/lib/server/decisioning/index.d.ts +2 -1
- package/dist/lib/server/decisioning/index.d.ts.map +1 -1
- package/dist/lib/server/decisioning/index.js +2 -1
- package/dist/lib/server/decisioning/index.js.map +1 -1
- package/dist/lib/server/decisioning/platform-helpers.d.ts +18 -0
- package/dist/lib/server/decisioning/platform-helpers.d.ts.map +1 -1
- package/dist/lib/server/decisioning/platform-helpers.js +20 -0
- package/dist/lib/server/decisioning/platform-helpers.js.map +1 -1
- package/dist/lib/server/decisioning/platform.d.ts +19 -21
- 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/runtime/from-platform.d.ts.map +1 -1
- package/dist/lib/server/decisioning/runtime/from-platform.js +334 -44
- package/dist/lib/server/decisioning/runtime/from-platform.js.map +1 -1
- package/dist/lib/server/decisioning/runtime/observed-modes.d.ts +40 -0
- package/dist/lib/server/decisioning/runtime/observed-modes.d.ts.map +1 -0
- package/dist/lib/server/decisioning/runtime/observed-modes.js +82 -0
- package/dist/lib/server/decisioning/runtime/observed-modes.js.map +1 -0
- package/dist/lib/server/decisioning/runtime/protocol-for-tool.js +2 -2
- package/dist/lib/server/decisioning/runtime/protocol-for-tool.js.map +1 -1
- package/dist/lib/server/decisioning/runtime/validate-platform.d.ts.map +1 -1
- package/dist/lib/server/decisioning/runtime/validate-platform.js +9 -1
- package/dist/lib/server/decisioning/runtime/validate-platform.js.map +1 -1
- package/dist/lib/server/decisioning/specialisms/sponsored-intelligence.d.ts +125 -0
- package/dist/lib/server/decisioning/specialisms/sponsored-intelligence.d.ts.map +1 -0
- package/dist/lib/server/decisioning/specialisms/sponsored-intelligence.js +52 -0
- package/dist/lib/server/decisioning/specialisms/sponsored-intelligence.js.map +1 -0
- package/dist/lib/server/decisioning/tenant-registry.d.ts +16 -0
- package/dist/lib/server/decisioning/tenant-registry.d.ts.map +1 -1
- package/dist/lib/server/decisioning/tenant-registry.js.map +1 -1
- package/dist/lib/server/index.d.ts +4 -1
- package/dist/lib/server/index.d.ts.map +1 -1
- package/dist/lib/server/index.js +9 -3
- package/dist/lib/server/index.js.map +1 -1
- package/dist/lib/server/serve.js +20 -2
- package/dist/lib/server/serve.js.map +1 -1
- package/dist/lib/server/test-controller.d.ts +3 -0
- package/dist/lib/server/test-controller.d.ts.map +1 -1
- package/dist/lib/server/test-controller.js +23 -20
- package/dist/lib/server/test-controller.js.map +1 -1
- package/dist/lib/testing/comply-controller.d.ts +23 -2
- package/dist/lib/testing/comply-controller.d.ts.map +1 -1
- package/dist/lib/testing/comply-controller.js +19 -2
- package/dist/lib/testing/comply-controller.js.map +1 -1
- package/dist/lib/testing/index.d.ts +1 -1
- package/dist/lib/testing/index.d.ts.map +1 -1
- package/dist/lib/testing/index.js.map +1 -1
- package/dist/lib/testing/storyboard/validations.d.ts.map +1 -1
- package/dist/lib/testing/storyboard/validations.js +36 -54
- package/dist/lib/testing/storyboard/validations.js.map +1 -1
- package/dist/lib/testing/test-controller.d.ts +10 -4
- package/dist/lib/testing/test-controller.d.ts.map +1 -1
- package/dist/lib/testing/test-controller.js +9 -3
- package/dist/lib/testing/test-controller.js.map +1 -1
- package/dist/lib/types/index.d.ts +3 -2
- package/dist/lib/types/index.d.ts.map +1 -1
- package/dist/lib/types/index.js +3 -0
- package/dist/lib/types/index.js.map +1 -1
- package/dist/lib/utils/glob.d.ts +4 -2
- package/dist/lib/utils/glob.d.ts.map +1 -1
- package/dist/lib/utils/glob.js +4 -2
- package/dist/lib/utils/glob.js.map +1 -1
- package/dist/lib/version.d.ts +3 -3
- package/dist/lib/version.js +3 -3
- package/docs/llms.txt +2 -2
- package/examples/README.md +29 -13
- package/examples/hello-cluster.ts +62 -23
- package/examples/hello_creative_adapter_ad_server.ts +790 -0
- package/examples/hello_seller_adapter_guaranteed.ts +80 -22
- package/examples/hello_seller_adapter_non_guaranteed.ts +1020 -0
- package/examples/hello_si_adapter_brand.ts +572 -0
- package/package.json +3 -2
- package/skills/build-creative-agent/SKILL.md +103 -183
- package/skills/build-generative-seller-agent/SKILL.md +15 -9
- package/skills/build-governance-agent/SKILL.md +20 -11
- package/skills/build-retail-media-agent/SKILL.md +10 -8
- package/skills/build-seller-agent/SKILL.md +15 -13
- package/skills/build-seller-agent/specialisms/sales-non-guaranteed.md +9 -31
- package/skills/build-si-agent/SKILL.md +251 -196
- package/skills/build-signals-agent/SKILL.md +2 -0
- package/skills/call-adcp-agent/SKILL.md +7 -1
- package/skills/call-adcp-agent.previous/SKILL.md +0 -261
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `sales-non-guaranteed` upstream-shape mock-server. Programmatic-auction
|
|
4
|
+
* remnant inventory; sync confirmation on `POST /v1/orders`; floor pricing
|
|
5
|
+
* per product; spend-only forecast (no availability check).
|
|
6
|
+
*
|
|
7
|
+
* Closes #1457 (sub-issue of #1381). Pattern modeled on
|
|
8
|
+
* `sales-guaranteed/server.ts` with these deltas:
|
|
9
|
+
*
|
|
10
|
+
* - Order confirmation is **sync**: `POST /v1/orders` returns
|
|
11
|
+
* `status: 'confirmed'` immediately. No HITL approval task.
|
|
12
|
+
* - Pricing is **floor-based** (`min_cpm`). Effective CPM at the
|
|
13
|
+
* requested budget = `target_cpm` if set, else `1.3 × min_cpm`,
|
|
14
|
+
* saturating toward `2 × min_cpm` at high budgets.
|
|
15
|
+
* - Forecast is **`spend`-only**. No `availability` unit; auction
|
|
16
|
+
* mocks don't pre-commit inventory.
|
|
17
|
+
* - Delivery scales with `(budget × elapsed_pct × pacing_curve)`.
|
|
18
|
+
* Pacing modes: `even`, `asap`, `front_loaded`.
|
|
19
|
+
* - No CAPI / conversions surface (out of scope per #1457).
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.bootSalesNonGuaranteed = bootSalesNonGuaranteed;
|
|
23
|
+
const node_http_1 = require("node:http");
|
|
24
|
+
const node_crypto_1 = require("node:crypto");
|
|
25
|
+
const seed_data_1 = require("./seed-data");
|
|
26
|
+
async function bootSalesNonGuaranteed(options) {
|
|
27
|
+
const apiKey = options.apiKey ?? seed_data_1.DEFAULT_API_KEY;
|
|
28
|
+
const networks = options.networks ?? seed_data_1.NETWORKS;
|
|
29
|
+
const adUnits = options.adUnits ?? seed_data_1.AD_UNITS;
|
|
30
|
+
const products = options.products ?? seed_data_1.PRODUCTS;
|
|
31
|
+
const orders = new Map();
|
|
32
|
+
const creatives = new Map();
|
|
33
|
+
// Idempotency table — keyed `<network_code>::<resource_kind>::<client_request_id>`.
|
|
34
|
+
// Value is the resource id or a 409-conflict marker `409:<fingerprint>`.
|
|
35
|
+
const idempotency = new Map();
|
|
36
|
+
// Traffic counters keyed by `<METHOD> <route-template>`. Harness queries
|
|
37
|
+
// `GET /_debug/traffic` after the storyboard run and asserts headline
|
|
38
|
+
// routes were hit ≥1. Façade adapters that skip the upstream produce
|
|
39
|
+
// zero counters and fail the assertion. Mirrors the pattern from
|
|
40
|
+
// `sales-guaranteed/server.ts` (#1225 lineage).
|
|
41
|
+
const traffic = new Map();
|
|
42
|
+
const bump = (routeTemplate) => {
|
|
43
|
+
traffic.set(routeTemplate, (traffic.get(routeTemplate) ?? 0) + 1);
|
|
44
|
+
};
|
|
45
|
+
const server = (0, node_http_1.createServer)((req, res) => {
|
|
46
|
+
handleRequest(req, res).catch(err => {
|
|
47
|
+
writeJson(res, 500, { code: 'internal_error', message: err?.message ?? 'unexpected error' });
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
await new Promise((resolve, reject) => {
|
|
51
|
+
server.once('error', reject);
|
|
52
|
+
server.listen(options.port, '127.0.0.1', () => {
|
|
53
|
+
server.removeListener('error', reject);
|
|
54
|
+
resolve();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
const addr = server.address();
|
|
58
|
+
const boundPort = typeof addr === 'object' && addr ? addr.port : options.port;
|
|
59
|
+
const url = `http://127.0.0.1:${boundPort}`;
|
|
60
|
+
return {
|
|
61
|
+
url,
|
|
62
|
+
close: () => new Promise((resolve, reject) => {
|
|
63
|
+
server.close(err => (err ? reject(err) : resolve()));
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
66
|
+
async function handleRequest(req, res) {
|
|
67
|
+
const reqUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
68
|
+
const path = reqUrl.pathname;
|
|
69
|
+
const method = req.method ?? 'GET';
|
|
70
|
+
// Façade-detection traffic dump — harness-only, no auth required.
|
|
71
|
+
if (method === 'GET' && path === '/_debug/traffic') {
|
|
72
|
+
writeJson(res, 200, { traffic: Object.fromEntries(traffic) });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
// Discovery endpoint — replaces hardcoded principal-mapping. Adapters
|
|
76
|
+
// resolve at runtime by querying with the AdCP-side identifier from
|
|
77
|
+
// buyers (`account.publisher`, `account.brand.domain`). No auth —
|
|
78
|
+
// discovery happens before the agent has any network context. #1225.
|
|
79
|
+
if (method === 'GET' && path === '/_lookup/network') {
|
|
80
|
+
bump('GET /_lookup/network');
|
|
81
|
+
const adcpPublisher = reqUrl.searchParams.get('adcp_publisher');
|
|
82
|
+
if (!adcpPublisher) {
|
|
83
|
+
writeJson(res, 400, { code: 'invalid_request', message: 'adcp_publisher query parameter is required.' });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const match = networks.find(n => n.adcp_publisher === adcpPublisher);
|
|
87
|
+
if (!match) {
|
|
88
|
+
writeJson(res, 404, {
|
|
89
|
+
code: 'network_not_found',
|
|
90
|
+
message: `No upstream network registered for adcp_publisher=${adcpPublisher}.`,
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
writeJson(res, 200, {
|
|
95
|
+
adcp_publisher: match.adcp_publisher,
|
|
96
|
+
network_code: match.network_code,
|
|
97
|
+
display_name: match.display_name,
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const auth = req.headers['authorization'];
|
|
102
|
+
if (!auth || !auth.startsWith('Bearer ') || auth.slice(7) !== apiKey) {
|
|
103
|
+
writeJson(res, 401, { code: 'unauthorized', message: 'Missing or invalid bearer credential.' });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const networkHeader = req.headers['x-network-code'];
|
|
107
|
+
const networkCode = Array.isArray(networkHeader) ? networkHeader[0] : networkHeader;
|
|
108
|
+
if (!networkCode) {
|
|
109
|
+
writeJson(res, 403, { code: 'network_required', message: 'X-Network-Code header is required on every request.' });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const network = networks.find(n => n.network_code === networkCode);
|
|
113
|
+
if (!network) {
|
|
114
|
+
writeJson(res, 403, { code: 'unknown_network', message: `Unknown network: ${networkCode}` });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (method === 'GET' && path === '/v1/inventory') {
|
|
118
|
+
bump('GET /v1/inventory');
|
|
119
|
+
return handleListInventory(network, res);
|
|
120
|
+
}
|
|
121
|
+
if (method === 'GET' && path === '/v1/products') {
|
|
122
|
+
bump('GET /v1/products');
|
|
123
|
+
return handleListProducts(reqUrl, network, res);
|
|
124
|
+
}
|
|
125
|
+
if (method === 'POST' && path === '/v1/forecast') {
|
|
126
|
+
bump('POST /v1/forecast');
|
|
127
|
+
return handleForecast(req, network, res);
|
|
128
|
+
}
|
|
129
|
+
if (method === 'GET' && path === '/v1/creatives') {
|
|
130
|
+
bump('GET /v1/creatives');
|
|
131
|
+
return handleListCreatives(network, res);
|
|
132
|
+
}
|
|
133
|
+
if (method === 'POST' && path === '/v1/creatives') {
|
|
134
|
+
bump('POST /v1/creatives');
|
|
135
|
+
return handleCreateCreative(req, network, res);
|
|
136
|
+
}
|
|
137
|
+
if (method === 'GET' && path === '/v1/orders') {
|
|
138
|
+
bump('GET /v1/orders');
|
|
139
|
+
return handleListOrders(network, res);
|
|
140
|
+
}
|
|
141
|
+
if (method === 'POST' && path === '/v1/orders') {
|
|
142
|
+
bump('POST /v1/orders');
|
|
143
|
+
return handleCreateOrder(req, network, res);
|
|
144
|
+
}
|
|
145
|
+
const orderMatch = path.match(/^\/v1\/orders\/([^/]+)(\/.*)?$/);
|
|
146
|
+
if (orderMatch && orderMatch[1]) {
|
|
147
|
+
const orderId = decodeURIComponent(orderMatch[1]);
|
|
148
|
+
const subPath = orderMatch[2] ?? '/';
|
|
149
|
+
const order = orders.get(orderId);
|
|
150
|
+
if (!order || order.network_code !== network.network_code) {
|
|
151
|
+
writeJson(res, 404, { code: 'order_not_found', message: `Order ${orderId} not found.` });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (method === 'GET' && subPath === '/') {
|
|
155
|
+
bump('GET /v1/orders/{id}');
|
|
156
|
+
return handleGetOrder(order, res);
|
|
157
|
+
}
|
|
158
|
+
if (method === 'PATCH' && subPath === '/') {
|
|
159
|
+
bump('PATCH /v1/orders/{id}');
|
|
160
|
+
return handleUpdateOrder(req, order, res);
|
|
161
|
+
}
|
|
162
|
+
if (method === 'GET' && subPath === '/lineitems') {
|
|
163
|
+
bump('GET /v1/orders/{id}/lineitems');
|
|
164
|
+
return handleListLineItems(order, res);
|
|
165
|
+
}
|
|
166
|
+
if (method === 'POST' && subPath === '/lineitems') {
|
|
167
|
+
bump('POST /v1/orders/{id}/lineitems');
|
|
168
|
+
return handleCreateLineItem(req, order, res);
|
|
169
|
+
}
|
|
170
|
+
if (method === 'GET' && subPath === '/delivery') {
|
|
171
|
+
bump('GET /v1/orders/{id}/delivery');
|
|
172
|
+
return handleGetDelivery(order, res);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
writeJson(res, 404, { code: 'not_found', message: `No route for ${method} ${path}` });
|
|
176
|
+
}
|
|
177
|
+
// ────────────────────────────────────────────────────────────
|
|
178
|
+
// Inventory / Products
|
|
179
|
+
// ────────────────────────────────────────────────────────────
|
|
180
|
+
function handleListInventory(network, res) {
|
|
181
|
+
const visible = adUnits.filter(au => au.network_code === network.network_code);
|
|
182
|
+
writeJson(res, 200, { ad_units: visible });
|
|
183
|
+
}
|
|
184
|
+
function handleListProducts(reqUrl, network, res) {
|
|
185
|
+
let visible = products.filter(p => p.network_code === network.network_code);
|
|
186
|
+
const channel = reqUrl.searchParams.get('channel');
|
|
187
|
+
if (channel)
|
|
188
|
+
visible = visible.filter(p => p.channel === channel);
|
|
189
|
+
// Per-query forecast embedding. Mirrors the sales-guaranteed pattern
|
|
190
|
+
// (PR #1414): when the caller passes targeting / flight / budget
|
|
191
|
+
// params, attach a deterministic-seeded forecast curve to each
|
|
192
|
+
// product so `getProducts` surfaces both the catalog and the
|
|
193
|
+
// forecast in one call. Back-compat: omit the params, get the
|
|
194
|
+
// static catalog.
|
|
195
|
+
const targeting = reqUrl.searchParams.get('targeting');
|
|
196
|
+
const flightStart = parseDateParam(reqUrl.searchParams.get('flight_start'));
|
|
197
|
+
const flightEnd = parseDateParam(reqUrl.searchParams.get('flight_end'));
|
|
198
|
+
const budget = parsePositiveNumber(reqUrl.searchParams.get('budget'));
|
|
199
|
+
const hasQuery = Boolean(targeting || flightStart || flightEnd || budget !== undefined);
|
|
200
|
+
if (hasQuery) {
|
|
201
|
+
const decorated = visible.map(p => ({
|
|
202
|
+
...p,
|
|
203
|
+
forecast: synthForecast(p, { targeting, dates: { start: flightStart, end: flightEnd }, budget }),
|
|
204
|
+
}));
|
|
205
|
+
writeJson(res, 200, { products: decorated });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
writeJson(res, 200, { products: visible });
|
|
209
|
+
}
|
|
210
|
+
// ────────────────────────────────────────────────────────────
|
|
211
|
+
// Forecast (spend-only — no availability check; auction-cleared)
|
|
212
|
+
// ────────────────────────────────────────────────────────────
|
|
213
|
+
async function handleForecast(req, network, res) {
|
|
214
|
+
const body = await readJsonObject(req, res);
|
|
215
|
+
if (!body)
|
|
216
|
+
return;
|
|
217
|
+
const { product_id, targeting, flight_dates, budget } = body;
|
|
218
|
+
if (typeof product_id !== 'string') {
|
|
219
|
+
writeJson(res, 400, { code: 'invalid_request', message: 'product_id is required.' });
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
// Forecast stays strict on product existence even though order/lineitem
|
|
223
|
+
// creation is permissive — `synthForecast` needs the product's pricing
|
|
224
|
+
// and channel parameters to compute a curve. Cascade scenarios that
|
|
225
|
+
// seed products via `comply_test_controller` should also forecast
|
|
226
|
+
// against seeded products; storyboards that hit forecast on a buyer-
|
|
227
|
+
// supplied unknown id are exercising the not-found path.
|
|
228
|
+
const product = products.find(p => p.product_id === product_id && p.network_code === network.network_code);
|
|
229
|
+
if (!product) {
|
|
230
|
+
writeJson(res, 404, { code: 'product_not_found', message: `Product ${product_id} not found.` });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const dates = isObject(flight_dates) ? flight_dates : {};
|
|
234
|
+
const targetingKey = serializeTargeting(targeting);
|
|
235
|
+
const forecast = synthForecast(product, {
|
|
236
|
+
targeting: targetingKey,
|
|
237
|
+
dates: {
|
|
238
|
+
start: typeof dates.start === 'string' ? dates.start : undefined,
|
|
239
|
+
end: typeof dates.end === 'string' ? dates.end : undefined,
|
|
240
|
+
},
|
|
241
|
+
budget: typeof budget === 'number' ? budget : undefined,
|
|
242
|
+
});
|
|
243
|
+
writeJson(res, 200, forecast);
|
|
244
|
+
}
|
|
245
|
+
// ────────────────────────────────────────────────────────────
|
|
246
|
+
// Creatives library (network-scoped, idempotent on client_request_id)
|
|
247
|
+
// ────────────────────────────────────────────────────────────
|
|
248
|
+
function handleListCreatives(network, res) {
|
|
249
|
+
const visible = Array.from(creatives.values()).filter(c => c.network_code === network.network_code);
|
|
250
|
+
writeJson(res, 200, { creatives: visible });
|
|
251
|
+
}
|
|
252
|
+
async function handleCreateCreative(req, network, res) {
|
|
253
|
+
const body = await readJsonObject(req, res);
|
|
254
|
+
if (!body)
|
|
255
|
+
return;
|
|
256
|
+
const clientRequestId = typeof body.client_request_id === 'string' ? body.client_request_id : undefined;
|
|
257
|
+
const fingerprint = sha256(JSON.stringify(body));
|
|
258
|
+
if (clientRequestId) {
|
|
259
|
+
const replayed = checkIdempotency(network.network_code, 'creative', clientRequestId, fingerprint);
|
|
260
|
+
if (replayed.kind === 'replay') {
|
|
261
|
+
const existing = creatives.get(replayed.id);
|
|
262
|
+
if (existing) {
|
|
263
|
+
writeJson(res, 200, { ...existing, replayed: true });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (replayed.kind === 'conflict') {
|
|
268
|
+
writeJson(res, 409, {
|
|
269
|
+
code: 'idempotency_conflict',
|
|
270
|
+
message: `client_request_id ${clientRequestId} previously used for a different body.`,
|
|
271
|
+
});
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const name = typeof body.name === 'string' ? body.name : 'Untitled Creative';
|
|
276
|
+
const formatId = typeof body.format_id === 'string' ? body.format_id : null;
|
|
277
|
+
const advertiserId = typeof body.advertiser_id === 'string' ? body.advertiser_id : null;
|
|
278
|
+
if (!formatId || !advertiserId) {
|
|
279
|
+
writeJson(res, 400, { code: 'invalid_request', message: 'format_id and advertiser_id are required.' });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const creativeId = `cr_${(0, node_crypto_1.randomUUID)().slice(0, 8)}`;
|
|
283
|
+
const creative = {
|
|
284
|
+
creative_id: creativeId,
|
|
285
|
+
network_code: network.network_code,
|
|
286
|
+
name,
|
|
287
|
+
format_id: formatId,
|
|
288
|
+
advertiser_id: advertiserId,
|
|
289
|
+
snippet: typeof body.snippet === 'string' ? body.snippet : undefined,
|
|
290
|
+
status: 'active',
|
|
291
|
+
body_fingerprint: fingerprint,
|
|
292
|
+
created_at: new Date().toISOString(),
|
|
293
|
+
};
|
|
294
|
+
creatives.set(creativeId, creative);
|
|
295
|
+
if (clientRequestId)
|
|
296
|
+
recordIdempotency(network.network_code, 'creative', clientRequestId, fingerprint, creativeId);
|
|
297
|
+
writeJson(res, 201, creative);
|
|
298
|
+
}
|
|
299
|
+
// ────────────────────────────────────────────────────────────
|
|
300
|
+
// Orders — sync confirmation (no HITL)
|
|
301
|
+
// ────────────────────────────────────────────────────────────
|
|
302
|
+
function handleListOrders(network, res) {
|
|
303
|
+
const visible = Array.from(orders.values())
|
|
304
|
+
.filter(o => o.network_code === network.network_code)
|
|
305
|
+
.map(toWireOrder);
|
|
306
|
+
writeJson(res, 200, { orders: visible });
|
|
307
|
+
}
|
|
308
|
+
async function handleCreateOrder(req, network, res) {
|
|
309
|
+
const body = await readJsonObject(req, res);
|
|
310
|
+
if (!body)
|
|
311
|
+
return;
|
|
312
|
+
const clientRequestId = typeof body.client_request_id === 'string' ? body.client_request_id : undefined;
|
|
313
|
+
const fingerprint = sha256(JSON.stringify(body));
|
|
314
|
+
if (clientRequestId) {
|
|
315
|
+
const replayed = checkIdempotency(network.network_code, 'order', clientRequestId, fingerprint);
|
|
316
|
+
if (replayed.kind === 'replay') {
|
|
317
|
+
const existing = orders.get(replayed.id);
|
|
318
|
+
if (existing) {
|
|
319
|
+
writeJson(res, 200, { ...toWireOrder(existing), replayed: true });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (replayed.kind === 'conflict') {
|
|
324
|
+
writeJson(res, 409, {
|
|
325
|
+
code: 'idempotency_conflict',
|
|
326
|
+
message: `client_request_id ${clientRequestId} previously used for a different body.`,
|
|
327
|
+
});
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const name = typeof body.name === 'string' ? body.name : 'Untitled Order';
|
|
332
|
+
const advertiserId = typeof body.advertiser_id === 'string' ? body.advertiser_id : null;
|
|
333
|
+
const budget = typeof body.budget === 'number' ? body.budget : null;
|
|
334
|
+
const currency = typeof body.currency === 'string' ? body.currency : 'USD';
|
|
335
|
+
if (!advertiserId || budget === null) {
|
|
336
|
+
writeJson(res, 400, { code: 'invalid_request', message: 'advertiser_id and budget are required.' });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (budget <= 0) {
|
|
340
|
+
writeJson(res, 400, { code: 'invalid_request', message: 'budget must be positive.', field: 'budget' });
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
// Validate line_items if supplied. Product existence isn't required —
|
|
344
|
+
// storyboard cascades seed product fixtures via comply_test_controller
|
|
345
|
+
// independent of the seller's actual catalog (mirrors the sales-guaranteed
|
|
346
|
+
// mock's looser pattern). Min_spend is enforced ONLY when the product is
|
|
347
|
+
// known on this network — gives compliance harnesses a permissive path
|
|
348
|
+
// while keeping the floor-pricing test surface available for known products.
|
|
349
|
+
const lineItemsInput = Array.isArray(body.line_items) ? body.line_items : [];
|
|
350
|
+
const lineItems = [];
|
|
351
|
+
for (const raw of lineItemsInput) {
|
|
352
|
+
if (!isObject(raw)) {
|
|
353
|
+
writeJson(res, 400, { code: 'invalid_request', message: 'each line_item must be an object.' });
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const productId = typeof raw.product_id === 'string' ? raw.product_id : null;
|
|
357
|
+
const liBudget = typeof raw.budget === 'number' ? raw.budget : 0;
|
|
358
|
+
if (!productId) {
|
|
359
|
+
writeJson(res, 400, { code: 'invalid_request', message: 'each line_item requires product_id.' });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const product = products.find(p => p.product_id === productId && p.network_code === network.network_code);
|
|
363
|
+
if (product && product.pricing.min_spend !== undefined && liBudget < product.pricing.min_spend) {
|
|
364
|
+
writeJson(res, 400, {
|
|
365
|
+
code: 'budget_too_low',
|
|
366
|
+
message: `line_item for ${productId}: budget ${liBudget} below product min_spend ${product.pricing.min_spend}.`,
|
|
367
|
+
field: 'line_items[].budget',
|
|
368
|
+
});
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
lineItems.push({
|
|
372
|
+
line_item_id: `li_${(0, node_crypto_1.randomUUID)().slice(0, 8)}`,
|
|
373
|
+
order_id: '', // filled below once order_id is known
|
|
374
|
+
product_id: productId,
|
|
375
|
+
status: 'ready',
|
|
376
|
+
budget: liBudget,
|
|
377
|
+
ad_unit_targeting: Array.isArray(raw.ad_unit_ids)
|
|
378
|
+
? raw.ad_unit_ids.filter((s) => typeof s === 'string')
|
|
379
|
+
: [],
|
|
380
|
+
creative_ids: Array.isArray(raw.creative_ids)
|
|
381
|
+
? raw.creative_ids.filter((s) => typeof s === 'string')
|
|
382
|
+
: [],
|
|
383
|
+
body_fingerprint: sha256(JSON.stringify(raw)),
|
|
384
|
+
created_at: new Date().toISOString(),
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
const orderId = `ord_${(0, node_crypto_1.randomUUID)().slice(0, 8)}`;
|
|
388
|
+
const pacing = parsePacing(body.pacing);
|
|
389
|
+
const now = new Date().toISOString();
|
|
390
|
+
const order = {
|
|
391
|
+
order_id: orderId,
|
|
392
|
+
network_code: network.network_code,
|
|
393
|
+
name,
|
|
394
|
+
// Sync confirmation — auction-cleared programmatic, no HITL approval.
|
|
395
|
+
status: 'confirmed',
|
|
396
|
+
advertiser_id: advertiserId,
|
|
397
|
+
currency,
|
|
398
|
+
budget,
|
|
399
|
+
pacing,
|
|
400
|
+
flight_start: typeof body.flight_start === 'string' ? body.flight_start : undefined,
|
|
401
|
+
flight_end: typeof body.flight_end === 'string' ? body.flight_end : undefined,
|
|
402
|
+
line_items: new Map(),
|
|
403
|
+
body_fingerprint: fingerprint,
|
|
404
|
+
created_at: now,
|
|
405
|
+
updated_at: now,
|
|
406
|
+
};
|
|
407
|
+
for (const li of lineItems) {
|
|
408
|
+
li.order_id = orderId;
|
|
409
|
+
order.line_items.set(li.line_item_id, li);
|
|
410
|
+
}
|
|
411
|
+
orders.set(orderId, order);
|
|
412
|
+
if (clientRequestId)
|
|
413
|
+
recordIdempotency(network.network_code, 'order', clientRequestId, fingerprint, orderId);
|
|
414
|
+
writeJson(res, 201, toWireOrder(order));
|
|
415
|
+
}
|
|
416
|
+
function handleGetOrder(order, res) {
|
|
417
|
+
writeJson(res, 200, toWireOrder(order));
|
|
418
|
+
}
|
|
419
|
+
async function handleUpdateOrder(req, order, res) {
|
|
420
|
+
const body = await readJsonObject(req, res);
|
|
421
|
+
if (!body)
|
|
422
|
+
return;
|
|
423
|
+
if (typeof body.status === 'string') {
|
|
424
|
+
const validStatuses = ['confirmed', 'delivering', 'completed', 'canceled', 'rejected'];
|
|
425
|
+
if (!validStatuses.includes(body.status)) {
|
|
426
|
+
writeJson(res, 400, { code: 'invalid_request', message: `Invalid status: ${body.status}` });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
order.status = body.status;
|
|
430
|
+
}
|
|
431
|
+
if (typeof body.budget === 'number' && body.budget > 0)
|
|
432
|
+
order.budget = body.budget;
|
|
433
|
+
if (typeof body.pacing === 'string')
|
|
434
|
+
order.pacing = parsePacing(body.pacing);
|
|
435
|
+
order.updated_at = new Date().toISOString();
|
|
436
|
+
writeJson(res, 200, toWireOrder(order));
|
|
437
|
+
}
|
|
438
|
+
function handleListLineItems(order, res) {
|
|
439
|
+
writeJson(res, 200, { line_items: Array.from(order.line_items.values()) });
|
|
440
|
+
}
|
|
441
|
+
async function handleCreateLineItem(req, order, res) {
|
|
442
|
+
const body = await readJsonObject(req, res);
|
|
443
|
+
if (!body)
|
|
444
|
+
return;
|
|
445
|
+
const productId = typeof body.product_id === 'string' ? body.product_id : null;
|
|
446
|
+
const liBudget = typeof body.budget === 'number' ? body.budget : 0;
|
|
447
|
+
if (!productId) {
|
|
448
|
+
writeJson(res, 400, { code: 'invalid_request', message: 'product_id is required.' });
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
// Product existence isn't required at line-item creation — cascade
|
|
452
|
+
// scenarios seed product fixtures via comply_test_controller. Min_spend
|
|
453
|
+
// is enforced ONLY when the product is known on this network.
|
|
454
|
+
const product = products.find(p => p.product_id === productId && p.network_code === order.network_code);
|
|
455
|
+
if (product && product.pricing.min_spend !== undefined && liBudget < product.pricing.min_spend) {
|
|
456
|
+
writeJson(res, 400, {
|
|
457
|
+
code: 'budget_too_low',
|
|
458
|
+
message: `budget ${liBudget} below product min_spend ${product.pricing.min_spend}.`,
|
|
459
|
+
field: 'budget',
|
|
460
|
+
});
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const lineItem = {
|
|
464
|
+
line_item_id: `li_${(0, node_crypto_1.randomUUID)().slice(0, 8)}`,
|
|
465
|
+
order_id: order.order_id,
|
|
466
|
+
product_id: productId,
|
|
467
|
+
status: 'ready',
|
|
468
|
+
budget: liBudget,
|
|
469
|
+
ad_unit_targeting: Array.isArray(body.ad_unit_ids)
|
|
470
|
+
? body.ad_unit_ids.filter((s) => typeof s === 'string')
|
|
471
|
+
: [],
|
|
472
|
+
creative_ids: Array.isArray(body.creative_ids)
|
|
473
|
+
? body.creative_ids.filter((s) => typeof s === 'string')
|
|
474
|
+
: [],
|
|
475
|
+
body_fingerprint: sha256(JSON.stringify(body)),
|
|
476
|
+
created_at: new Date().toISOString(),
|
|
477
|
+
};
|
|
478
|
+
order.line_items.set(lineItem.line_item_id, lineItem);
|
|
479
|
+
order.updated_at = new Date().toISOString();
|
|
480
|
+
writeJson(res, 201, lineItem);
|
|
481
|
+
}
|
|
482
|
+
// ────────────────────────────────────────────────────────────
|
|
483
|
+
// Delivery — synth `(budget × elapsed_pct × pacing_curve)`
|
|
484
|
+
// ────────────────────────────────────────────────────────────
|
|
485
|
+
function handleGetDelivery(order, res) {
|
|
486
|
+
// Determine elapsed-pct of flight. If flight dates aren't set,
|
|
487
|
+
// assume the order is mid-flight at 50% so adapters get non-zero
|
|
488
|
+
// numbers even on synthetic test orders.
|
|
489
|
+
const elapsed = computeElapsedPct(order);
|
|
490
|
+
const pacingCurve = pacingCurveAt(order.pacing, elapsed);
|
|
491
|
+
const liDeliveries = [];
|
|
492
|
+
let totalImpressions = 0;
|
|
493
|
+
let totalSpend = 0;
|
|
494
|
+
let totalClicks = 0;
|
|
495
|
+
for (const li of order.line_items.values()) {
|
|
496
|
+
const product = products.find(p => p.product_id === li.product_id);
|
|
497
|
+
if (!product)
|
|
498
|
+
continue;
|
|
499
|
+
const targetCpm = product.pricing.target_cpm ?? product.pricing.min_cpm * 1.3;
|
|
500
|
+
const spent = li.budget * pacingCurve;
|
|
501
|
+
const impressions = Math.max(0, Math.floor((spent / targetCpm) * 1000));
|
|
502
|
+
const ctr = ctrFor(product.channel);
|
|
503
|
+
const clicks = Math.max(0, Math.floor(impressions * ctr));
|
|
504
|
+
liDeliveries.push({
|
|
505
|
+
line_item_id: li.line_item_id,
|
|
506
|
+
product_id: li.product_id,
|
|
507
|
+
impressions,
|
|
508
|
+
clicks,
|
|
509
|
+
spend: round2(spent),
|
|
510
|
+
currency: order.currency,
|
|
511
|
+
effective_cpm: round2(targetCpm),
|
|
512
|
+
pacing_pct: round2(pacingCurve * 100),
|
|
513
|
+
});
|
|
514
|
+
totalImpressions += impressions;
|
|
515
|
+
totalSpend += spent;
|
|
516
|
+
totalClicks += clicks;
|
|
517
|
+
}
|
|
518
|
+
writeJson(res, 200, {
|
|
519
|
+
order_id: order.order_id,
|
|
520
|
+
currency: order.currency,
|
|
521
|
+
pacing: order.pacing,
|
|
522
|
+
reporting_period: { start: order.flight_start, end: order.flight_end },
|
|
523
|
+
totals: {
|
|
524
|
+
impressions: totalImpressions,
|
|
525
|
+
clicks: totalClicks,
|
|
526
|
+
spend: round2(totalSpend),
|
|
527
|
+
budget_remaining: round2(Math.max(0, order.budget - totalSpend)),
|
|
528
|
+
},
|
|
529
|
+
line_items: liDeliveries,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
// ────────────────────────────────────────────────────────────
|
|
533
|
+
// Forecast synthesis (spend-only, deterministic-seeded)
|
|
534
|
+
// ────────────────────────────────────────────────────────────
|
|
535
|
+
function synthForecast(product, opts) {
|
|
536
|
+
const seed = sha256(`${product.product_id}::${opts.targeting ?? ''}::${opts.dates.start ?? ''}::${opts.dates.end ?? ''}`);
|
|
537
|
+
const seedNum = parseInt(seed.slice(0, 8), 16);
|
|
538
|
+
// Effective CPM scales with budget — auction-clearing premium.
|
|
539
|
+
// At small budgets: ~1.0x floor (low competition).
|
|
540
|
+
// At target_cpm-aligned budgets: target_cpm.
|
|
541
|
+
// At very large budgets: saturating toward 2x floor (auction pressure).
|
|
542
|
+
const minCpm = product.pricing.min_cpm;
|
|
543
|
+
const targetCpm = product.pricing.target_cpm ?? minCpm * 1.3;
|
|
544
|
+
const points = [];
|
|
545
|
+
if (opts.budget !== undefined && opts.budget > 0) {
|
|
546
|
+
const eff = computeEffectiveCpm(minCpm, targetCpm, opts.budget);
|
|
547
|
+
const impressions = Math.floor((opts.budget / eff) * 1000);
|
|
548
|
+
// ±15% variance, deterministic-seeded.
|
|
549
|
+
const variance = 0.15;
|
|
550
|
+
const lowImps = Math.floor(impressions * (1 - variance));
|
|
551
|
+
const highImps = Math.floor(impressions * (1 + variance));
|
|
552
|
+
const ctr = ctrFor(product.channel);
|
|
553
|
+
points.push({
|
|
554
|
+
budget: opts.budget,
|
|
555
|
+
metrics: {
|
|
556
|
+
impressions: { low: lowImps, mid: impressions, high: highImps },
|
|
557
|
+
clicks: {
|
|
558
|
+
low: Math.floor(lowImps * ctr),
|
|
559
|
+
mid: Math.floor(impressions * ctr),
|
|
560
|
+
high: Math.floor(highImps * ctr),
|
|
561
|
+
},
|
|
562
|
+
spend: { low: opts.budget, mid: opts.budget, high: opts.budget },
|
|
563
|
+
},
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
// No budget specified — return three indicative points: $1k / $10k / $100k.
|
|
568
|
+
for (const b of [1_000, 10_000, 100_000]) {
|
|
569
|
+
const eff = computeEffectiveCpm(minCpm, targetCpm, b);
|
|
570
|
+
const impressions = Math.floor((b / eff) * 1000);
|
|
571
|
+
const variance = 0.15;
|
|
572
|
+
const lowImps = Math.floor(impressions * (1 - variance));
|
|
573
|
+
const highImps = Math.floor(impressions * (1 + variance));
|
|
574
|
+
const ctr = ctrFor(product.channel);
|
|
575
|
+
points.push({
|
|
576
|
+
budget: b,
|
|
577
|
+
metrics: {
|
|
578
|
+
impressions: { low: lowImps, mid: impressions, high: highImps },
|
|
579
|
+
clicks: {
|
|
580
|
+
low: Math.floor(lowImps * ctr),
|
|
581
|
+
mid: Math.floor(impressions * ctr),
|
|
582
|
+
high: Math.floor(highImps * ctr),
|
|
583
|
+
},
|
|
584
|
+
spend: { low: b, mid: b, high: b },
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
// Suppress unused-var warning for the deterministic seed when no
|
|
589
|
+
// budget is supplied (the seed shapes the variance for a future
|
|
590
|
+
// tightening; today we use a fixed ±15%). Keep the seed-derivation
|
|
591
|
+
// call site so the variance hook is in place.
|
|
592
|
+
void seedNum;
|
|
593
|
+
}
|
|
594
|
+
const out = {
|
|
595
|
+
product_id: product.product_id,
|
|
596
|
+
forecast_range_unit: 'spend',
|
|
597
|
+
method: 'modeled',
|
|
598
|
+
currency: product.pricing.currency,
|
|
599
|
+
points,
|
|
600
|
+
};
|
|
601
|
+
if (opts.budget !== undefined &&
|
|
602
|
+
product.pricing.min_spend !== undefined &&
|
|
603
|
+
opts.budget < product.pricing.min_spend) {
|
|
604
|
+
out.min_budget_warning = {
|
|
605
|
+
required: product.pricing.min_spend,
|
|
606
|
+
reason: `Budget ${opts.budget} is below the product's learning-phase floor (${product.pricing.min_spend}). Programmatic remnant typically requires a minimum daily spend to clear auction.`,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
return out;
|
|
610
|
+
}
|
|
611
|
+
function computeEffectiveCpm(minCpm, targetCpm, budget) {
|
|
612
|
+
// Auction-clearing curve. Saturating function that:
|
|
613
|
+
// - At budget=0: returns ~minCpm (low competition).
|
|
614
|
+
// - At budget=10x targetCpm: returns ~targetCpm.
|
|
615
|
+
// - At budget=∞: asymptotes to 2*minCpm (high competition).
|
|
616
|
+
const inflection = targetCpm * 1000; // point where bidding starts pushing CPM up
|
|
617
|
+
const ratio = budget / (budget + inflection);
|
|
618
|
+
const ceiling = minCpm * 2;
|
|
619
|
+
return minCpm + (ceiling - minCpm) * ratio;
|
|
620
|
+
}
|
|
621
|
+
// ────────────────────────────────────────────────────────────
|
|
622
|
+
// Idempotency helpers
|
|
623
|
+
// ────────────────────────────────────────────────────────────
|
|
624
|
+
function checkIdempotency(networkCode, kind, clientRequestId, fingerprint) {
|
|
625
|
+
const key = `${networkCode}::${kind}::${clientRequestId}`;
|
|
626
|
+
const existing = idempotency.get(key);
|
|
627
|
+
if (!existing)
|
|
628
|
+
return { kind: 'fresh' };
|
|
629
|
+
if (existing.startsWith('409:')) {
|
|
630
|
+
const storedFingerprint = existing.slice(4);
|
|
631
|
+
if (storedFingerprint !== fingerprint)
|
|
632
|
+
return { kind: 'conflict' };
|
|
633
|
+
return { kind: 'fresh' };
|
|
634
|
+
}
|
|
635
|
+
// existing is "<resourceId>::<fingerprint>"
|
|
636
|
+
const [resourceId, storedFingerprint] = existing.split('::');
|
|
637
|
+
if (!resourceId || !storedFingerprint)
|
|
638
|
+
return { kind: 'fresh' };
|
|
639
|
+
if (storedFingerprint !== fingerprint) {
|
|
640
|
+
idempotency.set(key, `409:${storedFingerprint}`);
|
|
641
|
+
return { kind: 'conflict' };
|
|
642
|
+
}
|
|
643
|
+
return { kind: 'replay', id: resourceId };
|
|
644
|
+
}
|
|
645
|
+
function recordIdempotency(networkCode, kind, clientRequestId, fingerprint, resourceId) {
|
|
646
|
+
const key = `${networkCode}::${kind}::${clientRequestId}`;
|
|
647
|
+
idempotency.set(key, `${resourceId}::${fingerprint}`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
// ────────────────────────────────────────────────────────────
|
|
651
|
+
// Helpers (module-scoped, no closure capture needed)
|
|
652
|
+
// ────────────────────────────────────────────────────────────
|
|
653
|
+
function toWireOrder(order) {
|
|
654
|
+
return {
|
|
655
|
+
order_id: order.order_id,
|
|
656
|
+
name: order.name,
|
|
657
|
+
status: order.status,
|
|
658
|
+
advertiser_id: order.advertiser_id,
|
|
659
|
+
currency: order.currency,
|
|
660
|
+
budget: order.budget,
|
|
661
|
+
pacing: order.pacing,
|
|
662
|
+
flight_start: order.flight_start,
|
|
663
|
+
flight_end: order.flight_end,
|
|
664
|
+
line_items: Array.from(order.line_items.values()),
|
|
665
|
+
rejection_reason: order.rejection_reason,
|
|
666
|
+
created_at: order.created_at,
|
|
667
|
+
updated_at: order.updated_at,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
function parsePacing(raw) {
|
|
671
|
+
if (raw === 'asap' || raw === 'front_loaded' || raw === 'even')
|
|
672
|
+
return raw;
|
|
673
|
+
return 'even';
|
|
674
|
+
}
|
|
675
|
+
function pacingCurveAt(pacing, elapsed) {
|
|
676
|
+
// Returns fraction-of-budget-spent at the elapsed-fraction-of-flight.
|
|
677
|
+
const t = Math.max(0, Math.min(1, elapsed));
|
|
678
|
+
if (pacing === 'even')
|
|
679
|
+
return t;
|
|
680
|
+
if (pacing === 'asap')
|
|
681
|
+
return Math.min(1, t * 3); // 3x acceleration, caps at 100%
|
|
682
|
+
if (pacing === 'front_loaded')
|
|
683
|
+
return Math.min(1, Math.sqrt(t)); // sqrt curve front-loads
|
|
684
|
+
return t;
|
|
685
|
+
}
|
|
686
|
+
function computeElapsedPct(order) {
|
|
687
|
+
if (!order.flight_start || !order.flight_end)
|
|
688
|
+
return 0.5;
|
|
689
|
+
const start = Date.parse(order.flight_start);
|
|
690
|
+
const end = Date.parse(order.flight_end);
|
|
691
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start)
|
|
692
|
+
return 0.5;
|
|
693
|
+
const now = Date.now();
|
|
694
|
+
if (now <= start)
|
|
695
|
+
return 0;
|
|
696
|
+
if (now >= end)
|
|
697
|
+
return 1;
|
|
698
|
+
return (now - start) / (end - start);
|
|
699
|
+
}
|
|
700
|
+
function ctrFor(channel) {
|
|
701
|
+
// CTR baselines per channel. Programmatic remnant is lower than
|
|
702
|
+
// premium guaranteed.
|
|
703
|
+
if (channel === 'video')
|
|
704
|
+
return 0.005; // 0.5%
|
|
705
|
+
if (channel === 'ctv')
|
|
706
|
+
return 0.001; // 0.1% (mostly view-through, low click)
|
|
707
|
+
if (channel === 'audio')
|
|
708
|
+
return 0.001;
|
|
709
|
+
return 0.001; // display 0.1%
|
|
710
|
+
}
|
|
711
|
+
function round2(n) {
|
|
712
|
+
return Math.round(n * 100) / 100;
|
|
713
|
+
}
|
|
714
|
+
function parseDateParam(raw) {
|
|
715
|
+
if (!raw)
|
|
716
|
+
return undefined;
|
|
717
|
+
// Permissive — accept any string. The forecast hash includes it
|
|
718
|
+
// verbatim; bad input still gives deterministic output.
|
|
719
|
+
return raw;
|
|
720
|
+
}
|
|
721
|
+
function parsePositiveNumber(raw) {
|
|
722
|
+
if (!raw)
|
|
723
|
+
return undefined;
|
|
724
|
+
const n = Number(raw);
|
|
725
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
726
|
+
return undefined;
|
|
727
|
+
return n;
|
|
728
|
+
}
|
|
729
|
+
function serializeTargeting(t) {
|
|
730
|
+
if (!isObject(t))
|
|
731
|
+
return '';
|
|
732
|
+
// Deterministic stringify (recursive sort) so the same targeting
|
|
733
|
+
// produces the same hash regardless of key insertion order. Mirrors
|
|
734
|
+
// the helper from sales-guaranteed/server.ts (commit `c626f750` —
|
|
735
|
+
// the determinism-bug fix lineage).
|
|
736
|
+
return JSON.stringify(deepSort(t));
|
|
737
|
+
}
|
|
738
|
+
function deepSort(v) {
|
|
739
|
+
if (Array.isArray(v))
|
|
740
|
+
return v.map(deepSort);
|
|
741
|
+
if (isObject(v)) {
|
|
742
|
+
const out = {};
|
|
743
|
+
for (const k of Object.keys(v).sort()) {
|
|
744
|
+
out[k] = deepSort(v[k]);
|
|
745
|
+
}
|
|
746
|
+
return out;
|
|
747
|
+
}
|
|
748
|
+
return v;
|
|
749
|
+
}
|
|
750
|
+
function isObject(x) {
|
|
751
|
+
return typeof x === 'object' && x !== null && !Array.isArray(x);
|
|
752
|
+
}
|
|
753
|
+
function sha256(input) {
|
|
754
|
+
return (0, node_crypto_1.createHash)('sha256').update(input).digest('hex');
|
|
755
|
+
}
|
|
756
|
+
function writeJson(res, status, body) {
|
|
757
|
+
res.statusCode = status;
|
|
758
|
+
res.setHeader('content-type', 'application/json');
|
|
759
|
+
res.end(JSON.stringify(body));
|
|
760
|
+
}
|
|
761
|
+
async function readJsonObject(req, res) {
|
|
762
|
+
const chunks = [];
|
|
763
|
+
for await (const chunk of req) {
|
|
764
|
+
chunks.push(chunk);
|
|
765
|
+
}
|
|
766
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
767
|
+
if (!raw.trim())
|
|
768
|
+
return {};
|
|
769
|
+
try {
|
|
770
|
+
const parsed = JSON.parse(raw);
|
|
771
|
+
if (!isObject(parsed)) {
|
|
772
|
+
writeJson(res, 400, { code: 'invalid_request', message: 'request body must be a JSON object.' });
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
return parsed;
|
|
776
|
+
}
|
|
777
|
+
catch (e) {
|
|
778
|
+
writeJson(res, 400, { code: 'invalid_request', message: `malformed JSON: ${e.message}` });
|
|
779
|
+
return null;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
//# sourceMappingURL=server.js.map
|