Skip to content

YeboMart Middleware — Auth & Validation

Authentication, authorization, rate limiting, and request validation.


Middleware Stack

Request

  ├── helmet           — Security headers
  ├── cors             — Cross-origin handling
  ├── compression      — Response compression
  ├── morgan           — Request logging
  ├── express.json     — Body parsing

  └── Route-specific:
      ├── authMiddleware     — Verify JWT
      ├── requireRole        — Role-based access
      ├── requireFeature     — Feature gating
      ├── validateRequest    — Joi validation
      ├── rateLimit          — Rate limiting
      ├── checkProductLimit  — Tier limits
      ├── checkUserLimit     — Staff limits
      ├── checkAiUsage       — AI quota
      └── trackUsage         — Usage counters

Authentication Middleware

authMiddleware

Verifies JWT and attaches user to request.

typescript
// src/middleware/auth.middleware.ts

export interface AuthRequest extends Request {
  user?: IDecodedToken;
}

export const authMiddleware = (req: AuthRequest, res: Response, next: NextFunction): void => {
  try {
    const authHeader = req.headers.authorization;

    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      ApiResponse.unauthorized(res, 'No token provided');
      return;
    }

    const token = authHeader.substring(7);
    const decoded = JWTUtil.verifyAccessToken(token);

    if (!decoded) {
      ApiResponse.unauthorized(res, 'Invalid or expired token');
      return;
    }

    req.user = decoded;
    next();
  } catch (error) {
    ApiResponse.unauthorized(res, 'Authentication failed');
  }
};

Token Payload Structure

typescript
interface ITokenPayload {
  id: string;      // User or Shop ID
  shopId: string;  // Always the shop ID
  phone: string;
  email?: string;
  role: 'OWNER' | 'MANAGER' | 'CASHIER';
  type: 'shop' | 'user' | 'admin';
}

Role-Based Access

typescript
// Require specific roles
export const requireRole = (...roles: UserRole[]) => {
  return (req: AuthRequest, res: Response, next: NextFunction): void => {
    authMiddleware(req, res, () => {
      if (!req.user) {
        ApiResponse.unauthorized(res, 'Authentication required');
        return;
      }

      if (!roles.includes(req.user.role)) {
        ApiResponse.forbidden(res, `Required role: ${roles.join(' or ')}`);
        return;
      }

      next();
    });
  };
};

// Convenience exports
export const ownerAuth = requireRole('OWNER');
export const managerAuth = requireRole('OWNER', 'MANAGER');
export const staffAuth = requireRole('OWNER', 'MANAGER', 'CASHIER');

Optional Authentication

typescript
// Sets user if token present, but doesn't require it
export const optionalAuth = (req: AuthRequest, res: Response, next: NextFunction): void => {
  try {
    const authHeader = req.headers.authorization;

    if (authHeader && authHeader.startsWith('Bearer ')) {
      const token = authHeader.substring(7);
      const decoded = JWTUtil.verifyAccessToken(token);
      if (decoded) {
        req.user = decoded;
      }
    }

    next();
  } catch {
    next();
  }
};

Admin Authentication

typescript
export const authenticateAdmin = (req: AuthRequest, res: Response, next: NextFunction): void => {
  // ... verify JWT
  
  // Check if it's an admin token
  if (decoded.type !== 'admin') {
    ApiResponse.forbidden(res, 'Admin access required');
    return;
  }

  req.user = decoded;
  next();
};

License & Feature Middleware

requireFeature

Check if feature is available for shop's tier.

typescript
// src/middleware/license.middleware.ts

export const requireFeature = (feature: string) => {
  return async (req: AuthRequest, res: Response, next: NextFunction): Promise<void> => {
    try {
      if (!req.user) {
        ApiResponse.unauthorized(res, 'Authentication required');
        return;
      }

      const shop = await prisma.shop.findUnique({
        where: { id: req.user.shopId },
        select: { tier: true, licenseExpiry: true },
      });

      if (!shop) {
        ApiResponse.notFound(res, 'Shop not found');
        return;
      }

      // Check if license is expired
      if (shop.licenseExpiry && shop.licenseExpiry < new Date()) {
        ApiResponse.forbidden(res, 'License expired. Please renew.');
        return;
      }

      // Check if feature is available
      if (!LicenseService.hasFeature(shop.tier, feature)) {
        ApiResponse.forbidden(res, `Requires upgraded plan. Current: ${shop.tier}`);
        return;
      }

      next();
    } catch (error) {
      ApiResponse.serverError(res, 'Failed to verify license');
    }
  };
};

checkProductLimit

Enforce product count limits based on tier.

typescript
export const checkProductLimit = async (
  req: AuthRequest, res: Response, next: NextFunction
): Promise<void> => {
  const shop = await prisma.shop.findUnique({
    where: { id: req.user!.shopId },
    select: {
      tier: true,
      _count: { select: { products: true } },
    },
  });

  const limits = LicenseService.getTierLimits(shop!.tier);
  
  if (shop!._count.products >= limits.maxProducts) {
    ApiResponse.forbidden(res, 
      `Product limit reached (${limits.maxProducts}). Upgrade to add more.`
    );
    return;
  }

  next();
};

