@usequark/quark-create-app 1.25.13 → 1.25.14

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.13",
3
+ "version": "1.25.14",
4
4
  "description": "CLI to scaffold new Quark projects with Next.js, Prisma, BullMQ, and Railway deployment",
5
5
  "keywords": [
6
6
  "quark",
@@ -2,7 +2,7 @@ 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
4
  import { AppError } from "@usequark/quark-core/errors";
5
- import { Prisma, prisma, user } from "@usequark/quark-db";
5
+ import { Prisma, prisma, user as userQueries } from "@usequark/quark-db";
6
6
  import NextAuth from "next-auth";
7
7
  import CredentialsProvider from "next-auth/providers/credentials";
8
8
  import GithubProvider from "next-auth/providers/github";
@@ -66,7 +66,7 @@ const providers = [
66
66
  }
67
67
 
68
68
  try {
69
- const existingUser = await user.findByEmail(credentials.email);
69
+ const existingUser = await userQueries.findByEmail(credentials.email);
70
70
 
71
71
  if (!existingUser?.password) {
72
72
  return null;
@@ -156,6 +156,64 @@ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
156
156
  );
157
157
  }
158
158
 
159
+ /**
160
+ * Re-validate the session against the database on every request.
161
+ *
162
+ * Web sessions are signed JWTs: a credentials provider forces the JWT
163
+ * strategy (Auth.js rejects `strategy: "database"` when every provider is
164
+ * credentials, and scaffolds always ship one), and the cookie would otherwise
165
+ * stay valid for its full 30 days no matter what happened to the account
166
+ * behind it. That was the review's "logins can't be revoked" finding — banning
167
+ * a user, demoting an admin, or changing a password did nothing until the
168
+ * token expired on its own.
169
+ *
170
+ * The revocation check is a single indexed primary-key read, which is the
171
+ * same cost Auth.js itself pays for database sessions — but it works for
172
+ * credentials apps too:
173
+ *
174
+ * - user gone (deleted / banned by deletion) → reject, cookie cleared
175
+ * - `passwordChangedAt` after the token's iat → reject, cookie cleared
176
+ * - role or profile changed → refreshed into the token,
177
+ * so `requireRole` reads the current role, not the sign-in-time one
178
+ *
179
+ * Returning null from the `jwt` callback is Auth.js's documented "invalidate
180
+ * this session" signal: it clears the cookie and the request continues
181
+ * unauthenticated.
182
+ */
183
+ async function validateSessionToken(token) {
184
+ if (typeof token.id !== "string" || token.id.length === 0) {
185
+ return null;
186
+ }
187
+
188
+ try {
189
+ const account = await userQueries.findById(token.id);
190
+
191
+ if (!account) {
192
+ return null;
193
+ }
194
+
195
+ if (
196
+ account.passwordChangedAt &&
197
+ typeof token.iat === "number" &&
198
+ account.passwordChangedAt.getTime() / 1000 > token.iat
199
+ ) {
200
+ return null;
201
+ }
202
+
203
+ token.email = account.email;
204
+ token.name = account.name;
205
+ token.role = account.role;
206
+ return token;
207
+ } catch (err) {
208
+ // Fail closed: an unreachable database invalidates the session rather
209
+ // than letting an unverified cookie through. The next request retries.
210
+ logger.error("Session validation failed", {
211
+ message: err?.message ?? String(err),
212
+ });
213
+ return null;
214
+ }
215
+ }
216
+
159
217
  export function getAuthOptions() {
160
218
  return createAuthConfig({
161
219
  adapter: PrismaAdapter(prisma),
@@ -172,7 +230,28 @@ export function getAuthOptions() {
172
230
  },
173
231
  },
174
232
  session: {
233
+ // JWT is not a preference here, it is a constraint: Auth.js throws
234
+ // `UnsupportedStrategy` when strategy is "database" and every
235
+ // configured provider is credentials, and the scaffold always ships
236
+ // the credentials provider. Revocation is therefore enforced by
237
+ // `validateSessionToken` above, not by server-side session rows.
175
238
  strategy: "jwt",
239
+ maxAge: 30 * 24 * 60 * 60, // 30 days
240
+ },
241
+ callbacks: {
242
+ async jwt({ token, user: signedInUser }) {
243
+ // Sign-in: stamp the identity once; the DB check below is for
244
+ // every subsequent request.
245
+ if (signedInUser) {
246
+ token.id = signedInUser.id;
247
+ token.email = signedInUser.email;
248
+ token.name = signedInUser.name;
249
+ token.role = signedInUser.role || "viewer";
250
+ return token;
251
+ }
252
+
253
+ return validateSessionToken(token);
254
+ },
176
255
  },
177
256
  });
178
257
  }
