diff --git a/eslint.config.mjs b/eslint.config.mjs index fc93951080..57ad41f70f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -18,6 +18,37 @@ const config = [ 'react-hooks/immutability': 'off', }, }, + // P1.5 — Discourage bare `fetch('/api/...')`. + // The team must migrate to `apiFetch()` 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() 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() from '@/lib/api-client' instead of bare fetch(`/api/...`). It handles 401 redirect, 403/5xx typed errors, and network failures uniformly.", + }, + ], + }, + }, ] export default config diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e80a841622..70401fa04d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ @@ -114,6 +115,7 @@ export default async function RootLayout({ disableTransitionOnChange > +
{children}
diff --git a/src/components/auth-expired-listener.tsx b/src/components/auth-expired-listener.tsx new file mode 100644 index 0000000000..f376983084 --- /dev/null +++ b/src/components/auth-expired-listener.tsx @@ -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 +} diff --git a/src/components/panels/task-board-panel.tsx b/src/components/panels/task-board-panel.tsx index 9638ed8390..f0ca38bd75 100644 --- a/src/components/panels/task-board-panel.tsx +++ b/src/components/panels/task-board-panel.tsx @@ -7,6 +7,7 @@ import { useMissionControl } from '@/store' import { useSmartPoll } from '@/lib/use-smart-poll' import { createClientLogger } from '@/lib/client-logger' +import { apiFetch } from '@/lib/api-client' import { useFocusTrap } from '@/lib/use-focus-trap' @@ -135,11 +136,10 @@ function useAgentSessions(agentName: string | undefined) { useEffect(() => { if (!agentName) { setSessions([]); return } let cancelled = false - fetch('/api/sessions') - .then(r => r.json()) + apiFetch<{ sessions?: Array<{ key: string; id: string; agent?: string; channel?: string; kind?: string; label?: string; active?: boolean }> }>('/api/sessions') .then(data => { if (cancelled) return - const all = (data.sessions || []) as Array<{ key: string; id: string; agent?: string; channel?: string; kind?: string; label?: string; active?: boolean }> + const all = data.sessions || [] const filtered = all.filter(s => s.agent?.toLowerCase() === agentName.toLowerCase() || s.key?.toLowerCase().includes(agentName.toLowerCase()) @@ -173,9 +173,7 @@ function useMentionTargets() { let cancelled = false const run = async () => { try { - const response = await fetch('/api/mentions?limit=200') - if (!response.ok) return - const data = await response.json() + const data = await apiFetch<{ mentions?: MentionOption[] }>('/api/mentions?limit=200') if (!cancelled) setMentionTargets(data.mentions || []) } catch { // mention autocomplete is non-critical @@ -340,10 +338,11 @@ function DunkItButton({ taskId, onDunked }: { taskId: number; onDunked: (id: num e.stopPropagation() if (phase !== 'idle') return try { - const res = await fetch(`/api/tasks/${taskId}`, { + const res = await apiFetch(`/api/tasks/${taskId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'done' }), + raw: true, }) if (!res.ok) throw new Error('Failed') setPhase('success') @@ -454,20 +453,12 @@ export function TaskBoardPanel() { } const tasksUrl = tasksQuery.toString() ? `/api/tasks?${tasksQuery.toString()}` : '/api/tasks' - const [tasksResponse, agentsResponse, projectsResponse] = await Promise.all([ - fetch(tasksUrl), - fetch('/api/agents'), - fetch('/api/projects') + const [tasksData, agentsData, projectsData] = await Promise.all([ + apiFetch<{ tasks?: Task[] }>(tasksUrl), + apiFetch<{ agents?: Agent[] }>('/api/agents'), + apiFetch<{ projects?: Project[] }>('/api/projects') ]) - if (!tasksResponse.ok || !agentsResponse.ok || !projectsResponse.ok) { - throw new Error('Failed to fetch data') - } - - const tasksData = await tasksResponse.json() - const agentsData = await agentsResponse.json() - const projectsData = await projectsResponse.json() - const tasksList = tasksData.tasks || [] const taskIds = tasksList.map((task: Task) => task.id) @@ -477,8 +468,7 @@ export function TaskBoardPanel() { setProjects(projectsData.projects || []) if (taskIds.length > 0) { - fetch(`/api/quality-review?taskIds=${taskIds.join(',')}`) - .then((reviewResponse) => reviewResponse.ok ? reviewResponse.json() : null) + apiFetch<{ latest?: Record }>(`/api/quality-review?taskIds=${taskIds.join(',')}`) .then((reviewData) => { const latest = reviewData?.latest || {} const newAegisMap: Record = Object.fromEntries( @@ -508,8 +498,7 @@ export function TaskBoardPanel() { // Fetch GNAP status useEffect(() => { - fetch('/api/gnap') - .then(r => r.ok ? r.json() : null) + apiFetch('/api/gnap') .then(data => { if (data) setGnapStatus(data) }) .catch(() => {}) }, []) @@ -517,7 +506,7 @@ export function TaskBoardPanel() { const handleGnapSync = useCallback(async () => { setGnapSyncing(true) try { - const res = await fetch('/api/gnap?action=sync', { method: 'POST' }) + const res = await apiFetch('/api/gnap?action=sync', { method: 'POST', raw: true }) if (res.ok) { const data = await res.json() setGnapStatus(prev => prev ? { ...prev, taskCount: data.pushed, lastSync: data.lastSync } : prev) @@ -604,11 +593,7 @@ export function TaskBoardPanel() { try { if (newStatus === 'done') { - const reviewResponse = await fetch(`/api/quality-review?taskId=${draggedTask.id}`) - if (!reviewResponse.ok) { - throw new Error('Unable to verify Aegis approval') - } - const reviewData = await reviewResponse.json() + const reviewData = await apiFetch<{ reviews?: any[] }>(`/api/quality-review?taskId=${draggedTask.id}`) const latest = reviewData.reviews?.find((review: any) => review.reviewer === 'aegis') if (!latest || latest.status !== 'approved') { throw new Error('Aegis approval is required before moving to done') @@ -622,12 +607,13 @@ export function TaskBoardPanel() { }) // Update on server - const response = await fetch('/api/tasks', { + const response = await apiFetch('/api/tasks', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tasks: [{ id: draggedTask.id, status: newStatus }] - }) + }), + raw: true, }) if (!response.ok) { @@ -677,12 +663,11 @@ export function TaskBoardPanel() { }) try { - const response = await fetch('/api/spawn', { + const result = await apiFetch<{ success?: boolean; agent?: string; sessionKey?: string; sessionInfo?: any; error?: string }>('/api/spawn', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(spawnFormData), }) - const result = await response.json() if (result.success) { updateSpawnRequest(spawnId, { @@ -1235,9 +1220,7 @@ function TaskDetailModal({ const fetchReviews = useCallback(async () => { try { - const response = await fetch(`/api/quality-review?taskId=${task.id}`) - if (!response.ok) throw new Error('Failed to fetch reviews') - const data = await response.json() + const data = await apiFetch<{ reviews: any[] }>(`/api/quality-review?taskId=${task.id}`) setReviews(data.reviews || []) } catch (error) { setReviewError('Failed to load quality reviews') @@ -1247,9 +1230,7 @@ function TaskDetailModal({ const fetchComments = useCallback(async () => { try { setLoadingComments(true) - const response = await fetch(`/api/tasks/${task.id}/comments`) - if (!response.ok) throw new Error('Failed to fetch comments') - const data = await response.json() + const data = await apiFetch<{ comments: any[] }>(`/api/tasks/${task.id}/comments`) setComments(data.comments || []) } catch (error) { setCommentError('Failed to load comments') @@ -1273,13 +1254,14 @@ function TaskDetailModal({ try { setCommentError(null) - const response = await fetch(`/api/tasks/${task.id}/comments`, { + const response = await apiFetch(`/api/tasks/${task.id}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ author: commentAuthor || 'system', content: commentText - }) + }), + raw: true, }) if (!response.ok) throw new Error('Failed to add comment') setCommentText('') @@ -1296,13 +1278,14 @@ function TaskDetailModal({ try { setBroadcastStatus(null) - const response = await fetch(`/api/tasks/${task.id}/broadcast`, { + const response = await apiFetch(`/api/tasks/${task.id}/broadcast`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ author: commentAuthor || 'system', message: broadcastMessage - }) + }), + raw: true, }) const data = await response.json() if (!response.ok) throw new Error(data.error || 'Broadcast failed') @@ -1317,7 +1300,7 @@ function TaskDetailModal({ e.preventDefault() try { setReviewError(null) - const response = await fetch('/api/quality-review', { + const response = await apiFetch('/api/quality-review', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -1325,7 +1308,8 @@ function TaskDetailModal({ reviewer, status: reviewStatus, notes: reviewNotes - }) + }), + raw: true, }) const data = await response.json() if (!response.ok) throw new Error(data.error || 'Failed to submit review') @@ -1460,7 +1444,7 @@ function TaskDetailModal({ onClick={async () => { if (!confirm(t('deleteTaskConfirm', { title: task.title }))) return try { - const res = await fetch(`/api/tasks/${task.id}`, { method: 'DELETE' }) + const res = await apiFetch(`/api/tasks/${task.id}`, { method: 'DELETE', raw: true }) if (!res.ok) { const errorData = await res.json().catch(() => ({ error: 'Failed to delete task' })) throw new Error(errorData.error || 'Failed to delete task') @@ -1500,10 +1484,11 @@ function TaskDetailModal({