@usequark/quark-create-app 1.25.8 → 1.25.10

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 (30) hide show
  1. package/package.json +1 -1
  2. package/templates/base-project/apps/web/src/app/api/auth/apple/route.js +1 -1
  3. package/templates/base-project/apps/web/src/app/api/auth/google/route.js +20 -1
  4. package/templates/base-project/apps/web/src/app/api/auth/refresh/route.js +1 -1
  5. package/templates/base-project/apps/web/src/app/api/auth/register/route.js +7 -12
  6. package/templates/base-project/apps/web/src/app/api/auth/token/route.js +2 -1
  7. package/templates/base-project/apps/web/src/app/api/csrf/route.js +1 -1
  8. package/templates/base-project/apps/web/src/app/api/device/register/route.js +1 -1
  9. package/templates/base-project/apps/web/src/app/api/error-handler.js +1 -1
  10. package/templates/base-project/apps/web/src/app/api/files/[id]/route.js +2 -2
  11. package/templates/base-project/apps/web/src/app/api/files/route.js +6 -4
  12. package/templates/base-project/apps/web/src/app/api/health/route.js +4 -4
  13. package/templates/base-project/apps/web/src/app/api/users/[id]/route.js +1 -1
  14. package/templates/base-project/apps/web/src/app/api/users/me/route.js +1 -1
  15. package/templates/base-project/apps/web/src/app/api/users/route.js +1 -1
  16. package/templates/base-project/apps/web/src/lib/auth-middleware.js +1 -1
  17. package/templates/base-project/apps/web/src/lib/auth.js +29 -5
  18. package/templates/base-project/apps/web/src/lib/email-verified.js +28 -0
  19. package/templates/base-project/apps/web/src/lib/enqueue-welcome-email.js +51 -0
  20. package/templates/base-project/apps/web/src/lib/jwt.js +2 -1
  21. package/templates/base-project/apps/web/src/preflight.js +1 -1
  22. package/templates/base-project/biome.json +1 -1
  23. package/templates/base-project/packages/db/src/umami.js +1 -1
  24. package/templates/config/src/redis.js +1 -1
  25. package/templates/config/src/validate-env.js +1 -1
  26. package/templates/worker/src/handlers/email.js +2 -2
  27. package/templates/worker/src/handlers/files.js +1 -1
  28. package/templates/worker/src/index.js +4 -4
  29. package/templates/worker/src/lib/railway.js +1 -1
  30. package/templates/worker/src/lib/tokens.js +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequark/quark-create-app",
3
- "version": "1.25.8",
3
+ "version": "1.25.10",
4
4
  "description": "CLI to scaffold new Quark projects with Next.js, Prisma, BullMQ, and Railway deployment",
5
5
  "keywords": [
6
6
  "quark",
@@ -1,6 +1,6 @@
1
1
  import { createHash, timingSafeEqual } from "node:crypto";
2
2
  import { getAppleClientIds } from "@usequark/quark-config/oauth";
3
- import { validateBody } from "@usequark/quark-core";
3
+ import { validateBody } from "@usequark/quark-core/core";
4
4
  import { user } from "@usequark/quark-db";
5
5
  import { importJWK, jwtVerify } from "jose";
6
6
  import { NextResponse } from "next/server";
@@ -2,10 +2,11 @@ import {
2
2
  getGoogleClientId,
3
3
  isAudienceValid,
4
4
  } from "@usequark/quark-config/oauth";
5
- import { validateBody } from "@usequark/quark-core";
5
+ 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 { isEmailVerified } from "../../../../lib/email-verified";
9
10
  import { issueTokenPair } from "../../../../lib/jwt";
10
11
  import { handleError } from "../../error-handler";
11
12
 
@@ -73,6 +74,24 @@ export async function POST(request) {
73
74
  );
74
75
  }
75
76
 
