create-tigra 1.1.0 → 2.0.1
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/LICENSE +21 -21
- package/README.md +80 -87
- package/bin/create-tigra.js +259 -308
- package/package.json +49 -41
- package/template/_claude/QUICK_REFERENCE.md +193 -0
- package/template/_claude/README.md +53 -0
- package/template/_claude/commands/create-client.md +881 -0
- package/template/_claude/commands/create-server.md +383 -0
- package/template/_claude/rules/client/01-project-structure.md +133 -0
- package/template/_claude/rules/client/02-components-and-types.md +146 -0
- package/template/_claude/rules/client/03-data-and-state.md +156 -0
- package/template/_claude/rules/client/04-design-system.md +185 -0
- package/template/_claude/rules/client/05-security.md +55 -0
- package/template/_claude/rules/client/06-ux-checklist.md +81 -0
- package/template/_claude/rules/client/core.md +42 -0
- package/template/_claude/rules/global/core.md +77 -0
- package/template/_claude/rules/server/core.md +50 -0
- package/template/_claude/rules/server/database.md +124 -0
- package/template/_claude/rules/server/project-conventions.md +150 -0
- package/template/_claude/rules/server/response-handling.md +144 -0
- package/template/client/.env.example +5 -0
- package/template/client/README.md +36 -0
- package/template/client/components.json +23 -0
- package/template/client/eslint.config.mjs +18 -0
- package/template/client/next.config.ts +34 -0
- package/template/client/package.json +44 -0
- package/template/client/postcss.config.mjs +7 -0
- package/template/client/src/app/(auth)/layout.tsx +18 -0
- package/template/client/src/app/(auth)/login/page.tsx +13 -0
- package/template/client/src/app/(auth)/register/page.tsx +13 -0
- package/template/client/src/app/(main)/dashboard/page.tsx +22 -0
- package/template/client/src/app/(main)/layout.tsx +11 -0
- package/template/client/src/app/error.tsx +27 -0
- package/template/client/src/app/favicon.ico +0 -0
- package/template/client/src/app/globals.css +145 -0
- package/template/client/src/app/layout.tsx +36 -0
- package/template/client/src/app/loading.tsx +11 -0
- package/template/client/src/app/not-found.tsx +23 -0
- package/template/client/src/app/page.tsx +45 -0
- package/template/client/src/app/providers.tsx +43 -0
- package/template/client/src/components/common/ConfirmDialog.tsx +56 -0
- package/template/client/src/components/common/EmptyState.tsx +31 -0
- package/template/client/src/components/common/LoadingSpinner.tsx +30 -0
- package/template/client/src/components/common/Pagination.tsx +55 -0
- package/template/client/src/components/layout/Footer.tsx +17 -0
- package/template/client/src/components/layout/Header.tsx +173 -0
- package/template/client/src/components/layout/MainLayout.tsx +18 -0
- package/template/client/src/components/ui/alert-dialog.tsx +196 -0
- package/template/client/src/components/ui/badge.tsx +48 -0
- package/template/client/src/components/ui/button.tsx +64 -0
- package/template/client/src/components/ui/card.tsx +92 -0
- package/template/client/src/components/ui/input.tsx +21 -0
- package/template/client/src/components/ui/label.tsx +24 -0
- package/template/client/src/components/ui/select.tsx +190 -0
- package/template/client/src/components/ui/skeleton.tsx +13 -0
- package/template/client/src/components/ui/table.tsx +116 -0
- package/template/client/src/features/auth/components/AuthInitializer.tsx +55 -0
- package/template/client/src/features/auth/components/LoginForm.tsx +107 -0
- package/template/client/src/features/auth/components/RegisterForm.tsx +178 -0
- package/template/client/src/features/auth/hooks/useAuth.ts +84 -0
- package/template/client/src/features/auth/services/auth.service.ts +52 -0
- package/template/client/src/features/auth/store/authSlice.ts +38 -0
- package/template/client/src/features/auth/types/auth.types.ts +32 -0
- package/template/client/src/hooks/useDebounce.ts +14 -0
- package/template/client/src/hooks/useLocalStorage.ts +55 -0
- package/template/client/src/hooks/useMediaQuery.ts +27 -0
- package/template/client/src/lib/api/api.types.ts +34 -0
- package/template/client/src/lib/api/axios.config.ts +98 -0
- package/template/client/src/lib/constants/api-endpoints.ts +18 -0
- package/template/client/src/lib/constants/app.constants.ts +12 -0
- package/template/client/src/lib/constants/routes.ts +9 -0
- package/template/client/src/lib/utils/error.ts +32 -0
- package/template/client/src/lib/utils/format.ts +37 -0
- package/template/client/src/lib/utils/security.ts +34 -0
- package/template/client/src/lib/utils.ts +6 -0
- package/template/client/src/middleware.ts +57 -0
- package/template/client/src/store/hooks.ts +7 -0
- package/template/client/src/store/index.ts +12 -0
- package/template/client/src/types/index.ts +3 -0
- package/template/client/tsconfig.json +34 -0
- package/template/gitignore +34 -0
- package/template/server/.dockerignore +66 -0
- package/template/server/.env.example +96 -69
- package/template/server/.env.production.example +90 -0
- package/template/server/Dockerfile +94 -0
- package/template/server/docker-compose.yml +82 -111
- package/template/server/docs/logging.md +62 -0
- package/template/server/eslint.config.mjs +17 -0
- package/template/server/package.json +68 -81
- package/template/server/phpmyadmin-config.php +26 -0
- package/template/server/postman_collection.json +666 -0
- package/template/server/prisma/schema.prisma +77 -93
- package/template/server/prisma/seed.ts +46 -142
- package/template/server/scripts/flush-redis.ts +41 -0
- package/template/server/src/app.ts +243 -71
- package/template/server/src/config/env.ts +67 -94
- package/template/server/src/libs/auth.ts +88 -0
- package/template/server/src/libs/cleanup.ts +35 -0
- package/template/server/src/libs/cookies.ts +46 -0
- package/template/server/src/libs/logger.ts +33 -60
- package/template/server/src/libs/monitoring.ts +205 -0
- package/template/server/src/libs/password.ts +38 -0
- package/template/server/src/libs/prisma.ts +68 -0
- package/template/server/src/libs/redis.ts +60 -79
- package/template/server/src/libs/requestLogger.ts +66 -0
- package/template/server/src/libs/storage/file-storage.service.ts +211 -0
- package/template/server/src/libs/storage/file-validator.ts +97 -0
- package/template/server/src/libs/storage/filename-sanitizer.ts +71 -0
- package/template/server/src/libs/storage/image-optimizer.service.ts +144 -0
- package/template/server/src/modules/auth/__tests__/auth.service.test.ts +365 -0
- package/template/server/src/modules/auth/auth.controller.ts +90 -141
- package/template/server/src/modules/auth/auth.repo.ts +120 -218
- package/template/server/src/modules/auth/auth.routes.ts +96 -83
- package/template/server/src/modules/auth/auth.schemas.ts +35 -137
- package/template/server/src/modules/auth/auth.service.ts +286 -329
- package/template/server/src/modules/auth/session.repo.ts +110 -0
- package/template/server/src/modules/users/users.controller.ts +120 -0
- package/template/server/src/modules/users/users.repo.ts +77 -0
- package/template/server/src/modules/users/users.routes.ts +89 -0
- package/template/server/src/modules/users/users.schemas.ts +21 -0
- package/template/server/src/modules/users/users.service.ts +169 -0
- package/template/server/src/server.ts +58 -139
- package/template/server/src/shared/errors/AppError.ts +21 -0
- package/template/server/src/shared/errors/errors.ts +43 -0
- package/template/server/src/shared/responses/paginatedResponse.ts +38 -0
- package/template/server/src/shared/responses/successResponse.ts +17 -0
- package/template/server/src/shared/schemas/pagination.schema.ts +12 -0
- package/template/server/src/shared/types/index.ts +26 -0
- package/template/server/src/test/setup.ts +74 -38
- package/template/server/tsconfig.json +27 -89
- package/template/server/uploads/avatars/.gitkeep +1 -0
- package/template/server/vitest.config.ts +43 -98
- package/template/.agent/rules/client/01-project-structure.md +0 -326
- package/template/.agent/rules/client/02-component-patterns.md +0 -249
- package/template/.agent/rules/client/03-typescript-rules.md +0 -226
- package/template/.agent/rules/client/04-state-management.md +0 -474
- package/template/.agent/rules/client/05-api-integration.md +0 -129
- package/template/.agent/rules/client/06-forms-validation.md +0 -129
- package/template/.agent/rules/client/07-common-patterns.md +0 -150
- package/template/.agent/rules/client/08-color-system.md +0 -93
- package/template/.agent/rules/client/09-security-rules.md +0 -97
- package/template/.agent/rules/client/10-testing-strategy.md +0 -370
- package/template/.agent/rules/global/ai-edit-safety.md +0 -38
- package/template/.agent/rules/server/01-db-and-migrations.md +0 -242
- package/template/.agent/rules/server/02-general-rules.md +0 -111
- package/template/.agent/rules/server/03-migrations.md +0 -20
- package/template/.agent/rules/server/04-pagination.md +0 -130
- package/template/.agent/rules/server/05-project-conventions.md +0 -71
- package/template/.agent/rules/server/06-response-handling.md +0 -173
- package/template/.agent/rules/server/07-testing-strategy.md +0 -506
- package/template/.agent/rules/server/08-observability.md +0 -180
- package/template/.agent/rules/server/10-background-jobs-v2.md +0 -185
- package/template/.agent/rules/server/11-rate-limiting-v2.md +0 -210
- package/template/.agent/rules/server/12-performance-optimization.md +0 -567
- package/template/.claude/rules/client-01-project-structure.md +0 -327
- package/template/.claude/rules/client-02-component-patterns.md +0 -250
- package/template/.claude/rules/client-03-typescript-rules.md +0 -227
- package/template/.claude/rules/client-04-state-management.md +0 -475
- package/template/.claude/rules/client-05-api-integration.md +0 -130
- package/template/.claude/rules/client-06-forms-validation.md +0 -130
- package/template/.claude/rules/client-07-common-patterns.md +0 -151
- package/template/.claude/rules/client-08-color-system.md +0 -94
- package/template/.claude/rules/client-09-security-rules.md +0 -98
- package/template/.claude/rules/client-10-testing-strategy.md +0 -371
- package/template/.claude/rules/global-ai-edit-safety.md +0 -39
- package/template/.claude/rules/server-01-db-and-migrations.md +0 -243
- package/template/.claude/rules/server-02-general-rules.md +0 -112
- package/template/.claude/rules/server-03-migrations.md +0 -21
- package/template/.claude/rules/server-04-pagination.md +0 -131
- package/template/.claude/rules/server-05-project-conventions.md +0 -72
- package/template/.claude/rules/server-06-response-handling.md +0 -174
- package/template/.claude/rules/server-07-testing-strategy.md +0 -507
- package/template/.claude/rules/server-08-observability.md +0 -181
- package/template/.claude/rules/server-10-background-jobs-v2.md +0 -186
- package/template/.claude/rules/server-11-rate-limiting-v2.md +0 -211
- package/template/.claude/rules/server-12-performance-optimization.md +0 -568
- package/template/.cursor/rules/client-01-project-structure.mdc +0 -327
- package/template/.cursor/rules/client-02-component-patterns.mdc +0 -250
- package/template/.cursor/rules/client-03-typescript-rules.mdc +0 -227
- package/template/.cursor/rules/client-04-state-management.mdc +0 -475
- package/template/.cursor/rules/client-05-api-integration.mdc +0 -130
- package/template/.cursor/rules/client-06-forms-validation.mdc +0 -130
- package/template/.cursor/rules/client-07-common-patterns.mdc +0 -151
- package/template/.cursor/rules/client-08-color-system.mdc +0 -94
- package/template/.cursor/rules/client-09-security-rules.mdc +0 -98
- package/template/.cursor/rules/client-10-testing-strategy.mdc +0 -371
- package/template/.cursor/rules/global-ai-edit-safety.mdc +0 -39
- package/template/.cursor/rules/server-01-db-and-migrations.mdc +0 -243
- package/template/.cursor/rules/server-02-general-rules.mdc +0 -112
- package/template/.cursor/rules/server-03-migrations.mdc +0 -21
- package/template/.cursor/rules/server-04-pagination.mdc +0 -131
- package/template/.cursor/rules/server-05-project-conventions.mdc +0 -72
- package/template/.cursor/rules/server-06-response-handling.mdc +0 -174
- package/template/.cursor/rules/server-07-testing-strategy.mdc +0 -507
- package/template/.cursor/rules/server-08-observability.mdc +0 -181
- package/template/.cursor/rules/server-09-api-documentation-v2.mdc +0 -169
- package/template/.cursor/rules/server-10-background-jobs-v2.mdc +0 -186
- package/template/.cursor/rules/server-11-rate-limiting-v2.mdc +0 -211
- package/template/.cursor/rules/server-12-performance-optimization.mdc +0 -568
- package/template/CLAUDE.md +0 -207
- package/template/server/.tsc-aliasrc.json +0 -13
- package/template/server/IMPORT_FIX_CHECKLIST.md +0 -98
- package/template/server/IMPORT_FIX_COMPLETE.md +0 -89
- package/template/server/README.md +0 -183
- package/template/server/REMAINING_IMPORT_FIXES.md +0 -150
- package/template/server/SECURITY.md +0 -190
- package/template/server/Tigra-API.postman_collection.json +0 -733
- package/template/server/biome.json +0 -42
- package/template/server/scripts/fix-all-imports.ps1 +0 -52
- package/template/server/scripts/fix-imports-reference.ps1 +0 -16
- package/template/server/scripts/fix-imports.mjs +0 -55
- package/template/server/scripts/setup-env.js +0 -50
- package/template/server/scripts/wait-for-db.js +0 -60
- package/template/server/src/hooks/request-timing.hook.ts +0 -26
- package/template/server/src/libs/auth/authenticate.middleware.ts +0 -22
- package/template/server/src/libs/auth/rbac.middleware.test.ts +0 -134
- package/template/server/src/libs/auth/rbac.middleware.ts +0 -147
- package/template/server/src/libs/db.ts +0 -76
- package/template/server/src/libs/error-handler.ts +0 -89
- package/template/server/src/libs/queue.ts +0 -79
- package/template/server/src/modules/admin/admin.controller.ts +0 -122
- package/template/server/src/modules/admin/admin.routes.ts +0 -62
- package/template/server/src/modules/admin/admin.schemas.ts +0 -35
- package/template/server/src/modules/admin/admin.service.ts +0 -167
- package/template/server/src/modules/auth/auth.integration.test.ts +0 -150
- package/template/server/src/modules/auth/auth.service.test.ts +0 -119
- package/template/server/src/modules/auth/auth.types.ts +0 -97
- package/template/server/src/modules/resources/resources.controller.ts +0 -218
- package/template/server/src/modules/resources/resources.repo.ts +0 -253
- package/template/server/src/modules/resources/resources.routes.ts +0 -116
- package/template/server/src/modules/resources/resources.schemas.ts +0 -146
- package/template/server/src/modules/resources/resources.service.ts +0 -218
- package/template/server/src/modules/resources/resources.types.ts +0 -73
- package/template/server/src/plugins/rate-limit.plugin.ts +0 -21
- package/template/server/src/plugins/security.plugin.ts +0 -21
- package/template/server/src/routes/health.routes.ts +0 -31
- package/template/server/src/types/fastify.d.ts +0 -36
- package/template/server/src/utils/errors.ts +0 -108
- package/template/server/src/utils/pagination.ts +0 -120
- package/template/server/src/utils/response.ts +0 -110
- package/template/server/src/workers/file.worker.ts +0 -106
- package/template/server/tsconfig.build.json +0 -30
- package/template/server/tsconfig.test.json +0 -22
|
@@ -1,71 +1,243 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
import
|
|
14
|
-
import {
|
|
15
|
-
import
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
import {
|
|
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
|
-
|
|
71
|
-
|
|
1
|
+
import Fastify, { type FastifyError } from 'fastify';
|
|
2
|
+
import cors from '@fastify/cors';
|
|
3
|
+
import helmet from '@fastify/helmet';
|
|
4
|
+
import rateLimit from '@fastify/rate-limit';
|
|
5
|
+
import cookie from '@fastify/cookie';
|
|
6
|
+
import jwt from '@fastify/jwt';
|
|
7
|
+
import compress from '@fastify/compress';
|
|
8
|
+
import multipart from '@fastify/multipart';
|
|
9
|
+
import fastifyStatic from '@fastify/static';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
import { env } from '@config/env.js';
|
|
13
|
+
import { logger } from '@libs/logger.js';
|
|
14
|
+
import { markRequestStart, logRequestLine } from '@libs/requestLogger.js';
|
|
15
|
+
import { initAuth } from '@libs/auth.js';
|
|
16
|
+
import { isAppError } from '@shared/errors/AppError.js';
|
|
17
|
+
import { successResponse, errorResponse } from '@shared/responses/successResponse.js';
|
|
18
|
+
import { authRoutes } from '@modules/auth/auth.routes.js';
|
|
19
|
+
import { usersRoutes } from '@modules/users/users.routes.js';
|
|
20
|
+
import { fileStorageService } from '@libs/storage/file-storage.service.js';
|
|
21
|
+
import { registerCleanupJob } from '@libs/cleanup.js';
|
|
22
|
+
import {
|
|
23
|
+
serializerCompiler,
|
|
24
|
+
validatorCompiler,
|
|
25
|
+
type ZodTypeProvider,
|
|
26
|
+
} from 'fastify-type-provider-zod';
|
|
27
|
+
|
|
28
|
+
// Import types to register Fastify augmentations
|
|
29
|
+
import type {} from '@shared/types/index.js';
|
|
30
|
+
|
|
31
|
+
export async function buildApp() {
|
|
32
|
+
const app = Fastify({
|
|
33
|
+
logger: false,
|
|
34
|
+
// Trust proxy headers (X-Forwarded-For) for accurate client IP behind Nginx/load balancer
|
|
35
|
+
trustProxy: env.NODE_ENV === 'production',
|
|
36
|
+
// Graceful shutdown configuration
|
|
37
|
+
forceCloseConnections: true, // Force close idle connections on shutdown
|
|
38
|
+
requestTimeout: 30000, // 30s request timeout
|
|
39
|
+
connectionTimeout: 60000, // 60s connection timeout
|
|
40
|
+
keepAliveTimeout: 5000, // 5s keep-alive timeout
|
|
41
|
+
// Request body size limits (prevent DoS attacks)
|
|
42
|
+
bodyLimit: 1048576, // 1MB default limit (1024 * 1024)
|
|
43
|
+
}).withTypeProvider<ZodTypeProvider>();
|
|
44
|
+
|
|
45
|
+
// Set Zod validator and serializer
|
|
46
|
+
app.setValidatorCompiler(validatorCompiler);
|
|
47
|
+
app.setSerializerCompiler(serializerCompiler);
|
|
48
|
+
|
|
49
|
+
// --- Plugins ---
|
|
50
|
+
// CORS: Allow all origins in development, specific origin(s) in production
|
|
51
|
+
const corsOrigin = env.NODE_ENV === 'development'
|
|
52
|
+
? true
|
|
53
|
+
: env.CORS_ORIGIN?.includes(',')
|
|
54
|
+
? env.CORS_ORIGIN.split(',').map((o) => o.trim())
|
|
55
|
+
: env.CORS_ORIGIN;
|
|
56
|
+
|
|
57
|
+
await app.register(cors, {
|
|
58
|
+
origin: corsOrigin,
|
|
59
|
+
credentials: true,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Enhanced security headers for production
|
|
63
|
+
await app.register(helmet, {
|
|
64
|
+
global: true,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Response compression (gzip/brotli) for performance
|
|
68
|
+
await app.register(compress, {
|
|
69
|
+
global: true,
|
|
70
|
+
threshold: 1024, // Only compress responses > 1KB
|
|
71
|
+
encodings: ['gzip', 'deflate'],
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await app.register(rateLimit, {
|
|
75
|
+
global: false,
|
|
76
|
+
max: 100,
|
|
77
|
+
timeWindow: '1 minute',
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await app.register(cookie, {
|
|
81
|
+
secret: env.COOKIE_SECRET || env.JWT_SECRET,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await app.register(jwt, {
|
|
85
|
+
secret: env.JWT_SECRET,
|
|
86
|
+
cookie: {
|
|
87
|
+
cookieName: 'access_token',
|
|
88
|
+
signed: false,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Initialize auth helpers after JWT plugin is registered
|
|
93
|
+
initAuth(app);
|
|
94
|
+
|
|
95
|
+
// File upload handling (multipart/form-data)
|
|
96
|
+
await app.register(multipart, {
|
|
97
|
+
limits: {
|
|
98
|
+
fileSize: 5 * 1024 * 1024, // 5MB max file size
|
|
99
|
+
files: 1, // Only one file per request
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Static file serving for uploads
|
|
104
|
+
// Get __dirname equivalent in ES modules
|
|
105
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
106
|
+
const __dirname = path.dirname(__filename);
|
|
107
|
+
|
|
108
|
+
await app.register(fastifyStatic, {
|
|
109
|
+
root: path.join(__dirname, '..', 'uploads'),
|
|
110
|
+
prefix: '/uploads/',
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Initialize file storage (create directories)
|
|
114
|
+
await fileStorageService.initialize();
|
|
115
|
+
|
|
116
|
+
// --- Request/Response Logging ---
|
|
117
|
+
const skipLogPaths = new Set(['/api/v1/health', '/api/v1/ready', '/api/v1/live']);
|
|
118
|
+
|
|
119
|
+
app.addHook('preHandler', async (request) => {
|
|
120
|
+
const pathname = request.url.split('?')[0];
|
|
121
|
+
if (!skipLogPaths.has(pathname)) {
|
|
122
|
+
markRequestStart(request);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
app.addHook('onResponse', async (request, reply) => {
|
|
127
|
+
const pathname = request.url.split('?')[0];
|
|
128
|
+
if (!skipLogPaths.has(pathname)) {
|
|
129
|
+
logRequestLine(request, reply);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// --- Global Error Handler (must be set before routes) ---
|
|
134
|
+
app.setErrorHandler((error: FastifyError, request, reply) => {
|
|
135
|
+
// AppError — our typed errors (use duck-type check to avoid instanceof issues)
|
|
136
|
+
if (isAppError(error)) {
|
|
137
|
+
return reply.status(error.statusCode).send(errorResponse(error.code, error.message));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Zod validation error
|
|
141
|
+
if (error.name === 'ZodError') {
|
|
142
|
+
return reply.status(422).send(errorResponse('VALIDATION_FAILED', 'Validation failed'));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Fastify validation error
|
|
146
|
+
if (error.validation) {
|
|
147
|
+
return reply.status(400).send(
|
|
148
|
+
errorResponse(
|
|
149
|
+
'BAD_REQUEST',
|
|
150
|
+
error.message || 'Invalid request',
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Fastify plugin errors (file size, rate limiting, etc.)
|
|
156
|
+
// These have statusCode properties but aren't AppError instances
|
|
157
|
+
if (error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
|
|
158
|
+
// Map common Fastify error codes to user-friendly messages
|
|
159
|
+
const errorCodeMap: Record<number, { code: string; message: string }> = {
|
|
160
|
+
413: { code: 'FILE_TOO_LARGE', message: 'File size exceeds the maximum allowed limit' },
|
|
161
|
+
429: { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many requests. Please try again later' },
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const errorInfo = errorCodeMap[error.statusCode] || {
|
|
165
|
+
code: 'BAD_REQUEST',
|
|
166
|
+
message: error.message || 'Bad request',
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
return reply.status(error.statusCode).send(errorResponse(errorInfo.code, errorInfo.message));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Unexpected error — log and return generic 500
|
|
173
|
+
const requestId = request.id || 'unknown';
|
|
174
|
+
logger.error(
|
|
175
|
+
{
|
|
176
|
+
err: error,
|
|
177
|
+
requestId,
|
|
178
|
+
url: request.url,
|
|
179
|
+
method: request.method,
|
|
180
|
+
stack: error.stack,
|
|
181
|
+
},
|
|
182
|
+
`Unhandled error [${requestId}]: ${error.message}`,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
return reply.status(500).send(errorResponse('INTERNAL_ERROR', 'Internal server error'));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// --- Monitoring & Health Checks ---
|
|
189
|
+
const { performHealthCheck, checkReadiness, checkLiveness } = await import('@libs/monitoring.js');
|
|
190
|
+
|
|
191
|
+
// Comprehensive health check (DB + Redis + Memory + Uptime)
|
|
192
|
+
app.get('/api/v1/health', async (_request, reply) => {
|
|
193
|
+
const health = await performHealthCheck();
|
|
194
|
+
|
|
195
|
+
const statusCode = health.status === 'healthy' ? 200 : health.status === 'degraded' ? 200 : 503;
|
|
196
|
+
|
|
197
|
+
return reply.status(statusCode).send(
|
|
198
|
+
successResponse(
|
|
199
|
+
health.status === 'healthy'
|
|
200
|
+
? 'All systems operational'
|
|
201
|
+
: health.status === 'degraded'
|
|
202
|
+
? 'Some systems degraded'
|
|
203
|
+
: 'System unhealthy',
|
|
204
|
+
health,
|
|
205
|
+
),
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Readiness probe (for load balancers / K8s)
|
|
210
|
+
app.get('/api/v1/ready', async (_request, reply) => {
|
|
211
|
+
const ready = await checkReadiness();
|
|
212
|
+
const statusCode = ready ? 200 : 503;
|
|
213
|
+
return reply.status(statusCode).send(
|
|
214
|
+
successResponse(ready ? 'Service is ready' : 'Service not ready', {
|
|
215
|
+
ready,
|
|
216
|
+
timestamp: new Date().toISOString(),
|
|
217
|
+
})
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Liveness probe (for container orchestration)
|
|
222
|
+
app.get('/api/v1/live', (_request, reply) => {
|
|
223
|
+
const alive = checkLiveness();
|
|
224
|
+
const statusCode = alive ? 200 : 503;
|
|
225
|
+
return reply.status(statusCode).send(
|
|
226
|
+
successResponse(alive ? 'Service is alive' : 'Service not alive', {
|
|
227
|
+
alive,
|
|
228
|
+
timestamp: new Date().toISOString(),
|
|
229
|
+
})
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// --- Routes ---
|
|
234
|
+
await app.register(authRoutes, { prefix: '/api/v1' });
|
|
235
|
+
await app.register(usersRoutes, { prefix: '/api/v1' });
|
|
236
|
+
|
|
237
|
+
// --- Cleanup Jobs ---
|
|
238
|
+
await registerCleanupJob(app);
|
|
239
|
+
|
|
240
|
+
return app;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export default buildApp;
|
|
@@ -1,94 +1,67 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
* Throws error if validation fails, preventing server startup
|
|
69
|
-
* with invalid configuration.
|
|
70
|
-
*/
|
|
71
|
-
const parseEnv = () => {
|
|
72
|
-
try {
|
|
73
|
-
return envSchema.parse(process.env);
|
|
74
|
-
} catch (error) {
|
|
75
|
-
if (error instanceof z.ZodError) {
|
|
76
|
-
console.error('Environment validation failed:');
|
|
77
|
-
console.error(JSON.stringify(error.errors, null, 2));
|
|
78
|
-
process.exit(1);
|
|
79
|
-
}
|
|
80
|
-
throw error;
|
|
81
|
-
}
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Typed environment configuration
|
|
86
|
-
*
|
|
87
|
-
* Use this instead of process.env for type safety
|
|
88
|
-
*/
|
|
89
|
-
export const env = parseEnv();
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Type for environment variables
|
|
93
|
-
*/
|
|
94
|
-
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
|
+
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
|
+
// --- JWT Authentication ---
|
|
25
|
+
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
|
|
26
|
+
JWT_ACCESS_EXPIRY: z.string().default('15m'),
|
|
27
|
+
JWT_REFRESH_EXPIRY: z.string().default('7d'),
|
|
28
|
+
|
|
29
|
+
// --- Cookie ---
|
|
30
|
+
// Separate secret for cookie signing (defaults to JWT_SECRET if not set)
|
|
31
|
+
COOKIE_SECRET: z.string().min(32, 'COOKIE_SECRET must be at least 32 characters').optional(),
|
|
32
|
+
|
|
33
|
+
// --- CORS ---
|
|
34
|
+
// In development: CORS_ORIGIN is optional (allows all origins)
|
|
35
|
+
// In production: REQUIRED for security
|
|
36
|
+
// Supports comma-separated multiple origins: "https://a.com,https://b.com"
|
|
37
|
+
CORS_ORIGIN: z.string().optional(),
|
|
38
|
+
|
|
39
|
+
// --- Logging ---
|
|
40
|
+
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
|
41
|
+
|
|
42
|
+
// --- Error Tracking (Optional) ---
|
|
43
|
+
SENTRY_DSN: z.string().url().optional(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const parsed = envSchema.safeParse(process.env);
|
|
47
|
+
|
|
48
|
+
if (!parsed.success) {
|
|
49
|
+
const formatted = parsed.error.issues
|
|
50
|
+
.map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`)
|
|
51
|
+
.join('\n');
|
|
52
|
+
|
|
53
|
+
// eslint-disable-next-line no-console
|
|
54
|
+
console.error(`\nEnvironment validation failed:\n${formatted}\n`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Validate CORS_ORIGIN in production
|
|
59
|
+
if (parsed.data.NODE_ENV === 'production' && !parsed.data.CORS_ORIGIN) {
|
|
60
|
+
// eslint-disable-next-line no-console
|
|
61
|
+
console.error('\nCORS_ORIGIN is required in production for security\n');
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const env = parsed.data;
|
|
66
|
+
|
|
67
|
+
export type Env = z.infer<typeof envSchema>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
+
import { env } from '@config/env.js';
|
|
4
|
+
import { UnauthorizedError, ForbiddenError } from '@shared/errors/errors.js';
|
|
5
|
+
import type { JwtPayload, UserRole } from '@shared/types/index.js';
|
|
6
|
+
|
|
7
|
+
let app: FastifyInstance | null = null;
|
|
8
|
+
|
|
9
|
+
export function initAuth(fastify: FastifyInstance): void {
|
|
10
|
+
app = fastify;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getApp(): FastifyInstance {
|
|
14
|
+
if (!app) {
|
|
15
|
+
throw new Error('Auth not initialized. Call initAuth(fastify) before using auth helpers.');
|
|
16
|
+
}
|
|
17
|
+
return app;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function signAccessToken(payload: JwtPayload): string {
|
|
21
|
+
return getApp().jwt.sign(
|
|
22
|
+
{ userId: payload.userId, role: payload.role },
|
|
23
|
+
{ expiresIn: env.JWT_ACCESS_EXPIRY },
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function generateRefreshToken(): string {
|
|
28
|
+
return uuidv4();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function parseDurationMs(duration: string, fallbackMs: number): number {
|
|
32
|
+
const match = duration.match(/^(\d+)([smhd])$/);
|
|
33
|
+
if (!match) return fallbackMs;
|
|
34
|
+
|
|
35
|
+
const value = parseInt(match[1], 10);
|
|
36
|
+
const unit = match[2];
|
|
37
|
+
const multipliers: Record<string, number> = {
|
|
38
|
+
s: 1000,
|
|
39
|
+
m: 60 * 1000,
|
|
40
|
+
h: 60 * 60 * 1000,
|
|
41
|
+
d: 24 * 60 * 60 * 1000,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return value * multipliers[unit];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getRefreshTokenExpiresAt(): Date {
|
|
48
|
+
const ms = parseDurationMs(env.JWT_REFRESH_EXPIRY, 7 * 24 * 60 * 60 * 1000);
|
|
49
|
+
return new Date(Date.now() + ms);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function authenticate(
|
|
53
|
+
request: FastifyRequest,
|
|
54
|
+
_reply: FastifyReply,
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
try {
|
|
57
|
+
await request.jwtVerify();
|
|
58
|
+
} catch {
|
|
59
|
+
throw new UnauthorizedError('Invalid or expired token');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function optionalAuth(
|
|
64
|
+
request: FastifyRequest,
|
|
65
|
+
_reply: FastifyReply,
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
try {
|
|
68
|
+
await request.jwtVerify();
|
|
69
|
+
} catch {
|
|
70
|
+
// Not authenticated — that's okay for optional auth
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function authorize(...roles: UserRole[]) {
|
|
75
|
+
return async function authorizeHandler(
|
|
76
|
+
request: FastifyRequest,
|
|
77
|
+
_reply: FastifyReply,
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
if (!request.user) {
|
|
80
|
+
throw new UnauthorizedError('Authentication required');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!roles.includes(request.user.role)) {
|
|
84
|
+
throw new ForbiddenError('Insufficient permissions');
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { prisma } from '@libs/prisma.js';
|
|
3
|
+
import { logger } from '@libs/logger.js';
|
|
4
|
+
|
|
5
|
+
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
|
6
|
+
|
|
7
|
+
export async function registerCleanupJob(app: FastifyInstance): Promise<void> {
|
|
8
|
+
const intervalId = setInterval(async () => {
|
|
9
|
+
try {
|
|
10
|
+
const now = new Date();
|
|
11
|
+
|
|
12
|
+
const deletedTokens = await prisma.refreshToken.deleteMany({
|
|
13
|
+
where: { expiresAt: { lt: now } },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const deletedSessions = await prisma.session.deleteMany({
|
|
17
|
+
where: { expiresAt: { lt: now } },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
if (deletedTokens.count > 0 || deletedSessions.count > 0) {
|
|
21
|
+
logger.info(
|
|
22
|
+
{ deletedTokens: deletedTokens.count, deletedSessions: deletedSessions.count },
|
|
23
|
+
'Expired auth records cleaned up',
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
} catch (error) {
|
|
27
|
+
logger.error({ err: error }, 'Failed to clean up expired auth records');
|
|
28
|
+
}
|
|
29
|
+
}, CLEANUP_INTERVAL_MS);
|
|
30
|
+
|
|
31
|
+
// Clear interval on server shutdown
|
|
32
|
+
app.addHook('onClose', async () => {
|
|
33
|
+
clearInterval(intervalId);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { FastifyReply } from 'fastify';
|
|
2
|
+
import { env } from '@config/env.js';
|
|
3
|
+
import { parseDurationMs } from '@libs/auth.js';
|
|
4
|
+
|
|
5
|
+
const isProduction = env.NODE_ENV === 'production';
|
|
6
|
+
|
|
7
|
+
const ACCESS_TOKEN_MAX_AGE_MS = parseDurationMs(env.JWT_ACCESS_EXPIRY, 15 * 60 * 1000);
|
|
8
|
+
const REFRESH_TOKEN_MAX_AGE_MS = parseDurationMs(env.JWT_REFRESH_EXPIRY, 7 * 24 * 60 * 60 * 1000);
|
|
9
|
+
|
|
10
|
+
export function setAuthCookies(
|
|
11
|
+
reply: FastifyReply,
|
|
12
|
+
accessToken: string,
|
|
13
|
+
refreshToken: string,
|
|
14
|
+
): void {
|
|
15
|
+
reply.setCookie('access_token', accessToken, {
|
|
16
|
+
httpOnly: true,
|
|
17
|
+
secure: isProduction,
|
|
18
|
+
sameSite: 'strict',
|
|
19
|
+
path: '/',
|
|
20
|
+
maxAge: Math.floor(ACCESS_TOKEN_MAX_AGE_MS / 1000), // setCookie expects seconds
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
reply.setCookie('refresh_token', refreshToken, {
|
|
24
|
+
httpOnly: true,
|
|
25
|
+
secure: isProduction,
|
|
26
|
+
sameSite: 'strict',
|
|
27
|
+
path: '/api/v1/auth',
|
|
28
|
+
maxAge: Math.floor(REFRESH_TOKEN_MAX_AGE_MS / 1000),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function clearAuthCookies(reply: FastifyReply): void {
|
|
33
|
+
reply.clearCookie('access_token', {
|
|
34
|
+
httpOnly: true,
|
|
35
|
+
secure: isProduction,
|
|
36
|
+
sameSite: 'strict',
|
|
37
|
+
path: '/',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
reply.clearCookie('refresh_token', {
|
|
41
|
+
httpOnly: true,
|
|
42
|
+
secure: isProduction,
|
|
43
|
+
sameSite: 'strict',
|
|
44
|
+
path: '/api/v1/auth',
|
|
45
|
+
});
|
|
46
|
+
}
|