From 6a322159bc00a518b3f20f79bb6d06ac8be95046 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:07:38 -0400 Subject: [PATCH] fix(frontend): centralize cookie credentials in fetchWrapper and route callers through it --- docs/configuration/authentication.md | 1 + docs/configuration/environment.md | 4 +- frontend/src/api/authInfo.test.ts | 92 ++++++++++++++ frontend/src/api/authInfo.ts | 40 +++++++ frontend/src/api/fetchWrapper.test.ts | 113 ++++++++++++++++++ frontend/src/api/fetchWrapper.ts | 14 ++- frontend/src/api/files.test.ts | 113 ++++++++++++++++++ frontend/src/api/files.ts | 35 +++++- frontend/src/api/stt.ts | 53 ++------ frontend/src/api/tts.test.ts | 70 +++++++++++ frontend/src/api/tts.ts | 1 + .../components/file-browser/FileBrowser.tsx | 61 ++-------- .../components/file-browser/FilePreview.tsx | 12 +- .../components/settings/AccountSettings.tsx | 17 +-- frontend/src/contexts/AuthContext.tsx | 26 +--- frontend/src/contexts/TTSContext.tsx | 41 +++---- frontend/src/hooks/useSTT.test.tsx | 4 +- frontend/src/hooks/useSTT.ts | 5 +- frontend/src/lib/auth-loaders.ts | 32 +---- .../browserTransport.test.ts | 61 ++++++++++ .../opencode-event-stream/browserTransport.ts | 18 +-- 21 files changed, 598 insertions(+), 215 deletions(-) create mode 100644 frontend/src/api/authInfo.test.ts create mode 100644 frontend/src/api/authInfo.ts create mode 100644 frontend/src/api/fetchWrapper.test.ts create mode 100644 frontend/src/api/files.test.ts create mode 100644 frontend/src/api/tts.test.ts create mode 100644 frontend/src/lib/opencode-event-stream/browserTransport.test.ts diff --git a/docs/configuration/authentication.md b/docs/configuration/authentication.md index 580be0663..bc3a6ec11 100644 --- a/docs/configuration/authentication.md +++ b/docs/configuration/authentication.md @@ -177,6 +177,7 @@ PASSKEY_ORIGIN=http://192.168.1.244:5003 1. Check AUTH_SECRET is persistent across restarts 2. Verify cookies aren't being blocked 3. Check AUTH_SECURE_COOKIES setting +4. If accessing from a different site (cross-site, not just cross-origin), note that CORS alone is not sufficient: the auth config in `backend/src/auth/index.ts` sets no `sameSite` override, so better-auth's `SameSite=Lax` default prevents the session cookie from being attached to cross-site requests regardless of `credentials: 'include'` on the client. The app is designed same-origin (`VITE_API_URL` defaults to `''`); cross-site access requires changing `sameSite` to `'none'` with `secure: true`, which is an open question rather than a supported configuration. ### Passkey Not Working diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md index 93a448278..5fc7d7791 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -72,7 +72,7 @@ When configured, users can enable push notifications in Settings → Notificatio | `PORT` | Server port | `5003` | | `HOST` | Server bind address | `0.0.0.0` | | `NODE_ENV` | Environment (`development` or `production`) | `development` | -| `CORS_ORIGIN` | CORS origin for frontend | `http://localhost:5173` | +| `CORS_ORIGIN` | **Unused / deprecated.** Parsed by `shared/src/config` but consumed nowhere; the effective CORS control is `AUTH_TRUSTED_ORIGINS` (see [Authentication](./authentication.md)). Kept for backwards compatibility — do not rely on it. | `http://localhost:5173` | | `LOG_LEVEL` | Logging level | `info` | | `DEBUG` | Enable debug logging | `false` | @@ -128,7 +128,7 @@ When configured, users can enable push notifications in Settings → Notificatio | Variable | Description | Default | |----------|-------------|---------| -| `VITE_API_URL` | Backend API URL for frontend | `http://localhost:5003` | +| `VITE_API_URL` | Backend API URL for frontend. Defaults to `''` (empty), making the app same-origin — the backend serves the built frontend (`backend/src/index.ts`) and dev uses the Vite `/api` proxy (`frontend/vite.config.ts`). Setting it to a different host puts the app in an **unsupported split-origin mode**; see the open question in `Decisions.md` ("is split-origin deployment supported?") before doing so. | `''` | | `VITE_SERVER_PORT` | Backend port hint for frontend | `5003` | | `VITE_OPENCODE_PORT` | OpenCode server port hint | `5551` | | `VITE_MAX_FILE_SIZE_MB` | File size limit for frontend | `50` | diff --git a/frontend/src/api/authInfo.test.ts b/frontend/src/api/authInfo.test.ts new file mode 100644 index 000000000..212bc734f --- /dev/null +++ b/frontend/src/api/authInfo.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getAuthConfig, DEFAULT_AUTH_CONFIG, listUserPasskeys } from './authInfo' + +describe('getAuthConfig', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('returns the parsed config on success', async () => { + const config = { + enabledProviders: ['credentials', 'github'], + registrationEnabled: true, + isFirstUser: false, + adminConfigured: true, + } + fetchMock.mockResolvedValue( + new Response(JSON.stringify(config), { status: 200 }), + ) + + const result = await getAuthConfig() + + expect(result).toEqual(config) + + const callOptions = fetchMock.mock.calls[0][1] + expect(callOptions.credentials).toBe('include') + }) + + it('falls back to DEFAULT_AUTH_CONFIG on a non-OK response', async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ error: 'server error' }), { status: 500 }), + ) + + const result = await getAuthConfig() + + expect(result).toEqual(DEFAULT_AUTH_CONFIG) + }) + + it('falls back to DEFAULT_AUTH_CONFIG when the network rejects', async () => { + fetchMock.mockRejectedValue(new Error('offline')) + + const result = await getAuthConfig() + + expect(result).toEqual(DEFAULT_AUTH_CONFIG) + }) + + it('defaults isFirstUser to false so the setup flow is not offered when config is unknown', () => { + expect(DEFAULT_AUTH_CONFIG.isFirstUser).toBe(false) + }) +}) + +describe('listUserPasskeys', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('returns the passkey list and sends credentials', async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify([{ id: 'p1', name: 'laptop' }]), { status: 200 }), + ) + + const result = await listUserPasskeys() + + expect(result).toEqual([{ id: 'p1', name: 'laptop' }]) + + const callOptions = fetchMock.mock.calls[0][1] + expect(callOptions.credentials).toBe('include') + }) + + it('returns an empty array when the request fails', async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401 }), + ) + + const result = await listUserPasskeys() + + expect(result).toEqual([]) + }) +}) diff --git a/frontend/src/api/authInfo.ts b/frontend/src/api/authInfo.ts new file mode 100644 index 000000000..43386ebf4 --- /dev/null +++ b/frontend/src/api/authInfo.ts @@ -0,0 +1,40 @@ +import { API_BASE_URL } from '@/config' +import { fetchWrapper } from './fetchWrapper' + +export interface AuthConfig { + enabledProviders: string[] + registrationEnabled: boolean + isFirstUser: boolean + adminConfigured: boolean +} + +export const DEFAULT_AUTH_CONFIG: AuthConfig = { + enabledProviders: ['credentials'], + registrationEnabled: true, + isFirstUser: false, + adminConfigured: false, +} + +export async function getAuthConfig(): Promise { + try { + return await fetchWrapper(`${API_BASE_URL}/api/auth-info/config`) + } catch { + return DEFAULT_AUTH_CONFIG + } +} + +export interface Passkey { + id: string + name?: string + credentialID: string + createdAt: string + deviceType: string +} + +export async function listUserPasskeys(): Promise { + try { + return await fetchWrapper(`${API_BASE_URL}/api/auth/passkey/list-user-passkeys`) + } catch { + return [] + } +} diff --git a/frontend/src/api/fetchWrapper.test.ts b/frontend/src/api/fetchWrapper.test.ts new file mode 100644 index 000000000..a5f5d166f --- /dev/null +++ b/frontend/src/api/fetchWrapper.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FetchError } from '@opencode-manager/shared' +import { fetchWrapper } from './fetchWrapper' + +describe('fetchWrapper', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('sends credentials: include on every request', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })) + + await fetchWrapper('/api/x') + + expect(fetchMock.mock.calls[0][1].credentials).toBe('include') + }) + + it('keeps credentials: include when the caller passes its own options', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })) + + await fetchWrapper('/api/x', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }) + + const init = fetchMock.mock.calls[0][1] + expect(init.credentials).toBe('include') + expect(init.method).toBe('POST') + }) + + it('aborts the request when the caller signal aborts', async () => { + fetchMock.mockImplementation((_url, init) => + new Promise((_resolve, reject) => { + init.signal.addEventListener('abort', () => + reject(new DOMException('Aborted', 'AbortError')), + ) + }), + ) + + const controller = new AbortController() + const promise = fetchWrapper('/api/x', { signal: controller.signal }) + controller.abort() + await expect(promise).rejects.toThrow() + }) + + it('rejects with a 408 TIMEOUT FetchError when the timeout elapses', async () => { + vi.useFakeTimers() + fetchMock.mockImplementation((_url, init) => { + const signal = init.signal as AbortSignal + return new Promise((_resolve, reject) => { + if (signal.aborted) { + const err = new Error('The operation was aborted') + err.name = 'AbortError' + reject(err) + return + } + signal.addEventListener('abort', () => { + const err = new Error('The operation was aborted') + err.name = 'AbortError' + reject(err) + }, { once: true }) + }) + }) + + const promise = fetchWrapper('/api/x', { timeout: 100 }) + vi.advanceTimersByTime(101) + + await expect(promise).rejects.toBeInstanceOf(FetchError) + await expect(promise).rejects.toMatchObject({ + statusCode: 408, + code: 'TIMEOUT', + }) + + vi.useRealTimers() + }) + + it('rejects with 499 CANCELED when the caller aborts, not 408 TIMEOUT', async () => { + fetchMock.mockImplementation((_url, init) => { + const signal = init.signal as AbortSignal + return new Promise((_resolve, reject) => { + if (signal.aborted) { + const err = new Error('The operation was aborted') + err.name = 'AbortError' + reject(err) + return + } + signal.addEventListener('abort', () => { + const err = new Error('The operation was aborted') + err.name = 'AbortError' + reject(err) + }, { once: true }) + }) + }) + + const controller = new AbortController() + const promise = fetchWrapper('/api/x', { signal: controller.signal }) + controller.abort() + + await expect(promise).rejects.toBeInstanceOf(FetchError) + await expect(promise).rejects.toMatchObject({ + statusCode: 499, + code: 'CANCELED', + }) + }) +}) diff --git a/frontend/src/api/fetchWrapper.ts b/frontend/src/api/fetchWrapper.ts index 4f301d5d5..9c1ba1518 100644 --- a/frontend/src/api/fetchWrapper.ts +++ b/frontend/src/api/fetchWrapper.ts @@ -72,12 +72,19 @@ async function fetchWithTimeout( url: string, options: FetchWrapperOptions = {} ): Promise { - const { timeout = 30000, params, ...fetchOptions } = options + const { timeout = 30000, params, signal: callerSignal, ...fetchOptions } = options const urlObj = buildUrl(url, params) const controller = new AbortController() const timeoutId = timeout > 0 ? setTimeout(() => controller.abort(), timeout) : null + const abortFromCaller = () => controller.abort() + if (callerSignal?.aborted) { + controller.abort() + } else { + callerSignal?.addEventListener('abort', abortFromCaller, { once: true }) + } + try { const response = await fetch(urlObj.toString(), { credentials: 'include', @@ -86,6 +93,7 @@ async function fetchWithTimeout( }) if (timeoutId) clearTimeout(timeoutId) + if (callerSignal) callerSignal.removeEventListener('abort', abortFromCaller) if (!response.ok) { await handleResponse(response) @@ -94,7 +102,11 @@ async function fetchWithTimeout( return response } catch (error) { if (timeoutId) clearTimeout(timeoutId) + if (callerSignal) callerSignal.removeEventListener('abort', abortFromCaller) if (error instanceof Error && error.name === 'AbortError') { + if (callerSignal?.aborted) { + throw new FetchError('Request canceled', 499, 'CANCELED') + } throw new FetchError('Request timeout', 408, 'TIMEOUT') } throw error diff --git a/frontend/src/api/files.test.ts b/frontend/src/api/files.test.ts new file mode 100644 index 000000000..aaf9e6906 --- /dev/null +++ b/frontend/src/api/files.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FetchError } from '@opencode-manager/shared' +import { + fetchFileInfo, + uploadFileEntry, + writeFileEntry, + deleteFileEntry, + renameFileEntry, +} from './files' + +describe('files api', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('fetchFileInfo requests the file path and sends credentials', async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ name: 'a.txt', path: 'a.txt', isDirectory: false }), { status: 200 }), + ) + + await fetchFileInfo('dir/a.txt') + + const [url, init] = fetchMock.mock.calls[0] + expect(url).toContain('/api/files') + expect(url).toContain('path=dir%2Fa.txt') + expect(init.credentials).toBe('include') + expect(init.method).toBeUndefined() + }) + + it('uploadFileEntry posts FormData without forcing a Content-Type', async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 204 })) + + const formData = new FormData() + formData.append('file', new File(['x'], 'x.txt')) + + await uploadFileEntry('dir', formData) + + const [, init] = fetchMock.mock.calls[0] + expect(init.method).toBe('POST') + expect(init.credentials).toBe('include') + expect(init.body).toBeInstanceOf(FormData) + const contentType = (init.headers as Record | undefined)?.['Content-Type'] + expect(contentType).toBeUndefined() + }) + + it('writeFileEntry puts JSON type and content', async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 204 })) + + await writeFileEntry('dir/a.txt', 'file', '') + + const [url, init] = fetchMock.mock.calls[0] + expect(url).toContain('path=dir%2Fa.txt') + expect(init.method).toBe('PUT') + expect(init.credentials).toBe('include') + expect((init.headers as Record)['Content-Type']).toBe('application/json') + expect(JSON.parse(init.body as string)).toEqual({ type: 'file', content: '' }) + }) + + it('deleteFileEntry issues DELETE with no timeout cap', async () => { + vi.useFakeTimers() + let resolveFetch: (value: Response) => void = () => {} + fetchMock.mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve + }), + ) + + const pending = deleteFileEntry('dir/a.txt') + await vi.advanceTimersByTimeAsync(60_000) + + const [, init] = fetchMock.mock.calls[0] + expect(init.method).toBe('DELETE') + expect(init.credentials).toBe('include') + expect(init.signal.aborted).toBe(false) + + resolveFetch(new Response(null, { status: 204 })) + vi.useRealTimers() + await pending + }) + + it('renameFileEntry patches the new path', async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 204 })) + + await renameFileEntry('dir/old.txt', 'dir/new.txt') + + const [url, init] = fetchMock.mock.calls[0] + expect(url).toContain('path=dir%2Fold.txt') + expect(init.method).toBe('PATCH') + expect(init.credentials).toBe('include') + expect((init.headers as Record)['Content-Type']).toBe('application/json') + expect(JSON.parse(init.body as string)).toEqual({ newPath: 'dir/new.txt' }) + }) + + it('uploadFileEntry surfaces the server error message as FetchError', async () => { + fetchMock.mockImplementation(() => + Promise.resolve(new Response(JSON.stringify({ error: 'File too large' }), { status: 413 })), + ) + + await expect(uploadFileEntry('dir', new FormData())).rejects.toThrow(FetchError) + await expect(uploadFileEntry('dir', new FormData())).rejects.toMatchObject({ + statusCode: 413, + message: 'File too large', + }) + }) +}) diff --git a/frontend/src/api/files.ts b/frontend/src/api/files.ts index 7337d9f31..d4dd450d7 100644 --- a/frontend/src/api/files.ts +++ b/frontend/src/api/files.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { fetchWrapper, fetchWrapperBlob } from './fetchWrapper' +import { fetchWrapper, fetchWrapperBlob, fetchWrapperVoid } from './fetchWrapper' import { API_BASE_URL } from '@/config' import type { FileInfo, ChunkedFileInfo, PatchOperation } from '@/types/files' @@ -22,14 +22,14 @@ export function getFileApiUrl(path: string, options: FileApiUrlOptions = {}): st return `${API_BASE_URL}/api/files${routePath}${query ? `?${query}` : ''}` } -async function fetchFile(path: string): Promise { +export async function fetchFileInfo(path: string): Promise { return fetchWrapper(getFileApiUrl(path)) } export function useFile(path: string | undefined) { return useQuery({ queryKey: ['file', path], - queryFn: () => path ? fetchFile(path) : Promise.reject(new Error('No file path provided')), + queryFn: () => path ? fetchFileInfo(path) : Promise.reject(new Error('No file path provided')), enabled: !!path, }) } @@ -74,6 +74,35 @@ export async function applyFilePatches(path: string, patches: PatchOperation[]): const url = getFileApiUrl(path) return fetchWrapper(url, { method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ patches }), }) } + +export async function uploadFileEntry(directoryPath: string, formData: FormData): Promise { + await fetchWrapperVoid(getFileApiUrl(directoryPath), { + method: 'POST', + body: formData, + timeout: 0, + }) +} + +export async function writeFileEntry(path: string, type: 'file' | 'folder', content?: string): Promise { + await fetchWrapperVoid(getFileApiUrl(path), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type, content }), + }) +} + +export async function deleteFileEntry(path: string): Promise { + await fetchWrapperVoid(getFileApiUrl(path), { method: 'DELETE', timeout: 0 }) +} + +export async function renameFileEntry(oldPath: string, newPath: string): Promise { + await fetchWrapperVoid(getFileApiUrl(oldPath), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ newPath }), + }) +} diff --git a/frontend/src/api/stt.ts b/frontend/src/api/stt.ts index 01d6291d4..f00bf450b 100644 --- a/frontend/src/api/stt.ts +++ b/frontend/src/api/stt.ts @@ -1,5 +1,5 @@ import { API_BASE_URL } from '@/config' -import { fetchWrapper, FetchError } from './fetchWrapper' +import { fetchWrapper } from './fetchWrapper' export interface STTModelsResponse { models: string[] @@ -50,49 +50,12 @@ export const sttApi = { type.includes('mp4') ? 'm4a' : 'wav' formData.append('audio', audioBlob, `recording.${extension}`) - const urlObj = new URL(`${API_BASE_URL}/api/stt/transcribe`, window.location.origin) - urlObj.searchParams.set('userId', userId) - - const controller = new AbortController() - let timeoutFired = false - const timeoutId = setTimeout(() => { - timeoutFired = true - controller.abort() - }, 60000) - - if (signal?.aborted) { - controller.abort() - } - const onAbort = () => controller.abort() - signal?.addEventListener('abort', onAbort, { once: true }) - - try { - const response = await fetch(urlObj.toString(), { - method: 'POST', - body: formData, - signal: controller.signal, - }) - - clearTimeout(timeoutId) - signal?.removeEventListener('abort', onAbort) - - if (!response.ok) { - const data = await response.json().catch(() => ({ error: 'Transcription failed' })) - throw new FetchError(data.error || 'Transcription failed', response.status) - } - - return response.json() - } catch (error) { - clearTimeout(timeoutId) - signal?.removeEventListener('abort', onAbort) - - if (error instanceof Error && error.name === 'AbortError') { - if (signal?.aborted && !timeoutFired) { - throw new FetchError('Transcription canceled', 499, 'CANCELED') - } - throw new FetchError('Transcription timeout', 408, 'TIMEOUT') - } - throw error - } + return fetchWrapper(`${API_BASE_URL}/api/stt/transcribe`, { + method: 'POST', + params: { userId }, + body: formData, + timeout: 60000, + signal, + }) }, } diff --git a/frontend/src/api/tts.test.ts b/frontend/src/api/tts.test.ts new file mode 100644 index 000000000..eef55ddcc --- /dev/null +++ b/frontend/src/api/tts.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ttsApi } from './tts' +import { FetchError } from './fetchWrapper' + +describe('ttsApi.synthesize', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('posts text as JSON to the synthesize endpoint with credentials and userId', async () => { + const audioBlob = new Blob(['audio-data'], { type: 'audio/wav' }) + fetchMock.mockResolvedValue( + new Response(audioBlob, { status: 200, headers: { 'Content-Type': 'audio/wav' } }), + ) + + const result = await ttsApi.synthesize('hello', 'default') + + expect(result).toBeInstanceOf(Blob) + + const [url, init] = fetchMock.mock.calls[0] + expect(url).toContain('/api/tts/synthesize') + expect(url).toContain('userId=default') + expect(init.method).toBe('POST') + expect(init.credentials).toBe('include') + expect(JSON.parse(init.body as string).text).toBe('hello') + }) + + it('propagates the caller abort signal', async () => { + fetchMock.mockImplementation((_url: string, options: RequestInit) => { + const signal = options.signal as AbortSignal + return new Promise((_resolve, reject) => { + if (signal.aborted) { + const err = new Error('The operation was aborted') + err.name = 'AbortError' + reject(err) + return + } + signal.addEventListener('abort', () => { + const err = new Error('The operation was aborted') + err.name = 'AbortError' + reject(err) + }, { once: true }) + }) + }) + + const controller = new AbortController() + const promise = ttsApi.synthesize('hello', 'default', controller.signal) + controller.abort() + + await expect(promise).rejects.toThrow() + }) + + it('surfaces a 401 as a FetchError with statusCode 401', async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }), + ) + + await expect(ttsApi.synthesize('hello', 'default')).rejects.toThrow(FetchError) + await expect(ttsApi.synthesize('hello', 'default')).rejects.toMatchObject({ + statusCode: 401, + }) + }) +}) diff --git a/frontend/src/api/tts.ts b/frontend/src/api/tts.ts index 4535a2289..b4b8e69cc 100644 --- a/frontend/src/api/tts.ts +++ b/frontend/src/api/tts.ts @@ -48,6 +48,7 @@ export const ttsApi = { params: { userId }, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), + timeout: 60000, signal, }) }, diff --git a/frontend/src/components/file-browser/FileBrowser.tsx b/frontend/src/components/file-browser/FileBrowser.tsx index c6df1a35d..dcf29504f 100644 --- a/frontend/src/components/file-browser/FileBrowser.tsx +++ b/frontend/src/components/file-browser/FileBrowser.tsx @@ -11,7 +11,7 @@ import { Progress } from '@/components/ui/progress' import { FolderOpen, Upload, RefreshCw, X } from 'lucide-react' import type { FileInfo } from '@/types/files' import { useMobile } from '@/hooks/useMobile' -import { getFileApiUrl, useFile } from '@/api/files' +import { fetchFileInfo, uploadFileEntry, writeFileEntry, deleteFileEntry, renameFileEntry, useFile } from '@/api/files' export interface FileBrowserHandle { goBack: () => void @@ -169,12 +169,7 @@ useEffect(() => { setError(null) try { - const response = await fetch(getFileApiUrl(path)) - if (!response.ok) { - throw new Error(`Failed to load files: ${response.statusText}`) - } - - const data = await response.json() + const data = await fetchFileInfo(path) setFiles(data) setCurrentPath(path) onDirectoryLoad?.({ workspaceRoot: data.workspaceRoot, currentPath: path }) @@ -251,12 +246,7 @@ useEffect(() => { // Fetch the full file content when selecting a file setLoading(true) try { - const response = await fetch(getFileApiUrl(file.path)) - if (!response.ok) { - throw new Error(`Failed to load file: ${response.statusText}`) - } - - const fullFileData = await response.json() + const fullFileData = await fetchFileInfo(file.path) setSelectedFile(fullFileData) onFileSelect?.(fullFileData) @@ -293,16 +283,7 @@ useEffect(() => { formData.append('relativePath', item.relativePath) try { - const response = await fetch(getFileApiUrl(currentPath), { - method: 'POST', - body: formData, - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - return errorData.error || `Upload failed: ${response.statusText}` - } - + await uploadFileEntry(currentPath, formData) return null } catch (err) { return err instanceof Error ? err.message : 'Upload failed' @@ -363,16 +344,8 @@ useEffect(() => { const handleCreateFile = useCallback(async (name: string, type: 'file' | 'folder') => { try { - const response = await fetch(getFileApiUrl([currentPath, name].filter(Boolean).join('/')), { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ type, content: type === 'file' ? '' : undefined }), - }) - - if (!response.ok) { - throw new Error(`Create failed: ${response.statusText}`) - } - + await writeFileEntry([currentPath, name].filter(Boolean).join('/'), type, type === 'file' ? '' : undefined) + await loadFiles(currentPath) } catch (err) { setError(err instanceof Error ? err.message : 'Create failed') @@ -381,14 +354,8 @@ useEffect(() => { const handleDelete = useCallback(async (path: string) => { try { - const response = await fetch(getFileApiUrl(path), { - method: 'DELETE', - }) - - if (!response.ok) { - throw new Error(`Delete failed: ${response.statusText}`) - } - + await deleteFileEntry(path) + await loadFiles(currentPath) setSelectedFile(null) } catch (err) { @@ -398,16 +365,8 @@ useEffect(() => { const handleRename = useCallback(async (oldPath: string, newPath: string) => { try { - const response = await fetch(getFileApiUrl(oldPath), { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ newPath }), - }) - - if (!response.ok) { - throw new Error(`Rename failed: ${response.statusText}`) - } - + await renameFileEntry(oldPath, newPath) + await loadFiles(currentPath) } catch (err) { setError(err instanceof Error ? err.message : 'Rename failed') diff --git a/frontend/src/components/file-browser/FilePreview.tsx b/frontend/src/components/file-browser/FilePreview.tsx index 9f8dfcb33..53d4be473 100644 --- a/frontend/src/components/file-browser/FilePreview.tsx +++ b/frontend/src/components/file-browser/FilePreview.tsx @@ -2,7 +2,7 @@ import { useState, useCallback, useRef, useEffect, memo } from 'react' import { Button } from '@/components/ui/button' import { Download, X, Edit3, Save, X as XIcon, WrapText, Eye, Code } from 'lucide-react' import type { FileInfo } from '@/types/files' -import { getFileApiUrl } from '@/api/files' +import { getFileApiUrl, writeFileEntry } from '@/api/files' import { VirtualizedTextView, type VirtualizedTextViewHandle } from '@/components/ui/virtualized-text-view' import { MarkdownRenderer } from './MarkdownRenderer' @@ -116,15 +116,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, } const saveFileContent = useCallback(async (content: string) => { - const response = await fetch(getFileApiUrl(file.path), { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ type: 'file', content }), - }) - - if (!response.ok) { - throw new Error(`Save failed: ${response.statusText}`) - } + await writeFileEntry(file.path, 'file', content) const event = new CustomEvent('fileSaved', { detail: { path: file.path, content } }) window.dispatchEvent(event) diff --git a/frontend/src/components/settings/AccountSettings.tsx b/frontend/src/components/settings/AccountSettings.tsx index c346332ac..ae9ea10b2 100644 --- a/frontend/src/components/settings/AccountSettings.tsx +++ b/frontend/src/components/settings/AccountSettings.tsx @@ -10,14 +10,7 @@ import { DeleteDialog } from '@/components/ui/delete-dialog' import { Loader2, User, KeyRound, LogOut, Plus, Trash2, AlertCircle, CheckCircle, Lock } from 'lucide-react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { passkey, changePassword } from '@/lib/auth-client' - -interface Passkey { - id: string - name?: string - credentialID: string - createdAt: string - deviceType: string -} +import { listUserPasskeys } from '@/api/authInfo' export function AccountSettings() { const { user, addPasskey, logout } = useAuth() @@ -33,13 +26,7 @@ export function AccountSettings() { const { data: passkeys, isLoading: passkeysLoading } = useQuery({ queryKey: ['passkeys'], - queryFn: async () => { - const response = await fetch('/api/auth/passkey/list-user-passkeys', { - credentials: 'include', - }) - if (!response.ok) return [] - return response.json() as Promise - }, + queryFn: () => listUserPasskeys(), enabled: !!user, }) diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index ec826ba34..dbc55f4ff 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -2,13 +2,7 @@ import { createContext, useEffect, useMemo, useState, useCallback, type ReactNode } from 'react' import { useSession, signIn, signUp, signOut, authClient, type AuthUser } from '@/lib/auth-client' import { useNavigate, useLocation } from 'react-router-dom' - -interface AuthConfig { - enabledProviders: string[] - registrationEnabled: boolean - isFirstUser: boolean - adminConfigured: boolean -} +import { getAuthConfig, type AuthConfig } from '@/api/authInfo' interface AuthContextValue { user: AuthUser | null @@ -39,23 +33,7 @@ export function AuthProvider({ children }: AuthProviderProps) { const location = useLocation() useEffect(() => { - const fetchConfig = async () => { - try { - const response = await fetch('/api/auth-info/config') - if (response.ok) { - const data = await response.json() - setConfig(data) - } - } catch { - setConfig({ - enabledProviders: ['credentials'], - registrationEnabled: true, - isFirstUser: true, - adminConfigured: false, - }) - } - } - fetchConfig() + void getAuthConfig().then(setConfig) }, []) const signInWithEmail = useCallback(async (email: string, password: string) => { diff --git a/frontend/src/contexts/TTSContext.tsx b/frontend/src/contexts/TTSContext.tsx index d5d814fd4..68e620de8 100644 --- a/frontend/src/contexts/TTSContext.tsx +++ b/frontend/src/contexts/TTSContext.tsx @@ -1,6 +1,7 @@ import { useState, useRef, useCallback, useEffect, type ReactNode } from 'react' import { useSettings } from '@/hooks/useSettings' -import { API_BASE_URL } from '@/config' +import { ttsApi } from '@/api/tts' +import { FetchError } from '@/api/fetchWrapper' import { TTSContext, type TTSState, type TTSConfig } from './tts-context' import { sanitizeForTTS } from '@/lib/utils' import { getWebSpeechSynthesizer, isWebSpeechSupported } from '@/lib/webSpeechSynthesizer' @@ -114,43 +115,35 @@ export function TTSProvider({ children }: TTSProviderProps) { if (stoppedRef.current) return null try { - const response = await fetch(`${API_BASE_URL}/api/tts/synthesize`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text }), - signal, - }) + const blob = await ttsApi.synthesize(text, 'default', signal) if (stoppedRef.current) return null - if (!response.ok) { - let errorMessage = 'TTS request failed' - try { - const errorData = await response.json() - errorMessage = errorData.error || errorData.details || errorMessage - } catch { - if (response.status === 401) errorMessage = 'Invalid API key' - else if (response.status === 429) errorMessage = 'Rate limit exceeded' - else if (response.status >= 500) errorMessage = 'Service unavailable' - } - throw new Error(errorMessage) - } - - const contentType = response.headers.get('content-type') - if (!contentType?.includes('audio')) { + if (!blob.type.includes('audio')) { throw new Error('Invalid response from TTS service') } - const blob = await response.blob() if (blob.size === 0) { throw new Error('Empty audio response') } return blob } catch (err) { - if (err instanceof Error && err.name === 'AbortError') { + if (signal?.aborted) { return null } + if (err instanceof FetchError) { + const status = err.statusCode ?? 0 + const backendMessage = err.message + const isFallback = backendMessage === 'Request failed' || backendMessage === 'An error occurred' || !backendMessage + let errorMessage = backendMessage + if (isFallback) { + if (status === 401) errorMessage = 'Invalid API key' + else if (status === 429) errorMessage = 'Rate limit exceeded' + else if (status >= 500) errorMessage = 'Service unavailable' + } + throw new Error(errorMessage) + } throw err } }, []) diff --git a/frontend/src/hooks/useSTT.test.tsx b/frontend/src/hooks/useSTT.test.tsx index 927e8b618..243ca4807 100644 --- a/frontend/src/hooks/useSTT.test.tsx +++ b/frontend/src/hooks/useSTT.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook, act, waitFor } from '@testing-library/react' +import { FetchError } from '../api/fetchWrapper' import { useSTT } from './useSTT' type MockRecorder = { @@ -253,8 +254,7 @@ describe('useSTT external provider lifecycle', () => { expect(signalArg.aborted).toBe(true) - const cancelError = new Error('canceled') - cancelError.name = 'CanceledError' + const cancelError = new FetchError('Request canceled', 499, 'CANCELED') rejectTranscribe(cancelError) await waitFor(() => { diff --git a/frontend/src/hooks/useSTT.ts b/frontend/src/hooks/useSTT.ts index 0440bf508..18f4ee0ab 100644 --- a/frontend/src/hooks/useSTT.ts +++ b/frontend/src/hooks/useSTT.ts @@ -157,10 +157,7 @@ export function useSTT(userId = 'default') { setTranscript((prev) => appendTranscriptSegment(prev, result.text)) setInterimTranscript('') } catch (err) { - if (err instanceof Error && ( - err.name === 'CanceledError' || - (err instanceof FetchError && (err.code === 'CANCELED' || err.statusCode === 499)) - )) { + if (err instanceof FetchError && (err.code === 'CANCELED' || err.statusCode === 499)) { return } diff --git a/frontend/src/lib/auth-loaders.ts b/frontend/src/lib/auth-loaders.ts index cce1af144..c428ab8a2 100644 --- a/frontend/src/lib/auth-loaders.ts +++ b/frontend/src/lib/auth-loaders.ts @@ -1,34 +1,12 @@ import { redirect } from 'react-router-dom' import { getSession } from './auth-client' +import { getAuthConfig, type AuthConfig } from '@/api/authInfo' -export interface AuthConfig { - enabledProviders: string[] - registrationEnabled: boolean - isFirstUser: boolean - adminConfigured: boolean -} - -async function fetchAuthConfig(): Promise { - const defaultConfig: AuthConfig = { - enabledProviders: ['credentials'], - registrationEnabled: true, - isFirstUser: false, - adminConfigured: false, - } - const response = await fetch('/api/auth-info/config') - if (!response.ok) { - return defaultConfig - } - try { - return await response.json() - } catch { - return defaultConfig - } -} +export type { AuthConfig } export async function loginLoader() { const [config, session] = await Promise.all([ - fetchAuthConfig(), + getAuthConfig(), getSession(), ]) @@ -45,7 +23,7 @@ export async function loginLoader() { export async function setupLoader() { const [config, session] = await Promise.all([ - fetchAuthConfig(), + getAuthConfig(), getSession(), ]) @@ -62,7 +40,7 @@ export async function setupLoader() { export async function registerLoader() { const [config, session] = await Promise.all([ - fetchAuthConfig(), + getAuthConfig(), getSession(), ]) diff --git a/frontend/src/lib/opencode-event-stream/browserTransport.test.ts b/frontend/src/lib/opencode-event-stream/browserTransport.test.ts new file mode 100644 index 000000000..af01f6724 --- /dev/null +++ b/frontend/src/lib/opencode-event-stream/browserTransport.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createBrowserEventStreamTransport } from './browserTransport' + +describe('browserTransport.post', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('posts JSON with credentials and resolves true on success', async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 204 })) + + const result = await createBrowserEventStreamTransport().post('/api/sse/subscribe', { + clientId: 'c1', + directories: ['/a'], + }) + + expect(result).toBe(true) + + const callUrl = fetchMock.mock.calls[0][0] + expect(callUrl).toEqual(expect.stringContaining('/api/sse/subscribe')) + + const init = fetchMock.mock.calls[0][1] as RequestInit + expect(init.method).toBe('POST') + expect(init.credentials).toBe('include') + expect((init.headers as Record)['Content-Type']).toBe('application/json') + + const body = JSON.parse(init.body as string) + expect(body).toEqual({ clientId: 'c1', directories: ['/a'] }) + }) + + it('resolves false instead of throwing when the server rejects', async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ error: 'Internal error' }), { status: 500 }), + ) + + const result = await createBrowserEventStreamTransport().post('/api/sse/subscribe', { + clientId: 'c1', + directories: ['/a'], + }) + + expect(result).toBe(false) + }) + + it('resolves false instead of throwing when the network rejects', async () => { + fetchMock.mockRejectedValue(new TypeError('Failed to fetch')) + + const result = await createBrowserEventStreamTransport().post('/api/sse/subscribe', { + clientId: 'c1', + directories: ['/a'], + }) + + expect(result).toBe(false) + }) +}) diff --git a/frontend/src/lib/opencode-event-stream/browserTransport.ts b/frontend/src/lib/opencode-event-stream/browserTransport.ts index 1051ee65a..0db760eb3 100644 --- a/frontend/src/lib/opencode-event-stream/browserTransport.ts +++ b/frontend/src/lib/opencode-event-stream/browserTransport.ts @@ -1,3 +1,4 @@ +import { fetchWrapperVoid } from '@/api/fetchWrapper' import type { EventStreamConnection, EventStreamTransport, EventStreamTransportHandlers } from './types' export function createBrowserEventStreamTransport(): EventStreamTransport { @@ -19,13 +20,16 @@ export function createBrowserEventStreamTransport(): EventStreamTransport { }, async post(path: string, body: unknown): Promise { - const response = await fetch(path, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - - return response.ok + try { + await fetchWrapperVoid(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return true + } catch { + return false + } }, } }