All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m0s
393 lines
11 KiB
Markdown
393 lines
11 KiB
Markdown
# ADR-043: Express Middleware Pipeline Architecture
|
|
|
|
**Date**: 2026-01-09
|
|
|
|
**Status**: Accepted
|
|
|
|
**Implemented**: 2026-01-09
|
|
|
|
## Context
|
|
|
|
The Express application uses a layered middleware pipeline to handle cross-cutting concerns:
|
|
|
|
1. **Security**: Helmet headers, CORS, rate limiting.
|
|
2. **Parsing**: JSON body, URL-encoded, cookies.
|
|
3. **Authentication**: Session management, JWT verification.
|
|
4. **Validation**: Request body/params validation.
|
|
5. **File Handling**: Multipart form data, file uploads.
|
|
6. **Error Handling**: Centralized error responses.
|
|
|
|
Middleware ordering is critical - incorrect ordering can cause security vulnerabilities or broken functionality. This ADR documents the canonical middleware order and patterns.
|
|
|
|
## Decision
|
|
|
|
We will establish a strict middleware ordering convention:
|
|
|
|
1. **Security First**: Security headers and protections apply to all requests.
|
|
2. **Parsing Before Logic**: Body/cookie parsing before route handlers.
|
|
3. **Auth Before Routes**: Authentication middleware before protected routes.
|
|
4. **Validation At Route Level**: Per-route validation middleware.
|
|
5. **Error Handler Last**: Centralized error handling catches all errors.
|
|
|
|
### Design Principles
|
|
|
|
- **Defense in Depth**: Multiple security layers.
|
|
- **Fail-Fast**: Reject bad requests early in the pipeline.
|
|
- **Explicit Ordering**: Document and enforce middleware order.
|
|
- **Route-Level Flexibility**: Specific middleware per route as needed.
|
|
|
|
## Implementation Details
|
|
|
|
### Global Middleware Order
|
|
|
|
Located in `src/server.ts`:
|
|
|
|
```typescript
|
|
import express from 'express';
|
|
import helmet from 'helmet';
|
|
import cors from 'cors';
|
|
import cookieParser from 'cookie-parser';
|
|
import { requestTimeoutMiddleware } from './middleware/timeout.middleware';
|
|
import { rateLimiter } from './middleware/rateLimit.middleware';
|
|
import { errorHandler } from './middleware/errorHandler.middleware';
|
|
|
|
const app = express();
|
|
|
|
// ============================================
|
|
// LAYER 1: Security Headers & Protections
|
|
// ============================================
|
|
app.use(
|
|
helmet({
|
|
contentSecurityPolicy: {
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc: ["'self'", "'unsafe-inline'"],
|
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
imgSrc: ["'self'", 'data:', 'blob:'],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
app.use(
|
|
cors({
|
|
origin: process.env.FRONTEND_URL,
|
|
credentials: true,
|
|
}),
|
|
);
|
|
|
|
// ============================================
|
|
// LAYER 2: Request Limits & Timeouts
|
|
// ============================================
|
|
app.use(requestTimeoutMiddleware(30000)); // 30s default
|
|
app.use(rateLimiter); // Rate limiting per IP
|
|
|
|
// ============================================
|
|
// LAYER 3: Body & Cookie Parsing
|
|
// ============================================
|
|
app.use(express.json({ limit: '10mb' }));
|
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
|
app.use(cookieParser());
|
|
|
|
// ============================================
|
|
// LAYER 4: Static Assets (before auth)
|
|
// ============================================
|
|
app.use('/flyer-images', express.static('flyer-images'));
|
|
|
|
// ============================================
|
|
// LAYER 5: Authentication Setup
|
|
// ============================================
|
|
app.use(passport.initialize());
|
|
app.use(passport.session());
|
|
|
|
// ============================================
|
|
// LAYER 6: Routes (with per-route middleware)
|
|
// ============================================
|
|
app.use('/api/auth', authRoutes);
|
|
app.use('/api/flyers', flyerRoutes);
|
|
app.use('/api/admin', adminRoutes);
|
|
// ... more routes
|
|
|
|
// ============================================
|
|
// LAYER 7: Error Handling (must be last)
|
|
// ============================================
|
|
app.use(errorHandler);
|
|
```
|
|
|
|
### Validation Middleware
|
|
|
|
Located in `src/middleware/validation.middleware.ts`:
|
|
|
|
```typescript
|
|
import { z } from 'zod';
|
|
import { Request, Response, NextFunction } from 'express';
|
|
import { ValidationError } from '../services/db/errors.db';
|
|
|
|
export const validate = <T extends z.ZodType>(schema: T) => {
|
|
return (req: Request, res: Response, next: NextFunction) => {
|
|
const result = schema.safeParse({
|
|
body: req.body,
|
|
query: req.query,
|
|
params: req.params,
|
|
});
|
|
|
|
if (!result.success) {
|
|
const errors = result.error.errors.map((err) => ({
|
|
path: err.path.join('.'),
|
|
message: err.message,
|
|
}));
|
|
return next(new ValidationError(errors));
|
|
}
|
|
|
|
// Attach validated data to request
|
|
req.validated = result.data;
|
|
next();
|
|
};
|
|
};
|
|
|
|
// Usage in routes:
|
|
router.post('/flyers', authenticate, validate(CreateFlyerSchema), flyerController.create);
|
|
```
|
|
|
|
### File Upload Middleware
|
|
|
|
Located in `src/middleware/fileUpload.middleware.ts`:
|
|
|
|
```typescript
|
|
import multer from 'multer';
|
|
import path from 'path';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
|
|
const storage = multer.diskStorage({
|
|
destination: (req, file, cb) => {
|
|
cb(null, 'flyer-images/');
|
|
},
|
|
filename: (req, file, cb) => {
|
|
const ext = path.extname(file.originalname);
|
|
cb(null, `${uuidv4()}${ext}`);
|
|
},
|
|
});
|
|
|
|
const fileFilter = (req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
|
|
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
|
|
if (allowedTypes.includes(file.mimetype)) {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error('Invalid file type'));
|
|
}
|
|
};
|
|
|
|
export const uploadFlyer = multer({
|
|
storage,
|
|
fileFilter,
|
|
limits: {
|
|
fileSize: 10 * 1024 * 1024, // 10MB
|
|
files: 10, // Max 10 files per request
|
|
},
|
|
});
|
|
|
|
// Usage:
|
|
router.post('/flyers/upload', uploadFlyer.array('files', 10), flyerController.upload);
|
|
```
|
|
|
|
### Authentication Middleware
|
|
|
|
Located in `src/middleware/auth.middleware.ts`:
|
|
|
|
```typescript
|
|
import passport from 'passport';
|
|
import { Request, Response, NextFunction } from 'express';
|
|
|
|
// Require authenticated user
|
|
export const authenticate = (req: Request, res: Response, next: NextFunction) => {
|
|
passport.authenticate('jwt', { session: false }, (err, user) => {
|
|
if (err) return next(err);
|
|
if (!user) {
|
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
req.user = user;
|
|
next();
|
|
})(req, res, next);
|
|
};
|
|
|
|
// Require admin role
|
|
export const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
|
|
if (!req.user?.role || req.user.role !== 'admin') {
|
|
return res.status(403).json({ error: 'Forbidden' });
|
|
}
|
|
next();
|
|
};
|
|
|
|
// Optional auth (attach user if present, continue if not)
|
|
export const optionalAuth = (req: Request, res: Response, next: NextFunction) => {
|
|
passport.authenticate('jwt', { session: false }, (err, user) => {
|
|
if (user) req.user = user;
|
|
next();
|
|
})(req, res, next);
|
|
};
|
|
```
|
|
|
|
### Error Handler Middleware
|
|
|
|
Located in `src/middleware/errorHandler.middleware.ts`:
|
|
|
|
```typescript
|
|
import { Request, Response, NextFunction } from 'express';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { logger } from '../services/logger.server';
|
|
import { ValidationError, NotFoundError, UniqueConstraintError } from '../services/db/errors.db';
|
|
|
|
export const errorHandler = (err: Error, req: Request, res: Response, next: NextFunction) => {
|
|
const errorId = uuidv4();
|
|
|
|
// Log error with context
|
|
logger.error(
|
|
{
|
|
errorId,
|
|
err,
|
|
path: req.path,
|
|
method: req.method,
|
|
userId: req.user?.user_id,
|
|
},
|
|
'Request error',
|
|
);
|
|
|
|
// Map error types to HTTP responses
|
|
if (err instanceof ValidationError) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: { code: 'VALIDATION_ERROR', message: err.message, details: err.errors },
|
|
meta: { errorId },
|
|
});
|
|
}
|
|
|
|
if (err instanceof NotFoundError) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: { code: 'NOT_FOUND', message: err.message },
|
|
meta: { errorId },
|
|
});
|
|
}
|
|
|
|
if (err instanceof UniqueConstraintError) {
|
|
return res.status(409).json({
|
|
success: false,
|
|
error: { code: 'CONFLICT', message: err.message },
|
|
meta: { errorId },
|
|
});
|
|
}
|
|
|
|
// Default: Internal Server Error
|
|
return res.status(500).json({
|
|
success: false,
|
|
error: {
|
|
code: 'INTERNAL_ERROR',
|
|
message: process.env.NODE_ENV === 'production' ? 'An unexpected error occurred' : err.message,
|
|
},
|
|
meta: { errorId },
|
|
});
|
|
};
|
|
```
|
|
|
|
### Request Timeout Middleware
|
|
|
|
```typescript
|
|
export const requestTimeoutMiddleware = (timeout: number) => {
|
|
return (req: Request, res: Response, next: NextFunction) => {
|
|
res.setTimeout(timeout, () => {
|
|
if (!res.headersSent) {
|
|
res.status(503).json({
|
|
success: false,
|
|
error: { code: 'TIMEOUT', message: 'Request timed out' },
|
|
});
|
|
}
|
|
});
|
|
next();
|
|
};
|
|
};
|
|
```
|
|
|
|
## Route-Level Middleware Patterns
|
|
|
|
### Protected Route with Validation
|
|
|
|
```typescript
|
|
router.put(
|
|
'/flyers/:flyerId',
|
|
authenticate, // 1. Auth check
|
|
validate(UpdateFlyerSchema), // 2. Input validation
|
|
flyerController.update, // 3. Handler
|
|
);
|
|
```
|
|
|
|
### Admin-Only Route
|
|
|
|
```typescript
|
|
router.delete(
|
|
'/admin/users/:userId',
|
|
authenticate, // 1. Auth check
|
|
requireAdmin, // 2. Role check
|
|
validate(DeleteUserSchema), // 3. Input validation
|
|
adminController.deleteUser, // 4. Handler
|
|
);
|
|
```
|
|
|
|
### File Upload Route
|
|
|
|
```typescript
|
|
router.post(
|
|
'/flyers/upload',
|
|
authenticate, // 1. Auth check
|
|
uploadFlyer.array('files', 10), // 2. File handling
|
|
validate(UploadFlyerSchema), // 3. Metadata validation
|
|
flyerController.upload, // 4. Handler
|
|
);
|
|
```
|
|
|
|
### Public Route with Optional Auth
|
|
|
|
```typescript
|
|
router.get(
|
|
'/flyers/:flyerId',
|
|
optionalAuth, // 1. Attach user if present
|
|
flyerController.getById, // 2. Handler (can check req.user)
|
|
);
|
|
```
|
|
|
|
## Consequences
|
|
|
|
### Positive
|
|
|
|
- **Security**: Defense-in-depth with multiple security layers.
|
|
- **Consistency**: Predictable request processing order.
|
|
- **Maintainability**: Clear separation of concerns.
|
|
- **Debuggability**: Errors caught and logged centrally.
|
|
- **Flexibility**: Per-route middleware composition.
|
|
|
|
### Negative
|
|
|
|
- **Order Sensitivity**: Middleware order bugs can be subtle.
|
|
- **Performance**: Many middleware layers add latency.
|
|
- **Complexity**: New developers must understand the pipeline.
|
|
|
|
### Mitigation
|
|
|
|
- Document middleware order in comments (as shown above).
|
|
- Use integration tests that verify middleware chain behavior.
|
|
- Profile middleware performance in production.
|
|
|
|
## Key Files
|
|
|
|
- `src/server.ts` - Global middleware registration
|
|
- `src/middleware/validation.middleware.ts` - Zod validation
|
|
- `src/middleware/fileUpload.middleware.ts` - Multer configuration
|
|
- `src/middleware/multer.middleware.ts` - File upload handling
|
|
- `src/middleware/errorHandler.middleware.ts` - Error handling (implicit)
|
|
|
|
## Related ADRs
|
|
|
|
- [ADR-001](./0001-standardized-error-handling.md) - Error Handling
|
|
- [ADR-003](./0003-standardized-input-validation-using-middleware.md) - Input Validation
|
|
- [ADR-016](./0016-api-security-hardening.md) - API Security
|
|
- [ADR-032](./0032-rate-limiting-strategy.md) - Rate Limiting
|
|
- [ADR-033](./0033-file-upload-and-storage-strategy.md) - File Uploads
|