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
@@ -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'),
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Rate Limiting Configuration
3
+ *
4
+ * Single source of truth for all rate limit values.
5
+ * Routes import from here instead of hardcoding limits.
6
+ *
7
+ * ENV controls:
8
+ * - RATE_LIMIT_ENABLED: master switch (default: true)
9
+ * - RATE_LIMIT_MULTIPLIER: multiply all max values (default: 1, set 10 for dev)
10
+ * - RATE_LIMIT_AUTH_LOGIN_MAX: override login max
11
+ * - RATE_LIMIT_AUTH_REGISTER_MAX: override register max
12
+ */
13
+
14
+ import { env } from '@config/env.js';
15
+ import { getRedis } from '@libs/redis.js';
16
+ import { logger } from '@libs/logger.js';
17
+ import type { Redis } from 'ioredis';
18
+
19
+ // ─── Global settings ───────────────────────────────────────────
20
+
21
+ export const RATE_LIMIT_ENABLED = env.RATE_LIMIT_ENABLED;
22
+
23
+ const MULTIPLIER = env.RATE_LIMIT_MULTIPLIER;
24
+
25
+ /**
26
+ * Apply the global multiplier to a max value.
27
+ * Ensures minimum of 1 if rate limiting is enabled.
28
+ */
29
+ function applyMultiplier(max: number): number {
30
+ return Math.max(1, Math.round(max * MULTIPLIER));
31
+ }
32
+
33
+ // ─── Per-route configs ─────────────────────────────────────────
34
+
35
+ export const RATE_LIMITS = {
36
+ // Auth routes
37
+ AUTH_REGISTER: {
38
+ max: applyMultiplier(env.RATE_LIMIT_AUTH_REGISTER_MAX ?? 5),
39
+ timeWindow: '1 hour',
40
+ },
41
+ AUTH_LOGIN: {
42
+ max: applyMultiplier(env.RATE_LIMIT_AUTH_LOGIN_MAX ?? 10),
43
+ timeWindow: '15 minutes',
44
+ },
45
+ AUTH_LOGOUT: {
46
+ max: applyMultiplier(50),
47
+ timeWindow: '15 minutes',
48
+ },
49
+ AUTH_REFRESH: {
50
+ max: applyMultiplier(20),
51
+ timeWindow: '15 minutes',
52
+ },
53
+ AUTH_ME: {
54
+ max: applyMultiplier(60),
55
+ timeWindow: '1 minute',
56
+ },
57
+ AUTH_SESSIONS: {
58
+ max: applyMultiplier(30),
59
+ timeWindow: '1 minute',
60
+ },
61
+ AUTH_LOGOUT_ALL: {
62
+ max: applyMultiplier(10),
63
+ timeWindow: '15 minutes',
64
+ },
65
+
66
+ // Users — profile management
67
+ USERS_UPDATE_PROFILE: {
68
+ max: applyMultiplier(10),
69
+ timeWindow: '1 minute',
70
+ },
71
+ USERS_CHANGE_PASSWORD: {
72
+ max: applyMultiplier(5),
73
+ timeWindow: '1 minute',
74
+ },
75
+ USERS_DELETE_ACCOUNT: {
76
+ max: applyMultiplier(3),
77
+ timeWindow: '1 minute',
78
+ },
79
+
80
+ // Users — avatar management
81
+ USERS_UPLOAD_AVATAR: {
82
+ max: applyMultiplier(5),
83
+ timeWindow: '1 minute',
84
+ },
85
+ USERS_DELETE_AVATAR: {
86
+ max: applyMultiplier(10),
87
+ timeWindow: '1 minute',
88
+ },
89
+ USERS_GET_AVATAR: {
90
+ max: applyMultiplier(100),
91
+ timeWindow: '1 minute',
92
+ },
93
+
94
+ // Admin routes
95
+ ADMIN_DEFAULT: {
96
+ max: applyMultiplier(30),
97
+ timeWindow: '1 minute',
98
+ },
99
+ } as const;
100
+
101
+ // ─── Redis store helper ────────────────────────────────────────
102
+
103
+ /**
104
+ * Returns the Redis instance for @fastify/rate-limit's `redis` option.
105
+ * Returns undefined if Redis is not available (falls back to in-memory).
106
+ */
107
+ export function getRateLimitRedisStore(): Redis | undefined {
108
+ try {
109
+ return getRedis();
110
+ } catch {
111
+ logger.warn('[RATE-LIMIT] Redis unavailable, falling back to in-memory store');
112
+ return undefined;
113
+ }
114
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Cleanup Deleted Accounts Job
3
+ *
4
+ * Permanently purges soft-deleted user accounts after a 30-day retention period.
5
+ * Deletes all user media from disk and hard-deletes the user record (cascades to
6
+ * refresh tokens and sessions via onDelete: Cascade).
7
+ *
8
+ * Runs once daily.
9
+ */
10
+
11
+ import type { FastifyInstance } from 'fastify';
12
+ import { prisma } from '@libs/prisma.js';
13
+ import { logger } from '@libs/logger.js';
14
+ import { fileStorageService } from '@libs/storage/file-storage.service.js';
15
+
16
+ const INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
17
+ const RETENTION_DAYS = 30;
18
+
19
+ export function startCleanupDeletedAccountsJob(app: FastifyInstance): void {
20
+ const intervalId = setInterval(async () => {
21
+ try {
22
+ const cutoffDate = new Date();
23
+ cutoffDate.setDate(cutoffDate.getDate() - RETENTION_DAYS);
24
+
25
+ // Find users soft-deleted more than 30 days ago
26
+ const usersToDelete = await prisma.user.findMany({
27
+ where: {
28
+ deletedAt: {
29
+ not: null,
30
+ lt: cutoffDate,
31
+ },
32
+ },
33
+ select: {
34
+ id: true,
35
+ },
36
+ });
37
+
38
+ if (usersToDelete.length === 0) {
39
+ return;
40
+ }
41
+
42
+ logger.info(
43
+ { count: usersToDelete.length },
44
+ 'Starting purge of soft-deleted accounts',
45
+ );
46
+
47
+ let purgedCount = 0;
48
+
49
+ for (const user of usersToDelete) {
50
+ try {
51
+ // Delete all user media from disk (no-op if dir doesn't exist)
52
+ await fileStorageService.deleteUserMedia(user.id);
53
+
54
+ // Hard delete user record (cascades to RefreshToken + Session)
55
+ await prisma.user.delete({
56
+ where: { id: user.id },
57
+ });
58
+
59
+ purgedCount++;
60
+ } catch (error) {
61
+ logger.error(
62
+ { err: error, userId: user.id },
63
+ 'Failed to purge deleted account',
64
+ );
65
+ }
66
+ }
67
+
68
+ logger.info(
69
+ { purgedCount, totalFound: usersToDelete.length },
70
+ 'Deleted accounts purge complete',
71
+ );
72
+ } catch (error) {
73
+ logger.error({ err: error }, 'Failed to run deleted accounts cleanup');
74
+ }
75
+ }, INTERVAL_MS);
76
+
77
+ app.addHook('onClose', async () => {
78
+ clearInterval(intervalId);
79
+ });
80
+ }
@@ -1,10 +1,17 @@
1
+ /**
2
+ * Cleanup Expired Auth Records Job
3
+ *
4
+ * Periodically removes expired refresh tokens and sessions from the database.
5
+ * Runs every hour.
6
+ */
7
+
1
8
  import type { FastifyInstance } from 'fastify';
2
9
  import { prisma } from '@libs/prisma.js';
3
10
  import { logger } from '@libs/logger.js';
4
11
 
5
- const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
12
+ const INTERVAL_MS = 60 * 60 * 1000; // 1 hour
6
13
 
7
- export async function registerCleanupJob(app: FastifyInstance): Promise<void> {
14
+ export function startCleanupExpiredAuthJob(app: FastifyInstance): void {
8
15
  const intervalId = setInterval(async () => {
9
16
  try {
10
17
  const now = new Date();
@@ -26,9 +33,8 @@ export async function registerCleanupJob(app: FastifyInstance): Promise<void> {
26
33
  } catch (error) {
27
34
  logger.error({ err: error }, 'Failed to clean up expired auth records');
28
35
  }
29
- }, CLEANUP_INTERVAL_MS);
36
+ }, INTERVAL_MS);
30
37
 
31
- // Clear interval on server shutdown
32
38
  app.addHook('onClose', async () => {
33
39
  clearInterval(intervalId);
34
40
  });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Jobs Registry
3
+ *
4
+ * Registers all background jobs with the Fastify instance.
5
+ * Each job manages its own interval and cleanup via onClose hook.
6
+ */
7
+
8
+ import type { FastifyInstance } from 'fastify';
9
+ import { startCleanupExpiredAuthJob } from './cleanup-expired-auth.job.js';
10
+ import { startCleanupDeletedAccountsJob } from './cleanup-deleted-accounts.job.js';
11
+
12
+ /**
13
+ * Registers all background jobs
14
+ *
15
+ * @param app - Fastify instance (used for onClose cleanup hooks)
16
+ */
17
+ export function registerJobs(app: FastifyInstance): void {
18
+ startCleanupExpiredAuthJob(app);
19
+ startCleanupDeletedAccountsJob(app);
20
+ }
@@ -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
+
@@ -0,0 +1,206 @@
1
+ /**
2
+ * IP Blocking Service
3
+ *
4
+ * Two-tier IP blocking:
5
+ * - Permanent blocks: DB (source of truth) + Redis SET (hot cache). Admin-managed via API.
6
+ * - Auto-blocks: Redis ZSET with expiry timestamps (triggered by excessive rate-limit violations)
7
+ *
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
12
+ * - Fails open: if Redis is down, requests are NOT blocked (availability > security for rate limiting)
13
+ * - Lazy cleanup: expired auto-blocks are removed on check, no separate cleanup job needed
14
+ */
15
+
16
+ import { prisma } from '@libs/prisma.js';
17
+ import { getRedis } from '@libs/redis.js';
18
+ import { logger } from '@libs/logger.js';
19
+ import { ConflictError } from '@shared/errors/errors.js';
20
+
21
+ // Redis keys
22
+ const BLOCKED_IPS_KEY = 'blocked_ips';
23
+ const AUTO_BLOCKED_KEY = 'auto_blocked_ips';
24
+ const VIOLATION_PREFIX = 'rl_violations:';
25
+
26
+ // Auto-block thresholds
27
+ const AUTO_BLOCK_THRESHOLD = 10; // violations before auto-block
28
+ const AUTO_BLOCK_WINDOW_SECONDS = 300; // 5-minute sliding window
29
+ const AUTO_BLOCK_DURATION_SECONDS = 3600; // block for 1 hour
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
+
55
+ /**
56
+ * Check if an IP is blocked (permanent or auto-blocked).
57
+ *
58
+ * @param ip - IP address to check
59
+ * @returns true if blocked, false otherwise (including Redis failures)
60
+ */
61
+ export async function isIpBlocked(ip: string): Promise<boolean> {
62
+ try {
63
+ const redis = getRedis();
64
+
65
+ // Check permanent block list
66
+ const permanent = await redis.sismember(BLOCKED_IPS_KEY, ip);
67
+ if (permanent === 1) return true;
68
+
69
+ // Check auto-block list (score = expiry Unix timestamp)
70
+ const score = await redis.zscore(AUTO_BLOCKED_KEY, ip);
71
+ if (score) {
72
+ const expiresAt = Number(score);
73
+ if (expiresAt > Date.now() / 1000) return true;
74
+
75
+ // Expired — clean up lazily
76
+ await redis.zrem(AUTO_BLOCKED_KEY, ip);
77
+ }
78
+
79
+ return false;
80
+ } catch {
81
+ // Fail open: if Redis is down, don't block
82
+ logger.warn('[IP-BLOCK] Redis unavailable, skipping IP block check');
83
+ return false;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Block an IP permanently. Writes to DB (source of truth) + Redis (hot cache).
89
+ *
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
93
+ */
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;
119
+ }
120
+
121
+ /**
122
+ * Unblock an IP. Removes from DB + Redis + auto-block list.
123
+ *
124
+ * @param ip - IP address to unblock
125
+ */
126
+ export async function unblockIp(ip: string): Promise<void> {
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
+
139
+ logger.info({ ip }, '[IP-BLOCK] IP unblocked');
140
+ }
141
+
142
+ /**
143
+ * List all currently blocked IPs (permanent from DB + active auto-blocks from Redis).
144
+ */
145
+ export async function getBlockedIps(): Promise<{
146
+ permanent: { id: string; ip: string; reason: string | null; blockedBy: string; createdAt: Date }[];
147
+ autoBlocked: string[];
148
+ }> {
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
+ }
164
+
165
+ return { permanent, autoBlocked };
166
+ }
167
+
168
+ /**
169
+ * Record a rate-limit violation for an IP.
170
+ *
171
+ * If the IP exceeds AUTO_BLOCK_THRESHOLD violations within
172
+ * AUTO_BLOCK_WINDOW_SECONDS, it gets auto-blocked for
173
+ * AUTO_BLOCK_DURATION_SECONDS.
174
+ *
175
+ * Called from the rate-limit `onExceeded` callback.
176
+ *
177
+ * @param ip - IP address that violated rate limit
178
+ */
179
+ export async function recordRateLimitViolation(ip: string): Promise<void> {
180
+ try {
181
+ const redis = getRedis();
182
+ const key = `${VIOLATION_PREFIX}${ip}`;
183
+
184
+ const count = await redis.incr(key);
185
+
186
+ // Set TTL on first violation (sliding window)
187
+ if (count === 1) {
188
+ await redis.expire(key, AUTO_BLOCK_WINDOW_SECONDS);
189
+ }
190
+
191
+ if (count >= AUTO_BLOCK_THRESHOLD) {
192
+ // Auto-block: add to ZSET with expiry timestamp as score
193
+ const expiresAt = Math.floor(Date.now() / 1000) + AUTO_BLOCK_DURATION_SECONDS;
194
+ await redis.zadd(AUTO_BLOCKED_KEY, expiresAt, ip);
195
+ await redis.del(key); // Reset violation counter
196
+
197
+ logger.warn(
198
+ { ip, violations: count, blockedForSeconds: AUTO_BLOCK_DURATION_SECONDS },
199
+ '[IP-BLOCK] Auto-blocked IP due to excessive rate-limit violations',
200
+ );
201
+ }
202
+ } catch {
203
+ // Non-critical: don't break the request if violation tracking fails
204
+ logger.warn('[IP-BLOCK] Failed to record rate-limit violation');
205
+ }
206
+ }
@@ -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
  }