create-tigra 2.2.0 → 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 (27) 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/package.json +2 -1
  10. package/template/server/postman/collection.json +114 -5
  11. package/template/server/postman/environment.json +2 -2
  12. package/template/server/prisma/schema.prisma +17 -1
  13. package/template/server/src/app.ts +4 -1
  14. package/template/server/src/jobs/cleanup-deleted-accounts.job.ts +3 -6
  15. package/template/server/src/libs/auth.ts +45 -1
  16. package/template/server/src/libs/ip-block.ts +90 -29
  17. package/template/server/src/libs/requestLogger.ts +1 -1
  18. package/template/server/src/libs/storage/file-storage.service.ts +65 -18
  19. package/template/server/src/libs/storage/file-validator.ts +0 -8
  20. package/template/server/src/modules/admin/admin.controller.ts +4 -3
  21. package/template/server/src/modules/auth/auth.repo.ts +18 -0
  22. package/template/server/src/modules/auth/auth.service.ts +52 -26
  23. package/template/server/src/modules/users/users.controller.ts +39 -21
  24. package/template/server/src/modules/users/users.routes.ts +127 -6
  25. package/template/server/src/modules/users/users.schemas.ts +24 -4
  26. package/template/server/src/modules/users/users.service.ts +23 -10
  27. package/template/server/src/shared/types/index.ts +2 -0
@@ -21,6 +21,8 @@ const FILES_TO_REPLACE = [
21
21
  'server/docker-compose.yml',
22
22
  'client/package.json',
23
23
  'client/.env.example',
24
+ 'server/postman/collection.json',
25
+ 'server/postman/environment.json',
24
26
  ];
25
27
 
26
28
  // Directories/files to skip when copying
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-tigra",
3
- "version": "2.2.0",
3
+ "version": "2.3.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,9 @@
39
39
  "url": "https://github.com/blessandsoul/create-tigra/issues"
40
40
  },
41
41
  "homepage": "https://github.com/blessandsoul/create-tigra#readme",
