Files
mercadodevida/project/src/app/build-app.ts
rikrdo 75293f39bc feat(identity): F-005 register/login/logout with argon2 sessions and rate limiting
- Hexagonal identity module: domain ports, use cases, argon2id hasher, pg repos
- Migration 002_identity: identity_users + identity_sessions (token hash only)
- Opaque 512-bit session tokens; DB stores SHA-256 hash; 7-day TTL in SQL
- Cookie HttpOnly + Secure (COOKIE_SECURE, default true) + SameSite=Lax
- LoginRateLimiter: 10 failures -> 429 + Retry-After, 15-min cooldown
- Anti-enumeration: identical generic 401 + dummy-hash timing equalization
- buildApp gains optional pool/cookieSecure; foundation-only app preserved
- 47 unit + 14 integration tests; live smoke covers all acceptance criteria
2026-08-14 22:58:32 +02:00

127 lines
4.2 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 type { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
import type { IncomingMessage } from 'node:http';
import type pg from 'pg';
import { registerHealthRoutes } from '../modules/health/index.js';
import { registerIdentityRoutes } from '../modules/identity/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 });
app.decorate('flags', flags);
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) {
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);
});
if (deps.pool) {
await app.register(async (instance) => {
await registerIdentityRoutes(instance, {
pool: deps.pool as pg.Pool,
cookieSecure: deps.cookieSecure,
});
});
}
return app;
}