fe8ffbeb9f
Build & Push / Pipeline Tests (push) Failing after 59s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 48s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
378 lines
16 KiB
TypeScript
378 lines
16 KiB
TypeScript
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<string, 'ok' | 'error'> = { 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
|
|
}
|