checkUserLimit

Enforce staff count limits.

typescript
export const checkUserLimit = async (
  req: AuthRequest, res: Response, next: NextFunction
): Promise<void> => {
  const shop = await prisma.shop.findUnique({
    where: { id: req.user!.shopId },
    select: {
      tier: true,
      _count: { select: { users: true } },
    },
  });

  const limits = LicenseService.getTierLimits(shop!.tier);
  
  if (shop!._count.users >= limits.maxUsers) {
    ApiResponse.forbidden(res, 
      `User limit reached (${limits.maxUsers}). Upgrade to add more staff.`
    );
    return;
  }

  next();
};

checkAiUsage

Enforce AI query limits and track usage.

typescript
export const checkAiUsage = async (
  req: AuthRequest, res: Response, next: NextFunction
): Promise<void> => {
  const shop = await prisma.shop.findUnique({
    where: { id: req.user!.shopId },
    select: { tier: true, monthlyAiQueries: true },
  });

  const limits = LicenseService.getTierLimits(shop!.tier);

  if (limits.aiQueriesPerMonth !== Infinity && 
      shop!.monthlyAiQueries >= limits.aiQueriesPerMonth) {
    const tierNames: Record<string, string> = { 
      LITE: 'Starter', STARTER: 'Business', BUSINESS: 'Pro', PRO: 'Enterprise' 
    };
    const upgradeTo = tierNames[shop!.tier] || 'a higher plan';
    
    ApiResponse.forbidden(res, 
      `AI limit reached (${limits.aiQueriesPerMonth}/month). Upgrade to ${upgradeTo}.`
    );
    return;
  }

  // Increment counter in background (non-blocking)
  prisma.shop.update({
    where: { id: req.user!.shopId },
    data: { monthlyAiQueries: { increment: 1 } },
  }).catch(console.error);

  // Attach usage info for frontend
  (req as any).aiUsage = {
    used: shop!.monthlyAiQueries + 1,
    limit: limits.aiQueriesPerMonth,
    remaining: limits.aiQueriesPerMonth === Infinity 
      ? Infinity 
      : limits.aiQueriesPerMonth - shop!.monthlyAiQueries - 1,
  };

  next();
};

trackUsage

Track monthly transactions and stock moves.

typescript
export const trackUsage = (type: 'transaction' | 'stockMove') => {
  return async (req: AuthRequest, res: Response, next: NextFunction): Promise<void> => {
    if (!req.user) {
      next();
      return;
    }

    // Update counter in background (non-blocking)
    const field = type === 'transaction' 
      ? 'monthlyTransactions' 
      : 'monthlyStockMoves';
    
    prisma.shop.update({
      where: { id: req.user.shopId },
      data: { [field]: { increment: 1 } },
    }).catch(console.error);

    next();
  };
};

Validation Middleware

Joi Request Validation

typescript
// src/middleware/validation.middleware.ts

export const validateRequest = (schema: Joi.ObjectSchema) => {
  return (req: Request, res: Response, next: NextFunction): void => {
    const { error, value } = schema.validate(req.body, {
      abortEarly: false,
      stripUnknown: true,
    });

    if (error) {
      const details = error.details.map(d => ({
        field: d.path.join('.'),
        message: d.message,
      }));
      
      ApiResponse.badRequest(res, 'Validation failed', details);
      return;
    }

    req.body = value;  // Use validated/sanitized value
    next();
  };
};

export const validateQuery = (schema: Joi.ObjectSchema) => {
  return (req: Request, res: Response, next: NextFunction): void => {
    const { error, value } = schema.validate(req.query, {
      abortEarly: false,
      stripUnknown: true,
    });

    if (error) {
      const details = error.details.map(d => ({
        field: d.path.join('.'),
        message: d.message,
      }));
      
      ApiResponse.badRequest(res, 'Invalid query parameters', details);
      return;
    }

    req.query = value;
    next();
  };
};

Example Schemas

typescript
// controllers/auth.controller.ts

export const registerSchema = Joi.object({
  name: Joi.string().min(2).max(100).required(),
  ownerName: Joi.string().min(2).max(100).required(),
  phone: Joi.string()
    .pattern(/^\+?[1-9]\d{6,14}$/)
    .required()
    .messages({
      'string.pattern.base': 'Phone must be in international format'
    }),
  password: Joi.string().min(4).max(50).required(),
  ownerEmail: Joi.string().email().optional(),
  assistantName: Joi.string().min(1).max(20).optional(),
  businessType: Joi.string().valid(
    'general', 'tuckshop', 'grocery', 'hardware', 
    'pharmacy', 'salon', 'electronics', 'clothing', 'restaurant'
  ).optional(),
  countryCode: Joi.string().length(2).uppercase().optional(),
});

