Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/configuration/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions docs/configuration/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down Expand Up @@ -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` |
Expand Down
92 changes: 92 additions & 0 deletions frontend/src/api/authInfo.test.ts
Original file line number Diff line number Diff line change
@@ -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([])
})
})
40 changes: 40 additions & 0 deletions frontend/src/api/authInfo.ts
Original file line number Diff line number Diff line change
@@ -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<AuthConfig> {
try {
return await fetchWrapper<AuthConfig>(`${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<Passkey[]> {
try {
return await fetchWrapper<Passkey[]>(`${API_BASE_URL}/api/auth/passkey/list-user-passkeys`)
} catch {
return []
}
}
113 changes: 113 additions & 0 deletions frontend/src/api/fetchWrapper.test.ts
Original file line number Diff line number Diff line change
@@ -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',
})
})
})
14 changes: 13 additions & 1 deletion frontend/src/api/fetchWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,19 @@ async function fetchWithTimeout(
url: string,
options: FetchWrapperOptions = {}
): Promise<Response> {
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',
Expand All @@ -86,6 +93,7 @@ async function fetchWithTimeout(
})

if (timeoutId) clearTimeout(timeoutId)
if (callerSignal) callerSignal.removeEventListener('abort', abortFromCaller)

if (!response.ok) {
await handleResponse(response)
Expand All @@ -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
Expand Down
Loading