create-tigra 2.8.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) 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 +42 -0
  23. package/template/server/.env.example.production +40 -0
  24. package/template/server/Dockerfile +29 -5
  25. package/template/server/docker-compose.yml +15 -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 +40 -8
  30. package/template/server/src/config/env.ts +72 -28
  31. package/template/server/src/config/rate-limit.config.ts +16 -0
  32. package/template/server/src/libs/__tests__/http.test.ts +23 -9
  33. package/template/server/src/libs/__tests__/origin-check.test.ts +53 -0
  34. package/template/server/src/libs/auth.ts +6 -16
  35. package/template/server/src/libs/cookies.ts +1 -1
  36. package/template/server/src/libs/duration.ts +30 -0
  37. package/template/server/src/libs/ip-block.ts +10 -4
  38. package/template/server/src/libs/origin-check.ts +38 -0
  39. package/template/server/src/libs/redis.ts +1 -1
  40. package/template/server/src/modules/auth/__tests__/auth.service.test.ts +274 -44
  41. package/template/server/src/modules/auth/auth.repo.ts +2 -0
  42. package/template/server/src/modules/auth/auth.service.ts +103 -12
  43. package/template/server/src/test/setup.ts +22 -2
  44. package/template/server/vitest.config.ts +43 -43
@@ -1,75 +1,76 @@
1
- {
2
- "name": "{{PROJECT_NAME}}-server",
3
- "version": "1.0.0",
4
- "type": "module",
5
- "description": "",
6
- "main": "dist/server.js",
7
- "scripts": {
8
- "dev": "tsx watch src/server.ts",
9
- "build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
10
- "start": "node dist/server.js",
11
- "test": "vitest run",
12
- "test:watch": "vitest",
13
- "test:ui": "vitest --ui",
14
- "test:coverage": "vitest run --coverage",
15
- "prisma:generate": "prisma generate",
16
- "prisma:migrate:dev": "prisma migrate dev",
17
- "prisma:migrate:deploy": "prisma migrate deploy",
18
- "prisma:reset": "prisma migrate reset",
19
- "prisma:seed": "prisma db seed",
20
- "prisma:studio": "prisma studio",
21
- "lint": "eslint src/",
22
- "typecheck": "tsc --noEmit",
23
- "redis:flush": "tsx scripts/flush-redis.ts",
24
- "docker:up": "docker compose up -d",
25
- "docker:down": "docker compose down",
26
- "docker:logs": "docker compose logs -f",
27
- "generate:env": "node -e \"import('fs').then(f=>{if(f.existsSync('.env'))console.log('.env already exists, skipping');else{f.copyFileSync('.env.example','.env');console.log('.env created from .env.example')}})\""
28
- },
29
- "prisma": {
30
- "seed": "tsx prisma/seed.ts"
31
- },
32
- "dependencies": {
33
- "@fastify/cookie": "^11.0.2",
34
- "@fastify/cors": "^11.2.0",
35
- "@fastify/helmet": "^13.0.2",
36
- "@fastify/jwt": "^10.0.0",
37
- "@fastify/multipart": "^9.0.2",
38
- "@fastify/rate-limit": "^10.3.0",
39
- "@fastify/static": "^9.0.0",
40
- "@prisma/client": "^6.19.2",
41
- "argon2": "^0.44.0",
42
- "axios": "^1.7.9",
43
- "dotenv": "^16.4.7",
44
- "fastify": "^5.7.4",
45
- "fastify-type-provider-zod": "^6.1.0",
46
- "ioredis": "^5.9.2",
47
- "pino": "^10.3.1",
48
- "pino-pretty": "^13.1.3",
49
- "resend": "^6.9.4",
50
- "sharp": "^0.33.5",
51
- "uuid": "^10.0.0",
52
- "zod": "^4.3.6"
53
- },
54
- "overrides": {
55
- "bn.js": ">=5.2.3",
56
- "flatted": ">=3.4.2",
57
- "@typescript-eslint/typescript-estree": {
58
- "minimatch": ">=10.2.1"
59
- }
60
- },
61
- "devDependencies": {
62
- "@eslint/js": "^10.0.1",
63
- "@types/node": "^20.17.10",
64
- "@types/uuid": "^10.0.0",
65
- "@vitest/coverage-v8": "^4.0.18",
66
- "@vitest/ui": "^4.0.18",
67
- "eslint": "^10.0.1",
68
- "prisma": "^6.19.2",
69
- "tsc-alias": "^1.8.16",
70
- "tsx": "^4.21.0",
71
- "typescript": "^5.9.3",
72
- "typescript-eslint": "^8.55.0",
73
- "vitest": "^4.0.18"
74
- }
75
- }
1
+ {
2
+ "name": "{{PROJECT_NAME}}-server",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "",
6
+ "main": "dist/server.js",
7
+ "scripts": {
8
+ "dev": "tsx watch src/server.ts",
9
+ "build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
10
+ "start": "node dist/server.js",
11
+ "test": "vitest run",
12
+ "test:watch": "vitest",
13
+ "test:ui": "vitest --ui",
14
+ "test:coverage": "vitest run --coverage",
15
+ "prisma:generate": "prisma generate",
16
+ "prisma:migrate:dev": "prisma migrate dev",
17
+ "prisma:migrate:deploy": "prisma migrate deploy",
18
+ "prisma:reset": "prisma migrate reset",
19
+ "prisma:seed": "prisma db seed",
20
+ "prisma:studio": "prisma studio",
21
+ "lint": "eslint src/",
22
+ "typecheck": "tsc --noEmit",
23
+ "redis:flush": "tsx scripts/flush-redis.ts",
24
+ "docker:up": "docker compose up -d",
25
+ "docker:tools": "docker compose --profile tools up -d",
26
+ "docker:down": "docker compose --profile tools down",
27
+ "docker:logs": "docker compose logs -f",
28
+ "generate:env": "node -e \"import('fs').then(f=>{if(f.existsSync('.env'))console.log('.env already exists, skipping');else{f.copyFileSync('.env.example','.env');console.log('.env created from .env.example')}})\""
29
+ },
30
+ "prisma": {
31
+ "seed": "tsx prisma/seed.ts"
32
+ },
33
+ "dependencies": {
34
+ "@fastify/cookie": "^11.0.2",
35
+ "@fastify/cors": "^11.2.0",
36
+ "@fastify/helmet": "^13.0.2",
37
+ "@fastify/jwt": "^10.1.0",
38
+ "@fastify/multipart": "^9.0.2",
39
+ "@fastify/rate-limit": "^10.3.0",
40
+ "@fastify/static": "^9.1.3",
41
+ "@prisma/client": "^6.19.3",
42
+ "argon2": "^0.44.0",
43
+ "axios": "^1.17.0",
44
+ "dotenv": "^16.4.7",
45
+ "fastify": "^5.8.5",
46
+ "fastify-type-provider-zod": "^6.1.0",
47
+ "ioredis": "^5.9.2",
48
+ "pino": "^10.3.1",
49
+ "pino-pretty": "^13.1.3",
50
+ "resend": "^6.9.4",
51
+ "sharp": "^0.33.5",
52
+ "uuid": "^10.0.0",
53
+ "zod": "^4.3.6"
54
+ },
55
+ "overrides": {
56
+ "bn.js": ">=5.2.3",
57
+ "flatted": ">=3.4.2",
58
+ "@typescript-eslint/typescript-estree": {
59
+ "minimatch": ">=10.2.1"
60
+ }
61
+ },
62
+ "devDependencies": {
63
+ "@eslint/js": "^10.0.1",
64
+ "@types/node": "^20.17.10",
65
+ "@types/uuid": "^10.0.0",
66
+ "@vitest/coverage-v8": "^4.0.18",
67
+ "@vitest/ui": "^4.0.18",
68
+ "eslint": "^10.0.1",
69
+ "prisma": "^6.19.3",
70
+ "tsc-alias": "^1.8.16",
71
+ "tsx": "^4.21.0",
72
+ "typescript": "^5.9.3",
73
+ "typescript-eslint": "^8.55.0",
74
+ "vitest": "^4.0.18"
75
+ }
76
+ }
@@ -3,9 +3,27 @@ import argon2 from 'argon2';
3
3
 