77
+ // An unverified address proves nothing about who controls the inbox, and
78
+ // this route creates the account on first sight — so without this check the
79
+ // confirmation that is supposed to establish ownership of the address is
80
+ // the attacker's to skip rather than the victim's to perform.
81
+ //
82
+ // It sits after the missing-email `400` on purpose. That one describes the
83
+ // shape of the token rather than reporting that the token was refused, and
84
+ // putting this check ahead of it would change a response operators already
85
+ // read for what it is. What matters for security is the position relative
86
+ // to the database: both checks land before `findByEmail`, so an unverified
87
+ // address can never become a registration or existence oracle.
88
+ if (!isEmailVerified(tokenInfo.email_verified)) {
89
+ return NextResponse.json(
90
+ { message: "Invalid Google token" },
91
+ { status: 401 },
92
+ );
93
+ }
94
+
76
95
  // Find or create user
77
96
  let existingUser = await user.findByEmail(googleEmail);
78
97
  if (!existingUser) {
@@ -1,4 +1,4 @@
1
- import { createLogger, validateBody } from "@usequark/quark-core";
1
+ import { createLogger, validateBody } from "@usequark/quark-core/core";
2
2
  import { NextResponse } from "next/server";
3
3
  import { z } from "zod";
4
4
  import { issueTokenPair, verifyMobileToken } from "../../../../lib/jwt";
@@ -1,13 +1,9 @@
1
- import {
2
- createQueue,
3
- hashPassword,
4
- validateBody,
5
- withCsrfProtection,
6
- } from "@usequark/quark-core";
1
+ import { hashPassword } from "@usequark/quark-core/auth";
2
+ import { validateBody, withCsrfProtection } from "@usequark/quark-core/core";
7
3
  import { user, userRegisterSchema } from "@usequark/quark-db";
8
- import { JOB_NAMES, JOB_QUEUES } from "@usequark/quark-jobs";
9
4
  import { NextResponse } from "next/server";
10
5
  import { isSignupEnabled } from "@/lib/auth-signup";
6
+ import { enqueueWelcomeEmail } from "@/lib/enqueue-welcome-email";
11
7
  import { handleError } from "../../error-handler";
12
8
 
13
9
  export const POST = withCsrfProtection(async (request) => {
@@ -46,12 +42,11 @@ export const POST = withCsrfProtection(async (request) => {
46
42
  // Don't return the password
47
43
  const { password: _, ...safeUser } = newUser;
48
44
 
49
- // Enqueue welcome email (fire-and-forget - don't block the response)
45
+ // Enqueue welcome email (fire-and-forget - don't block the response).
46
+ // The queue module is reached through a lazily-loaded helper, so BullMQ
47
+ // stays out of this process until a registration actually happens.
50
48
  try {
51
- const emailQueue = createQueue(JOB_QUEUES.EMAIL);
52
- await emailQueue.add(JOB_NAMES.SEND_WELCOME_EMAIL, {
53
- userId: newUser.id,
54
- });
49
+ await enqueueWelcomeEmail(newUser.id);
55
50
  } catch {
56
51
  // Non-critical - user is created even if email fails to enqueue
57
52
  }
@@ -1,4 +1,5 @@
1
- import { validateBody, verifyPassword } from "@usequark/quark-core";
1
+ import { verifyPassword } from "@usequark/quark-core/auth";
2
+ import { validateBody } from "@usequark/quark-core/core";
2
3
  import { user } from "@usequark/quark-db";
3
4
  import { NextResponse } from "next/server";
4
5
  import { z } from "zod";
@@ -4,7 +4,7 @@
4
4
  * and returns it to the client for inclusion in request headers.
5
5
  */
6
6
 
7
- import { generateCsrfToken } from "@usequark/quark-core";
7
+ import { generateCsrfToken } from "@usequark/quark-core/core";
8
8
  import { NextResponse } from "next/server";
9
9
  import { getCsrfCookieOptions } from "@/lib/csrf-cookie";
10
10
 
@@ -1,4 +1,4 @@
1
- import { validateBody } from "@usequark/quark-core";
1
+ import { validateBody } from "@usequark/quark-core/core";
2
2
  import { prisma } from "@usequark/quark-db";
3
3
  import { NextResponse } from "next/server";
4
4
  import { z } from "zod";
@@ -1,4 +1,4 @@
1
- import { AppError, createLogger } from "@usequark/quark-core";
1
+ import { AppError, createLogger } from "@usequark/quark-core/core";
2
2
  import { NextResponse } from "next/server";
3
3
 
4
4
  const logger = createLogger("api");
@@ -7,11 +7,11 @@
7
7
  import {
8
8
  ConflictError,
9
9
  createLogger,
10
- createStorage,
11
10
  ForbiddenError,
12
11
  NotFoundError,
13
12
  withCsrfProtection,
14
- } from "@usequark/quark-core";
13
+ } from "@usequark/quark-core/core";
14
+ import { createStorage } from "@usequark/quark-core/storage";
15
15
  import { file } from "@usequark/quark-db";
16
16
  import { NextResponse } from "next/server";
17
17
  import { requireAuth } from "@/lib/auth-middleware";
@@ -5,13 +5,15 @@
5
5
  */
6
6
 
7
7
  import {
8
- createStorage,
9
- generateStorageKey,
10
- parseMultipart,
11
8
  parsePaginationQuery,
12
9
  validateFile,
13
10
  withCsrfProtection,
14
- } from "@usequark/quark-core";
11
+ } from "@usequark/quark-core/core";
12
+ import { parseMultipart } from "@usequark/quark-core/multipart";
13
+ import {
14
+ createStorage,
15
+ generateStorageKey,
16
+ } from "@usequark/quark-core/storage";
15
17
  import { file } from "@usequark/quark-db";
16
18
  import { NextResponse } from "next/server";
17
19
  import { requireAuth } from "@/lib/auth-middleware";
@@ -17,14 +17,14 @@
17
17
  * what the core module replaces.
18
18
  */
19
19
 
20
+ import { createLogger } from "@usequark/quark-core/core";
20
21
  import {
21
22
  checkQueues,
22
23
  checkStorage,
23
- createLogger,
24
- getRegisteredQueues,
25
- pingRedis,
26
24
  runHealthChecks,
27
- } from "@usequark/quark-core";
25
+ } from "@usequark/quark-core/health";
26
+ import { getRegisteredQueues } from "@usequark/quark-core/queue";
27
+ import { pingRedis } from "@usequark/quark-core/redis";
28
28
  import { pingDatabase } from "@usequark/quark-db";
29
29
  import { NextResponse } from "next/server";
30
30
 
@@ -2,7 +2,7 @@ import {
2
2
  ConflictError,
3
3
  validateBody,
4
4
  withCsrfProtection,
5
- } from "@usequark/quark-core";
5
+ } from "@usequark/quark-core/core";
6
6
  import { user, userUpdateSchema } from "@usequark/quark-db";
7
7
  import { NextResponse } from "next/server";
8
8
  import { requireRole } from "@/lib/auth-middleware";
@@ -1,4 +1,4 @@
1
- import { validateBody, withCsrfProtection } from "@usequark/quark-core";
1
+ import { validateBody, withCsrfProtection } from "@usequark/quark-core/core";
2
2
  import { user, userUpdateSchema } from "@usequark/quark-db";
3
3
  import { NextResponse } from "next/server";
4
4
  import { extractBearerPayload } from "../../../../lib/jwt";
@@ -2,7 +2,7 @@ import {
2
2
  parsePaginationQuery,
3
3
  validateBody,
4
4
  withCsrfProtection,
5
- } from "@usequark/quark-core";
5
+ } from "@usequark/quark-core/core";
6
6
  import { user, userCreateSchema } from "@usequark/quark-db";
