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
@@ -4,44 +4,82 @@
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1",
7
+ "test": "vitest run",
8
+ "test:watch": "vitest",
8
9
  "dev": "tsx watch src/server.ts",
9
- "build": "tsc"
10
+ "build": "tsc",
11
+ "lint": "eslint .",
12
+ "lint:fix": "eslint . --fix",
13
+ "format": "prettier --write .",
14
+ "check": "tsc --noEmit",
15
+ "db:migrate": "prisma migrate dev",
16
+ "db:deploy": "prisma migrate deploy"
10
17
  },
11
18
  "keywords": [],
12
19
  "author": "",
13
20
  "license": "ISC",
14
21
  "type": "module",
15
22
  "dependencies": {
16
- "@prisma/adapter-pg": "^7.5.0",
17
- "@prisma/client": "^7.5.0",
23
+ "@prisma/adapter-pg": "^7.8.0",
24
+ "@prisma/client": "^7.8.0",
18
25
  "bcrypt": "^6.0.0",
19
26
  "cloudinary": "^2.7.0",
20
27
  "cookie-parser": "^1.4.7",
21
28
  "cors": "^2.8.5",
22
- "dotenv": "^17.3.1",
29
+ "dotenv": "^17.4.2",
23
30
  "express": "^5.1.0",
31
+ "express-rate-limit": "^8.7.1",
32
+ "helmet": "^8.3.0",
24
33
  "jsonwebtoken": "^9.0.2",
25
34
  "multer": "^2.0.2",
26
- "nodemailer": "^7.0.9",
35
+ "nodemailer": "^8.0.5",
36
+ "passport": "^0.7.0",
37
+ "passport-facebook": "^3.0.0",
38
+ "passport-github": "^1.1.0",
39
+ "passport-google-oauth20": "^2.0.0",
40
+ "passport-oauth1": "^1.3.0",
41
+ "passport-oauth2": "^1.8.0",
27
42
  "pg": "^8.20.0",
43
+ "pg-boss": "^12.37.1",
44
+ "pino": "^10.3.1",
28
45
  "swagger-jsdoc": "^6.2.8",
29
46
  "swagger-ui-express": "^5.0.1",
30
47
  "zod": "^4.1.12"
31
48
  },
32
49
  "devDependencies": {
50
+ "@eslint/js": "^10.0.1",
33
51
  "@types/bcrypt": "^6.0.0",
34
52
  "@types/cookie-parser": "^1.4.9",
35
53
  "@types/cors": "^2.8.19",
36
54
  "@types/express": "^5.0.3",
37
55
  "@types/jsonwebtoken": "^9.0.10",
38
56
  "@types/multer": "^2.0.0",
39
- "@types/node": "^24.10.9",
57
+ "@types/node": "^24.12.2",
40
58
  "@types/nodemailer": "^7.0.2",
59
+ "@types/passport": "^1.0.17",
60
+ "@types/passport-facebook": "^3.0.4",
61
+ "@types/passport-github": "^1.1.13",
62
+ "@types/passport-google-oauth20": "^2.0.17",
63
+ "@types/passport-oauth2": "^1.8.0",
41
64
  "@types/pg": "^8.20.0",
65
+ "@types/supertest": "^7.2.1",
42
66
  "@types/swagger-jsdoc": "^6.0.4",
43
67
  "@types/swagger-ui-express": "^4.1.8",
44
- "prisma": "^7.5.0",
45
- "tsx": "^4.21.0"
68
+ "@typescript-eslint/eslint-plugin": "^8.59.0",
69
+ "@typescript-eslint/parser": "^8.59.0",
70
+ "eslint": "^10.2.1",
71
+ "eslint-config-prettier": "^10.1.8",
72
+ "eslint-plugin-prettier": "^5.5.5",
73
+ "pino-pretty": "^13.1.3",
74
+ "prettier": "^3.8.3",
75
+ "prisma": "^7.8.0",
76
+ "supertest": "^7.3.1",
77
+ "tsx": "^4.21.0",
78
+ "typescript": "^5.9.3",
79
+ "typescript-eslint": "^8.59.0",
80
+ "vitest": "^5.0.4"
81
+ },
82
+ "engines": {
83
+ "node": ">=22.12.0"
46
84
  }
47
85
  }
