@usequark/quark-create-app 1.24.3 → 1.25.0

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.0",
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 });
@@ -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
  }
@@ -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];
@@ -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...");