create-tigra 2.8.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.
Files changed (53) hide show
  1. package/README.md +10 -3
  2. package/bin/create-tigra.js +77 -37
  3. package/package.json +5 -5
  4. package/template/_claude/commands/create-server.md +8 -2
  5. package/template/_claude/rules/client/01-project-structure.md +12 -0
  6. package/template/_claude/rules/client/03-data-and-state.md +1 -1
  7. package/template/_claude/rules/client/04-design-system.md +23 -0
  8. package/template/_claude/rules/client/07-deployment.md +99 -0
  9. package/template/_claude/rules/client/08-lockfile-cross-platform.md +79 -0
  10. package/template/_claude/rules/client/core.md +1 -0
  11. package/template/_claude/rules/global/core.md +20 -1
  12. package/template/_claude/rules/global/investigation-before-conclusions.md +57 -0
  13. package/template/_claude/rules/server/core.md +2 -0
  14. package/template/_claude/rules/server/deployment.md +78 -0
  15. package/template/client/next.config.ts +12 -2
  16. package/template/client/package-lock.json +12345 -0
  17. package/template/client/package.json +3 -2
  18. package/template/client/src/components/common/SafeImage.tsx +2 -1
  19. package/template/client/src/lib/api/axios.config.ts +19 -4
  20. package/template/client/src/middleware.ts +7 -0
  21. package/template/gitignore +1 -0
  22. package/template/server/.env.example +248 -194
  23. package/template/server/.env.example.production +221 -168
  24. package/template/server/Dockerfile +29 -5
  25. package/template/server/docker-compose.yml +32 -4
  26. package/template/server/package-lock.json +6544 -6823
  27. package/template/server/package.json +76 -75
  28. package/template/server/prisma/seed.ts +20 -4
  29. package/template/server/src/app.ts +316 -271
  30. package/template/server/src/config/env.ts +150 -99
  31. package/template/server/src/config/rate-limit.config.ts +16 -0
  32. package/template/server/src/libs/__tests__/auth-path.test.ts +24 -0
  33. package/template/server/src/libs/__tests__/client-ip.test.ts +121 -0
  34. package/template/server/src/libs/__tests__/http.test.ts +23 -9
  35. package/template/server/src/libs/__tests__/ip-block.test.ts +62 -0
  36. package/template/server/src/libs/__tests__/origin-check.test.ts +53 -0
  37. package/template/server/src/libs/__tests__/url-safety.test.ts +80 -0
  38. package/template/server/src/libs/auth-path.ts +14 -0
  39. package/template/server/src/libs/auth.ts +6 -16
  40. package/template/server/src/libs/client-ip.ts +77 -0
  41. package/template/server/src/libs/cookies.ts +1 -1
  42. package/template/server/src/libs/duration.ts +30 -0
  43. package/template/server/src/libs/ip-block.ts +220 -206
  44. package/template/server/src/libs/origin-check.ts +38 -0
  45. package/template/server/src/libs/query-counter.ts +59 -0
  46. package/template/server/src/libs/redis.ts +1 -1
  47. package/template/server/src/libs/url-safety.ts +121 -0
  48. package/template/server/src/modules/auth/__tests__/auth.service.test.ts +274 -44
  49. package/template/server/src/modules/auth/auth.controller.ts +128 -127
  50. package/template/server/src/modules/auth/auth.repo.ts +2 -0
  51. package/template/server/src/modules/auth/auth.service.ts +103 -12
  52. package/template/server/src/test/setup.ts +22 -2
  53. package/template/server/vitest.config.ts +43 -43
