@usequark/quark-create-app 1.25.16 → 1.27.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 +6 -8
- package/package.json +1 -1
- package/src/index.js +75 -30
- package/templates/base-project/apps/web/.env.example +10 -0
- package/templates/base-project/apps/web/src/app/api/auth/apple/route.js +24 -0
- package/templates/base-project/apps/web/src/app/api/auth/forgot-password/route.js +57 -0
- package/templates/base-project/apps/web/src/app/api/auth/google/route.js +5 -1
- package/templates/base-project/apps/web/src/app/api/auth/refresh/route.js +57 -8
- package/templates/base-project/apps/web/src/app/api/auth/register/route.js +20 -1
- package/templates/base-project/apps/web/src/app/api/auth/reset-password/route.js +105 -0
- package/templates/base-project/apps/web/src/app/api/auth/verify-email/route.js +58 -0
- package/templates/base-project/apps/web/src/app/auth/forgot-password/ForgotPasswordPageClient.js +159 -0
- package/templates/base-project/apps/web/src/app/auth/forgot-password/page.js +2 -51
- package/templates/base-project/apps/web/src/app/auth/reset-password/ResetPasswordPageClient.js +280 -0
- package/templates/base-project/apps/web/src/app/auth/reset-password/page.js +9 -0
- package/templates/base-project/apps/web/src/app/auth/signin/SignInPageClient.js +22 -0
- package/templates/base-project/apps/web/src/lib/auth-email-verification.js +5 -0
- package/templates/base-project/apps/web/src/lib/auth.js +31 -10
- package/templates/base-project/apps/web/src/lib/enqueue-email-job.js +67 -0
- package/templates/base-project/apps/web/src/lib/enqueue-reset-password-email.js +33 -0
- package/templates/base-project/apps/web/src/lib/enqueue-verification-email.js +25 -0
- package/templates/base-project/apps/web/src/lib/enqueue-welcome-email.js +13 -48
- package/templates/base-project/apps/web/src/lib/issue-verification-token.js +52 -0
- package/templates/base-project/apps/web/src/lib/proxy-auth.js +7 -0
- package/templates/base-project/apps/web/src/lib/send-reset-password-email.js +24 -0
- package/templates/base-project/apps/web/src/lib/send-verification-email.js +28 -0
- package/templates/base-project/apps/web/src/proxy.js +48 -7
- package/templates/base-project/packages/db/prisma/schema.prisma +7 -2
- package/templates/base-project/packages/db/src/queries.js +13 -0
- package/templates/base-project/packages/db/src/schemas.js +16 -6
- package/templates/config/src/environment.js +5 -0
- package/templates/config/src/load-config.js +8 -0
- package/templates/config/src/validate-env.js +5 -0
- package/templates/jobs/src/definitions.js +1 -0
- package/templates/worker/package.json +1 -0
- package/templates/worker/scripts/sync-readme.js +41 -0
- package/templates/worker/src/handlers/email-job-validation.js +13 -0
- package/templates/worker/src/handlers/email.js +39 -0
- package/templates/worker/src/handlers/index.js +2 -0
- package/templates/worker/src/readme-table.js +68 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Queue name and enqueue mechanics shared by every transactional email the web
|
|
3
|
+
* service sends.
|
|
4
|
+
*
|
|
5
|
+
* The queue module is imported dynamically from inside this file, so callers
|
|
6
|
+
* that import a thin wrapper statically never pull BullMQ into their module
|
|
7
|
+
* graph. That matters most for `/api/auth/*` routes, which are reachable on the
|
|
8
|
+
* web service: a module-scope `createQueue` import both loaded BullMQ at
|
|
9
|
+
* startup and pinned a Redis connection to the web process for as long as the
|
|
10
|
+
* process lived.
|
|
11
|
+
*
|
|
12
|
+
* It also gives each route a static boundary to substitute in tests. `mock.module`
|
|
13
|
+
* intercepts a dynamic import only when the importing module registered the
|
|
14
|
+
* mock, so a dynamic import made by the route itself cannot be mocked from the
|
|
15
|
+
* route's test. A static import of a wrapper module can.
|
|
16
|
+
*
|
|
17
|
+
* The queue name is duplicated here rather than imported from `@<scope>/jobs`
|
|
18
|
+
* on purpose: `jobs` is an optional scaffold package, and importing it from the
|
|
19
|
+
* web module broke the build of every project that declined it. The
|
|
20
|
+
* `packages/cli` scaffold-drift test fails when this copy diverges from the
|
|
21
|
+
* jobs definitions.
|
|
22
|
+
*/
|
|
23
|
+
export const EMAIL_QUEUE = "email-queue";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Loads the queue module on demand.
|
|
27
|
+
*
|
|
28
|
+
* Kept as a named binding rather than an inline `import()` so it can be
|
|
29
|
+
* substituted — see `loadQueue` on {@link enqueueEmailJob}.
|
|
30
|
+
*
|
|
31
|
+
* @returns {Promise<typeof import("@usequark/quark-core/queue")>}
|
|
32
|
+
*/
|
|
33
|
+
function loadQueueModule() {
|
|
34
|
+
return import("@usequark/quark-core/queue");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Enqueues an email job, closing the queue either way.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} jobName - Job name from the jobs package (e.g.
|
|
41
|
+
* `SEND_WELCOME_EMAIL`). The worker rejects an unknown name, so this is not
|
|
42
|
+
* validated client-side — a helper that silently accepted a typo would hide
|
|
43
|
+
* the rejection in a log line.
|
|
44
|
+
* @param {object} data - Job payload, validated by the worker's handler.
|
|
45
|
+
* @param {() => Promise<{ createQueue: (name: string, options?: object) => object }>} [loadQueue]
|
|
46
|
+
* How to obtain the queue module. Defaults to the dynamic import above;
|
|
47
|
+
* overridden in tests.
|
|
48
|
+
* @returns {Promise<void>}
|
|
49
|
+
* @throws Whatever `createQueue` or `add` throws. The queue is closed either
|
|
50
|
+
* way, so a caller that swallows the error still leaves no connection behind.
|
|
51
|
+
*/
|
|
52
|
+
export async function enqueueEmailJob(
|
|
53
|
+
jobName,
|
|
54
|
+
data,
|
|
55
|
+
loadQueue = loadQueueModule,
|
|
56
|
+
) {
|
|
57
|
+
const { createQueue } = await loadQueue();
|
|
58
|
+
const queue = createQueue(EMAIL_QUEUE);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
await queue.add(jobName, data);
|
|
62
|
+
} finally {
|
|
63
|
+
// Never let a failing close mask the enqueue outcome — the caller only
|
|
64
|
+
// needs to know whether the job reached the queue.
|
|
65
|
+
await queue.close().catch(() => {});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { enqueueEmailJob } from "./enqueue-email-job.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Job name for the password-reset message.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors `JOB_NAMES.SEND_RESET_PASSWORD_EMAIL` in the optional `@<scope>/jobs`
|
|
7
|
+
* package (see `enqueue-email-job.js` for why it is duplicated). The
|
|
8
|
+
* `packages/cli` scaffold-drift test fails when the two copies diverge.
|
|
9
|
+
*
|
|
10
|
+
* Until this helper existed, no scaffolded route enqueued this job: the worker
|
|
11
|
+
* handler and the email template shipped, but nothing ever called them. The
|
|
12
|
+
* forgot-password route is the caller.
|
|
13
|
+
*/
|
|
14
|
+
export const SEND_RESET_PASSWORD_EMAIL = "send-reset-password-email";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Enqueues the password-reset email for a user who requested one.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} userId - The user to email. The worker resolves the address
|
|
20
|
+
* from this id, so the reset link cannot be sent to an attacker-chosen
|
|
21
|
+
* mailbox.
|
|
22
|
+
* @param {string} resetUrl - Absolute, single-use reset link.
|
|
23
|
+
* @param {() => Promise<{ createQueue: (name: string, options?: object) => object }>} [loadQueue]
|
|
24
|
+
* Queue-module loader, overridden in tests. See {@link enqueueEmailJob}.
|
|
25
|
+
* @returns {Promise<void>}
|
|
26
|
+
*/
|
|
27
|
+
export async function enqueueResetPasswordEmail(userId, resetUrl, loadQueue) {
|
|
28
|
+
return enqueueEmailJob(
|
|
29
|
+
SEND_RESET_PASSWORD_EMAIL,
|
|
30
|
+
{ userId, resetUrl },
|
|
31
|
+
loadQueue,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { enqueueEmailJob } from "./enqueue-email-job.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Job name for the email-verification message.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors `JOB_NAMES.SEND_VERIFY_EMAIL` in the optional `@<scope>/jobs`
|
|
7
|
+
* package (see `enqueue-email-job.js` for why it is duplicated). The
|
|
8
|
+
* `packages/cli` scaffold-drift test fails when the two copies diverge.
|
|
9
|
+
*/
|
|
10
|
+
export const SEND_VERIFY_EMAIL = "send-verify-email";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Enqueues the verification email a new account needs to confirm its address.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} userId - The user whose address is being confirmed. The worker
|
|
16
|
+
* resolves the address from this id rather than trusting the caller, so an
|
|
17
|
+
* attacker cannot enqueue a verification mail to an address they do not own.
|
|
18
|
+
* @param {string} verifyUrl - Absolute, single-use confirmation link.
|
|
19
|
+
* @param {() => Promise<{ createQueue: (name: string, options?: object) => object }>} [loadQueue]
|
|
20
|
+
* Queue-module loader, overridden in tests. See {@link enqueueEmailJob}.
|
|
21
|
+
* @returns {Promise<void>}
|
|
22
|
+
*/
|
|
23
|
+
export async function enqueueVerificationEmail(userId, verifyUrl, loadQueue) {
|
|
24
|
+
return enqueueEmailJob(SEND_VERIFY_EMAIL, { userId, verifyUrl }, loadQueue);
|
|
25
|
+
}
|
|
@@ -1,62 +1,27 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
* there puts `@<scope>/jobs` in the web module graph of every project, and a
|
|
7
|
-
* project that declines `jobs` has no such dependency to resolve: the web app
|
|
8
|
-
* fails to build before any test can run. The strings are duplicated here on
|
|
9
|
-
* purpose; a `packages/cli` test fails when the two copies diverge.
|
|
10
|
-
*/
|
|
11
|
-
export const EMAIL_QUEUE = "email-queue";
|
|
12
|
-
export const SEND_WELCOME_EMAIL = "send-welcome-email";
|
|
1
|
+
import { enqueueEmailJob } from "./enqueue-email-job.js";
|
|
2
|
+
|
|
3
|
+
// Re-exported so callers that already import the welcome helper keep their
|
|
4
|
+
// import site, and so its test still asserts the queue name in one place.
|
|
5
|
+
export { EMAIL_QUEUE } from "./enqueue-email-job.js";
|
|
13
6
|
|
|
14
7
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* Kept as a named binding rather than an inline `import()` so it can be
|
|
18
|
-
* substituted — see `loadQueue` on {@link enqueueWelcomeEmail}.
|
|
8
|
+
* Job name for the signup welcome email.
|
|
19
9
|
*
|
|
20
|
-
*
|
|
10
|
+
* Mirrors `JOB_NAMES.SEND_WELCOME_EMAIL` in the optional `@<scope>/jobs`
|
|
11
|
+
* package (see `enqueue-email-job.js` for why it is duplicated). The
|
|
12
|
+
* `packages/cli` scaffold-drift test fails when the two copies diverge.
|
|
21
13
|
*/
|
|
22
|
-
|
|
23
|
-
return import("@usequark/quark-core/queue");
|
|
24
|
-
}
|
|
14
|
+
export const SEND_WELCOME_EMAIL = "send-welcome-email";
|
|
25
15
|
|
|
26
16
|
/**
|
|
27
17
|
* Enqueues the post-signup welcome email for a newly created user.
|
|
28
18
|
*
|
|
29
|
-
* This lives in its own module for two reasons.
|
|
30
|
-
*
|
|
31
|
-
* The queue module is imported dynamically from inside this file, so callers
|
|
32
|
-
* that import *this* statically never pull BullMQ into their module graph. That
|
|
33
|
-
* matters most for `/api/auth/register`, which is reachable on the web service:
|
|
34
|
-
* a module-scope `createQueue` import both loaded BullMQ at startup and pinned a
|
|
35
|
-
* Redis connection to the web process for as long as the process lived.
|
|
36
|
-
*
|
|
37
|
-
* It also gives the route a static boundary to substitute in tests. `mock.module`
|
|
38
|
-
* intercepts a dynamic import only when the importing module registered the mock,
|
|
39
|
-
* so a dynamic import made by the route itself cannot be mocked from the route's
|
|
40
|
-
* test. A static import of this module can.
|
|
41
|
-
*
|
|
42
19
|
* @param {string} userId - The user to welcome. The worker rejects a job with
|
|
43
20
|
* no userId, so this is required rather than defaulted.
|
|
44
21
|
* @param {() => Promise<{ createQueue: (name: string, options?: object) => object }>} [loadQueue]
|
|
45
|
-
*
|
|
46
|
-
* overridden in tests.
|
|
22
|
+
* Queue-module loader, overridden in tests. See {@link enqueueEmailJob}.
|
|
47
23
|
* @returns {Promise<void>}
|
|
48
|
-
* @throws Whatever `createQueue` or `add` throws. The queue is closed either
|
|
49
|
-
* way, so a caller that swallows the error still leaves no connection behind.
|
|
50
24
|
*/
|
|
51
|
-
export async function enqueueWelcomeEmail(userId, loadQueue
|
|
52
|
-
|
|
53
|
-
const queue = createQueue(EMAIL_QUEUE);
|
|
54
|
-
|
|
55
|
-
try {
|
|
56
|
-
await queue.add(SEND_WELCOME_EMAIL, { userId });
|
|
57
|
-
} finally {
|
|
58
|
-
// Never let a failing close mask the enqueue outcome — the caller only
|
|
59
|
-
// needs to know whether the job reached the queue.
|
|
60
|
-
await queue.close().catch(() => {});
|
|
61
|
-
}
|
|
25
|
+
export async function enqueueWelcomeEmail(userId, loadQueue) {
|
|
26
|
+
return enqueueEmailJob(SEND_WELCOME_EMAIL, { userId }, loadQueue);
|
|
62
27
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { generateToken, hashToken } from "@usequark/quark-core/auth";
|
|
2
|
+
import { verificationToken } from "@usequark/quark-db";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Lifetime of a password-reset link.
|
|
6
|
+
*
|
|
7
|
+
* An hour is enough for a mail to arrive and a person to act on it, and short
|
|
8
|
+
* enough that a link found later in a mailbox archive is already dead. The
|
|
9
|
+
* reset email quotes the same window; changing one without the other would make
|
|
10
|
+
* the email lie.
|
|
11
|
+
*/
|
|
12
|
+
export const RESET_TOKEN_TTL_MS = 60 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Lifetime of an email-verification link.
|
|
16
|
+
*
|
|
17
|
+
* Longer than a reset: an address is confirmed on the user's own schedule, and
|
|
18
|
+
* nothing is ambiently at risk while the link lives — redeeming it only sets
|
|
19
|
+
* the verified flag, it does not grant a session.
|
|
20
|
+
*/
|
|
21
|
+
export const VERIFY_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Issues a single-use verification token for an identifier and returns the raw
|
|
25
|
+
* value to embed in the emailed link.
|
|
26
|
+
*
|
|
27
|
+
* The stored row holds only the SHA-256 digest (`hashToken`), never the value
|
|
28
|
+
* the link carries: a leaked database row must not be redeemable. Resolution
|
|
29
|
+
* hashes the presented token before looking it up, so the two always meet.
|
|
30
|
+
*
|
|
31
|
+
* Any outstanding token for the identifier is deleted first. A user who clicks
|
|
32
|
+
* "resend" twice should not leave two working links behind — the newest request
|
|
33
|
+
* is the one they are looking at, and an old email in a shared inbox should
|
|
34
|
+
* stop working the moment a replacement is issued.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} identifier - The user's email address (the `VerificationToken`
|
|
37
|
+
* identifier column).
|
|
38
|
+
* @param {number} ttlMs - Token lifetime in milliseconds.
|
|
39
|
+
* @returns {Promise<string>} The raw token to place in the link. Not stored.
|
|
40
|
+
*/
|
|
41
|
+
export async function issueVerificationToken(identifier, ttlMs) {
|
|
42
|
+
await verificationToken.deleteByIdentifier(identifier);
|
|
43
|
+
|
|
44
|
+
const token = generateToken();
|
|
45
|
+
await verificationToken.create({
|
|
46
|
+
identifier,
|
|
47
|
+
token: hashToken(token),
|
|
48
|
+
expires: new Date(Date.now() + ttlMs),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return token;
|
|
52
|
+
}
|
|
@@ -7,6 +7,13 @@ const STRICT_AUTH_RATE_LIMIT_ROUTES = new Set([
|
|
|
7
7
|
// redirects to the sign-in page — nothing to brute force.
|
|
8
8
|
"/api/auth/callback/credentials",
|
|
9
9
|
"/api/auth/register",
|
|
10
|
+
// Password recovery surfaces are credential-adjacent and stay strict:
|
|
11
|
+
// `forgot-password` is an email-sending surface (5 per 15 minutes per
|
|
12
|
+
// IP:path stops an attacker from flooding one mailbox), and
|
|
13
|
+
// `reset-password` is a token-redeeming surface (the same budget limits
|
|
14
|
+
// grinding through stolen or guessed links).
|
|
15
|
+
"/api/auth/forgot-password",
|
|
16
|
+
"/api/auth/reset-password",
|
|
10
17
|
]);
|
|
11
18
|
|
|
12
19
|
/**
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getAppUrl } from "@usequark/quark-config";
|
|
2
|
+
import { enqueueResetPasswordEmail } from "./enqueue-reset-password-email.js";
|
|
3
|
+
import {
|
|
4
|
+
issueVerificationToken,
|
|
5
|
+
RESET_TOKEN_TTL_MS,
|
|
6
|
+
} from "./issue-verification-token.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Issues a password-reset link for an account and enqueues the email that
|
|
10
|
+
* carries it.
|
|
11
|
+
*
|
|
12
|
+
* Kept out of the forgot-password route for the same reason the verification
|
|
13
|
+
* equivalent is kept out of the register route: one place that defines how the
|
|
14
|
+
* link is built, and one test for that definition.
|
|
15
|
+
*
|
|
16
|
+
* @param {{ id: string, email: string }} account - The account that requested a reset.
|
|
17
|
+
* @returns {Promise<void>}
|
|
18
|
+
*/
|
|
19
|
+
export async function sendResetPasswordEmail(account) {
|
|
20
|
+
const token = await issueVerificationToken(account.email, RESET_TOKEN_TTL_MS);
|
|
21
|
+
const resetUrl = `${getAppUrl()}/auth/reset-password?token=${token}`;
|
|
22
|
+
|
|
23
|
+
await enqueueResetPasswordEmail(account.id, resetUrl);
|
|
24
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { getAppUrl } from "@usequark/quark-config";
|
|
2
|
+
import { enqueueVerificationEmail } from "./enqueue-verification-email.js";
|
|
3
|
+
import {
|
|
4
|
+
issueVerificationToken,
|
|
5
|
+
VERIFY_TOKEN_TTL_MS,
|
|
6
|
+
} from "./issue-verification-token.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Issues a verification link for an account and enqueues the email that carries
|
|
10
|
+
* it.
|
|
11
|
+
*
|
|
12
|
+
* Kept out of the register route so the route reads as "create the user, then
|
|
13
|
+
* hand off" and so this sequence has one test that does not have to stand up
|
|
14
|
+
* the whole registration path. The route treats any failure here as
|
|
15
|
+
* non-fatal — the account exists either way.
|
|
16
|
+
*
|
|
17
|
+
* @param {{ id: string, email: string }} account - The freshly created user.
|
|
18
|
+
* @returns {Promise<void>}
|
|
19
|
+
*/
|
|
20
|
+
export async function sendVerificationEmail(account) {
|
|
21
|
+
const token = await issueVerificationToken(
|
|
22
|
+
account.email,
|
|
23
|
+
VERIFY_TOKEN_TTL_MS,
|
|
24
|
+
);
|
|
25
|
+
const verifyUrl = `${getAppUrl()}/api/auth/verify-email?token=${token}`;
|
|
26
|
+
|
|
27
|
+
await enqueueVerificationEmail(account.id, verifyUrl);
|
|
28
|
+
}
|
|
@@ -93,23 +93,64 @@ export async function getRateLimiter() {
|
|
|
93
93
|
return rateLimiterPromise;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Boolean-like values that disable trusting proxy headers. Mirrors the
|
|
98
|
+
* "explicit false wins" convention used by `AUTH_ALLOW_SIGNUP` and
|
|
99
|
+
* `DB_INSTRUMENTATION`, so operators don't have to learn a new dialect.
|
|
100
|
+
*/
|
|
101
|
+
const PROXY_HEADERS_FALSY_VALUES = new Set(["false", "0", "no", "off"]);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Whether the client-supplied proxy headers (`x-forwarded-for`, `x-real-ip`)
|
|
105
|
+
* may be trusted to identify the client.
|
|
106
|
+
*
|
|
107
|
+
* Default `true`: the shipped behaviour. The app assumes it sits behind a
|
|
108
|
+
* reverse proxy (Railway, Docker, nginx) that overwrites these headers on
|
|
109
|
+
* every request, so the left-most `x-forwarded-for` entry is treated as the
|
|
110
|
+
* client IP.
|
|
111
|
+
*
|
|
112
|
+
* Set `TRUST_PROXY_HEADERS=false` when the app is reachable directly (no
|
|
113
|
+
* proxy rewriting the header on the way in). The headers are then ignored and
|
|
114
|
+
* every client keys on the literal "unknown". Because the bucket key is
|
|
115
|
+
* `${ip}:${path}`, that means one bucket shared by every client of a route —
|
|
116
|
+
* not one bucket across all routes. A header a client can set is not an
|
|
117
|
+
* identity, so this is what stops a spoofed `x-forwarded-for` from buying a
|
|
118
|
+
* fresh bucket per request. The trade-off is the reverse of the default: one
|
|
119
|
+
* client can now exhaust the shared bucket for the route it floods, which is
|
|
120
|
+
* why the default keeps trusting the headers.
|
|
121
|
+
*
|
|
122
|
+
* Read lazily (per request) rather than at module load: tests flip it and a
|
|
123
|
+
* value set after import still takes effect. Only explicit false-like values
|
|
124
|
+
* disable trusting; unset, empty, "true", or a typo all keep the default.
|
|
125
|
+
*
|
|
126
|
+
* @returns {boolean}
|
|
127
|
+
*/
|
|
128
|
+
export function shouldTrustProxyHeaders() {
|
|
129
|
+
const raw = process.env.TRUST_PROXY_HEADERS?.trim().toLowerCase();
|
|
130
|
+
return !(raw && PROXY_HEADERS_FALSY_VALUES.has(raw));
|
|
131
|
+
}
|
|
132
|
+
|
|
96
133
|
/**
|
|
97
134
|
* Resolve the client IP for rate-limit keying.
|
|
98
135
|
*
|
|
99
136
|
* `NextRequest.ip` was removed in Next 15, so `request.ip` is always
|
|
100
|
-
* `undefined` here and every caller
|
|
101
|
-
*
|
|
102
|
-
* burst of 5 credential posts
|
|
103
|
-
* everyone for 15 minutes.
|
|
137
|
+
* `undefined` here and every caller would key on the literal "unknown" — one
|
|
138
|
+
* bucket shared by every client of a route (the bucket key is `${ip}:${path}`).
|
|
139
|
+
* That is what made one burst of 5 credential posts lock out sign-in for
|
|
140
|
+
* everyone for 15 minutes, which is why this reads the proxy headers instead.
|
|
104
141
|
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* client
|
|
142
|
+
* When `TRUST_PROXY_HEADERS` is enabled (the default) the left-most
|
|
143
|
+
* `x-forwarded-for` entry is used, falling back to `x-real-ip`; both are
|
|
144
|
+
* client-supplied unless a reverse proxy overwrites them, which is the
|
|
145
|
+
* assumption the default bakes in (see `shouldTrustProxyHeaders`). With the
|
|
146
|
+
* flag off both headers are ignored and every request resolves to "unknown".
|
|
108
147
|
*
|
|
109
148
|
* @param {import("next/server").NextRequest} request
|
|
110
149
|
* @returns {string}
|
|
111
150
|
*/
|
|
112
151
|
function getClientIp(request) {
|
|
152
|
+
if (!shouldTrustProxyHeaders()) return "unknown";
|
|
153
|
+
|
|
113
154
|
const forwardedFor = request.headers.get("x-forwarded-for");
|
|
114
155
|
if (forwardedFor) {
|
|
115
156
|
// Left-most entry is the originating client; later entries are proxies.
|
|
@@ -112,8 +112,13 @@ model Job {
|
|
|
112
112
|
createdAt DateTime @default(now())
|
|
113
113
|
updatedAt DateTime @updatedAt
|
|
114
114
|
|
|
115
|
-
// (status, runAt) serves status-only
|
|
116
|
-
//
|
|
115
|
+
// (status, runAt) serves status-only and (status, runAt) queries
|
|
116
|
+
// through its leftmost prefix. It does not index runAt alone: a
|
|
117
|
+
// runAt-only range query cannot use it, so add a single-column
|
|
118
|
+
// [runAt] index if such a query appears. The shipped code has none
|
|
119
|
+
// (BullMQ schedules in Redis; this model is an audit trail), so the
|
|
120
|
+
// single-column [status] and [runAt] indexes were redundant for the
|
|
121
|
+
// query patterns in use and were removed.
|
|
117
122
|
@@index([queue])
|
|
118
123
|
@@index([status, runAt])
|
|
119
124
|
@@index([createdAt])
|
|
@@ -197,6 +197,19 @@ export const verificationToken = {
|
|
|
197
197
|
where: { identifier, token },
|
|
198
198
|
});
|
|
199
199
|
},
|
|
200
|
+
/**
|
|
201
|
+
* Removes every token for an identifier.
|
|
202
|
+
*
|
|
203
|
+
* A verification or reset flow issues one live token per address: without
|
|
204
|
+
* this, requesting a second link leaves the first valid too, so an old email
|
|
205
|
+
* keeps working after the user asked for a new one. Called before issuing a
|
|
206
|
+
* replacement, and again once a token has been redeemed.
|
|
207
|
+
*/
|
|
208
|
+
deleteByIdentifier: (identifier) => {
|
|
209
|
+
return prisma.verificationToken.deleteMany({
|
|
210
|
+
where: { identifier },
|
|
211
|
+
});
|
|
212
|
+
},
|
|
200
213
|
deleteExpired: () => {
|
|
201
214
|
return prisma.verificationToken.deleteMany({
|
|
202
215
|
where: { expires: { lt: new Date() } },
|
|
@@ -6,14 +6,24 @@ export const userCreateSchema = z.object({
|
|
|
6
6
|
image: z.string().url().optional(),
|
|
7
7
|
});
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Password rules, shared by every route that sets one.
|
|
11
|
+
*
|
|
12
|
+
* Registration and password reset must not drift: a reset that accepts a weaker
|
|
13
|
+
* password than signup would let a user downgrade their own credential, and the
|
|
14
|
+
* reverse would lock a legitimate password out of recovery. Composed into
|
|
15
|
+
* `userRegisterSchema` rather than duplicated, so there is one definition.
|
|
16
|
+
*/
|
|
17
|
+
export const passwordSchema = z
|
|
18
|
+
.string()
|
|
19
|
+
.min(8, "Password must be at least 8 characters")
|
|
20
|
+
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
|
21
|
+
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
|
22
|
+
.regex(/[0-9]/, "Password must contain at least one number");
|
|
23
|
+
|
|
9
24
|
export const userRegisterSchema = z.object({
|
|
10
25
|
email: z.string().email("Invalid email address"),
|
|
11
|
-
password:
|
|
12
|
-
.string()
|
|
13
|
-
.min(8, "Password must be at least 8 characters")
|
|
14
|
-
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
|
15
|
-
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
|
16
|
-
.regex(/[0-9]/, "Password must contain at least one number"),
|
|
26
|
+
password: passwordSchema,
|
|
17
27
|
name: z.string().min(2, "Name must be at least 2 characters").optional(),
|
|
18
28
|
});
|
|
19
29
|
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
* @property {boolean} security.trustProxy - Trust proxy headers
|
|
35
35
|
* @property {Object} auth - Authentication configuration
|
|
36
36
|
* @property {boolean} auth.allowSignup - Allow public self-service signup
|
|
37
|
+
* @property {boolean} auth.requireEmailVerification - Require verified email for sign-in
|
|
37
38
|
* @property {Object} features - Feature flags
|
|
38
39
|
* @property {boolean} features.debugRoutes - Enable debug endpoints
|
|
39
40
|
* @property {boolean} features.seedOnStart - Auto-seed database on startup
|
|
@@ -76,6 +77,7 @@ const ENVIRONMENT_CONFIGS = {
|
|
|
76
77
|
},
|
|
77
78
|
auth: {
|
|
78
79
|
allowSignup: true,
|
|
80
|
+
requireEmailVerification: true,
|
|
79
81
|
},
|
|
80
82
|
features: {
|
|
81
83
|
debugRoutes: true,
|
|
@@ -118,6 +120,7 @@ const ENVIRONMENT_CONFIGS = {
|
|
|
118
120
|
},
|
|
119
121
|
auth: {
|
|
120
122
|
allowSignup: true,
|
|
123
|
+
requireEmailVerification: true,
|
|
121
124
|
},
|
|
122
125
|
features: {
|
|
123
126
|
debugRoutes: true,
|
|
@@ -160,6 +163,7 @@ const ENVIRONMENT_CONFIGS = {
|
|
|
160
163
|
},
|
|
161
164
|
auth: {
|
|
162
165
|
allowSignup: true,
|
|
166
|
+
requireEmailVerification: true,
|
|
163
167
|
},
|
|
164
168
|
features: {
|
|
165
169
|
debugRoutes: false,
|
|
@@ -202,6 +206,7 @@ const ENVIRONMENT_CONFIGS = {
|
|
|
202
206
|
},
|
|
203
207
|
auth: {
|
|
204
208
|
allowSignup: true,
|
|
209
|
+
requireEmailVerification: true,
|
|
205
210
|
},
|
|
206
211
|
features: {
|
|
207
212
|
debugRoutes: false,
|
|
@@ -121,6 +121,14 @@ function buildEnvOverrides(_envConfig) {
|
|
|
121
121
|
overrides.auth = { ...overrides.auth, allowSignup };
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
|
+
if (process.env.REQUIRE_EMAIL_VERIFICATION) {
|
|
125
|
+
const requireEmailVerification = parseBooleanEnv(
|
|
126
|
+
process.env.REQUIRE_EMAIL_VERIFICATION,
|
|
127
|
+
);
|
|
128
|
+
if (requireEmailVerification !== undefined) {
|
|
129
|
+
overrides.auth = { ...overrides.auth, requireEmailVerification };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
124
132
|
|
|
125
133
|
// Database pool
|
|
126
134
|
if (process.env.DB_POOL_MAX) {
|
|
@@ -110,6 +110,8 @@ const ENV_DESCRIPTIONS = {
|
|
|
110
110
|
PORT: "Web server port",
|
|
111
111
|
AUTH_ALLOW_SIGNUP:
|
|
112
112
|
'Set to "false" to disable public self-service signup routes and UI',
|
|
113
|
+
REQUIRE_EMAIL_VERIFICATION:
|
|
114
|
+
'Set to "true" (default) to require verified email for sign-in; set to "false" to allow unverified emails',
|
|
113
115
|
|
|
114
116
|
// Worker
|
|
115
117
|
WORKER_CONCURRENCY: "Number of concurrent jobs per queue (default: 5)",
|
|
@@ -259,6 +261,9 @@ function createEnvSchema(service) {
|
|
|
259
261
|
AUTH_ALLOW_SIGNUP: booleanLike(
|
|
260
262
|
"AUTH_ALLOW_SIGNUP must be a boolean-like value (true/false/1/0/yes/no/on/off)",
|
|
261
263
|
),
|
|
264
|
+
REQUIRE_EMAIL_VERIFICATION: booleanLike(
|
|
265
|
+
"REQUIRE_EMAIL_VERIFICATION must be a boolean-like value (true/false/1/0/yes/no/on/off)",
|
|
266
|
+
),
|
|
262
267
|
|
|
263
268
|
// Worker
|
|
264
269
|
WORKER_CONCURRENCY: optionalString,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regenerate the handler-table block in README.md from JOB_NAMES.
|
|
3
|
+
*
|
|
4
|
+
* Usage (from apps/worker): pnpm sync-readme
|
|
5
|
+
*
|
|
6
|
+
* `src/readme-consistency.test.js` fails on drift between the job definitions,
|
|
7
|
+
* the registry, and the table; this command writes the fix instead of leaving
|
|
8
|
+
* the maintainer to retype it by hand.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { jobHandlers } from "../src/handlers/index.js";
|
|
15
|
+
import {
|
|
16
|
+
replaceHandlerTable,
|
|
17
|
+
TABLE_END,
|
|
18
|
+
TABLE_START,
|
|
19
|
+
} from "../src/readme-table.js";
|
|
20
|
+
|
|
21
|
+
const readmePath = join(
|
|
22
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
23
|
+
"..",
|
|
24
|
+
"README.md",
|
|
25
|
+
);
|
|
26
|
+
const current = readFileSync(readmePath, "utf-8");
|
|
27
|
+
|
|
28
|
+
// The scaffolded worker README is a hand-authored quickstart with no table.
|
|
29
|
+
if (!current.includes(TABLE_START) || !current.includes(TABLE_END)) {
|
|
30
|
+
console.log("README has no handler-table markers; nothing to regenerate.");
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const next = replaceHandlerTable(current, jobHandlers);
|
|
35
|
+
|
|
36
|
+
if (next === current) {
|
|
37
|
+
console.log("README handler table is already up to date.");
|
|
38
|
+
} else {
|
|
39
|
+
writeFileSync(readmePath, next);
|
|
40
|
+
console.log("Regenerated the README handler table.");
|
|
41
|
+
}
|
|
@@ -23,6 +23,19 @@ export function requireResetPasswordEmailData(data) {
|
|
|
23
23
|
return { userId, resetUrl };
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export function requireVerifyEmailData(data) {
|
|
27
|
+
const userId = data?.userId;
|
|
28
|
+
const verifyUrl = data?.verifyUrl;
|
|
29
|
+
|
|
30
|
+
if (!userId || !verifyUrl) {
|
|
31
|
+
throw new ValidationError(
|
|
32
|
+
"userId and verifyUrl are required for SEND_VERIFY_EMAIL job",
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { userId, verifyUrl };
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
export function requireUserEmailRecord(userId, userRecord) {
|
|
27
40
|
if (!userRecord?.email) {
|
|
28
41
|
throw new AppError(
|