7
7
  import { NextResponse } from "next/server";
8
8
  import { requireRole } from "@/lib/auth-middleware";
@@ -1,4 +1,4 @@
1
- import { ForbiddenError, UnauthorizedError } from "@usequark/quark-core";
1
+ import { ForbiddenError, UnauthorizedError } from "@usequark/quark-core/errors";
2
2
  import { auth } from "./auth";
3
3
 
4
4
  const UNAUTHENTICATED = "You must be logged in to access this resource";
@@ -1,14 +1,13 @@
1
1
  import { PrismaAdapter } from "@auth/prisma-adapter";
2
- import {
3
- createAuthConfig,
4
- createLogger,
5
- verifyPassword,
6
- } from "@usequark/quark-core";
2
+ import { createAuthConfig, verifyPassword } from "@usequark/quark-core/auth";
3
+ import { createLogger } from "@usequark/quark-core/core";
4
+ import { AppError } from "@usequark/quark-core/errors";
7
5
  import { Prisma, prisma, user } from "@usequark/quark-db";
8
6
  import NextAuth from "next-auth";
9
7
  import CredentialsProvider from "next-auth/providers/credentials";
10
8
  import GithubProvider from "next-auth/providers/github";
11
9
  import GoogleProvider from "next-auth/providers/google";
