import { notify } from './toast'; const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; class AuthError extends Error { constructor(message = 'Authentication required') { super(message); this.name = 'AuthError'; } } const getStoredToken = (): string | null => localStorage.getItem('helix_access_token'); const getRefreshToken = (): string | null => localStorage.getItem('helix_refresh_token'); const storeTokens = (accessToken: string, refreshToken: string) => { localStorage.setItem('helix_access_token', accessToken); localStorage.setItem('helix_refresh_token', refreshToken); }; const clearTokens = () => { localStorage.removeItem('helix_access_token'); localStorage.removeItem('helix_refresh_token'); }; // Shared auth headers const authHeaders = (): Record => { const token = getStoredToken(); return { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }; }; // Token refresh — attempts to get a new access token using the refresh token let refreshPromise: Promise | null = null; const tryRefreshToken = async (): Promise => { // Deduplicate concurrent refresh attempts if (refreshPromise) return refreshPromise; refreshPromise = (async () => { const refreshToken = getRefreshToken(); if (!refreshToken) return false; try { const response = await fetch(`${API_URL}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken }), }); if (!response.ok) return false; const data = await response.json(); if (data.accessToken && data.refreshToken) { storeTokens(data.accessToken, data.refreshToken); return true; } return false; } catch { return false; } finally { refreshPromise = null; } })(); return refreshPromise; }; // Shared response handler — extracts error message, handles 401 with auto-refresh, toasts on failure const handleResponse = async (response: Response, silent = false, retryFn?: () => Promise): Promise => { if (response.status === 401 && retryFn) { const refreshed = await tryRefreshToken(); if (refreshed) { const retryResponse = await retryFn(); return handleResponse(retryResponse, silent); } clearTokens(); if (!silent) notify.error('Session expired. Please log in again.'); throw new AuthError(); } if (response.status === 401) { clearTokens(); if (!silent) notify.error('Session expired. Please log in again.'); throw new AuthError(); } const json = await response.json().catch(() => null); // Sidecar may return 400 when the underlying platform token expired — retry with refreshed token if (!response.ok && retryFn) { const msg = (json?.message ?? '').toLowerCase(); if (msg.includes('agent identity') || msg.includes('token') || msg.includes('unauthenticated')) { const refreshed = await tryRefreshToken(); if (refreshed) { const retryResponse = await retryFn(); return handleResponse(retryResponse, silent); } clearTokens(); if (!silent) notify.error('Session expired. Please log in again.'); throw new AuthError(); } } if (!response.ok) { const message = json?.message ?? json?.error ?? `Request failed (${response.status})`; if (!silent) notify.error(message); throw new Error(message); } return json as T; }; export const apiClient = { // Auth — no token needed, no toast on failure (login page shows inline errors) async login(email: string, password: string) { const response = await fetch(`${API_URL}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.message ?? 'Login failed'); } const data = await response.json(); storeTokens(data.accessToken, data.refreshToken); return data as { accessToken: string; refreshToken: string; user?: { id?: string; email?: string; firstName?: string; lastName?: string; avatarUrl?: string; role?: string; platformRoles?: string[]; }; }; }, // GraphQL — all platform data queries go through this async graphql(query: string, variables?: Record, options?: { silent?: boolean }): Promise { const token = getStoredToken(); if (!token) throw new AuthError(); const doFetch = () => fetch(`${API_URL}/graphql`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ query, variables }), }); let response = await doFetch(); if (response.status === 401) { const refreshed = await tryRefreshToken(); if (refreshed) { response = await doFetch(); } else { clearTokens(); if (!options?.silent) notify.error('Session expired', 'Please log in again.'); throw new AuthError(); } } let json = await response.json(); // Platform returns 200 with UNAUTHENTICATED error when token expires — retry with refresh const authError = json.errors?.find((e: any) => e.extensions?.code === 'UNAUTHENTICATED'); if (authError) { const refreshed = await tryRefreshToken(); if (refreshed) { const retryResponse = await doFetch(); json = await retryResponse.json(); } else { clearTokens(); if (!options?.silent) notify.error('Session expired', 'Please log in again.'); throw new AuthError(); } } if (json.errors) { const message = json.errors[0]?.message ?? 'GraphQL error'; if (!options?.silent) notify.error('Query failed', message); throw new Error(message); } return json.data; }, // REST — all sidecar API calls go through these async post(path: string, body?: Record, options?: { silent?: boolean }): Promise { const doFetch = () => fetch(`${API_URL}${path}`, { method: 'POST', headers: authHeaders(), body: body ? JSON.stringify(body) : undefined, }); const response = await doFetch(); return handleResponse(response, options?.silent, doFetch); }, async get(path: string, options?: { silent?: boolean }): Promise { const doFetch = () => fetch(`${API_URL}${path}`, { method: 'GET', headers: authHeaders(), }); const response = await doFetch(); return handleResponse(response, options?.silent, doFetch); }, async put(path: string, body?: Record, options?: { silent?: boolean }): Promise { const doFetch = () => fetch(`${API_URL}${path}`, { method: 'PUT', headers: authHeaders(), body: body ? JSON.stringify(body) : undefined, }); const response = await doFetch(); return handleResponse(response, options?.silent, doFetch); }, // Health check — silent, no toasts async healthCheck(): Promise<{ status: string; platform: { reachable: boolean } }> { try { const response = await fetch(`${API_URL}/api/health`, { signal: AbortSignal.timeout(3000) }); if (!response.ok) return { status: 'down', platform: { reachable: false } }; return response.json(); } catch { return { status: 'down', platform: { reachable: false } }; } }, getStoredToken, getRefreshToken, storeTokens, clearTokens, isAuthenticated: () => !!getStoredToken(), };