lapeeh 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.env.example +14 -0
  2. package/LICENSE +21 -0
  3. package/bin/index.js +934 -0
  4. package/doc/en/ARCHITECTURE_GUIDE.md +79 -0
  5. package/doc/en/CHANGELOG.md +203 -0
  6. package/doc/en/CHEATSHEET.md +90 -0
  7. package/doc/en/CLI.md +111 -0
  8. package/doc/en/CONTRIBUTING.md +119 -0
  9. package/doc/en/DEPLOYMENT.md +171 -0
  10. package/doc/en/FAQ.md +69 -0
  11. package/doc/en/FEATURES.md +99 -0
  12. package/doc/en/GETTING_STARTED.md +84 -0
  13. package/doc/en/INTRODUCTION.md +62 -0
  14. package/doc/en/PACKAGES.md +63 -0
  15. package/doc/en/PERFORMANCE.md +98 -0
  16. package/doc/en/ROADMAP.md +104 -0
  17. package/doc/en/SECURITY.md +95 -0
  18. package/doc/en/STRUCTURE.md +79 -0
  19. package/doc/en/TUTORIAL.md +145 -0
  20. package/doc/id/ARCHITECTURE_GUIDE.md +76 -0
  21. package/doc/id/CHANGELOG.md +203 -0
  22. package/doc/id/CHEATSHEET.md +90 -0
  23. package/doc/id/CLI.md +139 -0
  24. package/doc/id/CONTRIBUTING.md +119 -0
  25. package/doc/id/DEPLOYMENT.md +171 -0
  26. package/doc/id/FAQ.md +69 -0
  27. package/doc/id/FEATURES.md +169 -0
  28. package/doc/id/GETTING_STARTED.md +91 -0
  29. package/doc/id/INTRODUCTION.md +62 -0
  30. package/doc/id/PACKAGES.md +63 -0
  31. package/doc/id/PERFORMANCE.md +100 -0
  32. package/doc/id/ROADMAP.md +107 -0
  33. package/doc/id/SECURITY.md +94 -0
  34. package/doc/id/STRUCTURE.md +79 -0
  35. package/doc/id/TUTORIAL.md +145 -0
  36. package/docker-compose.yml +24 -0
  37. package/ecosystem.config.js +17 -0
  38. package/eslint.config.mjs +26 -0
  39. package/gitignore.template +30 -0
  40. package/lib/bootstrap.ts +210 -0
  41. package/lib/core/realtime.ts +34 -0
  42. package/lib/core/redis.ts +139 -0
  43. package/lib/core/serializer.ts +63 -0
  44. package/lib/core/server.ts +70 -0
  45. package/lib/core/store.ts +116 -0
  46. package/lib/middleware/auth.ts +63 -0
  47. package/lib/middleware/error.ts +50 -0
  48. package/lib/middleware/multipart.ts +13 -0
  49. package/lib/middleware/rateLimit.ts +14 -0
  50. package/lib/middleware/requestLogger.ts +27 -0
  51. package/lib/middleware/visitor.ts +178 -0
  52. package/lib/utils/logger.ts +100 -0
  53. package/lib/utils/pagination.ts +56 -0
  54. package/lib/utils/response.ts +88 -0
  55. package/lib/utils/validator.ts +394 -0
  56. package/nodemon.json +6 -0
  57. package/package.json +126 -0
  58. package/readme.md +357 -0
  59. package/scripts/check-update.js +92 -0
  60. package/scripts/config-clear.js +45 -0
  61. package/scripts/generate-jwt-secret.js +38 -0
  62. package/scripts/init-project.js +84 -0
  63. package/scripts/make-module.js +89 -0
  64. package/scripts/release.js +494 -0
  65. package/scripts/seed-json.js +158 -0
  66. package/scripts/verify-rbac-functional.js +187 -0
  67. package/src/config/app.ts +9 -0
  68. package/src/config/cors.ts +5 -0
  69. package/src/modules/Auth/auth.controller.ts +519 -0
  70. package/src/modules/Rbac/rbac.controller.ts +533 -0
  71. package/src/routes/auth.ts +74 -0
  72. package/src/routes/index.ts +7 -0
  73. package/src/routes/rbac.ts +42 -0
  74. package/storage/logs/.gitkeep +0 -0
  75. package/tsconfig.build.json +12 -0
  76. package/tsconfig.json +30 -0
