@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
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.25.16",
3
+ "version": "1.27.0",
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) {
@@ -0,0 +1,57 @@
1
+ import {
2
+ createLogger,
3
+ validateBody,
4
+ withCsrfProtection,
5
+ } from "@usequark/quark-core/core";
6
+ import { user } from "@usequark/quark-db";
7
+ import { NextResponse } from "next/server";
8
+ import { z } from "zod";
9
+ import { sendResetPasswordEmail } from "@/lib/send-reset-password-email";
10
+ import { handleError } from "../../error-handler";
11
+
12
+ const logger = createLogger("auth:forgot-password");
13
+
14
+ const forgotPasswordSchema = z.object({
15
+ email: z.string().email("Invalid email address"),
16
+ });
17
+
18
+ /**
19
+ * The response for every request, registered address or not.
20
+ *
21
+ * Replying differently for unknown addresses turns this route into an
22
+ * email-enumeration oracle: the difference between "no such user" and "check
23
+ * your inbox" is exactly the information a spammer or a spear-phisher is
24
+ * probing for. The same 200 with the same body is also what the rate limiter
25
+ * needs — a distinguishable error path would let an attacker distinguish
26
+ * "limited" from "not a valid address" from the outside.
27
+ */
28
+ const NEUTRAL_RESPONSE = {
29
+ message: "If that email address has an account, a reset link is on its way.",
30
+ };
31
+
32
+ export const POST = withCsrfProtection(async (request) => {
33
+ try {
34
+ const { email } = await validateBody(request, forgotPasswordSchema);
35
+
36
+ const account = await user.findByEmail(email);
37
+
38
+ if (account) {
39
+ try {
40
+ await sendResetPasswordEmail(account);
41
+ } catch (error) {
42
+ // The address exists; the answer must still be the neutral one. A
43
+ // failure here is operational (token issue or queue down), so it is
44
+ // logged where an operator can see it and deliberately not surfaced
45
+ // to the caller.
46
+ logger.error("Failed to send password reset email", {
47
+ userId: account.id,
48
+ message: error?.message ?? String(error),
49
+ });
50
+ }
51
+ }
52
+
53
+ return NextResponse.json(NEUTRAL_RESPONSE);
54
+ } catch (error) {
55
+ return handleError(error);
56
+ }
57
+ });
@@ -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 });
@@ -1,11 +1,18 @@
1
1
  import { hashPassword } from "@usequark/quark-core/auth";
2
- import { validateBody, withCsrfProtection } from "@usequark/quark-core/core";
2
+ import {
3
+ createLogger,
4
+ validateBody,
5
+ withCsrfProtection,
6
+ } from "@usequark/quark-core/core";
3
7
  import { user, userRegisterSchema } from "@usequark/quark-db";
4
8
  import { NextResponse } from "next/server";
5
9
  import { isSignupEnabled } from "@/lib/auth-signup";
6
10
  import { enqueueWelcomeEmail } from "@/lib/enqueue-welcome-email";
11
+ import { sendVerificationEmail } from "@/lib/send-verification-email";
7
12
  import { handleError } from "../../error-handler";
8
13
 