@@ -3,17 +3,99 @@ enum ROLE {
3
3
  USER
4
4
  }
5
5
 
6
+ enum PROVIDER {
7
+ LOCAL
8
+ GOOGLE
9
+ FACEBOOK
10
+ GITHUB
11
+ TWITTER
12
+ LINKEDIN
13
+ DISCORD
14
+ }
15
+
6
16
  model Account {
7
- id String @id @default(uuid())
8
- email String @unique
9
- password String
10
- role ROLE @default(USER)
11
- lastOtp String?
17
+ // PK
18
+ id String @id @default(uuid())
19
+ email String @unique
20
+ password String?
21
+ // provider the account was created with
22
+ provider PROVIDER @default(LOCAL)
23
+ role ROLE @default(USER)
24
+ isDeleted Boolean @default(false)
25
+ isAccountVerified Boolean @default(false)
26
+ // bumped on password change/reset and "sign out everywhere"; access tokens
27
+ // and reset links carrying an older version stop working
28
+ tokenVersion Int @default(0)
29
+
30
+ // email verification (the OTP is stored as an HMAC, never in plain text)
31
+ lastOtp String?
12
32
  lastOtpSendingTime DateTime?
13
- isDeleted Boolean @default(false)
14
- isAccountVerified Boolean @default(false)
15
- createdAt DateTime @default(now())
16
- updatedAt DateTime @default(now())
33
+ otpAttempts Int @default(0)
34
+
35
+ createdAt DateTime @default(now())
36
+ updatedAt DateTime @updatedAt
37
+
38
+ // Relation
39
+ profile Profile?
40
+ socialAccounts SocialAccount[]
41
+ loginCodes SocialLoginCode[]
42
+ refreshTokens RefreshToken[]
43
+ }
44
+
45
+ /// Refresh tokens are opaque random strings; only their hash is stored. Every
46
+ /// refresh rotates the token, and all tokens of one sign-in share a `familyId`, so
47
+ /// a stolen token that gets reused can revoke the whole session.
48
+ model RefreshToken {
49
+ tokenHash String @id
50
+ accountId String
51
+ familyId String
52
+ // the account's tokenVersion when issued; a password change makes it stale
53
+ tokenVersion Int
54
+ expiresAt DateTime
55
+ // set when the token is rotated; kept until expiry to detect reuse
56
+ usedAt DateTime?
57
+ createdAt DateTime @default(now())
58
+
59
+ account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
60
+
61
+ @@index([accountId])
62
+ @@index([familyId])
63
+ @@index([expiresAt])
64
+ }
65
+
66
+ /// A social identity linked to an account. Lookups use the provider's stable user
67
+ /// id, so a login keeps working even if the email at the provider changes.
68
+ model SocialAccount {
69
+ id String @id @default(uuid())
70
+ accountId String
71
+ provider PROVIDER
72
+ providerUserId String
73
+ createdAt DateTime @default(now())
74
+
75
+ account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
76
+
77
+ @@unique([provider, providerUserId])
78
+ @@index([accountId])
79
+ }
80
+
81
+ /// Single-use code handed to the front-end after a social login, exchanged for an
82
+ /// access token via `POST /api/auth/social/exchange`. Only the hash is stored.
83
+ model SocialLoginCode {
84
+ codeHash String @id
85
+ accountId String
86
+ expiresAt DateTime
87
+ createdAt DateTime @default(now())
88
+
89
+ account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
90
+
91
+ @@index([expiresAt])
92
+ }
93
+
94
+ /// Hit counters for the auth rate limiters, shared by every app instance.
95
+ model RateLimit {
96
+ key String @id
97
+ hits Int
98
+ resetAt DateTime
17
99
 
18
- profile Profile?
100
+ @@index([resetAt])
19
101
  }
@@ -1,8 +1,13 @@
1
1
  model Profile {
2
- id String @id @default(uuid())
3
- accountId String @unique
4
- account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
2
+ // PK
3
+ id String @id @default(uuid())
4
+
5
+ // FK
6
+ accountId String @unique
5
7
 
6
8
  fullName String
7
9
  profilePhoto String?
10
+
11
+ // Relation
12
+ account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
8
13
  }
@@ -1,6 +1,5 @@
1
1
  generator client {
2
- provider = "prisma-client"
3
- output = "../generated/prisma"
2
+ provider = "prisma-client-js"
4
3
  }
