@robosystems/core 0.11.2 → 0.11.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.
@@ -4,17 +4,20 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState, }
4
4
  import { clearEntitySelection } from '../actions/entity-actions';
5
5
  import { clearGraphSelection } from '../actions/graph-actions';
6
6
  import { performLogoutCleanup } from '../auth-core/cleanup';
7
- import { RoboSystemsAuthClient } from '../auth-core/client';
7
+ import { isTransientError, RoboSystemsAuthClient } from '../auth-core/client';
8
8
  import { CURRENT_APP, isLoginHome } from '../auth-core/config';
9
9
  import { useTokenExpiryHandler } from '../auth-core/hooks';
10
10
  import { buildLoginHomeUrl, buildReturnTo } from '../auth-core/login-home';
11
- import { getTimeUntilExpiry, getTokenStatus } from '../auth-core/token-storage';
11
+ import { getRefreshToken, getTimeUntilExpiry, getTokenStatus, TOKEN_REFRESH_GRACE_MS, } from '../auth-core/token-storage';
12
12
  // Configuration constants
13
13
  const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
14
14
  const TOKEN_REFRESH_INTERVAL_MS = 25 * 60 * 1000; // 25 minutes (5 min before 30 min expiry)
15
15
  const TOKEN_WARNING_CHECK_INTERVAL_MS = 30 * 1000; // 30 seconds - reduced for better battery life
16
16
  const ACTIVITY_THROTTLE_MS = 1000; // 1 second
17
17
  const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes - server validation heartbeat
18
+ const HEARTBEAT_BACKOFF_BASE_MS = 30 * 1000; // first retry after a transient heartbeat failure
19
+ const SESSION_CHECK_MAX_RETRIES = 4; // page-load /me retries on a transient failure
20
+ const SESSION_CHECK_BACKOFF_BASE_MS = 1000;
18
21
  const REFRESH_COOLDOWN_MS = 60 * 1000; // 60 seconds - prevent duplicate background refreshes
19
22
  const CACHE_VERSION = '1'; // Increment to invalidate all cached auth data
20
23
  // Debug logging helper
@@ -34,6 +37,18 @@ const logStorageError = (operation, error) => {
34
37
  console.error(message, error);
35
38
  }
36
39
  };
40
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
41
+ /**
42
+ * Whether a failure is the server's final answer about this session: any
43
+ * 4xx but 429 (401 and 403 in practice). Outages, rate limits, network
44
+ * drops and malformed bodies are transient.
45
+ */
46
+ const endsSession = (error) => !isTransientError(error);
47
+ /**
48
+ * Seconds the warning dialog counts down: to the end of the refresh grace,
49
+ * the last moment a renewal can still succeed.
50
+ */
51
+ const warningSeconds = (timeLeftMs) => Math.ceil((timeLeftMs + TOKEN_REFRESH_GRACE_MS) / 1000);
37
52
  const AuthContext = createContext(null);
38
53
  export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSYSTEMS_API_URL ||
39
54
  'http://localhost:8000', }) {
@@ -64,8 +79,14 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
64
79
  }
65
80
  }
66
81
  }
