@usequark/quark-create-app 1.26.0 → 1.27.1

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 CHANGED
@@ -48,7 +48,7 @@ npx @usequark/quark-create-app@latest my-app
48
48
  npx @usequark/quark-create-app add jobs
49
49
 
50
50
  # Print an embedded skill for a feature
51
- npx @usequark/quark-create-app skill bookings
51
+ npx @usequark/quark-create-app skill model
52
52
 
53
53
  # Update Quark packages in an existing project
54
54
  npx @usequark/quark-create-app update
@@ -95,7 +95,7 @@ npx @usequark/quark-create-app@latest my-app --no-prompts
95
95
 
96
96
  ### Packages
97
97
 
98
- Specify which optional packages to include (default: `ui,jobs`; valid: `ui`, `jobs`):
98
+ Specify which optional packages to include (default: `ui,jobs`; valid: `ui`, `jobs`, `pwa` — `mobile` is added post-creation via `quark add mobile`):
99
99
 
100
100
  ```bash
101
101
  # Only include UI package
@@ -131,16 +131,13 @@ npx @usequark/quark-create-app@latest my-app --no-prompts --signup disabled
131
131
  Print an embedded skill for a feature:
132
132
 
133
133
  ```bash
134
- # Print the bookings skill
135
- npx @usequark/quark-create-app@latest skill bookings
136
-
137
134
  # Print a core skill (model, endpoint, dashboard)
138
135
  npx @usequark/quark-create-app@latest skill model
139
136
  npx @usequark/quark-create-app@latest skill endpoint
140
137
  npx @usequark/quark-create-app@latest skill dashboard
