@usequark/quark-create-app 1.25.13 → 1.25.15

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.15",
4
4
  "description": "CLI to scaffold new Quark projects with Next.js, Prisma, BullMQ, and Railway deployment",
5
5
  "keywords": [
6
6
  "quark",
@@ -59,17 +59,43 @@ export async function GET(_request, { params }) {
59
59
  // max-age bounds that window instead.
60
60
  headers.set("Cache-Control", "public, max-age=3600");
61
61
 
62
- // Inline display for images; attachment download for everything else
63
- const isImage = record.mimeType.startsWith("image/");
62
+ // Inline display is limited to raster formats. SVG is deliberately not
63
+ // among them: an SVG is an XML document that can carry <script>, this
64
+ // endpoint is public and same-origin, and CSP `default-src 'self'`
65
+ // permits those scripts to run — so a stored SVG served inline executes
66
+ // in the app's own origin (stored XSS; audit item 8). SVG therefore
67
+ // downloads as an attachment, and its response additionally carries a
68
+ // sandbox CSP so a browser that ignores the disposition still cannot
69
+ // run scripts, fetch, or embed the document.
70
+ const INLINE_SAFE_TYPES = new Set([
71
+ "image/jpeg",
72
+ "image/png",
73
+ "image/gif",
74
+ "image/webp",
75
+ "image/avif",
76
+ ]);
77
+ const isInlineSafe = INLINE_SAFE_TYPES.has(record.mimeType);
78
+ const isSvg = record.mimeType === "image/svg+xml";
64
79
  const safeName = record.originalName.replace(/[\\"\r\n]/g, "_");
65
80
  const encodedName = encodeURIComponent(record.originalName);
66
81
  headers.set(
67
82
  "Content-Disposition",
68
- isImage
83
+ isInlineSafe
69
84
  ? `inline; filename="${safeName}"; filename*=UTF-8''${encodedName}`
70
85
  : `attachment; filename="${safeName}"; filename*=UTF-8''${encodedName}`,
71
86
  );
72
87
 
88
+ if (isSvg) {
89
+ // `sandbox` with no allow-list: no scripts, no same-origin framing,
90
+ // no form submission. `style-src 'unsafe-inline'` keeps simple
91
+ // presentational SVGs viewable; nothing in this policy can execute
92
+ // code.
93
+ headers.set(
94
+ "Content-Security-Policy",
95
+ "default-src 'none'; style-src 'unsafe-inline'; sandbox",
96
+ );
97
+ }
98
+
73
99
  return new Response(body, { status: 200, headers });
74
100
  } catch (error) {
75
101
  return handleError(error);
@@ -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
@@ -10,11 +10,14 @@
10
10
  */
11
11
 
12
12
  import { getAllowedOrigins } from "@usequark/quark-config/app-url";
13
+ import { createLogger, createRateLimiter } from "@usequark/quark-core/core";
13
14
  import { NextResponse } from "next/server";
14
15
 
15
16
  import { buildContentSecurityPolicy } from "./lib/analytics/umami-csp.js";
16
17
  import { getRateLimitBucket, isRateLimitExempt } from "./lib/proxy-auth";
17
18
 
19
+ const logger = createLogger("proxy");
20
+
18
21
  // ─── 1. Metrics guard ────────────────────────────────────────────────────────
19
22
 
20
23
  function metricsGuard(request) {
@@ -38,8 +41,57 @@ function metricsGuard(request) {
38
41
  return null; // authorised - continue
39
42
  }
40
43
 
41
- // Simple in-memory rate limiter (use Redis for production)
42
- const rateLimit = new Map();
44
+ // Rate limiter: Redis-backed when REDIS_URL is set (multi-instance), otherwise
45
+ // in-memory (single instance). Fails CLOSED by default (see createRateLimiter).
46
+ // The Redis client is created once and reused; ioredis reconnects on its own.
47
+ let rateLimiterPromise = null;
48
+
49
+ // Exported for tests: the Redis-vs-memory backend selection is otherwise only
50
+ // observable through a live Redis connection, which the unit suite doesn't have.
51
+ // The reset clears the memoised instance so a test can flip REDIS_URL and
52
+ // re-select a backend; production code never calls it.
53
+ export function __resetRateLimiterForTests() {
54
+ rateLimiterPromise = null;
55
+ }
56
+
57
+ export async function getRateLimiter() {
58
+ if (rateLimiterPromise) return rateLimiterPromise;
59
+
60
+ rateLimiterPromise = (async () => {
61
+ if (!process.env.REDIS_URL) {
62
+ return createRateLimiter({ type: "memory" });
63
+ }
64
+
65
+ try {
66
+ const { default: Redis } = await import("ioredis");
67
+ const redisClient = new Redis(process.env.REDIS_URL, {
68
+ maxRetriesPerRequest: 1,
69
+ enableReadyCheck: true,
70
+ retryStrategy(times) {
71
+ return Math.min(times * 50, 2000);
72
+ },
73
+ });
74
+ // ioredis emits an 'error' event on connection trouble; without a
75
+ // listener Node treats it as unhandled and kills the process.
76
+ redisClient.on("error", (err) => {
77
+ logger.error("Redis rate-limiter client error", {
78
+ message: err?.message ?? String(err),
79
+ });
80
+ });
81
+ return createRateLimiter({ type: "redis", redisClient });
82
+ } catch (error) {
83
+ logger.error("Redis rate limiter unavailable; using in-memory", {
84
+ message: error?.message ?? String(error),
85
+ });
86
+ // Losing Redis must not lose rate limiting entirely: a per-instance
87
+ // in-memory limiter is still a limiter. It just no longer spans
88
+ // instances, so a distributed attacker gets one budget per instance.
89
+ return createRateLimiter({ type: "memory" });
90
+ }
91
+ })();
92
+
93
+ return rateLimiterPromise;
94
+ }
43
95
 
44
96
  /**
45
97
  * Resolve the client IP for rate-limit keying.
@@ -83,61 +135,25 @@ const RATE_LIMIT_CONFIG = {
83
135
  };
84
136
 
85
137
  /**
86
- * Rate limiting implementation
138
+ * Rate limiting implementation.
139
+ *
140
+ * Delegates to the core limiter (`createRateLimiter`): Redis-backed when
141
+ * REDIS_URL is set, in-memory otherwise. Its `checkLimit(key, max, window)`
142
+ * returns `{ limited, remaining, resetTime }` — the same shape this proxy
143
+ * already returned, so the response headers below are unchanged.
87
144
  */
88
- function checkRateLimit(ip, path, method) {
89
- const now = Date.now();
90
- const key = `${ip}:${path}`;
91
-
145
+ async function checkRateLimit(ip, path, method) {
146
+ const limiter = await getRateLimiter();
92
147
  const maxRequests =
93
148
  RATE_LIMIT_CONFIG.maxRequests[getRateLimitBucket(path, method)];
94
149
 
95
- // Get or create rate limit record
96
- const record = rateLimit.get(key) || {
97
- count: 0,
98
- resetTime: now + RATE_LIMIT_CONFIG.windowMs,
99
- };
100
-
101
- // Reset if window has passed
102
- if (now > record.resetTime) {
103
- record.count = 0;
104
- record.resetTime = now + RATE_LIMIT_CONFIG.windowMs;
105
- }
106
-
107
- // Check if limit exceeded
108
- if (record.count >= maxRequests) {
109
- return {
110
- limited: true,
111
- resetTime: record.resetTime,
112
- remaining: 0,
113
- };
114
- }
115
-
116
- // Increment counter
117
- record.count++;
118
- rateLimit.set(key, record);
119
-
120
- return {
121
- limited: false,
122
- resetTime: record.resetTime,
123
- remaining: maxRequests - record.count,
124
- };
150
+ return limiter.checkLimit(
151
+ `${ip}:${path}`,
152
+ maxRequests,
153
+ RATE_LIMIT_CONFIG.windowMs,
154
+ );
125
155
  }
126
156
 
127
- /**
128
- * Clean up old rate limit records periodically.
129
- * `unref()` so the timer never holds the Node process open on shutdown.
130
- */
131
- const rateLimitCleanup = setInterval(() => {
132
- const now = Date.now();
133
- for (const [key, record] of rateLimit.entries()) {
134
- if (now > record.resetTime) {
135
- rateLimit.delete(key);
136
- }
137
- }
138
- }, 60 * 1000); // Clean up every minute
139
- if (typeof rateLimitCleanup.unref === "function") rateLimitCleanup.unref();
140
-
141
157
  /**
142
158
  * CORS configuration
143
159
  */
@@ -225,7 +241,7 @@ export async function proxy(request) {
225
241
  const ip = getClientIp(request);
226
242
  const rateLimitBucket = getRateLimitBucket(pathname, request.method);
227
243
  const maxRequests = RATE_LIMIT_CONFIG.maxRequests[rateLimitBucket];
228
- const rateLimitResult = checkRateLimit(ip, pathname, request.method);
244
+ const rateLimitResult = await checkRateLimit(ip, pathname, request.method);
229
245
 
230
246
  if (rateLimitResult.limited) {
231
247
  const retryAfter = Math.ceil(
@@ -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
  };