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
31 changes: 31 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,37 @@ const config = [
'react-hooks/immutability': 'off',
},
},
// P1.5 — Discourage bare `fetch('/api/...')`.
// The team must migrate to `apiFetch<T>()` from `@/lib/api-client` so that
// 401 / 403 / 5xx / network failures are handled uniformly.
// Phase 1 (this PR): warn level, allow incremental migration.
// Phase 3 (final cleanup): upgrade to error and forbid merges that introduce
// new bare fetch('/api/...') sites.
// Selector rationale:
// - covers single-quoted, double-quoted, and template-literal forms
// - filters by /api prefix so cross-origin / external fetches stay untouched
// - exempts api-client.ts itself (the one allowed implementer)
{
files: ['src/**/*.{ts,tsx,js,jsx}'],
ignores: ['src/lib/api-client.ts'],
rules: {
'no-restricted-syntax': [
'warn',
{
selector:
"CallExpression[callee.name='fetch'] > Literal[value=/^\\/api\\//]",
message:
"Use apiFetch<T>() from '@/lib/api-client' instead of bare fetch('/api/...'). It handles 401 redirect, 403/5xx typed errors, and network failures uniformly. See PR-api-client.md.",
},
{
selector:
"CallExpression[callee.name='fetch'] > TemplateLiteral.arguments:first-child[quasis.0.value.raw=/^\\/api\\//]",
message:
"Use apiFetch<T>() from '@/lib/api-client' instead of bare fetch(`/api/...`). It handles 401 redirect, 403/5xx typed errors, and network failures uniformly.",
},
],
},
},
]

export default config
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { NextIntlClientProvider } from 'next-intl'
import { getLocale, getMessages } from 'next-intl/server'
import { THEME_IDS } from '@/lib/themes'
import { ThemeBackground } from '@/components/ui/theme-background'
import { AuthExpiredListener } from '@/components/auth-expired-listener'
import './globals.css'