141
138
  ```
142
139
 
143
- Available skills: `model`, `endpoint`, `dashboard`, `bookings`, `crm`, `cms`, `ai`.
140
+ Available skills: `model`, `endpoint`, `dashboard` (core workflows), plus the embedded domain skills shipped with every project (`payment`, `ecommerce` incl. catalog/cart/checkout, `i18n`). Domain verticals (`bookings`, `crm`, `cms`, `ai`) are built on demand via those workflows, not printed as named skills.
144
141
 
145
142
  ### Skip Installation Steps
146
143
 
@@ -189,8 +186,9 @@ npx @usequark/quark-create-app@latest my-app \
189
186
  # Lightweight template checks
190
187
  pnpm test
191
188
 
192
- # E2E scaffold simulation
193
- pnpm test:e2e
189
+ # Full lifecycle E2E (scaffold -> build -> Docker -> migrate -> run). Phase 3
190
+ # runs `pnpm build`, so a scaffolded import that does not compile fails here.
191
+ pnpm test:e2e:full
194
192
 
195
193
  # Full build verification (opt-in)
196
194
  QUARK_CLI_BUILD_TEST=1 pnpm test:build
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequark/quark-create-app",
3
- "version": "1.26.0",
3
+ "version": "1.27.1",
4
4
  "description": "CLI to scaffold new Quark projects with Next.js, Prisma, BullMQ, and Railway deployment",
5
5
  "keywords": [
6
6
  "quark",
package/src/index.js CHANGED
@@ -1363,6 +1363,13 @@ STORAGE_PROVIDER=local
1363
1363
  # RATE_LIMIT_WINDOW_MS=900000 # Window in ms (default: 15 minutes)
1364
1364
  # API_BODY_SIZE_LIMIT=2097152 # 2MB (default)
1365
1365
  # UPLOAD_SIZE_LIMIT=10485760 # 10MB proxy-level limit (default)
1366
+ # Rate-limit buckets key on <client-ip>:<path>, the client IP taken from the
1367
+ # proxy headers (left-most x-forwarded-for, then x-real-ip). Default (unset)
1368
+ # trusts those headers - the right choice behind a reverse proxy (Railway/
1369
+ # Docker/nginx) that overwrites them. Set to false when the app is exposed
1370
+ # directly, so a spoofed header cannot earn a fresh bucket; every client then
1371
+ # shares one bucket per route.
1372
+ # TRUST_PROXY_HEADERS=true
1366
1373
 
1367
1374
  # --- Logging & Cache ---
1368
1375
  # LOG_LEVEL=debug # debug, info, warn, error (default: debug dev, info prod)
@@ -1936,6 +1943,58 @@ async function compareScaffoldManagedFiles(expectedDir, projectDir) {
1936
1943
  };
1937
1944
  }
1938
1945
 
1946
+ /**
1947
+ * Apply the mobile-specific edits `add mobile` makes to scaffold-managed ROOT
1948
+ * files.
1949
+ *
1950
+ * `mobile` is a post-creation-only feature: `create` rejects it, so the
1951
+ * reference scaffold `update --scaffold-check` builds can never carry these
1952
+ * edits. Replaying them on the reference when the project has mobile installed
1953
+ * scopes the byte comparison by installed feature instead of reporting the
1954
+ * expected `EXPO_PUBLIC_API_URL` block as drift.
1955
+ *
1956
+ * This does not weaken drift detection: the edits are constants, so tampering
1957
+ * with — or deleting — the mobile block in a project still differs from the
1958
+ * reference and is still reported.
1959
+ *
1960
+ * Idempotent, and only writes a file when it actually changes it.
1961
+ *
1962
+ * @param {string} projectDir
1963
+ */
1964
+ async function applyMobileScaffoldEdits(projectDir) {
1965
+ const envExamplePath = path.join(projectDir, ".env.example");
1966
+ if (await fs.pathExists(envExamplePath)) {
1967
+ const envContent = await fs.readFile(envExamplePath, "utf-8");
1968
+ if (!envContent.includes("EXPO_PUBLIC_API_URL")) {
1969
+ await fs.writeFile(
1970
+ envExamplePath,
1971
+ `${envContent}\n# --- Mobile App ---\nEXPO_PUBLIC_API_URL=http://localhost:3000\n`,
1972
+ );
1973
+ }
1974
+ }
1975
+
1976
+ const rootPkgPath = path.join(projectDir, "package.json");
1977
+ if (!(await fs.pathExists(rootPkgPath))) return;
1978
+
1979
+ const scope = await detectProjectScope(projectDir);
1980
+ if (!scope) return;
1981
+
1982
+ const rootPkg = await fs.readJSON(rootPkgPath);
1983
+ rootPkg.scripts = rootPkg.scripts || {};
1984
+ let changed = false;
1985
+ if (!rootPkg.scripts["dev:mobile"]) {
1986
+ rootPkg.scripts["dev:mobile"] = `pnpm --filter @${scope}/mobile start`;
1987
+ changed = true;
1988
+ }
1989
+ if (!rootPkg.scripts["build:mobile"]) {
1990
+ rootPkg.scripts["build:mobile"] = `pnpm --filter @${scope}/mobile export`;
1991
+ changed = true;
1992
+ }
1993
+ if (changed) {
1994
+ await fs.writeFile(rootPkgPath, `${JSON.stringify(rootPkg, null, "\t")}\n`);
1995
+ }
1996
+ }
1997
+
1939
1998
  async function createReferenceScaffold(projectDir, quarkLink) {
1940
1999
  const projectName = quarkLink.projectName || path.basename(projectDir);
1941
2000
  const linkedFeatures = resolveFeatureSelection(
@@ -2023,9 +2082,19 @@ async function createReferenceScaffold(projectDir, quarkLink) {
2023
2082
  );
2024
2083
  }
2025
2084
 
2085
+ const expectedDir = path.join(tempRoot, projectName);
2086
+
2087
+ // `mobile` is post-creation only, so the subprocess above can never build its
2088
+ // root edits (see scaffoldFeatures). Replay them onto the reference when the
2089
+ // project has mobile installed so the mobile block reads as expected, not as
2090
+ // drift.
2091
+ if (comparisonFeatures.includes("mobile")) {
2092
+ await applyMobileScaffoldEdits(expectedDir);
2093
+ }
2094
+
2026
2095
  return {
2027
2096
  tempRoot,
2028
- expectedDir: path.join(tempRoot, projectName),
2097
+ expectedDir,
2029
2098
  linkedFeatures,
2030
2099
  detectedFeatures,
2031
2100
  comparisonFeatures,
@@ -2390,35 +2459,11 @@ program
2390
2459
  scope,
2391
2460
  );
2392
2461
 
2393
- // Add EXPO_PUBLIC_API_URL to .env.example
2394
- const envExamplePath = path.join(projectDir, ".env.example");
2395
- if (await fs.pathExists(envExamplePath)) {
2396
- let envContent = await fs.readFile(envExamplePath, "utf-8");
2397
- if (!envContent.includes("EXPO_PUBLIC_API_URL")) {
2398
- envContent +=
2399
- "\n# --- Mobile App ---\nEXPO_PUBLIC_API_URL=http://localhost:3000\n";
2400
- await fs.writeFile(envExamplePath, envContent);
2401
- }
2402
- }
2403
-
2404
- // Add mobile scripts to root package.json
2405
- const rootPkgPath = path.join(projectDir, "package.json");
2406
- if (await fs.pathExists(rootPkgPath)) {
2407
- const rootPkg = await fs.readJSON(rootPkgPath);
2408
- rootPkg.scripts = rootPkg.scripts || {};
2409
- if (!rootPkg.scripts["dev:mobile"]) {
2410
- rootPkg.scripts["dev:mobile"] =
2411
- `pnpm --filter @${scope}/mobile start`;
2412
- }
2413
- if (!rootPkg.scripts["build:mobile"]) {
2414
- rootPkg.scripts["build:mobile"] =
2415
- `pnpm --filter @${scope}/mobile export`;
2416
- }
2417
- await fs.writeFile(
2418
- rootPkgPath,
2419
- `${JSON.stringify(rootPkg, null, "\t")}\n`,
2420
- );
2421
- }
2462
+ // Mobile-specific root edits (`.env.example` +
2463
+ // dev:mobile/build:mobile scripts). Shared with the reference
2464
+ // scaffold so `update --scaffold-check` can reproduce them —
2465
+ // see applyMobileScaffoldEdits.
2466
+ await applyMobileScaffoldEdits(projectDir);
2422
2467
 
2423
2468
  console.log(
2424
2469
  chalk.green(" ✓ Mobile app scaffolded (apps/mobile)"),
@@ -102,6 +102,16 @@ ADMIN_PASSWORD=SET_ME
102
102
  # Leave unset in development - the endpoint is unprotected by default.
103
103
  # METRICS_TOKEN=replace_with_a_long_random_secret
104
104
 
105
+ # --- Rate Limiting & Security ---
106
+ # Rate-limit buckets are keyed on the client IP taken from the proxy headers
107
+ # (left-most x-forwarded-for, then x-real-ip). Proxy headers are client-supplied
108
+ # and only an identity when a reverse proxy overwrites them on every request.
109
+ # Default (unset) trusts them - correct behind Railway/Docker/nginx.
110
+ # Set to false when the app is reachable directly: the headers are then ignored
111
+ # and every client shares one bucket per route, so a spoofed x-forwarded-for
112
+ # cannot earn a fresh bucket. Boolean-like: true/false/1/0/yes/no/on/off.
113
+ # TRUST_PROXY_HEADERS=true
114
+
105
115
  # --- File Storage ---
106
116
  # Default: local filesystem (./uploads). No further config needed for development.
107
117
  STORAGE_PROVIDER=local
@@ -5,6 +5,8 @@ import { user } from "@usequark/quark-db";
5
5
  import { importJWK, jwtVerify } from "jose";
6
6
  import { NextResponse } from "next/server";
7
7
  import { z } from "zod";
8
+ import { isEmailVerificationRequired } from "../../../../lib/auth-email-verification";
9
+ import { isEmailVerified } from "../../../../lib/email-verified";
8
10
  import { issueTokenPair } from "../../../../lib/jwt";
9
11
  import { handleError } from "../../error-handler";
10
12
 
@@ -122,6 +124,28 @@ export async function POST(request) {
122
124
  );
123
125
  }
124
126
 
127
+ // An unverified address proves nothing about who controls the inbox, and
128
+ // this route creates the account on first sight — so without this check the
129
+ // confirmation that is supposed to establish ownership of the address is
130
+ // the attacker's to skip rather than the victim's to perform.
131
+ //
132
+ // It sits after the missing-email `400` on purpose, matching the Google
133
+ // route's ordering: that response describes the shape of the token rather
134
+ // than reporting that the token was refused. What matters for security is
135
+ // the position relative to the database: the check lands before
136
+ // `findByEmail`, so an unverified address can never become a registration
137
+ // or existence oracle. Governed by `REQUIRE_EMAIL_VERIFICATION` like every
138
+ // other sign-in path — `false` restores the pre-gate behaviour.
139
+ if (
140
+ isEmailVerificationRequired() &&
141
+ !isEmailVerified(payload.email_verified)
142
+ ) {
143
+ return NextResponse.json(
144
+ { message: "Invalid Apple token" },
145
+ { status: 401 },
146
+ );
147
+ }
148
+
125
149
  // Find or create user
126
150
  let existingUser = await user.findByEmail(appleEmail);
127
151
  if (!existingUser) {
@@ -6,6 +6,7 @@ import { validateBody } from "@usequark/quark-core/core";
6
6
  import { user } from "@usequark/quark-db";
7
7
  import { NextResponse } from "next/server";
8
8
  import { z } from "zod";
9
+ import { isEmailVerificationRequired } from "../../../../lib/auth-email-verification";
9
10
  import { isEmailVerified } from "../../../../lib/email-verified";
10
11
  import { issueTokenPair } from "../../../../lib/jwt";
11
12
  import { handleError } from "../../error-handler";
@@ -85,7 +86,10 @@ export async function POST(request) {
85
86
  // read for what it is. What matters for security is the position relative
86
87
  // to the database: both checks land before `findByEmail`, so an unverified
87
88
  // address can never become a registration or existence oracle.
88
- if (!isEmailVerified(tokenInfo.email_verified)) {
89
+ if (
90
+ isEmailVerificationRequired() &&
91
+ !isEmailVerified(tokenInfo.email_verified)
92
+ ) {
89
93
  return NextResponse.json(
90
94
  { message: "Invalid Google token" },
91
95
  { status: 401 },
@@ -1,4 +1,5 @@
1
1
  import { createLogger, validateBody } from "@usequark/quark-core/core";
2
+ import { user as userQueries } from "@usequark/quark-db";
2
3
  import { NextResponse } from "next/server";
3
4
  import { z } from "zod";
4
5
  import { issueTokenPair, verifyMobileToken } from "../../../../lib/jwt";
@@ -10,9 +11,29 @@ const refreshRequestSchema = z.object({
10
11
  refreshToken: z.string().min(1),
11
12
  });
12
13
 
14
+ /**
15
+ * The one response every refused refresh gets.
16
+ *
17
+ * A deleted account, a password reset, a forged signature and an expired token
18
+ * are indistinguishable to the caller: they share a status and a body, so a
19
+ * probing client cannot learn which check failed.
20
+ */
21
+ function invalidRefreshToken() {
22
+ return NextResponse.json(
23
+ { message: "Invalid or expired refresh token" },
24
+ { status: 401 },
25
+ );
26
+ }
27
+
13
28
  /**
14
29
  * POST /api/auth/refresh
15
30
  * Exchange a refresh token for a new access token.
31
+ *
32
+ * A refresh token lives for 30 days, so the identity it was minted with cannot
33
+ * be trusted for that long. This re-reads the current id, email, name and role
34
+ * from the user's row on every exchange — the same revocation contract
35
+ * `validateSessionToken` applies to web sessions — and refuses the exchange
36
+ * when the account is gone or its password changed after the token was issued.
16
37
  */
17
38
  export async function POST(request) {
18
39
  try {
@@ -21,10 +42,7 @@ export async function POST(request) {
21
42
  const payload = await verifyMobileToken(refreshToken);
22
43
  if (!payload) {
23
44
  logger.warn("Invalid refresh token");
24
- return NextResponse.json(
25
- { message: "Invalid or expired refresh token" },
26
- { status: 401 },
27
- );
45
+ return invalidRefreshToken();
28
46
  }
29
47
 
30
48
  if (payload.type !== "refresh") {
@@ -34,11 +52,42 @@ export async function POST(request) {
34
52
  );
35
53
  }
36
54
 
55
+ // A genuine refresh token always carries `sub`; refuse anything else
56
+ // rather than handing `undefined` to the query layer.
57
+ if (typeof payload.sub !== "string" || payload.sub.length === 0) {
58
+ logger.warn("Refresh token has no subject");
59
+ return invalidRefreshToken();
60
+ }
61
+
62
+ const account = await userQueries.findById(payload.sub);
63
+ if (!account) {
64
+ // Deleted (or otherwise removed) between issue and exchange. Without
65
+ // this read the route would keep minting access tokens for a ghost.
66
+ logger.warn("Refresh token for an account that no longer exists");
67
+ return invalidRefreshToken();
68
+ }
69
+
70
+ // Mirrors `validateSessionToken`: a password reset stamps
71
+ // `passwordChangedAt`, and any token issued before that instant is dead.
72
+ // `iat` is whole seconds; comparing against the millisecond stamp as a
73
+ // fraction keeps a change in the same second from leaking a token.
74
+ if (
75
+ account.passwordChangedAt &&
76
+ typeof payload.iat === "number" &&
77
+ account.passwordChangedAt.getTime() / 1000 > payload.iat
78
+ ) {
79
+ logger.warn("Refresh token issued before the password changed");
80
+ return invalidRefreshToken();
81
+ }
82
+
83
+ // The claims come from the row, never from the token: a 30-day-old
84
+ // `role` (or any stale profile field) must not be re-stamped onto a fresh
85
+ // access token.
37
86
  const { token } = await issueTokenPair({
38
- id: payload.sub,
39
- email: payload.email,
40
- name: payload.name,
41
- role: payload.role,
87
+ id: account.id,
88
+ email: account.email,
89
+ name: account.name,
90
+ role: account.role,
42
91
  });
43
92
 
44
93
  return NextResponse.json({ token });
@@ -84,10 +84,11 @@ export const POST = withCsrfProtection(async (request) => {
84
84
  const hashedPassword = await hashPassword(password);
85
85
  await user.update(account.id, {
86
86
  password: hashedPassword,
87
- // Stamping the change is what reaches every live session (web JWT and
88
- // mobile refresh token): `validateSessionToken` and the refresh route
89
- // reject anything issued before this instant, so a password known to
90
- // an attacker stops working the moment it is replaced.
87
+ // Stamping the change is what reaches every live session even though
88
+ // both are self-contained tokens: `validateSessionToken` refuses a web
89
+ // JWT issued before this instant, and `POST /api/auth/refresh` refuses
90
+ // a mobile refresh token issued before it — so a password known to an
91
+ // attacker stops working the moment it is replaced.
91
92
  passwordChangedAt: new Date(),
92
93
  });
93
94
 
@@ -0,0 +1,5 @@
1
+ import { loadConfig } from "@usequark/quark-config";
2
+
3
+ export function isEmailVerificationRequired() {
4
+ return loadConfig().auth.requireEmailVerification;
5
+ }
@@ -7,6 +7,7 @@ import NextAuth from "next-auth";
7
7
  import CredentialsProvider from "next-auth/providers/credentials";
8
8
  import GithubProvider from "next-auth/providers/github";
9
9
  import GoogleProvider from "next-auth/providers/google";
10
+ import { isEmailVerificationRequired } from "./auth-email-verification";
10
11
  import { isEmailVerified } from "./email-verified";
11
12
 
12
13
  const logger = createLogger({ name: "auth" });
@@ -65,8 +66,10 @@ const providers = [
65
66
  return null;
66
67
  }
67
68
 
69
+ let existingUser;
70
+
68
71
  try {
69
- const existingUser = await userQueries.findByEmail(credentials.email);
72
+ existingUser = await userQueries.findByEmail(credentials.email);
70
73
 
71
74
  if (!existingUser?.password) {
72
75
  return null;
@@ -80,14 +83,6 @@ const providers = [
80
83
  if (!isValid) {
81
84
  return null;
82
85
  }
83
-
84
- return {
85
- id: existingUser.id,
86
- email: existingUser.email,
87
- name: existingUser.name,
88
- image: existingUser.image,
89
- role: existingUser.role,
90
- };
91
86
  } catch (err) {
92
87
  // Re-throw if the database is unreachable so Auth.js surfaces a server error
93
88
  // rather than "Invalid credentials", which misleads both user and developer.
@@ -109,6 +104,29 @@ const providers = [
109
104
  });
110
105
  return null;
111
106
  }
107
+
108
+ // Deliberately after the try/catch above. The verification refusal is a
109
+ // decision, not a failure: leaving it outside means the fail-closed
110
+ // handler cannot turn it into a bare `null` that Auth.js would report as
111
+ // "Invalid credentials". It throws the same `OAUTH_EMAIL_NOT_VERIFIED`
112
+ // error the OAuth path does, so the operator sees one reason, with one
113
+ // code, whichever provider the sign-in came through — and, as there, a
114
+ // missing or unset `emailVerified` reads as unverified.
115
+ if (isEmailVerificationRequired() && !existingUser.emailVerified) {
116
+ throw new AppError(
117
+ "Credentials sign-in refused: the email address is not verified",
118
+ 401,
119
+ "OAUTH_EMAIL_NOT_VERIFIED",
120
+ );
121
+ }
122
+
123
+ return {
124
+ id: existingUser.id,
125
+ email: existingUser.email,
126
+ name: existingUser.name,
127
+ image: existingUser.image,
128
+ role: existingUser.role,
129
+ };
112
130
  },
113
131
  }),
114
132
  ];
@@ -142,7 +160,10 @@ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
142
160
  // why in the one place an operator can read it, and lands in the same
143
161
  // catch: no session, no user row, redirect back to the sign-in page.
144
162
  profile: (profile) => {
145
- if (!isEmailVerified(profile.email_verified)) {
163
+ if (
164
+ isEmailVerificationRequired() &&
165
+ !isEmailVerified(profile.email_verified)
166
+ ) {
146
167
  throw new AppError(
147
168
  "Google sign-in refused: the account's email address is not verified",
148
169
  401,
@@ -216,6 +237,13 @@ async function validateSessionToken(token) {
216
237
 
217
238
  export function getAuthOptions() {
218
239
  return createAuthConfig({
240
+ // The adapter is load-bearing even though sessions are JWT (see
241
+ // `session.strategy` below): it persists the `Account` rows that link
242
+ // OAuth identities to users — the adapter reads and writes them, and
243
+ // `account.findByUserIdAndProvider` is covered in `packages/db`. Do
244
+ // not remove it. The `Session` model queries (`session.*` in
245
+ // `packages/db/src/queries.js`) are the opposite: app-owned helpers the
246
+ // JWT strategy never populates.
219
247
  adapter: PrismaAdapter(prisma),
220
248
  providers: providers,
221
249
  logger: {
@@ -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])
@@ -139,6 +139,12 @@ export const account = {
139
139
  };
140
140
 
141
141
  // Session queries (NextAuth)
142
+ //
143
+ // App-owned helpers, not Auth.js storage: the scaffold pins
144
+ // `session.strategy: "jwt"` (see `apps/web/src/lib/auth.js`), under which
145
+ // Auth.js neither reads nor writes session rows — revocation is enforced by
146
+ // `validateSessionToken` re-reading the user row instead. Kept for apps that
147
+ // need server-side session bookkeeping of their own.
142
148
  export const session = {
143
149
  findByToken: (sessionToken) => {
144
150
  return prisma.session.findUnique({
@@ -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,
@@ -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
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Generates the README job-handler table.
3
+ *
4
+ * The row set is driven by JOB_NAMES (the jobs package is the source of truth
5
+ * for which jobs exist), never by the registry alone: generating from
6
+ * `jobHandlers` would produce a table that always matches what is registered
7
+ * and so could never expose a job name with no handler.
8
+ */
9
+
10
+ import { AppError } from "@usequark/quark-core/errors";
11
+ import { JOB_NAMES } from "@usequark/quark-jobs";
12
+
13
+ /** Module under `src/handlers/` that defines each job's handler. */
14
+ const HANDLER_MODULES = {
15
+ [JOB_NAMES.SEND_WELCOME_EMAIL]: "email.js",
16
+ [JOB_NAMES.SEND_VERIFY_EMAIL]: "email.js",
17
+ [JOB_NAMES.SEND_RESET_PASSWORD_EMAIL]: "email.js",
18
+ [JOB_NAMES.CLEANUP_ORPHANED_FILES]: "files.js",
19
+ };
20
+
21
+ export const TABLE_START = "<!-- handler-table:start -->";
22
+ export const TABLE_END = "<!-- handler-table:end -->";
23
+
24
+ /**
25
+ * Render the handler table for JOB_NAMES, one row per job name.
26
+ * @param {Record<string, Function>} jobHandlers
27
+ * @returns {string} markdown table (marker lines excluded)
28
+ */
29
+ export function renderHandlerTable(jobHandlers) {
30
+ const rows = Object.values(JOB_NAMES).map((jobName) => {
31
+ const handler = jobHandlers[jobName];
32
+ const module = HANDLER_MODULES[jobName];
33
+ if (typeof handler !== "function" || !module) {
34
+ throw new AppError(
35
+ `No handler registration or README metadata for job: ${jobName}`,
36
+ 500,
37
+ "JOB_README_ROW_MISSING",
38
+ );
39
+ }
40
+ return `| \`${jobName}\` | \`${handler.name}\` (\`handlers/${module}\`) |`;
41
+ });
42
+ return ["| Job name | Handler |", "|---|---|", ...rows].join("\n");
43
+ }
44
+
45
+ /**
46
+ * Replace the marked table block in a README with the generated one.
47
+ * @param {string} markdown
48
+ * @param {Record<string, Function>} jobHandlers
49
+ * @returns {string} updated markdown
50
+ */
51
+ export function replaceHandlerTable(markdown, jobHandlers) {
52
+ const start = markdown.indexOf(TABLE_START);
53
+ const end = markdown.indexOf(TABLE_END);
54
+ if (start === -1 || end === -1 || end < start) {
55
+ throw new AppError(
56
+ "README is missing handler-table markers",
57
+ 500,
58
+ "JOB_README_MARKERS_MISSING",
59
+ );
60
+ }
61
+ return (
62
+ markdown.slice(0, start + TABLE_START.length) +
63
+ "\n" +
64
+ renderHandlerTable(jobHandlers) +
65
+ "\n" +
66
+ markdown.slice(end)
67
+ );
68
+ }