@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 CHANGED
@@ -1,51 +1,99 @@
1
- # wavebird publisher API
1
+ # @wavebirdai/api
2
2
 
3
- `@wavebirdai/api` provides Banner, Native and Clip integration with the wavebird REST API and hosted renderer. The pinned public GitHub `.4` and optional `.5` archives, and npm `0.1.0-beta.1`, support Banner and Native. Existing publishers keep their installed version unless they opt in. The established [`wavebird`](https://www.npmjs.com/package/wavebird) SDK remains separate.
3
+ Add Wavebird Banner, Native, and Clip placements to an authenticated chat app. The package provides server handlers for placement, consent, and generation reporting, plus a browser adapter for the hosted renderer. Your server keeps the Wavebird key; your app keeps control of its login, model stream, layout, and consent choice.
4
4
 
5
- ## Installation
5
+ ## Integrate with Next.js
6
6
 
7
- Use the exact available package version and install command shown in Dashboard Overview for your saved project.
7
+ You need a saved Wavebird Test project and placement in Dashboard, a Server Test Key, an existing authenticated Next.js App Router app, and a durable turn store. Start with the placement's saved formats, sizes, timing, and consent mode. Test traffic is nonpayable.
8
8
 
9
- The public `.4` GitHub asset uses fixed sizes. Public `.5` adds optional responsive selection. A version change does not migrate an existing integration automatically.
9
+ 1. Install the package and generate the route files for your app directory:
10
10
 
11
- The [full REST reference](https://wavebird.ai/api/reference/endpoints) remains available for integrations without this package.
11
+ ```sh
12
+ npm install @wavebirdai/api@0.1.0-beta.4
13
+ ./node_modules/.bin/wavebird-next init --lang ts --root . --app-dir src/app
14
+ ```
12
15
 
13
- Both JavaScript and TypeScript use the same package and URL; there is no separate language package.
16
+ Use `--lang js --app-dir app` for a JavaScript app with `app/` at the project root. The generator adds files without overwriting existing ones and writes `WAVEBIRD-SETUP.md` with the exact connection points.
14
17
 
15
- For a saved single-format placement such as Clip-only After, pass `getFormat: () => "clip"` to `createPublisherAds` along with the matching same-origin placement endpoint. The package forwards that choice to the hosted renderer; the server still enforces the saved project and format. Omit `getFormat` when the saved placement intentionally lets wavebird choose among supported formats.
18
+ 2. Set the server-only values in your deployment environment:
16
19
 
17
- ## Next.js route generator
20
+ ```dotenv
21
+ WAVEBIRD_CLIENT_ID=<saved-project-id>
22
+ WAVEBIRD_SECRET_KEY=<server-test-key>
23
+ WAVEBIRD_PREFERENCE_SECRET=<stable-random-value-at-least-32-bytes>
24
+ WAVEBIRD_PUBLIC_ORIGINS=https://your-app.example
25
+ ```
18
26
 
19
- After installing the package, run one of these commands in an existing Next.js App Router project:
27
+ `WAVEBIRD_PUBLIC_ORIGINS` must contain each exact app origin, including scheme and port. For local Server Test-key work, add the exact loopback origin to this list while keeping the Dashboard project domain at its real HTTPS host. Never expose the server key or preference secret through `NEXT_PUBLIC_`, client props, or source control.
20
28
 
21
- ```sh
22
- ./node_modules/.bin/wavebird-next init --lang js --root . --app-dir app
23
- ./node_modules/.bin/wavebird-next init --lang ts --root . --app-dir src/app
24
- ```
29
+ 3. Connect the generated files to your app: copy the **saved** placement settings into `lib/publisher`; have `lib/auth` validate the real logged-in request and return a stable, non-credential session ID; replace `lib/turn-store` with your persistent store; and connect `lib/model` to your existing generation stream. The supplied auth, store, and model hooks fail closed until you do this. If you use PostgreSQL, the provided adapter uses your existing pool and its `install()` belongs in your normal migration step.
25
30
 
26
- Choose one language and the actual App Router directory. The command refuses to overwrite existing files. It installs `lib/publisher`, `lib/auth`, and a fail-closed `lib/model`; required `app/api/generate`, `app/api/sponsor-slot`, and `app/api/sponsor-consent` route files; `components/WavebirdPlacement`; and `WAVEBIRD-SETUP.md`. The `src/app` option puts `lib` and `components` under `src/`. These routes are application files produced by the generator, not implicit package exports. JS and TS Next.js example files are also included in the package's `examples/` directory; connect their authorization and generation hooks before use. The generated publisher lists two illustrative Banner sizes and enables responsive selection; replace both sizes with the exact saved project sizes before Test traffic.
31
+ The generated server route keeps the Wavebird key on the server and wraps your existing stream:
27
32
 
28
- Set the server-only environment variables, including `WAVEBIRD_PUBLIC_ORIGINS` with the exact scheme, host, and port where your app is opened. Keep the Dashboard project domain at its real HTTPS host; for local Server Test-key testing, add the exact loopback origin to this server-only list without changing that project domain. Browser Test Keys have a separate Allowed origins field in API Keys. Insert actual saved Dashboard settings in `lib/publisher`, connect your real session check in `lib/auth`, and connect your existing model stream in `lib/model`. The supplied auth hook fails closed without its publisher-owned check path, and the generated model hook throws until replaced. The generated client component requires authenticated user and conversation IDs from the publisher. Mount it by the existing result area and call its returned ads object's `connectGeneration` around the existing request/stream consumer. The generated setup file shows the short entry point. Use your real consent lifecycle: the template selects wavebird's consent dialog, while an `own_cmp` saved project must be wired to the publisher's actual CMP before any served test.
33
+ ```ts
34
+ import { getPublisher } from "../../../lib/publisher";
35
+ import { existingGeneration } from "../../../lib/model";
29
36
 
37
+ export async function POST(request: Request): Promise<Response> {
38
+ return getPublisher().withGeneration({ request, run: existingGeneration });
39
+ }
40
+ ```
41
+
42
+ 4. Mount the generated `WavebirdPlacement` beside your app's result area using real authenticated user and conversation IDs, then wrap the existing generation request:
43
+
44
+ ```tsx
45
+ <WavebirdPlacement
46
+ clientId={publicProjectId}
47
+ userId={authenticatedUserId}
48
+ conversationId={activeConversationId}
49
+ onReady={setAds}
50
+ />
51
+
52
+ await ads.connectGeneration({
53
+ signal,
54
+ request: headers => fetch("/api/generate", { method: "POST", headers, signal }),
55
+ consume: async (response, lifecycle) => {
56
+ if (!response.ok || !response.body) throw new Error("generation_unavailable");
57
+ const reader = response.body.getReader();
58
+ while (true) {
59
+ const next = await reader.read();
60
+ if (next.done) break;
61
+ // Forward next.value to your existing stream consumer.
62
+ lifecycle.onDelta();
63
+ }
64
+ },
65
+ });
66
+ ```
67
+
68
+ The generated `WAVEBIRD-SETUP.md` shows where the component and routes go. Use Wavebird's consent dialog only when that matches the saved project; for `own_cmp`, connect your actual CMP lifecycle.
69
+
70
+ 5. Validate in Test: confirm the saved format and size, consent and GPC behavior, No-Fill, Stop/abort, and late responses. The generation result must continue independently of the ad path. Move to another package version or Live only through your normal project approval process.
71
+
72
+ JavaScript and TypeScript use this same package. Complete Next.js JavaScript and TypeScript examples are included in `node_modules/@wavebirdai/api/examples/next-js` and `node_modules/@wavebirdai/api/examples/next-ts`. For other frameworks, use the explicit `@wavebirdai/api/server` and `@wavebirdai/api/browser` exports described below. The [REST reference](https://wavebird.ai/api/reference/endpoints) covers integrations without this package. The established [`wavebird`](https://www.npmjs.com/package/wavebird) SDK is a separate package.
73
+
30
74
  ## Server and browser boundary
31
75
 
32
- `@wavebirdai/api/server` exports `createPublisherServer`. Configure it with server-only Project ID, Server Test or Live Key, preference signing secret, the publisher's real request authorization function, exact `publicOrigins`, and the saved project/placement values shown in the dashboard. The origin list is required. The package compares the browser Origin with a configured public origin whose host matches the route Host, even when Next.js or a proxy gives the route an internal URL. `localhost` and `127.0.0.1` are separate origins, and each port and scheme must match exactly; HTTP is accepted only for loopback. It returns authenticated `placementPOST`, `consentGET`, `consentPOST`, and `withGeneration` handlers, plus the server-only `reportGeneration(jobId, event)` method. The helper constructs the existing `/v1/placements`, `/v1/consent`, and `/v1/jobs/{id}/generation/{event}` requests; it does not introduce a new wavebird protocol. The wavebird API remains authoritative for saved scope, creative formats, duration, fallback, and financial classification.
76
+ `@wavebirdai/api/server` exports `createPublisherServer`. Configure it with server-only Project ID, Server Test or production Key, preference signing secret, the publisher's real request authorization function, exact `publicOrigins`, and the saved project/placement values shown in the dashboard. The origin list is required. The package compares the browser Origin with a configured public origin whose host matches the route Host, even when Next.js or a proxy gives the route an internal URL. `localhost` and `127.0.0.1` are separate origins, and each port and scheme must match exactly; HTTP loopback is available only with a Server Test Key. A production key also requires its separately approved project and Wavebird server authority. Omit `bidfloor` and `bidfloorCurrency` to inherit saved pricing. An explicit override supplies both a nonnegative amount, including zero, and a three-letter currency; the API validates it against the saved project. It returns authenticated `placementPOST`, `consentGET`, `consentPOST`, and `withGeneration` handlers, plus the server-only `reportGeneration(jobId, event)` method. The helper constructs the existing `/v1/placements`, `/v1/consent`, and `/v1/jobs/{id}/generation/{event}` requests; it does not introduce a new Wavebird protocol. The Wavebird API remains authoritative for saved scope, creative formats, duration, fallback, and financial classification.
33
77
 
34
- Responsive selection is opt-in with `responsiveSizeSelection: true`. The hosted browser adapter sends the measured reserved container size. The server chooses one of the saved `allowedSizes` that fits, or returns `no_fit` without an upstream placement request. A size can carry `formats: ["banner"]`, `formats: ["native"]`, `formats: ["clip"]`, or an approved combination; this annotation remains on the publisher server and is stripped from the wavebird request. A browser format preference is accepted only when it is among the saved `allowedFormats` and compatible with the selected size. Without opt-in, existing fixed-size requests retain their previous behavior. Reserve enough height for the selected creative and its controls; do not stretch the returned creative or place it over the model result.
78
+ Responsive size selection is enabled by default in this version. The browser adapter sends the measured reserved container size. The server chooses one of the saved `allowedSizes` that fits, or returns `no_fit` without an upstream placement request. Numeric `maxWidth` and `maxHeight` are optional advanced caps: when omitted, the saved sizes define the envelope; when supplied, they can only narrow eligible sizes. A size can carry `formats: ["banner"]`, `formats: ["native"]`, `formats: ["clip"]`, or an approved combination; this annotation remains on the publisher server and is stripped from the wavebird request. A browser format preference is accepted only when it is among the saved `allowedFormats` and compatible with the selected size. Set `responsiveSizeSelection: false` only for a deliberate fixed-size compatibility integration. Reserve enough height for the selected creative and its controls; do not stretch the returned creative or place it over the model result.
35
79
 
36
- Create the server helper when a request arrives, as the included Next.js examples do. This lets Next.js build a publisher app without loading its server-only Test key into the build process; the helper still validates configuration before handling traffic.
80
+ Create the server helper when a request arrives, as the included Next.js examples do. This lets Next.js build a publisher app without loading its server key into the build process; the helper still validates configuration before handling traffic.
37
81
 
38
82
  `withGeneration({ request, run, onLifecycle })` accepts an optional server-side observer. It receives `{ type, sessionId, generationId }` for `started` and one terminal state: `done`, `error`, or `abort`. Use it to forward lifecycle events to your own generation reporter; observer failures do not change the model response. It runs only for authenticated, same-origin requests with valid generation transport IDs. The callback is not awaited, and it receives no prompt or model bytes.
39
83
 
84
+ With `sessionIdForRequest` and `turnJournal` configured, the package validates the browser non-credential session ID against the publisher authenticated request, binds one turn to its authorized placement job, and automatically retries ordered generation reports. The PostgreSQL adapter uses the publisher existing pool; run its `install()` in the normal migration step. An unavailable journal suppresses ad placement while an authenticated model request can still run. The store and HMAC key must survive worker restarts. A durable per-project key-version guard rejects a changed preference secret before accepting another turn; pending reports can still reconcile. Coordinate key rotation after active turns are drained rather than clearing the guard while turns remain. This opt-in path requires a package version that includes these exports; earlier published versions retain their original behavior.
85
+
40
86
  Call `reportGeneration` only from trusted server code with a job ID returned by an authorized placement. Its events are `started`, `finished`, and `failed`. The server key scopes the request at wavebird; invalid IDs or events are rejected locally, and each report has a five-second deadline and a parallel request limit. Reporting errors reject the returned promise so the caller can handle them outside the model response path.
41
87
 
42
88
  `@wavebirdai/api/browser` exports the existing tested `createPublisherAds` and `connectPublisherGeneration` lifecycle helper. It loads the hosted renderer and attaches only to a reserved container selected by the publisher. Initialize it with the current app user and conversation IDs; pass optional same-origin `placementEndpoint` and `consentEndpoint` paths when using the server routes in this package. The helper forwards those paths to the hosted renderer. Keep the Server Key and preference signing secret in the server configuration; never pass them as browser options. Use the real wavebird consent dialog or the publisher's actual CMP. Consent on an idle page does not request an ad. For `during`, the helper displays only during an active generation. Its generation request and response stream run without waiting for consent synchronization, the ad response, or renderer readiness. It clears late responses on completion, error, abort, conversation change, GPC, or withdrawal.
43
89
 
90
+ For `after`, resolve the generation consumer only after your app presents the result. The browser helper waits for both the transport finish and that consumer before starting the placement. If presentation fails, reject the consumer so the placement does not start.
91
+
44
92
  ## Next.js
45
93
 
46
94
  The package contains equivalent JavaScript and TypeScript Next.js example files in `examples/next-js` and `examples/next-ts`. Each shows a publisher-owned reserved slot outside the input and scrolling transcript, same-origin consent and placement routes, the server-only configuration, and an existing generation stream wrapped by `withGeneration`. Their authorization hook fails closed until connected to the publisher's actual session validation. The sample generation stream is a local demonstration and is meant to be replaced by the publisher's existing model call.
47
95
 
48
- The required server-only values are `WAVEBIRD_CLIENT_ID`, `WAVEBIRD_SECRET_KEY`, `WAVEBIRD_PREFERENCE_SECRET`, and `WAVEBIRD_PUBLIC_ORIGINS`. Keep secrets outside browser bundles and source control. Set project surface, placement, job type, timing, formats, locale, consent profile, identity/domain, and ad-space dimensions from the saved dashboard configuration. Do not shrink the reserve below the saved creative envelope plus the 256px Banner/Native control space. The publisher positions the section in its own UI.
96
+ The required server-only values are `WAVEBIRD_CLIENT_ID`, `WAVEBIRD_SECRET_KEY`, `WAVEBIRD_PREFERENCE_SECRET`, and `WAVEBIRD_PUBLIC_ORIGINS`. Keep secrets outside browser bundles and source control. Set project surface, placement, job type, timing, formats, locale, consent profile, identity/domain, and ad-space dimensions from the saved dashboard configuration. Reserve space for the selected creative and controls without distorting it. The publisher positions the section in its own UI.
49
97
 
50
98
  ## Test scope
51
99
 
@@ -35,10 +35,12 @@ export async function plan(argv) {
35
35
  const files = [
36
36
  [join(example, "lib", `publisher.${ext}`), join(base, "lib", `publisher.${ext}`)],
37
37
  [join(example, "lib", `auth.${ext}`), join(base, "lib", `auth.${ext}`)],
38
+ [join(example, "lib", `turn-store.${ext}`), join(base, "lib", `turn-store.${ext}`)],
38
39
  [resolve(packageRoot, "templates", `model.${ext}`), join(base, "lib", `model.${ext}`)],
39
40
  [join(example, "app", "api", "generate", `route.${ext}`), join(appPath, "api", "generate", `route.${ext}`)],
40
41
  [join(example, "app", "api", "sponsor-slot", `route.${ext}`), join(appPath, "api", "sponsor-slot", `route.${ext}`)],
41
42
  [join(example, "app", "api", "sponsor-consent", `route.${ext}`), join(appPath, "api", "sponsor-consent", `route.${ext}`)],
43
+ [join(example, "app", "api", "sponsor-session", `route.${ext}`), join(appPath, "api", "sponsor-session", `route.${ext}`)],
42
44
  [resolve(packageRoot, "templates", `WavebirdPlacement.${componentExt}`), join(base, "components", `WavebirdPlacement.${componentExt}`)],
43
45
  [resolve(packageRoot, "templates", "NEXT-SETUP.md"), join(projectRoot, "WAVEBIRD-SETUP.md")]
44
46
  ];
package/build.mjs ADDED
@@ -0,0 +1,10 @@
1
+ import { mkdir, copyFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, resolve } from "node:path";
4
+
5
+ const root = dirname(fileURLToPath(import.meta.url));
6
+ const dist = resolve(root, "dist");
7
+ await mkdir(dist, { recursive: true });
8
+ for (const file of ["generation.js", "turn-journal.js", "postgres-turn-storage.js", "browser.js", "server.js", "server.d.ts", "browser.d.ts"]) {
9
+ await copyFile(resolve(root, "src", file), resolve(dist, file));
10
+ }
package/dist/browser.d.ts CHANGED
@@ -1,8 +1,9 @@
1
+ /** Resolve after the publisher has presented the result; After timing starts then. */
1
2
  export type GenerationConsumer<T> = (response: Response, lifecycle: { onDelta(): void }) => T | Promise<T>;
2
3
  export type PublisherAds = {
3
4
  ready: Promise<void>;
4
5
  connectGeneration<T>(options: { request: (headers: Record<string, string>) => Response | Promise<Response>; consume: GenerationConsumer<T>; signal?: AbortSignal }): Promise<T>;
5
- setConversation(binding: { userId: string; conversationId: string }): void;
6
+ setConversation(binding: { userId: string; conversationId: string; sessionId?: string }): void;
6
7
  dispose(): void;
7
8
  openPrivacySettings(): void;
8
9
  grant(choice?: unknown): Promise<unknown>;
@@ -16,6 +17,9 @@ export function createPublisherAds(options: {
16
17
  jobType: string;
17
18
  userId: string;
18
19
  conversationId: string;
20
+ /** Opt-in successor contract: a session ID supplied by the host's authenticated context. */
21
+ sessionId?: string;
22
+ turnBinding?: boolean;
19
23
  timing?: "before" | "during" | "after";
20
24
  consentMode?: "wavebird_consent" | "own_cmp";
21
25
  rendererUrl?: string;
package/dist/browser.js CHANGED
@@ -21,9 +21,13 @@ function loadRenderer(url) {
21
21
  export function createPublisherAds(options) {
22
22
  const target = options.target;
23
23
  if (!target || !options.userId || !options.conversationId) throw new Error("Application container and session are required");
24
+ const turnBinding = options.turnBinding === true;
25
+ const validId = value => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
26
+ if (turnBinding && !validId(options.sessionId)) throw new Error("publisher_trusted_session_required");
24
27
  const timing = options.timing || "during", consentMode = options.consentMode || "wavebird_consent";
25
28
  if (!["before", "during", "after"].includes(timing) || !["wavebird_consent", "own_cmp"].includes(consentMode)) throw new Error("Invalid publisher configuration");
26
- let binding = { userId: options.userId, conversationId: options.conversationId, sessionId: crypto.randomUUID() };
29
+ let binding = { userId: options.userId, conversationId: options.conversationId,
30
+ sessionId: turnBinding ? options.sessionId : crypto.randomUUID() };
27
31
  let sponsor = null, disposed = false, context = 0, active = null;
28
32
  const slot = document.createElement("section");
29
33
  slot.setAttribute("aria-label", "Sponsored placement");
@@ -48,6 +52,8 @@ export function createPublisherAds(options) {
48
52
  job.state = "started"; notify("onStarted", job.id);
49
53
  } else if (type === "progress") {
50
54
  if (job.state === "started") { job.progressed = true; notify("onDelta", job.id); }
55
+ } else if (type === "no_result") {
56
+ if (job.state === "started") job.state = "no_result";
51
57
  } else if (["done", "error", "abort"].includes(type)) {
52
58
  if (job.state !== "started") return;
53
59
  job.state = type;
@@ -58,10 +64,14 @@ export function createPublisherAds(options) {
58
64
  function setConversation(next) {
59
65
  if (disposed) return;
60
66
  if (!next.userId || !next.conversationId) throw new Error("Application session is required");
61
- if (next.userId === binding.userId && next.conversationId === binding.conversationId) return;
67
+ if (turnBinding && !validId(next.sessionId)) throw new Error("publisher_trusted_session_required");
68
+ if (next.userId === binding.userId && next.conversationId === binding.conversationId &&
69
+ (!turnBinding || next.sessionId === binding.sessionId)) return;
70
+ if (turnBinding && next.sessionId === binding.sessionId) throw new Error("publisher_session_reuse");
62
71
  if (active) event(active, "abort");
63
72
  context++; active = null;
64
- binding = { userId: next.userId, conversationId: next.conversationId, sessionId: crypto.randomUUID() };
73
+ binding = { userId: next.userId, conversationId: next.conversationId,
74
+ sessionId: turnBinding ? next.sessionId : crypto.randomUUID() };
65
75
  sponsor?.setConversation(binding); prepare();
66
76
  }
67
77
  function dispose() {
@@ -77,6 +87,8 @@ export function createPublisherAds(options) {
77
87
  if (disposed) return;
78
88
  sponsor = window.wavebird.createChatSponsor({ target: slot, ...binding,
79
89
  clientId: options.clientId, surfaceId: options.surfaceId, timing, consentMode,
90
+ cleanupAfterClip: true,
91
+ ...(turnBinding ? { turnBinding: true } : {}),
80
92
  ...(options.getFormat === undefined ? {} : { getFormat: options.getFormat }),
81
93
  ...(options.placementEndpoint === undefined ? {} : { placementEndpoint: options.placementEndpoint }),
82
94
  ...(options.consentEndpoint === undefined ? {} : { consentEndpoint: options.consentEndpoint }) });
@@ -144,7 +156,8 @@ export function createPublisherAds(options) {
144
156
  return;
145
157
  }
146
158
  if (["done", "error", "abort"].includes(record.type)) {
147
- ended = true; event(job, record.type);
159
+ ended = true;
160
+ if (record.type !== "done" || timing !== "after") event(job, record.type);
148
161
  if (record.type === "done" || !metadata.status.toString().startsWith("2")) controller.close();
149
162
  else controller.error(new Error("generation_" + record.type));
150
163
  return;
@@ -158,12 +171,16 @@ export function createPublisherAds(options) {
158
171
  if ([204, 205, 304].includes(metadata.status)) {
159
172
  const record = await nextRecord();
160
173
  if (!["done", "error", "abort"].includes(record.type)) throw new Error("generation_transport_invalid");
161
- ended = true; event(job, record.type);
174
+ ended = true;
175
+ if (record.type !== "done" || timing !== "after") event(job, record.type);
162
176
  }
163
- const primary = new Response([204, 205, 304].includes(metadata.status) ? null : output, {
177
+ const noBody = [204, 205, 304].includes(metadata.status);
178
+ const primary = new Response(noBody ? null : output, {
164
179
  status: metadata.status, statusText: metadata.status_text || "", headers: metadata.headers
165
180
  });
166
- return await consume(primary, { onDelta: () => event(job, "progress") });
181
+ const result = await consume(primary, { onDelta: () => event(job, "progress") });
182
+ if (timing === "after" && ended) event(job, noBody ? "no_result" : "done");
183
+ return result;
167
184
  } catch (error) {
168
185
  event(job, signal?.aborted ? "abort" : "error");
169
186
  throw error;
@@ -0,0 +1,81 @@
1
+ /** PostgreSQL adapter for an existing publisher database; no database client is bundled. */
2
+ const TABLE = "wavebird_publisher_turns";
3
+ const CREATE_TABLE = `CREATE TABLE IF NOT EXISTS ${TABLE} (
4
+ scope_id text NOT NULL,
5
+ turn_key text NOT NULL,
6
+ state_json jsonb NOT NULL,
7
+ pending boolean NOT NULL,
8
+ created_at_ms bigint NOT NULL,
9
+ updated_at_ms bigint NOT NULL,
10
+ PRIMARY KEY (scope_id, turn_key)
11
+ )`;
12
+ const CREATE_INDEX = `CREATE INDEX IF NOT EXISTS wavebird_publisher_turns_pending
13
+ ON ${TABLE} (scope_id, turn_key) WHERE pending = true`;
14
+
15
+ export function createPostgresTurnStorage(pool) {
16
+ if (!pool || typeof pool.connect !== "function") throw new Error("publisher_postgres_pool_required");
17
+ async function withClient(run) {
18
+ const client = await pool.connect();
19
+ try { return await run(client); } finally { client.release(); }
20
+ }
21
+ async function install() {
22
+ await withClient(async client => {
23
+ await client.query(CREATE_TABLE);
24
+ await client.query(CREATE_INDEX);
25
+ });
26
+ }
27
+ async function atomic(scope, key, transition) {
28
+ return withClient(async client => {
29
+ await client.query("BEGIN");
30
+ try {
31
+ const now = Date.now();
32
+ await client.query(`INSERT INTO ${TABLE}(scope_id,turn_key,state_json,pending,created_at_ms,updated_at_ms)
33
+ VALUES ($1,$2,'null'::jsonb,false,$3,$3) ON CONFLICT DO NOTHING`, [scope, key, now]);
34
+ const row = await client.query(`SELECT state_json FROM ${TABLE} WHERE scope_id=$1 AND turn_key=$2 FOR UPDATE`, [scope, key]);
35
+ if (row.rows.length !== 1) throw new Error("publisher_turn_storage_conflict");
36
+ const value = row.rows[0].state_json;
37
+ const previous = typeof value === "string" ? JSON.parse(value) : value;
38
+ const { state, result } = transition(previous);
39
+ if (state === null) {
40
+ await client.query(`DELETE FROM ${TABLE} WHERE scope_id=$1 AND turn_key=$2`, [scope, key]);
41
+ } else {
42
+ const pending = Boolean(state.jobId && state.phase !== "idle" &&
43
+ (!state.startedReported || (["finished", "failed"].includes(state.phase) && !state.terminalReported)));
44
+ await client.query(`UPDATE ${TABLE} SET state_json=$3::jsonb,pending=$4,updated_at_ms=$5
45
+ WHERE scope_id=$1 AND turn_key=$2`, [scope, key, JSON.stringify(state), pending, now]);
46
+ }
47
+ await client.query("COMMIT");
48
+ return result;
49
+ } catch (error) {
50
+ try { await client.query("ROLLBACK"); } catch {}
51
+ throw error;
52
+ }
53
+ });
54
+ }
55
+ async function scan(scope, cursor, limit) {
56
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new Error("publisher_turn_scan_invalid");
57
+ return withClient(async client => {
58
+ const result = await client.query(`SELECT turn_key FROM ${TABLE}
59
+ WHERE scope_id=$1 AND pending=true AND turn_key>$2 ORDER BY turn_key LIMIT $3`, [scope, cursor, limit]);
60
+ return result.rows.map(row => row.turn_key);
61
+ });
62
+ }
63
+ async function prune(scope) {
64
+ return withClient(async client => {
65
+ await client.query("BEGIN");
66
+ try {
67
+ const now = Date.now();
68
+ await client.query(`DELETE FROM ${TABLE} WHERE scope_id=$1 AND pending=false AND updated_at_ms<$2`,
69
+ [scope, now - 86_400_000]);
70
+ const expired = await client.query(`DELETE FROM ${TABLE} WHERE scope_id=$1 AND pending=true AND created_at_ms<$2 RETURNING turn_key`,
71
+ [scope, now - 7 * 86_400_000]);
72
+ await client.query("COMMIT");
73
+ return expired.rowCount;
74
+ } catch (error) {
75
+ try { await client.query("ROLLBACK"); } catch {}
76
+ throw error;
77
+ }
78
+ });
79
+ }
80
+ return { install, atomic, scan, prune };
81
+ }
package/dist/server.d.ts CHANGED
@@ -1,9 +1,45 @@
1
+ export type PublisherTurnIdentity = { projectId: string; surfaceId: string; sessionId: string; turnId: string };
2
+ /** The successor journal must make claims and phase transitions durable across workers. */
3
+ export type PublisherTurnJournal = {
4
+ claimPlacement(turn: PublisherTurnIdentity): Promise<boolean>;
5
+ releaseRejectedPlacement(turn: PublisherTurnIdentity): Promise<void>;
6
+ bind(turn: PublisherTurnIdentity, jobId: string): Promise<void>;
7
+ claimGeneration(turn: PublisherTurnIdentity): Promise<boolean>;
8
+ phase(turn: PublisherTurnIdentity, event: "started" | "finished" | "failed"): Promise<void>;
9
+ reconcilePending?(projectId: string, surfaceId: string): Promise<void>;
10
+ };
11
+ /** Implement this over an existing serializable, persistent application store. */
12
+ export type PublisherAtomicTurnStorage = {
13
+ atomic<T>(scope: string, key: string,
14
+ transition: (previous: unknown | null) => { state: unknown | null; result: T }): Promise<T>;
15
+ /** Return increasing hashed keys after cursor, at most limit. */
16
+ scan(scope: string, cursor: string, limit: number): Promise<string[]>;
17
+ prune?(scope: string): Promise<number>;
18
+ };
19
+ /** Use an existing pg-compatible pool; run install once in the publisher's normal migration step. */
20
+ export function createPostgresTurnStorage(pool: {
21
+ connect(): Promise<{ query(sql: string, params?: unknown[]): Promise<{ rows: Array<Record<string, unknown>>; rowCount?: number }>;
22
+ release(): void }>;
23
+ }): PublisherAtomicTurnStorage & { install(): Promise<void>; prune(scope: string): Promise<number> };
24
+ export function createPublisherTurnJournal(options: {
25
+ storage: PublisherAtomicTurnStorage;
26
+ reportGeneration: (jobId: string, event: "started" | "finished" | "failed") => Promise<void>;
27
+ /** Stable server-only secret for pseudonymous durable turn keys, at least 32 UTF-8 bytes. */
28
+ keySecret: string;
29
+ onDiagnostic?: (code: string) => void;
30
+ }): PublisherTurnJournal;
31
+ /** Derive a stable, domain-separated journal key from the existing server-only preference secret. */
32
+ export function derivePublisherTurnKeySecret(preferenceSecret: string): string;
1
33
  export type PublisherServerOptions = {
2
34
  apiBaseUrl?: string;
3
35
  clientId: string;
4
36
  secretKey: string;
5
37
  preferenceSecret: string;
6
38
  authorize: (request: Request) => boolean | Promise<boolean>;
39
+ /** Opt-in successor: derive the exact browser session from the host's authenticated request. */
40
+ sessionIdForRequest?: (request: Request) => string | null | Promise<string | null>;
41
+ turnJournal?: PublisherTurnJournal;
42
+ onDiagnostic?: (code: string) => void;
7
43
  publicOrigins: string[];
8
44
  surfaceId: string;
9
45
  placementKey: string;
@@ -15,8 +51,9 @@ export type PublisherServerOptions = {
15
51
  publisher: { app_name: string; app_domain: string; categories: string[] };
16
52
  allowedFormats: Array<"banner" | "native" | "clip">;
17
53
  positionHint: "above" | "below" | "sidebar" | "between";
18
- maxWidth: number;
19
- maxHeight: number;
54
+ /** Optional advanced caps; saved allowedSizes determine the normal envelope. */
55
+ maxWidth?: number;
56
+ maxHeight?: number;
20
57
  allowedSizes: Array<{ width: number; height: number; formats?: Array<"banner" | "native" | "clip"> }>;
21
58
  responsiveSizeSelection?: boolean;
22
59
  bidfloor?: number;
@@ -29,6 +66,7 @@ export type GenerationLifecycleEvent = {
29
66
  generationId: string;
30
67
  };
31
68
  export type PublisherServer = {
69
+ sessionGET(request: Request): Promise<Response>;
32
70
  placementPOST(request: Request): Promise<Response>;
33
71
  consentGET(request: Request): Promise<Response>;
34
72
  consentPOST(request: Request): Promise<Response>;