waitspin 0.1.2 → 0.1.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.
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env node
2
+
3
+ // WaitSpin OpenCode Statusline Runtime
4
+ // Installed by: waitspin opencode install
5
+ //
6
+ // Called periodically by the OpenCode TUI plugin. Fetches the next sponsored
7
+ // message from the WaitSpin API, manages serve caching, and records
8
+ // impressions after the minimum visible interval.
9
+ //
10
+ // Usage: node opencode-statusline.mjs --state STATE_PATH
11
+
12
+ import {
13
+ mkdir,
14
+ readFile,
15
+ rename,
16
+ rm,
17
+ stat,
18
+ writeFile,
19
+ } from "node:fs/promises"
20
+ import os from "node:os"
21
+ import path from "node:path"
22
+
23
+ // ─── Constants ──────────────────────────────────────────────
24
+
25
+ const FETCH_INTERVAL_MS = 15_000
26
+ const FETCH_TIMEOUT_MS = 2_500
27
+ const MAX_ACTIVE_AGE_MS = 60_000
28
+ const LOCK_RETRY_MS = 40
29
+ const LOCK_TIMEOUT_MS = 2_000
30
+ const LOCK_STALE_MS = 10_000
31
+ const DEFAULT_MIN_VISIBLE_MS = 5_000
32
+ const CACHE_KEY = "opencode"
33
+ const MANAGED_STATE_PATH = path.join(
34
+ os.homedir(),
35
+ ".waitspin",
36
+ "opencode-install.json",
37
+ )
38
+
39
+ // ─── Helpers ────────────────────────────────────────────────
40
+
41
+ function argValue(name) {
42
+ for (let index = 2; index < process.argv.length; index += 1) {
43
+ if (process.argv[index] !== name) continue
44
+ const value = process.argv[index + 1]
45
+ return value && !value.startsWith("--") ? value : undefined
46
+ }
47
+ return undefined
48
+ }
49
+
50
+ function managedStatePath(value) {
51
+ if (!value) return null
52
+ const resolved = path.resolve(value)
53
+ return resolved === path.resolve(MANAGED_STATE_PATH) ? resolved : null
54
+ }
55
+
56
+ async function readJson(filePath, fallback) {
57
+ try {
58
+ return JSON.parse(await readFile(filePath, "utf8"))
59
+ } catch {
60
+ return fallback
61
+ }
62
+ }
63
+
64
+ function readInstallState(value) {
65
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null
66
+ const apiKey = typeof value.api_key === "string" ? value.api_key.trim() : ""
67
+ const installId =
68
+ typeof value.install_id === "string" ? value.install_id.trim() : ""
69
+ const baseUrl =
70
+ typeof value.base_url === "string" ? value.base_url.trim() : ""
71
+ const cachePath =
72
+ typeof value.cache_path === "string" ? value.cache_path.trim() : ""
73
+ if (!apiKey || !installId || !baseUrl || !cachePath) return null
74
+ return {
75
+ ...value,
76
+ api_key: apiKey,
77
+ install_id: installId,
78
+ base_url: baseUrl,
79
+ cache_path: cachePath,
80
+ }
81
+ }
82
+
83
+ async function writeJson(filePath, value) {
84
+ const tmp = filePath + "." + process.pid + ".tmp"
85
+ await writeFile(tmp, JSON.stringify(value, null, 2) + "\n", {
86
+ encoding: "utf8",
87
+ mode: 0o600,
88
+ })
89
+ await rename(tmp, filePath)
90
+ }
91
+
92
+ function sleep(ms) {
93
+ return new Promise((resolve) => setTimeout(resolve, ms))
94
+ }
95
+
96
+ async function acquireCacheLock(cachePath) {
97
+ const lockPath = cachePath + ".lock"
98
+ const startedAt = Date.now()
99
+
100
+ while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
101
+ try {
102
+ await mkdir(lockPath)
103
+ return async () => {
104
+ await rm(lockPath, { recursive: true, force: true })
105
+ }
106
+ } catch {
107
+ try {
108
+ const lockStat = await stat(lockPath)
109
+ if (Date.now() - lockStat.mtimeMs > LOCK_STALE_MS) {
110
+ await rm(lockPath, { recursive: true, force: true })
111
+ continue
112
+ }
113
+ } catch {
114
+ // Another process may have released the lock between mkdir/stat
115
+ }
116
+ await sleep(LOCK_RETRY_MS)
117
+ }
118
+ }
119
+
120
+ throw new Error("Timed out waiting for WaitSpin OpenCode cache lock.")
121
+ }
122
+
123
+ async function withCacheLock(cachePath, callback) {
124
+ const release = await acquireCacheLock(cachePath)
125
+ try {
126
+ return await callback()
127
+ } finally {
128
+ await release()
129
+ }
130
+ }
131
+
132
+ function cleanLine(value) {
133
+ return String(value || "")
134
+ .replace(
135
+ /(?:\u001B\[[0-?]*[ -/]*[@-~]|\u001B\][^\u0007]*(?:\u0007|\u001B\\)|\u001B[P^_][\s\S]*?\u001B\\|\u001B[@-Z\\-_]|\u009B[0-?]*[ -/]*[@-~])/g,
136
+ " ",
137
+ )
138
+ .replace(/[\r\n\u0000-\u001F\u007F-\u009F]/g, " ")
139
+ .replace(/\s+/g, " ")
140
+ .trim()
141
+ .slice(0, 120)
142
+ }
143
+
144
+ function parseServe(payload) {
145
+ if (!payload || typeof payload !== "object") return null
146
+ const creative = payload.creative
147
+ if (!creative || typeof creative !== "object") return null
148
+ const line = cleanLine(creative.line)
149
+ if (!line) return null
150
+ if (
151
+ typeof payload.serve_id !== "string" ||
152
+ typeof payload.serve_receipt !== "string"
153
+ ) {
154
+ return null
155
+ }
156
+ return {
157
+ serveId: payload.serve_id,
158
+ serveReceipt: payload.serve_receipt,
159
+ line,
160
+ shownAt: Date.now(),
161
+ minVisibleMs:
162
+ typeof payload.min_visible_ms === "number" && payload.min_visible_ms >= DEFAULT_MIN_VISIBLE_MS
163
+ ? payload.min_visible_ms
164
+ : DEFAULT_MIN_VISIBLE_MS,
165
+ impressionRecorded: false,
166
+ }
167
+ }
168
+
169
+ // ─── WaitSpin API Calls ─────────────────────────────────────
170
+
171
+ async function waitspinFetch(url, init) {
172
+ const controller = new AbortController()
173
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
174
+ try {
175
+ return await fetch(url, { ...init, signal: controller.signal })
176
+ } finally {
177
+ clearTimeout(timeout)
178
+ }
179
+ }
180
+
181
+ async function fetchNextServe(state, session) {
182
+ const response = await waitspinFetch(state.base_url + "/v1/serve/next", {
183
+ method: "POST",
184
+ headers: {
185
+ Authorization: "Bearer " + state.api_key,
186
+ "Content-Type": "application/json",
187
+ },
188
+ body: JSON.stringify({ install_id: state.install_id }),
189
+ })
190
+ session.lastFetchAt = Date.now()
191
+ if (response.status === 204) {
192
+ session.activeServe = null
193
+ return
194
+ }
195
+ if (!response.ok) return
196
+ const parsed = parseServe(await response.json())
197
+ if (parsed) session.activeServe = parsed
198
+ }
199
+
200
+ async function recordImpression(state, session) {
201
+ const serve = session.activeServe
202
+ if (!serve || serve.impressionRecorded) return
203
+ const visibleMs = Date.now() - serve.shownAt
204
+ if (visibleMs < serve.minVisibleMs) return
205
+ const response = await waitspinFetch(state.base_url + "/v1/events/impression", {
206
+ method: "POST",
207
+ headers: {
208
+ Authorization: "Bearer " + state.api_key,
209
+ "Content-Type": "application/json",
210
+ },
211
+ body: JSON.stringify({
212
+ serve_id: serve.serveId,
213
+ serve_receipt: serve.serveReceipt,
214
+ install_id: state.install_id,
215
+ visible_ms: Math.max(visibleMs, serve.minVisibleMs),
216
+ }),
217
+ })
218
+ if (response.ok) serve.impressionRecorded = true
219
+ }
220
+
221
+ function pruneSessions(cache) {
222
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000
223
+ for (const [key, value] of Object.entries(cache.sessions || {})) {
224
+ if ((value.lastSeenAt || 0) < cutoff) delete cache.sessions[key]
225
+ }
226
+ }
227
+
228
+ // ─── Main ───────────────────────────────────────────────────
229
+
230
+ async function main() {
231
+ const statePath = managedStatePath(argValue("--state"))
232
+ if (!statePath) return
233
+
234
+ const state = readInstallState(await readJson(statePath, null))
235
+ if (!state) return
236
+
237
+ let sponsorLine = ""
238
+
239
+ try {
240
+ sponsorLine = await withCacheLock(state.cache_path, async () => {
241
+ const cache = await readJson(state.cache_path, { sessions: {} })
242
+ if (!cache.sessions || typeof cache.sessions !== "object") cache.sessions = {}
243
+
244
+ // OpenCode uses a single session key
245
+ const session = cache.sessions[CACHE_KEY] || {}
246
+ session.lastSeenAt = Date.now()
247
+ cache.sessions[CACHE_KEY] = session
248
+
249
+ // Expire stale active serve
250
+ if (
251
+ session.activeServe &&
252
+ Date.now() - session.activeServe.shownAt > MAX_ACTIVE_AGE_MS
253
+ ) {
254
+ session.activeServe = null
255
+ }
256
+
257
+ await recordImpression(state, session)
258
+
259
+ const shouldFetchNext = !session.activeServe
260
+ ? Date.now() - (session.lastFetchAt || 0) >= FETCH_INTERVAL_MS
261
+ : session.activeServe.impressionRecorded &&
262
+ Date.now() - (session.lastFetchAt || 0) >= FETCH_INTERVAL_MS
263
+
264
+ if (shouldFetchNext) {
265
+ await fetchNextServe(state, session)
266
+ }
267
+
268
+ pruneSessions(cache)
269
+ await writeJson(state.cache_path, cache)
270
+ return session
271
+ })
272
+ } catch {
273
+ // Statusline rendering must never interrupt the host
274
+ }
275
+
276
+ const line = sponsorLine?.activeServe?.line || ""
277
+ if (line) process.stdout.write(line)
278
+ }
279
+
280
+ main().catch(() => {})
@@ -0,0 +1,267 @@
1
+ // WaitSpin OpenCode TUI Plugin
2
+ // Installed by: waitspin opencode install
3
+ // Slot: app_bottom
4
+ //
5
+ // Displays a WaitSpin sponsored message in the OpenCode TUI app_bottom slot.
6
+ // Polls the WaitSpin API every 15s and records impressions after 5s visible.
7
+
8
+ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
9
+ import { execFile } from "node:child_process"
10
+ import { readFile } from "node:fs/promises"
11
+ import { createSignal, Show } from "solid-js"
12
+
13
+ const POLL_INTERVAL_MS = 15_000
14
+ const MIN_VISIBLE_MS = 5_000
15
+ const FETCH_TIMEOUT_MS = 5_000
16
+
17
+ const INSTALL_CONFIG = {
18
+ statePath: "__WAITSPIN_STATE_PATH__",
19
+ }
20
+
21
+ interface WaitSpinConfig {
22
+ baseUrl: string
23
+ apiKey: string
24
+ installId: string
25
+ }
26
+
27
+ interface ActiveServe {
28
+ serveId: string
29
+ line: string
30
+ destinationUrl: string
31
+ serveReceipt: string
32
+ shownAt: number
33
+ minVisibleMs: number
34
+ }
35
+
36
+ const plugin: TuiPlugin = async (api: TuiPluginApi) => {
37
+ const [sponsorLine, setSponsorLine] = createSignal("")
38
+ const [destinationUrl, setDestinationUrl] = createSignal("")
39
+
40
+ let activeServe: ActiveServe | null = null
41
+ let pollTimer: ReturnType<typeof setInterval> | undefined
42
+ let impressionTimer: ReturnType<typeof setTimeout> | undefined
43
+ let isPolling = false
44
+ let cachedConfig: WaitSpinConfig | null | undefined
45
+
46
+ function kvGet(key: string): string {
47
+ try {
48
+ return api.kv.get<string>(key) || ""
49
+ } catch {
50
+ return ""
51
+ }
52
+ }
53
+
54
+ async function readStateConfig(): Promise<WaitSpinConfig | null> {
55
+ if (cachedConfig !== undefined) return cachedConfig
56
+ try {
57
+ const raw = await readFile(INSTALL_CONFIG.statePath, "utf8")
58
+ const state = JSON.parse(raw) as Record<string, unknown>
59
+ const baseUrl = typeof state.base_url === "string" ? state.base_url.trim() : ""
60
+ const apiKey = typeof state.api_key === "string" ? state.api_key.trim() : ""
61
+ const installId = typeof state.install_id === "string" ? state.install_id.trim() : ""
62
+ cachedConfig = baseUrl && apiKey && installId
63
+ ? { baseUrl, apiKey, installId }
64
+ : null
65
+ } catch {
66
+ cachedConfig = null
67
+ }
68
+ return cachedConfig
69
+ }
70
+
71
+ async function resolveConfig(): Promise<WaitSpinConfig | null> {
72
+ const apiKey = kvGet("waitspin_api_key")
73
+ const installId = kvGet("waitspin_install_id")
74
+ const baseUrl = kvGet("waitspin_base_url") || "https://api.waitspin.com"
75
+ if (apiKey && installId) return { baseUrl, apiKey, installId }
76
+ return readStateConfig()
77
+ }
78
+
79
+ function isSafeUrl(url: string): boolean {
80
+ try {
81
+ const rawHost = rawHostname(url)
82
+ if (/^(?:\d+|0x[0-9a-f]+|0[0-7]+)$/i.test(rawHost)) return false
83
+ const parsed = new URL(url)
84
+ if (parsed.protocol !== "https:") return false
85
+ if (parsed.username || parsed.password) return false
86
+ const host = parsed.hostname.toLowerCase()
87
+ if (["localhost", "::1", "0.0.0.0"].includes(host)) return false
88
+ if (/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.)/.test(host)) return false
89
+ if (host.startsWith("::ffff:")) return false
90
+ if (host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd")) return false
91
+ return true
92
+ } catch {
93
+ return false
94
+ }
95
+ }
96
+
97
+ function rawHostname(url: string): string {
98
+ const withoutProtocol = url.trim().replace(/^[a-z][a-z\d+.-]*:\/\//i, "")
99
+ const authority = withoutProtocol.split(/[/?#]/, 1)[0] || ""
100
+ const hostPort = authority.includes("@") ? authority.slice(authority.lastIndexOf("@") + 1) : authority
101
+ if (hostPort.startsWith("[")) {
102
+ return hostPort.slice(1, hostPort.indexOf("]")).toLowerCase()
103
+ }
104
+ return hostPort.split(":")[0].toLowerCase()
105
+ }
106
+
107
+ function openExternalUrl(url: string): void {
108
+ if (!isSafeUrl(url)) return
109
+ const platform = typeof process === "object" ? process.platform : ""
110
+ const command =
111
+ platform === "darwin" ? "open" : platform === "linux" ? "xdg-open" : ""
112
+ if (!command) return
113
+ try {
114
+ const child = execFile(command, [url], { timeout: 2000 }, () => {})
115
+ child.unref()
116
+ } catch {
117
+ // Opening destinations is best-effort; display must never fail.
118
+ }
119
+ }
120
+
121
+ function cleanLine(value: unknown): string {
122
+ return String(value || "")
123
+ .replace(
124
+ /(?:\u001B\[[0-?]*[ -/]*[@-~]|\u001B\][^\u0007]*(?:\u0007|\u001B\\)|\u001B[P^_][\s\S]*?\u001B\\|\u001B[@-Z\\-_]|\u009B[0-?]*[ -/]*[@-~])/g,
125
+ " ",
126
+ )
127
+ .replace(/[\r\n\u0000-\u001F\u007F-\u009F]/g, " ")
128
+ .replace(/\s+/g, " ")
129
+ .trim()
130
+ .slice(0, 120)
131
+ }
132
+
133
+ function parseServePayload(payload: unknown): ActiveServe | null {
134
+ if (!payload || typeof payload !== "object") return null
135
+ const r = payload as Record<string, unknown>
136
+ const serveId = typeof r.serve_id === "string" ? r.serve_id.trim() : ""
137
+ if (serveId.length < 8) return null
138
+ const serveReceipt = typeof r.serve_receipt === "string" ? r.serve_receipt.trim() : ""
139
+ if (serveReceipt.length < 32) return null
140
+ const creative = r.creative
141
+ if (!creative || typeof creative !== "object") return null
142
+ const c = creative as Record<string, unknown>
143
+ const line = cleanLine(c.line)
144
+ if (!line) return null
145
+ const destinationUrl = typeof c.destination_url === "string" ? c.destination_url.trim() : ""
146
+ if (!destinationUrl || !isSafeUrl(destinationUrl)) return null
147
+ const minVisibleMs = typeof r.min_visible_ms === "number" && r.min_visible_ms >= MIN_VISIBLE_MS
148
+ ? r.min_visible_ms
149
+ : MIN_VISIBLE_MS
150
+ return { serveId, line, destinationUrl, serveReceipt, shownAt: Date.now(), minVisibleMs }
151
+ }
152
+
153
+ async function fetchNextServe(): Promise<void> {
154
+ if (isPolling) return
155
+ const cfg = await resolveConfig()
156
+ if (!cfg) return
157
+ isPolling = true
158
+ const controller = new AbortController()
159
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
160
+ try {
161
+ const response = await fetch(`${cfg.baseUrl}/v1/serve/next`, {
162
+ method: "POST",
163
+ headers: { Authorization: `Bearer ${cfg.apiKey}`, "Content-Type": "application/json" },
164
+ body: JSON.stringify({ install_id: cfg.installId }),
165
+ signal: controller.signal,
166
+ })
167
+
168
+ if (response.status === 204) { clearAd(); return }
169
+ if (!response.ok) return
170
+
171
+ const parsed = parseServePayload(await response.json())
172
+ if (!parsed) return
173
+
174
+ activeServe = parsed
175
+ setSponsorLine(parsed.line)
176
+ setDestinationUrl(parsed.destinationUrl)
177
+
178
+ if (impressionTimer) clearTimeout(impressionTimer)
179
+ impressionTimer = setTimeout(() => {
180
+ impressionTimer = undefined
181
+ if (!activeServe || activeServe.serveId !== parsed.serveId) return
182
+ recordImpression(parsed.serveId, parsed.serveReceipt, Math.max(Date.now() - parsed.shownAt, parsed.minVisibleMs))
183
+ }, parsed.minVisibleMs)
184
+ } catch {
185
+ // Network errors are non-fatal.
186
+ } finally {
187
+ clearTimeout(timeout)
188
+ isPolling = false
189
+ }
190
+ }
191
+
192
+ async function recordImpression(serveId: string, serveReceipt: string, visibleMs: number): Promise<void> {
193
+ const cfg = await resolveConfig()
194
+ if (!cfg) return
195
+ const controller = new AbortController()
196
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
197
+ try {
198
+ await fetch(`${cfg.baseUrl}/v1/events/impression`, {
199
+ method: "POST",
200
+ headers: { Authorization: `Bearer ${cfg.apiKey}`, "Content-Type": "application/json" },
201
+ body: JSON.stringify({ serve_id: serveId, serve_receipt: serveReceipt, install_id: cfg.installId, visible_ms: visibleMs }),
202
+ signal: controller.signal,
203
+ })
204
+ } catch {
205
+ // Impression failures are non-fatal.
206
+ } finally {
207
+ clearTimeout(timeout)
208
+ }
209
+ }
210
+
211
+ function clearAd(): void {
212
+ if (impressionTimer) { clearTimeout(impressionTimer); impressionTimer = undefined }
213
+ activeServe = null
214
+ setSponsorLine("")
215
+ setDestinationUrl("")
216
+ }
217
+
218
+ function startPolling(): void {
219
+ if (pollTimer) return
220
+ void fetchNextServe()
221
+ pollTimer = setInterval(() => { void fetchNextServe() }, POLL_INTERVAL_MS)
222
+ }
223
+
224
+ function stopPolling(): void {
225
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = undefined }
226
+ if (impressionTimer) { clearTimeout(impressionTimer); impressionTimer = undefined }
227
+ activeServe = null
228
+ setSponsorLine("")
229
+ setDestinationUrl("")
230
+ }
231
+
232
+ startPolling()
233
+ api.lifecycle.onDispose(stopPolling)
234
+
235
+ api.slots.register({
236
+ slots: {
237
+ app_bottom() {
238
+ const [hovered, setHovered] = createSignal(false)
239
+
240
+ return (
241
+ <Show when={sponsorLine()}>
242
+ <box
243
+ flexShrink={0}
244
+ paddingLeft={1}
245
+ paddingRight={1}
246
+ backgroundColor={hovered() ? api.theme.current.backgroundElement : api.theme.current.backgroundPanel}
247
+ onMouseOver={() => setHovered(true)}
248
+ onMouseOut={() => setHovered(false)}
249
+ onMouseUp={() => {
250
+ const url = destinationUrl()
251
+ if (url) {
252
+ openExternalUrl(url)
253
+ }
254
+ }}
255
+ >
256
+ <text fg={hovered() ? api.theme.current.text : api.theme.current.textMuted}>
257
+ ⧉ {sponsorLine()}
258
+ </text>
259
+ </box>
260
+ </Show>
261
+ )
262
+ },
263
+ },
264
+ })
265
+ }
266
+
267
+ export default plugin
@@ -71,7 +71,7 @@ function resolveApiKey() {
71
71
  if (secretApiKey) {
72
72
  return secretApiKey;
73
73
  }
74
- return process.env.WAITSPIN_API_KEY?.trim();
74
+ return undefined;
75
75
  }
76
76
  async function migratePublisherApiKeyToSecretStorage(context) {
77
77
  let stored;
@@ -127,20 +127,33 @@ function readGlobalWaitSpinSetting(name) {
127
127
  function allowDeveloperApiBase() {
128
128
  return process.env.WAITSPIN_ALLOW_DEV_API_BASE === "1";
129
129
  }
130
+ function isLoopbackApiHostname(hostname) {
131
+ const normalized = hostname.toLowerCase().replace(/\.$/, "");
132
+ return (normalized === "localhost" ||
133
+ normalized === "::1" ||
134
+ normalized === "[::1]" ||
135
+ /^127(?:\.\d{1,3}){3}$/.test(normalized));
136
+ }
130
137
  function normalizeTrustedApiBase(value) {
131
138
  try {
132
139
  const parsed = new URL(value);
133
140
  const hostname = parsed.hostname.toLowerCase().replace(/\.$/, "");
134
- const isProductionApi = parsed.protocol === "https:" &&
135
- hostname === "api.waitspin.com" &&
136
- !parsed.username &&
141
+ const isCleanOrigin = !parsed.username &&
137
142
  !parsed.password &&
143
+ !parsed.search &&
144
+ !parsed.hash &&
138
145
  (parsed.pathname === "/" || parsed.pathname === "");
146
+ const isProductionApi = parsed.protocol === "https:" &&
147
+ hostname === "api.waitspin.com" &&
148
+ isCleanOrigin;
139
149
  if (isProductionApi) {
140
150
  return parsed.origin;
141
151
  }
142
- if (allowDeveloperApiBase() && !parsed.username && !parsed.password) {
143
- return parsed.origin.replace(/\/+$/, "");
152
+ if (allowDeveloperApiBase() &&
153
+ isCleanOrigin &&
154
+ (parsed.protocol === "http:" || parsed.protocol === "https:") &&
155
+ isLoopbackApiHostname(parsed.hostname)) {
156
+ return parsed.origin;
144
157
  }
145
158
  }
146
159
  catch {
@@ -196,6 +209,12 @@ function parseServePayload(payload) {
196
209
  if (typeof serveId !== "string" || serveId.trim().length < 8) {
197
210
  return undefined;
198
211
  }
212
+ const serveReceipt = record.serve_receipt;
213
+ if (typeof serveReceipt !== "string" ||
214
+ serveReceipt.trim().length < 32 ||
215
+ serveReceipt.length > 2048) {
216
+ return undefined;
217
+ }
199
218
  const creative = record.creative;
200
219
  if (!creative || typeof creative !== "object") {
201
220
  return undefined;
@@ -217,6 +236,7 @@ function parseServePayload(payload) {
217
236
  serveId: serveId.trim(),
218
237
  line: line.trim(),
219
238
  destinationUrl: destinationUrl.trim(),
239
+ serveReceipt: serveReceipt.trim(),
220
240
  minVisibleMs,
221
241
  };
222
242
  }
@@ -304,6 +324,7 @@ async function fetchNextCreative() {
304
324
  serveId: parsed.serveId,
305
325
  line: parsed.line,
306
326
  destinationUrl: parsed.destinationUrl,
327
+ serveReceipt: parsed.serveReceipt,
307
328
  shownAt: Date.now(),
308
329
  minVisibleMs: parsed.minVisibleMs,
309
330
  };
@@ -331,7 +352,7 @@ function showStatusBarAd(serve, minVisibleMs) {
331
352
  return;
332
353
  }
333
354
  const visibleMs = Math.max(Date.now() - serve.shownAt, minVisibleMs);
334
- void recordImpression(serve.serveId, visibleMs, resolveInstallId());
355
+ void recordImpression(serve.serveId, serve.serveReceipt, visibleMs, resolveInstallId());
335
356
  }, minVisibleMs);
336
357
  }
337
358
  function flushPendingImpressionIfEligible() {
@@ -341,7 +362,7 @@ function flushPendingImpressionIfEligible() {
341
362
  }
342
363
  const visibleMs = Date.now() - serve.shownAt;
343
364
  if (visibleMs >= serve.minVisibleMs) {
344
- void recordImpression(serve.serveId, visibleMs, resolveInstallId());
365
+ void recordImpression(serve.serveId, serve.serveReceipt, visibleMs, resolveInstallId());
345
366
  }
346
367
  }
347
368
  function clearImpressionSchedulingAndFlush() {
@@ -380,7 +401,7 @@ function startPollingIfConfigured() {
380
401
  void fetchNextCreative();
381
402
  }, POLL_INTERVAL_MS);
382
403
  }
383
- async function recordImpression(serveId, visibleMs, installId) {
404
+ async function recordImpression(serveId, serveReceipt, visibleMs, installId) {
384
405
  const apiKey = resolveApiKey();
385
406
  const apiBase = resolveApiBase();
386
407
  if (!apiKey || !installId || !apiBase) {
@@ -395,6 +416,7 @@ async function recordImpression(serveId, visibleMs, installId) {
395
416
  },
396
417
  body: JSON.stringify({
397
418
  serve_id: serveId,
419
+ serve_receipt: serveReceipt,
398
420
  install_id: installId,
399
421
  visible_ms: visibleMs,
400
422
  }),