exp-node-server 1.1.7 → 2.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 (181) hide show
  1. package/README.md +82 -20
  2. package/dist/cli.js +525 -0
  3. package/package.json +29 -9
  4. package/templates/mongoose/.dockerignore +10 -0
  5. package/templates/mongoose/.env.example +56 -7
  6. package/templates/mongoose/.prettierignore +3 -0
  7. package/templates/mongoose/.prettierrc +7 -0
  8. package/templates/mongoose/Dockerfile +33 -0
  9. package/templates/mongoose/_gitignore +32 -0
  10. package/templates/mongoose/docker-compose.yml +21 -0
  11. package/templates/mongoose/eslint.config.js +35 -0
  12. package/templates/mongoose/package-lock.json +6377 -0
  13. package/templates/mongoose/package.json +49 -10
  14. package/templates/mongoose/readme.md +85 -0
  15. package/templates/mongoose/src/app/configs/index.ts +178 -26
  16. package/templates/mongoose/src/app/errors/zodError.ts +16 -16
  17. package/templates/mongoose/src/app/lib/db.ts +18 -0
  18. package/templates/mongoose/src/app/lib/logger.ts +12 -0
  19. package/templates/mongoose/src/app/middlewares/auth.ts +48 -44
  20. package/templates/mongoose/src/app/middlewares/global_error_handler.ts +59 -65
  21. package/templates/mongoose/src/app/middlewares/not_found_api.ts +7 -7
  22. package/templates/mongoose/src/app/middlewares/rate_limiter.ts +153 -0
  23. package/templates/mongoose/src/app/middlewares/request_validator.ts +11 -10
  24. package/templates/mongoose/src/app/middlewares/uploader.ts +17 -13
  25. package/templates/mongoose/src/app/modules/account/account.controller.ts +156 -0
  26. package/templates/mongoose/src/app/modules/account/account.interface.ts +65 -0
  27. package/templates/mongoose/src/app/modules/account/account.route.ts +101 -0
  28. package/templates/mongoose/src/app/modules/account/account.schema.ts +97 -0
  29. package/templates/mongoose/src/app/modules/account/account.service.ts +422 -0
  30. package/templates/mongoose/src/app/modules/account/account.swagger.ts +278 -0
  31. package/templates/mongoose/src/app/modules/account/account.validation.ts +63 -0
  32. package/templates/mongoose/src/app/modules/profile/profile.controller.ts +17 -0
  33. package/templates/mongoose/src/app/modules/profile/profile.interface.ts +7 -0
  34. package/templates/mongoose/src/app/modules/profile/profile.route.ts +28 -0
  35. package/templates/mongoose/src/app/modules/profile/profile.schema.ts +13 -0
  36. package/templates/mongoose/src/app/modules/profile/profile.service.ts +28 -0
  37. package/templates/mongoose/src/app/modules/profile/profile.swagger.ts +34 -0
  38. package/templates/mongoose/src/app/modules/profile/profile.validation.ts +8 -0
  39. package/templates/mongoose/src/app/passport-js/index.ts +87 -0
  40. package/templates/mongoose/src/app/passport-js/oauth-state.ts +150 -0
  41. package/templates/mongoose/src/app/passport-js/providers.ts +258 -0
  42. package/templates/mongoose/src/app/passport-js/social-account.ts +141 -0
  43. package/templates/mongoose/src/app/queues/agenda.ts +24 -0
  44. package/templates/mongoose/src/app/queues/email.queue.ts +53 -0
  45. package/templates/mongoose/src/app/queues/index.ts +13 -0
  46. package/templates/mongoose/src/app/templates/otpEmailTemplate.ts +185 -0
  47. package/templates/mongoose/src/app/templates/resetPasswordEmailTemplate.ts +157 -0
  48. package/templates/mongoose/src/app/types/error.d.ts +7 -7
  49. package/templates/mongoose/src/app/types/express.d.ts +11 -0
  50. package/templates/mongoose/src/app/types/passport-oauth1.d.ts +51 -0
  51. package/templates/mongoose/src/app/utils/JWT.ts +38 -18
  52. package/templates/mongoose/src/app/utils/app_error.ts +10 -10
  53. package/templates/mongoose/src/app/utils/auth_cookie.ts +27 -0
  54. package/templates/mongoose/src/app/utils/catch_async.ts +8 -8
  55. package/templates/mongoose/src/app/utils/cloudinary.ts +26 -59
  56. package/templates/mongoose/src/app/utils/mail_sender.ts +26 -92
  57. package/templates/mongoose/src/app/utils/manage_response.ts +19 -19
  58. package/templates/mongoose/src/app/utils/otp.ts +20 -0
  59. package/templates/mongoose/src/app/utils/session.ts +55 -0
  60. package/templates/mongoose/src/app.ts +73 -42
  61. package/templates/mongoose/src/routes.ts +13 -13
  62. package/templates/mongoose/src/server.ts +38 -18
  63. package/templates/mongoose/src/swaggerOptions.ts +23 -23
  64. package/templates/mongoose/tests/app.test.ts +97 -0
  65. package/templates/mongoose/tests/global-setup.ts +15 -0
  66. package/templates/mongoose/tests/helpers/fixtures.ts +42 -0
  67. package/templates/mongoose/tests/routes/account.test.ts +614 -0
  68. package/templates/mongoose/tests/routes/profile.test.ts +95 -0
  69. package/templates/mongoose/tests/routes/social.test.ts +310 -0
  70. package/templates/mongoose/tests/setup.ts +25 -0
  71. package/templates/mongoose/tsconfig.json +16 -20
  72. package/templates/mongoose/vitest.config.ts +43 -0
  73. package/templates/prisma/.dockerignore +2 -0
  74. package/templates/prisma/.env.example +62 -6
  75. package/templates/prisma/.prettierignore +3 -0
  76. package/templates/prisma/.prettierrc +7 -0
  77. package/templates/prisma/.projectrc.json +1 -1
  78. package/templates/prisma/Dockerfile +21 -7
  79. package/templates/prisma/_gitignore +35 -0
  80. package/templates/prisma/docker-compose.yml +8 -7
  81. package/templates/prisma/eslint.config.js +35 -0
  82. package/templates/prisma/package-lock.json +7327 -0
  83. package/templates/prisma/package.json +47 -9
  84. package/templates/prisma/prisma/schema/account.schema.prisma +92 -10
  85. package/templates/prisma/prisma/schema/profile.schema.prisma +8 -3
  86. package/templates/prisma/prisma/schema/schema.prisma +1 -2
  87. package/templates/prisma/prisma.config.ts +5 -5
  88. package/templates/prisma/readme.md +81 -0
  89. package/templates/prisma/src/app/configs/index.ts +175 -26
  90. package/templates/prisma/src/app/errors/zodError.ts +16 -16
  91. package/templates/prisma/src/app/lib/logger.ts +12 -0
  92. package/templates/prisma/src/app/lib/prisma.ts +4 -6
  93. package/templates/prisma/src/app/middlewares/auth.ts +37 -16
  94. package/templates/prisma/src/app/middlewares/global_error_handler.ts +42 -35
  95. package/templates/prisma/src/app/middlewares/not_found_api.ts +7 -7
  96. package/templates/prisma/src/app/middlewares/rate_limiter.ts +124 -0
  97. package/templates/prisma/src/app/middlewares/request_validator.ts +4 -3
  98. package/templates/prisma/src/app/middlewares/uploader.ts +17 -13
  99. package/templates/prisma/src/app/modules/account/account.controller.ts +74 -34
  100. package/templates/prisma/src/app/modules/account/account.route.ts +68 -22
  101. package/templates/prisma/src/app/modules/account/account.service.ts +329 -309
  102. package/templates/prisma/src/app/modules/account/account.swagger.ts +167 -76
  103. package/templates/prisma/src/app/modules/account/account.validation.ts +41 -19
  104. package/templates/prisma/src/app/modules/profile/profile.controller.ts +4 -4
  105. package/templates/prisma/src/app/modules/profile/profile.route.ts +18 -12
  106. package/templates/prisma/src/app/modules/profile/profile.service.ts +11 -18
  107. package/templates/prisma/src/app/modules/profile/profile.swagger.ts +11 -11
  108. package/templates/prisma/src/app/modules/profile/profile.validation.ts +2 -2
  109. package/templates/prisma/src/app/passport-js/index.ts +87 -0
  110. package/templates/prisma/src/app/passport-js/oauth-state.ts +150 -0
  111. package/templates/prisma/src/app/passport-js/providers.ts +263 -0
  112. package/templates/prisma/src/app/passport-js/social-account.ts +147 -0
  113. package/templates/prisma/src/app/queues/boss.ts +8 -0
  114. package/templates/prisma/src/app/queues/email.queue.ts +56 -0
  115. package/templates/prisma/src/app/queues/index.ts +14 -0
  116. package/templates/prisma/src/app/queues/maintenance.queue.ts +30 -0
  117. package/templates/prisma/src/app/templates/otpEmailTemplate.ts +185 -0
  118. package/templates/prisma/src/app/templates/resetPasswordEmailTemplate.ts +157 -0
  119. package/templates/prisma/src/app/types/error.d.ts +7 -7
  120. package/templates/prisma/src/app/types/express.d.ts +11 -0
  121. package/templates/prisma/src/app/types/passport-oauth1.d.ts +51 -0
  122. package/templates/prisma/src/app/utils/JWT.ts +36 -17
  123. package/templates/prisma/src/app/utils/app_error.ts +10 -10
  124. package/templates/prisma/src/app/utils/auth_cookie.ts +27 -0
  125. package/templates/prisma/src/app/utils/catch_async.ts +8 -8
  126. package/templates/prisma/src/app/utils/cloudinary.ts +26 -59
  127. package/templates/prisma/src/app/utils/mail_sender.ts +26 -92
  128. package/templates/prisma/src/app/utils/manage_response.ts +19 -19
  129. package/templates/prisma/src/app/utils/otp.ts +20 -0
  130. package/templates/prisma/src/app/utils/session.ts +57 -0
  131. package/templates/prisma/src/app.ts +73 -42
  132. package/templates/prisma/src/routes.ts +12 -10
  133. package/templates/prisma/src/server.ts +37 -19
  134. package/templates/prisma/src/swaggerOptions.ts +22 -26
  135. package/templates/prisma/tests/app.test.ts +86 -0
  136. package/templates/prisma/tests/helpers/fixtures.ts +35 -0
  137. package/templates/prisma/tests/helpers/prisma.ts +32 -0
  138. package/templates/prisma/tests/routes/account.test.ts +627 -0
  139. package/templates/prisma/tests/routes/profile.test.ts +91 -0
  140. package/templates/prisma/tests/routes/social.test.ts +317 -0
  141. package/templates/prisma/tests/setup.ts +10 -0
  142. package/templates/prisma/tsconfig.json +16 -23
  143. package/templates/prisma/vitest.config.ts +38 -0
  144. package/bin/cli.js +0 -41
  145. package/src/createProject.js +0 -61
  146. package/src/generateModule.js +0 -93
  147. package/src/utils/getProjectType.js +0 -13
  148. package/src/utils/mongoose/controllerGenerator.js +0 -70
  149. package/src/utils/mongoose/routeGenerator.js +0 -27
  150. package/src/utils/mongoose/schemaGenerator.js +0 -11
  151. package/src/utils/mongoose/serviceGenerator.js +0 -49
  152. package/src/utils/mongoose/swaggerGenerator.js +0 -112
  153. package/src/utils/mongoose/validationGenerator.js +0 -13
  154. package/src/utils/prisma/prismaSchemaGenerator.js +0 -10
  155. package/src/utils/prisma/prismaServiceGenerator.js +0 -47
  156. package/src/utils/routeInjector.js +0 -40
  157. package/src/utils/swaggerInjector.js +0 -52
  158. package/src/utils/validateModuleName.js +0 -17
  159. package/src/utils/validateProjectName.js +0 -15
  160. package/src/utils.js +0 -26
  161. package/templates/mongoose/src/app/errors/castError.ts +0 -23
  162. package/templates/mongoose/src/app/errors/duplicateError.ts +0 -26
  163. package/templates/mongoose/src/app/errors/validationError.ts +0 -25
  164. package/templates/mongoose/src/app/modules/auth/auth.controller.ts +0 -127
  165. package/templates/mongoose/src/app/modules/auth/auth.interface.ts +0 -24
  166. package/templates/mongoose/src/app/modules/auth/auth.route.ts +0 -46
  167. package/templates/mongoose/src/app/modules/auth/auth.schema.ts +0 -19
  168. package/templates/mongoose/src/app/modules/auth/auth.service.ts +0 -324
  169. package/templates/mongoose/src/app/modules/auth/auth.swagger.ts +0 -214
  170. package/templates/mongoose/src/app/modules/auth/auth.validation.ts +0 -37
  171. package/templates/mongoose/src/app/modules/user/user.controller.ts +0 -17
  172. package/templates/mongoose/src/app/modules/user/user.interface.ts +0 -15
  173. package/templates/mongoose/src/app/modules/user/user.route.ts +0 -20
  174. package/templates/mongoose/src/app/modules/user/user.schema.ts +0 -22
  175. package/templates/mongoose/src/app/modules/user/user.service.ts +0 -22
  176. package/templates/mongoose/src/app/modules/user/user.swagger.ts +0 -75
  177. package/templates/mongoose/src/app/modules/user/user.validation.ts +0 -18
  178. package/templates/mongoose/src/app/types/index.d.ts +0 -12
  179. package/templates/mongoose/src/app/utils/isAccountExist.ts +0 -31
  180. package/templates/prisma/src/app/types/index.d.ts +0 -12
  181. package/templates/prisma/src/app/utils/otpGenerator.ts +0 -29
