@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/README.md +68 -24
- package/bin/wavebird-next.mjs +2 -0
- package/build.mjs +10 -0
- package/dist/browser.d.ts +16 -6
- package/dist/browser.js +71 -17
- package/dist/generation.js +2 -0
- package/dist/postgres-turn-storage.js +81 -0
- package/dist/server.d.ts +48 -7
- package/dist/server.js +150 -26
- package/dist/turn-journal.js +157 -0
- package/examples/next-js/app/api/sponsor-session/route.js +5 -0
- package/examples/next-js/app/page.jsx +15 -4
- package/examples/next-js/lib/auth.js +31 -10
- package/examples/next-js/lib/publisher.js +12 -5
- package/examples/next-js/lib/turn-store.js +9 -0
- package/examples/next-ts/app/api/sponsor-session/route.ts +5 -0
- package/examples/next-ts/app/page.tsx +16 -5
- package/examples/next-ts/lib/auth.ts +31 -10
- package/examples/next-ts/lib/publisher.ts +12 -5
- package/examples/next-ts/lib/turn-store.ts +10 -0
- package/package.json +3 -3
- package/src/browser.d.ts +37 -0
- package/src/browser.js +248 -0
- package/src/generation.js +98 -0
- package/src/postgres-turn-storage.js +81 -0
- package/src/server.d.ts +79 -0
- package/src/server.js +473 -0
- package/src/turn-journal.js +157 -0
- package/templates/NEXT-SETUP.md +7 -6
- package/templates/WavebirdPlacement.jsx +14 -4
- package/templates/WavebirdPlacement.tsx +16 -5
|
@@ -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
|
+
}
|
package/templates/NEXT-SETUP.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# Connect wavebird to this Next.js app
|
|
2
2
|
|
|
3
|
-
The generator installed `lib/publisher`, `lib/auth`, and `lib/model`; `app/api/generate`, `app/api/sponsor-slot`, and `app/api/sponsor-
|
|
3
|
+
The generator installed `lib/publisher`, `lib/auth`, `lib/turn-store` and `lib/model`; `app/api/generate`, `app/api/sponsor-slot`, `app/api/sponsor-consent` and `app/api/sponsor-session`; and `components/WavebirdPlacement` (under `src/` when `src/app` was selected). No existing page was replaced.
|
|
4
4
|
|
|
5
|
-
1. Set server-only `WAVEBIRD_CLIENT_ID`, `WAVEBIRD_SECRET_KEY`, `WAVEBIRD_PREFERENCE_SECRET
|
|
6
|
-
2. Replace `lib/publisher`'s example surface, placement,
|
|
7
|
-
3. Connect `lib/auth` to the app's real session authorization. Its supplied path check fails closed when `PUBLISHER_AUTH_CHECK_PATH` is absent. The path must be a publisher-owned same-origin route that returns 204 only for an authorized user
|
|
8
|
-
4.
|
|
9
|
-
5.
|
|
5
|
+
1. Set server-only `WAVEBIRD_CLIENT_ID`, `WAVEBIRD_SECRET_KEY`, `WAVEBIRD_PREFERENCE_SECRET` (a stable random value of at least 32 bytes), and `WAVEBIRD_PUBLIC_ORIGINS`. The last value is a comma-separated list of the exact scheme, host, and port where your app is opened. Keep the Dashboard project domain at your real HTTPS host. For local Server Test-key testing, include the exact loopback origin in `WAVEBIRD_PUBLIC_ORIGINS` without changing that project domain; Browser Test Keys instead use their separate Allowed origins field in API Keys. `localhost` and `127.0.0.1` are distinct. Do not use wildcards. The package rejects an absent or malformed list. Never expose server secrets through `NEXT_PUBLIC_` or client props. `clientId` is the public project ID.
|
|
6
|
+
2. Replace `lib/publisher`'s example surface, placement, allowed sizes, formats, locale, consent mode, and publisher identity with the **saved Dashboard values**. The responsive example lists 728×90 and 320×50 Banner sizes only to show the size-selection shape. The saved sizes define the normal envelope; optional `maxWidth` and `maxHeight` are advanced caps that only narrow it. Keep `responsiveSizeSelection: true` only when the selected project and placement approve every configured size. Remove any size absent from the saved project before Test traffic. The API enforces its own authority; example values do not create a valid placement.
|
|
7
|
+
3. Connect `lib/auth` to the app's real session authorization. Its supplied path check fails closed when `PUBLISHER_AUTH_CHECK_PATH` is absent. The path must be a publisher-owned same-origin route that returns 204 only for an authorized user and supplies an `x-publisher-session-id` header containing a stable non-credential correlation ID for that authenticated session. Never return a cookie, bearer token or raw credential as that ID. The route must not be any wavebird route. The example sends the session cookie to that check only when the request origin exactly matches `WAVEBIRD_PUBLIC_ORIGINS`; a missing or different origin fails closed. The package checks that the browser's session ID matches this server-validated value before placement or generation.
|
|
8
|
+
4. Replace `lib/turn-store`'s fail-closed stub with `createPostgresTurnStorage(yourExistingPgPool)` or another persistent store implementing the package's atomic interface. For PostgreSQL, run the adapter's `install()` once in your normal database migration step; use a pool shared by the publisher's workers. Keep `WAVEBIRD_PREFERENCE_SECRET` stable across workers and restarts; the package derives a separate journal key from it and rejects a changed key for an active project. Coordinate secret rotation after active turns are drained; never clear the journal guard to restore ad service while turns remain. The package owns turn claims, ordered reports, retry and bounded reconciliation. An unavailable store suppresses ad placement while the authenticated model route remains available.
|
|
9
|
+
5. Connect `lib/model` to the existing generation stream. The installed function throws until replaced. Keep the existing generation response and pass the supplied abort signal to it. Do not put model calls in the browser solely for this integration.
|
|
10
|
+
6. In an existing client page, mount `<WavebirdPlacement clientId={publicProjectId} userId={authenticatedUserId} conversationId={activeConversationId} onReady={setAds} />` beside the real generation result, outside the input and transcript. The component retrieves the non-credential session ID through the authenticated same-origin route. When the user starts their existing generation, call `ads.connectGeneration({ signal, request: headers => fetch('/api/generate', { method: 'POST', headers, signal }), consume: async (response, lifecycle) => { /* consume your existing stream and call lifecycle.onDelta() for each delta */ } })`. Abort that same signal on Stop, logout or unmount. Use a stable `onReady` callback. This example uses wavebird's consent dialog; if the saved project uses the publisher's own CMP, connect its real grant/deny/withdrawal/expiry/GPC lifecycle instead of changing only the label.
|
|
10
11
|
|
|
11
12
|
These routes are real required Next.js files, not hidden package behavior. The package has no knowledge of your login session, CMP, or model stream. Start with a Test project and check that No-Fill, abort and late responses never block the primary result; all Test facts must remain nonpayable.
|
|
@@ -7,7 +7,14 @@ export function WavebirdPlacement({ clientId, userId, conversationId, onReady })
|
|
|
7
7
|
const target = useRef(null);
|
|
8
8
|
useEffect(() => {
|
|
9
9
|
if (!target.current || !clientId || !userId || !conversationId) return;
|
|
10
|
-
const
|
|
10
|
+
const controller = new AbortController();
|
|
11
|
+
let disposed = false;
|
|
12
|
+
let ads = null;
|
|
13
|
+
void fetch("/api/sponsor-session", { cache: "no-store", signal: controller.signal }).then(async response => {
|
|
14
|
+
if (!response.ok) return;
|
|
15
|
+
const sessionId = (await response.json()).session_id;
|
|
16
|
+
if (disposed || !/^[A-Za-z0-9_-]{1,128}$/.test(sessionId)) return;
|
|
17
|
+
ads = createPublisherAds({
|
|
11
18
|
target: target.current,
|
|
12
19
|
clientId,
|
|
13
20
|
surfaceId: "chat_assistant",
|
|
@@ -16,13 +23,16 @@ export function WavebirdPlacement({ clientId, userId, conversationId, onReady })
|
|
|
16
23
|
consentMode: "wavebird_consent",
|
|
17
24
|
userId,
|
|
18
25
|
conversationId,
|
|
26
|
+
sessionId,
|
|
27
|
+
turnBinding: true,
|
|
19
28
|
rendererUrl: "https://api.wavebird.ai/v1/render.js",
|
|
20
29
|
placementEndpoint: "/api/sponsor-slot",
|
|
21
30
|
consentEndpoint: "/api/sponsor-consent",
|
|
22
31
|
privacySettingsTarget: "#wavebird-privacy-settings"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
32
|
+
});
|
|
33
|
+
onReady?.(ads);
|
|
34
|
+
}).catch(() => {});
|
|
35
|
+
return () => { disposed = true; controller.abort(); onReady?.(null); ads?.dispose(); };
|
|
26
36
|
}, [clientId, userId, conversationId, onReady]);
|
|
27
37
|
return <>
|
|
28
38
|
<button id="wavebird-privacy-settings" type="button">Sponsored choices</button>
|
|
@@ -12,8 +12,16 @@ export function WavebirdPlacement({ clientId, userId, conversationId, onReady }:
|
|
|
12
12
|
const target = useRef<HTMLElement | null>(null);
|
|
13
13
|
useEffect(() => {
|
|
14
14
|
if (!target.current || !clientId || !userId || !conversationId) return;
|
|
15
|
-
const
|
|
16
|
-
|
|
15
|
+
const container = target.current;
|
|
16
|
+
const controller = new AbortController();
|
|
17
|
+
let disposed = false;
|
|
18
|
+
let ads: PublisherAds | null = null;
|
|
19
|
+
void fetch("/api/sponsor-session", { cache: "no-store", signal: controller.signal }).then(async response => {
|
|
20
|
+
if (!response.ok) return;
|
|
21
|
+
const sessionId = (await response.json()).session_id;
|
|
22
|
+
if (disposed || !/^[A-Za-z0-9_-]{1,128}$/.test(sessionId)) return;
|
|
23
|
+
ads = createPublisherAds({
|
|
24
|
+
target: container,
|
|
17
25
|
clientId,
|
|
18
26
|
surfaceId: "chat_assistant",
|
|
19
27
|
jobType: "chat",
|
|
@@ -21,13 +29,16 @@ export function WavebirdPlacement({ clientId, userId, conversationId, onReady }:
|
|
|
21
29
|
consentMode: "wavebird_consent",
|
|
22
30
|
userId,
|
|
23
31
|
conversationId,
|
|
32
|
+
sessionId,
|
|
33
|
+
turnBinding: true,
|
|
24
34
|
rendererUrl: "https://api.wavebird.ai/v1/render.js",
|
|
25
35
|
placementEndpoint: "/api/sponsor-slot",
|
|
26
36
|
consentEndpoint: "/api/sponsor-consent",
|
|
27
37
|
privacySettingsTarget: "#wavebird-privacy-settings"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
38
|
+
});
|
|
39
|
+
onReady?.(ads);
|
|
40
|
+
}).catch(() => {});
|
|
41
|
+
return () => { disposed = true; controller.abort(); onReady?.(null); ads?.dispose(); };
|
|
31
42
|
}, [clientId, userId, conversationId, onReady]);
|
|
32
43
|
return <>
|
|
33
44
|
<button id="wavebird-privacy-settings" type="button">Sponsored choices</button>
|