@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,618 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `creative-ad-server` upstream-shape mock-server. Stateful creative
|
|
4
|
+
* library (POST writes, GET reads), tag generation with macro substitution,
|
|
5
|
+
* synth delivery reporting. Closes #1459 (sub-issue of #1381).
|
|
6
|
+
*
|
|
7
|
+
* Pattern source:
|
|
8
|
+
* - Library state shape: `sales-guaranteed/server.ts` handleCreateCreative /
|
|
9
|
+
* handleListCreatives — network-scoped, idempotency on client_request_id.
|
|
10
|
+
* - Format catalog projection: `creative-template/server.ts` (templates
|
|
11
|
+
* in seed-data, projected at request-time).
|
|
12
|
+
*
|
|
13
|
+
* Specialism deltas vs `creative-template`:
|
|
14
|
+
* - Stateful library (writes persist across calls).
|
|
15
|
+
* - Format auto-detection from upload mime (handleCreateCreative).
|
|
16
|
+
* - Tag generation (POST /v1/creatives/{id}/render) substitutes macros
|
|
17
|
+
* into a stored snippet template — `{click_url}`, `{impression_pixel}`,
|
|
18
|
+
* `{cb}`, `{advertiser_id}`, etc.
|
|
19
|
+
* - Real `/serve/{creative_id}` HTML response — adopters get a true
|
|
20
|
+
* iframe-embeddable URL on previewCreative, not a synthetic string.
|
|
21
|
+
* - Delivery reporting (GET /v1/creatives/{id}/delivery) — synth
|
|
22
|
+
* impressions/clicks/CTR scaled by days-active, deterministic-seeded
|
|
23
|
+
* on (creative_id, day).
|
|
24
|
+
* - Multi-tenancy via X-Network-Code header (mirrors sales-guaranteed).
|
|
25
|
+
*/
|
|
26
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
|
+
exports.projectFormat = void 0;
|
|
28
|
+
exports.bootCreativeAdServer = bootCreativeAdServer;
|
|
29
|
+
const node_http_1 = require("node:http");
|
|
30
|
+
const node_crypto_1 = require("node:crypto");
|
|
31
|
+
const seed_data_1 = require("./seed-data");
|
|
32
|
+
Object.defineProperty(exports, "projectFormat", { enumerable: true, get: function () { return seed_data_1.projectFormat; } });
|
|
33
|
+
const PAGE_SIZE_DEFAULT = 50;
|
|
34
|
+
async function bootCreativeAdServer(options) {
|
|
35
|
+
const apiKey = options.apiKey ?? seed_data_1.DEFAULT_API_KEY;
|
|
36
|
+
const networks = options.networks ?? seed_data_1.NETWORKS;
|
|
37
|
+
const formats = options.formats ?? seed_data_1.FORMATS;
|
|
38
|
+
const seededCreatives = options.creatives ?? seed_data_1.CREATIVES;
|
|
39
|
+
const creatives = new Map();
|
|
40
|
+
for (const seed of seededCreatives) {
|
|
41
|
+
creatives.set(seed.creative_id, {
|
|
42
|
+
...seed,
|
|
43
|
+
body_fingerprint: sha256(JSON.stringify(seed)),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// Idempotency table — keyed `<network_code>::creative::<client_request_id>`.
|
|
47
|
+
const idempotency = new Map();
|
|
48
|
+
// Traffic counters keyed by `<METHOD> <route-template>`. Harness queries
|
|
49
|
+
// `GET /_debug/traffic` after the storyboard run and asserts headline
|
|
50
|
+
// routes were hit ≥1. Façade adapters that skip the upstream produce
|
|
51
|
+
// zero counters and fail the assertion.
|
|
52
|
+
const traffic = new Map();
|
|
53
|
+
const bump = (routeTemplate) => {
|
|
54
|
+
traffic.set(routeTemplate, (traffic.get(routeTemplate) ?? 0) + 1);
|
|
55
|
+
};
|
|
56
|
+
const server = (0, node_http_1.createServer)((req, res) => {
|
|
57
|
+
handleRequest(req, res).catch(err => {
|
|
58
|
+
writeJson(res, 500, { code: 'internal_error', message: err?.message ?? 'unexpected error' });
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
await new Promise((resolve, reject) => {
|
|
62
|
+
server.once('error', reject);
|
|
63
|
+
server.listen(options.port, '127.0.0.1', () => {
|
|
64
|
+
server.removeListener('error', reject);
|
|
65
|
+
resolve();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
const addr = server.address();
|
|
69
|
+
const boundPort = typeof addr === 'object' && addr ? addr.port : options.port;
|
|
70
|
+
const url = `http://127.0.0.1:${boundPort}`;
|
|
71
|
+
return {
|
|
72
|
+
url,
|
|
73
|
+
close: () => new Promise((resolve, reject) => {
|
|
74
|
+
server.close(err => (err ? reject(err) : resolve()));
|
|
75
|
+
}),
|
|
76
|
+
};
|
|
77
|
+
async function handleRequest(req, res) {
|
|
78
|
+
const reqUrl = new URL(req.url ?? '/', url);
|
|
79
|
+
const path = reqUrl.pathname;
|
|
80
|
+
const method = req.method ?? 'GET';
|
|
81
|
+
// Façade-detection traffic dump — harness-only, no auth required.
|
|
82
|
+
if (method === 'GET' && path === '/_debug/traffic') {
|
|
83
|
+
writeJson(res, 200, { traffic: Object.fromEntries(traffic) });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// Network discovery — no auth (happens before tenant context is known).
|
|
87
|
+
if (method === 'GET' && path === '/_lookup/network') {
|
|
88
|
+
bump('GET /_lookup/network');
|
|
89
|
+
const adcpPublisher = reqUrl.searchParams.get('adcp_publisher');
|
|
90
|
+
if (!adcpPublisher) {
|
|
91
|
+
writeJson(res, 400, { code: 'invalid_request', message: 'adcp_publisher query parameter is required.' });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const match = networks.find(n => n.adcp_publisher === adcpPublisher);
|
|
95
|
+
if (!match) {
|
|
96
|
+
writeJson(res, 404, {
|
|
97
|
+
code: 'network_not_found',
|
|
98
|
+
message: `No upstream network registered for adcp_publisher=${adcpPublisher}.`,
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
writeJson(res, 200, {
|
|
103
|
+
adcp_publisher: match.adcp_publisher,
|
|
104
|
+
network_code: match.network_code,
|
|
105
|
+
display_name: match.display_name,
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// /serve/{creative_id} — real HTML response. No bearer auth: this is
|
|
110
|
+
// the URL real ad servers expose to publisher iframes; gating it on
|
|
111
|
+
// bearer tokens would defeat the test-mode CDN-style pattern. The
|
|
112
|
+
// creative_id itself is the capability — leak prevention is whoever
|
|
113
|
+
// gets the URL gets the render. Production servers of course gate
|
|
114
|
+
// this through signed URLs / referrer checks.
|
|
115
|
+
const serveMatch = path.match(/^\/serve\/([^/]+)$/);
|
|
116
|
+
if (method === 'GET' && serveMatch && serveMatch[1]) {
|
|
117
|
+
bump('GET /serve/{id}');
|
|
118
|
+
const creativeId = decodeURIComponent(serveMatch[1]);
|
|
119
|
+
const cr = creatives.get(creativeId);
|
|
120
|
+
if (!cr) {
|
|
121
|
+
res.writeHead(404, { 'Content-Type': 'text/html' });
|
|
122
|
+
res.end('<!doctype html><title>Not found</title>');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const ctxParam = reqUrl.searchParams.get('ctx') ?? '';
|
|
126
|
+
const html = renderServeHtml(cr, formats, url, ctxParam);
|
|
127
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
128
|
+
res.end(html);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// Auth gate for /v1/* surface.
|
|
132
|
+
const auth = req.headers['authorization'];
|
|
133
|
+
if (!auth || !auth.startsWith('Bearer ') || auth.slice(7) !== apiKey) {
|
|
134
|
+
writeJson(res, 401, { code: 'unauthorized', message: 'Missing or invalid bearer credential.' });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const networkHeader = req.headers['x-network-code'];
|
|
138
|
+
const networkCode = Array.isArray(networkHeader) ? networkHeader[0] : networkHeader;
|
|
139
|
+
if (!networkCode) {
|
|
140
|
+
writeJson(res, 403, { code: 'network_required', message: 'X-Network-Code header is required on every request.' });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const network = networks.find(n => n.network_code === networkCode);
|
|
144
|
+
if (!network) {
|
|
145
|
+
writeJson(res, 403, { code: 'unknown_network', message: `Unknown network: ${networkCode}` });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (method === 'GET' && path === '/v1/formats') {
|
|
149
|
+
bump('GET /v1/formats');
|
|
150
|
+
const filtered = formats.filter(f => f.network_code === network.network_code);
|
|
151
|
+
writeJson(res, 200, {
|
|
152
|
+
formats: filtered.map(f => ({
|
|
153
|
+
format_id: f.format_id,
|
|
154
|
+
name: f.name,
|
|
155
|
+
channel: f.channel,
|
|
156
|
+
render_kind: f.render_kind,
|
|
157
|
+
...(f.width !== undefined && { width: f.width }),
|
|
158
|
+
...(f.height !== undefined && { height: f.height }),
|
|
159
|
+
...(f.duration_seconds !== undefined && { duration_seconds: f.duration_seconds }),
|
|
160
|
+
accepted_mimes: f.accepted_mimes,
|
|
161
|
+
})),
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (method === 'GET' && path === '/v1/creatives') {
|
|
166
|
+
bump('GET /v1/creatives');
|
|
167
|
+
return handleListCreatives(reqUrl, network, res);
|
|
168
|
+
}
|
|
169
|
+
if (method === 'POST' && path === '/v1/creatives') {
|
|
170
|
+
bump('POST /v1/creatives');
|
|
171
|
+
return handleCreateCreative(req, network, res);
|
|
172
|
+
}
|
|
173
|
+
const creativeMatch = path.match(/^\/v1\/creatives\/([^/]+)(\/.*)?$/);
|
|
174
|
+
if (creativeMatch && creativeMatch[1]) {
|
|
175
|
+
const creativeId = decodeURIComponent(creativeMatch[1]);
|
|
176
|
+
const subPath = creativeMatch[2] ?? '/';
|
|
177
|
+
const creative = creatives.get(creativeId);
|
|
178
|
+
if (!creative || creative.network_code !== network.network_code) {
|
|
179
|
+
writeJson(res, 404, { code: 'creative_not_found', message: `Creative ${creativeId} not found.` });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (method === 'GET' && subPath === '/') {
|
|
183
|
+
bump('GET /v1/creatives/{id}');
|
|
184
|
+
writeJson(res, 200, stripFingerprint(creative));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (method === 'PATCH' && subPath === '/') {
|
|
188
|
+
bump('PATCH /v1/creatives/{id}');
|
|
189
|
+
return handleUpdateCreative(req, creative, res);
|
|
190
|
+
}
|
|
191
|
+
if (method === 'POST' && subPath === '/render') {
|
|
192
|
+
bump('POST /v1/creatives/{id}/render');
|
|
193
|
+
return handleRenderCreative(req, creative, res);
|
|
194
|
+
}
|
|
195
|
+
if (method === 'GET' && subPath === '/delivery') {
|
|
196
|
+
bump('GET /v1/creatives/{id}/delivery');
|
|
197
|
+
return handleGetDelivery(reqUrl, creative, res);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
writeJson(res, 404, { code: 'not_found', message: `No route for ${method} ${path}` });
|
|
201
|
+
}
|
|
202
|
+
// ────────────────────────────────────────────────────────────
|
|
203
|
+
// Creatives library
|
|
204
|
+
// ────────────────────────────────────────────────────────────
|
|
205
|
+
function handleListCreatives(reqUrl, network, res) {
|
|
206
|
+
let visible = Array.from(creatives.values()).filter(c => c.network_code === network.network_code);
|
|
207
|
+
const advertiserId = reqUrl.searchParams.get('advertiser_id');
|
|
208
|
+
if (advertiserId)
|
|
209
|
+
visible = visible.filter(c => c.advertiser_id === advertiserId);
|
|
210
|
+
const formatId = reqUrl.searchParams.get('format_id');
|
|
211
|
+
if (formatId)
|
|
212
|
+
visible = visible.filter(c => c.format_id === formatId);
|
|
213
|
+
const status = reqUrl.searchParams.get('status');
|
|
214
|
+
if (status)
|
|
215
|
+
visible = visible.filter(c => c.status === status);
|
|
216
|
+
const createdAfter = reqUrl.searchParams.get('created_after');
|
|
217
|
+
if (createdAfter)
|
|
218
|
+
visible = visible.filter(c => c.created_at >= createdAfter);
|
|
219
|
+
const creativeIdsParam = reqUrl.searchParams.get('creative_ids');
|
|
220
|
+
if (creativeIdsParam) {
|
|
221
|
+
const ids = new Set(creativeIdsParam.split(','));
|
|
222
|
+
visible = visible.filter(c => ids.has(c.creative_id));
|
|
223
|
+
}
|
|
224
|
+
visible.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
225
|
+
// Pagination cursor — opaque base64 of the next-row created_at.
|
|
226
|
+
const limit = Math.min(Math.max(parsePositiveNumber(reqUrl.searchParams.get('limit')) ?? PAGE_SIZE_DEFAULT, 1), 200);
|
|
227
|
+
const cursor = reqUrl.searchParams.get('cursor');
|
|
228
|
+
let startIdx = 0;
|
|
229
|
+
if (cursor) {
|
|
230
|
+
try {
|
|
231
|
+
const decoded = Buffer.from(cursor, 'base64url').toString('utf8');
|
|
232
|
+
startIdx = visible.findIndex(c => c.created_at > decoded);
|
|
233
|
+
if (startIdx < 0)
|
|
234
|
+
startIdx = visible.length;
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
writeJson(res, 400, { code: 'invalid_cursor', message: 'cursor is not a valid pagination token.' });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const page = visible.slice(startIdx, startIdx + limit);
|
|
242
|
+
const next = visible[startIdx + limit];
|
|
243
|
+
const nextCursor = next ? Buffer.from(page[page.length - 1]?.created_at ?? '', 'utf8').toString('base64url') : null;
|
|
244
|
+
writeJson(res, 200, {
|
|
245
|
+
creatives: page.map(stripFingerprint),
|
|
246
|
+
...(nextCursor && { next_cursor: nextCursor }),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
async function handleCreateCreative(req, network, res) {
|
|
250
|
+
const body = await readJsonObject(req, res);
|
|
251
|
+
if (!body)
|
|
252
|
+
return;
|
|
253
|
+
const name = typeof body.name === 'string' ? body.name : null;
|
|
254
|
+
const advertiserId = typeof body.advertiser_id === 'string' ? body.advertiser_id : null;
|
|
255
|
+
const explicitFormatId = typeof body.format_id === 'string' ? body.format_id : null;
|
|
256
|
+
const snippet = typeof body.snippet === 'string' ? body.snippet : undefined;
|
|
257
|
+
const clickUrl = typeof body.click_url === 'string' ? body.click_url : undefined;
|
|
258
|
+
const uploadMime = typeof body.upload_mime === 'string' ? body.upload_mime : undefined;
|
|
259
|
+
const widthHint = typeof body.width === 'number' ? body.width : undefined;
|
|
260
|
+
const heightHint = typeof body.height === 'number' ? body.height : undefined;
|
|
261
|
+
const clientRequestId = typeof body.client_request_id === 'string' ? body.client_request_id : undefined;
|
|
262
|
+
if (!name || !advertiserId) {
|
|
263
|
+
writeJson(res, 400, { code: 'invalid_request', message: 'name and advertiser_id are required.' });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
// Format auto-detection when format_id isn't supplied. Sniff by mime
|
|
267
|
+
// type + dimensions hint. Real ad servers use richer detection
|
|
268
|
+
// (binary header inspection, codec sniff for video); this is the
|
|
269
|
+
// minimum to demonstrate the auto-detect surface.
|
|
270
|
+
let format;
|
|
271
|
+
if (explicitFormatId) {
|
|
272
|
+
format = formats.find(f => f.format_id === explicitFormatId && f.network_code === network.network_code);
|
|
273
|
+
if (!format) {
|
|
274
|
+
writeJson(res, 404, {
|
|
275
|
+
code: 'format_not_found',
|
|
276
|
+
message: `Format ${explicitFormatId} not found on network ${network.network_code}.`,
|
|
277
|
+
});
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
else if (uploadMime) {
|
|
282
|
+
const candidates = formats.filter(f => f.network_code === network.network_code && f.accepted_mimes.includes(uploadMime));
|
|
283
|
+
// Prefer dimension-matched fixed format when hint provided.
|
|
284
|
+
if (widthHint !== undefined && heightHint !== undefined) {
|
|
285
|
+
format = candidates.find(c => c.width === widthHint && c.height === heightHint);
|
|
286
|
+
}
|
|
287
|
+
if (!format)
|
|
288
|
+
format = candidates[0];
|
|
289
|
+
if (!format) {
|
|
290
|
+
writeJson(res, 422, {
|
|
291
|
+
code: 'format_auto_detect_failed',
|
|
292
|
+
message: `Could not auto-detect format for upload_mime=${uploadMime}; pass an explicit format_id.`,
|
|
293
|
+
field: 'format_id',
|
|
294
|
+
});
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
writeJson(res, 400, {
|
|
300
|
+
code: 'invalid_request',
|
|
301
|
+
message: 'either format_id or upload_mime is required.',
|
|
302
|
+
field: 'format_id',
|
|
303
|
+
});
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const fingerprint = sha256(JSON.stringify({ name, advertiserId, formatId: format.format_id, snippet, clickUrl }));
|
|
307
|
+
if (clientRequestId) {
|
|
308
|
+
const key = `${network.network_code}::creative::${clientRequestId}`;
|
|
309
|
+
const existing = idempotency.get(key);
|
|
310
|
+
if (existing) {
|
|
311
|
+
const existingCr = creatives.get(existing);
|
|
312
|
+
if (existingCr) {
|
|
313
|
+
if (existingCr.body_fingerprint !== fingerprint) {
|
|
314
|
+
writeJson(res, 409, {
|
|
315
|
+
code: 'idempotency_conflict',
|
|
316
|
+
message: `client_request_id ${clientRequestId} previously used for a different body.`,
|
|
317
|
+
});
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
writeJson(res, 200, { ...stripFingerprint(existingCr), replayed: true });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// Allow caller-supplied `creative_id` override — real ad servers
|
|
326
|
+
// reject this (the platform owns the namespace), but cascade-test
|
|
327
|
+
// seeders need to write under a known id so storyboard fixtures
|
|
328
|
+
// can reference them by alias. Production sellers ship without this
|
|
329
|
+
// override path.
|
|
330
|
+
//
|
|
331
|
+
// SECURITY GATES:
|
|
332
|
+
// 1. Env allowlist — `MOCK_ALLOW_CREATIVE_ID_OVERRIDE=1` is required.
|
|
333
|
+
// Default-off prevents an adopter who forks this mock and points it
|
|
334
|
+
// at a public host from accepting buyer-supplied ids. The compliance
|
|
335
|
+
// runner sets it via the seeder env; production never does.
|
|
336
|
+
// 2. Cross-tenant collision check — if the id already exists on a
|
|
337
|
+
// DIFFERENT network, refuse with 409. Without this, tenant B can
|
|
338
|
+
// POST `creative_id: <tenant-A-id>` and overwrite tenant A's record;
|
|
339
|
+
// reads stay network-gated but `/serve/{id}` is auth-free, so the
|
|
340
|
+
// overwrite would be served as canonical.
|
|
341
|
+
const overrideAllowed = typeof body.creative_id === 'string' && process.env['MOCK_ALLOW_CREATIVE_ID_OVERRIDE'] === '1';
|
|
342
|
+
const explicitCreativeId = overrideAllowed ? body.creative_id : null;
|
|
343
|
+
if (explicitCreativeId) {
|
|
344
|
+
const existing = creatives.get(explicitCreativeId);
|
|
345
|
+
if (existing && existing.network_code !== network.network_code) {
|
|
346
|
+
writeJson(res, 409, {
|
|
347
|
+
code: 'creative_id_conflict',
|
|
348
|
+
message: `creative_id ${explicitCreativeId} already exists on a different network; cannot overwrite cross-tenant.`,
|
|
349
|
+
field: 'creative_id',
|
|
350
|
+
});
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const creativeId = explicitCreativeId ?? `cr_${(0, node_crypto_1.randomUUID)().replace(/-/g, '').slice(0, 16)}`;
|
|
355
|
+
const now = new Date().toISOString();
|
|
356
|
+
const cr = {
|
|
357
|
+
creative_id: creativeId,
|
|
358
|
+
network_code: network.network_code,
|
|
359
|
+
advertiser_id: advertiserId,
|
|
360
|
+
format_id: format.format_id,
|
|
361
|
+
name,
|
|
362
|
+
...(snippet !== undefined && { snippet }),
|
|
363
|
+
...(clickUrl !== undefined && { click_url: clickUrl }),
|
|
364
|
+
status: 'active',
|
|
365
|
+
created_at: now,
|
|
366
|
+
updated_at: now,
|
|
367
|
+
body_fingerprint: fingerprint,
|
|
368
|
+
};
|
|
369
|
+
creatives.set(creativeId, cr);
|
|
370
|
+
if (clientRequestId) {
|
|
371
|
+
idempotency.set(`${network.network_code}::creative::${clientRequestId}`, creativeId);
|
|
372
|
+
}
|
|
373
|
+
writeJson(res, 201, stripFingerprint(cr));
|
|
374
|
+
}
|
|
375
|
+
async function handleUpdateCreative(req, cr, res) {
|
|
376
|
+
const body = await readJsonObject(req, res);
|
|
377
|
+
if (!body)
|
|
378
|
+
return;
|
|
379
|
+
if (typeof body.snippet === 'string')
|
|
380
|
+
cr.snippet = body.snippet;
|
|
381
|
+
if (typeof body.click_url === 'string')
|
|
382
|
+
cr.click_url = body.click_url;
|
|
383
|
+
if (typeof body.name === 'string')
|
|
384
|
+
cr.name = body.name;
|
|
385
|
+
if (typeof body.status === 'string') {
|
|
386
|
+
const allowed = ['active', 'paused', 'archived', 'rejected'];
|
|
387
|
+
if (!allowed.includes(body.status)) {
|
|
388
|
+
writeJson(res, 400, { code: 'invalid_status', message: `status must be one of ${allowed.join(', ')}.` });
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
cr.status = body.status;
|
|
392
|
+
}
|
|
393
|
+
cr.updated_at = new Date().toISOString();
|
|
394
|
+
cr.body_fingerprint = sha256(JSON.stringify({
|
|
395
|
+
name: cr.name,
|
|
396
|
+
advertiserId: cr.advertiser_id,
|
|
397
|
+
formatId: cr.format_id,
|
|
398
|
+
snippet: cr.snippet,
|
|
399
|
+
clickUrl: cr.click_url,
|
|
400
|
+
}));
|
|
401
|
+
writeJson(res, 200, stripFingerprint(cr));
|
|
402
|
+
}
|
|
403
|
+
// ────────────────────────────────────────────────────────────
|
|
404
|
+
// Tag generation — macro substitution
|
|
405
|
+
// ────────────────────────────────────────────────────────────
|
|
406
|
+
async function handleRenderCreative(req, cr, res) {
|
|
407
|
+
const body = await readJsonObject(req, res);
|
|
408
|
+
if (!body)
|
|
409
|
+
return;
|
|
410
|
+
const ctx = isObject(body.context) ? body.context : {};
|
|
411
|
+
const format = formats.find(f => f.format_id === cr.format_id && f.network_code === cr.network_code);
|
|
412
|
+
if (!format) {
|
|
413
|
+
writeJson(res, 500, {
|
|
414
|
+
code: 'format_not_found',
|
|
415
|
+
message: `Creative ${cr.creative_id} references unknown format ${cr.format_id}.`,
|
|
416
|
+
});
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const template = cr.snippet ?? format.snippet_template;
|
|
420
|
+
// POST /render returns substituted HTML in the response body; the buyer
|
|
421
|
+
// is expected to apply their own context (signed clickthroughs, viewability
|
|
422
|
+
// shims) before serving. Escape values defensively because the same
|
|
423
|
+
// bytes can land in a publisher's iframe via `tag_url` if the buyer
|
|
424
|
+
// skips the secondary binding step.
|
|
425
|
+
const substituted = substituteMacros(template, cr, format, ctx, true);
|
|
426
|
+
const tagUrl = `${url}/serve/${encodeURIComponent(cr.creative_id)}?ctx=${encodeURIComponent(serializeCtx(ctx))}`;
|
|
427
|
+
writeJson(res, 200, {
|
|
428
|
+
creative_id: cr.creative_id,
|
|
429
|
+
format_id: cr.format_id,
|
|
430
|
+
tag_html: substituted,
|
|
431
|
+
tag_url: tagUrl,
|
|
432
|
+
preview_url: tagUrl,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
// ────────────────────────────────────────────────────────────
|
|
436
|
+
// Delivery — synth impressions/clicks scaled by days-active
|
|
437
|
+
// ────────────────────────────────────────────────────────────
|
|
438
|
+
function handleGetDelivery(reqUrl, cr, res) {
|
|
439
|
+
const startStr = reqUrl.searchParams.get('start');
|
|
440
|
+
const endStr = reqUrl.searchParams.get('end');
|
|
441
|
+
const now = Date.now();
|
|
442
|
+
const start = startStr ? Date.parse(startStr) : Date.parse(cr.created_at);
|
|
443
|
+
const end = endStr ? Date.parse(endStr) : now;
|
|
444
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) {
|
|
445
|
+
writeJson(res, 400, { code: 'invalid_request', message: 'start/end must be ISO 8601 with end ≥ start.' });
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const format = formats.find(f => f.format_id === cr.format_id && f.network_code === cr.network_code);
|
|
449
|
+
const channel = format?.channel ?? 'display';
|
|
450
|
+
// Per-format CTR baselines (industry-typical):
|
|
451
|
+
// display ~0.10%, video ~1.5%, ctv ~3%, audio ~0.5%
|
|
452
|
+
const ctrBaseline = channel === 'video' ? 0.015 : channel === 'ctv' ? 0.03 : channel === 'audio' ? 0.005 : 0.001;
|
|
453
|
+
const days = Math.max(1, Math.ceil((end - start) / (24 * 60 * 60 * 1000)));
|
|
454
|
+
const breakdown = [];
|
|
455
|
+
let totalImpressions = 0;
|
|
456
|
+
let totalClicks = 0;
|
|
457
|
+
for (let d = 0; d < days; d++) {
|
|
458
|
+
const dayStart = new Date(start + d * 24 * 60 * 60 * 1000);
|
|
459
|
+
const dateIso = dayStart.toISOString().slice(0, 10);
|
|
460
|
+
const seedHex = sha256(`${cr.creative_id}::${dateIso}`).slice(0, 8);
|
|
461
|
+
const seed = parseInt(seedHex, 16);
|
|
462
|
+
// Deterministic pseudo-random impressions in [10_000, 100_000].
|
|
463
|
+
const impressions = 10_000 + (seed % 90_001);
|
|
464
|
+
const clicks = Math.round(impressions * ctrBaseline);
|
|
465
|
+
breakdown.push({ date: dateIso, impressions, clicks });
|
|
466
|
+
totalImpressions += impressions;
|
|
467
|
+
totalClicks += clicks;
|
|
468
|
+
}
|
|
469
|
+
writeJson(res, 200, {
|
|
470
|
+
creative_id: cr.creative_id,
|
|
471
|
+
reporting_period: {
|
|
472
|
+
start: new Date(start).toISOString(),
|
|
473
|
+
end: new Date(end).toISOString(),
|
|
474
|
+
},
|
|
475
|
+
totals: {
|
|
476
|
+
impressions: totalImpressions,
|
|
477
|
+
clicks: totalClicks,
|
|
478
|
+
ctr: totalImpressions > 0 ? totalClicks / totalImpressions : 0,
|
|
479
|
+
},
|
|
480
|
+
breakdown,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// ────────────────────────────────────────────────────────────
|
|
485
|
+
// Helpers
|
|
486
|
+
// ────────────────────────────────────────────────────────────
|
|
487
|
+
function writeJson(res, status, body) {
|
|
488
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
489
|
+
res.end(JSON.stringify(body));
|
|
490
|
+
}
|
|
491
|
+
async function readJsonObject(req, res) {
|
|
492
|
+
const text = await new Promise((resolve, reject) => {
|
|
493
|
+
const chunks = [];
|
|
494
|
+
req.on('data', c => chunks.push(c));
|
|
495
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
496
|
+
req.on('error', reject);
|
|
497
|
+
});
|
|
498
|
+
if (!text)
|
|
499
|
+
return {};
|
|
500
|
+
try {
|
|
501
|
+
const parsed = JSON.parse(text);
|
|
502
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
503
|
+
writeJson(res, 400, { code: 'invalid_request', message: 'request body must be a JSON object.' });
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
return parsed;
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
writeJson(res, 400, { code: 'invalid_request', message: 'request body is not valid JSON.' });
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
function isObject(v) {
|
|
514
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
515
|
+
}
|
|
516
|
+
function parsePositiveNumber(s) {
|
|
517
|
+
if (s === null)
|
|
518
|
+
return undefined;
|
|
519
|
+
const n = Number(s);
|
|
520
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
521
|
+
}
|
|
522
|
+
function sha256(s) {
|
|
523
|
+
return (0, node_crypto_1.createHash)('sha256').update(s).digest('hex');
|
|
524
|
+
}
|
|
525
|
+
function stripFingerprint(cr) {
|
|
526
|
+
const { body_fingerprint: _bf, ...rest } = cr;
|
|
527
|
+
void _bf;
|
|
528
|
+
return rest;
|
|
529
|
+
}
|
|
530
|
+
/** Substitute `{macro}` placeholders into the template.
|
|
531
|
+
*
|
|
532
|
+
* When `htmlEscapeValues` is true, every macro value is HTML-escaped before
|
|
533
|
+
* substitution. The raw-substitution path (htmlEscapeValues=false) returns
|
|
534
|
+
* values verbatim — used by `POST /v1/creatives/{id}/render` callers who
|
|
535
|
+
* apply their own context binding (signed clickthroughs, viewability shims).
|
|
536
|
+
* The escape path is used by `GET /serve/{id}` because that endpoint emits
|
|
537
|
+
* the substituted HTML directly to the browser with no caller intervention,
|
|
538
|
+
* so caller-controlled `ctx` values (e.g. `click_url`) must NOT be able to
|
|
539
|
+
* break out of attribute context and inject `<script>`.
|
|
540
|
+
*/
|
|
541
|
+
function substituteMacros(template, cr, format, ctx, htmlEscapeValues) {
|
|
542
|
+
const click = typeof ctx['click_url'] === 'string' ? ctx['click_url'] : (cr.click_url ?? 'https://example.com/click');
|
|
543
|
+
const assetUrl = typeof ctx['asset_url'] === 'string'
|
|
544
|
+
? ctx['asset_url']
|
|
545
|
+
: 'https://test-assets.adcontextprotocol.org/placeholder.jpg';
|
|
546
|
+
const impressionPixel = typeof ctx['impression_pixel'] === 'string'
|
|
547
|
+
? ctx['impression_pixel']
|
|
548
|
+
: `https://imp.example/i?cr=${encodeURIComponent(cr.creative_id)}`;
|
|
549
|
+
const cb = typeof ctx['cb'] === 'string' ? ctx['cb'] : String(Date.now());
|
|
550
|
+
const macros = {
|
|
551
|
+
click_url: String(click),
|
|
552
|
+
asset_url: String(assetUrl),
|
|
553
|
+
impression_pixel: String(impressionPixel),
|
|
554
|
+
cb: String(cb),
|
|
555
|
+
advertiser_id: cr.advertiser_id,
|
|
556
|
+
creative_id: cr.creative_id,
|
|
557
|
+
width: String(format.width ?? 0),
|
|
558
|
+
height: String(format.height ?? 0),
|
|
559
|
+
duration_seconds: String(format.duration_seconds ?? 0),
|
|
560
|
+
};
|
|
561
|
+
return template.replace(/\{(\w+)\}/g, (m, key) => {
|
|
562
|
+
const value = macros[key];
|
|
563
|
+
if (value === undefined)
|
|
564
|
+
return m;
|
|
565
|
+
return htmlEscapeValues ? escapeHtml(value) : value;
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
function serializeCtx(ctx) {
|
|
569
|
+
// Compact deterministic serialization for the /serve URL — sorted
|
|
570
|
+
// keys + JSON. Real ad servers use a binary keyed lookup; this is
|
|
571
|
+
// human-debuggable for storyboard output.
|
|
572
|
+
const sorted = {};
|
|
573
|
+
for (const k of Object.keys(ctx).sort())
|
|
574
|
+
sorted[k] = ctx[k];
|
|
575
|
+
return JSON.stringify(sorted);
|
|
576
|
+
}
|
|
577
|
+
function renderServeHtml(cr, formats, baseUrl, ctxRaw) {
|
|
578
|
+
const format = formats.find(f => f.format_id === cr.format_id && f.network_code === cr.network_code);
|
|
579
|
+
let ctx = {};
|
|
580
|
+
try {
|
|
581
|
+
if (ctxRaw)
|
|
582
|
+
ctx = JSON.parse(ctxRaw);
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
// best-effort — treat as empty ctx
|
|
586
|
+
}
|
|
587
|
+
const template = cr.snippet ?? format?.snippet_template ?? '';
|
|
588
|
+
// Escape macro values when rendering for the auth-free /serve endpoint.
|
|
589
|
+
// Caller-controlled `ctx` (click_url, impression_pixel, etc.) flows
|
|
590
|
+
// straight into attribute context; without escaping, a value like
|
|
591
|
+
// `"><script>...</script>` breaks out and executes under this origin.
|
|
592
|
+
const body = format ? substituteMacros(template, cr, format, ctx, true) : template;
|
|
593
|
+
return `<!doctype html>
|
|
594
|
+
<html><head><meta charset="utf-8"><title>${escapeHtml(cr.name)} — preview</title></head>
|
|
595
|
+
<body style="margin:0;padding:0">
|
|
596
|
+
<!-- creative_id=${escapeHtml(cr.creative_id)} format_id=${escapeHtml(cr.format_id)} served from ${escapeHtml(baseUrl)} -->
|
|
597
|
+
${body}
|
|
598
|
+
</body></html>`;
|
|
599
|
+
}
|
|
600
|
+
function escapeHtml(s) {
|
|
601
|
+
return s.replace(/[&<>"']/g, c => {
|
|
602
|
+
switch (c) {
|
|
603
|
+
case '&':
|
|
604
|
+
return '&';
|
|
605
|
+
case '<':
|
|
606
|
+
return '<';
|
|
607
|
+
case '>':
|
|
608
|
+
return '>';
|
|
609
|
+
case '"':
|
|
610
|
+
return '"';
|
|
611
|
+
case "'":
|
|
612
|
+
return ''';
|
|
613
|
+
default:
|
|
614
|
+
return c;
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
//# sourceMappingURL=server.js.map
|