10
+ import { isEmailVerified } from "./email-verified";
12
11
 
13
12
  const logger = createLogger({ name: "auth" });
14
13
  const STALE_SESSION_PATTERN = /jwtsessionerror|no matching decryption secret/i;
@@ -128,6 +127,31 @@ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
128
127
  GoogleProvider({
129
128
  clientId: process.env.GOOGLE_CLIENT_ID,
130
129
  clientSecret: process.env.GOOGLE_CLIENT_SECRET,
130
+ // The web sign-in creates its account on first sight through the
131
+ // Prisma adapter, so an unconfirmed address here means the same thing it
132
+ // does on the mobile route: the confirmation that is supposed to
133
+ // establish ownership of the address never happened. Google does issue
134
+ // tokens for unconfirmed addresses.
135
+ //
136
+ // The refusal is thrown rather than returned, and that is deliberate.
137
+ // Auth.js has no "null means refuse" contract: `getUserAndAccount` reads
138
+ // `profile.email` straight off whatever the mapper hands back, so a null
139
+ // denial works only because it trips a TypeError that is caught and
140
+ // logged as `OAuthProfileParseError` — indistinguishable, in the log, from
141
+ // a malformed provider response or a user who cancelled. Throwing says
142
+ // why in the one place an operator can read it, and lands in the same
143
+ // catch: no session, no user row, redirect back to the sign-in page.
144
+ profile: (profile) => {
145
+ if (!isEmailVerified(profile.email_verified)) {
146
+ throw new AppError(
147
+ "Google sign-in refused: the account's email address is not verified",
148
+ 401,
149
+ "OAUTH_EMAIL_NOT_VERIFIED",
150
+ );
151
+ }
152
+
153
+ return profile;
154
+ },
131
155
  }),
132
156
  );
