@usequark/quark-create-app 1.24.3 → 1.25.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequark/quark-create-app",
3
- "version": "1.24.3",
3
+ "version": "1.25.1",
4
4
  "description": "CLI to scaffold new Quark projects with Next.js, Prisma, BullMQ, and Railway deployment",
5
5
  "keywords": [
6
6
  "quark",
package/src/index.js CHANGED
@@ -214,8 +214,23 @@ async function initializeGit(projectDir) {
214
214
  }
215
215
 
216
216
  try {
217
- // Initialize git repo
218
- await execa("git", ["init"], { cwd: projectDir });
217
+ // Initialize git repo.
218
+ //
219
+ // `-b main` pins the branch name rather than inheriting whatever the
220
+ // machine has in `init.defaultBranch`. Without it the scaffold is not
221
+ // reproducible: `git init` falls back to `master` wherever that setting
222
+ // is unset (the common case, and GitHub's own default until the hint
223
+ // is followed), so the same CLI produced `master` on one machine and
224
+ // `main` on another. `main` is the name GitHub defaults a new repository
225
+ // to, so it is also what the README's push instructions assume.
226
+ //
227
+ // `-b` needs git 2.28 (2020). If it is unavailable the bare `init` still
228
+ // produces a working repository, just with git's own default branch.
229
+ try {
230
+ await execa("git", ["init", "-b", "main"], { cwd: projectDir });
231
+ } catch {
232
+ await execa("git", ["init"], { cwd: projectDir });
233
+ }
219
234
 
220
235
  // Add all files
221
236
  await execa("git", ["add", "."], { cwd: projectDir });
@@ -5,6 +5,8 @@
5
5
  */
6
6
 
7
7
  import {
8
+ ConflictError,
9
+ createLogger,
8
10
  createStorage,
9
11
  ForbiddenError,
10
12
  NotFoundError,
@@ -15,16 +17,30 @@ import { NextResponse } from "next/server";
15
17
  import { requireAuth } from "@/lib/auth-middleware";
16
18
  import { handleError } from "../../error-handler";
17
19
 
20
+ const logger = createLogger("files");
21
+
18
22
  /**
19
23
  * GET /api/files/[id]
20
- * Serve / download a file by its database ID.
21
- * Public endpoint (no auth required) - access control is by knowledge of ID.
24
+ * Serve / download a file by its database ID or its storage key.
25
+ * Public endpoint (no auth required) - access control is by knowledge of the
26
+ * identifier.
27
+ *
28
+ * Both identifier forms are accepted because `getAssetUrl()` in
29
+ * `@usequark/quark-core` builds `/api/files/<key>` from a storage key, and
30
+ * nothing in this app ever called it with anything else. Serving only by id
31
+ * made every URL that function produces a 404.
22
32
  */
23
33
  export async function GET(_request, { params }) {
24
34
  try {
25
35
  const { id } = await params;
26
36
 
27
- const record = await file.findById(id);
37
+ // The two forms are disjoint: a cuid never contains "/", and a storage key
38
+ // always does (`<prefix>/<year>/<month>/<id>-<name>`). An id lookup that
39
+ // misses is followed by a key lookup, so the id path costs no extra query.
40
+ let record = await file.findById(id);
41
+ if (!record && id.includes("/")) {
42
+ record = await file.findByStorageKey(id);
43
+ }
28
44
  if (!record) {
29
45
  throw new NotFoundError("File not found");
30
46
  }
@@ -35,7 +51,13 @@ export async function GET(_request, { params }) {
35
51
  const headers = new Headers();
36
52
  headers.set("Content-Type", contentType || record.mimeType);
37
53
  headers.set("Content-Length", String(body.length));
38
- headers.set("Cache-Control", "public, max-age=31536000, immutable");
54
+ // Bounded, not `immutable`. The URL carries no version segment and nothing
55
+ // in the path is content-addressed, so `immutable` (never revalidate for a
56
+ // year) was a promise this route cannot keep: any code path that reuses an
57
+ // identifier — admin tooling, a seed, a restore from backup — would leave
58
+ // every browser and CDN holding the old bytes indefinitely. A bounded
59
+ // max-age bounds that window instead.
60
+ headers.set("Cache-Control", "public, max-age=3600");
39
61
 
40
62
  // Inline display for images; attachment download for everything else
41
63
  const isImage = record.mimeType.startsWith("image/");
@@ -57,6 +79,20 @@ export async function GET(_request, { params }) {
57
79
  /**
58
80
  * DELETE /api/files/[id]
59
81
  * Delete a file. Only the uploader or an admin can delete.
82
+ *
83
+ * Order matters: the database row goes first, the storage object second.
84
+ *
85
+ * `File` has no incoming relations today, so nothing can make the row delete
86
+ * fail on a foreign key — but a future incoming relation with a restrictive
87
+ * `onDelete` will, and at that point this ordering is what keeps the storage
88
+ * object intact. The old order (storage, then row) destroyed the bytes first,
89
+ * so a refused row delete left a row pointing at a blob that no longer existed:
90
+ * unrecoverable, because the bytes are the only copy and they are already gone.
91
+ *
92
+ * `deleteIfPresent` also closes the concurrent-delete race. Two overlapping
93
+ * requests both pass the ownership check above; the row delete reports how many
94
+ * rows it removed, so only the request that actually removed the row goes on to
95
+ * delete the storage object. The loser returns 404 without touching storage.
60
96
  */
61
97
  export const DELETE = withCsrfProtection(async (_request, { params }) => {
62
98
  try {
@@ -75,12 +111,42 @@ export const DELETE = withCsrfProtection(async (_request, { params }) => {
75
111
  throw new ForbiddenError("You can only delete your own files");
76
112
  }
77
113
 
78
- // Remove from storage
79
- const storage = createStorage();
80
- await storage.delete(record.storageKey);
114
+ // Remove the database record first. This is the reversible step: if the
115
+ // storage delete below fails, we have an orphaned blob, which is junk but
116
+ // harmless. The reverse order risks a row whose bytes are already gone.
117
+ let deleted;
118
+ try {
119
+ ({ count: deleted } = await file.deleteIfPresent(record.id));
120
+ } catch (error) {
121
+ // P2003: a foreign key still references this row. Reachable once File
122
+ // gains incoming relations; the storage object is untouched at this point.
123
+ if (error?.code === "P2003") {
124
+ throw new ConflictError("File is referenced and cannot be deleted");
125
+ }
126
+ throw error;
127
+ }
81
128
 
82
- // Remove database record
83
- await file.delete(record.id);
129
+ // count === 0 means a concurrent request already removed the row. It owns
130
+ // the storage cleanup; touching it here would race that request's delete.
131
+ if (deleted === 0) {
132
+ throw new NotFoundError("File not found");
133
+ }
134
+
135
+ // Row is gone, so the blob is now unreferenced. This is the irreversible
136
+ // step and deliberately last.
137
+ const storage = createStorage();
138
+ try {
139
+ await storage.delete(record.storageKey);
140
+ } catch (error) {
141
+ // The row is already deleted, so the delete as a whole succeeded from the
142
+ // caller's point of view. Reporting an error would be a lie the client
143
+ // cannot act on, and the leftover blob is recoverable via the existing
144
+ // orphaned-file cleanup job. Log it rather than swallow it.
145
+ logger.error("Failed to remove file from storage", {
146
+ fileId: record.id,
147
+ error: error?.message ?? String(error),
148
+ });
149
+ }
84
150
 
85
151
  return NextResponse.json({ message: "File deleted" });
86
152
  } catch (error) {
@@ -1,7 +1,15 @@
1
1
  /**
2
2
  * Health Check Endpoint
3
- * Verifies the application and its dependencies are functioning correctly.
4
- * Times out after 5 seconds to prevent hanging.
3
+ *
4
+ * Reports the status of the application and its dependencies. Every dependency
5
+ * is probed concurrently, each under its own deadline, and the route always
6
+ * answers `200` — the verdict lives in the body's `status` field.
7
+ *
8
+ * Why always 200: this endpoint is the platform healthcheck. A non-200 here
9
+ * makes the orchestrator restart the container, which drops every warm
10
+ * connection and produces a fresh connection storm on the dependency that was
11
+ * already struggling. That turns a transient blip into a restart loop.
12
+ * Callers that need a hard verdict should read `status`, not the status code.
5
13
  */
6
14
 
7
15
  import {
@@ -15,146 +23,241 @@ import { NextResponse } from "next/server";
15
23
 
16
24
  const logger = createLogger("health");
17
25
 
18
- /** Overall timeout for the health check (ms). */
26
+ /** Ceiling for the whole check (ms). */
19
27
  const HEALTH_CHECK_TIMEOUT = 5000;
20
28
 
29
+ /** Ceiling for one dependency probe (ms). Clamped to the overall budget, so a
30
+ * single probe can never outlive the request. */
31
+ const PROBE_TIMEOUT = 3000;
32
+
33
+ /** Message returned in place of a real error when NODE_ENV is production. */
34
+ const GENERIC_MESSAGES = {
35
+ database: "Database unavailable",
36
+ redis: "Redis unavailable",
37
+ storage: "Storage unavailable",
38
+ queue: "Queue unavailable",
39
+ };
40
+
21
41
  export async function GET() {
22
42
  try {
23
- const result = await new Promise((resolve, reject) => {
24
- const timer = setTimeout(
25
- () => reject(new Error("Health check timed out")),
26
- HEALTH_CHECK_TIMEOUT,
27
- );
28
- runHealthChecks().then(
29
- (val) => {
30
- clearTimeout(timer);
31
- resolve(val);
32
- },
33
- (err) => {
34
- clearTimeout(timer);
35
- reject(err);
36
- },
37
- );
38
- });
43
+ const result = await runHealthChecks();
39
44
 
45
+ // Always 200 — see the module comment. An orchestrator restarting this
46
+ // service is what turns a dependency blip into an outage.
40
47
  return NextResponse.json(result, {
41
- status: result.status === "error" ? 503 : 200,
48
+ status: 200,
49
+ headers: { "Cache-Control": "no-store" },
42
50
  });
43
51
  } catch (error) {
52
+ // Unreachable in practice: every probe below is individually settled and
53
+ // deadline-bounded, so runHealthChecks cannot reject. Kept so a future
54
+ // edit that reintroduces a throw still returns a parseable body.
44
55
  logger.error("Health check failed", {
45
56
  error: error.message,
46
57
  stack: error.stack,
47
58
  });
59
+
48
60
  return NextResponse.json(
49
61
  {
50
62
  status: "error",
51
63
  timestamp: new Date().toISOString(),
52
64
  message: "Service health check failed",
53
65
  },
54
- { status: 500 },
66
+ { status: 200, headers: { "Cache-Control": "no-store" } },
55
67
  );
56
68
  }
57
69
  }
58
70
 
59
71
  /**
60
- * Runs all individual health checks and aggregates the results.
61
- * @returns {Promise<{ status: string, timestamp: string, checks: Record<string, object> }>}
72
+ * Runs every dependency probe concurrently and aggregates the results.
73
+ *
74
+ * Concurrency matters as much as the deadlines: run sequentially, a 3s database
75
+ * timeout plus a 3s Redis timeout exhausts the 5s budget before storage is even
76
+ * attempted, and the whole request falls through to the give-up path.
77
+ *
78
+ * @returns {Promise<{ status: string, timestamp: string, durationMs: number, checks: Record<string, object> }>}
62
79
  */
63
80
  async function runHealthChecks() {
64
- const health = {
65
- status: "ok",
81
+ const startedAt = performance.now();
82
+
83
+ // Every probe is wrapped in settleProbe(), so this Promise.all cannot reject
84
+ // and cannot outlast the deadline.
85
+ const [database, redis, storage, queues] = await Promise.all([
86
+ settleProbe("database", () => pingDatabase()),
87
+ settleProbe("redis", () => pingRedis()),
88
+ // `provider` rides along on the error result so a failure still names the
89
+ // provider that failed — settleProbe owns the message redaction.
90
+ settleProbe("storage", checkStorage, {
91
+ provider: process.env.STORAGE_PROVIDER || "local",
92
+ }),
93
+ settleProbe("queues", checkQueues),
94
+ ]);
95
+
96
+ const checks = { database, redis, storage };
97
+ if (queues) checks.queues = queues;
98
+
99
+ return {
100
+ // "degraded" means at least one dependency failed. Both this and "ok"
101
+ // answer 200 — see the module comment.
102
+ status: Object.values(checks).some(isFailing) ? "degraded" : "ok",
66
103
  timestamp: new Date().toISOString(),
67
- checks: {},
104
+ durationMs: Math.round(performance.now() - startedAt),
105
+ checks,
68
106
  };
107
+ }
69
108
 
70
- // Check database connectivity
71
- const dbResult = await pingDatabase();
72
- if (dbResult.status === "ok") {
73
- health.checks.database = { status: "ok", latencyMs: dbResult.latencyMs };
74
- } else {
75
- health.checks.database = {
76
- status: "error",
77
- message: dbResult.message,
78
- };
79
- health.status = "degraded";
109
+ /**
110
+ * Reports whether a check entry counts as a failure. Handles both a flat probe
111
+ * result (`{ status: "error" }`) and the nested queue map, where the verdict
112
+ * lives on each queue rather than on the map itself.
113
+ *
114
+ * @param {unknown} check
115
+ * @returns {boolean}
116
+ */
117
+ function isFailing(check) {
118
+ if (!check || typeof check !== "object") return false;
119
+
120
+ // A flat probe result carries its own `status`. The nested queue map does
121
+ // not, so fall through to its entries.
122
+ if (typeof (/** @type {any} */ (check).status) === "string") {
123
+ return /** @type {any} */ (check).status === "error";
80
124
  }
81
125
 
82
- // Check Redis connectivity (actual PING)
83
- const redisResult = await pingRedis();
84
- if (redisResult.status === "ok") {
85
- health.checks.redis = { status: "ok", latencyMs: redisResult.latencyMs };
86
- } else {
87
- health.checks.redis = {
126
+ return Object.values(/** @type {Record<string, any>} */ (check)).some(
127
+ (entry) => entry?.status === "error",
128
+ );
129
+ }
130
+
131
+ /**
132
+ * Runs one dependency probe with its own deadline and normalises every failure
133
+ * mode — rejection, timeout, or a probe that never settles — into a result
134
+ * object. Never throws, so callers can rely on `Promise.all` resolving.
135
+ *
136
+ * @param {string} name - Probe name, also the key under `checks`.
137
+ * @param {() => Promise<object>} probe
138
+ * @param {object} [context] - Fields to merge into the error result.
139
+ * @returns {Promise<{ status: string } & Record<string, unknown>>}
140
+ */
141
+ async function settleProbe(name, probe, context = {}) {
142
+ // A probe that hangs must not hold up the aggregate, and must never outlive
143
+ // the overall budget. The timer is always cleared, so it never leaks.
144
+ let timer;
145
+ const deadline = new Promise((resolve) => {
146
+ timer = setTimeout(
147
+ () => resolve({ status: "error", message: `${name} check timed out` }),
148
+ Math.min(PROBE_TIMEOUT, HEALTH_CHECK_TIMEOUT),
149
+ );
150
+ });
151
+
152
+ try {
153
+ return withSafeMessage(name, await Promise.race([probe(), deadline]));
154
+ } catch (/** @type {any} */ error) {
155
+ return withSafeMessage(name, {
156
+ ...context,
88
157
  status: "error",
89
- message: redisResult.message,
90
- };
91
- health.status = "degraded";
158
+ message: error?.message,
159
+ });
160
+ } finally {
161
+ clearTimeout(timer);
92
162
  }
163
+ }
93
164
 
94
- // Check storage connectivity
95
- const storageResult = await checkStorage();
96
- health.checks.storage = storageResult;
97
- if (storageResult.status === "error") {
98
- health.status = "degraded";
99
- }
165
+ /**
166
+ * Returns a check result whose message is safe to serve publicly.
167
+ *
168
+ * A probe can signal failure two ways — by rejecting, or by *resolving* with
169
+ * `{ status: "error", message }`. Both are routed through here so neither can
170
+ * bypass the redaction: `pingRedis` returned `{ status: "error" }` carrying the
171
+ * raw `REDIS_URL`, so a normaliser that only handled rejections would have let
172
+ * the password straight through.
173
+ *
174
+ * @param {string} name - Probe name.
175
+ * @param {object} [result]
176
+ * @returns {object}
177
+ */
178
+ function withSafeMessage(name, result) {
179
+ if (result?.status !== "error") return result;
100
180
 
101
- // Check queue depths (only when queues are registered in this process)
102
- const registeredQueues = getRegisteredQueues();
103
- if (registeredQueues.size > 0) {
104
- const entries = [...registeredQueues.entries()];
105
- const results = await Promise.allSettled(
106
- entries.map(([, queue]) =>
107
- Promise.all([
108
- queue.getWaitingCount(),
109
- queue.getActiveCount(),
110
- queue.getFailedCount(),
111
- ]),
112
- ),
113
- );
181
+ const { message, ...rest } = result;
182
+ return { ...rest, status: "error", message: safeMessage(name, message) };
183
+ }
114
184
 
115
- const queueChecks = {};
116
- for (let i = 0; i < entries.length; i++) {
117
- const [name] = entries[i];
118
- const result = results[i];
119
- if (result.status === "fulfilled") {
120
- const [waiting, active, failed] = result.value;
121
- queueChecks[name] = { status: "ok", waiting, active, failed };
122
- } else {
123
- const message =
124
- process.env.NODE_ENV === "production"
125
- ? "Queue unavailable"
126
- : result.reason?.message;
127
- queueChecks[name] = { status: "error", message };
128
- health.status = "degraded";
129
- }
130
- }
131
- health.checks.queues = queueChecks;
185
+ /**
186
+ * Reduces a failure to a message safe to return from an unauthenticated route.
187
+ *
188
+ * Driver errors carry the connection string they failed on, so the raw text is
189
+ * only echoed outside production. `pingRedis`/`pingDatabase` redact at the
190
+ * source regardless; this is the second layer.
191
+ *
192
+ * @param {string} name - Probe name.
193
+ * @param {string|Error} [source] - The failure message or the error itself.
194
+ * @returns {string}
195
+ */
196
+ function safeMessage(name, source) {
197
+ if (process.env.NODE_ENV === "production") {
198
+ return GENERIC_MESSAGES[name] ?? "Check unavailable";
132
199
  }
133
200
 
134
- return health;
201
+ return source?.message ?? source ?? "Unknown error";
135
202
  }
136
203
 
137
204
  /**
138
205
  * Verifies storage is reachable and writable by writing then deleting a
139
206
  * small sentinel object. Uses whichever provider is configured via
140
207
  * STORAGE_PROVIDER (defaults to "local" when unset).
141
- * @returns {Promise<{ status: string, provider: string, message?: string }>}
208
+ * @returns {Promise<{ status: string, provider: string }>}
142
209
  */
143
210
  async function checkStorage() {
144
211
  const provider = process.env.STORAGE_PROVIDER || "local";
145
- try {
146
- const storage = createStorage();
147
- const sentinelKey = ".health-check-sentinel";
148
- await storage.put(sentinelKey, Buffer.from("ok"), {
149
- contentType: "text/plain",
150
- });
151
- await storage.delete(sentinelKey);
152
- return { status: "ok", provider };
153
- } catch (error) {
154
- const message =
155
- process.env.NODE_ENV === "production"
156
- ? "Storage unavailable"
157
- : error.message;
158
- return { status: "error", provider, message };
212
+ const storage = createStorage();
213
+ const sentinelKey = ".health-check-sentinel";
214
+
215
+ await storage.put(sentinelKey, Buffer.from("ok"), {
216
+ contentType: "text/plain",
217
+ });
218
+ await storage.delete(sentinelKey);
219
+
220
+ return { status: "ok", provider };
221
+ }
222
+
223
+ /**
224
+ * Reports queue depths. Only meaningful in a process that registered the
225
+ * queues — the web app registers none, so this resolves to null and the key is
226
+ * omitted from the response rather than reported as a false pass.
227
+ *
228
+ * @returns {Promise<Record<string, { status: string, waiting?: number, active?: number, failed?: number, message?: string }> | null>}
229
+ */
230
+ async function checkQueues() {
231
+ const registeredQueues = getRegisteredQueues();
232
+ if (registeredQueues.size === 0) return null;
233
+
234
+ const entries = [...registeredQueues.entries()];
235
+ const results = await Promise.allSettled(
236
+ entries.map(([, queue]) =>
237
+ Promise.all([
238
+ queue.getWaitingCount(),
239
+ queue.getActiveCount(),
240
+ queue.getFailedCount(),
241
+ ]),
242
+ ),
243
+ );
244
+
245
+ const queueChecks = {};
246
+
247
+ for (let i = 0; i < entries.length; i++) {
248
+ const [name] = entries[i];
249
+ const result = results[i];
250
+
251
+ if (result.status === "fulfilled") {
252
+ const [waiting, active, failed] = result.value;
253
+ queueChecks[name] = { status: "ok", waiting, active, failed };
254
+ } else {
255
+ queueChecks[name] = withSafeMessage("queue", {
256
+ status: "error",
257
+ message: result.reason?.message,
258
+ });
259
+ }
159
260
  }
261
+
262
+ return queueChecks;
160
263
  }
@@ -1,4 +1,5 @@
1
1
  import "./globals.css";
2
+ import { ThemeProvider } from "@usequark/quark-ui";
2
3
  import {
3
4
  THEME_ATTR,
4
5
  THEME_STORAGE_KEY,
@@ -48,7 +49,11 @@ export default function RootLayout({ children }) {
48
49
  ) : null}
49
50
  </head>
50
51
  <body>
51
- {children}
52
+ {/* ThemeProvider renders no markup. It publishes theme state to
53
+ React consumers (useTheme/ThemeToggle) and syncs data-theme
54
+ after hydration via useLayoutEffect, which is why it must
55
+ coexist with the blocking script above rather than replace it. */}
56
+ <ThemeProvider>{children}</ThemeProvider>
52
57
  {umamiConfig.enabled ? <UmamiWebVitals /> : null}
53
58
  {umamiConfig.replayEnabled ? <UmamiReplayRecorder /> : null}
54
59
  </body>
@@ -7,6 +7,17 @@ const STRICT_AUTH_RATE_LIMIT_ROUTES = new Set([
7
7
  "/api/auth/signin/credentials",
8
8
  ]);
9
9
 
10
+ /**
11
+ * Routes that are never rate limited.
12
+ *
13
+ * `/api/health` is the platform healthcheck. It sits behind the same 100-req
14
+ * `api` bucket as everything else, so a probe that lands in an already-full
15
+ * bucket gets a 429 — the orchestrator reads that as unhealthy and restarts the
16
+ * container, which fills the bucket again on startup. Limiting the probe is how
17
+ * a healthy service gets killed.
18
+ */
19
+ const RATE_LIMIT_EXEMPT_ROUTES = new Set(["/api/health"]);
20
+
10
21
  function getForwardedProtocol(request) {
11
22
  const forwardedProto = request.headers.get("x-forwarded-proto");
12
23
  if (!forwardedProto) return null;
@@ -78,3 +89,12 @@ export function getRateLimitBucket(pathname, method = "GET") {
78
89
  ? "auth"
79
90
  : "api";
80
91
  }
92
+
93
+ /**
94
+ * Whether a route bypasses rate limiting entirely.
95
+ * @param {string} pathname
96
+ * @returns {boolean}
97
+ */
98
+ export function isRateLimitExempt(pathname) {
99
+ return RATE_LIMIT_EXEMPT_ROUTES.has(pathname);
100
+ }
@@ -5,14 +5,15 @@
5
5
  * Guards (in order):
6
6
  * 1. Metrics guard - optionally protects /api/metrics with a bearer
7
7
  * token. Set METRICS_TOKEN in env to enable.
8
- * 2. Rate limiting, CORS, security headers for all API routes.
8
+ * 2. Rate limiting, CORS, security headers for all API routes, except the
9
+ * rate-limit-exempt healthcheck (see lib/proxy-auth.js).
9
10
  */
10
11
 
11
12
  import { getAllowedOrigins } from "@usequark/quark-config/app-url";
12
13
  import { NextResponse } from "next/server";
13
14
 
14
15
  import { buildContentSecurityPolicy } from "./lib/analytics/umami-csp.js";
15
- import { getRateLimitBucket } from "./lib/proxy-auth";
16
+ import { getRateLimitBucket, isRateLimitExempt } from "./lib/proxy-auth";
16
17
 
17
18
  // ─── 1. Metrics guard ────────────────────────────────────────────────────────
18
19
 
@@ -218,8 +219,9 @@ export async function proxy(request) {
218
219
  }
219
220
  }
220
221
 
221
- // Apply rate limiting to API routes only
222
- if (pathname.startsWith("/api/")) {
222
+ // Apply rate limiting to API routes only. Exempt routes (the healthcheck)
223
+ // skip the bucket entirely — no counter, no headers.
224
+ if (pathname.startsWith("/api/") && !isRateLimitExempt(pathname)) {
223
225
  const ip = getClientIp(request);
224
226
  const rateLimitBucket = getRateLimitBucket(pathname, request.method);
225
227
  const maxRequests = RATE_LIMIT_CONFIG.maxRequests[rateLimitBucket];
@@ -283,6 +283,23 @@ export const file = {
283
283
  delete: (id) => {
284
284
  return prisma.file.delete({ where: { id } });
285
285
  },
286
+ /**
287
+ * Deletes a File row and reports whether this call was the one that removed
288
+ * it.
289
+ *
290
+ * `delete()` throws P2025 when the row is already gone, which is the normal
291
+ * outcome of two concurrent DELETEs of the same file and is not an error
292
+ * worth surfacing. This returns `{ count }` instead so the caller can tell
293
+ * "I deleted it" from "someone beat me to it" and skip the destructive
294
+ * follow-up (removing the storage object) in the second case.
295
+ *
296
+ * A count of 1 means the caller owns the cleanup. Any incoming relation
297
+ * added later still surfaces here as a P2003 foreign-key error, before the
298
+ * storage object has been touched.
299
+ */
300
+ deleteIfPresent: (id) => {
301
+ return prisma.file.deleteMany({ where: { id } });
302
+ },
286
303
  deleteMany: (ids) => {
287
304
  return prisma.file.deleteMany({ where: { id: { in: ids } } });
288
305
  },
@@ -31,7 +31,18 @@ function collectTests(targetPath) {
31
31
  continue;
32
32
  }
33
33
 
34
- if (!entry.isFile() || !entry.name.endsWith(".test.js")) {
34
+ // `*.integration-test.js` renders components through jsdom + react-dom,
35
+ // so it must be collected too. It was previously skipped, which meant
36
+ // those suites never ran in CI - a real gap, not a naming preference:
37
+ // every one of them passed locally but was invisible to the pipeline.
38
+ // Note the file suffix is `-integration-test.js`, so
39
+ // `*.integration.test.js` (a different, separately-excluded name) is
40
+ // still only picked up by the plain `.test.js` rule below.
41
+ if (
42
+ !entry.isFile() ||
43
+ (!entry.name.endsWith(".test.js") &&
44
+ !entry.name.endsWith(".integration-test.js"))
45
+ ) {
35
46
  continue;
36
47
  }
37
48
 
@@ -57,7 +57,8 @@ test("FormField - renders label and input", async () => {
57
57
  assert.ok(input, "should render an input");
58
58
  assert.strictEqual(label.textContent, "Name");
59
59
  assert.strictEqual(input.getAttribute("name"), "name");
60
- root.unmount();
60
+ await act(() => root.unmount());
61
+ container.remove();
61
62
  });
62
63
 
63
64
  test("FormField - connects label to input via htmlFor/id", async () => {
@@ -76,7 +77,8 @@ test("FormField - connects label to input via htmlFor/id", async () => {
76
77
  const input = container.querySelector("input");
77
78
  assert.strictEqual(label.getAttribute("for"), "custom-id");
78
79
  assert.strictEqual(input.getAttribute("id"), "custom-id");
79
- root.unmount();
80
+ await act(() => root.unmount());
81
+ container.remove();
80
82
  });
81
83
 
82
84
  test("FormField - renders error message with role alert", async () => {
@@ -94,7 +96,8 @@ test("FormField - renders error message with role alert", async () => {
94
96
  const errorEl = container.querySelector("[role='alert']");
95
97
  assert.ok(errorEl, "should render an error element with role alert");
96
98
  assert.strictEqual(errorEl.textContent, "Required");
97
- root.unmount();
99
+ await act(() => root.unmount());
100
+ container.remove();
98
101
  });
99
102
 
100
103
  test("FormField - sets aria-invalid and aria-describedby when error present", async () => {
@@ -110,13 +113,127 @@ test("FormField - sets aria-invalid and aria-describedby when error present", as
110
113
  }),
111
114
  ),
112
115
  );
113
- const wrapper = container.querySelector('[aria-invalid="true"]');
114
- assert.ok(wrapper, "should set aria-invalid on wrapper");
115
- const errorId = wrapper.getAttribute("aria-describedby");
116
- assert.ok(errorId, "should set aria-describedby");
116
+ const input = container.querySelector("input");
117
+ assert.ok(input, "should render an input");
118
+ assert.strictEqual(
119
+ input.getAttribute("aria-invalid"),
120
+ "true",
121
+ "aria-invalid must be on the focusable control",
122
+ );
123
+ assert.ok(
124
+ !container.querySelector("div[aria-invalid]"),
125
+ "aria-invalid on a non-interactive wrapper has no effect",
126
+ );
127
+ const errorId = input.getAttribute("aria-describedby");
128
+ assert.ok(errorId, "should set aria-describedby on the input");
129
+ assert.strictEqual(errorId, "email-field-error");
117
130
  const errorEl = container.querySelector(`#${errorId}`);
118
131
  assert.ok(errorEl, "aria-describedby should point to existing error element");
119
- root.unmount();
132
+ await act(() => root.unmount());
133
+ container.remove();
134
+ });
135
+
136
+ test("FormField - applies className to the control, not the wrapper", async () => {
137
+ const container = setupContainer();
138
+ const root = createRoot(container);
139
+ await act(() =>
140
+ root.render(
141
+ createElement(FormField, {
142
+ label: "Name",
143
+ name: "name",
144
+ className: "w-72",
145
+ }),
146
+ ),
147
+ );
148
+ const input = container.querySelector("input");
149
+ assert.match(input.getAttribute("class"), /\bw-72\b/);
150
+ assert.doesNotMatch(
151
+ container.firstElementChild.getAttribute("class"),
152
+ /\bw-72\b/,
153
+ "className targets the control; the wrapper is layout-only",
154
+ );
155
+ await act(() => root.unmount());
156
+ container.remove();
157
+ });
158
+
159
+ test("FormField - wrapperClassName styles the layout container", async () => {
160
+ const container = setupContainer();
161
+ const root = createRoot(container);
162
+ await act(() =>
163
+ root.render(
164
+ createElement(FormField, {
165
+ label: "Name",
166
+ name: "name",
167
+ wrapperClassName: "sm:col-span-2",
168
+ }),
169
+ ),
170
+ );
171
+ assert.match(
172
+ container.firstElementChild.getAttribute("class"),
173
+ /\bsm:col-span-2\b/,
174
+ );
175
+ await act(() => root.unmount());
176
+ container.remove();
177
+ });
178
+
179
+ test("FormField - forwards error ARIA to a custom child control", async () => {
180
+ const container = setupContainer();
181
+ const root = createRoot(container);
182
+ await act(() =>
183
+ root.render(
184
+ createElement(
185
+ FormField,
186
+ { label: "Bio", name: "bio", error: "Too short", id: "bio" },
187
+ createElement("textarea", { id: "bio", "data-testid": "custom" }),
188
+ ),
189
+ ),
190
+ );
191
+ const textarea = container.querySelector("textarea");
192
+ assert.strictEqual(textarea.getAttribute("aria-invalid"), "true");
193
+ assert.strictEqual(textarea.getAttribute("aria-describedby"), "bio-error");
194
+ assert.ok(
195
+ container.querySelector("#bio-error"),
196
+ "describedby target must exist",
197
+ );
198
+ assert.strictEqual(
199
+ textarea.getAttribute("name"),
200
+ "bio",
201
+ "name should be forwarded to the custom control",
202
+ );
203
+ await act(() => root.unmount());
204
+ container.remove();
205
+ });
206
+
207
+ test("FormField - preserves a custom child's own className", async () => {
208
+ const container = setupContainer();
209
+ const root = createRoot(container);
210
+ await act(() =>
211
+ root.render(
212
+ createElement(
213
+ FormField,
214
+ { label: "Bio", name: "bio", className: "h-40" },
215
+ createElement("textarea", { className: "resize-y" }),
216
+ ),
217
+ ),
218
+ );
219
+ const cls = container.querySelector("textarea").getAttribute("class");
220
+ assert.match(cls, /\bresize-y\b/);
221
+ assert.match(cls, /\bh-40\b/);
222
+ await act(() => root.unmount());
223
+ container.remove();
224
+ });
225
+
226
+ test("FormField - leaves aria-invalid off when there is no error", async () => {
227
+ const container = setupContainer();
228
+ const root = createRoot(container);
229
+ await act(() =>
230
+ root.render(createElement(FormField, { label: "Name", name: "name" })),
231
+ );
232
+ const input = container.querySelector("input");
233
+ assert.strictEqual(input.getAttribute("aria-invalid"), null);
234
+ assert.strictEqual(input.getAttribute("aria-describedby"), null);
235
+ await act(() => root.unmount());
236
+ container.remove();
120
237
  });
121
238
 
122
239
  test("FormField - renders children instead of default input", async () => {
@@ -138,5 +255,6 @@ test("FormField - renders children instead of default input", async () => {
138
255
  null,
139
256
  "should not render default input",
140
257
  );
141
- root.unmount();
258
+ await act(() => root.unmount());
259
+ container.remove();
142
260
  });
@@ -3,12 +3,33 @@ import React, { useId } from "react";
3
3
  import { Input } from "./input.js";
4
4
  import { Label } from "./label.js";
5
5
 
6
+ /**
7
+ * Label + control + error message, wired together for accessibility.
8
+ *
9
+ * `className` is applied to the control, matching every other component in this
10
+ * package (`Input`, `Textarea`, `Select`, `Card`, …), where it extends the
11
+ * component's own root element. Use `wrapperClassName` to style the layout
12
+ * container instead.
13
+ *
14
+ * The error association (`aria-invalid` / `aria-describedby`) always lands on
15
+ * the focusable control, never on a wrapper element, so a screen reader that is
16
+ * sitting on the control actually announces it.
17
+ *
18
+ * @param {object} props
19
+ * @param {React.ReactNode} [props.label]
20
+ * @param {string} [props.name]
21
+ * @param {string} [props.error]
22
+ * @param {React.ReactNode} [props.children] custom control, e.g. a <Textarea>
23
+ * @param {string} [props.className] classes for the control
24
+ * @param {string} [props.wrapperClassName] classes for the layout container
25
+ */
6
26
  export function FormField({
7
27
  label,
8
28
  name,
9
29
  error,
10
30
  children,
11
31
  className = "",
32
+ wrapperClassName = "",
12
33
  ...props
13
34
  }) {
14
35
  const generatedId = useId();
@@ -16,25 +37,44 @@ export function FormField({
16
37
 
17
38
  const errorId = `${fieldId}-error`;
18
39
 
19
- const inputProps = { id: fieldId, name, ...props };
20
- if (error) {
21
- inputProps["aria-invalid"] = true;
22
- inputProps["aria-describedby"] = errorId;
23
- }
40
+ const ariaProps = error
41
+ ? { "aria-invalid": true, "aria-describedby": errorId }
42
+ : {};
24
43
 
25
- const childrenWrapperProps = {};
26
- if (error) {
27
- childrenWrapperProps["aria-invalid"] = true;
28
- childrenWrapperProps["aria-describedby"] = errorId;
29
- }
44
+ const control = children
45
+ ? // A caller-supplied control already owns its own markup; forward the
46
+ // accessibility wiring to it so the attributes land on the element
47
+ // that can receive focus. Explicit props on the child win, so a
48
+ // caller can still override them.
49
+ React.Children.map(children, (child) => {
50
+ if (!React.isValidElement(child)) return child;
51
+ const merged = [child.props.className, className]
52
+ .filter(Boolean)
53
+ .join(" ");
54
+ return React.cloneElement(child, {
55
+ id: child.props.id ?? fieldId,
56
+ name: child.props.name ?? name,
57
+ // Omitted entirely when empty so a bare child is not given a
58
+ // meaningless class="" attribute.
59
+ ...(merged ? { className: merged } : {}),
60
+ ...ariaProps,
61
+ });
62
+ })
63
+ : React.createElement(Input, {
64
+ id: fieldId,
65
+ name,
66
+ className,
67
+ ...ariaProps,
68
+ ...props,
69
+ });
30
70
 
31
71
  return React.createElement(
32
72
  "div",
33
- { className: `min-w-0 space-y-1.5 ${className}`.trim() },
73
+ {
74
+ className: `min-w-0 space-y-1.5 ${wrapperClassName}`.trim(),
75
+ },
34
76
  label ? React.createElement(Label, { htmlFor: fieldId }, label) : null,
35
- children
36
- ? React.createElement("div", childrenWrapperProps, children)
37
- : React.createElement(Input, inputProps),
77
+ control,
38
78
  error
39
79
  ? React.createElement(
40
80
  "p",
@@ -56,7 +56,8 @@ test("Lightbox - renders nothing when closed", async () => {
56
56
  const root = createRoot(container);
57
57
  await act(() => root.render(createElement(Lightbox, { open: false })));
58
58
  assert.strictEqual(container.innerHTML, "");
59
- root.unmount();
59
+ await act(() => root.unmount());
60
+ container.remove();
60
61
  });
61
62
 
62
63
  test("Lightbox - renders overlay when open", async () => {
@@ -76,7 +77,8 @@ test("Lightbox - renders overlay when open", async () => {
76
77
  assert.ok(img, "should render an img element");
77
78
  assert.strictEqual(img.getAttribute("src"), "/test.jpg");
78
79
  assert.strictEqual(img.getAttribute("alt"), "Test image");
79
- root.unmount();
80
+ await act(() => root.unmount());
81
+ container.remove();
80
82
  });
81
83
 
82
84
  test("Lightbox - calls onClose when close button clicked", async () => {
@@ -100,7 +102,8 @@ test("Lightbox - calls onClose when close button clicked", async () => {
100
102
  assert.ok(closeBtn, "should have a close button");
101
103
  await act(() => closeBtn.click());
102
104
  assert.ok(closed, "onClose should be called");
103
- root.unmount();
105
+ await act(() => root.unmount());
106
+ container.remove();
104
107
  });
105
108
 
106
109
  test("Lightbox - shows prev/next buttons and counter", async () => {
@@ -127,7 +130,8 @@ test("Lightbox - shows prev/next buttons and counter", async () => {
127
130
  );
128
131
  assert.ok(container.textContent.includes("Next"), "should show Next button");
129
132
  assert.ok(container.textContent.includes("2 / 5"), "should show counter");
130
- root.unmount();
133
+ await act(() => root.unmount());
134
+ container.remove();
131
135
  });
132
136
 
133
137
  test("Lightbox - calls onPrevious and onNext on button clicks", async () => {
@@ -163,5 +167,6 @@ test("Lightbox - calls onPrevious and onNext on button clicks", async () => {
163
167
  assert.ok(prev, "onPrevious should be called");
164
168
  await act(() => nextBtn.click());
165
169
  assert.ok(next, "onNext should be called");
166
- root.unmount();
170
+ await act(() => root.unmount());
171
+ container.remove();
167
172
  });
@@ -13,7 +13,11 @@ import {
13
13
  THEME_STORAGE_KEY,
14
14
  } from "./theme-constants.js";
15
15
 
16
- const ThemeCtx = createContext({ theme: "dark", setTheme: () => {} });
16
+ // `undefined` means "no ThemeProvider above this consumer". It must not be a
17
+ // usable default: a plausible-looking default ({ theme, setTheme: noop }) turns a
18
+ // missing provider into a silently broken toggle that still renders and still
19
+ // reports a theme. See useTheme().
20
+ const ThemeCtx = createContext(undefined);
17
21
 
18
22
  /**
19
23
  * Wraps a subtree with a shared theme value.
@@ -90,18 +94,29 @@ export function ThemeProvider({ defaultTheme, children }) {
90
94
 
91
95
  /**
92
96
  * Returns the current theme and a setter from the nearest ThemeProvider.
93
- * Falls back to `{ theme: 'dark' }` when used outside a provider.
97
+ *
98
+ * Throws when there is no ThemeProvider above the caller. A missing provider is
99
+ * a wiring bug, not a state to default: `ThemeToggle` renders a labelled,
100
+ * focusable button either way, so a silent fallback yields a control that looks
101
+ * real, reports the wrong theme, and does nothing when clicked.
94
102
  *
95
103
  * @returns {{ theme: 'light' | 'dark', setTheme: (t: string) => void }}
104
+ * @throws {Error} if called outside a ThemeProvider
96
105
  */
97
106
  export function useTheme() {
98
- return useContext(ThemeCtx);
107
+ const ctx = useContext(ThemeCtx);
108
+ if (ctx === undefined) {
109
+ throw new Error(
110
+ "useTheme() must be used within a ThemeProvider. Wrap your app in <ThemeProvider> (import it from @scope/ui) around the tree containing this component.",
111
+ );
112
+ }
113
+ return ctx;
99
114
  }
100
115
 
101
116
  /**
102
117
  * Labeled pill button that shows the current theme and switches on click.
103
118
  * Displays "🌙 Dark Mode" when dark, "☀ Light Mode" when light.
104
- * Must be rendered inside a ThemeProvider.
119
+ * Must be rendered inside a ThemeProvider — outside one, `useTheme()` throws.
105
120
  */
106
121
  export function ThemeToggle({ className = "" }) {
107
122
  const { theme, setTheme } = useTheme();
@@ -37,8 +37,16 @@ export async function handleCleanupOrphanedFiles(bullJob, logger) {
37
37
 
38
38
  for (const record of orphaned) {
39
39
  try {
40
+ // Row first, storage second — same ordering as DELETE /api/files/[id].
41
+ // Storage-first means a failed row delete leaves a row pointing at bytes
42
+ // that are already gone, which cannot be recovered: the blob was the only
43
+ // copy. A failed storage delete here leaves an orphaned blob instead, and
44
+ // this very job is what sweeps those up on a later pass.
45
+ const { count } = await file.deleteIfPresent(record.id);
46
+ // A concurrent request (or an earlier pass of this job) already took it.
47
+ if (count === 0) continue;
48
+
40
49
  await storage.delete(record.storageKey);
41
- await file.delete(record.id);
42
50
  deleted++;
43
51
  } catch (err) {
44
52
  errors.push({ id: record.id, error: err.message });
@@ -9,7 +9,7 @@ import {
9
9
  createLogger,
10
10
  createQueue,
11
11
  createWorker,
12
- getRedisUrl,
12
+ getRedisEndpoint,
13
13
  updateQueueDepths,
14
14
  } from "@usequark/quark-core";
15
15
  import { AppError } from "@usequark/quark-core/errors";
@@ -170,7 +170,7 @@ export async function waitForRedis(
170
170
 
171
171
  // All retries exhausted
172
172
  throw new AppError(
173
- `Redis unavailable at ${getRedisUrl()} after ${maxRetries} attempts. Start Redis or check REDIS_URL/REDIS_HOST/REDIS_PORT.`,
173
+ `Redis unavailable at ${getRedisEndpoint()} after ${maxRetries} attempts. Start Redis or check REDIS_URL/REDIS_HOST/REDIS_PORT.`,
174
174
  503,
175
175
  "REDIS_UNAVAILABLE",
176
176
  );
@@ -397,7 +397,7 @@ async function startWorker() {
397
397
  isDevMode ? { maxRetries: 3, intervalMs: 500 } : {},
398
398
  );
399
399
 
400
- logger.info("Redis connected", { address: getRedisUrl() });
400
+ logger.info("Redis connected", { address: getRedisEndpoint() });
401
401
 
402
402
  // Wait for database schema to be ready (migrations may still be running)
403
403
  logger.info("Checking database schema readiness...");