5
4
 
6
5
  datasource db {
@@ -1,14 +1,14 @@
1
1
  // This file was generated by Prisma, and assumes you have installed the following:
2
2
  // npm install --save-dev prisma dotenv
3
- import "dotenv/config";
4
- import { defineConfig } from "prisma/config";
3
+ import 'dotenv/config';
4
+ import { defineConfig } from 'prisma/config';
5
5
 
6
6
  export default defineConfig({
7
- schema: "prisma/schema",
7
+ schema: 'prisma/schema',
8
8
  migrations: {
9
- path: "prisma/migrations",
9
+ path: 'prisma/migrations',
10
10
  },
11
11
  datasource: {
12
- url: process.env["DATABASE_URL"],
12
+ url: process.env['DATABASE_URL'] as string,
13
13
  },
14
14
  });
@@ -93,9 +93,90 @@ Swagger docs available at:
93
93
  http://localhost:5000/docs
94
94
  ```
95
95
 
96
+ ## 🔐 Environment
97
+
98
+ Copy `.env.example` to `.env` and fill it in. The app validates its environment
99
+ on startup and exits with a list of anything missing or invalid.
100
+
101
+ `ACCESS_TOKEN`, `VERIFIED_TOKEN` and `RESET_SECRET` must be **different** random
102
+ values of at least 32 characters:
103
+
104
+ ```bash
105
+ openssl rand -hex 32
106
+ ```
107
+
108
+ ## 📬 Background Jobs
109
+
110
+ Emails (account verification and password reset) are sent through a single
111
+ `email` queue powered by [pg-boss](https://github.com/timgit/pg-boss). It stores
112
+ jobs in a `pgboss` schema in the same PostgreSQL database, so no Redis is needed.
113
+ Failed sends are retried with backoff.
114
+
115
+ To add a new email, add a variant to `EmailJob` in `src/app/queues/email.queue.ts`
116
+ and handle it in `render()`.
117
+
118
+ A scheduled `maintenance` job (every 15 minutes, run once across all instances)
119
+ deletes expired rate limit counters and social login codes.
120
+
121
+ ## 🔐 Sessions & Refresh Tokens
122
+
123
+ Signing in (password or social) returns a short-lived **access token** (`ACCESS_EXPIRES`,
124
+ 15 minutes by default) and a **refresh token** (`REFRESH_EXPIRES_DAYS`, 30 by default).
125
+ Both are also set as httpOnly cookies; the refresh cookie is only sent to `/api/auth`.
126
+
127
+ - `POST /api/auth/refresh-token`: when the access token expires (401), get new tokens.
128
+ Browsers send the cookie automatically; other clients send `{ "refreshToken": "..." }`.
129
+ - `POST /api/auth/sign-out`: ends this device's session.
130
+ - `POST /api/auth/sign-out-all`: ends every session (also happens on password change/reset).
131
+
132
+ Refresh tokens are random strings stored only as hashes, and are **rotated** on every
133
+ refresh. If an already-used refresh token shows up again, it was most likely stolen,
134
+ so the whole session is revoked. (A reuse within 30 seconds is treated as two tabs
135
+ refreshing at once and only that request fails.)
136
+
137
+ ## 🔑 Social Login
138
+
139
+ Supported: **Google, Facebook, GitHub, X (Twitter), LinkedIn and Discord**. Set
140
+ `IS_<PROVIDER>_LOGIN=true` and fill in the credentials in `.env`.
141
+
142
+ - `GET /api/auth/social/providers`: enabled providers, for rendering buttons
143
+ - `GET /api/auth/social/<provider>?returnTo=/dashboard`: start the login (a link, not `fetch`)
144
+ - `GET /api/auth/social/<provider>/callback`: OAuth callback (register this URL at the provider)
145
+ - `POST /api/auth/social/exchange` `{ "code": "..." }`: trade the code for an access token
146
+
147
+ After the provider, the browser lands on `SOCIAL_REDIRECT_URL` (default
148
+ `FRONT_END_URL/auth/social/callback`) with either `?code=...&returnTo=...` or
149
+ `?error=...`. The front-end posts the code (single-use, valid for 60 seconds) to
150
+ `/exchange`, which returns the access token and sets the cookie, like `/sign-in`.
151
+
152
+ Every login is protected by an OAuth `state` (and PKCE for Google), stored in a
153
+ short-lived encrypted cookie, so no session store is needed. Identities are linked
154
+ by the provider's user id, and only verified provider emails are used to create or
155
+ link accounts. Providers are defined in `src/app/passport-js/providers.ts`.
156
+
157
+ ## 🛡️ Rate Limiting
158
+
159
+ The auth endpoints have their own limits (sign-in per IP + email, emails per
160
+ recipient, OTP / reset guesses per IP), stored in the `RateLimit` table so they are
161
+ shared by every instance — no Redis needed. A pg-boss job removes expired rows.
162
+ The whole API also has a lenient per-instance in-memory limit. Tune them in
163
+ `src/app/middlewares/rate_limiter.ts`, and set `TRUST_PROXY` when running behind a
164
+ proxy so the real client IP is used.
165
+
166
+ ## 🧪 Tests
167
+
168
+ ```bash
169
+ npm test
170
+ ```
171
+
172
+ Route tests with [supertest](https://github.com/ladjs/supertest) run each request
173
+ through the route, controller and service. Prisma, the email queue and Cloudinary
174
+ are mocked, so no database is needed.
175
+
96
176
  ## 🧠 Tech Stack
97
177
 
98
178
  - Express.js — Backend framework
179
+ - pg-boss — PostgreSQL job queue
99
180
  - TypeScript — Strongly typed JavaScript
100
181
  - Mongoose — MongoDB ODM
101
182
  - Prisma — PostgreSQL ORM
@@ -1,26 +1,175 @@
1
- import "dotenv/config";
2
-
3
- export const configs = {
4
- port: process.env.PORT,
5
- env: process.env.NODE_ENV,
6
- db_url: process.env.DATABASE_URL,
7
- jwt: {
8
- access_token: process.env.ACCESS_TOKEN,
9
- refresh_token: process.env.REFRESH_TOKEN,
10
- access_expires: process.env.ACCESS_EXPIRES,
11
- refresh_expires: process.env.REFRESH_EXPIRES,
12
- reset_secret: process.env.RESET_SECRET,
13
- reset_expires: process.env.RESET_EXPIRES,
14
- front_end_url: process.env.FRONT_END_URL,
15
- verified_token: process.env.VERIFIED_TOKEN,
16
- },
17
- email: {
18
- app_email: process.env.APP_USER_EMAIL,
19
- app_password: process.env.APP_PASSWORD,
20
- },
21
- cloudinary: {
22
- cloud_name: process.env.CLOUD_NAME,
23
- cloud_api_key: process.env.CLOUD_API_KEY,
24
- cloud_api_secret: process.env.CLOUD_API_SECRET,
25
- },
26
- };
1
+ import 'dotenv/config';
2
+ import { z } from 'zod';
3
+
4
+ const bool = z
5
+ .enum(['true', 'false'])
6
+ .default('false')
7
+ .transform((v) => v === 'true');
8
+
9
+ // JWT/session secrets must be long and random, e.g. `openssl rand -hex 32`
10
+ const secret = z.string().min(32, 'must be at least 32 characters');
11
+
12
+ const envSchema = z.object({
13
+ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
14
+ PORT: z.coerce.number().int().positive().default(5000),
15
+ DATABASE_URL: z.url(),
16
+
17
+ // auth
18
+ ACCESS_TOKEN: secret,
19
+ // short-lived; clients get a new one from POST /api/auth/refresh-token
20
+ ACCESS_EXPIRES: z.string().default('15m'),
21
+ REFRESH_EXPIRES_DAYS: z.coerce.number().int().positive().default(30),
22
+ VERIFIED_TOKEN: secret,
23
+ RESET_SECRET: secret,
24
+ RESET_EXPIRES: z.string().default('15m'),
25
+ FRONT_END_URL: z.url(),
26
+ CORS_ORIGINS: z.string().optional(),
27
+ TRUST_PROXY: z.coerce.number().int().min(0).default(0),
28
+
29
+ // email
30
+ APP_USER_EMAIL: z.string().optional(),
31
+ APP_PASSWORD: z.string().optional(),
32
+ MAIL_FROM: z.string().optional(),
33
+
34
+ // cloudinary
35
+ CLOUD_NAME: z.string().optional(),
36
+ CLOUD_API_KEY: z.string().optional(),
37
+ CLOUD_API_SECRET: z.string().optional(),
38
+
39
+ // swagger
40
+ ENABLE_SWAGGER: z.enum(['true', 'false']).optional(),
41
+ DEVELOPMENT_BASE_URL: z.string().default('http://localhost:5000'),
42
+ PRODUCTION_BASE_URL: z.string().default(''),
43
+
44
+ // social login
45
+ // where the browser is sent after a social login, with `?code=` or `?error=`
46
+ SOCIAL_REDIRECT_URL: z.url().optional(),
47
+
48
+ IS_GOOGLE_LOGIN: bool,
49
+ GOOGLE_CLIENT_ID: z.string().optional(),
50
+ GOOGLE_CLIENT_SECRET: z.string().optional(),
51
+ GOOGLE_CALLBACK_URL: z.string().optional(),
52
+
53
+ IS_FACEBOOK_LOGIN: bool,
54
+ FACEBOOK_APP_ID: z.string().optional(),
55
+ FACEBOOK_APP_SECRET: z.string().optional(),
56
+ FACEBOOK_CALLBACK_URL: z.string().optional(),
57
+
58
+ IS_GITHUB_LOGIN: bool,
59
+ GITHUB_CLIENT_ID: z.string().optional(),
60
+ GITHUB_CLIENT_SECRET: z.string().optional(),
61
+ GITHUB_CALLBACK_URL: z.string().optional(),
62
+
63
+ IS_TWITTER_LOGIN: bool,
64
+ TWITTER_CONSUMER_KEY: z.string().optional(),
65
+ TWITTER_CONSUMER_SECRET: z.string().optional(),
66
+ TWITTER_CALLBACK_URL: z.string().optional(),
67
+
68
+ IS_LINKEDIN_LOGIN: bool,
69
+ LINKEDIN_CLIENT_ID: z.string().optional(),
70
+ LINKEDIN_CLIENT_SECRET: z.string().optional(),
71
+ LINKEDIN_CALLBACK_URL: z.string().optional(),
72
+
73
+ IS_DISCORD_LOGIN: bool,
74
+ DISCORD_CLIENT_ID: z.string().optional(),
75
+ DISCORD_CLIENT_SECRET: z.string().optional(),
76
+ DISCORD_CALLBACK_URL: z.string().optional(),
77
+ });
78
+
79
+ type Env = z.infer<typeof envSchema>;
80
+
81
+ // logger depends on configs, so failures here are reported with console
82
+ const fail = (message: string): never => {
83
+ // eslint-disable-next-line no-console
84
+ console.error(message);
85
+ process.exit(1);
86
+ };
87
+
88
+ const parsed = envSchema.safeParse(process.env);
89
+ if (!parsed.success) {
90
+ const issues = parsed.error.issues.map((i) => ` - ${i.path.join('.')}: ${i.message}`);
91
+ fail(`Invalid environment variables:\n${issues.join('\n')}`);
92
+ }
93
+ const env = parsed.data as Env;
94
+
95
+ /** Credentials for a social provider; all are required once the provider is enabled. */
96
+ const provider = (enabled: boolean, keys: [keyof Env, keyof Env, keyof Env]) => {
97
+ const [clientId, clientSecret, callbackUrl] = keys.map((key) => {
98
+ const value = env[key];
99
+ if (enabled && (typeof value !== 'string' || !value)) {
100
+ fail(`${key} is required when its login provider is enabled`);
101
+ }
102
+ return typeof value === 'string' ? value : '';
103
+ }) as [string, string, string];
104
+
105
+ return { enabled, client_id: clientId, client_secret: clientSecret, callback_url: callbackUrl };
106
+ };
107
+
108
+ export const configs = {
109
+ port: env.PORT,
110
+ env: env.NODE_ENV,
111
+ is_production: env.NODE_ENV === 'production',
112
+ is_test: env.NODE_ENV === 'test',
113
+ db_url: env.DATABASE_URL,
114
+ cors_origins: (env.CORS_ORIGINS ?? env.FRONT_END_URL).split(',').map((o) => o.trim()),
115
+ trust_proxy: env.TRUST_PROXY,
116
+ enable_swagger: env.ENABLE_SWAGGER
117
+ ? env.ENABLE_SWAGGER === 'true'
118
+ : env.NODE_ENV !== 'production',
119
+ jwt: {
120
+ access_token: env.ACCESS_TOKEN,
121
+ access_expires: env.ACCESS_EXPIRES,
122
+ refresh_expires_days: env.REFRESH_EXPIRES_DAYS,
123
+ reset_secret: env.RESET_SECRET,
124
+ reset_expires: env.RESET_EXPIRES,
125
+ verified_token: env.VERIFIED_TOKEN,
126
+ front_end_url: env.FRONT_END_URL,
127
+ },
128
+ email: {
129
+ app_email: env.APP_USER_EMAIL,
130
+ app_password: env.APP_PASSWORD,
131
+ from: env.MAIL_FROM ?? env.APP_USER_EMAIL,
132
+ },
133
+ cloudinary: {
134
+ cloud_name: env.CLOUD_NAME,
135
+ cloud_api_key: env.CLOUD_API_KEY,
136
+ cloud_api_secret: env.CLOUD_API_SECRET,
137
+ },
138
+ swagger_config: {
139
+ development_base_url: env.DEVELOPMENT_BASE_URL,
140
+ production_base_url: env.PRODUCTION_BASE_URL,
141
+ },
142
+ social: {
143
+ redirect_url: env.SOCIAL_REDIRECT_URL ?? `${env.FRONT_END_URL}/auth/social/callback`,
144
+ google: provider(env.IS_GOOGLE_LOGIN, [
145
+ 'GOOGLE_CLIENT_ID',
146
+ 'GOOGLE_CLIENT_SECRET',
147
+ 'GOOGLE_CALLBACK_URL',
148
+ ]),
149
+ facebook: provider(env.IS_FACEBOOK_LOGIN, [
150
+ 'FACEBOOK_APP_ID',
151
+ 'FACEBOOK_APP_SECRET',
152
+ 'FACEBOOK_CALLBACK_URL',
153
+ ]),
154
+ github: provider(env.IS_GITHUB_LOGIN, [
155
+ 'GITHUB_CLIENT_ID',
156
+ 'GITHUB_CLIENT_SECRET',
157
+ 'GITHUB_CALLBACK_URL',
158
+ ]),
159
+ twitter: provider(env.IS_TWITTER_LOGIN, [
160
+ 'TWITTER_CONSUMER_KEY',
161
+ 'TWITTER_CONSUMER_SECRET',
162
+ 'TWITTER_CALLBACK_URL',
163
+ ]),
164
+ linkedin: provider(env.IS_LINKEDIN_LOGIN, [
165
+ 'LINKEDIN_CLIENT_ID',
166
+ 'LINKEDIN_CLIENT_SECRET',
167
+ 'LINKEDIN_CALLBACK_URL',
168
+ ]),
169
+ discord: provider(env.IS_DISCORD_LOGIN, [
170
+ 'DISCORD_CLIENT_ID',
171
+ 'DISCORD_CLIENT_SECRET',
172
+ 'DISCORD_CALLBACK_URL',
173
+ ]),
174
+ },
175
+ };
@@ -1,20 +1,20 @@
1
- import { ZodError, ZodIssue } from 'zod'
2
- import { TErrorSources, TGenericErrorResponse } from '../types/error'
1
+ import { ZodError, ZodIssue } from 'zod';
2
+ import { TErrorSources, TGenericErrorResponse } from '../types/error.js';
3
3
 
4
4
  const handleZodError = (err: ZodError): TGenericErrorResponse => {
5
- const errorSources: TErrorSources = err.issues.map((issue: ZodIssue) => {
6
- return {
7
- path: issue?.path[issue.path.length - 1] as string,
8
- message: issue.message
9
- }
10
- })
11
-
12
- const statusCode = 400
5
+ const errorSources: TErrorSources = err.issues.map((issue: ZodIssue) => {
13
6
  return {
14
- statusCode,
15
- message: 'Validation Error',
16
- errorSources
17
- }
18
- }
7
+ path: issue?.path[issue.path.length - 1] as string,
8
+ message: issue.message,
9
+ };
10
+ });
11
+
12
+ const statusCode = 400;
13
+ return {
14
+ statusCode,
15
+ message: 'Validation Error',
16
+ errorSources,
17
+ };
18
+ };
19
19
 
20
- export default handleZodError
20
+ export default handleZodError;
@@ -0,0 +1,12 @@
1
+ import pino from 'pino';
2
+ import { configs } from '../configs/index.js';
3
+
4
+ export const logger = pino({
5
+ level: configs.is_test ? 'silent' : configs.env === 'development' ? 'debug' : 'info',
6
+ transport:
7
+ configs.env === 'development'
8
+ ? {
9
+ target: 'pino-pretty',
10
+ }
11
+ : undefined,
12
+ });
@@ -1,10 +1,8 @@
1
- import { PrismaPg } from "@prisma/adapter-pg";
2
- import "dotenv/config";
3
- import { PrismaClient } from "../../../prisma/generated/prisma/client";
1
+ import { PrismaPg } from '@prisma/adapter-pg';
2
+ import { PrismaClient } from '@prisma/client';
3
+ import { configs } from '../configs/index.js';
4
4
 
5
- const connectionString = `${process.env.DATABASE_URL}`;
6
-
7
- const adapter = new PrismaPg({ connectionString });
5
+ const adapter = new PrismaPg({ connectionString: configs.db_url as string });
8
6
  const prisma = new PrismaClient({ adapter });
9
7
 
10
8
  export { prisma };
@@ -1,25 +1,46 @@
1
- import { NextFunction, Request, Response } from "express";
2
- import { configs } from "../configs";
3
- import { AppError } from "../utils/app_error";
4
- import { jwtHelpers, JwtPayloadType } from "../utils/JWT";
1
+ import type { ROLE } from '@prisma/client';
2
+ import type { NextFunction, Request, Response } from 'express';
3
+ import { configs } from '../configs/index.js';
4
+ import { prisma } from '../lib/prisma.js';
5
+ import { AppError } from '../utils/app_error.js';
6
+ import { ACCESS_TOKEN_COOKIE } from '../utils/auth_cookie.js';
7
+ import { type JwtPayloadType, jwtHelpers } from '../utils/JWT.js';
5
8
 
6
- type Role = "ADMIN" | "USER";
9
+ const getToken = (req: Request): string | undefined => {
10
+ const header = req.headers.authorization;
11
+ if (header) return header.startsWith('Bearer ') ? header.slice(7) : header;
12
+ return req.cookies?.[ACCESS_TOKEN_COOKIE];
13
+ };
7
14
 
8
- const auth = (...roles: Role[]) => {
9
- return async (req: Request, res: Response, next: NextFunction) => {
15
+ /**
16
+ * Requires a valid access token. With roles, also requires one of them:
17
+ * `auth()` = any signed-in user, `auth('ADMIN')` = admins only.
18
+ */
19
+ const auth = (...roles: ROLE[]) => {
20
+ return async (req: Request, _res: Response, next: NextFunction) => {
10
21
  try {
11
- const token = req.headers.authorization || req.cookies.access_token;
22
+ const token = getToken(req);
12
23
  if (!token) {
13
- throw new AppError("You are not authorize!!", 401);
24
+ throw new AppError('You are not authorized', 401);
25
+ }
26
+
27
+ const payload = jwtHelpers.verifyToken<JwtPayloadType>(token, configs.jwt.access_token);
28
+
29
+ // tokens outlive account changes, so check the account is still active and
30
+ // the token wasn't revoked (password change/reset, sign out everywhere)
31
+ const account = await prisma.account.findUnique({
32
+ where: { id: payload.accountId },
33
+ select: { id: true, email: true, role: true, isDeleted: true, tokenVersion: true },
34
+ });
35
+ if (!account || account.isDeleted || account.tokenVersion !== payload.tv) {
36
+ throw new AppError('You are not authorized', 401);
14
37
  }
15
- const verifiedUser = jwtHelpers.verifyToken(
16
- token,
17
- configs.jwt.access_token as string,
18
- );
19
- if (!roles.length || !roles.includes(verifiedUser.role)) {
20
- throw new AppError("You are not authorize!!", 401);
38
+
39
+ if (roles.length && !roles.includes(account.role)) {
40
+ throw new AppError('You do not have permission to perform this action', 403);
21
41
  }
22
- req.user = verifiedUser as JwtPayloadType;
42
+
43
+ req.user = { accountId: account.id, email: account.email, role: account.role };
23
44
  next();
24
45
  } catch (err) {
25
46
  next(err);