133
157
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Whether a provider's `email_verified` claim says the address is confirmed.
3
+ *
4
+ * Providers disagree on the wire format. Google's `tokeninfo` endpoint reports
5
+ * the claim as the *string* `"true"` or `"false"`; the OIDC userinfo that
6
+ * reaches a NextAuth profile mapper carries the same string. The id token's own
7
+ * claim is a boolean. A plain truthiness check would therefore do the exact
8
+ * opposite of the obvious thing: accept `"false"` while refusing every real
9
+ * sign-in. Both spellings mean the same thing and both are accepted; nothing
10
+ * else is.
11
+ *
12
+ * Everything else — a missing claim, a null, a number, `"yes"` — reads as
13
+ * unverified, because the failure modes are asymmetric. A wrong refusal costs
14
+ * one unlucky sign-in. A wrong acceptance hands an unconfirmed address a
15
+ * session on a route or a provider that creates the account on first sight.
16
+ *
17
+ * Shared by both Google sign-in paths — the hand-written `POST /api/auth/google`
18
+ * the mobile app posts to, and the NextAuth provider the web button uses — so
19
+ * there is one definition of "confirmed" rather than two that can drift.
20
+ *
21
+ * @param {unknown} value
22
+ * @returns {boolean}
23
+ */
24
+ export function isEmailVerified(value) {
25
+ if (typeof value === "boolean") return value;
26
+ if (typeof value !== "string") return false;
27
+ return value.trim().toLowerCase() === "true";
28
+ }
@@ -0,0 +1,51 @@
1
+ import { JOB_NAMES, JOB_QUEUES } from "@usequark/quark-jobs";
2
+
3
+ /**
4
+ * Loads the queue module on demand.
5
+ *
6
+ * Kept as a named binding rather than an inline `import()` so it can be
7
+ * substituted — see `loadQueue` on {@link enqueueWelcomeEmail}.
8
+ *
9
+ * @returns {Promise<typeof import("@usequark/quark-core/queue")>}
10
+ */
11
+ function loadQueueModule() {
12
+ return import("@usequark/quark-core/queue");
13
+ }
14
+
15
+ /**
16
+ * Enqueues the post-signup welcome email for a newly created user.
17
+ *
18
+ * This lives in its own module for two reasons.
19
+ *
20
+ * The queue module is imported dynamically from inside this file, so callers
21
+ * that import *this* statically never pull BullMQ into their module graph. That
22
+ * matters most for `/api/auth/register`, which is reachable on the web service:
23
+ * a module-scope `createQueue` import both loaded BullMQ at startup and pinned a
24
+ * Redis connection to the web process for as long as the process lived.
25
+ *
26
+ * It also gives the route a static boundary to substitute in tests. `mock.module`
27
+ * intercepts a dynamic import only when the importing module registered the mock,
28
+ * so a dynamic import made by the route itself cannot be mocked from the route's
29
+ * test. A static import of this module can.
30
+ *
31
+ * @param {string} userId - The user to welcome. The worker rejects a job with
32
+ * no userId, so this is required rather than defaulted.
33
+ * @param {() => Promise<{ createQueue: (name: string, options?: object) => object }>} [loadQueue]
34
+ * How to obtain the queue module. Defaults to the dynamic import above;
35
+ * overridden in tests.
36
+ * @returns {Promise<void>}
37
+ * @throws Whatever `createQueue` or `add` throws. The queue is closed either
38
+ * way, so a caller that swallows the error still leaves no connection behind.
39
+ */
40
+ export async function enqueueWelcomeEmail(userId, loadQueue = loadQueueModule) {
41
+ const { createQueue } = await loadQueue();
42
+ const queue = createQueue(JOB_QUEUES.EMAIL);
43
+
44
+ try {
45
+ await queue.add(JOB_NAMES.SEND_WELCOME_EMAIL, { userId });
46
+ } finally {
47
+ // Never let a failing close mask the enqueue outcome — the caller only
48
+ // needs to know whether the job reached the queue.
49
+ await queue.close().catch(() => {});
50
+ }
51
+ }
@@ -1,4 +1,5 @@
1
- import { AppError, getAuthSecret } from "@usequark/quark-core";
1
+ import { getAuthSecret } from "@usequark/quark-core/auth";
2
+ import { AppError } from "@usequark/quark-core/core";
2
3
  import { jwtVerify, SignJWT } from "jose";
3
4
 
4
5
  const ISSUER = "quark-mobile";
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { loadConfig } from "@usequark/quark-config";
4
- import { createLogger } from "@usequark/quark-core";
4
+ import { createLogger } from "@usequark/quark-core/core";
5
5
  import { pingDatabase } from "@usequark/quark-db";
