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,99 +1,150 @@
1
- import { z } from 'zod';
2
- import dotenv from 'dotenv';
3
-
4
- // Suppress dotenv's informational logs
5
- process.env.DOTENV_CONFIG_QUIET = 'true';
6
- dotenv.config();
7
-
8
- const envSchema = z.object({
9
- // --- Application ---
10
- NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
11
- PORT: z.coerce.number().int().min(1).max(65535).default(8000),
12
- HOST: z.string().default('0.0.0.0'),
13
-
14
- // --- Database (MySQL 8.0+) ---
15
- DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
16
- DATABASE_POOL_MIN: z.coerce.number().int().min(1).default(2),
17
- DATABASE_POOL_MAX: z.coerce.number().int().min(1).max(1000).default(10),
18
-
19
- // --- Redis ---
20
- REDIS_URL: z.string().default('redis://localhost:6379'),
21
- REDIS_MAX_RETRIES: z.coerce.number().int().min(0).default(3),
22
- REDIS_CONNECT_TIMEOUT: z.coerce.number().int().min(1000).default(10000), // ms
23
-
24
- // --- Rate Limiting ---
25
- RATE_LIMIT_ENABLED: z.string().default('true').transform((val) => val === '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
-
33
- // --- JWT Authentication ---
34
- JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
35
- JWT_ACCESS_EXPIRY: z.string().default('15m'),
36
- JWT_REFRESH_EXPIRY: z.string().default('7d'),
37
-
38
- // --- Cookie ---
39
- // Separate secret for cookie signing (defaults to JWT_SECRET if not set)
40
- COOKIE_SECRET: z.string().min(32, 'COOKIE_SECRET must be at least 32 characters').optional(),
41
-
42
- // Cookie domain for cross-origin deployments (client ≠ server hostname)
43
- // Required when client and API are on different subdomains (e.g., app.example.com + api.example.com)
44
- // Set to the shared parent domain with a leading dot: ".example.com"
45
- // Leave empty for same-origin deployments or local development
46
- COOKIE_DOMAIN: z.string().optional(),
47
-
48
- // --- Account Activation ---
49
- // When true (default), new users are created as inactive and must verify
50
- // their account before they can log in. When false, users are active immediately.
51
- REQUIRE_USER_VERIFICATION: z.string().default('true').transform((val) => val === 'true'),
52
-
53
- // --- CORS ---
54
- // In development: CORS_ORIGIN is optional (allows all origins)
55
- // In production: REQUIRED for security
56
- // Supports comma-separated multiple origins: "https://a.com,https://b.com"
57
- CORS_ORIGIN: z.string().optional(),
58
-
59
- // --- Logging ---
60
- LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
61
-
62
- // --- Email (Resend) ---
63
- RESEND_API_KEY: z.string().min(1).optional(),
64
- RESEND_FROM_EMAIL: z.string().email().default('onboarding@resend.dev'),
65
- CLIENT_URL: z.string().url().default('http://localhost:3000'),
66
-
67
- // --- Error Tracking (Optional) ---
68
- SENTRY_DSN: z.string().url().optional(),
69
- });
70
-
71
- const parsed = envSchema.safeParse(process.env);
72
-
73
- if (!parsed.success) {
74
- const formatted = parsed.error.issues
75
- .map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`)
76
- .join('\n');
77
-
78
- // eslint-disable-next-line no-console
79
- console.error(`\nEnvironment validation failed:\n${formatted}\n`);
80
- process.exit(1);
81
- }
82
-
83
- // Validate CORS_ORIGIN in production
84
- if (parsed.data.NODE_ENV === 'production' && !parsed.data.CORS_ORIGIN) {
85
- // eslint-disable-next-line no-console
86
- console.error('\nCORS_ORIGIN is required in production for security\n');
87
- process.exit(1);
88
- }
89
-
90
- // Validate RESEND_API_KEY when email verification is enabled
91
- if (parsed.data.REQUIRE_USER_VERIFICATION && !parsed.data.RESEND_API_KEY) {
92
- // eslint-disable-next-line no-console
93
- console.error('\nRESEND_API_KEY is required when REQUIRE_USER_VERIFICATION is enabled.\nGet your API key from: https://resend.com/api-keys\n');
94
- process.exit(1);
95
- }
96
-
97
- export const env = parsed.data;
98
-
99
- export type Env = z.infer<typeof envSchema>;
1
+ import { z } from 'zod';
2
+ import dotenv from 'dotenv';
3
+
4
+ // Suppress dotenv's informational logs
5
+ process.env.DOTENV_CONFIG_QUIET = 'true';
6
+ dotenv.config();
7
+
8
+ /**
9
+ * Treat present-but-empty env vars as unset.
10
+ *
11
+ * The .env ↔ .env.example sync convention produces `VAR=""` placeholders, and
12
+ * Zod's `.optional()` rejects a present-but-empty string (e.g. `.url()` or
13
+ * `.min(32)` fails on "") — which killed boot in incident aeaaf94a. Mapping
14
+ * "" undefined makes empty placeholders behave like missing vars: optionals
15
+ * stay undefined, defaults kick in. Production presence guards (`!env.VAR`)
16
+ * keep working since "" parses to undefined.
17
+ */
18
+ const optionalEnv = <T extends z.ZodTypeAny>(schema: T): z.ZodPreprocess<T> =>
19
+ z.preprocess((v) => (v === '' ? undefined : v), schema);
20
+
21
+ const envSchema = z.object({
22
+ // --- Application ---
23
+ NODE_ENV: optionalEnv(z.enum(['development', 'production', 'test']).default('development')),
24
+ PORT: optionalEnv(z.coerce.number().int().min(1).max(65535).default(8000)),
25
+ HOST: optionalEnv(z.string().default('0.0.0.0')),
26
+
27
+ // --- Server timeouts ---
28
+ // Fastify request/connection timeouts. Defaults match the previous hardcoded
29
+ // values. Long-running routes (LLM calls, large exports) may need 180s+ —
30
+ // remember the reverse proxy (Nginx/Coolify) timeout must be raised to match,
31
+ // or the proxy will cut the connection before the server does.
32
+ REQUEST_TIMEOUT_MS: optionalEnv(z.coerce.number().int().min(1).default(30000)),
33
+ CONNECTION_TIMEOUT_MS: optionalEnv(z.coerce.number().int().min(1).default(60000)),
34
+
35
+ // --- Database (MySQL 8.0+) ---
36
+ DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
37
+ DATABASE_POOL_MIN: optionalEnv(z.coerce.number().int().min(1).default(2)),
38
+ DATABASE_POOL_MAX: optionalEnv(z.coerce.number().int().min(1).max(1000).default(10)),
39
+
40
+ // --- Redis ---
41
+ REDIS_URL: optionalEnv(z.string().default('redis://localhost:6379')),
42
+ REDIS_MAX_RETRIES: optionalEnv(z.coerce.number().int().min(0).default(3)),
43
+ REDIS_CONNECT_TIMEOUT: optionalEnv(z.coerce.number().int().min(1000).default(10000)), // ms
44
+
45
+ // --- Rate Limiting ---
46
+ RATE_LIMIT_ENABLED: optionalEnv(z.string().default('true').transform((val) => val === 'true')),
47
+ RATE_LIMIT_MULTIPLIER: optionalEnv(z.coerce.number().min(0.1).max(100).default(1)),
48
+
49
+ // When true, derive the client IP for rate-limiting / IP-blocking from the
50
+ // Cloudflare `CF-Connecting-IP` header instead of the socket/X-Forwarded-For
51
+ // IP (which is a Cloudflare edge IP behind the proxy — see src/libs/client-ip.ts).
52
+ // Default false: the header is client-spoofable, so enable this ONLY when the
53
+ // origin accepts traffic exclusively via Cloudflare.
54
+ TRUST_CLOUDFLARE: optionalEnv(z.string().default('false').transform((val) => val === 'true')),
55
+ RATE_LIMIT_AUTH_LOGIN_MAX: optionalEnv(z.coerce.number().int().min(1).optional()),
56
+ RATE_LIMIT_AUTH_REGISTER_MAX: optionalEnv(z.coerce.number().int().min(1).optional()),
57
+
58
+ // --- IP Auto-Block (see src/libs/ip-block.ts) ---
59
+ // An IP that exceeds rate limits IP_AUTO_BLOCK_THRESHOLD times within
60
+ // IP_AUTO_BLOCK_WINDOW_SECONDS is blocked for IP_AUTO_BLOCK_DURATION_SECONDS.
61
+ // The threshold targets sustained abuse, not a single burst — a retry-looping
62
+ // but legitimate client (or a NAT'd office sharing one IP) must not self-ban.
63
+ IP_AUTO_BLOCK_THRESHOLD: optionalEnv(z.coerce.number().int().min(1).default(20)),
64
+ IP_AUTO_BLOCK_WINDOW_SECONDS: optionalEnv(z.coerce.number().int().min(1).default(300)),
65
+ IP_AUTO_BLOCK_DURATION_SECONDS: optionalEnv(z.coerce.number().int().min(1).default(3600)),
66
+
67
+ // --- File Upload ---
68
+ MAX_FILE_SIZE_MB: optionalEnv(z.coerce.number().min(1).max(100).default(10)),
69
+
70
+ // --- JWT Authentication ---
71
+ JWT_SECRET: z
72
+ .string()
73
+ .min(32, 'JWT_SECRET must be at least 32 characters')
74
+ // The committed .env.example placeholder is 43 chars and would pass min(32) —
75
+ // every scaffolded app would boot with the same publicly-known signing key.
76
+ .refine(
77
+ (s) => !s.startsWith('CHANGE_ME'),
78
+ 'JWT_SECRET is still the placeholder — generate one: openssl rand -hex 48',
79
+ ),
80
+ JWT_ACCESS_EXPIRY: optionalEnv(z.string().default('15m')),
81
+ JWT_REFRESH_EXPIRY: optionalEnv(z.string().default('7d')),
82
+
83
+ // --- Cookie ---
84
+ // Separate secret for cookie signing (defaults to JWT_SECRET if not set)
85
+ COOKIE_SECRET: optionalEnv(
86
+ z
87
+ .string()
88
+ .min(32, 'COOKIE_SECRET must be at least 32 characters')
89
+ .refine(
90
+ (s) => !s.startsWith('CHANGE_ME'),
91
+ 'COOKIE_SECRET is still the placeholder — generate one: openssl rand -hex 48',
92
+ )
93
+ .optional(),
94
+ ),
95
+
96
+ // Cookie domain for cross-origin deployments (client ≠ server hostname)
97
+ // Required when client and API are on different subdomains (e.g., app.example.com + api.example.com)
98
+ // Set to the shared parent domain with a leading dot: ".example.com"
99
+ // Leave empty for same-origin deployments or local development
100
+ COOKIE_DOMAIN: optionalEnv(z.string().optional()),
101
+
102
+ // --- Account Activation ---
103
+ // When true (default), new users are created as inactive and must verify
104
+ // their account before they can log in. When false, users are active immediately.
105
+ REQUIRE_USER_VERIFICATION: optionalEnv(z.string().default('true').transform((val) => val === 'true')),
106
+
107
+ // --- CORS ---
108
+ // In development: CORS_ORIGIN is optional (allows all origins)
109
+ // In production: REQUIRED for security
110
+ // Supports comma-separated multiple origins: "https://a.com,https://b.com"
111
+ CORS_ORIGIN: optionalEnv(z.string().optional()),
112
+
113
+ // --- Logging ---
114
+ LOG_LEVEL: optionalEnv(z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info')),
115
+
116
+ // --- Email (Resend) ---
117
+ RESEND_API_KEY: optionalEnv(z.string().min(1).optional()),
118
+ RESEND_FROM_EMAIL: optionalEnv(z.string().email().default('onboarding@resend.dev')),
119
+ CLIENT_URL: optionalEnv(z.string().url().default('http://localhost:3000')),
120
+
121
+ // --- Error Tracking (Optional) ---
122
+ SENTRY_DSN: optionalEnv(z.string().url().optional()),
123
+ });
124
+
125
+ const parsed = envSchema.safeParse(process.env);
126
+
127
+ if (!parsed.success) {
128
+ const formatted = parsed.error.issues
129
+ .map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`)
130
+ .join('\n');
131
+
132
+ console.error(`\nEnvironment validation failed:\n${formatted}\n`);
133
+ process.exit(1);
134
+ }
135
+
136
+ // Validate CORS_ORIGIN in production
137
+ if (parsed.data.NODE_ENV === 'production' && !parsed.data.CORS_ORIGIN) {
138
+ console.error('\nCORS_ORIGIN is required in production for security\n');
139
+ process.exit(1);
140
+ }
141
+
142
+ // Validate RESEND_API_KEY when email verification is enabled
143
+ if (parsed.data.REQUIRE_USER_VERIFICATION && !parsed.data.RESEND_API_KEY) {
144
+ console.error('\nRESEND_API_KEY is required when REQUIRE_USER_VERIFICATION is enabled.\nGet your API key from: https://resend.com/api-keys\n');
145
+ process.exit(1);
146
+ }
147
+
148
+ export const env = parsed.data;
149
+
150
+ export type Env = z.infer<typeof envSchema>;
@@ -9,6 +9,22 @@
9
9
  * - RATE_LIMIT_MULTIPLIER: multiply all max values (default: 1, set 10 for dev)
