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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import request from 'supertest';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import app from '../../src/app.js';
|
|
4
|
+
import { profile_model } from '../../src/app/modules/profile/profile.schema.js';
|
|
5
|
+
import uploadCloud from '../../src/app/utils/cloudinary.js';
|
|
6
|
+
import { bearer, createAccount } from '../helpers/fixtures.js';
|
|
7
|
+
|
|
8
|
+
vi.mock('../../src/app/utils/cloudinary.js', () => ({ default: vi.fn() }));
|
|
9
|
+
|
|
10
|
+
const PNG = Buffer.from('89504e470d0a1a0a', 'hex');
|
|
11
|
+
|
|
12
|
+
describe('PATCH /api/profile', () => {
|
|
13
|
+
let auth: string;
|
|
14
|
+
let accountId: string;
|
|
15
|
+
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
const account = await createAccount();
|
|
18
|
+
auth = bearer(account);
|
|
19
|
+
accountId = String(account._id);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const storedProfile = () => profile_model.findOne({ accountId }).lean();
|
|
23
|
+
|
|
24
|
+
it('requires a token', async () => {
|
|
25
|
+
const res = await request(app).patch('/api/profile').field('data', '{}');
|
|
26
|
+
expect(res.status).toBe(401);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('updates the signed-in user profile', async () => {
|
|
30
|
+
const res = await request(app)
|
|
31
|
+
.patch('/api/profile')
|
|
32
|
+
.set('Authorization', auth)
|
|
33
|
+
.field('data', JSON.stringify({ fullName: ' Jane Doe ' }));
|
|
34
|
+
|
|
35
|
+
expect(res.status).toBe(200);
|
|
36
|
+
expect(res.body.data.fullName).toBe('Jane Doe');
|
|
37
|
+
expect((await storedProfile())!.fullName).toBe('Jane Doe');
|
|
38
|
+
expect(uploadCloud).not.toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('ignores fields that are not in the schema', async () => {
|
|
42
|
+
const res = await request(app)
|
|
43
|
+
.patch('/api/profile')
|
|
44
|
+
.set('Authorization', auth)
|
|
45
|
+
.field('data', JSON.stringify({ fullName: 'Jane', accountId: '507f1f77bcf86cd799439011' }));
|
|
46
|
+
|
|
47
|
+
expect(res.status).toBe(200);
|
|
48
|
+
expect(String((await storedProfile())!.accountId)).toBe(accountId);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('uploads the photo and stores its url', async () => {
|
|
52
|
+
vi.mocked(uploadCloud).mockResolvedValue({ secure_url: 'https://cdn.test/p.png' } as never);
|
|
53
|
+
|
|
54
|
+
const res = await request(app)
|
|
55
|
+
.patch('/api/profile')
|
|
56
|
+
.set('Authorization', auth)
|
|
57
|
+
.attach('file', PNG, { filename: 'me.png', contentType: 'image/png' });
|
|
58
|
+
|
|
59
|
+
expect(res.status).toBe(200);
|
|
60
|
+
expect(vi.mocked(uploadCloud).mock.calls[0]![0].buffer).toEqual(PNG);
|
|
61
|
+
expect((await storedProfile())!.profilePhoto).toBe('https://cdn.test/p.png');
|
|
62
|
+
expect((await storedProfile())!.fullName).toBe('Test User'); // untouched
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('rejects files that are not images', async () => {
|
|
66
|
+
const res = await request(app)
|
|
67
|
+
.patch('/api/profile')
|
|
68
|
+
.set('Authorization', auth)
|
|
69
|
+
.attach('file', Buffer.from('#!/bin/sh'), { filename: 'x.sh', contentType: 'text/x-sh' });
|
|
70
|
+
|
|
71
|
+
expect(res.status).toBe(400);
|
|
72
|
+
expect(uploadCloud).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('rejects invalid data without uploading anything', async () => {
|
|
76
|
+
const res = await request(app)
|
|
77
|
+
.patch('/api/profile')
|
|
78
|
+
.set('Authorization', auth)
|
|
79
|
+
.field('data', JSON.stringify({ fullName: '' }))
|
|
80
|
+
.attach('file', PNG, { filename: 'me.png', contentType: 'image/png' });
|
|
81
|
+
|
|
82
|
+
expect(res.status).toBe(400);
|
|
83
|
+
expect(uploadCloud).not.toHaveBeenCalled();
|
|
84
|
+
expect((await storedProfile())!.fullName).toBe('Test User');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('rejects malformed JSON in `data`', async () => {
|
|
88
|
+
const res = await request(app)
|
|
89
|
+
.patch('/api/profile')
|
|
90
|
+
.set('Authorization', auth)
|
|
91
|
+
.field('data', '{not json');
|
|
92
|
+
|
|
93
|
+
expect(res.status).toBe(400);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import passport, { type Profile } from 'passport';
|
|
3
|
+
import request from 'supertest';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import app from '../../src/app.js';
|
|
6
|
+
import {
|
|
7
|
+
account_model,
|
|
8
|
+
social_account_model,
|
|
9
|
+
social_login_code_model,
|
|
10
|
+
} from '../../src/app/modules/account/account.schema.js';
|
|
11
|
+
import { profile_model } from '../../src/app/modules/profile/profile.schema.js';
|
|
12
|
+
import { hashLoginCode } from '../../src/app/passport-js/social-account.js';
|
|
13
|
+
import { createAccount, findAccount } from '../helpers/fixtures.js';
|
|
14
|
+
|
|
15
|
+
const FRONT_END_CALLBACK = 'http://localhost:5173/auth/social/callback';
|
|
16
|
+
|
|
17
|
+
type Callback = (...args: unknown[]) => void;
|
|
18
|
+
type OAuth2Internals = {
|
|
19
|
+
_oauth2: { getOAuthAccessToken: Callback };
|
|
20
|
+
userProfile: Callback;
|
|
21
|
+
};
|
|
22
|
+
type OAuth1Internals = {
|
|
23
|
+
_oauth: { getOAuthRequestToken: Callback; getOAuthAccessToken: Callback };
|
|
24
|
+
userProfile: Callback;
|
|
25
|
+
};
|
|
26
|
+
const strategy = <T>(name: string) =>
|
|
27
|
+
(passport as unknown as { _strategy: (n: string) => T })._strategy(name);
|
|
28
|
+
|
|
29
|
+
/** Calls the node-style callback passed as the last argument. */
|
|
30
|
+
const reply =
|
|
31
|
+
(...result: unknown[]) =>
|
|
32
|
+
(...args: unknown[]) =>
|
|
33
|
+
(args.at(-1) as Callback)(null, ...result);
|
|
34
|
+
|
|
35
|
+
const googleProfile = (overrides: Partial<Profile> = {}): Profile => ({
|
|
36
|
+
provider: 'google',
|
|
37
|
+
id: 'google-123',
|
|
38
|
+
displayName: 'Jane Doe',
|
|
39
|
+
emails: [{ value: 'Jane@Example.com', verified: true } as never],
|
|
40
|
+
photos: [{ value: 'https://photos.test/jane.png' }],
|
|
41
|
+
...overrides,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** Stubs the provider's HTTP calls (code -> token, token -> profile). */
|
|
45
|
+
const stubGoogle = (profile: Profile) => {
|
|
46
|
+
const google = strategy<OAuth2Internals>('google');
|
|
47
|
+
const tokenCall = vi
|
|
48
|
+
.spyOn(google._oauth2, 'getOAuthAccessToken')
|
|
49
|
+
.mockImplementation(reply('access-token', 'refresh-token', {}));
|
|
50
|
+
vi.spyOn(google, 'userProfile').mockImplementation(reply(profile));
|
|
51
|
+
return tokenCall;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const firstCookie = (res: request.Response) =>
|
|
55
|
+
([] as string[]).concat(res.headers['set-cookie'] ?? [])[0]!.split(';')[0]!;
|
|
56
|
+
|
|
57
|
+
/** Starts a login and returns what the browser would carry back to the callback. */
|
|
58
|
+
const startGoogleLogin = async (query = '') => {
|
|
59
|
+
const res = await request(app).get(`/api/auth/social/google${query}`);
|
|
60
|
+
const location = new URL(res.headers.location);
|
|
61
|
+
return { res, location, cookie: firstCookie(res), state: location.searchParams.get('state')! };
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const finishGoogleLogin = (state: string, cookie: string) =>
|
|
65
|
+
request(app)
|
|
66
|
+
.get(`/api/auth/social/google/callback?code=auth-code&state=${state}`)
|
|
67
|
+
.set('Cookie', cookie);
|
|
68
|
+
|
|
69
|
+
const loginWithGoogle = async (query = '') => {
|
|
70
|
+
const { cookie, state } = await startGoogleLogin(query);
|
|
71
|
+
return finishGoogleLogin(state, cookie);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const redirectParams = (res: request.Response) => {
|
|
75
|
+
const url = new URL(res.headers.location);
|
|
76
|
+
expect(`${url.origin}${url.pathname}`).toBe(FRONT_END_CALLBACK);
|
|
77
|
+
return url.searchParams;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
describe('GET /api/auth/social/providers', () => {
|
|
81
|
+
it('lists only the enabled providers', async () => {
|
|
82
|
+
const res = await request(app).get('/api/auth/social/providers');
|
|
83
|
+
|
|
84
|
+
expect(res.status).toBe(200);
|
|
85
|
+
expect(res.body.data.map((p: { name: string }) => p.name)).toEqual([
|
|
86
|
+
'google',
|
|
87
|
+
'github',
|
|
88
|
+
'twitter',
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('has no routes for disabled providers', async () => {
|
|
93
|
+
const res = await request(app).get('/api/auth/social/facebook');
|
|
94
|
+
expect(res.status).toBe(404);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('GET /api/auth/social/<provider>', () => {
|
|
99
|
+
it('redirects to the provider with state and PKCE, kept in an httpOnly cookie', async () => {
|
|
100
|
+
const { res, location, cookie } = await startGoogleLogin();
|
|
101
|
+
|
|
102
|
+
expect(res.status).toBe(302);
|
|
103
|
+
expect(location.host).toBe('accounts.google.com');
|
|
104
|
+
expect(location.searchParams.get('state')).toBeTruthy();
|
|
105
|
+
expect(location.searchParams.get('code_challenge_method')).toBe('S256');
|
|
106
|
+
expect(location.searchParams.get('scope')).toBe('openid email profile');
|
|
107
|
+
expect(cookie).toMatch(/^oauth_google=/);
|
|
108
|
+
expect(res.headers['set-cookie']![0]).toMatch(/HttpOnly/);
|
|
109
|
+
// the cookie is encrypted: the state isn't readable in it
|
|
110
|
+
expect(cookie).not.toContain(location.searchParams.get('state'));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('uses state (no PKCE) for GitHub', async () => {
|
|
114
|
+
const res = await request(app).get('/api/auth/social/github');
|
|
115
|
+
const location = new URL(res.headers.location);
|
|
116
|
+
|
|
117
|
+
expect(location.host).toBe('github.com');
|
|
118
|
+
expect(location.searchParams.get('state')).toBeTruthy();
|
|
119
|
+
expect(location.searchParams.get('code_challenge')).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('GET /api/auth/social/<provider>/callback', () => {
|
|
124
|
+
it('creates an account on first login and redirects with a single-use code', async () => {
|
|
125
|
+
const tokenCall = stubGoogle(googleProfile());
|
|
126
|
+
|
|
127
|
+
const { location, cookie, state } = await startGoogleLogin('?returnTo=/dashboard');
|
|
128
|
+
const res = await finishGoogleLogin(state, cookie);
|
|
129
|
+
|
|
130
|
+
expect(res.status).toBe(302);
|
|
131
|
+
const params = redirectParams(res);
|
|
132
|
+
expect(params.get('returnTo')).toBe('/dashboard');
|
|
133
|
+
|
|
134
|
+
const account = await findAccount('jane@example.com');
|
|
135
|
+
expect(account).toMatchObject({ provider: 'GOOGLE', password: null, isAccountVerified: true });
|
|
136
|
+
expect(await profile_model.findOne({ accountId: account!._id }).lean()).toMatchObject({
|
|
137
|
+
fullName: 'Jane Doe',
|
|
138
|
+
profilePhoto: 'https://photos.test/jane.png',
|
|
139
|
+
});
|
|
140
|
+
expect(
|
|
141
|
+
await social_account_model.exists({ provider: 'GOOGLE', providerUserId: 'google-123' }),
|
|
142
|
+
).toBeTruthy();
|
|
143
|
+
|
|
144
|
+
// the code in the URL is stored only as a hash, for the new account
|
|
145
|
+
const code = await social_login_code_model.findById(hashLoginCode(params.get('code')!)).lean();
|
|
146
|
+
expect(String(code!.accountId)).toBe(String(account!._id));
|
|
147
|
+
|
|
148
|
+
// PKCE: the verifier sent with the code matches the challenge from the start
|
|
149
|
+
const verifier = (tokenCall.mock.calls[0]![1] as { code_verifier: string }).code_verifier;
|
|
150
|
+
expect(createHash('sha256').update(verifier).digest('base64url')).toBe(
|
|
151
|
+
location.searchParams.get('code_challenge'),
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('logs a linked identity into its account, even if the email changed', async () => {
|
|
156
|
+
const account = await createAccount();
|
|
157
|
+
await social_account_model.create({
|
|
158
|
+
accountId: account._id,
|
|
159
|
+
provider: 'GOOGLE',
|
|
160
|
+
providerUserId: 'google-123',
|
|
161
|
+
});
|
|
162
|
+
stubGoogle(googleProfile({ emails: [] }));
|
|
163
|
+
|
|
164
|
+
const res = await loginWithGoogle();
|
|
165
|
+
|
|
166
|
+
const code = redirectParams(res).get('code')!;
|
|
167
|
+
const row = await social_login_code_model.findById(hashLoginCode(code)).lean();
|
|
168
|
+
expect(String(row!.accountId)).toBe(String(account._id));
|
|
169
|
+
expect(await account_model.countDocuments()).toBe(1);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('links to an existing unverified account and drops its unproven password', async () => {
|
|
173
|
+
await createAccount({ email: 'jane@example.com', isAccountVerified: false });
|
|
174
|
+
stubGoogle(googleProfile());
|
|
175
|
+
|
|
176
|
+
const res = await loginWithGoogle();
|
|
177
|
+
|
|
178
|
+
expect(redirectParams(res).get('code')).toBeTruthy();
|
|
179
|
+
expect(await findAccount('jane@example.com')).toMatchObject({
|
|
180
|
+
isAccountVerified: true,
|
|
181
|
+
password: null,
|
|
182
|
+
});
|
|
183
|
+
expect(await social_account_model.countDocuments()).toBe(1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('refuses unverified provider emails', async () => {
|
|
187
|
+
stubGoogle(
|
|
188
|
+
googleProfile({ emails: [{ value: 'jane@example.com', verified: false } as never] }),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const res = await loginWithGoogle();
|
|
192
|
+
|
|
193
|
+
expect(redirectParams(res).get('error')).toBe('email_unverified');
|
|
194
|
+
expect(await account_model.countDocuments()).toBe(0);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('refuses deleted accounts', async () => {
|
|
198
|
+
await createAccount({ email: 'jane@example.com', isDeleted: true });
|
|
199
|
+
stubGoogle(googleProfile());
|
|
200
|
+
|
|
201
|
+
const res = await loginWithGoogle();
|
|
202
|
+
|
|
203
|
+
expect(redirectParams(res).get('error')).toBe('account_disabled');
|
|
204
|
+
expect(await social_login_code_model.countDocuments()).toBe(0);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('rejects a callback whose state does not match (login CSRF)', async () => {
|
|
208
|
+
const tokenCall = stubGoogle(googleProfile());
|
|
209
|
+
const { cookie } = await startGoogleLogin();
|
|
210
|
+
|
|
211
|
+
const res = await finishGoogleLogin('attacker-state', cookie);
|
|
212
|
+
|
|
213
|
+
expect(redirectParams(res).get('error')).toBe('invalid_state');
|
|
214
|
+
expect(tokenCall).not.toHaveBeenCalled();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('rejects a callback without the state cookie', async () => {
|
|
218
|
+
const tokenCall = stubGoogle(googleProfile());
|
|
219
|
+
const { state } = await startGoogleLogin();
|
|
220
|
+
|
|
221
|
+
const res = await request(app).get(
|
|
222
|
+
`/api/auth/social/google/callback?code=auth-code&state=${state}`,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
expect(redirectParams(res).get('error')).toBe('invalid_state');
|
|
226
|
+
expect(tokenCall).not.toHaveBeenCalled();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('reports when the user cancels at the provider', async () => {
|
|
230
|
+
const res = await request(app).get('/api/auth/social/google/callback?error=access_denied');
|
|
231
|
+
|
|
232
|
+
expect(redirectParams(res).get('error')).toBe('access_denied');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('never redirects to another site through returnTo', async () => {
|
|
236
|
+
stubGoogle(googleProfile());
|
|
237
|
+
|
|
238
|
+
const res = await loginWithGoogle(`?returnTo=${encodeURIComponent('//evil.com')}`);
|
|
239
|
+
|
|
240
|
+
const params = redirectParams(res);
|
|
241
|
+
expect(params.get('code')).toBeTruthy();
|
|
242
|
+
expect(params.get('returnTo')).toBeNull();
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('X (Twitter) login, OAuth 1.0a', () => {
|
|
247
|
+
const twitter = () => strategy<OAuth1Internals>('twitter');
|
|
248
|
+
|
|
249
|
+
const startTwitterLogin = async (query = '') => {
|
|
250
|
+
vi.spyOn(twitter()._oauth, 'getOAuthRequestToken').mockImplementation(
|
|
251
|
+
reply('req-token', 'req-secret', {}),
|
|
252
|
+
);
|
|
253
|
+
const res = await request(app).get(`/api/auth/social/twitter${query}`);
|
|
254
|
+
return { res, cookie: firstCookie(res) };
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
it('keeps the request token in an encrypted cookie and completes the login', async () => {
|
|
258
|
+
const accessCall = vi
|
|
259
|
+
.spyOn(twitter()._oauth, 'getOAuthAccessToken')
|
|
260
|
+
.mockImplementation(reply('access', 'access-secret', {}));
|
|
261
|
+
vi.spyOn(twitter(), 'userProfile').mockImplementation(
|
|
262
|
+
reply({
|
|
263
|
+
provider: 'twitter',
|
|
264
|
+
id: 'x-1',
|
|
265
|
+
displayName: 'Jane',
|
|
266
|
+
emails: [{ value: 'jane@x.com' }],
|
|
267
|
+
}),
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const { res: start, cookie } = await startTwitterLogin('?returnTo=/home');
|
|
271
|
+
const location = new URL(start.headers.location);
|
|
272
|
+
expect(`${location.origin}${location.pathname}`).toBe(
|
|
273
|
+
'https://api.twitter.com/oauth/authenticate',
|
|
274
|
+
);
|
|
275
|
+
expect(location.searchParams.get('oauth_token')).toBe('req-token');
|
|
276
|
+
expect(cookie).toMatch(/^oauth_twitter=/);
|
|
277
|
+
expect(cookie).not.toContain('req-secret');
|
|
278
|
+
|
|
279
|
+
const res = await request(app)
|
|
280
|
+
.get('/api/auth/social/twitter/callback?oauth_token=req-token&oauth_verifier=v')
|
|
281
|
+
.set('Cookie', cookie);
|
|
282
|
+
|
|
283
|
+
// the token secret from the cookie was used to get the access token
|
|
284
|
+
expect(accessCall.mock.calls[0]!.slice(0, 3)).toEqual(['req-token', 'req-secret', 'v']);
|
|
285
|
+
const params = redirectParams(res);
|
|
286
|
+
expect(params.get('code')).toBeTruthy();
|
|
287
|
+
expect(params.get('returnTo')).toBe('/home');
|
|
288
|
+
expect(await findAccount('jane@x.com')).toMatchObject({ provider: 'TWITTER' });
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('rejects a callback whose token does not match the one in the cookie', async () => {
|
|
292
|
+
const accessCall = vi.spyOn(twitter()._oauth, 'getOAuthAccessToken');
|
|
293
|
+
const { cookie } = await startTwitterLogin();
|
|
294
|
+
|
|
295
|
+
const res = await request(app)
|
|
296
|
+
.get('/api/auth/social/twitter/callback?oauth_token=other-token&oauth_verifier=v')
|
|
297
|
+
.set('Cookie', cookie);
|
|
298
|
+
|
|
299
|
+
expect(redirectParams(res).get('error')).toBe('invalid_state');
|
|
300
|
+
expect(accessCall).not.toHaveBeenCalled();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('rejects a callback for a request token it did not issue', async () => {
|
|
304
|
+
const res = await request(app).get(
|
|
305
|
+
'/api/auth/social/twitter/callback?oauth_token=forged&oauth_verifier=v',
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
expect(redirectParams(res).get('error')).toBe('invalid_state');
|
|
309
|
+
});
|
|
310
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import mongoose from 'mongoose';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { afterAll, beforeAll, beforeEach, inject, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
// external boundaries only: everything between the route and MongoDB runs for real
|
|
6
|
+
vi.mock('../src/app/queues/email.queue.js', () => ({
|
|
7
|
+
enqueueEmail: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
await mongoose.connect(inject('mongoUri'), { dbName: `test-${randomUUID()}` });
|
|
12
|
+
// unique and TTL indexes are part of the behaviour under test
|
|
13
|
+
await mongoose.connection.syncIndexes();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
// a clean database, and clean rate limit counters, for every test
|
|
18
|
+
const collections = await mongoose.connection.db!.collections();
|
|
19
|
+
await Promise.all(collections.map((c) => c.deleteMany({})));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterAll(async () => {
|
|
23
|
+
await mongoose.connection.dropDatabase();
|
|
24
|
+
await mongoose.disconnect();
|
|
25
|
+
});
|
|
@@ -1,20 +1,16 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "
|
|
4
|
-
"module": "
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"skipLibCheck": true,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
],
|
|
15
|
-
"exclude": [
|
|
16
|
-
|
|
17
|
-
"dist",
|
|
18
|
-
"build"
|
|
19
|
-
]
|
|
20
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "nodenext",
|
|
5
|
+
"moduleResolution": "nodenext",
|
|
6
|
+
"rootDir": "./src",
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"strict": true,
|
|
12
|
+
"noImplicitAny": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*"],
|
|
15
|
+
"exclude": ["node_modules", "dist", "build", "generated/prisma"]
|
|
16
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
const secret = (name: string) => `${name}-test-secret-0123456789abcdefghijklmnop`;
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
test: {
|
|
7
|
+
include: ['tests/**/*.test.ts'],
|
|
8
|
+
// starts one in-memory MongoDB for the whole run
|
|
9
|
+
globalSetup: ['./tests/global-setup.ts'],
|
|
10
|
+
setupFiles: ['./tests/setup.ts'],
|
|
11
|
+
// every test starts with fresh mocks
|
|
12
|
+
mockReset: true,
|
|
13
|
+
// the first run downloads a MongoDB binary
|
|
14
|
+
hookTimeout: 120_000,
|
|
15
|
+
// set before the app reads its config; values in .env don't override these
|
|
16
|
+
env: {
|
|
17
|
+
NODE_ENV: 'test',
|
|
18
|
+
// placeholder for config validation; tests connect to the in-memory server
|
|
19
|
+
DATABASE_URL: 'mongodb://localhost:27017/unused',
|
|
20
|
+
ACCESS_TOKEN: secret('access'),
|
|
21
|
+
VERIFIED_TOKEN: secret('verify'),
|
|
22
|
+
RESET_SECRET: secret('reset'),
|
|
23
|
+
FRONT_END_URL: 'http://localhost:5173',
|
|
24
|
+
CORS_ORIGINS: 'http://localhost:5173',
|
|
25
|
+
ENABLE_SWAGGER: 'false',
|
|
26
|
+
IS_GOOGLE_LOGIN: 'true',
|
|
27
|
+
GOOGLE_CLIENT_ID: 'google-client-id',
|
|
28
|
+
GOOGLE_CLIENT_SECRET: 'google-client-secret',
|
|
29
|
+
GOOGLE_CALLBACK_URL: 'http://localhost:5000/api/auth/social/google/callback',
|
|
30
|
+
IS_GITHUB_LOGIN: 'true',
|
|
31
|
+
GITHUB_CLIENT_ID: 'github-client-id',
|
|
32
|
+
GITHUB_CLIENT_SECRET: 'github-client-secret',
|
|
33
|
+
GITHUB_CALLBACK_URL: 'http://localhost:5000/api/auth/social/github/callback',
|
|
34
|
+
IS_TWITTER_LOGIN: 'true',
|
|
35
|
+
TWITTER_CONSUMER_KEY: 'twitter-key',
|
|
36
|
+
TWITTER_CONSUMER_SECRET: 'twitter-secret',
|
|
37
|
+
TWITTER_CALLBACK_URL: 'http://localhost:5000/api/auth/social/twitter/callback',
|
|
38
|
+
IS_FACEBOOK_LOGIN: 'false',
|
|
39
|
+
IS_LINKEDIN_LOGIN: 'false',
|
|
40
|
+
IS_DISCORD_LOGIN: 'false',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|
|
@@ -1,20 +1,76 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
# Default Node config
|
|
3
2
|
NODE_ENV=development
|
|
4
3
|
PORT=5000
|
|
4
|
+
# Number of reverse proxies in front of the app (1 behind Caddy/Nginx). Keep 0 if exposed directly.
|
|
5
|
+
TRUST_PROXY=0
|
|
6
|
+
|
|
7
|
+
# Database config
|
|
8
|
+
DATABASE_URL="postgresql://postgres:change-me@localhost:5432/server_db?sslmode=disable"
|
|
9
|
+
# Used by docker-compose for the postgres container
|
|
10
|
+
POSTGRES_USER=postgres
|
|
11
|
+
POSTGRES_PASSWORD=change-me
|
|
12
|
+
POSTGRES_DB=server_db
|
|
13
|
+
|
|
5
14
|
# JWT Configuration
|
|
15
|
+
# Each secret must be a different random value of 32+ characters: `openssl rand -hex 32`
|
|
6
16
|
ACCESS_TOKEN=
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
17
|
+
# Access tokens are short-lived; refresh tokens (opaque, rotated on every use) last longer
|
|
18
|
+
ACCESS_EXPIRES=15m
|
|
19
|
+
REFRESH_EXPIRES_DAYS=30
|
|
10
20
|
VERIFIED_TOKEN=
|
|
11
|
-
|
|
21
|
+
RESET_SECRET=
|
|
22
|
+
RESET_EXPIRES=15m
|
|
23
|
+
FRONT_END_URL=http://localhost:5173
|
|
24
|
+
# Comma separated list of allowed origins. Defaults to FRONT_END_URL.
|
|
25
|
+
CORS_ORIGINS=
|
|
12
26
|
|
|
13
27
|
# Email Configuration
|
|
14
28
|
APP_USER_EMAIL=
|
|
15
29
|
APP_PASSWORD=
|
|
30
|
+
# "From" address. Defaults to APP_USER_EMAIL.
|
|
31
|
+
MAIL_FROM=
|
|
16
32
|
|
|
17
33
|
# Cloudinary Configuration
|
|
18
34
|
CLOUD_NAME=
|
|
19
35
|
CLOUD_API_KEY=
|
|
20
36
|
CLOUD_API_SECRET=
|
|
37
|
+
|
|
38
|
+
# Swagger Config (docs are disabled in production unless ENABLE_SWAGGER=true)
|
|
39
|
+
ENABLE_SWAGGER=
|
|
40
|
+
DEVELOPMENT_BASE_URL=http://localhost:5000
|
|
41
|
+
PRODUCTION_BASE_URL=
|
|
42
|
+
|
|
43
|
+
# Social login: set IS_<PROVIDER>_LOGIN=true and fill in its credentials.
|
|
44
|
+
# After the provider, the browser is sent here with `?code=` (trade it via
|
|
45
|
+
# POST /api/auth/social/exchange) or `?error=`. Defaults to FRONT_END_URL/auth/social/callback
|
|
46
|
+
SOCIAL_REDIRECT_URL=
|
|
47
|
+
|
|
48
|
+
IS_GOOGLE_LOGIN=false
|
|
49
|
+
GOOGLE_CLIENT_ID=
|
|
50
|
+
GOOGLE_CLIENT_SECRET=
|
|
51
|
+
GOOGLE_CALLBACK_URL=http://localhost:5000/api/auth/social/google/callback
|
|
52
|
+
|
|
53
|
+
IS_FACEBOOK_LOGIN=false
|
|
54
|
+
FACEBOOK_APP_ID=
|
|
55
|
+
FACEBOOK_APP_SECRET=
|
|
56
|
+
FACEBOOK_CALLBACK_URL=http://localhost:5000/api/auth/social/facebook/callback
|
|
57
|
+
|
|
58
|
+
IS_GITHUB_LOGIN=false
|
|
59
|
+
GITHUB_CLIENT_ID=
|
|
60
|
+
GITHUB_CLIENT_SECRET=
|
|
61
|
+
GITHUB_CALLBACK_URL=http://localhost:5000/api/auth/social/github/callback
|
|
62
|
+
|
|
63
|
+
IS_TWITTER_LOGIN=false
|
|
64
|
+
TWITTER_CONSUMER_KEY=
|
|
65
|
+
TWITTER_CONSUMER_SECRET=
|
|
66
|
+
TWITTER_CALLBACK_URL=http://localhost:5000/api/auth/social/twitter/callback
|
|
67
|
+
|
|
68
|
+
IS_LINKEDIN_LOGIN=false
|
|
69
|
+
LINKEDIN_CLIENT_ID=
|
|
70
|
+
LINKEDIN_CLIENT_SECRET=
|
|
71
|
+
LINKEDIN_CALLBACK_URL=http://localhost:5000/api/auth/social/linkedin/callback
|
|
72
|
+
|
|
73
|
+
IS_DISCORD_LOGIN=false
|
|
74
|
+
DISCORD_CLIENT_ID=
|
|
75
|
+
DISCORD_CLIENT_SECRET=
|
|
76
|
+
DISCORD_CALLBACK_URL=http://localhost:5000/api/auth/social/discord/callback
|
|
@@ -1,27 +1,41 @@
|
|
|
1
1
|
# ---------- BUILD STAGE ----------
|
|
2
|
-
FROM node:
|
|
2
|
+
FROM node:22-alpine AS builder
|
|
3
3
|
|
|
4
4
|
WORKDIR /app
|
|
5
5
|
|
|
6
|
+
# Only install deps first (cache friendly)
|
|
6
7
|
COPY package*.json ./
|
|
7
|
-
RUN npm
|
|
8
|
+
RUN npm ci
|
|
8
9
|
|
|
10
|
+
# Copy source
|
|
9
11
|
COPY . .
|
|
12
|
+
|
|
13
|
+
# Generate prisma + build
|
|
14
|
+
RUN npx prisma generate
|
|
10
15
|
RUN npm run build
|
|
11
16
|
|
|
12
17
|
|
|
13
18
|
# ---------- PRODUCTION STAGE ----------
|
|
14
|
-
FROM node:
|
|
19
|
+
FROM node:22-alpine
|
|
15
20
|
|
|
16
21
|
WORKDIR /app
|
|
17
22
|
|
|
23
|
+
ENV NODE_ENV=production
|
|
24
|
+
|
|
25
|
+
# Only install production deps
|
|
18
26
|
COPY package*.json ./
|
|
19
|
-
RUN npm
|
|
27
|
+
RUN npm ci --omit=dev && npm cache clean --force
|
|
20
28
|
|
|
29
|
+
# Copy Prisma generated client + schema
|
|
30
|
+
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
|
31
|
+
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
|
32
|
+
COPY --from=builder /app/prisma ./prisma
|
|
33
|
+
|
|
34
|
+
# Copy built app
|
|
21
35
|
COPY --from=builder /app/dist ./dist
|
|
22
|
-
|
|
23
|
-
|
|
36
|
+
|
|
37
|
+
USER node
|
|
24
38
|
|
|
25
39
|
EXPOSE 5000
|
|
26
40
|
|
|
27
|
-
CMD ["node", "dist/server.js"]
|
|
41
|
+
CMD ["node", "dist/server.js"]
|