4
4
  const prisma = new PrismaClient();
5
5
 
6
+ // Well-known dev-only fallbacks. NEVER used in production — the guard in
7
+ // main() refuses to seed when NODE_ENV=production, and even outside production
8
+ // you can override via SEED_ADMIN_PASSWORD / SEED_USER_PASSWORD.
9
+ const DEV_ADMIN_PASSWORD = 'Admin123!';
10
+ const DEV_USER_PASSWORD = 'User123!';
11
+
6
12
  async function main(): Promise<void> {
7
- const adminPassword = await argon2.hash('Admin123!');
8
- const userPassword = await argon2.hash('User123!');
13
+ // Refuse to seed production: this script creates a well-known admin account
14
+ // (admin@example.com). On a production database that is a backdoor, not a
15
+ // convenience. Seed data belongs to dev/test environments only.
16
+ if (process.env.NODE_ENV === 'production') {
17
+ console.error(
18
+ 'Refusing to seed: NODE_ENV is "production".\n' +
19
+ 'The seed script creates well-known demo accounts (admin@example.com) and must never run against a production database.\n' +
20
+ 'If you really need initial data in production, create it manually or write a dedicated, audited provisioning script.',
21
+ );
22
+ process.exit(1);
23
+ }
24
+
25
+ const adminPassword = await argon2.hash(process.env.SEED_ADMIN_PASSWORD || DEV_ADMIN_PASSWORD);
26
+ const userPassword = await argon2.hash(process.env.SEED_USER_PASSWORD || DEV_USER_PASSWORD);
9
27
 
10
28
  const admin = await prisma.user.upsert({
11
29
  where: { email: 'admin@example.com' },
@@ -31,13 +49,11 @@ async function main(): Promise<void> {
31
49
  },
32
50
  });
33
51
 
34
- // eslint-disable-next-line no-console
35
52
  console.log('Seeded users:', { admin: admin.email, user: user.email });
36
53
  }
