import express from 'express' import cors, { type CorsOptions } from 'cors' import helmet from 'helmet' import morgan from 'morgan' import swaggerUi from 'swagger-ui-express' import { openApiDocument } from './swagger/openapi' import { getPublicStorageRoot } from './lib/storage' import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter' import { requestIdMiddleware } from './middleware/requestId' // ─── Module routes ──────────────────────────────────────────── import webhookRouter from './modules/webhooks/webhook.routes' import companyAuthRouter from './modules/auth/auth.company.routes' import employeeAuthRouter from './modules/auth/auth.employee.routes' import accountAuthRouter from './modules/auth/auth.account.routes' import renterAuthRouter from './modules/auth/auth.renter.routes' import teamRouter from './modules/team/team.routes' import offersRouter from './modules/offers/offer.routes' import analyticsRouter from './modules/analytics/analytics.routes' import notificationsRouter from './modules/notifications/notification.routes' import adminRouter from './modules/admin/admin.routes' import subscriptionsRouter, { subscriptionPublicRouter, subscriptionWebhookRouter, } from './modules/subscriptions/subscription.routes' import paymentsRouter from './modules/payments/payment.routes' import billingRouter from './modules/billing/billing.routes' import customersRouter from './modules/customers/customer.routes' import vehiclesRouter from './modules/vehicles/vehicle.routes' import companiesRouter from './modules/companies/company.routes' import reservationsRouter from './modules/reservations/reservation.routes' import carplaceRouter from './modules/carplace/carplace.routes' import siteRouter from './modules/site/site.routes' import reviewsRouter from './modules/reviews/review.routes' import complaintsRouter from './modules/complaints/complaint.routes' import licenseValidationRouter from './modules/licenses/license.validation.routes' // ─── Centralized error handling ─────────────────────────────── import { errorMiddleware } from './http/errors/errorMiddleware' const v1 = '/api/v1' const defaultCorsOrigins = [ 'http://localhost:3000', 'http://localhost:3001', 'http://localhost:3002', 'http://localhost:4000', 'http://127.0.0.1:3000', 'http://127.0.0.1:3001', 'http://127.0.0.1:3002', 'http://127.0.0.1:4000', ] export const corsOrigins = process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean) : defaultCorsOrigins function isAllowedLocalDevOrigin(origin: string) { if (process.env.NODE_ENV === 'production') return false try { const url = new URL(origin) return ( url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname) && ['3000', '3001', '3002', '4000'].includes(url.port) ) } catch { return false } } export function isCorsOriginAllowed(origin: string | undefined) { if (!origin) return true return corsOrigins.includes(origin) || isAllowedLocalDevOrigin(origin) } export const corsOptions: CorsOptions = { origin(origin, callback) { callback(null, isCorsOriginAllowed(origin)) }, credentials: true, } const routeDocs = [ { method: 'GET', path: '/health', description: 'Health check' }, { method: 'GET', path: `${v1}/docs`, description: 'Machine-readable API index' }, { method: 'GET', path: `${v1}/auth/renter/me`, description: 'Current renter profile' }, { method: 'GET', path: `${v1}/vehicles`, description: 'List company vehicles' }, { method: 'POST', path: `${v1}/vehicles`, description: 'Create vehicle' }, { method: 'GET', path: `${v1}/reservations`, description: 'List reservations' }, { method: 'POST', path: `${v1}/reservations`, description: 'Create reservation' }, { method: 'GET', path: `${v1}/customers`, description: 'List customers' }, { method: 'POST', path: `${v1}/customers`, description: 'Create customer' }, { method: 'GET', path: `${v1}/offers`, description: 'List offers' }, { method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' }, { method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' }, { method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' }, { method: 'GET', path: `${v1}/carplace/home`, description: 'Carplace home' }, { method: 'GET', path: `${v1}/carplace/search`, description: 'Carplace paginated vehicle search' }, { method: 'POST', path: `${v1}/carplace/quotes`, description: 'Carplace availability and price estimate' }, { method: 'POST', path: `${v1}/carplace/reservations`, description: 'Create Carplace reservation request' }, { method: 'GET', path: `${v1}/site/:slug/brand`, description: 'Public site brand config' }, { method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' }, { method: 'GET', path: `${v1}/subscriptions/plans`, description: 'Subscription plans' }, { method: 'GET', path: `${v1}/subscriptions/features`, description: 'Subscription plan features' }, { method: 'POST', path: `${v1}/admin/auth/login`, description: 'Admin login' }, ] export function createApp() { const app = express() const corsMiddleware = cors(corsOptions) if (process.env.NODE_ENV === 'production') { app.set('trust proxy', 1) } app.use(requestIdMiddleware) app.use((req, res, next) => { if (req.headers['x-middleware-subrequest']) { return res.status(400).json({ error: 'bad_request', message: 'Unsupported internal request header', statusCode: 400 }) } next() }) app.use(corsMiddleware) // Customer identity documents must never be anonymously retrievable from the // public storage mount, even if an older raw storage URL leaks. app.use('/storage/companies/:companyId/customers/:customerId', (_req, res) => { res.status(404).end() }) // Public storage assets (logos, vehicle photos, etc.) must be loadable cross-origin // by the browser. Without this header, helmet's default CORP: same-origin reaches // 404 responses (when express.static calls next() on a miss) and the browser blocks // the response even for broken-image cases, producing ERR_BLOCKED_BY_RESPONSE. app.use('/storage', (_req, res, next) => { res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin') next() }) // Serve only explicitly public uploaded assets. Private documents are resolved // through authenticated API routes such as /customers/:id/license-image. app.use('/storage', express.static(getPublicStorageRoot())) // Swagger UI — mounted before helmet so its assets are not blocked by CSP app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument, { customSiteTitle: 'RentalDriveGo API Docs' })) app.get('/api/v1/openapi.json', (_req, res) => res.json(openApiDocument)) // Webhooks must use raw body BEFORE express.json(); signature verification // must never reconstruct the payload with JSON.stringify(req.body). app.use(`${v1}/webhooks`, express.raw({ type: 'application/json' }), webhookRouter) app.use(`${v1}/payments/webhooks`, express.raw({ type: 'application/json' })) app.use(`${v1}/subscriptions/webhooks`, express.raw({ type: 'application/json' })) // Let /storage responses manage CORP explicitly so missing files still return // a normal cross-origin 404 instead of being blocked by Helmet's default // same-origin policy. Keep CSP explicit instead of letting browser security // drift into wishful thinking with headers. app.use(helmet({ crossOriginResourcePolicy: false, contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], baseUri: ["'self'"], frameAncestors: ["'none'"], formAction: ["'self'"], objectSrc: ["'none'"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], imgSrc: ["'self'", 'data:', 'blob:', 'https:'], connectSrc: ["'self'", 'https:', 'wss:'], upgradeInsecureRequests: process.env.NODE_ENV === 'production' ? [] : null, }, }, referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, frameguard: { action: 'deny' }, })) if (process.env.NODE_ENV !== 'test') app.use(morgan('combined')) app.use(express.json({ limit: '10mb' })) // ─── API Routes ───────────────────────────────────────────── app.use(`${v1}/auth/account`, authLimiter, accountAuthRouter) app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter) app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter) app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter) app.use(`${v1}/admin/auth`, authLimiter) app.use(`${v1}/admin`, adminLimiter, adminRouter) app.use(`${v1}/carplace`, publicLimiter, carplaceRouter) app.use(`${v1}/site`, publicLimiter, siteRouter) app.use(`${v1}/subscriptions`, subscriptionPublicRouter) app.use(`${v1}/subscriptions`, subscriptionWebhookRouter) app.use(`${v1}/vehicles`, apiLimiter, vehiclesRouter) app.use(`${v1}/reservations`, apiLimiter, reservationsRouter) app.use(`${v1}/team`, apiLimiter, teamRouter) app.use(`${v1}/customers`, apiLimiter, customersRouter) app.use(`${v1}/offers`, apiLimiter, offersRouter) app.use(`${v1}/analytics`, apiLimiter, analyticsRouter) app.use(`${v1}/notifications`, apiLimiter, notificationsRouter) app.use(`${v1}/companies`, apiLimiter, companiesRouter) app.use(`${v1}/subscriptions`, apiLimiter, subscriptionsRouter) app.use(`${v1}/payments`, apiLimiter, paymentsRouter) app.use(`${v1}/billing`, apiLimiter, billingRouter) app.use(`${v1}/reviews`, apiLimiter, reviewsRouter) app.use(`${v1}/complaints`, apiLimiter, complaintsRouter) app.use(`${v1}/licenses`, publicLimiter, licenseValidationRouter) // ─── Health / Docs ────────────────────────────────────────── app.get(v1, (_req, res) => { res.json({ name: 'rentaldrivego-api', version: '1.0.0', baseUrl: v1, health: '/health', docs: `${v1}/docs`, openApi: `${v1}/openapi.json`, }) }) app.get('/health', (_req, res) => { res.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() }) }) app.get(`${v1}/docs`, (_req, res) => { res.json({ name: 'rentaldrivego-api', version: '1.0.0', baseUrl: v1, routes: routeDocs, swaggerUi: '/docs', openApi: '/api/v1/openapi.json', }) }) // ─── Error handler ────────────────────────────────────────── app.use(errorMiddleware) return app }