@@ -1,271 +1,316 @@
1
- import Fastify, { type FastifyError, 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 { ForbiddenError } from '@shared/errors/errors.js';
25
- import {
26
- serializerCompiler,
27
- validatorCompiler,
28
- type ZodTypeProvider,
29
- } from 'fastify-type-provider-zod';
30
-
31
- // Import types to register Fastify augmentations
32
- import type {} from '@shared/types/index.js';
33
-
34
- export async function buildApp() {
35
- const app = Fastify({
36
- logger: false,
37
- // Trust proxy headers (X-Forwarded-For) for accurate client IP behind Nginx/load balancer
38
- trustProxy: env.NODE_ENV === 'production',
39
- // Graceful shutdown configuration
40
- forceCloseConnections: true, // Force close idle connections on shutdown
41
- requestTimeout: 30000, // 30s request timeout
42
- connectionTimeout: 60000, // 60s connection timeout
43
- keepAliveTimeout: 5000, // 5s keep-alive timeout
44
- // Request body size limits (prevent DoS attacks)
45
- bodyLimit: 1048576, // 1MB default limit (1024 * 1024)
46
- }).withTypeProvider<ZodTypeProvider>();
47
-
48
- // Set Zod validator and serializer
49
- app.setValidatorCompiler(validatorCompiler);
50
- app.setSerializerCompiler(serializerCompiler);
51
-
52
- // --- Plugins ---
53
- // CORS: Allow all origins in development, specific origin(s) in production
54
- const corsOrigin = env.NODE_ENV === 'development'
55
- ? true
56
- : env.CORS_ORIGIN?.includes(',')
57
- ? env.CORS_ORIGIN.split(',').map((o) => o.trim())
58
- : env.CORS_ORIGIN;
59
-
60
- await app.register(cors, {
61
- origin: corsOrigin,
62
- credentials: true,
63
- methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
64
- });
65
-
66
- // Enhanced security headers for production
67
- await app.register(helmet, {
68
- global: true,
69
- crossOriginResourcePolicy: { policy: 'cross-origin' },
70
- });
71
-
72
-
73
- // Rate limiting: Redis-backed when available, in-memory fallback
74
- if (RATE_LIMIT_ENABLED) {
75
- const redisStore = getRateLimitRedisStore();
76
- await app.register(rateLimit, {
77
- global: false,
78
- max: 100,
79
- timeWindow: '1 minute',
80
- redis: redisStore,
81
- nameSpace: 'rl:',
82
- skipOnError: true, // Gracefully degrade if Redis fails mid-request
83
- onExceeded: (request: { ip: string }) => {
84
- recordRateLimitViolation(request.ip);
85
- },
86
- });
87
- } else {
88
- // Register with effectively no limit so per-route configs don't error
89
- await app.register(rateLimit, {
90
- global: false,
91
- max: 1_000_000,
92
- timeWindow: '1 minute',
93
- });
94
- logger.warn('[RATE-LIMIT] Rate limiting is DISABLED (RATE_LIMIT_ENABLED=false)');
95
- }
96
-
97
- await app.register(cookie, {
98
- secret: env.COOKIE_SECRET || env.JWT_SECRET,
99
- });
100
-
101
- await app.register(jwt, {
102
- secret: env.JWT_SECRET,
103
- cookie: {
104
- cookieName: 'access_token',
105
- signed: false,
106
- },
107
- });
108
-
109
- // Initialize auth helpers after JWT plugin is registered
110
- initAuth(app);
111
-
112
- // File upload handling (multipart/form-data)
113
- await app.register(multipart, {
114
- limits: {
115
- fileSize: env.MAX_FILE_SIZE_MB * 1024 * 1024, // ENV-configurable (default 10MB)
116
- files: 1, // Only one file per request
117
- },
118
- });
119
-
120
- // Static file serving for uploads
121
- // Get __dirname equivalent in ES modules
122
- const __filename = fileURLToPath(import.meta.url);
123
- const __dirname = path.dirname(__filename);
124
-
125
- await app.register(fastifyStatic, {
126
- root: path.join(__dirname, '..', 'uploads'),
127
- prefix: '/uploads/',
128
- });
129
-
130
- // Initialize file storage (create directories)
131
- await fileStorageService.initialize();
132
-
133
- // --- Sync permanent IP blocks from DB to Redis ---
134
- await syncBlockedIpsToRedis();
135
-
136
- // --- IP Block Check (runs before everything else) ---
137
- app.addHook('onRequest', async (request: FastifyRequest) => {
138
- if (await isIpBlocked(request.ip)) {
139
- throw new ForbiddenError('Access denied', 'IP_BLOCKED');
140
- }
141
- });
142
-
143
- // --- Request/Response Logging ---
144
- const skipLogPaths = new Set(['/api/v1/health', '/api/v1/ready', '/api/v1/live']);
145
-
146
- app.addHook('preHandler', async (request) => {
147
- const pathname = request.url.split('?')[0];
148
- if (!skipLogPaths.has(pathname)) {
149
- markRequestStart(request);
150
- }
151
- });
152
-
153
- app.addHook('onResponse', async (request, reply) => {
154
- const pathname = request.url.split('?')[0];
155
- if (!skipLogPaths.has(pathname)) {
156
- logRequestLine(request, reply);
157
- }
158
- });
159
-
160
- // --- Global Error Handler (must be set before routes) ---
161
- app.setErrorHandler((error: FastifyError, request, reply) => {
162
- // AppError — our typed errors (use duck-type check to avoid instanceof issues)
163
- if (isAppError(error)) {
164
- return reply.status(error.statusCode).send(errorResponse(error.code, error.message));
165
- }
166
-
167
- // Zod validation error
168
- if (error.name === 'ZodError') {
169
- return reply.status(422).send(errorResponse('VALIDATION_FAILED', 'Validation failed'));
170
- }
171
-
172
- // Fastify validation error
173
- if (error.validation) {
174
- return reply.status(400).send(
175
- errorResponse(
176
- 'BAD_REQUEST',
177
- error.message || 'Invalid request',
178
- ),
179
- );
180
- }
181
-
182
- // Fastify plugin errors (file size, rate limiting, etc.)
183
- // These have statusCode properties but aren't AppError instances
184
- if (error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
185
- // Map common Fastify error codes to user-friendly messages
186
- const errorCodeMap: Record<number, { code: string; message: string }> = {
187
- 413: { code: 'FILE_TOO_LARGE', message: 'File size exceeds the maximum allowed limit' },
188
- 429: { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many requests. Please try again later' },
189
- };
190
-
191
- const errorInfo = errorCodeMap[error.statusCode] || {
192
- code: 'BAD_REQUEST',
193
- message: error.message || 'Bad request',
194
- };
195
-
196
- return reply.status(error.statusCode).send(errorResponse(errorInfo.code, errorInfo.message));
197
- }
198
-
199
- // Unexpected error — log and return generic 500
200
- const requestId = request.id || 'unknown';
201
- logger.error(
202
- {
203
- err: error,
204
- requestId,
205
- url: request.url,
206
- method: request.method,
207
- stack: error.stack,
208
- },
209
- `Unhandled error [${requestId}]: ${error.message}`,
210
- );
211
-
212
- return reply.status(500).send(errorResponse('INTERNAL_ERROR', 'Internal server error'));
213
- });
214
-
215
- // --- Monitoring & Health Checks ---
216
- const { performHealthCheck, checkReadiness, checkLiveness } = await import('@libs/monitoring.js');
217
-
218
- // Comprehensive health check (DB + Redis + Memory + Uptime)
219
- app.get('/api/v1/health', async (_request, reply) => {
220
- const health = await performHealthCheck();
221
-
222
- const statusCode = health.status === 'healthy' ? 200 : health.status === 'degraded' ? 200 : 503;
223
-
224
- return reply.status(statusCode).send(
225
- successResponse(
226
- health.status === 'healthy'
227
- ? 'All systems operational'
228
- : health.status === 'degraded'
229
- ? 'Some systems degraded'
230
- : 'System unhealthy',
231
- health,
232
- ),
233
- );
234
- });
235
-
236
- // Readiness probe (for load balancers / K8s)
237
- app.get('/api/v1/ready', async (_request, reply) => {
238
- const ready = await checkReadiness();
239
- const statusCode = ready ? 200 : 503;
240
- return reply.status(statusCode).send(
241
- successResponse(ready ? 'Service is ready' : 'Service not ready', {
242
- ready,
243
- timestamp: new Date().toISOString(),
244
- })
245
- );
246
- });
247
-
248
- // Liveness probe (for container orchestration)
249
- app.get('/api/v1/live', (_request, reply) => {
250
- const alive = checkLiveness();
251
- const statusCode = alive ? 200 : 503;
252
- return reply.status(statusCode).send(
253
- successResponse(alive ? 'Service is alive' : 'Service not alive', {
254
- alive,
255
- timestamp: new Date().toISOString(),
256
- })
257
- );
258
- });
259
-
260
- // --- Routes ---
261
- await app.register(authRoutes, { prefix: '/api/v1' });
262
- await app.register(usersRoutes, { prefix: '/api/v1' });
263
- await app.register(adminRoutes, { prefix: '/api/v1' });
264
-
265
- // --- Background Jobs ---
266
- registerJobs(app);
267
-
268
- return app;
269
- }
270
-
271
- 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;