@@ -0,0 +1,519 @@
1
+ import { Request, Response } from "express";
2
+ import bcrypt from "bcryptjs";
3
+ import jwt from "jsonwebtoken";
4
+ import { v4 as uuidv4 } from "uuid";
5
+ import { sendError, sendFastSuccess } from "@lapeeh/utils/response";
6
+ import { Validator } from "@lapeeh/utils/validator";
7
+ import { getSerializer, createResponseSchema } from "@lapeeh/core/serializer";
8
+ import { users, roles, user_roles, saveStore } from "@lapeeh/core/store";
9
+ import { redis } from "@lapeeh/core/redis";
10
+
11
+ export const ACCESS_TOKEN_EXPIRES_IN_SECONDS = 7 * 24 * 60 * 60;
12
+
13
+ // --- Serializers ---
14
+
15
+ const registerSchema = {
16
+ type: "object",
17
+ properties: {
18
+ id: { type: "string" },
19
+ email: { type: "string" },
20
+ name: { type: "string" },
21
+ role: { type: "string" },
22
+ },
23
+ };
24
+
25
+ const loginSchema = {
26
+ type: "object",
27
+ properties: {
28
+ token: { type: "string" },
29
+ refreshToken: { type: "string" },
30
+ expiresIn: { type: "integer" },
31
+ expiresAt: { type: "string" },
32
+ name: { type: "string" },
33
+ role: { type: "string" },
34
+ },
35
+ };
36
+
37
+ const userProfileSchema = {
38
+ type: "object",
39
+ properties: {
40
+ id: { type: "string" },
41
+ name: { type: "string" },
42
+ email: { type: "string" },
43
+ role: { type: "string" },
44
+ avatar: { type: "string", nullable: true },
45
+ avatar_url: { type: "string", nullable: true },
46
+ email_verified_at: { type: "string", format: "date-time", nullable: true },
47
+ created_at: { type: "string", format: "date-time", nullable: true },
48
+ updated_at: { type: "string", format: "date-time", nullable: true },
49
+ },
50
+ };
51
+
52
+ const refreshTokenSchema = {
53
+ type: "object",
54
+ properties: {
55
+ token: { type: "string" },
56
+ expiresIn: { type: "integer" },
57
+ expiresAt: { type: "string" },
58
+ name: { type: "string" },
59
+ role: { type: "string" },
60
+ },
61
+ };
62
+
63
+ const registerSerializer = getSerializer(
64
+ "auth-register",
65
+ createResponseSchema(registerSchema)
66
+ );
67
+ const loginSerializer = getSerializer(
68
+ "auth-login",
69
+ createResponseSchema(loginSchema)
70
+ );
71
+ const userProfileSerializer = getSerializer(
72
+ "auth-profile",
73
+ createResponseSchema(userProfileSchema)
74
+ );
75
+ const refreshTokenSerializer = getSerializer(
76
+ "auth-refresh",
77
+ createResponseSchema(refreshTokenSchema)
78
+ );
79
+
80
+ const voidSerializer = getSerializer(
81
+ "void",
82
+ createResponseSchema({ type: "null" })
83
+ );
84
+
85
+ // --- Controllers ---
86
+
87
+ export async function register(req: Request, res: Response) {
88
+ const validator = Validator.make(req.body || {}, {
89
+ email: "required|email|unique:users,email",
90
+ name: "required|min:1",
91
+ password: "required|min:4",
92
+ confirmPassword: "required|min:4|same:password",
93
+ });
94
+
95
+ if (await validator.fails()) {
96
+ sendError(res, 422, "Validation error", validator.errors());
97
+ return;
98
+ }
99
+ const { email, name, password } = await validator.validated();
100
+
101
+ // Manual unique check (In-Memory)
102
+ if (users.find((u) => u.email === email)) {
103
+ sendError(res, 422, "Validation error", { email: "Email already taken" });
104
+ return;
105
+ }
106
+
107
+ const hash = await bcrypt.hash(password, 10);
108
+
109
+ const newUser = {
110
+ id: (users.length + 1).toString(), // Simple ID generation
111
+ email,
112
+ name,
113
+ password: hash,
114
+ uuid: uuidv4(),
115
+ created_at: new Date(),
116
+ updated_at: new Date(),
117
+ avatar: null,
118
+ avatar_url: null,
119
+ email_verified_at: null,
120
+ remember_token: null,
121
+ };
122
+ users.push(newUser);
123
+
124
+ const defaultRole = roles.find((r) => r.slug === "user");
125
+ if (defaultRole) {
126
+ user_roles.push({
127
+ id: (user_roles.length + 1).toString(),
128
+ user_id: newUser.id,
129
+ role_id: defaultRole.id,
130
+ created_at: new Date(),
131
+ });
132
+ }
133
+ saveStore();
134
+
135
+ sendFastSuccess(res, 201, registerSerializer, {
136
+ status: "success",
137
+ message: "Registration successful. You can now login.",
138
+ data: {
139
+ id: newUser.id.toString(),
140
+ email: newUser.email,
141
+ name: newUser.name,
142
+ role: defaultRole ? defaultRole.slug : "user",
143
+ },
144
+ });
145
+ }
146
+
147
+ export async function login(req: Request, res: Response) {
148
+ const validator = Validator.make(req.body || {}, {
149
+ email: "required|email",
150
+ password: "required|min:4",
151
+ });
152
+
153
+ if (await validator.fails()) {
154
+ sendError(res, 422, "Validation error", validator.errors());
155
+ return;
156
+ }
157
+ const { email, password } = await validator.validated();
158
+
159
+ const user = users.find((u) => u.email === email);
160
+
161
+ if (!user) {
162
+ sendError(res, 401, "Email not registered", {
163
+ field: "email",
164
+ message: "Email is not registered, please register first",
165
+ });
166
+ return;
167
+ }
168
+ const ok = await bcrypt.compare(password, user.password || "");
169
+ if (!ok) {
170
+ sendError(res, 401, "Invalid credentials", {
171
+ field: "password",
172
+ message: "The password you entered is incorrect",
173
+ });
174
+ return;
175
+ }
176
+ const secret = process.env.JWT_SECRET;
177
+ if (!secret) {
178
+ sendError(res, 500, "Server misconfigured");
179
+ return;
180
+ }
181
+
182
+ // Find user roles
183
+ const userRoleLinks = user_roles.filter((ur) => ur.user_id === user.id);
184
+ const userRoleObjects = userRoleLinks
185
+ .map((ur) => roles.find((r) => r.id === ur.role_id))
186
+ .filter((r) => r);
187
+
188
+ const primaryUserRole =
189
+ userRoleObjects.length > 0 && userRoleObjects[0]
190
+ ? userRoleObjects[0].slug
191
+ : "user";
192
+
193
+ const accessExpiresInSeconds = ACCESS_TOKEN_EXPIRES_IN_SECONDS;
194
+ const accessExpiresAt = new Date(
195
+ Date.now() + accessExpiresInSeconds * 1000
196
+ ).toISOString();
197
+ const token = jwt.sign(
198
+ { userId: user.id.toString(), role: primaryUserRole },
199
+ secret,
200
+ { expiresIn: accessExpiresInSeconds }
201
+ );
202
+ const refreshExpiresInSeconds = 30 * 24 * 60 * 60;
203
+ const refreshToken = jwt.sign(
204
+ {
205
+ userId: user.id.toString(),
206
+ role: primaryUserRole,
207
+ tokenType: "refresh",
208
+ },
209
+ secret,
210
+ { expiresIn: refreshExpiresInSeconds }
211
+ );
212
+ sendFastSuccess(res, 200, loginSerializer, {
213
+ status: "success",
214
+ message: "Login successful",
215
+ data: {
216
+ token,
217
+ refreshToken,
218
+ expiresIn: accessExpiresInSeconds,
219
+ expiresAt: accessExpiresAt,
220
+ name: user.name,
221
+ role: primaryUserRole,
222
+ },
223
+ });
224
+ }
225
+
226
+ export async function me(req: Request, res: Response) {
227
+ const payload = (req as any).user as { userId: string; role: string };
228
+ if (!payload || !payload.userId) {
229
+ sendError(res, 401, "Unauthorized");
230
+ return;
231
+ }
232
+
233
+ // Try to get from Redis
234
+ const cachedUser = await redis.get(`user:${payload.userId}`);
235
+ if (cachedUser) {
236
+ const user = JSON.parse(cachedUser);
237
+ sendFastSuccess(res, 200, userProfileSerializer, {
238
+ status: "success",
239
+ message: "User profile (cached)",
240
+ data: user,
241
+ });
242
+ return;
243
+ }
244
+
245
+ const user = users.find((u) => u.id === payload.userId);
246
+
247
+ if (!user) {
248
+ sendError(res, 404, "User not found");
249
+ return;
250
+ }
251
+
252
+ // Find user roles
253
+ const userRoleLinks = user_roles.filter((ur) => ur.user_id === user.id);
254
+ const userRoleObjects = userRoleLinks
255
+ .map((ur) => roles.find((r) => r.id === ur.role_id))
256
+ .filter((r) => r);
257
+
258
+ const primaryRoleSlug =
259
+ userRoleObjects.length > 0 ? userRoleObjects[0]?.slug : "user";
260
+
261
+ const { password, ...rest } = user;
262
+ const userData = {
263
+ ...rest,
264
+ id: user.id.toString(),
265
+ role: primaryRoleSlug,
266
+ };
267
+
268
+ // Cache in Redis for 1 hour
269
+ await redis.set(
270
+ `user:${payload.userId}`,
271
+ JSON.stringify(userData),
272
+ "EX",
273
+ 3600
274
+ );
275
+
276
+ sendFastSuccess(res, 200, userProfileSerializer, {
277
+ status: "success",
278
+ message: "User profile",
279
+ data: userData,
280
+ });
281
+ }
282
+
283
+ export async function logout(_req: Request, res: Response) {
284
+ // In a stateless JWT setup, logout is client-side (delete token).
285
+ // If using a whitelist/blacklist in Redis, invalidate the token here.
286
+ // For now, just return success.
287
+ sendFastSuccess(res, 200, voidSerializer, {
288
+ status: "success",
289
+ message: "Logout successful",
290
+ data: null,
291
+ });
292
+ }
293
+
294
+ export async function refreshToken(req: Request, res: Response) {
295
+ const validator = Validator.make(req.body || {}, {
296
+ refreshToken: "required|min:1",
297
+ });
298
+ if (await validator.fails()) {
299
+ sendError(res, 422, "Validation error", validator.errors());
300
+ return;
301
+ }
302
+ const secret = process.env.JWT_SECRET;
303
+ if (!secret) {
304
+ sendError(res, 500, "Server misconfigured");
305
+ return;
306
+ }
307
+ try {
308
+ const validatedData = await validator.validated();
309
+ const decoded = jwt.verify(validatedData.refreshToken, secret) as {
310
+ userId: string;
311
+ role: string;
312
+ tokenType?: string;
313
+ iat: number;
314
+ exp: number;
315
+ };
316
+ if (decoded.tokenType !== "refresh") {
317
+ sendError(res, 401, "Invalid refresh token");
318
+ return;
319
+ }
320
+
321
+ const user = users.find((u) => u.id === decoded.userId);
322
+
323
+ if (!user) {
324
+ sendError(res, 401, "Invalid refresh token");
325
+ return;
326
+ }
327
+
328
+ // Find user roles
329
+ const userRoleLinks = user_roles.filter((ur) => ur.user_id === user.id);
330
+ const userRoleObjects = userRoleLinks
331
+ .map((ur) => roles.find((r) => r.id === ur.role_id))
332
+ .filter((r) => r);
333
+
334
+ const primaryUserRole =
335
+ userRoleObjects.length > 0 && userRoleObjects[0]
336
+ ? userRoleObjects[0].slug
337
+ : "user";
338
+
339
+ const accessExpiresInSeconds = ACCESS_TOKEN_EXPIRES_IN_SECONDS;
340
+ const accessExpiresAt = new Date(
341
+ Date.now() + accessExpiresInSeconds * 1000
342
+ ).toISOString();
343
+ const token = jwt.sign(
344
+ { userId: user.id.toString(), role: primaryUserRole },
345
+ secret,
346
+ { expiresIn: accessExpiresInSeconds }
347
+ );
348
+ sendFastSuccess(res, 200, refreshTokenSerializer, {
349
+ status: "success",
350
+ message: "Token refreshed",
351
+ data: {
352
+ token,
353
+ expiresIn: accessExpiresInSeconds,
354
+ expiresAt: accessExpiresAt,
355
+ name: user.name,
356
+ role: primaryUserRole,
357
+ },
358
+ });
359
+ } catch {
360
+ sendError(res, 401, "Invalid refresh token");
361
+ }
362
+ }
363
+
364
+ export async function updateAvatar(req: Request, res: Response) {
365
+ const payload = (req as any).user as { userId: string; role: string };
366
+ if (!payload || !payload.userId) {
367
+ sendError(res, 401, "Unauthorized");
368
+ return;
369
+ }
370
+
371
+ const data = {
372
+ avatar: (req as any).file,
373
+ };
374
+
375
+ const validator = Validator.make(data, {
376
+ avatar: "nullable|image|mimes:jpeg,png,jpg,gif|max:2048",
377
+ });
378
+
379
+ if (await validator.fails()) {
380
+ sendError(res, 422, "Validation error", validator.errors());
381
+ return;
382
+ }
383
+
384
+ const { avatar: file } = await validator.validated();
385
+
386
+ if (!file) {
387
+ sendError(res, 400, "Avatar file is required");
388
+ return;
389
+ }
390
+ const userId = payload.userId;
391
+ const avatar = file.filename;
392
+ const avatar_url =
393
+ process.env.AVATAR_BASE_URL || `/uploads/avatars/${file.filename}`;
394
+
395
+ const userIndex = users.findIndex((u) => u.id === userId);
396
+ if (userIndex === -1) {
397
+ sendError(res, 404, "User not found");
398
+ return;
399
+ }
400
+
401
+ users[userIndex] = {
402
+ ...users[userIndex],
403
+ avatar,
404
+ avatar_url,
405
+ updated_at: new Date(),
406
+ };
407
+
408
+ const updated = users[userIndex];
409
+ const { password, ...rest } = updated;
410
+
411
+ sendFastSuccess(res, 200, userProfileSerializer, {
412
+ status: "success",
413
+ message: "Avatar updated successfully",
414
+ data: {
415
+ ...rest,
416
+ id: updated.id.toString(),
417
+ role: payload.role, // Use role from JWT payload as it shouldn't change here
418
+ },
419
+ });
420
+ }
421
+
422
+ export async function updatePassword(req: Request, res: Response) {
423
+ const payload = (req as any).user as { userId: string; role: string };
424
+ if (!payload || !payload.userId) {
425
+ sendError(res, 401, "Unauthorized");
426
+ return;
427
+ }
428
+ const validator = Validator.make(req.body || {}, {
429
+ currentPassword: "required|min:4",
430
+ newPassword: "required|min:4",
431
+ confirmPassword: "required|min:4|same:newPassword",
432
+ });
433
+ if (await validator.fails()) {
434
+ sendError(res, 422, "Validation error", validator.errors());
435
+ return;
436
+ }
437
+ const { currentPassword, newPassword } = await validator.validated();
438
+
439
+ const userIndex = users.findIndex((u) => u.id === payload.userId);
440
+ if (userIndex === -1) {
441
+ sendError(res, 404, "User not found");
442
+ return;
443
+ }
444
+
445
+ const user = users[userIndex];
446
+ const ok = await bcrypt.compare(currentPassword, user.password || "");
447
+ if (!ok) {
448
+ sendError(res, 401, "Invalid credentials", {
449
+ field: "currentPassword",
450
+ message: "Current password is incorrect",
451
+ });
452
+ return;
453
+ }
454
+ const hash = await bcrypt.hash(newPassword, 10);
455
+
456
+ users[userIndex] = {
457
+ ...user,
458
+ password: hash,
459
+ updated_at: new Date(),
460
+ };
461
+
462
+ sendFastSuccess(res, 200, voidSerializer, {
463
+ status: "success",
464
+ message: "Password updated successfully",
465
+ data: null,
466
+ });
467
+ }
468
+
469
+ export async function updateProfile(req: Request, res: Response) {
470
+ const payload = (req as any).user as { userId: string; role: string };
471
+ if (!payload || !payload.userId) {
472
+ sendError(res, 401, "Unauthorized");
473
+ return;
474
+ }
475
+ const validator = Validator.make(req.body || {}, {
476
+ name: "required|min:1",
477
+ email: `required|email|unique:users,email,${payload.userId}`,
478
+ });
479
+ if (await validator.fails()) {
480
+ sendError(res, 422, "Validation error", validator.errors());
481
+ return;
482
+ }
483
+ const { name, email } = await validator.validated();
484
+ const userId = payload.userId;
485
+
486
+ // Manual unique check (In-Memory)
487
+ if (users.find((u) => u.email === email && u.id !== userId)) {
488
+ sendError(res, 422, "Validation error", { email: "Email already taken" });
489
+ return;
490
+ }
491
+
492
+ const userIndex = users.findIndex((u) => u.id === userId);
493
+ if (userIndex === -1) {
494
+ sendError(res, 404, "User not found");
495
+ return;
496
+ }
497
+
498
+ users[userIndex] = {
499
+ ...users[userIndex],
500
+ name,
501
+ email,
502
+ updated_at: new Date(),
503
+ };
504
+ saveStore();
505
+ await redis.del(`user:${userId}`);
506
+
507
+ const updated = users[userIndex];
508
+ const { password, ...rest } = updated;
509
+
510
+ sendFastSuccess(res, 200, userProfileSerializer, {
511
+ status: "success",
512
+ message: "Profile updated successfully",
513
+ data: {
514
+ ...rest,
515
+ id: updated.id.toString(),
516
+ role: payload.role, // Use role from JWT payload
517
+ },
518
+ });
519
+ }