import "reflect-metadata"; import * as dotenv from "dotenv"; dotenv.config(); import { createRequire } from "node:module"; import { NestFactory } from "@nestjs/core"; import type { NestExpressApplication } from "@nestjs/platform-express"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; import { HttpExceptionFilter, ResponseTransformInterceptor, createValidationPipe, } from "@edr/api-common"; import { AppModule } from "./app.module"; /** * JSON body ceiling. Signing posts the signature AND the company stamp as * base64 in one JSON body, and base64 inflates bytes by ~4/3 — a 10MB stamp is * ~13.4MB on the wire. Express defaults to 100kb, which rejected any real stamp * image with a 413 "request entity too large". */ const JSON_BODY_LIMIT = "20mb"; /** * Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as * `host=ip,host2=ip2`. Some internal hosts (MinIO) resolve only inside the * deployment network, so dev machines get ENOTFOUND on every upload. Patching * `dns.lookup` keeps the real hostname on the wire — the IP is used for the * connection only — so TLS still validates against the certificate's CN. * * The module is loaded through `createRequire`, NOT `import * as dns`: an ESM * namespace object is frozen, so assigning to it is silently dropped and the * patch becomes a no-op. `require` returns the live module object every other * caller (minio's http agent included) reads `lookup` off. */ function applyDnsHostOverrides(): void { const raw = process.env.DNS_HOST_OVERRIDES?.trim(); if (!raw) return; const overrides = new Map(); for (const entry of raw.split(",")) { const [host, ip] = entry.split("=").map((part) => part?.trim()); if (host && ip) overrides.set(host.toLowerCase(), ip); } if (overrides.size === 0) return; const dns = createRequire(__filename)( "node:dns", ) as typeof import("node:dns"); const originalLookup = dns.lookup.bind(dns); // `dns.lookup` is overloaded (options optional, all/family variants); the // cast keeps that surface intact while we intercept only mapped hostnames. (dns as { lookup: unknown }).lookup = (( hostname: string, options: unknown, callback?: unknown, ) => { const ip = overrides.get(hostname?.toLowerCase?.()); if (!ip) return (originalLookup as Function)(hostname, options, callback); const done = (typeof options === "function" ? options : callback) as ( err: NodeJS.ErrnoException | null, address: string | { address: string; family: number }[], family?: number, ) => void; const family = ip.includes(":") ? 6 : 4; const wantsAll = typeof options === "object" && options !== null && (options as { all?: boolean }).all; process.nextTick(() => wantsAll ? done(null, [{ address: ip, family }]) : done(null, ip, family), ); }) as typeof dns.lookup; console.log( `[DNS] Host overrides active: ${[...overrides].map(([h, ip]) => `${h}->${ip}`).join(", ")}`, ); } applyDnsHostOverrides(); /** * Build the app with every global the production process applies, but do NOT * listen. Exported so a test harness can boot the REAL app in its own process * (integration/src/app.ts) and get the same prefix, pipe, filter, interceptor * and body-parser configuration — replaying this list by hand is how an e2e * harness silently drifts from production (routes 404 without the "api" * prefix, responses lose the transform envelope). */ export async function createFreightApp(): Promise { const app = await NestFactory.create(AppModule); // Nest's own body-parser API, NOT `app.use(json(...))` from express: express // is not a declared dependency of this app (it arrives under // @nestjs/platform-express), so importing it directly resolved only through // pnpm's hoisted dev store and died as MODULE_NOT_FOUND in the production // image, where `pnpm deploy --prod` installs declared dependencies only. // This also RECONFIGURES the default parsers rather than racing them. app.useBodyParser("json", { limit: JSON_BODY_LIMIT }); app.useBodyParser("urlencoded", { limit: JSON_BODY_LIMIT, extended: true }); // Dev CORS: reflect any localhost origin and allow credentials so the // freight portal (5173), passenger portal (5174), backoffices (5183/5184) // and any other dev port can call the API with cookies + Authorization. // For production, restrict `origin` to known FQDNs. app.enableCors({ origin: true, // reflect request origin credentials: true, methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"], allowedHeaders: [ "Content-Type", "Accept", "Authorization", "X-Requested-With", // Which freight frontend is calling — /auth/login uses this to reject // cross-audience credentials (EDRFREIGHT-415). "X-Client-App", // IAM context headers required by @tria-plc/api-common's JwtGuard "organization-unit-id", "delegator-position-id", "current-project-id", "current-position-id", // x-prefixed variants sent by the user-management / record-management // frontend modules (same values, different naming convention) "x-organization-unit-id", "x-delegator-id", "x-delegator-position-id", "x-current-project-id", "x-current-position-id", // Headers sent by the freight-backoffice OKR/objective-service client // (withHeaders.tsx, signatureAndTeeterService.ts, useIncomingReport.ts) // under yet another naming convention — unprefixed "tenant-key"/"unit-id", // and "x-delegated-position-id" (delegated, not delegator). "tenant-key", "unit-id", "x-delegated-position-id", ], exposedHeaders: ["Content-Disposition"], maxAge: 86400, // cache preflight for 24h to cut chatter in dev }); // /fayda/callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack // endpoint. Exact path, not "fayda" — exclusion is an exact route match, so // "fayda" would leave /fayda/callback prefixed (404 at the registered // redirect_uri) while still reading as if it covered the whole subtree. app.setGlobalPrefix("api", { exclude: ["fayda/callback"] }); // enableImplicitConversion is OFF: class-transformer's implicit boolean // coercion turns any non-empty multipart/form-data string (including the // literal "false") into `true`, silently corrupting flags like isHazardous // and isGovernment. With it off, only explicit @Transform/@Type decorators // coerce values — every numeric/boolean DTO field in this API already has one. app.useGlobalPipes( createValidationPipe({ transformOptions: { enableImplicitConversion: false }, }), ); app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new ResponseTransformInterceptor()); const config = new DocumentBuilder() .setTitle("EDR Freight API") .setDescription("API for the EDR Freight Management application") .setVersion("0.1.0") .addBearerAuth() .build(); const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup("api/docs", app, document); return app; } async function bootstrap() { const app = await createFreightApp(); const port = parseInt(process.env.PORT ?? "3001", 10); // await app.listen(port, "0.0.0.0"); await app.listen(port); // eslint-disable-next-line no-console console.log(`[freight-api] listening on port ${port}`); } // Only self-start when this file IS the entrypoint. The Dockerfile's // `CMD ["node", "dist/main.js"]` still boots; importers get `createFreightApp` // without the process binding a port behind their back. if (require.main === module) { bootstrap(); }