42
+ "scripts": {
43
+ "create:test": "node -e \"import('fs').then(f=>f.rmSync('testapp',{recursive:true,force:true}))\" && node bin/create-tigra.js testapp"
44
+ },
42
45
  "dependencies": {
43
46
  "chalk": "^5.4.1",
44
47
  "commander": "^13.1.0",
@@ -180,8 +180,6 @@ export const API_ENDPOINTS = {
180
180
  LOGOUT: '/auth/logout',
181
181
  REFRESH: '/auth/refresh',
182
182
  ME: '/auth/me',
183
- VERIFY_EMAIL: '/auth/verify-email',
184
- RESEND_VERIFICATION: '/auth/resend-verification',
185
183
  REQUEST_PASSWORD_RESET: '/auth/request-password-reset',
186
184
  RESET_PASSWORD: '/auth/reset-password',
187
185
  },
@@ -199,7 +197,6 @@ export const ROUTES = {
199
197
  HOME: '/',
200
198
  LOGIN: '/login',
201
199
  REGISTER: '/register',
202
- VERIFY_EMAIL: '/verify-email',
203
200
  RESET_PASSWORD: '/reset-password',
204
201
  DASHBOARD: '/dashboard',
205
202
  PROFILE: '/profile',
@@ -369,7 +366,7 @@ Auth types from `02-components-and-types.md`:
369
366
 
370
367
  #### `src/features/auth/services/auth.service.ts`
371
368
  Auth service class from `03-data-and-state.md`:
372
- - `register`, `login`, `logout`, `refreshToken`, `getMe`, `verifyEmail`, `requestPasswordReset`, `resetPassword`
369
+ - `register`, `login`, `logout`, `refreshToken`, `getMe`, `requestPasswordReset`, `resetPassword`
373
370
  - Uses `apiClient` and `API_ENDPOINTS`
374
371
  - Singleton export: `export const authService = new AuthService();`
375
372
 
@@ -325,7 +325,6 @@ model User {
325
325
  lastName String
326
326
  role String @default("USER")
327
327
  isActive Boolean @default(true)
328
- emailVerified Boolean @default(false)
329
328
  deletedAt DateTime?
330
329
  createdAt DateTime @default(now())
331
330
  updatedAt DateTime @updatedAt
@@ -78,8 +78,6 @@ export const API_ENDPOINTS = {
78
78
  LOGOUT: '/auth/logout',
79
79
  REFRESH: '/auth/refresh',
80
80
  ME: '/auth/me',
81
- VERIFY_EMAIL: '/auth/verify-email',
82
- RESEND_VERIFICATION: '/auth/resend-verification',
83
81
  REQUEST_PASSWORD_RESET: '/auth/request-password-reset',
84
82
  RESET_PASSWORD: '/auth/reset-password',
85
83
  },
@@ -103,7 +101,6 @@ export const ROUTES = {
103
101
  HOME: '/',
104
102
  LOGIN: '/login',
105
103
  REGISTER: '/register',
106
- VERIFY_EMAIL: '/verify-email',
107
104
  RESET_PASSWORD: '/reset-password',
108
105
  DASHBOARD: '/dashboard',
109
106
  PROFILE: '/profile',
@@ -92,7 +92,7 @@ class ItemService {
92
92
  export const itemService = new ItemService();
93
93
  ```
94
94
 
95
- Auth service methods: `register`, `login`, `logout`, `refreshToken`, `getMe`, `verifyEmail`, `requestPasswordReset`, `resetPassword`.
95
+ Auth service methods: `register`, `login`, `logout`, `refreshToken`, `getMe`, `requestPasswordReset`, `resetPassword`.
96
96
 
97
97
  ---
98
98
 
@@ -212,3 +212,16 @@ const apiClient = axios.create({ ...httpClient.defaults, baseURL: 'https://api.e
212
212
  - Always use `httpClient` — never `fetch`, `node:http`, or inline `axios.create()`.
213
213
  - Never add auth headers, cookies, or credentials to the singleton itself.
214
214
  - Never log response bodies (may contain PII or secrets).
215
+
216
+ ## File Storage
217
+
218
+ Upload directory structure: `uploads/users/{userId}/<media-type>/`
219
+
220
+ | Media type | Path | Example |
221
+ |---|---|---|
222
+ | Avatar | `uploads/users/{userId}/avatar/` | `uploads/users/abc123/avatar/john-doe-avatar.webp` |
223
+
224
+ - All user media lives under `uploads/users/{userId}/` for easy per-user cleanup.
225
+ - On account purge, delete the entire `uploads/users/{userId}/` directory via `deleteUserMedia()`.
226
+ - Public URL pattern: `/uploads/users/{userId}/<media-type>/{filename}`
227
+ - New media types follow the same pattern: add a subfolder under the user directory.
@@ -6,7 +6,8 @@
6
6
  "dev": "next dev",
7
7
  "build": "next build",
8
8
  "start": "next start",
9
- "lint": "eslint src/"
9
+ "lint": "eslint src/",
10
+ "generate:env": "node -e \"import('fs').then(f=>{if(f.existsSync('.env'))console.log('.env already exists, skipping');else{f.copyFileSync('.env.example','.env');console.log('.env created from .env.example')}})\""
10
11
  },
11
12
  "dependencies": {
12
13
  "@hookform/resolvers": "^5.2.2",
@@ -22,7 +22,8 @@
22
22
  "redis:flush": "tsx scripts/flush-redis.ts",
23
23
  "docker:up": "docker compose up -d",
24
24
  "docker:down": "docker compose down",
25
- "docker:logs": "docker compose logs -f"
25
+ "docker:logs": "docker compose logs -f",
26
+ "generate:env": "node -e \"import('fs').then(f=>{if(f.existsSync('.env'))console.log('.env already exists, skipping');else{f.copyFileSync('.env.example','.env');console.log('.env created from .env.example')}})\""
26
27
  },
27
28
  "prisma": {
28
29
  "seed": "tsx prisma/seed.ts"
@@ -1,8 +1,8 @@
1
1
  {
2
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.",
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
6
  "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
7
7
  },
8
8
  "auth": {
@@ -31,6 +31,10 @@
31
31
  {
32
32
  "key": "userId",
33
33
  "value": ""
34
+ },
35
+ {
36
+ "key": "targetUserId",
37
+ "value": ""
34
38
  }
35
39
  ],
36
40
  "item": [
@@ -358,6 +362,111 @@
358
362
  "name": "Admin",
359
363
  "description": "Admin-only endpoints. Requires ADMIN role. Auth inherited from collection root.",
360
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
+ },
361
470
  {
362
471
  "name": "List Blocked IPs",
363
472
  "request": {
@@ -384,14 +493,14 @@
384
493
  ],
385
494
  "body": {
386
495
  "mode": "raw",
387
- "raw": "{\n \"ip\": \"1.2.3.4\"\n}"
496
+ "raw": "{\n \"ip\": \"1.2.3.4\",\n \"reason\": \"Spam bot\"\n}"
388
497
  },
389
498
  "url": {
390
499
  "raw": "{{baseUrl}}/admin/blocked-ips",
391
500
  "host": ["{{baseUrl}}"],
392
501
  "path": ["admin", "blocked-ips"]
393
502
  },
394
- "description": "Permanently block an IP address. Blocked IPs receive 403 IP_BLOCKED on all requests."
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."
395
504
  },
396
505
  "response": []
397
506
  },
@@ -1,6 +1,6 @@
1
1
  {
2
- "id": "tigra-server-env",
3
- "name": "Tigra Server - Local",
2
+ "id": "{{PROJECT_NAME}}-server-env",
3
+ "name": "{{PROJECT_DISPLAY_NAME}} Server - Local",
4
4
  "values": [
5
5
  {
6
6
  "key": "baseUrl",
@@ -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
+ }
@@ -21,7 +21,7 @@ import { adminRoutes } from '@modules/admin/admin.routes.js';
21
21
  import { fileStorageService } from '@libs/storage/file-storage.service.js';
22
22
  import { registerJobs } from '@jobs/index.js';
23
23
  import { RATE_LIMIT_ENABLED, getRateLimitRedisStore } from '@config/rate-limit.config.js';
24
- import { isIpBlocked, recordRateLimitViolation } from '@libs/ip-block.js';
24
+ import { isIpBlocked, recordRateLimitViolation, syncBlockedIpsToRedis } from '@libs/ip-block.js';
25
25
  import { ForbiddenError } from '@shared/errors/errors.js';
26
26
  import {
27
27
  serializerCompiler,
@@ -135,6 +135,9 @@ export async function buildApp() {
135
135
  // Initialize file storage (create directories)
136
136
  await fileStorageService.initialize();
137
137
 
138
+ // --- Sync permanent IP blocks from DB to Redis ---
139
+ await syncBlockedIpsToRedis();
140
+
138
141
  // --- IP Block Check (runs before everything else) ---
139
142
  app.addHook('onRequest', async (request: FastifyRequest) => {
140
143
  if (await isIpBlocked(request.ip)) {
@@ -2,7 +2,7 @@
2
2
  * Cleanup Deleted Accounts Job
3
3
  *
4
4
  * Permanently purges soft-deleted user accounts after a 30-day retention period.
5
- * Deletes avatar files from disk and hard-deletes the user record (cascades to
5
+ * Deletes all user media from disk and hard-deletes the user record (cascades to
6
6
  * refresh tokens and sessions via onDelete: Cascade).
7
7
  *
8
8
  * Runs once daily.
@@ -32,7 +32,6 @@ export function startCleanupDeletedAccountsJob(app: FastifyInstance): void {
32
32
  },
33
33
  select: {
34
34
  id: true,
35
- avatarUrl: true,
36
35
  },
37
36
  });
38
37
 
@@ -49,10 +48,8 @@ export function startCleanupDeletedAccountsJob(app: FastifyInstance): void {
49
48
 
50
49
  for (const user of usersToDelete) {
51
50
  try {
52
- // Delete avatar files from disk if they exist
53
- if (user.avatarUrl) {
54
- await fileStorageService.deleteAvatar(user.id);
55
- }
51
+ // Delete all user media from disk (no-op if dir doesn't exist)
52
+ await fileStorageService.deleteUserMedia(user.id);
56
53
 
57
54
  // Hard delete user record (cascades to RefreshToken + Session)
58
55
  await prisma.user.delete({
@@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2
2
  import { v4 as uuidv4 } from 'uuid';
3
3
  import { env } from '@config/env.js';
4
4
  import { prisma } from '@libs/prisma.js';
5
- import { UnauthorizedError, ForbiddenError } from '@shared/errors/errors.js';
5
+ import { UnauthorizedError, ForbiddenError, BadRequestError } from '@shared/errors/errors.js';
6
6
  import type { JwtPayload, UserRole } from '@shared/types/index.js';
7
7
 
8
8
  let app: FastifyInstance | null = null;
@@ -97,3 +97,47 @@ export function authorize(...roles: UserRole[]) {
97
97
  };
98
98
  }
99
99
 
100
+ /**
101
+ * Middleware: resolves targetUserId from the authenticated user's JWT.
102
+ * Used for /users/me routes.
103
+ * Must run AFTER authenticate.
104
+ */
105
+ export async function resolveMe(
106
+ request: FastifyRequest,
107
+ _reply: FastifyReply,
108
+ ): Promise<void> {
109
+ request.targetUserId = request.user.userId;
110
+ request.isAdminAction = false;
111
+ }
112
+
113
+ /**
114
+ * Middleware: resolves targetUserId from :userId param.
115
+ * Allows access if the authenticated user is the owner OR has ADMIN role.
116
+ * Must run AFTER authenticate.
117
+ *
118
+ * Sets:
119
+ * - request.targetUserId: the resolved user ID from params
120
+ * - request.isAdminAction: true if admin is acting on a different user
121
+ */
122
+ export async function resolveTargetUser(
123
+ request: FastifyRequest,
124
+ _reply: FastifyReply,
125
+ ): Promise<void> {
126
+ const params = request.params as { userId?: string };
127
+ const targetUserId = params.userId;
128
+
129
+ if (!targetUserId) {
130
+ throw new BadRequestError('Missing userId parameter', 'MISSING_USER_ID');
131
+ }
132
+
133
+ const isOwner = request.user.userId === targetUserId;
134
+ const isAdmin = request.user.role === 'ADMIN';
135
+
136
+ if (!isOwner && !isAdmin) {
137
+ throw new ForbiddenError('You do not have permission to perform this action');
138
+ }
139
+
140
+ request.targetUserId = targetUserId;
141
+ request.isAdminAction = !isOwner && isAdmin;
142
+ }
143
+
@@ -1,18 +1,22 @@
1
1
  /**
2
2
  * IP Blocking Service
3
3
  *
4
- * Redis-backed IP blocking with two tiers:
5
- * - Permanent blocks: Redis SET (admin-managed via API)
4
+ * Two-tier IP blocking:
5
+ * - Permanent blocks: DB (source of truth) + Redis SET (hot cache). Admin-managed via API.
6
6
  * - Auto-blocks: Redis ZSET with expiry timestamps (triggered by excessive rate-limit violations)
7
7
  *
8
8
  * Design decisions:
9
+ * - DB is the source of truth for permanent blocks — survives Redis restarts
10
+ * - Redis is the hot cache — all runtime checks hit Redis only (O(1))
11
+ * - On server boot, syncBlockedIpsToRedis() loads all permanent blocks from DB into Redis
9
12
  * - Fails open: if Redis is down, requests are NOT blocked (availability > security for rate limiting)
10
- * - O(1) lookups: both SISMEMBER and ZSCORE are constant-time operations
11
13
  * - Lazy cleanup: expired auto-blocks are removed on check, no separate cleanup job needed
12
14
  */
13
15
 
16
+ import { prisma } from '@libs/prisma.js';
14
17
  import { getRedis } from '@libs/redis.js';
15
18
  import { logger } from '@libs/logger.js';
19
+ import { ConflictError } from '@shared/errors/errors.js';
16
20
 
17
21
  // Redis keys
18
22
  const BLOCKED_IPS_KEY = 'blocked_ips';
@@ -24,6 +28,30 @@ const AUTO_BLOCK_THRESHOLD = 10; // violations before auto-block
24
28
  const AUTO_BLOCK_WINDOW_SECONDS = 300; // 5-minute sliding window
25
29
  const AUTO_BLOCK_DURATION_SECONDS = 3600; // block for 1 hour
26
30
 
31
+ /**
32
+ * Sync all permanent blocked IPs from DB to Redis.
33
+ * Called once during server startup.
34
+ */
35
+ export async function syncBlockedIpsToRedis(): Promise<void> {
36
+ try {
37
+ const blockedIps = await prisma.blockedIp.findMany({ select: { ip: true } });
38
+
39
+ const redis = getRedis();
40
+
41
+ // Clear stale Redis state and repopulate from DB
42
+ await redis.del(BLOCKED_IPS_KEY);
43
+
44
+ if (blockedIps.length > 0) {
45
+ await redis.sadd(BLOCKED_IPS_KEY, ...blockedIps.map((b) => b.ip));
46
+ }
47
+
48
+ logger.info(`[IP-BLOCK] Synced ${blockedIps.length} blocked IPs from DB to Redis`);
49
+ } catch (error) {
50
+ logger.warn('[IP-BLOCK] Failed to sync blocked IPs from DB to Redis — permanent blocks may not be enforced until next restart');
51
+ logger.debug(error);
52
+ }
53
+ }
54
+
27
55
  /**
28
56
  * Check if an IP is blocked (permanent or auto-blocked).
29
57
  *
@@ -57,51 +85,84 @@ export async function isIpBlocked(ip: string): Promise<boolean> {
57
85
  }
58
86
 
59
87
  /**
60
- * Add an IP to the permanent block list (admin-managed).
88
+ * Block an IP permanently. Writes to DB (source of truth) + Redis (hot cache).
61
89
  *
62
90
  * @param ip - IP address to block
91
+ * @param blockedBy - Admin user ID who initiated the block
92
+ * @param reason - Optional reason for the block
63
93
  */
64
- export async function blockIp(ip: string): Promise<void> {
65
- const redis = getRedis();
66
- await redis.sadd(BLOCKED_IPS_KEY, ip);
67
- logger.info({ ip }, '[IP-BLOCK] IP permanently blocked');
94
+ export async function blockIp(
95
+ ip: string,
96
+ blockedBy: string,
97
+ reason?: string,
98
+ ): Promise<{ id: string; ip: string; reason: string | null; blockedBy: string; createdAt: Date }> {
99
+ // Write to DB (source of truth)
100
+ const existing = await prisma.blockedIp.findUnique({ where: { ip } });
101
+ if (existing) {
102
+ throw new ConflictError('IP is already blocked', 'IP_ALREADY_BLOCKED');
103
+ }
104
+
105
+ const blocked = await prisma.blockedIp.create({
106
+ data: { ip, blockedBy, reason: reason ?? null },
107
+ });
108
+
109
+ // Sync to Redis cache
110
+ try {
111
+ const redis = getRedis();
112
+ await redis.sadd(BLOCKED_IPS_KEY, ip);
113
+ } catch {
114
+ logger.warn({ ip }, '[IP-BLOCK] Failed to sync block to Redis — will be synced on next restart');
115
+ }
116
+
117
+ logger.info({ ip, blockedBy, reason }, '[IP-BLOCK] IP permanently blocked');
118
+ return blocked;
68
119
  }
69
120
 
70
121
  /**
71
- * Remove an IP from the permanent block list.
122
+ * Unblock an IP. Removes from DB + Redis + auto-block list.
72
123
  *
73
124
  * @param ip - IP address to unblock
74
125
  */
75
126
  export async function unblockIp(ip: string): Promise<void> {
76
- const redis = getRedis();
77
- await redis.srem(BLOCKED_IPS_KEY, ip);
78
- // Also remove from auto-block list if present
79
- await redis.zrem(AUTO_BLOCKED_KEY, ip);
127
+ // Remove from DB
128
+ await prisma.blockedIp.deleteMany({ where: { ip } });
129
+
130
+ // Remove from Redis (both permanent and auto-block)
131
+ try {
132
+ const redis = getRedis();
133
+ await redis.srem(BLOCKED_IPS_KEY, ip);
134
+ await redis.zrem(AUTO_BLOCKED_KEY, ip);
135
+ } catch {
136
+ logger.warn({ ip }, '[IP-BLOCK] Failed to sync unblock to Redis — will be synced on next restart');
137
+ }
138
+
80
139
  logger.info({ ip }, '[IP-BLOCK] IP unblocked');
81
140
  }
82
141
 
83
142
  /**
84
- * List all currently blocked IPs (permanent + active auto-blocks).
85
- *
86
- * @returns Object with permanent and autoBlocked arrays
143
+ * List all currently blocked IPs (permanent from DB + active auto-blocks from Redis).
87
144
  */
88
145
  export async function getBlockedIps(): Promise<{
89
- permanent: string[];
146
+ permanent: { id: string; ip: string; reason: string | null; blockedBy: string; createdAt: Date }[];
90
147
  autoBlocked: string[];
91
148
  }> {
92
- const redis = getRedis();
93
- const nowSeconds = Date.now() / 1000;
94
-
95
- const permanent = await redis.smembers(BLOCKED_IPS_KEY);
96
-
97
- // Get auto-blocked IPs that haven't expired yet
98
- const autoBlockedWithScores = await redis.zrangebyscore(
99
- AUTO_BLOCKED_KEY,
100
- nowSeconds,
101
- '+inf',
102
- );
149
+ // Permanent blocks from DB (source of truth)
150
+ const permanent = await prisma.blockedIp.findMany({
151
+ select: { id: true, ip: true, reason: true, blockedBy: true, createdAt: true },
152
+ orderBy: { createdAt: 'desc' },
153
+ });
154
+
155
+ // Auto-blocks from Redis
156
+ let autoBlocked: string[] = [];
157
+ try {
158
+ const redis = getRedis();
159
+ const nowSeconds = Date.now() / 1000;
160
+ autoBlocked = await redis.zrangebyscore(AUTO_BLOCKED_KEY, nowSeconds, '+inf');
161
+ } catch {
162
+ logger.warn('[IP-BLOCK] Redis unavailable, cannot retrieve auto-blocked IPs');
163
+ }
103
164
 
104
- return { permanent, autoBlocked: autoBlockedWithScores };
165
+ return { permanent, autoBlocked };
105
166
  }
106
167
 
107
168
  /**
@@ -29,7 +29,7 @@ function getStatusColor(statusCode: number): string {
29
29
  }
30
30
 
31
31
  function formatDuration(ms: number): string {
32
- if (ms < 1) return `${(ms * 1000).toFixed(0)}μs`;
32
+ if (ms < 1) return `${(ms * 1000).toFixed(0)}us`;
33
33
  if (ms < 1000) return `${ms.toFixed(0)}ms`;
34
34
  return `${(ms / 1000).toFixed(2)}s`;
35
35
  }