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.
Files changed (243) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +80 -87
  3. package/bin/create-tigra.js +259 -308
  4. package/package.json +49 -41
  5. package/template/_claude/QUICK_REFERENCE.md +193 -0
  6. package/template/_claude/README.md +53 -0
  7. package/template/_claude/commands/create-client.md +881 -0
  8. package/template/_claude/commands/create-server.md +383 -0
  9. package/template/_claude/rules/client/01-project-structure.md +133 -0
  10. package/template/_claude/rules/client/02-components-and-types.md +146 -0
  11. package/template/_claude/rules/client/03-data-and-state.md +156 -0
  12. package/template/_claude/rules/client/04-design-system.md +185 -0
  13. package/template/_claude/rules/client/05-security.md +55 -0
  14. package/template/_claude/rules/client/06-ux-checklist.md +81 -0
  15. package/template/_claude/rules/client/core.md +42 -0
  16. package/template/_claude/rules/global/core.md +77 -0
  17. package/template/_claude/rules/server/core.md +50 -0
  18. package/template/_claude/rules/server/database.md +124 -0
  19. package/template/_claude/rules/server/project-conventions.md +150 -0
  20. package/template/_claude/rules/server/response-handling.md +144 -0
  21. package/template/client/.env.example +5 -0
  22. package/template/client/README.md +36 -0
  23. package/template/client/components.json +23 -0
  24. package/template/client/eslint.config.mjs +18 -0
  25. package/template/client/next.config.ts +34 -0
  26. package/template/client/package.json +44 -0
  27. package/template/client/postcss.config.mjs +7 -0
  28. package/template/client/src/app/(auth)/layout.tsx +18 -0
  29. package/template/client/src/app/(auth)/login/page.tsx +13 -0
  30. package/template/client/src/app/(auth)/register/page.tsx +13 -0
  31. package/template/client/src/app/(main)/dashboard/page.tsx +22 -0
  32. package/template/client/src/app/(main)/layout.tsx +11 -0
  33. package/template/client/src/app/error.tsx +27 -0
  34. package/template/client/src/app/favicon.ico +0 -0
  35. package/template/client/src/app/globals.css +145 -0
  36. package/template/client/src/app/layout.tsx +36 -0
  37. package/template/client/src/app/loading.tsx +11 -0
  38. package/template/client/src/app/not-found.tsx +23 -0
  39. package/template/client/src/app/page.tsx +45 -0
  40. package/template/client/src/app/providers.tsx +43 -0
  41. package/template/client/src/components/common/ConfirmDialog.tsx +56 -0
  42. package/template/client/src/components/common/EmptyState.tsx +31 -0
  43. package/template/client/src/components/common/LoadingSpinner.tsx +30 -0
  44. package/template/client/src/components/common/Pagination.tsx +55 -0
  45. package/template/client/src/components/layout/Footer.tsx +17 -0
  46. package/template/client/src/components/layout/Header.tsx +173 -0
  47. package/template/client/src/components/layout/MainLayout.tsx +18 -0
  48. package/template/client/src/components/ui/alert-dialog.tsx +196 -0
  49. package/template/client/src/components/ui/badge.tsx +48 -0
  50. package/template/client/src/components/ui/button.tsx +64 -0
  51. package/template/client/src/components/ui/card.tsx +92 -0
  52. package/template/client/src/components/ui/input.tsx +21 -0
  53. package/template/client/src/components/ui/label.tsx +24 -0
  54. package/template/client/src/components/ui/select.tsx +190 -0
  55. package/template/client/src/components/ui/skeleton.tsx +13 -0
  56. package/template/client/src/components/ui/table.tsx +116 -0
  57. package/template/client/src/features/auth/components/AuthInitializer.tsx +55 -0
  58. package/template/client/src/features/auth/components/LoginForm.tsx +107 -0
  59. package/template/client/src/features/auth/components/RegisterForm.tsx +178 -0
  60. package/template/client/src/features/auth/hooks/useAuth.ts +84 -0
  61. package/template/client/src/features/auth/services/auth.service.ts +52 -0
  62. package/template/client/src/features/auth/store/authSlice.ts +38 -0
  63. package/template/client/src/features/auth/types/auth.types.ts +32 -0
  64. package/template/client/src/hooks/useDebounce.ts +14 -0
  65. package/template/client/src/hooks/useLocalStorage.ts +55 -0
  66. package/template/client/src/hooks/useMediaQuery.ts +27 -0
  67. package/template/client/src/lib/api/api.types.ts +34 -0
  68. package/template/client/src/lib/api/axios.config.ts +98 -0
  69. package/template/client/src/lib/constants/api-endpoints.ts +18 -0
  70. package/template/client/src/lib/constants/app.constants.ts +12 -0
  71. package/template/client/src/lib/constants/routes.ts +9 -0
  72. package/template/client/src/lib/utils/error.ts +32 -0
  73. package/template/client/src/lib/utils/format.ts +37 -0
  74. package/template/client/src/lib/utils/security.ts +34 -0
  75. package/template/client/src/lib/utils.ts +6 -0
  76. package/template/client/src/middleware.ts +57 -0
  77. package/template/client/src/store/hooks.ts +7 -0
  78. package/template/client/src/store/index.ts +12 -0
  79. package/template/client/src/types/index.ts +3 -0
  80. package/template/client/tsconfig.json +34 -0
  81. package/template/gitignore +34 -0
  82. package/template/server/.dockerignore +66 -0
  83. package/template/server/.env.example +96 -69
  84. package/template/server/.env.production.example +90 -0
  85. package/template/server/Dockerfile +94 -0
  86. package/template/server/docker-compose.yml +82 -111
  87. package/template/server/docs/logging.md +62 -0
  88. package/template/server/eslint.config.mjs +17 -0
  89. package/template/server/package.json +68 -81
  90. package/template/server/phpmyadmin-config.php +26 -0
  91. package/template/server/postman_collection.json +666 -0
  92. package/template/server/prisma/schema.prisma +77 -93
  93. package/template/server/prisma/seed.ts +46 -142
  94. package/template/server/scripts/flush-redis.ts +41 -0
  95. package/template/server/src/app.ts +243 -71
  96. package/template/server/src/config/env.ts +67 -94
  97. package/template/server/src/libs/auth.ts +88 -0
  98. package/template/server/src/libs/cleanup.ts +35 -0
  99. package/template/server/src/libs/cookies.ts +46 -0
  100. package/template/server/src/libs/logger.ts +33 -60
  101. package/template/server/src/libs/monitoring.ts +205 -0
  102. package/template/server/src/libs/password.ts +38 -0
  103. package/template/server/src/libs/prisma.ts +68 -0
  104. package/template/server/src/libs/redis.ts +60 -79
  105. package/template/server/src/libs/requestLogger.ts +66 -0
  106. package/template/server/src/libs/storage/file-storage.service.ts +211 -0
  107. package/template/server/src/libs/storage/file-validator.ts +97 -0
  108. package/template/server/src/libs/storage/filename-sanitizer.ts +71 -0
  109. package/template/server/src/libs/storage/image-optimizer.service.ts +144 -0
  110. package/template/server/src/modules/auth/__tests__/auth.service.test.ts +365 -0
  111. package/template/server/src/modules/auth/auth.controller.ts +90 -141
  112. package/template/server/src/modules/auth/auth.repo.ts +120 -218
  113. package/template/server/src/modules/auth/auth.routes.ts +96 -83
  114. package/template/server/src/modules/auth/auth.schemas.ts +35 -137
  115. package/template/server/src/modules/auth/auth.service.ts +286 -329
  116. package/template/server/src/modules/auth/session.repo.ts +110 -0
  117. package/template/server/src/modules/users/users.controller.ts +120 -0
  118. package/template/server/src/modules/users/users.repo.ts +77 -0
  119. package/template/server/src/modules/users/users.routes.ts +89 -0
  120. package/template/server/src/modules/users/users.schemas.ts +21 -0
  121. package/template/server/src/modules/users/users.service.ts +169 -0
  122. package/template/server/src/server.ts +58 -139
  123. package/template/server/src/shared/errors/AppError.ts +21 -0
  124. package/template/server/src/shared/errors/errors.ts +43 -0
  125. package/template/server/src/shared/responses/paginatedResponse.ts +38 -0
  126. package/template/server/src/shared/responses/successResponse.ts +17 -0
  127. package/template/server/src/shared/schemas/pagination.schema.ts +12 -0
  128. package/template/server/src/shared/types/index.ts +26 -0
  129. package/template/server/src/test/setup.ts +74 -38
  130. package/template/server/tsconfig.json +27 -89
  131. package/template/server/uploads/avatars/.gitkeep +1 -0
  132. package/template/server/vitest.config.ts +43 -98
  133. package/template/.agent/rules/client/01-project-structure.md +0 -326
  134. package/template/.agent/rules/client/02-component-patterns.md +0 -249
  135. package/template/.agent/rules/client/03-typescript-rules.md +0 -226
  136. package/template/.agent/rules/client/04-state-management.md +0 -474
  137. package/template/.agent/rules/client/05-api-integration.md +0 -129
  138. package/template/.agent/rules/client/06-forms-validation.md +0 -129
  139. package/template/.agent/rules/client/07-common-patterns.md +0 -150
  140. package/template/.agent/rules/client/08-color-system.md +0 -93
  141. package/template/.agent/rules/client/09-security-rules.md +0 -97
  142. package/template/.agent/rules/client/10-testing-strategy.md +0 -370
  143. package/template/.agent/rules/global/ai-edit-safety.md +0 -38
  144. package/template/.agent/rules/server/01-db-and-migrations.md +0 -242
  145. package/template/.agent/rules/server/02-general-rules.md +0 -111
  146. package/template/.agent/rules/server/03-migrations.md +0 -20
  147. package/template/.agent/rules/server/04-pagination.md +0 -130
  148. package/template/.agent/rules/server/05-project-conventions.md +0 -71
  149. package/template/.agent/rules/server/06-response-handling.md +0 -173
  150. package/template/.agent/rules/server/07-testing-strategy.md +0 -506
  151. package/template/.agent/rules/server/08-observability.md +0 -180
  152. package/template/.agent/rules/server/10-background-jobs-v2.md +0 -185
  153. package/template/.agent/rules/server/11-rate-limiting-v2.md +0 -210
  154. package/template/.agent/rules/server/12-performance-optimization.md +0 -567
  155. package/template/.claude/rules/client-01-project-structure.md +0 -327
  156. package/template/.claude/rules/client-02-component-patterns.md +0 -250
  157. package/template/.claude/rules/client-03-typescript-rules.md +0 -227
  158. package/template/.claude/rules/client-04-state-management.md +0 -475
  159. package/template/.claude/rules/client-05-api-integration.md +0 -130
  160. package/template/.claude/rules/client-06-forms-validation.md +0 -130
  161. package/template/.claude/rules/client-07-common-patterns.md +0 -151
  162. package/template/.claude/rules/client-08-color-system.md +0 -94
  163. package/template/.claude/rules/client-09-security-rules.md +0 -98
  164. package/template/.claude/rules/client-10-testing-strategy.md +0 -371
  165. package/template/.claude/rules/global-ai-edit-safety.md +0 -39
  166. package/template/.claude/rules/server-01-db-and-migrations.md +0 -243
  167. package/template/.claude/rules/server-02-general-rules.md +0 -112
  168. package/template/.claude/rules/server-03-migrations.md +0 -21
  169. package/template/.claude/rules/server-04-pagination.md +0 -131
  170. package/template/.claude/rules/server-05-project-conventions.md +0 -72
  171. package/template/.claude/rules/server-06-response-handling.md +0 -174
  172. package/template/.claude/rules/server-07-testing-strategy.md +0 -507
  173. package/template/.claude/rules/server-08-observability.md +0 -181
  174. package/template/.claude/rules/server-10-background-jobs-v2.md +0 -186
  175. package/template/.claude/rules/server-11-rate-limiting-v2.md +0 -211
  176. package/template/.claude/rules/server-12-performance-optimization.md +0 -568
  177. package/template/.cursor/rules/client-01-project-structure.mdc +0 -327
  178. package/template/.cursor/rules/client-02-component-patterns.mdc +0 -250
  179. package/template/.cursor/rules/client-03-typescript-rules.mdc +0 -227
  180. package/template/.cursor/rules/client-04-state-management.mdc +0 -475
  181. package/template/.cursor/rules/client-05-api-integration.mdc +0 -130
  182. package/template/.cursor/rules/client-06-forms-validation.mdc +0 -130
  183. package/template/.cursor/rules/client-07-common-patterns.mdc +0 -151
  184. package/template/.cursor/rules/client-08-color-system.mdc +0 -94
  185. package/template/.cursor/rules/client-09-security-rules.mdc +0 -98
  186. package/template/.cursor/rules/client-10-testing-strategy.mdc +0 -371
  187. package/template/.cursor/rules/global-ai-edit-safety.mdc +0 -39
  188. package/template/.cursor/rules/server-01-db-and-migrations.mdc +0 -243
  189. package/template/.cursor/rules/server-02-general-rules.mdc +0 -112
  190. package/template/.cursor/rules/server-03-migrations.mdc +0 -21
  191. package/template/.cursor/rules/server-04-pagination.mdc +0 -131
  192. package/template/.cursor/rules/server-05-project-conventions.mdc +0 -72
  193. package/template/.cursor/rules/server-06-response-handling.mdc +0 -174
  194. package/template/.cursor/rules/server-07-testing-strategy.mdc +0 -507
  195. package/template/.cursor/rules/server-08-observability.mdc +0 -181
  196. package/template/.cursor/rules/server-09-api-documentation-v2.mdc +0 -169
  197. package/template/.cursor/rules/server-10-background-jobs-v2.mdc +0 -186
  198. package/template/.cursor/rules/server-11-rate-limiting-v2.mdc +0 -211
  199. package/template/.cursor/rules/server-12-performance-optimization.mdc +0 -568
  200. package/template/CLAUDE.md +0 -207
  201. package/template/server/.tsc-aliasrc.json +0 -13
  202. package/template/server/IMPORT_FIX_CHECKLIST.md +0 -98
  203. package/template/server/IMPORT_FIX_COMPLETE.md +0 -89
  204. package/template/server/README.md +0 -183
  205. package/template/server/REMAINING_IMPORT_FIXES.md +0 -150
  206. package/template/server/SECURITY.md +0 -190
  207. package/template/server/Tigra-API.postman_collection.json +0 -733
  208. package/template/server/biome.json +0 -42
  209. package/template/server/scripts/fix-all-imports.ps1 +0 -52
  210. package/template/server/scripts/fix-imports-reference.ps1 +0 -16
  211. package/template/server/scripts/fix-imports.mjs +0 -55
  212. package/template/server/scripts/setup-env.js +0 -50
  213. package/template/server/scripts/wait-for-db.js +0 -60
  214. package/template/server/src/hooks/request-timing.hook.ts +0 -26
  215. package/template/server/src/libs/auth/authenticate.middleware.ts +0 -22
  216. package/template/server/src/libs/auth/rbac.middleware.test.ts +0 -134
  217. package/template/server/src/libs/auth/rbac.middleware.ts +0 -147
  218. package/template/server/src/libs/db.ts +0 -76
  219. package/template/server/src/libs/error-handler.ts +0 -89
  220. package/template/server/src/libs/queue.ts +0 -79
  221. package/template/server/src/modules/admin/admin.controller.ts +0 -122
  222. package/template/server/src/modules/admin/admin.routes.ts +0 -62
  223. package/template/server/src/modules/admin/admin.schemas.ts +0 -35
  224. package/template/server/src/modules/admin/admin.service.ts +0 -167
  225. package/template/server/src/modules/auth/auth.integration.test.ts +0 -150
  226. package/template/server/src/modules/auth/auth.service.test.ts +0 -119
  227. package/template/server/src/modules/auth/auth.types.ts +0 -97
  228. package/template/server/src/modules/resources/resources.controller.ts +0 -218
  229. package/template/server/src/modules/resources/resources.repo.ts +0 -253
  230. package/template/server/src/modules/resources/resources.routes.ts +0 -116
  231. package/template/server/src/modules/resources/resources.schemas.ts +0 -146
  232. package/template/server/src/modules/resources/resources.service.ts +0 -218
  233. package/template/server/src/modules/resources/resources.types.ts +0 -73
  234. package/template/server/src/plugins/rate-limit.plugin.ts +0 -21
  235. package/template/server/src/plugins/security.plugin.ts +0 -21
  236. package/template/server/src/routes/health.routes.ts +0 -31
  237. package/template/server/src/types/fastify.d.ts +0 -36
  238. package/template/server/src/utils/errors.ts +0 -108
  239. package/template/server/src/utils/pagination.ts +0 -120
  240. package/template/server/src/utils/response.ts +0 -110
  241. package/template/server/src/workers/file.worker.ts +0 -106
  242. package/template/server/tsconfig.build.json +0 -30
  243. package/template/server/tsconfig.test.json +0 -22
