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.
- package/README.md +10 -3
- package/bin/create-tigra.js +77 -37
- package/package.json +5 -5
- package/template/_claude/commands/create-server.md +8 -2
- package/template/_claude/rules/client/01-project-structure.md +12 -0
- package/template/_claude/rules/client/03-data-and-state.md +1 -1
- package/template/_claude/rules/client/04-design-system.md +23 -0
- package/template/_claude/rules/client/07-deployment.md +99 -0
- package/template/_claude/rules/client/08-lockfile-cross-platform.md +79 -0
- package/template/_claude/rules/client/core.md +1 -0
- package/template/_claude/rules/global/core.md +20 -1
- package/template/_claude/rules/global/investigation-before-conclusions.md +57 -0
- package/template/_claude/rules/server/core.md +2 -0
- package/template/_claude/rules/server/deployment.md +78 -0
- package/template/client/next.config.ts +12 -2
- package/template/client/package-lock.json +12345 -0
- package/template/client/package.json +3 -2
- package/template/client/src/components/common/SafeImage.tsx +2 -1
- package/template/client/src/lib/api/axios.config.ts +19 -4
- package/template/client/src/middleware.ts +7 -0
- package/template/gitignore +1 -0
- package/template/server/.env.example +248 -194
- package/template/server/.env.example.production +221 -168
- package/template/server/Dockerfile +29 -5
- package/template/server/docker-compose.yml +32 -4
- package/template/server/package-lock.json +6544 -6823
- package/template/server/package.json +76 -75
- package/template/server/prisma/seed.ts +20 -4
- package/template/server/src/app.ts +316 -271
- package/template/server/src/config/env.ts +150 -99
- package/template/server/src/config/rate-limit.config.ts +16 -0
- package/template/server/src/libs/__tests__/auth-path.test.ts +24 -0
- package/template/server/src/libs/__tests__/client-ip.test.ts +121 -0
- package/template/server/src/libs/__tests__/http.test.ts +23 -9
- package/template/server/src/libs/__tests__/ip-block.test.ts +62 -0
- package/template/server/src/libs/__tests__/origin-check.test.ts +53 -0
- package/template/server/src/libs/__tests__/url-safety.test.ts +80 -0
- package/template/server/src/libs/auth-path.ts +14 -0
- package/template/server/src/libs/auth.ts +6 -16
- package/template/server/src/libs/client-ip.ts +77 -0
- package/template/server/src/libs/cookies.ts +1 -1
- package/template/server/src/libs/duration.ts +30 -0
- package/template/server/src/libs/ip-block.ts +220 -206
- package/template/server/src/libs/origin-check.ts +38 -0
- package/template/server/src/libs/query-counter.ts +59 -0
- package/template/server/src/libs/redis.ts +1 -1
- package/template/server/src/libs/url-safety.ts +121 -0
- package/template/server/src/modules/auth/__tests__/auth.service.test.ts +274 -44
- package/template/server/src/modules/auth/auth.controller.ts +128 -127
- package/template/server/src/modules/auth/auth.repo.ts +2 -0
- package/template/server/src/modules/auth/auth.service.ts +103 -12
- package/template/server/src/test/setup.ts +22 -2
- 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 {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|