const inter = Inter({
Expand Down Expand Up @@ -114,6 +115,7 @@ export default async function RootLayout({
disableTransitionOnChange
>
<ThemeBackground />
<AuthExpiredListener />
<div className="h-screen overflow-hidden bg-background text-foreground">
{children}
</div>
Expand Down
33 changes: 33 additions & 0 deletions src/components/auth-expired-listener.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use client'

import { useEffect } from 'react'

/**
* Listens for `mc:auth-expired` CustomEvent dispatched by `apiFetch()` when the
* server returns 401. The redirect to `/login?from=...` is already handled inside
* `apiFetch`; this listener exists so we have a single observability hook the
* team can extend (toast, telemetry, Sentry).
*
* Mounted once at the root layout. SSR-safe (effect runs only on the client).
*
* Why a separate component?
* - layout.tsx is a server component (uses `await headers()`); we cannot
* attach window listeners there directly.
* - Co-locating the listener with the api-client keeps the auth-failure
* contract in one place.
*/
export function AuthExpiredListener(): null {
useEffect(() => {
const onExpired = (e: Event) => {
const detail = (e as CustomEvent<{ path: string; status: number }>).detail
// No toast lib installed yet — log for now. Replace with sonner in next PR.
console.warn(
`[mc] session expired on ${detail?.path ?? 'unknown'} (status=${detail?.status ?? 401}), redirecting to /login`
)
}
window.addEventListener('mc:auth-expired', onExpired)
return () => window.removeEventListener('mc:auth-expired', onExpired)
}, [])

return null
}
31 changes: 19 additions & 12 deletions src/components/onboarding/runtime-setup-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { apiFetch } from '@/lib/api-client'

interface RuntimeSetupModalProps {
runtime: 'openclaw' | 'hermes' | 'claude' | 'codex'
Expand Down Expand Up @@ -40,14 +41,15 @@ function OpenClawSetup({ onClose, onComplete }: { onClose: () => void; onComplet
setError(null)
setOutput('')
try {
const res = await fetch('/api/agent-runtimes', {
const res = await apiFetch<Response>('/api/agent-runtimes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'install', runtime: 'openclaw', mode: 'local' }),
raw: true,
})
// The onboard command runs as part of post-install in agent-runtimes.ts
// Let's use the doctor endpoint to check health instead
const doctorRes = await fetch('/api/openclaw/doctor')
const doctorRes = await apiFetch<Response>('/api/openclaw/doctor', { raw: true })
if (doctorRes.ok) {
const data = await doctorRes.json()
setHealthStatus(data)
Expand All @@ -71,7 +73,7 @@ function OpenClawSetup({ onClose, onComplete }: { onClose: () => void; onComplet
setRunning(true)
setError(null)
try {
const res = await fetch('/api/openclaw/doctor', { method: 'POST' })
const res = await apiFetch<Response>('/api/openclaw/doctor', { method: 'POST', raw: true })
if (res.ok) {
const data = await res.json()
if (data.success) {
Expand All @@ -90,7 +92,7 @@ function OpenClawSetup({ onClose, onComplete }: { onClose: () => void; onComplet

const checkHealth = useCallback(async () => {
try {
const res = await fetch('/api/openclaw/doctor')
const res = await apiFetch<Response>('/api/openclaw/doctor', { raw: true })
if (res.ok) {
const data = await res.json()
setHealthStatus(data)
Expand Down Expand Up @@ -255,7 +257,7 @@ function HermesSetup({ onClose, onComplete }: { onClose: () => void; onComplete:

const fetchStatus = useCallback(async () => {
try {
const res = await fetch('/api/hermes')
const res = await apiFetch<Response>('/api/hermes', { raw: true })
if (res.ok) {
const data = await res.json()
setHermesStatus(data)
Expand Down Expand Up @@ -288,10 +290,11 @@ function HermesSetup({ onClose, onComplete }: { onClose: () => void; onComplete:
setRunning(true)
setError(null)
try {
const res = await fetch('/api/hermes', {
const res = await apiFetch<Response>('/api/hermes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'install-hook' }),
raw: true,
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
Expand Down Expand Up @@ -528,10 +531,11 @@ function HermesSetup({ onClose, onComplete }: { onClose: () => void; onComplete:
setOauthCode(null)
try {
const providerForOAuth = (currentProvider && 'oauthHermesProvider' in currentProvider ? currentProvider.oauthHermesProvider : currentProvider?.hermesProvider) || providerType
const res = await fetch('/api/hermes', {
const res = await apiFetch<Response>('/api/hermes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'run-oauth-model', provider: providerForOAuth, model: customModel || selectedModel, authMethod: 'device_code' }),
raw: true,
})
const data = await res.json().catch(() => ({}))
if (typeof data.deviceUrl === 'string' && data.deviceUrl) setOauthUrl(data.deviceUrl)
Expand Down Expand Up @@ -645,10 +649,11 @@ function HermesSetup({ onClose, onComplete }: { onClose: () => void; onComplete:
xai: 'XAI_API_KEY',
}
if (authMethod !== 'device_code' && providerKey.trim()) {
const res = await fetch('/api/hermes', {
const res = await apiFetch<Response>('/api/hermes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'set-env', key: envMap[providerType], value: providerKey }),
raw: true,
})
if (res.ok) {
setProviderSaved(true)
Expand Down Expand Up @@ -701,10 +706,11 @@ function HermesSetup({ onClose, onComplete }: { onClose: () => void; onComplete:
onClick={async () => {
if (soulContent.trim()) {
try {
await fetch('/api/hermes', {
await apiFetch<Response>('/api/hermes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'set-soul', content: soulContent }),
raw: true,
})
} catch {
// non-critical
Expand Down Expand Up @@ -836,10 +842,11 @@ function CopyableCommand({ command, label, runnable = false, onOutput }: {
outputStickToBottomRef.current = true
setShowOutputJump(false)
try {
const res = await fetch('/api/hermes', {
const res = await apiFetch<Response>('/api/hermes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'run-command', command }),
raw: true,
})
const data = await res.json()
if (res.ok && data.success) {
Expand Down Expand Up @@ -934,7 +941,7 @@ function ClaudeSetup({ onClose, onComplete }: { onClose: () => void; onComplete:
setChecking(true)
setError(null)
try {
const res = await fetch('/api/agent-runtimes')
const res = await apiFetch<Response>('/api/agent-runtimes', { raw: true })
if (res.ok) {
const data = await res.json()
const claude = (data.runtimes || []).find((r: any) => r.id === 'claude')
Expand Down Expand Up @@ -1055,7 +1062,7 @@ function CodexSetup({ onClose, onComplete }: { onClose: () => void; onComplete:
setChecking(true)
setError(null)
try {
const res = await fetch('/api/agent-runtimes')
const res = await apiFetch<Response>('/api/agent-runtimes', { raw: true })
if (res.ok) {
const data = await res.json()
const codex = (data.runtimes || []).find((r: any) => r.id === 'codex')
Expand Down
114 changes: 114 additions & 0 deletions src/lib/__tests__/api-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { apiFetch, ApiError } from '../api-client'

const realFetch = global.fetch
const realLocation = window.location

function mockResponse(status: number, body: unknown = {}, opts: { json?: boolean } = { json: true }) {
return new Response(opts.json ? JSON.stringify(body) : String(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}

describe('apiFetch — global 401 / 403 / 5xx / network handling', () => {
let dispatched: CustomEvent[] = []
let originalHref = ''
let authExpiredListener: ((e: Event) => void) | null = null

beforeEach(() => {
dispatched = []
authExpiredListener = (e) => dispatched.push(e as CustomEvent)
window.addEventListener('mc:auth-expired', authExpiredListener)

// Stub location so redirect-to-login is observable
originalHref = window.location.href
Object.defineProperty(window, 'location', {
writable: true,
value: {
...realLocation,
pathname: '/cost-tracker',
search: '',
href: 'http://127.0.0.1:3000/cost-tracker',
},
})
})

afterEach(() => {
if (authExpiredListener) {
window.removeEventListener('mc:auth-expired', authExpiredListener)
authExpiredListener = null
}
global.fetch = realFetch
Object.defineProperty(window, 'location', { writable: true, value: realLocation })
window.location.href = originalHref
vi.restoreAllMocks()
})

it('returns parsed JSON on 200', async () => {
global.fetch = vi.fn().mockResolvedValue(mockResponse(200, { ok: true, count: 42 }))
const data = await apiFetch<{ ok: boolean; count: number }>('/api/tokens')
expect(data).toEqual({ ok: true, count: 42 })
})

it('emits mc:auth-expired and redirects to /login on 401', async () => {
global.fetch = vi.fn().mockResolvedValue(mockResponse(401, { error: 'Authentication required' }))
await expect(apiFetch('/api/tokens')).rejects.toMatchObject({
code: 'UNAUTHENTICATED',
status: 401,
})
expect(dispatched).toHaveLength(1)
expect(dispatched[0].detail).toMatchObject({ path: '/api/tokens', status: 401 })
expect(window.location.href).toContain('/login?from=%2Fcost-tracker')
})

it('does NOT redirect when redirectOnUnauthenticated=false', async () => {
global.fetch = vi.fn().mockResolvedValue(mockResponse(401, { error: 'Authentication required' }))
await expect(
apiFetch('/api/tokens', { redirectOnUnauthenticated: false })
).rejects.toThrow(ApiError)
expect(window.location.href).toBe('http://127.0.0.1:3000/cost-tracker')
})

it('throws FORBIDDEN on 403 without redirecting', async () => {
global.fetch = vi.fn().mockResolvedValue(mockResponse(403, { error: 'Requires admin role' }))
await expect(apiFetch('/api/tokens')).rejects.toMatchObject({
code: 'FORBIDDEN',
status: 403,
})
expect(window.location.href).toBe('http://127.0.0.1:3000/cost-tracker')
})

it('throws SERVER_ERROR on 500 with upstream message', async () => {
global.fetch = vi.fn().mockResolvedValue(mockResponse(500, { error: 'database is locked' }))
await expect(apiFetch('/api/tokens')).rejects.toMatchObject({
code: 'SERVER_ERROR',
status: 500,
message: 'database is locked',
})
})

it('throws NETWORK_ERROR when fetch rejects', async () => {
global.fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))
await expect(apiFetch('/api/tokens')).rejects.toMatchObject({
code: 'NETWORK_ERROR',
status: 0,
})
})

it('does not redirect when already on /login (avoid infinite loop)', async () => {
Object.defineProperty(window, 'location', {
writable: true,
value: { ...realLocation, pathname: '/login', search: '', href: 'http://127.0.0.1:3000/login' },
})
global.fetch = vi.fn().mockResolvedValue(mockResponse(401))
await expect(apiFetch('/api/auth/login', { method: 'POST' })).rejects.toThrow(ApiError)
expect(window.location.href).toBe('http://127.0.0.1:3000/login')
})

it('returns undefined for 204 No Content', async () => {
global.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
const data = await apiFetch('/api/sessions/123', { method: 'DELETE' })
expect(data).toBeUndefined()
})
})
Loading