create-tigra 2.7.2 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-tigra",
3
- "version": "2.7.2",
3
+ "version": "2.8.0",
4
4
  "type": "module",
5
5
  "description": "Create a production-ready full-stack app with Next.js 16 + Fastify 5 + Prisma + Redis",
6
6
  "bin": {
@@ -25,12 +25,15 @@
25
25
  Defaults in `app/providers.tsx`:
26
26
 
27
27
  ```typescript
28
- staleTime: 5 * 60 * 1000 // 5 min
29
- gcTime: 10 * 60 * 1000 // 10 min
30
- refetchOnWindowFocus: false
28
+ staleTime: 30 * 1000 // 30s — short enough that back-navigation refetches
29
+ gcTime: 5 * 60 * 1000 // 5 min
30
+ refetchOnWindowFocus: true // catch cross-tab edits
31
+ refetchOnMount: true // always refetch stale data on mount
31
32
  retry: 1
32
33
  ```
33
34
 
35
+ **Why these values matter**: With a long `staleTime` (e.g. 5 min) and `refetchOnWindowFocus: false`, navigating back to a list page after editing a record on another page will show stale data until the user hard-refreshes. Keep `staleTime` short and let invalidation + remount refetch do their job.
36
+
34
37
  ### Query Key Factory Pattern
35
38
 
36
39
  ```typescript
@@ -45,9 +48,35 @@ export const itemKeys = {
45
48
 
46
49
  ### Mutations
47
50
 
48
- On success: invalidate related queries, show toast, navigate if needed.
51
+ On success:
52
+ 1. **`queryClient.invalidateQueries({ queryKey: ... })`** — refreshes any client-fetched data (React Query).
53
+ 2. **`router.refresh()`** — refreshes any Server-Component-rendered data on the next route the user navigates to. Without this, the Next.js Router Cache will serve stale RSC payloads on back-navigation, and your edit will not appear until a hard refresh.
54
+ 3. Show a toast.
55
+ 4. Navigate if needed.
56
+
49
57
  On error: `toast.error(getErrorMessage(error))`.
50
58
 
59
+ **Always call both `invalidateQueries` AND `router.refresh()`** unless you are 100% certain no Server Component on any reachable route reads the mutated data. The two caches are independent — invalidating one does not touch the other.
60
+
61
+ ```typescript
62
+ const router = useRouter();
63
+ const queryClient = useQueryClient();
64
+
65
+ const mutation = useMutation({
66
+ mutationFn: (data) => itemService.updateItem(id, data),
67
+ onSuccess: () => {
68
+ queryClient.invalidateQueries({ queryKey: itemKeys.all });
69
+ router.refresh();
70
+ toast.success('Item updated');
71
+ },
72
+ onError: (error) => toast.error(getErrorMessage(error)),
73
+ });
74
+ ```
75
+
76
+ ### Next.js Router Cache
77
+
78
+ `next.config.ts` sets `experimental.staleTimes: { dynamic: 0, static: 0 }` to disable client-side Router Cache reuse. **Never raise these values** — doing so reintroduces the back-navigation stale-data bug across every page that uses Server Components for data fetching.
79
+
51
80
  ---
52
81
 
53
82
  ## Redux
@@ -61,7 +90,17 @@ State shape:
61
90
  { user: IUser | null; isAuthenticated: boolean; isInitializing: boolean; isLoggingOut: boolean }
62
91
  ```
63
92
 
64
- **Not persisted to localStorage** — auth state is hydrated on page load by `AuthInitializer` calling `getMe()`. Tokens are stored in httpOnly cookies (not accessible from JS).
93
+ **Not persisted to localStorage** — auth state is hydrated by `AuthInitializer`, which calls the `useCurrentUser()` hook. `useCurrentUser()` is a React Query wrapper around `authService.getMe()` that syncs the result into Redux via a side effect. Tokens are stored in httpOnly cookies (not accessible from JS).
94
+
95
+ **Refreshing the current user**: any mutation that changes the logged-in user's own data (profile update, role change, avatar upload, email verification, subscription change, etc.) MUST invalidate the auth query so Redux picks up the new values:
96
+
97
+ ```typescript
98
+ import { authKeys } from '@/features/auth/hooks/useCurrentUser';
99
+
100
+ queryClient.invalidateQueries({ queryKey: authKeys.me() });
101
+ ```
102
+
103
+ Without this, Redux will hold the stale snapshot from initial page load until the next window-focus refetch (30s staleTime), or until logout/hard refresh. Never write directly to the auth slice from outside the auth feature — always go through invalidation.
65
104
 
66
105
  ---
67
106
 
@@ -37,7 +37,7 @@ X-XSS-Protection: 1; mode=block
37
37
  default-src 'self';
38
38
  script-src 'self' 'unsafe-eval' 'unsafe-inline';
39
39
  style-src 'self' 'unsafe-inline';
40
- img-src 'self' blob: data: https:;
40
+ img-src 'self' blob: data: https: ${apiOrigin};
41
41
  font-src 'self';
42
42
  object-src 'none';
43
43
  base-uri 'self';
@@ -11,6 +11,9 @@ const apiOrigin = (() => {
11
11
 
12
12
  const nextConfig: NextConfig = {
13
13
  output: "standalone",
14
+ experimental: {
15
+ staleTimes: { dynamic: 0, static: 0 },
16
+ },
14
17
  async headers() {
15
18
  return [
16
19
  {
@@ -26,7 +29,7 @@ const nextConfig: NextConfig = {
26
29
  "default-src 'self'",
27
30
  "script-src 'self' 'unsafe-eval' 'unsafe-inline'",
28
31
  "style-src 'self' 'unsafe-inline'",
29
- "img-src 'self' blob: data: https:",
32
+ `img-src 'self' blob: data: https: ${apiOrigin}`,
30
33
  "font-src 'self'",
31
34
  "object-src 'none'",
32
35
  "base-uri 'self'",
@@ -88,8 +88,16 @@
88
88
  transition-duration: 200ms;
89
89
  transition-timing-function: ease-out;
90
90
  }
91
+ html {
92
+ @apply bg-background;
93
+ overflow-x: hidden;
94
+ -webkit-text-size-adjust: 100%;
95
+ text-size-adjust: 100%;
96
+ }
91
97
  body {
92
98
  @apply bg-background text-foreground;
93
99
  font-feature-settings: "rlig" 1, "calt" 1;
100
+ overflow-x: hidden;
101
+ touch-action: manipulation;
94
102
  }
95
103
  }
@@ -1,4 +1,4 @@
1
- import type { Metadata } from 'next';
1
+ import type { Metadata, Viewport } from 'next';
2
2
  import type React from 'react';
3
3
  import { Inter, JetBrains_Mono } from 'next/font/google';
4
4
 
@@ -21,6 +21,12 @@ export const metadata: Metadata = {
21
21
  description: 'A full-stack application built with Next.js and Fastify',
22
22
  };
23
23
 
24
+ export const viewport: Viewport = {
25
+ width: 'device-width',
26
+ initialScale: 1,
27
+ viewportFit: 'cover',
28
+ };
29
+
24
30
  export default function RootLayout({
25
31
  children,
26
32
  }: Readonly<{
@@ -17,9 +17,10 @@ export function Providers({ children }: { children: React.ReactNode }): React.Re
17
17
  new QueryClient({
18
18
  defaultOptions: {
19
19
  queries: {
20
- staleTime: 5 * 60 * 1000,
21
- gcTime: 10 * 60 * 1000,
22
- refetchOnWindowFocus: false,
20
+ staleTime: 30 * 1000,
21
+ gcTime: 5 * 60 * 1000,
22
+ refetchOnWindowFocus: true,
23
+ refetchOnMount: true,
23
24
  retry: 1,
24
25
  },
25
26
  },
@@ -29,7 +30,7 @@ export function Providers({ children }: { children: React.ReactNode }): React.Re
29
30
  return (
30
31
  <ReduxProvider store={store}>
31
32
  <QueryClientProvider client={queryClient}>
32
- <ThemeProvider attribute="class" defaultTheme="light">
33
+ <ThemeProvider attribute="class" defaultTheme="light" enableColorScheme={false}>
33
34
  <AuthInitializer>
34
35
  {children}
35
36
  </AuthInitializer>
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
4
+ import { useRouter } from 'next/navigation';
4
5
  import { toast } from 'sonner';
5
6
 
6
7
  import { getErrorMessage } from '@/lib/utils/error';
@@ -48,6 +49,7 @@ interface UseForceExpireSessionReturn {
48
49
 
49
50
  export const useForceExpireSession = (): UseForceExpireSessionReturn => {
50
51
  const queryClient = useQueryClient();
52
+ const router = useRouter();
51
53
 
52
54
  const mutation = useMutation({
53
55
  mutationFn: (sessionId: string) => adminService.deleteSession(sessionId),
@@ -55,6 +57,7 @@ export const useForceExpireSession = (): UseForceExpireSessionReturn => {
55
57
  toast.success('Session expired successfully');
56
58
  queryClient.invalidateQueries({ queryKey: adminKeys.sessions() });
57
59
  queryClient.invalidateQueries({ queryKey: adminKeys.stats() });
60
+ router.refresh();
58
61
  },
59
62
  onError: (error) => {
60
63
  toast.error(getErrorMessage(error));
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
4
+ import { useRouter } from 'next/navigation';
4
5
  import { toast } from 'sonner';
5
6
 
6
7
  import { getErrorMessage } from '@/lib/utils/error';
@@ -81,6 +82,7 @@ interface UseUpdateUserStatusReturn {
81
82
 
82
83
  export const useUpdateUserStatus = (): UseUpdateUserStatusReturn => {
83
84
  const queryClient = useQueryClient();
85
+ const router = useRouter();
84
86
 
85
87
  const mutation = useMutation({
86
88
  mutationFn: ({ userId, isActive }: { userId: string; isActive: boolean }) =>
@@ -90,6 +92,7 @@ export const useUpdateUserStatus = (): UseUpdateUserStatusReturn => {
90
92
  toast.success(`User ${action} successfully`);
91
93
  queryClient.invalidateQueries({ queryKey: adminKeys.users() });
92
94
  queryClient.invalidateQueries({ queryKey: adminKeys.stats() });
95
+ router.refresh();
93
96
  },
94
97
  onError: (error) => {
95
98
  toast.error(getErrorMessage(error));
@@ -111,6 +114,7 @@ interface UseUpdateUserRoleReturn {
111
114
 
112
115
  export const useUpdateUserRole = (): UseUpdateUserRoleReturn => {
113
116
  const queryClient = useQueryClient();
117
+ const router = useRouter();
114
118
 
115
119
  const mutation = useMutation({
116
120
  mutationFn: ({ userId, role }: { userId: string; role: 'USER' | 'ADMIN' }) =>
@@ -119,6 +123,7 @@ export const useUpdateUserRole = (): UseUpdateUserRoleReturn => {
119
123
  toast.success('User role updated successfully');
120
124
  queryClient.invalidateQueries({ queryKey: adminKeys.users() });
121
125
  queryClient.invalidateQueries({ queryKey: adminKeys.stats() });
126
+ router.refresh();
122
127
  },
123
128
  onError: (error) => {
124
129
  toast.error(getErrorMessage(error));
@@ -7,11 +7,10 @@ import { usePathname, useRouter } from 'next/navigation';
7
7
 
8
8
  import { toast } from 'sonner';
9
9
 
10
- import { useAppDispatch, useAppSelector } from '@/store/hooks';
10
+ import { useAppSelector } from '@/store/hooks';
11
11
  import { ROUTES } from '@/lib/constants/routes';
12
12
  import { isErrorCode, ERROR_CODES } from '@/lib/utils/error';
13
- import { authService } from '../services/auth.service';
14
- import { setUser, setInitialized } from '../store/authSlice';
13
+ import { useCurrentUser } from '../hooks/useCurrentUser';
15
14
 
16
15
  const PROTECTED_PATHS: string[] = [ROUTES.DASHBOARD, ROUTES.PROFILE, '/admin'];
17
16
 
@@ -32,53 +31,37 @@ function isAuthPage(pathname: string): boolean {
32
31
  return AUTH_PATHS.some((path) => pathname.startsWith(path));
33
32
  }
34
33
 
34
+ interface HttpLikeError {
35
+ response?: { status?: number };
36
+ }
37
+
35
38
  export const AuthInitializer = ({ children }: AuthInitializerProps): React.ReactElement => {
36
- const dispatch = useAppDispatch();
37
39
  const pathname = usePathname();
38
40
  const router = useRouter();
39
- const { isAuthenticated, isLoggingOut } = useAppSelector((state) => state.auth);
41
+ const { isLoggingOut } = useAppSelector((state) => state.auth);
40
42
 
41
- useEffect(() => {
42
- // On auth pages (login, register, etc.), never call getMe().
43
- // There is no session to hydrate, and a 401 here would trigger
44
- // the refresh → fail → redirect chain, causing an infinite loop.
45
- if (isAuthPage(pathname)) {
46
- dispatch(setInitialized());
47
- return;
48
- }
43
+ // Skip getMe() on auth pages and during logout.
44
+ // React Query handles deduping, refetch-on-focus, and invalidation
45
+ // any mutation that touches the current user (profile update, role change,
46
+ // avatar upload, email verification) should call:
47
+ // queryClient.invalidateQueries({ queryKey: authKeys.me() })
48
+ // to refresh Redux state automatically.
49
+ const enabled = !isAuthPage(pathname) && !isLoggingOut;
49
50
 
50
- // On other public pages, skip auth hydration — just mark as initialized
51
- if (!isProtectedPath(pathname)) {
52
- dispatch(setInitialized());
53
- return;
54
- }
51
+ const { error } = useCurrentUser({ enabled });
55
52
 
56
- if (isAuthenticated || isLoggingOut) return;
57
-
58
- let cancelled = false;
59
-
60
- authService
61
- .getMe()
62
- .then((user) => {
63
- if (!cancelled) dispatch(setUser(user));
64
- })
65
- .catch((error) => {
66
- if (cancelled) return;
67
- dispatch(setInitialized());
68
- // Only redirect on auth errors (401/403), not network failures
69
- const status = error?.response?.status;
70
- if (status === 401 || status === 403) {
71
- if (isErrorCode(error, ERROR_CODES.ACCOUNT_NOT_ACTIVE)) {
72
- toast.error('Your account is not yet activated. Please verify your account.');
73
- }
74
- router.push(ROUTES.LOGIN);
75
- }
76
- });
77
-
78
- return (): void => {
79
- cancelled = true;
80
- };
81
- }, [dispatch, pathname, isAuthenticated, isLoggingOut, router]);
53
+ useEffect(() => {
54
+ if (!error) return;
55
+ if (!isProtectedPath(pathname)) return;
56
+
57
+ const status = (error as HttpLikeError)?.response?.status;
58
+ if (status === 401 || status === 403) {
59
+ if (isErrorCode(error, ERROR_CODES.ACCOUNT_NOT_ACTIVE)) {
60
+ toast.error('Your account is not yet activated. Please verify your account.');
61
+ }
62
+ router.push(ROUTES.LOGIN);
63
+ }
64
+ }, [error, pathname, router]);
82
65
 
83
66
  return <>{children}</>;
84
67
  };
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useCallback, useRef } from 'react';
4
4
 
5
- import { useMutation } from '@tanstack/react-query';
5
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
6
6
  import { useRouter } from 'next/navigation';
7
7
  import { toast } from 'sonner';
8
8
 
@@ -29,12 +29,14 @@ interface UseAuthReturn {
29
29
  export const useAuth = (): UseAuthReturn => {
30
30
  const dispatch = useAppDispatch();
31
31
  const router = useRouter();
32
+ const queryClient = useQueryClient();
32
33
  const { user, isAuthenticated, isInitializing, isLoggingOut } = useAppSelector((state) => state.auth);
33
34
  const pendingRedirectRef = useRef<string>(ROUTES.DASHBOARD);
34
35
 
35
36
  const loginMutation = useMutation({
36
37
  mutationFn: (data: ILoginRequest) => authService.login(data),
37
38
  onSuccess: (data) => {
39
+ queryClient.clear();
38
40
  dispatch(setUser(data.user));
39
41
  toast.success('Signed in successfully');
40
42
  router.push(pendingRedirectRef.current);
@@ -56,6 +58,7 @@ export const useAuth = (): UseAuthReturn => {
56
58
  router.push(ROUTES.VERIFY_ACCOUNT);
57
59
  return;
58
60
  }
61
+ queryClient.clear();
59
62
  dispatch(setUser(data.user));
60
63
  toast.success('Account created successfully');
61
64
  router.push(ROUTES.DASHBOARD);
@@ -72,10 +75,11 @@ export const useAuth = (): UseAuthReturn => {
72
75
  } catch {
73
76
  // Proceed with local logout even if server call fails
74
77
  } finally {
78
+ queryClient.clear();
75
79
  dispatch(logoutAction());
76
80
  router.push(ROUTES.LOGIN);
77
81
  }
78
- }, [dispatch, router]);
82
+ }, [dispatch, router, queryClient]);
79
83
 
80
84
  return {
81
85
  user,
@@ -0,0 +1,50 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+
5
+ import { useQuery } from '@tanstack/react-query';
6
+
7
+ import { useAppDispatch } from '@/store/hooks';
8
+ import { authService } from '../services/auth.service';
9
+ import { setUser, setInitialized } from '../store/authSlice';
10
+
11
+ import type { IUser } from '../types/auth.types';
12
+
13
+ export const authKeys = {
14
+ all: ['auth'] as const,
15
+ me: () => [...authKeys.all, 'me'] as const,
16
+ };
17
+
18
+ interface UseCurrentUserOptions {
19
+ enabled?: boolean;
20
+ }
21
+
22
+ interface UseCurrentUserReturn {
23
+ user: IUser | undefined;
24
+ isLoading: boolean;
25
+ error: unknown;
26
+ }
27
+
28
+ export const useCurrentUser = (
29
+ { enabled = true }: UseCurrentUserOptions = {}
30
+ ): UseCurrentUserReturn => {
31
+ const dispatch = useAppDispatch();
32
+
33
+ const { data, isLoading, error } = useQuery({
34
+ queryKey: authKeys.me(),
35
+ queryFn: () => authService.getMe(),
36
+ enabled,
37
+ staleTime: 30 * 1000,
38
+ retry: false,
39
+ });
40
+
41
+ useEffect(() => {
42
+ if (data) dispatch(setUser(data));
43
+ }, [data, dispatch]);
44
+
45
+ useEffect(() => {
46
+ if (!enabled || error) dispatch(setInitialized());
47
+ }, [enabled, error, dispatch]);
48
+
49
+ return { user: data, isLoading, error };
50
+ };
@@ -30,7 +30,6 @@
30
30
  "seed": "tsx prisma/seed.ts"
31
31
  },
32
32
  "dependencies": {
33
- "@fastify/compress": "^8.3.1",
34
33
  "@fastify/cookie": "^11.0.2",
35
34
  "@fastify/cors": "^11.2.0",
36
35
  "@fastify/helmet": "^13.0.2",
@@ -4,7 +4,6 @@ import helmet from '@fastify/helmet';
4
4
  import rateLimit from '@fastify/rate-limit';
5
5
  import cookie from '@fastify/cookie';
6
6
  import jwt from '@fastify/jwt';
7
- import compress from '@fastify/compress';
8
7
  import multipart from '@fastify/multipart';
9
8
  import fastifyStatic from '@fastify/static';
10
9
  import path from 'path';
@@ -67,14 +66,9 @@ export async function buildApp() {
67
66
  // Enhanced security headers for production
68
67
  await app.register(helmet, {
69
68
  global: true,
69
+ crossOriginResourcePolicy: { policy: 'cross-origin' },
70
70
  });
71
71
 
72
- // Response compression (gzip/brotli) for performance
73
- await app.register(compress, {
74
- global: true,
75
- threshold: 1024, // Only compress responses > 1KB
76
- encodings: ['gzip', 'deflate'],
77
- });
78
72
 
79
73
  // Rate limiting: Redis-backed when available, in-memory fallback
80
74
  if (RATE_LIMIT_ENABLED) {
@@ -27,6 +27,7 @@ const MULTIPLIER = env.RATE_LIMIT_MULTIPLIER;
27
27
  * Ensures minimum of 1 if rate limiting is enabled.
28
28
  */
29
29
  function applyMultiplier(max: number): number {
30
+ if (!RATE_LIMIT_ENABLED) return 1_000_000;
30
31
  return Math.max(1, Math.round(max * MULTIPLIER));
31
32
  }
32
33
 
@@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
3
3
  import { env } from '@config/env.js';
4
4
  import { prisma } from '@libs/prisma.js';
5
5
  import { UnauthorizedError, ForbiddenError, BadRequestError } from '@shared/errors/errors.js';
6
+ import { clearAuthCookies } from '@libs/cookies.js';
6
7
  import type { JwtPayload, UserRole } from '@shared/types/index.js';
7
8
 
8
9
  let app: FastifyInstance | null = null;
@@ -52,7 +53,7 @@ export function getRefreshTokenExpiresAt(): Date {
52
53
 
53
54
  export async function authenticate(
54
55
  request: FastifyRequest,
55
- _reply: FastifyReply,
56
+ reply: FastifyReply,
56
57
  ): Promise<void> {
57
58
  try {
58
59
  await request.jwtVerify();
@@ -60,16 +61,22 @@ export async function authenticate(
60
61
  throw new UnauthorizedError('Invalid or expired token');
61
62
  }
62
63
 
63
- // Verify user still exists, is active, and not soft-deleted
64
+ // Verify user still exists, is active, and not soft-deleted.
65
+ // When the session is definitively dead (user gone/deleted/inactive), clear auth
66
+ // cookies on the response so the browser stops replaying stale credentials.
67
+ // Without this, middleware keeps seeing the (still-unexpired) JWT cookie and
68
+ // bounces /login → /dashboard → 401 → /login in an infinite loop.
64
69
  const user = await prisma.user.findUnique({
65
70
  where: { id: request.user.userId },
66
71
  select: { isActive: true, deletedAt: true },
67
72
  });
68
73
 
69
74
  if (!user || user.deletedAt) {
75
+ clearAuthCookies(reply);
70
76
  throw new UnauthorizedError('Account is deactivated or deleted');
71
77
  }
72
78
  if (!user.isActive) {
79
+ clearAuthCookies(reply);
73
80
  throw new ForbiddenError('Account is not activated. Please verify your account.', 'ACCOUNT_NOT_ACTIVE');
74
81
  }
75
82
  }
@@ -1,6 +1,6 @@
1
1
  import type { FastifyRequest, FastifyReply } from 'fastify';
2
2
  import { successResponse } from '@shared/responses/successResponse.js';
3
- import { UnauthorizedError } from '@shared/errors/errors.js';
3
+ import { UnauthorizedError, ForbiddenError } from '@shared/errors/errors.js';
4
4
  import { setAuthCookies, clearAuthCookies } from '@libs/cookies.js';
5
5
  import type {
6
6
  RegisterInput,
@@ -49,13 +49,24 @@ export async function refresh(
49
49
  ): Promise<void> {
50
50
  const refreshToken = request.cookies.refresh_token;
51
51
  if (!refreshToken) {
52
+ clearAuthCookies(reply);
52
53
  throw new UnauthorizedError('Refresh token not provided', 'MISSING_REFRESH_TOKEN');
53
54
  }
54
55
 
55
- const tokens = await authService.refresh(refreshToken);
56
-
57
- setAuthCookies(reply, tokens.accessToken, tokens.refreshToken);
58
- reply.send(successResponse('Token refreshed successfully', null));
56
+ try {
57
+ const tokens = await authService.refresh(refreshToken);
58
+ setAuthCookies(reply, tokens.accessToken, tokens.refreshToken);
59
+ reply.send(successResponse('Token refreshed successfully', null));
60
+ } catch (error) {
61
+ // Session is definitively dead (refresh token revoked, user gone, inactive).
62
+ // Clear all auth cookies so the browser stops replaying them — otherwise the
63
+ // client keeps retrying refresh and middleware keeps redirecting, producing
64
+ // an infinite loop that trips the rate limiter.
65
+ if (error instanceof UnauthorizedError || error instanceof ForbiddenError) {
66
+ clearAuthCookies(reply);
67
+ }
68
+ throw error;
69
+ }
59
70
  }
60
71
 
61
72
  export async function logout(