Skip to content

YeboShops Middleware

Documentation of all Express middleware used in the YeboShops API.

Middleware Stack

Applied in app.ts in this order:

typescript
// 1. Trust proxy (Cloud Run)
app.set('trust proxy', true);

// 2. Security headers
app.use(helmet());

// 3. CORS
app.use(cors({ ... }));

// 4. Rate limiting
app.use(limiter);

// 5. Raw body for Stripe webhook
app.use('/api/billing/webhook', express.raw({ type: 'application/json' }));

// 6. Body parsing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// 7. Cookie parsing
app.use(cookieParser());

// 8. Request logging
app.use(requestLogger);

// 9. Country context detection
app.use(countryContextMiddleware);

Security Middleware

Helmet

Adds security HTTP headers:

typescript
app.use(helmet());

// Headers set:
// - X-DNS-Prefetch-Control: off
// - X-Frame-Options: SAMEORIGIN
// - X-Content-Type-Options: nosniff
// - Strict-Transport-Security: max-age=15552000
// - X-XSS-Protection: 0
// - X-Download-Options: noopen
// - Referrer-Policy: no-referrer

CORS

Cross-Origin Resource Sharing configuration:

typescript
app.use(cors({
  origin: config.corsOrigin === '*' 
    ? true 
    : config.corsOrigin,
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'],
  allowedHeaders: [
    'Content-Type',
    'Authorization',
    'X-Requested-With',
    'Accept',
    'Origin',
    'X-Guest-ID',
    'CF-IPCountry',      // Cloudflare country header
    'CF-Ray',
    'CF-Connecting-IP',
    'X-Country-Code',
    'X-Country-ID',
    'X-API-Key'
  ],
  exposedHeaders: ['Content-Range', 'X-Content-Range']
}));

Rate Limiting

typescript
const limiter = rateLimit({
  windowMs: config.rateLimitWindowMs,  // 15 minutes
  max: config.rateLimitMax,            // 100 requests per window
  standardHeaders: true,
  legacyHeaders: false,
  validate: { trustProxy: false, xForwardedForHeader: false }
});
app.use(limiter);

Authentication Middleware

JWT Authentication

typescript
// middleware/auth.ts
import jwt from 'jsonwebtoken';
import { Request, Response, NextFunction } from 'express';

interface AuthRequest extends Request {
  user?: {
    id: string;
    role: string;
  };
}

export const authenticate = async (
  req: AuthRequest, 
  res: Response, 
  next: NextFunction
) => {
  try {
    // Get token from Authorization header
    const authHeader = req.headers.authorization;
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return res.status(401).json({ error: 'No token provided' });
    }
    
    const token = authHeader.substring(7);
    
    // Verify token
    const decoded = jwt.verify(token, config.jwtSecret) as {
      id: string;
      role: string;
    };
    
    // Attach user to request
    req.user = {
      id: decoded.id,
      role: decoded.role
    };
    
    next();
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      return res.status(401).json({ error: 'Token expired' });
    }
    return res.status(401).json({ error: 'Invalid token' });
  }
};

// Optional auth (doesn't fail if no token)
export const optionalAuth = async (
  req: AuthRequest, 
  res: Response, 
  next: NextFunction
) => {
  try {
    const authHeader = req.headers.authorization;
    if (authHeader && authHeader.startsWith('Bearer ')) {
      const token = authHeader.substring(7);
      const decoded = jwt.verify(token, config.jwtSecret);
      req.user = decoded;
    }
  } catch {
    // Ignore errors, continue without auth
  }
  next();
};

Admin Authorization

typescript
export const requireAdmin = (
  req: AuthRequest, 
  res: Response, 
  next: NextFunction
) => {
  if (req.user?.role !== 'ADMIN') {
    return res.status(403).json({ error: 'Admin access required' });
  }
  next();
};

API Key Authentication

For internal/webhook routes:

typescript
export const requireApiKey = (
  req: Request, 
  res: Response, 
  next: NextFunction
) => {
  const apiKey = req.headers['x-api-key'];
  
  if (!apiKey || apiKey !== config.internalApiKey) {
    return res.status(401).json({ error: 'Invalid API key' });
  }
  
  next();
};

Country Context Middleware

Detects user's country from Cloudflare headers or request headers:

typescript
// middleware/country-context.middleware.ts
export const countryContextMiddleware = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  // Priority order for country detection:
  // 1. X-Country-ID header (explicit)
  // 2. X-Country-Code header
  // 3. CF-IPCountry (Cloudflare)
  // 4. User's profile country (if authenticated)
  
  let countryCode = 
    req.headers['x-country-code'] as string ||
    req.headers['cf-ipcountry'] as string;
  
  if (countryCode) {
    // Lookup country in database
    const country = await prisma.country.findFirst({
      where: { 
        code: countryCode.toUpperCase(),
        isActive: true 
      }
    });
    
    if (country) {
      (req as any).countryContext = {
        id: country.id,
        code: country.code,
        currency: country.currency,
        callingCode: country.callingCode
      };
    }
  }
  
  next();
};

