@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.
Files changed (40) hide show
  1. package/README.md +6 -8
  2. package/package.json +1 -1
  3. package/src/index.js +75 -30
  4. package/templates/base-project/apps/web/.env.example +10 -0
  5. package/templates/base-project/apps/web/src/app/api/auth/apple/route.js +24 -0
  6. package/templates/base-project/apps/web/src/app/api/auth/forgot-password/route.js +57 -0
  7. package/templates/base-project/apps/web/src/app/api/auth/google/route.js +5 -1
  8. package/templates/base-project/apps/web/src/app/api/auth/refresh/route.js +57 -8
  9. package/templates/base-project/apps/web/src/app/api/auth/register/route.js +20 -1
  10. package/templates/base-project/apps/web/src/app/api/auth/reset-password/route.js +105 -0
  11. package/templates/base-project/apps/web/src/app/api/auth/verify-email/route.js +58 -0
  12. package/templates/base-project/apps/web/src/app/auth/forgot-password/ForgotPasswordPageClient.js +159 -0
  13. package/templates/base-project/apps/web/src/app/auth/forgot-password/page.js +2 -51
  14. package/templates/base-project/apps/web/src/app/auth/reset-password/ResetPasswordPageClient.js +280 -0
  15. package/templates/base-project/apps/web/src/app/auth/reset-password/page.js +9 -0
  16. package/templates/base-project/apps/web/src/app/auth/signin/SignInPageClient.js +22 -0
  17. package/templates/base-project/apps/web/src/lib/auth-email-verification.js +5 -0
  18. package/templates/base-project/apps/web/src/lib/auth.js +31 -10
  19. package/templates/base-project/apps/web/src/lib/enqueue-email-job.js +67 -0
  20. package/templates/base-project/apps/web/src/lib/enqueue-reset-password-email.js +33 -0
  21. package/templates/base-project/apps/web/src/lib/enqueue-verification-email.js +25 -0
  22. package/templates/base-project/apps/web/src/lib/enqueue-welcome-email.js +13 -48
  23. package/templates/base-project/apps/web/src/lib/issue-verification-token.js +52 -0
  24. package/templates/base-project/apps/web/src/lib/proxy-auth.js +7 -0
  25. package/templates/base-project/apps/web/src/lib/send-reset-password-email.js +24 -0
  26. package/templates/base-project/apps/web/src/lib/send-verification-email.js +28 -0
  27. package/templates/base-project/apps/web/src/proxy.js +48 -7
  28. package/templates/base-project/packages/db/prisma/schema.prisma +7 -2
  29. package/templates/base-project/packages/db/src/queries.js +13 -0
  30. package/templates/base-project/packages/db/src/schemas.js +16 -6
  31. package/templates/config/src/environment.js +5 -0
  32. package/templates/config/src/load-config.js +8 -0
  33. package/templates/config/src/validate-env.js +5 -0
  34. package/templates/jobs/src/definitions.js +1 -0
  35. package/templates/worker/package.json +1 -0
  36. package/templates/worker/scripts/sync-readme.js +41 -0
  37. package/templates/worker/src/handlers/email-job-validation.js +13 -0
  38. package/templates/worker/src/handlers/email.js +39 -0
  39. package/templates/worker/src/handlers/index.js +2 -0
  40. 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
- * Queue and job name for the signup welcome email.
3
- *
4
- * These mirror `JOB_QUEUES.EMAIL` and `JOB_NAMES.SEND_WELCOME_EMAIL` in the
5
- * `@<scope>/jobs` package — an *optional* scaffold package. Importing them from
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
- * Loads the queue module on demand.
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
- * @returns {Promise<typeof import("@usequark/quark-core/queue")>}
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
- function loadQueueModule() {
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
- * How to obtain the queue module. Defaults to the dynamic import above;
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 = loadQueueModule) {
52
- const { createQueue } = await loadQueue();
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 collapsed onto the literal key
101
- * "unknown" — one shared bucket for the whole server. That let a single
102
- * burst of 5 credential posts (the `auth` bucket) lock out sign-in for
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
- * Trust `x-forwarded-for`/`x-real-ip` only when you are actually behind a
106
- * reverse proxy (Railway, Docker, nginx) that overwrites them; otherwise a
107
- * client can spoof them to evade the limiter.
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 queries through its leftmost prefix;
116
- // single-column [status] and [runAt] indexes were removed as redundant.
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: z
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,
@@ -6,6 +6,7 @@ export const JOB_QUEUES = {
6
6
 
7
7
  export const JOB_NAMES = {
8
8
  SEND_WELCOME_EMAIL: "send-welcome-email",
9
+ SEND_VERIFY_EMAIL: "send-verify-email",
9
10
  SEND_RESET_PASSWORD_EMAIL: "send-reset-password-email",
10
11
  CLEANUP_ORPHANED_FILES: "cleanup-orphaned-files",
11
12
  };
@@ -15,6 +15,7 @@
15
15
  "dev": "tsx watch src/index.js",
16
16
  "start:deploy": "node src/index.js",
17
17
  "preflight": "node src/index.js --preflight",
18
+ "sync-readme": "node scripts/sync-readme.js",
18
19
  "lint": "biome format --write && biome check --write"
19
20
  },
20
21
  "keywords": [],
@@ -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(