/** * Composition root. The only place allowed to wire modules together. * Cross-cutting HTTP behavior (request id, logging, errors) is explicit here. */ import { randomUUID } from 'node:crypto'; import { performance } from 'node:perf_hooks'; import Fastify, { type FastifyInstance } from 'fastify'; import fastifyCookie from '@fastify/cookie'; import fastifyCors from '@fastify/cors'; import fastifySwagger from '@fastify/swagger'; import fastifySwaggerUi from '@fastify/swagger-ui'; import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'; import type { IncomingMessage } from 'node:http'; import type pg from 'pg'; import { swaggerConfig } from '../shared/swagger.js'; import { registerHealthRoutes } from '../modules/health/index.js'; import { createSessionAuthenticator, registerIdentityRoutes, SettingsPasswordResetMailer, SettingsWelcomeMailer, } from '../modules/identity/index.js'; import { registerBackofficeRoutes, createBackofficeSessionAuthenticator, createCombinedAuthenticator, } from '../modules/backoffice/index.js'; import { registerUsersRoutes } from '../modules/users/index.js'; import { registerCategoriesRoutes } from '../modules/categories/index.js'; import { registerCatalogRoutes } from '../modules/catalog/index.js'; import { registerBrandsRoutes } from '../modules/brands/index.js'; import { createInventoryService, registerInventoryRoutes } from '../modules/inventory/index.js'; import { createPricingService, registerPricingRoutes } from '../modules/pricing/index.js'; import { createPromotionService, registerPromotionsRoutes } from '../modules/promotions/index.js'; import { registerCartRoutes } from '../modules/cart/index.js'; import { registerShippingRoutes } from '../modules/shipping/index.js'; import { registerOrdersRoutes } from '../modules/orders/index.js'; import { PgStoreRepository } from '../modules/pos/infrastructure/pg-store-repository.js'; import { PgTerminalRepository } from '../modules/pos/infrastructure/pg-terminal-repository.js'; import { PgPaymentMethodRepository } from '../modules/pos/infrastructure/pg-payment-method-repository.js'; import { PgCashSessionRepository } from '../modules/pos/infrastructure/pg-cash-session-repository.js'; import { registerCheckoutRoutes } from '../modules/checkout/index.js'; import { registerPaymentsRoutes } from '../modules/payments/index.js'; import { registerNotificationsRoutes } from '../modules/notifications/index.js'; import { registerReportingRoutes } from '../modules/reporting/index.js'; import { registerReviewsRoutes } from '../modules/reviews/index.js'; import { registerCmsRoutes } from '../modules/cms/index.js'; import { registerStoreSettingsRoutes } from '../modules/store-settings/index.js'; import { CacheService, InMemoryCacheAdapter, registerCacheRoutes } from '../modules/cache/index.js'; import { AuditLogger, RateLimiter, registerSecurityRoutes } from '../modules/security/index.js'; import { getLogBroadcaster } from '../infrastructure/logging/log-broadcaster.js'; import { registerAdminStatsRoutes } from '../modules/admin-stats/index.js'; import { createInMemoryTelemetry, registerMetricsRoutes } from '../modules/observability/index.js'; import { LoggingEmailProvider } from '../modules/notifications/index.js'; import { createFlagStore, type FeatureFlagProvider } from '../modules/flags/index.js'; import { AppError, errorEnvelope } from '../shared/errors.js'; import { createLogger, type Logger } from '../infrastructure/logging/logger.js'; declare module 'fastify' { interface FastifyInstance { flags: FeatureFlagProvider; } } const REQUEST_ID_HEADER = 'x-request-id'; const SAFE_REQUEST_ID = /^[A-Za-z0-9._-]{1,128}$/; export interface BuildAppDeps { /** Injectable logger so tests can capture structured output. */ logger?: Logger; /** Feature flags. Default: empty store, every flag OFF (fail-safe). */ flags?: FeatureFlagProvider; /** Database pool. When present, DB-backed modules (identity) are wired. */ pool?: pg.Pool; /** Secure cookie flag forwarded to identity routes. */ cookieSecure?: boolean; } function generateRequestId(raw: IncomingMessage): string { const incoming = raw.headers[REQUEST_ID_HEADER]; const value = Array.isArray(incoming) ? incoming[0] : incoming; if (typeof value === 'string' && SAFE_REQUEST_ID.test(value)) { return value; } return randomUUID(); } /** * Composition root. The only place allowed to wire modules together. */ export async function buildApp(deps: BuildAppDeps = {}): Promise { const logger = deps.logger ?? createLogger(); const flags = deps.flags ?? createFlagStore(); const startTimes = new WeakMap(); const app = Fastify({ logger: false, genReqId: generateRequestId }); // Route schemas are also the OpenAPI source. Several endpoints intentionally // use broad response schemas, so serialize the explicit route DTOs without // letting fast-json-stringify discard fields absent from those broad schemas. app.setSerializerCompiler(() => (payload) => JSON.stringify(payload)); app.decorate('flags', flags); await app.register(fastifyCors, { origin: true, credentials: true, }); app.addHook('onRequest', async (request, reply) => { startTimes.set(request, performance.now()); void reply.header(REQUEST_ID_HEADER, request.id); }); app.addHook('onResponse', async (request, reply) => { const startedAt = startTimes.get(request); const durationMs = startedAt === undefined ? 0 : Math.round((performance.now() - startedAt) * 100) / 100; logger.info( { requestId: request.id, method: request.method, url: request.url, statusCode: reply.statusCode, durationMs, }, 'request completed', ); }); app.setErrorHandler((error: FastifyError, request: FastifyRequest, reply: FastifyReply) => { const requestId = request.id; let statusCode = error.statusCode !== undefined && error.statusCode >= 400 && error.statusCode < 600 ? error.statusCode : 500; let code = 'INTERNAL_ERROR'; let message = 'Internal Server Error'; let details: AppError['details']; if (error instanceof AppError) { statusCode = error.statusCode; code = error.code; details = error.details; message = statusCode < 500 ? error.message : 'Internal Server Error'; } else if (statusCode < 500) { if (error.code === 'FST_ERR_VALIDATION') { code = 'VALIDATION_ERROR'; message = 'Invalid request'; } else { code = error.code ?? 'BAD_REQUEST'; message = error.message; } } if (statusCode >= 500) { // Stack stays server-side: logs only, tagged with the request id. logger.error({ err: error, requestId }, 'request failed'); } else { logger.info({ requestId, statusCode, code }, 'request rejected'); } void reply.code(statusCode).send(errorEnvelope(statusCode, code, message, requestId, details)); }); app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => { void reply.code(404).send(errorEnvelope(404, 'NOT_FOUND', 'Not Found', request.id)); }); await app.register(async (instance) => { await registerHealthRoutes(instance); }); // Cookie infrastructure is cross-module (identity + users): register once at root. await app.register(fastifyCookie); // ── OpenAPI / Swagger ──────────────────────────────────────────────────────── await app.register(fastifySwagger, swaggerConfig); await app.register(fastifySwaggerUi, { routePrefix: '/docs', uiConfig: { docExpansion: 'list', deepLinking: true, tryItOutEnabled: true, }, }); if (deps.pool) { // Session resolution; created before identity registration so it can be passed // to identity's /auth/me route. const authenticate = createSessionAuthenticator(deps.pool); const auditLogger = new AuditLogger(deps.pool as pg.Pool); await app.register(async (instance) => { await registerIdentityRoutes(instance, { pool: deps.pool as pg.Pool, cookieSecure: deps.cookieSecure, authenticate, welcomeMailer: new SettingsWelcomeMailer(deps.pool as pg.Pool), passwordReset: { mailer: new SettingsPasswordResetMailer(deps.pool as pg.Pool), audit: (entry) => { // Best-effort audit; never block the request on audit failures. void auditLogger .log({ actorId: entry.userId ?? null, action: entry.action, target: entry.email ?? entry.userId ?? 'unknown', metadata: { ip: entry.ip ?? null }, }) .catch(() => undefined); }, }, }); }); // FIX-14: backoffice has its own auth mechanism (backoffice_session cookie, // backoffice_users table). Two more authenticators: // backofficeAuth — backoffice_session only (for backoffice routes). // combinedAuth — tries backoffice_session first, then mdv_session // (for routes shared by both admin and storefront). const backofficeAuth = createBackofficeSessionAuthenticator(deps.pool as pg.Pool); const combinedAuth = createCombinedAuthenticator(deps.pool as pg.Pool); await app.register(async (instance) => { await registerBackofficeRoutes(instance, { pool: deps.pool as pg.Pool, authenticate: backofficeAuth, }); }); // Reporting contracts + RBAC foundation (F-143). Reporting is backoffice-only // sharing the combined authenticator; F-146 adds ReportingService reads. await app.register(async (instance) => { await registerReportingRoutes(instance, { authenticate: combinedAuth, pool: deps.pool as pg.Pool, }); }); // Session resolution is identity's; users receives it by injection so no // module ever imports another module. await app.register(async (instance) => { await registerUsersRoutes(instance, { pool: deps.pool as pg.Pool, authenticate: combinedAuth, }); }); await app.register(async (instance) => { await registerCategoriesRoutes(instance, { pool: deps.pool as pg.Pool, authenticate: combinedAuth, }); }); await app.register(async (instance) => { await registerBrandsRoutes(instance, { pool: deps.pool as pg.Pool, authenticate: combinedAuth, }); }); const pricing = createPricingService(deps.pool); await app.register(async (instance) => { await registerCatalogRoutes(instance, { pool: deps.pool as pg.Pool, authenticate: combinedAuth, logger, pricing, }); }); const inventory = createInventoryService(deps.pool); await app.register(async (instance) => { await registerInventoryRoutes(instance, { pool: deps.pool as pg.Pool, authenticate: combinedAuth, }); }); await app.register(async (instance) => { await registerPricingRoutes(instance, { pool: deps.pool as pg.Pool, authenticate: combinedAuth, }); }); const promotions = createPromotionService(deps.pool); await app.register(async (instance) => { await registerPromotionsRoutes(instance, { pool: deps.pool as pg.Pool, authenticate: combinedAuth, }); }); await app.register(async (instance) => { await registerCartRoutes(instance, { pool: deps.pool as pg.Pool, authenticate: combinedAuth, pricing, inventory, promotions, }); }); await app.register(async (instance) => { await registerShippingRoutes(instance, { pool: deps.pool as pg.Pool, authenticate: combinedAuth, }); }); // POS module repositories (wired here so POS-004+ can use them) const posStoreRepo = deps.pool ? new PgStoreRepository(deps.pool) : null; const posTerminalRepo = deps.pool ? new PgTerminalRepository(deps.pool) : null; const posPaymentMethodRepo = deps.pool ? new PgPaymentMethodRepository(deps.pool) : null; const posSessionRepo = deps.pool ? new PgCashSessionRepository(deps.pool) : null; await app.register(async (instance) => { await registerOrdersRoutes(instance, { pool: deps.pool as pg.Pool, authenticate: combinedAuth, }); }); const { telemetry, meter } = createInMemoryTelemetry(); await app.register(async (instance) => { await registerMetricsRoutes(instance, { meter, authenticate: combinedAuth }); }); await app.register(async (instance) => { await registerCheckoutRoutes(instance, { pool: deps.pool as pg.Pool, authenticate: combinedAuth, tracer: telemetry.tracer, }); }); await app.register(async (instance) => { await registerPaymentsRoutes(instance, { pool: deps.pool as pg.Pool, authenticate: combinedAuth, stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET ?? 'whsec_test', stripeSecretKey: process.env.STRIPE_SECRET_KEY, }); }); await app.register(async (instance) => { await registerNotificationsRoutes(instance, { pool: deps.pool as pg.Pool, authenticate: combinedAuth, emailProvider: new LoggingEmailProvider(), }); }); await app.register(async (instance) => { await registerReviewsRoutes(instance, { pool: deps.pool as pg.Pool, authenticate: combinedAuth, }); }); await app.register(async (instance) => { await registerCmsRoutes(instance, { pool: deps.pool as pg.Pool, authenticate: combinedAuth, }); }); await app.register(async (instance) => { await registerStoreSettingsRoutes(instance, { pool: deps.pool as pg.Pool, authenticate: combinedAuth, }); await registerAdminStatsRoutes(instance, { pool: deps.pool as pg.Pool, authenticate: combinedAuth, }); }); const cache = new CacheService(new InMemoryCacheAdapter()); cache.registerContract({ name: 'product', keyPattern: 'product:{slug}', ttlSeconds: 300, sourceOfTruth: 'catalog_products', invalidation: 'ProductUpdated', }); cache.registerContract({ name: 'category', keyPattern: 'category:{slug}', ttlSeconds: 300, sourceOfTruth: 'categories_categories', invalidation: 'CategoryUpdated', }); cache.registerContract({ name: 'nav', keyPattern: 'nav:tree', ttlSeconds: 600, sourceOfTruth: 'categories_categories', invalidation: 'NavigationInvalidated', }); cache.registerContract({ name: 'search_popular', keyPattern: 'search:popular', ttlSeconds: 60, sourceOfTruth: 'catalog_search', invalidation: 'SearchInvalidated', }); await app.register(async (instance) => { await registerCacheRoutes(instance, { cache, authenticate: combinedAuth }); }); const rateLimiter = new RateLimiter(); await app.register(async (instance) => { const broadcaster = getLogBroadcaster(); await registerSecurityRoutes(instance, { pool: deps.pool as pg.Pool, authenticate: combinedAuth, rateLimiter, auditLogger, broadcaster, }); }); } return app; }