@usequark/quark-core 2.5.7 → 2.6.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-core",
3
- "version": "2.5.7",
3
+ "version": "2.6.0",
4
4
  "description": "Core utilities for Quark apps: auth, BullMQ queues, Redis, email, storage, rate limiting, logging, and error handling",
5
5
  "keywords": [
6
6
  "quark",
@@ -35,6 +35,7 @@
35
35
  "./db": "./src/db.js",
36
36
  "./email": "./src/email.js",
37
37
  "./errors": "./src/errors.js",
38
+ "./health": "./src/health.js",
38
39
  "./locale": "./src/locale.js",
39
40
  "./logger": "./src/logger.js",
40
41
  "./metrics": "./src/metrics.js",
@@ -10,6 +10,7 @@ import * as coreLight from "@usequark/quark-core/core";
10
10
  import * as db from "@usequark/quark-core/db";
11
11
  import * as email from "@usequark/quark-core/email";
12
12
  import * as errors from "@usequark/quark-core/errors";
13
+ import * as health from "@usequark/quark-core/health";
13
14
  import * as locale from "@usequark/quark-core/locale";
14
15
  import * as logger from "@usequark/quark-core/logger";
15
16
  import * as metrics from "@usequark/quark-core/metrics";
@@ -90,6 +91,22 @@ test("subpath './errors' exports error classes", () => {
90
91
  assert.equal(typeof errors.ForbiddenError, "function");
91
92
  });
92
93
 
94
+ test("subpath './health' exports the health-check API", () => {
95
+ assert.equal(typeof health.runHealthChecks, "function");
96
+ assert.equal(typeof health.checkStorage, "function");
97
+ assert.equal(typeof health.checkQueues, "function");
98
+ assert.equal(typeof health.createDefaultProbes, "function");
99
+ assert.equal(typeof health.isFailing, "function");
100
+ });
101
+
102
+ test("subpath '.' (full barrel) re-exports the health-check API", () => {
103
+ // The scaffolded route imports these from the barrel, so a subpath-only export
104
+ // would leave it broken.
105
+ assert.equal(typeof core.runHealthChecks, "function");
106
+ assert.equal(typeof core.checkStorage, "function");
107
+ assert.equal(typeof core.checkQueues, "function");
108
+ });
109
+
93
110
  test("subpath './logger' exports the browser-safe logger", () => {
94
111
  assert.equal(typeof logger.createLogger, "function");
95
112
  });
package/src/health.js ADDED
@@ -0,0 +1,315 @@
1
+ /**
2
+ * @usequark/quark-core - Health Checks
3
+ *
4
+ * Dependency health probing for the platform healthcheck endpoint.
5
+ *
6
+ * Two invariants this module exists to hold:
7
+ *
8
+ * 1. **The aggregate completes inside one overall deadline.** Every probe runs
9
+ * concurrently and under its own deadline, clamped to the overall budget, so
10
+ * a hung dependency cannot hold the request open. Run sequentially, the sum
11
+ * of three 3s timeouts exhausts a 5s budget before the last probe starts —
12
+ * and an exhausted budget reads as a dead service.
13
+ *
14
+ * 2. **No message leaves carrying a credential.** Driver errors embed the
15
+ * connection string they failed on. Probes are normalised in one place so
16
+ * both failure shapes — rejecting, *and* resolving with
17
+ * `{ status: "error", message }` — pass through redaction. Handling only
18
+ * rejections is what let `pingRedis` publish the Redis password from an
19
+ * unauthenticated endpoint once already.
20
+ *
21
+ * Usage:
22
+ * const report = await runHealthChecks({
23
+ * probes: { database: pingDatabase, storage: checkStorage },
24
+ * });
25
+ * // → { status: "ok" | "degraded", checks: { database: {...} } }
26
+ */
27
+
28
+ import { createLogger } from "./logger.js";
29
+ import { createStorage } from "./storage.js";
30
+ import { redactUrl } from "./utils.js";
31
+
32
+ const logger = createLogger("health");
33
+
34
+ /** Ceiling for one dependency probe (ms). Clamped to the overall budget. */
35
+ export const DEFAULT_PROBE_TIMEOUT = 3000;
36
+
37
+ /** Ceiling for the whole check (ms). */
38
+ export const DEFAULT_HEALTH_TIMEOUT = 5000;
39
+
40
+ /**
41
+ * Message returned in place of a real error when NODE_ENV is production.
42
+ * Keyed by probe name.
43
+ */
44
+ const GENERIC_MESSAGES = {
45
+ database: "Database unavailable",
46
+ redis: "Redis unavailable",
47
+ storage: "Storage unavailable",
48
+ queue: "Queue unavailable",
49
+ };
50
+
51
+ /**
52
+ * Runs every dependency probe concurrently and aggregates the results.
53
+ *
54
+ * Never rejects: each probe is individually settled and deadline-bounded, so a
55
+ * rejecting probe becomes a `{ status: "error" }` entry rather than an exception
56
+ * out of the aggregate.
57
+ *
58
+ * @param {object} options
59
+ * @param {Record<string, () => Promise<object>|object>} options.probes - Probe name → probe fn.
60
+ * @param {number} [options.timeout=5000] - Overall budget in ms.
61
+ * @param {number} [options.probeTimeout=3000] - Per-probe budget in ms.
62
+ * @returns {Promise<{ status: string, timestamp: string, durationMs: number, checks: Record<string, object> }>}
63
+ */
64
+ export async function runHealthChecks({
65
+ probes,
66
+ timeout = DEFAULT_HEALTH_TIMEOUT,
67
+ probeTimeout = DEFAULT_PROBE_TIMEOUT,
68
+ } = {}) {
69
+ const startedAt = performance.now();
70
+ const names = Object.keys(probes ?? {});
71
+
72
+ // Every probe is wrapped in settleProbe(), so this Promise.all cannot reject
73
+ // and cannot outlast the deadline. Concurrency is the point: three sequential
74
+ // 3s probes would exceed a 5s budget on their own.
75
+ const settled = await Promise.all(
76
+ names.map((name) =>
77
+ settleProbe(name, probes[name], { timeout, probeTimeout }),
78
+ ),
79
+ );
80
+
81
+ const checks = {};
82
+ for (let i = 0; i < names.length; i++) {
83
+ const result = settled[i];
84
+ // A probe may return null to opt out (e.g. queues when none are registered
85
+ // in this process). Omitting the key is honest; reporting a false pass is not.
86
+ if (result !== null) checks[names[i]] = result;
87
+ }
88
+
89
+ return {
90
+ // "degraded" means at least one dependency failed. The HTTP status is the
91
+ // caller's decision — see the module comment on why it is not derived here.
92
+ status: Object.values(checks).some(isFailing) ? "degraded" : "ok",
93
+ timestamp: new Date().toISOString(),
94
+ durationMs: Math.round(performance.now() - startedAt),
95
+ checks,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Reports whether a check entry counts as a failure. Handles both a flat probe
101
+ * result (`{ status: "error" }`) and the nested queue map, where the verdict
102
+ * lives on each queue rather than on the map itself.
103
+ *
104
+ * @param {unknown} check
105
+ * @returns {boolean}
106
+ */
107
+ export function isFailing(check) {
108
+ if (!check || typeof check !== "object") return false;
109
+
110
+ // A flat probe result carries its own `status`. The nested queue map does not,
111
+ // so fall through to its entries.
112
+ if (typeof check.status === "string") {
113
+ return check.status === "error";
114
+ }
115
+
116
+ return Object.values(check).some((entry) => entry?.status === "error");
117
+ }
118
+
119
+ /**
120
+ * Runs one dependency probe with its own deadline and normalises every failure
121
+ * mode — rejection, timeout, or a probe that never settles — into a result
122
+ * object. Never throws, so callers can rely on `Promise.all` resolving.
123
+ *
124
+ * @param {string} name - Probe name, also the key under `checks`.
125
+ * @param {() => Promise<object>|object} probe
126
+ * @param {object} [options]
127
+ * @param {number} [options.timeout]
128
+ * @param {number} [options.probeTimeout]
129
+ * @returns {Promise<object|null>}
130
+ */
131
+ async function settleProbe(name, probe, { timeout, probeTimeout }) {
132
+ // A probe that hangs must not hold up the aggregate, and must never outlive
133
+ // the overall budget. The timer is always cleared, so it never leaks.
134
+ let timer;
135
+ const deadline = new Promise((resolve) => {
136
+ timer = setTimeout(
137
+ () => resolve({ status: "error", message: `${name} check timed out` }),
138
+ Math.min(probeTimeout, timeout),
139
+ );
140
+ });
141
+
142
+ try {
143
+ const result = await Promise.race([
144
+ Promise.resolve().then(probe),
145
+ deadline,
146
+ ]);
147
+ // A probe that opts out (null/undefined) is reported as absent, not as a pass.
148
+ if (result === null || result === undefined) return null;
149
+ return withSafeMessage(name, result);
150
+ } catch (error) {
151
+ return withSafeMessage(name, { status: "error", message: error?.message });
152
+ } finally {
153
+ clearTimeout(timer);
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Returns a check result whose message is safe to serve publicly.
159
+ *
160
+ * A probe can signal failure two ways — by rejecting, or by *resolving* with
161
+ * `{ status: "error", message }`. Both are routed through here so neither can
162
+ * bypass the redaction.
163
+ *
164
+ * @param {string} name - Probe name.
165
+ * @param {object} [result]
166
+ * @returns {object}
167
+ */
168
+ function withSafeMessage(name, result) {
169
+ if (result?.status !== "error") return result;
170
+
171
+ const { message, ...rest } = result;
172
+ return { ...rest, status: "error", message: safeMessage(name, message) };
173
+ }
174
+
175
+ /**
176
+ * Reduces a failure to a message safe to return from an unauthenticated route.
177
+ *
178
+ * Driver errors carry the connection string they failed on, so the raw text is
179
+ * only echoed outside production — and even then it goes through `redactUrl()`
180
+ * first, which strips any userinfo segment that survived the driver's own
181
+ * formatting. In production a fixed generic message is returned.
182
+ *
183
+ * @param {string} name - Probe name.
184
+ * @param {string|Error} [source] - The failure message or the error itself.
185
+ * @returns {string}
186
+ */
187
+ function safeMessage(name, source) {
188
+ if (process.env.NODE_ENV === "production") {
189
+ return GENERIC_MESSAGES[name] ?? "Check unavailable";
190
+ }
191
+
192
+ const text = source?.message ?? (typeof source === "string" ? source : "");
193
+ if (!text) return "Unknown error";
194
+ return redactUrl(text);
195
+ }
196
+
197
+ /**
198
+ * Verifies storage is reachable and writable by writing then deleting a small
199
+ * sentinel object. Uses whichever provider is configured via STORAGE_PROVIDER
200
+ * (defaults to "local" when unset).
201
+ *
202
+ * Deliberately a real round-trip rather than a `stat`: `stat` only proves a
203
+ * path exists and cannot tell a read-only mount from a writable one, which is
204
+ * the only failure this check exists to catch. The sentinel also works
205
+ * unchanged for S3/R2, where a filesystem permission check means nothing.
206
+ *
207
+ * @returns {Promise<{ status: string, provider: string }>}
208
+ */
209
+ export async function checkStorage() {
210
+ const provider = process.env.STORAGE_PROVIDER || "local";
211
+ const storage = createStorage();
212
+ const sentinelKey = ".health-check-sentinel";
213
+
214
+ await storage.put(sentinelKey, Buffer.from("ok"), {
215
+ contentType: "text/plain",
216
+ });
217
+ await storage.delete(sentinelKey);
218
+
219
+ return { status: "ok", provider };
220
+ }
221
+
222
+ /**
223
+ * Reports queue depths. Only meaningful in a process that registered the
224
+ * queues, so this resolves to null when none are registered and the key is
225
+ * omitted from the report rather than reported as a false pass.
226
+ *
227
+ * @returns {Promise<Record<string, { status: string, waiting?: number, active?: number, failed?: number, message?: string }> | null>}
228
+ */
229
+ export async function checkQueues(getRegisteredQueues) {
230
+ const registeredQueues = getRegisteredQueues();
231
+ if (registeredQueues.size === 0) return null;
232
+
233
+ const entries = [...registeredQueues.entries()];
234
+ const results = await Promise.allSettled(
235
+ entries.map(([, queue]) =>
236
+ Promise.all([
237
+ queue.getWaitingCount(),
238
+ queue.getActiveCount(),
239
+ queue.getFailedCount(),
240
+ ]),
241
+ ),
242
+ );
243
+
244
+ const queueChecks = {};
245
+
246
+ for (let i = 0; i < entries.length; i++) {
247
+ const [name] = entries[i];
248
+ const result = results[i];
249
+
250
+ if (result.status === "fulfilled") {
251
+ const [waiting, active, failed] = result.value;
252
+ queueChecks[name] = { status: "ok", waiting, active, failed };
253
+ } else {
254
+ queueChecks[name] = withSafeMessage("queue", {
255
+ status: "error",
256
+ message: result.reason?.message,
257
+ });
258
+ }
259
+ }
260
+
261
+ return queueChecks;
262
+ }
263
+
264
+ /**
265
+ * Composes the default probe set for a Quark app: the dependencies in
266
+ * `@usequark/quark-core` itself.
267
+ *
268
+ * `pingDatabase` is not imported here — it lives in `@usequark/quark-db`, which
269
+ * depends on this package, so importing it here would be circular. Callers pass
270
+ * it in:
271
+ *
272
+ * import { pingDatabase } from "@usequark/quark-db";
273
+ * import { getRegisteredQueues, pingRedis } from "@usequark/quark-core";
274
+ * import { runHealthChecks, checkStorage, checkQueues } from "@usequark/quark-core/health";
275
+ *
276
+ * const report = await runHealthChecks({
277
+ * probes: {
278
+ * database: pingDatabase,
279
+ * redis: pingRedis,
280
+ * storage: checkStorage,
281
+ * queues: () => checkQueues(getRegisteredQueues),
282
+ * },
283
+ * });
284
+ *
285
+ * @param {object} deps
286
+ * @param {() => Promise<object>} deps.pingDatabase
287
+ * @param {() => Promise<object>} [deps.pingRedis]
288
+ * @param {() => Map<string, object>} deps.getRegisteredQueues
289
+ * @returns {Record<string, () => Promise<object>>}
290
+ */
291
+ export function createDefaultProbes({
292
+ pingDatabase,
293
+ pingRedis,
294
+ getRegisteredQueues,
295
+ }) {
296
+ return {
297
+ database: pingDatabase,
298
+ ...(pingRedis ? { redis: pingRedis } : {}),
299
+ storage: checkStorage,
300
+ queues: () => checkQueues(getRegisteredQueues),
301
+ };
302
+ }
303
+
304
+ /**
305
+ * Logs a health report. Useful for startup logging, where the report is wanted
306
+ * in the log stream but not returned to a caller.
307
+ *
308
+ * @param {{ status: string, checks: Record<string, object> }} report
309
+ */
310
+ export function logHealthReport(report) {
311
+ logger.info("Health report", {
312
+ status: report.status,
313
+ checks: Object.keys(report.checks ?? {}),
314
+ });
315
+ }
@@ -0,0 +1,388 @@
1
+ import assert from "node:assert";
2
+ import { afterEach, beforeEach, test } from "node:test";
3
+
4
+ import {
5
+ checkQueues,
6
+ checkStorage,
7
+ isFailing,
8
+ runHealthChecks,
9
+ } from "./health.js";
10
+
11
+ // ── Helpers ──────────────────────────────────────────────────────────────────
12
+
13
+ /**
14
+ * A probe that records how many probes overlap it.
15
+ *
16
+ * `counter` is shared across every probe in one test — a per-probe counter could
17
+ * never observe more than one in flight, which would make the concurrency
18
+ * assertion vacuously pass.
19
+ */
20
+ function counter() {
21
+ return { inFlight: 0, peak: 0 };
22
+ }
23
+
24
+ /** A probe that settles after `latencyMs`, sharing a single in-flight counter. */
25
+ function tracked(c, status, latencyMs = 0) {
26
+ return async () => {
27
+ c.inFlight++;
28
+ c.peak = Math.max(c.peak, c.inFlight);
29
+ await new Promise((resolve) => setTimeout(resolve, latencyMs));
30
+ c.inFlight--;
31
+ return { status };
32
+ };
33
+ }
34
+
35
+ let originalNodeEnv;
36
+ let originalDir;
37
+
38
+ beforeEach(() => {
39
+ originalNodeEnv = process.env.NODE_ENV;
40
+ originalDir = process.env.STORAGE_LOCAL_DIR;
41
+ });
42
+
43
+ afterEach(() => {
44
+ if (originalNodeEnv === undefined) delete process.env.NODE_ENV;
45
+ else process.env.NODE_ENV = originalNodeEnv;
46
+
47
+ if (originalDir === undefined) delete process.env.STORAGE_LOCAL_DIR;
48
+ else process.env.STORAGE_LOCAL_DIR = originalDir;
49
+ });
50
+
51
+ // ── runHealthChecks ──────────────────────────────────────────────────────────
52
+
53
+ test("runHealthChecks returns ok when every probe passes", async () => {
54
+ const report = await runHealthChecks({
55
+ probes: {
56
+ database: async () => ({ status: "ok", latencyMs: 1 }),
57
+ redis: async () => ({ status: "ok", latencyMs: 2 }),
58
+ },
59
+ });
60
+
61
+ assert.strictEqual(report.status, "ok");
62
+ assert.strictEqual(report.checks.database.status, "ok");
63
+ assert.strictEqual(report.checks.redis.status, "ok");
64
+ assert.ok(typeof report.timestamp === "string");
65
+ assert.ok(Number.isFinite(report.durationMs));
66
+ });
67
+
68
+ test("runHealthChecks probes concurrently, not one after another", async () => {
69
+ // The defect this exists to prevent. Sequentially, three probes at 80ms would
70
+ // never put more than one in flight, and their 240ms total is a real fraction
71
+ // of the budget.
72
+ const shared = counter();
73
+ const startedAt = Date.now();
74
+
75
+ await runHealthChecks({
76
+ probes: {
77
+ a: tracked(shared, "ok", 80),
78
+ b: tracked(shared, "ok", 80),
79
+ c: tracked(shared, "ok", 80),
80
+ },
81
+ });
82
+ const elapsed = Date.now() - startedAt;
83
+
84
+ assert.strictEqual(
85
+ shared.peak,
86
+ 3,
87
+ "probes did not overlap — they are running sequentially",
88
+ );
89
+ // Overlap and duration together: 3x80ms serialised would also show peak 1, so
90
+ // the wall-clock bound is the independent check that the aggregate is the max
91
+ // of the probes rather than their sum.
92
+ assert.ok(
93
+ elapsed < 200,
94
+ `probes appear serialised (${elapsed}ms for 3x80ms)`,
95
+ );
96
+ });
97
+
98
+ test("runHealthChecks aggregates inside the overall deadline when probes hang", async () => {
99
+ // Never settles. Without a per-probe deadline the aggregate would hang until
100
+ // the caller gave up.
101
+ const startedAt = Date.now();
102
+ const report = await runHealthChecks({
103
+ timeout: 120,
104
+ probeTimeout: 120,
105
+ probes: {
106
+ database: () => new Promise(() => {}),
107
+ redis: async () => ({ status: "ok" }),
108
+ },
109
+ });
110
+ const elapsed = Date.now() - startedAt;
111
+
112
+ assert.strictEqual(report.status, "degraded");
113
+ assert.strictEqual(report.checks.database.status, "error");
114
+ assert.match(report.checks.database.message, /timed out/);
115
+ assert.ok(elapsed < 600, `aggregate outlived its budget (${elapsed}ms)`);
116
+ });
117
+
118
+ test("runHealthChecks clamps the probe deadline to the overall budget", async () => {
119
+ // A probeTimeout above the overall budget must not let a probe outlive the
120
+ // request: the clamp is what makes "the aggregate always completes inside
121
+ // the budget" true regardless of how the caller configured it.
122
+ const startedAt = Date.now();
123
+ await runHealthChecks({
124
+ timeout: 100,
125
+ probeTimeout: 60_000,
126
+ probes: { database: () => new Promise(() => {}) },
127
+ });
128
+ const elapsed = Date.now() - startedAt;
129
+
130
+ assert.ok(elapsed < 600, `probe outlived the overall budget (${elapsed}ms)`);
131
+ });
132
+
133
+ test("runHealthChecks does not reject when a probe throws", async () => {
134
+ const report = await runHealthChecks({
135
+ probes: {
136
+ database: async () => {
137
+ throw new Error("connect ECONNREFUSED 10.0.0.5:5432");
138
+ },
139
+ redis: async () => ({ status: "ok" }),
140
+ },
141
+ });
142
+
143
+ assert.strictEqual(report.status, "degraded");
144
+ assert.strictEqual(report.checks.database.status, "error");
145
+ assert.strictEqual(report.checks.redis.status, "ok");
146
+ });
147
+
148
+ test("runHealthChecks omits a key when a probe opts out", async () => {
149
+ // A probe returning null is absent, not passing. Reporting a false pass for a
150
+ // queue that was never registered would be worse than silence.
151
+ const report = await runHealthChecks({
152
+ probes: {
153
+ database: async () => ({ status: "ok" }),
154
+ queues: async () => null,
155
+ },
156
+ });
157
+
158
+ assert.ok(!("queues" in report.checks));
159
+ assert.strictEqual(report.status, "ok");
160
+ });
161
+
162
+ // ── Credential redaction ────────────────────────────────────────────────────
163
+
164
+ test("runHealthChecks redacts credentials from a probe that REJECTS", async () => {
165
+ process.env.NODE_ENV = "development";
166
+
167
+ const report = await runHealthChecks({
168
+ probes: {
169
+ redis: async () => {
170
+ throw new Error(
171
+ "connect ECONNREFUSED redis://default:hunter2@cache.internal:6379",
172
+ );
173
+ },
174
+ },
175
+ });
176
+
177
+ assert.ok(
178
+ !JSON.stringify(report).includes("hunter2"),
179
+ `report leaked a credential: ${JSON.stringify(report)}`,
180
+ );
181
+ });
182
+
183
+ test("runHealthChecks redacts credentials from a probe that RESOLVES with an error", async () => {
184
+ // The shape that bit us before: `pingRedis` reported failure by resolving
185
+ // with { status: "error", message }, not by rejecting. A normaliser that
186
+ // handled only rejections would pass this straight through.
187
+ process.env.NODE_ENV = "development";
188
+
189
+ const report = await runHealthChecks({
190
+ probes: {
191
+ redis: async () => ({
192
+ status: "error",
193
+ message: "unreachable at redis://default:hunter2@cache.internal:6379",
194
+ }),
195
+ },
196
+ });
197
+
198
+ assert.ok(
199
+ !JSON.stringify(report).includes("hunter2"),
200
+ `report leaked a credential: ${JSON.stringify(report)}`,
201
+ );
202
+ });
203
+
204
+ test("runHealthChecks returns a generic message in production", async () => {
205
+ process.env.NODE_ENV = "production";
206
+
207
+ const report = await runHealthChecks({
208
+ probes: {
209
+ database: async () => {
210
+ throw new Error("password authentication failed for user admin");
211
+ },
212
+ },
213
+ });
214
+
215
+ assert.strictEqual(report.checks.database.message, "Database unavailable");
216
+ });
217
+
218
+ test("runHealthChecks keeps the real message outside production", async () => {
219
+ process.env.NODE_ENV = "development";
220
+
221
+ const report = await runHealthChecks({
222
+ probes: {
223
+ redis: async () => {
224
+ throw new Error("Redis unreachable at cache.internal:6379");
225
+ },
226
+ },
227
+ });
228
+
229
+ assert.strictEqual(
230
+ report.checks.redis.message,
231
+ "Redis unreachable at cache.internal:6379",
232
+ );
233
+ });
234
+
235
+ // ── isFailing ───────────────────────────────────────────────────────────────
236
+
237
+ test("isFailing reads a flat probe result", () => {
238
+ assert.strictEqual(isFailing({ status: "ok" }), false);
239
+ assert.strictEqual(isFailing({ status: "error" }), true);
240
+ });
241
+
242
+ test("isFailing reads the nested queue map", () => {
243
+ // The verdict lives on each queue, not on the map itself.
244
+ assert.strictEqual(
245
+ isFailing({ emails: { status: "ok" }, reports: { status: "ok" } }),
246
+ false,
247
+ );
248
+ assert.strictEqual(
249
+ isFailing({ emails: { status: "ok" }, reports: { status: "error" } }),
250
+ true,
251
+ );
252
+ });
253
+
254
+ test("isFailing treats a missing check as not failing", () => {
255
+ assert.strictEqual(isFailing(null), false);
256
+ assert.strictEqual(isFailing(undefined), false);
257
+ });
258
+
259
+ // ── checkStorage ────────────────────────────────────────────────────────────
260
+
261
+ test("checkStorage writes and removes a sentinel object", async () => {
262
+ const { mkdtemp, rm, readdir } = await import("node:fs/promises");
263
+ const { tmpdir } = await import("node:os");
264
+ const { join } = await import("node:path");
265
+
266
+ const dir = await mkdtemp(join(tmpdir(), "health-storage-"));
267
+ const previousProvider = process.env.STORAGE_PROVIDER;
268
+ process.env.STORAGE_LOCAL_DIR = dir;
269
+ process.env.STORAGE_PROVIDER = "local";
270
+
271
+ try {
272
+ const result = await checkStorage();
273
+
274
+ assert.strictEqual(result.status, "ok");
275
+ assert.strictEqual(result.provider, "local");
276
+ // The sentinel must not be left behind, or every probe leaks a file.
277
+ assert.deepStrictEqual(await readdir(dir), []);
278
+ } finally {
279
+ if (previousProvider === undefined) delete process.env.STORAGE_PROVIDER;
280
+ else process.env.STORAGE_PROVIDER = previousProvider;
281
+ await rm(dir, { recursive: true, force: true });
282
+ }
283
+ });
284
+
285
+ test("checkStorage fails when the directory is not writable", async () => {
286
+ // The defect the brief flags: a `stat`-based check passes here, because the
287
+ // directory exists. It is the only failure this probe exists to catch, so the
288
+ // sentinel round-trip is what distinguishes the two.
289
+ if (process.getuid?.() === 0) return; // root ignores the mode bits
290
+
291
+ const { mkdtemp, chmod, rm } = await import("node:fs/promises");
292
+ const { tmpdir } = await import("node:os");
293
+ const { join } = await import("node:path");
294
+
295
+ const dir = await mkdtemp(join(tmpdir(), "health-readonly-"));
296
+ const previousProvider = process.env.STORAGE_PROVIDER;
297
+ process.env.STORAGE_LOCAL_DIR = dir;
298
+ process.env.STORAGE_PROVIDER = "local";
299
+
300
+ try {
301
+ await chmod(dir, 0o500); // read + execute, no write
302
+ await assert.rejects(() => checkStorage());
303
+ } finally {
304
+ await chmod(dir, 0o700).catch(() => {});
305
+ if (previousProvider === undefined) delete process.env.STORAGE_PROVIDER;
306
+ else process.env.STORAGE_PROVIDER = previousProvider;
307
+ await rm(dir, { recursive: true, force: true });
308
+ }
309
+ });
310
+
311
+ // ── checkQueues ─────────────────────────────────────────────────────────────
312
+
313
+ function fakeQueue({
314
+ waiting = 0,
315
+ active = 0,
316
+ failed = 0,
317
+ throwOn = null,
318
+ } = {}) {
319
+ const count = (kind) => {
320
+ if (throwOn === kind) throw new Error(`queue gone (${kind})`);
321
+ return { waiting, active, failed }[kind];
322
+ };
323
+ return {
324
+ getWaitingCount: async () => count("waiting"),
325
+ getActiveCount: async () => count("active"),
326
+ getFailedCount: async () => count("failed"),
327
+ };
328
+ }
329
+
330
+ test("checkQueues reports depths for registered queues", async () => {
331
+ const result = await checkQueues(
332
+ () => new Map([["emails", fakeQueue({ waiting: 3, active: 1 })]]),
333
+ );
334
+
335
+ assert.deepStrictEqual(result.emails, {
336
+ status: "ok",
337
+ waiting: 3,
338
+ active: 1,
339
+ failed: 0,
340
+ });
341
+ });
342
+
343
+ test("checkQueues returns null when no queues are registered", async () => {
344
+ // The web app registers none, so this must be absent from the report rather
345
+ // than reported as a passing check.
346
+ assert.strictEqual(await checkQueues(() => new Map()), null);
347
+ });
348
+
349
+ test("checkQueues marks a failing queue without failing its siblings", async () => {
350
+ const result = await checkQueues(
351
+ () =>
352
+ new Map([
353
+ ["emails", fakeQueue({ waiting: 1 })],
354
+ ["reports", fakeQueue({ throwOn: "waiting" })],
355
+ ]),
356
+ );
357
+
358
+ assert.strictEqual(result.emails.status, "ok");
359
+ assert.strictEqual(result.reports.status, "error");
360
+ assert.match(result.reports.message, /queue gone/);
361
+ });
362
+
363
+ test("checkQueues redacts a credential from a queue failure", async () => {
364
+ process.env.NODE_ENV = "development";
365
+
366
+ const result = await checkQueues(
367
+ () =>
368
+ new Map([
369
+ [
370
+ "emails",
371
+ {
372
+ getWaitingCount: async () => {
373
+ throw new Error(
374
+ "NOAUTH redis://default:hunter2@cache.internal:6379",
375
+ );
376
+ },
377
+ getActiveCount: async () => 0,
378
+ getFailedCount: async () => 0,
379
+ },
380
+ ],
381
+ ]),
382
+ );
383
+
384
+ assert.ok(
385
+ !JSON.stringify(result).includes("hunter2"),
386
+ `queue failure leaked a credential: ${JSON.stringify(result)}`,
387
+ );
388
+ });
package/src/index.js CHANGED
@@ -25,6 +25,8 @@ export * from "./error-reporter.js";
25
25
  export * from "./errors.js";
26
26
  // File validation exports
27
27
  export * from "./file-validation.js";
28
+ // Health check exports
29
+ export * from "./health.js";
28
30
  // Logger exports
29
31
  export * from "./logger.js";
30
32
  // Mail exports
package/src/storage.js CHANGED
@@ -187,11 +187,16 @@ export function generateStorageKey(originalFilename, options = {}) {
187
187
  *
188
188
  * Checks `ASSET_CDN_URL` first - if set, prepends it to the key.
189
189
  * This is provider-agnostic: works with any CDN (CloudFront, Cloudflare,
190
- * Bunny, Fastly, etc.) as long as the CDN is pointed at the same bucket.
190
+ * Bunny, Fastly, etc.) provided the CDN is rooted at the bucket, so that a
191
+ * request for `<cdn>/<key>` maps to the object stored at `<key>`. That is the
192
+ * standard arrangement for a CDN in front of a bucket and needs no rewrite
193
+ * rules. If your CDN is not bucket-rooted, set `S3_PUBLIC_URL` instead and use
194
+ * the S3 adapter's `getPublicUrl()`.
191
195
  *
192
196
  * Falls back to the local API route (`/api/files/<key>`) when no CDN is
193
197
  * configured - covers local development and any environment where
194
- * `STORAGE_PROVIDER=local` is used.
198
+ * `STORAGE_PROVIDER=local` is used. The scaffolded `/api/files/[id]` route
199
+ * accepts a storage key as well as a database id, so this resolves.
195
200
  *
196
201
  * @param {string} key - Storage key (e.g. "uploads/2026/02/abc-photo.jpg")
197
202
  * @returns {string} Full CDN URL or local API route
@@ -351,6 +351,55 @@ test("getAssetUrl - percent-encodes key in fallback URL", () => {
351
351
  assert.strictEqual(url, "/api/files/uploads%2Ffile%20with%20spaces.jpg");
352
352
  });
353
353
 
354
+ // ─── getAssetUrl / route contract ────────────────────────────────────────────
355
+ //
356
+ // The scaffolded GET /api/files/[id] route used to look up by database id only,
357
+ // so every URL this function produced resolved to a 404. It now also resolves a
358
+ // storage key. These tests pin the two halves of that contract from each side:
359
+ // the shape produced here, and the lookup performed by the route (in
360
+ // apps/web/src/app/api/files/[id]/route.test.js).
361
+ test("getAssetUrl - fallback URL carries the key in a single path segment", () => {
362
+ delete process.env.ASSET_CDN_URL;
363
+ const url = getAssetUrl("uploads/2026/02/abc-photo.jpg");
364
+
365
+ // A key with slashes must stay inside ONE path segment, or the request never
366
+ // reaches the [id] route at all — it 404s as an unrouted path. `URL` keeps the
367
+ // percent-encoding in `pathname`, so it is the encoded slashes that hold the
368
+ // segment together.
369
+ const segments = new URL(url, "http://localhost").pathname.split("/");
370
+ assert.strictEqual(
371
+ segments.length,
372
+ 4,
373
+ `key leaked into extra segments: ${url}`,
374
+ );
375
+ assert.strictEqual(segments[3], "uploads%2F2026%2F02%2Fabc-photo.jpg");
376
+ // And it must decode back to exactly the key, which is what the route looks up.
377
+ assert.strictEqual(
378
+ decodeURIComponent(segments[3]),
379
+ "uploads/2026/02/abc-photo.jpg",
380
+ );
381
+ });
382
+
383
+ test("getAssetUrl - CDN URL leaves the key path unencoded", () => {
384
+ // The CDN branch is a real URL space, not a path segment: encoding the slashes
385
+ // would ask the CDN for a single object literally named "uploads/2026/02/...".
386
+ // A bucket-rooted CDN maps the path straight onto the key.
387
+ process.env.ASSET_CDN_URL = "https://assets.example.com";
388
+ try {
389
+ const url = getAssetUrl("uploads/2026/02/abc-photo.jpg");
390
+ assert.strictEqual(
391
+ url,
392
+ "https://assets.example.com/uploads/2026/02/abc-photo.jpg",
393
+ );
394
+ assert.ok(
395
+ !url.includes("%2F"),
396
+ "CDN URL must not percent-encode the key path",
397
+ );
398
+ } finally {
399
+ delete process.env.ASSET_CDN_URL;
400
+ }
401
+ });
402
+
354
403
  // ---------------------------------------------------------------------------
355
404
  // getSignedUploadUrl
356
405
  // ---------------------------------------------------------------------------
package/src/utils.js CHANGED
@@ -232,29 +232,53 @@ const REDACTED_CREDENTIAL = "REDACTED";
232
232
  * of those is a credential disclosure, so nothing that embeds a URL in a
233
233
  * message should skip this.
234
234
  *
235
- * Parseable URLs are rewritten field by field. Anything else is run through a
236
- * regex that strips the userinfo segment of a URL-shaped string. A string that
237
- * matches neither has no recognisable credential segment and is returned
238
- * unchanged.
235
+ * Handles both shapes a connection string arrives in:
236
+ *
237
+ * - A bare, parseable URL — rewritten field by field.
238
+ * - A URL *embedded in a larger message*, which is what drivers actually
239
+ * produce: `connect ECONNREFUSED redis://default:hunter2@cache:6379`. Every
240
+ * URL-shaped substring is scanned and its userinfo segment replaced.
241
+ *
242
+ * Deciding between the two is not "did it parse" — `new URL()` accepts any
243
+ * string with a `<scheme>:` prefix, so a message like
244
+ * `Error: getaddrinfo ENOTFOUND postgres://u:p@db:5432/x` parses as a URL with
245
+ * protocol `error:` and *no* credentials of its own. Field-by-field rewriting
246
+ * then finds nothing to strip and returns the whole message untouched, with
247
+ * the embedded password intact. The field-by-field path is therefore gated on
248
+ * the string actually being a bare URL; everything else goes to the substring
249
+ * scan, which handles both embedded cases identically.
250
+ *
251
+ * A string with no recognisable credential segment is returned unchanged.
239
252
  *
240
253
  * @param {string} url
241
- * @returns {string} The URL with any username/password replaced
254
+ * @returns {string} The value with any username/password replaced
242
255
  */
243
256
  export const redactUrl = (url) => {
244
257
  if (typeof url !== "string" || url.length === 0) return url;
245
258
 
246
- try {
247
- const parsed = new URL(url);
259
+ // A bare URL is exactly `scheme://...` with no leading text and no
260
+ // surrounding whitespace. Anything else is treated as a message.
261
+ const isBareUrl = url === url.trim() && /^[a-z][a-z0-9+.-]*:\/\//i.test(url);
262
+
263
+ if (isBareUrl) {
264
+ try {
265
+ const parsed = new URL(url);
248
266
 
249
- if (!parsed.username && !parsed.password) return url;
267
+ if (!parsed.username && !parsed.password) return url;
250
268
 
251
- parsed.username = parsed.username ? REDACTED_CREDENTIAL : "";
252
- parsed.password = parsed.password ? REDACTED_CREDENTIAL : "";
253
- return parsed.toString();
254
- } catch {
255
- return url.replace(
256
- /^([a-z][a-z0-9+.-]*:\/\/)[^/?#@]*@/i,
257
- `$1${REDACTED_CREDENTIAL}@`,
258
- );
269
+ parsed.username = parsed.username ? REDACTED_CREDENTIAL : "";
270
+ parsed.password = parsed.password ? REDACTED_CREDENTIAL : "";
271
+ return parsed.toString();
272
+ } catch {
273
+ // Malformed enough that the serializer rejected it. Fall through to
274
+ // the substring scan, which still finds the credential segment.
275
+ }
259
276
  }
277
+
278
+ // Redact every URL-shaped substring, wherever it sits: the scheme, then the
279
+ // userinfo segment, terminated by `@`.
280
+ return url.replace(
281
+ /([a-z][a-z0-9+.-]*:\/\/)[^/?#@\s]*@/gi,
282
+ `$1${REDACTED_CREDENTIAL}@`,
283
+ );
260
284
  };
package/src/utils.test.js CHANGED
@@ -226,12 +226,71 @@ test("redactUrl", async (t) => {
226
226
  assert(!result.includes("hunter2"), `password leaked: ${result}`);
227
227
  });
228
228
 
229
+ await t.test("strips credentials from a URL embedded mid-message", () => {
230
+ // The shape drivers actually produce. The old regex was anchored to the
231
+ // start of the string, so a credential appearing after any prefix survived
232
+ // intact — which is exactly the form a failed connection reports.
233
+ const result = redactUrl(
234
+ "connect ECONNREFUSED redis://default:hunter2@cache.internal:6379",
235
+ );
236
+
237
+ assert(!result.includes("hunter2"), `password leaked: ${result}`);
238
+ assert(!result.includes("default"), `username leaked: ${result}`);
239
+ // The diagnostic value of the message survives.
240
+ assert(result.includes("connect ECONNREFUSED"));
241
+ assert(result.includes("cache.internal"));
242
+ });
243
+
244
+ await t.test("strips every embedded URL in a multi-URL message", () => {
245
+ const result = redactUrl(
246
+ "failed on redis://u1:p1@a:6379 and postgres://u2:p2@b:5432/db",
247
+ );
248
+
249
+ assert(!result.includes("p1"), `first password leaked: ${result}`);
250
+ assert(!result.includes("p2"), `second password leaked: ${result}`);
251
+ assert(result.includes("a:6379"));
252
+ assert(result.includes("b:5432"));
253
+ });
254
+
255
+ await t.test(
256
+ "strips credentials when the message itself parses as a URL",
257
+ () => {
258
+ // `new URL()` accepts any string with a `<scheme>:` prefix, so both of
259
+ // these "parse" — as protocol `error:` / `connect:`, with no credentials
260
+ // of their own. Rewriting field-by-field therefore found nothing to strip
261
+ // and returned the message whole, embedded password intact. This is the
262
+ // shape a driver error takes once something has prefixed it with a
263
+ // label, and it reached the unauthenticated /api/health body.
264
+ for (const message of [
265
+ "Error: getaddrinfo ENOTFOUND postgres://u:p@db.example.com:5432/x",
266
+ "connect: ECONNREFUSED redis://default:hunter2@cache.internal:6379",
267
+ ]) {
268
+ const result = redactUrl(message);
269
+
270
+ assert(!result.includes(":p@"), `password leaked: ${result}`);
271
+ assert(!result.includes("hunter2"), `password leaked: ${result}`);
272
+ // The diagnostic value survives redaction.
273
+ assert(result.includes("ENOTFOUND") || result.includes("ECONNREFUSED"));
274
+ assert(
275
+ result.includes("db.example.com") ||
276
+ result.includes("cache.internal"),
277
+ );
278
+ }
279
+ },
280
+ );
281
+
229
282
  await t.test("leaves a credential-free URL untouched", () => {
230
283
  const url = "redis://cache.internal:6379";
231
284
 
232
285
  assert(redactUrl(url) === url);
233
286
  });
234
287
 
288
+ await t.test("leaves an embedded credential-free URL untouched", () => {
289
+ const message = "connected to redis://cache.internal:6379 in 3ms";
290
+
291
+ assert(redactUrl(message) === message);
292
+ });
293
+
235
294
  await t.test("passes through non-URL and empty values", () => {
236
295
  assert(redactUrl("not-a-url") === "not-a-url");
237
296
  assert(redactUrl("") === "");