import express, { type Request, type Response } 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, webhookLimiter } from './middleware/rateLimiter' import { requireTrustedOriginForCookieMutations } from './middleware/csrf' import { sanitizeForwardedHeaders } from './middleware/forwardedHeaders' import { requestIdMiddleware } from './middleware/requestId' import { metricsMiddleware, renderPrometheusText, setGauge } from './lib/opsMetrics' // ─── 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 unifiedAuthRouter from './modules/auth/auth.unified.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, } 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' import searchRouter from './modules/search/search.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', ] const frontendOriginEnvKeys = [ 'SITE_ORIGIN', 'WEBSITE_URL', 'HOMEPAGE_URL', 'DASHBOARD_URL', 'ADMIN_URL', 'CARPLACE_URL', 'NEXT_PUBLIC_HOMEPAGE_URL', 'NEXT_PUBLIC_WEBSITE_URL', 'NEXT_PUBLIC_DASHBOARD_URL', 'NEXT_PUBLIC_ADMIN_URL', 'NEXT_PUBLIC_CARPLACE_URL', ] as const function normalizeConfiguredOrigin(value: string): string | null { try { return new URL(value).origin } catch { return null } } export function getConfiguredCorsOrigins(env: NodeJS.ProcessEnv = process.env) { const configuredOrigins = (env.CORS_ORIGINS ?? '') .split(',') .map((origin) => origin.trim()) .filter(Boolean) const frontendOrigins = frontendOriginEnvKeys .flatMap((key) => (env[key] ?? '').split(',')) .map((origin) => origin.trim()) .filter(Boolean) const rawOrigins = configuredOrigins.length > 0 || frontendOrigins.length > 0 ? [...configuredOrigins, ...frontendOrigins] : defaultCorsOrigins return Array.from(new Set(rawOrigins.map(normalizeConfiguredOrigin).filter((origin): origin is string => Boolean(origin)))) } export const corsOrigins = getConfiguredCorsOrigins() function isAllowedLocalDevOrigin(origin: string) { if (process.env.NODE_ENV === 'production') return false try { const url = new URL(origin) if (url.protocol !== 'http:') return false if (!['3000', '3001', '3002', '3004', '4000'].includes(url.port)) return false if (['localhost', '127.0.0.1'].includes(url.hostname)) return true const octets = url.hostname.split('.').map((part) => Number(part)) if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { return false } const first = octets[0]! const second = octets[1]! return first === 10 || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168) } 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: 'Liveness health check' }, { method: 'GET', path: '/ready', description: 'Readiness probe (database, redis, storage)' }, { method: 'GET', path: '/metrics', description: 'Prometheus-style ops metrics' }, { 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}/search`, description: 'Global account search' }, { 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(sanitizeForwardedHeaders) app.use(requestIdMiddleware) app.use(metricsMiddleware) 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) app.use(requireTrustedOriginForCookieMutations) // 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() }) app.use('/storage/companies/:companyId/reservations/:reservationId', (_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())) const publicDocsEnabled = process.env.NODE_ENV !== 'production' || process.env.ENABLE_PUBLIC_API_DOCS === 'true' const docsDisabled = (_req: Request, res: Response) => res.status(404).json({ error: 'not_found', message: 'API docs are not available in this environment', statusCode: 404 }) // Swagger UI — never expose API reconnaissance material publicly in production // unless explicitly enabled for a protected/internal deployment. if (publicDocsEnabled) { app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument, { customSiteTitle: 'RentalDriveGo API Docs' })) app.get('/api/v1/openapi.json', (_req, res) => res.json(openApiDocument)) } else { app.use('/docs', docsDisabled) app.get('/api/v1/openapi.json', docsDisabled) } // Webhooks must use raw body BEFORE express.json(); signature verification // must never reconstruct the payload with JSON.stringify(req.body). app.use(`${v1}/webhooks`, webhookLimiter, express.raw({ type: 'application/json', limit: '1mb' }), webhookRouter) app.use(`${v1}/payments/webhooks`, webhookLimiter, express.raw({ type: 'application/json', limit: '1mb' })) app.use(`${v1}/subscriptions/webhooks`, webhookLimiter, express.raw({ type: 'application/json', limit: '1mb' })) // 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((tokens, req, res) => { const requestId = (req as any).requestId ?? '-' return JSON.stringify({ level: 'info', msg: 'http_request', requestId, method: tokens.method(req, res), url: tokens.url(req, res), status: Number(tokens.status(req, res)), durationMs: Number(tokens['response-time'](req, res)), contentLength: tokens.res(req, res, 'content-length'), }) })) } app.use(express.json({ limit: '10mb' })) // ─── API Routes ───────────────────────────────────────────── app.use(`${v1}/auth`, authLimiter, unifiedAuthRouter) 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`, (req, res, next) => { if (req.method === 'GET' && req.path === '/me') return next() return authLimiter(req, res, next) }) 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}/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}/search`, apiLimiter, searchRouter) 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('/metrics', async (_req, res) => { try { const { prisma } = await import('./lib/prisma') const [pending, published] = await Promise.all([ prisma.notificationOutbox.count({ where: { status: 'PENDING' } }), prisma.notificationOutbox.count({ where: { status: 'PUBLISHED' } }), ]) setGauge('notification_outbox_pending', pending) setGauge('notification_outbox_completed', published) } catch { /* leave previous gauges */ } res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8') res.status(200).send(renderPrometheusText()) }) app.get('/ready', async (_req, res) => { const { prisma } = await import('./lib/prisma') const { redis } = await import('./lib/redis') const { checkStorageReady } = await import('./lib/storage') const checks: Record = { database: 'error', redis: 'error', storage: 'error' } try { await prisma.$queryRaw`SELECT 1` checks.database = 'ok' } catch { checks.database = 'error' } try { const pong = await redis.ping() checks.redis = pong === 'PONG' ? 'ok' : 'error' } catch { checks.redis = 'error' } try { await checkStorageReady() checks.storage = 'ok' } catch { checks.storage = 'error' } const ready = Object.values(checks).every((v) => v === 'ok') res.status(ready ? 200 : 503).json({ status: ready ? 'ready' : 'not_ready', checks, timestamp: new Date().toISOString(), }) }) app.get(`${v1}/docs`, (_req, res) => { if (!publicDocsEnabled) return docsDisabled(_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 }