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 countersAuthentication 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,
},
};