@bounded-sh/core 0.0.69 → 0.0.71

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.
Files changed (25) hide show
  1. package/dist/client/config.d.ts +22 -1
  2. package/dist/{index-B3aNfGi6.mjs → index-P4eUuhOB.mjs} +141 -17
  3. package/dist/index-P4eUuhOB.mjs.map +1 -0
  4. package/dist/{index-DlJKTmRv.js → index-yT8ZfzrL.js} +141 -17
  5. package/dist/index-yT8ZfzrL.js.map +1 -0
  6. package/dist/index.js +1 -1
  7. package/dist/index.mjs +1 -1
  8. package/dist/{realtime-store-CkgpUOrE.js → realtime-store-BDpIOFMH.js} +2 -2
  9. package/dist/{realtime-store-CkgpUOrE.js.map → realtime-store-BDpIOFMH.js.map} +1 -1
  10. package/dist/{realtime-store-i8pxSuZE.mjs → realtime-store-Bcnv3f0g.mjs} +2 -2
  11. package/dist/{realtime-store-i8pxSuZE.mjs.map → realtime-store-Bcnv3f0g.mjs.map} +1 -1
  12. package/dist/{solana-message-DnV4FQ__.js → solana-message-Dh9ryO-Y.js} +2 -2
  13. package/dist/{solana-message-DnV4FQ__.js.map → solana-message-Dh9ryO-Y.js.map} +1 -1
  14. package/dist/{solana-message-AXfv2U1S.mjs → solana-message-x8ElgC9P.mjs} +2 -2
  15. package/dist/{solana-message-AXfv2U1S.mjs.map → solana-message-x8ElgC9P.mjs.map} +1 -1
  16. package/dist/{solana-operations-Bte92rmq.mjs → solana-operations-DEWIxJS8.mjs} +127 -8
  17. package/dist/solana-operations-DEWIxJS8.mjs.map +1 -0
  18. package/dist/{solana-operations-DPul1dWo.js → solana-operations-DGEGqFd9.js} +127 -8
  19. package/dist/solana-operations-DGEGqFd9.js.map +1 -0
  20. package/dist/utils/refresh-error.d.ts +9 -0
  21. package/package.json +2 -2
  22. package/dist/index-B3aNfGi6.mjs.map +0 -1
  23. package/dist/index-DlJKTmRv.js.map +0 -1
  24. package/dist/solana-operations-Bte92rmq.mjs.map +0 -1
  25. package/dist/solana-operations-DPul1dWo.js.map +0 -1
@@ -70,6 +70,17 @@ export interface ClientConfig {
70
70
  * 'privy-expo' because @privy-io/expo is hook-based and must be rendered by
71
71
  * the RN component tree (see PrivyExpoProvider). */
72
72
  privyExpoProvider?: AuthProvider;
73
+ /** Solana Mobile Wallet Adapter (Saga/Seeker) registration tuning. Wallet
74
+ * login already surfaces the device wallet automatically on capable Android
75
+ * browsers (the SDK registers MWA as a Wallet-Standard wallet whenever the
76
+ * wallet lane is active), so this is OPTIONAL and only overrides defaults:
77
+ * appIdentity (defaults to config.name/logoUrl + the page origin; per the
78
+ * MWA spec `icon` is a path RELATIVE to `uri`), cluster ('mainnet-beta' |
79
+ * 'devnet' - the mobile wallet authorizes per cluster and signs on the app's
80
+ * chain, so this may NAME the chain a chainless app authorizes on but may
81
+ * never contradict `chain`; a contradiction throws), and remoteHostAuthority
82
+ * (a reflector websocket authority that additionally enables the desktop
83
+ * QR-code "connect your phone" lane). */
73
84
  mobileWalletConfig?: {
74
85
  appIdentity?: {
75
86
  name?: string;
@@ -77,7 +88,7 @@ export interface ClientConfig {
77
88
  icon?: string;
78
89
  };
79
90
  cluster?: string;
80
- theme?: 'light' | 'dark';
91
+ remoteHostAuthority?: string;
81
92
  };
82
93
  /** OPT-IN gate for Solana WALLET LOGIN (authMethod:'phantom' / 'wallet').
83
94
  * OFF BY DEFAULT — the vast majority of apps never support wallets, so the
@@ -91,6 +102,13 @@ export interface ClientConfig {
91
102
  getProvider?: () => any;
92
103
  network?: string | null;
93
104
  rpcUrl?: string | null;
105
+ /** Awaited immediately before every wallet operation, after all async
106
+ * preparation, so the app can collect a fresh user gesture. Required for
107
+ * Solana Mobile (Saga/Seeker): each operation dispatches an Android intent
108
+ * through window.location.assign, which Chrome blocks without transient
109
+ * activation. Reject to abort the operation. Leave unset for injected
110
+ * wallets, which sign in-page. */
111
+ confirmWalletAction?: (action: 'connect' | 'login' | 'signMessage' | 'signTransaction' | 'signAndSubmitTransaction') => Promise<void>;
94
112
  };
95
113
  /** LOGIN MODE for the unified login widget (default 'turnkey'). 'turnkey' runs
96
114
  * Turnkey-native email OTP INLINE in the widget (no second Bounded OTP).
@@ -124,6 +142,9 @@ export interface ClientConfig {
124
142
  getProvider?: () => any;
125
143
  network?: string | null;
126
144
  rpcUrl?: string | null;
145
+ /** See walletLogin.confirmWalletAction - the fresh-gesture hook Solana
146
+ * Mobile needs before each wallet operation. */
147
+ confirmWalletAction?: (action: 'connect' | 'login' | 'signMessage' | 'signTransaction' | 'signAndSubmitTransaction') => Promise<void>;
127
148
  };
