@usequark/quark-create-app 1.25.16 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -8
- package/package.json +1 -1
- package/src/index.js +75 -30
- package/templates/base-project/apps/web/.env.example +10 -0
- package/templates/base-project/apps/web/src/app/api/auth/apple/route.js +24 -0
- package/templates/base-project/apps/web/src/app/api/auth/forgot-password/route.js +57 -0
- package/templates/base-project/apps/web/src/app/api/auth/google/route.js +5 -1
- package/templates/base-project/apps/web/src/app/api/auth/refresh/route.js +57 -8
- package/templates/base-project/apps/web/src/app/api/auth/register/route.js +20 -1
- package/templates/base-project/apps/web/src/app/api/auth/reset-password/route.js +105 -0
- package/templates/base-project/apps/web/src/app/api/auth/verify-email/route.js +58 -0
- package/templates/base-project/apps/web/src/app/auth/forgot-password/ForgotPasswordPageClient.js +159 -0
- package/templates/base-project/apps/web/src/app/auth/forgot-password/page.js +2 -51
- package/templates/base-project/apps/web/src/app/auth/reset-password/ResetPasswordPageClient.js +280 -0
- package/templates/base-project/apps/web/src/app/auth/reset-password/page.js +9 -0
- package/templates/base-project/apps/web/src/app/auth/signin/SignInPageClient.js +22 -0
- package/templates/base-project/apps/web/src/lib/auth-email-verification.js +5 -0
- package/templates/base-project/apps/web/src/lib/auth.js +31 -10
- package/templates/base-project/apps/web/src/lib/enqueue-email-job.js +67 -0
- package/templates/base-project/apps/web/src/lib/enqueue-reset-password-email.js +33 -0
- package/templates/base-project/apps/web/src/lib/enqueue-verification-email.js +25 -0
- package/templates/base-project/apps/web/src/lib/enqueue-welcome-email.js +13 -48
- package/templates/base-project/apps/web/src/lib/issue-verification-token.js +52 -0
- package/templates/base-project/apps/web/src/lib/proxy-auth.js +7 -0
- package/templates/base-project/apps/web/src/lib/send-reset-password-email.js +24 -0
- package/templates/base-project/apps/web/src/lib/send-verification-email.js +28 -0
- package/templates/base-project/apps/web/src/proxy.js +48 -7
- package/templates/base-project/packages/db/prisma/schema.prisma +7 -2
- package/templates/base-project/packages/db/src/queries.js +13 -0
- package/templates/base-project/packages/db/src/schemas.js +16 -6
- package/templates/config/src/environment.js +5 -0
- package/templates/config/src/load-config.js +8 -0
- package/templates/config/src/validate-env.js +5 -0
- package/templates/jobs/src/definitions.js +1 -0
- package/templates/worker/package.json +1 -0
- package/templates/worker/scripts/sync-readme.js +41 -0
- package/templates/worker/src/handlers/email-job-validation.js +13 -0
- package/templates/worker/src/handlers/email.js +39 -0
- package/templates/worker/src/handlers/index.js +2 -0
- package/templates/worker/src/readme-table.js +68 -0
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
|
|
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
|
|
193
|
-
pnpm
|
|
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
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
|
|
2097
|
+
expectedDir,
|
|
2029
2098
|
linkedFeatures,
|
|
2030
2099
|
detectedFeatures,
|
|
2031
2100
|
comparisonFeatures,
|
|
@@ -2390,35 +2459,11 @@ program
|
|
|
2390
2459
|
scope,
|
|
2391
2460
|
);
|
|
2392
2461
|
|
|
2393
|
-
//
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
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 (
|
|
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
|
|
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:
|
|
39
|
-
email:
|
|
40
|
-
name:
|
|
41
|
-
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 {
|
|
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
|
+
}
|