Skip to content

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
└─────────────────┘


Response

Authentication Middleware

File: src/middleware/auth.middleware.ts

authenticate

Validates JWT access token and attaches user to request.

typescript
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:

typescript
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.

typescript
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.

typescript
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:

typescript
router.post('/admin-only', authenticate, requireRole('ADMIN', 'SUPER_ADMIN'), handler);

requireMinRole

Requires user to have at least the specified role level.

typescript
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:

typescript
// 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.).

typescript
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:

typescript
// 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.

typescript
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.

typescript
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.

typescript
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.

typescript
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.

typescript
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:

typescript
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):

typescript
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):

typescript
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):

typescript
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

typescript
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.

typescript
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.

typescript
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

typescript
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

MiddlewarePurposeApplied To
helmetSecurity headersAll routes
corsCross-origin controlAll routes
generalLimiter100 req/15minAll routes
authLimiter10 req/15minAuth endpoints
otpLimiter3 req/1minOTP endpoints
uploadLimiter20 req/hourUpload endpoints
authenticateJWT validationProtected routes
requireRoleRole checkAdmin routes
requireMinRoleMin role levelAdmin routes
requireActiveStatusActive accountMost user routes
validateInput validationAll data endpoints
errorHandlerError formattingAll routes

One chat. Everything done.