@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/dist/server.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { generationTransport } from "./generation.js";
|
|
2
|
+
export { createPublisherTurnJournal, derivePublisherTurnKeySecret } from "./turn-journal.js";
|
|
3
|
+
export { createPostgresTurnStorage } from "./postgres-turn-storage.js";
|
|
2
4
|
|
|
3
5
|
const PREFERENCE_VERSION = "contextual-choice/v2";
|
|
4
6
|
const PREFERENCE_TTL = 30 * 24 * 60 * 60;
|
|
@@ -8,6 +10,11 @@ const MAX_PARALLEL_WAVEBIRD_REQUESTS = 2;
|
|
|
8
10
|
const ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
9
11
|
const JOB_ID = /^job_[A-Za-z0-9_-]{1,128}$/;
|
|
10
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"]);
|
|
11
18
|
|
|
12
19
|
function response(error, status, headers = {}) {
|
|
13
20
|
return Response.json({ error }, { status, headers: { "Cache-Control": "no-store", ...headers } });
|
|
@@ -15,19 +22,32 @@ function response(error, status, headers = {}) {
|
|
|
15
22
|
|
|
16
23
|
function normalizeOptions(options) {
|
|
17
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");
|
|
18
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");
|
|
19
31
|
if (!String(options.clientId || "").startsWith("wbproj_")) throw new Error("publisher_project_invalid");
|
|
20
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");
|
|
21
34
|
if (!ID.test(options.surfaceId || "") || !PLACEMENT_KEY.test(options.placementKey || "")) throw new Error("publisher_placement_invalid");
|
|
22
35
|
if (!["before", "during", "after"].includes(options.timing)) throw new Error("publisher_timing_invalid");
|
|
23
36
|
if (!["wavebird_consent", "own_cmp"].includes(options.consentMode)) throw new Error("publisher_consent_mode_invalid");
|
|
24
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");
|
|
25
38
|
if (!["above", "below", "sidebar", "between"].includes(options.positionHint)) throw new Error("publisher_position_invalid");
|
|
26
|
-
if (!
|
|
27
|
-
if (!Array.isArray(options.allowedSizes) || options.allowedSizes.length === 0 || options.allowedSizes.some(size => !Number.isSafeInteger(size.width) || !Number.isSafeInteger(size.height) || size.width < 1 || size.height < 1 || size.width > options.maxWidth || size.height > options.maxHeight ||
|
|
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 ||
|
|
28
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");
|
|
29
49
|
if (options.responsiveSizeSelection !== undefined && typeof options.responsiveSizeSelection !== "boolean") throw new Error("publisher_responsive_selection_invalid");
|
|
30
|
-
if (!options.publisher || typeof options.publisher.app_name !== "string" || typeof options.publisher.app_domain !== "string" || !Array.isArray(options.publisher.categories)) throw new Error("publisher_identity_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");
|
|
31
51
|
if (typeof options.jobType !== "string" || !options.jobType || typeof options.locale !== "string" || !options.locale) throw new Error("publisher_job_invalid");
|
|
32
52
|
const base = String(options.apiBaseUrl || "https://api.wavebird.ai").trim().replace(/\/+$/, "").replace(/\/v1$/, "") + "/v1";
|
|
33
53
|
if (!/^https:\/\//.test(base)) throw new Error("publisher_api_https_required");
|
|
@@ -37,11 +57,12 @@ function normalizeOptions(options) {
|
|
|
37
57
|
let url;
|
|
38
58
|
try { url = new URL(origin); } catch { throw new Error("publisher_public_origins_invalid"); }
|
|
39
59
|
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
40
|
-
if (url.origin !== origin || (url.protocol !== "https:" && !(loopback && url.protocol === "http:"))) throw new Error("publisher_public_origins_invalid");
|
|
60
|
+
if (url.origin !== origin || (url.protocol !== "https:" && !(loopback && url.protocol === "http:" && options.secretKey.startsWith("sk_test_")))) throw new Error("publisher_public_origins_invalid");
|
|
41
61
|
}
|
|
42
62
|
if (new Set(options.publicOrigins).size !== options.publicOrigins.length) throw new Error("publisher_public_origins_invalid");
|
|
43
63
|
}
|
|
44
|
-
return { ...options,
|
|
64
|
+
return { ...options, allowedSizes: eligibleSizes, maxWidth, maxHeight, base,
|
|
65
|
+
consentSource: options.consentMode === "wavebird_consent" ? "wavebird_consent" : "wrapper_cmp" };
|
|
45
66
|
}
|
|
46
67
|
|
|
47
68
|
function publicOrigin(request, options) {
|
|
@@ -163,12 +184,17 @@ function boundedGenerationReporter() {
|
|
|
163
184
|
};
|
|
164
185
|
}
|
|
165
186
|
|
|
166
|
-
function observeGeneration(response, request, onLifecycle) {
|
|
167
|
-
if (typeof onLifecycle !== "function"
|
|
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;
|
|
168
190
|
const reader = response.body.getReader();
|
|
169
191
|
const decoder = new TextDecoder();
|
|
170
192
|
let terminal = false;
|
|
171
|
-
const notify = event => {
|
|
193
|
+
const notify = async event => {
|
|
194
|
+
if (typeof onCritical === "function") {
|
|
195
|
+
try { await onCritical(event); } catch {}
|
|
196
|
+
}
|
|
197
|
+
if (typeof onLifecycle !== "function") return;
|
|
172
198
|
try { Promise.resolve(onLifecycle(event)).catch(() => {}); } catch {}
|
|
173
199
|
};
|
|
174
200
|
const body = new ReadableStream({
|
|
@@ -181,7 +207,7 @@ function observeGeneration(response, request, onLifecycle) {
|
|
|
181
207
|
const record = JSON.parse(decoder.decode(next.value));
|
|
182
208
|
if (["started", "done", "error", "abort"].includes(record.type)) {
|
|
183
209
|
if (record.type !== "started") terminal = true;
|
|
184
|
-
notify({ type: record.type, sessionId: record.session_id, generationId: record.generation_id });
|
|
210
|
+
await notify({ type: record.type, sessionId: record.session_id, generationId: record.generation_id });
|
|
185
211
|
}
|
|
186
212
|
} catch {}
|
|
187
213
|
controller.enqueue(next.value);
|
|
@@ -190,7 +216,7 @@ function observeGeneration(response, request, onLifecycle) {
|
|
|
190
216
|
async cancel(reason) {
|
|
191
217
|
if (!terminal) {
|
|
192
218
|
terminal = true;
|
|
193
|
-
notify({ type: "abort", sessionId: request.headers.get("x-wavebird-session-id"), generationId: request.headers.get("x-wavebird-generation-id") });
|
|
219
|
+
await notify({ type: "abort", sessionId: request.headers.get("x-wavebird-session-id"), generationId: request.headers.get("x-wavebird-generation-id") });
|
|
194
220
|
}
|
|
195
221
|
try { await reader.cancel(reason); } catch {}
|
|
196
222
|
}
|
|
@@ -203,6 +229,34 @@ export function createPublisherServer(inputOptions) {
|
|
|
203
229
|
const placementFetch = boundedFetcher();
|
|
204
230
|
const consentFetch = boundedFetcher();
|
|
205
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
|
+
}
|
|
206
260
|
const cookieName = `__Host-wavebird-preference-${options.clientId}-${options.surfaceId}`;
|
|
207
261
|
const cookie = (value, ttl = PREFERENCE_TTL) => `${cookieName}=${value}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${ttl}`;
|
|
208
262
|
const scope = request => ({ project: options.clientId, surface: options.surfaceId, version: PREFERENCE_VERSION,
|
|
@@ -229,11 +283,14 @@ export function createPublisherServer(inputOptions) {
|
|
|
229
283
|
if (qaOutcomes.length && !options.secretKey.startsWith("sk_test_")) return response("qa_test_outcome_requires_test_key", 403);
|
|
230
284
|
const input = await readPublisherJson(request);
|
|
231
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);
|
|
232
288
|
let allowedSizes = options.allowedSizes.map(({ width, height }) => ({ width, height }));
|
|
233
289
|
let allowedFormats = options.allowedFormats;
|
|
234
290
|
let maxWidth = options.maxWidth;
|
|
235
291
|
let maxHeight = options.maxHeight;
|
|
236
|
-
|
|
292
|
+
const noFit = () => Response.json({ decision: { fill: false, reason: "no_fit" }, placement: null }, { headers: { "Cache-Control": "no-store" } });
|
|
293
|
+
if (options.responsiveSizeSelection !== false) {
|
|
237
294
|
const bounds = input?.container_size;
|
|
238
295
|
if (!bounds || !Number.isSafeInteger(bounds.width) || !Number.isSafeInteger(bounds.height) ||
|
|
239
296
|
bounds.width < 1 || bounds.height < 1 || bounds.width > 8192 || bounds.height > 8192) {
|
|
@@ -241,10 +298,11 @@ export function createPublisherServer(inputOptions) {
|
|
|
241
298
|
}
|
|
242
299
|
const requestedFormat = input?.format ?? "auto";
|
|
243
300
|
if (requestedFormat !== "auto" && !options.allowedFormats.includes(requestedFormat)) return response("publisher_format_invalid", 400);
|
|
301
|
+
if (allowedSizes.length === 0) return noFit();
|
|
244
302
|
const fitting = options.allowedSizes.filter(size => size.width <= bounds.width && size.height <= bounds.height &&
|
|
245
303
|
(requestedFormat === "auto" || (size.formats ?? options.allowedFormats).includes(requestedFormat)))
|
|
246
304
|
.sort((a, b) => b.width - a.width || b.height - a.height);
|
|
247
|
-
if (fitting.length === 0) return
|
|
305
|
+
if (fitting.length === 0) return noFit();
|
|
248
306
|
const selected = fitting[0];
|
|
249
307
|
allowedSizes = [{ width: selected.width, height: selected.height }];
|
|
250
308
|
allowedFormats = requestedFormat === "auto"
|
|
@@ -253,6 +311,13 @@ export function createPublisherServer(inputOptions) {
|
|
|
253
311
|
maxWidth = Math.min(maxWidth, bounds.width);
|
|
254
312
|
maxHeight = Math.min(maxHeight, bounds.height);
|
|
255
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
|
+
}
|
|
256
321
|
const sessionId = typeof input?.session_id === "string" && input.session_id.trim() ? input.session_id.trim() : crypto.randomUUID();
|
|
257
322
|
const body = {
|
|
258
323
|
client_id: options.clientId, session_id: sessionId, job_type: options.jobType, locale: options.locale,
|
|
@@ -260,7 +325,7 @@ export function createPublisherServer(inputOptions) {
|
|
|
260
325
|
slot_hint: { position: options.positionHint, max_width: maxWidth, max_height: maxHeight,
|
|
261
326
|
allowed_sizes: allowedSizes, presentation_version: "sponsored_moment_v2" },
|
|
262
327
|
overrides: { allowed_formats: allowedFormats, timing: options.timing,
|
|
263
|
-
bidfloor: options.bidfloor
|
|
328
|
+
...(Object.hasOwn(options, "bidfloor") ? { bidfloor: options.bidfloor, bidfloor_currency: options.bidfloorCurrency.toUpperCase() } : {}),
|
|
264
329
|
gdpr_applies: options.gdprApplies === true,
|
|
265
330
|
publisher: { app_name: options.publisher.app_name, app_domain: options.publisher.app_domain,
|
|
266
331
|
...(options.publisher.categories.length ? { categories: options.publisher.categories } : {}) },
|
|
@@ -269,9 +334,27 @@ export function createPublisherServer(inputOptions) {
|
|
|
269
334
|
consent_source: options.consentSource }
|
|
270
335
|
};
|
|
271
336
|
try {
|
|
272
|
-
|
|
337
|
+
const result = await placementFetch(`${options.base}/placements?wait_ms=1500${qaOutcomes.length ? "&test_outcome=no_fill" : ""}`, { method: "POST", headers: {
|
|
273
338
|
Authorization: `Bearer ${options.secretKey}`, "Content-Type": "application/json"
|
|
274
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;
|
|
275
358
|
} catch { return response("wavebird_placement_unavailable", 502); }
|
|
276
359
|
}
|
|
277
360
|
async function consentGET(request) {
|
|
@@ -336,8 +419,24 @@ export function createPublisherServer(inputOptions) {
|
|
|
336
419
|
async function withGeneration({ request, run, onLifecycle }) {
|
|
337
420
|
if (!await authorized(request)) return response("authentication_required", 401);
|
|
338
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
|
+
}
|
|
339
433
|
const result = await generationTransport({ request, run });
|
|
340
|
-
|
|
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);
|
|
341
440
|
}
|
|
342
441
|
async function reportGeneration(jobId, event) {
|
|
343
442
|
if (!JOB_ID.test(jobId) || !["started", "finished", "failed"].includes(event)) throw new Error("invalid_generation_report");
|
|
@@ -345,5 +444,5 @@ export function createPublisherServer(inputOptions) {
|
|
|
345
444
|
method: "POST", headers: { Authorization: `Bearer ${options.secretKey}`, "Content-Type": "application/json" }, body: "{}"
|
|
346
445
|
});
|
|
347
446
|
}
|
|
348
|
-
return { placementPOST, consentGET, consentPOST, withGeneration, reportGeneration };
|
|
447
|
+
return { sessionGET, placementPOST, consentGET, consentPOST, withGeneration, reportGeneration };
|
|
349
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
|
+
}
|
|
@@ -9,7 +9,15 @@ export default function Page() {
|
|
|
9
9
|
const abort = useRef(null);
|
|
10
10
|
const [answer, setAnswer] = useState("");
|
|
11
11
|
useEffect(() => {
|
|
12
|
-
|
|
12
|
+
if (!slot.current) return;
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
let disposed = false;
|
|
15
|
+
let current = null;
|
|
16
|
+
void fetch("/api/sponsor-session", { cache: "no-store", signal: controller.signal }).then(async response => {
|
|
17
|
+
if (!response.ok) return;
|
|
18
|
+
const sessionId = (await response.json()).session_id;
|
|
19
|
+
if (disposed || !/^[A-Za-z0-9_-]{1,128}$/.test(sessionId)) return;
|
|
20
|
+
current = createPublisherAds({
|
|
13
21
|
target: slot.current,
|
|
14
22
|
clientId: "wbproj_replace_from_dashboard",
|
|
15
23
|
surfaceId: "chat_assistant",
|
|
@@ -18,13 +26,16 @@ export default function Page() {
|
|
|
18
26
|
consentMode: "wavebird_consent",
|
|
19
27
|
userId: "replace-with-authenticated-user-id",
|
|
20
28
|
conversationId: "replace-with-current-conversation-id",
|
|
29
|
+
sessionId,
|
|
30
|
+
turnBinding: true,
|
|
21
31
|
rendererUrl: "https://api.wavebird.ai/v1/render.js",
|
|
22
32
|
placementEndpoint: "/api/sponsor-slot",
|
|
23
33
|
consentEndpoint: "/api/sponsor-consent",
|
|
24
34
|
privacySettingsTarget: "#privacy-settings"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
35
|
+
});
|
|
36
|
+
ads.current = current;
|
|
37
|
+
}).catch(() => {});
|
|
38
|
+
return () => { disposed = true; controller.abort(); abort.current?.abort(); current?.dispose(); ads.current = null; };
|
|
28
39
|
}, []);
|
|
29
40
|
async function generate() {
|
|
30
41
|
if (!ads.current) return;
|
|
@@ -1,12 +1,33 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
const cache = new WeakMap();
|
|
4
|
+
const ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
5
|
+
|
|
6
|
+
/** Resolve a non-credential correlation ID from the app's authenticated session. */
|
|
7
|
+
export function sessionIdForRequest(request) {
|
|
8
|
+
const prior = cache.get(request);
|
|
9
|
+
if (prior) return prior;
|
|
10
|
+
const pending = (async () => {
|
|
11
|
+
const path = process.env.PUBLISHER_AUTH_CHECK_PATH;
|
|
12
|
+
if (!path || !/^\/(?!\/)[A-Za-z0-9/_-]+$/.test(path) ||
|
|
13
|
+
path.startsWith("/api/sponsor") || path === "/api/generate") return null;
|
|
14
|
+
try {
|
|
15
|
+
const requestOrigin = new URL(request.url).origin;
|
|
16
|
+
const trustedOrigin = (process.env.WAVEBIRD_PUBLIC_ORIGINS ?? "").split(",")
|
|
17
|
+
.map(value => value.trim()).find(value => value === requestOrigin);
|
|
18
|
+
if (!trustedOrigin) return null;
|
|
19
|
+
const result = await fetch(new URL(path, trustedOrigin), {
|
|
20
|
+
headers: { cookie: request.headers.get("cookie") || "" },
|
|
21
|
+
cache: "no-store", redirect: "manual", signal: AbortSignal.timeout(1500),
|
|
22
|
+
});
|
|
23
|
+
const id = result.headers.get("x-publisher-session-id");
|
|
24
|
+
return result.status === 204 && ID.test(id || "") ? id : null;
|
|
25
|
+
} catch { return null; }
|
|
26
|
+
})();
|
|
27
|
+
cache.set(request, pending);
|
|
28
|
+
return pending;
|
|
29
|
+
}
|
|
30
|
+
|
|
1
31
|
export async function authorizePublisherRequest(request) {
|
|
2
|
-
|
|
3
|
-
if (!path || !/^\/(?!\/)[A-Za-z0-9/_-]+$/.test(path) || path.startsWith("/api/sponsor") || path === "/api/generate") return false;
|
|
4
|
-
try {
|
|
5
|
-
const result = await fetch(new URL(path, request.url), {
|
|
6
|
-
headers: { cookie: request.headers.get("cookie") || "" },
|
|
7
|
-
cache: "no-store",
|
|
8
|
-
redirect: "manual"
|
|
9
|
-
});
|
|
10
|
-
return result.status === 204;
|
|
11
|
-
} catch { return false; }
|
|
32
|
+
return (await sessionIdForRequest(request)) !== null;
|
|
12
33
|
}
|
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
import "server-only";
|
|
2
|
-
import { createPublisherServer } from "@wavebirdai/api/server";
|
|
3
|
-
import { authorizePublisherRequest } from "./auth.js";
|
|
2
|
+
import { createPublisherServer, createPublisherTurnJournal, derivePublisherTurnKeySecret } from "@wavebirdai/api/server";
|
|
3
|
+
import { authorizePublisherRequest, sessionIdForRequest } from "./auth.js";
|
|
4
|
+
import { publisherTurnStorage } from "./turn-store.js";
|
|
4
5
|
|
|
5
6
|
let publisher;
|
|
6
7
|
|
|
7
8
|
export function getPublisher() {
|
|
8
|
-
|
|
9
|
+
if (publisher) return publisher;
|
|
10
|
+
const turnJournal = createPublisherTurnJournal({
|
|
11
|
+
storage: publisherTurnStorage(),
|
|
12
|
+
keySecret: derivePublisherTurnKeySecret(process.env.WAVEBIRD_PREFERENCE_SECRET ?? ""),
|
|
13
|
+
reportGeneration: (jobId, event) => getPublisher().reportGeneration(jobId, event),
|
|
14
|
+
});
|
|
15
|
+
return publisher = createPublisherServer({
|
|
9
16
|
apiBaseUrl: process.env.WAVEBIRD_API_BASE_URL,
|
|
10
17
|
clientId: process.env.WAVEBIRD_CLIENT_ID,
|
|
11
18
|
secretKey: process.env.WAVEBIRD_SECRET_KEY,
|
|
12
19
|
preferenceSecret: process.env.WAVEBIRD_PREFERENCE_SECRET,
|
|
13
20
|
authorize: authorizePublisherRequest,
|
|
21
|
+
sessionIdForRequest,
|
|
22
|
+
turnJournal,
|
|
14
23
|
publicOrigins: (process.env.WAVEBIRD_PUBLIC_ORIGINS ?? "").split(",").map(value => value.trim()).filter(Boolean),
|
|
15
24
|
surfaceId: "chat_assistant",
|
|
16
25
|
placementKey: "default",
|
|
@@ -22,8 +31,6 @@ export function getPublisher() {
|
|
|
22
31
|
publisher: { app_name: "Your saved app name", app_domain: "your.example", categories: [] },
|
|
23
32
|
allowedFormats: ["banner"],
|
|
24
33
|
positionHint: "between",
|
|
25
|
-
maxWidth: 728,
|
|
26
|
-
maxHeight: 90,
|
|
27
34
|
allowedSizes: [{ width: 728, height: 90 }, { width: 320, height: 50 }],
|
|
28
35
|
responsiveSizeSelection: true
|
|
29
36
|
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
/** Replace this fail-closed stub with createPostgresTurnStorage(your existing pg pool). */
|
|
4
|
+
export function publisherTurnStorage() {
|
|
5
|
+
return {
|
|
6
|
+
async atomic() { throw new Error("Connect your existing durable turn store"); },
|
|
7
|
+
async scan() { throw new Error("Connect your existing durable turn store"); },
|
|
8
|
+
};
|
|
9
|
+
}
|
|
@@ -10,8 +10,16 @@ export default function Page() {
|
|
|
10
10
|
const [answer, setAnswer] = useState("");
|
|
11
11
|
useEffect(() => {
|
|
12
12
|
if (!slot.current) return;
|
|
13
|
-
const
|
|
14
|
-
|
|
13
|
+
const target = slot.current;
|
|
14
|
+
const controller = new AbortController();
|
|
15
|
+
let disposed = false;
|
|
16
|
+
let current: PublisherAds | null = null;
|
|
17
|
+
void fetch("/api/sponsor-session", { cache: "no-store", signal: controller.signal }).then(async response => {
|
|
18
|
+
if (!response.ok) return;
|
|
19
|
+
const sessionId = (await response.json()).session_id;
|
|
20
|
+
if (disposed || !/^[A-Za-z0-9_-]{1,128}$/.test(sessionId)) return;
|
|
21
|
+
current = createPublisherAds({
|
|
22
|
+
target,
|
|
15
23
|
clientId: "wbproj_replace_from_dashboard",
|
|
16
24
|
surfaceId: "chat_assistant",
|
|
17
25
|
jobType: "chat",
|
|
@@ -19,13 +27,16 @@ export default function Page() {
|
|
|
19
27
|
consentMode: "wavebird_consent",
|
|
20
28
|
userId: "replace-with-authenticated-user-id",
|
|
21
29
|
conversationId: "replace-with-current-conversation-id",
|
|
30
|
+
sessionId,
|
|
31
|
+
turnBinding: true,
|
|
22
32
|
rendererUrl: "https://api.wavebird.ai/v1/render.js",
|
|
23
33
|
placementEndpoint: "/api/sponsor-slot",
|
|
24
34
|
consentEndpoint: "/api/sponsor-consent",
|
|
25
35
|
privacySettingsTarget: "#privacy-settings"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
36
|
+
});
|
|
37
|
+
ads.current = current;
|
|
38
|
+
}).catch(() => {});
|
|
39
|
+
return () => { disposed = true; controller.abort(); abort.current?.abort(); current?.dispose(); ads.current = null; };
|
|
29
40
|
}, []);
|
|
30
41
|
async function generate() {
|
|
31
42
|
if (!ads.current) return;
|
|
@@ -1,12 +1,33 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
const cache = new WeakMap<Request, Promise<string | null>>();
|
|
4
|
+
const ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
5
|
+
|
|
6
|
+
/** Resolve a non-credential correlation ID from the app's authenticated session. */
|
|
7
|
+
export function sessionIdForRequest(request: Request): Promise<string | null> {
|
|
8
|
+
const prior = cache.get(request);
|
|
9
|
+
if (prior) return prior;
|
|
10
|
+
const pending = (async () => {
|
|
11
|
+
const path = process.env.PUBLISHER_AUTH_CHECK_PATH;
|
|
12
|
+
if (!path || !/^\/(?!\/)[A-Za-z0-9/_-]+$/.test(path) ||
|
|
13
|
+
path.startsWith("/api/sponsor") || path === "/api/generate") return null;
|
|
14
|
+
try {
|
|
15
|
+
const requestOrigin = new URL(request.url).origin;
|
|
16
|
+
const trustedOrigin = (process.env.WAVEBIRD_PUBLIC_ORIGINS ?? "").split(",")
|
|
17
|
+
.map(value => value.trim()).find(value => value === requestOrigin);
|
|
18
|
+
if (!trustedOrigin) return null;
|
|
19
|
+
const result = await fetch(new URL(path, trustedOrigin), {
|
|
20
|
+
headers: { cookie: request.headers.get("cookie") || "" },
|
|
21
|
+
cache: "no-store", redirect: "manual", signal: AbortSignal.timeout(1500),
|
|
22
|
+
});
|
|
23
|
+
const id = result.headers.get("x-publisher-session-id");
|
|
24
|
+
return result.status === 204 && ID.test(id || "") ? id : null;
|
|
25
|
+
} catch { return null; }
|
|
26
|
+
})();
|
|
27
|
+
cache.set(request, pending);
|
|
28
|
+
return pending;
|
|
29
|
+
}
|
|
30
|
+
|
|
1
31
|
export async function authorizePublisherRequest(request: Request): Promise<boolean> {
|
|
2
|
-
|
|
3
|
-
if (!path || !/^\/(?!\/)[A-Za-z0-9/_-]+$/.test(path) || path.startsWith("/api/sponsor") || path === "/api/generate") return false;
|
|
4
|
-
try {
|
|
5
|
-
const result = await fetch(new URL(path, request.url), {
|
|
6
|
-
headers: { cookie: request.headers.get("cookie") || "" },
|
|
7
|
-
cache: "no-store",
|
|
8
|
-
redirect: "manual"
|
|
9
|
-
});
|
|
10
|
-
return result.status === 204;
|
|
11
|
-
} catch { return false; }
|
|
32
|
+
return (await sessionIdForRequest(request)) !== null;
|
|
12
33
|
}
|