@usequark/quark-core 2.5.6 → 2.5.8

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.6",
3
+ "version": "2.5.8",
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",
package/src/redis.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { redactUrl } from "./utils.js";
2
+
1
3
  /**
2
4
  * Builds REDIS_URL from individual environment variables if not explicitly provided.
3
5
  * Allows configuration via individual REDIS_* vars instead of a single REDIS_URL.
@@ -39,6 +41,9 @@ export const createRedisClient = createRedisConfig;
39
41
  * Dynamically imports ioredis so it doesn't fail at load time if the package is not installed.
40
42
  * Creates a temporary connection, sends PING, expects PONG, then disconnects.
41
43
  *
44
+ * The `message` on the error path is safe to return from an unauthenticated
45
+ * endpoint: it never contains a password (see `getRedisEndpoint`).
46
+ *
42
47
  * @param {object} [options]
43
48
  * @param {number} [options.timeout=3000] - Connection/ping timeout in milliseconds.
44
49
  * @returns {Promise<{ status: "ok", latencyMs: number } | { status: "error", message: string }>}
@@ -107,7 +112,7 @@ export async function pingRedis({ timeout = 3000 } = {}) {
107
112
  error?.code === "ECONNREFUSED" ||
108
113
  /connection is closed|ECONNREFUSED/i.test(error?.message ?? "")
109
114
  ) {
110
- message = `Redis unreachable at ${getRedisUrl()}`;
115
+ message = `Redis unreachable at ${getRedisEndpoint()}`;
111
116
  } else {
112
117
  message = error?.message ?? String(error);
113
118
  }
@@ -121,6 +126,31 @@ export async function pingRedis({ timeout = 3000 } = {}) {
121
126
  }
122
127
  }
123
128
 
129
+ /**
130
+ * Returns the Redis endpoint as `host:port`, with every credential removed.
131
+ *
132
+ * The message from `pingRedis` is returned verbatim by the unauthenticated
133
+ * `/api/health` route, so it must never carry a password. `getRedisUrl()` is
134
+ * deliberately not used for this: callers need the working connection string,
135
+ * and it embeds `REDIS_URL` credentials as-is.
136
+ *
137
+ * @returns {string} e.g. `redis.railway.internal:6379`
138
+ */
139
+ export function getRedisEndpoint() {
140
+ const url = getRedisUrl();
141
+
142
+ try {
143
+ const parsed = new URL(url);
144
+ const port =
145
+ parsed.port || (parsed.protocol === "rediss:" ? "6380" : "6379");
146
+ return `${parsed.hostname}:${port}`;
147
+ } catch {
148
+ // Malformed REDIS_URL — fall back to whatever credentials-safe form the
149
+ // redaction pass can produce rather than echoing the raw value.
150
+ return redactUrl(url);
151
+ }
152
+ }
153
+
124
154
  /**
125
155
  * Resolves Redis connection config as an ioredis-compatible options object.
126
156
  * Precedence: explicit override > REDIS_URL > REDIS_HOST/REDIS_PORT > localhost defaults.
package/src/redis.test.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { afterEach, beforeEach, describe, it } from "node:test";
3
- import { getRedisUrl, resolveRedisConnection } from "./redis.js";
3
+ import {
4
+ getRedisEndpoint,
5
+ getRedisUrl,
6
+ resolveRedisConnection,
7
+ } from "./redis.js";
4
8
 
5
9
  describe("getRedisUrl", () => {
6
10
  let originalEnv;
@@ -148,3 +152,61 @@ describe("resolveRedisConnection", () => {
148
152
  assert.equal(result.port, 6382);
149
153
  });
150
154
  });
155
+
156
+ describe("getRedisEndpoint", () => {
157
+ let originalEnv;
158
+
159
+ beforeEach(() => {
160
+ originalEnv = { ...process.env };
161
+ });
162
+
163
+ afterEach(() => {
164
+ process.env = originalEnv;
165
+ });
166
+
167
+ it("never returns the password from REDIS_URL", () => {
168
+ process.env.REDIS_URL = "redis://default:hunter2@cache.internal:6379";
169
+
170
+ const endpoint = getRedisEndpoint();
171
+
172
+ assert.equal(endpoint, "cache.internal:6379");
173
+ assert.ok(!endpoint.includes("hunter2"), "endpoint leaked the password");
174
+ assert.ok(!endpoint.includes("default"), "endpoint leaked the username");
175
+ });
176
+
177
+ it("never returns a password-only credential", () => {
178
+ process.env.REDIS_URL = "redis://:hunter2@cache.internal:6379";
179
+
180
+ const endpoint = getRedisEndpoint();
181
+
182
+ assert.equal(endpoint, "cache.internal:6379");
183
+ assert.ok(!endpoint.includes("hunter2"), "endpoint leaked the password");
184
+ });
185
+
186
+ it("defaults the port to 6379, or 6380 for rediss://", () => {
187
+ process.env.REDIS_URL = "redis://user:pw@cache.internal";
188
+ assert.equal(getRedisEndpoint(), "cache.internal:6379");
189
+
190
+ process.env.REDIS_URL = "rediss://user:pw@secure.internal";
191
+ assert.equal(getRedisEndpoint(), "secure.internal:6380");
192
+ });
193
+
194
+ it("reads host and port from REDIS_HOST/REDIS_PORT when REDIS_URL is unset", () => {
195
+ delete process.env.REDIS_URL;
196
+ process.env.REDIS_HOST = "cache.internal";
197
+ process.env.REDIS_PORT = "6390";
198
+
199
+ assert.equal(getRedisEndpoint(), "cache.internal:6390");
200
+ });
201
+
202
+ it("does not leak a password out of a URL it cannot parse", () => {
203
+ process.env.REDIS_URL = "redis://user:hunter2@cache internal:6379";
204
+
205
+ const endpoint = getRedisEndpoint();
206
+
207
+ assert.ok(
208
+ !endpoint.includes("hunter2"),
209
+ `unparseable REDIS_URL leaked its password: ${endpoint}`,
210
+ );
211
+ });
212
+ });
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
@@ -217,3 +217,44 @@ export const memoize = (fn, ttl = 0) => {
217
217
  return value;
218
218
  };
219
219
  };
220
+
221
+ /** Placeholder written over a credential segment. No brackets — the URL
222
+ * serializer would percent-encode them into `%5BREDACTED%5D`. */
223
+ const REDACTED_CREDENTIAL = "REDACTED";
224
+
225
+ /**
226
+ * Strips credentials from a URL so it is safe to log or return from an
227
+ * unauthenticated endpoint.
228
+ *
229
+ * Connection strings reach places the process cannot control: `/api/health`
230
+ * has no auth and echoes the message from a failed probe, and startup logs end
231
+ * up in a log aggregator. `redis://default:hunter2@cache.internal:6379` in any
232
+ * of those is a credential disclosure, so nothing that embeds a URL in a
233
+ * message should skip this.
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.
239
+ *
240
+ * @param {string} url
241
+ * @returns {string} The URL with any username/password replaced
242
+ */
243
+ export const redactUrl = (url) => {
244
+ if (typeof url !== "string" || url.length === 0) return url;
245
+
246
+ try {
247
+ const parsed = new URL(url);
248
+
249
+ if (!parsed.username && !parsed.password) return url;
250
+
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
+ );
259
+ }
260
+ };
package/src/utils.test.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  memoize,
9
9
  normalizeErrorMessage,
