@usions/sdk 2.12.0 → 2.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usions/sdk",
3
- "version": "2.12.0",
3
+ "version": "2.14.0",
4
4
  "description": "Usion Mini App SDK for iframe games and services",
5
5
  "type": "module",
6
6
  "main": "src/modules/index.js",
package/src/browser.js CHANGED
@@ -69,7 +69,7 @@ var Usion = (function () {
69
69
  * Core Usion object with init, _post, _request
70
70
  */
71
71
  const core = {
72
- version: '2.12.0', // injected from package.json at build
72
+ version: '2.14.0', // injected from package.json at build
73
73
  config: {},
74
74
  _initialized: false,
75
75
  _initCallback: null,
@@ -212,6 +212,22 @@ var Usion = (function () {
212
212
  return this.config.language || 'en';
213
213
  },
214
214
 
215
+ /**
216
+ * Launch parameters the host opened this app with. Use `path` to deep-link
217
+ * to a specific screen — e.g. when the user taps a notification sent via
218
+ * `Usion.notify.send({ path })`, the app reopens and `getLaunchParams().path`
219
+ * returns that same path so the app can route to it.
220
+ * @returns {{ path: string|null, ref: string|null, roomId: string|null }}
221
+ */
222
+ getLaunchParams: function() {
223
+ var c = this.config || {};
224
+ return {
225
+ path: c.launchPath || null,
226
+ ref: c.ref || c.launchRef || null,
227
+ roomId: c.roomId || null,
228
+ };
229
+ },
230
+
215
231
  /**
216
232
  * Send message to parent app
217
233
  * @private
@@ -410,6 +426,13 @@ var Usion = (function () {
410
426
  _balance: null,
411
427
  _balanceChangeHandler: null,
412
428
 
429
+ // Payment timing knobs (overridable, mainly for tests). The defaults are
430
+ // the production values.
431
+ _paymentTimeoutMs: 60000, // wait this long for the direct PAYMENT_SUCCESS
432
+ _recoveryPollMs: 2000, // gap between result-recovery re-queries
433
+ _recoveryMaxPolls: 10, // bounded recovery window after the timeout
434
+ _recoveryQueryTimeoutMs: 8000, // per re-query round-trip budget
435
+
413
436
  /**
414
437
  * Get current wallet balance
415
438
  * @returns {Promise<number>} Balance in credits
@@ -440,20 +463,69 @@ var Usion = (function () {
440
463
  },
441
464
 
442
465
  /**
443
- * Request payment from user with balance check
466
+ * Request payment from user with balance check.
467
+ *
468
+ * The charge is debited immediately by the host (escrow); the resolved
469
+ * value carries a `receiptToken` the mini-app server later settles/refunds.
470
+ *
471
+ * Reliability: if the host's PAYMENT_SUCCESS message is lost (network blip,
472
+ * backgrounded tab, race) the user may ALREADY be charged. Instead of
473
+ * rejecting at the timeout, this re-queries the host for the result and
474
+ * recovers the receipt token — so a dropped message never strands a paid
475
+ * charge. Pass `idempotencyKey` to additionally guarantee that any retry of
476
+ * the SAME intent reuses the SAME charge (the host + backend dedupe on it),
477
+ * so the user can never be charged twice even across reloads.
478
+ *
444
479
  * @param {number} amount - Credit amount to charge
445
480
  * @param {string} reason - Description shown to user
446
- * @param {object} data - Optional additional data
481
+ * @param {object} [data] - Optional options/payload. `data.idempotencyKey`
482
+ * (string) makes the charge safely retryable. Any other fields are
483
+ * forwarded to the host verbatim (backward compatible).
447
484
  * @returns {Promise} Resolves on payment success, rejects on failure
448
485
  */
449
486
  requestPayment: function(amount, reason, data) {
450
487
  const self = this;
451
488
 
489
+ const opts = (data && typeof data === 'object') ? data : null;
490
+ const idempotencyKey = (opts && typeof opts.idempotencyKey === 'string' && opts.idempotencyKey)
491
+ ? opts.idempotencyKey
492
+ : null;
493
+
452
494
  return new Promise(function(resolve, reject) {
453
495
  const requestId = getNextRequestId();
454
- const timeoutMs = 60000;
496
+ const timeoutMs = self._paymentTimeoutMs || 60000;
497
+ let settled = false;
498
+ let recoverTimer = null;
455
499
 
456
- // Listen for response
500
+ function cleanup() {
501
+ clearTimeout(timer);
502
+ if (recoverTimer) clearTimeout(recoverTimer);
503
+ window.removeEventListener('message', handler);
504
+ }
505
+
506
+ function finishResolve(response) {
507
+ if (settled) return;
508
+ settled = true;
509
+ cleanup();
510
+ // Update cached balance from the authoritative new balance, else
511
+ // best-effort subtract (no-op if balance is unknown).
512
+ if (response && response.newBalance !== undefined) {
513
+ self._balance = response.newBalance;
514
+ } else if (self._balance !== null) {
515
+ self._balance -= amount;
516
+ }
517
+ resolve(response);
518
+ }
519
+
520
+ function finishReject(error) {
521
+ if (settled) return;
522
+ settled = true;
523
+ cleanup();
524
+ reject(error);
525
+ }
526
+
527
+ // Direct response from the host. Kept registered through the recovery
528
+ // window so a late PAYMENT_SUCCESS still resolves.
457
529
  function handler(event) {
458
530
  // Only honor payment results from the trusted host shell — a forged
459
531
  // PAYMENT_SUCCESS must never resolve this promise.
@@ -467,32 +539,97 @@ var Usion = (function () {
467
539
  }
468
540
 
469
541
  // Only accept responses for this specific payment request.
470
- if (response._requestId !== requestId) {
542
+ if (!response || response._requestId !== requestId) {
471
543
  return;
472
544
  }
473
545
 
474
546
  if (response.type === 'PAYMENT_SUCCESS') {
475
- clearTimeout(timer);
476
- window.removeEventListener('message', handler);
477
- // Update cached balance
478
- if (response.newBalance !== undefined) {
479
- self._balance = response.newBalance;
480
- } else if (self._balance !== null) {
481
- self._balance -= amount;
482
- }
483
- resolve(response);
547
+ finishResolve(response);
484
548
  } else if (response.type === 'PAYMENT_FAILED') {
485
- clearTimeout(timer);
486
- window.removeEventListener('message', handler);
487
- reject(new Error(response.reason || 'Payment failed'));
549
+ finishReject(new Error(response.reason || 'Payment failed'));
550
+ }
551
+ }
552
+
553
+ // After the direct deadline, re-query the host for this payment's
554
+ // result instead of rejecting.
555
+ // succeeded -> resolve with the recovered token
556
+ // failed -> reject
557
+ // pending -> the modal is STILL OPEN / charge still in progress, so
558
+ // the charge is fully recoverable: keep waiting (do NOT
559
+ // count this toward the give-up cap). A slow user who
560
+ // confirms minutes later still resolves — via the next
561
+ // 'succeeded' poll or the direct handler, which stays
562
+ // registered. Without this, a charge made after we gave
563
+ // up would be stranded (the original bug, shifted to the
564
+ // slow-confirm case).
565
+ // none/other -> likely no charge; confirm twice then reject cleanly so
566
+ // a fresh requestPayment is safe.
567
+ // The bounded cap only governs the give-up paths (none-streak and a host
568
+ // that never answers the query at all, e.g. an older host shell).
569
+ function recoverPaymentResult() {
570
+ const pollMs = self._recoveryPollMs || 2000;
571
+ const maxPolls = self._recoveryMaxPolls || 10;
572
+ const queryTimeoutMs = self._recoveryQueryTimeoutMs || 8000;
573
+ let noneStreak = 0;
574
+ let errorStreak = 0;
575
+
576
+ function poll() {
577
+ if (settled) return;
578
+ Usion._request('PAYMENT_RESULT_QUERY', {
579
+ paymentRequestId: requestId,
580
+ idempotencyKey: idempotencyKey || undefined,
581
+ }, queryTimeoutMs).then(function(res) {
582
+ if (settled) return;
583
+ const status = res && res.status;
584
+ if (status === 'succeeded') {
585
+ finishResolve({
586
+ type: 'PAYMENT_SUCCESS',
587
+ _requestId: requestId,
588
+ newBalance: res.newBalance,
589
+ transactionId: res.transactionId,
590
+ receiptToken: res.receiptToken,
591
+ });
592
+ return;
593
+ }
594
+ if (status === 'failed') {
595
+ finishReject(new Error(res.reason || 'Payment failed'));
596
+ return;
597
+ }
598
+ if (status === 'pending') {
599
+ // Charge still recoverable — keep waiting, no give-up budget.
600
+ noneStreak = 0;
601
+ errorStreak = 0;
602
+ recoverTimer = setTimeout(poll, pollMs);
603
+ return;
604
+ }
605
+ // 'none' or unknown — host is confident no charge exists. Confirm
606
+ // twice (guards a host that simply hasn't recorded yet) then reject.
607
+ errorStreak = 0;
608
+ noneStreak += 1;
609
+ if (noneStreak >= 2) {
610
+ finishReject(new Error('Payment confirmation timeout'));
611
+ return;
612
+ }
613
+ recoverTimer = setTimeout(poll, pollMs);
614
+ }).catch(function() {
615
+ // Host didn't answer the query (e.g. an older host shell without
616
+ // PAYMENT_RESULT_QUERY support). Retry a bounded number of times,
617
+ // then fall back to the original timeout rejection.
618
+ if (settled) return;
619
+ errorStreak += 1;
620
+ if (errorStreak >= maxPolls) {
621
+ finishReject(new Error('Payment confirmation timeout'));
622
+ return;
623
+ }
624
+ recoverTimer = setTimeout(poll, pollMs);
625
+ });
488
626
  }
627
+
628
+ poll();
489
629
  }
490
630
 
491
631
  window.addEventListener('message', handler);
492
- const timer = setTimeout(function() {
493
- window.removeEventListener('message', handler);
494
- reject(new Error('Payment confirmation timeout'));
495
- }, timeoutMs);
632
+ const timer = setTimeout(recoverPaymentResult, timeoutMs);
496
633
 
497
634
  // Send payment request
498
635
  Usion._post({
@@ -500,7 +637,8 @@ var Usion = (function () {
500
637
  _requestId: requestId,
501
638
  amount: amount,
502
639
  reason: reason,
503
- data: data
640
+ data: data,
641
+ idempotencyKey: idempotencyKey || undefined,
504
642
  });
505
643
  });
506
644
  },
@@ -4835,6 +4973,77 @@ var Usion = (function () {
4835
4973
  };
4836
4974
  }
4837
4975
 
4976
+ /**
4977
+ * Usion SDK Notify — let a mini-app notify its own user.
4978
+ *
4979
+ * A notification reaches the user even when they aren't looking at the app:
4980
+ * - online elsewhere in Usion -> in-app banner
4981
+ * - offline / app backgrounded -> OS push notification
4982
+ * Tapping it re-opens THIS mini-app, optionally at a specific internal screen
4983
+ * via `path` (read back on launch with `Usion.getLaunchParams().path`).
4984
+ *
4985
+ * Rides the unified backend channel, so it works standalone AND embedded.
4986
+ * Scope & safety: a notification can only target the CURRENT user (you cannot
4987
+ * notify other people from here), and the platform rate-limits per user per
4988
+ * service. Users can silence a service with `setMuted(true)`.
4989
+ *
4990
+ * await Usion.notify.send({
4991
+ * title: 'Render complete',
4992
+ * body: 'Your video is ready to view.',
4993
+ * path: '/render/abc123', // optional — deep-links inside the app
4994
+ * });
4995
+ *
4996
+ * await Usion.notify.setMuted(true); // user opts out for this app
4997
+ * const muted = await Usion.notify.isMuted();
4998
+ *
4999
+ * For server-triggered notifications (e.g. a long-running job finishing while
5000
+ * the app is closed), a mini-app's own backend calls the signed REST endpoint
5001
+ * `POST /services/{id}/notify` instead — see the publishing reference.
5002
+ */
5003
+ function createNotifyModule(Usion) {
5004
+ function serviceId(opts) {
5005
+ return (opts && opts.serviceId) || (Usion.config && Usion.config.serviceId);
5006
+ }
5007
+
5008
+ return {
5009
+ /**
5010
+ * Send a notification to the current user.
5011
+ * @param {{title: string, body: string, path?: string, serviceId?: string}} opts
5012
+ * @returns {Promise<{success: boolean, delivered?: string}>}
5013
+ */
5014
+ send: function (opts) {
5015
+ opts = opts || {};
5016
+ return Usion._backendEmit('notify:send', {
5017
+ service_id: serviceId(opts),
5018
+ title: opts.title,
5019
+ body: opts.body,
5020
+ path: opts.path,
5021
+ });
5022
+ },
5023
+
5024
+ /**
5025
+ * Mute (or unmute) notifications from this app for the current user.
5026
+ * @param {boolean} muted
5027
+ * @returns {Promise<{success: boolean, muted: boolean}>}
5028
+ */
5029
+ setMuted: function (muted, opts) {
5030
+ return Usion._backendEmit('notify:set_pref', {
5031
+ service_id: serviceId(opts),
5032
+ muted: !!muted,
5033
+ });
5034
+ },
5035
+
5036
+ /**
5037
+ * Whether the current user has muted notifications from this app.
5038
+ * @returns {Promise<boolean>}
5039
+ */
5040
+ isMuted: function (opts) {
5041
+ return Usion._backendEmit('notify:get_pref', { service_id: serviceId(opts) })
5042
+ .then(function (r) { return !!(r && r.muted); });
5043
+ },
5044
+ };
5045
+ }
5046
+
4838
5047
  /**
4839
5048
  * Usion SDK — unified backend channel.
4840
5049
  *
@@ -4972,6 +5181,7 @@ var Usion = (function () {
4972
5181
  Usion.leaderboard = createLeaderboardModule(Usion);
4973
5182
  Usion.cloud = createCloudModule(Usion);
4974
5183
  Usion.matchmaking = createMatchmakingModule(Usion);
5184
+ Usion.notify = createNotifyModule(Usion);
4975
5185
 
4976
5186
  // Netcode toolkit (transport-agnostic, zero-dependency).
4977
5187
  Usion.netcode = netcode;
@@ -209,6 +209,22 @@ export const core = {
209
209
  return this.config.language || 'en';
210
210
  },
211
211
 
212
+ /**
213
+ * Launch parameters the host opened this app with. Use `path` to deep-link
214
+ * to a specific screen — e.g. when the user taps a notification sent via
215
+ * `Usion.notify.send({ path })`, the app reopens and `getLaunchParams().path`
216
+ * returns that same path so the app can route to it.
217
+ * @returns {{ path: string|null, ref: string|null, roomId: string|null }}
218
+ */
219
+ getLaunchParams: function() {
220
+ var c = this.config || {};
221
+ return {
222
+ path: c.launchPath || null,
223
+ ref: c.ref || c.launchRef || null,
224
+ roomId: c.roomId || null,
225
+ };
226
+ },
227
+
212
228
  /**
213
229
  * Send message to parent app
214
230
  * @private
@@ -29,6 +29,7 @@ import { createLobbyModule } from './lobby.js';
29
29
  import { createLeaderboardModule } from './leaderboard.js';
30
30
  import { createCloudModule } from './cloud.js';
31
31
  import { createMatchmakingModule } from './matchmaking.js';
32
+ import { createNotifyModule } from './notify.js';
32
33
  import { applyBackendChannel } from './backend-channel.js';
33
34
  import { netcode } from './netcode/index.js';
34
35
  import { UsionError, ERROR_CODES } from './errors.js';
@@ -54,6 +55,7 @@ Usion.lobby = createLobbyModule(Usion);
54
55
  Usion.leaderboard = createLeaderboardModule(Usion);
55
56
  Usion.cloud = createCloudModule(Usion);
56
57
  Usion.matchmaking = createMatchmakingModule(Usion);
58
+ Usion.notify = createNotifyModule(Usion);
57
59
 
58
60
  // Netcode toolkit (transport-agnostic, zero-dependency).
59
61
  Usion.netcode = netcode;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Usion SDK Notify — let a mini-app notify its own user.
3
+ *
4
+ * A notification reaches the user even when they aren't looking at the app:
5
+ * - online elsewhere in Usion -> in-app banner
6
+ * - offline / app backgrounded -> OS push notification
7
+ * Tapping it re-opens THIS mini-app, optionally at a specific internal screen
8
+ * via `path` (read back on launch with `Usion.getLaunchParams().path`).
9
+ *
10
+ * Rides the unified backend channel, so it works standalone AND embedded.
11
+ * Scope & safety: a notification can only target the CURRENT user (you cannot
12
+ * notify other people from here), and the platform rate-limits per user per
13
+ * service. Users can silence a service with `setMuted(true)`.
14
+ *
15
+ * await Usion.notify.send({
16
+ * title: 'Render complete',
17
+ * body: 'Your video is ready to view.',
18
+ * path: '/render/abc123', // optional — deep-links inside the app
19
+ * });
20
+ *
21
+ * await Usion.notify.setMuted(true); // user opts out for this app
22
+ * const muted = await Usion.notify.isMuted();
23
+ *
24
+ * For server-triggered notifications (e.g. a long-running job finishing while
25
+ * the app is closed), a mini-app's own backend calls the signed REST endpoint
26
+ * `POST /services/{id}/notify` instead — see the publishing reference.
27
+ */
28
+ export function createNotifyModule(Usion) {
29
+ function serviceId(opts) {
30
+ return (opts && opts.serviceId) || (Usion.config && Usion.config.serviceId);
31
+ }
32
+
33
+ return {
34
+ /**
35
+ * Send a notification to the current user.
36
+ * @param {{title: string, body: string, path?: string, serviceId?: string}} opts
37
+ * @returns {Promise<{success: boolean, delivered?: string}>}
38
+ */
39
+ send: function (opts) {
40
+ opts = opts || {};
41
+ return Usion._backendEmit('notify:send', {
42
+ service_id: serviceId(opts),
43
+ title: opts.title,
44
+ body: opts.body,
45
+ path: opts.path,
46
+ });
47
+ },
48
+
49
+ /**
50
+ * Mute (or unmute) notifications from this app for the current user.
51
+ * @param {boolean} muted
52
+ * @returns {Promise<{success: boolean, muted: boolean}>}
53
+ */
54
+ setMuted: function (muted, opts) {
55
+ return Usion._backendEmit('notify:set_pref', {
56
+ service_id: serviceId(opts),
57
+ muted: !!muted,
58
+ });
59
+ },
60
+
61
+ /**
62
+ * Whether the current user has muted notifications from this app.
63
+ * @returns {Promise<boolean>}
64
+ */
65
+ isMuted: function (opts) {
66
+ return Usion._backendEmit('notify:get_pref', { service_id: serviceId(opts) })
67
+ .then(function (r) { return !!(r && r.muted); });
68
+ },
69
+ };
70
+ }
@@ -12,6 +12,13 @@ export function createWalletModule(Usion) {
12
12
  _balance: null,
13
13
  _balanceChangeHandler: null,
14
14
 
15
+ // Payment timing knobs (overridable, mainly for tests). The defaults are
16
+ // the production values.
17
+ _paymentTimeoutMs: 60000, // wait this long for the direct PAYMENT_SUCCESS
18
+ _recoveryPollMs: 2000, // gap between result-recovery re-queries
19
+ _recoveryMaxPolls: 10, // bounded recovery window after the timeout
20
+ _recoveryQueryTimeoutMs: 8000, // per re-query round-trip budget
21
+
15
22
  /**
16
23
  * Get current wallet balance
17
24
  * @returns {Promise<number>} Balance in credits
@@ -42,20 +49,69 @@ export function createWalletModule(Usion) {
42
49
  },
43
50
 
44
51
  /**
45
- * Request payment from user with balance check
52
+ * Request payment from user with balance check.
53
+ *
54
+ * The charge is debited immediately by the host (escrow); the resolved
55
+ * value carries a `receiptToken` the mini-app server later settles/refunds.
56
+ *
57
+ * Reliability: if the host's PAYMENT_SUCCESS message is lost (network blip,
58
+ * backgrounded tab, race) the user may ALREADY be charged. Instead of
59
+ * rejecting at the timeout, this re-queries the host for the result and
60
+ * recovers the receipt token — so a dropped message never strands a paid
61
+ * charge. Pass `idempotencyKey` to additionally guarantee that any retry of
62
+ * the SAME intent reuses the SAME charge (the host + backend dedupe on it),
63
+ * so the user can never be charged twice even across reloads.
64
+ *
46
65
  * @param {number} amount - Credit amount to charge
47
66
  * @param {string} reason - Description shown to user
48
- * @param {object} data - Optional additional data
67
+ * @param {object} [data] - Optional options/payload. `data.idempotencyKey`
68
+ * (string) makes the charge safely retryable. Any other fields are
69
+ * forwarded to the host verbatim (backward compatible).
49
70
  * @returns {Promise} Resolves on payment success, rejects on failure
50
71
  */
51
72
  requestPayment: function(amount, reason, data) {
52
73
  const self = this;
53
74
 
75
+ const opts = (data && typeof data === 'object') ? data : null;
76
+ const idempotencyKey = (opts && typeof opts.idempotencyKey === 'string' && opts.idempotencyKey)
77
+ ? opts.idempotencyKey
78
+ : null;
79
+
54
80
  return new Promise(function(resolve, reject) {
55
81
  const requestId = getNextRequestId();
56
- const timeoutMs = 60000;
82
+ const timeoutMs = self._paymentTimeoutMs || 60000;
83
+ let settled = false;
84
+ let recoverTimer = null;
85
+
86
+ function cleanup() {
87
+ clearTimeout(timer);
88
+ if (recoverTimer) clearTimeout(recoverTimer);
89
+ window.removeEventListener('message', handler);
90
+ }
91
+
92
+ function finishResolve(response) {
93
+ if (settled) return;
94
+ settled = true;
95
+ cleanup();
96
+ // Update cached balance from the authoritative new balance, else
97
+ // best-effort subtract (no-op if balance is unknown).
98
+ if (response && response.newBalance !== undefined) {
99
+ self._balance = response.newBalance;
100
+ } else if (self._balance !== null) {
101
+ self._balance -= amount;
102
+ }
103
+ resolve(response);
104
+ }
57
105
 
58
- // Listen for response
106
+ function finishReject(error) {
107
+ if (settled) return;
108
+ settled = true;
109
+ cleanup();
110
+ reject(error);
111
+ }
112
+
113
+ // Direct response from the host. Kept registered through the recovery
114
+ // window so a late PAYMENT_SUCCESS still resolves.
59
115
  function handler(event) {
60
116
  // Only honor payment results from the trusted host shell — a forged
61
117
  // PAYMENT_SUCCESS must never resolve this promise.
@@ -69,32 +125,97 @@ export function createWalletModule(Usion) {
69
125
  }
70
126
 
71
127
  // Only accept responses for this specific payment request.
72
- if (response._requestId !== requestId) {
128
+ if (!response || response._requestId !== requestId) {
73
129
  return;
74
130
  }
75
131
 
76
132
  if (response.type === 'PAYMENT_SUCCESS') {
77
- clearTimeout(timer);
78
- window.removeEventListener('message', handler);
79
- // Update cached balance
80
- if (response.newBalance !== undefined) {
81
- self._balance = response.newBalance;
82
- } else if (self._balance !== null) {
83
- self._balance -= amount;
84
- }
85
- resolve(response);
133
+ finishResolve(response);
86
134
  } else if (response.type === 'PAYMENT_FAILED') {
87
- clearTimeout(timer);
88
- window.removeEventListener('message', handler);
89
- reject(new Error(response.reason || 'Payment failed'));
135
+ finishReject(new Error(response.reason || 'Payment failed'));
136
+ }
137
+ }
138
+
139
+ // After the direct deadline, re-query the host for this payment's
140
+ // result instead of rejecting.
141
+ // succeeded -> resolve with the recovered token
142
+ // failed -> reject
143
+ // pending -> the modal is STILL OPEN / charge still in progress, so
144
+ // the charge is fully recoverable: keep waiting (do NOT
145
+ // count this toward the give-up cap). A slow user who
146
+ // confirms minutes later still resolves — via the next
147
+ // 'succeeded' poll or the direct handler, which stays
148
+ // registered. Without this, a charge made after we gave
149
+ // up would be stranded (the original bug, shifted to the
150
+ // slow-confirm case).
151
+ // none/other -> likely no charge; confirm twice then reject cleanly so
152
+ // a fresh requestPayment is safe.
153
+ // The bounded cap only governs the give-up paths (none-streak and a host
154
+ // that never answers the query at all, e.g. an older host shell).
155
+ function recoverPaymentResult() {
156
+ const pollMs = self._recoveryPollMs || 2000;
157
+ const maxPolls = self._recoveryMaxPolls || 10;
158
+ const queryTimeoutMs = self._recoveryQueryTimeoutMs || 8000;
159
+ let noneStreak = 0;
160
+ let errorStreak = 0;
161
+
162
+ function poll() {
163
+ if (settled) return;
164
+ Usion._request('PAYMENT_RESULT_QUERY', {
165
+ paymentRequestId: requestId,
166
+ idempotencyKey: idempotencyKey || undefined,
167
+ }, queryTimeoutMs).then(function(res) {
168
+ if (settled) return;
169
+ const status = res && res.status;
170
+ if (status === 'succeeded') {
171
+ finishResolve({
172
+ type: 'PAYMENT_SUCCESS',
173
+ _requestId: requestId,
174
+ newBalance: res.newBalance,
175
+ transactionId: res.transactionId,
176
+ receiptToken: res.receiptToken,
177
+ });
178
+ return;
179
+ }
180
+ if (status === 'failed') {
181
+ finishReject(new Error(res.reason || 'Payment failed'));
182
+ return;
183
+ }
184
+ if (status === 'pending') {
185
+ // Charge still recoverable — keep waiting, no give-up budget.
186
+ noneStreak = 0;
187
+ errorStreak = 0;
188
+ recoverTimer = setTimeout(poll, pollMs);
189
+ return;
190
+ }
191
+ // 'none' or unknown — host is confident no charge exists. Confirm
192
+ // twice (guards a host that simply hasn't recorded yet) then reject.
193
+ errorStreak = 0;
194
+ noneStreak += 1;
195
+ if (noneStreak >= 2) {
196
+ finishReject(new Error('Payment confirmation timeout'));
197
+ return;
198
+ }
199
+ recoverTimer = setTimeout(poll, pollMs);
200
+ }).catch(function() {
201
+ // Host didn't answer the query (e.g. an older host shell without
202
+ // PAYMENT_RESULT_QUERY support). Retry a bounded number of times,
203
+ // then fall back to the original timeout rejection.
204
+ if (settled) return;
205
+ errorStreak += 1;
206
+ if (errorStreak >= maxPolls) {
207
+ finishReject(new Error('Payment confirmation timeout'));
208
+ return;
209
+ }
210
+ recoverTimer = setTimeout(poll, pollMs);
211
+ });
90
212
  }
213
+
214
+ poll();
91
215
  }
92
216
 
93
217
  window.addEventListener('message', handler);
94
- const timer = setTimeout(function() {
95
- window.removeEventListener('message', handler);
96
- reject(new Error('Payment confirmation timeout'));
97
- }, timeoutMs);
218
+ const timer = setTimeout(recoverPaymentResult, timeoutMs);
98
219
 
99
220
  // Send payment request
100
221
  Usion._post({
@@ -102,7 +223,8 @@ export function createWalletModule(Usion) {
102
223
  _requestId: requestId,
103
224
  amount: amount,
104
225
  reason: reason,
105
- data: data
226
+ data: data,
227
+ idempotencyKey: idempotencyKey || undefined,
106
228
  });
107
229
  });
108
230
  },
package/types/index.d.ts CHANGED
@@ -24,6 +24,13 @@ export interface UsionConfig {
24
24
  serviceName?: string;
25
25
  apiUrl?: string;
26
26
  connectionMode?: 'platform' | 'direct';
27
+ /**
28
+ * Internal path the host opened this app at — set when the user taps a
29
+ * notification carrying a `path`. Read via `Usion.getLaunchParams().path`.
30
+ */
31
+ launchPath?: string;
32
+ /** Referral code the app was opened with, if any. */
33
+ ref?: string;
27
34
  }
28
35
 
29
36
  // ─── Payment ─────────────────────────────────────────────────────
@@ -36,6 +43,17 @@ export interface PaymentResponse {
36
43
  transactionId?: string;
37
44
  }
38
45
 
46
+ export interface PaymentOptions {
47
+ /**
48
+ * Optional idempotency key. When set, the host + backend dedupe on it so any
49
+ * retry of the SAME intent (network error, reload, double-tap) reuses the
50
+ * SAME charge instead of debiting again, and returns the same receiptToken.
51
+ * Omit for the legacy one-shot behavior.
52
+ */
53
+ idempotencyKey?: string;
54
+ [key: string]: any;
55
+ }
56
+
39
57
  // ─── User ────────────────────────────────────────────────────────
40
58
 
41
59
  export interface UserProfile {
@@ -75,7 +93,7 @@ export interface FileStorageModule {
75
93
  export interface WalletModule {
76
94
  getBalance(): Promise<number>;
77
95
  hasCredits(amount: number): Promise<boolean>;
78
- requestPayment(amount: number, reason: string, data?: Record<string, any>): Promise<PaymentResponse>;
96
+ requestPayment(amount: number, reason: string, data?: PaymentOptions): Promise<PaymentResponse>;
79
97
  onBalanceChange(callback: (balance: number) => void): UnsubscribeFn;
80
98
  }
81
99
 
@@ -753,8 +771,15 @@ export interface UsionSDK {
753
771
  getTheme(): 'light' | 'dark';
754
772
  getLanguage(): string;
755
773
 
774
+ /**
775
+ * Launch parameters the host opened this app with. `path` is the deep-link
776
+ * target (e.g. from a tapped `Usion.notify` notification); route to it after
777
+ * init so the user lands on the right screen.
778
+ */
779
+ getLaunchParams(): { path: string | null; ref: string | null; roomId: string | null };
780
+
756
781
  // Payments
757
- requestPayment(amount: number, reason: string, data?: Record<string, any>): Promise<PaymentResponse>;
782
+ requestPayment(amount: number, reason: string, data?: PaymentOptions): Promise<PaymentResponse>;
758
783
 
759
784
  // Service communication
760
785
  submit(data: Record<string, any>): void;
@@ -831,9 +856,24 @@ export interface UsionSDK {
831
856
  leaderboard: LeaderboardModule;
832
857
  matchmaking: MatchmakingModule;
833
858
  cloud: CloudModule;
859
+ notify: NotifyModule;
834
860
  netcode: NetcodeModule;
835
861
  }
836
862
 
863
+ /**
864
+ * Send notifications to the CURRENT user that reopen this app (in-app banner
865
+ * when online, OS push when offline). `path` deep-links to a screen, read back
866
+ * via `Usion.getLaunchParams().path`. Rate-limited per user per service.
867
+ */
868
+ export interface NotifyModule {
869
+ /** Notify the current user. */
870
+ send(opts: { title: string; body: string; path?: string; serviceId?: string }): Promise<{ success: boolean; delivered?: string }>;
871
+ /** Mute/unmute notifications from this app for the current user. */
872
+ setMuted(muted: boolean, opts?: { serviceId?: string }): Promise<{ success: boolean; muted: boolean }>;
873
+ /** Whether the current user has muted this app's notifications. */
874
+ isMuted(opts?: { serviceId?: string }): Promise<boolean>;
875
+ }
876
+
837
877
  /** Scoped server-persisted KV operations (per-user or shared). */
838
878
  export interface CloudScope {
839
879
  /** Get a value; resolves to null when the key doesn't exist. */