create-tigra 3.0.0 → 3.0.3

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