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.
Files changed (53) hide show
  1. package/README.md +10 -3
  2. package/bin/create-tigra.js +77 -37
  3. package/package.json +5 -5
  4. package/template/_claude/commands/create-server.md +8 -2
  5. package/template/_claude/rules/client/01-project-structure.md +12 -0
  6. package/template/_claude/rules/client/03-data-and-state.md +1 -1
  7. package/template/_claude/rules/client/04-design-system.md +23 -0
  8. package/template/_claude/rules/client/07-deployment.md +99 -0
  9. package/template/_claude/rules/client/08-lockfile-cross-platform.md +79 -0
  10. package/template/_claude/rules/client/core.md +1 -0
  11. package/template/_claude/rules/global/core.md +20 -1
  12. package/template/_claude/rules/global/investigation-before-conclusions.md +57 -0
  13. package/template/_claude/rules/server/core.md +2 -0
  14. package/template/_claude/rules/server/deployment.md +78 -0
  15. package/template/client/next.config.ts +12 -2
  16. package/template/client/package-lock.json +12345 -0
  17. package/template/client/package.json +3 -2
  18. package/template/client/src/components/common/SafeImage.tsx +2 -1
  19. package/template/client/src/lib/api/axios.config.ts +19 -4
  20. package/template/client/src/middleware.ts +7 -0
  21. package/template/gitignore +1 -0
  22. package/template/server/.env.example +248 -194
  23. package/template/server/.env.example.production +221 -168
  24. package/template/server/Dockerfile +29 -5
  25. package/template/server/docker-compose.yml +32 -4
  26. package/template/server/package-lock.json +6544 -6823
  27. package/template/server/package.json +76 -75
  28. package/template/server/prisma/seed.ts +20 -4
  29. package/template/server/src/app.ts +316 -271
  30. package/template/server/src/config/env.ts +150 -99
  31. package/template/server/src/config/rate-limit.config.ts +16 -0
  32. package/template/server/src/libs/__tests__/auth-path.test.ts +24 -0
  33. package/template/server/src/libs/__tests__/client-ip.test.ts +121 -0
  34. package/template/server/src/libs/__tests__/http.test.ts +23 -9
  35. package/template/server/src/libs/__tests__/ip-block.test.ts +62 -0
  36. package/template/server/src/libs/__tests__/origin-check.test.ts +53 -0
  37. package/template/server/src/libs/__tests__/url-safety.test.ts +80 -0
  38. package/template/server/src/libs/auth-path.ts +14 -0
  39. package/template/server/src/libs/auth.ts +6 -16
  40. package/template/server/src/libs/client-ip.ts +77 -0
  41. package/template/server/src/libs/cookies.ts +1 -1
  42. package/template/server/src/libs/duration.ts +30 -0
  43. package/template/server/src/libs/ip-block.ts +220 -206
  44. package/template/server/src/libs/origin-check.ts +38 -0
  45. package/template/server/src/libs/query-counter.ts +59 -0
  46. package/template/server/src/libs/redis.ts +1 -1
  47. package/template/server/src/libs/url-safety.ts +121 -0
  48. package/template/server/src/modules/auth/__tests__/auth.service.test.ts +274 -44
  49. package/template/server/src/modules/auth/auth.controller.ts +128 -127
  50. package/template/server/src/modules/auth/auth.repo.ts +2 -0
  51. package/template/server/src/modules/auth/auth.service.ts +103 -12
  52. package/template/server/src/test/setup.ts +22 -2
  53. package/template/server/vitest.config.ts +43 -43
