@usequark/quark-create-app 1.25.9 → 1.25.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequark/quark-create-app",
3
- "version": "1.25.9",
3
+ "version": "1.25.11",
4
4
  "description": "CLI to scaffold new Quark projects with Next.js, Prisma, BullMQ, and Railway deployment",
5
5
  "keywords": [
6
6
  "quark",
@@ -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 { isEmailVerified } from "../../../../lib/email-verified";
9
10
  import { issueTokenPair } from "../../../../lib/jwt";
10
11
  import { handleError } from "../../error-handler";
11
12
 
@@ -15,26 +16,6 @@ const googleAuthSchema = z.object({
15
16
 
16
17
  const GOOGLE_TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo";
17
18
 
18
- /**
19
- * Whether Google's `email_verified` claim says the address is confirmed.
20
- *
21
- * `tokeninfo` returns the claim as the *string* `"true"` or `"false"`, not a
22
- * boolean, so a plain truthiness check would do the exact opposite of the
23
- * obvious thing: reject every real sign-in (`"false"` is truthy) while waving
24
- * through the one case that matters. Only an explicit confirmation counts.
25
- * Anything else — a missing claim, a number, a null — is unverified, because
26
- * the failure modes here are asymmetric: a wrong refusal costs one unlucky
27
- * sign-in, and a wrong acceptance hands an unconfirmed address a session.
28
- *
29
- * @param {unknown} value
30
- * @returns {boolean}
31
- */
32
- function isEmailVerified(value) {
33
- if (typeof value === "boolean") return value;
34
- if (typeof value !== "string") return false;
35
- return value.trim().toLowerCase() === "true";
36
- }
37
-
38
19
  /**
39
20
  * POST /api/auth/google
40
21
  * Verify Google ID token and issue a custom JWT.
@@ -18,12 +18,7 @@
18
18
  */
19
19
 
20
20
  import { createLogger } from "@usequark/quark-core/core";
21
- import {
22
- checkQueues,
23
- checkStorage,
24
- runHealthChecks,
25
- } from "@usequark/quark-core/health";
26
- import { getRegisteredQueues } from "@usequark/quark-core/queue";
21
+ import { checkStorage, runHealthChecks } from "@usequark/quark-core/health";
27
22
  import { pingRedis } from "@usequark/quark-core/redis";
28
23
  import { pingDatabase } from "@usequark/quark-db";
29
24
  import { NextResponse } from "next/server";
@@ -35,12 +30,21 @@ export async function GET() {
35
30
  // `pingDatabase` is passed in rather than imported by the core module:
36
31
  // `@usequark/quark-db` depends on `@usequark/quark-core`, so core cannot
37
32
  // import it back without a cycle.
33
+ //
34
+ // There is deliberately no `queues` probe. `getRegisteredQueues()` reads a
35
+ // Map that only queues created *in this process* are registered in, and
36
+ // the web service creates none: the worker owns every long-lived queue.
37
+ // So the probe reported `null` on every request here — while its
38
+ // `getRegisteredQueues` import pulled all of BullMQ into this route.
39
+ // This endpoint is polled by the platform healthcheck and by
40
+ // `<HealthIndicator />` on every page view, making it the worst possible
41
+ // place to load a queue library for a result that is always empty.
42
+ // Queue depth is the worker's to publish, via `job_queue_depth`.
38
43
  const result = await runHealthChecks({
39
44
  probes: {
40
45
  database: pingDatabase,
41
46
  redis: pingRedis,
42
47
  storage: checkStorage,
43
- queues: () => checkQueues(getRegisteredQueues),
44
48
  },
45
49
  });
46
50
 
@@ -1,11 +1,13 @@
1
1
  import { PrismaAdapter } from "@auth/prisma-adapter";
2
2
  import { createAuthConfig, verifyPassword } from "@usequark/quark-core/auth";
3
3
  import { createLogger } from "@usequark/quark-core/core";
4
+ import { AppError } from "@usequark/quark-core/errors";
4
5
  import { Prisma, prisma, user } from "@usequark/quark-db";
5
6
  import NextAuth from "next-auth";
6
7
  import CredentialsProvider from "next-auth/providers/credentials";
7
8
  import GithubProvider from "next-auth/providers/github";
8
9
  import GoogleProvider from "next-auth/providers/google";
10
+ import { isEmailVerified } from "./email-verified";
9
11
 
10
12
  const logger = createLogger({ name: "auth" });
11
13
  const STALE_SESSION_PATTERN = /jwtsessionerror|no matching decryption secret/i;
@@ -125,6 +127,31 @@ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
125
127
  GoogleProvider({
126
128
  clientId: process.env.GOOGLE_CLIENT_ID,
127
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
+ },
128
155
  }),
129
156
  );
130
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
+ }
@@ -12,7 +12,7 @@ import {
12
12
  createWorker,
13
13
  updateQueueDepths,
14
14
  } from "@usequark/quark-core/queue";
15
- import { getRedisEndpoint, getRedisUrl } from "@usequark/quark-core/redis";
15
+ import { getRedisEndpoint } 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";