@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 +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/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/reset-password/route.js +5 -4
- package/templates/base-project/apps/web/src/lib/auth-email-verification.js +5 -0
- package/templates/base-project/apps/web/src/lib/auth.js +38 -10
- 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 +6 -0
- 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/worker/package.json +1 -0
- package/templates/worker/scripts/sync-readme.js +41 -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) {
|
|
@@ -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 });
|
|
@@ -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
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
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
|
|
|
@@ -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
|
-
|
|
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 (
|
|
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
|
|
101
|
-
*
|
|
102
|
-
* burst of 5 credential posts
|
|
103
|
-
* everyone for 15 minutes.
|
|
137
|
+
* `undefined` here and every caller would key on the literal "unknown" — one
|
|
138
|
+
* bucket shared by every client of a route (the bucket key is `${ip}:${path}`).
|
|
139
|
+
* That is what made one burst of 5 credential posts lock out sign-in for
|
|
140
|
+
* everyone for 15 minutes, which is why this reads the proxy headers instead.
|
|
104
141
|
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* client
|
|
142
|
+
* When `TRUST_PROXY_HEADERS` is enabled (the default) the left-most
|
|
143
|
+
* `x-forwarded-for` entry is used, falling back to `x-real-ip`; both are
|
|
144
|
+
* client-supplied unless a reverse proxy overwrites them, which is the
|
|
145
|
+
* assumption the default bakes in (see `shouldTrustProxyHeaders`). With the
|
|
146
|
+
* flag off both headers are ignored and every request resolves to "unknown".
|
|
108
147
|
*
|
|
109
148
|
* @param {import("next/server").NextRequest} request
|
|
110
149
|
* @returns {string}
|
|
111
150
|
*/
|
|
112
151
|
function getClientIp(request) {
|
|
152
|
+
if (!shouldTrustProxyHeaders()) return "unknown";
|
|
153
|
+
|
|
113
154
|
const forwardedFor = request.headers.get("x-forwarded-for");
|
|
114
155
|
if (forwardedFor) {
|
|
115
156
|
// Left-most entry is the originating client; later entries are proxies.
|
|
@@ -112,8 +112,13 @@ model Job {
|
|
|
112
112
|
createdAt DateTime @default(now())
|
|
113
113
|
updatedAt DateTime @updatedAt
|
|
114
114
|
|
|
115
|
-
// (status, runAt) serves status-only
|
|
116
|
-
//
|
|
115
|
+
// (status, runAt) serves status-only and (status, runAt) queries
|
|
116
|
+
// through its leftmost prefix. It does not index runAt alone: a
|
|
117
|
+
// runAt-only range query cannot use it, so add a single-column
|
|
118
|
+
// [runAt] index if such a query appears. The shipped code has none
|
|
119
|
+
// (BullMQ schedules in Redis; this model is an audit trail), so the
|
|
120
|
+
// single-column [status] and [runAt] indexes were redundant for the
|
|
121
|
+
// query patterns in use and were removed.
|
|
117
122
|
@@index([queue])
|
|
118
123
|
@@index([status, runAt])
|
|
119
124
|
@@index([createdAt])
|
|
@@ -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,
|
|
@@ -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
|
+
}
|