@invonetwork/web-sdk 0.2.1 → 0.3.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 +27 -0
- package/LICENSE +18 -17
- package/README.md +464 -393
- package/dist/chunk-DV3WZGMH.js +231 -0
- package/dist/index.cjs +121 -34
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +6 -3
- package/dist/server.cjs +288 -44
- package/dist/server.d.cts +112 -7
- package/dist/server.d.ts +112 -7
- package/dist/server.js +173 -14
- package/dist/{errors-DV5QsftP.d.cts → types-CBkoUymV.d.cts} +121 -42
- package/dist/{errors-DV5QsftP.d.ts → types-CBkoUymV.d.ts} +121 -42
- package/package.json +2 -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);
|
|
92
104
|
}
|
|
105
|
+
// GET is always idempotent → safe to retry.
|
|
93
106
|
async get(path, auth) {
|
|
94
|
-
return this.request("GET", path, void 0, auth);
|
|
107
|
+
return this.request("GET", path, void 0, auth, true);
|
|
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) {
|
|
107
120
|
const url = `${this.baseUrl}${path}`;
|
|
108
121
|
const headers = {
|
|
109
122
|
Accept: "application/json",
|
|
@@ -111,38 +124,188 @@ 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
|
+
const start = Date.now();
|
|
130
|
+
this.fire("onRequest", { method, url, attempt });
|
|
131
|
+
const controller = new AbortController();
|
|
132
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
133
|
+
let res;
|
|
134
|
+
let networkError;
|
|
135
|
+
try {
|
|
136
|
+
res = await this.fetchImpl(url, { method, headers, body: payload, signal: controller.signal });
|
|
137
|
+
} catch (err) {
|
|
138
|
+
networkError = new InvoError({
|
|
139
|
+
message: err instanceof Error && err.name === "AbortError" ? `Request to ${path} timed out after ${this.timeoutMs}ms` : `Network error calling ${path}: ${err?.message ?? err}`,
|
|
140
|
+
status: 0,
|
|
141
|
+
body: null
|
|
142
|
+
});
|
|
143
|
+
} finally {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
}
|
|
146
|
+
if (networkError) {
|
|
147
|
+
const willRetry = idempotent && attempt < this.maxRetries;
|
|
148
|
+
this.fire("onError", { method, url, attempt, error: networkError, willRetry });
|
|
149
|
+
if (willRetry) {
|
|
150
|
+
await sleep(this.backoff(attempt));
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
throw networkError;
|
|
154
|
+
}
|
|
155
|
+
const requestId = pickRequestId(res.headers);
|
|
156
|
+
const text = await res.text();
|
|
157
|
+
let parsed = null;
|
|
158
|
+
if (text) {
|
|
159
|
+
try {
|
|
160
|
+
parsed = JSON.parse(text);
|
|
161
|
+
} catch {
|
|
162
|
+
parsed = text;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
this.fire("onResponse", {
|
|
119
166
|
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
|
|
167
|
+
url,
|
|
168
|
+
attempt,
|
|
169
|
+
status: res.status,
|
|
170
|
+
durationMs: Date.now() - start,
|
|
171
|
+
requestId
|
|
129
172
|
});
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
const err = errorFromResponse(res.status, parsed, requestId);
|
|
175
|
+
let wait;
|
|
176
|
+
if (idempotent && attempt < this.maxRetries && _Http.RETRIABLE_STATUS.has(res.status)) {
|
|
177
|
+
if (res.status === 429) {
|
|
178
|
+
const ra = retryAfterMs(parsed, res.headers);
|
|
179
|
+
wait = ra === void 0 ? this.backoff(attempt) : ra <= MAX_RETRY_AFTER_MS ? ra : void 0;
|
|
180
|
+
} else {
|
|
181
|
+
wait = this.backoff(attempt);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
this.fire("onError", { method, url, attempt, error: err, willRetry: wait !== void 0 });
|
|
185
|
+
if (wait !== void 0) {
|
|
186
|
+
await sleep(wait);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
throw err;
|
|
140
190
|
}
|
|
191
|
+
return parsed ?? {};
|
|
141
192
|
}
|
|
142
|
-
|
|
143
|
-
|
|
193
|
+
}
|
|
194
|
+
/** Invoke an observability hook, swallowing any error it throws (best-effort). */
|
|
195
|
+
fire(name, info) {
|
|
196
|
+
const hook = this.hooks?.[name];
|
|
197
|
+
if (!hook) return;
|
|
198
|
+
try {
|
|
199
|
+
hook(info);
|
|
200
|
+
} catch {
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** Exponential backoff with full jitter: base * 2^attempt, randomized. */
|
|
204
|
+
backoff(attempt) {
|
|
205
|
+
const ceil = this.retryBaseDelayMs * 2 ** attempt;
|
|
206
|
+
return Math.floor(Math.random() * ceil) + this.retryBaseDelayMs;
|
|
144
207
|
}
|
|
145
208
|
};
|
|
209
|
+
/** HTTP statuses worth retrying — rate limit + transient gateway/server errors. */
|
|
210
|
+
_Http.RETRIABLE_STATUS = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
211
|
+
var Http = _Http;
|
|
212
|
+
function sleep(ms) {
|
|
213
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
214
|
+
}
|
|
215
|
+
function pickRequestId(headers) {
|
|
216
|
+
if (!headers || typeof headers.get !== "function") return void 0;
|
|
217
|
+
return headers.get("x-invo-request-id") ?? headers.get("x-request-id") ?? void 0;
|
|
218
|
+
}
|
|
219
|
+
function retryAfterMs(parsed, headers) {
|
|
220
|
+
if (parsed && typeof parsed === "object") {
|
|
221
|
+
const v = parsed["retry_after"];
|
|
222
|
+
const n = typeof v === "string" ? Number(v) : v;
|
|
223
|
+
if (typeof n === "number" && Number.isFinite(n) && n >= 0) return n * 1e3;
|
|
224
|
+
}
|
|
225
|
+
const header = headers && typeof headers.get === "function" ? headers.get("retry-after") : null;
|
|
226
|
+
if (header) {
|
|
227
|
+
const n = Number(header);
|
|
228
|
+
if (Number.isFinite(n) && n >= 0) return n * 1e3;
|
|
229
|
+
}
|
|
230
|
+
return void 0;
|
|
231
|
+
}
|
|
232
|
+
var DEFAULT_TOLERANCE_SEC = 300;
|
|
233
|
+
var ENCODER = new TextEncoder();
|
|
234
|
+
function toBytes(body) {
|
|
235
|
+
return typeof body === "string" ? ENCODER.encode(body) : body;
|
|
236
|
+
}
|
|
237
|
+
function concatBytes(a, b) {
|
|
238
|
+
const out = new Uint8Array(a.length + b.length);
|
|
239
|
+
out.set(a, 0);
|
|
240
|
+
out.set(b, a.length);
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
function webhookError(message, code) {
|
|
244
|
+
return new InvoError({ message, code, status: 0 });
|
|
245
|
+
}
|
|
246
|
+
function parseSignatureHeader(header) {
|
|
247
|
+
let t = "";
|
|
248
|
+
const sigs = [];
|
|
249
|
+
for (const part of header.split(",")) {
|
|
250
|
+
const idx = part.indexOf("=");
|
|
251
|
+
if (idx === -1) continue;
|
|
252
|
+
const key = part.slice(0, idx).trim();
|
|
253
|
+
const val = part.slice(idx + 1).trim();
|
|
254
|
+
if (key === "t") t = val;
|
|
255
|
+
else if (key === "v1" && val) sigs.push(val);
|
|
256
|
+
}
|
|
257
|
+
return { t, sigs };
|
|
258
|
+
}
|
|
259
|
+
function hmacHex(secret, message) {
|
|
260
|
+
return crypto.createHmac("sha256", secret).update(message).digest("hex");
|
|
261
|
+
}
|
|
262
|
+
function safeEqualHex(a, b) {
|
|
263
|
+
if (a.length !== b.length) return false;
|
|
264
|
+
let diff = 0;
|
|
265
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
266
|
+
return diff === 0;
|
|
267
|
+
}
|
|
268
|
+
function verifyWebhook(rawBody, signatureHeader, secret, opts = {}) {
|
|
269
|
+
if (!signatureHeader) {
|
|
270
|
+
throw webhookError("Missing X-Invo-Signature header.", "WEBHOOK_SIGNATURE_MISSING");
|
|
271
|
+
}
|
|
272
|
+
const secrets = (Array.isArray(secret) ? secret : [secret]).filter(Boolean);
|
|
273
|
+
if (secrets.length === 0) {
|
|
274
|
+
throw webhookError("A signing secret is required to verify webhooks.", "WEBHOOK_SECRET_MISSING");
|
|
275
|
+
}
|
|
276
|
+
const { t, sigs } = parseSignatureHeader(signatureHeader);
|
|
277
|
+
if (!t || sigs.length === 0) {
|
|
278
|
+
throw webhookError("Malformed X-Invo-Signature header.", "WEBHOOK_MALFORMED");
|
|
279
|
+
}
|
|
280
|
+
const ts = Number(t);
|
|
281
|
+
const now = opts.nowSec ?? Math.floor(Date.now() / 1e3);
|
|
282
|
+
const tolerance = opts.toleranceSec ?? DEFAULT_TOLERANCE_SEC;
|
|
283
|
+
if (!Number.isFinite(ts) || Math.abs(now - ts) > tolerance) {
|
|
284
|
+
throw webhookError(
|
|
285
|
+
`Webhook timestamp outside the ${tolerance}s tolerance (replay guard).`,
|
|
286
|
+
"WEBHOOK_TIMESTAMP_EXPIRED"
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
const bodyBytes = toBytes(rawBody);
|
|
290
|
+
const message = concatBytes(ENCODER.encode(`${t}.`), bodyBytes);
|
|
291
|
+
const matched = secrets.some((s) => {
|
|
292
|
+
const expected = hmacHex(s, message);
|
|
293
|
+
return sigs.some((sig) => safeEqualHex(sig, expected));
|
|
294
|
+
});
|
|
295
|
+
if (!matched) {
|
|
296
|
+
throw webhookError("Webhook signature verification failed.", "WEBHOOK_SIGNATURE_INVALID");
|
|
297
|
+
}
|
|
298
|
+
let parsed;
|
|
299
|
+
try {
|
|
300
|
+
parsed = JSON.parse(new TextDecoder().decode(bodyBytes));
|
|
301
|
+
} catch {
|
|
302
|
+
throw webhookError("Webhook body is not valid JSON.", "WEBHOOK_MALFORMED");
|
|
303
|
+
}
|
|
304
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed) || typeof parsed.event_type !== "string") {
|
|
305
|
+
throw webhookError("Webhook body is not a valid event object.", "WEBHOOK_MALFORMED");
|
|
306
|
+
}
|
|
307
|
+
return parsed;
|
|
308
|
+
}
|
|
146
309
|
|
|
147
310
|
// src/server.ts
|
|
148
311
|
var DEFAULT_UA = "invonetwork-web-sdk/0.1.0 (+https://invo.network)";
|
|
@@ -177,6 +340,26 @@ function parseMoney(value, max, label) {
|
|
|
177
340
|
function assertUsdAmount(usdAmount) {
|
|
178
341
|
parseMoney(usdAmount, MAX_USD_AMOUNT, "usdAmount");
|
|
179
342
|
}
|
|
343
|
+
function toOrderDetails(raw) {
|
|
344
|
+
return {
|
|
345
|
+
order: raw["order"] ?? {},
|
|
346
|
+
financialSummary: raw["financial_summary"] ?? {},
|
|
347
|
+
statusTimeline: raw["status_timeline"] ?? null,
|
|
348
|
+
raw
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
function toCurrencyBalance(row) {
|
|
352
|
+
return {
|
|
353
|
+
currencyId: row["currency_id"] ?? "",
|
|
354
|
+
currencyName: String(row["currency_name"] ?? ""),
|
|
355
|
+
currencySymbol: String(row["currency_symbol"] ?? ""),
|
|
356
|
+
availableBalance: String(row["available_balance"] ?? ""),
|
|
357
|
+
reservedBalance: String(row["reserved_balance"] ?? ""),
|
|
358
|
+
totalBalance: String(row["total_balance"] ?? ""),
|
|
359
|
+
lastTransaction: row["last_transaction"],
|
|
360
|
+
raw: row
|
|
361
|
+
};
|
|
362
|
+
}
|
|
180
363
|
function requireField(value, field, raw) {
|
|
181
364
|
const s = value == null ? "" : String(value);
|
|
182
365
|
if (!s) {
|
|
@@ -198,17 +381,25 @@ var InvoServer = class {
|
|
|
198
381
|
baseUrl: config.baseUrl,
|
|
199
382
|
timeoutMs: config.timeoutMs,
|
|
200
383
|
fetchImpl: config.fetch,
|
|
201
|
-
userAgent: DEFAULT_UA
|
|
384
|
+
userAgent: DEFAULT_UA,
|
|
202
385
|
// must be a non-blocked UA (handoff doc §9)
|
|
386
|
+
maxRetries: config.maxRetries,
|
|
387
|
+
retryBaseDelayMs: config.retryBaseDelayMs,
|
|
388
|
+
hooks: config.hooks
|
|
203
389
|
});
|
|
204
390
|
this.auth = { kind: "game-secret", secret: config.gameSecret };
|
|
205
391
|
}
|
|
206
392
|
/** Mint a short-lived, game-scoped player token to hand to the browser InvoClient. */
|
|
207
393
|
async mintPlayerToken(input) {
|
|
394
|
+
if (typeof input.playerEmail !== "string" || !input.playerEmail.trim()) {
|
|
395
|
+
throw invalidInput("playerEmail", input.playerEmail, "is required");
|
|
396
|
+
}
|
|
208
397
|
const raw = await this.http.post(
|
|
209
398
|
"/api/sdk/player-token",
|
|
210
399
|
{ player_email: input.playerEmail },
|
|
211
|
-
this.auth
|
|
400
|
+
this.auth,
|
|
401
|
+
{ idempotent: true }
|
|
402
|
+
// safe to retry: re-mint just issues a fresh token
|
|
212
403
|
);
|
|
213
404
|
return {
|
|
214
405
|
token: requireField(raw["token"], "token", raw),
|
|
@@ -230,7 +421,9 @@ var InvoServer = class {
|
|
|
230
421
|
receiving_game_id: input.receivingGameId,
|
|
231
422
|
amount: input.amount
|
|
232
423
|
},
|
|
233
|
-
this.auth
|
|
424
|
+
this.auth,
|
|
425
|
+
{ idempotent: true }
|
|
426
|
+
// client_request_id makes this safe to retry (backend dedupes)
|
|
234
427
|
);
|
|
235
428
|
return this.toInitiateResult(raw);
|
|
236
429
|
}
|
|
@@ -248,7 +441,9 @@ var InvoServer = class {
|
|
|
248
441
|
target_game_id: input.targetGameId,
|
|
249
442
|
amount: input.amount
|
|
250
443
|
},
|
|
251
|
-
this.auth
|
|
444
|
+
this.auth,
|
|
445
|
+
{ idempotent: true }
|
|
446
|
+
// client_request_id makes this safe to retry (backend dedupes)
|
|
252
447
|
);
|
|
253
448
|
return this.toInitiateResult(raw);
|
|
254
449
|
}
|
|
@@ -257,6 +452,9 @@ var InvoServer = class {
|
|
|
257
452
|
* handles cards, saved cards, and 3-D Secure. Crediting is server-side via the
|
|
258
453
|
* purchase.completed webhook. */
|
|
259
454
|
async createCheckout(input) {
|
|
455
|
+
if (typeof input.playerEmail !== "string" || !input.playerEmail.trim()) {
|
|
456
|
+
throw invalidInput("playerEmail", input.playerEmail, "is required");
|
|
457
|
+
}
|
|
260
458
|
assertUsdAmount(input.usdAmount);
|
|
261
459
|
const body = {
|
|
262
460
|
player_email: input.playerEmail,
|
|
@@ -286,6 +484,9 @@ var InvoServer = class {
|
|
|
286
484
|
* - rail "steam": NOT a browser flow — the backend returns WRONG_RAIL_ENDPOINT.
|
|
287
485
|
*/
|
|
288
486
|
async purchaseCurrency(input) {
|
|
487
|
+
if (typeof input.playerEmail !== "string" || !input.playerEmail.trim()) {
|
|
488
|
+
throw invalidInput("playerEmail", input.playerEmail, "is required");
|
|
489
|
+
}
|
|
289
490
|
assertUsdAmount(input.usdAmount);
|
|
290
491
|
if (!input.purchaseReference) {
|
|
291
492
|
throw new InvoError({
|
|
@@ -314,7 +515,9 @@ var InvoServer = class {
|
|
|
314
515
|
const raw = await this.http.post(
|
|
315
516
|
"/api/currency-purchases/purchase-currency",
|
|
316
517
|
body,
|
|
317
|
-
this.auth
|
|
518
|
+
this.auth,
|
|
519
|
+
{ idempotent: true }
|
|
520
|
+
// purchase_reference makes this safe to retry (backend dedupes)
|
|
318
521
|
);
|
|
319
522
|
return {
|
|
320
523
|
status: String(raw["status"] ?? ""),
|
|
@@ -331,11 +534,19 @@ var InvoServer = class {
|
|
|
331
534
|
async confirmPayment(input) {
|
|
332
535
|
const body = { payment_intent_id: input.paymentIntentId };
|
|
333
536
|
if (input.orderId) body["order_id"] = input.orderId;
|
|
334
|
-
|
|
537
|
+
const raw = await this.http.post(
|
|
335
538
|
"/api/currency-purchases/confirm-payment",
|
|
336
539
|
body,
|
|
337
|
-
this.auth
|
|
540
|
+
this.auth,
|
|
541
|
+
{ idempotent: true }
|
|
542
|
+
// keyed by payment_intent_id — safe to retry
|
|
338
543
|
);
|
|
544
|
+
return {
|
|
545
|
+
status: String(raw["status"] ?? ""),
|
|
546
|
+
transactionId: raw["transaction_id"],
|
|
547
|
+
newBalance: raw["new_balance"] ?? null,
|
|
548
|
+
raw
|
|
549
|
+
};
|
|
339
550
|
}
|
|
340
551
|
/** Fetch purchase status (order + financial summary + timeline). */
|
|
341
552
|
async getOrderDetails(query) {
|
|
@@ -349,10 +560,11 @@ var InvoServer = class {
|
|
|
349
560
|
const q = new URLSearchParams();
|
|
350
561
|
if (query.orderId) q.set("order_id", query.orderId);
|
|
351
562
|
if (query.transactionId) q.set("transaction_id", query.transactionId);
|
|
352
|
-
|
|
563
|
+
const raw = await this.http.get(
|
|
353
564
|
`/api/currency-purchases/order-details?${q.toString()}`,
|
|
354
565
|
this.auth
|
|
355
566
|
);
|
|
567
|
+
return toOrderDetails(raw);
|
|
356
568
|
}
|
|
357
569
|
/**
|
|
358
570
|
* Buy an in-game item by SPENDING the player's existing game currency (§4.8).
|
|
@@ -403,7 +615,9 @@ var InvoServer = class {
|
|
|
403
615
|
const raw = await this.http.post(
|
|
404
616
|
"/api/item-purchases/purchase-item",
|
|
405
617
|
body,
|
|
406
|
-
this.auth
|
|
618
|
+
this.auth,
|
|
619
|
+
{ idempotent: true }
|
|
620
|
+
// client_request_id makes this safe to retry (dup → 409)
|
|
407
621
|
);
|
|
408
622
|
return {
|
|
409
623
|
status: String(raw["status"] ?? ""),
|
|
@@ -425,10 +639,15 @@ var InvoServer = class {
|
|
|
425
639
|
q.set("player_email", query.playerEmail);
|
|
426
640
|
if (query.limit != null) q.set("limit", String(query.limit));
|
|
427
641
|
if (query.offset != null) q.set("offset", String(query.offset));
|
|
428
|
-
|
|
642
|
+
const raw = await this.http.get(
|
|
429
643
|
`/api/item-purchases/player-purchase-history?${q.toString()}`,
|
|
430
644
|
this.auth
|
|
431
645
|
);
|
|
646
|
+
return {
|
|
647
|
+
history: Array.isArray(raw["item_purchase_history"]) ? raw["item_purchase_history"] : [],
|
|
648
|
+
pagination: raw["pagination"] ?? {},
|
|
649
|
+
raw
|
|
650
|
+
};
|
|
432
651
|
}
|
|
433
652
|
/** Look up one item order by EXACTLY ONE of orderId | transactionId | clientRequestId
|
|
434
653
|
* (§4.8). Use clientRequestId for saga/recovery: "did this purchase actually complete?" */
|
|
@@ -447,10 +666,34 @@ var InvoServer = class {
|
|
|
447
666
|
if (query.orderId) q.set("order_id", query.orderId);
|
|
448
667
|
if (query.transactionId) q.set("transaction_id", query.transactionId);
|
|
449
668
|
if (query.clientRequestId) q.set("client_request_id", query.clientRequestId);
|
|
450
|
-
|
|
669
|
+
const raw = await this.http.get(
|
|
451
670
|
`/api/item-purchases/order-details?${q.toString()}`,
|
|
452
671
|
this.auth
|
|
453
672
|
);
|
|
673
|
+
return toOrderDetails(raw);
|
|
674
|
+
}
|
|
675
|
+
/** Read a player's currency balances, by email or playerId (game-secret). */
|
|
676
|
+
async getPlayerBalance(query) {
|
|
677
|
+
let path;
|
|
678
|
+
if (query.playerId != null && String(query.playerId).trim()) {
|
|
679
|
+
path = `/api/player-balances/player/${encodeURIComponent(String(query.playerId))}`;
|
|
680
|
+
} else if (typeof query.playerEmail === "string" && query.playerEmail.trim()) {
|
|
681
|
+
path = `/api/player-balances/player/by-email/${encodeURIComponent(query.playerEmail)}`;
|
|
682
|
+
} else {
|
|
683
|
+
throw new InvoError({
|
|
684
|
+
message: "getPlayerBalance requires a playerEmail or playerId.",
|
|
685
|
+
code: "INVALID_INPUT",
|
|
686
|
+
status: 0
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
const raw = await this.http.get(path, this.auth);
|
|
690
|
+
const rows = Array.isArray(raw["balances"]) ? raw["balances"] : [];
|
|
691
|
+
return {
|
|
692
|
+
player: raw["player"] ?? {},
|
|
693
|
+
balances: rows.map(toCurrencyBalance),
|
|
694
|
+
summary: raw["summary"] ?? {},
|
|
695
|
+
raw
|
|
696
|
+
};
|
|
454
697
|
}
|
|
455
698
|
toInitiateResult(raw) {
|
|
456
699
|
const vm = raw["verification_method"];
|
|
@@ -466,5 +709,6 @@ var InvoServer = class {
|
|
|
466
709
|
|
|
467
710
|
exports.InvoError = InvoError;
|
|
468
711
|
exports.InvoServer = InvoServer;
|
|
712
|
+
exports.verifyWebhook = verifyWebhook;
|
|
469
713
|
//# sourceMappingURL=server.cjs.map
|
|
470
714
|
//# sourceMappingURL=server.cjs.map
|
package/dist/server.d.cts
CHANGED
|
@@ -1,5 +1,108 @@
|
|
|
1
|
-
import { S as ServerConfig, P as PlayerToken,
|
|
2
|
-
export { I as InvoError, R as Rail, V as VerificationMethod } from './
|
|
1
|
+
import { S as ServerConfig, P as PlayerToken, f as InitiateSendInput, g as InitiateResult, h as InitiateTransferInput, i as CreateCheckoutInput, j as CreateCheckoutResult, k as PurchaseInput, l as PurchaseResult, m as ConfirmPaymentResult, O as OrderDetailsResult, n as PurchaseItemInput, o as PurchaseItemResult, p as ItemHistoryQuery, q as ItemHistoryResult, r as ItemOrderQuery, s as PlayerBalanceQuery, t as PlayerBalanceResult } from './types-CBkoUymV.cjs';
|
|
2
|
+
export { u as CurrencyBalance, I as InvoError, b as InvoErrorInfo, c as InvoHooks, d as InvoRequestInfo, e as InvoResponseInfo, R as Rail, V as VerificationMethod } from './types-CBkoUymV.cjs';
|
|
3
|
+
|
|
4
|
+
interface VerifyWebhookOptions {
|
|
5
|
+
/** Max age of the signed timestamp, in seconds. Default 300 (5 min). */
|
|
6
|
+
toleranceSec?: number;
|
|
7
|
+
/** Override the current time (unix seconds) — for tests. Defaults to Date.now(). */
|
|
8
|
+
nowSec?: number;
|
|
9
|
+
}
|
|
10
|
+
interface WebhookBase {
|
|
11
|
+
event_id: string;
|
|
12
|
+
idempotency_key: string;
|
|
13
|
+
schema_version: string;
|
|
14
|
+
created_at: string;
|
|
15
|
+
/** Your game_id. */
|
|
16
|
+
tenant_id: string;
|
|
17
|
+
}
|
|
18
|
+
interface PurchaseCompletedData {
|
|
19
|
+
transaction_id: string;
|
|
20
|
+
order_id: string;
|
|
21
|
+
player_email: string;
|
|
22
|
+
identity_id: string;
|
|
23
|
+
usd_amount: string;
|
|
24
|
+
currency_amount: string;
|
|
25
|
+
currency_name: string;
|
|
26
|
+
new_balance: string;
|
|
27
|
+
rail: string;
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
interface PurchaseEventData {
|
|
31
|
+
transaction_id: string;
|
|
32
|
+
order_id?: string;
|
|
33
|
+
player_email?: string;
|
|
34
|
+
identity_id?: string;
|
|
35
|
+
/** Present on purchase.disputed, e.g. "lost". */
|
|
36
|
+
dispute_status?: string;
|
|
37
|
+
[key: string]: unknown;
|
|
38
|
+
}
|
|
39
|
+
interface ItemPurchasedData {
|
|
40
|
+
transaction_id: string;
|
|
41
|
+
order_id: string;
|
|
42
|
+
player_email: string;
|
|
43
|
+
identity_id: string;
|
|
44
|
+
item_id: string;
|
|
45
|
+
item_name: string;
|
|
46
|
+
item_quantity: number;
|
|
47
|
+
unit_price: string;
|
|
48
|
+
total_price: string;
|
|
49
|
+
currency_name: string;
|
|
50
|
+
new_balance: string;
|
|
51
|
+
fee_breakdown?: Record<string, unknown>;
|
|
52
|
+
[key: string]: unknown;
|
|
53
|
+
}
|
|
54
|
+
interface TransferEventData {
|
|
55
|
+
transaction_id: string;
|
|
56
|
+
/** "transfer" | "send". */
|
|
57
|
+
flow?: string;
|
|
58
|
+
amount?: string;
|
|
59
|
+
net_amount?: string;
|
|
60
|
+
/** "inbound" | "outbound" (relative to the receiving tenant). */
|
|
61
|
+
direction?: string;
|
|
62
|
+
/** On inbound claim_pending: the recipient's phone (match to your player). */
|
|
63
|
+
to_phone?: string;
|
|
64
|
+
/** Recipient's opaque identity, present only when the phone maps to a single player. */
|
|
65
|
+
to_identity_id?: string | null;
|
|
66
|
+
[key: string]: unknown;
|
|
67
|
+
}
|
|
68
|
+
type PurchaseEventType = "purchase.failed" | "purchase.refunded" | "purchase.disputed" | "purchase.fraud_warning";
|
|
69
|
+
type TransferEventType = "transfer.sent" | "transfer.received" | "transfer.claim_pending" | "transfer.claim_expired" | "transfer.refunded";
|
|
70
|
+
/**
|
|
71
|
+
* A verified webhook event. Discriminate on `event_type` to narrow `data`.
|
|
72
|
+
* Unknown/future event types fall through to the generic member (data is a record).
|
|
73
|
+
*/
|
|
74
|
+
type InvoWebhookEvent = (WebhookBase & {
|
|
75
|
+
event_type: "purchase.completed";
|
|
76
|
+
data: PurchaseCompletedData;
|
|
77
|
+
}) | (WebhookBase & {
|
|
78
|
+
event_type: "item.purchased";
|
|
79
|
+
data: ItemPurchasedData;
|
|
80
|
+
}) | (WebhookBase & {
|
|
81
|
+
event_type: PurchaseEventType;
|
|
82
|
+
data: PurchaseEventData;
|
|
83
|
+
}) | (WebhookBase & {
|
|
84
|
+
event_type: TransferEventType;
|
|
85
|
+
data: TransferEventData;
|
|
86
|
+
}) | (WebhookBase & {
|
|
87
|
+
event_type: "payout.status_changed";
|
|
88
|
+
data: Record<string, unknown>;
|
|
89
|
+
}) | (WebhookBase & {
|
|
90
|
+
event_type: "webhook.test";
|
|
91
|
+
data: Record<string, unknown>;
|
|
92
|
+
}) | (WebhookBase & {
|
|
93
|
+
event_type: string;
|
|
94
|
+
data: Record<string, unknown>;
|
|
95
|
+
});
|
|
96
|
+
/**
|
|
97
|
+
* Verify an Invo webhook and return the parsed, typed event.
|
|
98
|
+
*
|
|
99
|
+
* @param rawBody The exact raw request body (Buffer/Uint8Array or string).
|
|
100
|
+
* @param signatureHeader The `X-Invo-Signature` header value.
|
|
101
|
+
* @param secret Your signing secret, or an array of secrets (to accept old + new during rotation).
|
|
102
|
+
* @throws InvoError (status 0) with code WEBHOOK_SIGNATURE_MISSING | WEBHOOK_TIMESTAMP_EXPIRED |
|
|
103
|
+
* WEBHOOK_SIGNATURE_INVALID | WEBHOOK_MALFORMED on any failure.
|
|
104
|
+
*/
|
|
105
|
+
declare function verifyWebhook(rawBody: string | Uint8Array, signatureHeader: string | null | undefined, secret: string | string[], opts?: VerifyWebhookOptions): InvoWebhookEvent;
|
|
3
106
|
|
|
4
107
|
declare class InvoServer {
|
|
5
108
|
private readonly http;
|
|
@@ -30,12 +133,12 @@ declare class InvoServer {
|
|
|
30
133
|
confirmPayment(input: {
|
|
31
134
|
paymentIntentId: string;
|
|
32
135
|
orderId?: string;
|
|
33
|
-
}): Promise<
|
|
136
|
+
}): Promise<ConfirmPaymentResult>;
|
|
34
137
|
/** Fetch purchase status (order + financial summary + timeline). */
|
|
35
138
|
getOrderDetails(query: {
|
|
36
139
|
orderId?: string;
|
|
37
140
|
transactionId?: string;
|
|
38
|
-
}): Promise<
|
|
141
|
+
}): Promise<OrderDetailsResult>;
|
|
39
142
|
/**
|
|
40
143
|
* Buy an in-game item by SPENDING the player's existing game currency (§4.8).
|
|
41
144
|
* No real money, no payment rail, no passkey — it's a balance debit, authenticated
|
|
@@ -46,11 +149,13 @@ declare class InvoServer {
|
|
|
46
149
|
*/
|
|
47
150
|
purchaseItem(input: PurchaseItemInput): Promise<PurchaseItemResult>;
|
|
48
151
|
/** Paginated item-purchase history for a player (§4.8 companion read). */
|
|
49
|
-
getItemPurchaseHistory(query: ItemHistoryQuery): Promise<
|
|
152
|
+
getItemPurchaseHistory(query: ItemHistoryQuery): Promise<ItemHistoryResult>;
|
|
50
153
|
/** Look up one item order by EXACTLY ONE of orderId | transactionId | clientRequestId
|
|
51
154
|
* (§4.8). Use clientRequestId for saga/recovery: "did this purchase actually complete?" */
|
|
52
|
-
getItemOrderDetails(query: ItemOrderQuery): Promise<
|
|
155
|
+
getItemOrderDetails(query: ItemOrderQuery): Promise<OrderDetailsResult>;
|
|
156
|
+
/** Read a player's currency balances, by email or playerId (game-secret). */
|
|
157
|
+
getPlayerBalance(query: PlayerBalanceQuery): Promise<PlayerBalanceResult>;
|
|
53
158
|
private toInitiateResult;
|
|
54
159
|
}
|
|
55
160
|
|
|
56
|
-
export { CreateCheckoutInput, CreateCheckoutResult, InitiateResult, InitiateSendInput, InitiateTransferInput, InvoServer, ItemHistoryQuery, ItemOrderQuery, PlayerToken, PurchaseInput, PurchaseItemInput, PurchaseItemResult, PurchaseResult, ServerConfig };
|
|
161
|
+
export { ConfirmPaymentResult, CreateCheckoutInput, CreateCheckoutResult, InitiateResult, InitiateSendInput, InitiateTransferInput, InvoServer, type InvoWebhookEvent, ItemHistoryQuery, ItemHistoryResult, ItemOrderQuery, type ItemPurchasedData, OrderDetailsResult, PlayerBalanceQuery, PlayerBalanceResult, PlayerToken, type PurchaseCompletedData, type PurchaseEventData, PurchaseInput, PurchaseItemInput, PurchaseItemResult, PurchaseResult, ServerConfig, type TransferEventData, type VerifyWebhookOptions, verifyWebhook };
|