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.
- 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 +42 -0
- package/template/server/.env.example.production +40 -0
- package/template/server/Dockerfile +29 -5
- package/template/server/docker-compose.yml +15 -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 +40 -8
- package/template/server/src/config/env.ts +72 -28
- package/template/server/src/config/rate-limit.config.ts +16 -0
- package/template/server/src/libs/__tests__/http.test.ts +23 -9
- package/template/server/src/libs/__tests__/origin-check.test.ts +53 -0
- package/template/server/src/libs/auth.ts +6 -16
- 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 +10 -4
- package/template/server/src/libs/origin-check.ts +38 -0
- package/template/server/src/libs/redis.ts +1 -1
- package/template/server/src/modules/auth/__tests__/auth.service.test.ts +274 -44
- 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,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:
|
|
26
|
-
"docker:
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"@fastify/
|
|
35
|
-
"@fastify/
|
|
36
|
-
"@fastify/
|
|
37
|
-
"@fastify/
|
|
38
|
-
"@fastify/
|
|
39
|
-
"@fastify/
|
|
40
|
-
"@
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"fastify
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"pino
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"@
|
|
64
|
-
"@types/
|
|
65
|
-
"@
|
|
66
|
-
"@vitest/
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"typescript
|
|
73
|
-
"
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
// ---
|
|
144
|
-
|
|
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 (!
|
|
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 (!
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
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:
|
|
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(
|
|
26
|
-
|
|
27
|
-
|
|
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(
|
|
32
|
-
|
|
33
|
-
|
|
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({
|
|
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);
|