Merge pull request #702 from buster-so/staging

Staging
This commit is contained in:
dal 2025-08-11 16:49:27 -06:00 committed by GitHub
commit 4e7e8f0f0e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 164 additions and 45 deletions

View File

@ -10,6 +10,15 @@ const apiUrl = new URL(env.NEXT_PUBLIC_API_URL).origin;
const api2Url = new URL(env.NEXT_PUBLIC_API2_URL).origin; const api2Url = new URL(env.NEXT_PUBLIC_API2_URL).origin;
const profilePictureURL = 'https://googleusercontent.com'; const profilePictureURL = 'https://googleusercontent.com';
// Derive Supabase origins (HTTP and WS) from env so CSP allows them in all modes
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseOrigin = supabaseUrl ? new URL(supabaseUrl).origin : '';
const supabaseWsOrigin = supabaseUrl
? supabaseUrl.startsWith('https')
? supabaseOrigin.replace('https', 'wss')
: supabaseOrigin.replace('http', 'ws')
: '';
// Function to create CSP header with dynamic API URLs // Function to create CSP header with dynamic API URLs
const createCspHeader = (isEmbed = false) => { const createCspHeader = (isEmbed = false) => {
const isDev = process.env.NODE_ENV === 'development'; const isDev = process.env.NODE_ENV === 'development';
@ -42,6 +51,8 @@ const createCspHeader = (isEmbed = false) => {
"'self'", "'self'",
'data:', // Allow data URLs for PDF exports and other data URI downloads 'data:', // Allow data URLs for PDF exports and other data URI downloads
localDomains, localDomains,
supabaseOrigin,
supabaseWsOrigin,
'https://*.vercel.app', 'https://*.vercel.app',
'https://*.supabase.co', 'https://*.supabase.co',
'wss://*.supabase.co', 'wss://*.supabase.co',

View File

@ -1,7 +1,6 @@
'use server'; 'use server';
import type { RequestInit } from 'next/dist/server/web/spec-extension/request'; import type { RequestInit } from 'next/dist/server/web/spec-extension/request';
import { cookies } from 'next/headers';
import { createSupabaseServerClient } from '@/lib/supabase/server'; import { createSupabaseServerClient } from '@/lib/supabase/server';
import { BASE_URL } from './buster_rest/config'; import { BASE_URL } from './buster_rest/config';
import type { RustApiError } from './buster_rest/errors'; import type { RustApiError } from './buster_rest/errors';
@ -64,8 +63,7 @@ export const serverFetch = async <T>(url: string, config: FetchConfig = {}): Pro
}; };
export const getSupabaseTokenFromCookies = async () => { export const getSupabaseTokenFromCookies = async () => {
const cookiesManager = await cookies(); const supabase = await createSupabaseServerClient();
const tokenCookie = const sessionData = await supabase.auth.getSession();
cookiesManager.get('sb-127-auth-token') || cookiesManager.get('next-sb-access-token'); return sessionData.data?.session?.access_token || '';
return tokenCookie?.value || '';
}; };

View File

@ -20,54 +20,114 @@ const useSupabaseContextInternal = ({
supabaseContext: UseSupabaseUserContextType; supabaseContext: UseSupabaseUserContextType;
}) => { }) => {
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const { openErrorNotification, openInfoMessage } = useBusterNotifications(); const { openErrorNotification } = useBusterNotifications();
const [accessToken, setAccessToken] = useState(supabaseContext.accessToken || ''); const [accessToken, setAccessToken] = useState(supabaseContext.accessToken || '');
const refreshInFlightRef = useRef<Promise<string> | null>(null);
const isAnonymousUser: boolean = const isAnonymousUser: boolean =
!supabaseContext.user?.id || supabaseContext.user?.is_anonymous === true; !supabaseContext.user?.id || supabaseContext.user?.is_anonymous === true;
const getExpiresAt = useMemoizedFn((token?: string) => { const getExpiresAt = useMemoizedFn((token?: string) => {
try {
const decoded = jwtDecode(token || accessToken); const decoded = jwtDecode(token || accessToken);
const expiresAtDecoded = decoded?.exp || 0; const expiresAtDecoded = (decoded as { exp?: number } | undefined)?.exp ?? 0;
const ms = millisecondsFromUnixTimestamp(expiresAtDecoded); const expiresAtMs = millisecondsFromUnixTimestamp(expiresAtDecoded);
return ms; const msUntilExpiry = Math.max(0, expiresAtMs - Date.now());
return msUntilExpiry;
} catch {
// If token is missing/invalid, report that it is effectively expired now
return 0;
}
}); });
const onUpdateToken = useMemoizedFn(
async ({ accessToken, expiresAt: _expiresAt }: { accessToken: string; expiresAt: number }) => {
setAccessToken(accessToken);
flushSync(() => {
//noop
});
}
);
const checkTokenValidity = useMemoizedFn(async () => { const checkTokenValidity = useMemoizedFn(async () => {
try { try {
const ms = getExpiresAt(); // Anonymous users do not require refresh; return current token as-is
const minutesUntilExpiration = ms / 60000;
const isTokenExpired = minutesUntilExpiration < PREEMTIVE_REFRESH_MINUTES; //5 minutes
if (isAnonymousUser) { if (isAnonymousUser) {
return { return {
access_token: accessToken, access_token: accessToken,
isTokenValid: isTokenExpired isTokenValid: true
}; };
} }
if (isTokenExpired) { // If we don't have a token in memory, try to recover it from Supabase session
if (!accessToken) {
const { data: sessionData } = await supabase.auth.getSession();
const recoveredToken = sessionData.session?.access_token || '';
if (recoveredToken) {
await onUpdateToken({
accessToken: recoveredToken,
expiresAt: sessionData.session?.expires_at ?? 0
});
return {
access_token: recoveredToken,
isTokenValid: true
};
}
}
const msUntilExpiration = getExpiresAt();
const minutesUntilExpiration = msUntilExpiration / 60000;
const needsPreemptiveRefresh = minutesUntilExpiration < PREEMTIVE_REFRESH_MINUTES;
if (needsPreemptiveRefresh) {
try {
// Ensure only one refresh is in-flight
let refreshPromise = refreshInFlightRef.current;
if (!refreshPromise) {
refreshPromise = (async () => {
const { data: refreshedSession, error: refreshedSessionError } = const { data: refreshedSession, error: refreshedSessionError } =
await supabase.auth.refreshSession(); await supabase.auth.refreshSession();
if (refreshedSessionError || !refreshedSession.session) { if (refreshedSessionError || !refreshedSession.session) {
throw refreshedSessionError || new Error('Failed to refresh session');
}
const refreshedAccessToken = refreshedSession.session.access_token;
const expiresAt = refreshedSession.session.expires_at ?? 0;
await onUpdateToken({ accessToken: refreshedAccessToken, expiresAt });
return refreshedAccessToken;
})();
refreshInFlightRef.current = refreshPromise;
// Clear the ref when the refresh resolves/rejects
void refreshPromise.finally(() => {
refreshInFlightRef.current = null;
});
}
const refreshedAccessToken = await refreshPromise;
await timeout(25);
return {
access_token: refreshedAccessToken,
isTokenValid: true
};
} catch (err) {
openErrorNotification({ openErrorNotification({
title: 'Error refreshing session', title: 'Error refreshing session',
description: 'Please refresh the page and try again', description: 'Please refresh the page and try again',
duration: 120 * 1000 //2 minutes duration: 120 * 1000 //2 minutes
}); });
throw refreshedSessionError; // As a fallback, try to read whatever session is available
const { data: sessionData } = await supabase.auth.getSession();
const fallbackToken = sessionData.session?.access_token || '';
if (fallbackToken) {
await onUpdateToken({
accessToken: fallbackToken,
expiresAt: sessionData.session?.expires_at ?? 0
});
return { access_token: fallbackToken, isTokenValid: true };
}
throw err;
} }
const accessToken = refreshedSession.session?.access_token;
const expiresAt = refreshedSession.session?.expires_at ?? 0;
await onUpdateToken({ accessToken, expiresAt });
await timeout(25);
return {
access_token: accessToken,
isTokenValid: true
};
} }
return { return {
@ -85,15 +145,6 @@ const useSupabaseContextInternal = ({
} }
}); });
const onUpdateToken = useMemoizedFn(
async ({ accessToken, expiresAt: _expiresAt }: { accessToken: string; expiresAt: number }) => {
setAccessToken(accessToken);
flushSync(() => {
//noop
});
}
);
useLayoutEffect(() => { useLayoutEffect(() => {
if (supabaseContext.accessToken) { if (supabaseContext.accessToken) {
setAccessToken(supabaseContext.accessToken); setAccessToken(supabaseContext.accessToken);
@ -106,6 +157,10 @@ const useSupabaseContextInternal = ({
const refreshBuffer = PREEMTIVE_REFRESH_MINUTES * 60000; // Refresh minutes before expiration const refreshBuffer = PREEMTIVE_REFRESH_MINUTES * 60000; // Refresh minutes before expiration
const timeUntilRefresh = Math.max(0, expiresInMs - refreshBuffer); const timeUntilRefresh = Math.max(0, expiresInMs - refreshBuffer);
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
}
// Set up timer for future refresh // Set up timer for future refresh
refreshTimerRef.current = setTimeout(() => { refreshTimerRef.current = setTimeout(() => {
checkTokenValidity(); checkTokenValidity();
@ -120,7 +175,45 @@ const useSupabaseContextInternal = ({
clearTimeout(refreshTimerRef.current); clearTimeout(refreshTimerRef.current);
} }
}; };
}, [accessToken, checkTokenValidity]); }, [accessToken, checkTokenValidity, getExpiresAt]);
useEffect(() => {
// Keep access token in sync with Supabase client (captures auto-refresh events)
const { data: subscription } = supabase.auth.onAuthStateChange((event, session) => {
const newToken = session?.access_token;
if (event === 'SIGNED_OUT' || !newToken) {
setAccessToken('');
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
}
return;
}
void onUpdateToken({ accessToken: newToken, expiresAt: session?.expires_at ?? 0 });
});
return () => {
subscription.subscription.unsubscribe();
};
}, [onUpdateToken]);
useEffect(() => {
const onVisibility = () => {
if (document.visibilityState === 'visible') {
void checkTokenValidity();
}
};
const onOnline = () => {
void checkTokenValidity();
};
window.addEventListener('visibilitychange', onVisibility);
window.addEventListener('online', onOnline);
return () => {
window.removeEventListener('visibilitychange', onVisibility);
window.removeEventListener('online', onOnline);
};
}, [checkTokenValidity]);
return { return {
isAnonymousUser, isAnonymousUser,

View File

@ -61,14 +61,14 @@ vi.mock('remark-gfm', () => ({
})); }));
// Mock Supabase client to prevent environment variable errors in tests // Mock Supabase client to prevent environment variable errors in tests
vi.mock('@/lib/supabase/client', () => ({ vi.mock('@/lib/supabase/client', () => {
createBrowserClient: vi.fn(() => ({ const mockClient = {
auth: { auth: {
refreshSession: vi.fn().mockResolvedValue({ refreshSession: vi.fn().mockResolvedValue({
data: { data: {
session: { session: {
access_token: 'mock-token', access_token: 'mock-token',
expires_at: Date.now() / 1000 + 3600 // 1 hour from now expires_at: Math.floor(Date.now() / 1000) + 3600 // 1 hour from now
} }
}, },
error: null error: null
@ -76,7 +76,24 @@ vi.mock('@/lib/supabase/client', () => ({
getUser: vi.fn().mockResolvedValue({ getUser: vi.fn().mockResolvedValue({
data: { user: null }, data: { user: null },
error: null error: null
}),
getSession: vi.fn().mockResolvedValue({
data: {
session: {
access_token: 'mock-token',
expires_at: Math.floor(Date.now() / 1000) + 3600
}
},
error: null
}),
onAuthStateChange: vi.fn().mockReturnValue({
data: { subscription: { unsubscribe: vi.fn() } }
}) })
} }
})) };
}));
return {
createBrowserClient: vi.fn(() => mockClient),
getBrowserClient: vi.fn(() => mockClient)
};
});