@@ -1,206 +1,220 @@
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
- }
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 { env } from '@config/env.js';
17
+ import { prisma } from '@libs/prisma.js';
18
+ import { getRedis } from '@libs/redis.js';
19
+ import { logger } from '@libs/logger.js';
20
+ import { ConflictError } from '@shared/errors/errors.js';
21
+ import { isPrivateOrReservedIp } from '@libs/url-safety.js';
22
+
23
+ // Redis keys
24
+ const BLOCKED_IPS_KEY = 'blocked_ips';
25
+ const AUTO_BLOCKED_KEY = 'auto_blocked_ips';
26
+ const VIOLATION_PREFIX = 'rl_violations:';
27
+
28
+ // Auto-block thresholds env-configurable (see .env.example).
29
+ // The threshold default is deliberately high (20): auto-block must catch
30
+ // SUSTAINED abuse, not a single burst. Rate-limit counting happens before
31
+ // validation, so a retry-looping but legitimate client (or a NAT'd office
32
+ // sharing one egress IP) can rack up violations quickly — see the self-ban
33
+ // interaction note in src/config/rate-limit.config.ts before lowering it.
34
+ const AUTO_BLOCK_THRESHOLD = env.IP_AUTO_BLOCK_THRESHOLD; // violations before auto-block (default 20)
35
+ const AUTO_BLOCK_WINDOW_SECONDS = env.IP_AUTO_BLOCK_WINDOW_SECONDS; // sliding window (default 300 = 5 min)
36
+ const AUTO_BLOCK_DURATION_SECONDS = env.IP_AUTO_BLOCK_DURATION_SECONDS; // block duration (default 3600 = 1 hour)
37
+
38
+ /**
39
+ * Sync all permanent blocked IPs from DB to Redis.
40
+ * Called once during server startup.
41
+ */
42
+ export async function syncBlockedIpsToRedis(): Promise<void> {
43
+ try {
44
+ const blockedIps = await prisma.blockedIp.findMany({ select: { ip: true } });
45
+
46
+ const redis = getRedis();
47
+
48
+ // Clear stale Redis state and repopulate from DB
49
+ await redis.del(BLOCKED_IPS_KEY);
50
+
51
+ if (blockedIps.length > 0) {
52
+ await redis.sadd(BLOCKED_IPS_KEY, ...blockedIps.map((b) => b.ip));
53
+ }
54
+
55
+ logger.info(`[IP-BLOCK] Synced ${blockedIps.length} blocked IPs from DB to Redis`);
56
+ } catch (error) {
57
+ logger.warn('[IP-BLOCK] Failed to sync blocked IPs from DB to Redis — permanent blocks may not be enforced until next restart');
58
+ logger.debug(error);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Check if an IP is blocked (permanent or auto-blocked).
64
+ *
65
+ * @param ip - IP address to check
66
+ * @returns true if blocked, false otherwise (including Redis failures)
67
+ */
68
+ export async function isIpBlocked(ip: string): Promise<boolean> {
69
+ try {
70
+ const redis = getRedis();
71
+
72
+ // Check permanent block list
73
+ const permanent = await redis.sismember(BLOCKED_IPS_KEY, ip);
74
+ if (permanent === 1) return true;
75
+
76
+ // Check auto-block list (score = expiry Unix timestamp)
77
+ const score = await redis.zscore(AUTO_BLOCKED_KEY, ip);
78
+ if (score) {
79
+ const expiresAt = Number(score);
80
+ if (expiresAt > Date.now() / 1000) return true;
81
+
82
+ // Expired clean up lazily
83
+ await redis.zrem(AUTO_BLOCKED_KEY, ip);
84
+ }
85
+
86
+ return false;
87
+ } catch {
88
+ // Fail open: if Redis is down, don't block
89
+ logger.warn('[IP-BLOCK] Redis unavailable, skipping IP block check');
90
+ return false;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Block an IP permanently. Writes to DB (source of truth) + Redis (hot cache).
96
+ *
97
+ * @param ip - IP address to block
98
+ * @param blockedBy - Admin user ID who initiated the block
99
+ * @param reason - Optional reason for the block
100
+ */
101
+ export async function blockIp(
102
+ ip: string,
103
+ blockedBy: string,
104
+ reason?: string,
105
+ ): Promise<{ id: string; ip: string; reason: string | null; blockedBy: string; createdAt: Date }> {
106
+ // Write to DB (source of truth)
107
+ const existing = await prisma.blockedIp.findUnique({ where: { ip } });
108
+ if (existing) {
109
+ throw new ConflictError('IP is already blocked', 'IP_ALREADY_BLOCKED');
110
+ }
111
+
112
+ const blocked = await prisma.blockedIp.create({
113
+ data: { ip, blockedBy, reason: reason ?? null },
114
+ });
115
+
116
+ // Sync to Redis cache
117
+ try {
118
+ const redis = getRedis();
119
+ await redis.sadd(BLOCKED_IPS_KEY, ip);
120
+ } catch {
121
+ logger.warn({ ip }, '[IP-BLOCK] Failed to sync block to Redis — will be synced on next restart');
122
+ }
123
+
124
+ logger.info({ ip, blockedBy, reason }, '[IP-BLOCK] IP permanently blocked');
125
+ return blocked;
126
+ }
127
+
128
+ /**
129
+ * Unblock an IP. Removes from DB + Redis + auto-block list.
130
+ *
131
+ * @param ip - IP address to unblock
132
+ */
133
+ export async function unblockIp(ip: string): Promise<void> {
134
+ // Remove from DB
135
+ await prisma.blockedIp.deleteMany({ where: { ip } });
136
+
137
+ // Remove from Redis (both permanent and auto-block)
138
+ try {
139
+ const redis = getRedis();
140
+ await redis.srem(BLOCKED_IPS_KEY, ip);
141
+ await redis.zrem(AUTO_BLOCKED_KEY, ip);
142
+ } catch {
143
+ logger.warn({ ip }, '[IP-BLOCK] Failed to sync unblock to Redis will be synced on next restart');
144
+ }
145
+
146
+ logger.info({ ip }, '[IP-BLOCK] IP unblocked');
147
+ }
148
+
149
+ /**
150
+ * List all currently blocked IPs (permanent from DB + active auto-blocks from Redis).
151
+ */
152
+ export async function getBlockedIps(): Promise<{
153
+ permanent: { id: string; ip: string; reason: string | null; blockedBy: string; createdAt: Date }[];
154
+ autoBlocked: string[];
155
+ }> {
156
+ // Permanent blocks from DB (source of truth)
157
+ const permanent = await prisma.blockedIp.findMany({
158
+ select: { id: true, ip: true, reason: true, blockedBy: true, createdAt: true },
159
+ orderBy: { createdAt: 'desc' },
160
+ });
161
+
162
+ // Auto-blocks from Redis
163
+ let autoBlocked: string[] = [];
164
+ try {
165
+ const redis = getRedis();
166
+ const nowSeconds = Date.now() / 1000;
167
+ autoBlocked = await redis.zrangebyscore(AUTO_BLOCKED_KEY, nowSeconds, '+inf');
168
+ } catch {
169
+ logger.warn('[IP-BLOCK] Redis unavailable, cannot retrieve auto-blocked IPs');
170
+ }
171
+
172
+ return { permanent, autoBlocked };
173
+ }
174
+
175
+ /**
176
+ * Record a rate-limit violation for an IP.
177
+ *
178
+ * If the IP exceeds AUTO_BLOCK_THRESHOLD violations within
179
+ * AUTO_BLOCK_WINDOW_SECONDS, it gets auto-blocked for
180
+ * AUTO_BLOCK_DURATION_SECONDS.
181
+ *
182
+ * Called from the rate-limit `onExceeded` callback.
183
+ *
184
+ * @param ip - IP address that violated rate limit
185
+ */
186
+ export async function recordRateLimitViolation(ip: string): Promise<void> {
187
+ // Never auto-block infrastructure addresses (loopback / private / link-local /
188
+ // Traefik-Docker internal). A shared proxy IP getting auto-blocked would lock
189
+ // out every real user behind it. Public client IPs proceed as normal.
190
+ if (isPrivateOrReservedIp(ip)) {
191
+ return;
192
+ }
193
+
194
+ try {
195
+ const redis = getRedis();
196
+ const key = `${VIOLATION_PREFIX}${ip}`;
197
+
198
+ const count = await redis.incr(key);
199
+
200
+ // Set TTL on first violation (sliding window)
201
+ if (count === 1) {
202
+ await redis.expire(key, AUTO_BLOCK_WINDOW_SECONDS);
203
+ }
204
+
205
+ if (count >= AUTO_BLOCK_THRESHOLD) {
206
+ // Auto-block: add to ZSET with expiry timestamp as score
207
+ const expiresAt = Math.floor(Date.now() / 1000) + AUTO_BLOCK_DURATION_SECONDS;
208
+ await redis.zadd(AUTO_BLOCKED_KEY, expiresAt, ip);
209
+ await redis.del(key); // Reset violation counter
210
+
211
+ logger.warn(
212
+ { ip, violations: count, blockedForSeconds: AUTO_BLOCK_DURATION_SECONDS },
213
+ '[IP-BLOCK] Auto-blocked IP due to excessive rate-limit violations',
214
+ );
215
+ }
216
+ } catch {
217
+ // Non-critical: don't break the request if violation tracking fails
218
+ logger.warn('[IP-BLOCK] Failed to record rate-limit violation');
219
+ }
220
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * CSRF defense-in-depth — Origin verification for state-changing requests.
3
+ *
4
+ * With cross-origin cookie deployments (sameSite=none), browsers attach auth
5
+ * cookies to cross-site requests. CORS only protects *reading* responses — it
6
+ * does not stop the side effects of a forged POST/PUT/PATCH/DELETE. This check
7
+ * rejects browser-originated state-changing requests whose Origin header is
8
+ * neither the API itself (same-origin) nor a configured CORS origin.
9
+ *
10
+ * Deliberately conservative:
11
+ * - No Origin header → ALLOWED. Non-browser clients (curl, Postman,
12
+ * server-to-server) omit it, and they carry no ambient cookies, so they are
13
+ * not CSRF vectors. Browsers always send Origin on cross-site state-changing
14
+ * requests, which is the only case this defends against.
15
+ * - allowAllOrigins (development CORS) → ALLOWED.
16
+ */
17
+ export function isOriginAllowed(
18
+ origin: string | undefined,
19
+ requestHost: string | undefined,
20
+ allowedOrigins: ReadonlySet<string>,
21
+ allowAllOrigins: boolean,
22
+ ): boolean {
23
+ if (!origin) return true; // non-browser client — not a CSRF vector
24
+ if (allowAllOrigins) return true; // development: CORS allows all origins
25
+ if (allowedOrigins.has(origin)) return true; // configured cross-origin client
26
+
27
+ // Same-origin request: the Origin's host matches the request's Host header.
28
+ // Compared scheme-insensitively — TLS usually terminates at the reverse
29
+ // proxy, so the API may see the request as http while Origin says https.
30
+ // The Host header is lowercased before comparing: `new URL()` normalizes the
31
+ // Origin's host to lowercase, but the raw Host header arrives as-sent and
32
+ // host names are case-insensitive (RFC 9110).
33
+ try {
34
+ return requestHost !== undefined && new URL(origin).host === requestHost.toLowerCase();
35
+ } catch {
36
+ return false; // malformed Origin header — reject
37
+ }
38
+ }
@@ -0,0 +1,59 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import type { FastifyInstance } from 'fastify';
3
+ import { prisma } from './prisma.js';
4
+ import { env } from '@config/env.js';
5
+
6
+ /**
7
+ * Dev-only per-request Prisma query counter → the `X-Query-Count` response header.
8
+ *
9
+ * Rides the query events prisma.ts already emits (`{ emit: 'event', level: 'query' }`): an
10
+ * AsyncLocalStorage store is entered per request, every emitted query increments it, and the
11
+ * total ships back as `X-Query-Count`. This lets a black-box perf/load tester (the fleet's
12
+ * perf-tester) see N+1 at the source over plain HTTP — no DB access, no profiler needed.
13
+ *
14
+ * Note on Prisma N+1: the engine's DataLoader auto-batches relation `include`s and same-tick
15
+ * calls, so the queries this counts are the ones that actually matter — sequential awaited
16
+ * queries in a loop (`for (...) await prisma.x.find()`), the real application-level N+1.
17
+ *
18
+ * NEVER active in production: no listener, no header, zero overhead — purely a dev aid.
19
+ */
20
+ interface QueryCountStore {
21
+ count: number;
22
+ }
23
+
24
+ const als = new AsyncLocalStorage<QueryCountStore>();
25
+
26
+ let listenerAttached = false;
27
+ function attachQueryListener(): void {
28
+ if (listenerAttached) return;
29
+ listenerAttached = true;
30
+ // Queries outside a counted request (startup, jobs) simply find no store and are ignored.
31
+ prisma.$on('query' as never, () => {
32
+ const store = als.getStore();
33
+ if (store) store.count += 1;
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Wire the dev-only `X-Query-Count` header onto every route. Call once with the root app
39
+ * instance (top-level, so the hooks are global). No-op in production.
40
+ */
41
+ export function registerQueryCounter(app: FastifyInstance): void {
42
+ if (env.NODE_ENV === 'production') return;
43
+ attachQueryListener();
44
+
45
+ // Wrap the whole request lifecycle in a fresh store via the callback-style hook: passing
46
+ // Fastify's `done` continuation into als.run() means every subsequent hook, the handler, and
47
+ // the Prisma calls they make run inside this request's store (the @fastify/request-context
48
+ // pattern — more robust than enterWith, which can be lost across the hook boundary).
49
+ app.addHook('onRequest', (_request, _reply, done) => {
50
+ als.run({ count: 0 }, done);
51
+ });
52
+
53
+ // onSend can still mutate headers (runs before the body is flushed).
54
+ app.addHook('onSend', async (_request, reply, payload) => {
55
+ const store = als.getStore();
56
+ if (store) reply.header('X-Query-Count', String(store.count));
57
+ return payload;
58
+ });
59
+ }
@@ -10,7 +10,7 @@ export function getRedis(): Redis {
10
10
  maxRetriesPerRequest: env.REDIS_MAX_RETRIES,
11
11
  connectTimeout: env.REDIS_CONNECT_TIMEOUT,
12
12
  lazyConnect: true,
13
- retryStrategy: (times: number) => {
13
+ retryStrategy: (times: number): number | null => {
14
14
  // Exponential backoff with max delay of 3 seconds
15
15
  if (times > env.REDIS_MAX_RETRIES) {
16
16
  // Stop retrying after max retries