import express from "express"; import { globalDeviceRouter } from "./routes/global-device.routes.js"; import { projectDeviceRouter } from "./routes/project-device.routes.js"; import { documentBrandingRouter } from "./routes/document-branding.routes.js"; import { projectRouter } from "./routes/project.routes.js"; import { cableSizingRouter } from "./routes/cable-sizing.routes.js"; import { authRouter } from "./routes/auth.routes.js"; import { userRouter } from "./routes/user.routes.js"; import { errorMiddleware } from "./middleware/error.middleware.js"; import { createAuthGuard, requirePasswordChanged, requireSameOriginForWrites, } from "./auth/auth.middleware.js"; import { authConfig, sessionStore } from "./composition/auth.js"; import { userRepository } from "./composition/application-repositories.js"; import { createLogger } from "../shared/logging/logger.js"; const logger = createLogger("api"); /** * Builds the Express app without listening, so tests (for example the * security baseline under tests/security) can start it on a free port. * Process concerns (listen, signals, bootstrap admin) stay in index.ts. */ export function createApp(): express.Express { const app = express(); app.disable("x-powered-by"); // The web container sets the same header for pages (next.config.mjs); the // API sets it itself so it also holds when the API is reached directly. app.use((_req, res, next) => { res.setHeader("X-Content-Type-Options", "nosniff"); next(); }); app.use(express.json({ limit: "25mb" })); app.use((req, res, next) => { if (req.path === "/health") { next(); return; } const startedAt = Date.now(); logger.debug("request started", { method: req.method, path: req.originalUrl }); res.on("finish", () => { const meta = { method: req.method, path: req.originalUrl, status: res.statusCode, durationMs: Date.now() - startedAt, }; if (res.statusCode >= 500) logger.error("request completed", meta); else if (res.statusCode >= 400) logger.warn("request completed", meta); else logger.info("request completed", meta); }); res.on("close", () => { if (!res.writableEnded) { logger.warn("request aborted before response finished", { method: req.method, path: req.originalUrl, durationMs: Date.now() - startedAt, }); } }); next(); }); app.get("/health", (_req, res) => { res.json({ ok: true }); }); // Everything below /api requires a signed-in, active user, except // /auth/login, /auth/status and /auth/logout (see createAuthGuard's // openPaths). authRouter is mounted AFTER the guard (not before) so that // /auth/me and /auth/change-password still get req.authUser populated by it. app.use("/api", requireSameOriginForWrites()); app.use("/api", createAuthGuard(authConfig, sessionStore, userRepository)); app.use("/api", requirePasswordChanged()); app.use("/api/auth", authRouter); app.use("/api/users", userRouter); app.use("/api/cable-sizing", cableSizingRouter); app.use("/api/projects", projectRouter); app.use("/api/global-devices", globalDeviceRouter); app.use("/api/project-devices", projectDeviceRouter); app.use("/api/document-branding", documentBrandingRouter); app.use(errorMiddleware); return app; }