export const createSaleSchema = Joi.object({
  items: Joi.array().items(
    Joi.object({
      productId: Joi.string().required(),
      quantity: Joi.number().integer().min(1).required(),
      discount: Joi.number().min(0).optional(),
    })
  ).min(1).required(),
  paymentMethod: Joi.string()
    .valid('CASH', 'MOMO', 'EMALI', 'CARD', 'MIXED', 'CREDIT')
    .required(),
  amountPaid: Joi.number().min(0).required(),
  discount: Joi.number().min(0).optional(),
  customerId: Joi.string().optional(),
  localId: Joi.string().optional(),
  offlineAt: Joi.date().optional(),
});

Rate Limiting

typescript
// src/middleware/rateLimit.middleware.ts
import rateLimit from 'express-rate-limit';

// Auth endpoints - strict
export const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 10,                   // 10 attempts
  message: { 
    success: false, 
    error: 'Too many auth attempts. Try again in 15 minutes.' 
  },
  standardHeaders: true,
  legacyHeaders: false,
});

// POS transactions - high volume allowed
export const posLimiter = rateLimit({
  windowMs: 60 * 1000,      // 1 minute
  max: 60,                   // 60 sales per minute
  message: { 
    success: false, 
    error: 'POS rate limit exceeded. Slow down!' 
  },
});

// AI queries - moderate
export const aiLimiter = rateLimit({
  windowMs: 60 * 1000,      // 1 minute
  max: 10,                   // 10 queries per minute
  message: { 
    success: false, 
    error: 'AI rate limit exceeded. Wait a moment.' 
  },
});

// General API - balanced
export const apiLimiter = rateLimit({
  windowMs: 60 * 1000,      // 1 minute
  max: 100,                  // 100 requests per minute
  message: { 
    success: false, 
    error: 'Too many requests. Slow down.' 
  },
});

API Response Utility

typescript
// src/utils/ApiResponse.ts

export class ApiResponse {
  static success(res: Response, data: any, message?: string) {
    return res.status(200).json({
      success: true,
      message,
      data,
    });
  }

  static created(res: Response, data: any, message?: string) {
    return res.status(201).json({
      success: true,
      message: message || 'Created successfully',
      data,
    });
  }

  static badRequest(res: Response, message: string, details?: any) {
    return res.status(400).json({
      success: false,
      error: {
        code: 'VALIDATION_ERROR',
        message,
        details,
      },
    });
  }

  static unauthorized(res: Response, message: string) {
    return res.status(401).json({
      success: false,
      error: {
        code: 'UNAUTHORIZED',
        message,
      },
    });
  }

  static forbidden(res: Response, message: string) {
    return res.status(403).json({
      success: false,
      error: {
        code: 'FORBIDDEN',
        message,
      },
    });
  }

  static notFound(res: Response, message: string) {
    return res.status(404).json({
      success: false,
      error: {
        code: 'NOT_FOUND',
        message,
      },
    });
  }

  static serverError(res: Response, message?: string) {
    return res.status(500).json({
      success: false,
      error: {
        code: 'SERVER_ERROR',
        message: message || 'Internal server error',
      },
    });
  }
}

JWT Utility

typescript
// src/utils/jwt.ts
import jwt from 'jsonwebtoken';

const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret';
const ACCESS_TOKEN_EXPIRY = '1d';
const REFRESH_TOKEN_EXPIRY = '7d';

export class JWTUtil {
  static generateAccessToken(payload: ITokenPayload): string {
    return jwt.sign(payload, JWT_SECRET, { 
      expiresIn: ACCESS_TOKEN_EXPIRY 
    });
  }

  static generateRefreshToken(payload: ITokenPayload): string {
    return jwt.sign(payload, JWT_SECRET, { 
      expiresIn: REFRESH_TOKEN_EXPIRY 
    });
  }

  static verifyAccessToken(token: string): IDecodedToken | null {
    try {
      return jwt.verify(token, JWT_SECRET) as IDecodedToken;
    } catch {
      return null;
    }
  }

  static verifyRefreshToken(token: string): IDecodedToken | null {
    try {
      return jwt.verify(token, JWT_SECRET) as IDecodedToken;
    } catch {
      return null;
    }
  }
}

Tier Limits Reference

typescript
// Enforced by middleware
const TIER_LIMITS = {
  LITE: {
    maxProducts: 100,
    maxUsers: 1,
    maxTransactions: 500,
    maxStockMoves: 100,
    aiQueriesPerMonth: 100,
  },
  STARTER: {
    maxProducts: 500,
    maxUsers: 3,
    maxTransactions: 2000,
    maxStockMoves: 500,
    aiQueriesPerMonth: 500,
  },
  BUSINESS: {
    maxProducts: 2500,
    maxUsers: 10,
    maxTransactions: 10000,
    maxStockMoves: 2500,
    aiQueriesPerMonth: 2000,
  },
  PRO: {
    maxProducts: 10000,
    maxUsers: 25,
    maxTransactions: 50000,
    maxStockMoves: 15000,
    aiQueriesPerMonth: 10000,
  },
  ENTERPRISE: {
    maxProducts: Infinity,
    maxUsers: Infinity,
    maxTransactions: Infinity,
    maxStockMoves: Infinity,
    aiQueriesPerMonth: Infinity,
  },
};

One chat. Everything done.