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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C4 'sameSite|defaultCookieAttributes|cookieOptions|cookies' backend/src/auth/index.ts

Repository: chriswritescode-dev/opencode-manager

Length of output: 174


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== backend/src/auth/index.ts (exists? + line count) ==\n'
if [ -f backend/src/auth/index.ts ]; then
  wc -l backend/src/auth/index.ts
  echo
  cat -n backend/src/auth/index.ts | sed -n '1,240p'
else
  echo "missing"
fi

printf '\n== backend auth-related search ==\n'
rg -n -C3 'sameSite|secure: true|defaultCookieAttributes|cookieOptions|sessionCookie|cookie.*site|better-auth|createAuth|authConfig' backend/src -g '!**/node_modules/**'

Repository: chriswritescode-dev/opencode-manager

Length of output: 7645


Clarify the cross-site auth note: backend/src/auth/index.ts only sets advanced.useSecureCookies; it does not override sameSite, so the default SameSite=Lax behavior still blocks cross-site session cookies. If cross-site access isn’t supported, say that explicitly; otherwise document the required sameSite: 'none' + secure: true settings.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/configuration/authentication.md` at line 180, Update the cross-site
authentication note to accurately reflect the settings in the auth
configuration: `advanced.useSecureCookies` does not override the default
`SameSite=Lax`, so state that cross-site access is unsupported unless the cookie
configuration explicitly uses `sameSite: 'none'` with `secure: true`. Clarify
whether that configuration is supported, without implying that CORS or client
credentials alone enables cross-site cookies.


### 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 []
}
Comment on lines +34 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not present passkey-load failures as an empty passkey list.

Catching FetchError values—including 401/500, timeout, and the new 499 cancellation—in listUserPasskeys makes Account Settings display “No passkeys registered” for failed requests.

  • frontend/src/api/authInfo.ts#L34-L39: propagate fetchWrapper failures instead of returning [].
  • frontend/src/components/settings/AccountSettings.tsx#L27-L31: render React Query’s error state rather than the empty-list message when loading fails.
  • frontend/src/api/authInfo.test.ts#L83-L91: assert rejection/error handling instead of an empty-list fallback.
📍 Affects 3 files
  • frontend/src/api/authInfo.ts#L34-L39 (this comment)
  • frontend/src/components/settings/AccountSettings.tsx#L27-L31
  • frontend/src/api/authInfo.test.ts#L83-L91
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/api/authInfo.ts` around lines 34 - 39, Propagate fetchWrapper
failures from listUserPasskeys instead of converting them to an empty array;
update frontend/src/components/settings/AccountSettings.tsx lines 27-31 to
render React Query’s error state for failed loads, and update
frontend/src/api/authInfo.test.ts lines 83-91 to assert rejection/error handling
rather than an empty-list fallback.

}
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