From 34b5f7f5ad78501ad47bdd3ce64347232b1befd9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 18 May 2026 17:14:41 +0000 Subject: [PATCH 1/3] Harden server fetch JSON parsing Co-authored-by: Neil Raina --- src/app/(home)/page.tsx | 13 ++-- src/app/_fetchers/AllTasksFetcher.tsx | 4 +- src/app/_fetchers/AssigneeFetcher.tsx | 28 ++++---- src/app/_fetchers/TemplatesFetcher.tsx | 4 +- src/app/_fetchers/WorkflowStateFetcher.tsx | 12 ++-- .../_fetchers/fetchWithErrorHandler.test.ts | 49 +++++++++++++ src/app/_fetchers/fetchWithErrorHandler.ts | 71 +++++++++++++++++-- src/app/client/page.tsx | 15 ++-- src/app/configure-tasks-app/page.tsx | 32 +++++---- src/app/detail/[task_id]/[user_type]/page.tsx | 9 ++- .../manage-templates/[template_id]/page.tsx | 4 +- 11 files changed, 177 insertions(+), 64 deletions(-) create mode 100644 src/app/_fetchers/fetchWithErrorHandler.test.ts diff --git a/src/app/(home)/page.tsx b/src/app/(home)/page.tsx index c5c15d0e3..5f6681ff7 100644 --- a/src/app/(home)/page.tsx +++ b/src/app/(home)/page.tsx @@ -28,11 +28,13 @@ import { RealTimeTemplates } from '@/hoc/RealtimeTemplates' export const maxDuration = 300 export async function getAllWorkflowStates(token: string): Promise { - const res = await fetch(`${apiUrl}/api/workflow-states?token=${token}`, { - next: { tags: ['getAllWorkflowStates'] }, - }) + const data = await fetchWithErrorHandler<{ workflowStates: WorkflowStateResponse[] }>( + `${apiUrl}/api/workflow-states?token=${token}`, + { + next: { tags: ['getAllWorkflowStates'] }, + }, + ) - const data = await res.json() return data.workflowStates } @@ -67,10 +69,9 @@ export async function getWorkspace(token: string): Promise { } export async function getViewSettings(token: string): Promise { - const res = await fetch(`${apiUrl}/api/view-settings?token=${token}`, { + const resp = await fetchWithErrorHandler(`${apiUrl}/api/view-settings?token=${token}`, { next: { tags: ['getViewSettings'] }, }) - const resp = await res.json() return resp } diff --git a/src/app/_fetchers/AllTasksFetcher.tsx b/src/app/_fetchers/AllTasksFetcher.tsx index f223a76f9..c0ab871d2 100644 --- a/src/app/_fetchers/AllTasksFetcher.tsx +++ b/src/app/_fetchers/AllTasksFetcher.tsx @@ -1,14 +1,14 @@ export const fetchCache = 'force-no-store' import { apiUrl } from '@/config' +import { fetchWithErrorHandler } from '@/app/_fetchers/fetchWithErrorHandler' import { ClientSideStateUpdate } from '@/hoc/ClientSideStateUpdate' import { TaskResponse } from '@/types/dto/tasks.dto' import { PropsWithToken } from '@/types/interfaces' import { PropsWithChildren } from 'react' const getAllAccessibleTasks = async (token: string): Promise => { - const res = await fetch(`${apiUrl}/api/tasks?token=${token}&all=1`) - const { tasks } = await res.json() + const { tasks } = await fetchWithErrorHandler<{ tasks: TaskResponse[] }>(`${apiUrl}/api/tasks?token=${token}&all=1`) return tasks } diff --git a/src/app/_fetchers/AssigneeFetcher.tsx b/src/app/_fetchers/AssigneeFetcher.tsx index c47d0ac5d..96ced88db 100644 --- a/src/app/_fetchers/AssigneeFetcher.tsx +++ b/src/app/_fetchers/AssigneeFetcher.tsx @@ -1,6 +1,7 @@ export const fetchCache = 'force-no-store' import { AssigneeCacheSetter } from '@/app/_cache/AssigneeCacheSetter' +import { fetchWithErrorHandler } from '@/app/_fetchers/fetchWithErrorHandler' import { apiUrl } from '@/config' import { MAX_FETCH_ASSIGNEE_COUNT } from '@/constants/users' import { ClientSideStateUpdate } from '@/hoc/ClientSideStateUpdate' @@ -9,9 +10,6 @@ import { TaskResponse } from '@/types/dto/tasks.dto' import { CreateViewSettingsDTO } from '@/types/dto/viewSettings.dto' import { IAssignee, PropsWithToken, UserType } from '@/types/interfaces' import { addTypeToAssignee } from '@/utils/addTypeToAssignee' -import fetchRetry from 'fetch-retry' - -const fetchWithRetry = fetchRetry(globalThis.fetch) interface AssigneeFetcherProps extends PropsWithToken { viewSettings?: CreateViewSettingsDTO @@ -23,21 +21,23 @@ interface AssigneeFetcherProps extends PropsWithToken { const fetchAssignee = async (token: string, userType?: UserType, isPreview?: boolean): Promise => { if (userType === UserType.CLIENT_USER && !isPreview) { - const res = await fetchWithRetry(`${apiUrl}/api/users/client?token=${token}&limit=${MAX_FETCH_ASSIGNEE_COUNT}`, { - next: { tags: ['getAssigneeList'] }, - retries: 3, - retryDelay: 100, - }) - - const data = await res.json() + const data = await fetchWithErrorHandler<{ clients: IAssignee }>( + `${apiUrl}/api/users/client?token=${token}&limit=${MAX_FETCH_ASSIGNEE_COUNT}`, + { + next: { tags: ['getAssigneeList'] }, + }, + ) return data.clients } - const res = await fetch(`${apiUrl}/api/users?token=${token}&limit=${MAX_FETCH_ASSIGNEE_COUNT}`, { - next: { tags: ['getAssigneeList'] }, - }) - return (await res.json()).users as IAssignee + const data = await fetchWithErrorHandler<{ users: IAssignee }>( + `${apiUrl}/api/users?token=${token}&limit=${MAX_FETCH_ASSIGNEE_COUNT}`, + { + next: { tags: ['getAssigneeList'] }, + }, + ) + return data.users } export const AssigneeFetcher = async ({ token, diff --git a/src/app/_fetchers/TemplatesFetcher.tsx b/src/app/_fetchers/TemplatesFetcher.tsx index 55cc47c5a..05917be36 100644 --- a/src/app/_fetchers/TemplatesFetcher.tsx +++ b/src/app/_fetchers/TemplatesFetcher.tsx @@ -1,15 +1,15 @@ export const fetchCache = 'force-no-store' import { apiUrl } from '@/config' +import { fetchWithErrorHandler } from '@/app/_fetchers/fetchWithErrorHandler' import { ClientSideStateUpdate } from '@/hoc/ClientSideStateUpdate' import { ITemplate, PropsWithToken } from '@/types/interfaces' import { PropsWithChildren } from 'react' const getAllTemplates = async (token: string): Promise => { - const res = await fetch(`${apiUrl}/api/tasks/templates?token=${token}`, { + const { data } = await fetchWithErrorHandler<{ data: ITemplate[] }>(`${apiUrl}/api/tasks/templates?token=${token}`, { next: { tags: ['getAllTemplates'] }, }) - const { data } = await res.json() return data } diff --git a/src/app/_fetchers/WorkflowStateFetcher.tsx b/src/app/_fetchers/WorkflowStateFetcher.tsx index 3b7a5b777..86f07a25b 100644 --- a/src/app/_fetchers/WorkflowStateFetcher.tsx +++ b/src/app/_fetchers/WorkflowStateFetcher.tsx @@ -1,6 +1,7 @@ export const fetchCache = 'force-no-store' import { apiUrl } from '@/config' +import { fetchWithErrorHandler } from '@/app/_fetchers/fetchWithErrorHandler' import { ClientSideStateUpdate } from '@/hoc/ClientSideStateUpdate' import { TaskResponse } from '@/types/dto/tasks.dto' import { WorkflowStateResponse } from '@/types/dto/workflowStates.dto' @@ -12,11 +13,12 @@ interface WorkflowStateFetcherProps extends PropsWithToken, PropsWithChildren { } const getAllWorkflowStates = async (token: string): Promise => { - const res = await fetch(`${apiUrl}/api/workflow-states?token=${token}`, { - next: { tags: ['getAllWorkflowStates'] }, - }) - - const data = await res.json() + const data = await fetchWithErrorHandler<{ workflowStates: WorkflowStateResponse[] }>( + `${apiUrl}/api/workflow-states?token=${token}`, + { + next: { tags: ['getAllWorkflowStates'] }, + }, + ) return data.workflowStates } diff --git a/src/app/_fetchers/fetchWithErrorHandler.test.ts b/src/app/_fetchers/fetchWithErrorHandler.test.ts new file mode 100644 index 000000000..173ad3f80 --- /dev/null +++ b/src/app/_fetchers/fetchWithErrorHandler.test.ts @@ -0,0 +1,49 @@ +import { fetchWithErrorHandler } from '@/app/_fetchers/fetchWithErrorHandler' + +describe('fetchWithErrorHandler', () => { + const originalFetch = global.fetch + + afterEach(() => { + global.fetch = originalFetch + jest.clearAllMocks() + }) + + it('returns parsed JSON responses', async () => { + global.fetch = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + + await expect(fetchWithErrorHandler<{ ok: boolean }>('https://example.test/api?token=secret')).resolves.toEqual({ + ok: true, + }) + }) + + it('reports non-JSON success responses with a redacted URL', async () => { + global.fetch = jest.fn().mockResolvedValue( + new Response('An error occurred while rendering', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }), + ) + + await expect(fetchWithErrorHandler('https://example.test/api?token=secret')).rejects.toThrow( + 'Expected JSON response from https://example.test/api?token=[redacted] but received text/plain (200): An error occurred while rendering', + ) + }) + + it('reports non-OK responses with a redacted URL and body preview', async () => { + global.fetch = jest.fn().mockResolvedValue( + new Response('An error occurred during retry for token=secret', { + status: 500, + headers: { 'content-type': 'text/plain' }, + }), + ) + + await expect(fetchWithErrorHandler('https://example.test/api?token=secret')).rejects.toThrow( + 'Fetch failed (500) for https://example.test/api?token=[redacted]: An error occurred during retry for token=[redacted]', + ) + }) +}) diff --git a/src/app/_fetchers/fetchWithErrorHandler.ts b/src/app/_fetchers/fetchWithErrorHandler.ts index 2c1e6b9bf..a66dd9ce3 100644 --- a/src/app/_fetchers/fetchWithErrorHandler.ts +++ b/src/app/_fetchers/fetchWithErrorHandler.ts @@ -2,24 +2,83 @@ type FetchOptions = RequestInit & { next?: { tags?: string[] } } -export async function fetchWithErrorHandler(input: RequestInfo, options?: FetchOptions, retries = 3): Promise { +const ERROR_BODY_PREVIEW_LENGTH = 300 + +const getRequestUrl = (input: RequestInfo | URL): string => { + if (typeof input === 'string') return input + if (typeof URL !== 'undefined' && input instanceof URL) return input.toString() + if (typeof Request !== 'undefined' && input instanceof Request) return input.url + return String(input) +} + +const redactToken = (value: string): string => value.replace(/([?&]token=)[^&\s]+/g, '$1[redacted]') + +const getRedactedRequestUrl = (input: RequestInfo | URL): string => { + const url = getRequestUrl(input) + + try { + const parsedUrl = new URL(url, 'http://localhost') + if (parsedUrl.searchParams.has('token')) { + parsedUrl.searchParams.set('token', '[redacted]') + } + + if (url.startsWith('/')) return `${parsedUrl.pathname}${parsedUrl.search}` + return parsedUrl.toString().replaceAll('%5Bredacted%5D', '[redacted]') + } catch { + return redactToken(url) + } +} + +const getBodyPreview = (body: string): string => { + const preview = redactToken(body).replace(/\s+/g, ' ').trim() + return preview.length > ERROR_BODY_PREVIEW_LENGTH ? `${preview.slice(0, ERROR_BODY_PREVIEW_LENGTH)}...` : preview +} + +const getErrorBodyMessage = (body: string): string => { + if (!body) return 'Empty response body' + + try { + const parsed = JSON.parse(body) as { error?: unknown; message?: unknown } + if (typeof parsed.error === 'string') return parsed.error + if (typeof parsed.message === 'string') return parsed.message + return getBodyPreview(JSON.stringify(parsed)) + } catch { + return getBodyPreview(body) + } +} + +const parseJsonResponse = (body: string, res: Response, input: RequestInfo | URL): T => { + if (!body) return undefined as T + + try { + return JSON.parse(body) as T + } catch { + const contentType = res.headers.get('content-type') || 'unknown content type' + throw new Error( + `Expected JSON response from ${getRedactedRequestUrl(input)} but received ${contentType} (${res.status}): ${getBodyPreview( + body, + )}`, + ) + } +} + +export async function fetchWithErrorHandler(input: RequestInfo | URL, options?: FetchOptions, retries = 3): Promise { let lastError: any for (let attempt = 0; attempt <= retries; attempt++) { try { const res = await fetch(input, options) + const body = await res.text() if (res.status === 500) { - throw new Error('Internal server error.') + throw new Error(`Fetch failed (${res.status}) for ${getRedactedRequestUrl(input)}: ${getErrorBodyMessage(body)}`) } if (!res.ok) { - const text = await res.text() - throw new Error(`Fetch failed (${res.status}): ${text}`) + throw new Error(`Fetch failed (${res.status}) for ${getRedactedRequestUrl(input)}: ${getErrorBodyMessage(body)}`) } - const data = await res.json() - return data + return parseJsonResponse(body, res, input) } catch (error) { lastError = error if (attempt < retries) { diff --git a/src/app/client/page.tsx b/src/app/client/page.tsx index 3cadb02e2..47f31de32 100644 --- a/src/app/client/page.tsx +++ b/src/app/client/page.tsx @@ -5,6 +5,7 @@ import { getViewSettings } from '@/app/(home)/page' import { AssigneeCacheGetter } from '@/app/_cache/AssigneeCacheGetter' import { AllTasksFetcher } from '@/app/_fetchers/AllTasksFetcher' import { AssigneeFetcher } from '@/app/_fetchers/AssigneeFetcher' +import { fetchWithErrorHandler } from '@/app/_fetchers/fetchWithErrorHandler' import { TemplatesFetcher } from '@/app/_fetchers/TemplatesFetcher' import { ValidateNotificationCountFetcher } from '@/app/_fetchers/ValidateNotificationCountFetcher' import { ModalNewTaskForm } from '@/app/ui/Modal_NewTaskForm' @@ -29,18 +30,18 @@ import { z } from 'zod' export const maxDuration = 300 //just to be safe. the validate count job might take longer that 15 seconds(default max duration of server components) which will make the app crash. Increasing the duration to 60. async function getAllWorkflowStates(token: string): Promise { - const res = await fetch(`${apiUrl}/api/workflow-states?token=${token}`, { - next: { tags: ['getAllWorkflowStates'] }, - }) - - const data = await res.json() + const data = await fetchWithErrorHandler<{ workflowStates: WorkflowStateResponse[] }>( + `${apiUrl}/api/workflow-states?token=${token}`, + { + next: { tags: ['getAllWorkflowStates'] }, + }, + ) return data.workflowStates } async function getAllTasks(token: string): Promise { - const res = await fetch(`${apiUrl}/api/tasks?token=${token}`) - const data = await res.json() + const data = await fetchWithErrorHandler<{ tasks: TaskResponse[] }>(`${apiUrl}/api/tasks?token=${token}`) return data.tasks } diff --git a/src/app/configure-tasks-app/page.tsx b/src/app/configure-tasks-app/page.tsx index 55707dfc1..d71eae180 100644 --- a/src/app/configure-tasks-app/page.tsx +++ b/src/app/configure-tasks-app/page.tsx @@ -3,6 +3,7 @@ export const fetchCache = 'force-no-store' import { AppMargin, SizeofAppMargin } from '@/hoc/AppMargin' import { TemplateBoard } from './ui/TemplateBoard' import { apiUrl } from '@/config' +import { fetchWithErrorHandler } from '@/app/_fetchers/fetchWithErrorHandler' import { WorkflowStateResponse } from '@/types/dto/workflowStates.dto' import { IAssignee, ITemplate } from '@/types/interfaces' import { addTypeToAssignee } from '@/utils/addTypeToAssignee' @@ -19,32 +20,32 @@ import { StatusCustomizationSection } from '@/app/configure-tasks-app/ui/StatusC import { Stack } from '@mui/material' async function getAllWorkflowStates(token: string): Promise { - const res = await fetch(`${apiUrl}/api/workflow-states?token=${token}`, { - next: { tags: ['getAllWorkflowStates'] }, - }) - - const data = await res.json() + const data = await fetchWithErrorHandler<{ workflowStates: WorkflowStateResponse[] }>( + `${apiUrl}/api/workflow-states?token=${token}`, + { + next: { tags: ['getAllWorkflowStates'] }, + }, + ) return data.workflowStates } async function getAssigneeList(token: string): Promise { - const res = await fetch(`${apiUrl}/api/users?token=${token}&limit=${MAX_FETCH_ASSIGNEE_COUNT}`, { - next: { tags: ['getAssigneeList'] }, - }) - - const data = await res.json() + const data = await fetchWithErrorHandler<{ users: IAssignee }>( + `${apiUrl}/api/users?token=${token}&limit=${MAX_FETCH_ASSIGNEE_COUNT}`, + { + next: { tags: ['getAssigneeList'] }, + }, + ) return data.users } async function getAllTemplates(token: string): Promise { - const res = await fetch(`${apiUrl}/api/tasks/templates?token=${token}`, { + const templates = await fetchWithErrorHandler<{ data: ITemplate[] }>(`${apiUrl}/api/tasks/templates?token=${token}`, { next: { tags: ['getAllTemplates'] }, }) - const templates = await res.json() - return templates.data } @@ -60,8 +61,9 @@ async function getWorkspace(token: string): Promise { } async function getWorkspaceSetting(token: string): Promise<{ autoArchiveAfterDays: number }> { - const res = await fetch(`${apiUrl}/api/workspace-settings?token=${token}`, { cache: 'no-store' }) - return await res.json() + return await fetchWithErrorHandler<{ autoArchiveAfterDays: number }>(`${apiUrl}/api/workspace-settings?token=${token}`, { + cache: 'no-store', + }) } interface ConfigureTasksAppPageProps { diff --git a/src/app/detail/[task_id]/[user_type]/page.tsx b/src/app/detail/[task_id]/[user_type]/page.tsx index 2c4000520..b6dcda0c3 100644 --- a/src/app/detail/[task_id]/[user_type]/page.tsx +++ b/src/app/detail/[task_id]/[user_type]/page.tsx @@ -69,14 +69,13 @@ async function getWorkspace(token: string): Promise { } async function getSubTasksStatus(token: string, taskId: string): Promise { - const res = await fetch(`${apiUrl}/api/tasks/${taskId}/subtask-count?token=${token}`, {}) - const data = await res.json() - return data + return await fetchWithErrorHandler(`${apiUrl}/api/tasks/${taskId}/subtask-count?token=${token}`, {}) } async function getTaskPath(token: string, taskId: string): Promise { - const res = await fetch(`${apiUrl}/api/tasks/${taskId}/path?token=${token}`) - const { path } = await res.json() + const { path } = await fetchWithErrorHandler<{ path: AncestorTaskResponse[] }>( + `${apiUrl}/api/tasks/${taskId}/path?token=${token}`, + ) return path } diff --git a/src/app/manage-templates/[template_id]/page.tsx b/src/app/manage-templates/[template_id]/page.tsx index 5c2f98e74..1da36dd00 100644 --- a/src/app/manage-templates/[template_id]/page.tsx +++ b/src/app/manage-templates/[template_id]/page.tsx @@ -1,4 +1,5 @@ import { getAllWorkflowStates, getTokenPayload, getWorkspace } from '@/app/(home)/page' +import { fetchWithErrorHandler } from '@/app/_fetchers/fetchWithErrorHandler' import { ResponsiveStack } from '@/app/detail/ui/ResponsiveStack' import { apiUrl } from '@/config' import { ClientSideStateUpdate } from '@/hoc/ClientSideStateUpdate' @@ -21,11 +22,10 @@ import { AppMargin, SizeofAppMargin } from '@/hoc/AppMargin' import { getPreviewMode } from '@/utils/previewMode' async function getTemplate(id: string, token: string): Promise { - const res = await fetch(`${apiUrl}/api/tasks/templates/${id}?token=${token}`, { + const templates = await fetchWithErrorHandler<{ data: ITemplate }>(`${apiUrl}/api/tasks/templates/${id}?token=${token}`, { cache: 'no-store', }) - const templates = await res.json() return templates.data } From 6778d31355d50d22afa7648879073485cd95525e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 18 May 2026 17:15:40 +0000 Subject: [PATCH 2/3] Stabilize fetch helper tests Co-authored-by: Neil Raina --- src/app/_fetchers/fetchWithErrorHandler.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/_fetchers/fetchWithErrorHandler.test.ts b/src/app/_fetchers/fetchWithErrorHandler.test.ts index 173ad3f80..467637b7f 100644 --- a/src/app/_fetchers/fetchWithErrorHandler.test.ts +++ b/src/app/_fetchers/fetchWithErrorHandler.test.ts @@ -29,7 +29,7 @@ describe('fetchWithErrorHandler', () => { }), ) - await expect(fetchWithErrorHandler('https://example.test/api?token=secret')).rejects.toThrow( + await expect(fetchWithErrorHandler('https://example.test/api?token=secret', undefined, 0)).rejects.toThrow( 'Expected JSON response from https://example.test/api?token=[redacted] but received text/plain (200): An error occurred while rendering', ) }) @@ -42,7 +42,7 @@ describe('fetchWithErrorHandler', () => { }), ) - await expect(fetchWithErrorHandler('https://example.test/api?token=secret')).rejects.toThrow( + await expect(fetchWithErrorHandler('https://example.test/api?token=secret', undefined, 0)).rejects.toThrow( 'Fetch failed (500) for https://example.test/api?token=[redacted]: An error occurred during retry for token=[redacted]', ) }) From c17d1f3052cb2d0f57a9f3c0e1b824b389baf119 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 18 May 2026 17:16:31 +0000 Subject: [PATCH 3/3] Redact token values from fetch errors Co-authored-by: Neil Raina --- src/app/_fetchers/fetchWithErrorHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/_fetchers/fetchWithErrorHandler.ts b/src/app/_fetchers/fetchWithErrorHandler.ts index a66dd9ce3..f5fa0b956 100644 --- a/src/app/_fetchers/fetchWithErrorHandler.ts +++ b/src/app/_fetchers/fetchWithErrorHandler.ts @@ -11,7 +11,7 @@ const getRequestUrl = (input: RequestInfo | URL): string => { return String(input) } -const redactToken = (value: string): string => value.replace(/([?&]token=)[^&\s]+/g, '$1[redacted]') +const redactToken = (value: string): string => value.replace(/(\btoken=)[^&\s]+/g, '$1[redacted]') const getRedactedRequestUrl = (input: RequestInfo | URL): string => { const url = getRequestUrl(input)