37
54
 
38
55
  main()
39
56
  .catch((error) => {
40
- // eslint-disable-next-line no-console
41
57
  console.error('Seed failed:', error);
42
58
  process.exit(1);
43
59
  })
@@ -1,4 +1,4 @@
1
- import Fastify, { type FastifyError, type FastifyRequest } from 'fastify';
1
+ import Fastify, { type FastifyError, type FastifyInstance, type FastifyRequest } from 'fastify';
2
2
  import cors from '@fastify/cors';
3
3
  import helmet from '@fastify/helmet';
4
4
  import rateLimit from '@fastify/rate-limit';
@@ -21,6 +21,7 @@ import { fileStorageService } from '@libs/storage/file-storage.service.js';
21
21
  import { registerJobs } from '@jobs/index.js';
22
22
  import { RATE_LIMIT_ENABLED, getRateLimitRedisStore } from '@config/rate-limit.config.js';
23
23
  import { isIpBlocked, recordRateLimitViolation, syncBlockedIpsToRedis } from '@libs/ip-block.js';
24
+ import { isOriginAllowed } from '@libs/origin-check.js';
24
25
  import { ForbiddenError } from '@shared/errors/errors.js';
25
26
  import {
26
27
  serializerCompiler,
@@ -31,15 +32,18 @@ import {
31
32
  // Import types to register Fastify augmentations
32
33
  import type {} from '@shared/types/index.js';
33
34
 
34
- export async function buildApp() {
35
+ export async function buildApp(): Promise<FastifyInstance> {
35
36
  const app = Fastify({
36
37
  logger: false,
37
38
  // Trust proxy headers (X-Forwarded-For) for accurate client IP behind Nginx/load balancer
38
39
  trustProxy: env.NODE_ENV === 'production',
39
40
  // Graceful shutdown configuration
40
41
  forceCloseConnections: true, // Force close idle connections on shutdown
41
- requestTimeout: 30000, // 30s request timeout
42
- connectionTimeout: 60000, // 60s connection timeout
42
+ // Env-configurable timeouts (defaults: 30s request, 60s connection).
43
+ // Long-running routes (LLM calls, exports) may need 180s+ — raise the
44
+ // reverse proxy timeout to match. See REQUEST_TIMEOUT_MS in .env.example.
45
+ requestTimeout: env.REQUEST_TIMEOUT_MS,
46
+ connectionTimeout: env.CONNECTION_TIMEOUT_MS,
43
47
  keepAliveTimeout: 5000, // 5s keep-alive timeout
44
48
  // Request body size limits (prevent DoS attacks)
45
49
  bodyLimit: 1048576, // 1MB default limit (1024 * 1024)
@@ -133,26 +137,54 @@ export async function buildApp() {
133
137
  // --- Sync permanent IP blocks from DB to Redis ---
134
138
  await syncBlockedIpsToRedis();
135
139
 
140
+ // Monitoring endpoints exempt from IP blocking and request logging.
141
+ // Health probes (Coolify/Docker/K8s/load balancers) come from infrastructure
142
+ // IPs that must NEVER be blocked — a blocked probe IP would mark a healthy
143
+ // container as dead and restart-loop it. Exact match on the path (query
144
+ // string stripped) so the exemption cannot be widened by crafted URLs.
145
+ // These paths must match the route registrations below.
146
+ const monitoringPaths = new Set(['/api/v1/health', '/api/v1/ready', '/api/v1/live']);
147
+
136
148
  // --- IP Block Check (runs before everything else) ---
137
149
  app.addHook('onRequest', async (request: FastifyRequest) => {
150
+ if (monitoringPaths.has(request.url.split('?')[0])) {
151
+ return; // never block health probes
152
+ }
138
153
  if (await isIpBlocked(request.ip)) {
139
154
  throw new ForbiddenError('Access denied', 'IP_BLOCKED');
140
155
  }
141
156
  });
142
157
 
143
- // --- Request/Response Logging ---
144
- const skipLogPaths = new Set(['/api/v1/health', '/api/v1/ready', '/api/v1/live']);
158
+ // --- CSRF defense-in-depth: Origin check on state-changing methods ---
159
+ // With sameSite=none cookies (cross-origin deployments), the browser attaches
160
+ // auth cookies to cross-site requests. If a browser sends an Origin header on
161
+ // a state-changing request, it must be same-origin or a configured CORS
162
+ // origin. Requests WITHOUT an Origin header (curl, Postman, server-to-server,
163
+ // health probes) are allowed — they carry no ambient cookies and are not
164
+ // CSRF vectors. See src/libs/origin-check.ts for the full rationale.
165
+ const stateChangingMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
166
+ const allowAllOrigins = corsOrigin === true;
167
+ const allowedOrigins = new Set<string>(
168
+ Array.isArray(corsOrigin) ? corsOrigin : typeof corsOrigin === 'string' ? [corsOrigin] : [],
169
+ );
145
170
 
171
+ app.addHook('onRequest', async (request: FastifyRequest) => {
172
+ if (!stateChangingMethods.has(request.method)) return;
173
+ if (isOriginAllowed(request.headers.origin, request.headers.host, allowedOrigins, allowAllOrigins)) return;
174
+ throw new ForbiddenError('Origin not allowed', 'ORIGIN_NOT_ALLOWED');
175
+ });
176
+
177
+ // --- Request/Response Logging ---
146
178
  app.addHook('preHandler', async (request) => {
147
179
  const pathname = request.url.split('?')[0];
148
- if (!skipLogPaths.has(pathname)) {
180
+ if (!monitoringPaths.has(pathname)) {
149
181
  markRequestStart(request);
150
182
  }
151
183
  });
152
184
 
153
185
  app.addHook('onResponse', async (request, reply) => {
154
186
  const pathname = request.url.split('?')[0];
155
- if (!skipLogPaths.has(pathname)) {
187
+ if (!monitoringPaths.has(pathname)) {
156
188
  logRequestLine(request, reply);
157
189
  }
158
190
  });
@@ -5,67 +5,114 @@ import dotenv from 'dotenv';
5
5
  process.env.DOTENV_CONFIG_QUIET = 'true';
6
6
  dotenv.config();
7
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
+
8
21
  const envSchema = z.object({
9
22
  // --- 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'),
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)),
13
34
 
14
35
  // --- Database (MySQL 8.0+) ---
15
36
  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),
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)),
18
39
 
19
40
  // --- 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
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
23
44
 
24
45
  // --- 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(),
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
+ RATE_LIMIT_AUTH_LOGIN_MAX: optionalEnv(z.coerce.number().int().min(1).optional()),
49
+ RATE_LIMIT_AUTH_REGISTER_MAX: optionalEnv(z.coerce.number().int().min(1).optional()),
50
+
51
+ // --- IP Auto-Block (see src/libs/ip-block.ts) ---
52
+ // An IP that exceeds rate limits IP_AUTO_BLOCK_THRESHOLD times within
53
+ // IP_AUTO_BLOCK_WINDOW_SECONDS is blocked for IP_AUTO_BLOCK_DURATION_SECONDS.
54
+ // The threshold targets sustained abuse, not a single burst — a retry-looping
55
+ // but legitimate client (or a NAT'd office sharing one IP) must not self-ban.
56
+ IP_AUTO_BLOCK_THRESHOLD: optionalEnv(z.coerce.number().int().min(1).default(20)),
57
+ IP_AUTO_BLOCK_WINDOW_SECONDS: optionalEnv(z.coerce.number().int().min(1).default(300)),
58
+ IP_AUTO_BLOCK_DURATION_SECONDS: optionalEnv(z.coerce.number().int().min(1).default(3600)),
29
59
 
30
60
  // --- File Upload ---
31
- MAX_FILE_SIZE_MB: z.coerce.number().min(1).max(100).default(10),
61
+ MAX_FILE_SIZE_MB: optionalEnv(z.coerce.number().min(1).max(100).default(10)),
32
62
 
33
63
  // --- 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'),
64
+ JWT_SECRET: z
65
+ .string()
66
+ .min(32, 'JWT_SECRET must be at least 32 characters')
67
+ // The committed .env.example placeholder is 43 chars and would pass min(32) —
68
+ // every scaffolded app would boot with the same publicly-known signing key.
69
+ .refine(
70
+ (s) => !s.startsWith('CHANGE_ME'),
71
+ 'JWT_SECRET is still the placeholder — generate one: openssl rand -hex 48',
72
+ ),
73
+ JWT_ACCESS_EXPIRY: optionalEnv(z.string().default('15m')),
74
+ JWT_REFRESH_EXPIRY: optionalEnv(z.string().default('7d')),
37
75
 
38
76
  // --- Cookie ---
39
77
  // 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(),
78
+ COOKIE_SECRET: optionalEnv(
79
+ z
80
+ .string()
81
+ .min(32, 'COOKIE_SECRET must be at least 32 characters')
82
+ .refine(
83
+ (s) => !s.startsWith('CHANGE_ME'),
84
+ 'COOKIE_SECRET is still the placeholder — generate one: openssl rand -hex 48',
85
+ )
86
+ .optional(),
87
+ ),
41
88
 
42
89
  // Cookie domain for cross-origin deployments (client ≠ server hostname)
43
90
  // Required when client and API are on different subdomains (e.g., app.example.com + api.example.com)
44
91
  // Set to the shared parent domain with a leading dot: ".example.com"
45
92
  // Leave empty for same-origin deployments or local development
46
- COOKIE_DOMAIN: z.string().optional(),
93
+ COOKIE_DOMAIN: optionalEnv(z.string().optional()),
47
94
 
48
95
  // --- Account Activation ---
49
96
  // When true (default), new users are created as inactive and must verify
50
97
  // 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'),
98
+ REQUIRE_USER_VERIFICATION: optionalEnv(z.string().default('true').transform((val) => val === 'true')),
52
99
 
53
100
  // --- CORS ---
54
101
  // In development: CORS_ORIGIN is optional (allows all origins)
55
102
  // In production: REQUIRED for security
56
103
  // Supports comma-separated multiple origins: "https://a.com,https://b.com"
57
- CORS_ORIGIN: z.string().optional(),
104
+ CORS_ORIGIN: optionalEnv(z.string().optional()),
58
105
 
59
106
  // --- Logging ---
60
- LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
107
+ LOG_LEVEL: optionalEnv(z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info')),
61
108
 
62
109
  // --- 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'),
110
+ RESEND_API_KEY: optionalEnv(z.string().min(1).optional()),
111
+ RESEND_FROM_EMAIL: optionalEnv(z.string().email().default('onboarding@resend.dev')),
112
+ CLIENT_URL: optionalEnv(z.string().url().default('http://localhost:3000')),
66
113
 
67
114
  // --- Error Tracking (Optional) ---
68
- SENTRY_DSN: z.string().url().optional(),
115
+ SENTRY_DSN: optionalEnv(z.string().url().optional()),
69
116
  });
70
117
 
71
118
  const parsed = envSchema.safeParse(process.env);
@@ -75,21 +122,18 @@ if (!parsed.success) {
75
122
  .map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`)
76
123
  .join('\n');
77
124
 
78
- // eslint-disable-next-line no-console
79
125
  console.error(`\nEnvironment validation failed:\n${formatted}\n`);
80
126
  process.exit(1);
81
127
  }
82
128
 
83
129
  // Validate CORS_ORIGIN in production
84
130
  if (parsed.data.NODE_ENV === 'production' && !parsed.data.CORS_ORIGIN) {
85
- // eslint-disable-next-line no-console
86
131
  console.error('\nCORS_ORIGIN is required in production for security\n');
87
132
  process.exit(1);
88
133
  }
89
134
 
90
135
  // Validate RESEND_API_KEY when email verification is enabled
91
136
  if (parsed.data.REQUIRE_USER_VERIFICATION && !parsed.data.RESEND_API_KEY) {
92
- // eslint-disable-next-line no-console
93
137
  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
138
  process.exit(1);
95
139
  }
@@ -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';
@@ -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,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
+ });
@@ -4,8 +4,14 @@ import { env } from '@config/env.js';
4
4
  import { prisma } from '@libs/prisma.js';
5
5
  import { UnauthorizedError, ForbiddenError, BadRequestError } from '@shared/errors/errors.js';
6
6
  import { clearAuthCookies } from '@libs/cookies.js';
7
+ import { parseDurationMs } from '@libs/duration.js';
7
8
  import type { JwtPayload, UserRole } from '@shared/types/index.js';
8
9
 
10
+ // Re-export so existing consumers of `parseDurationMs` from '@libs/auth.js'
11
+ // keep working. The implementation lives in the import-free leaf module
12
+ // duration.ts to avoid the cookies.ts ↔ auth.ts circular import (TDZ crash).
13
+ export { parseDurationMs } from '@libs/duration.js';
14
+
9
15
  let app: FastifyInstance | null = null;
10
16
 
11
17
  export function initAuth(fastify: FastifyInstance): void {
@@ -30,22 +36,6 @@ export function generateRefreshToken(): string {
30
36
  return uuidv4();
31
37
  }
32
38
 
33
- export function parseDurationMs(duration: string, fallbackMs: number): number {
34
- const match = duration.match(/^(\d+)([smhd])$/);
35
- if (!match) return fallbackMs;
36
-
37
- const value = parseInt(match[1], 10);
38
- const unit = match[2];
39
- const multipliers: Record<string, number> = {
40
- s: 1000,
41
- m: 60 * 1000,
42
- h: 60 * 60 * 1000,
43
- d: 24 * 60 * 60 * 1000,
44
- };
45
-
46
- return value * multipliers[unit];
47
- }
48
-
49
39
  export function getRefreshTokenExpiresAt(): Date {
50
40
  const ms = parseDurationMs(env.JWT_REFRESH_EXPIRY, 7 * 24 * 60 * 60 * 1000);
51
41
  return new Date(Date.now() + ms);
@@ -1,6 +1,6 @@
1
1
  import type { FastifyReply } from 'fastify';
2
2
  import { env } from '@config/env.js';
3
- import { parseDurationMs } from '@libs/auth.js';
3
+ import { parseDurationMs } from '@libs/duration.js';
4
4
 
5
5
  const isProduction = env.NODE_ENV === 'production';
6
6