exp-node-server 1.1.7 → 2.1.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 +162 -20
- package/dist/cli.js +613 -0
- package/package.json +29 -9
- package/templates/mongoose/.dockerignore +10 -0
- package/templates/mongoose/.env.example +56 -7
- package/templates/mongoose/.prettierignore +4 -0
- package/templates/mongoose/.prettierrc +7 -0
- package/templates/mongoose/Dockerfile +33 -0
- package/templates/mongoose/_gitignore +35 -0
- package/templates/mongoose/docker-compose.yml +21 -0
- package/templates/mongoose/eslint.config.js +35 -0
- package/templates/mongoose/package-lock.json +6366 -0
- package/templates/mongoose/package.json +49 -11
- package/templates/mongoose/readme.md +88 -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 +36 -0
- package/templates/mongoose/src/app/queues/email.queue.ts +81 -0
- package/templates/mongoose/src/app/queues/index.ts +17 -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/queues/email.queue.test.ts +137 -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 +4 -0
- package/templates/prisma/.prettierrc +7 -0
- package/templates/prisma/.projectrc.json +1 -1
- package/templates/prisma/Dockerfile +21 -7
- package/templates/prisma/_gitignore +38 -0
- package/templates/prisma/docker-compose.yml +8 -7
- package/templates/prisma/eslint.config.js +35 -0
- package/templates/prisma/package-lock.json +7316 -0
- package/templates/prisma/package.json +47 -10
- 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 +90 -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 +33 -0
- package/templates/prisma/src/app/queues/email.queue.ts +71 -0
- package/templates/prisma/src/app/queues/index.ts +23 -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/queues/email.queue.test.ts +162 -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,150 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCipheriv,
|
|
3
|
+
createDecipheriv,
|
|
4
|
+
hkdfSync,
|
|
5
|
+
randomBytes,
|
|
6
|
+
timingSafeEqual,
|
|
7
|
+
} from 'node:crypto';
|
|
8
|
+
import type { CookieOptions, Request, Response } from 'express';
|
|
9
|
+
import { configs } from '../configs/index.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Stateless OAuth handshake storage. Instead of a server-side session, the data a
|
|
13
|
+
* provider round trip needs (CSRF state, PKCE verifier, OAuth 1.0a request token)
|
|
14
|
+
* lives in a short-lived, encrypted, httpOnly cookie scoped to the social routes.
|
|
15
|
+
* Works across instances and restarts, and nothing accumulates in memory.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const STATE_TTL_MS = 10 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
// separate key for this purpose, derived from the access token secret
|
|
21
|
+
const key = Buffer.from(hkdfSync('sha256', configs.jwt.access_token, '', 'oauth-state-cookie', 32));
|
|
22
|
+
|
|
23
|
+
const cookieOptions: CookieOptions = {
|
|
24
|
+
httpOnly: true,
|
|
25
|
+
secure: configs.is_production,
|
|
26
|
+
// the provider sends the user back with a top-level GET, which `lax` allows
|
|
27
|
+
sameSite: 'lax',
|
|
28
|
+
path: '/api/auth/social',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const cookieName = (provider: string) => `oauth_${provider}`;
|
|
32
|
+
|
|
33
|
+
const seal = (data: object): string => {
|
|
34
|
+
const iv = randomBytes(12);
|
|
35
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
36
|
+
const body = JSON.stringify({ ...data, exp: Date.now() + STATE_TTL_MS });
|
|
37
|
+
const encrypted = Buffer.concat([cipher.update(body, 'utf8'), cipher.final()]);
|
|
38
|
+
return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString('base64url');
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const unseal = <T>(value: unknown): T | undefined => {
|
|
42
|
+
if (typeof value !== 'string') return undefined;
|
|
43
|
+
try {
|
|
44
|
+
const raw = Buffer.from(value, 'base64url');
|
|
45
|
+
const decipher = createDecipheriv('aes-256-gcm', key, raw.subarray(0, 12));
|
|
46
|
+
decipher.setAuthTag(raw.subarray(12, 28));
|
|
47
|
+
const body = Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]);
|
|
48
|
+
const data = JSON.parse(body.toString('utf8'));
|
|
49
|
+
return data.exp > Date.now() ? data : undefined;
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined; // tampered, wrong key or malformed
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const res = (req: Request): Response => req.res!;
|
|
56
|
+
|
|
57
|
+
/** Reads and clears the handshake cookie, so it can only be used once. */
|
|
58
|
+
const take = <T>(req: Request, provider: string): T | undefined => {
|
|
59
|
+
res(req).clearCookie(cookieName(provider), cookieOptions);
|
|
60
|
+
return unseal<T>(req.cookies?.[cookieName(provider)]);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const put = (req: Request, provider: string, data: object) => {
|
|
64
|
+
res(req).cookie(cookieName(provider), seal(data), { ...cookieOptions, maxAge: STATE_TTL_MS });
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const safeEqual = (a: string, b: string) => {
|
|
68
|
+
const x = Buffer.from(a);
|
|
69
|
+
const y = Buffer.from(b);
|
|
70
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `?returnTo=` lets the front-end resume where the user was. Only same-site paths
|
|
75
|
+
* are kept (no `//host` or `\` tricks), so it can't become an open redirect.
|
|
76
|
+
*/
|
|
77
|
+
const readReturnTo = (req: Request): string | undefined => {
|
|
78
|
+
const value = req.query.returnTo;
|
|
79
|
+
if (typeof value !== 'string' || value.length > 512) return undefined;
|
|
80
|
+
return /^\/(?![/\\])[^\\]*$/.test(value) ? value : undefined;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** The `returnTo` saved when the login started, available in the callback. */
|
|
84
|
+
export const getReturnTo = (res: Response): string | undefined => res.locals.oauthReturnTo;
|
|
85
|
+
|
|
86
|
+
type OAuth2State = { handle: string; verifier?: string; returnTo?: string };
|
|
87
|
+
|
|
88
|
+
/** passport-oauth2 state store: `state` parameter (CSRF) + PKCE verifier. */
|
|
89
|
+
export class CookieStateStore {
|
|
90
|
+
constructor(private readonly provider: string) {}
|
|
91
|
+
|
|
92
|
+
store(
|
|
93
|
+
req: Request,
|
|
94
|
+
verifier: string | undefined,
|
|
95
|
+
_state: unknown,
|
|
96
|
+
_meta: unknown,
|
|
97
|
+
callback: (err: Error | null, handle?: string) => void,
|
|
98
|
+
) {
|
|
99
|
+
const handle = randomBytes(24).toString('base64url');
|
|
100
|
+
put(req, this.provider, {
|
|
101
|
+
handle,
|
|
102
|
+
verifier,
|
|
103
|
+
returnTo: readReturnTo(req),
|
|
104
|
+
} satisfies OAuth2State);
|
|
105
|
+
callback(null, handle);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
verify(
|
|
109
|
+
req: Request,
|
|
110
|
+
providedState: unknown,
|
|
111
|
+
callback: (err: Error | null, ok: string | boolean, info?: { message: string }) => void,
|
|
112
|
+
) {
|
|
113
|
+
const saved = take<OAuth2State>(req, this.provider);
|
|
114
|
+
if (!saved || typeof providedState !== 'string' || !safeEqual(saved.handle, providedState)) {
|
|
115
|
+
return callback(null, false, { message: 'invalid_state' });
|
|
116
|
+
}
|
|
117
|
+
res(req).locals.oauthReturnTo = saved.returnTo;
|
|
118
|
+
// a string tells passport-oauth2 to send it as the PKCE `code_verifier`
|
|
119
|
+
callback(null, saved.verifier ?? true);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
type OAuth1State = { token: string; tokenSecret: string; returnTo?: string };
|
|
124
|
+
|
|
125
|
+
/** passport-oauth1 request token store (Twitter / X). */
|
|
126
|
+
export class CookieRequestTokenStore {
|
|
127
|
+
constructor(private readonly provider: string) {}
|
|
128
|
+
|
|
129
|
+
get(req: Request, token: string, callback: (err: Error | null, tokenSecret?: string) => void) {
|
|
130
|
+
const saved = take<OAuth1State>(req, this.provider);
|
|
131
|
+
if (!saved || typeof token !== 'string' || !safeEqual(saved.token, token)) {
|
|
132
|
+
return callback(new Error('invalid_state'));
|
|
133
|
+
}
|
|
134
|
+
res(req).locals.oauthReturnTo = saved.returnTo;
|
|
135
|
+
callback(null, saved.tokenSecret);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
set(req: Request, token: string, tokenSecret: string, callback: (err?: Error) => void) {
|
|
139
|
+
put(req, this.provider, {
|
|
140
|
+
token,
|
|
141
|
+
tokenSecret,
|
|
142
|
+
returnTo: readReturnTo(req),
|
|
143
|
+
} satisfies OAuth1State);
|
|
144
|
+
callback();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
destroy(_req: Request, _token: string, callback: (err?: Error) => void) {
|
|
148
|
+
callback(); // already cleared by get()
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { PROVIDER } from '../modules/account/account.interface.js';
|
|
2
|
+
import type { Profile, Strategy } from 'passport';
|
|
3
|
+
import { Strategy as FacebookStrategy } from 'passport-facebook';
|
|
4
|
+
import { Strategy as GithubStrategy } from 'passport-github';
|
|
5
|
+
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
|
|
6
|
+
import OAuth1Strategy, { InternalOAuthError as OAuth1Error } from 'passport-oauth1';
|
|
7
|
+
import OAuth2Strategy, { InternalOAuthError } from 'passport-oauth2';
|
|
8
|
+
import { configs } from '../configs/index.js';
|
|
9
|
+
import { CookieRequestTokenStore, CookieStateStore } from './oauth-state.js';
|
|
10
|
+
import { findOrCreateSocialAccount } from './social-account.js';
|
|
11
|
+
|
|
12
|
+
type Done = (err: unknown, user?: Express.User | false) => void;
|
|
13
|
+
|
|
14
|
+
export type SocialProviderName =
|
|
15
|
+
'google' | 'facebook' | 'github' | 'twitter' | 'linkedin' | 'discord';
|
|
16
|
+
|
|
17
|
+
export interface SocialProvider {
|
|
18
|
+
/** Passport strategy name, also used in the route: `/api/auth/social/<name>`. */
|
|
19
|
+
name: SocialProviderName;
|
|
20
|
+
/** Human readable name, e.g. for login buttons. */
|
|
21
|
+
label: string;
|
|
22
|
+
provider: PROVIDER;
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
createStrategy: () => Strategy;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Shared verify callback: map the provider profile to one of our accounts. */
|
|
28
|
+
const verify =
|
|
29
|
+
(provider: PROVIDER) =>
|
|
30
|
+
(_token: string, _tokenSecret: string, profile: Profile, done: Done): void => {
|
|
31
|
+
findOrCreateSocialAccount(profile, provider).then(
|
|
32
|
+
(user) => done(null, user),
|
|
33
|
+
(err) => done(err),
|
|
34
|
+
);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Minimal OAuth 2.0 strategy for providers without a maintained passport package:
|
|
39
|
+
* fetches `profileURL` with the access token and maps the JSON to a passport profile.
|
|
40
|
+
*/
|
|
41
|
+
class JsonProfileStrategy extends OAuth2Strategy {
|
|
42
|
+
constructor(
|
|
43
|
+
name: string,
|
|
44
|
+
options: OAuth2Strategy.StrategyOptions,
|
|
45
|
+
private readonly profileURL: string,
|
|
46
|
+
private readonly toProfile: (json: Record<string, unknown>) => Profile,
|
|
47
|
+
) {
|
|
48
|
+
super(options, verify(name.toUpperCase() as PROVIDER));
|
|
49
|
+
this.name = name;
|
|
50
|
+
this._oauth2.useAuthorizationHeaderforGET(true);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
override userProfile(accessToken: string, done: (err?: unknown, profile?: Profile) => void) {
|
|
54
|
+
this._oauth2.get(this.profileURL, accessToken, (err, body) => {
|
|
55
|
+
if (err) return done(new InternalOAuthError('Failed to fetch user profile', err));
|
|
56
|
+
try {
|
|
57
|
+
done(null, this.toProfile(JSON.parse(String(body))));
|
|
58
|
+
} catch (e) {
|
|
59
|
+
done(e);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* X (Twitter) login over OAuth 1.0a, which is what returns the user's email. Built on
|
|
67
|
+
* passport-oauth1 directly: the old passport-twitter package is unmaintained and
|
|
68
|
+
* pulls in a vulnerable XML parser.
|
|
69
|
+
*/
|
|
70
|
+
class TwitterStrategy extends OAuth1Strategy {
|
|
71
|
+
constructor(options: { consumerKey: string; consumerSecret: string; callbackURL: string }) {
|
|
72
|
+
super(
|
|
73
|
+
{
|
|
74
|
+
...options,
|
|
75
|
+
requestTokenURL: 'https://api.twitter.com/oauth/request_token',
|
|
76
|
+
accessTokenURL: 'https://api.twitter.com/oauth/access_token',
|
|
77
|
+
userAuthorizationURL: 'https://api.twitter.com/oauth/authenticate',
|
|
78
|
+
// the request token lives in an encrypted cookie instead of a session
|
|
79
|
+
requestTokenStore: new CookieRequestTokenStore('twitter'),
|
|
80
|
+
},
|
|
81
|
+
verify('TWITTER'),
|
|
82
|
+
);
|
|
83
|
+
this.name = 'twitter';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
override userProfile(
|
|
87
|
+
token: string,
|
|
88
|
+
tokenSecret: string,
|
|
89
|
+
_params: unknown,
|
|
90
|
+
done: (err?: unknown, profile?: Profile) => void,
|
|
91
|
+
) {
|
|
92
|
+
const url =
|
|
93
|
+
'https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true&skip_status=true';
|
|
94
|
+
this._oauth.get(url, token, tokenSecret, (err, body) => {
|
|
95
|
+
if (err) return done(new OAuth1Error('Failed to fetch user profile', err));
|
|
96
|
+
try {
|
|
97
|
+
const json = JSON.parse(String(body));
|
|
98
|
+
done(null, {
|
|
99
|
+
provider: 'twitter',
|
|
100
|
+
id: String(json.id_str),
|
|
101
|
+
displayName: str(json.name) ?? str(json.screen_name) ?? '',
|
|
102
|
+
// X only returns the email when it is verified
|
|
103
|
+
emails: json.email ? [{ value: String(json.email) }] : [],
|
|
104
|
+
photos: str(json.profile_image_url_https)
|
|
105
|
+
? [{ value: json.profile_image_url_https }]
|
|
106
|
+
: [],
|
|
107
|
+
});
|
|
108
|
+
} catch (e) {
|
|
109
|
+
done(e);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `state` (login CSRF protection) and optional PKCE, kept in an encrypted cookie.
|
|
117
|
+
* Typed as `object` because the strategy typings don't all declare these options.
|
|
118
|
+
*/
|
|
119
|
+
const oauthState = (name: SocialProviderName, pkce = false): object => ({
|
|
120
|
+
store: new CookieStateStore(name),
|
|
121
|
+
state: true,
|
|
122
|
+
...(pkce && { pkce: true }),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const str = (value: unknown) => (typeof value === 'string' && value ? value : undefined);
|
|
126
|
+
|
|
127
|
+
const { google, facebook, github, twitter, linkedin, discord } = configs.social;
|
|
128
|
+
|
|
129
|
+
// every OAuth 2.0 provider gets `state`; PKCE is enabled where the provider supports it
|
|
130
|
+
export const socialProviders: SocialProvider[] = [
|
|
131
|
+
{
|
|
132
|
+
name: 'google',
|
|
133
|
+
label: 'Google',
|
|
134
|
+
provider: 'GOOGLE',
|
|
135
|
+
enabled: google.enabled,
|
|
136
|
+
createStrategy: () =>
|
|
137
|
+
new GoogleStrategy(
|
|
138
|
+
{
|
|
139
|
+
clientID: google.client_id,
|
|
140
|
+
clientSecret: google.client_secret,
|
|
141
|
+
callbackURL: google.callback_url,
|
|
142
|
+
scope: ['openid', 'email', 'profile'],
|
|
143
|
+
...oauthState('google', true),
|
|
144
|
+
},
|
|
145
|
+
verify('GOOGLE'),
|
|
146
|
+
),
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: 'facebook',
|
|
150
|
+
label: 'Facebook',
|
|
151
|
+
provider: 'FACEBOOK',
|
|
152
|
+
enabled: facebook.enabled,
|
|
153
|
+
createStrategy: () =>
|
|
154
|
+
new FacebookStrategy(
|
|
155
|
+
{
|
|
156
|
+
clientID: facebook.client_id,
|
|
157
|
+
clientSecret: facebook.client_secret,
|
|
158
|
+
callbackURL: facebook.callback_url,
|
|
159
|
+
scope: ['email', 'public_profile'],
|
|
160
|
+
profileFields: ['id', 'displayName', 'emails', 'photos'],
|
|
161
|
+
...oauthState('facebook'),
|
|
162
|
+
},
|
|
163
|
+
verify('FACEBOOK'),
|
|
164
|
+
),
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: 'github',
|
|
168
|
+
label: 'GitHub',
|
|
169
|
+
provider: 'GITHUB',
|
|
170
|
+
enabled: github.enabled,
|
|
171
|
+
createStrategy: () =>
|
|
172
|
+
new GithubStrategy(
|
|
173
|
+
{
|
|
174
|
+
clientID: github.client_id,
|
|
175
|
+
clientSecret: github.client_secret,
|
|
176
|
+
callbackURL: github.callback_url,
|
|
177
|
+
scope: ['user:email'],
|
|
178
|
+
...oauthState('github'),
|
|
179
|
+
},
|
|
180
|
+
verify('GITHUB'),
|
|
181
|
+
),
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: 'twitter',
|
|
185
|
+
label: 'X (Twitter)',
|
|
186
|
+
provider: 'TWITTER',
|
|
187
|
+
enabled: twitter.enabled,
|
|
188
|
+
createStrategy: () =>
|
|
189
|
+
new TwitterStrategy({
|
|
190
|
+
consumerKey: twitter.client_id,
|
|
191
|
+
consumerSecret: twitter.client_secret,
|
|
192
|
+
callbackURL: twitter.callback_url,
|
|
193
|
+
}),
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
// "Sign In with LinkedIn using OpenID Connect"
|
|
197
|
+
name: 'linkedin',
|
|
198
|
+
label: 'LinkedIn',
|
|
199
|
+
provider: 'LINKEDIN',
|
|
200
|
+
enabled: linkedin.enabled,
|
|
201
|
+
createStrategy: () =>
|
|
202
|
+
new JsonProfileStrategy(
|
|
203
|
+
'linkedin',
|
|
204
|
+
{
|
|
205
|
+
authorizationURL: 'https://www.linkedin.com/oauth/v2/authorization',
|
|
206
|
+
tokenURL: 'https://www.linkedin.com/oauth/v2/accessToken',
|
|
207
|
+
clientID: linkedin.client_id,
|
|
208
|
+
clientSecret: linkedin.client_secret,
|
|
209
|
+
callbackURL: linkedin.callback_url,
|
|
210
|
+
scope: ['openid', 'profile', 'email'],
|
|
211
|
+
...oauthState('linkedin'),
|
|
212
|
+
},
|
|
213
|
+
'https://api.linkedin.com/v2/userinfo',
|
|
214
|
+
(json) => ({
|
|
215
|
+
provider: 'linkedin',
|
|
216
|
+
id: String(json.sub),
|
|
217
|
+
displayName: str(json.name) ?? '',
|
|
218
|
+
emails: json.email
|
|
219
|
+
? [{ value: String(json.email), verified: json.email_verified === true } as never]
|
|
220
|
+
: [],
|
|
221
|
+
photos: str(json.picture) ? [{ value: String(json.picture) }] : [],
|
|
222
|
+
}),
|
|
223
|
+
),
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
name: 'discord',
|
|
227
|
+
label: 'Discord',
|
|
228
|
+
provider: 'DISCORD',
|
|
229
|
+
enabled: discord.enabled,
|
|
230
|
+
createStrategy: () =>
|
|
231
|
+
new JsonProfileStrategy(
|
|
232
|
+
'discord',
|
|
233
|
+
{
|
|
234
|
+
authorizationURL: 'https://discord.com/oauth2/authorize',
|
|
235
|
+
tokenURL: 'https://discord.com/api/oauth2/token',
|
|
236
|
+
clientID: discord.client_id,
|
|
237
|
+
clientSecret: discord.client_secret,
|
|
238
|
+
callbackURL: discord.callback_url,
|
|
239
|
+
scope: ['identify', 'email'],
|
|
240
|
+
...oauthState('discord'),
|
|
241
|
+
},
|
|
242
|
+
'https://discord.com/api/users/@me',
|
|
243
|
+
(json) => ({
|
|
244
|
+
provider: 'discord',
|
|
245
|
+
id: String(json.id),
|
|
246
|
+
displayName: str(json.global_name) ?? str(json.username) ?? '',
|
|
247
|
+
emails: json.email
|
|
248
|
+
? [{ value: String(json.email), verified: json.verified === true } as never]
|
|
249
|
+
: [],
|
|
250
|
+
photos: str(json.avatar)
|
|
251
|
+
? [{ value: `https://cdn.discordapp.com/avatars/${json.id}/${json.avatar}.png` }]
|
|
252
|
+
: [],
|
|
253
|
+
}),
|
|
254
|
+
),
|
|
255
|
+
},
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
export const enabledSocialProviders = socialProviders.filter((p) => p.enabled);
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import type { Types } from 'mongoose';
|
|
3
|
+
import type { Profile } from 'passport';
|
|
4
|
+
import type { PROVIDER } from '../modules/account/account.interface.js';
|
|
5
|
+
import {
|
|
6
|
+
account_model,
|
|
7
|
+
createAccountWithProfile,
|
|
8
|
+
social_account_model,
|
|
9
|
+
social_login_code_model,
|
|
10
|
+
} from '../modules/account/account.schema.js';
|
|
11
|
+
import type { TokenUser } from '../utils/JWT.js';
|
|
12
|
+
|
|
13
|
+
/** How long the front-end has to exchange the code from the social login redirect. */
|
|
14
|
+
export const SOCIAL_LOGIN_CODE_TTL_MS = 60 * 1000;
|
|
15
|
+
|
|
16
|
+
/** A failed social login. `code` is sent to the front-end as `?error=<code>`. */
|
|
17
|
+
export class SocialLoginError extends Error {
|
|
18
|
+
constructor(
|
|
19
|
+
public code: 'email_unverified' | 'account_disabled' | 'invalid_profile',
|
|
20
|
+
message: string,
|
|
21
|
+
) {
|
|
22
|
+
super(message);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type ProfileEmail = { value: string; verified?: boolean | string; primary?: boolean };
|
|
27
|
+
|
|
28
|
+
const isVerified = (e: ProfileEmail) => e.verified === true || e.verified === 'true';
|
|
29
|
+
|
|
30
|
+
// Facebook and X only ever return confirmed addresses; the others say whether the
|
|
31
|
+
// address is verified, and unverified ones are ignored
|
|
32
|
+
const ONLY_RETURNS_VERIFIED_EMAILS: PROVIDER[] = ['FACEBOOK', 'TWITTER'];
|
|
33
|
+
|
|
34
|
+
/** Picks an email we can trust, preferring the primary one. */
|
|
35
|
+
const pickEmail = (profile: Profile, provider: PROVIDER): string | undefined => {
|
|
36
|
+
const emails = (profile.emails ?? []) as ProfileEmail[];
|
|
37
|
+
const trusted = ONLY_RETURNS_VERIFIED_EMAILS.includes(provider)
|
|
38
|
+
? emails
|
|
39
|
+
: emails.filter(isVerified);
|
|
40
|
+
return (trusted.find((e) => e.primary) ?? trusted[0])?.value?.toLowerCase();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const USER_FIELDS = 'email role isDeleted tokenVersion';
|
|
44
|
+
|
|
45
|
+
type AccountForToken = {
|
|
46
|
+
_id: Types.ObjectId;
|
|
47
|
+
email: string;
|
|
48
|
+
role: TokenUser['role'];
|
|
49
|
+
isDeleted: boolean;
|
|
50
|
+
tokenVersion: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const toUser = (account: AccountForToken): TokenUser => {
|
|
54
|
+
if (account.isDeleted) {
|
|
55
|
+
throw new SocialLoginError('account_disabled', 'Account is deleted');
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
accountId: String(account._id),
|
|
59
|
+
email: account.email,
|
|
60
|
+
role: account.role,
|
|
61
|
+
tokenVersion: account.tokenVersion,
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Finds or creates the account for a social login and returns the user for `req.user`.
|
|
67
|
+
*
|
|
68
|
+
* 1. A provider identity that was used before logs straight into its account.
|
|
69
|
+
* 2. Otherwise the provider's verified email links the identity to the account with
|
|
70
|
+
* that email, or creates a new account.
|
|
71
|
+
*/
|
|
72
|
+
export const findOrCreateSocialAccount = async (
|
|
73
|
+
profile: Profile,
|
|
74
|
+
provider: PROVIDER,
|
|
75
|
+
): Promise<TokenUser> => {
|
|
76
|
+
const providerUserId = profile.id ? String(profile.id) : '';
|
|
77
|
+
if (!providerUserId) {
|
|
78
|
+
throw new SocialLoginError('invalid_profile', 'The provider did not return a user id');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const linked = await social_account_model.findOne({ provider, providerUserId }).lean();
|
|
82
|
+
if (linked) {
|
|
83
|
+
const account = await account_model.findById(linked.accountId).select(USER_FIELDS).lean();
|
|
84
|
+
if (account) return toUser(account);
|
|
85
|
+
// the account was removed; drop the stale link and continue as a new login
|
|
86
|
+
await social_account_model.deleteOne({ _id: linked._id });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const email = pickEmail(profile, provider);
|
|
90
|
+
if (!email) {
|
|
91
|
+
throw new SocialLoginError(
|
|
92
|
+
'email_unverified',
|
|
93
|
+
`Your ${provider.toLowerCase()} account doesn't have a verified email address.`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const existing = await account_model
|
|
98
|
+
.findOne({ email })
|
|
99
|
+
.select(`${USER_FIELDS} isAccountVerified`)
|
|
100
|
+
.lean();
|
|
101
|
+
|
|
102
|
+
let account: AccountForToken;
|
|
103
|
+
if (!existing) {
|
|
104
|
+
account = await createAccountWithProfile(
|
|
105
|
+
{ email, password: null, provider, isAccountVerified: true },
|
|
106
|
+
{
|
|
107
|
+
fullName: profile.displayName || email.split('@')[0]!,
|
|
108
|
+
profilePhoto: profile.photos?.[0]?.value ?? null,
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
} else {
|
|
112
|
+
toUser(existing); // rejects deleted accounts before linking anything
|
|
113
|
+
account = existing;
|
|
114
|
+
|
|
115
|
+
if (!existing.isAccountVerified) {
|
|
116
|
+
// The provider just proved ownership of this email, but nobody ever proved the
|
|
117
|
+
// password on this unverified account belongs to the owner (it may have been
|
|
118
|
+
// registered by someone else). Verify the account and drop that password.
|
|
119
|
+
await account_model.updateOne(
|
|
120
|
+
{ _id: existing._id },
|
|
121
|
+
{ $set: { isAccountVerified: true, password: null, lastOtp: null, otpAttempts: 0 } },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await social_account_model.create({ accountId: account._id, provider, providerUserId });
|
|
127
|
+
return toUser(account);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const hashLoginCode = (code: string) => createHash('sha256').update(code).digest('hex');
|
|
131
|
+
|
|
132
|
+
/** Creates the single-use code the front-end exchanges for an access token. */
|
|
133
|
+
export const createSocialLoginCode = async (accountId: string): Promise<string> => {
|
|
134
|
+
const code = randomBytes(32).toString('base64url');
|
|
135
|
+
await social_login_code_model.create({
|
|
136
|
+
_id: hashLoginCode(code),
|
|
137
|
+
accountId,
|
|
138
|
+
expiresAt: new Date(Date.now() + SOCIAL_LOGIN_CODE_TTL_MS),
|
|
139
|
+
});
|
|
140
|
+
return code;
|
|
141
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { MongoBackend } from '@agendajs/mongo-backend';
|
|
2
|
+
import { Agenda } from 'agenda';
|
|
3
|
+
import mongoose from 'mongoose';
|
|
4
|
+
import { logger } from '../lib/logger.js';
|
|
5
|
+
|
|
6
|
+
export type AgendaOptions = {
|
|
7
|
+
/** How often a worker looks for due jobs (ms or a human interval). */
|
|
8
|
+
processEvery?: string | number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Agenda stores jobs in the app's MongoDB (`agendaJobs` collection), so no Redis
|
|
13
|
+
* is needed. Created on start, once mongoose is connected, to reuse its connection.
|
|
14
|
+
*/
|
|
15
|
+
let agenda: Agenda | undefined;
|
|
16
|
+
|
|
17
|
+
/** A new Agenda instance (a worker) on the app's MongoDB connection. */
|
|
18
|
+
export const newAgenda = ({ processEvery = '5 seconds' }: AgendaOptions = {}) => {
|
|
19
|
+
const instance = new Agenda({
|
|
20
|
+
backend: new MongoBackend({ mongo: mongoose.connection.db! }),
|
|
21
|
+
processEvery,
|
|
22
|
+
});
|
|
23
|
+
instance.on('error', (err) => logger.error(err, 'Agenda error'));
|
|
24
|
+
return instance;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Creates the app's Agenda instance, used by `enqueueEmail()` & co. */
|
|
28
|
+
export const createAgenda = (options?: AgendaOptions) => {
|
|
29
|
+
agenda = newAgenda(options);
|
|
30
|
+
return agenda;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const getAgenda = (): Agenda => {
|
|
34
|
+
if (!agenda) throw new Error('Queues are not started, call startQueues() first');
|
|
35
|
+
return agenda;
|
|
36
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { type Agenda, exponential, type Job } from 'agenda';
|
|
2
|
+
import mongoose from 'mongoose';
|
|
3
|
+
import { configs } from '../configs/index.js';
|
|
4
|
+
import { logger } from '../lib/logger.js';
|
|
5
|
+
import { otpEmailTemplate } from '../templates/otpEmailTemplate.js';
|
|
6
|
+
import { resetPasswordEmailTemplate } from '../templates/resetPasswordEmailTemplate.js';
|
|
7
|
+
import sendMail from '../utils/mail_sender.js';
|
|
8
|
+
import { getAgenda } from './agenda.js';
|
|
9
|
+
|
|
10
|
+
export const EMAIL_QUEUE = 'email';
|
|
11
|
+
// Agenda's default collection (see agenda.ts)
|
|
12
|
+
export const JOBS_COLLECTION = 'agendaJobs';
|
|
13
|
+
|
|
14
|
+
/** Every email the app sends goes through this one queue. */
|
|
15
|
+
export type EmailJob =
|
|
16
|
+
| { type: 'verify-account'; email: string; otp: string; verificationLink: string }
|
|
17
|
+
| { type: 'reset-password'; email: string; resetLink: string };
|
|
18
|
+
|
|
19
|
+
const render = (job: EmailJob) => {
|
|
20
|
+
switch (job.type) {
|
|
21
|
+
case 'verify-account':
|
|
22
|
+
return {
|
|
23
|
+
subject: 'Verify Your Email',
|
|
24
|
+
htmlBody: otpEmailTemplate(job),
|
|
25
|
+
textBody: `Your verification code is ${job.otp}. Or verify using this link: ${job.verificationLink}`,
|
|
26
|
+
};
|
|
27
|
+
case 'reset-password':
|
|
28
|
+
return {
|
|
29
|
+
subject: 'Reset Your Password',
|
|
30
|
+
htmlBody: resetPasswordEmailTemplate(job.resetLink, configs.jwt.reset_expires),
|
|
31
|
+
textBody: `Reset your password using this link: ${job.resetLink}`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type EmailJobOptions = {
|
|
37
|
+
/** Delay before the first retry; it doubles on every further retry. */
|
|
38
|
+
retryDelayMs?: number;
|
|
39
|
+
maxRetries?: number;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Registers the email job on a worker. */
|
|
43
|
+
export const defineEmailJob = (
|
|
44
|
+
agenda: Agenda,
|
|
45
|
+
{ retryDelayMs = 10_000, maxRetries = 3 }: EmailJobOptions = {},
|
|
46
|
+
) =>
|
|
47
|
+
agenda.define(
|
|
48
|
+
EMAIL_QUEUE,
|
|
49
|
+
async ({ attrs }: Job<EmailJob>) => {
|
|
50
|
+
const data = attrs.data;
|
|
51
|
+
try {
|
|
52
|
+
await sendMail({ to: data.email, ...render(data) });
|
|
53
|
+
logger.info(`Email (${data.type}) sent to ${data.email}`);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
logger.error({ err, jobId: attrs._id }, `Failed to send ${data.type} email, will retry`);
|
|
56
|
+
throw err; // let Agenda retry the job
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
backoff: exponential({ delay: retryDelayMs, factor: 2, maxRetries }),
|
|
61
|
+
// Not `removeOnComplete`: Agenda emits "complete" before it forgets the job,
|
|
62
|
+
// and the extra await of the removal makes drain() miss it and hang until its
|
|
63
|
+
// timeout on every shutdown. A TTL index cleans up instead (below).
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Jobs carry OTPs and reset links, so finished email jobs (sent or given up on)
|
|
69
|
+
* are deleted by MongoDB an hour later. Only the email job: other jobs are kept.
|
|
70
|
+
*/
|
|
71
|
+
export const ensureEmailJobCleanup = () =>
|
|
72
|
+
mongoose.connection.db!.collection(JOBS_COLLECTION).createIndex(
|
|
73
|
+
{ lastFinishedAt: 1 },
|
|
74
|
+
{
|
|
75
|
+
name: 'email_jobs_ttl',
|
|
76
|
+
expireAfterSeconds: 60 * 60,
|
|
77
|
+
partialFilterExpression: { name: EMAIL_QUEUE },
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
export const enqueueEmail = (job: EmailJob) => getAgenda().now(EMAIL_QUEUE, job);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { logger } from '../lib/logger.js';
|
|
2
|
+
import { type AgendaOptions, createAgenda, getAgenda } from './agenda.js';
|
|
3
|
+
import { defineEmailJob, type EmailJobOptions, ensureEmailJobCleanup } from './email.queue.js';
|
|
4
|
+
|
|
5
|
+
/** The defaults are meant for production; tests pass shorter delays. */
|
|
6
|
+
export type QueueOptions = AgendaOptions & EmailJobOptions;
|
|
7
|
+
|
|
8
|
+
export const startQueues = async (options: QueueOptions = {}) => {
|
|
9
|
+
const agenda = createAgenda(options);
|
|
10
|
+
defineEmailJob(agenda, options);
|
|
11
|
+
await ensureEmailJobCleanup();
|
|
12
|
+
await agenda.start();
|
|
13
|
+
logger.info('Queues are running...');
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// waits for running jobs to finish (up to 30s) instead of abandoning them
|
|
17
|
+
export const stopQueues = () => getAgenda().drain(30_000);
|