@wavebirdai/api 0.1.0-beta.1 → 0.1.0-beta.11

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/dist/server.js CHANGED
@@ -1,47 +1,94 @@
1
+ import { Buffer } from "node:buffer";
1
2
  import { generationTransport } from "./generation.js";
3
+ export { createPublisherTurnJournal, derivePublisherTurnKeySecret } from "./turn-journal.js";
4
+ export { createPostgresTurnStorage } from "./postgres-turn-storage.js";
2
5
 
3
6
  const PREFERENCE_VERSION = "contextual-choice/v2";
4
7
  const PREFERENCE_TTL = 30 * 24 * 60 * 60;
5
8
  const MAX_PUBLISHER_BODY_BYTES = 16 * 1024;
6
9
  const MAX_WAVEBIRD_RESPONSE_BYTES = 256 * 1024;
7
10
  const MAX_PARALLEL_WAVEBIRD_REQUESTS = 2;
11
+ const MAX_ALLOWED_SIZES = 8;
12
+ const MAX_SIZE_EDGE = 4096;
13
+ const MAX_ENVELOPE_EDGE = 8192;
8
14
  const ID = /^[A-Za-z0-9_-]{1,128}$/;
9
15
  const JOB_ID = /^job_[A-Za-z0-9_-]{1,128}$/;
10
16
  const PLACEMENT_KEY = /^[a-z0-9][a-z0-9_-]{0,63}$/;
17
+ const SERVER_OPTION_KEYS = new Set(["apiBaseUrl", "clientId", "secretKey", "preferenceSecret", "authorize",
18
+ "sessionIdForRequest", "turnJournal", "onDiagnostic", "publicOrigins", "surfaceId", "placementKey",
19
+ "jobType", "locale", "timing", "consentMode", "gdprApplies", "publisher", "allowedFormats",
20
+ "positionHint", "maxWidth", "maxHeight", "allowedSizes", "responsiveSizeSelection", "bidfloor",
21
+ "bidfloorCurrency", "blockedCategories"]);
11
22
 
12
23
  function response(error, status, headers = {}) {
13
24
  return Response.json({ error }, { status, headers: { "Cache-Control": "no-store", ...headers } });
14
25
  }
15
26
 
