@wavebirdai/api 0.1.0-beta.2 → 0.1.0-beta.4
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/README.md +68 -20
- package/bin/wavebird-next.mjs +2 -0
- package/build.mjs +10 -0
- package/dist/browser.d.ts +5 -1
- package/dist/browser.js +24 -7
- package/dist/postgres-turn-storage.js +81 -0
- package/dist/server.d.ts +40 -2
- package/dist/server.js +115 -16
- package/dist/turn-journal.js +154 -0
- package/examples/next-js/app/api/sponsor-session/route.js +5 -0
- package/examples/next-js/app/page.jsx +15 -4
- package/examples/next-js/lib/auth.js +31 -10
- package/examples/next-js/lib/publisher.js +12 -5
- package/examples/next-js/lib/turn-store.js +9 -0
- package/examples/next-ts/app/api/sponsor-session/route.ts +5 -0
- package/examples/next-ts/app/page.tsx +16 -5
- package/examples/next-ts/lib/auth.ts +31 -10
- package/examples/next-ts/lib/publisher.ts +12 -5
- package/examples/next-ts/lib/turn-store.ts +10 -0
- package/package.json +3 -3
- package/src/browser.d.ts +31 -0
- package/src/browser.js +211 -0
- package/src/generation.js +96 -0
- package/src/postgres-turn-storage.js +81 -0
- package/src/server.d.ts +76 -0
- package/src/server.js +448 -0
- package/src/turn-journal.js +154 -0
- package/templates/NEXT-SETUP.md +7 -6
- package/templates/WavebirdPlacement.jsx +14 -4
- package/templates/WavebirdPlacement.tsx +16 -5
package/src/server.js
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { generationTransport } from "./generation.js";
|
|
2
|
+
export { createPublisherTurnJournal, derivePublisherTurnKeySecret } from "./turn-journal.js";
|
|
3
|
+
export { createPostgresTurnStorage } from "./postgres-turn-storage.js";
|
|
4
|
+
|
|
5
|
+
const PREFERENCE_VERSION = "contextual-choice/v2";
|
|
6
|
+
const PREFERENCE_TTL = 30 * 24 * 60 * 60;
|
|
7
|
+
const MAX_PUBLISHER_BODY_BYTES = 16 * 1024;
|
|
8
|
+
const MAX_WAVEBIRD_RESPONSE_BYTES = 256 * 1024;
|
|
9
|
+
const MAX_PARALLEL_WAVEBIRD_REQUESTS = 2;
|
|
10
|
+
const ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
11
|
+
const JOB_ID = /^job_[A-Za-z0-9_-]{1,128}$/;
|
|
12
|
+
const PLACEMENT_KEY = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
13
|
+
const SERVER_OPTION_KEYS = new Set(["apiBaseUrl", "clientId", "secretKey", "preferenceSecret", "authorize",
|
|
14
|
+
"sessionIdForRequest", "turnJournal", "onDiagnostic", "publicOrigins", "surfaceId", "placementKey",
|
|
15
|
+
"jobType", "locale", "timing", "consentMode", "gdprApplies", "publisher", "allowedFormats",
|
|
16
|
+
"positionHint", "maxWidth", "maxHeight", "allowedSizes", "responsiveSizeSelection", "bidfloor",
|
|
17
|
+
"bidfloorCurrency", "blockedCategories"]);
|
|
18
|
+
|
|
19
|
+
function response(error, status, headers = {}) {
|
|
20
|
+
return Response.json({ error }, { status, headers: { "Cache-Control": "no-store", ...headers } });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeOptions(options) {
|
|
24
|
+
if (typeof window !== "undefined") throw new Error("publisher_server_only");
|
|
25
|
+
if (options && Object.keys(options).some(key => !SERVER_OPTION_KEYS.has(key))) throw new Error("publisher_option_unknown");
|
|
26
|
+
if (!options || typeof options.authorize !== "function") throw new Error("publisher_authorize_required");
|
|
27
|
+
if ((options.turnJournal === undefined) !== (options.sessionIdForRequest === undefined) ||
|
|
28
|
+
(options.turnJournal !== undefined && (typeof options.sessionIdForRequest !== "function" ||
|
|
29
|
+
["claimPlacement", "releaseRejectedPlacement", "bind", "claimGeneration", "phase"].some(name => typeof options.turnJournal?.[name] !== "function"))))
|
|
30
|
+
throw new Error("publisher_turn_journal_invalid");
|
|
31
|
+
if (!String(options.clientId || "").startsWith("wbproj_")) throw new Error("publisher_project_invalid");
|
|
32
|
+
if (!String(options.secretKey || "").trim()) throw new Error("publisher_server_key_required");
|
|
33
|
+
if (!/^sk_(?:test|live)_/.test(options.secretKey)) throw new Error("publisher_server_key_invalid");
|
|
34
|
+
if (!ID.test(options.surfaceId || "") || !PLACEMENT_KEY.test(options.placementKey || "")) throw new Error("publisher_placement_invalid");
|
|
35
|
+
if (!["before", "during", "after"].includes(options.timing)) throw new Error("publisher_timing_invalid");
|
|
36
|
+
if (!["wavebird_consent", "own_cmp"].includes(options.consentMode)) throw new Error("publisher_consent_mode_invalid");
|
|
37
|
+
if (!Array.isArray(options.allowedFormats) || options.allowedFormats.length === 0 || options.allowedFormats.some(value => !["banner", "native", "clip"].includes(value))) throw new Error("publisher_formats_invalid");
|
|
38
|
+
if (!["above", "below", "sidebar", "between"].includes(options.positionHint)) throw new Error("publisher_position_invalid");
|
|
39
|
+
if (!Array.isArray(options.allowedSizes) || options.allowedSizes.length === 0 || options.allowedSizes.some(size => !size || typeof size !== "object" || Object.keys(size).some(key => !["width", "height", "formats"].includes(key)) || !Number.isSafeInteger(size.width) || !Number.isSafeInteger(size.height) || size.width < 1 || size.height < 1 ||
|
|
40
|
+
(size.formats !== undefined && (!Array.isArray(size.formats) || size.formats.length === 0 || size.formats.some(format => !options.allowedFormats.includes(format)))))) throw new Error("publisher_sizes_invalid");
|
|
41
|
+
const maxWidth = options.maxWidth === undefined ? options.allowedSizes.reduce((max, size) => Math.max(max, size.width), 0) : options.maxWidth;
|
|
42
|
+
const maxHeight = options.maxHeight === undefined ? options.allowedSizes.reduce((max, size) => Math.max(max, size.height), 0) : options.maxHeight;
|
|
43
|
+
if (!Number.isSafeInteger(maxWidth) || maxWidth < 1 || !Number.isSafeInteger(maxHeight) || maxHeight < 1) throw new Error("publisher_envelope_invalid");
|
|
44
|
+
const eligibleSizes = options.allowedSizes.filter(size => size.width <= maxWidth && size.height <= maxHeight);
|
|
45
|
+
const hasFloor = Object.hasOwn(options, "bidfloor");
|
|
46
|
+
const hasCurrency = Object.hasOwn(options, "bidfloorCurrency");
|
|
47
|
+
if (hasFloor !== hasCurrency || (hasFloor && (!Number.isFinite(options.bidfloor) || options.bidfloor < 0 || typeof options.bidfloorCurrency !== "string" || !/^[A-Za-z]{3}$/.test(options.bidfloorCurrency))))
|
|
48
|
+
throw new Error("publisher_bidfloor_override_invalid");
|
|
49
|
+
if (options.responsiveSizeSelection !== undefined && typeof options.responsiveSizeSelection !== "boolean") throw new Error("publisher_responsive_selection_invalid");
|
|
50
|
+
if (!options.publisher || typeof options.publisher !== "object" || Object.keys(options.publisher).some(key => !["app_name", "app_domain", "categories"].includes(key)) || typeof options.publisher.app_name !== "string" || typeof options.publisher.app_domain !== "string" || !Array.isArray(options.publisher.categories)) throw new Error("publisher_identity_invalid");
|
|
51
|
+
if (typeof options.jobType !== "string" || !options.jobType || typeof options.locale !== "string" || !options.locale) throw new Error("publisher_job_invalid");
|
|
52
|
+
const base = String(options.apiBaseUrl || "https://api.wavebird.ai").trim().replace(/\/+$/, "").replace(/\/v1$/, "") + "/v1";
|
|
53
|
+
if (!/^https:\/\//.test(base)) throw new Error("publisher_api_https_required");
|
|
54
|
+
{
|
|
55
|
+
if (!Array.isArray(options.publicOrigins) || options.publicOrigins.length < 1 || options.publicOrigins.length > 8) throw new Error("publisher_public_origins_invalid");
|
|
56
|
+
for (const origin of options.publicOrigins) {
|
|
57
|
+
let url;
|
|
58
|
+
try { url = new URL(origin); } catch { throw new Error("publisher_public_origins_invalid"); }
|
|
59
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
60
|
+
if (url.origin !== origin || (url.protocol !== "https:" && !(loopback && url.protocol === "http:" && options.secretKey.startsWith("sk_test_")))) throw new Error("publisher_public_origins_invalid");
|
|
61
|
+
}
|
|
62
|
+
if (new Set(options.publicOrigins).size !== options.publicOrigins.length) throw new Error("publisher_public_origins_invalid");
|
|
63
|
+
}
|
|
64
|
+
return { ...options, allowedSizes: eligibleSizes, maxWidth, maxHeight, base,
|
|
65
|
+
consentSource: options.consentMode === "wavebird_consent" ? "wavebird_consent" : "wrapper_cmp" };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function publicOrigin(request, options) {
|
|
69
|
+
const host = request.headers.get("host")?.toLowerCase();
|
|
70
|
+
if (!host) return null;
|
|
71
|
+
const matches = options.publicOrigins.filter(origin => new URL(origin).host.toLowerCase() === host);
|
|
72
|
+
const browserOrigin = request.headers.get("origin");
|
|
73
|
+
if (browserOrigin) return matches.includes(browserOrigin) ? browserOrigin : null;
|
|
74
|
+
if (matches.length === 1) return matches[0];
|
|
75
|
+
const routeOrigin = new URL(request.url).origin;
|
|
76
|
+
return matches.includes(routeOrigin) ? routeOrigin : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function validOrigin(request, options, strict = true) {
|
|
80
|
+
const origin = request.headers.get("origin");
|
|
81
|
+
const site = request.headers.get("sec-fetch-site");
|
|
82
|
+
const expected = publicOrigin(request, options);
|
|
83
|
+
return expected !== null && (strict ? origin === expected : !origin || origin === expected) &&
|
|
84
|
+
(strict ? site !== "cross-site" : !site || site === "same-origin");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function gpc(request) {
|
|
88
|
+
return (request.headers.get("sec-gpc") || "").split(",").some(value => value.trim() === "1");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function proxyResult(upstream, body, extra = {}) {
|
|
92
|
+
return new Response(body || null, { status: upstream.status, headers: {
|
|
93
|
+
"Content-Type": upstream.headers.get("content-type") || "application/json",
|
|
94
|
+
"Cache-Control": "no-store",
|
|
95
|
+
...(upstream.headers.get("x-request-id") ? { "x-request-id": upstream.headers.get("x-request-id") } : {}),
|
|
96
|
+
...extra
|
|
97
|
+
} });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function readBoundedText(value, limit, signal) {
|
|
101
|
+
if (!value.body) return "";
|
|
102
|
+
const reader = value.body.getReader();
|
|
103
|
+
const decoder = new TextDecoder();
|
|
104
|
+
let bytes = 0;
|
|
105
|
+
let output = "";
|
|
106
|
+
const cancel = () => { void reader.cancel().catch(() => {}); };
|
|
107
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
108
|
+
try {
|
|
109
|
+
while (true) {
|
|
110
|
+
if (signal.aborted) throw new Error("body_timeout");
|
|
111
|
+
const next = await reader.read();
|
|
112
|
+
if (signal.aborted) throw new Error("body_timeout");
|
|
113
|
+
if (next.done) break;
|
|
114
|
+
bytes += next.value.byteLength;
|
|
115
|
+
if (bytes > limit) { cancel(); throw new Error("body_limit_exceeded"); }
|
|
116
|
+
output += decoder.decode(next.value, { stream: true });
|
|
117
|
+
}
|
|
118
|
+
return output + decoder.decode();
|
|
119
|
+
} finally {
|
|
120
|
+
signal.removeEventListener("abort", cancel);
|
|
121
|
+
reader.releaseLock();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function readPublisherJson(request) {
|
|
126
|
+
const controller = new AbortController();
|
|
127
|
+
const timer = setTimeout(() => controller.abort(), 1500);
|
|
128
|
+
try { return JSON.parse(await readBoundedText(request, MAX_PUBLISHER_BODY_BYTES, controller.signal)); }
|
|
129
|
+
catch (error) {
|
|
130
|
+
const reason = error instanceof Error ? error.message : "invalid_json";
|
|
131
|
+
return response(reason, reason === "body_limit_exceeded" ? 413 : controller.signal.aborted ? 408 : 400);
|
|
132
|
+
} finally { clearTimeout(timer); }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function boundedFetcher() {
|
|
136
|
+
let active = 0;
|
|
137
|
+
return async (url, init) => {
|
|
138
|
+
if (active >= MAX_PARALLEL_WAVEBIRD_REQUESTS) return response("wavebird_capacity_exceeded", 429);
|
|
139
|
+
active++;
|
|
140
|
+
const controller = new AbortController();
|
|
141
|
+
const abort = () => controller.abort();
|
|
142
|
+
init.signal?.addEventListener("abort", abort, { once: true });
|
|
143
|
+
if (init.signal?.aborted) abort();
|
|
144
|
+
let timer;
|
|
145
|
+
const deadline = new Promise((_, reject) => {
|
|
146
|
+
timer = setTimeout(() => { abort(); reject(new Error("upstream_timeout")); }, 5000);
|
|
147
|
+
});
|
|
148
|
+
try {
|
|
149
|
+
return await Promise.race([deadline, (async () => {
|
|
150
|
+
const upstream = await fetch(url, { ...init, signal: controller.signal, redirect: "error", cache: "no-store" });
|
|
151
|
+
const body = await readBoundedText(upstream, MAX_WAVEBIRD_RESPONSE_BYTES, controller.signal);
|
|
152
|
+
return proxyResult(upstream, body);
|
|
153
|
+
})()]);
|
|
154
|
+
} finally {
|
|
155
|
+
controller.abort();
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
init.signal?.removeEventListener("abort", abort);
|
|
158
|
+
active--;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function boundedGenerationReporter() {
|
|
164
|
+
let active = 0;
|
|
165
|
+
return async (url, init) => {
|
|
166
|
+
if (active >= MAX_PARALLEL_WAVEBIRD_REQUESTS) throw new Error("wavebird_capacity_exceeded");
|
|
167
|
+
active++;
|
|
168
|
+
const controller = new AbortController();
|
|
169
|
+
let timer;
|
|
170
|
+
const deadline = new Promise((_, reject) => {
|
|
171
|
+
timer = setTimeout(() => { controller.abort(); reject(new Error("generation_report_timeout")); }, 5000);
|
|
172
|
+
});
|
|
173
|
+
try {
|
|
174
|
+
await Promise.race([deadline, (async () => {
|
|
175
|
+
const upstream = await fetch(url, { ...init, signal: controller.signal, redirect: "error", cache: "no-store" });
|
|
176
|
+
await upstream.body?.cancel();
|
|
177
|
+
if (!upstream.ok) throw new Error(`generation_report_http_${upstream.status}`);
|
|
178
|
+
})()]);
|
|
179
|
+
} finally {
|
|
180
|
+
controller.abort();
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
active--;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function observeGeneration(response, request, onLifecycle, onCritical) {
|
|
188
|
+
if (typeof onLifecycle !== "function" && typeof onCritical !== "function") return response;
|
|
189
|
+
if (response.headers.get("x-wavebird-generation-transport") !== "1" || !response.body) return response;
|
|
190
|
+
const reader = response.body.getReader();
|
|
191
|
+
const decoder = new TextDecoder();
|
|
192
|
+
let terminal = false;
|
|
193
|
+
const notify = async event => {
|
|
194
|
+
if (typeof onCritical === "function") {
|
|
195
|
+
try { await onCritical(event); } catch {}
|
|
196
|
+
}
|
|
197
|
+
if (typeof onLifecycle !== "function") return;
|
|
198
|
+
try { Promise.resolve(onLifecycle(event)).catch(() => {}); } catch {}
|
|
199
|
+
};
|
|
200
|
+
const body = new ReadableStream({
|
|
201
|
+
async pull(controller) {
|
|
202
|
+
try {
|
|
203
|
+
const next = await reader.read();
|
|
204
|
+
if (next.done) { controller.close(); return; }
|
|
205
|
+
// The canonical transport emits one complete JSONL record per chunk.
|
|
206
|
+
try {
|
|
207
|
+
const record = JSON.parse(decoder.decode(next.value));
|
|
208
|
+
if (["started", "done", "error", "abort"].includes(record.type)) {
|
|
209
|
+
if (record.type !== "started") terminal = true;
|
|
210
|
+
await notify({ type: record.type, sessionId: record.session_id, generationId: record.generation_id });
|
|
211
|
+
}
|
|
212
|
+
} catch {}
|
|
213
|
+
controller.enqueue(next.value);
|
|
214
|
+
} catch (error) { controller.error(error); }
|
|
215
|
+
},
|
|
216
|
+
async cancel(reason) {
|
|
217
|
+
if (!terminal) {
|
|
218
|
+
terminal = true;
|
|
219
|
+
await notify({ type: "abort", sessionId: request.headers.get("x-wavebird-session-id"), generationId: request.headers.get("x-wavebird-generation-id") });
|
|
220
|
+
}
|
|
221
|
+
try { await reader.cancel(reason); } catch {}
|
|
222
|
+
}
|
|
223
|
+
}, { highWaterMark: 1 });
|
|
224
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function createPublisherServer(inputOptions) {
|
|
228
|
+
const options = normalizeOptions(inputOptions);
|
|
229
|
+
const placementFetch = boundedFetcher();
|
|
230
|
+
const consentFetch = boundedFetcher();
|
|
231
|
+
const generationReportFetch = boundedGenerationReporter();
|
|
232
|
+
const diagnostic = code => { try { options.onDiagnostic?.(code); } catch {} };
|
|
233
|
+
if (typeof options.turnJournal?.reconcilePending === "function") {
|
|
234
|
+
void Promise.resolve().then(() => options.turnJournal.reconcilePending(options.clientId, options.surfaceId))
|
|
235
|
+
.catch(() => diagnostic("turn_journal_unavailable"));
|
|
236
|
+
}
|
|
237
|
+
async function journalCall(operation) {
|
|
238
|
+
let timer;
|
|
239
|
+
try { return await Promise.race([
|
|
240
|
+
Promise.resolve().then(operation),
|
|
241
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("turn_journal_timeout")), 1500); })
|
|
242
|
+
]); } finally { clearTimeout(timer); }
|
|
243
|
+
}
|
|
244
|
+
async function trustedTurn(request, sessionId, turnId) {
|
|
245
|
+
if (!ID.test(sessionId || "") || !ID.test(turnId || "")) return null;
|
|
246
|
+
let trusted;
|
|
247
|
+
try { trusted = await journalCall(() => options.sessionIdForRequest(request)); } catch { return null; }
|
|
248
|
+
return trusted === sessionId ? { projectId: options.clientId, surfaceId: options.surfaceId, sessionId, turnId } : null;
|
|
249
|
+
}
|
|
250
|
+
async function sessionGET(request) {
|
|
251
|
+
if (!options.turnJournal) return response("turn_binding_unavailable", 404);
|
|
252
|
+
if (!await authorized(request)) return response("authentication_required", 401);
|
|
253
|
+
if (!validOrigin(request, options, false)) return response("invalid_origin", 403);
|
|
254
|
+
let sessionId;
|
|
255
|
+
try { sessionId = await journalCall(() => options.sessionIdForRequest(request)); }
|
|
256
|
+
catch { diagnostic("publisher_session_unavailable"); return response("publisher_session_unavailable", 503); }
|
|
257
|
+
if (!ID.test(sessionId || "")) return response("publisher_session_unavailable", 401);
|
|
258
|
+
return Response.json({ session_id: sessionId }, { headers: { "Cache-Control": "no-store", Vary: "Cookie" } });
|
|
259
|
+
}
|
|
260
|
+
const cookieName = `__Host-wavebird-preference-${options.clientId}-${options.surfaceId}`;
|
|
261
|
+
const cookie = (value, ttl = PREFERENCE_TTL) => `${cookieName}=${value}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${ttl}`;
|
|
262
|
+
const scope = request => ({ project: options.clientId, surface: options.surfaceId, version: PREFERENCE_VERSION,
|
|
263
|
+
profile: options.consentMode === "wavebird_consent" ? "wavebird_dialog" : "publisher_custom", origin: publicOrigin(request, options) });
|
|
264
|
+
async function preferenceKey() {
|
|
265
|
+
if (!options.preferenceSecret || options.preferenceSecret.length < 32) throw new Error("preference_secret_required");
|
|
266
|
+
return crypto.subtle.importKey("raw", new TextEncoder().encode(options.preferenceSecret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
|
|
267
|
+
}
|
|
268
|
+
async function signPreference(request, state) {
|
|
269
|
+
const now = Date.now();
|
|
270
|
+
const payload = Buffer.from(JSON.stringify({ ...scope(request), state, issued_at: now, expires_at: now + PREFERENCE_TTL * 1000 })).toString("base64url");
|
|
271
|
+
const signature = await crypto.subtle.sign("HMAC", await preferenceKey(), new TextEncoder().encode(payload));
|
|
272
|
+
return cookie(payload + "." + Buffer.from(signature).toString("base64url"));
|
|
273
|
+
}
|
|
274
|
+
async function authorized(request) {
|
|
275
|
+
try { return await options.authorize(request) === true; } catch { return false; }
|
|
276
|
+
}
|
|
277
|
+
async function placementPOST(request) {
|
|
278
|
+
if (!await authorized(request)) return response("authentication_required", 401);
|
|
279
|
+
if (gpc(request)) return response("privacy_signal_policy_required", 403);
|
|
280
|
+
if (!validOrigin(request, options)) return response("invalid_origin", 403);
|
|
281
|
+
const qaOutcomes = new URL(request.url).searchParams.getAll("qa_test_outcome");
|
|
282
|
+
if (qaOutcomes.length > 1 || (qaOutcomes.length === 1 && qaOutcomes[0] !== "no_fill")) return response("invalid_qa_test_outcome", 400);
|
|
283
|
+
if (qaOutcomes.length && !options.secretKey.startsWith("sk_test_")) return response("qa_test_outcome_requires_test_key", 403);
|
|
284
|
+
const input = await readPublisherJson(request);
|
|
285
|
+
if (input instanceof Response) return input;
|
|
286
|
+
const turn = options.turnJournal ? await trustedTurn(request, input?.session_id, input?.turn_id) : null;
|
|
287
|
+
if (options.turnJournal && !turn) return response("publisher_turn_context_invalid", 401);
|
|
288
|
+
let allowedSizes = options.allowedSizes.map(({ width, height }) => ({ width, height }));
|
|
289
|
+
let allowedFormats = options.allowedFormats;
|
|
290
|
+
let maxWidth = options.maxWidth;
|
|
291
|
+
let maxHeight = options.maxHeight;
|
|
292
|
+
const noFit = () => Response.json({ decision: { fill: false, reason: "no_fit" }, placement: null }, { headers: { "Cache-Control": "no-store" } });
|
|
293
|
+
if (options.responsiveSizeSelection !== false) {
|
|
294
|
+
const bounds = input?.container_size;
|
|
295
|
+
if (!bounds || !Number.isSafeInteger(bounds.width) || !Number.isSafeInteger(bounds.height) ||
|
|
296
|
+
bounds.width < 1 || bounds.height < 1 || bounds.width > 8192 || bounds.height > 8192) {
|
|
297
|
+
return response("publisher_container_size_invalid", 400);
|
|
298
|
+
}
|
|
299
|
+
const requestedFormat = input?.format ?? "auto";
|
|
300
|
+
if (requestedFormat !== "auto" && !options.allowedFormats.includes(requestedFormat)) return response("publisher_format_invalid", 400);
|
|
301
|
+
if (allowedSizes.length === 0) return noFit();
|
|
302
|
+
const fitting = options.allowedSizes.filter(size => size.width <= bounds.width && size.height <= bounds.height &&
|
|
303
|
+
(requestedFormat === "auto" || (size.formats ?? options.allowedFormats).includes(requestedFormat)))
|
|
304
|
+
.sort((a, b) => b.width - a.width || b.height - a.height);
|
|
305
|
+
if (fitting.length === 0) return noFit();
|
|
306
|
+
const selected = fitting[0];
|
|
307
|
+
allowedSizes = [{ width: selected.width, height: selected.height }];
|
|
308
|
+
allowedFormats = requestedFormat === "auto"
|
|
309
|
+
? options.allowedFormats.filter(format => fitting.some(size => size.width === selected.width && size.height === selected.height && (size.formats ?? options.allowedFormats).includes(format)))
|
|
310
|
+
: [requestedFormat];
|
|
311
|
+
maxWidth = Math.min(maxWidth, bounds.width);
|
|
312
|
+
maxHeight = Math.min(maxHeight, bounds.height);
|
|
313
|
+
}
|
|
314
|
+
else if (allowedSizes.length === 0) return noFit();
|
|
315
|
+
if (turn) {
|
|
316
|
+
let claimed;
|
|
317
|
+
try { claimed = await journalCall(() => options.turnJournal.claimPlacement(turn)); }
|
|
318
|
+
catch { diagnostic("turn_journal_unavailable"); return response("turn_journal_unavailable", 503); }
|
|
319
|
+
if (!claimed) return response("publisher_turn_replayed", 409);
|
|
320
|
+
}
|
|
321
|
+
const sessionId = typeof input?.session_id === "string" && input.session_id.trim() ? input.session_id.trim() : crypto.randomUUID();
|
|
322
|
+
const body = {
|
|
323
|
+
client_id: options.clientId, session_id: sessionId, job_type: options.jobType, locale: options.locale,
|
|
324
|
+
slots_requested: 1, placement_key: options.placementKey,
|
|
325
|
+
slot_hint: { position: options.positionHint, max_width: maxWidth, max_height: maxHeight,
|
|
326
|
+
allowed_sizes: allowedSizes, presentation_version: "sponsored_moment_v2" },
|
|
327
|
+
overrides: { allowed_formats: allowedFormats, timing: options.timing,
|
|
328
|
+
...(Object.hasOwn(options, "bidfloor") ? { bidfloor: options.bidfloor, bidfloor_currency: options.bidfloorCurrency.toUpperCase() } : {}),
|
|
329
|
+
gdpr_applies: options.gdprApplies === true,
|
|
330
|
+
publisher: { app_name: options.publisher.app_name, app_domain: options.publisher.app_domain,
|
|
331
|
+
...(options.publisher.categories.length ? { categories: options.publisher.categories } : {}) },
|
|
332
|
+
...(options.blockedCategories?.length ? { blocked_categories: options.blockedCategories } : {}) },
|
|
333
|
+
consent: { semantic_targeting: false, prompt_shared: false, gdpr_applies: options.gdprApplies === true,
|
|
334
|
+
consent_source: options.consentSource }
|
|
335
|
+
};
|
|
336
|
+
try {
|
|
337
|
+
const result = await placementFetch(`${options.base}/placements?wait_ms=1500${qaOutcomes.length ? "&test_outcome=no_fill" : ""}`, { method: "POST", headers: {
|
|
338
|
+
Authorization: `Bearer ${options.secretKey}`, "Content-Type": "application/json"
|
|
339
|
+
}, body: JSON.stringify(body), cache: "no-store" });
|
|
340
|
+
if (turn && [400, 401, 403].includes(result.status)) {
|
|
341
|
+
try { await journalCall(() => options.turnJournal.releaseRejectedPlacement(turn)); }
|
|
342
|
+
catch { diagnostic("turn_journal_unavailable"); }
|
|
343
|
+
}
|
|
344
|
+
if (turn && result.status === 200) {
|
|
345
|
+
let payload;
|
|
346
|
+
try { payload = await result.clone().json(); } catch {}
|
|
347
|
+
const jobId = payload?.job_id;
|
|
348
|
+
if (JOB_ID.test(jobId || "")) {
|
|
349
|
+
try { await journalCall(() => options.turnJournal.bind(turn, jobId)); }
|
|
350
|
+
catch { diagnostic("turn_binding_unavailable"); return response("turn_binding_unavailable", 502); }
|
|
351
|
+
} else if (!(payload?.decision?.fill === false &&
|
|
352
|
+
Object.hasOwn(payload ?? {}, "placement") && payload.placement === null)) {
|
|
353
|
+
diagnostic("turn_binding_unavailable");
|
|
354
|
+
return response("turn_binding_unavailable", 502);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return result;
|
|
358
|
+
} catch { return response("wavebird_placement_unavailable", 502); }
|
|
359
|
+
}
|
|
360
|
+
async function consentGET(request) {
|
|
361
|
+
if (!await authorized(request)) return response("authentication_required", 401);
|
|
362
|
+
const headers = { "Cache-Control": "no-store", Vary: "Cookie, Sec-GPC" };
|
|
363
|
+
if (!validOrigin(request, options, false)) return response("invalid_origin", 403, headers);
|
|
364
|
+
if (gpc(request)) return Response.json({ preference: null }, { headers });
|
|
365
|
+
try {
|
|
366
|
+
const value = (request.headers.get("cookie") || "").split(";").map(value => value.trim())
|
|
367
|
+
.find(value => value.startsWith(cookieName + "="))?.slice(cookieName.length + 1) || "";
|
|
368
|
+
if (value.length > 2048) throw new Error("preference_invalid");
|
|
369
|
+
const [payload, signature, extra] = value.split(".");
|
|
370
|
+
if (!payload || !signature || extra || !/^[A-Za-z0-9_-]+$/.test(payload) || !/^[A-Za-z0-9_-]+$/.test(signature)) return Response.json({ preference: null }, { headers });
|
|
371
|
+
if (!await crypto.subtle.verify("HMAC", await preferenceKey(), Buffer.from(signature, "base64url"), new TextEncoder().encode(payload))) return Response.json({ preference: null }, { headers });
|
|
372
|
+
const record = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
373
|
+
const now = Date.now();
|
|
374
|
+
if (Object.entries(scope(request)).some(([key, value]) => record[key] !== value) ||
|
|
375
|
+
!["granted", "denied", "withdrawn"].includes(record.state) || !Number.isSafeInteger(record.issued_at) ||
|
|
376
|
+
!Number.isSafeInteger(record.expires_at) || record.issued_at > now || record.expires_at <= now ||
|
|
377
|
+
record.expires_at - record.issued_at !== PREFERENCE_TTL * 1000) return Response.json({ preference: null }, { headers });
|
|
378
|
+
return Response.json({ preference: { state: record.state, expires_at: record.expires_at } }, { headers });
|
|
379
|
+
} catch { return Response.json({ preference: null, error: "preference_unavailable" }, { headers }); }
|
|
380
|
+
}
|
|
381
|
+
async function consentPOST(request) {
|
|
382
|
+
if (!await authorized(request)) return response("authentication_required", 401);
|
|
383
|
+
if (!validOrigin(request, options)) return response("invalid_origin", 403);
|
|
384
|
+
if (gpc(request)) return response("privacy_signal_policy_required", 403);
|
|
385
|
+
const input = await readPublisherJson(request);
|
|
386
|
+
if (input instanceof Response) return input;
|
|
387
|
+
if (!input || typeof input.session_id !== "string" || !input.session_id.trim() ||
|
|
388
|
+
!["granted", "denied", "withdrawn", "cmp_error"].includes(input.lifecycle_state) ||
|
|
389
|
+
!Number.isSafeInteger(input.revision) || input.revision < 1) return response("consent_input_invalid", 400);
|
|
390
|
+
if (["tcf_string", "gpp_string", "gpp_sections"].some(field => Object.hasOwn(input, field))) return response("external_consent_protocol_requires_policy", 400);
|
|
391
|
+
const clear = input.remember_for_30_days === true ? { "Set-Cookie": cookie("", 0) } : {};
|
|
392
|
+
if (input.remember_for_30_days === true) {
|
|
393
|
+
try { await preferenceKey(); } catch { return response("preference_unavailable", 503, clear); }
|
|
394
|
+
}
|
|
395
|
+
let upstream;
|
|
396
|
+
let text;
|
|
397
|
+
try {
|
|
398
|
+
upstream = await consentFetch(options.base + "/consent", { method: "POST", headers: {
|
|
399
|
+
Authorization: `Bearer ${options.secretKey}`, "Content-Type": "application/json"
|
|
400
|
+
}, cache: "no-store", body: JSON.stringify({
|
|
401
|
+
client_id: options.clientId, session_id: input.session_id.trim(),
|
|
402
|
+
decision: options.consentMode === "wavebird_consent" ? "custom" : "basic",
|
|
403
|
+
source: options.consentMode === "wavebird_consent" ? "wavebird_dialog" : "publisher_custom",
|
|
404
|
+
lifecycle_state: input.lifecycle_state, revision: input.revision,
|
|
405
|
+
purposes: { semantic_targeting: false, session_persistence: false, cross_session_persistence: false, prompt_shared: false }
|
|
406
|
+
}) });
|
|
407
|
+
text = await upstream.text();
|
|
408
|
+
} catch { return response("consent_sync_unavailable", 502, clear); }
|
|
409
|
+
let setCookie = clear;
|
|
410
|
+
if (upstream.ok && input.remember_for_30_days === true && input.lifecycle_state !== "cmp_error") {
|
|
411
|
+
let snapshot;
|
|
412
|
+
try { snapshot = JSON.parse(text).authoritative_consent; } catch {}
|
|
413
|
+
if (snapshot?.lifecycle_state !== input.lifecycle_state || snapshot.revision !== input.revision) return response("consent_snapshot_invalid", 502, clear);
|
|
414
|
+
try { setCookie = { "Set-Cookie": await signPreference(request, input.lifecycle_state) }; }
|
|
415
|
+
catch { return response("preference_unavailable", 503, clear); }
|
|
416
|
+
}
|
|
417
|
+
return proxyResult(upstream, text, setCookie);
|
|
418
|
+
}
|
|
419
|
+
async function withGeneration({ request, run, onLifecycle }) {
|
|
420
|
+
if (!await authorized(request)) return response("authentication_required", 401);
|
|
421
|
+
if (!validOrigin(request, options)) return response("invalid_origin", 403);
|
|
422
|
+
const turn = options.turnJournal ? await trustedTurn(request,
|
|
423
|
+
request.headers.get("x-wavebird-session-id"), request.headers.get("x-wavebird-generation-id")) : null;
|
|
424
|
+
if (options.turnJournal && !turn) return response("publisher_turn_context_invalid", 401);
|
|
425
|
+
if (turn) {
|
|
426
|
+
let claimed;
|
|
427
|
+
try { claimed = await journalCall(() => options.turnJournal.claimGeneration(turn)); }
|
|
428
|
+
catch { diagnostic("turn_journal_unavailable"); return run(request.signal); }
|
|
429
|
+
if (!claimed) return response("publisher_turn_replayed", 409);
|
|
430
|
+
try { await journalCall(() => options.turnJournal.phase(turn, "started")); }
|
|
431
|
+
catch { diagnostic("turn_journal_unavailable"); return run(request.signal); }
|
|
432
|
+
}
|
|
433
|
+
const result = await generationTransport({ request, run });
|
|
434
|
+
const critical = turn ? async event => {
|
|
435
|
+
if (event.type === "started") return;
|
|
436
|
+
try { await journalCall(() => options.turnJournal.phase(turn, event.type === "done" ? "finished" : "failed")); }
|
|
437
|
+
catch { diagnostic("turn_journal_unavailable"); }
|
|
438
|
+
} : null;
|
|
439
|
+
return observeGeneration(result, request, onLifecycle, critical);
|
|
440
|
+
}
|
|
441
|
+
async function reportGeneration(jobId, event) {
|
|
442
|
+
if (!JOB_ID.test(jobId) || !["started", "finished", "failed"].includes(event)) throw new Error("invalid_generation_report");
|
|
443
|
+
await generationReportFetch(`${options.base}/jobs/${encodeURIComponent(jobId)}/generation/${event}`, {
|
|
444
|
+
method: "POST", headers: { Authorization: `Bearer ${options.secretKey}`, "Content-Type": "application/json" }, body: "{}"
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
return { sessionGET, placementPOST, consentGET, consentPOST, withGeneration, reportGeneration };
|
|
448
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { createHash, createHmac, randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
4
|
+
const JOB_ID = /^job_[A-Za-z0-9_-]{1,128}$/;
|
|
5
|
+
|
|
6
|
+
/** Domain-separate the stable server preference secret for pseudonymous turn keys. */
|
|
7
|
+
export function derivePublisherTurnKeySecret(preferenceSecret) {
|
|
8
|
+
if (typeof preferenceSecret !== "string" || Buffer.byteLength(preferenceSecret, "utf8") < 32)
|
|
9
|
+
throw new Error("publisher_preference_secret_invalid");
|
|
10
|
+
return createHmac("sha256", preferenceSecret).update("wavebird/turn-journal-key/v1").digest("hex");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Package-owned state machine over a publisher's existing atomic, durable store. */
|
|
14
|
+
export function createPublisherTurnJournal({ storage, reportGeneration, keySecret, onDiagnostic }) {
|
|
15
|
+
if (typeof storage?.atomic !== "function" || typeof storage?.scan !== "function" ||
|
|
16
|
+
typeof reportGeneration !== "function") throw new Error("publisher_turn_storage_invalid");
|
|
17
|
+
if (typeof keySecret !== "string" || Buffer.byteLength(keySecret, "utf8") < 32)
|
|
18
|
+
throw new Error("publisher_turn_key_secret_invalid");
|
|
19
|
+
const tasks = new Set();
|
|
20
|
+
let activeReports = 0;
|
|
21
|
+
const reportWaiters = [];
|
|
22
|
+
const diagnostic = code => { try { onDiagnostic?.(code); } catch {} };
|
|
23
|
+
const verifiedScopes = new Set();
|
|
24
|
+
const versionKey = createHash("sha256").update("wavebird/turn-journal-version-row/v1").digest("hex");
|
|
25
|
+
const keyFingerprint = createHmac("sha256", keySecret).update("wavebird/turn-journal-key-version/v1").digest("hex");
|
|
26
|
+
async function verifyKeyVersion(scope) {
|
|
27
|
+
if (verifiedScopes.has(scope)) return;
|
|
28
|
+
// A separate scope keeps this guard outside ordinary turn retention pruning.
|
|
29
|
+
const versionScope = createHash("sha256").update(JSON.stringify(["wavebird/turn-journal-meta/v1", scope])).digest("hex");
|
|
30
|
+
await storage.atomic(versionScope, versionKey, previous => {
|
|
31
|
+
if (previous && previous.keyFingerprint !== keyFingerprint)
|
|
32
|
+
throw new Error("publisher_turn_key_version_conflict");
|
|
33
|
+
return { state: previous || { keyFingerprint }, result: null };
|
|
34
|
+
});
|
|
35
|
+
verifiedScopes.add(scope);
|
|
36
|
+
}
|
|
37
|
+
function identity(turn) {
|
|
38
|
+
if (![turn?.projectId, turn?.surfaceId, turn?.sessionId, turn?.turnId].every(value => ID.test(value || "")))
|
|
39
|
+
throw new Error("publisher_turn_context_invalid");
|
|
40
|
+
const scope = createHash("sha256").update(JSON.stringify([turn.projectId, turn.surfaceId])).digest("hex");
|
|
41
|
+
const key = createHmac("sha256", keySecret).update(JSON.stringify([turn.projectId, turn.surfaceId, turn.sessionId, turn.turnId])).digest("hex");
|
|
42
|
+
return { scope, key };
|
|
43
|
+
}
|
|
44
|
+
function initial(scope) {
|
|
45
|
+
return { scope, createdAtMs: Date.now(), placementClaimed: false, generationClaimed: false,
|
|
46
|
+
jobId: null, phase: "idle", startedReported: false, terminalReported: false,
|
|
47
|
+
leaseId: null, leaseUntilMs: 0 };
|
|
48
|
+
}
|
|
49
|
+
async function change(turn, update) {
|
|
50
|
+
const { scope, key } = identity(turn);
|
|
51
|
+
await verifyKeyVersion(scope);
|
|
52
|
+
return storage.atomic(scope, key, previous => {
|
|
53
|
+
if (previous && previous.scope !== scope) throw new Error("publisher_turn_scope_conflict");
|
|
54
|
+
const state = { ...(previous || initial(scope)) };
|
|
55
|
+
const result = update(state);
|
|
56
|
+
return { state, result };
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async function claimPlacement(turn) {
|
|
60
|
+
return change(turn, state => {
|
|
61
|
+
if (state.placementClaimed) return false;
|
|
62
|
+
state.placementClaimed = true;
|
|
63
|
+
return true;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
async function releaseRejectedPlacement(turn) {
|
|
67
|
+
await change(turn, state => { if (!state.jobId) state.placementClaimed = false; });
|
|
68
|
+
}
|
|
69
|
+
async function claimGeneration(turn) {
|
|
70
|
+
return change(turn, state => {
|
|
71
|
+
if (state.generationClaimed) return false;
|
|
72
|
+
state.generationClaimed = true;
|
|
73
|
+
return true;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function schedule(scope, key) {
|
|
77
|
+
const task = drain(scope, key).catch(() => diagnostic("turn_journal_unavailable"));
|
|
78
|
+
tasks.add(task);
|
|
79
|
+
void task.finally(() => tasks.delete(task));
|
|
80
|
+
}
|
|
81
|
+
async function bind(turn, jobId) {
|
|
82
|
+
if (!JOB_ID.test(jobId || "")) throw new Error("publisher_job_id_invalid");
|
|
83
|
+
const { scope, key } = identity(turn);
|
|
84
|
+
const bound = await change(turn, state => {
|
|
85
|
+
if (!state.placementClaimed || state.jobId && state.jobId !== jobId) return false;
|
|
86
|
+
state.jobId = jobId;
|
|
87
|
+
return true;
|
|
88
|
+
});
|
|
89
|
+
if (!bound) throw new Error("publisher_turn_job_conflict");
|
|
90
|
+
schedule(scope, key);
|
|
91
|
+
}
|
|
92
|
+
async function phase(turn, event) {
|
|
93
|
+
if (!["started", "finished", "failed"].includes(event)) throw new Error("publisher_phase_invalid");
|
|
94
|
+
const { scope, key } = identity(turn);
|
|
95
|
+
const changed = await change(turn, state => {
|
|
96
|
+
if (!state.generationClaimed || state.phase !== (event === "started" ? "idle" : "started")) return false;
|
|
97
|
+
state.phase = event;
|
|
98
|
+
return true;
|
|
99
|
+
});
|
|
100
|
+
if (changed) schedule(scope, key);
|
|
101
|
+
}
|
|
102
|
+
async function lease(scope, key) {
|
|
103
|
+
return storage.atomic(scope, key, previous => {
|
|
104
|
+
if (!previous || previous.scope !== scope || !previous.jobId || previous.phase === "idle" ||
|
|
105
|
+
previous.leaseUntilMs > Date.now()) return { state: previous, result: null };
|
|
106
|
+
const event = !previous.startedReported ? "started" :
|
|
107
|
+
["finished", "failed"].includes(previous.phase) && !previous.terminalReported ? previous.phase : null;
|
|
108
|
+
if (!event) return { state: previous, result: null };
|
|
109
|
+
const leaseId = randomUUID();
|
|
110
|
+
return { state: { ...previous, leaseId, leaseUntilMs: Date.now() + 10_000 },
|
|
111
|
+
result: { jobId: previous.jobId, event, leaseId } };
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
async function ack(scope, key, event, leaseId, accepted) {
|
|
115
|
+
await storage.atomic(scope, key, previous => {
|
|
116
|
+
if (!previous || previous.scope !== scope || previous.leaseId !== leaseId)
|
|
117
|
+
return { state: previous, result: null };
|
|
118
|
+
const state = { ...previous, leaseId: null, leaseUntilMs: 0 };
|
|
119
|
+
if (accepted) state[event === "started" ? "startedReported" : "terminalReported"] = true;
|
|
120
|
+
return { state, result: null };
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
async function drain(scope, key) {
|
|
124
|
+
while (true) {
|
|
125
|
+
const next = await lease(scope, key);
|
|
126
|
+
if (!next) return;
|
|
127
|
+
if (activeReports >= 4) await new Promise(resolve => reportWaiters.push(resolve));
|
|
128
|
+
activeReports++;
|
|
129
|
+
let accepted = false;
|
|
130
|
+
try { await reportGeneration(next.jobId, next.event); accepted = true; }
|
|
131
|
+
catch { diagnostic("generation_report_unavailable"); }
|
|
132
|
+
finally { activeReports--; reportWaiters.shift()?.(); }
|
|
133
|
+
await ack(scope, key, next.event, next.leaseId, accepted);
|
|
134
|
+
if (!accepted) return;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async function reconcilePending(projectId, surfaceId) {
|
|
138
|
+
if (!ID.test(projectId || "") || !ID.test(surfaceId || "")) throw new Error("publisher_turn_scope_invalid");
|
|
139
|
+
const scope = createHash("sha256").update(JSON.stringify([projectId, surfaceId])).digest("hex");
|
|
140
|
+
if (typeof storage.prune === "function" && await storage.prune(scope) > 0)
|
|
141
|
+
diagnostic("generation_report_retention_expired");
|
|
142
|
+
let cursor = "";
|
|
143
|
+
while (true) {
|
|
144
|
+
const keys = await storage.scan(scope, cursor, 100);
|
|
145
|
+
if (!Array.isArray(keys) || keys.length > 100 || keys.some((key, index) => typeof key !== "string" ||
|
|
146
|
+
!/^[a-f0-9]{64}$/.test(key) || key <= (index ? keys[index - 1] : cursor)))
|
|
147
|
+
throw new Error("publisher_turn_scan_invalid");
|
|
148
|
+
if (keys.length === 0) return;
|
|
149
|
+
await Promise.all(keys.map(key => drain(scope, key)));
|
|
150
|
+
cursor = keys.at(-1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { claimPlacement, releaseRejectedPlacement, bind, claimGeneration, phase, reconcilePending };
|
|
154
|
+
}
|
package/templates/NEXT-SETUP.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# Connect wavebird to this Next.js app
|
|
2
2
|
|
|
3
|
-
The generator installed `lib/publisher`, `lib/auth`, and `lib/model`; `app/api/generate`, `app/api/sponsor-slot`, and `app/api/sponsor-
|
|
3
|
+
The generator installed `lib/publisher`, `lib/auth`, `lib/turn-store` and `lib/model`; `app/api/generate`, `app/api/sponsor-slot`, `app/api/sponsor-consent` and `app/api/sponsor-session`; and `components/WavebirdPlacement` (under `src/` when `src/app` was selected). No existing page was replaced.
|
|
4
4
|
|
|
5
|
-
1. Set server-only `WAVEBIRD_CLIENT_ID`, `WAVEBIRD_SECRET_KEY`, `WAVEBIRD_PREFERENCE_SECRET
|
|
6
|
-
2. Replace `lib/publisher`'s example surface, placement,
|
|
7
|
-
3. Connect `lib/auth` to the app's real session authorization. Its supplied path check fails closed when `PUBLISHER_AUTH_CHECK_PATH` is absent. The path must be a publisher-owned same-origin route that returns 204 only for an authorized user
|
|
8
|
-
4.
|
|
9
|
-
5.
|
|
5
|
+
1. Set server-only `WAVEBIRD_CLIENT_ID`, `WAVEBIRD_SECRET_KEY`, `WAVEBIRD_PREFERENCE_SECRET` (a stable random value of at least 32 bytes), and `WAVEBIRD_PUBLIC_ORIGINS`. The last value is a comma-separated list of the exact scheme, host, and port where your app is opened. Keep the Dashboard project domain at your real HTTPS host. For local Server Test-key testing, include the exact loopback origin in `WAVEBIRD_PUBLIC_ORIGINS` without changing that project domain; Browser Test Keys instead use their separate Allowed origins field in API Keys. `localhost` and `127.0.0.1` are distinct. Do not use wildcards. The package rejects an absent or malformed list. Never expose server secrets through `NEXT_PUBLIC_` or client props. `clientId` is the public project ID.
|
|
6
|
+
2. Replace `lib/publisher`'s example surface, placement, allowed sizes, formats, locale, consent mode, and publisher identity with the **saved Dashboard values**. The responsive example lists 728×90 and 320×50 Banner sizes only to show the size-selection shape. The saved sizes define the normal envelope; optional `maxWidth` and `maxHeight` are advanced caps that only narrow it. Keep `responsiveSizeSelection: true` only when the selected project and placement approve every configured size. Remove any size absent from the saved project before Test traffic. The API enforces its own authority; example values do not create a valid placement.
|
|
7
|
+
3. Connect `lib/auth` to the app's real session authorization. Its supplied path check fails closed when `PUBLISHER_AUTH_CHECK_PATH` is absent. The path must be a publisher-owned same-origin route that returns 204 only for an authorized user and supplies an `x-publisher-session-id` header containing a stable non-credential correlation ID for that authenticated session. Never return a cookie, bearer token or raw credential as that ID. The route must not be any wavebird route. The example sends the session cookie to that check only when the request origin exactly matches `WAVEBIRD_PUBLIC_ORIGINS`; a missing or different origin fails closed. The package checks that the browser's session ID matches this server-validated value before placement or generation.
|
|
8
|
+
4. Replace `lib/turn-store`'s fail-closed stub with `createPostgresTurnStorage(yourExistingPgPool)` or another persistent store implementing the package's atomic interface. For PostgreSQL, run the adapter's `install()` once in your normal database migration step; use a pool shared by the publisher's workers. Keep `WAVEBIRD_PREFERENCE_SECRET` stable across workers and restarts; the package derives a separate journal key from it and rejects a changed key for an active project. Coordinate secret rotation after active turns are drained; never clear the journal guard to restore ad service while turns remain. The package owns turn claims, ordered reports, retry and bounded reconciliation. An unavailable store suppresses ad placement while the authenticated model route remains available.
|
|
9
|
+
5. Connect `lib/model` to the existing generation stream. The installed function throws until replaced. Keep the existing generation response and pass the supplied abort signal to it. Do not put model calls in the browser solely for this integration.
|
|
10
|
+
6. In an existing client page, mount `<WavebirdPlacement clientId={publicProjectId} userId={authenticatedUserId} conversationId={activeConversationId} onReady={setAds} />` beside the real generation result, outside the input and transcript. The component retrieves the non-credential session ID through the authenticated same-origin route. When the user starts their existing generation, call `ads.connectGeneration({ signal, request: headers => fetch('/api/generate', { method: 'POST', headers, signal }), consume: async (response, lifecycle) => { /* consume your existing stream and call lifecycle.onDelta() for each delta */ } })`. Abort that same signal on Stop, logout or unmount. Use a stable `onReady` callback. This example uses wavebird's consent dialog; if the saved project uses the publisher's own CMP, connect its real grant/deny/withdrawal/expiry/GPC lifecycle instead of changing only the label.
|
|
10
11
|
|
|
11
12
|
These routes are real required Next.js files, not hidden package behavior. The package has no knowledge of your login session, CMP, or model stream. Start with a Test project and check that No-Fill, abort and late responses never block the primary result; all Test facts must remain nonpayable.
|