67
- catch (_a) {
68
- // User not authenticated anymore, clear cache
82
+ catch (error) {
83
+ // Only the server refusing the session ends it. An outage, a rate
84
+ // limit or a network drop keeps the cached user; the heartbeat
85
+ // re-validates once the API answers again.
86
+ if (!endsSession(error)) {
87
+ debugLog('Cached user validation deferred (transient error)', error);
88
+ return;
89
+ }
69
90
  setUser(null);
70
91
  if (typeof window !== 'undefined') {
71
92
  try {
@@ -80,7 +101,24 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
80
101
  }, [authClient]);
81
102
  const checkSession = useCallback(async () => {
82
103
  try {
83
- const user = await authClient.getCurrentUser();
104
+ // A transient failure at page load (deploy, 5xx, 429, network) is
105
+ // retried with backoff instead of being read as "logged out".
106
+ let user = null;
107
+ for (let attempt = 0;; attempt++) {
108
+ try {
109
+ user = await authClient.getCurrentUser();
110
+ break;
111
+ }
112
+ catch (error) {
113
+ if (!isTransientError(error) ||
114
+ attempt >= SESSION_CHECK_MAX_RETRIES ||
115
+ !mounted.current) {
116
+ throw error;
117
+ }
118
+ authClient.clearAuthCache();
119
+ await sleep(SESSION_CHECK_BACKOFF_BASE_MS * 2 ** attempt);
120
+ }
121
+ }
84
122
  setUser(user);
85
123
  // Cache the user data
86
124
  if (typeof window !== 'undefined') {
@@ -112,7 +150,15 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
112
150
  }, [authClient]);
113
151
  const logout = useCallback(async (reason, options) => {
114
152
  try {
115
- await authClient.logout();
153
+ // An automatic logout (the server already refused the session)
154
+ // skips the server call: there is nothing left to revoke.
155
+ if (!(options === null || options === void 0 ? void 0 : options.skipServerLogout)) {
156
+ await authClient.logout();
157
+ }
158
+ else {
159
+ const { clearToken } = await import('../auth-core/token-storage');
160
+ clearToken();
161
+ }
116
162
  }
117
163
  catch (error) {
118
164
  // Logout error - continue with local logout regardless
@@ -213,6 +259,9 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
213
259
  const response = await authClient.refreshSession();
214
260
  if (response.success) {
215
261
  setUser(response.user);
262
+ // A renewed session has nothing left to warn about ("Stay Logged
263
+ // In" relies on this to dismiss the dialog).
264
+ setSessionWarning({ show: false, timeLeft: 0 });
216
265
  // Cache the refreshed user data
217
266
  if (typeof window !== 'undefined') {
218
267
  try {
@@ -282,21 +331,81 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
282
331
  checkSession();
283
332
  // eslint-disable-next-line react-hooks/exhaustive-deps
284
333
  }, []);
285
- // Heartbeat - Server-side validation and background tab refresh
334
+ // Heartbeat - Server-side validation and background tab refresh.
335
+ //
336
+ // Only the server refusing the session (401, or 403 for a deactivated
337
+ // account) ends it, and then locally: the token is already dead, so there
338
+ // is no server logout to call. A network drop, a 5xx or a 429 keeps the
339
+ // session and backs off; focus events inside the backoff window are
340
+ // skipped so a flapping API is not hammered from every tab.
286
341
  useEffect(() => {
287
342
  if (!isAuthenticated)
288
343
  return;
289
344
  let isMounted = true;
290
- const performHeartbeat = async () => {
291
- if (!isMounted)
345
+ let inFlight = false;
346
+ let failures = 0;
347
+ let backoffUntil = 0;
348
+ let retryTimer = null;
349
+ let sentToken = null;
350
+ let recheckNow = false;
351
+ const tokenRotatedSince = (sent) => {
352
+ const current = getRefreshToken();
353
+ return current !== null && current !== sent;
354
+ };
355
+ const endSession = (reason) => logout(reason, { skipServerLogout: true });
356
+ const scheduleRetry = (error) => {
357
+ failures++;
358
+ const delay = Math.min(HEARTBEAT_BACKOFF_BASE_MS * 2 ** (failures - 1), HEARTBEAT_INTERVAL_MS);
359
+ debugLog(`Heartbeat: transient failure, retrying in ${Math.round(delay / 1000)}s`, error);
360
+ backoffUntil = Date.now() + delay;
361
+ if (retryTimer)
362
+ clearTimeout(retryTimer);
363
+ retryTimer = setTimeout(() => {
364
+ retryTimer = null;
365
+ performHeartbeat(true);
366
+ }, delay);
367
+ };
368
+ const performHeartbeat = async (scheduled = false) => {
369
+ if (!isMounted || inFlight)
370
+ return;
371
+ if (!scheduled && Date.now() < backoffUntil)
292
372
  return;
373
+ inFlight = true;
293
374
  debugLog('Heartbeat: Checking server authentication status');
294
375
  try {
376
+ // A token that expired while the tab slept cannot authenticate
377
+ // /me, but the refresh endpoint still renews it within its grace
378
+ // window — so renew first.
379
+ if (getTokenStatus() === 'expired') {
380
+ try {
381
+ await refreshSession(true);
382
+ }
383
+ catch (refreshError) {
384
+ if (!isMounted)
385
+ return;
386
+ if (endsSession(refreshError)) {
387
+ await endSession('session_expired');
388
+ }
389
+ else {
390
+ scheduleRetry(refreshError);
391
+ }
392
+ return;
393
+ }
394
+ // A refresh already in flight elsewhere skips this one; /me would
395
+ // then go out unauthenticated and read as a refusal. Wait instead.
396
+ if (getTokenStatus() === 'expired') {
397
+ scheduleRetry(new Error('Session refresh still pending'));
398
+ return;
399
+ }
400
+ }
295
401
  // Make real HTTP call to validate session server-side
296
402
  // This works even in background tabs (not throttled like timers)
403
+ sentToken = getRefreshToken();
297
404
  await authClient.getCurrentUser();
298
405
  if (!isMounted)
299
406
  return;
407
+ failures = 0;
408
+ backoffUntil = 0;
300
409
  debugLog('Heartbeat: Server validation successful');
301
410
  // After successful server check, verify local token status
302
411
  const tokenStatus = getTokenStatus();
@@ -311,17 +420,19 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
311
420
  if (!isMounted)
312
421
  return;
313
422
  debugLog('Heartbeat: Token refresh successful');
314
- setSessionWarning({ show: false, timeLeft: 0 });
315
423
  }
316
424
  catch (refreshError) {
317
425
  if (!isMounted)
318
426
  return;
319
427
  debugLog('Heartbeat: Token refresh failed', refreshError);
320
- // Show warning if refresh failed but token not expired
321
- if (timeLeft > 0) {
428
+ if (endsSession(refreshError)) {
429
+ await endSession('session_expired');
430
+ }
431
+ else if (timeLeft > 0) {
432
+ // Show warning if refresh failed but token not expired
322
433
  setSessionWarning({
323
434
  show: true,
324
- timeLeft: Math.ceil(timeLeft / 1000),
435
+ timeLeft: warningSeconds(timeLeft),
325
436
  });
326
437
  }
327
438
  }
@@ -330,13 +441,33 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
330
441
  catch (error) {
331
442
  if (!isMounted)
332
443
  return;
333
- debugLog('Heartbeat: Server validation failed - user may be logged out', error);
334
- // Server says we're not authenticated - immediate logout
335
- await logout('session_invalid');
444
+ if (endsSession(error) && tokenRotatedSince(sentToken)) {
445
+ // Another tab renewed the session while this check was in flight;
446
+ // the refusal was for the token it replaced. Re-check at once.
447
+ authClient.clearAuthCache();
448
+ recheckNow = true;
449
+ }
450
+ else if (endsSession(error)) {
451
+ debugLog('Heartbeat: Server refused the session', error);
452
+ await endSession('session_invalid');
453
+ }
454
+ else {
455
+ scheduleRetry(error);
456
+ }
457
+ }
458
+ finally {
459
+ inFlight = false;
460
+ if (recheckNow && isMounted) {
461
+ recheckNow = false;
462
+ retryTimer = setTimeout(() => {
463
+ retryTimer = null;
464
+ performHeartbeat(true);
465
+ }, 0);
466
+ }
336
467
  }
337
468
  };
338
469
  // Regular heartbeat interval
339
- const heartbeatInterval = setInterval(performHeartbeat, HEARTBEAT_INTERVAL_MS); // Every 5 minutes
470
+ const heartbeatInterval = setInterval(() => performHeartbeat(), HEARTBEAT_INTERVAL_MS); // Every 5 minutes
340
471
  // Also check immediately when tab regains focus
341
472
  const handleFocus = () => {
342
473
  if (!isMounted)
@@ -357,6 +488,8 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
357
488
  return () => {
358
489
  isMounted = false;
359
490
  clearInterval(heartbeatInterval);
491
+ if (retryTimer)
492
+ clearTimeout(retryTimer);
360
493
  window.removeEventListener('focus', handleFocus);
361
494
  document.removeEventListener('visibilitychange', handleVisibilityChange);
362
495
  };
@@ -383,19 +516,21 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
383
516
  if (!isMounted)
384
517
  return;
385
518
  debugLog('Token refresh failed', error);
386
- // Show warning instead of silent logout
387
519
  const timeLeft = getTimeUntilExpiry();
388
- if (timeLeft > 0) {
520
+ if (endsSession(error)) {
521
+ // The server refused the renewal: the session is over.
522
+ debugLog('Refresh refused, redirecting to login');
523
+ await logout('session_expired', { skipServerLogout: true });
524
+ }
525
+ else if (timeLeft > 0) {
526
+ // Show warning instead of silent logout
389
527
  setSessionWarning({
390
528
  show: true,
391
- timeLeft: Math.ceil(timeLeft / 1000),
529
+ timeLeft: warningSeconds(timeLeft),
392
530
  });
393
531
  }
394
- else {
395
- // Token is actually expired, need to re-authenticate
396
- debugLog('Token expired, redirecting to login');
397
- await logout('session_expired');
398
- }
532
+ // A transient failure past expiry is left to the heartbeat,
533
+ // which retries the renewal with backoff.
399
534
  }
400
535
  }
401
536
  }, TOKEN_REFRESH_INTERVAL_MS); // Check every 25 minutes
@@ -418,11 +553,15 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
418
553
  catch (error) {
419
554
  if (!isMounted)
420
555
  return;
556
+ if (endsSession(error)) {
557
+ await logout('session_expired', { skipServerLogout: true });
558
+ return;
559
+ }
421
560
  // Auto-refresh failed, show warning modal
422
561
  debugLog('Auto-refresh failed, showing warning modal', error);
423
562
  setSessionWarning({
424
563
  show: true,
425
- timeLeft: Math.ceil(timeLeft / 1000),
564
+ timeLeft: warningSeconds(timeLeft),
426
565
  });
427
566
  }
428
567
  }
@@ -431,7 +570,7 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
431
570
  return;
432
571
  setSessionWarning({ show: false, timeLeft: 0 });
433
572
  }
434
- }, TOKEN_WARNING_CHECK_INTERVAL_MS); // Check every 10 seconds
573
+ }, TOKEN_WARNING_CHECK_INTERVAL_MS); // Check every 30 seconds
435
574
  return () => {
436
575
  isMounted = false;
437
576
  clearInterval(refreshInterval);
@@ -17,8 +17,8 @@ export function SessionWarningDialog() {
17
17
  const timer = setInterval(() => {
18
18
  setCountdown((prev) => {
19
19
  if (prev <= 1) {
20
- // Time's up, logout
21
- logout();
20
+ // Time's up: the session lapsed, it was not ended by the user
21
+ logout('session_expired');
22
22
  return 0;
23
23
  }
24
24
  return prev - 1;
@@ -14,7 +14,8 @@ export interface SignInFormProps {
14
14
  * request never reached the server — `fetch` throws a `TypeError`) must NOT be
15
15
  * reported as bad credentials: that sends users to reset a password that is
16
16
  * actually correct. Reached-server auth rejection (401/403, or an empty/invalid
17
- * auth body) stays "Invalid email or password"; 5xx gets its own message.
17
+ * auth body) stays "Invalid email or password"; 429 and 5xx get their own
18
+ * messages.
18
19
  */
19
20
  export declare function loginErrorMessage(error: unknown): string;
20
21
  /**
@@ -4,23 +4,29 @@ import { useEffect, useMemo, useState } from 'react';
4
4
  import { RoboSystemsAuthClient } from '../auth-core/client';
5
5
  import { getAppConfig } from '../auth-core/config';
6
6
  import { useSSO } from '../auth-core/sso';
7
+ import { ApiError } from '../lib/sdk-errors';
7
8
  import { LogoBadge, Spinner } from '../ui-components';
8
9
  /**
9
10
  * Map a login failure to a user-facing message. A connectivity failure (the
10
11
  * request never reached the server — `fetch` throws a `TypeError`) must NOT be
11
12
  * reported as bad credentials: that sends users to reset a password that is
12
13
  * actually correct. Reached-server auth rejection (401/403, or an empty/invalid
13
- * auth body) stays "Invalid email or password"; 5xx gets its own message.
14
+ * auth body) stays "Invalid email or password"; 429 and 5xx get their own
15
+ * messages.
14
16
  */
15
17
  export function loginErrorMessage(error) {
16
18
  var _a, _b, _c;
17
19
  const err = error;
18
20
  const status = (_a = err === null || err === void 0 ? void 0 : err.status) !== null && _a !== void 0 ? _a : (_b = err === null || err === void 0 ? void 0 : err.response) === null || _b === void 0 ? void 0 : _b.status;
19
21
  const message = String((_c = err === null || err === void 0 ? void 0 : err.message) !== null && _c !== void 0 ? _c : '');
20
- if (error instanceof TypeError ||
22
+ if ((error instanceof ApiError && error.isNetworkError) ||
23
+ error instanceof TypeError ||
21
24
  /failed to fetch|networkerror|load failed|fetch failed|err_(connection|network|name_not_resolved)/i.test(message)) {
22
25
  return 'Unable to reach the server. Check your connection and try again.';
23
26
  }
27
+ if (status === 429) {
28
+ return 'Too many sign-in attempts. Please wait a few minutes and try again.';
29
+ }
24
30
  if (typeof status === 'number' && status >= 500) {
25
31
  return 'The server ran into a problem. Please try again in a moment.';
26
32
  }
@@ -21,7 +21,10 @@ export function SignUpForm({ onSuccess, onRedirect, redirectTo = '/login', class
21
21
  const [invitation, setInvitation] = useState(null);
22
22
  const [inviteChecked, setInviteChecked] = useState(!inviteToken);
23
23
  const debounceRef = useRef(null);
24
- const authClientRef = useRef(new RoboSystemsAuthClient(apiUrl));
24
+ // `useState`'s initializer runs once; `useRef(new …)` would construct a
25
+ // client on every render.
26
+ const [authClient] = useState(() => new RoboSystemsAuthClient(apiUrl));
27
+ const authClientRef = useRef(authClient);
25
28
  // Resolve the invitation before the form is usable. A token that no longer
26
29
  // resolves (revoked, expired, already accepted) degrades to ordinary
27
30
  // registration with a notice rather than a dead end.
@@ -1,4 +1,6 @@
1
+ import { isSessionRejection, isTransientError } from '../lib/sdk-errors';
1
2
  import type { APIKey, AuthProviders, AuthResponse, AuthUser, CreateAPIKeyRequest, PasskeyEnrollmentResult, SSOTokenResponse } from './types';
3
+ export { isSessionRejection, isTransientError };
2
4
  export declare class TokenExpiredError extends Error {
3
5
  constructor(message?: string);
4
6
  }
@@ -55,7 +57,12 @@ export declare class RoboSystemsAuthClient {
55
57
  private performAuthCheck;
56
58
  refreshSession(): Promise<AuthResponse>;
57
59
  /**
58
- * Refresh session with exponential backoff retry logic
60
+ * Refresh session with exponential backoff retry logic.
61
+ *
62
+ * The request presents the stored token even when it has just expired:
63
+ * the API renews a recently-expired token within its grace window, and
64
+ * the data-call path deliberately stops sending it at expiry. A refusal
65
+ * (401/403) is final; only network errors, 429 and 5xx are retried.
59
66
  */
60
67
  private refreshSessionWithRetry;
61
68
  createAPIKey(request: CreateAPIKeyRequest): Promise<APIKey>;