16
27
  function normalizeOptions(options) {
17
- if (typeof window !== "undefined") throw new Error("publisher_server_only");
28
+ if (typeof document !== "undefined") throw new Error("publisher_server_only");
29
+ if (options && Object.keys(options).some(key => !SERVER_OPTION_KEYS.has(key))) throw new Error("publisher_option_unknown");
18
30
  if (!options || typeof options.authorize !== "function") throw new Error("publisher_authorize_required");
19
- if (!String(options.clientId || "").startsWith("wbproj_")) throw new Error("publisher_project_invalid");
20
- if (!String(options.secretKey || "").trim()) throw new Error("publisher_server_key_required");
21
- if (!ID.test(options.surfaceId || "") || !PLACEMENT_KEY.test(options.placementKey || "")) throw new Error("publisher_placement_invalid");
31
+ if (options.onDiagnostic !== undefined && typeof options.onDiagnostic !== "function")
32
+ throw new Error("publisher_diagnostic_invalid");
33
+ if (typeof options.preferenceSecret !== "string" || new TextEncoder().encode(options.preferenceSecret).byteLength < 32) throw new Error("preference_secret_required");
34
+ if ((options.turnJournal === undefined) !== (options.sessionIdForRequest === undefined) ||
35
+ (options.turnJournal !== undefined && (typeof options.sessionIdForRequest !== "function" ||
36
+ ["claimPlacement", "releaseRejectedPlacement", "bind", "claimGeneration", "phase"].some(name => typeof options.turnJournal?.[name] !== "function"))))
37
+ throw new Error("publisher_turn_journal_invalid");
38
+ if (typeof options.clientId !== "string" || !options.clientId.startsWith("wbproj_")) throw new Error("publisher_project_invalid");
39
+ if (typeof options.secretKey !== "string") throw new Error("publisher_server_key_invalid");
40
+ if (!options.secretKey.trim()) throw new Error("publisher_server_key_required");
41
+ if (!/^sk_(?:test|live)_/.test(options.secretKey)) throw new Error("publisher_server_key_invalid");
42
+ if (typeof options.surfaceId !== "string" || typeof options.placementKey !== "string" || !ID.test(options.surfaceId) || !PLACEMENT_KEY.test(options.placementKey)) throw new Error("publisher_placement_invalid");
22
43
  if (!["before", "during", "after"].includes(options.timing)) throw new Error("publisher_timing_invalid");
23
44
  if (!["wavebird_consent", "own_cmp"].includes(options.consentMode)) throw new Error("publisher_consent_mode_invalid");
24
- if (!Array.isArray(options.allowedFormats) || options.allowedFormats.length === 0 || options.allowedFormats.some(value => !["banner", "native"].includes(value))) throw new Error("publisher_formats_invalid");
45
+ if (options.gdprApplies !== undefined && typeof options.gdprApplies !== "boolean") throw new Error("publisher_gdpr_invalid");
46
+ 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
47
  if (!["above", "below", "sidebar", "between"].includes(options.positionHint)) throw new Error("publisher_position_invalid");
26
- if (!Number.isSafeInteger(options.maxWidth) || options.maxWidth < 1 || !Number.isSafeInteger(options.maxHeight) || options.maxHeight < 1) throw new Error("publisher_envelope_invalid");
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 ||
48
+ if (!Array.isArray(options.allowedSizes) || options.allowedSizes.length === 0 || options.allowedSizes.length > MAX_ALLOWED_SIZES || 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 || size.width > MAX_SIZE_EDGE || size.height > MAX_SIZE_EDGE ||
28
49
  (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");
50
+ if (new Set(options.allowedSizes.map(size => `${size.width}x${size.height}`)).size !== options.allowedSizes.length)
51
+ throw new Error("publisher_sizes_invalid");
52
+ const maxWidth = options.maxWidth === undefined ? options.allowedSizes.reduce((max, size) => Math.max(max, size.width), 0) : options.maxWidth;
53
+ const maxHeight = options.maxHeight === undefined ? options.allowedSizes.reduce((max, size) => Math.max(max, size.height), 0) : options.maxHeight;
54
+ if (!Number.isSafeInteger(maxWidth) || maxWidth < 1 || maxWidth > MAX_ENVELOPE_EDGE ||
55
+ !Number.isSafeInteger(maxHeight) || maxHeight < 1 || maxHeight > MAX_ENVELOPE_EDGE)
56
+ throw new Error("publisher_envelope_invalid");
57
+ const eligibleSizes = options.allowedSizes.filter(size => size.width <= maxWidth && size.height <= maxHeight);
58
+ const hasFloor = Object.hasOwn(options, "bidfloor");
59
+ const hasCurrency = Object.hasOwn(options, "bidfloorCurrency");
60
+ if (hasFloor !== hasCurrency || (hasFloor && (!Number.isFinite(options.bidfloor) || options.bidfloor < 0 || typeof options.bidfloorCurrency !== "string" || !/^[A-Za-z]{3}$/.test(options.bidfloorCurrency))))
61
+ throw new Error("publisher_bidfloor_override_invalid");
29
62
  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");
31
- if (typeof options.jobType !== "string" || !options.jobType || typeof options.locale !== "string" || !options.locale) throw new Error("publisher_job_invalid");
32
- const base = String(options.apiBaseUrl || "https://api.wavebird.ai").trim().replace(/\/+$/, "").replace(/\/v1$/, "") + "/v1";
33
- if (!/^https:\/\//.test(base)) throw new Error("publisher_api_https_required");
63
+ if (!options.publisher || typeof options.publisher !== "object" || Object.keys(options.publisher).some(key => !["app_name", "app_domain", "categories"].includes(key)) ||
64
+ typeof options.publisher.app_name !== "string" || !options.publisher.app_name.trim() ||
65
+ typeof options.publisher.app_domain !== "string" || !options.publisher.app_domain.trim() ||
66
+ !Array.isArray(options.publisher.categories) ||
67
+ options.publisher.categories.some(value => typeof value !== "string" || !value.trim())) throw new Error("publisher_identity_invalid");
68
+ if (options.blockedCategories !== undefined && (!Array.isArray(options.blockedCategories) ||
69
+ options.blockedCategories.some(value => typeof value !== "string" || !value.trim())))
70
+ throw new Error("publisher_blocked_categories_invalid");
71
+ if (!["chat", "code", "image", "voice", "agent"].includes(options.jobType) || typeof options.locale !== "string" || !options.locale) throw new Error("publisher_job_invalid");
72
+ const apiBaseUrl = options.apiBaseUrl === undefined ? "https://api.wavebird.ai" : options.apiBaseUrl;
73
+ if (typeof apiBaseUrl !== "string" || !apiBaseUrl.trim() || /[\\?#]/.test(apiBaseUrl)) throw new Error("publisher_api_https_required");
74
+ const endpoint = apiBaseUrl.trim();
75
+ let parsedEndpoint;
76
+ try { parsedEndpoint = new URL(endpoint); } catch { throw new Error("publisher_api_https_required"); }
77
+ if (parsedEndpoint.protocol !== "https:" || !parsedEndpoint.hostname || parsedEndpoint.username || parsedEndpoint.password ||
78
+ parsedEndpoint.search || parsedEndpoint.hash) throw new Error("publisher_api_https_required");
79
+ const base = endpoint.replace(/\/+$/, "").replace(/\/v1$/, "") + "/v1";
34
80
  {
35
81
  if (!Array.isArray(options.publicOrigins) || options.publicOrigins.length < 1 || options.publicOrigins.length > 8) throw new Error("publisher_public_origins_invalid");
36
82
  for (const origin of options.publicOrigins) {
37
83
  let url;
38
84
  try { url = new URL(origin); } catch { throw new Error("publisher_public_origins_invalid"); }
39
85
  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");
86
+ if (url.origin !== origin || (url.protocol !== "https:" && !(loopback && url.protocol === "http:" && options.secretKey.startsWith("sk_test_")))) throw new Error("publisher_public_origins_invalid");
41
87
  }
42
88
  if (new Set(options.publicOrigins).size !== options.publicOrigins.length) throw new Error("publisher_public_origins_invalid");
43
89
  }
44
- return { ...options, base, consentSource: options.consentMode === "wavebird_consent" ? "wavebird_consent" : "wrapper_cmp" };
90
+ return { ...options, allowedSizes: eligibleSizes, maxWidth, maxHeight, base,
91
+ consentSource: options.consentMode === "wavebird_consent" ? "wavebird_consent" : "wrapper_cmp" };
45
92
  }
46
93
 
47
94
  function publicOrigin(request, options) {
@@ -163,12 +210,17 @@ function boundedGenerationReporter() {
163
210
  };
164
211
  }
165
212
 
166
- function observeGeneration(response, request, onLifecycle) {
167
- if (typeof onLifecycle !== "function" || response.headers.get("x-wavebird-generation-transport") !== "1" || !response.body) return response;
213
+ function observeGeneration(response, request, onLifecycle, onCritical) {
214
+ if (typeof onLifecycle !== "function" && typeof onCritical !== "function") return response;
215
+ if (response.headers.get("x-wavebird-generation-transport") !== "1" || !response.body) return response;
168
216
  const reader = response.body.getReader();
169
217
  const decoder = new TextDecoder();
170
218
  let terminal = false;
171
- const notify = event => {
219
+ const notify = async event => {
220
+ if (typeof onCritical === "function") {
221
+ try { await onCritical(event); } catch {}
222
+ }
223
+ if (typeof onLifecycle !== "function") return;
172
224
  try { Promise.resolve(onLifecycle(event)).catch(() => {}); } catch {}
173
225
  };
174
226
  const body = new ReadableStream({
@@ -181,7 +233,7 @@ function observeGeneration(response, request, onLifecycle) {
181
233
  const record = JSON.parse(decoder.decode(next.value));
182
234
  if (["started", "done", "error", "abort"].includes(record.type)) {
183
235
  if (record.type !== "started") terminal = true;
184
- notify({ type: record.type, sessionId: record.session_id, generationId: record.generation_id });
236
+ await notify({ type: record.type, sessionId: record.session_id, generationId: record.generation_id });
185
237
  }
186
238
  } catch {}
187
239
  controller.enqueue(next.value);
@@ -190,7 +242,7 @@ function observeGeneration(response, request, onLifecycle) {
190
242
  async cancel(reason) {
191
243
  if (!terminal) {
192
244
  terminal = true;
193
- notify({ type: "abort", sessionId: request.headers.get("x-wavebird-session-id"), generationId: request.headers.get("x-wavebird-generation-id") });
245
+ await notify({ type: "abort", sessionId: request.headers.get("x-wavebird-session-id"), generationId: request.headers.get("x-wavebird-generation-id") });
194
246
  }
195
247
  try { await reader.cancel(reason); } catch {}
196
248
  }
@@ -203,12 +255,40 @@ export function createPublisherServer(inputOptions) {
203
255
  const placementFetch = boundedFetcher();
204
256
  const consentFetch = boundedFetcher();
205
257
  const generationReportFetch = boundedGenerationReporter();
258
+ const diagnostic = code => { try { options.onDiagnostic?.(code); } catch {} };
259
+ if (typeof options.turnJournal?.reconcilePending === "function") {
260
+ void Promise.resolve().then(() => options.turnJournal.reconcilePending(options.clientId, options.surfaceId))
261
+ .catch(() => diagnostic("turn_journal_unavailable"));
262
+ }
263
+ async function journalCall(operation) {
264
+ let timer;
265
+ try { return await Promise.race([
266
+ Promise.resolve().then(operation),
267
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("turn_journal_timeout")), 1500); })
268
+ ]); } finally { clearTimeout(timer); }
269
+ }
270
+ async function trustedTurn(request, sessionId, turnId) {
271
+ if (!ID.test(sessionId || "") || !ID.test(turnId || "")) return null;
272
+ let trusted;
273
+ try { trusted = await journalCall(() => options.sessionIdForRequest(request)); } catch { return null; }
274
+ return trusted === sessionId ? { projectId: options.clientId, surfaceId: options.surfaceId, sessionId, turnId } : null;
275
+ }
276
+ async function sessionGET(request) {
277
+ if (!options.turnJournal) return response("turn_binding_unavailable", 404);
278
+ if (!await authorized(request)) return response("authentication_required", 401);
279
+ if (!validOrigin(request, options, false)) return response("invalid_origin", 403);
280
+ let sessionId;
281
+ try { sessionId = await journalCall(() => options.sessionIdForRequest(request)); }
282
+ catch { diagnostic("publisher_session_unavailable"); return response("publisher_session_unavailable", 503); }
283
+ if (!ID.test(sessionId || "")) return response("publisher_session_unavailable", 401);
284
+ return Response.json({ session_id: sessionId }, { headers: { "Cache-Control": "no-store", Vary: "Cookie" } });
285
+ }
206
286
  const cookieName = `__Host-wavebird-preference-${options.clientId}-${options.surfaceId}`;
207
287
  const cookie = (value, ttl = PREFERENCE_TTL) => `${cookieName}=${value}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${ttl}`;
208
288
  const scope = request => ({ project: options.clientId, surface: options.surfaceId, version: PREFERENCE_VERSION,
209
289
  profile: options.consentMode === "wavebird_consent" ? "wavebird_dialog" : "publisher_custom", origin: publicOrigin(request, options) });
210
290
  async function preferenceKey() {
211
- if (!options.preferenceSecret || options.preferenceSecret.length < 32) throw new Error("preference_secret_required");
291
+ if (new TextEncoder().encode(options.preferenceSecret).byteLength < 32) throw new Error("preference_secret_required");
212
292
  return crypto.subtle.importKey("raw", new TextEncoder().encode(options.preferenceSecret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
213
293
  }
214
294
  async function signPreference(request, state) {
@@ -229,11 +309,14 @@ export function createPublisherServer(inputOptions) {
229
309
  if (qaOutcomes.length && !options.secretKey.startsWith("sk_test_")) return response("qa_test_outcome_requires_test_key", 403);
230
310
  const input = await readPublisherJson(request);
231
311
  if (input instanceof Response) return input;
312
+ const turn = options.turnJournal ? await trustedTurn(request, input?.session_id, input?.turn_id) : null;
313
+ if (options.turnJournal && !turn) return response("publisher_turn_context_invalid", 401);
232
314
  let allowedSizes = options.allowedSizes.map(({ width, height }) => ({ width, height }));
233
315
  let allowedFormats = options.allowedFormats;
234
316
  let maxWidth = options.maxWidth;
235
317
  let maxHeight = options.maxHeight;
236
- if (options.responsiveSizeSelection === true) {
318
+ const noFit = () => Response.json({ decision: { fill: false, reason: "no_fit" }, placement: null }, { headers: { "Cache-Control": "no-store" } });
319
+ if (options.responsiveSizeSelection !== false) {
237
320
  const bounds = input?.container_size;
238
321
  if (!bounds || !Number.isSafeInteger(bounds.width) || !Number.isSafeInteger(bounds.height) ||
239
322
  bounds.width < 1 || bounds.height < 1 || bounds.width > 8192 || bounds.height > 8192) {
@@ -241,10 +324,11 @@ export function createPublisherServer(inputOptions) {
241
324
  }
242
325
  const requestedFormat = input?.format ?? "auto";
243
326
  if (requestedFormat !== "auto" && !options.allowedFormats.includes(requestedFormat)) return response("publisher_format_invalid", 400);
327
+ if (allowedSizes.length === 0) return noFit();
244
328
  const fitting = options.allowedSizes.filter(size => size.width <= bounds.width && size.height <= bounds.height &&
245
329
  (requestedFormat === "auto" || (size.formats ?? options.allowedFormats).includes(requestedFormat)))
246
330
  .sort((a, b) => b.width - a.width || b.height - a.height);
247
- if (fitting.length === 0) return Response.json({ decision: { fill: false, reason: "no_fit" }, placement: null }, { headers: { "Cache-Control": "no-store" } });
331
+ if (fitting.length === 0) return noFit();
248
332
  const selected = fitting[0];
249
333
  allowedSizes = [{ width: selected.width, height: selected.height }];
250
334
  allowedFormats = requestedFormat === "auto"
@@ -253,6 +337,13 @@ export function createPublisherServer(inputOptions) {
253
337
  maxWidth = Math.min(maxWidth, bounds.width);
254
338
  maxHeight = Math.min(maxHeight, bounds.height);
255
339
  }
340
+ else if (allowedSizes.length === 0) return noFit();
341
+ if (turn) {
342
+ let claimed;
343
+ try { claimed = await journalCall(() => options.turnJournal.claimPlacement(turn)); }
344
+ catch { diagnostic("turn_journal_unavailable"); return response("turn_journal_unavailable", 503); }
345
+ if (!claimed) return response("publisher_turn_replayed", 409);
346
+ }
256
347
  const sessionId = typeof input?.session_id === "string" && input.session_id.trim() ? input.session_id.trim() : crypto.randomUUID();
257
348
  const body = {
258
349
  client_id: options.clientId, session_id: sessionId, job_type: options.jobType, locale: options.locale,
@@ -260,8 +351,7 @@ export function createPublisherServer(inputOptions) {
260
351
  slot_hint: { position: options.positionHint, max_width: maxWidth, max_height: maxHeight,
261
352
  allowed_sizes: allowedSizes, presentation_version: "sponsored_moment_v2" },
262
353
  overrides: { allowed_formats: allowedFormats, timing: options.timing,
263
- bidfloor: options.bidfloor ?? 0.5, bidfloor_currency: options.bidfloorCurrency ?? "EUR",
264
- gdpr_applies: options.gdprApplies === true,
354
+ ...(Object.hasOwn(options, "bidfloor") ? { bidfloor: options.bidfloor, bidfloor_currency: options.bidfloorCurrency.toUpperCase() } : {}),
265
355
  publisher: { app_name: options.publisher.app_name, app_domain: options.publisher.app_domain,
266
356
  ...(options.publisher.categories.length ? { categories: options.publisher.categories } : {}) },
267
357
  ...(options.blockedCategories?.length ? { blocked_categories: options.blockedCategories } : {}) },
@@ -269,9 +359,27 @@ export function createPublisherServer(inputOptions) {
269
359
  consent_source: options.consentSource }
270
360
  };
271
361
  try {
272
- return await placementFetch(`${options.base}/placements?wait_ms=1500${qaOutcomes.length ? "&test_outcome=no_fill" : ""}`, { method: "POST", headers: {
362
+ const result = await placementFetch(`${options.base}/placements?wait_ms=1500${qaOutcomes.length ? "&test_outcome=no_fill" : ""}`, { method: "POST", headers: {
273
363
  Authorization: `Bearer ${options.secretKey}`, "Content-Type": "application/json"
274
364
  }, body: JSON.stringify(body), cache: "no-store" });
365
+ if (turn && [400, 401, 403].includes(result.status)) {
366
+ try { await journalCall(() => options.turnJournal.releaseRejectedPlacement(turn)); }
367
+ catch { diagnostic("turn_journal_unavailable"); }
368
+ }
369
+ if (turn && result.status === 200) {
370
+ let payload;
371
+ try { payload = await result.clone().json(); } catch {}
372
+ const jobId = payload?.job_id;
373
+ if (JOB_ID.test(jobId || "")) {
374
+ try { await journalCall(() => options.turnJournal.bind(turn, jobId)); }
375
+ catch { diagnostic("turn_binding_unavailable"); return response("turn_binding_unavailable", 502); }
376
+ } else if (!(payload?.decision?.fill === false &&
377
+ Object.hasOwn(payload ?? {}, "placement") && payload.placement === null)) {
378
+ diagnostic("turn_binding_unavailable");
379
+ return response("turn_binding_unavailable", 502);
380
+ }
381
+ }
382
+ return result;
275
383
  } catch { return response("wavebird_placement_unavailable", 502); }
276
384
  }
277
385
  async function consentGET(request) {
@@ -336,8 +444,24 @@ export function createPublisherServer(inputOptions) {
336
444
  async function withGeneration({ request, run, onLifecycle }) {
337
445
  if (!await authorized(request)) return response("authentication_required", 401);
338
446
  if (!validOrigin(request, options)) return response("invalid_origin", 403);
447
+ const turn = options.turnJournal ? await trustedTurn(request,
448
+ request.headers.get("x-wavebird-session-id"), request.headers.get("x-wavebird-generation-id")) : null;
449
+ if (options.turnJournal && !turn) return response("publisher_turn_context_invalid", 401);
450
+ if (turn) {
451
+ let claimed;
452
+ try { claimed = await journalCall(() => options.turnJournal.claimGeneration(turn)); }
453
+ catch { diagnostic("turn_journal_unavailable"); return run(request.signal); }
454
+ if (!claimed) return response("publisher_turn_replayed", 409);
455
+ try { await journalCall(() => options.turnJournal.phase(turn, "started")); }
456
+ catch { diagnostic("turn_journal_unavailable"); return run(request.signal); }
457
+ }
339
458
  const result = await generationTransport({ request, run });
340
- return observeGeneration(result, request, onLifecycle);
459
+ const critical = turn ? async event => {
460
+ if (event.type === "started") return;
461
+ try { await journalCall(() => options.turnJournal.phase(turn, event.type === "done" ? "finished" : "failed")); }
462
+ catch { diagnostic("turn_journal_unavailable"); }
463
+ } : null;
464
+ return observeGeneration(result, request, onLifecycle, critical);
341
465
  }
342
466
  async function reportGeneration(jobId, event) {
343
467
  if (!JOB_ID.test(jobId) || !["started", "finished", "failed"].includes(event)) throw new Error("invalid_generation_report");
@@ -345,5 +469,5 @@ export function createPublisherServer(inputOptions) {
345
469
  method: "POST", headers: { Authorization: `Bearer ${options.secretKey}`, "Content-Type": "application/json" }, body: "{}"
346
470
  });
347
471
  }
348
- return { placementPOST, consentGET, consentPOST, withGeneration, reportGeneration };
472
+ return { sessionGET, placementPOST, consentGET, consentPOST, withGeneration, reportGeneration };
349
473
  }
@@ -0,0 +1,157 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { createHash, createHmac, randomUUID } from "node:crypto";
3
+
4
+ const ID = /^[A-Za-z0-9_-]{1,128}$/;
5
+ const JOB_ID = /^job_[A-Za-z0-9_-]{1,128}$/;
6
+
7
+ /** Domain-separate the stable server preference secret for pseudonymous turn keys. */
8
+ export function derivePublisherTurnKeySecret(preferenceSecret) {
9
+ if (typeof preferenceSecret !== "string" || Buffer.byteLength(preferenceSecret, "utf8") < 32)
10
+ throw new Error("publisher_preference_secret_invalid");
11
+ return createHmac("sha256", preferenceSecret).update("wavebird/turn-journal-key/v1").digest("hex");
12
+ }
13
+
14
+ /** Package-owned state machine over a publisher's existing atomic, durable store. */
15
+ export function createPublisherTurnJournal({ storage, reportGeneration, keySecret, onDiagnostic }) {
16
+ if (typeof storage?.atomic !== "function" || typeof storage?.scan !== "function" ||
17
+ typeof reportGeneration !== "function") throw new Error("publisher_turn_storage_invalid");
18
+ if (typeof keySecret !== "string" || Buffer.byteLength(keySecret, "utf8") < 32)
19
+ throw new Error("publisher_turn_key_secret_invalid");
20
+ if (onDiagnostic !== undefined && typeof onDiagnostic !== "function")
21
+ throw new Error("publisher_diagnostic_invalid");
22
+ const tasks = new Set();
23
+ let activeReports = 0;
24
+ const reportWaiters = [];
25
+ const diagnostic = code => { try { onDiagnostic?.(code); } catch {} };
26
+ const verifiedScopes = new Set();
27
+ const versionKey = createHash("sha256").update("wavebird/turn-journal-version-row/v1").digest("hex");
28
+ const keyFingerprint = createHmac("sha256", keySecret).update("wavebird/turn-journal-key-version/v1").digest("hex");
29
+ async function verifyKeyVersion(scope) {
30
+ if (verifiedScopes.has(scope)) return;
31
+ // A separate scope keeps this guard outside ordinary turn retention pruning.
32
+ const versionScope = createHash("sha256").update(JSON.stringify(["wavebird/turn-journal-meta/v1", scope])).digest("hex");
33
+ await storage.atomic(versionScope, versionKey, previous => {
34
+ if (previous && previous.keyFingerprint !== keyFingerprint)
35
+ throw new Error("publisher_turn_key_version_conflict");
36
+ return { state: previous || { keyFingerprint }, result: null };
37
+ });
38
+ verifiedScopes.add(scope);
39
+ }
40
+ function identity(turn) {
41
+ if (![turn?.projectId, turn?.surfaceId, turn?.sessionId, turn?.turnId].every(value => ID.test(value || "")))
42
+ throw new Error("publisher_turn_context_invalid");
43
+ const scope = createHash("sha256").update(JSON.stringify([turn.projectId, turn.surfaceId])).digest("hex");
44
+ const key = createHmac("sha256", keySecret).update(JSON.stringify([turn.projectId, turn.surfaceId, turn.sessionId, turn.turnId])).digest("hex");
45
+ return { scope, key };
46
+ }
47
+ function initial(scope) {
48
+ return { scope, createdAtMs: Date.now(), placementClaimed: false, generationClaimed: false,
49
+ jobId: null, phase: "idle", startedReported: false, terminalReported: false,
50
+ leaseId: null, leaseUntilMs: 0 };
51
+ }
52
+ async function change(turn, update) {
53
+ const { scope, key } = identity(turn);
54
+ await verifyKeyVersion(scope);
55
+ return storage.atomic(scope, key, previous => {
56
+ if (previous && previous.scope !== scope) throw new Error("publisher_turn_scope_conflict");
57
+ const state = { ...(previous || initial(scope)) };
58
+ const result = update(state);
59
+ return { state, result };
60
+ });
61
+ }
62
+ async function claimPlacement(turn) {
63
+ return change(turn, state => {
64
+ if (state.placementClaimed) return false;
65
+ state.placementClaimed = true;
66
+ return true;
67
+ });
68
+ }
69
+ async function releaseRejectedPlacement(turn) {
70
+ await change(turn, state => { if (!state.jobId) state.placementClaimed = false; });
71
+ }
72
+ async function claimGeneration(turn) {
73
+ return change(turn, state => {
74
+ if (state.generationClaimed) return false;
75
+ state.generationClaimed = true;
76
+ return true;
77
+ });
78
+ }
79
+ function schedule(scope, key) {
80
+ const task = drain(scope, key).catch(() => diagnostic("turn_journal_unavailable"));
81
+ tasks.add(task);
82
+ void task.finally(() => tasks.delete(task));
83
+ }
84
+ async function bind(turn, jobId) {
85
+ if (!JOB_ID.test(jobId || "")) throw new Error("publisher_job_id_invalid");
86
+ const { scope, key } = identity(turn);
87
+ const bound = await change(turn, state => {
88
+ if (!state.placementClaimed || state.jobId && state.jobId !== jobId) return false;
89
+ state.jobId = jobId;
90
+ return true;
91
+ });
92
+ if (!bound) throw new Error("publisher_turn_job_conflict");
93
+ schedule(scope, key);
94
+ }
95
+ async function phase(turn, event) {
96
+ if (!["started", "finished", "failed"].includes(event)) throw new Error("publisher_phase_invalid");
97
+ const { scope, key } = identity(turn);
98
+ const changed = await change(turn, state => {
99
+ if (!state.generationClaimed || state.phase !== (event === "started" ? "idle" : "started")) return false;
100
+ state.phase = event;
101
+ return true;
102
+ });
103
+ if (changed) schedule(scope, key);
104
+ }
105
+ async function lease(scope, key) {
106
+ return storage.atomic(scope, key, previous => {
107
+ if (!previous || previous.scope !== scope || !previous.jobId || previous.phase === "idle" ||
108
+ previous.leaseUntilMs > Date.now()) return { state: previous, result: null };
109
+ const event = !previous.startedReported ? "started" :
110
+ ["finished", "failed"].includes(previous.phase) && !previous.terminalReported ? previous.phase : null;
111
+ if (!event) return { state: previous, result: null };
112
+ const leaseId = randomUUID();
113
+ return { state: { ...previous, leaseId, leaseUntilMs: Date.now() + 10_000 },
114
+ result: { jobId: previous.jobId, event, leaseId } };
115
+ });
116
+ }
117
+ async function ack(scope, key, event, leaseId, accepted) {
118
+ await storage.atomic(scope, key, previous => {
119
+ if (!previous || previous.scope !== scope || previous.leaseId !== leaseId)
120
+ return { state: previous, result: null };
121
+ const state = { ...previous, leaseId: null, leaseUntilMs: 0 };
122
+ if (accepted) state[event === "started" ? "startedReported" : "terminalReported"] = true;
123
+ return { state, result: null };
124
+ });
125
+ }
126
+ async function drain(scope, key) {
127
+ while (true) {
128
+ const next = await lease(scope, key);
129
+ if (!next) return;
130
+ if (activeReports >= 4) await new Promise(resolve => reportWaiters.push(resolve));
131
+ activeReports++;
132
+ let accepted = false;
133
+ try { await reportGeneration(next.jobId, next.event); accepted = true; }
134
+ catch { diagnostic("generation_report_unavailable"); }
135
+ finally { activeReports--; reportWaiters.shift()?.(); }
136
+ await ack(scope, key, next.event, next.leaseId, accepted);
137
+ if (!accepted) return;
138
+ }
139
+ }
140
+ async function reconcilePending(projectId, surfaceId) {
141
+ if (!ID.test(projectId || "") || !ID.test(surfaceId || "")) throw new Error("publisher_turn_scope_invalid");
142
+ const scope = createHash("sha256").update(JSON.stringify([projectId, surfaceId])).digest("hex");
143
+ if (typeof storage.prune === "function" && await storage.prune(scope) > 0)
144
+ diagnostic("generation_report_retention_expired");
145
+ let cursor = "";
146
+ while (true) {
147
+ const keys = await storage.scan(scope, cursor, 100);
148
+ if (!Array.isArray(keys) || keys.length > 100 || keys.some((key, index) => typeof key !== "string" ||
149
+ !/^[a-f0-9]{64}$/.test(key) || key <= (index ? keys[index - 1] : cursor)))
150
+ throw new Error("publisher_turn_scan_invalid");
151
+ if (keys.length === 0) return;
152
+ await Promise.all(keys.map(key => drain(scope, key)));
153
+ cursor = keys.at(-1);
154
+ }
155
+ }
156
+ return { claimPlacement, releaseRejectedPlacement, bind, claimGeneration, phase, reconcilePending };
157
+ }
@@ -0,0 +1,5 @@
1
+ import { getPublisher } from "../../../lib/publisher.js";
2
+
3
+ export async function GET(request) {
4
+ return getPublisher().sessionGET(request);
5
+ }
@@ -9,7 +9,15 @@ export default function Page() {
9
9
  const abort = useRef(null);
10
10
  const [answer, setAnswer] = useState("");
11
11
  useEffect(() => {
12
- const current = createPublisherAds({
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
- ads.current = current;
27
- return () => { abort.current?.abort(); current.dispose(); ads.current = null; };
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
- const path = process.env.PUBLISHER_AUTH_CHECK_PATH;
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
- return publisher ??= createPublisherServer({
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
+ }
@@ -0,0 +1,5 @@
1
+ import { getPublisher } from "../../../lib/publisher";
2
+
3
+ export async function GET(request: Request): Promise<Response> {
4
+ return getPublisher().sessionGET(request);
5
+ }