diff --git a/eslint.config.mjs b/eslint.config.mjs index c634629..2dac3b5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,6 +17,8 @@ const DYNAMIC_LOGGER_CALL_SELECTOR = const LOGGER_RECEIVER_SELECTOR = ":matches([callee.object.name=/^(logger|serverLogger)$/],[callee.object.property.name='logger'])"; const SERVER_LOGGER_CALL_SELECTOR = `:matches(${STATIC_LOGGER_CALL_SELECTOR}${LOGGER_RECEIVER_SELECTOR},${DYNAMIC_LOGGER_CALL_SELECTOR}${LOGGER_RECEIVER_SELECTOR})`; +const NEXT_RESPONSE_ERROR_JSON_SELECTOR = + "CallExpression[callee.type='MemberExpression'][callee.object.name='NextResponse'][callee.property.name='json'][arguments.0.type='ObjectExpression']:has(Property[key.name='error'])"; const eslintConfig = [ ...compat.extends("next/core-web-vitals", "next/typescript"), @@ -87,6 +89,11 @@ const eslintConfig = [ message: "Do not use top-level `detail` in server logger context; keep throwable text under `error.message`.", }, + { + selector: `${SERVER_LOGGER_CALL_SELECTOR} > ObjectExpression:first-child > Property[key.name='errorCode']`, + message: + "Do not use top-level `errorCode` in server logger context; classify failures under nested `error.code`.", + }, { selector: `${SERVER_LOGGER_CALL_SELECTOR} > ObjectExpression:first-child > Property[key.name='err']`, message: @@ -102,6 +109,11 @@ const eslintConfig = [ message: "Server logger `error` must be a structured object (prefer `errorToLog(...)`), not template text.", }, + { + selector: `CatchClause ReturnStatement > ${NEXT_RESPONSE_ERROR_JSON_SELECTOR}[arguments.1.type='ObjectExpression']:has(Property[key.name='status'][value.value=500])`, + message: + "Use shared error response helpers (e.g. errorResponse(...)) for terminal 500 route failures instead of direct NextResponse.json({ error }).", + }, ], }, }, diff --git a/package.json b/package.json index 74bcfa2..f509a63 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "compute:worker:dev": "pnpm --filter @openreader/compute-worker dev", "compute:worker:start": "pnpm --filter @openreader/compute-worker start", "compute:dev:compose": "docker compose --env-file compute/worker/.env -f compute/worker/docker-compose.yml up --build", - "compute:dev:watch": "docker compose --env-file compute/worker/.env -f compute/worker/docker-compose.yml up --watch" + "compute:dev:watch": "docker compose --env-file compute/worker/.env -f compute/worker/docker-compose.yml up --watch", + "lint:route-errors": "node scripts/check-route-error-responses.mjs" }, "dependencies": { "@aws-sdk/client-s3": "^3.1045.0", diff --git a/scripts/check-route-error-responses.mjs b/scripts/check-route-error-responses.mjs new file mode 100644 index 0000000..8770958 --- /dev/null +++ b/scripts/check-route-error-responses.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const ROOT = process.cwd(); +const API_DIR = path.join(ROOT, 'src', 'app', 'api'); + +async function walk(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...await walk(full)); + continue; + } + if (entry.isFile() && full.endsWith('.ts')) files.push(full); + } + return files; +} + +function findViolations(source) { + const violations = []; + const catchWithDirectJson500 = + /catch\s*\(\s*error\s*\)\s*\{[\s\S]{0,1200}?NextResponse\.json\s*\(\s*\{\s*error:[\s\S]{0,240}status:\s*500/g; + let match; + while ((match = catchWithDirectJson500.exec(source)) !== null) { + const index = match.index; + const line = source.slice(0, index).split('\n').length; + violations.push(line); + } + return violations; +} + +const files = await walk(API_DIR); +const failures = []; +for (const file of files) { + const source = await fs.readFile(file, 'utf8'); + const lines = findViolations(source); + if (lines.length > 0) { + failures.push({ file, lines }); + } +} + +if (failures.length > 0) { + for (const failure of failures) { + for (const line of failure.lines) { + console.error(`${path.relative(ROOT, failure.file)}:${line} direct NextResponse.json({ error }) in catch`); + } + } + process.exit(1); +} + +console.log('Route error response check passed.'); diff --git a/src/app/api/account/delete/route.ts b/src/app/api/account/delete/route.ts index c54505b..740aae5 100644 --- a/src/app/api/account/delete/route.ts +++ b/src/app/api/account/delete/route.ts @@ -5,6 +5,7 @@ import { isAuthEnabled } from '@/lib/server/auth/config'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { deleteUserStorageData } from '@/lib/server/user/data-cleanup'; import { errorToLog, hashForLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export async function DELETE() { if (!isAuthEnabled() || !auth) { @@ -51,9 +52,9 @@ export async function DELETE() { event: 'account.delete.failed', error: errorToLog(error), }, 'Failed to delete account'); - return NextResponse.json( - { error: 'Failed to delete account' }, - { status: 500 } - ); + return errorResponse(error, { + apiErrorMessage: 'Failed to delete account', + normalize: { code: 'ACCOUNT_DELETE_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/admin/providers/[id]/route.ts b/src/app/api/admin/providers/[id]/route.ts index a3be4fb..c3755f6 100644 --- a/src/app/api/admin/providers/[id]/route.ts +++ b/src/app/api/admin/providers/[id]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAdminContext } from '@/lib/server/auth/admin'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; import { AdminProviderError, deleteAdminProvider, @@ -47,7 +48,10 @@ export async function PUT( providerId: id, error: errorToLog(error), }, 'Admin provider update failed'); - return NextResponse.json({ error: 'Internal error' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Internal error', + normalize: { code: 'ADMIN_PROVIDERS_UPDATE_FAILED', errorClass: 'db' }, + }); } } @@ -71,6 +75,9 @@ export async function DELETE( providerId: id, error: errorToLog(error), }, 'Admin provider delete failed'); - return NextResponse.json({ error: 'Internal error' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Internal error', + normalize: { code: 'ADMIN_PROVIDERS_DELETE_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/admin/providers/route.ts b/src/app/api/admin/providers/route.ts index 778a6f7..96d8fa9 100644 --- a/src/app/api/admin/providers/route.ts +++ b/src/app/api/admin/providers/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAdminContext } from '@/lib/server/auth/admin'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; import { AdminProviderError, createAdminProvider, @@ -51,6 +52,9 @@ export async function POST(req: NextRequest) { event: 'admin.providers.create.failed', error: errorToLog(error), }, 'Admin provider create failed'); - return NextResponse.json({ error: 'Internal error' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Internal error', + normalize: { code: 'ADMIN_PROVIDERS_CREATE_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index 20b48e5..c2a2f27 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -44,6 +44,7 @@ import { coerceAudiobookGenerationSettings, type SharedProviderPolicyEntry, } from '@/lib/server/audiobooks/settings'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -340,7 +341,10 @@ export async function POST(request: NextRequest) { message: 'Invalid audiobook.meta.json settings payload', }, }, 'Invalid audiobook.meta.json settings payload'); - return NextResponse.json({ error: 'Invalid audiobook metadata settings' }, { status: 500 }); + return errorResponse(new Error('Invalid audiobook metadata settings payload'), { + apiErrorMessage: 'Invalid audiobook metadata settings', + normalize: { code: 'AUDIOBOOK_CHAPTER_META_SETTINGS_INVALID', errorClass: 'validation', httpStatus: 500 }, + }); } normalizedExistingSettings = normalizeNativeSpeedForSettings(existingResult.settings); existingSettingsNeedsMigration = existingResult.migrated; @@ -419,7 +423,7 @@ export async function POST(request: NextRequest) { step: 'persist_migrated_settings', bookId, storageUserId, - error: error instanceof Error ? error.message : String(error), + error: errorToLog(error), }, 'Failed to persist migrated audiobook metadata settings'); } } @@ -503,7 +507,10 @@ export async function POST(request: NextRequest) { } const provider = credResolved.provider; if (!isBuiltInTtsProviderId(provider)) { - return NextResponse.json({ error: `Unsupported TTS provider type: ${provider}` }, { status: 500 }); + return errorResponse(new Error(`Unsupported TTS provider type: ${provider}`), { + apiErrorMessage: `Unsupported TTS provider type: ${provider}`, + normalize: { code: 'AUDIOBOOK_CHAPTER_UNSUPPORTED_PROVIDER', errorClass: 'validation', httpStatus: 500 }, + }); } const openApiKey = credResolved.apiKey || 'none'; const openApiBaseUrl = credResolved.baseUrl; @@ -754,7 +761,10 @@ export async function POST(request: NextRequest) { event: 'audiobook.chapter.process.failed', error: errorToLog(error), }, 'Failed to process audio chapter'); - const response = NextResponse.json({ error: 'Failed to process audio chapter' }, { status: 500 }); + const response = errorResponse(error, { + apiErrorMessage: 'Failed to process audio chapter', + normalize: { code: 'AUDIOBOOK_CHAPTER_PROCESS_FAILED', errorClass: 'upstream' }, + }); attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); return response; } finally { @@ -852,7 +862,10 @@ export async function GET(request: NextRequest) { event: 'audiobook.chapter.download.failed', error: errorToLog(error), }, 'Failed to download chapter'); - return NextResponse.json({ error: 'Failed to download chapter' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Failed to download chapter', + normalize: { code: 'AUDIOBOOK_CHAPTER_DOWNLOAD_FAILED', errorClass: 'storage' }, + }); } } @@ -923,6 +936,9 @@ export async function DELETE(request: NextRequest) { event: 'audiobook.chapter.delete.failed', error: errorToLog(error), }, 'Failed to delete chapter'); - return NextResponse.json({ error: 'Failed to delete chapter' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Failed to delete chapter', + normalize: { code: 'AUDIOBOOK_CHAPTER_DELETE_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index d8e5aea..0b2a5da 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -8,6 +8,7 @@ import { db } from '@/db'; import { audiobooks, audiobookChapters } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth/auth'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; import { audiobookPrefix, deleteAudiobookObject, @@ -380,7 +381,10 @@ export async function GET(request: NextRequest) { event: 'audiobook.create.failed', error: errorToLog(error), }, 'Failed to create full audiobook'); - return NextResponse.json({ error: 'Failed to create full audiobook file' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Failed to create full audiobook file', + normalize: { code: 'AUDIOBOOK_CREATE_FAILED', errorClass: 'upstream' }, + }); } finally { if (workDir) await rm(workDir, { recursive: true, force: true }).catch(() => {}); } @@ -431,6 +435,9 @@ export async function DELETE(request: NextRequest) { event: 'audiobook.reset.failed', error: errorToLog(error), }, 'Failed to reset audiobook'); - return NextResponse.json({ error: 'Failed to reset audiobook' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Failed to reset audiobook', + normalize: { code: 'AUDIOBOOK_RESET_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/audiobook/status/route.ts b/src/app/api/audiobook/status/route.ts index 49e30dc..29bda02 100644 --- a/src/app/api/audiobook/status/route.ts +++ b/src/app/api/audiobook/status/route.ts @@ -12,6 +12,7 @@ import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/a import type { AudiobookGenerationSettings } from '@/types/client'; import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -160,6 +161,9 @@ export async function GET(request: NextRequest) { event: 'audiobook.status.fetch.failed', error: errorToLog(error), }, 'Failed to fetch audiobook chapters'); - return NextResponse.json({ error: 'Failed to fetch chapters' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Failed to fetch chapters', + normalize: { code: 'AUDIOBOOK_STATUS_FETCH_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/documents/[id]/parsed/events/route.ts b/src/app/api/documents/[id]/parsed/events/route.ts index 7d2109c..eb7e550 100644 --- a/src/app/api/documents/[id]/parsed/events/route.ts +++ b/src/app/api/documents/[id]/parsed/events/route.ts @@ -11,7 +11,9 @@ import { normalizeParseStatus, parseDocumentParseState } from '@/lib/server/docu import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; -import { createRequestLogger, errorToLog, hashForLog } from '@/lib/server/logger'; +import { createRequestLogger, hashForLog } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; +import { logDegraded, logServerError } from '@/lib/server/errors/logging'; import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; import { parseSseEventId, parseSsePayload } from '@openreader/compute-core'; import type { PdfLayoutJobResult, WorkerOperationEvent, WorkerOperationState } from '@openreader/compute-core/api-contracts'; @@ -267,15 +269,17 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string } }).catch((error) => { if (closed) return; - logger.warn({ + logDegraded(logger, { event: 'documents.parsed.events.db_resync_failed', - degraded: true, + msg: 'SSE DB resync failed', step: 'db_resync', - documentId: id, - storageUserIdHash, - requestId, - error: errorToLog(error), - }, 'SSE DB resync failed'); + context: { + documentId: id, + storageUserIdHash, + requestId, + }, + error, + }); controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: String(error) })}\n\n`)); }); }, SSE_RESYNC_INTERVAL_MS); @@ -468,11 +472,16 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string void runWorkerProxy() .catch((error) => { - logger.error({ + logServerError(logger, { event: 'documents.parsed.events.worker_proxy_crashed', - documentId: id, - error: errorToLog(error), - }, 'Worker proxy crashed while streaming parse events'); + error, + msg: 'Worker proxy crashed while streaming parse events', + context: { documentId: id }, + normalize: { + code: 'DOCUMENTS_PARSED_EVENTS_WORKER_PROXY_CRASHED', + errorClass: 'upstream', + }, + }); if (!closed) { controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: String(error) })}\n\n`)); } @@ -499,10 +508,12 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }, }); } catch (error) { - logger.error({ + return errorResponse(error, { + logger, event: 'documents.parsed.events.route_failed', - error: errorToLog(error), - }, 'Parsed events route failed'); - return NextResponse.json({ error: 'Failed to stream parsed PDF progress' }, { status: 500 }); + msg: 'Parsed events route failed', + apiErrorMessage: 'Failed to stream parsed PDF progress', + normalize: { code: 'DOCUMENTS_PARSED_EVENTS_ROUTE_FAILED', errorClass: 'upstream' }, + }); } } diff --git a/src/app/api/documents/[id]/parsed/route.ts b/src/app/api/documents/[id]/parsed/route.ts index d3e8539..51ef630 100644 --- a/src/app/api/documents/[id]/parsed/route.ts +++ b/src/app/api/documents/[id]/parsed/route.ts @@ -23,7 +23,9 @@ import { import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; -import { createRequestLogger, errorToLog, hashForLog, type ServerLogger } from '@/lib/server/logger'; +import { createRequestLogger, hashForLog, type ServerLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; +import { logDegraded } from '@/lib/server/errors/logging'; import type { ParsedPdfDocument } from '@/types/parsed-pdf'; import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts'; @@ -138,7 +140,10 @@ async function finalizeFromWorkerState(input: { } if (!parsedJsonKey) { - return NextResponse.json({ error: 'Worker completed without parsed output' }, { status: 500 }); + return errorResponse(new Error('Worker completed without parsed output'), { + apiErrorMessage: 'Worker completed without parsed output', + normalize: { code: 'DOCUMENTS_PARSED_WORKER_OUTPUT_MISSING', errorClass: 'upstream' }, + }); } await writeParseRowState({ @@ -218,18 +223,20 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string logger, }); } - logger.warn({ + logDegraded(logger, { event: 'documents.parsed.requested_op_unavailable', - degraded: true, + msg: 'Requested worker operation id was unavailable', step: 'requested_op_lookup', - documentId: id, - userIdHash: hashForLog(row.userId), - opId: requestedOpId, - error: { - name: 'RequestedOperationUnavailable', - message: `Requested worker operation ${requestedOpId} was unavailable`, + context: { + documentId: id, + userIdHash: hashForLog(row.userId), + opId: requestedOpId, + error: { + name: 'RequestedOperationUnavailable', + message: `Requested worker operation ${requestedOpId} was unavailable`, + }, }, - }, 'Requested worker operation id was unavailable'); + }); } let state = parseDocumentParseState(row.parseState); @@ -311,11 +318,13 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string throw error; } } catch (error) { - logger.error({ + return errorResponse(error, { + logger, event: 'documents.parsed.get_failed', - error: errorToLog(error), - }, 'Failed to read parsed PDF'); - return NextResponse.json({ error: 'Failed to read parsed PDF' }, { status: 500 }); + msg: 'Failed to read parsed PDF', + apiErrorMessage: 'Failed to read parsed PDF', + normalize: { code: 'DOCUMENTS_PARSED_GET_FAILED', errorClass: 'storage' }, + }); } } @@ -391,10 +400,12 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string opId: created.opId, }, { status: 202 }); } catch (error) { - logger.error({ + return errorResponse(error, { + logger, event: 'documents.parsed.force_refresh_failed', - error: errorToLog(error), - }, 'Failed to force PDF refresh'); - return NextResponse.json({ error: 'Failed to force PDF refresh' }, { status: 500 }); + msg: 'Failed to force PDF refresh', + apiErrorMessage: 'Failed to force PDF refresh', + normalize: { code: 'DOCUMENTS_PARSED_FORCE_REFRESH_FAILED', errorClass: 'upstream' }, + }); } } diff --git a/src/app/api/documents/[id]/settings/route.ts b/src/app/api/documents/[id]/settings/route.ts index 594da97..5b94e23 100644 --- a/src/app/api/documents/[id]/settings/route.ts +++ b/src/app/api/documents/[id]/settings/route.ts @@ -8,6 +8,7 @@ import { mergeDocumentSettings } from '@/lib/shared/document-settings'; import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings'; import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -97,7 +98,10 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string event: 'documents.settings.load.failed', error: errorToLog(error), }, 'Failed to load document settings'); - return NextResponse.json({ error: 'Failed to load document settings' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Failed to load document settings', + normalize: { code: 'DOCUMENTS_SETTINGS_LOAD_FAILED', errorClass: 'db' }, + }); } } @@ -169,6 +173,9 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string event: 'documents.settings.update.failed', error: errorToLog(error), }, 'Failed to update document settings'); - return NextResponse.json({ error: 'Failed to update document settings' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Failed to update document settings', + normalize: { code: 'DOCUMENTS_SETTINGS_UPDATE_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/documents/blob/get/fallback/route.ts b/src/app/api/documents/blob/get/fallback/route.ts index 75e6f18..5584cfc 100644 --- a/src/app/api/documents/blob/get/fallback/route.ts +++ b/src/app/api/documents/blob/get/fallback/route.ts @@ -8,6 +8,7 @@ import { getDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/se import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -91,6 +92,9 @@ export async function GET(req: NextRequest) { event: 'documents.blob.get.fallback.failed', error: errorToLog(error), }, 'Failed to load document content fallback'); - return NextResponse.json({ error: 'Failed to load document content' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Failed to load document content', + normalize: { code: 'DOCUMENTS_BLOB_GET_FALLBACK_FAILED', errorClass: 'storage' }, + }); } } diff --git a/src/app/api/documents/blob/get/presign/route.ts b/src/app/api/documents/blob/get/presign/route.ts index b6c30e8..8346c3c 100644 --- a/src/app/api/documents/blob/get/presign/route.ts +++ b/src/app/api/documents/blob/get/presign/route.ts @@ -7,6 +7,7 @@ import { isValidDocumentId, presignGet } from '@/lib/server/documents/blobstore' import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -72,6 +73,9 @@ export async function GET(req: NextRequest) { event: 'documents.blob.get.presign.failed', error: errorToLog(error), }, 'Failed to create document download signature'); - return NextResponse.json({ error: 'Failed to prepare document download' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Failed to prepare document download', + normalize: { code: 'DOCUMENTS_BLOB_GET_PRESIGN_FAILED', errorClass: 'storage' }, + }); } } diff --git a/src/app/api/documents/blob/preview/ensure/route.ts b/src/app/api/documents/blob/preview/ensure/route.ts index 802a868..1fa3450 100644 --- a/src/app/api/documents/blob/preview/ensure/route.ts +++ b/src/app/api/documents/blob/preview/ensure/route.ts @@ -3,6 +3,7 @@ import { presignDocumentPreviewGet } from '@/lib/server/documents/previews-blobs import { ensureDocumentPreview } from '@/lib/server/documents/previews'; import { validatePreviewRequest } from '../utils'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -55,6 +56,9 @@ export async function GET(req: NextRequest) { event: 'documents.preview.ensure.failed', error: errorToLog(error), }, 'Failed to ensure document preview'); - return NextResponse.json({ error: 'Failed to ensure document preview' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Failed to ensure document preview', + normalize: { code: 'DOCUMENTS_PREVIEW_ENSURE_FAILED', errorClass: 'storage' }, + }); } } diff --git a/src/app/api/documents/blob/preview/fallback/route.ts b/src/app/api/documents/blob/preview/fallback/route.ts index 09f0851..a83955e 100644 --- a/src/app/api/documents/blob/preview/fallback/route.ts +++ b/src/app/api/documents/blob/preview/fallback/route.ts @@ -4,6 +4,7 @@ import { db } from '@/db'; import { documents } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth/auth'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; import { getDocumentRange, isMissingBlobError as isMissingDocumentBlobError, @@ -183,6 +184,9 @@ export async function GET(req: NextRequest) { event: 'documents.preview.fallback.failed', error: errorToLog(error), }, 'Failed to load document preview fallback'); - return NextResponse.json({ error: 'Failed to load document preview' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Failed to load document preview', + normalize: { code: 'DOCUMENTS_PREVIEW_FALLBACK_FAILED', errorClass: 'storage' }, + }); } } diff --git a/src/app/api/documents/blob/preview/presign/route.ts b/src/app/api/documents/blob/preview/presign/route.ts index 8c98c49..717902e 100644 --- a/src/app/api/documents/blob/preview/presign/route.ts +++ b/src/app/api/documents/blob/preview/presign/route.ts @@ -3,6 +3,7 @@ import { presignDocumentPreviewGet } from '@/lib/server/documents/previews-blobs import { ensureDocumentPreview } from '@/lib/server/documents/previews'; import { validatePreviewRequest } from '../utils'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -59,6 +60,9 @@ export async function GET(req: NextRequest) { event: 'documents.preview.presign.failed', error: errorToLog(error), }, 'Failed to create document preview signature'); - return NextResponse.json({ error: 'Failed to prepare document preview' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Failed to prepare document preview', + normalize: { code: 'DOCUMENTS_PREVIEW_PRESIGN_FAILED', errorClass: 'storage' }, + }); } } diff --git a/src/app/api/documents/blob/upload/fallback/route.ts b/src/app/api/documents/blob/upload/fallback/route.ts index c3d7a9f..bc188df 100644 --- a/src/app/api/documents/blob/upload/fallback/route.ts +++ b/src/app/api/documents/blob/upload/fallback/route.ts @@ -4,6 +4,7 @@ import { isValidDocumentId, putDocumentBlob } from '@/lib/server/documents/blobs import { isS3Configured } from '@/lib/server/storage/s3'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -58,6 +59,9 @@ export async function PUT(req: NextRequest) { event: 'documents.blob.upload.fallback.failed', error: errorToLog(error), }, 'Failed to proxy-upload document blob'); - return NextResponse.json({ error: 'Failed to upload document blob' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Failed to upload document blob', + normalize: { code: 'DOCUMENTS_BLOB_UPLOAD_FALLBACK_FAILED', errorClass: 'storage' }, + }); } } diff --git a/src/app/api/documents/blob/upload/presign/route.ts b/src/app/api/documents/blob/upload/presign/route.ts index a05e6eb..07756aa 100644 --- a/src/app/api/documents/blob/upload/presign/route.ts +++ b/src/app/api/documents/blob/upload/presign/route.ts @@ -4,6 +4,7 @@ import { isValidDocumentId, presignPut } from '@/lib/server/documents/blobstore' import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -70,6 +71,9 @@ export async function POST(req: NextRequest) { event: 'documents.blob.upload.presign.failed', error: errorToLog(error), }, 'Failed to create document upload signatures'); - return NextResponse.json({ error: 'Failed to presign uploads' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Failed to presign uploads', + normalize: { code: 'DOCUMENTS_BLOB_UPLOAD_PRESIGN_FAILED', errorClass: 'storage' }, + }); } } diff --git a/src/app/api/documents/docx-to-pdf/upload/route.ts b/src/app/api/documents/docx-to-pdf/upload/route.ts index e427c78..91866af 100644 --- a/src/app/api/documents/docx-to-pdf/upload/route.ts +++ b/src/app/api/documents/docx-to-pdf/upload/route.ts @@ -16,6 +16,7 @@ import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/li import { isS3Configured } from '@/lib/server/storage/s3'; import { putDocumentBlob } from '@/lib/server/documents/blobstore'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp'); @@ -195,6 +196,9 @@ export async function POST(req: NextRequest) { event: 'documents.docx.convert_upload.failed', error: errorToLog(error), }, 'Failed converting/uploading DOCX'); - return NextResponse.json({ error: 'Failed to convert document' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Failed to convert document', + normalize: { code: 'DOCUMENTS_DOCX_CONVERT_UPLOAD_FAILED', errorClass: 'upstream' }, + }); } } diff --git a/src/app/api/documents/library/route.ts b/src/app/api/documents/library/route.ts index f66686c..44c8e32 100644 --- a/src/app/api/documents/library/route.ts +++ b/src/app/api/documents/library/route.ts @@ -6,6 +6,7 @@ import { parseLibraryRoots } from '@/lib/server/storage/library-mount'; import type { DocumentType } from '@/types/documents'; import { auth } from '@/lib/server/auth/auth'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -116,7 +117,10 @@ export async function GET(req: NextRequest) { event: 'documents.library.auth_check.failed', error: errorToLog(error), }, 'Failed to check auth for library route'); - return NextResponse.json({ error: 'Error checking auth' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Error checking auth', + normalize: { code: 'DOCUMENTS_LIBRARY_AUTH_CHECK_FAILED', errorClass: 'auth' }, + }); } const url = new URL(req.url); diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 141812b..c56891c 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -5,6 +5,7 @@ import { documents } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth/auth'; import { safeDocumentName, toDocumentTypeFromName } from '@/lib/server/documents/utils'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; import { cleanupDocumentPreviewArtifacts, deleteDocumentPreviewRows, @@ -197,7 +198,10 @@ export async function POST(req: NextRequest) { event: 'documents.register.failed', error: errorToLog(error), }, 'Failed to register documents'); - return NextResponse.json({ error: 'Failed to register documents' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Failed to register documents', + normalize: { code: 'DOCUMENTS_REGISTER_FAILED', errorClass: 'db' }, + }); } } @@ -276,7 +280,10 @@ export async function GET(req: NextRequest) { event: 'documents.list.failed', error: errorToLog(error), }, 'Failed to load document metadata'); - return NextResponse.json({ error: 'Failed to load documents' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Failed to load documents', + normalize: { code: 'DOCUMENTS_LIST_FAILED', errorClass: 'db' }, + }); } } @@ -391,6 +398,9 @@ export async function DELETE(req: NextRequest) { event: 'documents.delete.failed', error: errorToLog(error), }, 'Failed to delete documents'); - return NextResponse.json({ error: 'Failed to delete documents' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Failed to delete documents', + normalize: { code: 'DOCUMENTS_DELETE_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/rate-limit/status/route.ts b/src/app/api/rate-limit/status/route.ts index e8d3bbc..e541f9f 100644 --- a/src/app/api/rate-limit/status/route.ts +++ b/src/app/api/rate-limit/status/route.ts @@ -7,6 +7,7 @@ import { getClientIp } from '@/lib/server/rate-limit/request-ip'; import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id'; import { nextUtcMidnightTimestampMs } from '@/lib/shared/timestamps'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -89,9 +90,9 @@ export async function GET(req: NextRequest) { event: 'rate_limit.status.get.failed', error: errorToLog(error), }, 'Failed to get rate limit status'); - return NextResponse.json( - { error: 'Failed to get rate limit status' }, - { status: 500 } - ); + return errorResponse(error, { + apiErrorMessage: 'Failed to get rate limit status', + normalize: { code: 'RATE_LIMIT_STATUS_GET_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/tts/segments/audio/fallback/route.ts b/src/app/api/tts/segments/audio/fallback/route.ts index 159713d..22a3120 100644 --- a/src/app/api/tts/segments/audio/fallback/route.ts +++ b/src/app/api/tts/segments/audio/fallback/route.ts @@ -7,7 +7,8 @@ import { ttsSegmentsS3NotConfiguredResponse, } from '@/lib/server/tts/segments-audio'; import { getTtsSegmentAudioObjectStream } from '@/lib/server/tts/segments-blobstore'; -import { createRequestLogger, errorToLog } from '@/lib/server/logger'; +import { createRequestLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -50,10 +51,12 @@ export async function GET(request: NextRequest) { headers, }); } catch (error) { - logger.error({ + return errorResponse(error, { + logger, event: 'tts.segments.audio_fallback_failed', - error: errorToLog(error), - }, 'Failed to load segment audio from fallback route'); - return NextResponse.json({ error: 'Failed to load segment audio' }, { status: 500 }); + msg: 'Failed to load segment audio from fallback route', + apiErrorMessage: 'Failed to load segment audio', + normalize: { code: 'TTS_SEGMENT_AUDIO_FALLBACK_FAILED', errorClass: 'storage' }, + }); } } diff --git a/src/app/api/tts/segments/clear/route.ts b/src/app/api/tts/segments/clear/route.ts index c794bbf..bcbca11 100644 --- a/src/app/api/tts/segments/clear/route.ts +++ b/src/app/api/tts/segments/clear/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; import { clearTtsSegmentCache } from '@/lib/server/tts/segments-cache'; -import { createRequestLogger, errorToLog } from '@/lib/server/logger'; +import { createRequestLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -39,10 +40,12 @@ export async function POST(request: NextRequest) { ...cleared, }); } catch (error) { - logger.error({ + return errorResponse(error, { + logger, event: 'tts.segments.clear_failed', - error: errorToLog(error), - }, 'Failed to clear TTS segment cache'); - return NextResponse.json({ error: 'Failed to clear TTS segment cache' }, { status: 500 }); + msg: 'Failed to clear TTS segment cache', + apiErrorMessage: 'Failed to clear TTS segment cache', + normalize: { code: 'TTS_SEGMENTS_CLEAR_FAILED', errorClass: 'storage' }, + }); } } diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index 7e4f48f..a3dc204 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -35,6 +35,7 @@ import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; import { resolveTtsModelForProvider } from '@/lib/shared/tts-provider-policy'; import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls'; import { createRequestLogger, errorToLog } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; import type { TTSSegmentInput, TTSSegmentManifestItem, @@ -828,11 +829,13 @@ export async function POST(request: NextRequest) { attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); return response; } catch (error) { - logger.error({ + const response = errorResponse(error, { + logger, event: 'tts.segments.ensure.route_failed', - error: errorToLog(error), - }, 'TTS segments ensure route failed'); - const response = NextResponse.json({ error: 'Failed to ensure TTS segments' }, { status: 500 }); + msg: 'TTS segments ensure route failed', + apiErrorMessage: 'Failed to ensure TTS segments', + normalize: { code: 'TTS_SEGMENTS_ENSURE_ROUTE_FAILED', errorClass: 'upstream' }, + }); attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); return response; } diff --git a/src/app/api/tts/segments/manifest/route.ts b/src/app/api/tts/segments/manifest/route.ts index b8cad65..c78e602 100644 --- a/src/app/api/tts/segments/manifest/route.ts +++ b/src/app/api/tts/segments/manifest/route.ts @@ -20,7 +20,8 @@ import type { import { isTtsProviderType } from '@/lib/shared/tts-provider-catalog'; import { resolveEffectiveProviderType } from '@/lib/shared/tts-provider-policy'; import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls'; -import { createRequestLogger, errorToLog } from '@/lib/server/logger'; +import { createRequestLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -386,10 +387,12 @@ export async function GET(request: NextRequest) { }; return NextResponse.json(response); } catch (error) { - logger.error({ + return errorResponse(error, { + logger, event: 'tts.segments.manifest.list_failed', - error: errorToLog(error), - }, 'Failed to list TTS segments'); - return NextResponse.json({ error: 'Failed to list TTS segments' }, { status: 500 }); + msg: 'Failed to list TTS segments', + apiErrorMessage: 'Failed to list TTS segments', + normalize: { code: 'TTS_SEGMENTS_MANIFEST_LIST_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/tts/voices/route.ts b/src/app/api/tts/voices/route.ts index f024431..0e1b5f5 100644 --- a/src/app/api/tts/voices/route.ts +++ b/src/app/api/tts/voices/route.ts @@ -6,6 +6,8 @@ import { resolveVoices } from '@/lib/server/tts/voice-resolution'; import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials'; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; +import { normalizeServerError, toApiErrorBody, toHttpStatus } from '@/lib/server/errors/contract'; export async function GET(req: NextRequest) { try { @@ -41,7 +43,10 @@ export async function GET(req: NextRequest) { } if (!isBuiltInTtsProviderId(resolved.provider)) { - return NextResponse.json({ error: `Unsupported provider type: ${resolved.provider}` }, { status: 500 }); + return errorResponse(new Error(`Unsupported provider type: ${resolved.provider}`), { + apiErrorMessage: `Unsupported provider type: ${resolved.provider}`, + normalize: { code: 'TTS_VOICES_UNSUPPORTED_PROVIDER', errorClass: 'validation', httpStatus: 500 }, + }); } const effectiveProviderRef = resolved.adminRecord?.slug ?? req.headers.get('x-tts-provider') @@ -69,16 +74,24 @@ export async function GET(req: NextRequest) { const providerRef = req.headers.get('x-tts-provider') || 'openai'; const model = req.headers.get('x-tts-model') || 'tts-1'; const provider = isBuiltInTtsProviderId(providerRef) ? providerRef : 'openai'; + const normalized = normalizeServerError(error, { + code: 'TTS_VOICES_RESOLVE_FAILED', + errorClass: 'upstream', + }); + const fallbackVoices = resolveTtsProviderModelPolicy({ + providerRef, + providerType: provider, + model, + }).defaultVoices; return NextResponse.json( { - error: 'Failed to resolve voices', - fallbackVoices: resolveTtsProviderModelPolicy({ - providerRef, - providerType: provider, - model, - }).defaultVoices, + ...toApiErrorBody( + { ...normalized, message: 'Failed to resolve voices' }, + { includeDetails: false, includeRetryable: false }, + ), + fallbackVoices, }, - { status: 500 }, + { status: toHttpStatus(normalized) }, ); } } diff --git a/src/app/api/user/claim/route.ts b/src/app/api/user/claim/route.ts index d299d1b..14cd313 100644 --- a/src/app/api/user/claim/route.ts +++ b/src/app/api/user/claim/route.ts @@ -6,6 +6,7 @@ import { audiobooks, documents, userDocumentProgress, userPreferences } from '@/ import { count, eq, ne } from 'drizzle-orm'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; async function checkClaimMigrationReadiness(): Promise { const [legacyRows] = await db @@ -61,7 +62,10 @@ export async function GET(req: NextRequest) { event: 'user.claim.status.failed', error: errorToLog(error), }, 'Failed checking claimable data'); - return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Internal Server Error', + normalize: { code: 'USER_CLAIM_STATUS_FAILED', errorClass: 'db' }, + }); } } @@ -91,6 +95,9 @@ export async function POST(req: NextRequest) { event: 'user.claim.execute.failed', error: errorToLog(error), }, 'Failed claiming anonymous data'); - return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Internal Server Error', + normalize: { code: 'USER_CLAIM_EXECUTE_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/user/export/route.ts b/src/app/api/user/export/route.ts index 98e7552..66920bf 100644 --- a/src/app/api/user/export/route.ts +++ b/src/app/api/user/export/route.ts @@ -12,6 +12,7 @@ import { isS3Configured } from '@/lib/server/storage/s3'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { nowTimestampMs } from '@/lib/shared/timestamps'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -24,7 +25,10 @@ export async function GET(req: NextRequest) { } if (!auth) { - return NextResponse.json({ error: 'Auth not initialized' }, { status: 500 }); + return errorResponse(new Error('Auth not initialized'), { + apiErrorMessage: 'Auth not initialized', + normalize: { code: 'USER_EXPORT_AUTH_NOT_INITIALIZED', errorClass: 'auth', httpStatus: 500 }, + }); } const session = await auth.api.getSession({ diff --git a/src/app/api/user/state/changelog/version-check/route.ts b/src/app/api/user/state/changelog/version-check/route.ts index 47c7adc..b36d74f 100644 --- a/src/app/api/user/state/changelog/version-check/route.ts +++ b/src/app/api/user/state/changelog/version-check/route.ts @@ -6,6 +6,7 @@ import { normalizeVersion, shouldOpenChangelogForVersionChange } from '@/lib/sha import { nowTimestampMs } from '@/lib/shared/timestamps'; import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; import { deserializeUserPreferencesPayload, extractUserPreferencesMeta, @@ -85,6 +86,9 @@ export async function POST(req: NextRequest) { event: 'user.changelog.version_check.failed', error: errorToLog(error), }, 'Failed to check changelog version'); - return NextResponse.json({ error: 'Failed to check changelog version' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Failed to check changelog version', + normalize: { code: 'USER_CHANGELOG_VERSION_CHECK_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/user/state/preferences/route.ts b/src/app/api/user/state/preferences/route.ts index 19f10b4..78f38ee 100644 --- a/src/app/api/user/state/preferences/route.ts +++ b/src/app/api/user/state/preferences/route.ts @@ -10,6 +10,7 @@ import { listAdminProviders } from '@/lib/server/admin/providers'; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; import { normalizeLegacyProviderRef, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; import { deserializeUserPreferencesPayload, extractUserPreferencesMeta, @@ -246,7 +247,10 @@ export async function GET(req: NextRequest) { event: 'user.preferences.load.failed', error: errorToLog(error), }, 'Failed to load user preferences'); - return NextResponse.json({ error: 'Failed to load user preferences' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Failed to load user preferences', + normalize: { code: 'USER_PREFERENCES_LOAD_FAILED', errorClass: 'db' }, + }); } } @@ -323,6 +327,9 @@ export async function PUT(req: NextRequest) { event: 'user.preferences.update.failed', error: errorToLog(error), }, 'Failed to update user preferences'); - return NextResponse.json({ error: 'Failed to update user preferences' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Failed to update user preferences', + normalize: { code: 'USER_PREFERENCES_UPDATE_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/user/state/progress/route.ts b/src/app/api/user/state/progress/route.ts index 7e4d377..1fef9f5 100644 --- a/src/app/api/user/state/progress/route.ts +++ b/src/app/api/user/state/progress/route.ts @@ -7,6 +7,7 @@ import { isValidDocumentId } from '@/lib/server/documents/blobstore'; import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope'; import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -67,7 +68,10 @@ export async function GET(req: NextRequest) { event: 'user.progress.load.failed', error: errorToLog(error), }, 'Failed to load user progress'); - return NextResponse.json({ error: 'Failed to load user progress' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Failed to load user progress', + normalize: { code: 'USER_PROGRESS_LOAD_FAILED', errorClass: 'db' }, + }); } } @@ -180,6 +184,9 @@ export async function PUT(req: NextRequest) { event: 'user.progress.update.failed', error: errorToLog(error), }, 'Failed to update user progress'); - return NextResponse.json({ error: 'Failed to update user progress' }, { status: 500 }); + return errorResponse(error, { + apiErrorMessage: 'Failed to update user progress', + normalize: { code: 'USER_PROGRESS_UPDATE_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/lib/server/compute/worker-op-state.ts b/src/lib/server/compute/worker-op-state.ts index 7cfe88b..dc36e1e 100644 --- a/src/lib/server/compute/worker-op-state.ts +++ b/src/lib/server/compute/worker-op-state.ts @@ -1,6 +1,7 @@ import { getWorkerClientConfigFromEnv } from '@/lib/server/compute/worker'; import type { WorkerOperationState } from '@openreader/compute-core/api-contracts'; -import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { serverLogger } from '@/lib/server/logger'; +import { logDegraded } from '@/lib/server/errors/logging'; const WORKER_OP_REQUEST_TIMEOUT_MS = 2_500; @@ -14,13 +15,13 @@ export async function fetchWorkerOperationState( try { cfg = getWorkerClientConfigFromEnv(); } catch (error) { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'compute.worker_op_state.config.invalid', - opId: normalized, - degraded: true, + msg: 'Worker client env missing/invalid', step: 'read_worker_config', - error: errorToLog(error), - }, 'Worker client env missing/invalid'); + context: { opId: normalized }, + error, + }); return null; } @@ -40,39 +41,41 @@ export async function fetchWorkerOperationState( if (!res.ok) { const upstreamResponseBody = await res.text().catch(() => ''); - serverLogger.warn({ + logDegraded(serverLogger, { event: 'compute.worker_op_state.fetch.failed', - opId: normalized, - status: res.status, - upstreamResponseBody, - degraded: true, + msg: 'Worker op request failed', step: 'fetch_worker_op', + context: { + opId: normalized, + status: res.status, + upstreamResponseBody, + }, error: { name: 'WorkerOpStateFetchFailed', message: `Worker op request failed with status ${res.status}`, }, - }, 'Worker op request failed'); + }); return null; } const parsed = await res.json() as WorkerOperationState; if (!parsed || typeof parsed !== 'object' || parsed.opId !== normalized) { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'compute.worker_op_state.response.invalid', - opId: normalized, - degraded: true, + msg: 'Worker op response invalid', step: 'validate_worker_op_response', - }, 'Worker op response invalid'); + context: { opId: normalized }, + }); return null; } return parsed; } catch (error) { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'compute.worker_op_state.fetch.error', - opId: normalized, - degraded: true, + msg: 'Worker op request threw', step: 'fetch_worker_op', - error: errorToLog(error), - }, 'Worker op request threw'); + context: { opId: normalized }, + error, + }); return null; } finally { clearTimeout(timeout); diff --git a/src/lib/server/errors/catalog.ts b/src/lib/server/errors/catalog.ts new file mode 100644 index 0000000..c7febb0 --- /dev/null +++ b/src/lib/server/errors/catalog.ts @@ -0,0 +1,65 @@ +import type { ServerErrorClass } from '@/lib/server/errors/contract'; + +export type ServerErrorCatalogEntry = { + code: string; + errorClass: ServerErrorClass; + httpStatus: number; + retryable: boolean; + ownerDomain: string; + description: string; +}; + +export const SERVER_ERROR_CATALOG: ServerErrorCatalogEntry[] = [ + { + code: 'UPSTREAM_RATE_LIMIT', + errorClass: 'upstream', + httpStatus: 429, + retryable: true, + ownerDomain: 'tts', + description: 'Upstream provider rate limited the request.', + }, + { + code: 'UPSTREAM_TTS_ERROR', + errorClass: 'upstream', + httpStatus: 502, + retryable: true, + ownerDomain: 'tts', + description: 'Upstream TTS provider returned a 5xx/transport failure.', + }, + { + code: 'DOCUMENTS_BLOB_MISSING', + errorClass: 'storage', + httpStatus: 404, + retryable: false, + ownerDomain: 'documents', + description: 'Document blob was not found in backing storage.', + }, + { + code: 'COMPUTE_WORKER_UNAVAILABLE', + errorClass: 'upstream', + httpStatus: 503, + retryable: true, + ownerDomain: 'compute', + description: 'Compute worker endpoint unavailable or misconfigured.', + }, + { + code: 'AUTH_UNAUTHORIZED', + errorClass: 'auth', + httpStatus: 401, + retryable: false, + ownerDomain: 'auth', + description: 'Request requires authentication and no valid session was present.', + }, + { + code: 'UNKNOWN_SERVER_ERROR', + errorClass: 'unknown', + httpStatus: 500, + retryable: false, + ownerDomain: 'server', + description: 'Fallback classification for uncategorized server exceptions.', + }, +]; + +export const SERVER_ERROR_CATALOG_BY_CODE = new Map( + SERVER_ERROR_CATALOG.map((entry) => [entry.code, entry] as const), +); diff --git a/src/lib/server/errors/contract.ts b/src/lib/server/errors/contract.ts new file mode 100644 index 0000000..6917bd1 --- /dev/null +++ b/src/lib/server/errors/contract.ts @@ -0,0 +1,203 @@ +export type ServerErrorClass = + | 'validation' + | 'auth' + | 'permission' + | 'upstream' + | 'storage' + | 'db' + | 'timeout' + | 'unknown'; + +type ServerErrorDefaults = { + httpStatus: number; + retryable: boolean; +}; + +const CLASS_DEFAULTS: Record = { + validation: { httpStatus: 400, retryable: false }, + auth: { httpStatus: 401, retryable: false }, + permission: { httpStatus: 403, retryable: false }, + upstream: { httpStatus: 502, retryable: true }, + storage: { httpStatus: 503, retryable: true }, + db: { httpStatus: 500, retryable: true }, + timeout: { httpStatus: 504, retryable: true }, + unknown: { httpStatus: 500, retryable: false }, +}; + +const ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]{2,}$/; + +export type ServerErrorContext = { + code?: string; + message?: string; + errorClass?: ServerErrorClass; + httpStatus?: number; + retryable?: boolean; + details?: Record; +}; + +export class ServerAppError extends Error { + readonly code: string; + readonly errorClass: ServerErrorClass; + readonly httpStatus: number; + readonly retryable: boolean; + readonly details?: Record; + + constructor(input: { + code: string; + message: string; + errorClass: ServerErrorClass; + httpStatus?: number; + retryable?: boolean; + details?: Record; + cause?: unknown; + }) { + super(input.message, input.cause !== undefined ? { cause: input.cause } : undefined); + this.name = 'ServerAppError'; + this.code = input.code; + this.errorClass = input.errorClass; + const defaults = CLASS_DEFAULTS[input.errorClass]; + this.httpStatus = input.httpStatus ?? defaults.httpStatus; + this.retryable = input.retryable ?? defaults.retryable; + if (input.details) this.details = input.details; + } +} + +export function isServerAppError(value: unknown): value is ServerAppError { + return value instanceof ServerAppError; +} + +function normalizeFromErrorInstance(error: Error): { + message: string; + code?: string; + errorClass?: ServerErrorClass; + httpStatus?: number; + retryable?: boolean; + details?: Record; +} { + const rec = error as Error & { + code?: unknown; + errorClass?: unknown; + httpStatus?: unknown; + retryable?: unknown; + details?: unknown; + }; + const code = typeof rec.code === 'string' && rec.code.trim() ? rec.code.trim() : undefined; + const errorClass = typeof rec.errorClass === 'string' && rec.errorClass in CLASS_DEFAULTS + ? rec.errorClass as ServerErrorClass + : undefined; + const httpStatus = typeof rec.httpStatus === 'number' && Number.isFinite(rec.httpStatus) + ? Math.max(400, Math.min(599, Math.floor(rec.httpStatus))) + : undefined; + const retryable = typeof rec.retryable === 'boolean' ? rec.retryable : undefined; + const details = rec.details && typeof rec.details === 'object' + ? rec.details as Record + : undefined; + return { + message: error.message || 'Internal server error', + ...(code ? { code } : {}), + ...(errorClass ? { errorClass } : {}), + ...(httpStatus ? { httpStatus } : {}), + ...(typeof retryable === 'boolean' ? { retryable } : {}), + ...(details ? { details } : {}), + }; +} + +export type NormalizedServerError = { + code: string; + message: string; + errorClass: ServerErrorClass; + httpStatus: number; + retryable: boolean; + details?: Record; + cause?: unknown; +}; + +export function normalizeServerError( + error: unknown, + ctx: ServerErrorContext = {}, +): NormalizedServerError { + if (isServerAppError(error)) { + return { + code: error.code, + message: error.message, + errorClass: error.errorClass, + httpStatus: error.httpStatus, + retryable: error.retryable, + ...(error.details ? { details: error.details } : {}), + ...(error.cause !== undefined ? { cause: error.cause } : {}), + }; + } + + const normalizedFromThrowable = error instanceof Error ? normalizeFromErrorInstance(error) : { + message: String(error), + }; + + const errorClass = ctx.errorClass + ?? normalizedFromThrowable.errorClass + ?? 'unknown'; + const defaults = CLASS_DEFAULTS[errorClass]; + + const rawCode = ctx.code + ?? normalizedFromThrowable.code + ?? 'UNKNOWN_SERVER_ERROR'; + const code = ERROR_CODE_PATTERN.test(rawCode) ? rawCode : 'UNKNOWN_SERVER_ERROR'; + + return { + code, + message: ctx.message + ?? normalizedFromThrowable.message + ?? 'Internal server error', + errorClass, + httpStatus: ctx.httpStatus + ?? normalizedFromThrowable.httpStatus + ?? defaults.httpStatus, + retryable: ctx.retryable + ?? normalizedFromThrowable.retryable + ?? defaults.retryable, + ...(ctx.details ? { details: ctx.details } : normalizedFromThrowable.details ? { details: normalizedFromThrowable.details } : {}), + ...(error instanceof Error && error.cause !== undefined ? { cause: error.cause } : {}), + }; +} + +export function toApiErrorBody( + input: NormalizedServerError, + options: { + includeErrorCode?: boolean; + includeRetryable?: boolean; + includeDetails?: boolean; + } = {}, +): { + error: string; + errorCode?: string; + retryable?: boolean; + details?: Record; +} { + const includeErrorCode = options.includeErrorCode ?? true; + const includeRetryable = options.includeRetryable ?? true; + const includeDetails = options.includeDetails ?? true; + return { + error: input.message, + ...(includeErrorCode ? { errorCode: input.code } : {}), + ...(includeRetryable ? { retryable: input.retryable } : {}), + ...(includeDetails && input.details ? { details: input.details } : {}), + }; +} + +export function toHttpStatus(input: NormalizedServerError): number { + return input.httpStatus; +} + +export function createServerAppError(input: { + code: string; + message: string; + errorClass: ServerErrorClass; + httpStatus?: number; + retryable?: boolean; + details?: Record; + cause?: unknown; +}): ServerAppError { + if (!ERROR_CODE_PATTERN.test(input.code)) { + throw new Error(`Invalid ServerAppError code "${input.code}" (expected ${ERROR_CODE_PATTERN.toString()})`); + } + return new ServerAppError(input); +} diff --git a/src/lib/server/errors/logging.ts b/src/lib/server/errors/logging.ts new file mode 100644 index 0000000..0785134 --- /dev/null +++ b/src/lib/server/errors/logging.ts @@ -0,0 +1,48 @@ +import type { ServerLogger } from '@/lib/server/logger'; +import { errorToLog } from '@/lib/server/logger'; +import { normalizeServerError, type NormalizedServerError, type ServerErrorContext } from '@/lib/server/errors/contract'; + +type LogContext = Record; + +export function logServerError( + logger: ServerLogger, + input: { + event: string; + error: unknown; + msg: string; + context?: LogContext; + normalize?: ServerErrorContext; + }, +): NormalizedServerError { + const normalized = normalizeServerError(input.error, input.normalize); + logger.error({ + event: input.event, + ...(input.context || {}), + error: { + ...errorToLog(input.error), + code: normalized.code, + }, + }, `${input.msg}`); + return normalized; +} + +export function logDegraded( + logger: ServerLogger, + input: { + event: string; + msg: string; + step?: string; + fallbackPath?: string; + context?: LogContext; + error?: unknown; + }, +): void { + logger.warn({ + event: input.event, + degraded: true, + ...(input.step ? { step: input.step } : {}), + ...(input.fallbackPath ? { fallbackPath: input.fallbackPath } : {}), + ...(input.context || {}), + ...(input.error !== undefined ? { error: errorToLog(input.error) } : {}), + }, `${input.msg}`); +} diff --git a/src/lib/server/errors/next-response.ts b/src/lib/server/errors/next-response.ts new file mode 100644 index 0000000..599d8c0 --- /dev/null +++ b/src/lib/server/errors/next-response.ts @@ -0,0 +1,90 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import type { ServerLogger } from '@/lib/server/logger'; +import { normalizeServerError, toApiErrorBody, toHttpStatus, type NormalizedServerError, type ServerErrorContext } from '@/lib/server/errors/contract'; +import { logServerError } from '@/lib/server/errors/logging'; + +export type ErrorResponseOptions = { + logger?: ServerLogger; + event?: string; + msg?: string; + normalize?: ServerErrorContext; + apiErrorMessage?: string; + includeErrorCode?: boolean; + includeRetryable?: boolean; + includeDetails?: boolean; +}; + +export function errorResponse( + error: unknown, + options: ErrorResponseOptions = {}, +): NextResponse { + const normalized = normalizeServerError(error, options.normalize); + if (options.logger && options.event && options.msg) { + logServerError(options.logger, { + event: options.event, + error, + msg: options.msg, + context: {}, + normalize: options.normalize, + }); + } + const apiError = options.apiErrorMessage ?? normalized.message; + const body = toApiErrorBody( + { ...normalized, message: apiError }, + { + includeErrorCode: options.includeErrorCode ?? true, + includeRetryable: options.includeRetryable ?? true, + includeDetails: options.includeDetails ?? false, + }, + ); + return NextResponse.json(body, { status: toHttpStatus(normalized) }); +} + +export type ErrorBoundaryContext = { + route: string; + logger: ServerLogger; + event: string; + msg: string; + normalize?: ServerErrorContext; + apiErrorMessage?: string; +}; + +export function withErrorBoundary( + handler: (...args: Args) => Promise, + ctx: ErrorBoundaryContext, +): (...args: Args) => Promise { + return async (...args: Args): Promise => { + try { + return await handler(...args); + } catch (error) { + return errorResponse(error, { + logger: ctx.logger, + event: ctx.event, + msg: ctx.msg, + normalize: ctx.normalize, + apiErrorMessage: ctx.apiErrorMessage, + }); + } + }; +} + +export function asRouteErrorContext(input: { + request: NextRequest; + route: string; + method?: string; + requestId?: string; +}): Record { + return { + route: input.route, + method: input.method ?? input.request.method, + ...(input.requestId ? { requestId: input.requestId } : {}), + }; +} + +export function normalizeForResponseOnly( + error: unknown, + normalize?: ServerErrorContext, +): NormalizedServerError { + return normalizeServerError(error, normalize); +} diff --git a/src/lib/server/logger.ts b/src/lib/server/logger.ts index aff248d..cacb6dc 100644 --- a/src/lib/server/logger.ts +++ b/src/lib/server/logger.ts @@ -6,31 +6,6 @@ import pinoPretty from 'pino-pretty'; export type ServerLogger = Logger; export type ServerLogLevel = 'error' | 'warn' | 'info'; -export type ServerErrorClass = - | 'validation' - | 'auth' - | 'permission' - | 'upstream' - | 'storage' - | 'db' - | 'timeout' - | 'unknown'; - -export type ServerErrorContract = { - errorClass: ServerErrorClass; - retryable: boolean; - httpStatus: number; - operation: string; - degraded: boolean; - cause?: unknown; -}; - -export type ServerApiErrorBody = { - error: string; - retryable?: boolean; - details?: Record; -}; - export type LoggedError = { name: string; message: string; @@ -141,16 +116,6 @@ export function errorToLog(error: unknown, depth = 0): LoggedError { }; } -export function apiErrorBody( - input: ServerApiErrorBody, -): ServerApiErrorBody { - return { - error: input.error, - ...(typeof input.retryable === 'boolean' ? { retryable: input.retryable } : {}), - ...(input.details ? { details: input.details } : {}), - }; -} - export function hashForLog(value: string | null | undefined): string | null { const normalized = typeof value === 'string' ? value.trim() : ''; if (!normalized) return null; diff --git a/tests/unit/server-error-contract.spec.ts b/tests/unit/server-error-contract.spec.ts new file mode 100644 index 0000000..84307d7 --- /dev/null +++ b/tests/unit/server-error-contract.spec.ts @@ -0,0 +1,55 @@ +import { expect, test } from '@playwright/test'; +import { + ServerAppError, + createServerAppError, + isServerAppError, + normalizeServerError, + toApiErrorBody, + toHttpStatus, +} from '../../src/lib/server/errors/contract'; + +test.describe('server error contract', () => { + test('normalizes unknown throwable to fallback shape', () => { + const normalized = normalizeServerError('boom'); + expect(normalized.code).toBe('UNKNOWN_SERVER_ERROR'); + expect(normalized.errorClass).toBe('unknown'); + expect(normalized.httpStatus).toBe(500); + expect(normalized.retryable).toBe(false); + expect(normalized.message).toBe('boom'); + }); + + test('preserves ServerAppError metadata', () => { + const err = createServerAppError({ + code: 'USER_PROGRESS_UPDATE_FAILED', + message: 'Failed to update progress', + errorClass: 'db', + retryable: true, + httpStatus: 500, + details: { operation: 'update_progress' }, + }); + const normalized = normalizeServerError(err); + expect(isServerAppError(err)).toBe(true); + expect(normalized.code).toBe('USER_PROGRESS_UPDATE_FAILED'); + expect(normalized.errorClass).toBe('db'); + expect(normalized.httpStatus).toBe(500); + expect(normalized.retryable).toBe(true); + expect(normalized.details?.operation).toBe('update_progress'); + }); + + test('maps normalized errors to API body + status', () => { + const normalized = normalizeServerError( + new ServerAppError({ + code: 'UPSTREAM_TTS_ERROR', + message: 'Upstream failure', + errorClass: 'upstream', + }), + ); + const body = toApiErrorBody(normalized, { includeDetails: false }); + expect(body).toEqual({ + error: 'Upstream failure', + errorCode: 'UPSTREAM_TTS_ERROR', + retryable: true, + }); + expect(toHttpStatus(normalized)).toBe(502); + }); +}); diff --git a/tests/unit/server-error-response.spec.ts b/tests/unit/server-error-response.spec.ts new file mode 100644 index 0000000..a5ed779 --- /dev/null +++ b/tests/unit/server-error-response.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; +import { errorResponse } from '../../src/lib/server/errors/next-response'; +import { createServerAppError } from '../../src/lib/server/errors/contract'; + +test.describe('server error response helper', () => { + test('returns mapped 4xx response with explicit app code', async () => { + const response = errorResponse( + createServerAppError({ + code: 'AUTH_UNAUTHORIZED', + message: 'Unauthorized', + errorClass: 'auth', + httpStatus: 401, + retryable: false, + }), + { + apiErrorMessage: 'Unauthorized', + }, + ); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ + error: 'Unauthorized', + errorCode: 'AUTH_UNAUTHORIZED', + retryable: false, + }); + }); + + test('normalizes unknown errors to safe 500 without stack leakage', async () => { + const response = errorResponse(new Error('sensitive internal details'), { + apiErrorMessage: 'Internal Server Error', + normalize: { code: 'UNKNOWN_SERVER_ERROR', errorClass: 'unknown' }, + }); + const body = await response.json(); + expect(response.status).toBe(500); + expect(body).toEqual({ + error: 'Internal Server Error', + errorCode: 'UNKNOWN_SERVER_ERROR', + retryable: false, + }); + expect(JSON.stringify(body)).not.toContain('stack'); + expect(JSON.stringify(body)).not.toContain('cause'); + }); +});