@wavebirdai/api 0.1.0-beta.13 → 0.1.0-beta.15
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 +3 -3
- package/dist/server.js +24 -5
- package/docs/web/generation.md +1 -1
- package/docs/web/nextjs.md +8 -2
- package/docs/web/reference.md +3 -1
- package/examples/next-js/package.json +1 -1
- package/examples/next-ts/package.json +1 -1
- package/package.json +1 -1
- package/src/server.js +24 -5
- package/templates/NEXT-SETUP.md +3 -1
package/README.md
CHANGED
|
@@ -18,14 +18,14 @@ Native iOS and Android packages are not currently available. See [integration pa
|
|
|
18
18
|
|
|
19
19
|
Use Node.js 20 or later on your server.
|
|
20
20
|
|
|
21
|
-
1. **Open your project in Dashboard.** Choose your language and package version.
|
|
21
|
+
1. **Open your project in Dashboard.** Choose your language and package version. In App settings, verify the domain using a DNS TXT record or HTTPS file, then save your HTTPS app URL and approved origins. `www.example.com` and `example.com` are separate hosts.
|
|
22
22
|
2. **Install the package.** Run this in your app's terminal, in the folder containing `package.json`:
|
|
23
23
|
|
|
24
24
|
```sh
|
|
25
|
-
npm install @wavebirdai/api@0.1.0-beta.
|
|
25
|
+
npm install @wavebirdai/api@0.1.0-beta.15
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
3. **Save the Server ENV.**
|
|
28
|
+
3. **Save the Server ENV from Overview, Integration step 2.** The key-only download is not the full configuration. Keep `WAVEBIRD_SECRET_KEY` and the stable, project-specific `WAVEBIRD_PREFERENCE_SECRET` out of browser code and Git. Include the saved `WAVEBIRD_PUBLIC_ORIGINS`.
|
|
29
29
|
4. **Connect the backend and frontend code.** Follow Dashboard's generated files to connect your app session, durable turn storage, model stream and ad slot. Keep the saved formats, sizes and timing.
|
|
30
30
|
5. **Run your first Test ad.** Confirm that the ad appears and the request is recorded in Test Logs. Check that generation still works with declined consent, no available ad and Stop.
|
|
31
31
|
|
package/dist/server.js
CHANGED
|
@@ -80,6 +80,7 @@ function normalizeOptions(options) {
|
|
|
80
80
|
{
|
|
81
81
|
if (!Array.isArray(options.publicOrigins) || options.publicOrigins.length < 1 || options.publicOrigins.length > 8) throw new Error("publisher_public_origins_invalid");
|
|
82
82
|
for (const origin of options.publicOrigins) {
|
|
83
|
+
if (typeof origin !== "string" || origin.includes("*")) throw new Error("publisher_public_origins_invalid");
|
|
83
84
|
let url;
|
|
84
85
|
try { url = new URL(origin); } catch { throw new Error("publisher_public_origins_invalid"); }
|
|
85
86
|
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
@@ -387,21 +388,39 @@ export function createPublisherServer(inputOptions) {
|
|
|
387
388
|
const headers = { "Cache-Control": "no-store", Vary: "Cookie, Sec-GPC" };
|
|
388
389
|
if (!validOrigin(request, options, false)) return response("invalid_origin", 403, headers);
|
|
389
390
|
if (gpc(request)) return Response.json({ preference: null }, { headers });
|
|
391
|
+
const revision = {};
|
|
392
|
+
if (options.sessionIdForRequest) {
|
|
393
|
+
let sessionId;
|
|
394
|
+
try { sessionId = await journalCall(() => options.sessionIdForRequest(request)); }
|
|
395
|
+
catch { return response("publisher_session_unavailable", 503, headers); }
|
|
396
|
+
if (!ID.test(sessionId || "")) return response("publisher_session_unavailable", 401, headers);
|
|
397
|
+
try {
|
|
398
|
+
const query = new URLSearchParams({ client_id: options.clientId, session_id: sessionId });
|
|
399
|
+
const result = await consentFetch(`${options.base}/consent?${query}`, {
|
|
400
|
+
method: "GET", headers: { Authorization: `Bearer ${options.secretKey}` }, cache: "no-store"
|
|
401
|
+
});
|
|
402
|
+
const state = await result.json();
|
|
403
|
+
if (!result.ok || !Number.isSafeInteger(state.current_revision) || state.current_revision < 0 ||
|
|
404
|
+
state.current_revision >= Number.MAX_SAFE_INTEGER) throw new Error("consent_revision_unavailable");
|
|
405
|
+
revision.current_revision = state.current_revision;
|
|
406
|
+
} catch { return response("consent_revision_unavailable", 503, headers); }
|
|
407
|
+
}
|
|
408
|
+
const preferenceResponse = value => Response.json({ ...value, ...revision }, { headers });
|
|
390
409
|
try {
|
|
391
410
|
const value = (request.headers.get("cookie") || "").split(";").map(value => value.trim())
|
|
392
411
|
.find(value => value.startsWith(cookieName + "="))?.slice(cookieName.length + 1) || "";
|
|
393
412
|
if (value.length > 2048) throw new Error("preference_invalid");
|
|
394
413
|
const [payload, signature, extra] = value.split(".");
|
|
395
|
-
if (!payload || !signature || extra || !/^[A-Za-z0-9_-]+$/.test(payload) || !/^[A-Za-z0-9_-]+$/.test(signature)) return
|
|
396
|
-
if (!await crypto.subtle.verify("HMAC", await preferenceKey(), Buffer.from(signature, "base64url"), new TextEncoder().encode(payload))) return
|
|
414
|
+
if (!payload || !signature || extra || !/^[A-Za-z0-9_-]+$/.test(payload) || !/^[A-Za-z0-9_-]+$/.test(signature)) return preferenceResponse({ preference: null });
|
|
415
|
+
if (!await crypto.subtle.verify("HMAC", await preferenceKey(), Buffer.from(signature, "base64url"), new TextEncoder().encode(payload))) return preferenceResponse({ preference: null });
|
|
397
416
|
const record = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
398
417
|
const now = Date.now();
|
|
399
418
|
if (Object.entries(scope(request)).some(([key, value]) => record[key] !== value) ||
|
|
400
419
|
!["granted", "denied", "withdrawn"].includes(record.state) || !Number.isSafeInteger(record.issued_at) ||
|
|
401
420
|
!Number.isSafeInteger(record.expires_at) || record.issued_at > now || record.expires_at <= now ||
|
|
402
|
-
record.expires_at - record.issued_at !== PREFERENCE_TTL * 1000) return
|
|
403
|
-
return
|
|
404
|
-
} catch { return
|
|
421
|
+
record.expires_at - record.issued_at !== PREFERENCE_TTL * 1000) return preferenceResponse({ preference: null });
|
|
422
|
+
return preferenceResponse({ preference: { state: record.state, expires_at: record.expires_at } });
|
|
423
|
+
} catch { return preferenceResponse({ preference: null, error: "preference_unavailable" }); }
|
|
405
424
|
}
|
|
406
425
|
async function consentPOST(request) {
|
|
407
426
|
if (!await authorized(request)) return response("authentication_required", 401);
|
package/docs/web/generation.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Connect generation to the displayed result
|
|
2
2
|
|
|
3
|
-
These are connection helpers, not complete apps. Use the same installed `@wavebirdai/api@0.1.0-beta.
|
|
3
|
+
These are connection helpers, not complete apps. Use the same installed `@wavebirdai/api@0.1.0-beta.15` as your server routes.
|
|
4
4
|
|
|
5
5
|
- `ads` is the instance for this surface, returned by `createPublisherAds` or the generated component's `onReady` callback.
|
|
6
6
|
- `sendExistingRequest` sends your existing authenticated model request. Merge the provided headers into its headers and preserve its body and abort signal. Call the route wrapped by `withGeneration`.
|
package/docs/web/nextjs.md
CHANGED
|
@@ -7,12 +7,18 @@ Use this path instead of copying Dashboard's route files. It supports an existin
|
|
|
7
7
|
From the folder containing `package.json`:
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
npm install @wavebirdai/api@0.1.0-beta.
|
|
10
|
+
npm install @wavebirdai/api@0.1.0-beta.15 server-only
|
|
11
11
|
./node_modules/.bin/wavebird-next init --lang ts --root . --app-dir src/app
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
For JavaScript, use `--lang js`. For a root-level `app/` directory, use `--app-dir app`. The generator does not overwrite existing files. Read the generated `WAVEBIRD-SETUP.md` for their locations.
|
|
15
15
|
|
|
16
|
+
`server-only` marks backend modules and is required by these examples. If you use the PostgreSQL adapter, install `pg` in your app too.
|
|
17
|
+
|
|
18
|
+
### If a file already exists
|
|
19
|
+
|
|
20
|
+
Keep your existing file. Create a temporary sibling folder with an empty `app/` directory, then run the installed generator with `--root ../wavebird-staging --app-dir app` (replace the folder name as needed). Review the generated files there and merge the connections into your app. Keep your existing authentication, model and generation route. If you move a helper into `lib/wavebird/`, update its relative imports and the routes that use it. Do not run the template model or authorization stub unchanged. No existing app files need to be deleted or overwritten.
|
|
21
|
+
|
|
16
22
|
## Connect your app
|
|
17
23
|
|
|
18
24
|
| Generated area | Your connection |
|
|
@@ -33,4 +39,4 @@ Use wavebird's consent dialog or your real CMP according to the project's saved
|
|
|
33
39
|
|
|
34
40
|
Check visible ads and Test Logs, then No-Fill, denied consent, Stop and a conversation change. An ad failure must not prevent the model result. The complete framework examples are in `examples/next-js` and `examples/next-ts` inside the installed package. Their demonstration model must be replaced with your own.
|
|
35
41
|
|
|
36
|
-
[Return to setup](../../README.md#javascript-
|
|
42
|
+
[Return to setup](../../README.md#set-up-javascript-or-typescript) | [Configuration reference](reference.md)
|
package/docs/web/reference.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Web integration reference
|
|
2
2
|
|
|
3
|
-
Applies to `@wavebirdai/api@0.1.0-beta.
|
|
3
|
+
Applies to `@wavebirdai/api@0.1.0-beta.15`. [Start with the integration steps](../../README.md#set-up-javascript-or-typescript).
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
`@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.
|
|
@@ -22,6 +22,8 @@ For `after`, resolve the generation consumer only after your app presents the re
|
|
|
22
22
|
|
|
23
23
|
## Troubleshooting
|
|
24
24
|
|
|
25
|
+
With the trusted-session integration, consent restoration reads the current server revision before synchronizing the saved choice. Reloading does not reset consent revisions. An unavailable revision keeps ads disabled until consent can be synchronized; it does not block the model response.
|
|
26
|
+
|
|
25
27
|
- **401:** check the app session and server authorization before changing the wavebird key.
|
|
26
28
|
- **Origin rejected:** compare scheme, hostname and port with the saved project and server allowlist. `www.example.com` and `example.com` are separate origins.
|
|
27
29
|
- **No-Fill or `no_fit`:** keep the model result usable. Check saved formats and the measured slot size. Do not stretch a creative to force it to fit.
|
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": { "dev": "next dev", "build": "next build --webpack" },
|
|
6
|
-
"dependencies": { "next": "16.3.8", "react": "19.2.4", "react-dom": "19.2.4" }
|
|
6
|
+
"dependencies": { "next": "16.3.8", "react": "19.2.4", "react-dom": "19.2.4", "server-only": "0.0.1" }
|
|
7
7
|
}
|
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": { "dev": "next dev", "build": "next build --webpack", "typecheck": "tsc --noEmit" },
|
|
6
|
-
"dependencies": { "next": "16.3.8", "react": "19.2.4", "react-dom": "19.2.4" },
|
|
6
|
+
"dependencies": { "next": "16.3.8", "react": "19.2.4", "react-dom": "19.2.4", "server-only": "0.0.1" },
|
|
7
7
|
"devDependencies": { "@types/node": "^25", "@types/react": "^19", "@types/react-dom": "^19", "typescript": "^5" }
|
|
8
8
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wavebirdai/api",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.15",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "JavaScript and TypeScript integration for wavebird Banner, Native and Clip ads, with consent and generation lifecycle handling.",
|
package/src/server.js
CHANGED
|
@@ -80,6 +80,7 @@ function normalizeOptions(options) {
|
|
|
80
80
|
{
|
|
81
81
|
if (!Array.isArray(options.publicOrigins) || options.publicOrigins.length < 1 || options.publicOrigins.length > 8) throw new Error("publisher_public_origins_invalid");
|
|
82
82
|
for (const origin of options.publicOrigins) {
|
|
83
|
+
if (typeof origin !== "string" || origin.includes("*")) throw new Error("publisher_public_origins_invalid");
|
|
83
84
|
let url;
|
|
84
85
|
try { url = new URL(origin); } catch { throw new Error("publisher_public_origins_invalid"); }
|
|
85
86
|
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
@@ -387,21 +388,39 @@ export function createPublisherServer(inputOptions) {
|
|
|
387
388
|
const headers = { "Cache-Control": "no-store", Vary: "Cookie, Sec-GPC" };
|
|
388
389
|
if (!validOrigin(request, options, false)) return response("invalid_origin", 403, headers);
|
|
389
390
|
if (gpc(request)) return Response.json({ preference: null }, { headers });
|
|
391
|
+
const revision = {};
|
|
392
|
+
if (options.sessionIdForRequest) {
|
|
393
|
+
let sessionId;
|
|
394
|
+
try { sessionId = await journalCall(() => options.sessionIdForRequest(request)); }
|
|
395
|
+
catch { return response("publisher_session_unavailable", 503, headers); }
|
|
396
|
+
if (!ID.test(sessionId || "")) return response("publisher_session_unavailable", 401, headers);
|
|
397
|
+
try {
|
|
398
|
+
const query = new URLSearchParams({ client_id: options.clientId, session_id: sessionId });
|
|
399
|
+
const result = await consentFetch(`${options.base}/consent?${query}`, {
|
|
400
|
+
method: "GET", headers: { Authorization: `Bearer ${options.secretKey}` }, cache: "no-store"
|
|
401
|
+
});
|
|
402
|
+
const state = await result.json();
|
|
403
|
+
if (!result.ok || !Number.isSafeInteger(state.current_revision) || state.current_revision < 0 ||
|
|
404
|
+
state.current_revision >= Number.MAX_SAFE_INTEGER) throw new Error("consent_revision_unavailable");
|
|
405
|
+
revision.current_revision = state.current_revision;
|
|
406
|
+
} catch { return response("consent_revision_unavailable", 503, headers); }
|
|
407
|
+
}
|
|
408
|
+
const preferenceResponse = value => Response.json({ ...value, ...revision }, { headers });
|
|
390
409
|
try {
|
|
391
410
|
const value = (request.headers.get("cookie") || "").split(";").map(value => value.trim())
|
|
392
411
|
.find(value => value.startsWith(cookieName + "="))?.slice(cookieName.length + 1) || "";
|
|
393
412
|
if (value.length > 2048) throw new Error("preference_invalid");
|
|
394
413
|
const [payload, signature, extra] = value.split(".");
|
|
395
|
-
if (!payload || !signature || extra || !/^[A-Za-z0-9_-]+$/.test(payload) || !/^[A-Za-z0-9_-]+$/.test(signature)) return
|
|
396
|
-
if (!await crypto.subtle.verify("HMAC", await preferenceKey(), Buffer.from(signature, "base64url"), new TextEncoder().encode(payload))) return
|
|
414
|
+
if (!payload || !signature || extra || !/^[A-Za-z0-9_-]+$/.test(payload) || !/^[A-Za-z0-9_-]+$/.test(signature)) return preferenceResponse({ preference: null });
|
|
415
|
+
if (!await crypto.subtle.verify("HMAC", await preferenceKey(), Buffer.from(signature, "base64url"), new TextEncoder().encode(payload))) return preferenceResponse({ preference: null });
|
|
397
416
|
const record = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
398
417
|
const now = Date.now();
|
|
399
418
|
if (Object.entries(scope(request)).some(([key, value]) => record[key] !== value) ||
|
|
400
419
|
!["granted", "denied", "withdrawn"].includes(record.state) || !Number.isSafeInteger(record.issued_at) ||
|
|
401
420
|
!Number.isSafeInteger(record.expires_at) || record.issued_at > now || record.expires_at <= now ||
|
|
402
|
-
record.expires_at - record.issued_at !== PREFERENCE_TTL * 1000) return
|
|
403
|
-
return
|
|
404
|
-
} catch { return
|
|
421
|
+
record.expires_at - record.issued_at !== PREFERENCE_TTL * 1000) return preferenceResponse({ preference: null });
|
|
422
|
+
return preferenceResponse({ preference: { state: record.state, expires_at: record.expires_at } });
|
|
423
|
+
} catch { return preferenceResponse({ preference: null, error: "preference_unavailable" }); }
|
|
405
424
|
}
|
|
406
425
|
async function consentPOST(request) {
|
|
407
426
|
if (!await authorized(request)) return response("authentication_required", 401);
|
package/templates/NEXT-SETUP.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
The generator installed `lib/publisher`, `lib/auth`, `lib/turn-store` and `lib/model`; `app/api/generate`, `app/api/sponsor-slot`, `app/api/sponsor-consent` and `app/api/sponsor-session`; and `components/WavebirdPlacement` (under `src/` when `src/app` was selected). No existing page was replaced.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Install `server-only` in your app (`npm install server-only`). Install `pg` too if you use the PostgreSQL adapter. Use the full Server ENV from Dashboard's Integration step 2, including the saved preference secret and origins. A key-only ENV download is not the full configuration. Keep the preference secret unchanged across restarts.
|
|
6
|
+
|
|
7
|
+
1. Copy the complete Server ENV from the Dashboard: `WAVEBIRD_CLIENT_ID`, `WAVEBIRD_SECRET_KEY`, `WAVEBIRD_PREFERENCE_SECRET`, and `WAVEBIRD_PUBLIC_ORIGINS`. Keep the saved preference secret stable. The origins value is a comma-separated list of the exact scheme, host, and port where your app is opened. Keep the Dashboard project domain at your real HTTPS host. For local Server Test-key testing, include the exact loopback origin in `WAVEBIRD_PUBLIC_ORIGINS` without changing that project domain; Browser Test Keys instead use their separate Allowed origins field in API Keys. `localhost` and `127.0.0.1` are distinct. Do not use wildcards. The package rejects an absent or malformed list. Never expose server secrets through `NEXT_PUBLIC_` or client props. `clientId` is the public project ID.
|
|
6
8
|
2. Replace `lib/publisher`'s example surface, placement, allowed sizes, formats, locale, consent mode, and publisher identity with the **saved Dashboard values**. The responsive example lists 728×90 and 320×50 Banner sizes only to show the size-selection shape. The saved sizes define the normal envelope; optional `maxWidth` and `maxHeight` are advanced caps that only narrow it. Keep `responsiveSizeSelection: true` only when the selected project and placement approve every configured size. Remove any size absent from the saved project before Test traffic. The API enforces its own authority; example values do not create a valid placement.
|
|
7
9
|
3. Connect `lib/auth` to the app's real session authorization. Its supplied path check fails closed when `PUBLISHER_AUTH_CHECK_PATH` is absent. The path must be a publisher-owned same-origin route that returns 204 only for an authorized user and supplies an `x-publisher-session-id` header containing a stable non-credential correlation ID for that authenticated session. Never return a cookie, bearer token or raw credential as that ID. The route must not be any wavebird route. The example sends the session cookie to that check only when the request origin exactly matches `WAVEBIRD_PUBLIC_ORIGINS`; a missing or different origin fails closed. The package checks that the browser's session ID matches this server-validated value before placement or generation.
|
|
8
10
|
4. Replace `lib/turn-store`'s fail-closed stub with `createPostgresTurnStorage(yourExistingPgPool)` or another persistent store implementing the package's atomic interface. For PostgreSQL, run the adapter's `install()` once in your normal database migration step; use a pool shared by the publisher's workers. Keep `WAVEBIRD_PREFERENCE_SECRET` stable across workers and restarts; the package derives a separate journal key from it and rejects a changed key for an active project. Coordinate secret rotation after active turns are drained; never clear the journal guard to restore ad service while turns remain. The package owns turn claims, ordered reports, retry and bounded reconciliation. An unavailable store suppresses ad placement while the authenticated model route remains available.
|