YeboLink Middleware Deep Dive
Middleware handles cross-cutting concerns like authentication, rate limiting, validation, and error handling. Located in src/middleware/.
Middleware Stack
Applied in app.ts in this order:
app.use(helmet()); // Security headers
app.use(cors(corsOptions)); // CORS
app.use(express.json({ // JSON parsing with raw body preservation
verify: (req, res, buf) => { (req as any).rawBody = buf; }
}));
app.use(express.urlencoded({ extended: true }));
app.use(generalRateLimiter); // Global rate limit (1000/15min)Authentication (auth.ts)
authenticateJWT
Validates Bearer tokens for dashboard access.
export const authenticateJWT = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({
success: false,
error: 'Missing or invalid authorization header',
});
return;
}
const token = authHeader.substring(7);
try {
const payload: JwtPayload = AuthService.verifyJwt(token);
// Get workspace
const workspace = await WorkspaceModel.findById(payload.workspaceId);
if (!workspace) {
res.status(401).json({ success: false, error: 'Workspace not found' });
return;
}
if (!workspace.is_active) {
res.status(403).json({ success: false, error: 'Workspace is deactivated' });
return;
}
// Attach to request
req.workspaceId = workspace.id;
req.workspace = workspace;
next();
} catch (error) {
res.status(401).json({ success: false, error: 'Invalid or expired token' });
return;
}
} catch (error) {
next(error);
}
};JWT Payload Structure:
interface JwtPayload {
workspaceId: string;
email: string;
iat: number; // Issued at
exp: number; // Expiry
}authenticateApiKey
Validates X-API-Key header for programmatic access.
export const authenticateApiKey = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const apiKey = req.headers['x-api-key'] as string;
if (!apiKey) {
res.status(401).json({ success: false, error: 'Missing API key' });
return;
}
// Validate format
if (!apiKey.startsWith('ybk_')) {
res.status(401).json({ success: false, error: 'Invalid API key format' });
return;
}
// Hash the API key for lookup
const keyHash = hashApiKey(apiKey);
// Find API key in database
const apiKeyRecord = await ApiKeyModel.findByHash(keyHash);
if (!apiKeyRecord) {
res.status(401).json({ success: false, error: 'Invalid API key' });
return;
}
// Get workspace
const workspace = await WorkspaceModel.findById(apiKeyRecord.workspace_id);
if (!workspace) {
res.status(401).json({ success: false, error: 'Workspace not found' });
return;
}
if (!workspace.is_active) {
res.status(403).json({ success: false, error: 'Workspace is deactivated' });
return;
}
// Update last used timestamp
await ApiKeyModel.updateLastUsed(apiKeyRecord.id);
// Attach to request
req.workspaceId = workspace.id;
req.workspace = workspace;
req.apiKeyId = apiKeyRecord.id;
req.apiKey = apiKeyRecord;
next();
} catch (error) {
next(error);
}
};API Key Hashing:
// utils/crypto.ts
export const hashApiKey = (apiKey: string): string => {
return crypto
.createHmac('sha256', env.API_KEY_SECRET)
.update(apiKey)
.digest('hex');
};authenticateAny
Accepts either JWT or API key (for endpoints usable from dashboard or programmatically).
export const authenticateAny = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
const authHeader = req.headers.authorization;
const apiKeyHeader = req.headers['x-api-key'];
if (authHeader?.startsWith('Bearer ')) {
return authenticateJWT(req, res, next);
}
if (apiKeyHeader) {
return authenticateApiKey(req, res, next);
}
res.status(401).json({
success: false,
error: 'Authentication required. Provide a Bearer token or X-API-Key header.',
});
};requireScope
Validates API key has required scope (used for fine-grained permissions).
export const requireScope = (requiredScope: string) => {
return (req: Request, res: Response, next: NextFunction): void => {
if (!req.apiKey) {
res.status(403).json({ success: false, error: 'API key authentication required' });
return;
}
const scopes = req.apiKey.scopes || [];
if (!scopes.includes(requiredScope) && !scopes.includes('*')) {
res.status(403).json({
success: false,
error: `Missing required scope: ${requiredScope}`,
});
return;
}
next();
};
};Available Scopes:
send_messages— Send SMS, email, etc.read_messages— Read message historymanage_contacts— Create/update contactsmanage_webhooks— Configure webhooks*— All permissions
requireEmailVerification
Optional middleware to require verified email.
export const requireEmailVerification = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
if (!req.workspace) {
res.status(401).json({ success: false, error: 'Not authenticated' });
return;
}
if (!req.workspace.email_verified) {
res.status(403).json({ success: false, error: 'Email verification required' });
return;
}
next();
};Dashboard Auth (dashboardAuth.ts)
Simple API key authentication for CEO/admin dashboard.
export const dashboardAuth = (req: Request, res: Response, next: NextFunction) => {
const apiKey = req.headers['x-api-key'] as string;
const expectedKey = process.env.CEO_DASHBOARD_API_KEY;
if (!expectedKey) {
return res.status(500).json({
success: false,
error: 'Dashboard authentication not configured'
});
}
if (!apiKey) {
return res.status(401).json({
success: false,
error: 'API key required. Please provide X-API-Key header'
});
}
if (apiKey !== expectedKey) {
return res.status(401).json({
success: false,
error: 'Invalid API key'
});
}
next();
};Rate Limiting (rateLimiter.ts)
Uses express-rate-limit with in-memory store.
General Rate Limiter
Applied globally to all routes.
export const generalRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 min window
max: 1000, // 1000 req / IP
message: { success: false, error: 'Too many requests, please try again later.' },
standardHeaders: true,
legacyHeaders: false,
handler: (req: Request, res: Response) => {
logger.warn('Rate limit exceeded', { ip: req.ip, path: req.path });
res.status(429).json({ success: false, error: 'Too many requests...' });
},
});API Key Rate Limiter
Per-workspace limit for message sending.
export const apiKeyRateLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 min window
max: 300, // 300 sends/min per workspace
keyGenerator: (req: Request) => req.workspaceId || req.apiKeyId || req.ip || 'unknown',
message: { success: false, error: 'Rate limit exceeded. Maximum 300 requests per minute.' },
standardHeaders: true,
legacyHeaders: false,
handler: (req: Request, res: Response) => {
logger.warn('API key rate limit exceeded', {
apiKeyId: req.apiKeyId,
workspaceId: req.workspaceId
});
res.status(429).json({ success: false, error: 'Rate limit exceeded...' });
},
});Bulk Send Rate Limiter
Stricter limit for bulk operations.
export const bulkSendRateLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 min window
max: 50, // 50 bulk jobs per 5 min
keyGenerator: (req: Request) => `bulk:${req.workspaceId || req.ip}`,
message: { success: false, error: 'Bulk send rate limit exceeded. Maximum 50 bulk sends per 5 minutes.' },
});Auth Rate Limiter
Protects against brute force attacks.
export const authRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 min window
max: 10, // 10 failed attempts
skipSuccessfulRequests: true, // Only count failures
keyGenerator: (req: Request) => req.body?.email || req.ip || 'unknown',
message: { success: false, error: 'Too many authentication attempts. Please try again later.' },
handler: (req: Request, res: Response) => {
logger.warn('Auth rate limit exceeded', { email: req.body?.email, ip: req.ip });
res.status(429).json({ success: false, error: 'Too many login attempts. Please try again in 15 minutes.' });
},
});Validation (validator.ts)
Uses express-validator for request validation.
Validate Middleware
Collects validation errors and returns 400.
export const validate = (req: Request, res: Response, next: NextFunction): void => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({
success: false,
error: 'Validation failed',
details: errors.array(),
});
return;
}
next();
};Auth Validators
export const signupValidation = [
body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
body('password')
.isLength({ min: 4 })
.withMessage('Password must be at least 4 characters'),
body('company_name')
.trim()
.isLength({ min: 2, max: 100 })
.withMessage('Company name must be 2-100 characters'),
body('phone')
.optional()
.matches(/^\+[1-9]\d{6,14}$/)
.withMessage('Phone must be in E.164 format'),
body('country')
.optional()
.isISO31661Alpha2()
.withMessage('Invalid country code'),
];
export const loginValidation = [
body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
body('password').notEmpty().withMessage('Password is required'),
];Message Validators
export const sendMessageValidation = [
body('to').notEmpty().withMessage('Recipient is required'),
body('channel')
.isIn(['sms', 'whatsapp', 'email', 'voice', 'push', 'web'])
.withMessage('Invalid channel'),
body('content').isObject().withMessage('Content must be an object'),
body('content.text').optional().isString(),
body('content.html').optional().isString(),
body('content.from_name').optional().isString().isLength({ max: 100 }),
body('content.media_urls').optional().isArray(),
// Email-specific validations
body('to')
.if(body('channel').equals('email'))
.isEmail()
.withMessage('Recipient must be a valid email for email channel'),
body('content.subject')
.if(body('channel').equals('email'))
.notEmpty()
.withMessage('content.subject is required for email channel'),
body('from').optional().isString(),
body('metadata').optional().isObject(),
];
export const bulkSendValidation = [
body('recipients').isArray({ min: 1 }).withMessage('Recipients must be non-empty array'),
body('recipients.*.to').notEmpty().withMessage('Each recipient must have "to"'),
body('channel').isIn(['sms', 'whatsapp', 'email', 'voice', 'push', 'web']),
body('content').isObject(),
];Contact Validators
export const createContactValidation = [
body('phone')
.optional()
.matches(/^\+[1-9]\d{6,14}$/)
.withMessage('Phone must be in E.164 format'),
body('email').optional().isEmail().normalizeEmail(),
body('name').optional().trim().isLength({ max: 255 }),
body('custom_fields').optional().isObject(),
body('tags').optional().isArray(),
];Webhook Validators
export const createWebhookValidation = [
body('url').isURL().withMessage('Valid URL is required'),
body('events').isArray({ min: 1 }).withMessage('Events must be non-empty array'),
];
export const updateWebhookValidation = [
param('id').isUUID().withMessage('Invalid webhook ID'),
body('url').optional().isURL(),
body('events').optional().isArray({ min: 1 }),
body('is_active').optional().isBoolean(),
];Generic Validators
// Validate UUID path parameter
export const idValidation = [
param('id').isUUID().withMessage('Invalid ID format')
];
// Pagination for message list
export const messageFiltersValidation = [
query('channel').optional().isIn(['sms', 'whatsapp', 'email', 'voice', 'push', 'web']),
query('status').optional().isIn(['queued', 'sent', 'delivered', 'failed', 'read']),
query('startDate').optional().isISO8601(),
query('endDate').optional().isISO8601(),
query('page').optional().isInt({ min: 1 }),
query('limit').optional().isInt({ min: 1, max: 100 }),
];Error Handling (errorHandler.ts)
AppError Class
Custom error class for operational errors.
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);
}
}Usage:
throw new AppError('Insufficient credits', 402);
throw new AppError('Invalid API key', 401);
throw new AppError('Message not found', 404);Global Error Handler
export const errorHandler = (
err: Error | AppError,
req: Request,
res: Response,
next: NextFunction
): void => {
let statusCode = 500;
let message = 'Internal server error';
let isOperational = false;
if (err instanceof AppError) {
statusCode = err.statusCode;
message = err.message;
isOperational = err.isOperational;
}
// Log error
logger.error('Error occurred', {
message: err.message,
stack: err.stack,
statusCode,
path: req.path,
method: req.method,
ip: req.ip,
workspaceId: req.workspaceId,
});
// Don't leak details in production
if (process.env.NODE_ENV === 'production' && !isOperational) {
message = 'Something went wrong';
}
res.status(statusCode).json({
success: false,
error: message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
});
};Async Handler Wrapper
Catches errors in async route handlers.
export const asyncHandler = (
fn: (req: Request, res: Response, next: NextFunction) => Promise<any>
) => {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};Usage:
router.get('/messages', asyncHandler(async (req, res) => {
const messages = await MessageService.getMessages(req.workspaceId);
res.json({ success: true, data: { messages } });
}));
// Any thrown error automatically goes to errorHandler404 Handler
export const notFoundHandler = (
req: Request,
res: Response,
next: NextFunction
): void => {
res.status(404).json({
success: false,
error: 'Resource not found',
});
};Request Type Extensions
Extended Express types for TypeScript:
declare global {
namespace Express {
interface Request {
workspace?: any; // Full workspace object
workspaceId?: string; // Workspace UUID
apiKey?: any; // Full API key record
apiKeyId?: string; // API key UUID
rawBody?: Buffer; // Raw body for webhook signature verification
}
}
}