@wavebirdai/api 0.0.0-stage → 0.1.0-beta.10
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/LICENSE +21 -0
- package/README.md +99 -2
- package/bin/wavebird-next.mjs +69 -0
- package/build.mjs +10 -0
- package/dist/browser.d.ts +31 -0
- package/dist/browser.js +220 -0
- package/dist/generation.js +98 -0
- package/dist/postgres-turn-storage.js +81 -0
- package/dist/server.d.ts +76 -0
- package/dist/server.js +471 -0
- package/dist/turn-journal.js +155 -0
- package/examples/next-js/.env.example +6 -0
- package/examples/next-js/app/api/generate/route.js +6 -0
- package/examples/next-js/app/api/sponsor-consent/route.js +4 -0
- package/examples/next-js/app/api/sponsor-session/route.js +5 -0
- package/examples/next-js/app/api/sponsor-slot/route.js +3 -0
- package/examples/next-js/app/layout.jsx +3 -0
- package/examples/next-js/app/page.jsx +70 -0
- package/examples/next-js/lib/auth.js +33 -0
- package/examples/next-js/lib/model.js +3 -0
- package/examples/next-js/lib/publisher.js +37 -0
- package/examples/next-js/lib/turn-store.js +9 -0
- package/examples/next-js/package.json +7 -0
- package/examples/next-ts/.env.example +6 -0
- package/examples/next-ts/app/api/generate/route.ts +6 -0
- package/examples/next-ts/app/api/sponsor-consent/route.ts +4 -0
- package/examples/next-ts/app/api/sponsor-session/route.ts +5 -0
- package/examples/next-ts/app/api/sponsor-slot/route.ts +3 -0
- package/examples/next-ts/app/layout.tsx +5 -0
- package/examples/next-ts/app/page.tsx +71 -0
- package/examples/next-ts/lib/auth.ts +33 -0
- package/examples/next-ts/lib/model.ts +3 -0
- package/examples/next-ts/lib/publisher.ts +37 -0
- package/examples/next-ts/lib/turn-store.ts +10 -0
- package/examples/next-ts/package.json +8 -0
- package/examples/next-ts/tsconfig.json +20 -0
- package/package.json +21 -4
- package/src/browser.d.ts +31 -0
- package/src/browser.js +220 -0
- package/src/generation.js +98 -0
- package/src/postgres-turn-storage.js +81 -0
- package/src/server.d.ts +76 -0
- package/src/server.js +471 -0
- package/src/turn-journal.js +155 -0
- package/templates/NEXT-SETUP.md +12 -0
- package/templates/WavebirdPlacement.jsx +41 -0
- package/templates/WavebirdPlacement.tsx +47 -0
- package/templates/model.js +3 -0
- package/templates/model.ts +3 -0
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
const tasks = new Set();
|
|
21
|
+
let activeReports = 0;
|
|
22
|
+
const reportWaiters = [];
|
|
23
|
+
const diagnostic = code => { try { onDiagnostic?.(code); } catch {} };
|
|
24
|
+
const verifiedScopes = new Set();
|
|
25
|
+
const versionKey = createHash("sha256").update("wavebird/turn-journal-version-row/v1").digest("hex");
|
|
26
|
+
const keyFingerprint = createHmac("sha256", keySecret).update("wavebird/turn-journal-key-version/v1").digest("hex");
|
|
27
|
+
async function verifyKeyVersion(scope) {
|
|
28
|
+
if (verifiedScopes.has(scope)) return;
|
|
29
|
+
// A separate scope keeps this guard outside ordinary turn retention pruning.
|
|
30
|
+
const versionScope = createHash("sha256").update(JSON.stringify(["wavebird/turn-journal-meta/v1", scope])).digest("hex");
|
|
31
|
+
await storage.atomic(versionScope, versionKey, previous => {
|
|
32
|
+
if (previous && previous.keyFingerprint !== keyFingerprint)
|
|
33
|
+
throw new Error("publisher_turn_key_version_conflict");
|
|
34
|
+
return { state: previous || { keyFingerprint }, result: null };
|
|
35
|
+
});
|
|
36
|
+
verifiedScopes.add(scope);
|
|
37
|
+
}
|
|
38
|
+
function identity(turn) {
|
|
39
|
+
if (![turn?.projectId, turn?.surfaceId, turn?.sessionId, turn?.turnId].every(value => ID.test(value || "")))
|
|
40
|
+
throw new Error("publisher_turn_context_invalid");
|
|
41
|
+
const scope = createHash("sha256").update(JSON.stringify([turn.projectId, turn.surfaceId])).digest("hex");
|
|
42
|
+
const key = createHmac("sha256", keySecret).update(JSON.stringify([turn.projectId, turn.surfaceId, turn.sessionId, turn.turnId])).digest("hex");
|
|
43
|
+
return { scope, key };
|
|
44
|
+
}
|
|
45
|
+
function initial(scope) {
|
|
46
|
+
return { scope, createdAtMs: Date.now(), placementClaimed: false, generationClaimed: false,
|
|
47
|
+
jobId: null, phase: "idle", startedReported: false, terminalReported: false,
|
|
48
|
+
leaseId: null, leaseUntilMs: 0 };
|
|
49
|
+
}
|
|
50
|
+
async function change(turn, update) {
|
|
51
|
+
const { scope, key } = identity(turn);
|
|
52
|
+
await verifyKeyVersion(scope);
|
|
53
|
+
return storage.atomic(scope, key, previous => {
|
|
54
|
+
if (previous && previous.scope !== scope) throw new Error("publisher_turn_scope_conflict");
|
|
55
|
+
const state = { ...(previous || initial(scope)) };
|
|
56
|
+
const result = update(state);
|
|
57
|
+
return { state, result };
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
async function claimPlacement(turn) {
|
|
61
|
+
return change(turn, state => {
|
|
62
|
+
if (state.placementClaimed) return false;
|
|
63
|
+
state.placementClaimed = true;
|
|
64
|
+
return true;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
async function releaseRejectedPlacement(turn) {
|
|
68
|
+
await change(turn, state => { if (!state.jobId) state.placementClaimed = false; });
|
|
69
|
+
}
|
|
70
|
+
async function claimGeneration(turn) {
|
|
71
|
+
return change(turn, state => {
|
|
72
|
+
if (state.generationClaimed) return false;
|
|
73
|
+
state.generationClaimed = true;
|
|
74
|
+
return true;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function schedule(scope, key) {
|
|
78
|
+
const task = drain(scope, key).catch(() => diagnostic("turn_journal_unavailable"));
|
|
79
|
+
tasks.add(task);
|
|
80
|
+
void task.finally(() => tasks.delete(task));
|
|
81
|
+
}
|
|
82
|
+
async function bind(turn, jobId) {
|
|
83
|
+
if (!JOB_ID.test(jobId || "")) throw new Error("publisher_job_id_invalid");
|
|
84
|
+
const { scope, key } = identity(turn);
|
|
85
|
+
const bound = await change(turn, state => {
|
|
86
|
+
if (!state.placementClaimed || state.jobId && state.jobId !== jobId) return false;
|
|
87
|
+
state.jobId = jobId;
|
|
88
|
+
return true;
|
|
89
|
+
});
|
|
90
|
+
if (!bound) throw new Error("publisher_turn_job_conflict");
|
|
91
|
+
schedule(scope, key);
|
|
92
|
+
}
|
|
93
|
+
async function phase(turn, event) {
|
|
94
|
+
if (!["started", "finished", "failed"].includes(event)) throw new Error("publisher_phase_invalid");
|
|
95
|
+
const { scope, key } = identity(turn);
|
|
96
|
+
const changed = await change(turn, state => {
|
|
97
|
+
if (!state.generationClaimed || state.phase !== (event === "started" ? "idle" : "started")) return false;
|
|
98
|
+
state.phase = event;
|
|
99
|
+
return true;
|
|
100
|
+
});
|
|
101
|
+
if (changed) schedule(scope, key);
|
|
102
|
+
}
|
|
103
|
+
async function lease(scope, key) {
|
|
104
|
+
return storage.atomic(scope, key, previous => {
|
|
105
|
+
if (!previous || previous.scope !== scope || !previous.jobId || previous.phase === "idle" ||
|
|
106
|
+
previous.leaseUntilMs > Date.now()) return { state: previous, result: null };
|
|
107
|
+
const event = !previous.startedReported ? "started" :
|
|
108
|
+
["finished", "failed"].includes(previous.phase) && !previous.terminalReported ? previous.phase : null;
|
|
109
|
+
if (!event) return { state: previous, result: null };
|
|
110
|
+
const leaseId = randomUUID();
|
|
111
|
+
return { state: { ...previous, leaseId, leaseUntilMs: Date.now() + 10_000 },
|
|
112
|
+
result: { jobId: previous.jobId, event, leaseId } };
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
async function ack(scope, key, event, leaseId, accepted) {
|
|
116
|
+
await storage.atomic(scope, key, previous => {
|
|
117
|
+
if (!previous || previous.scope !== scope || previous.leaseId !== leaseId)
|
|
118
|
+
return { state: previous, result: null };
|
|
119
|
+
const state = { ...previous, leaseId: null, leaseUntilMs: 0 };
|
|
120
|
+
if (accepted) state[event === "started" ? "startedReported" : "terminalReported"] = true;
|
|
121
|
+
return { state, result: null };
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
async function drain(scope, key) {
|
|
125
|
+
while (true) {
|
|
126
|
+
const next = await lease(scope, key);
|
|
127
|
+
if (!next) return;
|
|
128
|
+
if (activeReports >= 4) await new Promise(resolve => reportWaiters.push(resolve));
|
|
129
|
+
activeReports++;
|
|
130
|
+
let accepted = false;
|
|
131
|
+
try { await reportGeneration(next.jobId, next.event); accepted = true; }
|
|
132
|
+
catch { diagnostic("generation_report_unavailable"); }
|
|
133
|
+
finally { activeReports--; reportWaiters.shift()?.(); }
|
|
134
|
+
await ack(scope, key, next.event, next.leaseId, accepted);
|
|
135
|
+
if (!accepted) return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async function reconcilePending(projectId, surfaceId) {
|
|
139
|
+
if (!ID.test(projectId || "") || !ID.test(surfaceId || "")) throw new Error("publisher_turn_scope_invalid");
|
|
140
|
+
const scope = createHash("sha256").update(JSON.stringify([projectId, surfaceId])).digest("hex");
|
|
141
|
+
if (typeof storage.prune === "function" && await storage.prune(scope) > 0)
|
|
142
|
+
diagnostic("generation_report_retention_expired");
|
|
143
|
+
let cursor = "";
|
|
144
|
+
while (true) {
|
|
145
|
+
const keys = await storage.scan(scope, cursor, 100);
|
|
146
|
+
if (!Array.isArray(keys) || keys.length > 100 || keys.some((key, index) => typeof key !== "string" ||
|
|
147
|
+
!/^[a-f0-9]{64}$/.test(key) || key <= (index ? keys[index - 1] : cursor)))
|
|
148
|
+
throw new Error("publisher_turn_scan_invalid");
|
|
149
|
+
if (keys.length === 0) return;
|
|
150
|
+
await Promise.all(keys.map(key => drain(scope, key)));
|
|
151
|
+
cursor = keys.at(-1);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { claimPlacement, releaseRejectedPlacement, bind, claimGeneration, phase, reconcilePending };
|
|
155
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
WAVEBIRD_API_BASE_URL=https://api.wavebird.ai
|
|
2
|
+
WAVEBIRD_CLIENT_ID=wbproj_replace_from_dashboard
|
|
3
|
+
WAVEBIRD_SECRET_KEY=sk_test_replace_from_dashboard
|
|
4
|
+
WAVEBIRD_PREFERENCE_SECRET=replace_with_at_least_32_random_characters
|
|
5
|
+
PUBLISHER_AUTH_CHECK_PATH=/api/your-existing-session-check
|
|
6
|
+
WAVEBIRD_PUBLIC_ORIGINS=
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { createPublisherAds } from "@wavebirdai/api/browser";
|
|
5
|
+
|
|
6
|
+
export default function Page() {
|
|
7
|
+
const slot = useRef(null);
|
|
8
|
+
const ads = useRef(null);
|
|
9
|
+
const abort = useRef(null);
|
|
10
|
+
const [answer, setAnswer] = useState("");
|
|
11
|
+
useEffect(() => {
|
|
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({
|
|
21
|
+
target: slot.current,
|
|
22
|
+
clientId: "wbproj_replace_from_dashboard",
|
|
23
|
+
surfaceId: "chat_assistant",
|
|
24
|
+
jobType: "chat",
|
|
25
|
+
timing: "during",
|
|
26
|
+
consentMode: "wavebird_consent",
|
|
27
|
+
userId: "replace-with-authenticated-user-id",
|
|
28
|
+
conversationId: "replace-with-current-conversation-id",
|
|
29
|
+
sessionId,
|
|
30
|
+
turnBinding: true,
|
|
31
|
+
rendererUrl: "https://api.wavebird.ai/v1/render.js",
|
|
32
|
+
placementEndpoint: "/api/sponsor-slot",
|
|
33
|
+
consentEndpoint: "/api/sponsor-consent",
|
|
34
|
+
privacySettingsTarget: "#privacy-settings"
|
|
35
|
+
});
|
|
36
|
+
ads.current = current;
|
|
37
|
+
}).catch(() => {});
|
|
38
|
+
return () => { disposed = true; controller.abort(); abort.current?.abort(); current?.dispose(); ads.current = null; };
|
|
39
|
+
}, []);
|
|
40
|
+
async function generate() {
|
|
41
|
+
if (!ads.current) return;
|
|
42
|
+
const current = new AbortController();
|
|
43
|
+
abort.current = current;
|
|
44
|
+
setAnswer("");
|
|
45
|
+
try {
|
|
46
|
+
await ads.current.connectGeneration({
|
|
47
|
+
signal: current.signal,
|
|
48
|
+
request: headers => fetch("/api/generate", { method: "POST", headers, signal: current.signal }),
|
|
49
|
+
consume: async (response, lifecycle) => {
|
|
50
|
+
if (!response.ok || !response.body) throw new Error("generation_unavailable");
|
|
51
|
+
const reader = response.body.getReader();
|
|
52
|
+
const decoder = new TextDecoder();
|
|
53
|
+
while (true) {
|
|
54
|
+
const next = await reader.read();
|
|
55
|
+
if (next.done) break;
|
|
56
|
+
setAnswer(value => value + decoder.decode(next.value, { stream: true }));
|
|
57
|
+
lifecycle.onDelta();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
} catch { setAnswer(value => value || "Generation unavailable."); }
|
|
62
|
+
}
|
|
63
|
+
return <main style={{ maxWidth: 760, margin: "2rem auto" }}>
|
|
64
|
+
<h1>Publisher chat</h1>
|
|
65
|
+
<button id="privacy-settings" type="button">Sponsored choices</button>
|
|
66
|
+
<p aria-live="polite">{answer}</p>
|
|
67
|
+
<section ref={slot} aria-label="Sponsored placement area" style={{ width: "100%", maxWidth: 728, height: 346, margin: "1rem auto" }} />
|
|
68
|
+
<div><button type="button" onClick={generate}>Generate</button><button type="button" onClick={() => abort.current?.abort()}>Stop</button></div>
|
|
69
|
+
</main>;
|
|
70
|
+
}
|
|
@@ -0,0 +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
|
+
|
|
31
|
+
export async function authorizePublisherRequest(request) {
|
|
32
|
+
return (await sessionIdForRequest(request)) !== null;
|
|
33
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { createPublisherServer, createPublisherTurnJournal, derivePublisherTurnKeySecret } from "@wavebirdai/api/server";
|
|
3
|
+
import { authorizePublisherRequest, sessionIdForRequest } from "./auth.js";
|
|
4
|
+
import { publisherTurnStorage } from "./turn-store.js";
|
|
5
|
+
|
|
6
|
+
let publisher;
|
|
7
|
+
|
|
8
|
+
export function getPublisher() {
|
|
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({
|
|
16
|
+
apiBaseUrl: process.env.WAVEBIRD_API_BASE_URL,
|
|
17
|
+
clientId: process.env.WAVEBIRD_CLIENT_ID,
|
|
18
|
+
secretKey: process.env.WAVEBIRD_SECRET_KEY,
|
|
19
|
+
preferenceSecret: process.env.WAVEBIRD_PREFERENCE_SECRET,
|
|
20
|
+
authorize: authorizePublisherRequest,
|
|
21
|
+
sessionIdForRequest,
|
|
22
|
+
turnJournal,
|
|
23
|
+
publicOrigins: (process.env.WAVEBIRD_PUBLIC_ORIGINS ?? "").split(",").map(value => value.trim()).filter(Boolean),
|
|
24
|
+
surfaceId: "chat_assistant",
|
|
25
|
+
placementKey: "default",
|
|
26
|
+
jobType: "chat",
|
|
27
|
+
locale: "en-US",
|
|
28
|
+
timing: "during",
|
|
29
|
+
consentMode: "wavebird_consent",
|
|
30
|
+
gdprApplies: true,
|
|
31
|
+
publisher: { app_name: "Your saved app name", app_domain: "your.example", categories: [] },
|
|
32
|
+
allowedFormats: ["banner"],
|
|
33
|
+
positionHint: "between",
|
|
34
|
+
allowedSizes: [{ width: 728, height: 90 }, { width: 320, height: 50 }],
|
|
35
|
+
responsiveSizeSelection: true
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -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,6 @@
|
|
|
1
|
+
WAVEBIRD_API_BASE_URL=https://api.wavebird.ai
|
|
2
|
+
WAVEBIRD_CLIENT_ID=wbproj_replace_from_dashboard
|
|
3
|
+
WAVEBIRD_SECRET_KEY=sk_test_replace_from_dashboard
|
|
4
|
+
WAVEBIRD_PREFERENCE_SECRET=replace_with_at_least_32_random_characters
|
|
5
|
+
PUBLISHER_AUTH_CHECK_PATH=/api/your-existing-session-check
|
|
6
|
+
WAVEBIRD_PUBLIC_ORIGINS=
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { createPublisherAds, type PublisherAds } from "@wavebirdai/api/browser";
|
|
5
|
+
|
|
6
|
+
export default function Page() {
|
|
7
|
+
const slot = useRef<HTMLElement | null>(null);
|
|
8
|
+
const ads = useRef<PublisherAds | null>(null);
|
|
9
|
+
const abort = useRef<AbortController | null>(null);
|
|
10
|
+
const [answer, setAnswer] = useState("");
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (!slot.current) return;
|
|
13
|
+
const target = slot.current;
|
|
14
|
+
const controller = new AbortController();
|
|
15
|
+
let disposed = false;
|
|
16
|
+
let current: PublisherAds | null = null;
|
|
17
|
+
void fetch("/api/sponsor-session", { cache: "no-store", signal: controller.signal }).then(async response => {
|
|
18
|
+
if (!response.ok) return;
|
|
19
|
+
const sessionId = (await response.json()).session_id;
|
|
20
|
+
if (disposed || !/^[A-Za-z0-9_-]{1,128}$/.test(sessionId)) return;
|
|
21
|
+
current = createPublisherAds({
|
|
22
|
+
target,
|
|
23
|
+
clientId: "wbproj_replace_from_dashboard",
|
|
24
|
+
surfaceId: "chat_assistant",
|
|
25
|
+
jobType: "chat",
|
|
26
|
+
timing: "during",
|
|
27
|
+
consentMode: "wavebird_consent",
|
|
28
|
+
userId: "replace-with-authenticated-user-id",
|
|
29
|
+
conversationId: "replace-with-current-conversation-id",
|
|
30
|
+
sessionId,
|
|
31
|
+
turnBinding: true,
|
|
32
|
+
rendererUrl: "https://api.wavebird.ai/v1/render.js",
|
|
33
|
+
placementEndpoint: "/api/sponsor-slot",
|
|
34
|
+
consentEndpoint: "/api/sponsor-consent",
|
|
35
|
+
privacySettingsTarget: "#privacy-settings"
|
|
36
|
+
});
|
|
37
|
+
ads.current = current;
|
|
38
|
+
}).catch(() => {});
|
|
39
|
+
return () => { disposed = true; controller.abort(); abort.current?.abort(); current?.dispose(); ads.current = null; };
|
|
40
|
+
}, []);
|
|
41
|
+
async function generate() {
|
|
42
|
+
if (!ads.current) return;
|
|
43
|
+
const current = new AbortController();
|
|
44
|
+
abort.current = current;
|
|
45
|
+
setAnswer("");
|
|
46
|
+
try {
|
|
47
|
+
await ads.current.connectGeneration({
|
|
48
|
+
signal: current.signal,
|
|
49
|
+
request: headers => fetch("/api/generate", { method: "POST", headers, signal: current.signal }),
|
|
50
|
+
consume: async (response, lifecycle) => {
|
|
51
|
+
if (!response.ok || !response.body) throw new Error("generation_unavailable");
|
|
52
|
+
const reader = response.body.getReader();
|
|
53
|
+
const decoder = new TextDecoder();
|
|
54
|
+
while (true) {
|
|
55
|
+
const next = await reader.read();
|
|
56
|
+
if (next.done) break;
|
|
57
|
+
setAnswer(value => value + decoder.decode(next.value, { stream: true }));
|
|
58
|
+
lifecycle.onDelta();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
} catch { setAnswer(value => value || "Generation unavailable."); }
|
|
63
|
+
}
|
|
64
|
+
return <main style={{ maxWidth: 760, margin: "2rem auto" }}>
|
|
65
|
+
<h1>Publisher chat</h1>
|
|
66
|
+
<button id="privacy-settings" type="button">Sponsored choices</button>
|
|
67
|
+
<p aria-live="polite">{answer}</p>
|
|
68
|
+
<section ref={slot} aria-label="Sponsored placement area" style={{ width: "100%", maxWidth: 728, height: 346, margin: "1rem auto" }} />
|
|
69
|
+
<div><button type="button" onClick={generate}>Generate</button><button type="button" onClick={() => abort.current?.abort()}>Stop</button></div>
|
|
70
|
+
</main>;
|
|
71
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
const cache = new WeakMap<Request, Promise<string | null>>();
|
|
4
|
+
const ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
5
|
+
|
|
6
|
+
/** Resolve a non-credential correlation ID from the app's authenticated session. */
|
|
7
|
+
export function sessionIdForRequest(request: Request): Promise<string | null> {
|
|
8
|
+
const prior = cache.get(request);
|
|
9
|
+
if (prior) return prior;
|
|
10
|
+
const pending = (async () => {
|
|
11
|
+
const path = process.env.PUBLISHER_AUTH_CHECK_PATH;
|
|
12
|
+
if (!path || !/^\/(?!\/)[A-Za-z0-9/_-]+$/.test(path) ||
|
|
13
|
+
path.startsWith("/api/sponsor") || path === "/api/generate") return null;
|
|
14
|
+
try {
|
|
15
|
+
const requestOrigin = new URL(request.url).origin;
|
|
16
|
+
const trustedOrigin = (process.env.WAVEBIRD_PUBLIC_ORIGINS ?? "").split(",")
|
|
17
|
+
.map(value => value.trim()).find(value => value === requestOrigin);
|
|
18
|
+
if (!trustedOrigin) return null;
|
|
19
|
+
const result = await fetch(new URL(path, trustedOrigin), {
|
|
20
|
+
headers: { cookie: request.headers.get("cookie") || "" },
|
|
21
|
+
cache: "no-store", redirect: "manual", signal: AbortSignal.timeout(1500),
|
|
22
|
+
});
|
|
23
|
+
const id = result.headers.get("x-publisher-session-id");
|
|
24
|
+
return result.status === 204 && ID.test(id || "") ? id : null;
|
|
25
|
+
} catch { return null; }
|
|
26
|
+
})();
|
|
27
|
+
cache.set(request, pending);
|
|
28
|
+
return pending;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function authorizePublisherRequest(request: Request): Promise<boolean> {
|
|
32
|
+
return (await sessionIdForRequest(request)) !== null;
|
|
33
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { createPublisherServer, createPublisherTurnJournal, derivePublisherTurnKeySecret } from "@wavebirdai/api/server";
|
|
3
|
+
import { authorizePublisherRequest, sessionIdForRequest } from "./auth";
|
|
4
|
+
import { publisherTurnStorage } from "./turn-store";
|
|
5
|
+
|
|
6
|
+
let publisher: ReturnType<typeof createPublisherServer> | null = null;
|
|
7
|
+
|
|
8
|
+
export function getPublisher(): ReturnType<typeof 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({
|
|
16
|
+
apiBaseUrl: process.env.WAVEBIRD_API_BASE_URL,
|
|
17
|
+
clientId: process.env.WAVEBIRD_CLIENT_ID ?? "",
|
|
18
|
+
secretKey: process.env.WAVEBIRD_SECRET_KEY ?? "",
|
|
19
|
+
preferenceSecret: process.env.WAVEBIRD_PREFERENCE_SECRET ?? "",
|
|
20
|
+
authorize: authorizePublisherRequest,
|
|
21
|
+
sessionIdForRequest,
|
|
22
|
+
turnJournal,
|
|
23
|
+
publicOrigins: (process.env.WAVEBIRD_PUBLIC_ORIGINS ?? "").split(",").map(value => value.trim()).filter(Boolean),
|
|
24
|
+
surfaceId: "chat_assistant",
|
|
25
|
+
placementKey: "default",
|
|
26
|
+
jobType: "chat",
|
|
27
|
+
locale: "en-US",
|
|
28
|
+
timing: "during",
|
|
29
|
+
consentMode: "wavebird_consent",
|
|
30
|
+
gdprApplies: true,
|
|
31
|
+
publisher: { app_name: "Your saved app name", app_domain: "your.example", categories: [] },
|
|
32
|
+
allowedFormats: ["banner"],
|
|
33
|
+
positionHint: "between",
|
|
34
|
+
allowedSizes: [{ width: 728, height: 90 }, { width: 320, height: 50 }],
|
|
35
|
+
responsiveSizeSelection: true
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import type { PublisherAtomicTurnStorage } from "@wavebirdai/api/server";
|
|
3
|
+
|
|
4
|
+
/** Replace this fail-closed stub with createPostgresTurnStorage(your existing pg pool). */
|
|
5
|
+
export function publisherTurnStorage(): PublisherAtomicTurnStorage {
|
|
6
|
+
return {
|
|
7
|
+
async atomic<T>(): Promise<T> { throw new Error("Connect your existing durable turn store"); },
|
|
8
|
+
async scan(): Promise<string[]> { throw new Error("Connect your existing durable turn store"); },
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wavebird-api-next-ts-example",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
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" },
|
|
7
|
+
"devDependencies": { "@types/node": "^25", "@types/react": "^19", "@types/react-dom": "^19", "typescript": "^5" }
|
|
8
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "es2022"],
|
|
5
|
+
"allowJs": false,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"module": "esnext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "react-jsx",
|
|
15
|
+
"incremental": true,
|
|
16
|
+
"plugins": [{ "name": "next" }]
|
|
17
|
+
},
|
|
18
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
19
|
+
"exclude": ["node_modules"]
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wavebirdai/api",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-beta.10",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Integrate Banner, Native, and Clip placements into authenticated chat apps with server-side keys, consent, and generation lifecycle handling",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": { "type": "git", "url": "https://github.com/wavebird-ai/api" },
|
|
9
|
+
"engines": { "node": ">=20" },
|
|
10
|
+
"files": ["dist", "src", "build.mjs", "examples", "bin", "templates", "README.md", "LICENSE"],
|
|
11
|
+
"bin": { "wavebird-next": "./bin/wavebird-next.mjs" },
|
|
12
|
+
"exports": {
|
|
13
|
+
"./server": { "types": "./dist/server.d.ts", "import": "./dist/server.js" },
|
|
14
|
+
"./browser": { "types": "./dist/browser.d.ts", "import": "./dist/browser.js" },
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "node build.mjs",
|
|
19
|
+
"test": "node --test tests/*.test.mjs",
|
|
20
|
+
"pretest": "npm run build",
|
|
21
|
+
"prepack": "npm run build"
|
|
22
|
+
}
|
|
23
|
+
}
|