128
149
  /** EVM WALLET LOGIN provider configuration (authMethod:'evm-wallet'). The EVM
129
150
  * twin of injectedWalletConfig: a slim, dependency-free "connect an Ethereum
@@ -23,7 +23,8 @@ const DEFAULT_CLIENT_CONFIG = {
23
23
  // No hidden modal fallback: browser clients start hosted Bounded Auth with
24
24
  // loginWithRedirect/loginWithPopup. For crypto/onchain wallet login use
25
25
  // authMethod:'phantom' (Solana / Phantom), and use signInAnonymously() for
26
- // zero-friction guest accounts. ('wallet' is an unimplemented stub; don't use.)
26
+ // zero-friction guest accounts. ('wallet' and 'mobile-wallet-adapter' are
27
+ // accepted spellings of the same lane and canonicalize to 'phantom'.)
27
28
  authMethod: 'none',
28
29
  chain: '',
29
30
  rpcUrl: '',
@@ -640,6 +641,53 @@ async function revokeSession(refreshToken, issuer) {
640
641
  async function signSessionCreateMessage(_signMessageFunction) {
641
642
  }
642
643
 
644
+ // Classify a session-refresh failure as a DEFINITIVE rejection of the credential
645
+ // vs a TRANSPORT failure (DS4-0054).
646
+ //
647
+ // The restore path refreshes an expired token at boot. The old code destroyed the
648
+ // local session on ANY refresh failure, so a network blip - common on mobile -
649
+ // signed the user out. Only a real HTTP rejection of the refresh token proves the
650
+ // session is invalid; anything else is unknown, and the safe move is to RETAIN local
651
+ // state and retry. Keeping a genuinely dead session locally is harmless: every
652
+ // request still fails closed server-side, and the next refresh that reaches the
653
+ // server clears it. Destroying a live session on a blip is user-visible damage.
654
+ //
655
+ // `/session/refresh` answers 401 (session_revoked / invalid_grant) when the token is
656
+ // dead. 400 (bad request), 408/429 (transient), and 5xx are NOT proof the session is
657
+ // invalid, and a network/DNS/timeout error carries no HTTP response at all.
658
+ //
659
+ // This is the single classifier for every refresh path: the per-request freshness
660
+ // check (auth-token-freshness) and the boot-time restore paths (web + RN session
661
+ // managers) all import it, so "definitive" means the same thing everywhere. It lives
662
+ // in this leaf module to avoid an import cycle between those consumers.
663
+ function isDefinitiveRejection(err) {
664
+ var _a;
665
+ const status = (_a = err === null || err === void 0 ? void 0 : err.response) === null || _a === void 0 ? void 0 : _a.status;
666
+ return status === 401 || status === 403;
667
+ }
668
+ // How long past the id token's own `exp` a session may survive purely on
669
+ // transport failures.
670
+ //
671
+ // "Retain on a blip" must not become "retain forever". A statusless failure is
672
+ // unknown, so the first ones are absorbed - but a revoked session, or a gateway
673
+ // that rewrites the issuer's 401 into a 502, would otherwise keep
674
+ // isAuthenticated() reporting a signed-in user for as long as the device never
675
+ // reaches the issuer again. One day covers an offline flight or a bad-network day
676
+ // while making the retention finite: past it, an unrefreshable session is cleared
677
+ // and the user is asked to sign in again.
678
+ const TRANSPORT_RETENTION_GRACE_MS = 24 * 60 * 60 * 1000;
679
+ /**
680
+ * May a session whose refresh failed WITHOUT a definitive rejection be kept?
681
+ *
682
+ * `expSeconds` is the id token's `exp` claim. Fails closed: a missing or
683
+ * non-numeric expiry is not a usable bound, so such a session is never retained.
684
+ */
685
+ function mayRetainAfterTransportFailure(expSeconds, now = Date.now()) {
686
+ if (typeof expSeconds !== 'number' || !Number.isFinite(expSeconds))
687
+ return false;
688
+ return now <= expSeconds * 1000 + TRANSPORT_RETENTION_GRACE_MS;
689
+ }
690
+
643
691
  // Shared session-binding guard for the web + React Native session managers, so
