create-tigra 2.1.5 → 2.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-tigra",
3
- "version": "2.1.5",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "description": "Create a production-ready full-stack app with Next.js 16 + Fastify 5 + Prisma + Redis",
6
6
  "bin": {
@@ -39,6 +39,29 @@ REDIS_MAX_RETRIES=3
39
39
  # Connection timeout in milliseconds
40
40
  REDIS_CONNECT_TIMEOUT=10000
41
41
 
42
+ # ===================================================================
43
+ # RATE LIMITING
44
+ # ===================================================================
45
+
46
+ # Master switch to enable/disable rate limiting (default: true)
47
+ # Set to false in development to disable all rate limits
48
+ RATE_LIMIT_ENABLED=true
49
+
50
+ # Multiply all rate limit max values by this factor (default: 1)
51
+ # Set to 10 in development for 10x headroom, or 0.5 for tighter limits
52
+ RATE_LIMIT_MULTIPLIER=1
53
+
54
+ # Optional: Override specific critical route limits (uses defaults if not set)
55
+ # RATE_LIMIT_AUTH_LOGIN_MAX=10
56
+ # RATE_LIMIT_AUTH_REGISTER_MAX=5
57
+
58
+ # ===================================================================
59
+ # FILE UPLOAD
60
+ # ===================================================================
61
+
62
+ # Maximum file upload size in MB (default: 10)
63
+ MAX_FILE_SIZE_MB=10
64
+
42
65
  # ===================================================================
43
66
  # DOCKER PORTS (auto-generated, unique per project)
44
67
  # ===================================================================
@@ -39,6 +39,27 @@ REDIS_URL="redis://:REDIS_PASSWORD@redis.internal:6379"
39
39
  REDIS_MAX_RETRIES=5
40
40
  REDIS_CONNECT_TIMEOUT=5000
41
41
 
42
+ # ===================================================================
43
+ # RATE LIMITING
44
+ # ===================================================================
45
+
46
+ # Always enabled in production
47
+ RATE_LIMIT_ENABLED=true
48
+
49
+ # Production: keep at 1 (default limits are tuned for production)
50
+ RATE_LIMIT_MULTIPLIER=1
51
+
52
+ # Tight limits for sensitive auth routes in production
53
+ RATE_LIMIT_AUTH_LOGIN_MAX=10
54
+ RATE_LIMIT_AUTH_REGISTER_MAX=5
55
+
56
+ # ===================================================================
57
+ # FILE UPLOAD
58
+ # ===================================================================
59
+
60
+ # Maximum file upload size in MB
61
+ MAX_FILE_SIZE_MB=10
62
+
42
63
  # ===================================================================
43
64
  # JWT AUTHENTICATION
44
65
  # ===================================================================
@@ -0,0 +1,415 @@
1
+ {
2
+ "info": {
3
+ "name": "Tigra Server API",
4
+ "_postman_id": "tigra-server-collection",
5
+ "description": "API collection for the Tigra server template.\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
+ "item": [
37
+ {
38
+ "name": "Auth",
39
+ "description": "Authentication endpoints. No auth required for these requests.",
40
+ "auth": {
41
+ "type": "noauth"
42
+ },
43
+ "item": [
44
+ {
45
+ "name": "Register",
46
+ "request": {
47
+ "method": "POST",
48
+ "header": [
49
+ {
50
+ "key": "Content-Type",
51
+ "value": "application/json"
52
+ }
53
+ ],
54
+ "body": {
55
+ "mode": "raw",
56
+ "raw": "{\n \"email\": \"john.doe@example.com\",\n \"password\": \"Password123\",\n \"firstName\": \"John\",\n \"lastName\": \"Doe\"\n}"
57
+ },
58
+ "url": {
59
+ "raw": "{{baseUrl}}/auth/register",
60
+ "host": ["{{baseUrl}}"],
61
+ "path": ["auth", "register"]
62
+ },
63
+ "description": "Register a new user account."
64
+ },
65
+ "event": [
66
+ {
67
+ "listen": "test",
68
+ "script": {
69
+ "type": "text/javascript",
70
+ "exec": [
71
+ "if (pm.response.code === 201) {",
72
+ " const res = pm.response.json();",
73
+ " if (res.data && res.data.user) {",
74
+ " pm.collectionVariables.set('userId', res.data.user.id);",
75
+ " }",
76
+ " // Tokens are set via httpOnly cookies, not in response body",
77
+ "}"
78
+ ]
79
+ }
80
+ }
81
+ ],
82
+ "response": []
83
+ },
84
+ {
85
+ "name": "Login",
86
+ "request": {
87
+ "method": "POST",
88
+ "header": [
89
+ {
90
+ "key": "Content-Type",
91
+ "value": "application/json"
92
+ }
93
+ ],
94
+ "body": {
95
+ "mode": "raw",
96
+ "raw": "{\n \"email\": \"john.doe@example.com\",\n \"password\": \"Password123\"\n}"
97
+ },
98
+ "url": {
99
+ "raw": "{{baseUrl}}/auth/login",
100
+ "host": ["{{baseUrl}}"],
101
+ "path": ["auth", "login"]
102
+ },
103
+ "description": "Login with email and password. Tokens are set as httpOnly cookies."
104
+ },
105
+ "event": [
106
+ {
107
+ "listen": "test",
108
+ "script": {
109
+ "type": "text/javascript",
110
+ "exec": [
111
+ "if (pm.response.code === 200) {",
112
+ " const res = pm.response.json();",
113
+ " if (res.data && res.data.user) {",
114
+ " pm.collectionVariables.set('userId', res.data.user.id);",
115
+ " }",
116
+ " // Tokens are set via httpOnly cookies, not in response body",
117
+ "}"
118
+ ]
119
+ }
120
+ }
121
+ ],
122
+ "response": []
123
+ },
124
+ {
125
+ "name": "Refresh Token",
126
+ "request": {
127
+ "method": "POST",
128
+ "header": [],
129
+ "url": {
130
+ "raw": "{{baseUrl}}/auth/refresh",
131
+ "host": ["{{baseUrl}}"],
132
+ "path": ["auth", "refresh"]
133
+ },
134
+ "description": "Refresh access token using the refresh_token cookie."
135
+ },
136
+ "response": []
137
+ },
138
+ {
139
+ "name": "Logout",
140
+ "request": {
141
+ "method": "POST",
142
+ "header": [],
143
+ "url": {
144
+ "raw": "{{baseUrl}}/auth/logout",
145
+ "host": ["{{baseUrl}}"],
146
+ "path": ["auth", "logout"]
147
+ },
148
+ "description": "Logout and clear auth cookies."
149
+ },
150
+ "response": []
151
+ },
152
+ {
153
+ "name": "Get Me",
154
+ "request": {
155
+ "auth": {
156
+ "type": "bearer",
157
+ "bearer": [
158
+ {
159
+ "key": "token",
160
+ "value": "{{accessToken}}",
161
+ "type": "string"
162
+ }
163
+ ]
164
+ },
165
+ "method": "GET",
166
+ "header": [],
167
+ "url": {
168
+ "raw": "{{baseUrl}}/auth/me",
169
+ "host": ["{{baseUrl}}"],
170
+ "path": ["auth", "me"]
171
+ },
172
+ "description": "Get the currently authenticated user."
173
+ },
174
+ "response": []
175
+ },
176
+ {
177
+ "name": "Get Sessions",
178
+ "request": {
179
+ "auth": {
180
+ "type": "bearer",
181
+ "bearer": [
182
+ {
183
+ "key": "token",
184
+ "value": "{{accessToken}}",
185
+ "type": "string"
186
+ }
187
+ ]
188
+ },
189
+ "method": "GET",
190
+ "header": [],
191
+ "url": {
192
+ "raw": "{{baseUrl}}/auth/sessions",
193
+ "host": ["{{baseUrl}}"],
194
+ "path": ["auth", "sessions"]
195
+ },
196
+ "description": "Get all active sessions for the current user."
197
+ },
198
+ "response": []
199
+ },
200
+ {
201
+ "name": "Logout All Sessions",
202
+ "request": {
203
+ "auth": {
204
+ "type": "bearer",
205
+ "bearer": [
206
+ {
207
+ "key": "token",
208
+ "value": "{{accessToken}}",
209
+ "type": "string"
210
+ }
211
+ ]
212
+ },
213
+ "method": "POST",
214
+ "header": [],
215
+ "url": {
216
+ "raw": "{{baseUrl}}/auth/logout-all",
217
+ "host": ["{{baseUrl}}"],
218
+ "path": ["auth", "logout-all"]
219
+ },
220
+ "description": "Logout from all sessions and invalidate all refresh tokens."
221
+ },
222
+ "response": []
223
+ }
224
+ ]
225
+ },
226
+ {
227
+ "name": "Users",
228
+ "description": "User profile and avatar management. All requests require authentication (inherited from collection root).",
229
+ "item": [
230
+ {
231
+ "name": "Update Profile",
232
+ "request": {
233
+ "method": "PATCH",
234
+ "header": [
235
+ {
236
+ "key": "Content-Type",
237
+ "value": "application/json"
238
+ }
239
+ ],
240
+ "body": {
241
+ "mode": "raw",
242
+ "raw": "{\n \"firstName\": \"Jane\",\n \"lastName\": \"Smith\"\n}"
243
+ },
244
+ "url": {
245
+ "raw": "{{baseUrl}}/users/me",
246
+ "host": ["{{baseUrl}}"],
247
+ "path": ["users", "me"]
248
+ },
249
+ "description": "Update the current user's profile. At least one field (firstName or lastName) must be provided."
250
+ },
251
+ "response": []
252
+ },
253
+ {
254
+ "name": "Change Password",
255
+ "request": {
256
+ "method": "PATCH",
257
+ "header": [
258
+ {
259
+ "key": "Content-Type",
260
+ "value": "application/json"
261
+ }
262
+ ],
263
+ "body": {
264
+ "mode": "raw",
265
+ "raw": "{\n \"currentPassword\": \"Password123\",\n \"newPassword\": \"NewPassword456\"\n}"
266
+ },
267
+ "url": {
268
+ "raw": "{{baseUrl}}/users/me/password",
269
+ "host": ["{{baseUrl}}"],
270
+ "path": ["users", "me", "password"]
271
+ },
272
+ "description": "Change the current user's password. Requires current password verification. Invalidates all sessions after successful change."
273
+ },
274
+ "response": []
275
+ },
276
+ {
277
+ "name": "Delete Account",
278
+ "request": {
279
+ "method": "DELETE",
280
+ "header": [
281
+ {
282
+ "key": "Content-Type",
283
+ "value": "application/json"
284
+ }
285
+ ],
286
+ "body": {
287
+ "mode": "raw",
288
+ "raw": "{\n \"password\": \"Password123\"\n}"
289
+ },
290
+ "url": {
291
+ "raw": "{{baseUrl}}/users/me",
292
+ "host": ["{{baseUrl}}"],
293
+ "path": ["users", "me"]
294
+ },
295
+ "description": "Soft-delete the current user's account. Requires password confirmation. Account data is permanently purged after 30 days."
296
+ },
297
+ "response": []
298
+ },
299
+ {
300
+ "name": "Upload Avatar",
301
+ "request": {
302
+ "method": "POST",
303
+ "header": [],
304
+ "body": {
305
+ "mode": "formdata",
306
+ "formdata": [
307
+ {
308
+ "key": "file",
309
+ "type": "file",
310
+ "src": "",
311
+ "description": "Image file (JPEG, PNG, or WebP, max 10MB)"
312
+ }
313
+ ]
314
+ },
315
+ "url": {
316
+ "raw": "{{baseUrl}}/users/avatar",
317
+ "host": ["{{baseUrl}}"],
318
+ "path": ["users", "avatar"]
319
+ },
320
+ "description": "Upload or replace the current user's avatar. Accepts JPEG, PNG, or WebP. Max 10MB. Image is optimized and converted to WebP."
321
+ },
322
+ "response": []
323
+ },
324
+ {
325
+ "name": "Delete Avatar",
326
+ "request": {
327
+ "method": "DELETE",
328
+ "header": [],
329
+ "url": {
330
+ "raw": "{{baseUrl}}/users/avatar",
331
+ "host": ["{{baseUrl}}"],
332
+ "path": ["users", "avatar"]
333
+ },
334
+ "description": "Delete the current user's avatar."
335
+ },
336
+ "response": []
337
+ },
338
+ {
339
+ "name": "Get Avatar",
340
+ "request": {
341
+ "auth": {
342
+ "type": "noauth"
343
+ },
344
+ "method": "GET",
345
+ "header": [],
346
+ "url": {
347
+ "raw": "{{baseUrl}}/users/{{userId}}/avatar",
348
+ "host": ["{{baseUrl}}"],
349
+ "path": ["users", "{{userId}}", "avatar"]
350
+ },
351
+ "description": "Get a user's avatar image by user ID. Public endpoint — no authentication required."
352
+ },
353
+ "response": []
354
+ }
355
+ ]
356
+ },
357
+ {
358
+ "name": "Admin",
359
+ "description": "Admin-only endpoints. Requires ADMIN role. Auth inherited from collection root.",
360
+ "item": [
361
+ {
362
+ "name": "List Blocked IPs",
363
+ "request": {
364
+ "method": "GET",
365
+ "header": [],
366
+ "url": {
367
+ "raw": "{{baseUrl}}/admin/blocked-ips",
368
+ "host": ["{{baseUrl}}"],
369
+ "path": ["admin", "blocked-ips"]
370
+ },
371
+ "description": "List all blocked IP addresses. Returns permanent blocks and active auto-blocks separately."
372
+ },
373
+ "response": []
374
+ },
375
+ {
376
+ "name": "Block IP",
377
+ "request": {
378
+ "method": "POST",
379
+ "header": [
380
+ {
381
+ "key": "Content-Type",
382
+ "value": "application/json"
383
+ }
384
+ ],
385
+ "body": {
386
+ "mode": "raw",
387
+ "raw": "{\n \"ip\": \"1.2.3.4\"\n}"
388
+ },
389
+ "url": {
390
+ "raw": "{{baseUrl}}/admin/blocked-ips",
391
+ "host": ["{{baseUrl}}"],
392
+ "path": ["admin", "blocked-ips"]
393
+ },
394
+ "description": "Permanently block an IP address. Blocked IPs receive 403 IP_BLOCKED on all requests."
395
+ },
396
+ "response": []
397
+ },
398
+ {
399
+ "name": "Unblock IP",
400
+ "request": {
401
+ "method": "DELETE",
402
+ "header": [],
403
+ "url": {
404
+ "raw": "{{baseUrl}}/admin/blocked-ips/1.2.3.4",
405
+ "host": ["{{baseUrl}}"],
406
+ "path": ["admin", "blocked-ips", "1.2.3.4"]
407
+ },
408
+ "description": "Remove an IP from the permanent block list. Also removes it from auto-blocks if present."
409
+ },
410
+ "response": []
411
+ }
412
+ ]
413
+ }
414
+ ]
415
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "id": "tigra-server-env",
3
+ "name": "Tigra 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
+ }
@@ -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 } 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,13 @@ export async function buildApp() {
113
135
  // Initialize file storage (create directories)
114
136
  await fileStorageService.initialize();
115
137
 
138
+ // --- IP Block Check (runs before everything else) ---
139
+ app.addHook('onRequest', async (request: FastifyRequest) => {
140
+ if (await isIpBlocked(request.ip)) {
141
+ throw new ForbiddenError('Access denied', 'IP_BLOCKED');
142
+ }
143
+ });
144
+
116
145
  // --- Request/Response Logging ---
117
146
  const skipLogPaths = new Set(['/api/v1/health', '/api/v1/ready', '/api/v1/live']);
118
147
 
@@ -233,9 +262,10 @@ export async function buildApp() {
233
262
  // --- Routes ---
234
263
  await app.register(authRoutes, { prefix: '/api/v1' });
235
264
  await app.register(usersRoutes, { prefix: '/api/v1' });
265
+ await app.register(adminRoutes, { prefix: '/api/v1' });
236
266
 
237
- // --- Cleanup Jobs ---
238
- await registerCleanupJob(app);
267
+ // --- Background Jobs ---
268
+ registerJobs(app);
239
269
 
240
270
  return app;
241
271
  }
@@ -21,6 +21,15 @@ const envSchema = z.object({
21
21
  REDIS_MAX_RETRIES: z.coerce.number().int().min(0).default(3),
22
22
  REDIS_CONNECT_TIMEOUT: z.coerce.number().int().min(1000).default(10000), // ms
23
23
 
24
+ // --- Rate Limiting ---
25
+ RATE_LIMIT_ENABLED: z.coerce.boolean().default(true),
26
+ RATE_LIMIT_MULTIPLIER: z.coerce.number().min(0.1).max(100).default(1),
27
+ RATE_LIMIT_AUTH_LOGIN_MAX: z.coerce.number().int().min(1).optional(),
28
+ RATE_LIMIT_AUTH_REGISTER_MAX: z.coerce.number().int().min(1).optional(),
29
+
30
+ // --- File Upload ---
31
+ MAX_FILE_SIZE_MB: z.coerce.number().min(1).max(100).default(10),
32
+
24
33
  // --- JWT Authentication ---
25
34
  JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
26
35
  JWT_ACCESS_EXPIRY: z.string().default('15m'),