@invonetwork/web-sdk 0.2.1 → 0.4.0
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/CHANGELOG.md +48 -1
- package/LICENSE +18 -17
- package/README.md +495 -393
- package/dist/chunk-EEWOAUXO.js +249 -0
- package/dist/index.cjs +188 -63
- package/dist/index.d.cts +9 -9
- package/dist/index.d.ts +9 -9
- package/dist/index.js +54 -31
- package/dist/server.cjs +474 -59
- package/dist/server.d.cts +177 -13
- package/dist/server.d.ts +177 -13
- package/dist/server.js +339 -29
- package/dist/{errors-DV5QsftP.d.cts → types-CBMLNwbe.d.cts} +152 -42
- package/dist/{errors-DV5QsftP.d.ts → types-CBMLNwbe.d.ts} +152 -42
- package/package.json +10 -2
- package/dist/chunk-A44O4KC3.js +0 -147
package/dist/server.cjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
var crypto = require('crypto');
|
|
4
|
+
|
|
3
5
|
// src/shared/errors.ts
|
|
4
6
|
var InvoError = class _InvoError extends Error {
|
|
5
7
|
constructor(args) {
|
|
@@ -8,6 +10,7 @@ var InvoError = class _InvoError extends Error {
|
|
|
8
10
|
this.code = args.code;
|
|
9
11
|
this.status = args.status;
|
|
10
12
|
this.body = args.body ?? null;
|
|
13
|
+
this.requestId = args.requestId;
|
|
11
14
|
Object.setPrototypeOf(this, _InvoError.prototype);
|
|
12
15
|
}
|
|
13
16
|
/** True if this is the "recipient isn't passkey-enrolled, fall back to claim code" signal. */
|
|
@@ -45,7 +48,7 @@ var InvoError = class _InvoError extends Error {
|
|
|
45
48
|
return this.body && typeof this.body === "object" ? this.body : {};
|
|
46
49
|
}
|
|
47
50
|
};
|
|
48
|
-
function errorFromResponse(status, body) {
|
|
51
|
+
function errorFromResponse(status, body, requestId) {
|
|
49
52
|
let message = `INVO request failed (HTTP ${status})`;
|
|
50
53
|
let code;
|
|
51
54
|
if (body && typeof body === "object") {
|
|
@@ -56,11 +59,12 @@ function errorFromResponse(status, body) {
|
|
|
56
59
|
} else if (typeof body === "string" && body.trim()) {
|
|
57
60
|
message = body;
|
|
58
61
|
}
|
|
59
|
-
return new InvoError({ message, code, status, body });
|
|
62
|
+
return new InvoError({ message, code, status, body, requestId });
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
// src/shared/http.ts
|
|
63
66
|
var DEFAULT_TIMEOUT = 3e4;
|
|
67
|
+
var MAX_RETRY_AFTER_MS = 2e4;
|
|
64
68
|
function assertSecureBaseUrl(baseUrl) {
|
|
65
69
|
let u;
|
|
66
70
|
try {
|
|
@@ -74,7 +78,7 @@ function assertSecureBaseUrl(baseUrl) {
|
|
|
74
78
|
`baseUrl must use https:// (got "${u.protocol}//"). Plaintext would expose the token/secret on the wire.`
|
|
75
79
|
);
|
|
76
80
|
}
|
|
77
|
-
var
|
|
81
|
+
var _Http = class _Http {
|
|
78
82
|
constructor(opts) {
|
|
79
83
|
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
80
84
|
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT;
|
|
@@ -86,12 +90,21 @@ var Http = class {
|
|
|
86
90
|
}
|
|
87
91
|
this.fetchImpl = f;
|
|
88
92
|
this.userAgent = opts.userAgent;
|
|
93
|
+
this.maxRetries = Math.max(0, opts.maxRetries ?? 2);
|
|
94
|
+
this.retryBaseDelayMs = opts.retryBaseDelayMs ?? 250;
|
|
95
|
+
this.hooks = opts.hooks;
|
|
89
96
|
}
|
|
90
|
-
|
|
91
|
-
|
|
97
|
+
/**
|
|
98
|
+
* @param opts.idempotent - mark this POST safe to auto-retry (it carries an
|
|
99
|
+
* idempotency key, or has no side effect). Default false: non-idempotent POSTs
|
|
100
|
+
* (e.g. single-use WebAuthn assertions) are NEVER auto-retried.
|
|
101
|
+
*/
|
|
102
|
+
async post(path, body, auth, opts) {
|
|
103
|
+
return this.request("POST", path, body, auth, opts?.idempotent ?? false, opts?.signal);
|
|
92
104
|
}
|
|
93
|
-
|
|
94
|
-
|
|
105
|
+
// GET is always idempotent → safe to retry.
|
|
106
|
+
async get(path, auth, opts) {
|
|
107
|
+
return this.request("GET", path, void 0, auth, true, opts?.signal);
|
|
95
108
|
}
|
|
96
109
|
authHeaders(auth) {
|
|
97
110
|
switch (auth.kind) {
|
|
@@ -103,7 +116,7 @@ var Http = class {
|
|
|
103
116
|
return {};
|
|
104
117
|
}
|
|
105
118
|
}
|
|
106
|
-
async request(method, path, body, auth) {
|
|
119
|
+
async request(method, path, body, auth, idempotent, signal) {
|
|
107
120
|
const url = `${this.baseUrl}${path}`;
|
|
108
121
|
const headers = {
|
|
109
122
|
Accept: "application/json",
|
|
@@ -111,41 +124,289 @@ var Http = class {
|
|
|
111
124
|
};
|
|
112
125
|
if (this.userAgent) headers["User-Agent"] = this.userAgent;
|
|
113
126
|
if (body !== void 0) headers["Content-Type"] = "application/json";
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
127
|
+
const payload = body !== void 0 ? JSON.stringify(body) : void 0;
|
|
128
|
+
for (let attempt = 0; ; attempt++) {
|
|
129
|
+
if (signal?.aborted) throw abortError(path);
|
|
130
|
+
const start = Date.now();
|
|
131
|
+
this.fire("onRequest", { method, url, attempt });
|
|
132
|
+
const controller = new AbortController();
|
|
133
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
134
|
+
const onAbort = () => controller.abort();
|
|
135
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
136
|
+
let res;
|
|
137
|
+
let networkError;
|
|
138
|
+
try {
|
|
139
|
+
res = await this.fetchImpl(url, { method, headers, body: payload, signal: controller.signal });
|
|
140
|
+
} catch (err) {
|
|
141
|
+
networkError = new InvoError({
|
|
142
|
+
message: err instanceof Error && err.name === "AbortError" ? `Request to ${path} timed out after ${this.timeoutMs}ms` : `Network error calling ${path}: ${err?.message ?? err}`,
|
|
143
|
+
status: 0,
|
|
144
|
+
body: null
|
|
145
|
+
});
|
|
146
|
+
} finally {
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
signal?.removeEventListener("abort", onAbort);
|
|
149
|
+
}
|
|
150
|
+
if (networkError) {
|
|
151
|
+
if (signal?.aborted) throw abortError(path);
|
|
152
|
+
const willRetry = idempotent && attempt < this.maxRetries;
|
|
153
|
+
this.fire("onError", { method, url, attempt, error: networkError, willRetry });
|
|
154
|
+
if (willRetry) {
|
|
155
|
+
await sleep(this.backoff(attempt), signal);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
throw networkError;
|
|
159
|
+
}
|
|
160
|
+
const requestId = pickRequestId(res.headers);
|
|
161
|
+
const text = await res.text();
|
|
162
|
+
let parsed = null;
|
|
163
|
+
if (text) {
|
|
164
|
+
try {
|
|
165
|
+
parsed = JSON.parse(text);
|
|
166
|
+
} catch {
|
|
167
|
+
parsed = text;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
this.fire("onResponse", {
|
|
119
171
|
method,
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
throw new InvoError({
|
|
126
|
-
message: err instanceof Error && err.name === "AbortError" ? `Request to ${path} timed out after ${this.timeoutMs}ms` : `Network error calling ${path}: ${err?.message ?? err}`,
|
|
127
|
-
status: 0,
|
|
128
|
-
body: null
|
|
172
|
+
url,
|
|
173
|
+
attempt,
|
|
174
|
+
status: res.status,
|
|
175
|
+
durationMs: Date.now() - start,
|
|
176
|
+
requestId
|
|
129
177
|
});
|
|
130
|
-
|
|
178
|
+
if (!res.ok) {
|
|
179
|
+
const err = errorFromResponse(res.status, parsed, requestId);
|
|
180
|
+
let wait;
|
|
181
|
+
if (idempotent && attempt < this.maxRetries && _Http.RETRIABLE_STATUS.has(res.status)) {
|
|
182
|
+
if (res.status === 429) {
|
|
183
|
+
const ra = retryAfterMs(parsed, res.headers);
|
|
184
|
+
wait = ra === void 0 ? this.backoff(attempt) : ra <= MAX_RETRY_AFTER_MS ? ra : void 0;
|
|
185
|
+
} else {
|
|
186
|
+
wait = this.backoff(attempt);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
this.fire("onError", { method, url, attempt, error: err, willRetry: wait !== void 0 });
|
|
190
|
+
if (wait !== void 0) {
|
|
191
|
+
await sleep(wait, signal);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
throw err;
|
|
195
|
+
}
|
|
196
|
+
return parsed ?? {};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** Invoke an observability hook, swallowing any error it throws (best-effort). */
|
|
200
|
+
fire(name, info) {
|
|
201
|
+
const hook = this.hooks?.[name];
|
|
202
|
+
if (!hook) return;
|
|
203
|
+
try {
|
|
204
|
+
hook(info);
|
|
205
|
+
} catch {
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** Exponential backoff with full jitter: base * 2^attempt, randomized. */
|
|
209
|
+
backoff(attempt) {
|
|
210
|
+
const ceil = this.retryBaseDelayMs * 2 ** attempt;
|
|
211
|
+
return Math.floor(Math.random() * ceil) + this.retryBaseDelayMs;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
/** HTTP statuses worth retrying — rate limit + transient gateway/server errors. */
|
|
215
|
+
_Http.RETRIABLE_STATUS = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
216
|
+
var Http = _Http;
|
|
217
|
+
function sleep(ms, signal) {
|
|
218
|
+
return new Promise((resolve) => {
|
|
219
|
+
if (signal?.aborted) return resolve();
|
|
220
|
+
const timer = setTimeout(done, ms);
|
|
221
|
+
const onAbort = () => done();
|
|
222
|
+
function done() {
|
|
131
223
|
clearTimeout(timer);
|
|
224
|
+
signal?.removeEventListener("abort", onAbort);
|
|
225
|
+
resolve();
|
|
132
226
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
227
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
function abortError(path) {
|
|
231
|
+
return new InvoError({ message: `Request to ${path} was aborted`, code: "ABORTED", status: 0 });
|
|
232
|
+
}
|
|
233
|
+
function pickRequestId(headers) {
|
|
234
|
+
if (!headers || typeof headers.get !== "function") return void 0;
|
|
235
|
+
return headers.get("x-invo-request-id") ?? headers.get("x-request-id") ?? void 0;
|
|
236
|
+
}
|
|
237
|
+
function retryAfterMs(parsed, headers) {
|
|
238
|
+
if (parsed && typeof parsed === "object") {
|
|
239
|
+
const v = parsed["retry_after"];
|
|
240
|
+
const n = typeof v === "string" ? Number(v) : v;
|
|
241
|
+
if (typeof n === "number" && Number.isFinite(n) && n >= 0) return n * 1e3;
|
|
242
|
+
}
|
|
243
|
+
const header = headers && typeof headers.get === "function" ? headers.get("retry-after") : null;
|
|
244
|
+
if (header) {
|
|
245
|
+
const n = Number(header);
|
|
246
|
+
if (Number.isFinite(n) && n >= 0) return n * 1e3;
|
|
247
|
+
}
|
|
248
|
+
return void 0;
|
|
249
|
+
}
|
|
250
|
+
var DEFAULT_TOLERANCE_SEC = 300;
|
|
251
|
+
var ENCODER = new TextEncoder();
|
|
252
|
+
function toBytes(body) {
|
|
253
|
+
return typeof body === "string" ? ENCODER.encode(body) : body;
|
|
254
|
+
}
|
|
255
|
+
function concatBytes(a, b) {
|
|
256
|
+
const out = new Uint8Array(a.length + b.length);
|
|
257
|
+
out.set(a, 0);
|
|
258
|
+
out.set(b, a.length);
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
function webhookError(message, code) {
|
|
262
|
+
return new InvoError({ message, code, status: 0 });
|
|
263
|
+
}
|
|
264
|
+
function parseSignatureHeader(header) {
|
|
265
|
+
let t = "";
|
|
266
|
+
const sigs = [];
|
|
267
|
+
for (const part of header.split(",")) {
|
|
268
|
+
const idx = part.indexOf("=");
|
|
269
|
+
if (idx === -1) continue;
|
|
270
|
+
const key = part.slice(0, idx).trim();
|
|
271
|
+
const val = part.slice(idx + 1).trim();
|
|
272
|
+
if (key === "t") t = val;
|
|
273
|
+
else if (key === "v1" && val) sigs.push(val);
|
|
274
|
+
}
|
|
275
|
+
return { t, sigs };
|
|
276
|
+
}
|
|
277
|
+
function hmacHex(secret, message) {
|
|
278
|
+
return crypto.createHmac("sha256", secret).update(message).digest("hex");
|
|
279
|
+
}
|
|
280
|
+
function safeEqualHex(a, b) {
|
|
281
|
+
if (a.length !== b.length) return false;
|
|
282
|
+
let diff = 0;
|
|
283
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
284
|
+
return diff === 0;
|
|
285
|
+
}
|
|
286
|
+
function prepareVerification(rawBody, signatureHeader, secret, opts) {
|
|
287
|
+
if (!signatureHeader) {
|
|
288
|
+
throw webhookError("Missing X-Invo-Signature header.", "WEBHOOK_SIGNATURE_MISSING");
|
|
289
|
+
}
|
|
290
|
+
const secrets = (Array.isArray(secret) ? secret : [secret]).filter(Boolean);
|
|
291
|
+
if (secrets.length === 0) {
|
|
292
|
+
throw webhookError("A signing secret is required to verify webhooks.", "WEBHOOK_SECRET_MISSING");
|
|
293
|
+
}
|
|
294
|
+
const { t, sigs } = parseSignatureHeader(signatureHeader);
|
|
295
|
+
if (!t || sigs.length === 0) {
|
|
296
|
+
throw webhookError("Malformed X-Invo-Signature header.", "WEBHOOK_MALFORMED");
|
|
297
|
+
}
|
|
298
|
+
const ts = Number(t);
|
|
299
|
+
const now = opts.nowSec ?? Math.floor(Date.now() / 1e3);
|
|
300
|
+
const tolerance = opts.toleranceSec ?? DEFAULT_TOLERANCE_SEC;
|
|
301
|
+
if (!Number.isFinite(ts) || Math.abs(now - ts) > tolerance) {
|
|
302
|
+
throw webhookError(
|
|
303
|
+
`Webhook timestamp outside the ${tolerance}s tolerance (replay guard).`,
|
|
304
|
+
"WEBHOOK_TIMESTAMP_EXPIRED"
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
const bodyBytes = toBytes(rawBody);
|
|
308
|
+
const message = concatBytes(ENCODER.encode(`${t}.`), bodyBytes);
|
|
309
|
+
return { secrets, sigs, message, bodyBytes };
|
|
310
|
+
}
|
|
311
|
+
function finalizeEvent(bodyBytes) {
|
|
312
|
+
let parsed;
|
|
313
|
+
try {
|
|
314
|
+
parsed = JSON.parse(new TextDecoder().decode(bodyBytes));
|
|
315
|
+
} catch {
|
|
316
|
+
throw webhookError("Webhook body is not valid JSON.", "WEBHOOK_MALFORMED");
|
|
317
|
+
}
|
|
318
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed) || typeof parsed.event_type !== "string") {
|
|
319
|
+
throw webhookError("Webhook body is not a valid event object.", "WEBHOOK_MALFORMED");
|
|
320
|
+
}
|
|
321
|
+
return parsed;
|
|
322
|
+
}
|
|
323
|
+
function verifyWebhook(rawBody, signatureHeader, secret, opts = {}) {
|
|
324
|
+
const { secrets, sigs, message, bodyBytes } = prepareVerification(
|
|
325
|
+
rawBody,
|
|
326
|
+
signatureHeader,
|
|
327
|
+
secret,
|
|
328
|
+
opts
|
|
329
|
+
);
|
|
330
|
+
const matched = secrets.some((s) => {
|
|
331
|
+
const expected = hmacHex(s, message);
|
|
332
|
+
return sigs.some((sig) => safeEqualHex(sig, expected));
|
|
333
|
+
});
|
|
334
|
+
if (!matched) {
|
|
335
|
+
throw webhookError("Webhook signature verification failed.", "WEBHOOK_SIGNATURE_INVALID");
|
|
336
|
+
}
|
|
337
|
+
return finalizeEvent(bodyBytes);
|
|
338
|
+
}
|
|
339
|
+
async function verifyWebhookAsync(rawBody, signatureHeader, secret, opts = {}) {
|
|
340
|
+
const { secrets, sigs, message, bodyBytes } = prepareVerification(
|
|
341
|
+
rawBody,
|
|
342
|
+
signatureHeader,
|
|
343
|
+
secret,
|
|
344
|
+
opts
|
|
345
|
+
);
|
|
346
|
+
for (const s of secrets) {
|
|
347
|
+
const expected = await hmacHexSubtle(s, message);
|
|
348
|
+
if (sigs.some((sig) => safeEqualHex(sig, expected))) {
|
|
349
|
+
return finalizeEvent(bodyBytes);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
throw webhookError("Webhook signature verification failed.", "WEBHOOK_SIGNATURE_INVALID");
|
|
353
|
+
}
|
|
354
|
+
function createWebhookHandler(opts) {
|
|
355
|
+
return async (request) => {
|
|
356
|
+
const raw = await request.text();
|
|
357
|
+
let event;
|
|
358
|
+
try {
|
|
359
|
+
event = await verifyWebhookAsync(raw, request.headers.get("x-invo-signature"), opts.secret, {
|
|
360
|
+
toleranceSec: opts.toleranceSec
|
|
361
|
+
});
|
|
362
|
+
} catch (e) {
|
|
363
|
+
const err = e instanceof InvoError ? e : new InvoError({ message: String(e), status: 0 });
|
|
364
|
+
if (opts.onError) {
|
|
365
|
+
try {
|
|
366
|
+
const r = await opts.onError(err, request);
|
|
367
|
+
if (r instanceof Response) return r;
|
|
368
|
+
} catch {
|
|
369
|
+
}
|
|
140
370
|
}
|
|
371
|
+
return new Response(err.code ?? "invalid_signature", { status: 400 });
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
await opts.onEvent(event, {
|
|
375
|
+
// Prefer the signature-covered body field over the (unauthenticated, optional)
|
|
376
|
+
// header — the body value is HMAC-verified and always present.
|
|
377
|
+
idempotencyKey: event.idempotency_key ?? request.headers.get("x-invo-idempotency-key"),
|
|
378
|
+
request
|
|
379
|
+
});
|
|
380
|
+
} catch {
|
|
381
|
+
return new Response("handler_error", { status: 500 });
|
|
141
382
|
}
|
|
142
|
-
|
|
143
|
-
|
|
383
|
+
return new Response(null, { status: 200 });
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
async function hmacHexSubtle(secret, message) {
|
|
387
|
+
const subtle = globalThis.crypto?.subtle;
|
|
388
|
+
if (!subtle) {
|
|
389
|
+
throw webhookError(
|
|
390
|
+
"Web Crypto (crypto.subtle) is unavailable in this runtime.",
|
|
391
|
+
"WEBHOOK_UNSUPPORTED"
|
|
392
|
+
);
|
|
144
393
|
}
|
|
145
|
-
|
|
394
|
+
const key = await subtle.importKey(
|
|
395
|
+
"raw",
|
|
396
|
+
ENCODER.encode(secret),
|
|
397
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
398
|
+
false,
|
|
399
|
+
["sign"]
|
|
400
|
+
);
|
|
401
|
+
const sig = await subtle.sign("HMAC", key, message);
|
|
402
|
+
const bytes = new Uint8Array(sig);
|
|
403
|
+
let hex = "";
|
|
404
|
+
for (let i = 0; i < bytes.length; i++) hex += bytes[i].toString(16).padStart(2, "0");
|
|
405
|
+
return hex;
|
|
406
|
+
}
|
|
146
407
|
|
|
147
408
|
// src/server.ts
|
|
148
|
-
var DEFAULT_UA = "invonetwork-web-sdk/0.
|
|
409
|
+
var DEFAULT_UA = "invonetwork-web-sdk/0.4.0 (+https://invo.network)";
|
|
149
410
|
var MAX_USD_AMOUNT = 999.99;
|
|
150
411
|
var MAX_ITEM_PRICE = 999999.99;
|
|
151
412
|
function invalidInput(label, value, why) {
|
|
@@ -177,6 +438,41 @@ function parseMoney(value, max, label) {
|
|
|
177
438
|
function assertUsdAmount(usdAmount) {
|
|
178
439
|
parseMoney(usdAmount, MAX_USD_AMOUNT, "usdAmount");
|
|
179
440
|
}
|
|
441
|
+
function toOrderDetails(raw) {
|
|
442
|
+
return {
|
|
443
|
+
order: raw["order"] ?? {},
|
|
444
|
+
financialSummary: raw["financial_summary"] ?? {},
|
|
445
|
+
statusTimeline: raw["status_timeline"] ?? null,
|
|
446
|
+
raw
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
function toInboundPendingItem(row) {
|
|
450
|
+
return {
|
|
451
|
+
transactionId: String(row["transaction_id"] ?? ""),
|
|
452
|
+
flow: String(row["flow"] ?? ""),
|
|
453
|
+
amount: String(row["amount"] ?? ""),
|
|
454
|
+
netAmount: String(row["net_amount"] ?? ""),
|
|
455
|
+
sourceGameId: row["source_game_id"] ?? "",
|
|
456
|
+
sourceGame: String(row["source_game"] ?? ""),
|
|
457
|
+
toPhone: String(row["to_phone"] ?? ""),
|
|
458
|
+
toIdentityId: row["to_identity_id"] ?? null,
|
|
459
|
+
createdAt: String(row["created_at"] ?? ""),
|
|
460
|
+
claimCodeExpiresAt: String(row["claim_code_expires_at"] ?? ""),
|
|
461
|
+
raw: row
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
function toCurrencyBalance(row) {
|
|
465
|
+
return {
|
|
466
|
+
currencyId: row["currency_id"] ?? "",
|
|
467
|
+
currencyName: String(row["currency_name"] ?? ""),
|
|
468
|
+
currencySymbol: String(row["currency_symbol"] ?? ""),
|
|
469
|
+
availableBalance: String(row["available_balance"] ?? ""),
|
|
470
|
+
reservedBalance: String(row["reserved_balance"] ?? ""),
|
|
471
|
+
totalBalance: String(row["total_balance"] ?? ""),
|
|
472
|
+
lastTransaction: row["last_transaction"],
|
|
473
|
+
raw: row
|
|
474
|
+
};
|
|
475
|
+
}
|
|
180
476
|
function requireField(value, field, raw) {
|
|
181
477
|
const s = value == null ? "" : String(value);
|
|
182
478
|
if (!s) {
|
|
@@ -198,17 +494,25 @@ var InvoServer = class {
|
|
|
198
494
|
baseUrl: config.baseUrl,
|
|
199
495
|
timeoutMs: config.timeoutMs,
|
|
200
496
|
fetchImpl: config.fetch,
|
|
201
|
-
userAgent: DEFAULT_UA
|
|
497
|
+
userAgent: DEFAULT_UA,
|
|
202
498
|
// must be a non-blocked UA (handoff doc §9)
|
|
499
|
+
maxRetries: config.maxRetries,
|
|
500
|
+
retryBaseDelayMs: config.retryBaseDelayMs,
|
|
501
|
+
hooks: config.hooks
|
|
203
502
|
});
|
|
204
503
|
this.auth = { kind: "game-secret", secret: config.gameSecret };
|
|
205
504
|
}
|
|
206
505
|
/** Mint a short-lived, game-scoped player token to hand to the browser InvoClient. */
|
|
207
|
-
async mintPlayerToken(input) {
|
|
506
|
+
async mintPlayerToken(input, opts) {
|
|
507
|
+
if (typeof input.playerEmail !== "string" || !input.playerEmail.trim()) {
|
|
508
|
+
throw invalidInput("playerEmail", input.playerEmail, "is required");
|
|
509
|
+
}
|
|
208
510
|
const raw = await this.http.post(
|
|
209
511
|
"/api/sdk/player-token",
|
|
210
512
|
{ player_email: input.playerEmail },
|
|
211
|
-
this.auth
|
|
513
|
+
this.auth,
|
|
514
|
+
{ idempotent: true, signal: opts?.signal }
|
|
515
|
+
// safe to retry: re-mint just issues a fresh token
|
|
212
516
|
);
|
|
213
517
|
return {
|
|
214
518
|
token: requireField(raw["token"], "token", raw),
|
|
@@ -217,7 +521,7 @@ var InvoServer = class {
|
|
|
217
521
|
};
|
|
218
522
|
}
|
|
219
523
|
/** Initiate a cross-game currency SEND. Inspect result.verificationMethod. */
|
|
220
|
-
async initiateSend(input) {
|
|
524
|
+
async initiateSend(input, opts) {
|
|
221
525
|
const raw = await this.http.post(
|
|
222
526
|
"/api/currency-sends/initiate-send",
|
|
223
527
|
{
|
|
@@ -230,12 +534,14 @@ var InvoServer = class {
|
|
|
230
534
|
receiving_game_id: input.receivingGameId,
|
|
231
535
|
amount: input.amount
|
|
232
536
|
},
|
|
233
|
-
this.auth
|
|
537
|
+
this.auth,
|
|
538
|
+
{ idempotent: true, signal: opts?.signal }
|
|
539
|
+
// client_request_id makes this safe to retry (backend dedupes)
|
|
234
540
|
);
|
|
235
541
|
return this.toInitiateResult(raw);
|
|
236
542
|
}
|
|
237
543
|
/** Initiate a cross-game TRANSFER. Inspect result.verificationMethod. */
|
|
238
|
-
async initiateTransfer(input) {
|
|
544
|
+
async initiateTransfer(input, opts) {
|
|
239
545
|
const raw = await this.http.post(
|
|
240
546
|
"/api/transfers/initiate-transfer",
|
|
241
547
|
{
|
|
@@ -248,7 +554,9 @@ var InvoServer = class {
|
|
|
248
554
|
target_game_id: input.targetGameId,
|
|
249
555
|
amount: input.amount
|
|
250
556
|
},
|
|
251
|
-
this.auth
|
|
557
|
+
this.auth,
|
|
558
|
+
{ idempotent: true, signal: opts?.signal }
|
|
559
|
+
// client_request_id makes this safe to retry (backend dedupes)
|
|
252
560
|
);
|
|
253
561
|
return this.toInitiateResult(raw);
|
|
254
562
|
}
|
|
@@ -256,7 +564,10 @@ var InvoServer = class {
|
|
|
256
564
|
* returned checkoutUrl in a WebView/redirect or an iframe; the INVO-hosted page
|
|
257
565
|
* handles cards, saved cards, and 3-D Secure. Crediting is server-side via the
|
|
258
566
|
* purchase.completed webhook. */
|
|
259
|
-
async createCheckout(input) {
|
|
567
|
+
async createCheckout(input, opts) {
|
|
568
|
+
if (typeof input.playerEmail !== "string" || !input.playerEmail.trim()) {
|
|
569
|
+
throw invalidInput("playerEmail", input.playerEmail, "is required");
|
|
570
|
+
}
|
|
260
571
|
assertUsdAmount(input.usdAmount);
|
|
261
572
|
const body = {
|
|
262
573
|
player_email: input.playerEmail,
|
|
@@ -269,7 +580,9 @@ var InvoServer = class {
|
|
|
269
580
|
const raw = await this.http.post(
|
|
270
581
|
"/api/checkout/sessions",
|
|
271
582
|
body,
|
|
272
|
-
this.auth
|
|
583
|
+
this.auth,
|
|
584
|
+
{ signal: opts?.signal }
|
|
585
|
+
// not idempotent — never auto-retried
|
|
273
586
|
);
|
|
274
587
|
return {
|
|
275
588
|
sessionId: String(raw["session_id"] ?? ""),
|
|
@@ -285,7 +598,10 @@ var InvoServer = class {
|
|
|
285
598
|
* - rail "game": returns status "pending_payment" + paymentUrl (redirect the browser).
|
|
286
599
|
* - rail "steam": NOT a browser flow — the backend returns WRONG_RAIL_ENDPOINT.
|
|
287
600
|
*/
|
|
288
|
-
async purchaseCurrency(input) {
|
|
601
|
+
async purchaseCurrency(input, opts) {
|
|
602
|
+
if (typeof input.playerEmail !== "string" || !input.playerEmail.trim()) {
|
|
603
|
+
throw invalidInput("playerEmail", input.playerEmail, "is required");
|
|
604
|
+
}
|
|
289
605
|
assertUsdAmount(input.usdAmount);
|
|
290
606
|
if (!input.purchaseReference) {
|
|
291
607
|
throw new InvoError({
|
|
@@ -314,7 +630,9 @@ var InvoServer = class {
|
|
|
314
630
|
const raw = await this.http.post(
|
|
315
631
|
"/api/currency-purchases/purchase-currency",
|
|
316
632
|
body,
|
|
317
|
-
this.auth
|
|
633
|
+
this.auth,
|
|
634
|
+
{ idempotent: true, signal: opts?.signal }
|
|
635
|
+
// purchase_reference makes this safe to retry (backend dedupes)
|
|
318
636
|
);
|
|
319
637
|
return {
|
|
320
638
|
status: String(raw["status"] ?? ""),
|
|
@@ -328,17 +646,25 @@ var InvoServer = class {
|
|
|
328
646
|
};
|
|
329
647
|
}
|
|
330
648
|
/** Complete the Stripe-rail 3-D Secure step after the client finished card action. */
|
|
331
|
-
async confirmPayment(input) {
|
|
649
|
+
async confirmPayment(input, opts) {
|
|
332
650
|
const body = { payment_intent_id: input.paymentIntentId };
|
|
333
651
|
if (input.orderId) body["order_id"] = input.orderId;
|
|
334
|
-
|
|
652
|
+
const raw = await this.http.post(
|
|
335
653
|
"/api/currency-purchases/confirm-payment",
|
|
336
654
|
body,
|
|
337
|
-
this.auth
|
|
655
|
+
this.auth,
|
|
656
|
+
{ idempotent: true, signal: opts?.signal }
|
|
657
|
+
// keyed by payment_intent_id — safe to retry
|
|
338
658
|
);
|
|
659
|
+
return {
|
|
660
|
+
status: String(raw["status"] ?? ""),
|
|
661
|
+
transactionId: raw["transaction_id"],
|
|
662
|
+
newBalance: raw["new_balance"] ?? null,
|
|
663
|
+
raw
|
|
664
|
+
};
|
|
339
665
|
}
|
|
340
666
|
/** Fetch purchase status (order + financial summary + timeline). */
|
|
341
|
-
async getOrderDetails(query) {
|
|
667
|
+
async getOrderDetails(query, opts) {
|
|
342
668
|
if (!query.orderId && !query.transactionId) {
|
|
343
669
|
throw new InvoError({
|
|
344
670
|
message: "getOrderDetails requires an `orderId` or `transactionId`.",
|
|
@@ -349,10 +675,12 @@ var InvoServer = class {
|
|
|
349
675
|
const q = new URLSearchParams();
|
|
350
676
|
if (query.orderId) q.set("order_id", query.orderId);
|
|
351
677
|
if (query.transactionId) q.set("transaction_id", query.transactionId);
|
|
352
|
-
|
|
678
|
+
const raw = await this.http.get(
|
|
353
679
|
`/api/currency-purchases/order-details?${q.toString()}`,
|
|
354
|
-
this.auth
|
|
680
|
+
this.auth,
|
|
681
|
+
{ signal: opts?.signal }
|
|
355
682
|
);
|
|
683
|
+
return toOrderDetails(raw);
|
|
356
684
|
}
|
|
357
685
|
/**
|
|
358
686
|
* Buy an in-game item by SPENDING the player's existing game currency (§4.8).
|
|
@@ -362,7 +690,7 @@ var InvoServer = class {
|
|
|
362
690
|
* (err.isInsufficientBalance; required_amount/current_balance on err.body). Grant the
|
|
363
691
|
* item to your inventory off the `item.purchased` webhook, not just this response.
|
|
364
692
|
*/
|
|
365
|
-
async purchaseItem(input) {
|
|
693
|
+
async purchaseItem(input, opts) {
|
|
366
694
|
const required = [
|
|
367
695
|
["clientRequestId", input.clientRequestId],
|
|
368
696
|
["playerEmail", input.playerEmail],
|
|
@@ -403,7 +731,9 @@ var InvoServer = class {
|
|
|
403
731
|
const raw = await this.http.post(
|
|
404
732
|
"/api/item-purchases/purchase-item",
|
|
405
733
|
body,
|
|
406
|
-
this.auth
|
|
734
|
+
this.auth,
|
|
735
|
+
{ idempotent: true, signal: opts?.signal }
|
|
736
|
+
// client_request_id makes this safe to retry (dup → 409)
|
|
407
737
|
);
|
|
408
738
|
return {
|
|
409
739
|
status: String(raw["status"] ?? ""),
|
|
@@ -417,7 +747,7 @@ var InvoServer = class {
|
|
|
417
747
|
};
|
|
418
748
|
}
|
|
419
749
|
/** Paginated item-purchase history for a player (§4.8 companion read). */
|
|
420
|
-
async getItemPurchaseHistory(query) {
|
|
750
|
+
async getItemPurchaseHistory(query, opts) {
|
|
421
751
|
if (typeof query.playerEmail !== "string" || !query.playerEmail.trim()) {
|
|
422
752
|
throw invalidInput("playerEmail", query.playerEmail, "is required");
|
|
423
753
|
}
|
|
@@ -425,14 +755,20 @@ var InvoServer = class {
|
|
|
425
755
|
q.set("player_email", query.playerEmail);
|
|
426
756
|
if (query.limit != null) q.set("limit", String(query.limit));
|
|
427
757
|
if (query.offset != null) q.set("offset", String(query.offset));
|
|
428
|
-
|
|
758
|
+
const raw = await this.http.get(
|
|
429
759
|
`/api/item-purchases/player-purchase-history?${q.toString()}`,
|
|
430
|
-
this.auth
|
|
760
|
+
this.auth,
|
|
761
|
+
{ signal: opts?.signal }
|
|
431
762
|
);
|
|
763
|
+
return {
|
|
764
|
+
history: Array.isArray(raw["item_purchase_history"]) ? raw["item_purchase_history"] : [],
|
|
765
|
+
pagination: raw["pagination"] ?? {},
|
|
766
|
+
raw
|
|
767
|
+
};
|
|
432
768
|
}
|
|
433
769
|
/** Look up one item order by EXACTLY ONE of orderId | transactionId | clientRequestId
|
|
434
770
|
* (§4.8). Use clientRequestId for saga/recovery: "did this purchase actually complete?" */
|
|
435
|
-
async getItemOrderDetails(query) {
|
|
771
|
+
async getItemOrderDetails(query, opts) {
|
|
436
772
|
const provided = [query.orderId, query.transactionId, query.clientRequestId].filter(
|
|
437
773
|
(v) => typeof v === "string" && v.trim()
|
|
438
774
|
);
|
|
@@ -447,10 +783,86 @@ var InvoServer = class {
|
|
|
447
783
|
if (query.orderId) q.set("order_id", query.orderId);
|
|
448
784
|
if (query.transactionId) q.set("transaction_id", query.transactionId);
|
|
449
785
|
if (query.clientRequestId) q.set("client_request_id", query.clientRequestId);
|
|
450
|
-
|
|
786
|
+
const raw = await this.http.get(
|
|
451
787
|
`/api/item-purchases/order-details?${q.toString()}`,
|
|
452
|
-
this.auth
|
|
788
|
+
this.auth,
|
|
789
|
+
{ signal: opts?.signal }
|
|
790
|
+
);
|
|
791
|
+
return toOrderDetails(raw);
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Async iterator over a player's entire item-purchase history — pages through
|
|
795
|
+
* `getItemPurchaseHistory` (limit/offset) until exhausted, yielding one row at a time.
|
|
796
|
+
*
|
|
797
|
+
* ```ts
|
|
798
|
+
* for await (const row of invo.iterateItemPurchaseHistory({ playerEmail })) { … }
|
|
799
|
+
* ```
|
|
800
|
+
*/
|
|
801
|
+
async *iterateItemPurchaseHistory(query, opts) {
|
|
802
|
+
if (typeof query.playerEmail !== "string" || !query.playerEmail.trim()) {
|
|
803
|
+
throw invalidInput("playerEmail", query.playerEmail, "is required");
|
|
804
|
+
}
|
|
805
|
+
const pageSize = typeof query.pageSize === "number" && query.pageSize > 0 ? Math.floor(query.pageSize) : 50;
|
|
806
|
+
let yielded = 0;
|
|
807
|
+
for (let offset = 0; ; offset += pageSize) {
|
|
808
|
+
const { history, pagination } = await this.getItemPurchaseHistory(
|
|
809
|
+
{ playerEmail: query.playerEmail, limit: pageSize, offset },
|
|
810
|
+
opts
|
|
811
|
+
);
|
|
812
|
+
if (history.length === 0) return;
|
|
813
|
+
for (const row of history) yield row;
|
|
814
|
+
yielded += history.length;
|
|
815
|
+
if (history.length < pageSize) return;
|
|
816
|
+
const total = Number(pagination?.["total"]);
|
|
817
|
+
if (Number.isFinite(total) && yielded >= total) return;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
/** Read a player's currency balances, by email or playerId (game-secret). */
|
|
821
|
+
async getPlayerBalance(query, opts) {
|
|
822
|
+
let path;
|
|
823
|
+
if (query.playerId != null && String(query.playerId).trim()) {
|
|
824
|
+
path = `/api/player-balances/player/${encodeURIComponent(String(query.playerId))}`;
|
|
825
|
+
} else if (typeof query.playerEmail === "string" && query.playerEmail.trim()) {
|
|
826
|
+
path = `/api/player-balances/player/by-email/${encodeURIComponent(query.playerEmail)}`;
|
|
827
|
+
} else {
|
|
828
|
+
throw new InvoError({
|
|
829
|
+
message: "getPlayerBalance requires a playerEmail or playerId.",
|
|
830
|
+
code: "INVALID_INPUT",
|
|
831
|
+
status: 0
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
const raw = await this.http.get(path, this.auth, { signal: opts?.signal });
|
|
835
|
+
const rows = Array.isArray(raw["balances"]) ? raw["balances"] : [];
|
|
836
|
+
return {
|
|
837
|
+
player: raw["player"] ?? {},
|
|
838
|
+
balances: rows.map(toCurrencyBalance),
|
|
839
|
+
summary: raw["summary"] ?? {},
|
|
840
|
+
raw
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* List LIVE, unclaimed inbound sends/transfers addressed to a player at YOUR game —
|
|
845
|
+
* the source of truth for a "you have X to collect" badge. Pass the player's email
|
|
846
|
+
* or phone. Match `toPhone` to the logged-in player (don't require `toIdentityId`,
|
|
847
|
+
* which is null when the phone maps to more than one of your players). Pairs with the
|
|
848
|
+
* `transfer.claim_pending` webhook (the webhook is the wake-up; this is the list).
|
|
849
|
+
*/
|
|
850
|
+
async getInboundPending(query, opts) {
|
|
851
|
+
const hasEmail = typeof query.playerEmail === "string" && query.playerEmail.trim();
|
|
852
|
+
const hasPhone = typeof query.playerPhone === "string" && query.playerPhone.trim();
|
|
853
|
+
if (!hasEmail && !hasPhone) {
|
|
854
|
+
throw invalidInput("query", query, "requires a playerEmail or playerPhone");
|
|
855
|
+
}
|
|
856
|
+
const q = new URLSearchParams();
|
|
857
|
+
if (hasEmail) q.set("player_email", query.playerEmail.trim());
|
|
858
|
+
if (hasPhone) q.set("player_phone", query.playerPhone.trim());
|
|
859
|
+
const raw = await this.http.get(
|
|
860
|
+
`/api/transfers/inbound-pending?${q.toString()}`,
|
|
861
|
+
this.auth,
|
|
862
|
+
{ signal: opts?.signal }
|
|
453
863
|
);
|
|
864
|
+
const rows = Array.isArray(raw["inbound_pending"]) ? raw["inbound_pending"] : [];
|
|
865
|
+
return { inboundPending: rows.map(toInboundPendingItem), raw };
|
|
454
866
|
}
|
|
455
867
|
toInitiateResult(raw) {
|
|
456
868
|
const vm = raw["verification_method"];
|
|
@@ -466,5 +878,8 @@ var InvoServer = class {
|
|
|
466
878
|
|
|
467
879
|
exports.InvoError = InvoError;
|
|
468
880
|
exports.InvoServer = InvoServer;
|
|
881
|
+
exports.createWebhookHandler = createWebhookHandler;
|
|
882
|
+
exports.verifyWebhook = verifyWebhook;
|
|
883
|
+
exports.verifyWebhookAsync = verifyWebhookAsync;
|
|
469
884
|
//# sourceMappingURL=server.cjs.map
|
|
470
885
|
//# sourceMappingURL=server.cjs.map
|