create-tigra 2.1.5 → 2.3.0

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 (39) hide show
  1. package/bin/create-tigra.js +2 -0
  2. package/package.json +4 -1
  3. package/template/_claude/commands/create-client.md +1 -4
  4. package/template/_claude/commands/create-server.md +0 -1
  5. package/template/_claude/rules/client/01-project-structure.md +0 -3
  6. package/template/_claude/rules/client/03-data-and-state.md +1 -1
  7. package/template/_claude/rules/server/project-conventions.md +13 -0
  8. package/template/client/package.json +2 -1
  9. package/template/server/.env.example +23 -0
  10. package/template/server/.env.example.production +21 -0
  11. package/template/server/package.json +2 -1
  12. package/template/server/postman/collection.json +524 -0
  13. package/template/server/postman/environment.json +31 -0
  14. package/template/server/prisma/schema.prisma +17 -1
  15. package/template/server/src/app.ts +43 -10
  16. package/template/server/src/config/env.ts +9 -0
  17. package/template/server/src/config/rate-limit.config.ts +114 -0
  18. package/template/server/src/jobs/cleanup-deleted-accounts.job.ts +80 -0
  19. package/template/server/src/{libs/cleanup.ts → jobs/cleanup-expired-auth.job.ts} +10 -4
  20. package/template/server/src/jobs/index.ts +20 -0
  21. package/template/server/src/libs/auth.ts +45 -1
  22. package/template/server/src/libs/ip-block.ts +206 -0
  23. package/template/server/src/libs/requestLogger.ts +1 -1
  24. package/template/server/src/libs/storage/file-storage.service.ts +65 -18
  25. package/template/server/src/libs/storage/file-validator.ts +4 -11
  26. package/template/server/src/libs/storage/image-optimizer.service.ts +1 -1
  27. package/template/server/src/modules/admin/admin.controller.ts +42 -0
  28. package/template/server/src/modules/admin/admin.routes.ts +45 -0
  29. package/template/server/src/modules/auth/auth.repo.ts +18 -0
  30. package/template/server/src/modules/auth/auth.routes.ts +10 -30
  31. package/template/server/src/modules/auth/auth.service.ts +52 -26
  32. package/template/server/src/modules/users/users.controller.ts +92 -5
  33. package/template/server/src/modules/users/users.repo.ts +27 -0
  34. package/template/server/src/modules/users/users.routes.ts +210 -19
  35. package/template/server/src/modules/users/users.schemas.ts +62 -4
  36. package/template/server/src/modules/users/users.service.ts +124 -3
  37. package/template/server/src/shared/types/index.ts +2 -0
  38. package/template/server/tsconfig.json +2 -1
  39. package/template/server/uploads/avatars/.gitkeep +0 -1
