@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.js
CHANGED
|
@@ -1,8 +1,167 @@
|
|
|
1
|
-
import { assertSecureBaseUrl, Http
|
|
2
|
-
export { InvoError } from './chunk-
|
|
1
|
+
import { InvoError, assertSecureBaseUrl, Http } from './chunk-EEWOAUXO.js';
|
|
2
|
+
export { InvoError } from './chunk-EEWOAUXO.js';
|
|
3
|
+
import { createHmac } from 'crypto';
|
|
4
|
+
|
|
5
|
+
var DEFAULT_TOLERANCE_SEC = 300;
|
|
6
|
+
var ENCODER = new TextEncoder();
|
|
7
|
+
function toBytes(body) {
|
|
8
|
+
return typeof body === "string" ? ENCODER.encode(body) : body;
|
|
9
|
+
}
|
|
10
|
+
function concatBytes(a, b) {
|
|
11
|
+
const out = new Uint8Array(a.length + b.length);
|
|
12
|
+
out.set(a, 0);
|
|
13
|
+
out.set(b, a.length);
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
function webhookError(message, code) {
|
|
17
|
+
return new InvoError({ message, code, status: 0 });
|
|
18
|
+
}
|
|
19
|
+
function parseSignatureHeader(header) {
|
|
20
|
+
let t = "";
|
|
21
|
+
const sigs = [];
|
|
22
|
+
for (const part of header.split(",")) {
|
|
23
|
+
const idx = part.indexOf("=");
|
|
24
|
+
if (idx === -1) continue;
|
|
25
|
+
const key = part.slice(0, idx).trim();
|
|
26
|
+
const val = part.slice(idx + 1).trim();
|
|
27
|
+
if (key === "t") t = val;
|
|
28
|
+
else if (key === "v1" && val) sigs.push(val);
|
|
29
|
+
}
|
|
30
|
+
return { t, sigs };
|
|
31
|
+
}
|
|
32
|
+
function hmacHex(secret, message) {
|
|
33
|
+
return createHmac("sha256", secret).update(message).digest("hex");
|
|
34
|
+
}
|
|
35
|
+
function safeEqualHex(a, b) {
|
|
36
|
+
if (a.length !== b.length) return false;
|
|
37
|
+
let diff = 0;
|
|
38
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
39
|
+
return diff === 0;
|
|
40
|
+
}
|
|
41
|
+
function prepareVerification(rawBody, signatureHeader, secret, opts) {
|
|
42
|
+
if (!signatureHeader) {
|
|
43
|
+
throw webhookError("Missing X-Invo-Signature header.", "WEBHOOK_SIGNATURE_MISSING");
|
|
44
|
+
}
|
|
45
|
+
const secrets = (Array.isArray(secret) ? secret : [secret]).filter(Boolean);
|
|
46
|
+
if (secrets.length === 0) {
|
|
47
|
+
throw webhookError("A signing secret is required to verify webhooks.", "WEBHOOK_SECRET_MISSING");
|
|
48
|
+
}
|
|
49
|
+
const { t, sigs } = parseSignatureHeader(signatureHeader);
|
|
50
|
+
if (!t || sigs.length === 0) {
|
|
51
|
+
throw webhookError("Malformed X-Invo-Signature header.", "WEBHOOK_MALFORMED");
|
|
52
|
+
}
|
|
53
|
+
const ts = Number(t);
|
|
54
|
+
const now = opts.nowSec ?? Math.floor(Date.now() / 1e3);
|
|
55
|
+
const tolerance = opts.toleranceSec ?? DEFAULT_TOLERANCE_SEC;
|
|
56
|
+
if (!Number.isFinite(ts) || Math.abs(now - ts) > tolerance) {
|
|
57
|
+
throw webhookError(
|
|
58
|
+
`Webhook timestamp outside the ${tolerance}s tolerance (replay guard).`,
|
|
59
|
+
"WEBHOOK_TIMESTAMP_EXPIRED"
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
const bodyBytes = toBytes(rawBody);
|
|
63
|
+
const message = concatBytes(ENCODER.encode(`${t}.`), bodyBytes);
|
|
64
|
+
return { secrets, sigs, message, bodyBytes };
|
|
65
|
+
}
|
|
66
|
+
function finalizeEvent(bodyBytes) {
|
|
67
|
+
let parsed;
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(new TextDecoder().decode(bodyBytes));
|
|
70
|
+
} catch {
|
|
71
|
+
throw webhookError("Webhook body is not valid JSON.", "WEBHOOK_MALFORMED");
|
|
72
|
+
}
|
|
73
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed) || typeof parsed.event_type !== "string") {
|
|
74
|
+
throw webhookError("Webhook body is not a valid event object.", "WEBHOOK_MALFORMED");
|
|
75
|
+
}
|
|
76
|
+
return parsed;
|
|
77
|
+
}
|
|
78
|
+
function verifyWebhook(rawBody, signatureHeader, secret, opts = {}) {
|
|
79
|
+
const { secrets, sigs, message, bodyBytes } = prepareVerification(
|
|
80
|
+
rawBody,
|
|
81
|
+
signatureHeader,
|
|
82
|
+
secret,
|
|
83
|
+
opts
|
|
84
|
+
);
|
|
85
|
+
const matched = secrets.some((s) => {
|
|
86
|
+
const expected = hmacHex(s, message);
|
|
87
|
+
return sigs.some((sig) => safeEqualHex(sig, expected));
|
|
88
|
+
});
|
|
89
|
+
if (!matched) {
|
|
90
|
+
throw webhookError("Webhook signature verification failed.", "WEBHOOK_SIGNATURE_INVALID");
|
|
91
|
+
}
|
|
92
|
+
return finalizeEvent(bodyBytes);
|
|
93
|
+
}
|
|
94
|
+
async function verifyWebhookAsync(rawBody, signatureHeader, secret, opts = {}) {
|
|
95
|
+
const { secrets, sigs, message, bodyBytes } = prepareVerification(
|
|
96
|
+
rawBody,
|
|
97
|
+
signatureHeader,
|
|
98
|
+
secret,
|
|
99
|
+
opts
|
|
100
|
+
);
|
|
101
|
+
for (const s of secrets) {
|
|
102
|
+
const expected = await hmacHexSubtle(s, message);
|
|
103
|
+
if (sigs.some((sig) => safeEqualHex(sig, expected))) {
|
|
104
|
+
return finalizeEvent(bodyBytes);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
throw webhookError("Webhook signature verification failed.", "WEBHOOK_SIGNATURE_INVALID");
|
|
108
|
+
}
|
|
109
|
+
function createWebhookHandler(opts) {
|
|
110
|
+
return async (request) => {
|
|
111
|
+
const raw = await request.text();
|
|
112
|
+
let event;
|
|
113
|
+
try {
|
|
114
|
+
event = await verifyWebhookAsync(raw, request.headers.get("x-invo-signature"), opts.secret, {
|
|
115
|
+
toleranceSec: opts.toleranceSec
|
|
116
|
+
});
|
|
117
|
+
} catch (e) {
|
|
118
|
+
const err = e instanceof InvoError ? e : new InvoError({ message: String(e), status: 0 });
|
|
119
|
+
if (opts.onError) {
|
|
120
|
+
try {
|
|
121
|
+
const r = await opts.onError(err, request);
|
|
122
|
+
if (r instanceof Response) return r;
|
|
123
|
+
} catch {
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return new Response(err.code ?? "invalid_signature", { status: 400 });
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
await opts.onEvent(event, {
|
|
130
|
+
// Prefer the signature-covered body field over the (unauthenticated, optional)
|
|
131
|
+
// header — the body value is HMAC-verified and always present.
|
|
132
|
+
idempotencyKey: event.idempotency_key ?? request.headers.get("x-invo-idempotency-key"),
|
|
133
|
+
request
|
|
134
|
+
});
|
|
135
|
+
} catch {
|
|
136
|
+
return new Response("handler_error", { status: 500 });
|
|
137
|
+
}
|
|
138
|
+
return new Response(null, { status: 200 });
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
async function hmacHexSubtle(secret, message) {
|
|
142
|
+
const subtle = globalThis.crypto?.subtle;
|
|
143
|
+
if (!subtle) {
|
|
144
|
+
throw webhookError(
|
|
145
|
+
"Web Crypto (crypto.subtle) is unavailable in this runtime.",
|
|
146
|
+
"WEBHOOK_UNSUPPORTED"
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
const key = await subtle.importKey(
|
|
150
|
+
"raw",
|
|
151
|
+
ENCODER.encode(secret),
|
|
152
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
153
|
+
false,
|
|
154
|
+
["sign"]
|
|
155
|
+
);
|
|
156
|
+
const sig = await subtle.sign("HMAC", key, message);
|
|
157
|
+
const bytes = new Uint8Array(sig);
|
|
158
|
+
let hex = "";
|
|
159
|
+
for (let i = 0; i < bytes.length; i++) hex += bytes[i].toString(16).padStart(2, "0");
|
|
160
|
+
return hex;
|
|
161
|
+
}
|
|
3
162
|
|
|
4
163
|
// src/server.ts
|
|
5
|
-
var DEFAULT_UA = "invonetwork-web-sdk/0.
|
|
164
|
+
var DEFAULT_UA = "invonetwork-web-sdk/0.4.0 (+https://invo.network)";
|
|
6
165
|
var MAX_USD_AMOUNT = 999.99;
|
|
7
166
|
var MAX_ITEM_PRICE = 999999.99;
|
|
8
167
|
function invalidInput(label, value, why) {
|
|
@@ -34,6 +193,41 @@ function parseMoney(value, max, label) {
|
|
|
34
193
|
function assertUsdAmount(usdAmount) {
|
|
35
194
|
parseMoney(usdAmount, MAX_USD_AMOUNT, "usdAmount");
|
|
36
195
|
}
|
|
196
|
+
function toOrderDetails(raw) {
|
|
197
|
+
return {
|
|
198
|
+
order: raw["order"] ?? {},
|
|
199
|
+
financialSummary: raw["financial_summary"] ?? {},
|
|
200
|
+
statusTimeline: raw["status_timeline"] ?? null,
|
|
201
|
+
raw
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function toInboundPendingItem(row) {
|
|
205
|
+
return {
|
|
206
|
+
transactionId: String(row["transaction_id"] ?? ""),
|
|
207
|
+
flow: String(row["flow"] ?? ""),
|
|
208
|
+
amount: String(row["amount"] ?? ""),
|
|
209
|
+
netAmount: String(row["net_amount"] ?? ""),
|
|
210
|
+
sourceGameId: row["source_game_id"] ?? "",
|
|
211
|
+
sourceGame: String(row["source_game"] ?? ""),
|
|
212
|
+
toPhone: String(row["to_phone"] ?? ""),
|
|
213
|
+
toIdentityId: row["to_identity_id"] ?? null,
|
|
214
|
+
createdAt: String(row["created_at"] ?? ""),
|
|
215
|
+
claimCodeExpiresAt: String(row["claim_code_expires_at"] ?? ""),
|
|
216
|
+
raw: row
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function toCurrencyBalance(row) {
|
|
220
|
+
return {
|
|
221
|
+
currencyId: row["currency_id"] ?? "",
|
|
222
|
+
currencyName: String(row["currency_name"] ?? ""),
|
|
223
|
+
currencySymbol: String(row["currency_symbol"] ?? ""),
|
|
224
|
+
availableBalance: String(row["available_balance"] ?? ""),
|
|
225
|
+
reservedBalance: String(row["reserved_balance"] ?? ""),
|
|
226
|
+
totalBalance: String(row["total_balance"] ?? ""),
|
|
227
|
+
lastTransaction: row["last_transaction"],
|
|
228
|
+
raw: row
|
|
229
|
+
};
|
|
230
|
+
}
|
|
37
231
|
function requireField(value, field, raw) {
|
|
38
232
|
const s = value == null ? "" : String(value);
|
|
39
233
|
if (!s) {
|
|
@@ -55,17 +249,25 @@ var InvoServer = class {
|
|
|
55
249
|
baseUrl: config.baseUrl,
|
|
56
250
|
timeoutMs: config.timeoutMs,
|
|
57
251
|
fetchImpl: config.fetch,
|
|
58
|
-
userAgent: DEFAULT_UA
|
|
252
|
+
userAgent: DEFAULT_UA,
|
|
59
253
|
// must be a non-blocked UA (handoff doc §9)
|
|
254
|
+
maxRetries: config.maxRetries,
|
|
255
|
+
retryBaseDelayMs: config.retryBaseDelayMs,
|
|
256
|
+
hooks: config.hooks
|
|
60
257
|
});
|
|
61
258
|
this.auth = { kind: "game-secret", secret: config.gameSecret };
|
|
62
259
|
}
|
|
63
260
|
/** Mint a short-lived, game-scoped player token to hand to the browser InvoClient. */
|
|
64
|
-
async mintPlayerToken(input) {
|
|
261
|
+
async mintPlayerToken(input, opts) {
|
|
262
|
+
if (typeof input.playerEmail !== "string" || !input.playerEmail.trim()) {
|
|
263
|
+
throw invalidInput("playerEmail", input.playerEmail, "is required");
|
|
264
|
+
}
|
|
65
265
|
const raw = await this.http.post(
|
|
66
266
|
"/api/sdk/player-token",
|
|
67
267
|
{ player_email: input.playerEmail },
|
|
68
|
-
this.auth
|
|
268
|
+
this.auth,
|
|
269
|
+
{ idempotent: true, signal: opts?.signal }
|
|
270
|
+
// safe to retry: re-mint just issues a fresh token
|
|
69
271
|
);
|
|
70
272
|
return {
|
|
71
273
|
token: requireField(raw["token"], "token", raw),
|
|
@@ -74,7 +276,7 @@ var InvoServer = class {
|
|
|
74
276
|
};
|
|
75
277
|
}
|
|
76
278
|
/** Initiate a cross-game currency SEND. Inspect result.verificationMethod. */
|
|
77
|
-
async initiateSend(input) {
|
|
279
|
+
async initiateSend(input, opts) {
|
|
78
280
|
const raw = await this.http.post(
|
|
79
281
|
"/api/currency-sends/initiate-send",
|
|
80
282
|
{
|
|
@@ -87,12 +289,14 @@ var InvoServer = class {
|
|
|
87
289
|
receiving_game_id: input.receivingGameId,
|
|
88
290
|
amount: input.amount
|
|
89
291
|
},
|
|
90
|
-
this.auth
|
|
292
|
+
this.auth,
|
|
293
|
+
{ idempotent: true, signal: opts?.signal }
|
|
294
|
+
// client_request_id makes this safe to retry (backend dedupes)
|
|
91
295
|
);
|
|
92
296
|
return this.toInitiateResult(raw);
|
|
93
297
|
}
|
|
94
298
|
/** Initiate a cross-game TRANSFER. Inspect result.verificationMethod. */
|
|
95
|
-
async initiateTransfer(input) {
|
|
299
|
+
async initiateTransfer(input, opts) {
|
|
96
300
|
const raw = await this.http.post(
|
|
97
301
|
"/api/transfers/initiate-transfer",
|
|
98
302
|
{
|
|
@@ -105,7 +309,9 @@ var InvoServer = class {
|
|
|
105
309
|
target_game_id: input.targetGameId,
|
|
106
310
|
amount: input.amount
|
|
107
311
|
},
|
|
108
|
-
this.auth
|
|
312
|
+
this.auth,
|
|
313
|
+
{ idempotent: true, signal: opts?.signal }
|
|
314
|
+
// client_request_id makes this safe to retry (backend dedupes)
|
|
109
315
|
);
|
|
110
316
|
return this.toInitiateResult(raw);
|
|
111
317
|
}
|
|
@@ -113,7 +319,10 @@ var InvoServer = class {
|
|
|
113
319
|
* returned checkoutUrl in a WebView/redirect or an iframe; the INVO-hosted page
|
|
114
320
|
* handles cards, saved cards, and 3-D Secure. Crediting is server-side via the
|
|
115
321
|
* purchase.completed webhook. */
|
|
116
|
-
async createCheckout(input) {
|
|
322
|
+
async createCheckout(input, opts) {
|
|
323
|
+
if (typeof input.playerEmail !== "string" || !input.playerEmail.trim()) {
|
|
324
|
+
throw invalidInput("playerEmail", input.playerEmail, "is required");
|
|
325
|
+
}
|
|
117
326
|
assertUsdAmount(input.usdAmount);
|
|
118
327
|
const body = {
|
|
119
328
|
player_email: input.playerEmail,
|
|
@@ -126,7 +335,9 @@ var InvoServer = class {
|
|
|
126
335
|
const raw = await this.http.post(
|
|
127
336
|
"/api/checkout/sessions",
|
|
128
337
|
body,
|
|
129
|
-
this.auth
|
|
338
|
+
this.auth,
|
|
339
|
+
{ signal: opts?.signal }
|
|
340
|
+
// not idempotent — never auto-retried
|
|
130
341
|
);
|
|
131
342
|
return {
|
|
132
343
|
sessionId: String(raw["session_id"] ?? ""),
|
|
@@ -142,7 +353,10 @@ var InvoServer = class {
|
|
|
142
353
|
* - rail "game": returns status "pending_payment" + paymentUrl (redirect the browser).
|
|
143
354
|
* - rail "steam": NOT a browser flow — the backend returns WRONG_RAIL_ENDPOINT.
|
|
144
355
|
*/
|
|
145
|
-
async purchaseCurrency(input) {
|
|
356
|
+
async purchaseCurrency(input, opts) {
|
|
357
|
+
if (typeof input.playerEmail !== "string" || !input.playerEmail.trim()) {
|
|
358
|
+
throw invalidInput("playerEmail", input.playerEmail, "is required");
|
|
359
|
+
}
|
|
146
360
|
assertUsdAmount(input.usdAmount);
|
|
147
361
|
if (!input.purchaseReference) {
|
|
148
362
|
throw new InvoError({
|
|
@@ -171,7 +385,9 @@ var InvoServer = class {
|
|
|
171
385
|
const raw = await this.http.post(
|
|
172
386
|
"/api/currency-purchases/purchase-currency",
|
|
173
387
|
body,
|
|
174
|
-
this.auth
|
|
388
|
+
this.auth,
|
|
389
|
+
{ idempotent: true, signal: opts?.signal }
|
|
390
|
+
// purchase_reference makes this safe to retry (backend dedupes)
|
|
175
391
|
);
|
|
176
392
|
return {
|
|
177
393
|
status: String(raw["status"] ?? ""),
|
|
@@ -185,17 +401,25 @@ var InvoServer = class {
|
|
|
185
401
|
};
|
|
186
402
|
}
|
|
187
403
|
/** Complete the Stripe-rail 3-D Secure step after the client finished card action. */
|
|
188
|
-
async confirmPayment(input) {
|
|
404
|
+
async confirmPayment(input, opts) {
|
|
189
405
|
const body = { payment_intent_id: input.paymentIntentId };
|
|
190
406
|
if (input.orderId) body["order_id"] = input.orderId;
|
|
191
|
-
|
|
407
|
+
const raw = await this.http.post(
|
|
192
408
|
"/api/currency-purchases/confirm-payment",
|
|
193
409
|
body,
|
|
194
|
-
this.auth
|
|
410
|
+
this.auth,
|
|
411
|
+
{ idempotent: true, signal: opts?.signal }
|
|
412
|
+
// keyed by payment_intent_id — safe to retry
|
|
195
413
|
);
|
|
414
|
+
return {
|
|
415
|
+
status: String(raw["status"] ?? ""),
|
|
416
|
+
transactionId: raw["transaction_id"],
|
|
417
|
+
newBalance: raw["new_balance"] ?? null,
|
|
418
|
+
raw
|
|
419
|
+
};
|
|
196
420
|
}
|
|
197
421
|
/** Fetch purchase status (order + financial summary + timeline). */
|
|
198
|
-
async getOrderDetails(query) {
|
|
422
|
+
async getOrderDetails(query, opts) {
|
|
199
423
|
if (!query.orderId && !query.transactionId) {
|
|
200
424
|
throw new InvoError({
|
|
201
425
|
message: "getOrderDetails requires an `orderId` or `transactionId`.",
|
|
@@ -206,10 +430,12 @@ var InvoServer = class {
|
|
|
206
430
|
const q = new URLSearchParams();
|
|
207
431
|
if (query.orderId) q.set("order_id", query.orderId);
|
|
208
432
|
if (query.transactionId) q.set("transaction_id", query.transactionId);
|
|
209
|
-
|
|
433
|
+
const raw = await this.http.get(
|
|
210
434
|
`/api/currency-purchases/order-details?${q.toString()}`,
|
|
211
|
-
this.auth
|
|
435
|
+
this.auth,
|
|
436
|
+
{ signal: opts?.signal }
|
|
212
437
|
);
|
|
438
|
+
return toOrderDetails(raw);
|
|
213
439
|
}
|
|
214
440
|
/**
|
|
215
441
|
* Buy an in-game item by SPENDING the player's existing game currency (§4.8).
|
|
@@ -219,7 +445,7 @@ var InvoServer = class {
|
|
|
219
445
|
* (err.isInsufficientBalance; required_amount/current_balance on err.body). Grant the
|
|
220
446
|
* item to your inventory off the `item.purchased` webhook, not just this response.
|
|
221
447
|
*/
|
|
222
|
-
async purchaseItem(input) {
|
|
448
|
+
async purchaseItem(input, opts) {
|
|
223
449
|
const required = [
|
|
224
450
|
["clientRequestId", input.clientRequestId],
|
|
225
451
|
["playerEmail", input.playerEmail],
|
|
@@ -260,7 +486,9 @@ var InvoServer = class {
|
|
|
260
486
|
const raw = await this.http.post(
|
|
261
487
|
"/api/item-purchases/purchase-item",
|
|
262
488
|
body,
|
|
263
|
-
this.auth
|
|
489
|
+
this.auth,
|
|
490
|
+
{ idempotent: true, signal: opts?.signal }
|
|
491
|
+
// client_request_id makes this safe to retry (dup → 409)
|
|
264
492
|
);
|
|
265
493
|
return {
|
|
266
494
|
status: String(raw["status"] ?? ""),
|
|
@@ -274,7 +502,7 @@ var InvoServer = class {
|
|
|
274
502
|
};
|
|
275
503
|
}
|
|
276
504
|
/** Paginated item-purchase history for a player (§4.8 companion read). */
|
|
277
|
-
async getItemPurchaseHistory(query) {
|
|
505
|
+
async getItemPurchaseHistory(query, opts) {
|
|
278
506
|
if (typeof query.playerEmail !== "string" || !query.playerEmail.trim()) {
|
|
279
507
|
throw invalidInput("playerEmail", query.playerEmail, "is required");
|
|
280
508
|
}
|
|
@@ -282,14 +510,20 @@ var InvoServer = class {
|
|
|
282
510
|
q.set("player_email", query.playerEmail);
|
|
283
511
|
if (query.limit != null) q.set("limit", String(query.limit));
|
|
284
512
|
if (query.offset != null) q.set("offset", String(query.offset));
|
|
285
|
-
|
|
513
|
+
const raw = await this.http.get(
|
|
286
514
|
`/api/item-purchases/player-purchase-history?${q.toString()}`,
|
|
287
|
-
this.auth
|
|
515
|
+
this.auth,
|
|
516
|
+
{ signal: opts?.signal }
|
|
288
517
|
);
|
|
518
|
+
return {
|
|
519
|
+
history: Array.isArray(raw["item_purchase_history"]) ? raw["item_purchase_history"] : [],
|
|
520
|
+
pagination: raw["pagination"] ?? {},
|
|
521
|
+
raw
|
|
522
|
+
};
|
|
289
523
|
}
|
|
290
524
|
/** Look up one item order by EXACTLY ONE of orderId | transactionId | clientRequestId
|
|
291
525
|
* (§4.8). Use clientRequestId for saga/recovery: "did this purchase actually complete?" */
|
|
292
|
-
async getItemOrderDetails(query) {
|
|
526
|
+
async getItemOrderDetails(query, opts) {
|
|
293
527
|
const provided = [query.orderId, query.transactionId, query.clientRequestId].filter(
|
|
294
528
|
(v) => typeof v === "string" && v.trim()
|
|
295
529
|
);
|
|
@@ -304,10 +538,86 @@ var InvoServer = class {
|
|
|
304
538
|
if (query.orderId) q.set("order_id", query.orderId);
|
|
305
539
|
if (query.transactionId) q.set("transaction_id", query.transactionId);
|
|
306
540
|
if (query.clientRequestId) q.set("client_request_id", query.clientRequestId);
|
|
307
|
-
|
|
541
|
+
const raw = await this.http.get(
|
|
308
542
|
`/api/item-purchases/order-details?${q.toString()}`,
|
|
309
|
-
this.auth
|
|
543
|
+
this.auth,
|
|
544
|
+
{ signal: opts?.signal }
|
|
545
|
+
);
|
|
546
|
+
return toOrderDetails(raw);
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Async iterator over a player's entire item-purchase history — pages through
|
|
550
|
+
* `getItemPurchaseHistory` (limit/offset) until exhausted, yielding one row at a time.
|
|
551
|
+
*
|
|
552
|
+
* ```ts
|
|
553
|
+
* for await (const row of invo.iterateItemPurchaseHistory({ playerEmail })) { … }
|
|
554
|
+
* ```
|
|
555
|
+
*/
|
|
556
|
+
async *iterateItemPurchaseHistory(query, opts) {
|
|
557
|
+
if (typeof query.playerEmail !== "string" || !query.playerEmail.trim()) {
|
|
558
|
+
throw invalidInput("playerEmail", query.playerEmail, "is required");
|
|
559
|
+
}
|
|
560
|
+
const pageSize = typeof query.pageSize === "number" && query.pageSize > 0 ? Math.floor(query.pageSize) : 50;
|
|
561
|
+
let yielded = 0;
|
|
562
|
+
for (let offset = 0; ; offset += pageSize) {
|
|
563
|
+
const { history, pagination } = await this.getItemPurchaseHistory(
|
|
564
|
+
{ playerEmail: query.playerEmail, limit: pageSize, offset },
|
|
565
|
+
opts
|
|
566
|
+
);
|
|
567
|
+
if (history.length === 0) return;
|
|
568
|
+
for (const row of history) yield row;
|
|
569
|
+
yielded += history.length;
|
|
570
|
+
if (history.length < pageSize) return;
|
|
571
|
+
const total = Number(pagination?.["total"]);
|
|
572
|
+
if (Number.isFinite(total) && yielded >= total) return;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
/** Read a player's currency balances, by email or playerId (game-secret). */
|
|
576
|
+
async getPlayerBalance(query, opts) {
|
|
577
|
+
let path;
|
|
578
|
+
if (query.playerId != null && String(query.playerId).trim()) {
|
|
579
|
+
path = `/api/player-balances/player/${encodeURIComponent(String(query.playerId))}`;
|
|
580
|
+
} else if (typeof query.playerEmail === "string" && query.playerEmail.trim()) {
|
|
581
|
+
path = `/api/player-balances/player/by-email/${encodeURIComponent(query.playerEmail)}`;
|
|
582
|
+
} else {
|
|
583
|
+
throw new InvoError({
|
|
584
|
+
message: "getPlayerBalance requires a playerEmail or playerId.",
|
|
585
|
+
code: "INVALID_INPUT",
|
|
586
|
+
status: 0
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
const raw = await this.http.get(path, this.auth, { signal: opts?.signal });
|
|
590
|
+
const rows = Array.isArray(raw["balances"]) ? raw["balances"] : [];
|
|
591
|
+
return {
|
|
592
|
+
player: raw["player"] ?? {},
|
|
593
|
+
balances: rows.map(toCurrencyBalance),
|
|
594
|
+
summary: raw["summary"] ?? {},
|
|
595
|
+
raw
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* List LIVE, unclaimed inbound sends/transfers addressed to a player at YOUR game —
|
|
600
|
+
* the source of truth for a "you have X to collect" badge. Pass the player's email
|
|
601
|
+
* or phone. Match `toPhone` to the logged-in player (don't require `toIdentityId`,
|
|
602
|
+
* which is null when the phone maps to more than one of your players). Pairs with the
|
|
603
|
+
* `transfer.claim_pending` webhook (the webhook is the wake-up; this is the list).
|
|
604
|
+
*/
|
|
605
|
+
async getInboundPending(query, opts) {
|
|
606
|
+
const hasEmail = typeof query.playerEmail === "string" && query.playerEmail.trim();
|
|
607
|
+
const hasPhone = typeof query.playerPhone === "string" && query.playerPhone.trim();
|
|
608
|
+
if (!hasEmail && !hasPhone) {
|
|
609
|
+
throw invalidInput("query", query, "requires a playerEmail or playerPhone");
|
|
610
|
+
}
|
|
611
|
+
const q = new URLSearchParams();
|
|
612
|
+
if (hasEmail) q.set("player_email", query.playerEmail.trim());
|
|
613
|
+
if (hasPhone) q.set("player_phone", query.playerPhone.trim());
|
|
614
|
+
const raw = await this.http.get(
|
|
615
|
+
`/api/transfers/inbound-pending?${q.toString()}`,
|
|
616
|
+
this.auth,
|
|
617
|
+
{ signal: opts?.signal }
|
|
310
618
|
);
|
|
619
|
+
const rows = Array.isArray(raw["inbound_pending"]) ? raw["inbound_pending"] : [];
|
|
620
|
+
return { inboundPending: rows.map(toInboundPendingItem), raw };
|
|
311
621
|
}
|
|
312
622
|
toInitiateResult(raw) {
|
|
313
623
|
const vm = raw["verification_method"];
|
|
@@ -321,6 +631,6 @@ var InvoServer = class {
|
|
|
321
631
|
}
|
|
322
632
|
};
|
|
323
633
|
|
|
324
|
-
export { InvoServer };
|
|
634
|
+
export { InvoServer, createWebhookHandler, verifyWebhook, verifyWebhookAsync };
|
|
325
635
|
//# sourceMappingURL=server.js.map
|
|
326
636
|
//# sourceMappingURL=server.js.map
|