Usage in routes:

typescript
router.get('/products', async (req, res) => {
  const countryContext = (req as any).countryContext;
  
  // Filter products by country
  const products = await prisma.product.findMany({
    where: {
      shop: {
        countryId: countryContext?.id
      }
    }
  });
  
  // Format prices with local currency
  const formatted = products.map(p => ({
    ...p,
    formattedPrice: `${countryContext?.currency?.symbol}${p.priceAmount}`
  }));
  
  res.json(formatted);
});

Request Logging

typescript
const requestLogger = (req: Request, res: Response, next: NextFunction) => {
  const start = Date.now();
  const requestId = Math.random().toString(36).substring(7);
  
  // Log request
  logger.info(`[${requestId}] ${req.method} ${req.url}`);
  
  // Log response on finish
  res.on('finish', () => {
    const duration = Date.now() - start;
    const statusLevel = res.statusCode >= 400 ? 'warn' : 'info';
    logger[statusLevel](`[${requestId}] ${res.statusCode} - ${duration}ms`);
  });
  
  next();
};

Error Handling Middleware

typescript
// middleware/error.middleware.ts
export const errorHandler = (
  error: Error,
  req: Request,
  res: Response,
  next: NextFunction
) => {
  // Log error
  logger.error('Error:', {
    message: error.message,
    stack: error.stack,
    path: req.path,
    method: req.method
  });
  
  // Handle known error types
  if (error.name === 'ValidationError') {
    return res.status(400).json({
      success: false,
      error: 'Validation Error',
      details: error.message
    });
  }
  
  if (error.name === 'UnauthorizedError') {
    return res.status(401).json({
      success: false,
      error: 'Unauthorized'
    });
  }
  
  if (error.name === 'NotFoundError') {
    return res.status(404).json({
      success: false,
      error: 'Not Found'
    });
  }
  
  // Prisma errors
  if (error.name === 'PrismaClientKnownRequestError') {
    const prismaError = error as any;
    if (prismaError.code === 'P2002') {
      return res.status(409).json({
        success: false,
        error: 'Duplicate entry'
      });
    }
  }
  
  // Default server error
  return res.status(500).json({
    success: false,
    error: 'Internal Server Error'
  });
};

Validation Middleware

Using express-validator or Zod:

typescript
import { body, validationResult } from 'express-validator';

// Validation rules
export const validateCreateProduct = [
  body('title')
    .notEmpty()
    .withMessage('Title is required')
    .isLength({ min: 2, max: 200 })
    .withMessage('Title must be 2-200 characters'),
  body('price')
    .optional()
    .isFloat({ min: 0 })
    .withMessage('Price must be positive'),
  body('shopId')
    .notEmpty()
    .withMessage('Shop ID is required')
];

// Validation check middleware
export const validate = (req: Request, res: Response, next: NextFunction) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({
      success: false,
      errors: errors.array()
    });
  }
  next();
};

// Usage in routes
router.post('/products', 
  authenticate,
  validateCreateProduct,
  validate,
  productController.create
);

Guest Session Middleware

For tracking anonymous users:

typescript
export const guestSessionMiddleware = (
  req: Request, 
  res: Response, 
  next: NextFunction
) => {
  // Check for existing guest ID in cookie or header
  let guestId = 
    req.cookies['X-Guest-ID'] || 
    req.headers['x-guest-id'] as string;
  
  if (!guestId) {
    // Generate new guest ID
    guestId = `guest_${crypto.randomUUID()}`;
    
    // Set cookie
    res.cookie('X-Guest-ID', guestId, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
    });
  }
  
  (req as any).guestId = guestId;
  next();
};

Shop Owner Middleware

Verifies user owns the shop:

typescript
export const requireShopOwner = async (
  req: AuthRequest, 
  res: Response, 
  next: NextFunction
) => {
  const shopId = req.params.shopId || req.body.shopId;
  
  if (!shopId) {
    return res.status(400).json({ error: 'Shop ID required' });
  }
  
  const shop = await prisma.shop.findUnique({
    where: { id: shopId },
    select: { ownerId: true }
  });
  
  if (!shop) {
    return res.status(404).json({ error: 'Shop not found' });
  }
  
  if (shop.ownerId !== req.user?.id && req.user?.role !== 'ADMIN') {
    return res.status(403).json({ error: 'Not shop owner' });
  }
  
  next();
};

Middleware Chain Example

typescript
// Full route with all middleware
router.post('/products/:productId/publish',
  authenticate,                    // 1. Verify JWT
  requireShopOwner,               // 2. Verify ownership
  validatePublishProduct,         // 3. Validate body
  validate,                       // 4. Check validation errors
  productController.publish       // 5. Handle request
);

// Protected admin route
router.get('/admin/stats',
  authenticate,
  requireAdmin,
  adminController.getStats
);

// Public route with optional auth
router.get('/feed',
  optionalAuth,
  guestSessionMiddleware,
  countryContextMiddleware,
  feedController.getFeed
);

One chat. Everything done.