14
+ const logger = createLogger("auth:register");
15
+
9
16
  export const POST = withCsrfProtection(async (request) => {
10
17
  if (!isSignupEnabled()) {
11
18
  return NextResponse.json(
@@ -51,6 +58,18 @@ export const POST = withCsrfProtection(async (request) => {
51
58
  // Non-critical - user is created even if email fails to enqueue
52
59
  }
53
60
 
61
+ // Issue and enqueue the email-verification link. Equally non-fatal: the
62
+ // account exists either way, and an unverified address is not (yet) a
63
+ // gated account — it just has no confirmation on file.
64
+ try {
65
+ await sendVerificationEmail(newUser);
66
+ } catch (error) {
67
+ logger.error("Failed to send verification email", {
68
+ userId: newUser.id,
69
+ message: error?.message ?? String(error),
70
+ });
71
+ }
72
+
54
73
  return NextResponse.json(safeUser, { status: 201 });
55
74
  } catch (error) {
56
75
  return handleError(error);
@@ -0,0 +1,105 @@
1
+ import { hashPassword, hashToken } from "@usequark/quark-core/auth";
2
+ import { validateBody, withCsrfProtection } from "@usequark/quark-core/core";
3
+ import { ValidationError } from "@usequark/quark-core/errors";
4
+ import { passwordSchema, user, verificationToken } from "@usequark/quark-db";
5
+ import { NextResponse } from "next/server";
6
+ import { z } from "zod";
7
+ import { handleError } from "../../error-handler";
8
+
9
+ const resetPasswordSchema = z.object({
10
+ token: z.string().min(1, "A reset token is required"),
11
+ password: passwordSchema,
12
+ });
13
+
14
+ /**
15
+ * The message used for every refused reset: unknown token, expired token, or
16
+ * account gone. An attacker probing a stolen link learns nothing from which
17
+ * wording comes back, because they are the same.
18
+ */
19
+ const UNUSABLE_LINK_MESSAGE =
20
+ "This reset link is invalid or has expired. Request a new one.";
21
+
22
+ /**
23
+ * Loads the token row for a presented token, or null when there is none usable.
24
+ *
25
+ * The row is found by the SHA-256 digest, never by the raw value — that is the
26
+ * entire point of storing the digest in the first place. Expiry is checked
27
+ * here so both GET and POST agree on what "usable" means, and so a consumed
28
+ * (deleted) token is indistinguishable from one that never existed.
29
+ *
30
+ * @param {string | null} rawToken - The token from the link's query string.
31
+ * @returns {Promise<object | null>} The `VerificationToken` row, or null.
32
+ */
33
+ async function resolveToken(rawToken) {
34
+ if (!rawToken) return null;
35
+
36
+ const record = await verificationToken.findByToken(hashToken(rawToken));
37
+ if (!record) return null;
38
+ if (record.expires.getTime() <= Date.now()) return null;
39
+
40
+ return record;
41
+ }
42
+
43
+ /**
44
+ * Answers whether a link is still usable, so the reset page can render the form
45
+ * or an error before the user types a new password into a dead form.
46
+ *
47
+ * GET is safe (no state change) and so is not CSRF-protected; the lookup is a
48
+ * single indexed primary-key read.
49
+ */
50
+ export async function GET(request) {
51
+ try {
52
+ const token = new URL(request.url).searchParams.get("token");
53
+ const record = await resolveToken(token);
54
+
55
+ return NextResponse.json(
56
+ { valid: Boolean(record) },
57
+ { status: record ? 200 : 400 },
58
+ );
59
+ } catch (error) {
60
+ return handleError(error);
61
+ }
62
+ }
63
+
64
+ export const POST = withCsrfProtection(async (request) => {
65
+ try {
66
+ const { token, password } = await validateBody(
67
+ request,
68
+ resetPasswordSchema,
69
+ );
70
+ const record = await resolveToken(token);
71
+
72
+ if (!record) {
73
+ throw new ValidationError(UNUSABLE_LINK_MESSAGE);
74
+ }
75
+
76
+ const account = await user.findByEmail(record.identifier);
77
+ if (!account) {
78
+ // The account was deleted after the link was issued. The link is dead
79
+ // either way; burn the token so it cannot be tried a second time.
80
+ await verificationToken.deleteByIdentifier(record.identifier);
81
+ throw new ValidationError(UNUSABLE_LINK_MESSAGE);
82
+ }
83
+
84
+ const hashedPassword = await hashPassword(password);
85
+ await user.update(account.id, {
86
+ password: hashedPassword,
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.
92
+ passwordChangedAt: new Date(),
93
+ });
94
+
95
+ // Single-use: the token dies with the reset it performed. Replaying the
96
+ // same link must not overwrite the new password with another.
97
+ await verificationToken.deleteByIdentifier(account.email);
98
+
99
+ return NextResponse.json({
100
+ message: "Your password has been updated. You can now sign in.",
101
+ });
102
+ } catch (error) {
103
+ return handleError(error);
104
+ }
105
+ });
@@ -0,0 +1,58 @@
1
+ import { getAppUrl } from "@usequark/quark-config";
2
+ import { hashToken } from "@usequark/quark-core/auth";
3
+ import { createLogger } from "@usequark/quark-core/core";
4
+ import { user, verificationToken } from "@usequark/quark-db";
5
+ import { NextResponse } from "next/server";
6
+
7
+ const logger = createLogger("auth:verify-email");
8
+
9
+ /**
10
+ * Redeems an emailed verification link.
11
+ *
12
+ * A GET performs the state change because that is the shape of an emailed
13
+ * link — a mail client fetches it by navigation, not by posting JSON. The
14
+ * consequence is a bearer link: whoever holds it can confirm the address, so
15
+ * the same threat model as the reset flow applies — the token is 256 bits of
16
+ * randomness, stored only as a SHA-256 digest, and single-use.
17
+ *
18
+ * Outcomes funnel into the sign-in page through `?verified=`:
19
+ * - `1` - address confirmed (or already confirmed)
20
+ * - `invalid` - no token, expired, consumed, or the account is gone
21
+ * - `error` - something broke server-side; logged, and the caller still
22
+ * lands somewhere useful rather than on a bare 500
23
+ */
24
+ export async function GET(request) {
25
+ const base = getAppUrl();
26
+
27
+ try {
28
+ const token = new URL(request.url).searchParams.get("token");
29
+ const record = token
30
+ ? await verificationToken.findByToken(hashToken(token))
31
+ : null;
32
+
33
+ if (!record || record.expires.getTime() <= Date.now()) {
34
+ return NextResponse.redirect(`${base}/auth/signin?verified=invalid`);
35
+ }
36
+
37
+ const account = await user.findByEmail(record.identifier);
38
+ if (!account) {
39
+ // The account is gone; the confirmation would be for nobody. Burn the
40
+ // token and refuse rather than leaving a live link sitting in an
41
+ // inbox.
42
+ await verificationToken.deleteByIdentifier(record.identifier);
43
+ return NextResponse.redirect(`${base}/auth/signin?verified=invalid`);
44
+ }
45
+
46
+ if (!account.emailVerified) {
47
+ await user.update(account.id, { emailVerified: new Date() });
48
+ }
49
+ await verificationToken.deleteByIdentifier(account.email);
50
+
51
+ return NextResponse.redirect(`${base}/auth/signin?verified=1`);
52
+ } catch (error) {
53
+ logger.error("Email verification failed", {
54
+ message: error?.message ?? String(error),
55
+ });
56
+ return NextResponse.redirect(`${base}/auth/signin?verified=error`);
57
+ }
58
+ }