create-tigra 3.0.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.
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Client IP resolution — Cloudflare- and reverse-proxy-aware.
3
+ *
4
+ * Behind a Cloudflare proxy (or any reverse proxy such as Traefik/Coolify),
5
+ * Fastify's `request.ip` (derived from the socket / X-Forwarded-For) resolves
6
+ * to an *edge* IP, not the real visitor. Every request through one edge node
7
+ * then shares a single IP, which collapses the per-IP rate limiter and the IP
8
+ * auto-block onto that shared edge address: one abusive client can rate-limit
9
+ * or self-ban every legitimate user behind the same edge, and conversely abuse
10
+ * from many clients hides behind one IP.
11
+ *
12
+ * Resolution is ordered, and every header value is VALIDATED as a real IPv4/IPv6
13
+ * literal (via `node:net.isIP`) before it is trusted — a junk or spoofed header
14
+ * falls through to the next tier instead of becoming the rate-limit key:
15
+ *
16
+ * 1. `CF-Connecting-IP` — the genuine client IP Cloudflare injects. Consulted
17
+ * ONLY when `TRUST_CLOUDFLARE` is enabled (orange-cloud / proxied origin).
18
+ * 2. Left-most VALID `X-Forwarded-For` entry — the original client, appended by
19
+ * the reverse proxy. XFF is a comma-separated list (client, proxy1, proxy2,
20
+ * ...), so the original client is the left-most entry. This tier is consulted
21
+ * regardless of the flag: it covers the grey-cloud / DNS-only case where the
22
+ * Cloudflare header is absent but traffic still arrives through Traefik —
23
+ * without it, every grey-clouded visitor collapses onto the single shared
24
+ * Traefik upstream IP and trips the auto-block site-wide.
25
+ * 3. `request.ip` — Fastify's socket / trustProxy-resolved peer IP (always
26
+ * non-empty), the safe fallback for any origin reachable directly.
27
+ *
28
+ * SECURITY PRECONDITION: both `CF-Connecting-IP` and `X-Forwarded-For` are
29
+ * client-supplied and therefore SPOOFABLE if a caller can reach the origin
30
+ * directly. They are only trustworthy when the origin accepts traffic
31
+ * EXCLUSIVELY via the proxy (Cloudflare / Traefik) — a documented deploy
32
+ * precondition. Validation here narrows the spoof surface to well-formed IP
33
+ * literals but does not, on its own, prove provenance.
34
+ *
35
+ * Use this for rate-limiting and IP-blocking decisions. Logging / session
36
+ * metadata can keep using `request.ip` — there a spoofed value is harmless.
37
+ */
38
+
39
+ import { isIP } from 'node:net';
40
+ import type { FastifyRequest } from 'fastify';
41
+ import { env } from '@config/env.js';
42
+
43
+ /** Return the trimmed value if it is a valid IPv4/IPv6 literal, else undefined. */
44
+ function asValidIp(value: string | undefined): string | undefined {
45
+ if (!value) return undefined;
46
+ const trimmed = value.trim();
47
+ return isIP(trimmed) !== 0 ? trimmed : undefined;
48
+ }
49
+
50
+ /**
51
+ * Resolve the real client IP for rate-limiting / blocking decisions.
52
+ *
53
+ * Synchronous and always returns a non-empty string (the `request.ip` fallback
54
+ * guarantees this), so it is safe to use directly as a rate-limit `keyGenerator`.
55
+ *
56
+ * @param request - The incoming Fastify request
57
+ * @returns The client IP to use as the rate-limit / block key
58
+ */
59
+ export function getClientIp(request: FastifyRequest): string {
60
+ // 1. CF-Connecting-IP — only when TRUST_CLOUDFLARE is set (origin locked to CF).
61
+ if (env.TRUST_CLOUDFLARE) {
62
+ const cfHeader = request.headers['cf-connecting-ip'];
63
+ const cf = asValidIp(typeof cfHeader === 'string' ? cfHeader : undefined);
64
+ if (cf) return cf;
65
+ }
66
+ // 2. Left-most VALID X-Forwarded-For entry — the original client, appended by the
67
+ // reverse proxy (Traefik/Coolify). Covers grey-cloud / DNS-only where the CF
68
+ // header is absent. Validated so a junk/spoofed header falls through.
69
+ const xffRaw = request.headers['x-forwarded-for'];
70
+ const xff = typeof xffRaw === 'string' ? xffRaw : Array.isArray(xffRaw) ? xffRaw[0] : undefined;
71
+ if (xff) {
72
+ const leftMost = asValidIp(xff.split(',')[0]);
73
+ if (leftMost) return leftMost;
74
+ }
75
+ // 3. Fastify's socket/trustProxy-resolved peer IP (always non-empty).
76
+ return request.ip;
77
+ }
@@ -1,212 +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 { 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
-
22
- // Redis keys
23
- const BLOCKED_IPS_KEY = 'blocked_ips';
24
- const AUTO_BLOCKED_KEY = 'auto_blocked_ips';
25
- const VIOLATION_PREFIX = 'rl_violations:';
26
-
27
- // Auto-block thresholds — env-configurable (see .env.example).
28
- // The threshold default is deliberately high (20): auto-block must catch
29
- // SUSTAINED abuse, not a single burst. Rate-limit counting happens before
30
- // validation, so a retry-looping but legitimate client (or a NAT'd office
31
- // sharing one egress IP) can rack up violations quickly see the self-ban
32
- // interaction note in src/config/rate-limit.config.ts before lowering it.
33
- const AUTO_BLOCK_THRESHOLD = env.IP_AUTO_BLOCK_THRESHOLD; // violations before auto-block (default 20)
34
- const AUTO_BLOCK_WINDOW_SECONDS = env.IP_AUTO_BLOCK_WINDOW_SECONDS; // sliding window (default 300 = 5 min)
35
- const AUTO_BLOCK_DURATION_SECONDS = env.IP_AUTO_BLOCK_DURATION_SECONDS; // block duration (default 3600 = 1 hour)
36
-
37
- /**
38
- * Sync all permanent blocked IPs from DB to Redis.
39
- * Called once during server startup.
40
- */
41
- export async function syncBlockedIpsToRedis(): Promise<void> {
42
- try {
43
- const blockedIps = await prisma.blockedIp.findMany({ select: { ip: true } });
44
-
45
- const redis = getRedis();
46
-
47
- // Clear stale Redis state and repopulate from DB
48
- await redis.del(BLOCKED_IPS_KEY);
49
-
50
- if (blockedIps.length > 0) {
51
- await redis.sadd(BLOCKED_IPS_KEY, ...blockedIps.map((b) => b.ip));
52
- }
53
-
54
- logger.info(`[IP-BLOCK] Synced ${blockedIps.length} blocked IPs from DB to Redis`);
55
- } catch (error) {
56
- logger.warn('[IP-BLOCK] Failed to sync blocked IPs from DB to Redis — permanent blocks may not be enforced until next restart');
57
- logger.debug(error);
58
- }
59
- }
60
-
61
- /**
62
- * Check if an IP is blocked (permanent or auto-blocked).
63
- *
64
- * @param ip - IP address to check
65
- * @returns true if blocked, false otherwise (including Redis failures)
66
- */
67
- export async function isIpBlocked(ip: string): Promise<boolean> {
68
- try {
69
- const redis = getRedis();
70
-
71
- // Check permanent block list
72
- const permanent = await redis.sismember(BLOCKED_IPS_KEY, ip);
73
- if (permanent === 1) return true;
74
-
75
- // Check auto-block list (score = expiry Unix timestamp)
76
- const score = await redis.zscore(AUTO_BLOCKED_KEY, ip);
77
- if (score) {
78
- const expiresAt = Number(score);
79
- if (expiresAt > Date.now() / 1000) return true;
80
-
81
- // Expired — clean up lazily
82
- await redis.zrem(AUTO_BLOCKED_KEY, ip);
83
- }
84
-
85
- return false;
86
- } catch {
87
- // Fail open: if Redis is down, don't block
88
- logger.warn('[IP-BLOCK] Redis unavailable, skipping IP block check');
89
- return false;
90
- }
91
- }
92
-
93
- /**
94
- * Block an IP permanently. Writes to DB (source of truth) + Redis (hot cache).
95
- *
96
- * @param ip - IP address to block
97
- * @param blockedBy - Admin user ID who initiated the block
98
- * @param reason - Optional reason for the block
99
- */
100
- export async function blockIp(
101
- ip: string,
102
- blockedBy: string,
103
- reason?: string,
104
- ): Promise<{ id: string; ip: string; reason: string | null; blockedBy: string; createdAt: Date }> {
105
- // Write to DB (source of truth)
106
- const existing = await prisma.blockedIp.findUnique({ where: { ip } });
107
- if (existing) {
108
- throw new ConflictError('IP is already blocked', 'IP_ALREADY_BLOCKED');
109
- }
110
-
111
- const blocked = await prisma.blockedIp.create({
112
- data: { ip, blockedBy, reason: reason ?? null },
113
- });
114
-
115
- // Sync to Redis cache
116
- try {
117
- const redis = getRedis();
118
- await redis.sadd(BLOCKED_IPS_KEY, ip);
119
- } catch {
120
- logger.warn({ ip }, '[IP-BLOCK] Failed to sync block to Redis — will be synced on next restart');
121
- }
122
-
123
- logger.info({ ip, blockedBy, reason }, '[IP-BLOCK] IP permanently blocked');
124
- return blocked;
125
- }
126
-
127
- /**
128
- * Unblock an IP. Removes from DB + Redis + auto-block list.
129
- *
130
- * @param ip - IP address to unblock
131
- */
132
- export async function unblockIp(ip: string): Promise<void> {
133
- // Remove from DB
134
- await prisma.blockedIp.deleteMany({ where: { ip } });
135
-
136
- // Remove from Redis (both permanent and auto-block)
137
- try {
138
- const redis = getRedis();
139
- await redis.srem(BLOCKED_IPS_KEY, ip);
140
- await redis.zrem(AUTO_BLOCKED_KEY, ip);
141
- } catch {
142
- logger.warn({ ip }, '[IP-BLOCK] Failed to sync unblock to Redis — will be synced on next restart');
143
- }
144
-
145
- logger.info({ ip }, '[IP-BLOCK] IP unblocked');
146
- }
147
-
148
- /**
149
- * List all currently blocked IPs (permanent from DB + active auto-blocks from Redis).
150
- */
151
- export async function getBlockedIps(): Promise<{
152
- permanent: { id: string; ip: string; reason: string | null; blockedBy: string; createdAt: Date }[];
153
- autoBlocked: string[];
154
- }> {
155
- // Permanent blocks from DB (source of truth)
156
- const permanent = await prisma.blockedIp.findMany({
157
- select: { id: true, ip: true, reason: true, blockedBy: true, createdAt: true },
158
- orderBy: { createdAt: 'desc' },
159
- });
160
-
161
- // Auto-blocks from Redis
162
- let autoBlocked: string[] = [];
163
- try {
164
- const redis = getRedis();
165
- const nowSeconds = Date.now() / 1000;
166
- autoBlocked = await redis.zrangebyscore(AUTO_BLOCKED_KEY, nowSeconds, '+inf');
167
- } catch {
168
- logger.warn('[IP-BLOCK] Redis unavailable, cannot retrieve auto-blocked IPs');
169
- }
170
-
171
- return { permanent, autoBlocked };
172
- }
173
-
174
- /**
175
- * Record a rate-limit violation for an IP.
176
- *
177
- * If the IP exceeds AUTO_BLOCK_THRESHOLD violations within
178
- * AUTO_BLOCK_WINDOW_SECONDS, it gets auto-blocked for
179
- * AUTO_BLOCK_DURATION_SECONDS.
180
- *
181
- * Called from the rate-limit `onExceeded` callback.
182
- *
183
- * @param ip - IP address that violated rate limit
184
- */
185
- export async function recordRateLimitViolation(ip: string): Promise<void> {
186
- try {
187
- const redis = getRedis();
188
- const key = `${VIOLATION_PREFIX}${ip}`;
189
-
190
- const count = await redis.incr(key);
191
-
192
- // Set TTL on first violation (sliding window)
193
- if (count === 1) {
194
- await redis.expire(key, AUTO_BLOCK_WINDOW_SECONDS);
195
- }
196
-
197
- if (count >= AUTO_BLOCK_THRESHOLD) {
198
- // Auto-block: add to ZSET with expiry timestamp as score
199
- const expiresAt = Math.floor(Date.now() / 1000) + AUTO_BLOCK_DURATION_SECONDS;
200
- await redis.zadd(AUTO_BLOCKED_KEY, expiresAt, ip);
201
- await redis.del(key); // Reset violation counter
202
-
203
- logger.warn(
204
- { ip, violations: count, blockedForSeconds: AUTO_BLOCK_DURATION_SECONDS },
205
- '[IP-BLOCK] Auto-blocked IP due to excessive rate-limit violations',
206
- );
207
- }
208
- } catch {
209
- // Non-critical: don't break the request if violation tracking fails
210
- logger.warn('[IP-BLOCK] Failed to record rate-limit violation');
211
- }
212
- }
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,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
+ }