diff --git a/src/lib/ai/client.ts b/src/lib/ai/client.ts index 8434749f6..aff8843dd 100644 --- a/src/lib/ai/client.ts +++ b/src/lib/ai/client.ts @@ -20,7 +20,7 @@ import { type SourceProvenance, type SourceSemanticMetadata, } from '../stores/session-store'; -import { supabase } from '@/lib/supabase/client'; +import { getActiveAccessToken, getActiveUserId, invokeAuthenticatedFunction, supabase } from '@/lib/supabase/client'; import { toast } from '@/hooks/use-toast'; import { captureException, captureProductEvent } from '@/lib/observability'; import { validateUrl } from '@/lib/validation/url'; @@ -469,8 +469,9 @@ async function callSupabaseAI( else options.signal?.addEventListener('abort', abortFromCaller, { once: true }); try { - const { data: sessionData } = await waitForAbortable(() => supabase.auth.getSession(), controller.signal); - const accessToken = sessionData.session?.access_token; + const accessToken = getActiveAccessToken() ?? ( + await waitForAbortable(() => supabase.auth.getSession(), controller.signal) + ).data.session?.access_token; if (DEBUG) { console.log(`[AI Request ${requestId}]`, { @@ -498,7 +499,7 @@ async function callSupabaseAI( workspaceContext: options.workspaceContext, }; - const { data, error } = await supabase.functions.invoke('ai', { + const { data, error } = await invokeAuthenticatedFunction>('ai', { headers: { Authorization: `Bearer ${accessToken}` }, body: requestBody, signal: controller.signal, @@ -1360,13 +1361,12 @@ function validateSparringScoreResponse(output: unknown): SparringScoreResponse { const pendingSparringTurns = new Map>(); const pendingSparringScores = new Map>(); -async function sparringRequestKey( +function sparringRequestKey( input: SparringRespondInput | SparringScoreInput, config: ProviderConfig, workspaceContext?: WorkspaceToolContext, -): Promise { - const { data } = await supabase.auth.getSession(); - const authScope = data.session?.user?.id ?? data.session?.access_token ?? 'signed-out'; +): string { + const authScope = getActiveUserId() ?? getActiveAccessToken() ?? 'signed-out'; return JSON.stringify([authScope, workspaceContext?.workspaceId ?? null, input, config.provider, config.model, config.temperature, config.reasoningEffort]); } @@ -1386,7 +1386,7 @@ export async function generateSparringTurn( details: { issues: inputResult.success ? [{ message: 'Expected respond action.' }] : inputResult.error.issues }, }); } - const requestKey = await sparringRequestKey(inputResult.data, config, workspaceContext); + const requestKey = sparringRequestKey(inputResult.data, config, workspaceContext); const pending = pendingSparringTurns.get(requestKey); if (pending) return pending; @@ -1437,7 +1437,7 @@ export async function judgeSparringSession( details: { issues: inputResult.success ? [{ message: 'Expected score action.' }] : inputResult.error.issues }, }); } - const requestKey = await sparringRequestKey(inputResult.data, config, workspaceContext); + const requestKey = sparringRequestKey(inputResult.data, config, workspaceContext); const pending = pendingSparringScores.get(requestKey); if (pending) return pending; diff --git a/src/lib/ai/streaming.ts b/src/lib/ai/streaming.ts index 2e3580b88..02188868b 100644 --- a/src/lib/ai/streaming.ts +++ b/src/lib/ai/streaming.ts @@ -1,4 +1,4 @@ -import { supabase, SUPABASE_URL } from '@/lib/supabase/client'; +import { getActiveAccessToken, supabase, SUPABASE_URL } from '@/lib/supabase/client'; import { captureProductEvent } from '@/lib/observability'; import { AIAuthError, AIResponseError, REQUEST_TIMEOUT_MS, TimeoutError, mapAIError, reportAIFailure, withOutputLanguage } from './client'; import type { ProviderConfig } from './providers/types'; @@ -130,14 +130,16 @@ export async function streamAskChat( throw error; } - let sessionData: Awaited>['data']; + let accessToken = getActiveAccessToken(); try { - ({ data: sessionData } = await waitForCaller(supabase.auth.getSession(), handlers.signal)); + if (!accessToken) { + const { data } = await waitForCaller(supabase.auth.getSession(), handlers.signal); + accessToken = data.session?.access_token ?? null; + } } catch (error) { captureStreamFailure(error, provider, config.model, startTime, requestId, Boolean(handlers.signal?.aborted)); throw error; } - const accessToken = sessionData.session?.access_token; if (!accessToken) { const error = new AIAuthError('Please sign in to use AI features', { requestId }); captureStreamFailure(error, provider, config.model, startTime, requestId, Boolean(handlers.signal?.aborted)); diff --git a/src/lib/supabase/AuthProvider.tsx b/src/lib/supabase/AuthProvider.tsx index b64d9d223..fecd0e6f0 100644 --- a/src/lib/supabase/AuthProvider.tsx +++ b/src/lib/supabase/AuthProvider.tsx @@ -1,7 +1,7 @@ /* eslint-disable react-refresh/only-export-components */ import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; import type { Session, User } from '@supabase/supabase-js'; -import { supabase } from './client'; +import { setActiveAccessToken, supabase } from './client'; import { useSessionStore } from '@/lib/stores/session-store'; import { useSettingsStore } from '@/lib/stores/settings-store'; import { useSparringStore } from '@/lib/stores/sparring-store'; @@ -163,6 +163,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (!mounted) return; if (oauthSession) { + setActiveAccessToken(oauthSession.access_token, oauthSession.user.id); setSession(oauthSession); currentUserIdRef.current = oauthSession.user?.id; setJamUserId(oauthSession.user?.id); @@ -183,6 +184,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (!mounted) return; const { data, error } = getSessionResult; if (error) console.warn('[Auth] getSession error:', error.message); + setActiveAccessToken(data.session?.access_token ?? null, data.session?.user.id ?? null); setSession(data.session ?? null); currentUserIdRef.current = data.session?.user?.id; setJamUserId(data.session?.user?.id); @@ -192,6 +194,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } catch (err) { console.warn('[Auth] init failed:', err); if (!mounted) return; + setActiveAccessToken(null); setSession(null); currentUserIdRef.current = undefined; setJamUserId(undefined); @@ -210,6 +213,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { clearCrossfireDraftOnAuthChange(prevUserId, nextUserId); currentUserIdRef.current = nextUserId; setJamUserId(nextUserId); + setActiveAccessToken(newSession?.access_token ?? null, newSession?.user.id ?? null); setSession(newSession); setLoading(false); diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts index ee2748120..18f852df4 100644 --- a/src/lib/supabase/client.ts +++ b/src/lib/supabase/client.ts @@ -1,4 +1,9 @@ -import { createClient } from '@supabase/supabase-js'; +import { + createClient, + FunctionsFetchError, + FunctionsHttpError, + FunctionsRelayError, +} from '@supabase/supabase-js'; import { getActiveEnv, SUPABASE_URL, SUPABASE_ANON_KEY } from './env'; export { SUPABASE_URL }; @@ -13,3 +18,53 @@ export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { storageKey: `debate-auth-${getActiveEnv()}`, }, }); + +let activeAccessToken: string | null = null; +let activeUserId: string | null = null; + +export function setActiveAccessToken(accessToken: string | null, userId: string | null = null): void { + activeAccessToken = accessToken; + activeUserId = userId; +} + +export function getActiveAccessToken(): string | null { + return activeAccessToken; +} + +export function getActiveUserId(): string | null { + return activeUserId; +} + +type AuthenticatedFunctionResult = + | { data: T; error: null } + | { data: null; error: FunctionsFetchError | FunctionsHttpError | FunctionsRelayError }; + +export async function invokeAuthenticatedFunction( + functionName: string, + options: { body: Record; headers: Record; signal?: AbortSignal }, +): Promise> { + let response: Response; + try { + response = await fetch(`${SUPABASE_URL}/functions/v1/${functionName}`, { + method: 'POST', + headers: { + apikey: SUPABASE_ANON_KEY, + 'Content-Type': 'application/json', + ...options.headers, + }, + body: JSON.stringify(options.body), + signal: options.signal, + }); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') throw error; + return { data: null, error: new FunctionsFetchError(error) }; + } + + if (response.headers.get('x-relay-error') === 'true') { + return { data: null, error: new FunctionsRelayError(response) }; + } + if (!response.ok) return { data: null, error: new FunctionsHttpError(response) }; + + const data: T = await response.json(); + return { data, error: null }; +} diff --git a/src/test/ai/client-analytics.test.ts b/src/test/ai/client-analytics.test.ts index 9a3c499a6..7bfc58072 100644 --- a/src/test/ai/client-analytics.test.ts +++ b/src/test/ai/client-analytics.test.ts @@ -20,6 +20,8 @@ vi.mock('@/lib/observability', () => ({ })); vi.mock('@/lib/supabase/client', () => ({ + getActiveAccessToken: (): string | null => null, + invokeAuthenticatedFunction: mocks.invoke, supabase: { auth: { getSession: mocks.getSession }, functions: { invoke: mocks.invoke }, diff --git a/src/test/ai/client-branches.test.ts b/src/test/ai/client-branches.test.ts index 38bf7e8ce..ee2c9d3ad 100644 --- a/src/test/ai/client-branches.test.ts +++ b/src/test/ai/client-branches.test.ts @@ -30,6 +30,8 @@ const { }); vi.mock('@/lib/supabase/client', () => ({ + getActiveAccessToken: (): string | null => null, + invokeAuthenticatedFunction: invokeMock, supabase: { auth: { getSession: getSessionMock, diff --git a/src/test/ai/client.test.ts b/src/test/ai/client.test.ts index 4fc3d9e65..58d7db267 100644 --- a/src/test/ai/client.test.ts +++ b/src/test/ai/client.test.ts @@ -32,6 +32,8 @@ const clientMocks = vi.hoisted(() => ({ // actually it's better to use doMock or inline return. vi.mock('@/lib/supabase/client', () => ({ + getActiveAccessToken: (): string | null => null, + invokeAuthenticatedFunction: supabaseMock.functions.invoke, supabase: supabaseMock })); diff --git a/src/test/ai/control-payload-matrix.test.ts b/src/test/ai/control-payload-matrix.test.ts index e7e78e893..2544bfa25 100644 --- a/src/test/ai/control-payload-matrix.test.ts +++ b/src/test/ai/control-payload-matrix.test.ts @@ -6,7 +6,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { supabaseMock, resetMockState, setMockSessionToken } from '../mocks/supabase'; -vi.mock('@/lib/supabase/client', () => ({ supabase: supabaseMock })); +vi.mock('@/lib/supabase/client', () => ({ + getActiveAccessToken: (): string | null => null, + invokeAuthenticatedFunction: supabaseMock.functions.invoke, + supabase: supabaseMock, +})); import { generateCase, attackCase, brainstorm, factoids, askChat } from '@/lib/ai/client'; import type { ProviderConfig } from '@/lib/ai/providers/types'; diff --git a/src/test/ai/crossfire-client.test.ts b/src/test/ai/crossfire-client.test.ts index 9edd0e3e0..4444d212b 100644 --- a/src/test/ai/crossfire-client.test.ts +++ b/src/test/ai/crossfire-client.test.ts @@ -18,6 +18,8 @@ const mocks = vi.hoisted(() => ({ })); vi.mock('@/lib/supabase/client', () => ({ + getActiveAccessToken: (): string | null => null, + invokeAuthenticatedFunction: mocks.invoke, supabase: { auth: { getSession: mocks.getSession }, functions: { invoke: mocks.invoke }, diff --git a/src/test/ai/request-dispatch.test.ts b/src/test/ai/request-dispatch.test.ts new file mode 100644 index 000000000..f06363834 --- /dev/null +++ b/src/test/ai/request-dispatch.test.ts @@ -0,0 +1,109 @@ +import { createElement } from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; + +const sessionState = { + sourceLibrary: [] as unknown[], + hydrateFromServer: vi.fn(), + resetLocalData: vi.fn(), +}; + +const settingsState = { + customSystemPrompt: '', + outputLanguage: 'en', + hydrateFromServer: vi.fn(), + resetLocalData: vi.fn(), +}; + +vi.mock('@/lib/stores/settings-store', () => ({ + useSettingsStore: Object.assign( + (selector: (state: typeof settingsState) => unknown) => selector(settingsState), + { getState: () => settingsState }, + ), +})); + +vi.mock('@/lib/stores/session-store', () => ({ + useSessionStore: Object.assign( + (selector: (state: typeof sessionState) => unknown) => selector(sessionState), + { getState: () => sessionState }, + ), +})); + +vi.mock('@/lib/stores/sparring-store', () => ({ + useSparringStore: (selector: (state: Record) => unknown) => selector({ + hydrateSavedSessions: vi.fn(), + prepareForUser: vi.fn(), + resetLocalData: vi.fn(), + }), +})); + +vi.mock('@/lib/observability', () => ({ + captureException: vi.fn(), + captureProductEvent: vi.fn(), +})); + +import { askChat } from '@/lib/ai/client'; +import { AuthProvider, useAuth } from '@/lib/supabase/AuthProvider'; +import { setActiveAccessToken, supabase } from '@/lib/supabase/client'; + +afterEach(() => { + setActiveAccessToken(null); + vi.restoreAllMocks(); +}); + +function AuthState() { + const { loading, user } = useAuth(); + return createElement('span', { 'data-testid': 'auth-state' }, loading ? 'loading' : user?.id ?? 'signed-out'); +} + +it('calls the AI fetch boundary without another session lookup', async () => { + const getSession = vi.spyOn(supabase.auth, 'getSession') + .mockResolvedValue({ + data: { session: { access_token: 'test-access-token', user: { id: 'user-1' } } }, + error: null, + } as Awaited>); + vi.spyOn(supabase.auth, 'onAuthStateChange').mockReturnValue({ + data: { subscription: { id: 'test', callback: vi.fn(), unsubscribe: vi.fn() } }, + } as ReturnType); + const fetchRequest = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ + output: { + response: 'Mock AI response.', + followUpQuestions: [], + uncertainties: [], + }, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + + render(createElement(AuthProvider, null, createElement(AuthState))); + await waitFor(() => expect(screen.getByTestId('auth-state')).toHaveTextContent('user-1')); + const authInitializationLookups = getSession.mock.calls.length; + + const result = await askChat('Hello', [], 'general', { + provider: 'openrouter', + model: 'openai/gpt-5.6-luna', + temperature: 0.7, + reasoningEffort: 'low', + }); + + expect(fetchRequest).toHaveBeenCalledOnce(); + expect(result).toMatchObject({ response: 'Mock AI response.' }); + expect(getSession).toHaveBeenCalledTimes(authInitializationLookups); +}); + +it('maps a rejected fetch through the Edge Function error contract', async () => { + setActiveAccessToken('test-access-token'); + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('Failed to fetch')); + + await expect(askChat('Hello', [], 'general', { + provider: 'openrouter', + model: 'openai/gpt-5.6-luna', + temperature: 0.7, + reasoningEffort: 'low', + })).rejects.toMatchObject({ + name: 'AIError', + message: 'AI service error: Failed to send a request to the Edge Function. Please try again.', + statusCode: 500, + }); +}); diff --git a/src/test/ai/sparring.test.ts b/src/test/ai/sparring.test.ts index 32d3c4413..044e9818c 100644 --- a/src/test/ai/sparring.test.ts +++ b/src/test/ai/sparring.test.ts @@ -8,7 +8,12 @@ import { import type { SparringRespondInput, SparringScoreInput } from '@/lib/ai/schemas/sparring'; import type { ProviderConfig } from '@/lib/ai/providers/types'; +const authState = vi.hoisted(() => ({ token: 'cached-token', userId: 'user-1' })); + vi.mock('@/lib/supabase/client', () => ({ + getActiveAccessToken: (): string | null => authState.token, + getActiveUserId: (): string | null => authState.userId, + invokeAuthenticatedFunction: supabaseMock.functions.invoke, supabase: supabaseMock, })); @@ -86,6 +91,8 @@ const SCORE_RESPONSE = { describe('sparring AI client', () => { beforeEach(() => { resetMockState(); + authState.token = 'cached-token'; + authState.userId = 'user-1'; }); it('sends a validated opponent-turn request with its full session context', async () => { @@ -112,19 +119,32 @@ describe('sparring AI client', () => { it('shares identical in-flight turn requests across remounts', async () => { let resolveInvoke!: (value: { data: { output: typeof TURN_RESPONSE }; error: null }) => void; - supabaseMock.functions.invoke.mockImplementationOnce(() => new Promise((resolve) => { + const invokeResponse = new Promise<{ data: { output: typeof TURN_RESPONSE }; error: null }>((resolve) => { resolveInvoke = resolve; - })); + }); + supabaseMock.functions.invoke.mockImplementation(() => invokeResponse); const first = generateSparringTurn(RESPOND_INPUT, CONFIG); - const second = generateSparringTurn(RESPOND_INPUT, CONFIG); await vi.waitFor(() => expect(supabaseMock.functions.invoke).toHaveBeenCalledTimes(1)); + authState.token = 'refreshed-token'; + const second = generateSparringTurn(RESPOND_INPUT, CONFIG); + await Promise.resolve(); resolveInvoke({ data: { output: TURN_RESPONSE }, error: null }); await expect(Promise.all([first, second])).resolves.toEqual([TURN_RESPONSE, TURN_RESPONSE]); expect(supabaseMock.functions.invoke).toHaveBeenCalledTimes(1); }); + it('sends turns and scores without reopening the Supabase session', async () => { + setMockInvokeResponse({ output: TURN_RESPONSE }); + supabaseMock.auth.getSession.mockRejectedValue(new Error('session lookup must not run')); + + await expect(generateSparringTurn(RESPOND_INPUT, CONFIG)).resolves.toEqual(TURN_RESPONSE); + setMockInvokeResponse({ output: SCORE_RESPONSE }); + await expect(judgeSparringSession(SCORE_INPUT, CONFIG)).resolves.toEqual(SCORE_RESPONSE); + expect(supabaseMock.auth.getSession).not.toHaveBeenCalled(); + }); + it('sends a score request and validates the scorecard envelope', async () => { setMockInvokeResponse({ output: SCORE_RESPONSE }); diff --git a/src/test/ai/streaming-integration.test.ts b/src/test/ai/streaming-integration.test.ts index 5c3cf0353..b05335619 100644 --- a/src/test/ai/streaming-integration.test.ts +++ b/src/test/ai/streaming-integration.test.ts @@ -2,6 +2,7 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { supabaseMock, resetMockState, setMockSessionToken } from '../mocks/supabase'; vi.mock('@/lib/supabase/client', () => ({ + getActiveAccessToken: (): string | null => null, supabase: supabaseMock, SUPABASE_URL: 'http://test-supabase.local', })); diff --git a/src/test/ai/streaming.test.ts b/src/test/ai/streaming.test.ts index 817fff09e..4a50e3053 100644 --- a/src/test/ai/streaming.test.ts +++ b/src/test/ai/streaming.test.ts @@ -12,6 +12,7 @@ vi.mock('@/lib/observability', () => ({ })); vi.mock('@/lib/supabase/client', () => ({ + getActiveAccessToken: (): string | null => null, supabase: supabaseMock, SUPABASE_URL: 'http://test-supabase.local', })); diff --git a/src/test/auth/AuthFlow.test.tsx b/src/test/auth/AuthFlow.test.tsx index 43244566e..e4d75e16e 100644 --- a/src/test/auth/AuthFlow.test.tsx +++ b/src/test/auth/AuthFlow.test.tsx @@ -8,6 +8,7 @@ import { supabase } from '@/lib/supabase/client'; // Mock Supabase client vi.mock('@/lib/supabase/client', () => ({ + setActiveAccessToken: vi.fn(), supabase: { auth: { getSession: vi.fn(), diff --git a/src/test/auth/AuthProvider.behavior.test.tsx b/src/test/auth/AuthProvider.behavior.test.tsx index 48d75dacd..1b7d3370e 100644 --- a/src/test/auth/AuthProvider.behavior.test.tsx +++ b/src/test/auth/AuthProvider.behavior.test.tsx @@ -74,6 +74,7 @@ const { vi.mock('@/lib/supabase/client', () => ({ SUPABASE_URL: 'https://example.supabase.co', + setActiveAccessToken: vi.fn(), supabase: { auth: { getSession: getSessionMock,