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.
- package/README.md +82 -20
- package/dist/cli.js +525 -0
- package/package.json +29 -9
- package/templates/mongoose/.dockerignore +10 -0
- package/templates/mongoose/.env.example +56 -7
- package/templates/mongoose/.prettierignore +3 -0
- package/templates/mongoose/.prettierrc +7 -0
- package/templates/mongoose/Dockerfile +33 -0
- package/templates/mongoose/_gitignore +32 -0
- package/templates/mongoose/docker-compose.yml +21 -0
- package/templates/mongoose/eslint.config.js +35 -0
- package/templates/mongoose/package-lock.json +6377 -0
- package/templates/mongoose/package.json +49 -10
- package/templates/mongoose/readme.md +85 -0
- package/templates/mongoose/src/app/configs/index.ts +178 -26
- package/templates/mongoose/src/app/errors/zodError.ts +16 -16
- package/templates/mongoose/src/app/lib/db.ts +18 -0
- package/templates/mongoose/src/app/lib/logger.ts +12 -0
- package/templates/mongoose/src/app/middlewares/auth.ts +48 -44
- package/templates/mongoose/src/app/middlewares/global_error_handler.ts +59 -65
- package/templates/mongoose/src/app/middlewares/not_found_api.ts +7 -7
- package/templates/mongoose/src/app/middlewares/rate_limiter.ts +153 -0
- package/templates/mongoose/src/app/middlewares/request_validator.ts +11 -10
- package/templates/mongoose/src/app/middlewares/uploader.ts +17 -13
- package/templates/mongoose/src/app/modules/account/account.controller.ts +156 -0
- package/templates/mongoose/src/app/modules/account/account.interface.ts +65 -0
- package/templates/mongoose/src/app/modules/account/account.route.ts +101 -0
- package/templates/mongoose/src/app/modules/account/account.schema.ts +97 -0
- package/templates/mongoose/src/app/modules/account/account.service.ts +422 -0
- package/templates/mongoose/src/app/modules/account/account.swagger.ts +278 -0
- package/templates/mongoose/src/app/modules/account/account.validation.ts +63 -0
- package/templates/mongoose/src/app/modules/profile/profile.controller.ts +17 -0
- package/templates/mongoose/src/app/modules/profile/profile.interface.ts +7 -0
- package/templates/mongoose/src/app/modules/profile/profile.route.ts +28 -0
- package/templates/mongoose/src/app/modules/profile/profile.schema.ts +13 -0
- package/templates/mongoose/src/app/modules/profile/profile.service.ts +28 -0
- package/templates/mongoose/src/app/modules/profile/profile.swagger.ts +34 -0
- package/templates/mongoose/src/app/modules/profile/profile.validation.ts +8 -0
- package/templates/mongoose/src/app/passport-js/index.ts +87 -0
- package/templates/mongoose/src/app/passport-js/oauth-state.ts +150 -0
- package/templates/mongoose/src/app/passport-js/providers.ts +258 -0
- package/templates/mongoose/src/app/passport-js/social-account.ts +141 -0
- package/templates/mongoose/src/app/queues/agenda.ts +24 -0
- package/templates/mongoose/src/app/queues/email.queue.ts +53 -0
- package/templates/mongoose/src/app/queues/index.ts +13 -0
- package/templates/mongoose/src/app/templates/otpEmailTemplate.ts +185 -0
- package/templates/mongoose/src/app/templates/resetPasswordEmailTemplate.ts +157 -0
- package/templates/mongoose/src/app/types/error.d.ts +7 -7
- package/templates/mongoose/src/app/types/express.d.ts +11 -0
- package/templates/mongoose/src/app/types/passport-oauth1.d.ts +51 -0
- package/templates/mongoose/src/app/utils/JWT.ts +38 -18
- package/templates/mongoose/src/app/utils/app_error.ts +10 -10
- package/templates/mongoose/src/app/utils/auth_cookie.ts +27 -0
- package/templates/mongoose/src/app/utils/catch_async.ts +8 -8
- package/templates/mongoose/src/app/utils/cloudinary.ts +26 -59
- package/templates/mongoose/src/app/utils/mail_sender.ts +26 -92
- package/templates/mongoose/src/app/utils/manage_response.ts +19 -19
- package/templates/mongoose/src/app/utils/otp.ts +20 -0
- package/templates/mongoose/src/app/utils/session.ts +55 -0
- package/templates/mongoose/src/app.ts +73 -42
- package/templates/mongoose/src/routes.ts +13 -13
- package/templates/mongoose/src/server.ts +38 -18
- package/templates/mongoose/src/swaggerOptions.ts +23 -23
- package/templates/mongoose/tests/app.test.ts +97 -0
- package/templates/mongoose/tests/global-setup.ts +15 -0
- package/templates/mongoose/tests/helpers/fixtures.ts +42 -0
- package/templates/mongoose/tests/routes/account.test.ts +614 -0
- package/templates/mongoose/tests/routes/profile.test.ts +95 -0
- package/templates/mongoose/tests/routes/social.test.ts +310 -0
- package/templates/mongoose/tests/setup.ts +25 -0
- package/templates/mongoose/tsconfig.json +16 -20
- package/templates/mongoose/vitest.config.ts +43 -0
- package/templates/prisma/.dockerignore +2 -0
- package/templates/prisma/.env.example +62 -6
- package/templates/prisma/.prettierignore +3 -0
- package/templates/prisma/.prettierrc +7 -0
- package/templates/prisma/.projectrc.json +1 -1
- package/templates/prisma/Dockerfile +21 -7
- package/templates/prisma/_gitignore +35 -0
- package/templates/prisma/docker-compose.yml +8 -7
- package/templates/prisma/eslint.config.js +35 -0
- package/templates/prisma/package-lock.json +7327 -0
- package/templates/prisma/package.json +47 -9
- package/templates/prisma/prisma/schema/account.schema.prisma +92 -10
- package/templates/prisma/prisma/schema/profile.schema.prisma +8 -3
- package/templates/prisma/prisma/schema/schema.prisma +1 -2
- package/templates/prisma/prisma.config.ts +5 -5
- package/templates/prisma/readme.md +81 -0
- package/templates/prisma/src/app/configs/index.ts +175 -26
- package/templates/prisma/src/app/errors/zodError.ts +16 -16
- package/templates/prisma/src/app/lib/logger.ts +12 -0
- package/templates/prisma/src/app/lib/prisma.ts +4 -6
- package/templates/prisma/src/app/middlewares/auth.ts +37 -16
- package/templates/prisma/src/app/middlewares/global_error_handler.ts +42 -35
- package/templates/prisma/src/app/middlewares/not_found_api.ts +7 -7
- package/templates/prisma/src/app/middlewares/rate_limiter.ts +124 -0
- package/templates/prisma/src/app/middlewares/request_validator.ts +4 -3
- package/templates/prisma/src/app/middlewares/uploader.ts +17 -13
- package/templates/prisma/src/app/modules/account/account.controller.ts +74 -34
- package/templates/prisma/src/app/modules/account/account.route.ts +68 -22
- package/templates/prisma/src/app/modules/account/account.service.ts +329 -309
- package/templates/prisma/src/app/modules/account/account.swagger.ts +167 -76
- package/templates/prisma/src/app/modules/account/account.validation.ts +41 -19
- package/templates/prisma/src/app/modules/profile/profile.controller.ts +4 -4
- package/templates/prisma/src/app/modules/profile/profile.route.ts +18 -12
- package/templates/prisma/src/app/modules/profile/profile.service.ts +11 -18
- package/templates/prisma/src/app/modules/profile/profile.swagger.ts +11 -11
- package/templates/prisma/src/app/modules/profile/profile.validation.ts +2 -2
- package/templates/prisma/src/app/passport-js/index.ts +87 -0
- package/templates/prisma/src/app/passport-js/oauth-state.ts +150 -0
- package/templates/prisma/src/app/passport-js/providers.ts +263 -0
- package/templates/prisma/src/app/passport-js/social-account.ts +147 -0
- package/templates/prisma/src/app/queues/boss.ts +8 -0
- package/templates/prisma/src/app/queues/email.queue.ts +56 -0
- package/templates/prisma/src/app/queues/index.ts +14 -0
- package/templates/prisma/src/app/queues/maintenance.queue.ts +30 -0
- package/templates/prisma/src/app/templates/otpEmailTemplate.ts +185 -0
- package/templates/prisma/src/app/templates/resetPasswordEmailTemplate.ts +157 -0
- package/templates/prisma/src/app/types/error.d.ts +7 -7
- package/templates/prisma/src/app/types/express.d.ts +11 -0
- package/templates/prisma/src/app/types/passport-oauth1.d.ts +51 -0
- package/templates/prisma/src/app/utils/JWT.ts +36 -17
- package/templates/prisma/src/app/utils/app_error.ts +10 -10
- package/templates/prisma/src/app/utils/auth_cookie.ts +27 -0
- package/templates/prisma/src/app/utils/catch_async.ts +8 -8
- package/templates/prisma/src/app/utils/cloudinary.ts +26 -59
- package/templates/prisma/src/app/utils/mail_sender.ts +26 -92
- package/templates/prisma/src/app/utils/manage_response.ts +19 -19
- package/templates/prisma/src/app/utils/otp.ts +20 -0
- package/templates/prisma/src/app/utils/session.ts +57 -0
- package/templates/prisma/src/app.ts +73 -42
- package/templates/prisma/src/routes.ts +12 -10
- package/templates/prisma/src/server.ts +37 -19
- package/templates/prisma/src/swaggerOptions.ts +22 -26
- package/templates/prisma/tests/app.test.ts +86 -0
- package/templates/prisma/tests/helpers/fixtures.ts +35 -0
- package/templates/prisma/tests/helpers/prisma.ts +32 -0
- package/templates/prisma/tests/routes/account.test.ts +627 -0
- package/templates/prisma/tests/routes/profile.test.ts +91 -0
- package/templates/prisma/tests/routes/social.test.ts +317 -0
- package/templates/prisma/tests/setup.ts +10 -0
- package/templates/prisma/tsconfig.json +16 -23
- package/templates/prisma/vitest.config.ts +38 -0
- package/bin/cli.js +0 -41
- package/src/createProject.js +0 -61
- package/src/generateModule.js +0 -93
- package/src/utils/getProjectType.js +0 -13
- package/src/utils/mongoose/controllerGenerator.js +0 -70
- package/src/utils/mongoose/routeGenerator.js +0 -27
- package/src/utils/mongoose/schemaGenerator.js +0 -11
- package/src/utils/mongoose/serviceGenerator.js +0 -49
- package/src/utils/mongoose/swaggerGenerator.js +0 -112
- package/src/utils/mongoose/validationGenerator.js +0 -13
- package/src/utils/prisma/prismaSchemaGenerator.js +0 -10
- package/src/utils/prisma/prismaServiceGenerator.js +0 -47
- package/src/utils/routeInjector.js +0 -40
- package/src/utils/swaggerInjector.js +0 -52
- package/src/utils/validateModuleName.js +0 -17
- package/src/utils/validateProjectName.js +0 -15
- package/src/utils.js +0 -26
- package/templates/mongoose/src/app/errors/castError.ts +0 -23
- package/templates/mongoose/src/app/errors/duplicateError.ts +0 -26
- package/templates/mongoose/src/app/errors/validationError.ts +0 -25
- package/templates/mongoose/src/app/modules/auth/auth.controller.ts +0 -127
- package/templates/mongoose/src/app/modules/auth/auth.interface.ts +0 -24
- package/templates/mongoose/src/app/modules/auth/auth.route.ts +0 -46
- package/templates/mongoose/src/app/modules/auth/auth.schema.ts +0 -19
- package/templates/mongoose/src/app/modules/auth/auth.service.ts +0 -324
- package/templates/mongoose/src/app/modules/auth/auth.swagger.ts +0 -214
- package/templates/mongoose/src/app/modules/auth/auth.validation.ts +0 -37
- package/templates/mongoose/src/app/modules/user/user.controller.ts +0 -17
- package/templates/mongoose/src/app/modules/user/user.interface.ts +0 -15
- package/templates/mongoose/src/app/modules/user/user.route.ts +0 -20
- package/templates/mongoose/src/app/modules/user/user.schema.ts +0 -22
- package/templates/mongoose/src/app/modules/user/user.service.ts +0 -22
- package/templates/mongoose/src/app/modules/user/user.swagger.ts +0 -75
- package/templates/mongoose/src/app/modules/user/user.validation.ts +0 -18
- package/templates/mongoose/src/app/types/index.d.ts +0 -12
- package/templates/mongoose/src/app/utils/isAccountExist.ts +0 -31
- package/templates/prisma/src/app/types/index.d.ts +0 -12
- 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": "
|
|
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.
|
|
17
|
-
"@prisma/client": "^7.
|
|
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.
|
|
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": "^
|
|
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.
|
|
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
|
-
"
|
|
45
|
-
"
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
createdAt
|
|
16
|
-
updatedAt
|
|
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
|
-
|
|
100
|
+
@@index([resetAt])
|
|
19
101
|
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
model Profile {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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,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
|
|
4
|
-
import { defineConfig } from
|
|
3
|
+
import 'dotenv/config';
|
|
4
|
+
import { defineConfig } from 'prisma/config';
|
|
5
5
|
|
|
6
6
|
export default defineConfig({
|
|
7
|
-
schema:
|
|
7
|
+
schema: 'prisma/schema',
|
|
8
8
|
migrations: {
|
|
9
|
-
path:
|
|
9
|
+
path: 'prisma/migrations',
|
|
10
10
|
},
|
|
11
11
|
datasource: {
|
|
12
|
-
url: process.env[
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
1
|
+
import { PrismaPg } from '@prisma/adapter-pg';
|
|
2
|
+
import { PrismaClient } from '@prisma/client';
|
|
3
|
+
import { configs } from '../configs/index.js';
|
|
4
4
|
|
|
5
|
-
const
|
|
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 {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
|
22
|
+
const token = getToken(req);
|
|
12
23
|
if (!token) {
|
|
13
|
-
throw new AppError(
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
42
|
+
|
|
43
|
+
req.user = { accountId: account.id, email: account.email, role: account.role };
|
|
23
44
|
next();
|
|
24
45
|
} catch (err) {
|
|
25
46
|
next(err);
|