@basictech/react 0.8.0-beta.2 → 0.8.0-beta.4

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/index.mjs CHANGED
@@ -71,6 +71,7 @@ var init_syncProtocol = __esm({
71
71
  var requestId = 0;
72
72
  var acceptCallbacks = {};
73
73
  var refreshTimer = null;
74
+ var pendingTokenUpdate = null;
74
75
  log("Connecting to", url);
75
76
  var ws = new WebSocket(url);
76
77
  function sendChanges(changes2, baseRevision2, partial2, onChangesAccepted2) {
@@ -93,6 +94,12 @@ var init_syncProtocol = __esm({
93
94
  refreshTimer = null;
94
95
  }
95
96
  }
97
+ function sendTokenUpdate(token) {
98
+ if (ws.readyState !== WebSocket.OPEN) return false;
99
+ pendingTokenUpdate = token;
100
+ ws.send(JSON.stringify({ type: "tokenUpdate", authToken: token }));
101
+ return true;
102
+ }
96
103
  function resolveGetToken() {
97
104
  var fn = getTokenGetter(url);
98
105
  if (!fn) throw new Error("No token getter registered for " + url);
@@ -108,10 +115,8 @@ var init_syncProtocol = __esm({
108
115
  refreshTimer = setTimeout(async function() {
109
116
  try {
110
117
  var newToken = await resolveGetToken()({ forceRefresh: true });
111
- if (ws.readyState === WebSocket.OPEN) {
118
+ if (sendTokenUpdate(newToken)) {
112
119
  log("Sending tokenUpdate on existing WebSocket");
113
- ws.send(JSON.stringify({ type: "tokenUpdate", authToken: newToken }));
114
- scheduleTokenRefresh(newToken);
115
120
  }
116
121
  } catch (err) {
117
122
  log("Proactive token refresh failed (non-fatal):", err);
@@ -137,14 +142,34 @@ var init_syncProtocol = __esm({
137
142
  onError("Authentication failed: " + (err.message || err), RECONNECT_DELAY);
138
143
  }
139
144
  };
145
+ function handleVisibilityResume() {
146
+ if (document.visibilityState === "visible" && ws.readyState === WebSocket.OPEN) {
147
+ log("Page became visible - refreshing token for WebSocket");
148
+ resolveGetToken()({ forceRefresh: true }).then(function(newToken) {
149
+ sendTokenUpdate(newToken);
150
+ }).catch(function(err) {
151
+ log("Token refresh on visibility resume failed:", err);
152
+ });
153
+ }
154
+ }
155
+ if (typeof document !== "undefined") {
156
+ document.addEventListener("visibilitychange", handleVisibilityResume);
157
+ }
158
+ function cleanupVisibilityListener() {
159
+ if (typeof document !== "undefined") {
160
+ document.removeEventListener("visibilitychange", handleVisibilityResume);
161
+ }
162
+ }
140
163
  ws.onerror = function(event) {
141
164
  clearRefreshTimer();
165
+ cleanupVisibilityListener();
142
166
  ws.close();
143
167
  log("ws.onerror", event);
144
168
  onError(event?.message, RECONNECT_DELAY);
145
169
  };
146
170
  ws.onclose = function(event) {
147
171
  clearRefreshTimer();
172
+ cleanupVisibilityListener();
148
173
  onError("Socket closed: " + event.reason, RECONNECT_DELAY);
149
174
  };
150
175
  var isFirstRound = true;
@@ -181,11 +206,25 @@ var init_syncProtocol = __esm({
181
206
  },
182
207
  disconnect: function() {
183
208
  clearRefreshTimer();
209
+ cleanupVisibilityListener();
184
210
  ws.close();
185
211
  }
186
212
  });
187
213
  isFirstRound = false;
188
214
  }
215
+ } else if (requestFromServer.type == "tokenUpdateAck") {
216
+ if (requestFromServer.ok) {
217
+ scheduleTokenRefresh(requestFromServer.authToken || pendingTokenUpdate);
218
+ pendingTokenUpdate = null;
219
+ } else {
220
+ log("tokenUpdate rejected by server:", requestFromServer.code || requestFromServer.message);
221
+ pendingTokenUpdate = null;
222
+ ws.close(4001, requestFromServer.code || "token_update_failed");
223
+ onError(
224
+ requestFromServer.message || "Authentication refresh failed",
225
+ RECONNECT_DELAY
226
+ );
227
+ }
189
228
  } else if (requestFromServer.type == "ack") {
190
229
  var requestId2 = requestFromServer.requestId;
191
230
  var acceptCallback = acceptCallbacks[requestId2.toString()];
@@ -220,7 +259,7 @@ var init_syncProtocol = __esm({
220
259
  var version;
221
260
  var init_package = __esm({
222
261
  "package.json"() {
223
- version = "0.8.0-beta.2";
262
+ version = "0.8.0-beta.4";
224
263
  }
225
264
  });
226
265
 
@@ -305,7 +344,7 @@ function cleanOAuthParamsFromUrl() {
305
344
  const url = new URL(window.location.href);
306
345
  url.searchParams.delete("code");
307
346
  url.searchParams.delete("state");
308
- window.history.pushState({}, document.title, url.pathname + url.search);
347
+ window.history.replaceState({}, document.title, url.pathname + url.search);
309
348
  log("Cleaned OAuth parameters from URL");
310
349
  }
311
350
  }
@@ -363,6 +402,8 @@ var init_context = __esm({
363
402
  BasicContext = createContext({
364
403
  isReady: false,
365
404
  isSignedIn: false,
405
+ authStatus: "bootstrapping",
406
+ authErrorCode: null,
366
407
  user: null,
367
408
  did: null,
368
409
  scope: null,
@@ -1018,7 +1059,14 @@ var init_BasicDevToolbar = __esm({
1018
1059
  });
1019
1060
 
1020
1061
  // src/AuthContext.tsx
1021
- import { useCallback as useCallback2, useEffect, useRef, useState as useState2, Suspense, lazy } from "react";
1062
+ import {
1063
+ useCallback as useCallback2,
1064
+ useEffect,
1065
+ useRef,
1066
+ useState as useState2,
1067
+ Suspense,
1068
+ lazy
1069
+ } from "react";
1022
1070
 
1023
1071
  // src/sync/index.ts
1024
1072
  init_config();
@@ -1612,6 +1660,21 @@ async function resolveHandle(handle) {
1612
1660
  // src/core/auth/AuthManager.ts
1613
1661
  init_network();
1614
1662
  init_config();
1663
+ var DEFINITIVE_TOKEN_ERRORS = /* @__PURE__ */ new Set([
1664
+ "invalid_grant",
1665
+ "invalid_client",
1666
+ "unauthorized_client"
1667
+ ]);
1668
+ var USER_RECOVERY_RETRY_COOLDOWN_MS = 3e4;
1669
+ var SESSION_RECONCILE_THROTTLE_MS = 5e3;
1670
+ var DefinitiveAuthError = class extends Error {
1671
+ code;
1672
+ constructor(code) {
1673
+ super(`Definitive auth failure: ${code}`);
1674
+ this.name = "DefinitiveAuthError";
1675
+ this.code = code;
1676
+ }
1677
+ };
1615
1678
  function generateCodeVerifier() {
1616
1679
  const array = new Uint8Array(32);
1617
1680
  crypto.getRandomValues(array);
@@ -1619,7 +1682,9 @@ function generateCodeVerifier() {
1619
1682
  }
1620
1683
  async function generateCodeChallenge(verifier) {
1621
1684
  if (typeof crypto === "undefined" || !crypto.subtle) {
1622
- log("crypto.subtle unavailable (non-secure context?) -- falling back to plain PKCE challenge");
1685
+ log(
1686
+ "crypto.subtle unavailable (non-secure context?) -- falling back to plain PKCE challenge"
1687
+ );
1623
1688
  return { challenge: verifier, method: "plain" };
1624
1689
  }
1625
1690
  const encoder = new TextEncoder();
@@ -1640,6 +1705,8 @@ var AuthManager = class {
1640
1705
  user = null;
1641
1706
  isSignedIn = false;
1642
1707
  isAuthReady = false;
1708
+ authStatus = "bootstrapping";
1709
+ authErrorCode = null;
1643
1710
  did = null;
1644
1711
  /** Space-separated scopes granted in the current access token */
1645
1712
  tokenScope = null;
@@ -1656,6 +1723,9 @@ var AuthManager = class {
1656
1723
  pendingRefresh = false;
1657
1724
  isOnline = typeof navigator !== "undefined" ? navigator.onLine : true;
1658
1725
  channel = null;
1726
+ nextUserRecoveryAt = 0;
1727
+ sessionCheckPromise = null;
1728
+ lastSessionCheckAt = 0;
1659
1729
  constructor(config, storage, notify) {
1660
1730
  this.config = config;
1661
1731
  this.storage = storage;
@@ -1670,31 +1740,26 @@ var AuthManager = class {
1670
1740
  this.channel.onmessage = (event) => {
1671
1741
  if (event.data?.type === "token_refreshed") {
1672
1742
  log("Received token refresh from another tab");
1673
- if (event.data.accessToken && this.token) {
1674
- this.token = { ...this.token, access_token: event.data.accessToken };
1675
- }
1676
- if (event.data.did) this.did = event.data.did;
1677
- if (event.data.tokenScope) this.tokenScope = event.data.tokenScope;
1678
- this.notify();
1743
+ void this.handleExternalTokenRefresh(event.data);
1679
1744
  }
1680
1745
  if (event.data?.type === "signed_in") {
1681
- log("Received sign-in from another tab, reloading");
1682
- if (typeof window !== "undefined") {
1683
- window.location.reload();
1684
- }
1746
+ log("Received sign-in from another tab, restoring session");
1747
+ void this.restoreStoredSession("cross-tab sign-in");
1685
1748
  }
1686
1749
  if (event.data?.type === "signed_out") {
1687
- log("Received sign-out from another tab, reloading");
1688
- this.user = null;
1689
- this.isSignedIn = false;
1690
- this.token = null;
1691
- this.did = null;
1692
- this.tokenScope = null;
1750
+ log("Received sign-out from another tab");
1751
+ this.resetAuthState("signed_out");
1693
1752
  this.notify();
1694
1753
  if (typeof window !== "undefined") {
1695
1754
  window.location.reload();
1696
1755
  }
1697
1756
  }
1757
+ if (event.data?.type === "session_invalidated") {
1758
+ log("Received session invalidation from another tab");
1759
+ void this.markReauthRequired(event.data.code || "invalid_grant", {
1760
+ broadcast: false
1761
+ });
1762
+ }
1698
1763
  };
1699
1764
  } catch {
1700
1765
  log("BroadcastChannel not available for cross-tab sync");
@@ -1714,6 +1779,9 @@ var AuthManager = class {
1714
1779
  broadcastSignOut() {
1715
1780
  this.channel?.postMessage({ type: "signed_out" });
1716
1781
  }
1782
+ broadcastSessionInvalidated(code) {
1783
+ this.channel?.postMessage({ type: "session_invalidated", code });
1784
+ }
1717
1785
  // ------------------------------------------------------------------
1718
1786
  // Public API
1719
1787
  // ------------------------------------------------------------------
@@ -1722,7 +1790,11 @@ var AuthManager = class {
1722
1790
  * from refresh token, or load cached user for offline mode.
1723
1791
  */
1724
1792
  async initialize() {
1725
- await this.storage.set(STORAGE_KEYS.DEBUG, this.config.debug ? "true" : "false");
1793
+ this.updateAuthStatus("bootstrapping");
1794
+ await this.storage.set(
1795
+ STORAGE_KEYS.DEBUG,
1796
+ this.config.debug ? "true" : "false"
1797
+ );
1726
1798
  const storedServerUrl = await this.storage.get(STORAGE_KEYS.SERVER_URL);
1727
1799
  if (storedServerUrl && storedServerUrl !== this.config.pdsUrl) {
1728
1800
  log("PDS URL changed, clearing stored tokens");
@@ -1734,7 +1806,7 @@ var AuthManager = class {
1734
1806
  if (params.has("code")) {
1735
1807
  const code = params.get("code");
1736
1808
  if (!code) {
1737
- this.isAuthReady = true;
1809
+ this.updateAuthStatus("signed_out");
1738
1810
  this.notify();
1739
1811
  return;
1740
1812
  }
@@ -1742,7 +1814,7 @@ var AuthManager = class {
1742
1814
  const urlState = params.get("state");
1743
1815
  if (!state || state !== urlState) {
1744
1816
  log("error: auth state does not match");
1745
- this.isAuthReady = true;
1817
+ this.updateAuthStatus("signed_out");
1746
1818
  this.notify();
1747
1819
  await this.storage.remove(STORAGE_KEYS.AUTH_STATE);
1748
1820
  cleanOAuthParamsFromUrl();
@@ -1753,31 +1825,18 @@ var AuthManager = class {
1753
1825
  this.freshSignIn = true;
1754
1826
  this.exchangeToken(code, false).catch((error) => {
1755
1827
  log("Error fetching token:", error);
1828
+ this.freshSignIn = false;
1829
+ void this.restoreCachedUser({
1830
+ hasRecoverableSession: !this.isDefinitiveAuthFailure(error)
1831
+ });
1756
1832
  });
1757
1833
  } else {
1758
- const refreshToken = await this.storage.get(STORAGE_KEYS.REFRESH_TOKEN);
1759
- if (refreshToken) {
1760
- log("Found refresh token in storage, attempting to refresh access token");
1761
- this.exchangeToken(refreshToken, true).catch((error) => {
1762
- log("Error fetching refresh token:", error);
1763
- });
1764
- } else {
1765
- const cachedUserInfo = await this.storage.get(STORAGE_KEYS.USER_INFO);
1766
- if (cachedUserInfo) {
1767
- try {
1768
- this.user = JSON.parse(cachedUserInfo);
1769
- this.isSignedIn = true;
1770
- log("Loaded cached user info for offline mode");
1771
- } catch (error) {
1772
- log("Error parsing cached user info:", error);
1773
- }
1774
- }
1775
- this.isAuthReady = true;
1776
- this.notify();
1777
- }
1834
+ await this.restoreStoredSession("initialize");
1778
1835
  }
1779
1836
  } catch (e) {
1780
1837
  log("error getting token", e);
1838
+ this.updateAuthStatus("signed_out");
1839
+ this.notify();
1781
1840
  }
1782
1841
  }
1783
1842
  /**
@@ -1787,7 +1846,7 @@ var AuthManager = class {
1787
1846
  async getToken(options) {
1788
1847
  log("getting token...");
1789
1848
  if (!this.token) {
1790
- const refreshToken = await this.storage.get(STORAGE_KEYS.REFRESH_TOKEN);
1849
+ const refreshToken = await this.getRefreshToken();
1791
1850
  if (refreshToken) {
1792
1851
  log("No token in memory, attempting to refresh from storage");
1793
1852
  if (this.refreshPromise) {
@@ -1810,7 +1869,12 @@ var AuthManager = class {
1810
1869
  } catch (error) {
1811
1870
  log("Failed to refresh token from storage:", error);
1812
1871
  if (this.isNetworkError(error)) {
1813
- throw new Error("Network offline - authentication will be retried when online");
1872
+ throw new Error(
1873
+ "Network offline - authentication will be retried when online"
1874
+ );
1875
+ }
1876
+ if (!this.isDefinitiveAuthFailure(error)) {
1877
+ throw error;
1814
1878
  }
1815
1879
  throw new Error("Authentication expired. Please sign in again.");
1816
1880
  }
@@ -1823,12 +1887,16 @@ var AuthManager = class {
1823
1887
  const isExpired = decoded.exp && decoded.exp < Date.now() / 1e3 + expirationBuffer;
1824
1888
  const shouldRefresh = isExpired || options?.forceRefresh === true;
1825
1889
  if (shouldRefresh) {
1826
- log(options?.forceRefresh ? "force refreshing token..." : "token is expired - refreshing ...");
1890
+ log(
1891
+ options?.forceRefresh ? "force refreshing token..." : "token is expired - refreshing ..."
1892
+ );
1827
1893
  if (this.refreshPromise) {
1828
1894
  log("Token refresh already in progress, waiting...");
1829
1895
  try {
1830
1896
  const newToken = await this.refreshPromise;
1831
- return newToken?.access_token || "";
1897
+ if (!newToken?.access_token)
1898
+ throw new Error("Token refresh returned empty access token");
1899
+ return newToken.access_token;
1832
1900
  } catch (error) {
1833
1901
  log("In-flight refresh failed:", error);
1834
1902
  if (this.isNetworkError(error)) {
@@ -1838,24 +1906,31 @@ var AuthManager = class {
1838
1906
  throw error;
1839
1907
  }
1840
1908
  }
1841
- const refreshToken = this.token.refresh_token || await this.storage.get(STORAGE_KEYS.REFRESH_TOKEN);
1909
+ const refreshToken = await this.getRefreshToken();
1842
1910
  if (refreshToken) {
1843
1911
  try {
1844
1912
  const newToken = await this.exchangeToken(refreshToken, true);
1845
- return newToken?.access_token || "";
1913
+ if (!newToken?.access_token)
1914
+ throw new Error("Token refresh returned empty access token");
1915
+ return newToken.access_token;
1846
1916
  } catch (error) {
1847
1917
  log("Failed to refresh expired token:", error);
1848
1918
  if (this.isNetworkError(error)) {
1849
1919
  log("Network issue - using expired token until network is restored");
1850
1920
  return this.token.access_token;
1851
1921
  }
1922
+ if (!this.isDefinitiveAuthFailure(error)) {
1923
+ throw error;
1924
+ }
1852
1925
  throw new Error("Authentication expired. Please sign in again.");
1853
1926
  }
1854
1927
  } else {
1855
1928
  throw new Error("no refresh token available");
1856
1929
  }
1857
1930
  }
1858
- return this.token.access_token || "";
1931
+ if (!this.token.access_token)
1932
+ throw new Error("Token exists but access_token is empty");
1933
+ return this.token.access_token;
1859
1934
  }
1860
1935
  async getSignInUrl(redirectUri, endpoints) {
1861
1936
  log("getting sign in link...");
@@ -1863,8 +1938,13 @@ var AuthManager = class {
1863
1938
  throw new Error("Project ID is required to generate sign-in link");
1864
1939
  }
1865
1940
  const pdsEndpoints = endpoints || this.defaultPdsEndpoints();
1866
- await this.storage.set(STORAGE_KEYS.PDS_ENDPOINTS, JSON.stringify(pdsEndpoints));
1867
- const randomState = Math.random().toString(36).substring(6);
1941
+ await this.storage.set(
1942
+ STORAGE_KEYS.PDS_ENDPOINTS,
1943
+ JSON.stringify(pdsEndpoints)
1944
+ );
1945
+ const randomState = base64UrlEncode(
1946
+ crypto.getRandomValues(new Uint8Array(16))
1947
+ );
1868
1948
  await this.storage.set(STORAGE_KEYS.AUTH_STATE, randomState);
1869
1949
  const redirectUrl = redirectUri || window.location.href;
1870
1950
  if (!redirectUrl || !redirectUrl.startsWith("http://") && !redirectUrl.startsWith("https://")) {
@@ -1933,7 +2013,10 @@ var AuthManager = class {
1933
2013
  if (state) {
1934
2014
  const storedState = await this.storage.get(STORAGE_KEYS.AUTH_STATE);
1935
2015
  if (storedState && storedState !== state) {
1936
- log("State parameter mismatch:", { provided: state, stored: storedState });
2016
+ log("State parameter mismatch:", {
2017
+ provided: state,
2018
+ stored: storedState
2019
+ });
1937
2020
  return { success: false, error: "State parameter mismatch" };
1938
2021
  }
1939
2022
  }
@@ -1949,6 +2032,7 @@ var AuthManager = class {
1949
2032
  }
1950
2033
  } catch (error) {
1951
2034
  log("signInWithCode error:", error);
2035
+ this.freshSignIn = false;
1952
2036
  return {
1953
2037
  success: false,
1954
2038
  error: error.message || "Authentication failed"
@@ -1961,13 +2045,64 @@ var AuthManager = class {
1961
2045
  */
1962
2046
  async signOut() {
1963
2047
  log("signing out!");
1964
- this.resetAuthState();
2048
+ this.resetAuthState("signed_out");
1965
2049
  await this.storage.remove(STORAGE_KEYS.AUTH_STATE);
1966
2050
  await this.storage.remove(STORAGE_KEYS.LAST_CONNECT_REPORT);
1967
2051
  await this.clearStoredAuth();
1968
2052
  this.broadcastSignOut();
1969
2053
  this.notify();
1970
2054
  }
2055
+ async reconcileSession(reason = "manual", options) {
2056
+ if (this.authStatus === "signed_out" || this.authStatus === "reauth_required") {
2057
+ return;
2058
+ }
2059
+ if (!this.isOnline) {
2060
+ this.updateAuthStatus("recovering", this.authErrorCode);
2061
+ this.notify();
2062
+ return;
2063
+ }
2064
+ const throttleMs = options?.throttleMs ?? SESSION_RECONCILE_THROTTLE_MS;
2065
+ const forceRefresh = options?.forceRefresh === true;
2066
+ const now = Date.now();
2067
+ if (this.sessionCheckPromise) {
2068
+ return this.sessionCheckPromise;
2069
+ }
2070
+ if (!forceRefresh && now - this.lastSessionCheckAt < throttleMs) {
2071
+ return;
2072
+ }
2073
+ this.lastSessionCheckAt = now;
2074
+ let sessionCheck = null;
2075
+ sessionCheck = (async () => {
2076
+ try {
2077
+ const accessToken = await this.getToken(
2078
+ forceRefresh ? { forceRefresh: true } : void 0
2079
+ );
2080
+ const currentSession = await this.fetchCurrentSession(accessToken);
2081
+ if (currentSession?.active) {
2082
+ this.updateAuthStatus("authenticated");
2083
+ this.notify();
2084
+ if (!this.user) {
2085
+ await this.recoverMissingUserProfile(reason, accessToken);
2086
+ }
2087
+ }
2088
+ } catch (error) {
2089
+ log(`Session reconciliation failed on ${reason}:`, error);
2090
+ if (this.isDefinitiveAuthFailure(error)) {
2091
+ return;
2092
+ }
2093
+ if (this.isNetworkError(error)) {
2094
+ this.updateAuthStatus("recovering", this.authErrorCode);
2095
+ this.notify();
2096
+ }
2097
+ } finally {
2098
+ if (this.sessionCheckPromise === sessionCheck) {
2099
+ this.sessionCheckPromise = null;
2100
+ }
2101
+ }
2102
+ })();
2103
+ this.sessionCheckPromise = sessionCheck;
2104
+ return sessionCheck;
2105
+ }
1971
2106
  hasScope(scope) {
1972
2107
  if (!this.tokenScope) return false;
1973
2108
  return this.tokenScope.split(/[\s,]+/).filter(Boolean).includes(scope);
@@ -1983,33 +2118,62 @@ var AuthManager = class {
1983
2118
  return requested.filter((s) => !granted.has(s));
1984
2119
  }
1985
2120
  /**
1986
- * Register online/offline handlers that retry pending refreshes.
2121
+ * Register online/offline and visibility handlers that retry pending
2122
+ * refreshes and proactively refresh tokens when the app resumes from
2123
+ * background (critical for PWAs and mobile browsers where timers are
2124
+ * frozen while backgrounded).
1987
2125
  * Returns a cleanup function for useEffect teardown.
1988
2126
  */
1989
2127
  setupNetworkListeners() {
1990
2128
  const handleOnline = async () => {
1991
2129
  log("Network came back online");
1992
2130
  this.isOnline = true;
1993
- if (this.pendingRefresh && this.token) {
2131
+ if (this.pendingRefresh) {
1994
2132
  log("Retrying pending token refresh");
1995
2133
  this.pendingRefresh = false;
1996
- const refreshToken = this.token.refresh_token || await this.storage.get(STORAGE_KEYS.REFRESH_TOKEN);
2134
+ const refreshToken = await this.getRefreshToken();
1997
2135
  if (refreshToken) {
1998
2136
  this.exchangeToken(refreshToken, true).catch((error) => {
1999
2137
  log("Retry refresh failed:", error);
2000
2138
  });
2001
2139
  }
2002
2140
  }
2141
+ if (this.isSignedIn) {
2142
+ this.reconcileSession("online event", {
2143
+ forceRefresh: true,
2144
+ throttleMs: 0
2145
+ }).catch((error) => {
2146
+ log("Session reconciliation on online failed:", error);
2147
+ });
2148
+ } else if (this.user) {
2149
+ await this.restoreStoredSession("online restore");
2150
+ }
2003
2151
  };
2004
2152
  const handleOffline = () => {
2005
2153
  log("Network went offline");
2006
2154
  this.isOnline = false;
2007
2155
  };
2156
+ const handleVisibilityChange = () => {
2157
+ if (document.visibilityState === "visible" && this.isSignedIn) {
2158
+ log("App became visible - reconciling auth session");
2159
+ this.reconcileSession("visibility resume", {
2160
+ forceRefresh: true
2161
+ }).catch((err) => {
2162
+ log("Session reconciliation on visibility resume failed:", err);
2163
+ });
2164
+ }
2165
+ };
2008
2166
  window.addEventListener("online", handleOnline);
2009
2167
  window.addEventListener("offline", handleOffline);
2168
+ if (typeof document !== "undefined") {
2169
+ document.addEventListener("visibilitychange", handleVisibilityChange);
2170
+ }
2010
2171
  return () => {
2011
2172
  window.removeEventListener("online", handleOnline);
2012
2173
  window.removeEventListener("offline", handleOffline);
2174
+ if (typeof document !== "undefined") {
2175
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
2176
+ }
2013
2177
  };
2014
2178
  }
2015
2179
  // ------------------------------------------------------------------
@@ -2048,12 +2212,18 @@ var AuthManager = class {
2048
2212
  if (elapsed < 24 * 60 * 60 * 1e3) return;
2049
2213
  }
2050
2214
  try {
2051
- await fetch(`${this.config.adminUrl}/project/${this.config.projectId}/user/connect`, {
2052
- method: "POST",
2053
- headers: { "Content-Type": "application/json" },
2054
- body: JSON.stringify({ token: accessToken })
2055
- });
2056
- await this.storage.set(STORAGE_KEYS.LAST_CONNECT_REPORT, Date.now().toString());
2215
+ await fetch(
2216
+ `${this.config.adminUrl}/project/${this.config.projectId}/user/connect`,
2217
+ {
2218
+ method: "POST",
2219
+ headers: { "Content-Type": "application/json" },
2220
+ body: JSON.stringify({ token: accessToken })
2221
+ }
2222
+ );
2223
+ await this.storage.set(
2224
+ STORAGE_KEYS.LAST_CONNECT_REPORT,
2225
+ Date.now().toString()
2226
+ );
2057
2227
  log("Reported connection to admin server");
2058
2228
  } catch (err) {
2059
2229
  log("Failed to report connection (non-blocking):", err);
@@ -2064,54 +2234,46 @@ var AuthManager = class {
2064
2234
  */
2065
2235
  async processNewToken() {
2066
2236
  if (!this.token) {
2067
- this.isAuthReady = true;
2237
+ this.updateAuthStatus("signed_out");
2068
2238
  this.notify();
2069
2239
  return;
2070
2240
  }
2071
2241
  try {
2072
2242
  const decoded = jwtDecode(this.token.access_token);
2073
- if (decoded.sub) this.did = decoded.sub;
2074
- if (decoded.scope) this.tokenScope = decoded.scope;
2075
- const expirationBuffer = 5;
2076
- const isExpired = decoded.exp && decoded.exp < Date.now() / 1e3 + expirationBuffer;
2077
- if (isExpired) {
2078
- log("token is expired - refreshing ...");
2079
- const refreshToken = this.token.refresh_token;
2080
- if (!refreshToken) {
2081
- log("Error: No refresh token available for expired token");
2082
- this.isAuthReady = true;
2083
- this.notify();
2084
- return;
2085
- }
2086
- try {
2087
- const newToken = await this.exchangeToken(refreshToken, true);
2088
- await this.fetchUser(newToken?.access_token || "");
2089
- } catch (error) {
2090
- log("Failed to refresh token in processNewToken:", error);
2091
- if (this.isNetworkError(error)) {
2092
- log("Network issue - continuing with expired token until online");
2093
- await this.fetchUser(this.token.access_token);
2094
- } else {
2095
- this.isAuthReady = true;
2096
- this.notify();
2097
- }
2098
- }
2099
- } else {
2100
- await this.fetchUser(this.token.access_token);
2101
- }
2243
+ this.applyTokenClaims(decoded);
2244
+ this.updateAuthStatus("authenticated");
2245
+ this.notify();
2246
+ this.broadcastSessionUpdate();
2247
+ await this.fetchUser(this.token.access_token);
2102
2248
  } catch (error) {
2103
2249
  log("Error processing token:", error);
2104
- this.isAuthReady = true;
2250
+ this.updateAuthStatus("recovering");
2105
2251
  this.notify();
2106
2252
  }
2107
2253
  }
2254
+ async restoreCachedUser(options) {
2255
+ const cached = await this.storage.get(STORAGE_KEYS.USER_INFO);
2256
+ if (cached && options?.hasRecoverableSession) {
2257
+ try {
2258
+ this.user = JSON.parse(cached);
2259
+ log("Restored cached user info for recoverable session");
2260
+ } catch {
2261
+ }
2262
+ } else {
2263
+ this.user = null;
2264
+ }
2265
+ this.updateAuthStatus(
2266
+ options?.hasRecoverableSession ? "recovering" : "signed_out"
2267
+ );
2268
+ this.notify();
2269
+ }
2108
2270
  async fetchUser(accessToken) {
2109
2271
  log("fetching user");
2110
2272
  try {
2111
2273
  const endpoints = await this.getActivePdsEndpoints();
2112
2274
  const response = await fetch(endpoints.userinfo_endpoint, {
2113
2275
  method: "GET",
2114
- headers: { "Authorization": `Bearer ${accessToken}` }
2276
+ headers: { Authorization: `Bearer ${accessToken}` }
2115
2277
  });
2116
2278
  if (!response.ok) {
2117
2279
  throw new Error(`Failed to fetch user info: ${response.status}`);
@@ -2122,24 +2284,22 @@ var AuthManager = class {
2122
2284
  throw new Error(`User info error: ${user.error}`);
2123
2285
  }
2124
2286
  if (this.token?.refresh_token) {
2125
- await this.storage.set(STORAGE_KEYS.REFRESH_TOKEN, this.token.refresh_token);
2287
+ await this.storage.set(
2288
+ STORAGE_KEYS.REFRESH_TOKEN,
2289
+ this.token.refresh_token
2290
+ );
2126
2291
  }
2127
2292
  await this.storage.set(STORAGE_KEYS.USER_INFO, JSON.stringify(user));
2128
2293
  log("Cached user info in storage");
2129
2294
  this.user = user;
2130
- this.isSignedIn = true;
2131
- this.isAuthReady = true;
2132
- if (this.freshSignIn) {
2133
- this.freshSignIn = false;
2134
- this.broadcastSignIn();
2135
- } else {
2136
- this.broadcastTokenRefresh();
2295
+ if (this.authStatus !== "reauth_required") {
2296
+ this.updateAuthStatus("authenticated");
2137
2297
  }
2298
+ this.nextUserRecoveryAt = 0;
2138
2299
  this.notify();
2139
2300
  } catch (error) {
2140
2301
  log("Failed to fetch user info:", error);
2141
- this.isAuthReady = true;
2142
- this.notify();
2302
+ await this.handleUserFetchFailure();
2143
2303
  }
2144
2304
  }
2145
2305
  /**
@@ -2166,7 +2326,9 @@ var AuthManager = class {
2166
2326
  if (!this.isOnline) {
2167
2327
  log("Network is offline, marking refresh as pending");
2168
2328
  this.pendingRefresh = true;
2169
- throw new Error("Network offline - refresh will be retried when online");
2329
+ throw new Error(
2330
+ "Network offline - refresh will be retried when online"
2331
+ );
2170
2332
  }
2171
2333
  const endpoints = await this.getActivePdsEndpoints();
2172
2334
  let requestBody;
@@ -2176,26 +2338,36 @@ var AuthManager = class {
2176
2338
  refresh_token: codeOrRefreshToken
2177
2339
  };
2178
2340
  if (this.config.projectId) {
2179
- requestBody.client_id = normalizeClientId(this.config.projectId, this.adminHostname);
2341
+ requestBody.client_id = normalizeClientId(
2342
+ this.config.projectId,
2343
+ this.adminHostname
2344
+ );
2180
2345
  }
2181
2346
  } else {
2182
2347
  requestBody = {
2183
2348
  grant_type: "authorization_code",
2184
2349
  code: codeOrRefreshToken
2185
2350
  };
2186
- const storedRedirectUri = await this.storage.get(STORAGE_KEYS.REDIRECT_URI);
2351
+ const storedRedirectUri = await this.storage.get(
2352
+ STORAGE_KEYS.REDIRECT_URI
2353
+ );
2187
2354
  if (storedRedirectUri) {
2188
2355
  requestBody.redirect_uri = storedRedirectUri;
2189
2356
  log("Including redirect_uri in token exchange:", storedRedirectUri);
2190
2357
  } else {
2191
2358
  log("Warning: No redirect_uri found in storage for token exchange");
2192
2359
  }
2193
- const codeVerifier = await this.storage.get(STORAGE_KEYS.CODE_VERIFIER);
2360
+ const codeVerifier = await this.storage.get(
2361
+ STORAGE_KEYS.CODE_VERIFIER
2362
+ );
2194
2363
  if (codeVerifier) {
2195
2364
  requestBody.code_verifier = codeVerifier;
2196
2365
  }
2197
2366
  if (this.config.projectId) {
2198
- requestBody.client_id = normalizeClientId(this.config.projectId, this.adminHostname);
2367
+ requestBody.client_id = normalizeClientId(
2368
+ this.config.projectId,
2369
+ this.adminHostname
2370
+ );
2199
2371
  }
2200
2372
  }
2201
2373
  log("Token exchange request body:", {
@@ -2211,7 +2383,9 @@ var AuthManager = class {
2211
2383
  log("Network error fetching token:", error);
2212
2384
  if (!this.isOnline) {
2213
2385
  this.pendingRefresh = true;
2214
- throw new Error("Network offline - refresh will be retried when online");
2386
+ throw new Error(
2387
+ "Network offline - refresh will be retried when online"
2388
+ );
2215
2389
  }
2216
2390
  throw new Error("Network error during token refresh");
2217
2391
  });
@@ -2220,36 +2394,52 @@ var AuthManager = class {
2220
2394
  const decoded = jwtDecode(token.access_token);
2221
2395
  if (decoded.typ === "refresh") {
2222
2396
  log("Error: received refresh token as access token");
2223
- throw new Error("Invalid token: received refresh token instead of access token");
2397
+ throw new Error(
2398
+ "Invalid token: received refresh token instead of access token"
2399
+ );
2224
2400
  }
2225
2401
  } catch (decodeError) {
2226
2402
  if (decodeError.message.includes("Invalid token")) {
2227
2403
  throw decodeError;
2228
2404
  }
2229
- log("Warning: could not decode access token for type check:", decodeError);
2405
+ log(
2406
+ "Warning: could not decode access token for type check:",
2407
+ decodeError
2408
+ );
2230
2409
  }
2231
2410
  }
2232
2411
  if (token.error) {
2233
2412
  log("error fetching token", token.error);
2234
2413
  if (typeof token.error === "string" && (token.error.includes("network") || token.error.includes("timeout"))) {
2235
2414
  this.pendingRefresh = true;
2236
- throw new Error("Network issue - refresh will be retried when online");
2415
+ throw new Error(
2416
+ "Network issue - refresh will be retried when online"
2417
+ );
2418
+ }
2419
+ if (this.isDefinitiveTokenErrorCode(token.error)) {
2420
+ await this.markReauthRequired(token.error);
2421
+ throw new DefinitiveAuthError(token.error);
2237
2422
  }
2238
- await this.clearStoredAuth();
2239
- this.resetAuthState();
2240
- this.notify();
2241
2423
  throw new Error(`Token refresh failed: ${token.error}`);
2242
2424
  } else {
2425
+ if (!token.access_token) {
2426
+ throw new Error("Token response missing access token");
2427
+ }
2243
2428
  this.token = token;
2244
2429
  this.pendingRefresh = false;
2245
2430
  if (token.refresh_token) {
2246
- await this.storage.set(STORAGE_KEYS.REFRESH_TOKEN, token.refresh_token);
2431
+ await this.storage.set(
2432
+ STORAGE_KEYS.REFRESH_TOKEN,
2433
+ token.refresh_token
2434
+ );
2247
2435
  log("Updated refresh token in storage");
2248
2436
  }
2249
2437
  if (!isRefreshToken) {
2250
2438
  await this.storage.remove(STORAGE_KEYS.REDIRECT_URI);
2251
2439
  await this.storage.remove(STORAGE_KEYS.CODE_VERIFIER);
2252
- log("Cleaned up redirect_uri and code_verifier from storage after successful exchange");
2440
+ log(
2441
+ "Cleaned up redirect_uri and code_verifier from storage after successful exchange"
2442
+ );
2253
2443
  }
2254
2444
  this.reportConnection(token.access_token).catch(() => {
2255
2445
  });
@@ -2258,10 +2448,12 @@ var AuthManager = class {
2258
2448
  return token;
2259
2449
  } catch (error) {
2260
2450
  log("Token refresh error:", error);
2261
- if (!this.isNetworkError(error)) {
2262
- await this.clearStoredAuth();
2263
- this.resetAuthState();
2264
- this.notify();
2451
+ if (this.isDefinitiveAuthFailure(error)) {
2452
+ log("Preserving cleared auth state after definitive token rejection");
2453
+ } else if (this.isNetworkError(error)) {
2454
+ log("Recoverable network auth failure - preserving session state");
2455
+ } else {
2456
+ log("Recoverable auth failure - preserving session state");
2265
2457
  }
2266
2458
  throw error;
2267
2459
  }
@@ -2285,13 +2477,15 @@ var AuthManager = class {
2285
2477
  }
2286
2478
  return tokenPromise;
2287
2479
  }
2288
- resetAuthState() {
2289
- this.user = null;
2290
- this.isSignedIn = false;
2480
+ resetAuthState(status = "signed_out") {
2481
+ this.user = status === "reauth_required" ? this.user : null;
2291
2482
  this.token = null;
2292
- this.did = null;
2483
+ if (status !== "reauth_required") {
2484
+ this.did = null;
2485
+ }
2293
2486
  this.tokenScope = null;
2294
- this.isAuthReady = true;
2487
+ this.nextUserRecoveryAt = 0;
2488
+ this.updateAuthStatus(status);
2295
2489
  }
2296
2490
  async clearStoredAuth() {
2297
2491
  await this.storage.remove(STORAGE_KEYS.REFRESH_TOKEN);
@@ -2302,11 +2496,221 @@ var AuthManager = class {
2302
2496
  await this.storage.remove(STORAGE_KEYS.PDS_ENDPOINTS);
2303
2497
  }
2304
2498
  isNetworkError(error) {
2499
+ if (error instanceof TypeError) return true;
2305
2500
  if (error instanceof Error) {
2306
2501
  return error.message.includes("offline") || error.message.includes("Network");
2307
2502
  }
2308
2503
  return false;
2309
2504
  }
2505
+ async getRefreshToken() {
2506
+ const storedRefreshToken = await this.storage.get(
2507
+ STORAGE_KEYS.REFRESH_TOKEN
2508
+ );
2509
+ if (storedRefreshToken) {
2510
+ log("Using refresh token from storage");
2511
+ if (this.token && this.token.refresh_token !== storedRefreshToken) {
2512
+ this.token = { ...this.token, refresh_token: storedRefreshToken };
2513
+ }
2514
+ return storedRefreshToken;
2515
+ }
2516
+ const memoryRefreshToken = this.token?.refresh_token ?? null;
2517
+ if (memoryRefreshToken) {
2518
+ log("Using refresh token from memory fallback");
2519
+ } else {
2520
+ log("No refresh token available in storage or memory");
2521
+ }
2522
+ return memoryRefreshToken;
2523
+ }
2524
+ async syncRefreshTokenFromStorage() {
2525
+ const storedRefreshToken = await this.storage.get(
2526
+ STORAGE_KEYS.REFRESH_TOKEN
2527
+ );
2528
+ if (storedRefreshToken && this.token && this.token.refresh_token !== storedRefreshToken) {
2529
+ this.token = { ...this.token, refresh_token: storedRefreshToken };
2530
+ log("Synced refresh token from shared storage into memory");
2531
+ }
2532
+ }
2533
+ applyTokenClaims(decoded) {
2534
+ this.did = decoded.sub || null;
2535
+ this.tokenScope = decoded.scope || null;
2536
+ }
2537
+ broadcastSessionUpdate() {
2538
+ if (this.freshSignIn) {
2539
+ this.freshSignIn = false;
2540
+ this.broadcastSignIn();
2541
+ } else {
2542
+ this.broadcastTokenRefresh();
2543
+ }
2544
+ }
2545
+ async handleUserFetchFailure() {
2546
+ if (this.isCompatibleUser(this.user)) {
2547
+ log("Preserving existing user after userinfo failure");
2548
+ } else if (this.user) {
2549
+ log("Discarding stale in-memory user after userinfo failure");
2550
+ this.user = null;
2551
+ }
2552
+ if (!this.user) {
2553
+ const cached = await this.storage.get(STORAGE_KEYS.USER_INFO);
2554
+ if (cached) {
2555
+ try {
2556
+ const parsed = JSON.parse(cached);
2557
+ if (this.isCompatibleUser(parsed)) {
2558
+ this.user = parsed;
2559
+ log("Recovered cached user after userinfo failure");
2560
+ } else {
2561
+ log("Cached user did not match the active session");
2562
+ }
2563
+ } catch (error) {
2564
+ log("Failed to parse cached user after userinfo failure:", error);
2565
+ }
2566
+ }
2567
+ }
2568
+ if (!this.user) {
2569
+ log("No compatible cached user available after userinfo failure");
2570
+ this.nextUserRecoveryAt = Date.now() + USER_RECOVERY_RETRY_COOLDOWN_MS;
2571
+ } else {
2572
+ this.nextUserRecoveryAt = 0;
2573
+ }
2574
+ if (this.authStatus === "bootstrapping") {
2575
+ this.updateAuthStatus(this.token ? "authenticated" : "recovering");
2576
+ }
2577
+ this.notify();
2578
+ }
2579
+ isCompatibleUser(user) {
2580
+ if (!user) return false;
2581
+ if (!this.did) return true;
2582
+ return user.sub === this.did;
2583
+ }
2584
+ async recoverMissingUserProfile(reason, accessToken) {
2585
+ if (!this.isSignedIn || this.user) return;
2586
+ const now = Date.now();
2587
+ if (this.nextUserRecoveryAt > now) {
2588
+ log(
2589
+ `Skipping user profile recovery on ${reason} until ${new Date(this.nextUserRecoveryAt).toISOString()}`
2590
+ );
2591
+ return;
2592
+ }
2593
+ log(`Attempting user profile recovery on ${reason}`);
2594
+ const token = accessToken ?? await this.getToken();
2595
+ if (this.user) return;
2596
+ await this.fetchUser(token);
2597
+ }
2598
+ isDefinitiveTokenErrorCode(code) {
2599
+ return typeof code === "string" && DEFINITIVE_TOKEN_ERRORS.has(code);
2600
+ }
2601
+ isDefinitiveAuthFailure(error) {
2602
+ return error instanceof DefinitiveAuthError;
2603
+ }
2604
+ /**
2605
+ * Centralised auth status setter. Derives `isSignedIn` and `isAuthReady`
2606
+ * from the status so they stay consistent.
2607
+ *
2608
+ * `isSignedIn` is intentionally `true` during `reauth_required` so the
2609
+ * UI layer can still display user info while prompting re-authentication.
2610
+ * Consumers should check `authStatus` (or a future convenience getter)
2611
+ * when they need to distinguish "healthy session" from "needs re-auth".
2612
+ */
2613
+ updateAuthStatus(status, errorCode = null) {
2614
+ this.authStatus = status;
2615
+ this.authErrorCode = errorCode;
2616
+ this.isSignedIn = status === "authenticated" || status === "recovering" || status === "reauth_required";
2617
+ this.isAuthReady = status !== "bootstrapping";
2618
+ }
2619
+ async clearStoredSessionTokens() {
2620
+ await this.storage.remove(STORAGE_KEYS.REFRESH_TOKEN);
2621
+ await this.storage.remove(STORAGE_KEYS.REDIRECT_URI);
2622
+ await this.storage.remove(STORAGE_KEYS.CODE_VERIFIER);
2623
+ }
2624
+ async restoreStoredSession(reason) {
2625
+ const refreshToken = await this.getRefreshToken();
2626
+ if (!refreshToken) {
2627
+ log(`No stored refresh token available during ${reason}`);
2628
+ await this.restoreCachedUser({ hasRecoverableSession: false });
2629
+ return;
2630
+ }
2631
+ log(`Restoring stored session during ${reason}`);
2632
+ await this.restoreCachedUser({ hasRecoverableSession: true });
2633
+ if (!this.isOnline) {
2634
+ return;
2635
+ }
2636
+ this.exchangeToken(refreshToken, true).catch(async (error) => {
2637
+ log(`Stored session refresh failed during ${reason}:`, error);
2638
+ if (this.isDefinitiveAuthFailure(error)) {
2639
+ return;
2640
+ }
2641
+ await this.restoreCachedUser({ hasRecoverableSession: true });
2642
+ });
2643
+ }
2644
+ async handleExternalTokenRefresh(data) {
2645
+ await this.syncRefreshTokenFromStorage();
2646
+ const refreshToken = await this.getRefreshToken();
2647
+ if (data.accessToken && refreshToken) {
2648
+ try {
2649
+ const decoded = jwtDecode(data.accessToken);
2650
+ const expiresIn = decoded.exp != null ? Math.max(0, decoded.exp - Math.floor(Date.now() / 1e3)) : 0;
2651
+ this.token = {
2652
+ access_token: data.accessToken,
2653
+ token_type: "Bearer",
2654
+ expires_in: expiresIn,
2655
+ refresh_token: refreshToken
2656
+ };
2657
+ this.applyTokenClaims(decoded);
2658
+ } catch (error) {
2659
+ log("Failed to decode token refreshed by another tab:", error);
2660
+ this.token = {
2661
+ access_token: data.accessToken,
2662
+ token_type: "Bearer",
2663
+ expires_in: 0,
2664
+ refresh_token: refreshToken
2665
+ };
2666
+ }
2667
+ if (this.authStatus !== "reauth_required") {
2668
+ this.updateAuthStatus("authenticated");
2669
+ }
2670
+ } else if (refreshToken) {
2671
+ await this.restoreCachedUser({ hasRecoverableSession: true });
2672
+ }
2673
+ if (data.did) this.did = data.did;
2674
+ if (data.tokenScope) this.tokenScope = data.tokenScope;
2675
+ this.notify();
2676
+ }
2677
+ async fetchCurrentSession(accessToken) {
2678
+ const endpoints = await this.getActivePdsEndpoints();
2679
+ const response = await fetch(`${endpoints.pds_url}/auth/session`, {
2680
+ method: "GET",
2681
+ headers: { Authorization: `Bearer ${accessToken}` }
2682
+ });
2683
+ const data = await response.json().catch(() => ({}));
2684
+ if (response.status === 401 && data.reauth_required) {
2685
+ await this.markReauthRequired(data.error || "invalid_session");
2686
+ throw new DefinitiveAuthError(data.error || "invalid_session");
2687
+ }
2688
+ if (!response.ok) {
2689
+ throw new Error(`Failed to reconcile session: ${response.status}`);
2690
+ }
2691
+ return data;
2692
+ }
2693
+ async markReauthRequired(code, options) {
2694
+ log("Marking auth session as requiring reauthentication:", code);
2695
+ await this.clearStoredSessionTokens();
2696
+ if (!this.user) {
2697
+ const cached = await this.storage.get(STORAGE_KEYS.USER_INFO);
2698
+ if (cached) {
2699
+ try {
2700
+ this.user = JSON.parse(cached);
2701
+ } catch {
2702
+ }
2703
+ }
2704
+ }
2705
+ this.token = null;
2706
+ this.tokenScope = null;
2707
+ this.nextUserRecoveryAt = 0;
2708
+ this.updateAuthStatus("reauth_required", code);
2709
+ if (options?.broadcast !== false) {
2710
+ this.broadcastSessionInvalidated(code);
2711
+ }
2712
+ this.notify();
2713
+ }
2310
2714
  };
2311
2715
 
2312
2716
  // src/AuthContext.tsx
@@ -2555,6 +2959,8 @@ function snapshotAuth(mgr) {
2555
2959
  isSignedIn: mgr.isSignedIn,
2556
2960
  hasToken: !!mgr.token,
2557
2961
  isAuthReady: mgr.isAuthReady,
2962
+ authStatus: mgr.authStatus,
2963
+ authErrorCode: mgr.authErrorCode,
2558
2964
  user: mgr.user,
2559
2965
  did: mgr.did,
2560
2966
  tokenScope: mgr.tokenScope
@@ -2589,6 +2995,8 @@ function BasicProvider({
2589
2995
  isSignedIn: false,
2590
2996
  hasToken: false,
2591
2997
  isAuthReady: false,
2998
+ authStatus: "bootstrapping",
2999
+ authErrorCode: null,
2592
3000
  user: null,
2593
3001
  did: null,
2594
3002
  tokenScope: null
@@ -2611,9 +3019,11 @@ function BasicProvider({
2611
3019
  const remoteDbRef = useRef(null);
2612
3020
  const [shouldConnect, setShouldConnect] = useState2(false);
2613
3021
  const [dbStatus, setDbStatus] = useState2("OFFLINE" /* OFFLINE */);
2614
- const [isReady, setIsReady] = useState2(false);
3022
+ const [isDbReady, setIsDbReady] = useState2(false);
2615
3023
  const [error, setError] = useState2(null);
2616
- const [schemaDevInfo, setSchemaDevInfo] = useState2(null);
3024
+ const [schemaDevInfo, setSchemaDevInfo] = useState2(
3025
+ null
3026
+ );
2617
3027
  const isDevMode = () => isDevelopment(debug);
2618
3028
  const refreshSchemaStatus = useCallback2(async () => {
2619
3029
  const s = schemaRef.current;
@@ -2653,10 +3063,16 @@ function BasicProvider({
2653
3063
  useEffect(() => {
2654
3064
  const runVersionUpdater = async () => {
2655
3065
  try {
2656
- const versionUpdater = createVersionUpdater(storageAdapter, version, getMigrations());
3066
+ const versionUpdater = createVersionUpdater(
3067
+ storageAdapter,
3068
+ version,
3069
+ getMigrations()
3070
+ );
2657
3071
  const updateResult = await versionUpdater.checkAndUpdate();
2658
3072
  if (updateResult.updated) {
2659
- log(`App updated from ${updateResult.fromVersion} to ${updateResult.toVersion}`);
3073
+ log(
3074
+ `App updated from ${updateResult.fromVersion} to ${updateResult.toVersion}`
3075
+ );
2660
3076
  } else {
2661
3077
  log(`App version ${updateResult.toVersion} is current`);
2662
3078
  }
@@ -2678,8 +3094,13 @@ function BasicProvider({
2678
3094
  const newStatus = getSyncStatus(status);
2679
3095
  setDbStatus(newStatus);
2680
3096
  if (newStatus === "ERROR_WILL_RETRY" /* ERROR_WILL_RETRY */) {
2681
- log("Sync entered ERROR_WILL_RETRY - proactively refreshing token");
2682
- authRef.current.getToken({ forceRefresh: true }).catch(() => {
3097
+ log(
3098
+ "Sync entered ERROR_WILL_RETRY - reconciling auth session before retry"
3099
+ );
3100
+ authRef.current.reconcileSession("sync retry", {
3101
+ forceRefresh: true,
3102
+ throttleMs: 0
3103
+ }).catch(() => {
2683
3104
  });
2684
3105
  }
2685
3106
  });
@@ -2688,7 +3109,7 @@ function BasicProvider({
2688
3109
  } else {
2689
3110
  log("Sync is disabled");
2690
3111
  }
2691
- setIsReady(true);
3112
+ setIsDbReady(true);
2692
3113
  }
2693
3114
  }
2694
3115
  function initRemoteDb() {
@@ -2699,7 +3120,7 @@ function BasicProvider({
2699
3120
  title: "Project ID Required",
2700
3121
  message: "Remote mode requires a project_id. Provide it via schema.project_id or the project_id prop."
2701
3122
  });
2702
- setIsReady(true);
3123
+ setIsDbReady(true);
2703
3124
  return;
2704
3125
  }
2705
3126
  log("Initializing Basic Remote DB");
@@ -2711,11 +3132,20 @@ function BasicProvider({
2711
3132
  debug,
2712
3133
  onAuthError: (error2) => {
2713
3134
  log("RemoteDB auth error:", error2);
2714
- handleSignOut();
3135
+ if (error2.errorType === "forbidden") {
3136
+ log("403 Forbidden - user lacks required scope, not signing out");
3137
+ return;
3138
+ }
3139
+ authRef.current.reconcileSession(`remote db ${error2.errorType}`, {
3140
+ forceRefresh: error2.errorType !== "network",
3141
+ throttleMs: 0
3142
+ }).catch((reconcileError) => {
3143
+ log("RemoteDB auth recovery failed:", reconcileError);
3144
+ });
2715
3145
  }
2716
3146
  });
2717
3147
  setDbStatus("ONLINE" /* ONLINE */);
2718
- setIsReady(true);
3148
+ setIsDbReady(true);
2719
3149
  }
2720
3150
  }
2721
3151
  async function checkSchema() {
@@ -2741,7 +3171,7 @@ function BasicProvider({
2741
3171
  title: "Basic Schema is invalid!",
2742
3172
  message: errorMessage
2743
3173
  });
2744
- setIsReady(true);
3174
+ setIsDbReady(true);
2745
3175
  return null;
2746
3176
  }
2747
3177
  setSchemaDevInfo({
@@ -2758,7 +3188,9 @@ function BasicProvider({
2758
3188
  await initSyncDb({ shouldConnect: true });
2759
3189
  } else {
2760
3190
  if (result.schemaStatus.status === "unpublished") {
2761
- log("Schema not published yet (version 0) - sync is disabled. Publish your schema to enable sync.");
3191
+ log(
3192
+ "Schema not published yet (version 0) - sync is disabled. Publish your schema to enable sync."
3193
+ );
2762
3194
  } else {
2763
3195
  log("Schema is invalid!", result.schemaStatus);
2764
3196
  }
@@ -2782,12 +3214,12 @@ function BasicProvider({
2782
3214
  if (dbMode === "remote" && project_id) {
2783
3215
  initRemoteDb();
2784
3216
  } else {
2785
- setIsReady(true);
3217
+ setIsDbReady(true);
2786
3218
  }
2787
3219
  }
2788
3220
  }, []);
2789
3221
  useEffect(() => {
2790
- if (authState.hasToken && syncRef.current && authState.isSignedIn && shouldConnect) {
3222
+ if (authState.hasToken && syncRef.current && authState.isSignedIn && authState.authStatus !== "reauth_required" && shouldConnect) {
2791
3223
  log("connecting to db...");
2792
3224
  syncRef.current?.connect({
2793
3225
  getToken: (opts) => authRef.current.getToken(opts),
@@ -2796,7 +3228,22 @@ function BasicProvider({
2796
3228
  log("error connecting to db", e);
2797
3229
  });
2798
3230
  }
2799
- }, [authState.isSignedIn, authState.hasToken, shouldConnect]);
3231
+ }, [
3232
+ authState.authStatus,
3233
+ authState.isSignedIn,
3234
+ authState.hasToken,
3235
+ shouldConnect
3236
+ ]);
3237
+ useEffect(() => {
3238
+ if (authState.authStatus !== "reauth_required" || !syncRef.current) {
3239
+ return;
3240
+ }
3241
+ log("Auth requires reauthentication - disconnecting sync without deleting local DB");
3242
+ setDbStatus("ERROR_TOKEN_EXPIRED" /* ERROR_TOKEN_EXPIRED */);
3243
+ syncRef.current.disconnect({ ws_url: authConfig.ws_url }).catch((disconnectError) => {
3244
+ log("Error disconnecting sync after auth invalidation:", disconnectError);
3245
+ });
3246
+ }, [authConfig.ws_url, authState.authStatus]);
2800
3247
  const handleSignOut = async () => {
2801
3248
  await authRef.current.signOut();
2802
3249
  if (syncRef.current) {
@@ -2804,11 +3251,13 @@ function BasicProvider({
2804
3251
  await syncRef.current.close();
2805
3252
  await syncRef.current.delete({ disableAutoOpen: false });
2806
3253
  syncRef.current = null;
2807
- window?.location?.reload();
2808
3254
  } catch (error2) {
2809
3255
  console.error("Error during database cleanup:", error2);
2810
3256
  }
2811
3257
  }
3258
+ if (typeof window !== "undefined") {
3259
+ window.location.reload();
3260
+ }
2812
3261
  };
2813
3262
  const handleSignIn = async () => {
2814
3263
  try {
@@ -2847,6 +3296,8 @@ function BasicProvider({
2847
3296
  const contextValue = {
2848
3297
  isReady: authState.isAuthReady,
2849
3298
  isSignedIn: authState.isSignedIn,
3299
+ authStatus: authState.authStatus,
3300
+ authErrorCode: authState.authErrorCode,
2850
3301
  user: authState.user,
2851
3302
  did: authState.did,
2852
3303
  scope: authState.tokenScope,
@@ -2872,7 +3323,7 @@ function BasicProvider({
2872
3323
  return /* @__PURE__ */ jsxs2(BasicContext.Provider, { value: contextValue, children: [
2873
3324
  error && isDevMode() && /* @__PURE__ */ jsx2(ErrorDisplay, { error }),
2874
3325
  devToolbar && isDevMode() && /* @__PURE__ */ jsx2(Suspense, { fallback: null, children: /* @__PURE__ */ jsx2(BasicDevToolbar2, { debug }) }),
2875
- isReady && children
3326
+ isDbReady && authState.isAuthReady && children
2876
3327
  ] });
2877
3328
  }
2878
3329
  function ErrorDisplay({ error }) {