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.
- package/README.md +10 -3
- package/bin/create-tigra.js +77 -37
- package/package.json +5 -5
- package/template/_claude/commands/create-server.md +8 -2
- package/template/_claude/rules/client/01-project-structure.md +12 -0
- package/template/_claude/rules/client/03-data-and-state.md +1 -1
- package/template/_claude/rules/client/04-design-system.md +23 -0
- package/template/_claude/rules/client/07-deployment.md +99 -0
- package/template/_claude/rules/client/08-lockfile-cross-platform.md +79 -0
- package/template/_claude/rules/client/core.md +1 -0
- package/template/_claude/rules/global/core.md +20 -1
- package/template/_claude/rules/global/investigation-before-conclusions.md +57 -0
- package/template/_claude/rules/server/core.md +2 -0
- package/template/_claude/rules/server/deployment.md +78 -0
- package/template/client/next.config.ts +12 -2
- package/template/client/package-lock.json +12345 -0
- package/template/client/package.json +3 -2
- package/template/client/src/components/common/SafeImage.tsx +2 -1
- package/template/client/src/lib/api/axios.config.ts +19 -4
- package/template/client/src/middleware.ts +7 -0
- package/template/gitignore +1 -0
- package/template/server/.env.example +248 -194
- package/template/server/.env.example.production +221 -168
- package/template/server/Dockerfile +29 -5
- package/template/server/docker-compose.yml +32 -4
- package/template/server/package-lock.json +6544 -6823
- package/template/server/package.json +76 -75
- package/template/server/prisma/seed.ts +20 -4
- package/template/server/src/app.ts +316 -271
- package/template/server/src/config/env.ts +150 -99
- package/template/server/src/config/rate-limit.config.ts +16 -0
- package/template/server/src/libs/__tests__/auth-path.test.ts +24 -0
- package/template/server/src/libs/__tests__/client-ip.test.ts +121 -0
- package/template/server/src/libs/__tests__/http.test.ts +23 -9
- package/template/server/src/libs/__tests__/ip-block.test.ts +62 -0
- package/template/server/src/libs/__tests__/origin-check.test.ts +53 -0
- package/template/server/src/libs/__tests__/url-safety.test.ts +80 -0
- package/template/server/src/libs/auth-path.ts +14 -0
- package/template/server/src/libs/auth.ts +6 -16
- package/template/server/src/libs/client-ip.ts +77 -0
- package/template/server/src/libs/cookies.ts +1 -1
- package/template/server/src/libs/duration.ts +30 -0
- package/template/server/src/libs/ip-block.ts +220 -206
- package/template/server/src/libs/origin-check.ts +38 -0
- package/template/server/src/libs/query-counter.ts +59 -0
- package/template/server/src/libs/redis.ts +1 -1
- package/template/server/src/libs/url-safety.ts +121 -0
- package/template/server/src/modules/auth/__tests__/auth.service.test.ts +274 -44
- package/template/server/src/modules/auth/auth.controller.ts +128 -127
- package/template/server/src/modules/auth/auth.repo.ts +2 -0
- package/template/server/src/modules/auth/auth.service.ts +103 -12
- package/template/server/src/test/setup.ts +22 -2
- 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 {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
await app.register(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (
|
|
164
|
-
return
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
);
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
// ---
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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;
|