6
6
 
7
7
  const logger = createLogger("web-preflight");
@@ -23,7 +23,7 @@
23
23
  "linter": {
24
24
  "enabled": true,
25
25
  "rules": {
26
- "preset": "recommended"
26
+ "recommended": true
27
27
  }
28
28
  },
29
29
  "javascript": {
@@ -4,7 +4,7 @@
4
4
  * No API key needed — we connect to the same DB Umami uses.
5
5
  */
6
6
 
7
- import { createLogger } from "@usequark/quark-core";
7
+ import { createLogger } from "@usequark/quark-core/core";
8
8
 
9
9
  const log = createLogger("lib:umami-db");
10
10
 
@@ -1,4 +1,4 @@
1
- import { resolveRedisConnection } from "@usequark/quark-core";
1
+ import { resolveRedisConnection } from "@usequark/quark-core/redis";
2
2
 
3
3
  let _redis = null;
4
4
 
@@ -1,4 +1,4 @@
1
- import { createLogger } from "@usequark/quark-core";
1
+ import { createLogger } from "@usequark/quark-core/core";
2
2
  import { ValidationError } from "@usequark/quark-core/errors";
3
3
  import { z } from "zod";
4
4
  import { syncNextAuthUrl } from "./app-url.js";
@@ -3,11 +3,11 @@
3
3
  * Processes email-related background jobs
4
4
  */
5
5
 
6
+ import { createEmailService } from "@usequark/quark-core/email";
6
7
  import {
7
- createEmailService,
8
8
  passwordResetEmail,
9
9
  welcomeEmail,
10
- } from "@usequark/quark-core";
10
+ } from "@usequark/quark-core/email-templates";
11
11
  import { prisma } from "@usequark/quark-db";
12
12
  import { JOB_NAMES } from "@usequark/quark-jobs";
13
13
  import {
@@ -3,7 +3,7 @@
3
3
  * Processes file-related background jobs (cleanup, etc.)
4
4
  */
5
5
 
6
- import { createStorage } from "@usequark/quark-core";
6
+ import { createStorage } from "@usequark/quark-core/storage";
7
7
  import { file } from "@usequark/quark-db";
8
8
  import { JOB_NAMES } from "@usequark/quark-jobs";
9
9
 
@@ -4,15 +4,15 @@
4
4
  * Handles job execution, retries, and error tracking
5
5
  */
6
6
 
7
+ import { createLogger } from "@usequark/quark-core/core";
8
+ import { AppError } from "@usequark/quark-core/errors";
7
9
  import {
8
10
  checkQueueHealth,
9
- createLogger,
10
11
  createQueue,
11
12
  createWorker,
12
- getRedisEndpoint,
13
13
  updateQueueDepths,
14
- } from "@usequark/quark-core";
15
- import { AppError } from "@usequark/quark-core/errors";
14
+ } from "@usequark/quark-core/queue";
15
+ import { getRedisEndpoint, getRedisUrl } from "@usequark/quark-core/redis";
16
16
  import { job, prisma } from "@usequark/quark-db";
17
17
  import { JOB_NAMES, JOB_QUEUES } from "@usequark/quark-jobs";
18
18
  import { jobHandlers } from "./handlers/index.js";
@@ -3,7 +3,7 @@
3
3
  * Fetches estimated usage and deployment data from Railway's GraphQL API.
4
4
  */
5
5
 
6
- import { createLogger } from "@usequark/quark-core";
6
+ import { createLogger } from "@usequark/quark-core/core";
7
7
  import { AppError } from "@usequark/quark-core/errors";
8
8
 
9
9
  const log = createLogger("lib:railway");
@@ -1,4 +1,4 @@
1
- import { createLogger } from "@usequark/quark-core";
1
+ import { createLogger } from "@usequark/quark-core/core";
2
2
 
3
3
  const _logger = createLogger("worker:tokens");
4
4