@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 +2 -1
- package/src/exports.test.js +17 -0
- package/src/health.js +315 -0
- package/src/health.test.js +388 -0
- package/src/index.js +2 -0
- package/src/storage.js +7 -2
- package/src/storage.test.js +49 -0
- package/src/utils.js +40 -16
- package/src/utils.test.js +59 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usequark/quark-core",
|
|
3
|
-
"version": "2.
|
|
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",
|
package/src/exports.test.js
CHANGED
|
@@ -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.)
|
|
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
|
package/src/storage.test.js
CHANGED
|
@@ -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
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
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
|
|
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
|
-
|
|
247
|
-
|
|
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
|
-
|
|
267
|
+
if (!parsed.username && !parsed.password) return url;
|
|
250
268
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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("") === "");
|