@@ -1,6 +1,3 @@
1
- import { getAuthSecret } from "@usequark/quark-core/auth";
2
- import { getToken } from "next-auth/jwt";
3
-
4
1
  const STRICT_AUTH_RATE_LIMIT_ROUTES = new Set([
5
2
  // `callback/credentials` is where the next-auth client POSTs a credentials
6
3
  // sign-in (next-auth/react posts to `callback/<provider>` for credentials
@@ -16,6 +16,7 @@ CREATE TABLE "User" (
16
16
  "password" TEXT,
17
17
  "image" TEXT,
18
18
  "role" "UserRole" NOT NULL DEFAULT 'viewer',
19
+ "passwordChangedAt" TIMESTAMP(3),
19
20
  "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
20
21
  "updatedAt" TIMESTAMP(3) NOT NULL,
21
22
 
@@ -117,9 +118,6 @@ CREATE TABLE "AuditLog" (
117
118
  -- CreateIndex
118
119
  CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
119
120
 
120
- -- CreateIndex
121
- CREATE INDEX "User_email_idx" ON "User"("email");
122
-
123
121
  -- CreateIndex
124
122
  CREATE INDEX "User_createdAt_idx" ON "User"("createdAt");
125
123
 
@@ -141,9 +139,6 @@ CREATE INDEX "Session_expires_idx" ON "Session"("expires");
141
139
  -- CreateIndex
142
140
  CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
143
141
 
144
- -- CreateIndex
145
- CREATE INDEX "VerificationToken_token_idx" ON "VerificationToken"("token");
146
-
147
142
  -- CreateIndex
148
143
  CREATE INDEX "VerificationToken_expires_idx" ON "VerificationToken"("expires");
149
144
 
@@ -153,12 +148,6 @@ CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationTok
153
148
  -- CreateIndex
154
149
  CREATE INDEX "Job_queue_idx" ON "Job"("queue");
155
150
 
156
- -- CreateIndex
157
- CREATE INDEX "Job_status_idx" ON "Job"("status");
158
-
159
- -- CreateIndex
160
- CREATE INDEX "Job_runAt_idx" ON "Job"("runAt");
161
-
162
151
  -- CreateIndex
163
152
  CREATE INDEX "Job_status_runAt_idx" ON "Job"("status", "runAt");
164
153
 
@@ -29,6 +29,10 @@ model User {
29
29
  password String?
30
30
  image String?
31
31
  role UserRole @default(viewer)
32
+ // Bumped whenever the account's password changes. Session tokens (web JWTs
33
+ // and mobile refresh tokens) issued before this instant are rejected, so a
34
+ // stolen or pre-change session dies with the password it was born under.
35
+ passwordChangedAt DateTime?
32
36
  createdAt DateTime @default(now())
33
37
  updatedAt DateTime @updatedAt
34
38
 
@@ -37,7 +41,8 @@ model User {
37
41
  auditLogs AuditLog[]
38
42
  files File[]
39
43
 
40
- @@index([email])
44
+ // `email` is already @unique (User_email_key); a second plain index on the
45
+ // same column only adds write cost.
41
46
  @@index([createdAt])
42
47
  }
43
48
 
@@ -85,8 +90,9 @@ model VerificationToken {
85
90
  createdAt DateTime @default(now())
86
91
  updatedAt DateTime @updatedAt
87
92
 
93
+ // `token` is already @unique (VerificationToken_token_key); a second plain
94
+ // index on the same column only adds write cost.
88
95
  @@unique([identifier, token])
89
- @@index([token])
90
96
  @@index([expires])
91
97
  }
92
98
 
@@ -106,9 +112,9 @@ model Job {
106
112
  createdAt DateTime @default(now())
107
113
  updatedAt DateTime @updatedAt
108
114
 
115
+ // (status, runAt) serves status-only queries through its leftmost prefix;
116
+ // single-column [status] and [runAt] indexes were removed as redundant.
109
117
  @@index([queue])
110
- @@index([status])
111
- @@index([runAt])
112
118
  @@index([status, runAt])
113
119
  @@index([createdAt])
114
120
  }
@@ -11,6 +11,7 @@ const USER_SAFE_SELECT = {
11
11
  name: true,
12
12
  image: true,
13
13
  role: true,
14
+ passwordChangedAt: true,
14
15
  createdAt: true,
15
16
  updatedAt: true,
16
17
  };