@@ -0,0 +1,97 @@
1
+ import { model, Schema, type Types } from 'mongoose';
2
+ import { profile_model } from '../profile/profile.schema.js';
3
+ import {
4
+ PROVIDERS,
5
+ ROLES,
6
+ type T_account,
7
+ type T_refresh_token,
8
+ type T_social_account,
9
+ type T_social_login_code,
10
+ } from './account.interface.js';
11
+
12
+ const accountSchema = new Schema<T_account>(
13
+ {
14
+ email: { type: String, required: true, unique: true, lowercase: true, trim: true },
15
+ // never loaded unless asked for with `.select('+password')`
16
+ password: { type: String, default: null, select: false },
17
+ provider: { type: String, enum: PROVIDERS, default: 'LOCAL' },
18
+ role: { type: String, enum: ROLES, default: 'USER' },
19
+ isDeleted: { type: Boolean, default: false },
20
+ isAccountVerified: { type: Boolean, default: false },
21
+ tokenVersion: { type: Number, default: 0 },
22
+ lastOtp: { type: String, default: null, select: false },
23
+ lastOtpSendingTime: { type: Date, default: null },
24
+ otpAttempts: { type: Number, default: 0 },
25
+ },
26
+ {
27
+ timestamps: true,
28
+ versionKey: false,
29
+ toJSON: {
30
+ // belt and braces: secrets never leave the server, even if selected
31
+ transform: (_doc, ret: Record<string, unknown>) => {
32
+ delete ret.password;
33
+ delete ret.lastOtp;
34
+ return ret;
35
+ },
36
+ },
37
+ },
38
+ );
39
+
40
+ export const account_model = model('account', accountSchema);
41
+
42
+ const socialAccountSchema = new Schema<T_social_account>(
43
+ {
44
+ accountId: { type: Schema.Types.ObjectId, ref: 'account', required: true, index: true },
45
+ provider: { type: String, enum: PROVIDERS, required: true },
46
+ providerUserId: { type: String, required: true },
47
+ },
48
+ { timestamps: { createdAt: true, updatedAt: false }, versionKey: false },
49
+ );
50
+ socialAccountSchema.index({ provider: 1, providerUserId: 1 }, { unique: true });
51
+
52
+ export const social_account_model = model('social_account', socialAccountSchema);
53
+
54
+ const socialLoginCodeSchema = new Schema<T_social_login_code>(
55
+ {
56
+ _id: { type: String, required: true },
57
+ accountId: { type: Schema.Types.ObjectId, ref: 'account', required: true },
58
+ // TTL index: MongoDB deletes expired codes by itself
59
+ expiresAt: { type: Date, required: true, expires: 0 },
60
+ },
61
+ { versionKey: false },
62
+ );
63
+
64
+ export const social_login_code_model = model('social_login_code', socialLoginCodeSchema);
65
+
66
+ const refreshTokenSchema = new Schema<T_refresh_token>(
67
+ {
68
+ _id: { type: String, required: true },
69
+ accountId: { type: Schema.Types.ObjectId, ref: 'account', required: true, index: true },
70
+ familyId: { type: String, required: true, index: true },
71
+ tokenVersion: { type: Number, required: true },
72
+ // TTL index: MongoDB deletes expired tokens by itself
73
+ expiresAt: { type: Date, required: true, expires: 0 },
74
+ usedAt: { type: Date, default: null },
75
+ },
76
+ { timestamps: { createdAt: true, updatedAt: false }, versionKey: false },
77
+ );
78
+
79
+ export const refresh_token_model = model('refresh_token', refreshTokenSchema);
80
+
81
+ /**
82
+ * Creates an account and its profile. Without a transaction (they need a replica
83
+ * set), the account is removed again if the profile can't be created.
84
+ */
85
+ export const createAccountWithProfile = async (
86
+ account: Partial<T_account> & { _id?: Types.ObjectId },
87
+ profile: { fullName: string; profilePhoto?: string | null },
88
+ ) => {
89
+ const created = await account_model.create(account);
90
+ try {
91
+ await profile_model.create({ accountId: created._id, ...profile });
92
+ } catch (err) {
93
+ await account_model.deleteOne({ _id: created._id });
94
+ throw err;
95
+ }
96
+ return created;
97
+ };
@@ -0,0 +1,422 @@
1
+ import bcrypt from 'bcrypt';
2
+ import type { Request } from 'express';
3
+ import { isValidObjectId, Types } from 'mongoose';
4
+ import { configs } from '../../configs/index.js';
5
+ import { hashLoginCode } from '../../passport-js/social-account.js';
6
+ import { enqueueEmail } from '../../queues/email.queue.js';
7
+ import { AppError } from '../../utils/app_error.js';
8
+ import { jwtHelpers, type TokenUser } from '../../utils/JWT.js';
9
+ import { hashRefreshToken, readRefreshToken } from '../../utils/session.js';
10
+ import {
11
+ generateOtp,
12
+ hashOtp,
13
+ isOtpMatch,
14
+ OTP_EXPIRY_MS,
15
+ OTP_MAX_ATTEMPTS,
16
+ OTP_RESEND_COOLDOWN_MS,
17
+ } from '../../utils/otp.js';
18
+ import { profile_model } from '../profile/profile.schema.js';
19
+ import {
20
+ account_model,
21
+ createAccountWithProfile,
22
+ refresh_token_model,
23
+ social_account_model,
24
+ social_login_code_model,
25
+ } from './account.schema.js';
26
+
27
+ // cheap hashes under tests, the real cost everywhere else
28
+ const BCRYPT_ROUNDS = configs.is_test ? 4 : 12;
29
+
30
+ // a rotated refresh token that comes back within this window is most likely a
31
+ // parallel refresh (two tabs at once), not theft, so it doesn't end the session
32
+ const REFRESH_REUSE_GRACE_MS = 30 * 1000;
33
+
34
+ // a valid hash to compare against when the account doesn't exist, so login takes
35
+ // the same time either way (prevents finding registered emails by timing)
36
+ const DUMMY_HASH = bcrypt.hashSync('dummy-password', BCRYPT_ROUNDS);
37
+
38
+ type VerifyTokenPayload = { purpose: 'verify-account'; accountId: string };
39
+ // `tv` is the account's token version; any password change makes the link stale
40
+ type ResetTokenPayload = { purpose: 'reset-password'; accountId: string; tv: number };
41
+
42
+ const toTokenUser = (account: {
43
+ _id: unknown;
44
+ email: string;
45
+ role: TokenUser['role'];
46
+ tokenVersion: number;
47
+ }): TokenUser => ({
48
+ accountId: String(account._id),
49
+ email: account.email,
50
+ role: account.role,
51
+ tokenVersion: account.tokenVersion,
52
+ });
53
+
54
+ /** A fresh OTP + verification link, and the fields to store for it. */
55
+ const newVerification = (accountId: string) => {
56
+ const otp = generateOtp();
57
+ const token = jwtHelpers.generateToken(
58
+ { purpose: 'verify-account', accountId } satisfies VerifyTokenPayload,
59
+ configs.jwt.verified_token,
60
+ '5m',
61
+ );
62
+ return {
63
+ otp,
64
+ verificationLink: `${configs.jwt.front_end_url}/verify?token=${encodeURIComponent(token)}`,
65
+ data: { lastOtp: hashOtp(otp), lastOtpSendingTime: new Date(), otpAttempts: 0 },
66
+ };
67
+ };
68
+
69
+ const create_account_into_db = async (req: Request) => {
70
+ const payload: { email: string; password: string; fullName: string } = req.body;
71
+
72
+ if (await account_model.exists({ email: payload.email })) {
73
+ throw new AppError('An account with this email already exists', 409);
74
+ }
75
+
76
+ // id generated up front so the account and its OTP are written in one insert
77
+ const _id = new Types.ObjectId();
78
+ const { otp, verificationLink, data } = newVerification(String(_id));
79
+
80
+ const account = await createAccountWithProfile(
81
+ {
82
+ _id,
83
+ email: payload.email,
84
+ password: await bcrypt.hash(payload.password, BCRYPT_ROUNDS),
85
+ ...data,
86
+ },
87
+ { fullName: payload.fullName },
88
+ );
89
+
90
+ await enqueueEmail({ type: 'verify-account', email: account.email, otp, verificationLink });
91
+
92
+ return {
93
+ id: String(account._id),
94
+ email: account.email,
95
+ role: account.role,
96
+ isAccountVerified: account.isAccountVerified,
97
+ profile: { fullName: payload.fullName },
98
+ };
99
+ };
100
+
101
+ const verify_account_using_otp_into_db = async (req: Request) => {
102
+ const payload: { email: string; otp: string } = req.body;
103
+ const invalid = new AppError('Invalid or expired OTP', 401);
104
+
105
+ const account = await account_model.findOne({ email: payload.email }).select('+lastOtp').lean();
106
+ if (!account || account.isAccountVerified || !account.lastOtp) {
107
+ throw invalid;
108
+ }
109
+
110
+ const isExpired =
111
+ !account.lastOtpSendingTime ||
112
+ Date.now() - account.lastOtpSendingTime.getTime() > OTP_EXPIRY_MS;
113
+ if (isExpired) {
114
+ throw invalid;
115
+ }
116
+
117
+ // use up an attempt atomically, so parallel guesses can't go past the limit
118
+ const attempt = await account_model.updateOne(
119
+ { _id: account._id, otpAttempts: { $lt: OTP_MAX_ATTEMPTS } },
120
+ { $inc: { otpAttempts: 1 } },
121
+ );
122
+ if (!attempt.modifiedCount) {
123
+ throw new AppError('Too many wrong attempts, please request a new OTP', 429);
124
+ }
125
+
126
+ if (!isOtpMatch(payload.otp, account.lastOtp)) {
127
+ throw invalid;
128
+ }
129
+
130
+ // only succeeds if this OTP wasn't replaced or used in the meantime
131
+ const verified = await account_model.updateOne(
132
+ { _id: account._id, lastOtp: account.lastOtp },
133
+ { $set: { isAccountVerified: true, lastOtp: null, otpAttempts: 0 } },
134
+ );
135
+ if (!verified.modifiedCount) {
136
+ throw invalid;
137
+ }
138
+ return null;
139
+ };
140
+
141
+ const verify_account_using_link_into_db = async (req: Request) => {
142
+ const decoded = jwtHelpers.verifyToken<VerifyTokenPayload>(
143
+ req.body.token,
144
+ configs.jwt.verified_token,
145
+ );
146
+ if (decoded.purpose !== 'verify-account' || !isValidObjectId(decoded.accountId)) {
147
+ throw new AppError('Invalid token', 401);
148
+ }
149
+
150
+ const result = await account_model.updateOne(
151
+ { _id: decoded.accountId, isDeleted: false },
152
+ { $set: { isAccountVerified: true, lastOtp: null, otpAttempts: 0 } },
153
+ );
154
+ if (!result.matchedCount) {
155
+ throw new AppError('Invalid token', 401);
156
+ }
157
+ return null;
158
+ };
159
+
160
+ const login_user_into_db = async (req: Request): Promise<TokenUser> => {
161
+ const payload: { email: string; password: string } = req.body;
162
+ const invalid = new AppError('Invalid email or password', 401);
163
+
164
+ const account = await account_model.findOne({ email: payload.email }).select('+password').lean();
165
+
166
+ // always run bcrypt, and never skip the password check: social-login accounts
167
+ // have no password and must sign in through their provider
168
+ const isPasswordMatch = await bcrypt.compare(payload.password, account?.password ?? DUMMY_HASH);
169
+ if (!account || !account.password || !isPasswordMatch) {
170
+ throw invalid;
171
+ }
172
+
173
+ if (account.isDeleted) {
174
+ throw new AppError('Account is deleted', 401);
175
+ }
176
+ if (!account.isAccountVerified) {
177
+ throw new AppError('Account is not verified', 401);
178
+ }
179
+
180
+ return toTokenUser(account);
181
+ };
182
+
183
+ const get_user_account_from_db = async (req: Request) => {
184
+ const accountId = req.user!.accountId;
185
+ const [account, profile, socialAccounts] = await Promise.all([
186
+ account_model
187
+ .findById(accountId)
188
+ .select('email role provider isAccountVerified isDeleted')
189
+ .lean(),
190
+ profile_model.findOne({ accountId }).select('fullName profilePhoto -_id').lean(),
191
+ social_account_model.find({ accountId }).select('provider createdAt -_id').lean(),
192
+ ]);
193
+ if (!account) return null;
194
+ const { _id, ...rest } = account;
195
+ return { id: String(_id), ...rest, profile, socialAccounts };
196
+ };
197
+
198
+ /** Changes the password and revokes every other session. Returns the user for a new token. */
199
+ const change_password_into_db = async (req: Request): Promise<TokenUser> => {
200
+ const payload: { oldPassword: string; newPassword: string } = req.body;
201
+
202
+ if (payload.oldPassword === payload.newPassword) {
203
+ throw new AppError('New password must be different from the old password', 400);
204
+ }
205
+
206
+ const account = await account_model.findById(req.user!.accountId).select('+password').lean();
207
+ if (!account) {
208
+ throw new AppError('Account not found', 404);
209
+ }
210
+ if (!account.password) {
211
+ throw new AppError('This account uses social login and has no password', 400);
212
+ }
213
+
214
+ const isPasswordMatch = await bcrypt.compare(payload.oldPassword, account.password);
215
+ if (!isPasswordMatch) {
216
+ throw new AppError('Incorrect password', 401);
217
+ }
218
+
219
+ const updated = await account_model
220
+ .findByIdAndUpdate(
221
+ account._id,
222
+ {
223
+ $set: { password: await bcrypt.hash(payload.newPassword, BCRYPT_ROUNDS) },
224
+ $inc: { tokenVersion: 1 },
225
+ },
226
+ { returnDocument: 'after' },
227
+ )
228
+ .lean();
229
+ await refresh_token_model.deleteMany({ accountId: account._id });
230
+ return toTokenUser(updated!);
231
+ };
232
+
233
+ const resend_otp_and_verification_link_from_db = async (req: Request) => {
234
+ const email: string = req.body.email;
235
+ const account = await account_model.findOne({ email }).select('isAccountVerified').lean();
236
+
237
+ // same response whether or not the account exists (no email enumeration)
238
+ if (!account || account.isAccountVerified) return null;
239
+
240
+ const { otp, verificationLink, data } = newVerification(String(account._id));
241
+
242
+ // the cooldown check is part of the update, so parallel requests send one email
243
+ const result = await account_model.updateOne(
244
+ {
245
+ _id: account._id,
246
+ isAccountVerified: false,
247
+ $or: [
248
+ { lastOtpSendingTime: null },
249
+ { lastOtpSendingTime: { $lt: new Date(Date.now() - OTP_RESEND_COOLDOWN_MS) } },
250
+ ],
251
+ },
252
+ { $set: data },
253
+ );
254
+ if (!result.modifiedCount) {
255
+ throw new AppError('Please wait a minute before requesting a new OTP', 429);
256
+ }
257
+
258
+ await enqueueEmail({ type: 'verify-account', email, otp, verificationLink });
259
+ return null;
260
+ };
261
+
262
+ const forget_password_generate_reset_token_from_db = async (req: Request) => {
263
+ const email: string = req.body.email;
264
+ const account = await account_model.findOne({ email }).select('isDeleted tokenVersion').lean();
265
+
266
+ // same response whether or not the account exists (no email enumeration)
267
+ if (!account || account.isDeleted) return null;
268
+
269
+ const resetToken = jwtHelpers.generateToken(
270
+ {
271
+ purpose: 'reset-password',
272
+ accountId: String(account._id),
273
+ tv: account.tokenVersion,
274
+ } satisfies ResetTokenPayload,
275
+ configs.jwt.reset_secret,
276
+ configs.jwt.reset_expires,
277
+ );
278
+ const resetLink = `${configs.jwt.front_end_url}/reset-password?token=${encodeURIComponent(resetToken)}`;
279
+
280
+ await enqueueEmail({ type: 'reset-password', email, resetLink });
281
+ return null;
282
+ };
283
+
284
+ const reset_password_using_token_into_db = async (req: Request) => {
285
+ const { token, newPass }: { token: string; newPass: string } = req.body;
286
+ const invalid = new AppError('Invalid or expired reset link', 401);
287
+
288
+ // the account comes from the signed token, never from the request body
289
+ const decoded = jwtHelpers.verifyToken<ResetTokenPayload>(token, configs.jwt.reset_secret);
290
+ if (
291
+ decoded.purpose !== 'reset-password' ||
292
+ typeof decoded.tv !== 'number' ||
293
+ !isValidObjectId(decoded.accountId)
294
+ ) {
295
+ throw invalid;
296
+ }
297
+
298
+ // Matching on the token version makes the link single-use (the update bumps it)
299
+ // and also signs out every existing session. Following the emailed link proves
300
+ // ownership of the address, so the account counts as verified from now on.
301
+ const result = await account_model.updateOne(
302
+ { _id: decoded.accountId, isDeleted: false, tokenVersion: decoded.tv },
303
+ {
304
+ $set: {
305
+ password: await bcrypt.hash(newPass, BCRYPT_ROUNDS),
306
+ isAccountVerified: true,
307
+ lastOtp: null,
308
+ otpAttempts: 0,
309
+ },
310
+ $inc: { tokenVersion: 1 },
311
+ },
312
+ );
313
+ if (!result.modifiedCount) {
314
+ throw invalid;
315
+ }
316
+ await refresh_token_model.deleteMany({ accountId: decoded.accountId });
317
+ return null;
318
+ };
319
+
320
+ /** Revokes every access and refresh token issued for the account. */
321
+ const sign_out_everywhere_from_db = async (req: Request) => {
322
+ const accountId = req.user!.accountId;
323
+ await account_model.updateOne({ _id: accountId }, { $inc: { tokenVersion: 1 } });
324
+ await refresh_token_model.deleteMany({ accountId });
325
+ return null;
326
+ };
327
+
328
+ /** Ends the session the refresh token belongs to (this device only). */
329
+ const sign_out_from_db = async (req: Request) => {
330
+ const token = readRefreshToken(req);
331
+ if (!token) return null;
332
+ const row = await refresh_token_model.findById(hashRefreshToken(token)).select('familyId').lean();
333
+ if (row) await refresh_token_model.deleteMany({ familyId: row.familyId });
334
+ return null;
335
+ };
336
+
337
+ /**
338
+ * Validates and uses up a refresh token. Returns the user and the session it
339
+ * belongs to, so the controller can issue the next token of that session.
340
+ */
341
+ const refresh_session_from_db = async (
342
+ req: Request,
343
+ ): Promise<{ user: TokenUser; familyId: string }> => {
344
+ const invalid = new AppError('Invalid or expired refresh token', 401);
345
+ const token = readRefreshToken(req);
346
+ if (!token) throw invalid;
347
+
348
+ const tokenHash = hashRefreshToken(token);
349
+ const row = await refresh_token_model.findById(tokenHash).lean();
350
+ if (!row) throw invalid;
351
+
352
+ const endSession = () => refresh_token_model.deleteMany({ familyId: row.familyId });
353
+
354
+ if (row.usedAt) {
355
+ // An already rotated token came back. Outside the grace window that means two
356
+ // parties hold this session (the token was stolen), so end the whole session.
357
+ if (Date.now() - row.usedAt.getTime() > REFRESH_REUSE_GRACE_MS) await endSession();
358
+ throw invalid;
359
+ }
360
+
361
+ const account = await account_model
362
+ .findById(row.accountId)
363
+ .select('email role tokenVersion isDeleted')
364
+ .lean();
365
+ const isStale =
366
+ row.expiresAt.getTime() < Date.now() ||
367
+ !account ||
368
+ account.isDeleted ||
369
+ account.tokenVersion !== row.tokenVersion; // password changed / signed out everywhere
370
+ if (isStale) {
371
+ await endSession();
372
+ throw invalid;
373
+ }
374
+
375
+ // mark it used atomically, so of two parallel refreshes only one gets a new token
376
+ const used = await refresh_token_model.updateOne(
377
+ { _id: tokenHash, usedAt: null },
378
+ { $set: { usedAt: new Date() } },
379
+ );
380
+ if (!used.modifiedCount) throw invalid;
381
+
382
+ return { user: toTokenUser(account), familyId: row.familyId };
383
+ };
384
+
385
+ /** Trades the single-use code from a social login redirect for the user to sign in. */
386
+ const exchange_social_login_code_from_db = async (req: Request): Promise<TokenUser> => {
387
+ const invalid = new AppError('Invalid or expired login code', 401);
388
+
389
+ // deleting is what makes the code single-use, even under concurrent requests
390
+ const row = await social_login_code_model
391
+ .findOneAndDelete({ _id: hashLoginCode(req.body.code) })
392
+ .lean();
393
+ // the TTL index removes expired codes within a minute; check expiry anyway
394
+ if (!row || row.expiresAt.getTime() < Date.now()) {
395
+ throw invalid;
396
+ }
397
+
398
+ const account = await account_model
399
+ .findById(row.accountId)
400
+ .select('email role tokenVersion isDeleted')
401
+ .lean();
402
+ if (!account || account.isDeleted) {
403
+ throw invalid;
404
+ }
405
+ return toTokenUser(account);
406
+ };
407
+
408
+ export const account_services = {
409
+ create_account_into_db,
410
+ login_user_into_db,
411
+ get_user_account_from_db,
412
+ change_password_into_db,
413
+ verify_account_using_otp_into_db,
414
+ resend_otp_and_verification_link_from_db,
415
+ verify_account_using_link_into_db,
416
+ forget_password_generate_reset_token_from_db,
417
+ reset_password_using_token_into_db,
418
+ sign_out_everywhere_from_db,
419
+ sign_out_from_db,
420
+ refresh_session_from_db,
421
+ exchange_social_login_code_from_db,
422
+ };