diff --git a/packages/backend/src/lib/errors.ts b/packages/backend/src/lib/errors.ts new file mode 100644 index 0000000..bd45d19 --- /dev/null +++ b/packages/backend/src/lib/errors.ts @@ -0,0 +1,47 @@ +export class AppError extends Error { + statusCode: number + + constructor(message: string, statusCode: number) { + super(message) + this.name = 'AppError' + this.statusCode = statusCode + } +} + +export class ValidationError extends AppError { + details?: unknown + + constructor(message: string, details?: unknown) { + super(message, 400) + this.name = 'ValidationError' + this.details = details + } +} + +export class NotFoundError extends AppError { + constructor(entity: string) { + super(`${entity} not found`, 404) + this.name = 'NotFoundError' + } +} + +export class ForbiddenError extends AppError { + constructor(message = 'Insufficient permissions') { + super(message, 403) + this.name = 'ForbiddenError' + } +} + +export class ConflictError extends AppError { + constructor(message: string) { + super(message, 409) + this.name = 'ConflictError' + } +} + +export class StorageError extends AppError { + constructor(message: string) { + super(message, 500) + this.name = 'StorageError' + } +} diff --git a/packages/backend/src/plugins/error-handler.ts b/packages/backend/src/plugins/error-handler.ts index a0aba3c..6d86cc5 100644 --- a/packages/backend/src/plugins/error-handler.ts +++ b/packages/backend/src/plugins/error-handler.ts @@ -1,23 +1,47 @@ import fp from 'fastify-plugin' +import { AppError, ValidationError } from '../lib/errors.js' export const errorHandlerPlugin = fp(async (app) => { app.setErrorHandler((error, request, reply) => { - const statusCode = error.statusCode ?? 500 + // Use AppError statusCode if available, else Fastify's, else 500 + const statusCode = error instanceof AppError + ? error.statusCode + : (error.statusCode ?? 500) - request.log.error({ - err: error, + // Only log 5xx as errors, 4xx as warnings + const logData = { + err: statusCode >= 500 ? error : undefined, statusCode, url: request.url, method: request.method, - }) + requestId: request.id, + message: error.message, + } - reply.status(statusCode).send({ + if (statusCode >= 500) { + request.log.error(logData) + } else { + request.log.warn(logData) + } + + const response: Record = { error: { message: statusCode >= 500 ? 'Internal Server Error' : error.message, statusCode, - ...(process.env.NODE_ENV === 'development' ? { stack: error.stack } : {}), }, - }) + } + + // Include validation details in non-production + if (error instanceof ValidationError && error.details) { + (response.error as Record).details = error.details + } + + // Stack trace only in development + if (process.env.NODE_ENV === 'development' && statusCode >= 500) { + (response.error as Record).stack = error.stack + } + + reply.status(statusCode).send(response) }) app.setNotFoundHandler((request, reply) => { diff --git a/packages/backend/src/routes/v1/files.ts b/packages/backend/src/routes/v1/files.ts index 3a2499c..f0ae5c8 100644 --- a/packages/backend/src/routes/v1/files.ts +++ b/packages/backend/src/routes/v1/files.ts @@ -1,11 +1,12 @@ import type { FastifyPluginAsync } from 'fastify' import multipart from '@fastify/multipart' import { FileService } from '../../services/file.service.js' +import { ValidationError } from '../../lib/errors.js' export const fileRoutes: FastifyPluginAsync = async (app) => { await app.register(multipart, { limits: { - fileSize: 25 * 1024 * 1024, // 25 MB max + fileSize: 25 * 1024 * 1024, files: 1, }, }) @@ -14,11 +15,10 @@ export const fileRoutes: FastifyPluginAsync = async (app) => { app.get('/files', { preHandler: [app.authenticate] }, async (request, reply) => { const { entityType, entityId } = request.query as { entityType?: string; entityId?: string } if (!entityType || !entityId) { - return reply.status(400).send({ - error: { message: 'entityType and entityId query params required', statusCode: 400 }, - }) + throw new ValidationError('entityType and entityId query params required') } + // Files are company-scoped in the service — companyId from JWT ensures access control const fileRecords = await FileService.listByEntity(app.db, request.companyId, entityType, entityId) const data = await Promise.all( fileRecords.map(async (f) => ({ ...f, url: await app.storage.getUrl(f.path) })), @@ -30,7 +30,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => { app.post('/files', { preHandler: [app.authenticate] }, async (request, reply) => { const data = await request.file() if (!data) { - return reply.status(400).send({ error: { message: 'No file provided', statusCode: 400 } }) + throw new ValidationError('No file provided') } const entityType = (data.fields.entityType as { value?: string })?.value @@ -38,38 +38,54 @@ export const fileRoutes: FastifyPluginAsync = async (app) => { const category = (data.fields.category as { value?: string })?.value if (!entityType || !entityId || !category) { - return reply.status(400).send({ - error: { message: 'entityType, entityId, and category are required', statusCode: 400 }, - }) + throw new ValidationError('entityType, entityId, and category are required') + } + + // Validate entityType is a known type + const allowedEntityTypes = ['member', 'member_identifier', 'product', 'rental_agreement', 'repair_ticket'] + if (!allowedEntityTypes.includes(entityType)) { + throw new ValidationError(`Invalid entityType: ${entityType}`) + } + + // Validate entityId format (UUID) + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(entityId)) { + throw new ValidationError('entityId must be a valid UUID') + } + + // Validate category (alphanumeric + underscore only) + if (!/^[a-z0-9_]+$/.test(category)) { + throw new ValidationError('category must be lowercase alphanumeric with underscores') } const buffer = await data.toBuffer() - try { - const file = await FileService.upload(app.db, app.storage, request.companyId, { - data: buffer, - filename: data.filename, - contentType: data.mimetype, - entityType, - entityId, - category, - uploadedBy: request.user.id, - }) - const url = await app.storage.getUrl(file.path) - return reply.status(201).send({ ...file, url }) - } catch (err) { - if (err instanceof Error && (err.message.includes('not allowed') || err.message.includes('too large') || err.message.includes('Maximum'))) { - return reply.status(400).send({ error: { message: err.message, statusCode: 400 } }) - } - throw err - } + const file = await FileService.upload(app.db, app.storage, request.companyId, { + data: buffer, + filename: data.filename, + contentType: data.mimetype, + entityType, + entityId, + category, + uploadedBy: request.user.id, + }) + + request.log.info({ fileId: file.id, entityType, entityId, category, sizeBytes: file.sizeBytes }, 'File uploaded') + + const url = await app.storage.getUrl(file.path) + return reply.status(201).send({ ...file, url }) }) // Serve file content (for local provider) + // Path traversal protection: validate the path starts with the requesting company's ID app.get('/files/serve/*', { preHandler: [app.authenticate] }, async (request, reply) => { const filePath = (request.params as { '*': string })['*'] if (!filePath) { - return reply.status(400).send({ error: { message: 'Path required', statusCode: 400 } }) + throw new ValidationError('Path required') + } + + // Path traversal protection: must start with company ID, no '..' allowed + if (filePath.includes('..') || !filePath.startsWith(request.companyId)) { + return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } }) } try { @@ -101,6 +117,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => { const { id } = request.params as { id: string } const file = await FileService.delete(app.db, app.storage, request.companyId, id) if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } }) + request.log.info({ fileId: id, path: file.path }, 'File deleted') return reply.send(file) }) } diff --git a/packages/backend/src/routes/v1/health.ts b/packages/backend/src/routes/v1/health.ts index 6073a97..cbcf41d 100644 --- a/packages/backend/src/routes/v1/health.ts +++ b/packages/backend/src/routes/v1/health.ts @@ -2,6 +2,7 @@ import type { FastifyPluginAsync } from 'fastify' import { sql } from 'drizzle-orm' export const healthRoutes: FastifyPluginAsync = async (app) => { + // Intentionally public — no auth. Load balancers, Docker health checks, and monitoring need this. app.get('/health', async (request, reply) => { let dbStatus = 'disconnected' let redisStatus = 'disconnected' diff --git a/packages/backend/src/routes/v1/lookups.ts b/packages/backend/src/routes/v1/lookups.ts index c0400e0..e77533a 100644 --- a/packages/backend/src/routes/v1/lookups.ts +++ b/packages/backend/src/routes/v1/lookups.ts @@ -1,6 +1,7 @@ import type { FastifyPluginAsync } from 'fastify' import { LookupCreateSchema, LookupUpdateSchema } from '@forte/shared/schemas' import { UnitStatusService, ItemConditionService } from '../../services/lookup.service.js' +import { ConflictError, ValidationError } from '../../lib/errors.js' function createLookupRoutes(prefix: string, service: typeof UnitStatusService) { const routes: FastifyPluginAsync = async (app) => { @@ -12,13 +13,12 @@ function createLookupRoutes(prefix: string, service: typeof UnitStatusService) { app.post(`/${prefix}`, { preHandler: [app.authenticate] }, async (request, reply) => { const parsed = LookupCreateSchema.safeParse(request.body) if (!parsed.success) { - return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + throw new ValidationError('Validation failed', parsed.error.flatten()) } - // Check slug uniqueness const existing = await service.getBySlug(app.db, request.companyId, parsed.data.slug) if (existing) { - return reply.status(409).send({ error: { message: `Slug "${parsed.data.slug}" already exists`, statusCode: 409 } }) + throw new ConflictError(`Slug "${parsed.data.slug}" already exists`) } const row = await service.create(app.db, request.companyId, parsed.data) @@ -29,32 +29,18 @@ function createLookupRoutes(prefix: string, service: typeof UnitStatusService) { const { id } = request.params as { id: string } const parsed = LookupUpdateSchema.safeParse(request.body) if (!parsed.success) { - return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) - } - try { - const row = await service.update(app.db, request.companyId, id, parsed.data) - if (!row) return reply.status(404).send({ error: { message: 'Not found', statusCode: 404 } }) - return reply.send(row) - } catch (err) { - if (err instanceof Error && err.message.includes('system')) { - return reply.status(403).send({ error: { message: err.message, statusCode: 403 } }) - } - throw err + throw new ValidationError('Validation failed', parsed.error.flatten()) } + const row = await service.update(app.db, request.companyId, id, parsed.data) + if (!row) return reply.status(404).send({ error: { message: 'Not found', statusCode: 404 } }) + return reply.send(row) }) app.delete(`/${prefix}/:id`, { preHandler: [app.authenticate] }, async (request, reply) => { const { id } = request.params as { id: string } - try { - const row = await service.delete(app.db, request.companyId, id) - if (!row) return reply.status(404).send({ error: { message: 'Not found', statusCode: 404 } }) - return reply.send(row) - } catch (err) { - if (err instanceof Error && err.message.includes('system')) { - return reply.status(403).send({ error: { message: err.message, statusCode: 403 } }) - } - throw err - } + const row = await service.delete(app.db, request.companyId, id) + if (!row) return reply.status(404).send({ error: { message: 'Not found', statusCode: 404 } }) + return reply.send(row) }) } return routes diff --git a/packages/backend/src/routes/v1/products.ts b/packages/backend/src/routes/v1/products.ts index 93398ff..4757fc9 100644 --- a/packages/backend/src/routes/v1/products.ts +++ b/packages/backend/src/routes/v1/products.ts @@ -59,15 +59,8 @@ export const productRoutes: FastifyPluginAsync = async (app) => { if (!parsed.success) { return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) } - try { - const unit = await InventoryUnitService.create(app.db, request.companyId, parsed.data) - return reply.status(201).send(unit) - } catch (err) { - if (err instanceof Error && (err.message.includes('Invalid condition') || err.message.includes('Invalid status'))) { - return reply.status(400).send({ error: { message: err.message, statusCode: 400 } }) - } - throw err - } + const unit = await InventoryUnitService.create(app.db, request.companyId, parsed.data) + return reply.status(201).send(unit) }) app.get('/products/:productId/units', { preHandler: [app.authenticate] }, async (request, reply) => { @@ -90,15 +83,8 @@ export const productRoutes: FastifyPluginAsync = async (app) => { if (!parsed.success) { return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) } - try { - const unit = await InventoryUnitService.update(app.db, request.companyId, id, parsed.data) - if (!unit) return reply.status(404).send({ error: { message: 'Unit not found', statusCode: 404 } }) - return reply.send(unit) - } catch (err) { - if (err instanceof Error && (err.message.includes('Invalid condition') || err.message.includes('Invalid status'))) { - return reply.status(400).send({ error: { message: err.message, statusCode: 400 } }) - } - throw err - } + const unit = await InventoryUnitService.update(app.db, request.companyId, id, parsed.data) + if (!unit) return reply.status(404).send({ error: { message: 'Unit not found', statusCode: 404 } }) + return reply.send(unit) }) } diff --git a/packages/backend/src/services/file.service.ts b/packages/backend/src/services/file.service.ts index abc55e5..fb277ca 100644 --- a/packages/backend/src/services/file.service.ts +++ b/packages/backend/src/services/file.service.ts @@ -3,6 +3,7 @@ import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' import { files } from '../db/schema/files.js' import type { StorageProvider } from '../storage/index.js' import { randomUUID } from 'crypto' +import { ValidationError } from '../lib/errors.js' const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'] const ALLOWED_PDF_TYPES = ['application/pdf'] @@ -38,13 +39,13 @@ export const FileService = { ) { // Validate content type if (!ALLOWED_TYPES.includes(input.contentType)) { - throw new Error(`File type not allowed: ${input.contentType}`) + throw new ValidationError(`File type not allowed: ${input.contentType}`) } // Validate size const maxSize = ALLOWED_IMAGE_TYPES.includes(input.contentType) ? MAX_IMAGE_SIZE : MAX_PDF_SIZE if (input.data.length > maxSize) { - throw new Error(`File too large: ${input.data.length} bytes (max ${maxSize})`) + throw new ValidationError(`File too large: ${input.data.length} bytes (max ${maxSize})`) } // Check per-entity limit @@ -59,7 +60,7 @@ export const FileService = { ), ) if (existing.total >= MAX_FILES_PER_ENTITY) { - throw new Error(`Maximum ${MAX_FILES_PER_ENTITY} files per entity`) + throw new ValidationError(`Maximum ${MAX_FILES_PER_ENTITY} files per entity`) } // Generate path diff --git a/packages/backend/src/services/lookup.service.ts b/packages/backend/src/services/lookup.service.ts index 38a4d00..d4155c2 100644 --- a/packages/backend/src/services/lookup.service.ts +++ b/packages/backend/src/services/lookup.service.ts @@ -1,5 +1,6 @@ import { eq, and } from 'drizzle-orm' import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import { ForbiddenError } from '../lib/errors.js' import { inventoryUnitStatuses, itemConditions, @@ -73,7 +74,7 @@ function createLookupService( if (!existing[0]) return null if (existing[0].isSystem && input.isActive === false) { - throw new Error('Cannot deactivate a system status') + throw new ForbiddenError('Cannot deactivate a system status') } const [row] = await db @@ -93,7 +94,7 @@ function createLookupService( if (!existing[0]) return null if (existing[0].isSystem) { - throw new Error('Cannot delete a system status') + throw new ForbiddenError('Cannot delete a system status') } const [row] = await db diff --git a/packages/backend/src/services/product.service.ts b/packages/backend/src/services/product.service.ts index 77401a6..2ee0790 100644 --- a/packages/backend/src/services/product.service.ts +++ b/packages/backend/src/services/product.service.ts @@ -1,6 +1,7 @@ import { eq, and, count } from 'drizzle-orm' import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' import { products, inventoryUnits, priceHistory } from '../db/schema/inventory.js' +import { ValidationError } from '../lib/errors.js' import type { ProductCreateInput, ProductUpdateInput, @@ -119,11 +120,11 @@ export const InventoryUnitService = { async create(db: PostgresJsDatabase, companyId: string, input: InventoryUnitCreateInput) { if (input.condition) { const valid = await ItemConditionService.validateSlug(db, companyId, input.condition) - if (!valid) throw new Error(`Invalid condition: "${input.condition}"`) + if (!valid) throw new ValidationError(`Invalid condition: "${input.condition}"`) } if (input.status) { const valid = await UnitStatusService.validateSlug(db, companyId, input.status) - if (!valid) throw new Error(`Invalid status: "${input.status}"`) + if (!valid) throw new ValidationError(`Invalid status: "${input.status}"`) } const [unit] = await db @@ -190,11 +191,11 @@ export const InventoryUnitService = { ) { if (input.condition) { const valid = await ItemConditionService.validateSlug(db, companyId, input.condition) - if (!valid) throw new Error(`Invalid condition: "${input.condition}"`) + if (!valid) throw new ValidationError(`Invalid condition: "${input.condition}"`) } if (input.status) { const valid = await UnitStatusService.validateSlug(db, companyId, input.status) - if (!valid) throw new Error(`Invalid status: "${input.status}"`) + if (!valid) throw new ValidationError(`Invalid status: "${input.status}"`) } const updates: Record = { ...input }