429 lines
15 KiB
TypeScript
429 lines
15 KiB
TypeScript
/**
|
|
* 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<FastifyInstance> {
|
|
const logger = deps.logger ?? createLogger();
|
|
const flags = deps.flags ?? createFlagStore();
|
|
const startTimes = new WeakMap<FastifyRequest, number>();
|
|
|
|
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;
|
|
}
|