Zaptam Middleware Reference
Complete documentation of authentication, validation, rate limiting, and error handling middleware.
Middleware Stack
Request
│
▼
┌─────────────────┐
│ helmet │ Security headers
└─────────────────┘
│
▼
┌─────────────────┐
│ cors │ Cross-origin handling
└─────────────────┘
│
▼
┌─────────────────┐
│ generalLimiter │ 100 req/15min
└─────────────────┘
│
▼
┌─────────────────┐
│ Route-specific │ authLimiter, otpLimiter
│ rate limits │
└─────────────────┘
│
▼
┌─────────────────┐
│ authenticate │ JWT verification
└─────────────────┘
│
▼
┌─────────────────┐
│ role checks │ requireRole, requireMinRole
└─────────────────┘
│
▼
┌─────────────────┐
│ validation │ Joi schemas
└─────────────────┘
│
▼
┌─────────────────┐
│ Controller │ Business logic
└─────────────────┘
│
▼
┌─────────────────┐
│ errorHandler │ Catch all errors
└─────────────────┘
│
▼
ResponseAuthentication Middleware
File: src/middleware/auth.middleware.ts
authenticate
Validates JWT access token and attaches user to request.
import { Response, NextFunction } from 'express';
import { prisma } from '../config/database.js';
import { verifyAccessToken } from '../utils/jwt.utils.js';
import { UnauthorizedError } from './error.middleware.js';
import type { AuthenticatedRequest } from '../types/index.js';
export async function authenticate(
req: AuthenticatedRequest,
_res: Response,
next: NextFunction
): Promise<void> {
try {
const authHeader = req.headers.authorization;
// Check for Bearer token
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedError('No token provided');
}
const token = authHeader.substring(7);
const payload = verifyAccessToken(token);
if (!payload) {
throw new UnauthorizedError('Invalid or expired token');
}
// Fetch user from database
const user = await prisma.user.findUnique({
where: { id: payload.userId },
select: {
id: true,
phoneNumber: true,
email: true,
role: true,
gender: true,
status: true,
verificationLevel: true,
},
});
if (!user) {
throw new UnauthorizedError('User not found');
}
// Block banned/deleted users
if (user.status === 'BANNED' || user.status === 'DELETED') {
throw new UnauthorizedError('Account is not accessible');
}
// Attach user to request
req.user = user;
next();
} catch (error) {
next(error);
}
}Request Extension:
interface AuthenticatedRequest extends Request {
user?: {
id: string;
phoneNumber: string;
email: string | null;
role: UserRole;
gender: Gender | null;
status: UserStatus;
verificationLevel: VerificationLevel;
};
}optionalAuth
Allows unauthenticated requests but attaches user if token provided.
export function optionalAuth(
req: AuthenticatedRequest,
_res: Response,
next: NextFunction
): void {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return next(); // Continue without user
}
authenticate(req, _res, next);
}Role Middleware
File: src/middleware/role.middleware.ts
requireRole
Requires user to have one of the specified roles.
export function requireRole(...allowedRoles: UserRole[]) {
return (req: AuthenticatedRequest, _res: Response, next: NextFunction): void => {
if (!req.user) {
return next(new UnauthorizedError('Authentication required'));
}
if (!allowedRoles.includes(req.user.role)) {
return next(new ForbiddenError('Insufficient permissions'));
}
next();
};
}Usage:
router.post('/admin-only', authenticate, requireRole('ADMIN', 'SUPER_ADMIN'), handler);requireMinRole
Requires user to have at least the specified role level.
const roleHierarchy: Record<UserRole, number> = {
USER: 1,
CURATOR: 2,
ADMIN: 3,
SUPER_ADMIN: 4,
};
export function requireMinRole(minRole: UserRole) {
return (req: AuthenticatedRequest, _res: Response, next: NextFunction): void => {
if (!req.user) {
return next(new UnauthorizedError('Authentication required'));
}
const userRoleLevel = roleHierarchy[req.user.role];
const requiredRoleLevel = roleHierarchy[minRole];
if (userRoleLevel < requiredRoleLevel) {
return next(new ForbiddenError('Insufficient permissions'));
}
next();
};
}Usage:
// All admin routes require at least CURATOR role
router.use(authenticate, requireMinRole('CURATOR'));
// Some routes require ADMIN+
router.patch('/users/:id', requireMinRole('ADMIN'), handler);requireActiveStatus
Ensures user account is active (not pending, suspended, etc.).
export function requireActiveStatus(
req: AuthenticatedRequest,
_res: Response,
next: NextFunction
): void {
if (!req.user) {
return next(new UnauthorizedError('Authentication required'));
}
if (req.user.status !== 'ACTIVE') {
return next(new ForbiddenError('Account is not active'));
}
next();
}Usage:
// Most user routes require active status
router.use(authenticate, requireActiveStatus);Rate Limiting
File: src/middleware/rateLimit.middleware.ts
Using express-rate-limit package.
generalLimiter
Applied to all routes.
export const generalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: {
success: false,
error: 'Too many requests, please try again later',
},
standardHeaders: true, // RateLimit-* headers
legacyHeaders: false,
});authLimiter
Applied to authentication endpoints.
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 requests per window
message: {
success: false,
error: 'Too many authentication attempts, please try again later',
},
standardHeaders: true,
legacyHeaders: false,
});Applied to:
- POST /api/auth/apply
- POST /api/auth/confirm-phone
- POST /api/auth/complete-profile
- POST /api/auth/login
- POST /api/auth/reset-password
otpLimiter
Applied to OTP request endpoints.
export const otpLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 3, // 3 requests per minute
message: {
success: false,
error: 'Too many OTP requests, please wait before trying again',
},
standardHeaders: true,
legacyHeaders: false,
});Applied to:
- POST /api/auth/verify-phone
- POST /api/auth/forgot-password
uploadLimiter
Applied to file upload endpoints.
export const uploadLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 20, // 20 uploads per hour
message: {
success: false,
error: 'Too many uploads, please try again later',
},
standardHeaders: true,
legacyHeaders: false,
});Validation Middleware
File: src/middleware/validation.middleware.ts
Using Joi for schema validation.
validate
Generic validator for body, query, and params.
import { Request, Response, NextFunction } from 'express';
import Joi from 'joi';
import { BadRequestError } from './error.middleware.js';
type ValidationSchema = {
body?: Joi.ObjectSchema;
query?: Joi.ObjectSchema;
params?: Joi.ObjectSchema;
};
export function validate(schema: ValidationSchema) {
return (req: Request, _res: Response, next: NextFunction): void => {
const errors: string[] = [];
// Validate body
if (schema.body) {
const { error } = schema.body.validate(req.body, { abortEarly: false });
if (error) {
errors.push(...error.details.map((d) => d.message));
}
}
// Validate query params
if (schema.query) {
const { error } = schema.query.validate(req.query, { abortEarly: false });
if (error) {
errors.push(...error.details.map((d) => d.message));
}
}
// Validate URL params
if (schema.params) {
const { error } = schema.params.validate(req.params, { abortEarly: false });
if (error) {
errors.push(...error.details.map((d) => d.message));
}
}
if (errors.length > 0) {
return next(new BadRequestError(errors.join(', ')));
}
next();
};
}Usage:
import Joi from 'joi';
const createUserSchema = {
body: Joi.object({
phoneNumber: Joi.string().pattern(/^\+[1-9]\d{6,14}$/).required(),
gender: Joi.string().valid('MALE', 'FEMALE').required(),
}),
};
router.post('/users', validate(createUserSchema), createUser);Common Validation Schemas
Auth Validators (src/validators/auth.validator.ts):
const phoneRegex = /^\+[1-9]\d{6,14}$/;
// Application schema with gender-specific fields
export const applySchema = {
body: Joi.object({
phoneNumber: Joi.string().pattern(phoneRegex).required(),
gender: Joi.string().valid('MALE', 'FEMALE').required(),
email: Joi.string().email().optional(),
occupation: Joi.string().max(200).when('gender', {
is: 'MALE',
then: Joi.optional(),
otherwise: Joi.forbidden(),
}),
netWorth: Joi.string()
.valid('50k-100k', '100k-500k', '500k-1m', '1m-5m', '5m+')
.when('gender', {
is: 'MALE',
then: Joi.required(),
otherwise: Joi.forbidden(),
}),
intro: Joi.string().max(500).when('gender', {
is: 'FEMALE',
then: Joi.optional(),
otherwise: Joi.forbidden(),
}),
}),
};
// Login schema
export const loginSchema = {
body: Joi.object({
phoneNumber: Joi.string().pattern(phoneRegex).required(),
password: Joi.string().required(),
}),
};
// Password with minimum length
export const completeProfileSchema = {
body: Joi.object({
phoneNumber: Joi.string().pattern(phoneRegex).required(),
password: Joi.string().min(8).max(100).required(),
bio: Joi.string().max(500).optional(),
dateOfBirth: Joi.date().max('now').min('1900-01-01').optional(),
}),
};Profile Validators (src/validators/profile.validator.ts):
export const updateProfileSchema = {
body: Joi.object({
alias: Joi.string().min(3).max(30).optional(),
bio: Joi.string().max(500).allow('').optional(),
email: Joi.string().email().optional(),
dateOfBirth: Joi.date().max('now').min('1900-01-01').optional(),
}),
};
export const updateSettingsSchema = {
body: Joi.object({
aliasMode: Joi.boolean().optional(),
showOnlineStatus: Joi.boolean().optional(),
regionMasking: Joi.boolean().optional(),
photoBlurLevel: Joi.number().min(0).max(100).optional(),
disappearingMsgs: Joi.number().min(0).max(168).allow(null).optional(),
}),
};
export const idParamSchema = {
params: Joi.object({
id: Joi.string().uuid().required(),
}),
};
export const paginationSchema = {
query: Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
}),
};Message Validators (src/validators/message.validator.ts):
export const sendMessageSchema = {
body: Joi.object({
content: Joi.string().max(2000).allow('').optional(),
mediaUrl: Joi.string().uri().optional(),
expiresIn: Joi.number().min(0).max(168).optional(),
}).custom((value, helpers) => {
if (!value.content && !value.mediaUrl) {
return helpers.error('any.custom', {
message: 'Either content or mediaUrl must be provided',
});
}
return value;
}),
params: Joi.object({
id: Joi.string().uuid().required(),
}),
};Error Handling
File: src/middleware/error.middleware.ts
Error Classes
export class AppError extends Error {
statusCode: number;
isOperational: boolean;
constructor(message: string, statusCode: number = 500) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
export class BadRequestError extends AppError {
constructor(message: string = 'Bad request') {
super(message, 400);
}
}
export class UnauthorizedError extends AppError {
constructor(message: string = 'Unauthorized') {
super(message, 401);
}
}
export class ForbiddenError extends AppError {
constructor(message: string = 'Forbidden') {
super(message, 403);
}
}
export class NotFoundError extends AppError {
constructor(message: string = 'Not found') {
super(message, 404);
}
}
export class ConflictError extends AppError {
constructor(message: string = 'Conflict') {
super(message, 409);
}
}errorHandler
Global error handler.
export function errorHandler(
err: Error,
_req: Request,
res: Response,
_next: NextFunction
): void {
console.error('Error:', err);
// Handle operational errors
if (err instanceof AppError) {
const response: ApiResponse = {
success: false,
error: err.message,
};
res.status(err.statusCode).json(response);
return;
}
// Handle Prisma unique constraint errors
if ((err as { code?: string }).code === 'P2002') {
const response: ApiResponse = {
success: false,
error: 'A record with this data already exists',
};
res.status(409).json(response);
return;
}
// Default to 500 for unexpected errors
const response: ApiResponse = {
success: false,
error: process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message,
};
res.status(500).json(response);
}notFoundHandler
Catches unmatched routes.
export function notFoundHandler(_req: Request, res: Response): void {
const response: ApiResponse = {
success: false,
error: 'Route not found',
};
res.status(404).json(response);
}App Configuration
File: src/app.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { generalLimiter } from './middleware/rateLimit.middleware.js';
import { errorHandler, notFoundHandler } from './middleware/error.middleware.js';
export function createApp() {
const app = express();
// Trust proxy for rate limiting behind reverse proxy
app.set('trust proxy', 1);
// Security headers
app.use(helmet());
// CORS configuration
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [
'http://localhost:5173',
'http://localhost:5174',
'http://localhost:5175',
];
app.use(cors({
origin: allowedOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
// Body parsing with size limits
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Global rate limiting
app.use(generalLimiter);
// Health check (no auth)
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API routes
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
// ... other routes
// Error handling (must be last)
app.use(notFoundHandler);
app.use(errorHandler);
return app;
}Security Middleware Summary
| Middleware | Purpose | Applied To |
|---|---|---|
helmet | Security headers | All routes |
cors | Cross-origin control | All routes |
generalLimiter | 100 req/15min | All routes |
authLimiter | 10 req/15min | Auth endpoints |
otpLimiter | 3 req/1min | OTP endpoints |
uploadLimiter | 20 req/hour | Upload endpoints |
authenticate | JWT validation | Protected routes |
requireRole | Role check | Admin routes |
requireMinRole | Min role level | Admin routes |
requireActiveStatus | Active account | Most user routes |
validate | Input validation | All data endpoints |
errorHandler | Error formatting | All routes |