@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/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 Http = class {
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
- async post(path, body, auth) {
91
- return this.request("POST", path, body, auth);
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
- async get(path, auth) {
94
- return this.request("GET", path, void 0, auth);
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 controller = new AbortController();
115
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
116
- let res;
117
- try {
118
- res = await this.fetchImpl(url, {
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
- headers,
121
- body: body !== void 0 ? JSON.stringify(body) : void 0,
122
- signal: controller.signal
123
- });
124
- } catch (err) {
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
- } finally {
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
- const text = await res.text();
134
- let parsed = null;
135
- if (text) {
136
- try {
137
- parsed = JSON.parse(text);
138
- } catch {
139
- parsed = text;
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
- if (!res.ok) throw errorFromResponse(res.status, parsed);
143
- return parsed ?? {};
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.1.0 (+https://invo.network)";
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
- return this.http.post(
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
- return this.http.get(
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
- return this.http.get(
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
- return this.http.get(
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