10
10
  * - RATE_LIMIT_AUTH_LOGIN_MAX: override login max
11
11
  * - RATE_LIMIT_AUTH_REGISTER_MAX: override register max
12
+ *
13
+ * ── Self-ban interaction with IP auto-block (read before tightening limits) ──
14
+ * Every rate-limit exceedance records ONE violation against the client IP
15
+ * (app.ts `onExceeded` → recordRateLimitViolation). An IP that accumulates
16
+ * IP_AUTO_BLOCK_THRESHOLD violations (default 20) within
17
+ * IP_AUTO_BLOCK_WINDOW_SECONDS (default 300s) is auto-blocked for
18
+ * IP_AUTO_BLOCK_DURATION_SECONDS (default 1h) — for EVERY route.
19
+ *
20
+ * Rate limits are counted BEFORE validation, so a legitimate client stuck in a
21
+ * retry loop on a 400/422 response still burns quota; and NAT'd offices share
22
+ * one counter per IP. When tuning a route, keep
23
+ * (realistic retries per window) × (windows per 5 min) well below the
24
+ * auto-block threshold, or a legit retry pattern self-bans for an hour.
25
+ * Example: AUTH_LOGIN at 10/15min can produce at most a handful of violations
26
+ * per 5-minute window — safely below 20. A hypothetical 5/10s limit could
27
+ * produce 30 violations in 5 minutes and trip the auto-block on its own.
12
28
  */
