@usions/sdk 2.1.4 → 2.2.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.1.4",
3
+ "version": "2.2.0",
4
4
  "description": "Usion Mini App SDK for iframe games and services",
5
5
  "type": "module",
6
6
  "main": "src/modules/index.js",
@@ -25,10 +25,12 @@
25
25
  "scripts": {
26
26
  "build": "rollup -c",
27
27
  "build:copy": "npm run build && cp src/browser.js ../../web/public/usion-sdk.js && cp src/browser.js ../../microservices/shared/usion-sdk.js",
28
- "watch": "rollup -c --watch"
28
+ "watch": "rollup -c --watch",
29
+ "test": "npm run build && vitest run"
29
30
  },
30
31
  "devDependencies": {
31
- "rollup": "^4.0.0"
32
+ "rollup": "^4.0.0",
33
+ "vitest": "^2.1.9"
32
34
  },
33
35
  "keywords": [
34
36
  "usion",
package/src/browser.js CHANGED
@@ -13,6 +13,58 @@ var Usion = (function () {
13
13
  return ++_requestId;
14
14
  }
15
15
 
16
+ // Trusted origin of the host shell that embedded us (web iframe only).
17
+ // Resolved lazily from the real embedder, never from message contents.
18
+ let _parentOrigin = null;
19
+
20
+ function _resolveParentOrigin() {
21
+ try {
22
+ if (window.location.ancestorOrigins && window.location.ancestorOrigins.length) {
23
+ return window.location.ancestorOrigins[0];
24
+ }
25
+ } catch (e) { /* not supported */ }
26
+ try {
27
+ if (typeof document !== 'undefined' && document.referrer) {
28
+ return new URL(document.referrer).origin;
29
+ }
30
+ } catch (e) { /* malformed referrer */ }
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * Decide whether an incoming postMessage may be trusted.
36
+ *
37
+ * The host shell is the only legitimate sender. A sibling iframe or any other
38
+ * script on the page must NOT be able to forge messages (e.g. a fake
39
+ * PAYMENT_SUCCESS that unlocks paid value for free).
40
+ *
41
+ * - React Native WebView: messages are delivered in-process and carry no
42
+ * usable origin/source, so they are trusted.
43
+ * - Web iframe: the only window that equals `window.parent` is the real
44
+ * embedder. `event.source` is set by the browser and cannot be spoofed, so
45
+ * `event.source === window.parent` rejects siblings and self-posts. We then
46
+ * cross-check `event.origin` against the embedder's origin as defense-in-depth.
47
+ * - Not embedded (standalone / tests): nothing to protect against; allowed.
48
+ *
49
+ * @param {MessageEvent} event
50
+ * @returns {boolean}
51
+ */
52
+ function isTrustedMessageSource(event) {
53
+ if (typeof window === 'undefined') return true;
54
+ if (window.ReactNativeWebView) return true;
55
+ if (window.parent === window) return true;
56
+
57
+ if (event && event.source && event.source !== window.parent) return false;
58
+
59
+ if (_parentOrigin === null) {
60
+ _parentOrigin = _resolveParentOrigin();
61
+ }
62
+ if (_parentOrigin && event && event.origin && event.origin !== _parentOrigin) {
63
+ return false;
64
+ }
65
+ return true;
66
+ }
67
+
16
68
  /**
17
69
  * Core Usion object with init, _post, _request
18
70
  */
@@ -49,6 +101,9 @@ var Usion = (function () {
49
101
 
50
102
  // Setup global message handler
51
103
  window.addEventListener('message', function(event) {
104
+ // Reject messages from anything other than the host shell.
105
+ if (!isTrustedMessageSource(event)) return;
106
+
52
107
  let data;
53
108
  try {
54
109
  data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
@@ -394,6 +449,10 @@ var Usion = (function () {
394
449
 
395
450
  // Listen for response
396
451
  function handler(event) {
452
+ // Only honor payment results from the trusted host shell — a forged
453
+ // PAYMENT_SUCCESS must never resolve this promise.
454
+ if (!isTrustedMessageSource(event)) return;
455
+
397
456
  let response;
398
457
  try {
399
458
  response = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
@@ -780,6 +839,7 @@ var Usion = (function () {
780
839
  * Usion SDK Misc — submit, error, exit, share, log, on, requestPayment (legacy)
781
840
  */
782
841
 
842
+
783
843
  const miscMethods = {
784
844
  /**
785
845
  * Request payment from user (legacy method)
@@ -886,6 +946,8 @@ var Usion = (function () {
886
946
  */
887
947
  on: function(type, callback) {
888
948
  window.addEventListener('message', function(event) {
949
+ if (!isTrustedMessageSource(event)) return;
950
+
889
951
  let data;
890
952
  try {
891
953
  data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
@@ -1367,6 +1429,7 @@ var Usion = (function () {
1367
1429
  * Usion SDK Game Proxy — postMessage relay through parent app
1368
1430
  */
1369
1431
 
1432
+
1370
1433
  /**
1371
1434
  * Add proxy connection methods to game module
1372
1435
  * @param {object} game - The game module object
@@ -1423,6 +1486,8 @@ var Usion = (function () {
1423
1486
  self._proxyListenerSetup = true;
1424
1487
 
1425
1488
  window.addEventListener('message', function(event) {
1489
+ if (!isTrustedMessageSource(event)) return;
1490
+
1426
1491
  var data;
1427
1492
  try {
1428
1493
  data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
@@ -1636,7 +1701,10 @@ var Usion = (function () {
1636
1701
  const self = this;
1637
1702
 
1638
1703
  if (self.directMode) {
1639
- self._sendDirect(actionType || 'action', actionData || {});
1704
+ self._sendDirect('action', {
1705
+ action_type: actionType || 'default',
1706
+ action_data: actionData || {}
1707
+ });
1640
1708
  return Promise.resolve({ success: true });
1641
1709
  }
1642
1710
 
@@ -1836,6 +1904,109 @@ var Usion = (function () {
1836
1904
  }
1837
1905
  };
1838
1906
 
1907
+ // ───────────────────────────────────────────────────────────
1908
+ // Cross-reload state persistence
1909
+ // ───────────────────────────────────────────────────────────
1910
+ // When a mini-app's iframe is unmounted and later re-mounted (e.g. the
1911
+ // user navigates back to the chat and re-opens the game from the same
1912
+ // room), the entire JS context is destroyed. Server-side room state is
1913
+ // preserved, but anything the game holds in memory — board state, phase,
1914
+ // whose turn it is, placement choices — is lost. The platform sync
1915
+ // mechanism only replays raw actions; reconstructing client-visible
1916
+ // state from a zero baseline is fragile or impossible.
1917
+ //
1918
+ // These helpers give every game a uniform way to snapshot whatever it
1919
+ // needs to localStorage. Keys are scoped to (player_id, room_id) so a
1920
+ // single browser can hold independent state for different rooms or
1921
+ // accounts without collision, and "play a different match in the same
1922
+ // room" naturally collides — which is the correct outcome.
1923
+
1924
+ const STATE_KEY_PREFIX = '_usion_game_state:';
1925
+
1926
+ function _stateKey(self) {
1927
+ const rid = self.roomId || (Usion.config && Usion.config.roomId);
1928
+ const pid = self.playerId
1929
+ || (Usion.user && typeof Usion.user.getId === 'function' && Usion.user.getId())
1930
+ || (Usion.config && Usion.config.userId);
1931
+ if (!rid || !pid) return null;
1932
+ return STATE_KEY_PREFIX + pid + ':' + rid;
1933
+ }
1934
+
1935
+ /**
1936
+ * Persist arbitrary JSON-serializable game state across iframe reloads.
1937
+ * The mini-app decides the schema; the SDK only stores/retrieves.
1938
+ * @param {*} state - Any JSON-serializable value
1939
+ * @returns {boolean} true if saved, false if not (no room/player yet, or storage error)
1940
+ */
1941
+ game.saveState = function(state) {
1942
+ const self = this;
1943
+ const key = _stateKey(self);
1944
+ if (!key) return false;
1945
+ try {
1946
+ localStorage.setItem(key, JSON.stringify({ state: state, savedAt: Date.now() }));
1947
+ return true;
1948
+ } catch (e) {
1949
+ // Quota exceeded, private-mode rejection, etc. — non-fatal.
1950
+ return false;
1951
+ }
1952
+ };
1953
+
1954
+ /**
1955
+ * Retrieve previously-saved state for the current (player, room).
1956
+ * @returns {*} The saved state value, or null if none / unreadable.
1957
+ */
1958
+ game.loadState = function() {
1959
+ const self = this;
1960
+ const key = _stateKey(self);
1961
+ if (!key) return null;
1962
+ try {
1963
+ const raw = localStorage.getItem(key);
1964
+ if (!raw) return null;
1965
+ const parsed = JSON.parse(raw);
1966
+ return parsed && parsed.state !== undefined ? parsed.state : null;
1967
+ } catch (e) {
1968
+ return null;
1969
+ }
1970
+ };
1971
+
1972
+ /**
1973
+ * Drop any persisted state for the current (player, room).
1974
+ * Call this when the game ends or starts fresh, so the next iframe
1975
+ * mount in the same room doesn't pick up stale data.
1976
+ */
1977
+ game.clearState = function() {
1978
+ const self = this;
1979
+ const key = _stateKey(self);
1980
+ if (!key) return;
1981
+ try { localStorage.removeItem(key); } catch (e) { /* non-fatal */ }
1982
+ };
1983
+
1984
+ /**
1985
+ * Forward a debug snapshot to the parent platform. The platform renders
1986
+ * it in a top-right overlay when the iframe host is opened with
1987
+ * `?debug=1`. The payload schema is up to the game — anything JSON-
1988
+ * serializable. No-op when not running inside an iframe.
1989
+ *
1990
+ * Games should call this at every meaningful state transition (turn
1991
+ * change, action sent, action received, sync, phase change, etc.) so
1992
+ * the overlay reflects live state.
1993
+ *
1994
+ * @param {object} payload - Arbitrary JSON-serializable debug data
1995
+ */
1996
+ game.debug = function(payload) {
1997
+ try {
1998
+ // Must work in both web iframes (window.parent !== window) and
1999
+ // React Native WebView (window.parent === window, but a host bridge
2000
+ // exists at window.ReactNativeWebView). Usion._post handles routing
2001
+ // for both; this guard just avoids no-op work in standalone pages.
2002
+ var inFrame = window.parent && window.parent !== window;
2003
+ var inRNWebView = !!window.ReactNativeWebView;
2004
+ if (inFrame || inRNWebView) {
2005
+ Usion._post({ type: 'GAME_DEBUG', payload: payload || {} });
2006
+ }
2007
+ } catch (e) { /* non-fatal */ }
2008
+ };
2009
+
1839
2010
  /**
1840
2011
  * Get connection status
1841
2012
  * @returns {boolean}
@@ -1890,17 +2061,6 @@ var Usion = (function () {
1890
2061
  return self.connectDirect();
1891
2062
  }
1892
2063
 
1893
- // Use config values as defaults
1894
- socketUrl = socketUrl || Usion.config.socketUrl;
1895
- token = token || Usion.user.getToken();
1896
-
1897
- if (!socketUrl) {
1898
- return Promise.reject(new Error('No socket URL provided'));
1899
- }
1900
- if (!token) {
1901
- return Promise.reject(new Error('No auth token available'));
1902
- }
1903
-
1904
2064
  // If already connected (direct or proxy), return immediately
1905
2065
  if (self._useProxy && self.connected) {
1906
2066
  return Promise.resolve();
@@ -1915,6 +2075,7 @@ var Usion = (function () {
1915
2075
  }
1916
2076
 
1917
2077
  // When running inside an iframe or WebView, use parent as socket proxy
2078
+ // (checked BEFORE token validation — iframe games don't need a token)
1918
2079
  var isInFrame = !!window.__USION_PROXY__
1919
2080
  || window.parent !== window
1920
2081
  || !!window.ReactNativeWebView
@@ -1925,6 +2086,17 @@ var Usion = (function () {
1925
2086
  return self._connectViaProxy();
1926
2087
  }
1927
2088
 
2089
+ // Use config values as defaults (only for direct socket connections)
2090
+ socketUrl = socketUrl || Usion.config.socketUrl;
2091
+ token = token || Usion.user.getToken();
2092
+
2093
+ if (!socketUrl) {
2094
+ return Promise.reject(new Error('No socket URL provided'));
2095
+ }
2096
+ if (!token) {
2097
+ return Promise.reject(new Error('No auth token available'));
2098
+ }
2099
+
1928
2100
  self._connecting = true;
1929
2101
  self._connectPromise = new Promise(function(resolve, reject) {
1930
2102
  // Check if socket.io-client is available
@@ -10,6 +10,58 @@ export function getNextRequestId() {
10
10
  return ++_requestId;
11
11
  }
12
12
 
13
+ // Trusted origin of the host shell that embedded us (web iframe only).
14
+ // Resolved lazily from the real embedder, never from message contents.
15
+ let _parentOrigin = null;
16
+
17
+ function _resolveParentOrigin() {
18
+ try {
19
+ if (window.location.ancestorOrigins && window.location.ancestorOrigins.length) {
20
+ return window.location.ancestorOrigins[0];
21
+ }
22
+ } catch (e) { /* not supported */ }
23
+ try {
24
+ if (typeof document !== 'undefined' && document.referrer) {
25
+ return new URL(document.referrer).origin;
26
+ }
27
+ } catch (e) { /* malformed referrer */ }
28
+ return null;
29
+ }
30
+
31
+ /**
32
+ * Decide whether an incoming postMessage may be trusted.
33
+ *
34
+ * The host shell is the only legitimate sender. A sibling iframe or any other
35
+ * script on the page must NOT be able to forge messages (e.g. a fake
36
+ * PAYMENT_SUCCESS that unlocks paid value for free).
37
+ *
38
+ * - React Native WebView: messages are delivered in-process and carry no
39
+ * usable origin/source, so they are trusted.
40
+ * - Web iframe: the only window that equals `window.parent` is the real
41
+ * embedder. `event.source` is set by the browser and cannot be spoofed, so
42
+ * `event.source === window.parent` rejects siblings and self-posts. We then
43
+ * cross-check `event.origin` against the embedder's origin as defense-in-depth.
44
+ * - Not embedded (standalone / tests): nothing to protect against; allowed.
45
+ *
46
+ * @param {MessageEvent} event
47
+ * @returns {boolean}
48
+ */
49
+ export function isTrustedMessageSource(event) {
50
+ if (typeof window === 'undefined') return true;
51
+ if (window.ReactNativeWebView) return true;
52
+ if (window.parent === window) return true;
53
+
54
+ if (event && event.source && event.source !== window.parent) return false;
55
+
56
+ if (_parentOrigin === null) {
57
+ _parentOrigin = _resolveParentOrigin();
58
+ }
59
+ if (_parentOrigin && event && event.origin && event.origin !== _parentOrigin) {
60
+ return false;
61
+ }
62
+ return true;
63
+ }
64
+
13
65
  /**
14
66
  * Core Usion object with init, _post, _request
15
67
  */
@@ -46,6 +98,9 @@ export const core = {
46
98
 
47
99
  // Setup global message handler
48
100
  window.addEventListener('message', function(event) {
101
+ // Reject messages from anything other than the host shell.
102
+ if (!isTrustedMessageSource(event)) return;
103
+
49
104
  let data;
50
105
  try {
51
106
  data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
@@ -44,17 +44,6 @@ export function createGameModule(Usion) {
44
44
  return self.connectDirect();
45
45
  }
46
46
 
47
- // Use config values as defaults
48
- socketUrl = socketUrl || Usion.config.socketUrl;
49
- token = token || Usion.user.getToken();
50
-
51
- if (!socketUrl) {
52
- return Promise.reject(new Error('No socket URL provided'));
53
- }
54
- if (!token) {
55
- return Promise.reject(new Error('No auth token available'));
56
- }
57
-
58
47
  // If already connected (direct or proxy), return immediately
59
48
  if (self._useProxy && self.connected) {
60
49
  return Promise.resolve();
@@ -69,6 +58,7 @@ export function createGameModule(Usion) {
69
58
  }
70
59
 
71
60
  // When running inside an iframe or WebView, use parent as socket proxy
61
+ // (checked BEFORE token validation — iframe games don't need a token)
72
62
  var isInFrame = !!window.__USION_PROXY__
73
63
  || window.parent !== window
74
64
  || !!window.ReactNativeWebView
@@ -79,6 +69,17 @@ export function createGameModule(Usion) {
79
69
  return self._connectViaProxy();
80
70
  }
81
71
 
72
+ // Use config values as defaults (only for direct socket connections)
73
+ socketUrl = socketUrl || Usion.config.socketUrl;
74
+ token = token || Usion.user.getToken();
75
+
76
+ if (!socketUrl) {
77
+ return Promise.reject(new Error('No socket URL provided'));
78
+ }
79
+ if (!token) {
80
+ return Promise.reject(new Error('No auth token available'));
81
+ }
82
+
82
83
  self._connecting = true;
83
84
  self._connectPromise = new Promise(function(resolve, reject) {
84
85
  // Check if socket.io-client is available
@@ -121,7 +121,10 @@ export function applyGameMethods(game, Usion) {
121
121
  const self = this;
122
122
 
123
123
  if (self.directMode) {
124
- self._sendDirect(actionType || 'action', actionData || {});
124
+ self._sendDirect('action', {
125
+ action_type: actionType || 'default',
126
+ action_data: actionData || {}
127
+ });
125
128
  return Promise.resolve({ success: true });
126
129
  }
127
130
 
@@ -321,6 +324,109 @@ export function applyGameMethods(game, Usion) {
321
324
  }
322
325
  };
323
326
 
327
+ // ───────────────────────────────────────────────────────────
328
+ // Cross-reload state persistence
329
+ // ───────────────────────────────────────────────────────────
330
+ // When a mini-app's iframe is unmounted and later re-mounted (e.g. the
331
+ // user navigates back to the chat and re-opens the game from the same
332
+ // room), the entire JS context is destroyed. Server-side room state is
333
+ // preserved, but anything the game holds in memory — board state, phase,
334
+ // whose turn it is, placement choices — is lost. The platform sync
335
+ // mechanism only replays raw actions; reconstructing client-visible
336
+ // state from a zero baseline is fragile or impossible.
337
+ //
338
+ // These helpers give every game a uniform way to snapshot whatever it
339
+ // needs to localStorage. Keys are scoped to (player_id, room_id) so a
340
+ // single browser can hold independent state for different rooms or
341
+ // accounts without collision, and "play a different match in the same
342
+ // room" naturally collides — which is the correct outcome.
343
+
344
+ const STATE_KEY_PREFIX = '_usion_game_state:';
345
+
346
+ function _stateKey(self) {
347
+ const rid = self.roomId || (Usion.config && Usion.config.roomId);
348
+ const pid = self.playerId
349
+ || (Usion.user && typeof Usion.user.getId === 'function' && Usion.user.getId())
350
+ || (Usion.config && Usion.config.userId);
351
+ if (!rid || !pid) return null;
352
+ return STATE_KEY_PREFIX + pid + ':' + rid;
353
+ }
354
+
355
+ /**
356
+ * Persist arbitrary JSON-serializable game state across iframe reloads.
357
+ * The mini-app decides the schema; the SDK only stores/retrieves.
358
+ * @param {*} state - Any JSON-serializable value
359
+ * @returns {boolean} true if saved, false if not (no room/player yet, or storage error)
360
+ */
361
+ game.saveState = function(state) {
362
+ const self = this;
363
+ const key = _stateKey(self);
364
+ if (!key) return false;
365
+ try {
366
+ localStorage.setItem(key, JSON.stringify({ state: state, savedAt: Date.now() }));
367
+ return true;
368
+ } catch (e) {
369
+ // Quota exceeded, private-mode rejection, etc. — non-fatal.
370
+ return false;
371
+ }
372
+ };
373
+
374
+ /**
375
+ * Retrieve previously-saved state for the current (player, room).
376
+ * @returns {*} The saved state value, or null if none / unreadable.
377
+ */
378
+ game.loadState = function() {
379
+ const self = this;
380
+ const key = _stateKey(self);
381
+ if (!key) return null;
382
+ try {
383
+ const raw = localStorage.getItem(key);
384
+ if (!raw) return null;
385
+ const parsed = JSON.parse(raw);
386
+ return parsed && parsed.state !== undefined ? parsed.state : null;
387
+ } catch (e) {
388
+ return null;
389
+ }
390
+ };
391
+
392
+ /**
393
+ * Drop any persisted state for the current (player, room).
394
+ * Call this when the game ends or starts fresh, so the next iframe
395
+ * mount in the same room doesn't pick up stale data.
396
+ */
397
+ game.clearState = function() {
398
+ const self = this;
399
+ const key = _stateKey(self);
400
+ if (!key) return;
401
+ try { localStorage.removeItem(key); } catch (e) { /* non-fatal */ }
402
+ };
403
+
404
+ /**
405
+ * Forward a debug snapshot to the parent platform. The platform renders
406
+ * it in a top-right overlay when the iframe host is opened with
407
+ * `?debug=1`. The payload schema is up to the game — anything JSON-
408
+ * serializable. No-op when not running inside an iframe.
409
+ *
410
+ * Games should call this at every meaningful state transition (turn
411
+ * change, action sent, action received, sync, phase change, etc.) so
412
+ * the overlay reflects live state.
413
+ *
414
+ * @param {object} payload - Arbitrary JSON-serializable debug data
415
+ */
416
+ game.debug = function(payload) {
417
+ try {
418
+ // Must work in both web iframes (window.parent !== window) and
419
+ // React Native WebView (window.parent === window, but a host bridge
420
+ // exists at window.ReactNativeWebView). Usion._post handles routing
421
+ // for both; this guard just avoids no-op work in standalone pages.
422
+ var inFrame = window.parent && window.parent !== window;
423
+ var inRNWebView = !!window.ReactNativeWebView;
424
+ if (inFrame || inRNWebView) {
425
+ Usion._post({ type: 'GAME_DEBUG', payload: payload || {} });
426
+ }
427
+ } catch (e) { /* non-fatal */ }
428
+ };
429
+
324
430
  /**
325
431
  * Get connection status
326
432
  * @returns {boolean}
@@ -2,6 +2,8 @@
2
2
  * Usion SDK Game Proxy — postMessage relay through parent app
3
3
  */
4
4
 
5
+ import { isTrustedMessageSource } from './core.js';
6
+
5
7
  /**
6
8
  * Add proxy connection methods to game module
7
9
  * @param {object} game - The game module object
@@ -58,6 +60,8 @@ export function applyGameProxy(game, Usion) {
58
60
  self._proxyListenerSetup = true;
59
61
 
60
62
  window.addEventListener('message', function(event) {
63
+ if (!isTrustedMessageSource(event)) return;
64
+
61
65
  var data;
62
66
  try {
63
67
  data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
@@ -2,6 +2,8 @@
2
2
  * Usion SDK Misc — submit, error, exit, share, log, on, requestPayment (legacy)
3
3
  */
4
4
 
5
+ import { isTrustedMessageSource } from './core.js';
6
+
5
7
  export const miscMethods = {
6
8
  /**
7
9
  * Request payment from user (legacy method)
@@ -108,6 +110,8 @@ export const miscMethods = {
108
110
  */
109
111
  on: function(type, callback) {
110
112
  window.addEventListener('message', function(event) {
113
+ if (!isTrustedMessageSource(event)) return;
114
+
111
115
  let data;
112
116
  try {
113
117
  data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
@@ -2,7 +2,7 @@
2
2
  * Usion SDK Wallet Module — wallet and payment operations
3
3
  */
4
4
 
5
- import { getNextRequestId } from './core.js';
5
+ import { getNextRequestId, isTrustedMessageSource } from './core.js';
6
6
 
7
7
  /**
8
8
  * @param {object} Usion - Reference to the main Usion object
@@ -57,6 +57,10 @@ export function createWalletModule(Usion) {
57
57
 
58
58
  // Listen for response
59
59
  function handler(event) {
60
+ // Only honor payment results from the trusted host shell — a forged
61
+ // PAYMENT_SUCCESS must never resolve this promise.
62
+ if (!isTrustedMessageSource(event)) return;
63
+
60
64
  let response;
61
65
  try {
62
66
  response = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
package/types/index.d.ts CHANGED
@@ -213,6 +213,16 @@ export interface GameModule {
213
213
  requestRematch(): void;
214
214
  forfeit(): Promise<{ success: boolean; error?: string }>;
215
215
 
216
+ // Persisted state — survives iframe unmount/remount.
217
+ // Keyed by (player_id, room_id) in localStorage. Schema is up to the game.
218
+ saveState<T = any>(state: T): boolean;
219
+ loadState<T = any>(): T | null;
220
+ clearState(): void;
221
+
222
+ // Forward a debug snapshot to the parent. Rendered as a top-right
223
+ // overlay over the iframe when the host page is opened with `?debug=1`.
224
+ debug(payload: Record<string, any>): void;
225
+
216
226
  // Event handlers
217
227
  onJoined(callback: (data: GameJoinResult) => void): void;
218
228
  onPlayerJoined(callback: (data: PlayerJoinedData) => void): void;
@@ -306,6 +316,10 @@ export interface UsionSDK {
306
316
 
307
317
  // Sharing
308
318
  share(contentType: 'audio' | 'image' | 'video' | 'text' | 'mixed', data: ShareData): void;
319
+ shareToFeed(contentType: 'text' | 'image' | 'video' | 'audio' | 'mixed', data: {
320
+ text?: string;
321
+ media?: Array<{ type: 'image' | 'video' | 'audio'; url: string; thumbnailUrl?: string; width?: number; height?: number; duration?: number }>;
322
+ }): Promise<{ success: boolean; postId?: string; shareUrl?: string }>;
309
323
 
310
324
  // Logging
311
325
  log(msg: string): void;