@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.js CHANGED
@@ -93,6 +93,7 @@ var init_syncProtocol = __esm({
93
93
  var requestId = 0;
94
94
  var acceptCallbacks = {};
95
95
  var refreshTimer = null;
96
+ var pendingTokenUpdate = null;
96
97
  log("Connecting to", url);
97
98
  var ws = new WebSocket(url);
98
99
  function sendChanges(changes2, baseRevision2, partial2, onChangesAccepted2) {
@@ -115,6 +116,12 @@ var init_syncProtocol = __esm({
115
116
  refreshTimer = null;
116
117
  }
117
118
  }
119
+ function sendTokenUpdate(token) {
120
+ if (ws.readyState !== WebSocket.OPEN) return false;
121
+ pendingTokenUpdate = token;
122
+ ws.send(JSON.stringify({ type: "tokenUpdate", authToken: token }));
123
+ return true;
124
+ }
118
125
  function resolveGetToken() {
119
126
  var fn = getTokenGetter(url);
120
127
  if (!fn) throw new Error("No token getter registered for " + url);
@@ -130,10 +137,8 @@ var init_syncProtocol = __esm({
130
137
  refreshTimer = setTimeout(async function() {
131
138
  try {
132
139
  var newToken = await resolveGetToken()({ forceRefresh: true });
133
- if (ws.readyState === WebSocket.OPEN) {
140
+ if (sendTokenUpdate(newToken)) {
134
141
  log("Sending tokenUpdate on existing WebSocket");
135
- ws.send(JSON.stringify({ type: "tokenUpdate", authToken: newToken }));
136
- scheduleTokenRefresh(newToken);
137
142
  }
138
143
  } catch (err) {
139
144
  log("Proactive token refresh failed (non-fatal):", err);
@@ -159,14 +164,34 @@ var init_syncProtocol = __esm({
159
164
  onError("Authentication failed: " + (err.message || err), RECONNECT_DELAY);
160
165
  }
161
166
  };
167
+ function handleVisibilityResume() {
168
+ if (document.visibilityState === "visible" && ws.readyState === WebSocket.OPEN) {
169
+ log("Page became visible - refreshing token for WebSocket");
170
+ resolveGetToken()({ forceRefresh: true }).then(function(newToken) {
171
+ sendTokenUpdate(newToken);
172
+ }).catch(function(err) {
173
+ log("Token refresh on visibility resume failed:", err);
174
+ });
175
+ }
176
+ }
177
+ if (typeof document !== "undefined") {
178
+ document.addEventListener("visibilitychange", handleVisibilityResume);
179
+ }
180
+ function cleanupVisibilityListener() {
181
+ if (typeof document !== "undefined") {
182
+ document.removeEventListener("visibilitychange", handleVisibilityResume);
183
+ }
184
+ }
162
185
  ws.onerror = function(event) {
163
186
  clearRefreshTimer();
187
+ cleanupVisibilityListener();
164
188
  ws.close();
165
189
  log("ws.onerror", event);
166
190
  onError(event?.message, RECONNECT_DELAY);
167
191
  };
168
192
  ws.onclose = function(event) {
169
193
  clearRefreshTimer();
194
+ cleanupVisibilityListener();
170
195
  onError("Socket closed: " + event.reason, RECONNECT_DELAY);
171
196
  };
172
197
  var isFirstRound = true;
@@ -203,11 +228,25 @@ var init_syncProtocol = __esm({
203
228
  },
204
229
  disconnect: function() {
205
230
  clearRefreshTimer();
231
+ cleanupVisibilityListener();
206
232
  ws.close();
207
233
  }
208
234
  });
209
235
  isFirstRound = false;
210
236
  }
237
+ } else if (requestFromServer.type == "tokenUpdateAck") {
238
+ if (requestFromServer.ok) {
239
+ scheduleTokenRefresh(requestFromServer.authToken || pendingTokenUpdate);
240
+ pendingTokenUpdate = null;
241
+ } else {
242
+ log("tokenUpdate rejected by server:", requestFromServer.code || requestFromServer.message);
243
+ pendingTokenUpdate = null;
244
+ ws.close(4001, requestFromServer.code || "token_update_failed");
245
+ onError(
246
+ requestFromServer.message || "Authentication refresh failed",
247
+ RECONNECT_DELAY
248
+ );
249
+ }
211
250
  } else if (requestFromServer.type == "ack") {
212
251
  var requestId2 = requestFromServer.requestId;
213
252
  var acceptCallback = acceptCallbacks[requestId2.toString()];
@@ -242,7 +281,7 @@ var init_syncProtocol = __esm({
242
281
  var version;
243
282
  var init_package = __esm({
244
283
  "package.json"() {
245
- version = "0.8.0-beta.2";
284
+ version = "0.8.0-beta.4";
246
285
  }
247
286
  });
248
287
 
@@ -326,7 +365,7 @@ function cleanOAuthParamsFromUrl() {
326
365
  const url = new URL(window.location.href);
327
366
  url.searchParams.delete("code");
328
367
  url.searchParams.delete("state");
329
- window.history.pushState({}, document.title, url.pathname + url.search);
368
+ window.history.replaceState({}, document.title, url.pathname + url.search);
330
369
  log("Cleaned OAuth parameters from URL");
331
370
  }
332
371
  }
@@ -386,6 +425,8 @@ var init_context = __esm({
386
425
  BasicContext = (0, import_react.createContext)({
387
426
  isReady: false,
388
427
  isSignedIn: false,
428
+ authStatus: "bootstrapping",
429
+ authErrorCode: null,
389
430
  user: null,
390
431
  did: null,
391
432
  scope: null,
@@ -1654,6 +1695,21 @@ async function resolveHandle(handle) {
1654
1695
  // src/core/auth/AuthManager.ts
1655
1696
  init_network();
1656
1697
  init_config();
1698
+ var DEFINITIVE_TOKEN_ERRORS = /* @__PURE__ */ new Set([
1699
+ "invalid_grant",
1700
+ "invalid_client",
1701
+ "unauthorized_client"
1702
+ ]);
1703
+ var USER_RECOVERY_RETRY_COOLDOWN_MS = 3e4;
1704
+ var SESSION_RECONCILE_THROTTLE_MS = 5e3;
1705
+ var DefinitiveAuthError = class extends Error {
1706
+ code;
1707
+ constructor(code) {
1708
+ super(`Definitive auth failure: ${code}`);
1709
+ this.name = "DefinitiveAuthError";
1710
+ this.code = code;
1711
+ }
1712
+ };
1657
1713
  function generateCodeVerifier() {
1658
1714
  const array = new Uint8Array(32);
1659
1715
  crypto.getRandomValues(array);
@@ -1661,7 +1717,9 @@ function generateCodeVerifier() {
1661
1717
  }
1662
1718
  async function generateCodeChallenge(verifier) {
1663
1719
  if (typeof crypto === "undefined" || !crypto.subtle) {
1664
- log("crypto.subtle unavailable (non-secure context?) -- falling back to plain PKCE challenge");
1720
+ log(
1721
+ "crypto.subtle unavailable (non-secure context?) -- falling back to plain PKCE challenge"
1722
+ );
1665
1723
  return { challenge: verifier, method: "plain" };
1666
1724
  }
1667
1725
  const encoder = new TextEncoder();
@@ -1682,6 +1740,8 @@ var AuthManager = class {
1682
1740
  user = null;
1683
1741
  isSignedIn = false;
1684
1742
  isAuthReady = false;
1743
+ authStatus = "bootstrapping";
1744
+ authErrorCode = null;
1685
1745
  did = null;
1686
1746
  /** Space-separated scopes granted in the current access token */
1687
1747
  tokenScope = null;
@@ -1698,6 +1758,9 @@ var AuthManager = class {
1698
1758
  pendingRefresh = false;
1699
1759
  isOnline = typeof navigator !== "undefined" ? navigator.onLine : true;
1700
1760
  channel = null;
1761
+ nextUserRecoveryAt = 0;
1762
+ sessionCheckPromise = null;
1763
+ lastSessionCheckAt = 0;
1701
1764
  constructor(config, storage, notify) {
1702
1765
  this.config = config;
1703
1766
  this.storage = storage;
@@ -1712,31 +1775,26 @@ var AuthManager = class {
1712
1775
  this.channel.onmessage = (event) => {
1713
1776
  if (event.data?.type === "token_refreshed") {
1714
1777
  log("Received token refresh from another tab");
1715
- if (event.data.accessToken && this.token) {
1716
- this.token = { ...this.token, access_token: event.data.accessToken };
1717
- }
1718
- if (event.data.did) this.did = event.data.did;
1719
- if (event.data.tokenScope) this.tokenScope = event.data.tokenScope;
1720
- this.notify();
1778
+ void this.handleExternalTokenRefresh(event.data);
1721
1779
  }
1722
1780
  if (event.data?.type === "signed_in") {
1723
- log("Received sign-in from another tab, reloading");
1724
- if (typeof window !== "undefined") {
1725
- window.location.reload();
1726
- }
1781
+ log("Received sign-in from another tab, restoring session");
1782
+ void this.restoreStoredSession("cross-tab sign-in");
1727
1783
  }
1728
1784
  if (event.data?.type === "signed_out") {
1729
- log("Received sign-out from another tab, reloading");
1730
- this.user = null;
1731
- this.isSignedIn = false;
1732
- this.token = null;
1733
- this.did = null;
1734
- this.tokenScope = null;
1785
+ log("Received sign-out from another tab");
1786
+ this.resetAuthState("signed_out");
1735
1787
  this.notify();
1736
1788
  if (typeof window !== "undefined") {
1737
1789
  window.location.reload();
1738
1790
  }
1739
1791
  }
1792
+ if (event.data?.type === "session_invalidated") {
1793
+ log("Received session invalidation from another tab");
1794
+ void this.markReauthRequired(event.data.code || "invalid_grant", {
1795
+ broadcast: false
1796
+ });
1797
+ }
1740
1798
  };
1741
1799
  } catch {
1742
1800
  log("BroadcastChannel not available for cross-tab sync");
@@ -1756,6 +1814,9 @@ var AuthManager = class {
1756
1814
  broadcastSignOut() {
1757
1815
  this.channel?.postMessage({ type: "signed_out" });
1758
1816
  }
1817
+ broadcastSessionInvalidated(code) {
1818
+ this.channel?.postMessage({ type: "session_invalidated", code });
1819
+ }
1759
1820
  // ------------------------------------------------------------------
1760
1821
  // Public API
1761
1822
  // ------------------------------------------------------------------
@@ -1764,7 +1825,11 @@ var AuthManager = class {
1764
1825
  * from refresh token, or load cached user for offline mode.
1765
1826
  */
1766
1827
  async initialize() {
1767
- await this.storage.set(STORAGE_KEYS.DEBUG, this.config.debug ? "true" : "false");
1828
+ this.updateAuthStatus("bootstrapping");
1829
+ await this.storage.set(
1830
+ STORAGE_KEYS.DEBUG,
1831
+ this.config.debug ? "true" : "false"
1832
+ );
1768
1833
  const storedServerUrl = await this.storage.get(STORAGE_KEYS.SERVER_URL);
1769
1834
  if (storedServerUrl && storedServerUrl !== this.config.pdsUrl) {
1770
1835
  log("PDS URL changed, clearing stored tokens");
@@ -1776,7 +1841,7 @@ var AuthManager = class {
1776
1841
  if (params.has("code")) {
1777
1842
  const code = params.get("code");
1778
1843
  if (!code) {
1779
- this.isAuthReady = true;
1844
+ this.updateAuthStatus("signed_out");
1780
1845
  this.notify();
1781
1846
  return;
1782
1847
  }
@@ -1784,7 +1849,7 @@ var AuthManager = class {
1784
1849
  const urlState = params.get("state");
1785
1850
  if (!state || state !== urlState) {
1786
1851
  log("error: auth state does not match");
1787
- this.isAuthReady = true;
1852
+ this.updateAuthStatus("signed_out");
1788
1853
  this.notify();
1789
1854
  await this.storage.remove(STORAGE_KEYS.AUTH_STATE);
1790
1855
  cleanOAuthParamsFromUrl();
@@ -1795,31 +1860,18 @@ var AuthManager = class {
1795
1860
  this.freshSignIn = true;
1796
1861
  this.exchangeToken(code, false).catch((error) => {
1797
1862
  log("Error fetching token:", error);
1863
+ this.freshSignIn = false;
1864
+ void this.restoreCachedUser({
1865
+ hasRecoverableSession: !this.isDefinitiveAuthFailure(error)
1866
+ });
1798
1867
  });
1799
1868
  } else {
1800
- const refreshToken = await this.storage.get(STORAGE_KEYS.REFRESH_TOKEN);
1801
- if (refreshToken) {
1802
- log("Found refresh token in storage, attempting to refresh access token");
1803
- this.exchangeToken(refreshToken, true).catch((error) => {
1804
- log("Error fetching refresh token:", error);
1805
- });
1806
- } else {
1807
- const cachedUserInfo = await this.storage.get(STORAGE_KEYS.USER_INFO);
1808
- if (cachedUserInfo) {
1809
- try {
1810
- this.user = JSON.parse(cachedUserInfo);
1811
- this.isSignedIn = true;
1812
- log("Loaded cached user info for offline mode");
1813
- } catch (error) {
1814
- log("Error parsing cached user info:", error);
1815
- }
1816
- }
1817
- this.isAuthReady = true;
1818
- this.notify();
1819
- }
1869
+ await this.restoreStoredSession("initialize");
1820
1870
  }
1821
1871
  } catch (e) {
1822
1872
  log("error getting token", e);
1873
+ this.updateAuthStatus("signed_out");
1874
+ this.notify();
1823
1875
  }
1824
1876
  }
1825
1877
  /**
@@ -1829,7 +1881,7 @@ var AuthManager = class {
1829
1881
  async getToken(options) {
1830
1882
  log("getting token...");
1831
1883
  if (!this.token) {
1832
- const refreshToken = await this.storage.get(STORAGE_KEYS.REFRESH_TOKEN);
1884
+ const refreshToken = await this.getRefreshToken();
1833
1885
  if (refreshToken) {
1834
1886
  log("No token in memory, attempting to refresh from storage");
1835
1887
  if (this.refreshPromise) {
@@ -1852,7 +1904,12 @@ var AuthManager = class {
1852
1904
  } catch (error) {
1853
1905
  log("Failed to refresh token from storage:", error);
1854
1906
  if (this.isNetworkError(error)) {
1855
- throw new Error("Network offline - authentication will be retried when online");
1907
+ throw new Error(
1908
+ "Network offline - authentication will be retried when online"
1909
+ );
1910
+ }
1911
+ if (!this.isDefinitiveAuthFailure(error)) {
1912
+ throw error;
1856
1913
  }
1857
1914
  throw new Error("Authentication expired. Please sign in again.");
1858
1915
  }
@@ -1865,12 +1922,16 @@ var AuthManager = class {
1865
1922
  const isExpired = decoded.exp && decoded.exp < Date.now() / 1e3 + expirationBuffer;
1866
1923
  const shouldRefresh = isExpired || options?.forceRefresh === true;
1867
1924
  if (shouldRefresh) {
1868
- log(options?.forceRefresh ? "force refreshing token..." : "token is expired - refreshing ...");
1925
+ log(
1926
+ options?.forceRefresh ? "force refreshing token..." : "token is expired - refreshing ..."
1927
+ );
1869
1928
  if (this.refreshPromise) {
1870
1929
  log("Token refresh already in progress, waiting...");
1871
1930
  try {
1872
1931
  const newToken = await this.refreshPromise;
1873
- return newToken?.access_token || "";
1932
+ if (!newToken?.access_token)
1933
+ throw new Error("Token refresh returned empty access token");
1934
+ return newToken.access_token;
1874
1935
  } catch (error) {
1875
1936
  log("In-flight refresh failed:", error);
1876
1937
  if (this.isNetworkError(error)) {
@@ -1880,24 +1941,31 @@ var AuthManager = class {
1880
1941
  throw error;
1881
1942
  }
1882
1943
  }
1883
- const refreshToken = this.token.refresh_token || await this.storage.get(STORAGE_KEYS.REFRESH_TOKEN);
1944
+ const refreshToken = await this.getRefreshToken();
1884
1945
  if (refreshToken) {
1885
1946
  try {
1886
1947
  const newToken = await this.exchangeToken(refreshToken, true);
1887
- return newToken?.access_token || "";
1948
+ if (!newToken?.access_token)
1949
+ throw new Error("Token refresh returned empty access token");
1950
+ return newToken.access_token;
1888
1951
  } catch (error) {
1889
1952
  log("Failed to refresh expired token:", error);
1890
1953
  if (this.isNetworkError(error)) {
1891
1954
  log("Network issue - using expired token until network is restored");
1892
1955
  return this.token.access_token;
1893
1956
  }
1957
+ if (!this.isDefinitiveAuthFailure(error)) {
1958
+ throw error;
1959
+ }
1894
1960
  throw new Error("Authentication expired. Please sign in again.");
1895
1961
  }
1896
1962
  } else {
1897
1963
  throw new Error("no refresh token available");
1898
1964
  }
1899
1965
  }
1900
- return this.token.access_token || "";
1966
+ if (!this.token.access_token)
1967
+ throw new Error("Token exists but access_token is empty");
1968
+ return this.token.access_token;
1901
1969
  }
1902
1970
  async getSignInUrl(redirectUri, endpoints) {
1903
1971
  log("getting sign in link...");
@@ -1905,8 +1973,13 @@ var AuthManager = class {
1905
1973
  throw new Error("Project ID is required to generate sign-in link");
1906
1974
  }
1907
1975
  const pdsEndpoints = endpoints || this.defaultPdsEndpoints();
1908
- await this.storage.set(STORAGE_KEYS.PDS_ENDPOINTS, JSON.stringify(pdsEndpoints));
1909
- const randomState = Math.random().toString(36).substring(6);
1976
+ await this.storage.set(
1977
+ STORAGE_KEYS.PDS_ENDPOINTS,
1978
+ JSON.stringify(pdsEndpoints)
1979
+ );
1980
+ const randomState = base64UrlEncode(
1981
+ crypto.getRandomValues(new Uint8Array(16))
1982
+ );
1910
1983
  await this.storage.set(STORAGE_KEYS.AUTH_STATE, randomState);
1911
1984
  const redirectUrl = redirectUri || window.location.href;
1912
1985
  if (!redirectUrl || !redirectUrl.startsWith("http://") && !redirectUrl.startsWith("https://")) {
@@ -1975,7 +2048,10 @@ var AuthManager = class {
1975
2048
  if (state) {
1976
2049
  const storedState = await this.storage.get(STORAGE_KEYS.AUTH_STATE);
1977
2050
  if (storedState && storedState !== state) {
1978
- log("State parameter mismatch:", { provided: state, stored: storedState });
2051
+ log("State parameter mismatch:", {
2052
+ provided: state,
2053
+ stored: storedState
2054
+ });
1979
2055
  return { success: false, error: "State parameter mismatch" };
1980
2056
  }
1981
2057
  }
@@ -1991,6 +2067,7 @@ var AuthManager = class {
1991
2067
  }
1992
2068
  } catch (error) {
1993
2069
  log("signInWithCode error:", error);
2070
+ this.freshSignIn = false;
1994
2071
  return {
1995
2072
  success: false,
1996
2073
  error: error.message || "Authentication failed"
@@ -2003,13 +2080,64 @@ var AuthManager = class {
2003
2080
  */
2004
2081
  async signOut() {
2005
2082
  log("signing out!");
2006
- this.resetAuthState();
2083
+ this.resetAuthState("signed_out");
2007
2084
  await this.storage.remove(STORAGE_KEYS.AUTH_STATE);
2008
2085
  await this.storage.remove(STORAGE_KEYS.LAST_CONNECT_REPORT);
2009
2086
  await this.clearStoredAuth();
2010
2087
  this.broadcastSignOut();
2011
2088
  this.notify();
2012
2089
  }
2090
+ async reconcileSession(reason = "manual", options) {
2091
+ if (this.authStatus === "signed_out" || this.authStatus === "reauth_required") {
2092
+ return;
2093
+ }
2094
+ if (!this.isOnline) {
2095
+ this.updateAuthStatus("recovering", this.authErrorCode);
2096
+ this.notify();
2097
+ return;
2098
+ }
2099
+ const throttleMs = options?.throttleMs ?? SESSION_RECONCILE_THROTTLE_MS;
2100
+ const forceRefresh = options?.forceRefresh === true;
2101
+ const now = Date.now();
2102
+ if (this.sessionCheckPromise) {
2103
+ return this.sessionCheckPromise;
2104
+ }
2105
+ if (!forceRefresh && now - this.lastSessionCheckAt < throttleMs) {
2106
+ return;
2107
+ }
2108
+ this.lastSessionCheckAt = now;
2109
+ let sessionCheck = null;
2110
+ sessionCheck = (async () => {
2111
+ try {
2112
+ const accessToken = await this.getToken(
2113
+ forceRefresh ? { forceRefresh: true } : void 0
2114
+ );
2115
+ const currentSession = await this.fetchCurrentSession(accessToken);
2116
+ if (currentSession?.active) {
2117
+ this.updateAuthStatus("authenticated");
2118
+ this.notify();
2119
+ if (!this.user) {
2120
+ await this.recoverMissingUserProfile(reason, accessToken);
2121
+ }
2122
+ }
2123
+ } catch (error) {
2124
+ log(`Session reconciliation failed on ${reason}:`, error);
2125
+ if (this.isDefinitiveAuthFailure(error)) {
2126
+ return;
2127
+ }
2128
+ if (this.isNetworkError(error)) {
2129
+ this.updateAuthStatus("recovering", this.authErrorCode);
2130
+ this.notify();
2131
+ }
2132
+ } finally {
2133
+ if (this.sessionCheckPromise === sessionCheck) {
2134
+ this.sessionCheckPromise = null;
2135
+ }
2136
+ }
2137
+ })();
2138
+ this.sessionCheckPromise = sessionCheck;
2139
+ return sessionCheck;
2140
+ }
2013
2141
  hasScope(scope) {
2014
2142
  if (!this.tokenScope) return false;
2015
2143
  return this.tokenScope.split(/[\s,]+/).filter(Boolean).includes(scope);
@@ -2025,33 +2153,62 @@ var AuthManager = class {
2025
2153
  return requested.filter((s) => !granted.has(s));
2026
2154
  }
2027
2155
  /**
2028
- * Register online/offline handlers that retry pending refreshes.
2156
+ * Register online/offline and visibility handlers that retry pending
2157
+ * refreshes and proactively refresh tokens when the app resumes from
2158
+ * background (critical for PWAs and mobile browsers where timers are
2159
+ * frozen while backgrounded).
2029
2160
  * Returns a cleanup function for useEffect teardown.
2030
2161
  */
2031
2162
  setupNetworkListeners() {
2032
2163
  const handleOnline = async () => {
2033
2164
  log("Network came back online");
2034
2165
  this.isOnline = true;
2035
- if (this.pendingRefresh && this.token) {
2166
+ if (this.pendingRefresh) {
2036
2167
  log("Retrying pending token refresh");
2037
2168
  this.pendingRefresh = false;
2038
- const refreshToken = this.token.refresh_token || await this.storage.get(STORAGE_KEYS.REFRESH_TOKEN);
2169
+ const refreshToken = await this.getRefreshToken();
2039
2170
  if (refreshToken) {
2040
2171
  this.exchangeToken(refreshToken, true).catch((error) => {
2041
2172
  log("Retry refresh failed:", error);
2042
2173
  });
2043
2174
  }
2044
2175
  }
2176
+ if (this.isSignedIn) {
2177
+ this.reconcileSession("online event", {
2178
+ forceRefresh: true,
2179
+ throttleMs: 0
2180
+ }).catch((error) => {
2181
+ log("Session reconciliation on online failed:", error);
2182
+ });
2183
+ } else if (this.user) {
2184
+ await this.restoreStoredSession("online restore");
2185
+ }
2045
2186
  };
2046
2187
  const handleOffline = () => {
2047
2188
  log("Network went offline");
2048
2189
  this.isOnline = false;
2049
2190
  };
2191
+ const handleVisibilityChange = () => {
2192
+ if (document.visibilityState === "visible" && this.isSignedIn) {
2193
+ log("App became visible - reconciling auth session");
2194
+ this.reconcileSession("visibility resume", {
2195
+ forceRefresh: true
2196
+ }).catch((err) => {
2197
+ log("Session reconciliation on visibility resume failed:", err);
2198
+ });
2199
+ }
2200
+ };
2050
2201
  window.addEventListener("online", handleOnline);
2051
2202
  window.addEventListener("offline", handleOffline);
2203
+ if (typeof document !== "undefined") {
2204
+ document.addEventListener("visibilitychange", handleVisibilityChange);
2205
+ }
2052
2206
  return () => {
2053
2207
  window.removeEventListener("online", handleOnline);
2054
2208
  window.removeEventListener("offline", handleOffline);
2209
+ if (typeof document !== "undefined") {
2210
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
2211
+ }
2055
2212
  };
2056
2213
  }
2057
2214
  // ------------------------------------------------------------------
@@ -2090,12 +2247,18 @@ var AuthManager = class {
2090
2247
  if (elapsed < 24 * 60 * 60 * 1e3) return;
2091
2248
  }
2092
2249
  try {
2093
- await fetch(`${this.config.adminUrl}/project/${this.config.projectId}/user/connect`, {
2094
- method: "POST",
2095
- headers: { "Content-Type": "application/json" },
2096
- body: JSON.stringify({ token: accessToken })
2097
- });
2098
- await this.storage.set(STORAGE_KEYS.LAST_CONNECT_REPORT, Date.now().toString());
2250
+ await fetch(
2251
+ `${this.config.adminUrl}/project/${this.config.projectId}/user/connect`,
2252
+ {
2253
+ method: "POST",
2254
+ headers: { "Content-Type": "application/json" },
2255
+ body: JSON.stringify({ token: accessToken })
2256
+ }
2257
+ );
2258
+ await this.storage.set(
2259
+ STORAGE_KEYS.LAST_CONNECT_REPORT,
2260
+ Date.now().toString()
2261
+ );
2099
2262
  log("Reported connection to admin server");
2100
2263
  } catch (err) {
2101
2264
  log("Failed to report connection (non-blocking):", err);
@@ -2106,54 +2269,46 @@ var AuthManager = class {
2106
2269
  */
2107
2270
  async processNewToken() {
2108
2271
  if (!this.token) {
2109
- this.isAuthReady = true;
2272
+ this.updateAuthStatus("signed_out");
2110
2273
  this.notify();
2111
2274
  return;
2112
2275
  }
2113
2276
  try {
2114
2277
  const decoded = (0, import_jwt_decode.jwtDecode)(this.token.access_token);
2115
- if (decoded.sub) this.did = decoded.sub;
2116
- if (decoded.scope) this.tokenScope = decoded.scope;
2117
- const expirationBuffer = 5;
2118
- const isExpired = decoded.exp && decoded.exp < Date.now() / 1e3 + expirationBuffer;
2119
- if (isExpired) {
2120
- log("token is expired - refreshing ...");
2121
- const refreshToken = this.token.refresh_token;
2122
- if (!refreshToken) {
2123
- log("Error: No refresh token available for expired token");
2124
- this.isAuthReady = true;
2125
- this.notify();
2126
- return;
2127
- }
2128
- try {
2129
- const newToken = await this.exchangeToken(refreshToken, true);
2130
- await this.fetchUser(newToken?.access_token || "");
2131
- } catch (error) {
2132
- log("Failed to refresh token in processNewToken:", error);
2133
- if (this.isNetworkError(error)) {
2134
- log("Network issue - continuing with expired token until online");
2135
- await this.fetchUser(this.token.access_token);
2136
- } else {
2137
- this.isAuthReady = true;
2138
- this.notify();
2139
- }
2140
- }
2141
- } else {
2142
- await this.fetchUser(this.token.access_token);
2143
- }
2278
+ this.applyTokenClaims(decoded);
2279
+ this.updateAuthStatus("authenticated");
2280
+ this.notify();
2281
+ this.broadcastSessionUpdate();
2282
+ await this.fetchUser(this.token.access_token);
2144
2283
  } catch (error) {
2145
2284
  log("Error processing token:", error);
2146
- this.isAuthReady = true;
2285
+ this.updateAuthStatus("recovering");
2147
2286
  this.notify();
2148
2287
  }
2149
2288
  }
2289
+ async restoreCachedUser(options) {
2290
+ const cached = await this.storage.get(STORAGE_KEYS.USER_INFO);
2291
+ if (cached && options?.hasRecoverableSession) {
2292
+ try {
2293
+ this.user = JSON.parse(cached);
2294
+ log("Restored cached user info for recoverable session");
2295
+ } catch {
2296
+ }
2297
+ } else {
2298
+ this.user = null;
2299
+ }
2300
+ this.updateAuthStatus(
2301
+ options?.hasRecoverableSession ? "recovering" : "signed_out"
2302
+ );
2303
+ this.notify();
2304
+ }
2150
2305
  async fetchUser(accessToken) {
2151
2306
  log("fetching user");
2152
2307
  try {
2153
2308
  const endpoints = await this.getActivePdsEndpoints();
2154
2309
  const response = await fetch(endpoints.userinfo_endpoint, {
2155
2310
  method: "GET",
2156
- headers: { "Authorization": `Bearer ${accessToken}` }
2311
+ headers: { Authorization: `Bearer ${accessToken}` }
2157
2312
  });
2158
2313
  if (!response.ok) {
2159
2314
  throw new Error(`Failed to fetch user info: ${response.status}`);
@@ -2164,24 +2319,22 @@ var AuthManager = class {
2164
2319
  throw new Error(`User info error: ${user.error}`);
2165
2320
  }
2166
2321
  if (this.token?.refresh_token) {
2167
- await this.storage.set(STORAGE_KEYS.REFRESH_TOKEN, this.token.refresh_token);
2322
+ await this.storage.set(
2323
+ STORAGE_KEYS.REFRESH_TOKEN,
2324
+ this.token.refresh_token
2325
+ );
2168
2326
  }
2169
2327
  await this.storage.set(STORAGE_KEYS.USER_INFO, JSON.stringify(user));
2170
2328
  log("Cached user info in storage");
2171
2329
  this.user = user;
2172
- this.isSignedIn = true;
2173
- this.isAuthReady = true;
2174
- if (this.freshSignIn) {
2175
- this.freshSignIn = false;
2176
- this.broadcastSignIn();
2177
- } else {
2178
- this.broadcastTokenRefresh();
2330
+ if (this.authStatus !== "reauth_required") {
2331
+ this.updateAuthStatus("authenticated");
2179
2332
  }
2333
+ this.nextUserRecoveryAt = 0;
2180
2334
  this.notify();
2181
2335
  } catch (error) {
2182
2336
  log("Failed to fetch user info:", error);
2183
- this.isAuthReady = true;
2184
- this.notify();
2337
+ await this.handleUserFetchFailure();
2185
2338
  }
2186
2339
  }
2187
2340
  /**
@@ -2208,7 +2361,9 @@ var AuthManager = class {
2208
2361
  if (!this.isOnline) {
2209
2362
  log("Network is offline, marking refresh as pending");
2210
2363
  this.pendingRefresh = true;
2211
- throw new Error("Network offline - refresh will be retried when online");
2364
+ throw new Error(
2365
+ "Network offline - refresh will be retried when online"
2366
+ );
2212
2367
  }
2213
2368
  const endpoints = await this.getActivePdsEndpoints();
2214
2369
  let requestBody;
@@ -2218,26 +2373,36 @@ var AuthManager = class {
2218
2373
  refresh_token: codeOrRefreshToken
2219
2374
  };
2220
2375
  if (this.config.projectId) {
2221
- requestBody.client_id = normalizeClientId(this.config.projectId, this.adminHostname);
2376
+ requestBody.client_id = normalizeClientId(
2377
+ this.config.projectId,
2378
+ this.adminHostname
2379
+ );
2222
2380
  }
2223
2381
  } else {
2224
2382
  requestBody = {
2225
2383
  grant_type: "authorization_code",
2226
2384
  code: codeOrRefreshToken
2227
2385
  };
2228
- const storedRedirectUri = await this.storage.get(STORAGE_KEYS.REDIRECT_URI);
2386
+ const storedRedirectUri = await this.storage.get(
2387
+ STORAGE_KEYS.REDIRECT_URI
2388
+ );
2229
2389
  if (storedRedirectUri) {
2230
2390
  requestBody.redirect_uri = storedRedirectUri;
2231
2391
  log("Including redirect_uri in token exchange:", storedRedirectUri);
2232
2392
  } else {
2233
2393
  log("Warning: No redirect_uri found in storage for token exchange");
2234
2394
  }
2235
- const codeVerifier = await this.storage.get(STORAGE_KEYS.CODE_VERIFIER);
2395
+ const codeVerifier = await this.storage.get(
2396
+ STORAGE_KEYS.CODE_VERIFIER
2397
+ );
2236
2398
  if (codeVerifier) {
2237
2399
  requestBody.code_verifier = codeVerifier;
2238
2400
  }
2239
2401
  if (this.config.projectId) {
2240
- requestBody.client_id = normalizeClientId(this.config.projectId, this.adminHostname);
2402
+ requestBody.client_id = normalizeClientId(
2403
+ this.config.projectId,
2404
+ this.adminHostname
2405
+ );
2241
2406
  }
2242
2407
  }
2243
2408
  log("Token exchange request body:", {
@@ -2253,7 +2418,9 @@ var AuthManager = class {
2253
2418
  log("Network error fetching token:", error);
2254
2419
  if (!this.isOnline) {
2255
2420
  this.pendingRefresh = true;
2256
- throw new Error("Network offline - refresh will be retried when online");
2421
+ throw new Error(
2422
+ "Network offline - refresh will be retried when online"
2423
+ );
2257
2424
  }
2258
2425
  throw new Error("Network error during token refresh");
2259
2426
  });
@@ -2262,36 +2429,52 @@ var AuthManager = class {
2262
2429
  const decoded = (0, import_jwt_decode.jwtDecode)(token.access_token);
2263
2430
  if (decoded.typ === "refresh") {
2264
2431
  log("Error: received refresh token as access token");
2265
- throw new Error("Invalid token: received refresh token instead of access token");
2432
+ throw new Error(
2433
+ "Invalid token: received refresh token instead of access token"
2434
+ );
2266
2435
  }
2267
2436
  } catch (decodeError) {
2268
2437
  if (decodeError.message.includes("Invalid token")) {
2269
2438
  throw decodeError;
2270
2439
  }
2271
- log("Warning: could not decode access token for type check:", decodeError);
2440
+ log(
2441
+ "Warning: could not decode access token for type check:",
2442
+ decodeError
2443
+ );
2272
2444
  }
2273
2445
  }
2274
2446
  if (token.error) {
2275
2447
  log("error fetching token", token.error);
2276
2448
  if (typeof token.error === "string" && (token.error.includes("network") || token.error.includes("timeout"))) {
2277
2449
  this.pendingRefresh = true;
2278
- throw new Error("Network issue - refresh will be retried when online");
2450
+ throw new Error(
2451
+ "Network issue - refresh will be retried when online"
2452
+ );
2453
+ }
2454
+ if (this.isDefinitiveTokenErrorCode(token.error)) {
2455
+ await this.markReauthRequired(token.error);
2456
+ throw new DefinitiveAuthError(token.error);
2279
2457
  }
2280
- await this.clearStoredAuth();
2281
- this.resetAuthState();
2282
- this.notify();
2283
2458
  throw new Error(`Token refresh failed: ${token.error}`);
2284
2459
  } else {
2460
+ if (!token.access_token) {
2461
+ throw new Error("Token response missing access token");
2462
+ }
2285
2463
  this.token = token;
2286
2464
  this.pendingRefresh = false;
2287
2465
  if (token.refresh_token) {
2288
- await this.storage.set(STORAGE_KEYS.REFRESH_TOKEN, token.refresh_token);
2466
+ await this.storage.set(
2467
+ STORAGE_KEYS.REFRESH_TOKEN,
2468
+ token.refresh_token
2469
+ );
2289
2470
  log("Updated refresh token in storage");
2290
2471
  }
2291
2472
  if (!isRefreshToken) {
2292
2473
  await this.storage.remove(STORAGE_KEYS.REDIRECT_URI);
2293
2474
  await this.storage.remove(STORAGE_KEYS.CODE_VERIFIER);
2294
- log("Cleaned up redirect_uri and code_verifier from storage after successful exchange");
2475
+ log(
2476
+ "Cleaned up redirect_uri and code_verifier from storage after successful exchange"
2477
+ );
2295
2478
  }
2296
2479
  this.reportConnection(token.access_token).catch(() => {
2297
2480
  });
@@ -2300,10 +2483,12 @@ var AuthManager = class {
2300
2483
  return token;
2301
2484
  } catch (error) {
2302
2485
  log("Token refresh error:", error);
2303
- if (!this.isNetworkError(error)) {
2304
- await this.clearStoredAuth();
2305
- this.resetAuthState();
2306
- this.notify();
2486
+ if (this.isDefinitiveAuthFailure(error)) {
2487
+ log("Preserving cleared auth state after definitive token rejection");
2488
+ } else if (this.isNetworkError(error)) {
2489
+ log("Recoverable network auth failure - preserving session state");
2490
+ } else {
2491
+ log("Recoverable auth failure - preserving session state");
2307
2492
  }
2308
2493
  throw error;
2309
2494
  }
@@ -2327,13 +2512,15 @@ var AuthManager = class {
2327
2512
  }
2328
2513
  return tokenPromise;
2329
2514
  }
2330
- resetAuthState() {
2331
- this.user = null;
2332
- this.isSignedIn = false;
2515
+ resetAuthState(status = "signed_out") {
2516
+ this.user = status === "reauth_required" ? this.user : null;
2333
2517
  this.token = null;
2334
- this.did = null;
2518
+ if (status !== "reauth_required") {
2519
+ this.did = null;
2520
+ }
2335
2521
  this.tokenScope = null;
2336
- this.isAuthReady = true;
2522
+ this.nextUserRecoveryAt = 0;
2523
+ this.updateAuthStatus(status);
2337
2524
  }
2338
2525
  async clearStoredAuth() {
2339
2526
  await this.storage.remove(STORAGE_KEYS.REFRESH_TOKEN);
@@ -2344,11 +2531,221 @@ var AuthManager = class {
2344
2531
  await this.storage.remove(STORAGE_KEYS.PDS_ENDPOINTS);
2345
2532
  }
2346
2533
  isNetworkError(error) {
2534
+ if (error instanceof TypeError) return true;
2347
2535
  if (error instanceof Error) {
2348
2536
  return error.message.includes("offline") || error.message.includes("Network");
2349
2537
  }
2350
2538
  return false;
2351
2539
  }
2540
+ async getRefreshToken() {
2541
+ const storedRefreshToken = await this.storage.get(
2542
+ STORAGE_KEYS.REFRESH_TOKEN
2543
+ );
2544
+ if (storedRefreshToken) {
2545
+ log("Using refresh token from storage");
2546
+ if (this.token && this.token.refresh_token !== storedRefreshToken) {
2547
+ this.token = { ...this.token, refresh_token: storedRefreshToken };
2548
+ }
2549
+ return storedRefreshToken;
2550
+ }
2551
+ const memoryRefreshToken = this.token?.refresh_token ?? null;
2552
+ if (memoryRefreshToken) {
2553
+ log("Using refresh token from memory fallback");
2554
+ } else {
2555
+ log("No refresh token available in storage or memory");
2556
+ }
2557
+ return memoryRefreshToken;
2558
+ }
2559
+ async syncRefreshTokenFromStorage() {
2560
+ const storedRefreshToken = await this.storage.get(
2561
+ STORAGE_KEYS.REFRESH_TOKEN
2562
+ );
2563
+ if (storedRefreshToken && this.token && this.token.refresh_token !== storedRefreshToken) {
2564
+ this.token = { ...this.token, refresh_token: storedRefreshToken };
2565
+ log("Synced refresh token from shared storage into memory");
2566
+ }
2567
+ }
2568
+ applyTokenClaims(decoded) {
2569
+ this.did = decoded.sub || null;
2570
+ this.tokenScope = decoded.scope || null;
2571
+ }
2572
+ broadcastSessionUpdate() {
2573
+ if (this.freshSignIn) {
2574
+ this.freshSignIn = false;
2575
+ this.broadcastSignIn();
2576
+ } else {
2577
+ this.broadcastTokenRefresh();
2578
+ }
2579
+ }
2580
+ async handleUserFetchFailure() {
2581
+ if (this.isCompatibleUser(this.user)) {
2582
+ log("Preserving existing user after userinfo failure");
2583
+ } else if (this.user) {
2584
+ log("Discarding stale in-memory user after userinfo failure");
2585
+ this.user = null;
2586
+ }
2587
+ if (!this.user) {
2588
+ const cached = await this.storage.get(STORAGE_KEYS.USER_INFO);
2589
+ if (cached) {
2590
+ try {
2591
+ const parsed = JSON.parse(cached);
2592
+ if (this.isCompatibleUser(parsed)) {
2593
+ this.user = parsed;
2594
+ log("Recovered cached user after userinfo failure");
2595
+ } else {
2596
+ log("Cached user did not match the active session");
2597
+ }
2598
+ } catch (error) {
2599
+ log("Failed to parse cached user after userinfo failure:", error);
2600
+ }
2601
+ }
2602
+ }
2603
+ if (!this.user) {
2604
+ log("No compatible cached user available after userinfo failure");
2605
+ this.nextUserRecoveryAt = Date.now() + USER_RECOVERY_RETRY_COOLDOWN_MS;
2606
+ } else {
2607
+ this.nextUserRecoveryAt = 0;
2608
+ }
2609
+ if (this.authStatus === "bootstrapping") {
2610
+ this.updateAuthStatus(this.token ? "authenticated" : "recovering");
2611
+ }
2612
+ this.notify();
2613
+ }
2614
+ isCompatibleUser(user) {
2615
+ if (!user) return false;
2616
+ if (!this.did) return true;
2617
+ return user.sub === this.did;
2618
+ }
2619
+ async recoverMissingUserProfile(reason, accessToken) {
2620
+ if (!this.isSignedIn || this.user) return;
2621
+ const now = Date.now();
2622
+ if (this.nextUserRecoveryAt > now) {
2623
+ log(
2624
+ `Skipping user profile recovery on ${reason} until ${new Date(this.nextUserRecoveryAt).toISOString()}`
2625
+ );
2626
+ return;
2627
+ }
2628
+ log(`Attempting user profile recovery on ${reason}`);
2629
+ const token = accessToken ?? await this.getToken();
2630
+ if (this.user) return;
2631
+ await this.fetchUser(token);
2632
+ }
2633
+ isDefinitiveTokenErrorCode(code) {
2634
+ return typeof code === "string" && DEFINITIVE_TOKEN_ERRORS.has(code);
2635
+ }
2636
+ isDefinitiveAuthFailure(error) {
2637
+ return error instanceof DefinitiveAuthError;
2638
+ }
2639
+ /**
2640
+ * Centralised auth status setter. Derives `isSignedIn` and `isAuthReady`
2641
+ * from the status so they stay consistent.
2642
+ *
2643
+ * `isSignedIn` is intentionally `true` during `reauth_required` so the
2644
+ * UI layer can still display user info while prompting re-authentication.
2645
+ * Consumers should check `authStatus` (or a future convenience getter)
2646
+ * when they need to distinguish "healthy session" from "needs re-auth".
2647
+ */
2648
+ updateAuthStatus(status, errorCode = null) {
2649
+ this.authStatus = status;
2650
+ this.authErrorCode = errorCode;
2651
+ this.isSignedIn = status === "authenticated" || status === "recovering" || status === "reauth_required";
2652
+ this.isAuthReady = status !== "bootstrapping";
2653
+ }
2654
+ async clearStoredSessionTokens() {
2655
+ await this.storage.remove(STORAGE_KEYS.REFRESH_TOKEN);
2656
+ await this.storage.remove(STORAGE_KEYS.REDIRECT_URI);
2657
+ await this.storage.remove(STORAGE_KEYS.CODE_VERIFIER);
2658
+ }
2659
+ async restoreStoredSession(reason) {
2660
+ const refreshToken = await this.getRefreshToken();
2661
+ if (!refreshToken) {
2662
+ log(`No stored refresh token available during ${reason}`);
2663
+ await this.restoreCachedUser({ hasRecoverableSession: false });
2664
+ return;
2665
+ }
2666
+ log(`Restoring stored session during ${reason}`);
2667
+ await this.restoreCachedUser({ hasRecoverableSession: true });
2668
+ if (!this.isOnline) {
2669
+ return;
2670
+ }
2671
+ this.exchangeToken(refreshToken, true).catch(async (error) => {
2672
+ log(`Stored session refresh failed during ${reason}:`, error);
2673
+ if (this.isDefinitiveAuthFailure(error)) {
2674
+ return;
2675
+ }
2676
+ await this.restoreCachedUser({ hasRecoverableSession: true });
2677
+ });
2678
+ }
2679
+ async handleExternalTokenRefresh(data) {
2680
+ await this.syncRefreshTokenFromStorage();
2681
+ const refreshToken = await this.getRefreshToken();
2682
+ if (data.accessToken && refreshToken) {
2683
+ try {
2684
+ const decoded = (0, import_jwt_decode.jwtDecode)(data.accessToken);
2685
+ const expiresIn = decoded.exp != null ? Math.max(0, decoded.exp - Math.floor(Date.now() / 1e3)) : 0;
2686
+ this.token = {
2687
+ access_token: data.accessToken,
2688
+ token_type: "Bearer",
2689
+ expires_in: expiresIn,
2690
+ refresh_token: refreshToken
2691
+ };
2692
+ this.applyTokenClaims(decoded);
2693
+ } catch (error) {
2694
+ log("Failed to decode token refreshed by another tab:", error);
2695
+ this.token = {
2696
+ access_token: data.accessToken,
2697
+ token_type: "Bearer",
2698
+ expires_in: 0,
2699
+ refresh_token: refreshToken
2700
+ };
2701
+ }
2702
+ if (this.authStatus !== "reauth_required") {
2703
+ this.updateAuthStatus("authenticated");
2704
+ }
2705
+ } else if (refreshToken) {
2706
+ await this.restoreCachedUser({ hasRecoverableSession: true });
2707
+ }
2708
+ if (data.did) this.did = data.did;
2709
+ if (data.tokenScope) this.tokenScope = data.tokenScope;
2710
+ this.notify();
2711
+ }
2712
+ async fetchCurrentSession(accessToken) {
2713
+ const endpoints = await this.getActivePdsEndpoints();
2714
+ const response = await fetch(`${endpoints.pds_url}/auth/session`, {
2715
+ method: "GET",
2716
+ headers: { Authorization: `Bearer ${accessToken}` }
2717
+ });
2718
+ const data = await response.json().catch(() => ({}));
2719
+ if (response.status === 401 && data.reauth_required) {
2720
+ await this.markReauthRequired(data.error || "invalid_session");
2721
+ throw new DefinitiveAuthError(data.error || "invalid_session");
2722
+ }
2723
+ if (!response.ok) {
2724
+ throw new Error(`Failed to reconcile session: ${response.status}`);
2725
+ }
2726
+ return data;
2727
+ }
2728
+ async markReauthRequired(code, options) {
2729
+ log("Marking auth session as requiring reauthentication:", code);
2730
+ await this.clearStoredSessionTokens();
2731
+ if (!this.user) {
2732
+ const cached = await this.storage.get(STORAGE_KEYS.USER_INFO);
2733
+ if (cached) {
2734
+ try {
2735
+ this.user = JSON.parse(cached);
2736
+ } catch {
2737
+ }
2738
+ }
2739
+ }
2740
+ this.token = null;
2741
+ this.tokenScope = null;
2742
+ this.nextUserRecoveryAt = 0;
2743
+ this.updateAuthStatus("reauth_required", code);
2744
+ if (options?.broadcast !== false) {
2745
+ this.broadcastSessionInvalidated(code);
2746
+ }
2747
+ this.notify();
2748
+ }
2352
2749
  };
2353
2750
 
2354
2751
  // src/AuthContext.tsx
@@ -2597,6 +2994,8 @@ function snapshotAuth(mgr) {
2597
2994
  isSignedIn: mgr.isSignedIn,
2598
2995
  hasToken: !!mgr.token,
2599
2996
  isAuthReady: mgr.isAuthReady,
2997
+ authStatus: mgr.authStatus,
2998
+ authErrorCode: mgr.authErrorCode,
2600
2999
  user: mgr.user,
2601
3000
  did: mgr.did,
2602
3001
  tokenScope: mgr.tokenScope
@@ -2631,6 +3030,8 @@ function BasicProvider({
2631
3030
  isSignedIn: false,
2632
3031
  hasToken: false,
2633
3032
  isAuthReady: false,
3033
+ authStatus: "bootstrapping",
3034
+ authErrorCode: null,
2634
3035
  user: null,
2635
3036
  did: null,
2636
3037
  tokenScope: null
@@ -2653,9 +3054,11 @@ function BasicProvider({
2653
3054
  const remoteDbRef = (0, import_react3.useRef)(null);
2654
3055
  const [shouldConnect, setShouldConnect] = (0, import_react3.useState)(false);
2655
3056
  const [dbStatus, setDbStatus] = (0, import_react3.useState)("OFFLINE" /* OFFLINE */);
2656
- const [isReady, setIsReady] = (0, import_react3.useState)(false);
3057
+ const [isDbReady, setIsDbReady] = (0, import_react3.useState)(false);
2657
3058
  const [error, setError] = (0, import_react3.useState)(null);
2658
- const [schemaDevInfo, setSchemaDevInfo] = (0, import_react3.useState)(null);
3059
+ const [schemaDevInfo, setSchemaDevInfo] = (0, import_react3.useState)(
3060
+ null
3061
+ );
2659
3062
  const isDevMode = () => isDevelopment(debug);
2660
3063
  const refreshSchemaStatus = (0, import_react3.useCallback)(async () => {
2661
3064
  const s = schemaRef.current;
@@ -2695,10 +3098,16 @@ function BasicProvider({
2695
3098
  (0, import_react3.useEffect)(() => {
2696
3099
  const runVersionUpdater = async () => {
2697
3100
  try {
2698
- const versionUpdater = createVersionUpdater(storageAdapter, version, getMigrations());
3101
+ const versionUpdater = createVersionUpdater(
3102
+ storageAdapter,
3103
+ version,
3104
+ getMigrations()
3105
+ );
2699
3106
  const updateResult = await versionUpdater.checkAndUpdate();
2700
3107
  if (updateResult.updated) {
2701
- log(`App updated from ${updateResult.fromVersion} to ${updateResult.toVersion}`);
3108
+ log(
3109
+ `App updated from ${updateResult.fromVersion} to ${updateResult.toVersion}`
3110
+ );
2702
3111
  } else {
2703
3112
  log(`App version ${updateResult.toVersion} is current`);
2704
3113
  }
@@ -2720,8 +3129,13 @@ function BasicProvider({
2720
3129
  const newStatus = getSyncStatus(status);
2721
3130
  setDbStatus(newStatus);
2722
3131
  if (newStatus === "ERROR_WILL_RETRY" /* ERROR_WILL_RETRY */) {
2723
- log("Sync entered ERROR_WILL_RETRY - proactively refreshing token");
2724
- authRef.current.getToken({ forceRefresh: true }).catch(() => {
3132
+ log(
3133
+ "Sync entered ERROR_WILL_RETRY - reconciling auth session before retry"
3134
+ );
3135
+ authRef.current.reconcileSession("sync retry", {
3136
+ forceRefresh: true,
3137
+ throttleMs: 0
3138
+ }).catch(() => {
2725
3139
  });
2726
3140
  }
2727
3141
  });
@@ -2730,7 +3144,7 @@ function BasicProvider({
2730
3144
  } else {
2731
3145
  log("Sync is disabled");
2732
3146
  }
2733
- setIsReady(true);
3147
+ setIsDbReady(true);
2734
3148
  }
2735
3149
  }
2736
3150
  function initRemoteDb() {
@@ -2741,7 +3155,7 @@ function BasicProvider({
2741
3155
  title: "Project ID Required",
2742
3156
  message: "Remote mode requires a project_id. Provide it via schema.project_id or the project_id prop."
2743
3157
  });
2744
- setIsReady(true);
3158
+ setIsDbReady(true);
2745
3159
  return;
2746
3160
  }
2747
3161
  log("Initializing Basic Remote DB");
@@ -2753,11 +3167,20 @@ function BasicProvider({
2753
3167
  debug,
2754
3168
  onAuthError: (error2) => {
2755
3169
  log("RemoteDB auth error:", error2);
2756
- handleSignOut();
3170
+ if (error2.errorType === "forbidden") {
3171
+ log("403 Forbidden - user lacks required scope, not signing out");
3172
+ return;
3173
+ }
3174
+ authRef.current.reconcileSession(`remote db ${error2.errorType}`, {
3175
+ forceRefresh: error2.errorType !== "network",
3176
+ throttleMs: 0
3177
+ }).catch((reconcileError) => {
3178
+ log("RemoteDB auth recovery failed:", reconcileError);
3179
+ });
2757
3180
  }
2758
3181
  });
2759
3182
  setDbStatus("ONLINE" /* ONLINE */);
2760
- setIsReady(true);
3183
+ setIsDbReady(true);
2761
3184
  }
2762
3185
  }
2763
3186
  async function checkSchema() {
@@ -2783,7 +3206,7 @@ function BasicProvider({
2783
3206
  title: "Basic Schema is invalid!",
2784
3207
  message: errorMessage
2785
3208
  });
2786
- setIsReady(true);
3209
+ setIsDbReady(true);
2787
3210
  return null;
2788
3211
  }
2789
3212
  setSchemaDevInfo({
@@ -2800,7 +3223,9 @@ function BasicProvider({
2800
3223
  await initSyncDb({ shouldConnect: true });
2801
3224
  } else {
2802
3225
  if (result.schemaStatus.status === "unpublished") {
2803
- log("Schema not published yet (version 0) - sync is disabled. Publish your schema to enable sync.");
3226
+ log(
3227
+ "Schema not published yet (version 0) - sync is disabled. Publish your schema to enable sync."
3228
+ );
2804
3229
  } else {
2805
3230
  log("Schema is invalid!", result.schemaStatus);
2806
3231
  }
@@ -2824,12 +3249,12 @@ function BasicProvider({
2824
3249
  if (dbMode === "remote" && project_id) {
2825
3250
  initRemoteDb();
2826
3251
  } else {
2827
- setIsReady(true);
3252
+ setIsDbReady(true);
2828
3253
  }
2829
3254
  }
2830
3255
  }, []);
2831
3256
  (0, import_react3.useEffect)(() => {
2832
- if (authState.hasToken && syncRef.current && authState.isSignedIn && shouldConnect) {
3257
+ if (authState.hasToken && syncRef.current && authState.isSignedIn && authState.authStatus !== "reauth_required" && shouldConnect) {
2833
3258
  log("connecting to db...");
2834
3259
  syncRef.current?.connect({
2835
3260
  getToken: (opts) => authRef.current.getToken(opts),
@@ -2838,7 +3263,22 @@ function BasicProvider({
2838
3263
  log("error connecting to db", e);
2839
3264
  });
2840
3265
  }
2841
- }, [authState.isSignedIn, authState.hasToken, shouldConnect]);
3266
+ }, [
3267
+ authState.authStatus,
3268
+ authState.isSignedIn,
3269
+ authState.hasToken,
3270
+ shouldConnect
3271
+ ]);
3272
+ (0, import_react3.useEffect)(() => {
3273
+ if (authState.authStatus !== "reauth_required" || !syncRef.current) {
3274
+ return;
3275
+ }
3276
+ log("Auth requires reauthentication - disconnecting sync without deleting local DB");
3277
+ setDbStatus("ERROR_TOKEN_EXPIRED" /* ERROR_TOKEN_EXPIRED */);
3278
+ syncRef.current.disconnect({ ws_url: authConfig.ws_url }).catch((disconnectError) => {
3279
+ log("Error disconnecting sync after auth invalidation:", disconnectError);
3280
+ });
3281
+ }, [authConfig.ws_url, authState.authStatus]);
2842
3282
  const handleSignOut = async () => {
2843
3283
  await authRef.current.signOut();
2844
3284
  if (syncRef.current) {
@@ -2846,11 +3286,13 @@ function BasicProvider({
2846
3286
  await syncRef.current.close();
2847
3287
  await syncRef.current.delete({ disableAutoOpen: false });
2848
3288
  syncRef.current = null;
2849
- window?.location?.reload();
2850
3289
  } catch (error2) {
2851
3290
  console.error("Error during database cleanup:", error2);
2852
3291
  }
2853
3292
  }
3293
+ if (typeof window !== "undefined") {
3294
+ window.location.reload();
3295
+ }
2854
3296
  };
2855
3297
  const handleSignIn = async () => {
2856
3298
  try {
@@ -2889,6 +3331,8 @@ function BasicProvider({
2889
3331
  const contextValue = {
2890
3332
  isReady: authState.isAuthReady,
2891
3333
  isSignedIn: authState.isSignedIn,
3334
+ authStatus: authState.authStatus,
3335
+ authErrorCode: authState.authErrorCode,
2892
3336
  user: authState.user,
2893
3337
  did: authState.did,
2894
3338
  scope: authState.tokenScope,
@@ -2914,7 +3358,7 @@ function BasicProvider({
2914
3358
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(BasicContext.Provider, { value: contextValue, children: [
2915
3359
  error && isDevMode() && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(ErrorDisplay, { error }),
2916
3360
  devToolbar && isDevMode() && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react3.Suspense, { fallback: null, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(BasicDevToolbar2, { debug }) }),
2917
- isReady && children
3361
+ isDbReady && authState.isAuthReady && children
2918
3362
  ] });
2919
3363
  }
2920
3364
  function ErrorDisplay({ error }) {