644
692
  // the cross-principal refresh guard (#220 / #221) lives in ONE place and the two
645
693
  // managers can't drift apart. Base64url decoding differs by platform (browser
@@ -1021,7 +1069,26 @@ class WebSessionManager {
1021
1069
  await this.clearSessionIfGeneration(generation);
1022
1070
  return null;
1023
1071
  }
1024
- const refreshed = await refreshSession(refreshToken, sessionObj.issuer);
1072
+ let refreshed;
1073
+ try {
1074
+ refreshed = await refreshSession(refreshToken, sessionObj.issuer);
1075
+ }
1076
+ catch (refreshErr) {
1077
+ // DS4-0054: only a DEFINITIVE server rejection (401/403) proves the
1078
+ // refresh token is dead. A transport failure - offline, DNS, timeout,
1079
+ // 5xx - is unknown, not proof the session is invalid. Destroying local
1080
+ // state here signs the user out on a network blip (common on mobile),
1081
+ // so RETAIN it and return the existing session; the next request will
1082
+ // retry the refresh. The retention is BOUNDED by the token's own
1083
+ // expiry plus a grace window, so a revoked session - or an issuer 401
1084
+ // a gateway rewrote into a 5xx - cannot keep isAuthenticated() true
1085
+ // forever on a device that never reaches the issuer again.
1086
+ if (isDefinitiveRejection(refreshErr) || !mayRetainAfterTransportFailure(exp)) {
1087
+ await this.clearSessionIfGeneration(generation);
1088
+ return null;
1089
+ }
1090
+ return { address: sessionObj.address, session: sessionObj };
1091
+ }
1025
1092
  if ((refreshed === null || refreshed === void 0 ? void 0 : refreshed.idToken) && (refreshed === null || refreshed === void 0 ? void 0 : refreshed.accessToken)) {
1026
1093
  const committed = await this.commitRefreshedTokens({ generation, idToken: sessionObj.idToken }, refreshed.idToken, refreshed.accessToken,
1027
1094
  // Persist the ROTATED refresh token — the old one is now
@@ -1042,8 +1109,9 @@ class WebSessionManager {
1042
1109
  return null;
1043
1110
  return { address: newObj.address, session: newObj };
1044
1111
  }
1045
- // Refresh failed — clear stale session to prevent retry loops, but only
1046
- // if it is still the session we set out to refresh.
1112
+ // The server answered but issued no usable tokens - a definitive refusal,
1113
+ // not a transport failure - so clear the stale session to stop retry loops,
1114
+ // but only if it is still the session we set out to refresh.
1047
1115
  await this.clearSessionIfGeneration(generation);
1048
1116
  return null;
1049
1117
  }
@@ -1429,7 +1497,26 @@ class ReactNativeSessionManager {
1429
1497
  await this.clearSessionIfGeneration(generation);
1430
1498
  return null;
1431
1499
  }
1432
- const refreshed = await refreshSession(refreshToken, sessionObj.issuer);
1500
+ let refreshed;
1501
+ try {
1502
+ refreshed = await refreshSession(refreshToken, sessionObj.issuer);
1503
+ }
1504
+ catch (refreshErr) {
1505
+ // DS4-0054: only a DEFINITIVE server rejection (401/403) proves the
1506
+ // refresh token is dead. A transport failure - offline, DNS, timeout,
1507
+ // 5xx - is unknown, not proof the session is invalid. On mobile these
1508
+ // blips are common, so destroying local state here signs the user out
1509
+ // on a bad network. RETAIN it and return the existing session; the next
1510
+ // request retries the refresh. The retention is BOUNDED by the token's
1511
+ // own expiry plus a grace window, so a revoked session - or an issuer
1512
+ // 401 a gateway rewrote into a 5xx - cannot keep isAuthenticated() true
1513
+ // forever on a device that never reaches the issuer again.
1514
+ if (isDefinitiveRejection(refreshErr) || !mayRetainAfterTransportFailure(exp)) {
1515
+ await this.clearSessionIfGeneration(generation);
1516
+ return null;
1517
+ }
1518
+ return { address: sessionObj.address, session: sessionObj };
1519
+ }
1433
1520
  if ((refreshed === null || refreshed === void 0 ? void 0 : refreshed.idToken) && (refreshed === null || refreshed === void 0 ? void 0 : refreshed.accessToken)) {
1434
1521
  const committed = await this.commitRefreshedTokens({ generation, idToken: sessionObj.idToken }, refreshed.idToken, refreshed.accessToken, refreshed.refreshToken);
1435
1522
  // Superseded means a login or logout landed mid-refresh. Returning
@@ -1446,8 +1533,9 @@ class ReactNativeSessionManager {
1446
1533
  return null;
1447
1534
  return { address: newObj.address, session: newObj };
1448
1535
  }
1449
- // Refresh failed — clear stale session to prevent retry loops, but only
1450
- // if it is still the session we set out to refresh.
1536
+ // The server answered but issued no usable tokens - a definitive refusal,
1537
+ // not a transport failure - so clear the stale session to stop retry loops,
1538
+ // but only if it is still the session we set out to refresh.
1451
1539
  await this.clearSessionIfGeneration(generation);
1452
1540
  return null;
1453
1541
  }
@@ -4043,7 +4131,7 @@ class ServerSessionManager {
4043
4131
  const sessionConfig = (_a = this.sessionConfig) !== null && _a !== void 0 ? _a : undefined;
4044
4132
  /* fetch nonce from auth API (bound to the snapshotted app+issuer) */
4045
4133
  const nonce = await genAuthNonce(sessionConfig);
4046
- const { genSolanaMessage } = await import('./solana-message-AXfv2U1S.mjs');
4134
+ const { genSolanaMessage } = await import('./solana-message-x8ElgC9P.mjs');
4047
4135
  const message = await genSolanaMessage(address, nonce, sessionConfig === null || sessionConfig === void 0 ? void 0 : sessionConfig.appId);
4048
4136
  /* sign the message */
4049
4137
  const sigBytes = nacl.sign.detached(new TextEncoder().encode(message), kp.secretKey);
@@ -4415,11 +4503,6 @@ const refreshTransactions = new Map();
4415
4503
  function transactionKey(scope, appId, generation, refreshToken) {
4416
4504
  return `${scope}:${appId}:${generation !== null && generation !== void 0 ? generation : 'none'}:${tokenFingerprint(refreshToken)}`;
4417
4505
  }
4418
- function isDefinitiveRejection(error) {
4419
- var _a;
4420
- const status = (_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.status;
4421
- return status === 401 || status === 403;
4422
- }
4423
4506
  async function readSessionParts(isServer) {
4424
4507
  var _a, _b, _c, _d, _e, _f;
4425
4508
  if (isServer) {
@@ -5404,6 +5487,35 @@ async function getWsAuthToken(isServer) {
5404
5487
  throw rememberAmbientAuthFailure(normalizeAuthExpiredError(error, 'Authentication expired and refresh failed'));
5405
5488
  }
5406
5489
  }
5490
+ /**
5491
+ * The server refused the token sent on the auth frame: its error frame arrives
5492
+ * while the auth ack is still pending, just before the 1008 close. Local expiry
5493
+ * had vouched for that exact token (unexpired but invalid - e.g. a rotated
5494
+ * issuer key), so re-sending it would only be refused again. Force a genuinely
5495
+ * new one: a definitive issuer rejection clears the session inside the shared
5496
+ * helper (firing notifySessionCleared, which signs the UI out), a successful
5497
+ * refresh commits a new token - and the ordinary reconnect loop picks up either
5498
+ * outcome on its own. Fresh token: authenticates. No token: connects
5499
+ * anonymously and public reads replay instead of erroring forever.
5500
+ */
5501
+ async function recoverFromServerAuthRejection(connection) {
5502
+ const rejected = connection.pendingAuthToken;
5503
+ if (!rejected)
5504
+ return;
5505
+ // One forced refresh per distinct rejected token value: a transiently
5506
+ // unreachable issuer must not turn the reconnect loop into a refresh storm.
5507
+ if (connection.serverRejectedAuthToken === rejected)
5508
+ return;
5509
+ connection.serverRejectedAuthToken = rejected;
5510
+ try {
5511
+ await getFreshAuthToken(connection.isServer, { force: true });
5512
+ }
5513
+ catch (_a) {
5514
+ // Every failure mode is already handled inside the helper: a dead
5515
+ // session was cleared (auth listeners notified), a transient refresh
5516
+ // failure keeps the session and the latch above stops the storm.
5517
+ }
5518
+ }
5407
5519
  async function getConnectionAuthToken(isServer, authTokenProvider, anonymous) {
5408
5520
  // A read-only scoped instance connects anonymously: no auth frame, and it
5409
5521
  // must never read (or refresh) the ambient global session - that session
@@ -5727,6 +5839,8 @@ async function getOrCreateConnectionOnce(appId, isServer, routePath, authTokenPr
5727
5839
  authFailure: null,
5728
5840
  source,
5729
5841
  anonymous,
5842
+ isServer,
5843
+ serverRejectedAuthToken: null,
5730
5844
  };
5731
5845
  connections.set(connKey, connection);
5732
5846
  // URL provider for reconnection with fresh tokens
@@ -5992,6 +6106,16 @@ function handleServerMessage(connection, message) {
5992
6106
  }
5993
6107
  case 'error': {
5994
6108
  console.error('[WS v2] Server error:', message.code, message.message);
6109
+ // The auth frame was just refused: while the auth ack is pending the
6110
+ // server sends no other error frame, and this one is immediately
6111
+ // followed by its 1008 close. Kick the forced-refresh recovery - the
6112
+ // ordinary reconnect loop then re-auths with a fresh token or, once a
6113
+ // definitively dead session is cleared, connects anonymously so
6114
+ // public reads still replay. Ambient connections only: wallet-scoped
6115
+ // connections refresh through their own provider.
6116
+ if (connection.isAuthenticating && connection.pendingAuthToken && !connection.authTokenProvider && !connection.anonymous) {
6117
+ void recoverFromServerAuthRejection(connection);
6118
+ }
5995
6119
  // Build an Error that ALSO carries the stable `code` as a structured
5996
6120
  // field (not just string-interpolated into the message) so callers can
5997
6121
  // branch on it — e.g. `catch (e) { if (e.code === 'invariant_violation') }`
@@ -6775,7 +6899,7 @@ async function doReconnectWithNewAuth() {
6775
6899
  console.warn('[WS v2] Failed to clear HTTP read cache on auth change:', error);
6776
6900
  }
6777
6901
  try {
6778
- const { reconnectRealtimeStoreWithNewAuth } = await import('./realtime-store-i8pxSuZE.mjs');
6902
+ const { reconnectRealtimeStoreWithNewAuth } = await import('./realtime-store-Bcnv3f0g.mjs');
6779
6903
  await reconnectRealtimeStoreWithNewAuth();
6780
6904
  }
6781
6905
  catch (error) {
@@ -8218,7 +8342,7 @@ async function setMany(many, options) {
8218
8342
  'Nothing was signed or submitted.');
8219
8343
  }
8220
8344
  const solanaChain = workerChain;
8221
- const { handlePreBuiltTransaction } = await import('./solana-operations-Bte92rmq.mjs');
8345
+ const { handlePreBuiltTransaction } = await import('./solana-operations-DEWIxJS8.mjs');
8222
8346
  const solanaOutcome = await handlePreBuiltTransaction(curTx, authProvider, options, expectedWritePaths);
8223
8347
  // `shouldSubmitTx: false` signed WITHOUT sending — no signature exists,
8224
8348
  // and there is nothing to mirror, so no synchronization is attempted.
@@ -9659,7 +9783,7 @@ function toMillis(seconds) {
9659
9783
 
9660
9784
  /** SIWS message creation is async already, so its onchain module can be lazy. */
9661
9785
  async function genSolanaMessage(address, nonce) {
9662
- const implementation = await import('./solana-message-AXfv2U1S.mjs');
9786
+ const implementation = await import('./solana-message-x8ElgC9P.mjs');
9663
9787
  return implementation.genSolanaMessage(address, nonce);
9664
9788
  }
9665
9789
 
@@ -10135,4 +10259,4 @@ function defineLiveModule(mod) {
10135
10259
  }
10136
10260
 
10137
10261
  export { getSessionGeneration as $, closeAllSubscriptions as A, clearCache as B, getCachedData as C, reconnectWithNewAuth as D, createClient as E, subscribeQuery as F, closeAllQuerySubscriptions as G, canonicalizeArgs as H, InsufficientBalanceError as I, canonicalizeResult as J, DEFAULT_QUERY_POLL_INTERVAL_MS as K, BoundedDeclineError as L, MIN_QUERY_POLL_INTERVAL_MS as M, deriveDecline as N, dispatchDeclineEvent as O, DECLINE_EVENT_NAME as P, dispatchRateLimitEvent as Q, RATE_LIMIT_EVENT_NAME as R, ScopedClientReadOnlyError as S, hasActiveConnection as T, increment as U, serverTimestamp as V, now as W, toSeconds as X, toMillis as Y, getIdToken as Z, deriveUserIdentityFromIdToken as _, getWebhookKeysUrl as a, WebSessionManager as a0, ReactNativeSessionManager as a1, getActiveSessionManager as a2, onSessionCleared as a3, SessionEnvironmentError as a4, UNSUPPORTED_ENVIRONMENT_CODE as a5, AUTH_CHANGED_CODE as a6, AUTH_EXPIRED_CODE as a7, isAuthExpiredError as a8, getFreshAuthToken as a9, ServerSessionManager as aa, createSessionWithSignature as ab, refreshSession as ac, revokeSession as ad, signSessionCreateMessage as ae, genAuthNonce as af, genSolanaMessage as ag, genSiweMessage as ah, normalizeEvmAddress as ai, toLowerEvmAddress as aj, functions as ak, invoke as al, FunctionInvokeError as am, live as an, intent as ao, status as ap, subscribeView as aq, LiveIntentError as ar, withEffects as as, isEffectResult as at, defineLiveModule as au, EFFECT_INTENT_ADDRESS as av, bufferExports as b, getConfiguredAppId as c, get as d, getMany as e, docId as f, getConfig as g, setMany as h, init as i, setFile as j, getFiles as k, search as l, runQueryMany as m, runExpression as n, runExpressionMany as o, signMessage as p, queryAggregate as q, runQuery as r, set as s, signTransaction as t, signAndSubmitTransaction as u, count as v, aggregate as w, reconcileSetResult as x, waitForSetResult as y, subscribe as z };
10138
- //# sourceMappingURL=index-B3aNfGi6.mjs.map
10262
+ //# sourceMappingURL=index-P4eUuhOB.mjs.map