create-tigra 3.0.0 → 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,303 +1,316 @@
1
- import Fastify, { type FastifyError, type FastifyInstance, type FastifyRequest } from 'fastify';
2
- import cors from '@fastify/cors';
3
- import helmet from '@fastify/helmet';
4
- import rateLimit from '@fastify/rate-limit';
5
- import cookie from '@fastify/cookie';
6
- import jwt from '@fastify/jwt';
7
- import multipart from '@fastify/multipart';
8
- import fastifyStatic from '@fastify/static';
9
- import path from 'path';
10
- import { fileURLToPath } from 'url';
11
- import { env } from '@config/env.js';
12
- import { logger } from '@libs/logger.js';
13
- import { markRequestStart, logRequestLine } from '@libs/requestLogger.js';
14
- import { initAuth } from '@libs/auth.js';
15
- import { isAppError } from '@shared/errors/AppError.js';
16
- import { successResponse, errorResponse } from '@shared/responses/successResponse.js';
17
- import { authRoutes } from '@modules/auth/auth.routes.js';
18
- import { usersRoutes } from '@modules/users/users.routes.js';
19
- import { adminRoutes } from '@modules/admin/admin.routes.js';
20
- import { fileStorageService } from '@libs/storage/file-storage.service.js';
21
- import { registerJobs } from '@jobs/index.js';
22
- import { RATE_LIMIT_ENABLED, getRateLimitRedisStore } from '@config/rate-limit.config.js';
23
- import { isIpBlocked, recordRateLimitViolation, syncBlockedIpsToRedis } from '@libs/ip-block.js';
24
- import { isOriginAllowed } from '@libs/origin-check.js';
25
- import { ForbiddenError } from '@shared/errors/errors.js';
26
- import {
27
- serializerCompiler,
28
- validatorCompiler,
29
- type ZodTypeProvider,
30
- } from 'fastify-type-provider-zod';
31
-
32
- // Import types to register Fastify augmentations
33
- import type {} from '@shared/types/index.js';
34
-
35
- export async function buildApp(): Promise<FastifyInstance> {
36
- const app = Fastify({
37
- logger: false,
38
- // Trust proxy headers (X-Forwarded-For) for accurate client IP behind Nginx/load balancer
39
- trustProxy: env.NODE_ENV === 'production',
40
- // Graceful shutdown configuration
41
- forceCloseConnections: true, // Force close idle connections on shutdown
42
- // Env-configurable timeouts (defaults: 30s request, 60s connection).
43
- // Long-running routes (LLM calls, exports) may need 180s+ — raise the
44
- // reverse proxy timeout to match. See REQUEST_TIMEOUT_MS in .env.example.
45
- requestTimeout: env.REQUEST_TIMEOUT_MS,
46
- connectionTimeout: env.CONNECTION_TIMEOUT_MS,
47
- keepAliveTimeout: 5000, // 5s keep-alive timeout
48
- // Request body size limits (prevent DoS attacks)
49
- bodyLimit: 1048576, // 1MB default limit (1024 * 1024)
50
- }).withTypeProvider<ZodTypeProvider>();
51
-
52
- // Set Zod validator and serializer
53
- app.setValidatorCompiler(validatorCompiler);
54
- app.setSerializerCompiler(serializerCompiler);
55
-
56
- // --- Plugins ---
57
- // CORS: Allow all origins in development, specific origin(s) in production
58
- const corsOrigin = env.NODE_ENV === 'development'
59
- ? true
60
- : env.CORS_ORIGIN?.includes(',')
61
- ? env.CORS_ORIGIN.split(',').map((o) => o.trim())
62
- : env.CORS_ORIGIN;
63
-
64
- await app.register(cors, {
65
- origin: corsOrigin,
66
- credentials: true,
67
- methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
68
- });
69
-
70
- // Enhanced security headers for production
71
- await app.register(helmet, {
72
- global: true,
73
- crossOriginResourcePolicy: { policy: 'cross-origin' },
74
- });
75
-
76
-
77
- // Rate limiting: Redis-backed when available, in-memory fallback
78
- if (RATE_LIMIT_ENABLED) {
79
- const redisStore = getRateLimitRedisStore();
80
- await app.register(rateLimit, {
81
- global: false,
82
- max: 100,
83
- timeWindow: '1 minute',
84
- redis: redisStore,
85
- nameSpace: 'rl:',
86
- skipOnError: true, // Gracefully degrade if Redis fails mid-request
87
- onExceeded: (request: { ip: string }) => {
88
- recordRateLimitViolation(request.ip);
89
- },
90
- });
91
- } else {
92
- // Register with effectively no limit so per-route configs don't error
93
- await app.register(rateLimit, {
94
- global: false,
95
- max: 1_000_000,
96
- timeWindow: '1 minute',
97
- });
98
- logger.warn('[RATE-LIMIT] Rate limiting is DISABLED (RATE_LIMIT_ENABLED=false)');
99
- }
100
-
101
- await app.register(cookie, {
102
- secret: env.COOKIE_SECRET || env.JWT_SECRET,
103
- });
104
-
105
- await app.register(jwt, {
106
- secret: env.JWT_SECRET,
107
- cookie: {
108
- cookieName: 'access_token',
109
- signed: false,
110
- },
111
- });
112
-
113
- // Initialize auth helpers after JWT plugin is registered
114
- initAuth(app);
115
-
116
- // File upload handling (multipart/form-data)
117
- await app.register(multipart, {
118
- limits: {
119
- fileSize: env.MAX_FILE_SIZE_MB * 1024 * 1024, // ENV-configurable (default 10MB)
120
- files: 1, // Only one file per request
121
- },
122
- });
123
-
124
- // Static file serving for uploads
125
- // Get __dirname equivalent in ES modules
126
- const __filename = fileURLToPath(import.meta.url);
127
- const __dirname = path.dirname(__filename);
128
-
129
- await app.register(fastifyStatic, {
130
- root: path.join(__dirname, '..', 'uploads'),
131
- prefix: '/uploads/',
132
- });
133
-
134
- // Initialize file storage (create directories)
135
- await fileStorageService.initialize();
136
-
137
- // --- Sync permanent IP blocks from DB to Redis ---
138
- await syncBlockedIpsToRedis();
139
-
140
- // Monitoring endpoints exempt from IP blocking and request logging.
141
- // Health probes (Coolify/Docker/K8s/load balancers) come from infrastructure
142
- // IPs that must NEVER be blocked — a blocked probe IP would mark a healthy
143
- // container as dead and restart-loop it. Exact match on the path (query
144
- // string stripped) so the exemption cannot be widened by crafted URLs.
145
- // These paths must match the route registrations below.
146
- const monitoringPaths = new Set(['/api/v1/health', '/api/v1/ready', '/api/v1/live']);
147
-
148
- // --- IP Block Check (runs before everything else) ---
149
- app.addHook('onRequest', async (request: FastifyRequest) => {
150
- if (monitoringPaths.has(request.url.split('?')[0])) {
151
- return; // never block health probes
152
- }
153
- if (await isIpBlocked(request.ip)) {
154
- throw new ForbiddenError('Access denied', 'IP_BLOCKED');
155
- }
156
- });
157
-
158
- // --- CSRF defense-in-depth: Origin check on state-changing methods ---
159
- // With sameSite=none cookies (cross-origin deployments), the browser attaches
160
- // auth cookies to cross-site requests. If a browser sends an Origin header on
161
- // a state-changing request, it must be same-origin or a configured CORS
162
- // origin. Requests WITHOUT an Origin header (curl, Postman, server-to-server,
163
- // health probes) are allowed — they carry no ambient cookies and are not
164
- // CSRF vectors. See src/libs/origin-check.ts for the full rationale.
165
- const stateChangingMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
166
- const allowAllOrigins = corsOrigin === true;
167
- const allowedOrigins = new Set<string>(
168
- Array.isArray(corsOrigin) ? corsOrigin : typeof corsOrigin === 'string' ? [corsOrigin] : [],
169
- );
170
-
171
- app.addHook('onRequest', async (request: FastifyRequest) => {
172
- if (!stateChangingMethods.has(request.method)) return;
173
- if (isOriginAllowed(request.headers.origin, request.headers.host, allowedOrigins, allowAllOrigins)) return;
174
- throw new ForbiddenError('Origin not allowed', 'ORIGIN_NOT_ALLOWED');
175
- });
176
-
177
- // --- Request/Response Logging ---
178
- app.addHook('preHandler', async (request) => {
179
- const pathname = request.url.split('?')[0];
180
- if (!monitoringPaths.has(pathname)) {
181
- markRequestStart(request);
182
- }
183
- });
184
-
185
- app.addHook('onResponse', async (request, reply) => {
186
- const pathname = request.url.split('?')[0];
187
- if (!monitoringPaths.has(pathname)) {
188
- logRequestLine(request, reply);
189
- }
190
- });
191
-
192
- // --- Global Error Handler (must be set before routes) ---
193
- app.setErrorHandler((error: FastifyError, request, reply) => {
194
- // AppError — our typed errors (use duck-type check to avoid instanceof issues)
195
- if (isAppError(error)) {
196
- return reply.status(error.statusCode).send(errorResponse(error.code, error.message));
197
- }
198
-
199
- // Zod validation error
200
- if (error.name === 'ZodError') {
201
- return reply.status(422).send(errorResponse('VALIDATION_FAILED', 'Validation failed'));
202
- }
203
-
204
- // Fastify validation error
205
- if (error.validation) {
206
- return reply.status(400).send(
207
- errorResponse(
208
- 'BAD_REQUEST',
209
- error.message || 'Invalid request',
210
- ),
211
- );
212
- }
213
-
214
- // Fastify plugin errors (file size, rate limiting, etc.)
215
- // These have statusCode properties but aren't AppError instances
216
- if (error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
217
- // Map common Fastify error codes to user-friendly messages
218
- const errorCodeMap: Record<number, { code: string; message: string }> = {
219
- 413: { code: 'FILE_TOO_LARGE', message: 'File size exceeds the maximum allowed limit' },
220
- 429: { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many requests. Please try again later' },
221
- };
222
-
223
- const errorInfo = errorCodeMap[error.statusCode] || {
224
- code: 'BAD_REQUEST',
225
- message: error.message || 'Bad request',
226
- };
227
-
228
- return reply.status(error.statusCode).send(errorResponse(errorInfo.code, errorInfo.message));
229
- }
230
-
231
- // Unexpected error log and return generic 500
232
- const requestId = request.id || 'unknown';
233
- logger.error(
234
- {
235
- err: error,
236
- requestId,
237
- url: request.url,
238
- method: request.method,
239
- stack: error.stack,
240
- },
241
- `Unhandled error [${requestId}]: ${error.message}`,
242
- );
243
-
244
- return reply.status(500).send(errorResponse('INTERNAL_ERROR', 'Internal server error'));
245
- });
246
-
247
- // --- Monitoring & Health Checks ---
248
- const { performHealthCheck, checkReadiness, checkLiveness } = await import('@libs/monitoring.js');
249
-
250
- // Comprehensive health check (DB + Redis + Memory + Uptime)
251
- app.get('/api/v1/health', async (_request, reply) => {
252
- const health = await performHealthCheck();
253
-
254
- const statusCode = health.status === 'healthy' ? 200 : health.status === 'degraded' ? 200 : 503;
255
-
256
- return reply.status(statusCode).send(
257
- successResponse(
258
- health.status === 'healthy'
259
- ? 'All systems operational'
260
- : health.status === 'degraded'
261
- ? 'Some systems degraded'
262
- : 'System unhealthy',
263
- health,
264
- ),
265
- );
266
- });
267
-
268
- // Readiness probe (for load balancers / K8s)
269
- app.get('/api/v1/ready', async (_request, reply) => {
270
- const ready = await checkReadiness();
271
- const statusCode = ready ? 200 : 503;
272
- return reply.status(statusCode).send(
273
- successResponse(ready ? 'Service is ready' : 'Service not ready', {
274
- ready,
275
- timestamp: new Date().toISOString(),
276
- })
277
- );
278
- });
279
-
280
- // Liveness probe (for container orchestration)
281
- app.get('/api/v1/live', (_request, reply) => {
282
- const alive = checkLiveness();
283
- const statusCode = alive ? 200 : 503;
284
- return reply.status(statusCode).send(
285
- successResponse(alive ? 'Service is alive' : 'Service not alive', {
286
- alive,
287
- timestamp: new Date().toISOString(),
288
- })
289
- );
290
- });
291
-
292
- // --- Routes ---
293
- await app.register(authRoutes, { prefix: '/api/v1' });
294
- await app.register(usersRoutes, { prefix: '/api/v1' });
295
- await app.register(adminRoutes, { prefix: '/api/v1' });
296
-
297
- // --- Background Jobs ---
298
- registerJobs(app);
299
-
300
- return app;
301
- }
302
-
303
- export default buildApp;
1
+ import Fastify, { type FastifyError, type FastifyInstance, type FastifyRequest } from 'fastify';
2
+ import cors from '@fastify/cors';
3
+ import helmet from '@fastify/helmet';
4
+ import rateLimit from '@fastify/rate-limit';
5
+ import cookie from '@fastify/cookie';
6
+ import jwt from '@fastify/jwt';
7
+ import multipart from '@fastify/multipart';
8
+ import fastifyStatic from '@fastify/static';
9
+ import path from 'path';
10
+ import { fileURLToPath } from 'url';
11
+ import { env } from '@config/env.js';
12
+ import { logger } from '@libs/logger.js';
13
+ import { markRequestStart, logRequestLine } from '@libs/requestLogger.js';
14
+ import { initAuth } from '@libs/auth.js';
15
+ import { registerQueryCounter } from '@libs/query-counter.js';
16
+ import { isAppError } from '@shared/errors/AppError.js';
17
+ import { successResponse, errorResponse } from '@shared/responses/successResponse.js';
18
+ import { authRoutes } from '@modules/auth/auth.routes.js';
19
+ import { usersRoutes } from '@modules/users/users.routes.js';
20
+ import { adminRoutes } from '@modules/admin/admin.routes.js';
21
+ import { fileStorageService } from '@libs/storage/file-storage.service.js';
22
+ import { registerJobs } from '@jobs/index.js';
23
+ import { RATE_LIMIT_ENABLED, getRateLimitRedisStore } from '@config/rate-limit.config.js';
24
+ import { isIpBlocked, recordRateLimitViolation, syncBlockedIpsToRedis } from '@libs/ip-block.js';
25
+ import { getClientIp } from '@libs/client-ip.js';
26
+ import { isAuthPath } from '@libs/auth-path.js';
27
+ import { isOriginAllowed } from '@libs/origin-check.js';
28
+ import { ForbiddenError } from '@shared/errors/errors.js';
29
+ import {
30
+ serializerCompiler,
31
+ validatorCompiler,
32
+ type ZodTypeProvider,
33
+ } from 'fastify-type-provider-zod';
34
+
35
+ // Import types to register Fastify augmentations
36
+ import type {} from '@shared/types/index.js';
37
+
38
+ export async function buildApp(): Promise<FastifyInstance> {
39
+ const app = Fastify({
40
+ logger: false,
41
+ // Trust proxy headers (X-Forwarded-For) for accurate client IP behind Nginx/load balancer
42
+ trustProxy: env.NODE_ENV === 'production',
43
+ // Graceful shutdown configuration
44
+ forceCloseConnections: true, // Force close idle connections on shutdown
45
+ // Env-configurable timeouts (defaults: 30s request, 60s connection).
46
+ // Long-running routes (LLM calls, exports) may need 180s+ — raise the
47
+ // reverse proxy timeout to match. See REQUEST_TIMEOUT_MS in .env.example.
48
+ requestTimeout: env.REQUEST_TIMEOUT_MS,
49
+ connectionTimeout: env.CONNECTION_TIMEOUT_MS,
50
+ keepAliveTimeout: 5000, // 5s keep-alive timeout
51
+ // Request body size limits (prevent DoS attacks)
52
+ bodyLimit: 1048576, // 1MB default limit (1024 * 1024)
53
+ }).withTypeProvider<ZodTypeProvider>();
54
+
55
+ // Set Zod validator and serializer
56
+ app.setValidatorCompiler(validatorCompiler);
57
+ app.setSerializerCompiler(serializerCompiler);
58
+
59
+ // --- Plugins ---
60
+ // CORS: Allow all origins in development, specific origin(s) in production
61
+ const corsOrigin = env.NODE_ENV === 'development'
62
+ ? true
63
+ : env.CORS_ORIGIN?.includes(',')
64
+ ? env.CORS_ORIGIN.split(',').map((o) => o.trim())
65
+ : env.CORS_ORIGIN;
66
+
67
+ await app.register(cors, {
68
+ origin: corsOrigin,
69
+ credentials: true,
70
+ methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
71
+ });
72
+
73
+ // Enhanced security headers for production
74
+ await app.register(helmet, {
75
+ global: true,
76
+ crossOriginResourcePolicy: { policy: 'cross-origin' },
77
+ });
78
+
79
+
80
+ // Rate limiting: Redis-backed when available, in-memory fallback
81
+ if (RATE_LIMIT_ENABLED) {
82
+ const redisStore = getRateLimitRedisStore();
83
+ await app.register(rateLimit, {
84
+ global: false,
85
+ max: 100,
86
+ timeWindow: '1 minute',
87
+ redis: redisStore,
88
+ nameSpace: 'rl:',
89
+ skipOnError: true, // Gracefully degrade if Redis fails mid-request
90
+ // Key the limiter on the real client IP (Cloudflare-aware) so users behind
91
+ // a shared CF edge IP aren't counted as one — see src/libs/client-ip.ts.
92
+ keyGenerator: (request: FastifyRequest) => getClientIp(request),
93
+ onExceeded: (request: FastifyRequest) => {
94
+ // Auth routes keep their own per-route limit + account lockout; don't let
95
+ // a mistyped password arm the IP-wide auto-ban.
96
+ if (isAuthPath(request)) return;
97
+ recordRateLimitViolation(getClientIp(request));
98
+ },
99
+ });
100
+ } else {
101
+ // Register with effectively no limit so per-route configs don't error
102
+ await app.register(rateLimit, {
103
+ global: false,
104
+ max: 1_000_000,
105
+ timeWindow: '1 minute',
106
+ });
107
+ logger.warn('[RATE-LIMIT] Rate limiting is DISABLED (RATE_LIMIT_ENABLED=false)');
108
+ }
109
+
110
+ await app.register(cookie, {
111
+ secret: env.COOKIE_SECRET || env.JWT_SECRET,
112
+ });
113
+
114
+ await app.register(jwt, {
115
+ secret: env.JWT_SECRET,
116
+ cookie: {
117
+ cookieName: 'access_token',
118
+ signed: false,
119
+ },
120
+ });
121
+
122
+ // Initialize auth helpers after JWT plugin is registered
123
+ initAuth(app);
124
+
125
+ // Dev-only: count Prisma queries per request → X-Query-Count header (N+1 signal for perf-tester).
126
+ // No-op in production. Registered early so its onRequest store is entered before any query runs.
127
+ registerQueryCounter(app);
128
+
129
+ // File upload handling (multipart/form-data)
130
+ await app.register(multipart, {
131
+ limits: {
132
+ fileSize: env.MAX_FILE_SIZE_MB * 1024 * 1024, // ENV-configurable (default 10MB)
133
+ files: 1, // Only one file per request
134
+ },
135
+ });
136
+
137
+ // Static file serving for uploads
138
+ // Get __dirname equivalent in ES modules
139
+ const __filename = fileURLToPath(import.meta.url);
140
+ const __dirname = path.dirname(__filename);
141
+
142
+ await app.register(fastifyStatic, {
143
+ root: path.join(__dirname, '..', 'uploads'),
144
+ prefix: '/uploads/',
145
+ });
146
+
147
+ // Initialize file storage (create directories)
148
+ await fileStorageService.initialize();
149
+
150
+ // --- Sync permanent IP blocks from DB to Redis ---
151
+ await syncBlockedIpsToRedis();
152
+
153
+ // Monitoring endpoints exempt from IP blocking and request logging.
154
+ // Health probes (Coolify/Docker/K8s/load balancers) come from infrastructure
155
+ // IPs that must NEVER be blocked — a blocked probe IP would mark a healthy
156
+ // container as dead and restart-loop it. Exact match on the path (query
157
+ // string stripped) so the exemption cannot be widened by crafted URLs.
158
+ // These paths must match the route registrations below.
159
+ const monitoringPaths = new Set(['/api/v1/health', '/api/v1/ready', '/api/v1/live']);
160
+
161
+ // --- IP Block Check (runs before everything else) ---
162
+ app.addHook('onRequest', async (request: FastifyRequest) => {
163
+ if (monitoringPaths.has(request.url.split('?')[0])) {
164
+ return; // never block health probes
165
+ }
166
+ if (await isIpBlocked(getClientIp(request))) {
167
+ throw new ForbiddenError('Access denied', 'IP_BLOCKED');
168
+ }
169
+ });
170
+
171
+ // --- CSRF defense-in-depth: Origin check on state-changing methods ---
172
+ // With sameSite=none cookies (cross-origin deployments), the browser attaches
173
+ // auth cookies to cross-site requests. If a browser sends an Origin header on
174
+ // a state-changing request, it must be same-origin or a configured CORS
175
+ // origin. Requests WITHOUT an Origin header (curl, Postman, server-to-server,
176
+ // health probes) are allowed — they carry no ambient cookies and are not
177
+ // CSRF vectors. See src/libs/origin-check.ts for the full rationale.
178
+ const stateChangingMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
179
+ const allowAllOrigins = corsOrigin === true;
180
+ const allowedOrigins = new Set<string>(
181
+ Array.isArray(corsOrigin) ? corsOrigin : typeof corsOrigin === 'string' ? [corsOrigin] : [],
182
+ );
183
+
184
+ app.addHook('onRequest', async (request: FastifyRequest) => {
185
+ if (!stateChangingMethods.has(request.method)) return;
186
+ if (isOriginAllowed(request.headers.origin, request.headers.host, allowedOrigins, allowAllOrigins)) return;
187
+ throw new ForbiddenError('Origin not allowed', 'ORIGIN_NOT_ALLOWED');
188
+ });
189
+
190
+ // --- Request/Response Logging ---
191
+ app.addHook('preHandler', async (request) => {
192
+ const pathname = request.url.split('?')[0];
193
+ if (!monitoringPaths.has(pathname)) {
194
+ markRequestStart(request);
195
+ }
196
+ });
197
+
198
+ app.addHook('onResponse', async (request, reply) => {
199
+ const pathname = request.url.split('?')[0];
200
+ if (!monitoringPaths.has(pathname)) {
201
+ logRequestLine(request, reply);
202
+ }
203
+ });
204
+
205
+ // --- Global Error Handler (must be set before routes) ---
206
+ app.setErrorHandler((error: FastifyError, request, reply) => {
207
+ // AppError — our typed errors (use duck-type check to avoid instanceof issues)
208
+ if (isAppError(error)) {
209
+ return reply.status(error.statusCode).send(errorResponse(error.code, error.message));
210
+ }
211
+
212
+ // Zod validation error
213
+ if (error.name === 'ZodError') {
214
+ return reply.status(422).send(errorResponse('VALIDATION_FAILED', 'Validation failed'));
215
+ }
216
+
217
+ // Fastify validation error
218
+ if (error.validation) {
219
+ return reply.status(400).send(
220
+ errorResponse(
221
+ 'BAD_REQUEST',
222
+ error.message || 'Invalid request',
223
+ ),
224
+ );
225
+ }
226
+
227
+ // Fastify plugin errors (file size, rate limiting, etc.)
228
+ // These have statusCode properties but aren't AppError instances
229
+ if (error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
230
+ // Map common Fastify error codes to user-friendly messages
231
+ const errorCodeMap: Record<number, { code: string; message: string }> = {
232
+ 413: { code: 'FILE_TOO_LARGE', message: 'File size exceeds the maximum allowed limit' },
233
+ 429: { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many requests. Please try again later' },
234
+ };
235
+
236
+ const errorInfo = errorCodeMap[error.statusCode] || {
237
+ code: 'BAD_REQUEST',
238
+ message: error.message || 'Bad request',
239
+ };
240
+
241
+ return reply.status(error.statusCode).send(errorResponse(errorInfo.code, errorInfo.message));
242
+ }
243
+
244
+ // Unexpected error log and return generic 500
245
+ const requestId = request.id || 'unknown';
246
+ logger.error(
247
+ {
248
+ err: error,
249
+ requestId,
250
+ url: request.url,
251
+ method: request.method,
252
+ stack: error.stack,
253
+ },
254
+ `Unhandled error [${requestId}]: ${error.message}`,
255
+ );
256
+
257
+ return reply.status(500).send(errorResponse('INTERNAL_ERROR', 'Internal server error'));
258
+ });
259
+
260
+ // --- Monitoring & Health Checks ---
261
+ const { performHealthCheck, checkReadiness, checkLiveness } = await import('@libs/monitoring.js');
262
+
263
+ // Comprehensive health check (DB + Redis + Memory + Uptime)
264
+ app.get('/api/v1/health', async (_request, reply) => {
265
+ const health = await performHealthCheck();
266
+
267
+ const statusCode = health.status === 'healthy' ? 200 : health.status === 'degraded' ? 200 : 503;
268
+
269
+ return reply.status(statusCode).send(
270
+ successResponse(
271
+ health.status === 'healthy'
272
+ ? 'All systems operational'
273
+ : health.status === 'degraded'
274
+ ? 'Some systems degraded'
275
+ : 'System unhealthy',
276
+ health,
277
+ ),
278
+ );
279
+ });
280
+
281
+ // Readiness probe (for load balancers / K8s)
282
+ app.get('/api/v1/ready', async (_request, reply) => {
283
+ const ready = await checkReadiness();
284
+ const statusCode = ready ? 200 : 503;
285
+ return reply.status(statusCode).send(
286
+ successResponse(ready ? 'Service is ready' : 'Service not ready', {
287
+ ready,
288
+ timestamp: new Date().toISOString(),
289
+ })
290
+ );
291
+ });
292
+
293
+ // Liveness probe (for container orchestration)
294
+ app.get('/api/v1/live', (_request, reply) => {
295
+ const alive = checkLiveness();
296
+ const statusCode = alive ? 200 : 503;
297
+ return reply.status(statusCode).send(
298
+ successResponse(alive ? 'Service is alive' : 'Service not alive', {
299
+ alive,
300
+ timestamp: new Date().toISOString(),
301
+ })
302
+ );
303
+ });
304
+
305
+ // --- Routes ---
306
+ await app.register(authRoutes, { prefix: '/api/v1' });
307
+ await app.register(usersRoutes, { prefix: '/api/v1' });
308
+ await app.register(adminRoutes, { prefix: '/api/v1' });
309
+
310
+ // --- Background Jobs ---
311
+ registerJobs(app);
312
+
313
+ return app;
314
+ }
315
+
316
+ export default buildApp;