10
10
  randomString,
11
+ redactUrl,
11
12
  retryAsync,
12
13
  sanitizeId,
13
14
  sleep,
@@ -191,3 +192,50 @@ test("Utils Module", async (t) => {
191
192
  }, 50);
192
193
  });
193
194
  });
195
+
196
+ test("redactUrl", async (t) => {
197
+ await t.test("strips username and password from a parseable URL", () => {
198
+ const result = redactUrl("redis://default:hunter2@cache.internal:6379");
199
+
200
+ assert(!result.includes("hunter2"), `password leaked: ${result}`);
201
+ assert(!result.includes("default"), `username leaked: ${result}`);
202
+ assert(result.includes("cache.internal"));
203
+ assert(result.includes("6379"));
204
+ });
205
+
206
+ await t.test("strips a password-only credential", () => {
207
+ const result = redactUrl("redis://:hunter2@cache.internal:6379");
208
+
209
+ assert(!result.includes("hunter2"), `password leaked: ${result}`);
210
+ assert(result.includes("cache.internal"));
211
+ });
212
+
213
+ await t.test("strips credentials from a postgres URL", () => {
214
+ const result = redactUrl(
215
+ "postgresql://app:s3cret@db.internal:5432/app?schema=public",
216
+ );
217
+
218
+ assert(!result.includes("s3cret"), `password leaked: ${result}`);
219
+ assert(result.includes("db.internal"));
220
+ assert(result.includes("schema=public"));
221
+ });
222
+
223
+ await t.test("redacts the userinfo of a URL it cannot parse", () => {
224
+ const result = redactUrl("redis://user:hunter2@cache internal:6379");
225
+
226
+ assert(!result.includes("hunter2"), `password leaked: ${result}`);
227
+ });
228
+
229
+ await t.test("leaves a credential-free URL untouched", () => {
230
+ const url = "redis://cache.internal:6379";
231
+
232
+ assert(redactUrl(url) === url);
233
+ });
234
+
235
+ await t.test("passes through non-URL and empty values", () => {
236
+ assert(redactUrl("not-a-url") === "not-a-url");
237
+ assert(redactUrl("") === "");
238
+ assert(redactUrl(undefined) === undefined);
239
+ assert(redactUrl(null) === null);
240
+ });
241
+ });