- 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
127 lines
4.2 KiB
TypeScript
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;
|
|
}
|