13
29
 
14
30
  import { env } from '@config/env.js';
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { isAuthPath } from '../auth-path.js';
3
+
4
+ describe('isAuthPath', () => {
5
+ it('returns true for an auth route', () => {
6
+ expect(isAuthPath({ url: '/api/v1/auth/login' })).toBe(true);
7
+ });
8
+
9
+ it('returns true when the auth route has a query string', () => {
10
+ expect(isAuthPath({ url: '/api/v1/auth/login?next=/dashboard' })).toBe(true);
11
+ });
12
+
13
+ it('returns false for a non-auth route (it still feeds the auto-ban)', () => {
14
+ expect(isAuthPath({ url: '/api/v1/widget/chat' })).toBe(false);
15
+ });
16
+
17
+ it('returns false for the bare /api/v1/auth prefix without a sub-path', () => {
18
+ expect(isAuthPath({ url: '/api/v1/auth' })).toBe(false);
19
+ });
20
+
21
+ it('returns false for a non-auth path that only starts with "auth"', () => {
22
+ expect(isAuthPath({ url: '/api/v1/auth-internal/health' })).toBe(false);
23
+ });
24
+ });
@@ -0,0 +1,121 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import type { FastifyRequest } from 'fastify';
3
+
4
+ // Mock the env module so each test can flip TRUST_CLOUDFLARE without relying on
5
+ // process.env load order (src/config/env.ts validates + exits on import).
6
+ const mockEnv = vi.hoisted(() => ({ TRUST_CLOUDFLARE: false }));
7
+ vi.mock('@config/env.js', () => ({ env: mockEnv }));
8
+
9
+ import { getClientIp } from '../client-ip.js';
10
+
11
+ // Minimal FastifyRequest stub — getClientIp only reads `.ip` and `.headers`.
12
+ function makeRequest(ip: string, headers: Record<string, unknown> = {}): FastifyRequest {
13
+ return { ip, headers } as unknown as FastifyRequest;
14
+ }
15
+
16
+ describe('getClientIp', () => {
17
+ beforeEach(() => {
18
+ mockEnv.TRUST_CLOUDFLARE = false;
19
+ });
20
+
21
+ describe('when TRUST_CLOUDFLARE is false (default)', () => {
22
+ it('returns request.ip and ignores the CF-Connecting-IP header', () => {
23
+ const request = makeRequest('10.0.0.1', { 'cf-connecting-ip': '203.0.113.7' });
24
+ expect(getClientIp(request)).toBe('10.0.0.1');
25
+ });
26
+
27
+ it('returns request.ip when no CF header is present', () => {
28
+ expect(getClientIp(makeRequest('10.0.0.1'))).toBe('10.0.0.1');
29
+ });
30
+ });
31
+
32
+ describe('when TRUST_CLOUDFLARE is true', () => {
33
+ beforeEach(() => {
34
+ mockEnv.TRUST_CLOUDFLARE = true;
35
+ });
36
+
37
+ it('returns the CF-Connecting-IP header value', () => {
38
+ const request = makeRequest('10.0.0.1', { 'cf-connecting-ip': '203.0.113.7' });
39
+ expect(getClientIp(request)).toBe('203.0.113.7');
40
+ });
41
+
42
+ it('falls back to request.ip when the CF header is absent', () => {
43
+ expect(getClientIp(makeRequest('10.0.0.1'))).toBe('10.0.0.1');
44
+ });
45
+
46
+ it('falls back to request.ip when the CF header is an empty string', () => {
47
+ const request = makeRequest('10.0.0.1', { 'cf-connecting-ip': '' });
48
+ expect(getClientIp(request)).toBe('10.0.0.1');
49
+ });
50
+
51
+ it('falls back to request.ip when the CF header is an array (duplicated header)', () => {
52
+ const request = makeRequest('10.0.0.1', { 'cf-connecting-ip': ['203.0.113.7', '198.51.100.2'] });
53
+ expect(getClientIp(request)).toBe('10.0.0.1');
54
+ });
55
+
56
+ it('falls through an array CF header to a valid X-Forwarded-For entry', () => {
57
+ // CF header is an array (untrusted shape) so it falls through; XFF is then used.
58
+ const request = makeRequest('10.0.0.1', {
59
+ 'cf-connecting-ip': ['203.0.113.7', '198.51.100.2'],
60
+ 'x-forwarded-for': '198.51.100.9',
61
+ });
62
+ expect(getClientIp(request)).toBe('198.51.100.9');
63
+ });
64
+
65
+ it('prefers a valid CF header over X-Forwarded-For', () => {
66
+ const request = makeRequest('10.0.0.1', {
67
+ 'cf-connecting-ip': '203.0.113.7',
68
+ 'x-forwarded-for': '198.51.100.9',
69
+ });
70
+ expect(getClientIp(request)).toBe('203.0.113.7');
71
+ });
72
+
73
+ it('falls through to X-Forwarded-For when the CF header is an invalid IP', () => {
74
+ const request = makeRequest('10.0.0.1', {
75
+ 'cf-connecting-ip': 'not-an-ip',
76
+ 'x-forwarded-for': '198.51.100.9',
77
+ });
78
+ expect(getClientIp(request)).toBe('198.51.100.9');
79
+ });
80
+ });
81
+
82
+ describe('X-Forwarded-For resolution (independent of the flag)', () => {
83
+ it('uses the left-most XFF entry when the CF flag is on but the CF header is absent', () => {
84
+ mockEnv.TRUST_CLOUDFLARE = true;
85
+ const request = makeRequest('10.0.0.1', { 'x-forwarded-for': '203.0.113.7' });
86
+ expect(getClientIp(request)).toBe('203.0.113.7');
87
+ });
88
+
89
+ it('uses the left-most XFF entry even when the CF flag is off (grey-cloud / DNS-only)', () => {
90
+ const request = makeRequest('10.0.0.1', { 'x-forwarded-for': '203.0.113.7' });
91
+ expect(getClientIp(request)).toBe('203.0.113.7');
92
+ });
93
+
94
+ it('returns the left-most entry of a multi-hop XFF chain', () => {
95
+ const request = makeRequest('10.0.0.1', {
96
+ 'x-forwarded-for': '203.0.113.7, 70.41.3.18, 150.172.238.178',
97
+ });
98
+ expect(getClientIp(request)).toBe('203.0.113.7');
99
+ });
100
+
101
+ it('trims surrounding whitespace from the XFF entry', () => {
102
+ const request = makeRequest('10.0.0.1', { 'x-forwarded-for': ' 203.0.113.7 , 70.41.3.18' });
103
+ expect(getClientIp(request)).toBe('203.0.113.7');
104
+ });
105
+
106
+ it('falls back to request.ip when the XFF entry is not a valid IP', () => {
107
+ const request = makeRequest('10.0.0.1', { 'x-forwarded-for': 'garbage, 70.41.3.18' });
108
+ expect(getClientIp(request)).toBe('10.0.0.1');
109
+ });
110
+
111
+ it('falls back to request.ip when the XFF header is an empty string', () => {
112
+ const request = makeRequest('10.0.0.1', { 'x-forwarded-for': '' });
113
+ expect(getClientIp(request)).toBe('10.0.0.1');
114
+ });
115
+
116
+ it('uses the first element when XFF is an array (duplicated header)', () => {
117
+ const request = makeRequest('10.0.0.1', { 'x-forwarded-for': ['203.0.113.7', '70.41.3.18'] });
118
+ expect(getClientIp(request)).toBe('203.0.113.7');
119
+ });
120
+ });
121
+ });
@@ -22,16 +22,26 @@ vi.mock('axios', () => ({
22
22
  create: vi.fn(() => ({
23
23
  interceptors: {
24
24
  request: {
25
- use: vi.fn((onFulfilled: any, onRejected: any) => {
26
- captured.requestFulfilled = onFulfilled;
27
- captured.requestRejected = onRejected;
28
- }),
25
+ use: vi.fn(
26
+ (
27
+ onFulfilled: (c: InternalAxiosRequestConfig) => InternalAxiosRequestConfig,
28
+ onRejected: (e: unknown) => never,
29
+ ) => {
30
+ captured.requestFulfilled = onFulfilled;
31
+ captured.requestRejected = onRejected;
32
+ },
33
+ ),
29
34
  },
30
35
  response: {
31
- use: vi.fn((onFulfilled: any, onRejected: any) => {
32
- captured.responseFulfilled = onFulfilled;
33
- captured.responseRejected = onRejected;
34
- }),
36
+ use: vi.fn(
37
+ (
38
+ onFulfilled: (r: AxiosResponse) => AxiosResponse,
39
+ onRejected: (e: unknown) => never,
40
+ ) => {
41
+ captured.responseFulfilled = onFulfilled;
42
+ captured.responseRejected = onRejected;
43
+ },
44
+ ),
35
45
  },
36
46
  },
37
47
  })),
@@ -404,7 +414,11 @@ describe('httpClient', () => {
404
414
 
405
415
  it('should not log request headers (may contain Authorization)', () => {
406
416
  captured.requestFulfilled!(
407
- makeConfig({ method: 'get', url: '/me', headers: { Authorization: 'Bearer token123' } as any }),
417
+ makeConfig({
418
+ method: 'get',
419
+ url: '/me',
420
+ headers: { Authorization: 'Bearer token123' } as unknown as InternalAxiosRequestConfig['headers'],
421
+ }),
408
422
  );
409
423
  const logCall = vi.mocked(logger.debug).mock.calls[0][0] as Record<string, unknown>;
410
424
  expect(logCall).not.toHaveProperty('headers');
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { getRedis } from '@libs/redis.js';
3
+ import { recordRateLimitViolation } from '../ip-block.js';
4
+
5
+ // Self-contained env so ip-block.ts can read its threshold constants at import
6
+ // time without loading/validating the real environment.
7
+ vi.mock('@config/env.js', () => ({
8
+ env: {
9
+ IP_AUTO_BLOCK_THRESHOLD: 20,
10
+ IP_AUTO_BLOCK_WINDOW_SECONDS: 300,
11
+ IP_AUTO_BLOCK_DURATION_SECONDS: 3600,
12
+ },
13
+ }));
14
+
15
+ vi.mock('@libs/prisma.js', () => ({
16
+ prisma: {
17
+ blockedIp: {
18
+ findMany: vi.fn(),
19
+ findUnique: vi.fn(),
20
+ create: vi.fn(),
21
+ deleteMany: vi.fn(),
22
+ },
23
+ },
24
+ }));
25
+
26
+ vi.mock('@libs/redis.js');
27
+ vi.mock('@libs/logger.js', () => ({
28
+ logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
29
+ }));
30
+
31
+ const redisMock = {
32
+ del: vi.fn(),
33
+ zadd: vi.fn(),
34
+ incr: vi.fn(),
35
+ expire: vi.fn(),
36
+ };
37
+
38
+ describe('recordRateLimitViolation — infra-IP guard', () => {
39
+ beforeEach(() => {
40
+ vi.clearAllMocks();
41
+ vi.mocked(getRedis).mockReturnValue(redisMock as never);
42
+ });
43
+
44
+ it('never auto-blocks a private/reserved IP (skips Redis accounting entirely)', async () => {
45
+ // A shared Traefik/Docker internal IP must never be auto-banned — that would
46
+ // lock out every real user behind it.
47
+ await recordRateLimitViolation('10.0.0.5');
48
+ expect(redisMock.incr).not.toHaveBeenCalled();
49
+ expect(redisMock.zadd).not.toHaveBeenCalled();
50
+ });
51
+
52
+ it('skips loopback addresses', async () => {
53
+ await recordRateLimitViolation('127.0.0.1');
54
+ expect(redisMock.incr).not.toHaveBeenCalled();
55
+ });
56
+
57
+ it('records a violation for a public client IP', async () => {
58
+ redisMock.incr.mockResolvedValue(1);
59
+ await recordRateLimitViolation('93.184.216.34');
60
+ expect(redisMock.incr).toHaveBeenCalledTimes(1);
61
+ });
62
+ });
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { isOriginAllowed } from '../origin-check.js';
3
+
4
+ describe('isOriginAllowed', () => {
5
+ const allowed = new Set(['https://app.example.com', 'https://admin.example.com']);
6
+
7
+ it('should allow requests without an Origin header (curl, Postman, server-to-server)', () => {
8
+ expect(isOriginAllowed(undefined, 'api.example.com', allowed, false)).toBe(true);
9
+ });
10
+
11
+ it('should allow any origin when CORS allows all origins (development)', () => {
12
+ expect(isOriginAllowed('https://evil.example.org', 'api.example.com', allowed, true)).toBe(true);
13
+ });
14
+
15
+ it('should allow a configured CORS origin', () => {
16
+ expect(isOriginAllowed('https://app.example.com', 'api.example.com', allowed, false)).toBe(true);
17
+ expect(isOriginAllowed('https://admin.example.com', 'api.example.com', allowed, false)).toBe(true);
18
+ });
19
+
20
+ it('should allow same-origin requests (Origin host matches request Host)', () => {
21
+ expect(isOriginAllowed('https://api.example.com', 'api.example.com', allowed, false)).toBe(true);
22
+ });
23
+
24
+ it('should allow same-origin requests with a port in the host', () => {
25
+ expect(isOriginAllowed('http://localhost:8000', 'localhost:8000', new Set(), false)).toBe(true);
26
+ });
27
+
28
+ it('should allow same-origin requests with a mixed-case Host header', () => {
29
+ expect(isOriginAllowed('https://api.example.com', 'API.Example.COM', allowed, false)).toBe(true);
30
+ });
31
+
32
+ it('should reject a cross-site origin that is not configured', () => {
33
+ expect(isOriginAllowed('https://evil.example.org', 'api.example.com', allowed, false)).toBe(false);
34
+ });
35
+
36
+ it('should reject a subdomain lookalike of an allowed origin', () => {
37
+ expect(
38
+ isOriginAllowed('https://app.example.com.evil.org', 'api.example.com', allowed, false),
39
+ ).toBe(false);
40
+ });
41
+
42
+ it('should reject a malformed Origin header', () => {
43
+ expect(isOriginAllowed('not a url', 'api.example.com', allowed, false)).toBe(false);
44
+ });
45
+
46
+ it('should reject "null" origin (sandboxed iframe, data: URL)', () => {
47
+ expect(isOriginAllowed('null', 'api.example.com', allowed, false)).toBe(false);
48
+ });
49
+
50
+ it('should reject an unknown origin when the request host is unavailable', () => {
51
+ expect(isOriginAllowed('https://evil.example.org', undefined, allowed, false)).toBe(false);
52
+ });
53
+ });