@@ -1,329 +1,286 @@
1
- /**
2
- * Authentication Service
3
- *
4
- * Business logic for authentication operations.
5
- * Handles registration, login, token refresh, and logout.
6
- *
7
- * @see /mnt/project/02-general-rules.md
8
- */
9
-
10
- import bcrypt from 'bcryptjs';
11
- import jwt from 'jsonwebtoken';
12
- import { env } from '../../config/env.js';
13
- import logger from '../../libs/logger.js';
14
- import {
15
- ConflictError,
16
- UnauthorizedError,
17
- } from '../../utils/errors.js';
18
- import * as authRepo from './auth.repo.js';
19
- import type {
20
- AuthResponse,
21
- TokenResponse,
22
- JwtPayload,
23
- JwtRefreshPayload,
24
- } from './auth.types.js';
25
- import type { RegisterInput, LoginInput } from './auth.schemas.js';
26
-
27
- /**
28
- * Password hashing rounds
29
- */
30
- const BCRYPT_ROUNDS = 10;
31
-
32
- /**
33
- * Parse JWT expiration string to seconds
34
- *
35
- * @param expiration - Expiration string (e.g., '15m', '7d')
36
- * @returns Expiration in seconds
37
- */
38
- function parseExpiration(expiration: string): number {
39
- const match = expiration.match(/^(\d+)([smhd])$/);
40
- if (!match) {
41
- throw new Error(`Invalid expiration format: ${expiration}`);
42
- }
43
-
44
- const value = parseInt(match[1]!);
45
- const unit = match[2]!;
46
-
47
- const multipliers: Record<string, number> = {
48
- s: 1,
49
- m: 60,
50
- h: 3600,
51
- d: 86400,
52
- };
53
-
54
- return value * multipliers[unit]!;
55
- }
56
-
57
- /**
58
- * Generate access token
59
- *
60
- * @param userId - User ID
61
- * @param email - User email
62
- * @param role - User role
63
- * @returns JWT access token
64
- */
65
- function generateAccessToken(
66
- userId: string,
67
- email: string,
68
- role: string
69
- ): string {
70
- const payload: JwtPayload = {
71
- userId,
72
- email,
73
- role: role as 'USER' | 'ADMIN',
74
- };
75
-
76
- return jwt.sign(payload, env.JWT_SECRET, {
77
- expiresIn: env.JWT_ACCESS_EXPIRATION,
78
- issuer: env.JWT_ISSUER,
79
- } as jwt.SignOptions);
80
- }
81
-
82
- /**
83
- * Generate refresh token
84
- *
85
- * @param userId - User ID
86
- * @param sessionId - Session ID
87
- * @returns JWT refresh token
88
- */
89
- function generateRefreshToken(userId: string, sessionId: string): string {
90
- const payload: JwtRefreshPayload = {
91
- userId,
92
- sessionId,
93
- };
94
-
95
- return jwt.sign(payload, env.JWT_SECRET, {
96
- expiresIn: env.JWT_REFRESH_EXPIRATION,
97
- issuer: env.JWT_ISSUER,
98
- } as jwt.SignOptions);
99
- }
100
-
101
- /**
102
- * Register a new user
103
- *
104
- * @param data - Registration data
105
- * @returns User and tokens
106
- * @throws ConflictError if email already exists
107
- */
108
- export async function register(data: RegisterInput): Promise<AuthResponse> {
109
- try {
110
- // Check if user already exists
111
- const existingUser = await authRepo.findUserByEmail(data.email);
112
- if (existingUser) {
113
- throw new ConflictError('Email already registered');
114
- }
115
-
116
- // Hash password
117
- const hashedPassword = await bcrypt.hash(data.password, BCRYPT_ROUNDS);
118
-
119
- // Create user
120
- const user = await authRepo.createUser({
121
- email: data.email,
122
- password: hashedPassword,
123
- name: data.name,
124
- });
125
-
126
- // Generate tokens
127
- const accessToken = generateAccessToken(user.id, user.email, user.role);
128
- const sessionId = `session_${Date.now()}_${user.id}`;
129
- const refreshToken = generateRefreshToken(user.id, sessionId);
130
-
131
- // Calculate refresh token expiration
132
- const expiresInSeconds = parseExpiration(env.JWT_REFRESH_EXPIRATION);
133
- const expiresAt = new Date(Date.now() + expiresInSeconds * 1000);
134
-
135
- // Create session
136
- await authRepo.createSession({
137
- userId: user.id,
138
- refreshToken,
139
- expiresAt,
140
- });
141
-
142
- logger.info({ userId: user.id, email: user.email }, 'User registered');
143
-
144
- return {
145
- user,
146
- tokens: {
147
- accessToken,
148
- refreshToken,
149
- expiresIn: parseExpiration(env.JWT_ACCESS_EXPIRATION),
150
- },
151
- };
152
- } catch (error) {
153
- logger.error({ error, email: data.email }, 'Registration failed');
154
- throw error;
155
- }
156
- }
157
-
158
- /**
159
- * Login user
160
- *
161
- * @param data - Login credentials
162
- * @returns User and tokens
163
- * @throws UnauthorizedError if credentials are invalid
164
- */
165
- export async function login(data: LoginInput): Promise<AuthResponse> {
166
- try {
167
- // Find user with password
168
- const userWithPassword = await authRepo.findUserByEmailWithPassword(
169
- data.email
170
- );
171
-
172
- if (!userWithPassword) {
173
- throw new UnauthorizedError('Invalid email or password');
174
- }
175
-
176
- // Verify password
177
- const isPasswordValid = await bcrypt.compare(
178
- data.password,
179
- userWithPassword.password
180
- );
181
-
182
- if (!isPasswordValid) {
183
- throw new UnauthorizedError('Invalid email or password');
184
- }
185
-
186
- // Get user without password
187
- const user = await authRepo.findUserById(userWithPassword.id);
188
- if (!user) {
189
- throw new UnauthorizedError('User not found');
190
- }
191
-
192
- // Generate tokens
193
- const accessToken = generateAccessToken(user.id, user.email, user.role);
194
- const sessionId = `session_${Date.now()}_${user.id}`;
195
- const refreshToken = generateRefreshToken(user.id, sessionId);
196
-
197
- // Calculate refresh token expiration
198
- const expiresInSeconds = parseExpiration(env.JWT_REFRESH_EXPIRATION);
199
- const expiresAt = new Date(Date.now() + expiresInSeconds * 1000);
200
-
201
- // Create session
202
- await authRepo.createSession({
203
- userId: user.id,
204
- refreshToken,
205
- expiresAt,
206
- });
207
-
208
- logger.info({ userId: user.id, email: user.email }, 'User logged in');
209
-
210
- return {
211
- user,
212
- tokens: {
213
- accessToken,
214
- refreshToken,
215
- expiresIn: parseExpiration(env.JWT_ACCESS_EXPIRATION),
216
- },
217
- };
218
- } catch (error) {
219
- logger.error({ error, email: data.email }, 'Login failed');
220
- throw error;
221
- }
222
- }
223
-
224
- /**
225
- * Refresh access token
226
- *
227
- * @param refreshToken - Refresh token
228
- * @returns New access and refresh tokens
229
- * @throws UnauthorizedError if token is invalid or expired
230
- */
231
- export async function refreshTokens(
232
- refreshToken: string
233
- ): Promise<TokenResponse> {
234
- try {
235
- // Find session
236
- const session = await authRepo.findSessionByToken(refreshToken);
237
- if (!session) {
238
- throw new UnauthorizedError('Invalid refresh token');
239
- }
240
-
241
- // Check if session expired
242
- if (session.expiresAt < new Date()) {
243
- await authRepo.deleteSession(refreshToken);
244
- throw new UnauthorizedError('Refresh token expired');
245
- }
246
-
247
- // Verify JWT token
248
- let payload: JwtRefreshPayload;
249
- try {
250
- payload = jwt.verify(refreshToken, env.JWT_SECRET, {
251
- issuer: env.JWT_ISSUER,
252
- }) as JwtRefreshPayload;
253
- } catch (error) {
254
- await authRepo.deleteSession(refreshToken);
255
- throw new UnauthorizedError('Invalid refresh token');
256
- }
257
-
258
- // Generate new tokens
259
- const newAccessToken = generateAccessToken(
260
- session.user.id,
261
- session.user.email,
262
- session.user.role
263
- );
264
- const sessionId = `session_${Date.now()}_${session.user.id}`;
265
- const newRefreshToken = generateRefreshToken(session.user.id, sessionId);
266
-
267
- // Calculate new expiration
268
- const expiresInSeconds = parseExpiration(env.JWT_REFRESH_EXPIRATION);
269
- const expiresAt = new Date(Date.now() + expiresInSeconds * 1000);
270
-
271
- // Update session with new refresh token
272
- await authRepo.updateSession(refreshToken, newRefreshToken, expiresAt);
273
-
274
- logger.info({ userId: session.user.id }, 'Tokens refreshed');
275
-
276
- return {
277
- accessToken: newAccessToken,
278
- refreshToken: newRefreshToken,
279
- expiresIn: parseExpiration(env.JWT_ACCESS_EXPIRATION),
280
- };
281
- } catch (error) {
282
- logger.error({ error }, 'Token refresh failed');
283
- throw error;
284
- }
285
- }
286
-
287
- /**
288
- * Logout user
289
- *
290
- * Deletes the session associated with the refresh token.
291
- *
292
- * @param refreshToken - Refresh token
293
- */
294
- export async function logout(refreshToken: string): Promise<void> {
295
- try {
296
- await authRepo.deleteSession(refreshToken);
297
- logger.info('User logged out');
298
- } catch (error) {
299
- // Ignore errors if session doesn't exist
300
- logger.warn({ error }, 'Logout failed - session may not exist');
301
- }
302
- }
303
-
304
- /**
305
- * Verify access token
306
- *
307
- * @param token - Access token
308
- * @returns JWT payload
309
- * @throws UnauthorizedError if token is invalid
310
- */
311
- export async function verifyAccessToken(token: string): Promise<JwtPayload> {
312
- try {
313
- const payload = jwt.verify(token, env.JWT_SECRET, {
314
- issuer: env.JWT_ISSUER,
315
- }) as JwtPayload;
316
-
317
- return payload;
318
- } catch (error) {
319
- if (error instanceof jwt.TokenExpiredError) {
320
- throw new UnauthorizedError('Access token expired');
321
- }
322
- if (error instanceof jwt.JsonWebTokenError) {
323
- throw new UnauthorizedError('Invalid access token');
324
- }
325
- throw new UnauthorizedError('Token verification failed');
326
- }
327
- }
328
-
329
-
1
+ import { signAccessToken, generateRefreshToken, getRefreshTokenExpiresAt } from '@libs/auth.js';
2
+ import { hashPassword, verifyPassword } from '@libs/password.js';
3
+ import {
4
+ ConflictError,
5
+ UnauthorizedError,
6
+ NotFoundError,
7
+ } from '@shared/errors/errors.js';
8
+ import type { UserRole } from '@shared/types/index.js';
9
+ import * as authRepo from './auth.repo.js';
10
+ import { sessionRepository } from './session.repo.js';
11
+ import type { RegisterInput, LoginInput } from './auth.schemas.js';
12
+
13
+ // Account lockout configuration
14
+ const LOCKOUT_THRESHOLDS = [
15
+ { attempts: 5, durationMs: 15 * 60 * 1000 }, // 5 failures → 15 min
16
+ { attempts: 10, durationMs: 30 * 60 * 1000 }, // 10 failures → 30 min
17
+ { attempts: 15, durationMs: 60 * 60 * 1000 }, // 15+ failures → 1 hour
18
+ ];
19
+
20
+ function getLockoutDuration(failedAttempts: number): number | null {
21
+ for (let i = LOCKOUT_THRESHOLDS.length - 1; i >= 0; i--) {
22
+ if (failedAttempts >= LOCKOUT_THRESHOLDS[i].attempts) {
23
+ return LOCKOUT_THRESHOLDS[i].durationMs;
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+
29
+ interface SanitizedUser {
30
+ id: string;
31
+ email: string;
32
+ firstName: string;
33
+ lastName: string;
34
+ avatarUrl: string | null;
35
+ role: UserRole;
36
+ isActive: boolean;
37
+ createdAt: string;
38
+ updatedAt: string;
39
+ }
40
+
41
+ interface AuthResult {
42
+ user: SanitizedUser;
43
+ accessToken: string;
44
+ refreshToken: string;
45
+ }
46
+
47
+ function sanitizeUser(user: {
48
+ id: string;
49
+ email: string;
50
+ firstName: string;
51
+ lastName: string;
52
+ avatarUrl?: string | null;
53
+ role: UserRole;
54
+ isActive: boolean;
55
+ createdAt: Date;
56
+ updatedAt: Date;
57
+ password: string;
58
+ }): SanitizedUser {
59
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
60
+ const { password: _password, createdAt, updatedAt, avatarUrl, ...rest } = user;
61
+ return {
62
+ ...rest,
63
+ avatarUrl: avatarUrl ?? null,
64
+ createdAt: createdAt.toISOString(),
65
+ updatedAt: updatedAt.toISOString(),
66
+ };
67
+ }
68
+
69
+ export async function register(
70
+ input: RegisterInput,
71
+ deviceInfo?: string,
72
+ ipAddress?: string,
73
+ ): Promise<AuthResult> {
74
+ const existingUser = await authRepo.findUserByEmail(input.email);
75
+ if (existingUser) {
76
+ throw new ConflictError('Email already registered', 'EMAIL_ALREADY_EXISTS');
77
+ }
78
+
79
+ const hashedPassword = await hashPassword(input.password);
80
+
81
+ const user = await authRepo.createUser({
82
+ email: input.email,
83
+ password: hashedPassword,
84
+ firstName: input.firstName,
85
+ lastName: input.lastName,
86
+ });
87
+
88
+ const accessToken = signAccessToken({
89
+ userId: user.id,
90
+ role: user.role,
91
+ });
92
+ const refreshToken = generateRefreshToken();
93
+ const refreshTokenExpiresAt = getRefreshTokenExpiresAt();
94
+
95
+ await authRepo.createRefreshToken({
96
+ token: refreshToken,
97
+ userId: user.id,
98
+ expiresAt: refreshTokenExpiresAt,
99
+ });
100
+
101
+ await sessionRepository.createSession({
102
+ userId: user.id,
103
+ deviceInfo,
104
+ ipAddress,
105
+ expiresAt: refreshTokenExpiresAt,
106
+ });
107
+
108
+ return {
109
+ user: sanitizeUser(user),
110
+ accessToken,
111
+ refreshToken,
112
+ };
113
+ }
114
+
115
+ export async function login(
116
+ input: LoginInput,
117
+ deviceInfo?: string,
118
+ ipAddress?: string,
119
+ ): Promise<AuthResult> {
120
+ const user = await authRepo.findUserByEmail(input.email);
121
+ if (!user) {
122
+ throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
123
+ }
124
+
125
+ // Generic error for disabled accounts — prevent info leakage
126
+ if (!user.isActive) {
127
+ throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
128
+ }
129
+
130
+ // Check account lockout
131
+ if (user.lockedUntil && user.lockedUntil > new Date()) {
132
+ throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
133
+ }
134
+
135
+ const { valid, needsRehash } = await verifyPassword(input.password, user.password);
136
+
137
+ if (!valid) {
138
+ // Increment failed attempts
139
+ const newAttempts = user.failedLoginAttempts + 1;
140
+ await authRepo.incrementFailedAttempts(user.id);
141
+
142
+ // Check if we need to lock the account
143
+ const lockDuration = getLockoutDuration(newAttempts);
144
+ if (lockDuration) {
145
+ await authRepo.setAccountLock(user.id, new Date(Date.now() + lockDuration));
146
+ }
147
+
148
+ throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
149
+ }
150
+
151
+ // Successful login — reset failed attempts
152
+ if (user.failedLoginAttempts > 0 || user.lockedUntil) {
153
+ await authRepo.resetFailedAttempts(user.id);
154
+ }
155
+
156
+ // Transparent rehash: upgrade bcrypt → argon2id
157
+ if (needsRehash) {
158
+ const newHash = await hashPassword(input.password);
159
+ await authRepo.updateUserPassword(user.id, newHash);
160
+ }
161
+
162
+ const accessToken = signAccessToken({
163
+ userId: user.id,
164
+ role: user.role,
165
+ });
166
+ const refreshToken = generateRefreshToken();
167
+ const refreshTokenExpiresAt = getRefreshTokenExpiresAt();
168
+
169
+ await authRepo.createRefreshToken({
170
+ token: refreshToken,
171
+ userId: user.id,
172
+ expiresAt: refreshTokenExpiresAt,
173
+ });
174
+
175
+ await sessionRepository.createSession({
176
+ userId: user.id,
177
+ deviceInfo,
178
+ ipAddress,
179
+ expiresAt: refreshTokenExpiresAt,
180
+ });
181
+
182
+ return {
183
+ user: sanitizeUser(user),
184
+ accessToken,
185
+ refreshToken,
186
+ };
187
+ }
188
+
189
+ export async function refresh(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
190
+ const storedToken = await authRepo.findRefreshToken(refreshToken);
191
+ if (!storedToken) {
192
+ throw new UnauthorizedError('Invalid refresh token', 'INVALID_REFRESH_TOKEN');
193
+ }
194
+
195
+ if (new Date() > storedToken.expiresAt) {
196
+ await authRepo.deleteRefreshToken(refreshToken);
197
+ throw new UnauthorizedError('Refresh token expired', 'REFRESH_TOKEN_EXPIRED');
198
+ }
199
+
200
+ const user = await authRepo.findUserById(storedToken.userId);
201
+ if (!user || !user.isActive) {
202
+ throw new UnauthorizedError('User not found or disabled', 'INVALID_REFRESH_TOKEN');
203
+ }
204
+
205
+ const newAccessToken = signAccessToken({
206
+ userId: user.id,
207
+ role: user.role,
208
+ });
209
+ const newRefreshToken = generateRefreshToken();
210
+
211
+ // Atomic rotation: delete old + create new in a single transaction.
212
+ // Returns false if old token was already consumed by a concurrent request.
213
+ const rotated = await authRepo.rotateRefreshToken(refreshToken, {
214
+ token: newRefreshToken,
215
+ userId: user.id,
216
+ expiresAt: getRefreshTokenExpiresAt(),
217
+ });
218
+
219
+ if (!rotated) {
220
+ throw new UnauthorizedError('Refresh token already used', 'INVALID_REFRESH_TOKEN');
221
+ }
222
+
223
+ return {
224
+ accessToken: newAccessToken,
225
+ refreshToken: newRefreshToken,
226
+ };
227
+ }
228
+
229
+ export async function logout(refreshToken: string): Promise<void> {
230
+ try {
231
+ // Find the token before deleting so we can match the corresponding session
232
+ const storedToken = await authRepo.findRefreshToken(refreshToken);
233
+ await authRepo.deleteRefreshToken(refreshToken);
234
+
235
+ if (storedToken) {
236
+ // Match session by creation time — token and session are created together during login
237
+ const sessions = await sessionRepository.getUserSessions(storedToken.userId);
238
+ const tokenCreatedMs = storedToken.createdAt.getTime();
239
+ const matchingSession = sessions.find(
240
+ (s) => Math.abs(s.createdAt.getTime() - tokenCreatedMs) < 5000,
241
+ );
242
+
243
+ if (matchingSession) {
244
+ await sessionRepository.deleteSession(matchingSession.id);
245
+ }
246
+ }
247
+ } catch {
248
+ // Token may already be deleted by concurrent request or cleanup job
249
+ }
250
+ }
251
+
252
+ export async function getCurrentUser(userId: string): Promise<SanitizedUser> {
253
+ const user = await authRepo.findUserById(userId);
254
+ if (!user) {
255
+ throw new NotFoundError('User not found', 'USER_NOT_FOUND');
256
+ }
257
+
258
+ return sanitizeUser(user);
259
+ }
260
+
261
+ interface SessionInfo {
262
+ id: string;
263
+ deviceInfo: string | null;
264
+ ipAddress: string | null;
265
+ lastActiveAt: string;
266
+ expiresAt: string;
267
+ createdAt: string;
268
+ }
269
+
270
+ export async function getUserSessions(userId: string): Promise<SessionInfo[]> {
271
+ const sessions = await sessionRepository.getUserSessions(userId);
272
+ return sessions.map((session) => ({
273
+ id: session.id,
274
+ deviceInfo: session.deviceInfo,
275
+ ipAddress: session.ipAddress,
276
+ lastActiveAt: session.lastActiveAt.toISOString(),
277
+ expiresAt: session.expiresAt.toISOString(),
278
+ createdAt: session.createdAt.toISOString(),
279
+ }));
280
+ }
281
+
282
+ export async function logoutAllSessions(userId: string): Promise<number> {
283
+ const sessionCount = await sessionRepository.deleteAllUserSessions(userId);
284
+ await authRepo.deleteRefreshTokensByUserId(userId);
285
+ return sessionCount;
286
+ }