@@ -0,0 +1,524 @@
1
+ {
2
+ "info": {
3
+ "name": "{{PROJECT_DISPLAY_NAME}} API",
4
+ "_postman_id": "{{PROJECT_NAME}}-server-collection",
5
+ "description": "API collection for {{PROJECT_DISPLAY_NAME}}.\n\nAll authenticated requests inherit Bearer Token auth from the collection root.\nLogin/Register requests auto-set tokens via test scripts.",
6
+ "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
7
+ },
8
+ "auth": {
9
+ "type": "bearer",
10
+ "bearer": [
11
+ {
12
+ "key": "token",
13
+ "value": "{{accessToken}}",
14
+ "type": "string"
15
+ }
16
+ ]
17
+ },
18
+ "variable": [
19
+ {
20
+ "key": "baseUrl",
21
+ "value": "http://localhost:8000/api/v1"
22
+ },
23
+ {
24
+ "key": "accessToken",
25
+ "value": ""
26
+ },
27
+ {
28
+ "key": "refreshToken",
29
+ "value": ""
30
+ },
31
+ {
32
+ "key": "userId",
33
+ "value": ""
34
+ },
35
+ {
36
+ "key": "targetUserId",
37
+ "value": ""
38
+ }
39
+ ],
40
+ "item": [
41
+ {
42
+ "name": "Auth",
43
+ "description": "Authentication endpoints. No auth required for these requests.",
44
+ "auth": {
45
+ "type": "noauth"
46
+ },
47
+ "item": [
48
+ {
49
+ "name": "Register",
50
+ "request": {
51
+ "method": "POST",
52
+ "header": [
53
+ {
54
+ "key": "Content-Type",
55
+ "value": "application/json"
56
+ }
57
+ ],
58
+ "body": {
59
+ "mode": "raw",
60
+ "raw": "{\n \"email\": \"john.doe@example.com\",\n \"password\": \"Password123\",\n \"firstName\": \"John\",\n \"lastName\": \"Doe\"\n}"
61
+ },
62
+ "url": {
63
+ "raw": "{{baseUrl}}/auth/register",
64
+ "host": ["{{baseUrl}}"],
65
+ "path": ["auth", "register"]
66
+ },
67
+ "description": "Register a new user account."
68
+ },
69
+ "event": [
70
+ {
71
+ "listen": "test",
72
+ "script": {
73
+ "type": "text/javascript",
74
+ "exec": [
75
+ "if (pm.response.code === 201) {",
76
+ " const res = pm.response.json();",
77
+ " if (res.data && res.data.user) {",
78
+ " pm.collectionVariables.set('userId', res.data.user.id);",
79
+ " }",
80
+ " // Tokens are set via httpOnly cookies, not in response body",
81
+ "}"
82
+ ]
83
+ }
84
+ }
85
+ ],
86
+ "response": []
87
+ },
88
+ {
89
+ "name": "Login",
90
+ "request": {
91
+ "method": "POST",
92
+ "header": [
93
+ {
94
+ "key": "Content-Type",
95
+ "value": "application/json"
96
+ }
97
+ ],
98
+ "body": {
99
+ "mode": "raw",
100
+ "raw": "{\n \"email\": \"john.doe@example.com\",\n \"password\": \"Password123\"\n}"
101
+ },
102
+ "url": {
103
+ "raw": "{{baseUrl}}/auth/login",
104
+ "host": ["{{baseUrl}}"],
105
+ "path": ["auth", "login"]
106
+ },
107
+ "description": "Login with email and password. Tokens are set as httpOnly cookies."
108
+ },
109
+ "event": [
110
+ {
111
+ "listen": "test",
112
+ "script": {
113
+ "type": "text/javascript",
114
+ "exec": [
115
+ "if (pm.response.code === 200) {",
116
+ " const res = pm.response.json();",
117
+ " if (res.data && res.data.user) {",
118
+ " pm.collectionVariables.set('userId', res.data.user.id);",
119
+ " }",
120
+ " // Tokens are set via httpOnly cookies, not in response body",
121
+ "}"
122
+ ]
123
+ }
124
+ }
125
+ ],
126
+ "response": []
127
+ },
128
+ {
129
+ "name": "Refresh Token",
130
+ "request": {
131
+ "method": "POST",
132
+ "header": [],
133
+ "url": {
134
+ "raw": "{{baseUrl}}/auth/refresh",
135
+ "host": ["{{baseUrl}}"],
136
+ "path": ["auth", "refresh"]
137
+ },
138
+ "description": "Refresh access token using the refresh_token cookie."
139
+ },
140
+ "response": []
141
+ },
142
+ {
143
+ "name": "Logout",
144
+ "request": {
145
+ "method": "POST",
146
+ "header": [],
147
+ "url": {
148
+ "raw": "{{baseUrl}}/auth/logout",
149
+ "host": ["{{baseUrl}}"],
150
+ "path": ["auth", "logout"]
151
+ },
152
+ "description": "Logout and clear auth cookies."
153
+ },
154
+ "response": []
155
+ },
156
+ {
157
+ "name": "Get Me",
158
+ "request": {
159
+ "auth": {
160
+ "type": "bearer",
161
+ "bearer": [
162
+ {
163
+ "key": "token",
164
+ "value": "{{accessToken}}",
165
+ "type": "string"
166
+ }
167
+ ]
168
+ },
169
+ "method": "GET",
170
+ "header": [],
171
+ "url": {
172
+ "raw": "{{baseUrl}}/auth/me",
173
+ "host": ["{{baseUrl}}"],
174
+ "path": ["auth", "me"]
175
+ },
176
+ "description": "Get the currently authenticated user."
177
+ },
178
+ "response": []
179
+ },
180
+ {
181
+ "name": "Get Sessions",
182
+ "request": {
183
+ "auth": {
184
+ "type": "bearer",
185
+ "bearer": [
186
+ {
187
+ "key": "token",
188
+ "value": "{{accessToken}}",
189
+ "type": "string"
190
+ }
191
+ ]
192
+ },
193
+ "method": "GET",
194
+ "header": [],
195
+ "url": {
196
+ "raw": "{{baseUrl}}/auth/sessions",
197
+ "host": ["{{baseUrl}}"],
198
+ "path": ["auth", "sessions"]
199
+ },
200
+ "description": "Get all active sessions for the current user."
201
+ },
202
+ "response": []
203
+ },
204
+ {
205
+ "name": "Logout All Sessions",
206
+ "request": {
207
+ "auth": {
208
+ "type": "bearer",
209
+ "bearer": [
210
+ {
211
+ "key": "token",
212
+ "value": "{{accessToken}}",
213
+ "type": "string"
214
+ }
215
+ ]
216
+ },
217
+ "method": "POST",
218
+ "header": [],
219
+ "url": {
220
+ "raw": "{{baseUrl}}/auth/logout-all",
221
+ "host": ["{{baseUrl}}"],
222
+ "path": ["auth", "logout-all"]
223
+ },
224
+ "description": "Logout from all sessions and invalidate all refresh tokens."
225
+ },
226
+ "response": []
227
+ }
228
+ ]
229
+ },
230
+ {
231
+ "name": "Users",
232
+ "description": "User profile and avatar management. All requests require authentication (inherited from collection root).",
233
+ "item": [
234
+ {
235
+ "name": "Update Profile",
236
+ "request": {
237
+ "method": "PATCH",
238
+ "header": [
239
+ {
240
+ "key": "Content-Type",
241
+ "value": "application/json"
242
+ }
243
+ ],
244
+ "body": {
245
+ "mode": "raw",
246
+ "raw": "{\n \"firstName\": \"Jane\",\n \"lastName\": \"Smith\"\n}"
247
+ },
248
+ "url": {
249
+ "raw": "{{baseUrl}}/users/me",
250
+ "host": ["{{baseUrl}}"],
251
+ "path": ["users", "me"]
252
+ },
253
+ "description": "Update the current user's profile. At least one field (firstName or lastName) must be provided."
254
+ },
255
+ "response": []
256
+ },
257
+ {
258
+ "name": "Change Password",
259
+ "request": {
260
+ "method": "PATCH",
261
+ "header": [
262
+ {
263
+ "key": "Content-Type",
264
+ "value": "application/json"
265
+ }
266
+ ],
267
+ "body": {
268
+ "mode": "raw",
269
+ "raw": "{\n \"currentPassword\": \"Password123\",\n \"newPassword\": \"NewPassword456\"\n}"
270
+ },
271
+ "url": {
272
+ "raw": "{{baseUrl}}/users/me/password",
273
+ "host": ["{{baseUrl}}"],
274
+ "path": ["users", "me", "password"]
275
+ },
276
+ "description": "Change the current user's password. Requires current password verification. Invalidates all sessions after successful change."
277
+ },
278
+ "response": []
279
+ },
280
+ {
281
+ "name": "Delete Account",
282
+ "request": {
283
+ "method": "DELETE",
284
+ "header": [
285
+ {
286
+ "key": "Content-Type",
287
+ "value": "application/json"
288
+ }
289
+ ],
290
+ "body": {
291
+ "mode": "raw",
292
+ "raw": "{\n \"password\": \"Password123\"\n}"
293
+ },
294
+ "url": {
295
+ "raw": "{{baseUrl}}/users/me",
296
+ "host": ["{{baseUrl}}"],
297
+ "path": ["users", "me"]
298
+ },
299
+ "description": "Soft-delete the current user's account. Requires password confirmation. Account data is permanently purged after 30 days."
300
+ },
301
+ "response": []
302
+ },
303
+ {
304
+ "name": "Upload Avatar",
305
+ "request": {
306
+ "method": "POST",
307
+ "header": [],
308
+ "body": {
309
+ "mode": "formdata",
310
+ "formdata": [
311
+ {
312
+ "key": "file",
313
+ "type": "file",
314
+ "src": "",
315
+ "description": "Image file (JPEG, PNG, or WebP, max 10MB)"
316
+ }
317
+ ]
318
+ },
319
+ "url": {
320
+ "raw": "{{baseUrl}}/users/avatar",
321
+ "host": ["{{baseUrl}}"],
322
+ "path": ["users", "avatar"]
323
+ },
324
+ "description": "Upload or replace the current user's avatar. Accepts JPEG, PNG, or WebP. Max 10MB. Image is optimized and converted to WebP."
325
+ },
326
+ "response": []
327
+ },
328
+ {
329
+ "name": "Delete Avatar",
330
+ "request": {
331
+ "method": "DELETE",
332
+ "header": [],
333
+ "url": {
334
+ "raw": "{{baseUrl}}/users/avatar",
335
+ "host": ["{{baseUrl}}"],
336
+ "path": ["users", "avatar"]
337
+ },
338
+ "description": "Delete the current user's avatar."
339
+ },
340
+ "response": []
341
+ },
342
+ {
343
+ "name": "Get Avatar",
344
+ "request": {
345
+ "auth": {
346
+ "type": "noauth"
347
+ },
348
+ "method": "GET",
349
+ "header": [],
350
+ "url": {
351
+ "raw": "{{baseUrl}}/users/{{userId}}/avatar",
352
+ "host": ["{{baseUrl}}"],
353
+ "path": ["users", "{{userId}}", "avatar"]
354
+ },
355
+ "description": "Get a user's avatar image by user ID. Public endpoint — no authentication required."
356
+ },
357
+ "response": []
358
+ }
359
+ ]
360
+ },
361
+ {
362
+ "name": "Admin",
363
+ "description": "Admin-only endpoints. Requires ADMIN role. Auth inherited from collection root.",
364
+ "item": [
365
+ {
366
+ "name": "User Management",
367
+ "description": "Manage users by ID. Requires ADMIN role. Set {{targetUserId}} before calling.",
368
+ "item": [
369
+ {
370
+ "name": "Update Profile (by ID)",
371
+ "request": {
372
+ "method": "PATCH",
373
+ "header": [
374
+ {
375
+ "key": "Content-Type",
376
+ "value": "application/json"
377
+ }
378
+ ],
379
+ "body": {
380
+ "mode": "raw",
381
+ "raw": "{\n \"firstName\": \"Jane\",\n \"lastName\": \"Smith\"\n}"
382
+ },
383
+ "url": {
384
+ "raw": "{{baseUrl}}/users/{{targetUserId}}",
385
+ "host": ["{{baseUrl}}"],
386
+ "path": ["users", "{{targetUserId}}"]
387
+ },
388
+ "description": "Update a user's profile by ID. Requires owner or admin access."
389
+ },
390
+ "response": []
391
+ },
392
+ {
393
+ "name": "Change Password (by ID)",
394
+ "request": {
395
+ "method": "PATCH",
396
+ "header": [
397
+ {
398
+ "key": "Content-Type",
399
+ "value": "application/json"
400
+ }
401
+ ],
402
+ "body": {
403
+ "mode": "raw",
404
+ "raw": "{\n \"newPassword\": \"NewPassword456\"\n}"
405
+ },
406
+ "url": {
407
+ "raw": "{{baseUrl}}/users/{{targetUserId}}/password",
408
+ "host": ["{{baseUrl}}"],
409
+ "path": ["users", "{{targetUserId}}", "password"]
410
+ },
411
+ "description": "Change a user's password by ID. Admin: only newPassword required. Invalidates all target user sessions."
412
+ },
413
+ "response": []
414
+ },
415
+ {
416
+ "name": "Delete Account (by ID)",
417
+ "request": {
418
+ "method": "DELETE",
419
+ "header": [],
420
+ "url": {
421
+ "raw": "{{baseUrl}}/users/{{targetUserId}}",
422
+ "host": ["{{baseUrl}}"],
423
+ "path": ["users", "{{targetUserId}}"]
424
+ },
425
+ "description": "Soft-delete a user's account by ID. No password required for admin. Invalidates all target user sessions."
426
+ },
427
+ "response": []
428
+ },
429
+ {
430
+ "name": "Upload Avatar (by ID)",
431
+ "request": {
432
+ "method": "POST",
433
+ "header": [],
434
+ "body": {
435
+ "mode": "formdata",
436
+ "formdata": [
437
+ {
438
+ "key": "file",
439
+ "type": "file",
440
+ "src": "",
441
+ "description": "Image file (JPEG, PNG, or WebP, max 10MB)"
442
+ }
443
+ ]
444
+ },
445
+ "url": {
446
+ "raw": "{{baseUrl}}/users/{{targetUserId}}/avatar",
447
+ "host": ["{{baseUrl}}"],
448
+ "path": ["users", "{{targetUserId}}", "avatar"]
449
+ },
450
+ "description": "Upload or replace a user's avatar by ID. Requires admin access. Accepts JPEG, PNG, or WebP."
451
+ },
452
+ "response": []
453
+ },
454
+ {
455
+ "name": "Delete Avatar (by ID)",
456
+ "request": {
457
+ "method": "DELETE",
458
+ "header": [],
459
+ "url": {
460
+ "raw": "{{baseUrl}}/users/{{targetUserId}}/avatar",
461
+ "host": ["{{baseUrl}}"],
462
+ "path": ["users", "{{targetUserId}}", "avatar"]
463
+ },
464
+ "description": "Delete a user's avatar by ID. Requires admin access."
465
+ },
466
+ "response": []
467
+ }
468
+ ]
469
+ },
470
+ {
471
+ "name": "List Blocked IPs",
472
+ "request": {
473
+ "method": "GET",
474
+ "header": [],
475
+ "url": {
476
+ "raw": "{{baseUrl}}/admin/blocked-ips",
477
+ "host": ["{{baseUrl}}"],
478
+ "path": ["admin", "blocked-ips"]
479
+ },
480
+ "description": "List all blocked IP addresses. Returns permanent blocks and active auto-blocks separately."
481
+ },
482
+ "response": []
483
+ },
484
+ {
485
+ "name": "Block IP",
486
+ "request": {
487
+ "method": "POST",
488
+ "header": [
489
+ {
490
+ "key": "Content-Type",
491
+ "value": "application/json"
492
+ }
493
+ ],
494
+ "body": {
495
+ "mode": "raw",
496
+ "raw": "{\n \"ip\": \"1.2.3.4\",\n \"reason\": \"Spam bot\"\n}"
497
+ },
498
+ "url": {
499
+ "raw": "{{baseUrl}}/admin/blocked-ips",
500
+ "host": ["{{baseUrl}}"],
501
+ "path": ["admin", "blocked-ips"]
502
+ },
503
+ "description": "Permanently block an IP address. Persisted to database and cached in Redis. Blocked IPs receive 403 IP_BLOCKED on all requests. Optional reason field for audit trail."
504
+ },
505
+ "response": []
506
+ },
507
+ {
508
+ "name": "Unblock IP",
509
+ "request": {
510
+ "method": "DELETE",
511
+ "header": [],
512
+ "url": {
513
+ "raw": "{{baseUrl}}/admin/blocked-ips/1.2.3.4",
514
+ "host": ["{{baseUrl}}"],
515
+ "path": ["admin", "blocked-ips", "1.2.3.4"]
516
+ },
517
+ "description": "Remove an IP from the permanent block list. Also removes it from auto-blocks if present."
518
+ },
519
+ "response": []
520
+ }
521
+ ]
522
+ }
523
+ ]
524
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "id": "{{PROJECT_NAME}}-server-env",
3
+ "name": "{{PROJECT_DISPLAY_NAME}} Server - Local",
4
+ "values": [
5
+ {
6
+ "key": "baseUrl",
7
+ "value": "http://localhost:8000/api/v1",
8
+ "type": "default",
9
+ "enabled": true
10
+ },
11
+ {
12
+ "key": "accessToken",
13
+ "value": "",
14
+ "type": "default",
15
+ "enabled": true
16
+ },
17
+ {
18
+ "key": "refreshToken",
19
+ "value": "",
20
+ "type": "default",
21
+ "enabled": true
22
+ },
23
+ {
24
+ "key": "userId",
25
+ "value": "",
26
+ "type": "default",
27
+ "enabled": true
28
+ }
29
+ ],
30
+ "_postman_variable_scope": "environment"
31
+ }
@@ -18,7 +18,7 @@ model User {
18
18
  password String
19
19
  firstName String
20
20
  lastName String
21
- avatarUrl String? // SEO-friendly path: /uploads/avatars/{userId}/{firstName-lastName}-avatar.webp
21
+ avatarUrl String? // SEO-friendly path: /uploads/users/{userId}/avatar/{firstName-lastName}-avatar.webp
22
22
  role UserRole @default(USER)
23
23
  isActive Boolean @default(true)
24
24
  deletedAt DateTime?
@@ -32,6 +32,7 @@ model User {
32
32
 
33
33
  refreshTokens RefreshToken[]
34
34
  sessions Session[]
35
+ blockedIps BlockedIp[]
35
36
 
36
37
  // Performance indexes for high-traffic scenarios (10K-100K users/day)
37
38
  @@index([role]) // For role-based authorization queries
@@ -75,3 +76,18 @@ model Session {
75
76
  @@index([expiresAt]) // For expired session cleanup
76
77
  @@map("sessions")
77
78
  }
79
+
80
+ model BlockedIp {
81
+ id String @id @default(uuid())
82
+ ip String @unique @db.VarChar(45)
83
+ reason String? @db.VarChar(500)
84
+ blockedBy String
85
+
86
+ createdAt DateTime @default(now())
87
+ updatedAt DateTime @updatedAt
88
+
89
+ user User @relation(fields: [blockedBy], references: [id])
90
+
91
+ @@index([ip])
92
+ @@map("blocked_ips")
93
+ }
@@ -1,4 +1,4 @@
1
- import Fastify, { type FastifyError } from 'fastify';
1
+ import Fastify, { type FastifyError, type FastifyRequest } from 'fastify';
2
2
  import cors from '@fastify/cors';
3
3
  import helmet from '@fastify/helmet';
4
4
  import rateLimit from '@fastify/rate-limit';
@@ -17,8 +17,12 @@ import { isAppError } from '@shared/errors/AppError.js';
17
17
  import { successResponse, errorResponse } from '@shared/responses/successResponse.js';
18
18
  import { authRoutes } from '@modules/auth/auth.routes.js';
19
19
  import { usersRoutes } from '@modules/users/users.routes.js';
20
+ import { adminRoutes } from '@modules/admin/admin.routes.js';
20
21
  import { fileStorageService } from '@libs/storage/file-storage.service.js';
21
- import { registerCleanupJob } from '@libs/cleanup.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 { ForbiddenError } from '@shared/errors/errors.js';
22
26
  import {
23
27
  serializerCompiler,
24
28
  validatorCompiler,
@@ -71,11 +75,29 @@ export async function buildApp() {
71
75
  encodings: ['gzip', 'deflate'],
72
76
  });
73
77
 
74
- await app.register(rateLimit, {
75
- global: false,
76
- max: 100,
77
- timeWindow: '1 minute',
78
- });
78
+ // Rate limiting: Redis-backed when available, in-memory fallback
79
+ if (RATE_LIMIT_ENABLED) {
80
+ const redisStore = getRateLimitRedisStore();
81
+ await app.register(rateLimit, {
82
+ global: false,
83
+ max: 100,
84
+ timeWindow: '1 minute',
85
+ redis: redisStore,
86
+ nameSpace: 'rl:',
87
+ skipOnError: true, // Gracefully degrade if Redis fails mid-request
88
+ onExceeded: (request: { ip: string }) => {
89
+ recordRateLimitViolation(request.ip);
90
+ },
91
+ });
92
+ } else {
93
+ // Register with effectively no limit so per-route configs don't error
94
+ await app.register(rateLimit, {
95
+ global: false,
96
+ max: 1_000_000,
97
+ timeWindow: '1 minute',
98
+ });
99
+ logger.warn('[RATE-LIMIT] Rate limiting is DISABLED (RATE_LIMIT_ENABLED=false)');
100
+ }
79
101
 
80
102
  await app.register(cookie, {
81
103
  secret: env.COOKIE_SECRET || env.JWT_SECRET,
@@ -95,7 +117,7 @@ export async function buildApp() {
95
117
  // File upload handling (multipart/form-data)
96
118
  await app.register(multipart, {
97
119
  limits: {
98
- fileSize: 5 * 1024 * 1024, // 5MB max file size
120
+ fileSize: env.MAX_FILE_SIZE_MB * 1024 * 1024, // ENV-configurable (default 10MB)
99
121
  files: 1, // Only one file per request
100
122
  },
101
123
  });
@@ -113,6 +135,16 @@ export async function buildApp() {
113
135
  // Initialize file storage (create directories)
114
136
  await fileStorageService.initialize();
115
137
 
138
+ // --- Sync permanent IP blocks from DB to Redis ---
139
+ await syncBlockedIpsToRedis();
140
+
141
+ // --- IP Block Check (runs before everything else) ---
142
+ app.addHook('onRequest', async (request: FastifyRequest) => {
143
+ if (await isIpBlocked(request.ip)) {
144
+ throw new ForbiddenError('Access denied', 'IP_BLOCKED');
145
+ }
146
+ });
147
+
116
148
  // --- Request/Response Logging ---
117
149
  const skipLogPaths = new Set(['/api/v1/health', '/api/v1/ready', '/api/v1/live']);
118
150
 
@@ -233,9 +265,10 @@ export async function buildApp() {
233
265
  // --- Routes ---
234
266
  await app.register(authRoutes, { prefix: '/api/v1' });
235
267
  await app.register(usersRoutes, { prefix: '/api/v1' });
268
+ await app.register(adminRoutes, { prefix: '/api/v1' });
236
269
 
237
- // --- Cleanup Jobs ---
238
- await registerCleanupJob(app);
270
+ // --- Background Jobs ---
271
+ registerJobs(app);
239
272
 
240
273
  return app;
241
274
  }