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
126 changes: 61 additions & 65 deletions src/app/[[...panel]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { createElement, useEffect, useMemo, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { apiFetch } from '@/lib/api-client'
import { NavRail } from '@/components/layout/nav-rail'
import { HeaderBar } from '@/components/layout/header-bar'
import { LiveFeed } from '@/components/layout/live-feed'
Expand Down Expand Up @@ -191,19 +192,18 @@ export default function Home() {

const connectWithPrimaryGateway = async (): Promise<{ attempted: boolean; connected: boolean }> => {
try {
const gatewaysRes = await fetch('/api/gateways')
if (!gatewaysRes.ok) return { attempted: false, connected: false }
const gatewaysJson = await gatewaysRes.json().catch(() => ({}))
const gatewaysJson = await apiFetch<any>('/api/gateways')
const gateways = Array.isArray(gatewaysJson?.gateways) ? gatewaysJson.gateways as GatewaySummary[] : []
if (gateways.length === 0) return { attempted: false, connected: false }

const primaryGateway = gateways.find(gw => Number(gw?.is_primary) === 1) || gateways[0]
if (!primaryGateway?.id) return { attempted: true, connected: false }

const connectRes = await fetch('/api/gateways/connect', {
const connectRes = await apiFetch<Response>('/api/gateways/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: primaryGateway.id }),
raw: true,
})
if (!connectRes.ok) return { attempted: true, connected: false }

Expand All @@ -220,35 +220,33 @@ export default function Home() {
}

// Fetch current user
fetch('/api/auth/me')
.then(async (res) => {
if (res.ok) return res.json()
if (res.status === 401) {
router.replace(`/login?next=${encodeURIComponent(pathname)}`)
}
return null
})
.then(data => { if (data?.user) setCurrentUser(data.user); markStep('auth') })
.catch(() => { markStep('auth') })

// Check for available updates
fetch('/api/releases/check')
.then(res => res.ok ? res.json() : null)
.then(data => {
;(async () => {
try {
const data = await apiFetch<any>('/api/auth/me')
if (data?.user) setCurrentUser(data.user)
} catch {
// 401 handled by apiFetch redirect; other errors silently ignored
}
markStep('auth')
})()

;(async () => {
try {
const data = await apiFetch<any>('/api/releases/check')
if (data?.updateAvailable) {
setUpdateAvailable({
latestVersion: data.latestVersion,
releaseUrl: data.releaseUrl,
releaseNotes: data.releaseNotes,
})
}
})
.catch(() => {})
} catch {}
})()

// Check for OpenClaw updates
fetch('/api/openclaw/version')
.then(res => res.ok ? res.json() : null)
.then(data => {
;(async () => {
try {
const data = await apiFetch<any>('/api/openclaw/version')
if (data?.updateAvailable) {
setOpenclawUpdate({
installed: data.installed,
Expand All @@ -260,13 +258,13 @@ export default function Home() {
} else {
setOpenclawUpdate(null)
}
})
.catch(() => {})
} catch {}
})()

// Check capabilities, then conditionally connect to gateway
fetch('/api/status?action=capabilities')
.then(res => res.ok ? res.json() : null)
.then(async data => {
;(async () => {
try {
const data = await apiFetch<any>('/api/status?action=capabilities')
const localGatewayUrl = localStorage.getItem(STORAGE_GATEWAY_URL)

if (data?.subscription) {
Expand Down Expand Up @@ -321,19 +319,19 @@ export default function Home() {
connectWithEnvFallback(null)
}
markStep('connect')
})
.catch(() => {
} catch {
// If capabilities check fails, still try to connect
setCapabilitiesChecked(true)
markStep('capabilities')
markStep('connect')
connectWithEnvFallback(null)
})
}
})()

// Check onboarding state
fetch('/api/onboarding')
.then(res => res.ok ? res.json() : null)
.then(data => {
;(async () => {
try {
const data = await apiFetch<any>('/api/onboarding')
const decision = getOnboardingSessionDecision({
isAdmin: data?.isAdmin === true,
serverShowOnboarding: data?.showOnboarding === true,
Expand All @@ -352,46 +350,44 @@ export default function Home() {
setShowOnboarding(true)
}
markStep('config')
})
.catch(() => { markStep('config') })
} catch { markStep('config') }
})()
// Preload workspace data in parallel
Promise.allSettled([
fetch('/api/agents')
.then(r => r.ok ? r.json() : null)
.then((agentsData) => {
(async () => {
try {
const agentsData = await apiFetch<any>('/api/agents')
if (agentsData?.agents) setAgents(agentsData.agents)
})
.finally(() => { markStep('agents') }),
} finally { markStep('agents') }
})(),
// Sessions can be slow with many JSONL files — don't block boot
(() => {
(async () => {
markStep('sessions')
return fetch('/api/sessions')
.then(r => r.ok ? r.json() : null)
.then((sessionsData) => {
if (sessionsData?.sessions) setSessions(sessionsData.sessions)
})
try {
const sessionsData = await apiFetch<any>('/api/sessions')
if (sessionsData?.sessions) setSessions(sessionsData.sessions)
} catch {}
})(),
fetch('/api/projects')
.then(r => r.ok ? r.json() : null)
.then((projectsData) => {
(async () => {
try {
const projectsData = await apiFetch<any>('/api/projects')
if (projectsData?.projects) setProjects(projectsData.projects)
})
.finally(() => { markStep('projects') }),
} finally { markStep('projects') }
})(),
// Memory graph can be slow — don't block boot
(() => {
(async () => {
markStep('memory')
return fetch('/api/memory/graph?agent=all')
.then(r => r.ok ? r.json() : null)
.then((graphData) => {
if (graphData?.agents) setMemoryGraphAgents(graphData.agents)
})
try {
const graphData = await apiFetch<any>('/api/memory/graph?agent=all')
if (graphData?.agents) setMemoryGraphAgents(graphData.agents)
} catch {}
})(),
fetch('/api/skills')
.then(r => r.ok ? r.json() : null)
.then((skillsData) => {
(async () => {
try {
const skillsData = await apiFetch<any>('/api/skills')
if (skillsData?.skills) setSkillsData(skillsData.skills, skillsData.groups || [], skillsData.total || 0)
})
.finally(() => { markStep('skills') }),
} finally { markStep('skills') }
})(),
]).catch(() => { /* panels will lazy-load as fallback */ })

// eslint-disable-next-line react-hooks/exhaustive-deps -- boot once on mount, not on every pathname change
Expand Down Expand Up @@ -499,7 +495,7 @@ function ContentRouter({ tab }: { tab: string }) {
size="sm"
onClick={async () => {
setInterfaceMode('full')
try { await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ settings: { 'general.interface_mode': 'full' } }) }) } catch {}
try { await apiFetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ settings: { 'general.interface_mode': 'full' } }) }) } catch {}
}}
>
{tp('switchToFull')}
Expand Down
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
}
21 changes: 11 additions & 10 deletions src/components/chat/chat-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { SessionMessage, shouldShowTimestamp, type SessionTranscriptMessage } fr
import { getSessionKindLabel, SessionKindAvatar } from './session-kind-brand'
import { TerminalView } from '@/components/terminal/terminal-view'
import { SplitPaneLayout, type SplitPane } from '@/components/terminal/split-pane-layout'
import { apiFetch } from '@/lib/api-client'

const log = createClientLogger('ChatWorkspace')

Expand Down Expand Up @@ -79,9 +80,7 @@ export function ChatWorkspace({ mode = 'embedded', onClose }: ChatWorkspaceProps
useEffect(() => {
async function loadAgents() {
try {
const res = await fetch('/api/agents')
if (!res.ok) return
const data = await res.json()
const data = await apiFetch<any>('/api/agents')
if (data.agents) setAgents(data.agents)
} catch (err) {
log.error('Failed to load agents:', err)
Expand All @@ -100,9 +99,7 @@ export function ChatWorkspace({ mode = 'embedded', onClose }: ChatWorkspaceProps
}

try {
const res = await fetch(`/api/chat/messages?conversation_id=${encodeURIComponent(activeConversation)}&limit=100`)
if (!res.ok) return
const data = await res.json()
const data = await apiFetch<any>(`/api/chat/messages?conversation_id=${encodeURIComponent(activeConversation)}&limit=100`)
if (data.messages) setChatMessages(data.messages)
} catch (err) {
log.error('Failed to load messages:', err)
Expand Down Expand Up @@ -164,7 +161,7 @@ export function ChatWorkspace({ mode = 'embedded', onClose }: ChatWorkspaceProps
setIsGenerating(true)

try {
const res = await fetch('/api/chat/messages', {
const res = await apiFetch<Response>('/api/chat/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Expand All @@ -176,6 +173,7 @@ export function ChatWorkspace({ mode = 'embedded', onClose }: ChatWorkspaceProps
attachments,
forward: true,
}),
raw: true,
})

if (res.ok) {
Expand Down Expand Up @@ -287,10 +285,11 @@ export function ChatWorkspace({ mode = 'embedded', onClose }: ChatWorkspaceProps
color: payload.colorTag || null,
}

const res = await fetch('/api/chat/session-prefs', {
const res = await apiFetch<Response>('/api/chat/session-prefs', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
raw: true,
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
Expand Down Expand Up @@ -587,7 +586,7 @@ function SessionConversationView({
if (isGatewaySession) {
// Gateway sessions: forward message to the agent via chat messages API
const agentName = session.agent || session.sessionId.split(':')[1] || 'unknown'
const res = await fetch('/api/chat/messages', {
const res = await apiFetch<Response>('/api/chat/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Expand All @@ -599,6 +598,7 @@ function SessionConversationView({
forward: true,
sessionKey: session.sessionKey || undefined,
}),
raw: true,
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
Expand All @@ -612,14 +612,15 @@ function SessionConversationView({
// Refresh transcript after a short delay to capture the response
setTimeout(() => onRefreshTranscript(), 2000)
} else {
const res = await fetch('/api/sessions/continue', {
const res = await apiFetch<Response>('/api/sessions/continue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind: session.sessionKind,
id: session.sessionId,
prompt,
}),
raw: true,
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
Expand Down
Loading